@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,11 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Install
|
|
2
|
+
* Install a plugin by name from the canonical GitHub source.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* A name resolves to one of two sources, materialized into
|
|
5
|
+
* `<workspacePluginsDir>/<name>/` so the daemon discovers it on next start:
|
|
6
|
+
* 1. A whitelisted external ecosystem plugin, when the name matches an entry
|
|
7
|
+
* in the curated `experimental/plugins/marketplace.json` manifest. The
|
|
8
|
+
* pinned `owner/repo[/path]@ref` (see {@link ./plugin-marketplace}) is
|
|
9
|
+
* fetched with a shallow `git` clone at that ref — one network operation
|
|
10
|
+
* regardless of repo size, immune to GitHub's unauthenticated API
|
|
11
|
+
* rate-limit, and recording the exact resolved commit for provenance.
|
|
12
|
+
* When we curate an adapter stub for the plugin (an
|
|
13
|
+
* `experimental/plugins/<name>/` directory in this repo with a
|
|
14
|
+
* `scripts.postinstall` command), the stub is overlaid onto the clone and
|
|
15
|
+
* its postinstall runs to translate a foreign-ecosystem layout into the
|
|
16
|
+
* shape Vellum's loader runs (see {@link applyAdapterStub}).
|
|
17
|
+
* 2. Otherwise the first-party convention
|
|
18
|
+
* `vellum-ai/vellum-assistant/experimental/plugins/<name>/` at the
|
|
19
|
+
* configured ref, fetched via the GitHub Contents API (a small handful of
|
|
20
|
+
* in-repo files — cloning the whole monorepo to install one would be
|
|
21
|
+
* wasteful).
|
|
9
22
|
*
|
|
10
23
|
* Designed for direct programmatic use. The CLI command
|
|
11
24
|
* `assistant plugins install <name>` is a thin wrapper that supplies
|
|
@@ -15,10 +28,29 @@
|
|
|
15
28
|
* a test fixture) and an override workspace directory.
|
|
16
29
|
*/
|
|
17
30
|
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
31
|
+
import { execFile } from "node:child_process";
|
|
32
|
+
import {
|
|
33
|
+
copyFileSync,
|
|
34
|
+
existsSync,
|
|
35
|
+
mkdirSync,
|
|
36
|
+
readdirSync,
|
|
37
|
+
readFileSync,
|
|
38
|
+
renameSync,
|
|
39
|
+
rmSync,
|
|
40
|
+
statSync,
|
|
41
|
+
writeFileSync,
|
|
42
|
+
} from "node:fs";
|
|
43
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
44
|
+
import { promisify } from "node:util";
|
|
20
45
|
|
|
46
|
+
import { ensureBun } from "../../util/bun-runtime.js";
|
|
21
47
|
import { getWorkspacePluginsDir } from "../../util/platform.js";
|
|
48
|
+
import {
|
|
49
|
+
fetchMarketplaceEntries,
|
|
50
|
+
resolveMarketplaceSource,
|
|
51
|
+
} from "./plugin-marketplace.js";
|
|
52
|
+
|
|
53
|
+
const execFileAsync = promisify(execFile);
|
|
22
54
|
|
|
23
55
|
const PLUGIN_SOURCE_OWNER = "vellum-ai";
|
|
24
56
|
const PLUGIN_SOURCE_REPO = "vellum-assistant";
|
|
@@ -47,6 +79,28 @@ export type FetchLike = (
|
|
|
47
79
|
init?: RequestInit,
|
|
48
80
|
) => Promise<Response>;
|
|
49
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Runs a `git` subcommand in `cwd` and resolves its stdout. Injected so tests
|
|
84
|
+
* can simulate a clone without spawning a real git process; production callers
|
|
85
|
+
* fall back to {@link defaultGitRunner}.
|
|
86
|
+
*/
|
|
87
|
+
export type GitRunner = (
|
|
88
|
+
args: readonly string[],
|
|
89
|
+
opts: { readonly cwd: string },
|
|
90
|
+
) => Promise<{ readonly stdout: string }>;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Runs a plugin's postinstall adapter script in `cwd`. Injected so tests can
|
|
94
|
+
* assert the adapter is invoked (and simulate its effects) without spawning a
|
|
95
|
+
* real subprocess; production callers fall back to {@link defaultPostinstallRunner}.
|
|
96
|
+
*/
|
|
97
|
+
export type PostinstallRunner = (opts: {
|
|
98
|
+
/** The staged install directory the adapter transforms in place. */
|
|
99
|
+
readonly cwd: string;
|
|
100
|
+
/** Absolute path to the adapter script to execute. */
|
|
101
|
+
readonly script: string;
|
|
102
|
+
}) => Promise<void>;
|
|
103
|
+
|
|
50
104
|
/** Options that control which plugin to install and how. */
|
|
51
105
|
export interface InstallPluginOptions {
|
|
52
106
|
readonly name: string;
|
|
@@ -63,6 +117,10 @@ export interface InstallPluginDeps {
|
|
|
63
117
|
readonly fetch: FetchLike;
|
|
64
118
|
/** Override the workspace plugins directory. Falls back to {@link getWorkspacePluginsDir}. */
|
|
65
119
|
readonly workspacePluginsDir?: string;
|
|
120
|
+
/** Override the git runner used to clone external plugin sources. Falls back to {@link defaultGitRunner}. */
|
|
121
|
+
readonly runGit?: GitRunner;
|
|
122
|
+
/** Override the runner used to execute a plugin's postinstall adapter. Falls back to {@link defaultPostinstallRunner}. */
|
|
123
|
+
readonly runPostinstall?: PostinstallRunner;
|
|
66
124
|
}
|
|
67
125
|
|
|
68
126
|
/** Successful install result. */
|
|
@@ -72,16 +130,36 @@ export interface InstallPluginResult {
|
|
|
72
130
|
readonly target: string;
|
|
73
131
|
readonly fileCount: number;
|
|
74
132
|
readonly ref: string;
|
|
133
|
+
/** Resolved commit SHA for git-cloned external sources; null for first-party (no clone). */
|
|
134
|
+
readonly commit: string | null;
|
|
75
135
|
}
|
|
76
136
|
|
|
77
137
|
/** Plugin name failed sanitization. */
|
|
78
138
|
export class InvalidPluginNameError extends Error {
|
|
79
139
|
constructor(name: string) {
|
|
80
|
-
super(
|
|
140
|
+
super(
|
|
141
|
+
`Invalid plugin name "${name}". Names must match /^[a-z0-9][a-z0-9_-]*$/.`,
|
|
142
|
+
);
|
|
81
143
|
this.name = "InvalidPluginNameError";
|
|
82
144
|
}
|
|
83
145
|
}
|
|
84
146
|
|
|
147
|
+
/**
|
|
148
|
+
* A plugin's curated postinstall adapter failed — its `scripts.postinstall`
|
|
149
|
+
* command was malformed/unsupported, its script was missing, or the script
|
|
150
|
+
* exited non-zero. The install is aborted and rolled back rather than
|
|
151
|
+
* materializing a half-transformed, non-functional plugin.
|
|
152
|
+
*/
|
|
153
|
+
export class PluginPostinstallError extends Error {
|
|
154
|
+
constructor(
|
|
155
|
+
readonly pluginName: string,
|
|
156
|
+
detail: string,
|
|
157
|
+
) {
|
|
158
|
+
super(`Postinstall adapter for "${pluginName}" failed: ${detail}`);
|
|
159
|
+
this.name = "PluginPostinstallError";
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
85
163
|
/** A plugin with the same name is already installed and `--force` was not passed. */
|
|
86
164
|
export class PluginAlreadyInstalledError extends Error {
|
|
87
165
|
constructor(
|
|
@@ -98,13 +176,115 @@ export class PluginNotFoundError extends Error {
|
|
|
98
176
|
constructor(
|
|
99
177
|
readonly pluginName: string,
|
|
100
178
|
readonly ref: string,
|
|
179
|
+
/** `owner/repo/path` the plugin was looked for at. */
|
|
180
|
+
sourceLabel: string,
|
|
101
181
|
) {
|
|
102
|
-
|
|
103
|
-
super(`Plugin "${pluginName}" not found at ${sourcePath} (ref ${ref}).`);
|
|
182
|
+
super(`Plugin "${pluginName}" not found at ${sourceLabel} (ref ${ref}).`);
|
|
104
183
|
this.name = "PluginNotFoundError";
|
|
105
184
|
}
|
|
106
185
|
}
|
|
107
186
|
|
|
187
|
+
/**
|
|
188
|
+
* The plugin source is temporarily unreachable — GitHub rate-limited us or
|
|
189
|
+
* returned a 5xx. Distinct from a hard failure (the plugin genuinely doesn't
|
|
190
|
+
* exist) so the caller can surface a retryable 503 instead of a 500.
|
|
191
|
+
*/
|
|
192
|
+
export class PluginSourceUnavailableError extends Error {
|
|
193
|
+
readonly status: number;
|
|
194
|
+
constructor(message: string, status: number) {
|
|
195
|
+
super(message);
|
|
196
|
+
this.name = "PluginSourceUnavailableError";
|
|
197
|
+
this.status = status;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Classify an upstream GitHub status as transient (worth retrying) vs hard.
|
|
203
|
+
* A 429 or 5xx is always transient. A 403 is GitHub's unauthenticated
|
|
204
|
+
* rate-limit signal only when the remaining-quota header is exhausted —
|
|
205
|
+
* a 403 without it is a genuine authorization failure and stays hard.
|
|
206
|
+
*/
|
|
207
|
+
function isTransientUpstreamStatus(res: Response): boolean {
|
|
208
|
+
if (res.status === 429 || res.status >= 500) return true;
|
|
209
|
+
if (res.status === 403) {
|
|
210
|
+
return res.headers.get("x-ratelimit-remaining") === "0";
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Resolved GitHub coordinates a plugin name is fetched from. */
|
|
216
|
+
interface PluginFetchSource {
|
|
217
|
+
/** Whether the plugin lives in our monorepo or an external whitelisted repo. */
|
|
218
|
+
readonly kind: "first-party" | "external";
|
|
219
|
+
readonly owner: string;
|
|
220
|
+
readonly repo: string;
|
|
221
|
+
/** Repo-relative directory holding the plugin root; `""` = repo root. */
|
|
222
|
+
readonly rootPath: string;
|
|
223
|
+
readonly ref: string;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Build the `owner/repo/path` label used in not-found errors. */
|
|
227
|
+
function sourceLabel(source: PluginFetchSource): string {
|
|
228
|
+
return source.rootPath
|
|
229
|
+
? `${source.owner}/${source.repo}/${source.rootPath}`
|
|
230
|
+
: `${source.owner}/${source.repo}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** First-party `experimental/plugins/<name>` coordinates at a given ref. */
|
|
234
|
+
function firstPartySource(name: string, ref: string): PluginFetchSource {
|
|
235
|
+
return {
|
|
236
|
+
kind: "first-party",
|
|
237
|
+
owner: PLUGIN_SOURCE_OWNER,
|
|
238
|
+
repo: PLUGIN_SOURCE_REPO,
|
|
239
|
+
rootPath: `${PLUGIN_SOURCE_PATH_PREFIX}/${name}`,
|
|
240
|
+
ref,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Resolve a plugin name to concrete GitHub coordinates.
|
|
246
|
+
*
|
|
247
|
+
* A name claimed by the curated marketplace resolves to its pinned external
|
|
248
|
+
* repo; any other name resolves to the first-party `experimental/plugins/<name>`
|
|
249
|
+
* convention. The marketplace is external-only by construction — a same-named
|
|
250
|
+
* `experimental/plugins/<name>` directory is the plugin's optional *adapter
|
|
251
|
+
* stub* (a curated `package.json` + postinstall script overlaid onto the clone
|
|
252
|
+
* to translate it into Vellum's shape; see {@link applyAdapterStub}), not a
|
|
253
|
+
* standalone first-party plugin. So letting the marketplace win the name is
|
|
254
|
+
* what makes the stub apply to the external clone, and the search catalog
|
|
255
|
+
* surfaces the same name as external — install and search stay in agreement.
|
|
256
|
+
*
|
|
257
|
+
* A missing or malformed manifest degrades to first-party resolution — the
|
|
258
|
+
* whitelist is supplementary and must never block installing a first-party
|
|
259
|
+
* plugin. An external name then surfaces a clear not-found error downstream.
|
|
260
|
+
*/
|
|
261
|
+
async function resolvePluginSource(
|
|
262
|
+
name: string,
|
|
263
|
+
marketplaceRef: string,
|
|
264
|
+
fetchFn: FetchLike,
|
|
265
|
+
): Promise<PluginFetchSource> {
|
|
266
|
+
let resolved = null;
|
|
267
|
+
try {
|
|
268
|
+
const entries = await fetchMarketplaceEntries(
|
|
269
|
+
{ fetch: fetchFn },
|
|
270
|
+
{ ref: marketplaceRef },
|
|
271
|
+
);
|
|
272
|
+
resolved = resolveMarketplaceSource(name, entries);
|
|
273
|
+
} catch {
|
|
274
|
+
// Degrade to first-party resolution below.
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!resolved) return firstPartySource(name, marketplaceRef);
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
kind: "external",
|
|
281
|
+
owner: resolved.owner,
|
|
282
|
+
repo: resolved.repo,
|
|
283
|
+
rootPath: resolved.path,
|
|
284
|
+
ref: resolved.ref,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
108
288
|
/**
|
|
109
289
|
* Reject plugin names that could escape the canonical source path or the
|
|
110
290
|
* install target. The source convention is a flat namespace under
|
|
@@ -141,7 +321,9 @@ function assertSafeFilename(label: string, candidate: string): void {
|
|
|
141
321
|
candidate.includes("\0") ||
|
|
142
322
|
candidate.split(/[/\\]/).some((seg) => seg === "..")
|
|
143
323
|
) {
|
|
144
|
-
throw new Error(
|
|
324
|
+
throw new Error(
|
|
325
|
+
`Unsafe ${label} from GitHub response: ${JSON.stringify(candidate)}`,
|
|
326
|
+
);
|
|
145
327
|
}
|
|
146
328
|
}
|
|
147
329
|
|
|
@@ -158,9 +340,12 @@ export async function installPlugin(
|
|
|
158
340
|
deps: InstallPluginDeps,
|
|
159
341
|
): Promise<InstallPluginResult> {
|
|
160
342
|
const name = sanitizePluginName(opts.name);
|
|
161
|
-
const
|
|
343
|
+
const marketplaceRef = opts.ref ?? DEFAULT_PLUGIN_REF;
|
|
162
344
|
const force = opts.force ?? false;
|
|
163
345
|
|
|
346
|
+
const source = await resolvePluginSource(name, marketplaceRef, deps.fetch);
|
|
347
|
+
const ref = source.ref;
|
|
348
|
+
|
|
164
349
|
const pluginsDir = deps.workspacePluginsDir ?? getWorkspacePluginsDir();
|
|
165
350
|
const target = join(pluginsDir, name);
|
|
166
351
|
|
|
@@ -168,23 +353,74 @@ export async function installPlugin(
|
|
|
168
353
|
throw new PluginAlreadyInstalledError(name, target);
|
|
169
354
|
}
|
|
170
355
|
|
|
171
|
-
// Stage
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
356
|
+
// Stage *outside* the served `plugins/` directory. The daemon watches that
|
|
357
|
+
// directory and its startup loader enumerates it, so a staging dir living
|
|
358
|
+
// inside it is observed mid-install — before the adapter overlay runs, an
|
|
359
|
+
// external clone still carries its upstream `package.json` (wrong name, no
|
|
360
|
+
// plugin-api peer dep), which the loader rejects with spurious name-mismatch
|
|
361
|
+
// and missing-peer-dependency warnings. Staging in a sibling directory keeps
|
|
362
|
+
// the half-built tree invisible until the final swap. The root is on the
|
|
363
|
+
// same filesystem as the target, so that swap stays an atomic rename.
|
|
364
|
+
// `process.pid` keeps concurrent installs of the same plugin from clobbering
|
|
365
|
+
// each other's staging.
|
|
366
|
+
const stagingRoot = join(dirname(pluginsDir), ".plugins-staging");
|
|
367
|
+
mkdirSync(stagingRoot, { recursive: true });
|
|
368
|
+
const stagingDir = join(stagingRoot, `${name}.installing.${process.pid}`);
|
|
175
369
|
if (existsSync(stagingDir)) {
|
|
176
370
|
rmSync(stagingDir, { recursive: true, force: true });
|
|
177
371
|
}
|
|
178
372
|
mkdirSync(stagingDir, { recursive: true });
|
|
179
373
|
|
|
180
374
|
let fileCount: number;
|
|
375
|
+
let commit: string | null = null;
|
|
181
376
|
try {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
377
|
+
if (source.kind === "external") {
|
|
378
|
+
const cloned = await copyExternalViaGit(
|
|
379
|
+
source,
|
|
380
|
+
stagingDir,
|
|
381
|
+
deps.runGit ?? defaultGitRunner,
|
|
382
|
+
);
|
|
383
|
+
fileCount = cloned.fileCount;
|
|
384
|
+
commit = cloned.commit;
|
|
385
|
+
// An external clone is often a foreign-ecosystem plugin (e.g. a Claude
|
|
386
|
+
// Code plugin) that the Vellum loader can't run as-is. When we curate an
|
|
387
|
+
// adapter stub for it, overlay the stub and run its transform so the
|
|
388
|
+
// materialized tree is a valid Vellum plugin. Raw clones (no stub) are
|
|
389
|
+
// left untouched.
|
|
390
|
+
if (fileCount > 0) {
|
|
391
|
+
await applyAdapterStub(name, marketplaceRef, stagingDir, deps);
|
|
392
|
+
}
|
|
393
|
+
} else {
|
|
394
|
+
fileCount = await copyDir(
|
|
395
|
+
source.owner,
|
|
396
|
+
source.repo,
|
|
397
|
+
source.rootPath,
|
|
398
|
+
ref,
|
|
399
|
+
stagingDir,
|
|
400
|
+
deps.fetch,
|
|
401
|
+
);
|
|
402
|
+
// We only land in the first-party branch for this name when the
|
|
403
|
+
// marketplace lookup returned no claim. A *healthy* marketplace that
|
|
404
|
+
// claims the name routes to the external+adapter branch above; reaching
|
|
405
|
+
// here for a directory that is actually an adapter stub (declares a
|
|
406
|
+
// `scripts.postinstall`) therefore means the marketplace failed to load
|
|
407
|
+
// (rate-limit / 5xx / malformed) and we degraded past it. A stub has no
|
|
408
|
+
// hooks/tools of its own — it only transforms an external clone — so
|
|
409
|
+
// installing it alone would materialize a non-functional plugin. Fail
|
|
410
|
+
// loudly and retryably instead of silently shipping a broken plugin.
|
|
411
|
+
// Genuine first-party plugins (no postinstall) install normally.
|
|
412
|
+
if (
|
|
413
|
+
fileCount > 0 &&
|
|
414
|
+
resolvePostinstallScript(name, stagingDir) !== null
|
|
415
|
+
) {
|
|
416
|
+
throw new PluginPostinstallError(
|
|
417
|
+
name,
|
|
418
|
+
"resolved to a first-party adapter stub, but its marketplace entry " +
|
|
419
|
+
"could not be read to locate the external source it adapts — the " +
|
|
420
|
+
"marketplace lookup likely failed transiently. Retry the install.",
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
188
424
|
} catch (err) {
|
|
189
425
|
rmSync(stagingDir, { recursive: true, force: true });
|
|
190
426
|
throw err;
|
|
@@ -192,65 +428,587 @@ export async function installPlugin(
|
|
|
192
428
|
|
|
193
429
|
if (fileCount === 0) {
|
|
194
430
|
rmSync(stagingDir, { recursive: true, force: true });
|
|
195
|
-
throw new PluginNotFoundError(name, ref);
|
|
431
|
+
throw new PluginNotFoundError(name, ref, sourceLabel(source));
|
|
196
432
|
}
|
|
197
433
|
|
|
434
|
+
// Record install provenance (source coordinates + resolved commit) as a
|
|
435
|
+
// hidden sidecar before the swap so it lands atomically with the files. The
|
|
436
|
+
// daemon loader enumerates plugin directories and reads each plugin's
|
|
437
|
+
// `package.json`, skipping dotfiles — so this never gets mistaken for code.
|
|
438
|
+
writeInstallManifest(stagingDir, name, source, ref, commit);
|
|
439
|
+
|
|
198
440
|
// Atomic-ish swap: rmSync + renameSync. On POSIX the rename itself is
|
|
199
441
|
// atomic, so the only window where the target is absent is between the
|
|
200
442
|
// rm and the rename — and at that point the staging dir is fully populated.
|
|
443
|
+
// Ensure the served `plugins/` directory exists: staging now lives outside
|
|
444
|
+
// it, so the target's parent is no longer created as a side effect.
|
|
445
|
+
mkdirSync(pluginsDir, { recursive: true });
|
|
201
446
|
if (existsSync(target)) {
|
|
202
447
|
rmSync(target, { recursive: true, force: true });
|
|
203
448
|
}
|
|
204
449
|
renameSync(stagingDir, target);
|
|
205
450
|
|
|
206
|
-
return { name, target, fileCount, ref };
|
|
451
|
+
return { name, target, fileCount, ref, commit };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Cap on any single git invocation; a shallow fetch is well under this. */
|
|
455
|
+
const GIT_TIMEOUT_MS = 120_000;
|
|
456
|
+
|
|
457
|
+
/** Install-provenance sidecar written at the plugin root. */
|
|
458
|
+
const INSTALL_MANIFEST_FILENAME = ".vellum-plugin.json";
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Materialize an external plugin by shallow-cloning its repo at the pinned ref.
|
|
462
|
+
*
|
|
463
|
+
* A single `git fetch --depth 1 <ref>` transfers the tree in one network
|
|
464
|
+
* operation regardless of how many directories the plugin spans, so it is
|
|
465
|
+
* immune to GitHub's 60/hr unauthenticated Contents-API rate limit — the
|
|
466
|
+
* failure mode a recursive per-directory walk hit on plugins like caveman
|
|
467
|
+
* (dozens of nested folders, one API request each). Cloning also resolves the
|
|
468
|
+
* exact commit the ref points at, recorded for version provenance.
|
|
469
|
+
*
|
|
470
|
+
* The clone lands in a sibling scratch dir; only the plugin root (the repo
|
|
471
|
+
* root, or `source.rootPath` within it) is copied into `destDir`, minus the
|
|
472
|
+
* `.git` metadata and any symlinks (the loader follows neither). Returns the
|
|
473
|
+
* file count and resolved commit; zero files means the ref exists but the
|
|
474
|
+
* declared sub-path doesn't, which the caller maps to not-found.
|
|
475
|
+
*/
|
|
476
|
+
async function copyExternalViaGit(
|
|
477
|
+
source: PluginFetchSource,
|
|
478
|
+
destDir: string,
|
|
479
|
+
runGit: GitRunner,
|
|
480
|
+
): Promise<{ fileCount: number; commit: string | null }> {
|
|
481
|
+
const cloneDir = `${destDir}.gitclone`;
|
|
482
|
+
rmSync(cloneDir, { recursive: true, force: true });
|
|
483
|
+
mkdirSync(cloneDir, { recursive: true });
|
|
484
|
+
|
|
485
|
+
try {
|
|
486
|
+
const repoUrl = `https://github.com/${source.owner}/${source.repo}.git`;
|
|
487
|
+
await runGit(["init", "--quiet"], { cwd: cloneDir });
|
|
488
|
+
await runGit(["remote", "add", "origin", repoUrl], { cwd: cloneDir });
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
await runGit(["fetch", "--depth", "1", "--quiet", "origin", source.ref], {
|
|
492
|
+
cwd: cloneDir,
|
|
493
|
+
});
|
|
494
|
+
} catch (err) {
|
|
495
|
+
// A missing repo/ref (or a private one we can't reach) is a hard
|
|
496
|
+
// not-found, surfaced as zero files. Anything else — network loss, a
|
|
497
|
+
// transient GitHub outage — is retryable, so map it to a 503.
|
|
498
|
+
if (isGitRefNotFound(err)) return { fileCount: 0, commit: null };
|
|
499
|
+
throw new PluginSourceUnavailableError(
|
|
500
|
+
`git clone failed for ${sourceLabel(source)} @ ${source.ref}: ${subprocessErrorText(err)}`,
|
|
501
|
+
503,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
await runGit(["checkout", "--quiet", "FETCH_HEAD"], { cwd: cloneDir });
|
|
506
|
+
|
|
507
|
+
let commit: string | null = null;
|
|
508
|
+
try {
|
|
509
|
+
const { stdout } = await runGit(["rev-parse", "HEAD"], { cwd: cloneDir });
|
|
510
|
+
commit = stdout.trim() || null;
|
|
511
|
+
} catch {
|
|
512
|
+
// Provenance is best-effort; a missing commit must not fail the install.
|
|
513
|
+
commit = null;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Defense in depth: external marketplace refs are full commit SHAs (the
|
|
517
|
+
// manifest schema rejects mutable tags/branches), so the checked-out
|
|
518
|
+
// commit must equal the requested ref. If it ever diverges, refuse the
|
|
519
|
+
// install rather than materialize and `import()` unexpected code.
|
|
520
|
+
if (commit && commit.toLowerCase() !== source.ref.toLowerCase()) {
|
|
521
|
+
throw new PluginSourceUnavailableError(
|
|
522
|
+
`git checkout of ${sourceLabel(source)} resolved to ${commit}, ` +
|
|
523
|
+
`which does not match the pinned commit ${source.ref}`,
|
|
524
|
+
502,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const srcRoot = source.rootPath
|
|
529
|
+
? join(cloneDir, source.rootPath)
|
|
530
|
+
: cloneDir;
|
|
531
|
+
if (!existsSync(srcRoot) || !statSync(srcRoot).isDirectory()) {
|
|
532
|
+
return { fileCount: 0, commit };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const fileCount = copyTreeSkippingGit(srcRoot, destDir);
|
|
536
|
+
return { fileCount, commit };
|
|
537
|
+
} finally {
|
|
538
|
+
rmSync(cloneDir, { recursive: true, force: true });
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Cap on a postinstall adapter; the curated transforms are fast and file-only. */
|
|
543
|
+
const POSTINSTALL_TIMEOUT_MS = 60_000;
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Overlay our curated adapter stub onto a freshly cloned external plugin and
|
|
547
|
+
* run its postinstall transform, returning whether a transform ran.
|
|
548
|
+
*
|
|
549
|
+
* The stub lives at `experimental/plugins/<name>/` in our own repo and carries
|
|
550
|
+
* a `package.json` (with a `scripts.postinstall` adapter command) plus the
|
|
551
|
+
* adapter script it names. We fetch it via the Contents API — a couple of
|
|
552
|
+
* small files, well within the rate limit — and copy it over the clone so the
|
|
553
|
+
* postinstall we run is ours, never the upstream repo's lifecycle script. The
|
|
554
|
+
* overlaid stub `package.json` exists only to name that adapter; the installed
|
|
555
|
+
* plugin's manifest is rebuilt from the upstream `package.json` afterwards (see
|
|
556
|
+
* {@link normalizeInstalledManifest}). Absent a stub (the common case for a
|
|
557
|
+
* plugin already in Vellum shape), nothing is overlaid and the clone is
|
|
558
|
+
* installed as-is.
|
|
559
|
+
*
|
|
560
|
+
* On any adapter failure the error propagates so {@link installPlugin} rolls
|
|
561
|
+
* back staging — better to fail loudly than ship a half-transformed plugin.
|
|
562
|
+
*/
|
|
563
|
+
async function applyAdapterStub(
|
|
564
|
+
name: string,
|
|
565
|
+
ref: string,
|
|
566
|
+
stagingDir: string,
|
|
567
|
+
deps: InstallPluginDeps,
|
|
568
|
+
): Promise<boolean> {
|
|
569
|
+
// Capture the cloned upstream manifest before the stub overlay replaces it,
|
|
570
|
+
// so the installed plugin can preserve it verbatim except for the two fields
|
|
571
|
+
// the Vellum loader requires (name + plugin-api peer dep).
|
|
572
|
+
const upstreamPkg = readPackageJson(join(stagingDir, "package.json"));
|
|
573
|
+
|
|
574
|
+
const stubFileCount = await copyDir(
|
|
575
|
+
PLUGIN_SOURCE_OWNER,
|
|
576
|
+
PLUGIN_SOURCE_REPO,
|
|
577
|
+
`${PLUGIN_SOURCE_PATH_PREFIX}/${name}`,
|
|
578
|
+
ref,
|
|
579
|
+
stagingDir,
|
|
580
|
+
deps.fetch,
|
|
581
|
+
);
|
|
582
|
+
if (stubFileCount === 0) return false;
|
|
583
|
+
|
|
584
|
+
const script = resolvePostinstallScript(name, stagingDir);
|
|
585
|
+
if (script === null) return false;
|
|
586
|
+
|
|
587
|
+
const run = deps.runPostinstall ?? defaultPostinstallRunner;
|
|
588
|
+
try {
|
|
589
|
+
await run({ cwd: stagingDir, script });
|
|
590
|
+
} catch (err) {
|
|
591
|
+
throw new PluginPostinstallError(name, subprocessErrorText(err));
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
normalizeInstalledManifest(name, stagingDir, upstreamPkg);
|
|
595
|
+
return true;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Default `@vellumai/plugin-api` peer-dependency range stamped onto an adapted
|
|
600
|
+
* plugin that doesn't already declare one.
|
|
601
|
+
*/
|
|
602
|
+
const PLUGIN_API_PEER_RANGE = ">=0.8.0";
|
|
603
|
+
|
|
604
|
+
type PackageManifest = Record<string, unknown>;
|
|
605
|
+
|
|
606
|
+
/** Parse the `package.json` at `path`, or null if it's absent or unparseable. */
|
|
607
|
+
function readPackageJson(path: string): PackageManifest | null {
|
|
608
|
+
if (!existsSync(path)) return null;
|
|
609
|
+
try {
|
|
610
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
611
|
+
return typeof parsed === "object" &&
|
|
612
|
+
parsed !== null &&
|
|
613
|
+
!Array.isArray(parsed)
|
|
614
|
+
? (parsed as PackageManifest)
|
|
615
|
+
: null;
|
|
616
|
+
} catch {
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Write the installed plugin's final `package.json` after the adapter has run.
|
|
623
|
+
*
|
|
624
|
+
* A curated adapter stub deliberately overlays its own `package.json` onto the
|
|
625
|
+
* clone so the installer can find and run the stub's `scripts.postinstall`.
|
|
626
|
+
* That stub is install-time machinery, not the plugin's manifest, so once the
|
|
627
|
+
* adapter has run we rebuild the manifest from the upstream `package.json`
|
|
628
|
+
* captured before the overlay — preserving its `version`, `description`,
|
|
629
|
+
* `license`, and every other field — and mutate only what the Vellum loader
|
|
630
|
+
* requires: `name` must equal the install directory, and `@vellumai/plugin-api`
|
|
631
|
+
* must be declared as a peer dependency. The spent `postinstall` script is
|
|
632
|
+
* dropped so the installed plugin carries no install-time machinery.
|
|
633
|
+
*
|
|
634
|
+
* When the upstream repo shipped no `package.json`, the overlaid stub is the
|
|
635
|
+
* only manifest available, so it becomes the base instead.
|
|
636
|
+
*/
|
|
637
|
+
function normalizeInstalledManifest(
|
|
638
|
+
name: string,
|
|
639
|
+
stagingDir: string,
|
|
640
|
+
upstreamPkg: PackageManifest | null,
|
|
641
|
+
): void {
|
|
642
|
+
const manifestPath = join(stagingDir, "package.json");
|
|
643
|
+
const base = upstreamPkg ?? readPackageJson(manifestPath) ?? {};
|
|
644
|
+
|
|
645
|
+
const peer =
|
|
646
|
+
typeof base.peerDependencies === "object" && base.peerDependencies !== null
|
|
647
|
+
? (base.peerDependencies as Record<string, unknown>)
|
|
648
|
+
: {};
|
|
649
|
+
const existingRange = peer["@vellumai/plugin-api"];
|
|
650
|
+
|
|
651
|
+
const manifest: PackageManifest = {
|
|
652
|
+
...base,
|
|
653
|
+
name,
|
|
654
|
+
peerDependencies: {
|
|
655
|
+
...peer,
|
|
656
|
+
"@vellumai/plugin-api":
|
|
657
|
+
typeof existingRange === "string"
|
|
658
|
+
? existingRange
|
|
659
|
+
: PLUGIN_API_PEER_RANGE,
|
|
660
|
+
},
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
if (typeof manifest.scripts === "object" && manifest.scripts !== null) {
|
|
664
|
+
const scripts = { ...(manifest.scripts as Record<string, unknown>) };
|
|
665
|
+
delete scripts.postinstall;
|
|
666
|
+
if (Object.keys(scripts).length === 0) {
|
|
667
|
+
delete manifest.scripts;
|
|
668
|
+
} else {
|
|
669
|
+
manifest.scripts = scripts;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Resolve the absolute path of the adapter script named by the (overlaid stub)
|
|
678
|
+
* `package.json`'s `scripts.postinstall`, or `null` when there is no stub
|
|
679
|
+
* package.json / postinstall script.
|
|
680
|
+
*
|
|
681
|
+
* Curated adapters declare a single `bun <script>` invocation; bun is resolved
|
|
682
|
+
* via {@link ensureBun} at execution time (see {@link defaultPostinstallRunner})
|
|
683
|
+
* so the `bun` token marks the convention without hard-coding the binary path.
|
|
684
|
+
* Anything else — extra args, a shell pipeline, a non-script file — is rejected
|
|
685
|
+
* rather than executed, and the script path is constrained to a file inside the
|
|
686
|
+
* staging dir so a stub can never escape it.
|
|
687
|
+
*/
|
|
688
|
+
function resolvePostinstallScript(
|
|
689
|
+
name: string,
|
|
690
|
+
stagingDir: string,
|
|
691
|
+
): string | null {
|
|
692
|
+
const pkgPath = join(stagingDir, "package.json");
|
|
693
|
+
if (!existsSync(pkgPath)) return null;
|
|
694
|
+
|
|
695
|
+
let parsed: unknown;
|
|
696
|
+
try {
|
|
697
|
+
parsed = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
698
|
+
} catch {
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const scripts =
|
|
703
|
+
typeof parsed === "object" && parsed !== null && "scripts" in parsed
|
|
704
|
+
? (parsed as { scripts?: unknown }).scripts
|
|
705
|
+
: undefined;
|
|
706
|
+
const command =
|
|
707
|
+
typeof scripts === "object" && scripts !== null && "postinstall" in scripts
|
|
708
|
+
? (scripts as { postinstall?: unknown }).postinstall
|
|
709
|
+
: undefined;
|
|
710
|
+
if (typeof command !== "string" || command.trim() === "") return null;
|
|
711
|
+
|
|
712
|
+
const match = /^bun\s+(\S+)$/.exec(command.trim());
|
|
713
|
+
if (!match) {
|
|
714
|
+
throw new PluginPostinstallError(
|
|
715
|
+
name,
|
|
716
|
+
`unsupported postinstall command ${JSON.stringify(command)} — ` +
|
|
717
|
+
"curated adapters must be a single `bun <script>` invocation",
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
let rel = match[1]!;
|
|
722
|
+
if (rel.startsWith("./")) rel = rel.slice(2);
|
|
723
|
+
if (!/\.(?:ts|mts|cts|mjs|cjs|js)$/.test(rel)) {
|
|
724
|
+
throw new PluginPostinstallError(
|
|
725
|
+
name,
|
|
726
|
+
`postinstall script ${JSON.stringify(rel)} must be a ` +
|
|
727
|
+
".ts/.mts/.cts/.mjs/.cjs/.js file",
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
for (const segment of rel.split("/")) {
|
|
731
|
+
assertSafeFilename("postinstall script segment", segment);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const abs = resolve(stagingDir, rel);
|
|
735
|
+
if (
|
|
736
|
+
abs !== resolve(stagingDir) &&
|
|
737
|
+
!abs.startsWith(`${resolve(stagingDir)}${sep}`)
|
|
738
|
+
) {
|
|
739
|
+
throw new PluginPostinstallError(
|
|
740
|
+
name,
|
|
741
|
+
`postinstall script ${JSON.stringify(rel)} escapes the plugin directory`,
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
if (!existsSync(abs)) {
|
|
745
|
+
throw new PluginPostinstallError(
|
|
746
|
+
name,
|
|
747
|
+
`postinstall script ${JSON.stringify(rel)} was not found in the plugin`,
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
return abs;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Production postinstall runner: executes the adapter with a real `bun` binary
|
|
755
|
+
* resolved via {@link ensureBun}, under a stripped environment and a timeout.
|
|
756
|
+
*
|
|
757
|
+
* `process.execPath` is unusable here: inside a `bun build --compile` binary it
|
|
758
|
+
* is the compiled assistant app, not the bun CLI (see `util/bun-runtime.ts`),
|
|
759
|
+
* so passing the adapter script to it would launch the daemon rather than
|
|
760
|
+
* interpret the script. `ensureBun()` locates (or downloads) a standalone bun
|
|
761
|
+
* the same way every other subsystem that spawns bun does. The minimal env
|
|
762
|
+
* (bun's dir + standard bins, `HOME` only) keeps the adapter from inheriting
|
|
763
|
+
* surprising config while still finding the runtime.
|
|
764
|
+
*/
|
|
765
|
+
export const defaultPostinstallRunner: PostinstallRunner = async ({
|
|
766
|
+
cwd,
|
|
767
|
+
script,
|
|
768
|
+
}) => {
|
|
769
|
+
const bun = await ensureBun();
|
|
770
|
+
await execFileAsync(bun, [script], {
|
|
771
|
+
cwd,
|
|
772
|
+
encoding: "utf8",
|
|
773
|
+
timeout: POSTINSTALL_TIMEOUT_MS,
|
|
774
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
775
|
+
env: pluginPostinstallEnv(bun),
|
|
776
|
+
});
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
function pluginPostinstallEnv(bun: string): NodeJS.ProcessEnv {
|
|
780
|
+
const env: NodeJS.ProcessEnv = {
|
|
781
|
+
PATH: [
|
|
782
|
+
dirname(bun),
|
|
783
|
+
"/opt/homebrew/bin",
|
|
784
|
+
"/usr/local/bin",
|
|
785
|
+
"/usr/bin",
|
|
786
|
+
"/bin",
|
|
787
|
+
]
|
|
788
|
+
.filter(Boolean)
|
|
789
|
+
.join(":"),
|
|
790
|
+
};
|
|
791
|
+
if (process.env.HOME) env.HOME = process.env.HOME;
|
|
792
|
+
return env;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Recursively copy regular files from `srcRoot` into `destDir`, skipping the
|
|
797
|
+
* top-level `.git` directory, a top-level `bunfig.toml` (see below), and any
|
|
798
|
+
* symlinks. Returns the file count.
|
|
799
|
+
*/
|
|
800
|
+
function copyTreeSkippingGit(srcRoot: string, destDir: string): number {
|
|
801
|
+
let count = 0;
|
|
802
|
+
const walk = (relDir: string): void => {
|
|
803
|
+
const absDir = relDir ? join(srcRoot, relDir) : srcRoot;
|
|
804
|
+
for (const entry of readdirSync(absDir, { withFileTypes: true })) {
|
|
805
|
+
// Drop git metadata and symlinks: the loader follows neither, and a
|
|
806
|
+
// symlink could otherwise point outside the staging tree.
|
|
807
|
+
if (relDir === "" && entry.name === ".git") continue;
|
|
808
|
+
// Drop a top-level `bunfig.toml`. The adapter postinstall runs `bun` with
|
|
809
|
+
// its cwd at the staged root, and Bun auto-loads `$cwd/bunfig.toml` as
|
|
810
|
+
// project config — including a `preload` list it executes before the
|
|
811
|
+
// entry point. An upstream config would therefore run arbitrary code
|
|
812
|
+
// ahead of the curated adapter, defeating the command/env guards. Bun
|
|
813
|
+
// reads only the cwd's file (it neither walks up nor descends), so
|
|
814
|
+
// dropping it at the root closes the vector; a Vellum plugin never
|
|
815
|
+
// consumes `bunfig.toml`. Match case-insensitively because the macOS
|
|
816
|
+
// install target's filesystem is case-insensitive, where Bun would still
|
|
817
|
+
// open a clone-supplied `BUNFIG.TOML`.
|
|
818
|
+
if (relDir === "" && entry.name.toLowerCase() === "bunfig.toml") continue;
|
|
819
|
+
if (entry.isSymbolicLink()) continue;
|
|
820
|
+
|
|
821
|
+
const rel = relDir ? join(relDir, entry.name) : entry.name;
|
|
822
|
+
if (entry.isDirectory()) {
|
|
823
|
+
walk(rel);
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
if (!entry.isFile()) continue;
|
|
827
|
+
|
|
828
|
+
const dest = join(destDir, rel);
|
|
829
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
830
|
+
copyFileSync(join(srcRoot, rel), dest);
|
|
831
|
+
count++;
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
walk("");
|
|
835
|
+
return count;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/** True when a git fetch failed because the repo or ref is unreachable. */
|
|
839
|
+
function isGitRefNotFound(err: unknown): boolean {
|
|
840
|
+
const text = subprocessErrorText(err).toLowerCase();
|
|
841
|
+
return [
|
|
842
|
+
"could not find remote ref",
|
|
843
|
+
"couldn't find remote ref",
|
|
844
|
+
"remote branch",
|
|
845
|
+
"repository not found",
|
|
846
|
+
"could not read from remote repository",
|
|
847
|
+
"could not read username",
|
|
848
|
+
"terminal prompts disabled",
|
|
849
|
+
"authentication failed",
|
|
850
|
+
].some((needle) => text.includes(needle));
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** Extract a stderr/message blob from a spawn error for classification/logging. */
|
|
854
|
+
function subprocessErrorText(err: unknown): string {
|
|
855
|
+
if (err instanceof Error) {
|
|
856
|
+
const withStreams = err as Error & { stderr?: unknown };
|
|
857
|
+
const stderr =
|
|
858
|
+
typeof withStreams.stderr === "string" ? withStreams.stderr : "";
|
|
859
|
+
return `${err.message} ${stderr}`.trim();
|
|
860
|
+
}
|
|
861
|
+
return String(err);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Hardened `git` runner used in production. Strips inherited `GIT_*` vars
|
|
866
|
+
* (which could redirect config, hooks, or the object store), disables the
|
|
867
|
+
* credential prompt so a private/missing repo fails fast instead of hanging
|
|
868
|
+
* the daemon, and augments `PATH` so the real git is found when the daemon is
|
|
869
|
+
* launched from a macOS `.app` bundle with a minimal environment.
|
|
870
|
+
*/
|
|
871
|
+
export const defaultGitRunner: GitRunner = async (args, opts) => {
|
|
872
|
+
const { stdout } = await execFileAsync("git", [...args], {
|
|
873
|
+
cwd: opts.cwd,
|
|
874
|
+
encoding: "utf8",
|
|
875
|
+
timeout: GIT_TIMEOUT_MS,
|
|
876
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
877
|
+
env: pluginGitEnv(),
|
|
878
|
+
});
|
|
879
|
+
return { stdout };
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
function pluginGitEnv(): NodeJS.ProcessEnv {
|
|
883
|
+
const env: NodeJS.ProcessEnv = {};
|
|
884
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
885
|
+
if (value !== undefined && !key.startsWith("GIT_")) env[key] = value;
|
|
886
|
+
}
|
|
887
|
+
env.GIT_TERMINAL_PROMPT = "0";
|
|
888
|
+
const extraPaths = ["/opt/homebrew/bin", "/usr/local/bin"];
|
|
889
|
+
const current = (env.PATH ?? "").split(":").filter(Boolean);
|
|
890
|
+
const missing = extraPaths.filter((p) => !current.includes(p));
|
|
891
|
+
if (missing.length > 0) {
|
|
892
|
+
env.PATH = [...current, ...missing].join(":");
|
|
893
|
+
}
|
|
894
|
+
return env;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Write the install-provenance sidecar into the staged plugin root, recording
|
|
899
|
+
* the resolved source coordinates and commit so we can later report or verify
|
|
900
|
+
* exactly what is installed. Hidden (dot-prefixed) so the daemon loader, which
|
|
901
|
+
* skips dotfiles, never mistakes it for plugin code.
|
|
902
|
+
*/
|
|
903
|
+
function writeInstallManifest(
|
|
904
|
+
stagingDir: string,
|
|
905
|
+
name: string,
|
|
906
|
+
source: PluginFetchSource,
|
|
907
|
+
ref: string,
|
|
908
|
+
commit: string | null,
|
|
909
|
+
): void {
|
|
910
|
+
const manifest = {
|
|
911
|
+
name,
|
|
912
|
+
source: {
|
|
913
|
+
kind: source.kind,
|
|
914
|
+
owner: source.owner,
|
|
915
|
+
repo: source.repo,
|
|
916
|
+
path: source.rootPath || undefined,
|
|
917
|
+
ref,
|
|
918
|
+
},
|
|
919
|
+
commit: commit ?? undefined,
|
|
920
|
+
installedAt: new Date().toISOString(),
|
|
921
|
+
};
|
|
922
|
+
writeFileSync(
|
|
923
|
+
join(stagingDir, INSTALL_MANIFEST_FILENAME),
|
|
924
|
+
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
925
|
+
);
|
|
207
926
|
}
|
|
208
927
|
|
|
928
|
+
/**
|
|
929
|
+
* Recursively copy a first-party plugin directory via the GitHub Contents API.
|
|
930
|
+
*
|
|
931
|
+
* First-party plugins live in our own monorepo as a small handful of files, so
|
|
932
|
+
* the per-directory walk stays well within the unauthenticated rate limit —
|
|
933
|
+
* and avoids cloning the entire repository just to install one plugin. Returns
|
|
934
|
+
* the number of files written; zero means the directory doesn't exist at this
|
|
935
|
+
* ref, which the caller maps to a not-found error.
|
|
936
|
+
*/
|
|
209
937
|
async function copyDir(
|
|
938
|
+
owner: string,
|
|
939
|
+
repo: string,
|
|
210
940
|
apiPath: string,
|
|
211
941
|
ref: string,
|
|
212
942
|
destDir: string,
|
|
213
943
|
fetchFn: FetchLike,
|
|
214
944
|
): Promise<number> {
|
|
215
|
-
const entries = await listDir(apiPath, ref, fetchFn);
|
|
945
|
+
const entries = await listDir(owner, repo, apiPath, ref, fetchFn);
|
|
216
946
|
if (entries === null) return 0;
|
|
217
947
|
|
|
218
948
|
let count = 0;
|
|
219
949
|
for (const entry of entries) {
|
|
950
|
+
// The daemon loader follows neither symlinks nor submodules; skip them.
|
|
951
|
+
if (entry.type === "symlink" || entry.type === "submodule") continue;
|
|
220
952
|
assertSafeFilename("entry name", entry.name);
|
|
953
|
+
|
|
221
954
|
if (entry.type === "dir") {
|
|
222
955
|
const subDest = join(destDir, entry.name);
|
|
223
956
|
mkdirSync(subDest, { recursive: true });
|
|
224
|
-
count += await copyDir(entry.path, ref, subDest, fetchFn);
|
|
225
|
-
continue;
|
|
226
|
-
}
|
|
227
|
-
if (entry.type === "file") {
|
|
228
|
-
await copyFile(entry, destDir, fetchFn);
|
|
229
|
-
count++;
|
|
957
|
+
count += await copyDir(owner, repo, entry.path, ref, subDest, fetchFn);
|
|
230
958
|
continue;
|
|
231
959
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
960
|
+
|
|
961
|
+
await copyFile(entry, join(destDir, entry.name), fetchFn);
|
|
962
|
+
count++;
|
|
235
963
|
}
|
|
236
964
|
return count;
|
|
237
965
|
}
|
|
238
966
|
|
|
967
|
+
/** Download one file entry from the Contents API into `dest`. */
|
|
968
|
+
async function copyFile(
|
|
969
|
+
entry: GitHubContentEntry,
|
|
970
|
+
dest: string,
|
|
971
|
+
fetchFn: FetchLike,
|
|
972
|
+
): Promise<void> {
|
|
973
|
+
if (!entry.download_url) {
|
|
974
|
+
throw new Error(`No download URL for ${entry.path}`);
|
|
975
|
+
}
|
|
976
|
+
const res = await githubFetch(
|
|
977
|
+
entry.download_url,
|
|
978
|
+
"application/octet-stream",
|
|
979
|
+
fetchFn,
|
|
980
|
+
);
|
|
981
|
+
if (!res.ok) {
|
|
982
|
+
const label = `Download failed for ${entry.path}: HTTP ${res.status}`;
|
|
983
|
+
if (isTransientUpstreamStatus(res)) {
|
|
984
|
+
throw new PluginSourceUnavailableError(label, res.status);
|
|
985
|
+
}
|
|
986
|
+
throw new Error(label);
|
|
987
|
+
}
|
|
988
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
989
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
990
|
+
writeFileSync(dest, buf);
|
|
991
|
+
}
|
|
992
|
+
|
|
239
993
|
async function listDir(
|
|
994
|
+
owner: string,
|
|
995
|
+
repo: string,
|
|
240
996
|
apiPath: string,
|
|
241
997
|
ref: string,
|
|
242
998
|
fetchFn: FetchLike,
|
|
243
999
|
): Promise<readonly GitHubContentEntry[] | null> {
|
|
244
1000
|
const url =
|
|
245
|
-
`https://api.github.com/repos/${
|
|
1001
|
+
`https://api.github.com/repos/${owner}/${repo}` +
|
|
246
1002
|
`/contents/${encodeURIComponent(apiPath).replaceAll("%2F", "/")}?ref=${encodeURIComponent(ref)}`;
|
|
247
1003
|
|
|
248
1004
|
const res = await githubFetch(url, "application/vnd.github+json", fetchFn);
|
|
249
1005
|
if (res.status === 404) return null;
|
|
250
1006
|
if (!res.ok) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
1007
|
+
const label = `GitHub contents listing failed for ${apiPath} @ ${ref}: HTTP ${res.status}`;
|
|
1008
|
+
if (isTransientUpstreamStatus(res)) {
|
|
1009
|
+
throw new PluginSourceUnavailableError(label, res.status);
|
|
1010
|
+
}
|
|
1011
|
+
throw new Error(label);
|
|
254
1012
|
}
|
|
255
1013
|
|
|
256
1014
|
const body = (await res.json()) as unknown;
|
|
@@ -263,27 +1021,6 @@ async function listDir(
|
|
|
263
1021
|
return body as readonly GitHubContentEntry[];
|
|
264
1022
|
}
|
|
265
1023
|
|
|
266
|
-
async function copyFile(
|
|
267
|
-
entry: GitHubContentEntry,
|
|
268
|
-
destDir: string,
|
|
269
|
-
fetchFn: FetchLike,
|
|
270
|
-
): Promise<void> {
|
|
271
|
-
if (!entry.download_url) {
|
|
272
|
-
throw new Error(`GitHub contents entry has no download_url: ${entry.path}`);
|
|
273
|
-
}
|
|
274
|
-
const res = await githubFetch(entry.download_url, "application/octet-stream", fetchFn);
|
|
275
|
-
if (!res.ok) {
|
|
276
|
-
throw new Error(`Download failed for ${entry.path}: HTTP ${res.status}`);
|
|
277
|
-
}
|
|
278
|
-
const buf = Buffer.from(await res.arrayBuffer());
|
|
279
|
-
// entry.name was already validated by the caller; assert again as a
|
|
280
|
-
// belt-and-braces guard so copyFile is safe to call from future paths.
|
|
281
|
-
assertSafeFilename("file entry name", entry.name);
|
|
282
|
-
const dest = join(destDir, entry.name);
|
|
283
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
284
|
-
writeFileSync(dest, buf);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
1024
|
/**
|
|
288
1025
|
* Wraps `fetchFn` with the headers we want to send to GitHub for every
|
|
289
1026
|
* request. Unauthenticated — the canonical source is a public repo, so
|