@vellumai/assistant 0.8.11 → 0.8.12-staging.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +15 -17
- package/README.md +0 -6
- package/bun.lock +6 -122
- package/node_modules/@vellumai/gateway-client/bun.lock +1 -0
- package/node_modules/@vellumai/gateway-client/package.json +3 -1
- package/node_modules/@vellumai/gateway-client/src/__tests__/gateway-client.test.ts +1 -1
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +87 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +3 -5
- package/openapi.yaml +633 -4
- package/package.json +1 -3
- package/src/__tests__/adaptive-thinking-repair.test.ts +185 -0
- package/src/__tests__/agent-loop-compaction-events.test.ts +7 -6
- package/src/__tests__/anthropic-provider.test.ts +129 -0
- package/src/__tests__/background-workers-disk-pressure.test.ts +4 -1
- package/src/__tests__/btw-routes.test.ts +7 -34
- package/src/__tests__/checker.test.ts +6 -12
- package/src/__tests__/config-loader-backfill.test.ts +4 -2
- package/src/__tests__/config-loader-quarantine-notice.test.ts +167 -0
- package/src/__tests__/config-watcher.test.ts +2 -2
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
- package/src/__tests__/conversation-error.test.ts +2 -6
- package/src/__tests__/conversation-history-web-search.test.ts +8 -0
- package/src/__tests__/conversation-title-service.test.ts +2 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -1
- package/src/__tests__/disk-pressure-tools.test.ts +1 -1
- package/src/__tests__/exploration-drift-hook.test.ts +692 -0
- package/src/__tests__/filing-service.test.ts +8 -3
- package/src/__tests__/guardian-action-store.test.ts +0 -167
- package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +1 -1
- package/src/__tests__/heartbeat-disk-pressure.test.ts +4 -1
- package/src/__tests__/heartbeat-service.test.ts +5 -2
- package/src/__tests__/identity-intro-cache.test.ts +12 -5
- package/src/__tests__/identity-routes.test.ts +16 -57
- package/src/__tests__/injector-chain.test.ts +8 -3
- package/src/__tests__/injector-config-quarantine-notice.test.ts +115 -0
- package/src/__tests__/llm-catalog-parity.test.ts +16 -0
- package/src/__tests__/llm-usage-store.test.ts +11 -0
- package/src/__tests__/log-export-workspace.test.ts +468 -3
- package/src/__tests__/memory-v2-static-injector.test.ts +22 -0
- package/src/__tests__/model-intents.test.ts +1 -1
- package/src/__tests__/oauth-cli.test.ts +19 -8
- package/src/__tests__/openai-provider.test.ts +34 -0
- package/src/__tests__/prechat-onboarding-contract.test.ts +0 -1
- package/src/__tests__/recurrence-engine.test.ts +45 -0
- package/src/__tests__/schedule-routes.test.ts +34 -0
- package/src/__tests__/scheduler-disk-pressure.test.ts +1 -1
- package/src/__tests__/script-proxy-conversation-manager.test.ts +10 -5
- package/src/__tests__/secret-fixtures.ts +20 -0
- package/src/__tests__/skill-tool-factory.test.ts +49 -0
- package/src/__tests__/subagent-role-registry.test.ts +24 -1
- package/src/__tests__/subagent-tools.test.ts +1 -0
- package/src/__tests__/system-prompt.test.ts +109 -11
- package/src/__tests__/tool-approval-handler.test.ts +85 -0
- package/src/__tests__/tool-audit-listener.test.ts +86 -0
- package/src/__tests__/tool-error-hook.test.ts +1 -0
- package/src/__tests__/tool-result-spool.test.ts +337 -0
- package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
- package/src/__tests__/validate-input.test.ts +95 -1
- package/src/__tests__/workspace-migration-098-remove-stale-updates-bulletin-file.test.ts +65 -0
- package/src/__tests__/workspace-migration-099-disable-cache-one-shot-callsites.test.ts +139 -0
- package/src/__tests__/workspace-migration-100-upgrade-quality-profile-to-fable-5.test.ts +174 -0
- package/src/__tests__/workspace-migration-101-upgrade-balanced-economy-to-minimax-m3.test.ts +162 -0
- package/src/__tests__/workspace-release-notes-feature-flag-guard.test.ts +45 -95
- package/src/acp/__tests__/agent-process.test.ts +315 -2
- package/src/acp/__tests__/prepare-agent-env.test.ts +79 -5
- package/src/acp/agent-process.ts +163 -34
- package/src/acp/prepare-agent-env.ts +55 -15
- package/src/agent/loop.ts +81 -24
- package/src/api/events/usage-progress.ts +28 -0
- package/src/api/index.ts +6 -0
- package/src/background-wake/wake-intent-hooks.test.ts +2 -0
- package/src/bundler/app-bundler.ts +25 -42
- package/src/bundler/app-compiler.ts +8 -0
- package/src/calls/call-controller.ts +1 -1
- package/src/cli/commands/plugins.ts +248 -15
- package/src/cli/lib/__tests__/inspect-plugin.test.ts +318 -0
- package/src/cli/lib/__tests__/install-from-github.test.ts +16 -9
- package/src/cli/lib/__tests__/plugin-artifact.test.ts +183 -0
- package/src/cli/lib/__tests__/plugin-details.test.ts +158 -0
- package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +245 -0
- package/src/cli/lib/__tests__/upgrade-plugin.test.ts +307 -0
- package/src/cli/lib/inspect-plugin.ts +252 -0
- package/src/cli/lib/install-from-github.ts +214 -21
- package/src/cli/lib/list-installed-plugins.ts +17 -6
- package/src/cli/lib/plugin-artifact.ts +103 -0
- package/src/cli/lib/plugin-details.ts +18 -1
- package/src/cli/lib/plugin-fingerprint.ts +197 -0
- package/src/cli/lib/upgrade-plugin.ts +225 -0
- package/src/config/bundled-skills/subagent/SKILL.md +2 -0
- package/src/config/bundled-skills/subagent/TOOLS.json +8 -2
- package/src/config/call-site-defaults.ts +13 -2
- package/src/config/feature-flag-registry.json +8 -16
- package/src/config/loader.ts +52 -59
- package/src/config/schema.ts +0 -2
- package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
- package/src/config/schemas/__tests__/memory-v3.test.ts +10 -0
- package/src/config/schemas/llm.ts +10 -0
- package/src/config/schemas/memory-v2.ts +13 -0
- package/src/config/schemas/memory-v3.ts +92 -0
- package/src/config/seed-inference-profiles.ts +4 -8
- package/src/context/post-turn-tool-result-truncation.ts +32 -18
- package/src/context/tool-result-spool.ts +104 -0
- package/src/credential-execution/feature-gates.ts +0 -1
- package/src/daemon/conversation-agent-loop-handlers.ts +41 -16
- package/src/daemon/conversation-error.ts +6 -15
- package/src/daemon/conversation.ts +9 -0
- package/src/daemon/disk-pressure-policy.ts +0 -1
- package/src/daemon/lifecycle.ts +1 -20
- package/src/daemon/message-types/conversations.ts +2 -15
- package/src/daemon/trust-context.ts +1 -1
- package/src/events/tool-audit-listener.ts +40 -9
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +1 -1
- package/src/home/__tests__/home-content-refresh.test.ts +114 -0
- package/src/home/__tests__/suggested-prompts.test.ts +86 -5
- package/src/home/home-content-refresh.ts +43 -31
- package/src/home/home-greeting-cache.ts +8 -1
- package/src/home/home-greeting.ts +13 -9
- package/src/home/suggested-prompts.ts +77 -24
- package/src/ipc/routes/trust-rules.test.ts +66 -72
- package/src/media/image-credentials.ts +2 -2
- package/src/memory/__tests__/compaction-log-store-clickhouse.test.ts +432 -0
- package/src/memory/{compaction-log-writer-clickhouse.ts → compaction-log-store-clickhouse.ts} +264 -55
- package/src/memory/conversation-attention-store.ts +1 -0
- package/src/memory/conversation-bootstrap.ts +18 -9
- package/src/memory/conversation-crud.ts +12 -2
- package/src/memory/conversation-title-service.ts +53 -9
- package/src/memory/delivery-channels.ts +0 -69
- package/src/memory/graph/extraction-job.ts +0 -15
- package/src/memory/guardian-action-store.ts +1 -376
- package/src/memory/llm-usage-store.ts +5 -1
- package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +2 -2
- package/src/memory/v2/__tests__/consolidation-job.test.ts +183 -2
- package/src/memory/v2/__tests__/injection.test.ts +70 -0
- package/src/memory/v2/__tests__/static-context.test.ts +12 -0
- package/src/memory/v2/consolidation-job.ts +93 -9
- package/src/memory/v2/injection.ts +53 -0
- package/src/memory/v2/prompts/consolidation.ts +1 -0
- package/src/memory/v2/static-context.ts +13 -1
- package/src/memory/v2/sweep-job.ts +1 -1
- package/src/memory/v2/types.ts +5 -0
- package/src/plugin-api/types.ts +7 -0
- package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +300 -0
- package/src/plugins/defaults/exploration-drift/package.json +15 -0
- package/src/plugins/defaults/index.ts +25 -0
- package/src/plugins/defaults/memory-retrieval/injectors.ts +132 -4
- package/src/plugins/defaults/memory-v3-shadow/__tests__/card.test.ts +92 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +2 -1
- package/src/plugins/defaults/memory-v3-shadow/__tests__/fresh-set.test.ts +52 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +1 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +2 -1
- package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +136 -5
- package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +17 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +6 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +5 -1
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +68 -4
- package/src/plugins/defaults/memory-v3-shadow/card.ts +49 -5
- package/src/plugins/defaults/memory-v3-shadow/fresh-set.ts +59 -0
- package/src/plugins/defaults/memory-v3-shadow/injector.ts +4 -2
- package/src/plugins/defaults/memory-v3-shadow/learned-edges.test.ts +169 -0
- package/src/plugins/defaults/memory-v3-shadow/learned-edges.ts +178 -0
- package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +115 -26
- package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +13 -9
- package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +144 -22
- package/src/plugins/defaults/memory-v3-shadow/types.ts +24 -6
- package/src/plugins/defaults/title-generate/hooks/stop.ts +13 -0
- package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +16 -0
- package/src/prompts/cache-boundary.ts +17 -0
- package/src/prompts/sections.ts +50 -17
- package/src/prompts/system-prompt.ts +12 -4
- package/src/prompts/templates/system-sections.ts +22 -0
- package/src/providers/__tests__/unparseable-tool-args.test.ts +53 -0
- package/src/providers/anthropic/client.ts +74 -28
- package/src/providers/gemini/client.ts +5 -1
- package/src/providers/minimax/client.ts +9 -0
- package/src/providers/model-catalog.ts +28 -0
- package/src/providers/model-intents.ts +3 -3
- package/src/providers/openai/chat-completions-provider.ts +4 -2
- package/src/providers/openai/responses-provider.ts +7 -2
- package/src/providers/retry.ts +8 -0
- package/src/providers/types.ts +11 -0
- package/src/providers/unparseable-tool-args.ts +56 -0
- package/src/runtime/AGENTS.md +6 -0
- package/src/runtime/__tests__/agent-wake.test.ts +2 -2
- package/src/runtime/agent-wake.ts +5 -5
- package/src/runtime/background-job-runner.ts +2 -2
- package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +150 -3
- package/src/runtime/migrations/vbundle-import-analyzer.ts +29 -6
- package/src/runtime/migrations/vbundle-import-policy.ts +23 -0
- package/src/runtime/migrations/vbundle-importer.ts +9 -4
- package/src/runtime/migrations/vbundle-streaming-importer.ts +8 -3
- package/src/runtime/pre-first-message-gate.ts +1 -1
- package/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +241 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +132 -0
- package/src/runtime/routes/__tests__/gateway-log-routes.test.ts +97 -185
- package/src/runtime/routes/__tests__/home-feed-routes.test.ts +17 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +348 -0
- package/src/runtime/routes/__tests__/task-routes.test.ts +3 -3
- package/src/runtime/routes/btw-routes.ts +0 -14
- package/src/runtime/routes/conversation-compaction-routes.ts +86 -19
- package/src/runtime/routes/conversation-list-routes.ts +77 -5
- package/src/runtime/routes/conversation-management-routes.ts +54 -0
- package/src/runtime/routes/conversation-query-routes.ts +79 -4
- package/src/runtime/routes/gateway-log-routes.ts +14 -64
- package/src/runtime/routes/home-feed-routes.ts +10 -0
- package/src/runtime/routes/identity-intro-cache.ts +1 -1
- package/src/runtime/routes/identity-routes.ts +76 -20
- package/src/runtime/routes/inbound-message-handler.ts +0 -36
- package/src/runtime/routes/log-export-routes.ts +143 -96
- package/src/runtime/routes/plugins-routes.ts +380 -0
- package/src/runtime/routes/redact-staged-export.ts +259 -0
- package/src/runtime/routes/schedule-routes.ts +19 -2
- package/src/runtime/routes/trust-rules-routes.ts +14 -67
- package/src/schedule/recurrence-engine.ts +34 -0
- package/src/schedule/scheduler.ts +1 -0
- package/src/security/redact-json.ts +61 -0
- package/src/skills/validate-input.ts +41 -1
- package/src/subagent/types.ts +26 -1
- package/src/telemetry/types.ts +15 -1
- package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
- package/src/telemetry/usage-telemetry-reporter.ts +1 -0
- package/src/tools/apps/executors.ts +1 -1
- package/src/tools/skills/skill-tool-factory.ts +19 -8
- package/src/tools/tool-approval-handler.ts +31 -0
- package/src/usage/types.ts +8 -1
- package/src/util/platform.ts +16 -0
- package/src/watcher/engine.ts +1 -0
- package/src/workspace/adaptive-thinking-repair.ts +113 -0
- package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +70 -67
- package/src/workspace/migrations/098-remove-stale-updates-bulletin-file.ts +31 -0
- package/src/workspace/migrations/099-disable-cache-one-shot-callsites.ts +81 -0
- package/src/workspace/migrations/100-upgrade-quality-profile-to-fable-5.ts +86 -0
- package/src/workspace/migrations/101-upgrade-balanced-economy-to-minimax-m3.ts +70 -0
- package/src/workspace/migrations/registry.ts +8 -0
- package/src/__tests__/config-loader-quarantine-bulletin.test.ts +0 -202
- package/src/__tests__/conversation-starters-cadence.test.ts +0 -161
- package/src/__tests__/guardian-action-followup-executor.test.ts +0 -322
- package/src/__tests__/guardian-action-followup-store.test.ts +0 -373
- package/src/__tests__/guardian-action-late-reply.test.ts +0 -1083
- package/src/__tests__/update-bulletin-job.test.ts +0 -292
- package/src/config/schemas/updates.ts +0 -14
- package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +0 -227
- package/src/memory/conversation-starters-cadence.ts +0 -78
- package/src/prompts/update-bulletin-job.ts +0 -180
- package/src/runtime/guardian-action-followup-executor.ts +0 -306
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inspect a single plugin: what is installed locally versus what the curated
|
|
3
|
+
* marketplace currently pins, and whether the two have drifted.
|
|
4
|
+
*
|
|
5
|
+
* The marketplace pins every plugin to a full, immutable commit SHA (see
|
|
6
|
+
* {@link ./plugin-marketplace}); an install records the exact commit it
|
|
7
|
+
* materialized in an `install-meta.json` provenance sidecar (see
|
|
8
|
+
* {@link ./install-from-github}). Drift detection is therefore an exact
|
|
9
|
+
* commit-SHA comparison — the pin only moves when a curator bumps it, so a
|
|
10
|
+
* mismatch means a newer pin is available. The local `package.json` version is
|
|
11
|
+
* surfaced as informational metadata, not the drift signal: a semver string may
|
|
12
|
+
* not change between pins, whereas the SHA always determines the bytes.
|
|
13
|
+
*
|
|
14
|
+
* Designed for direct programmatic use with an injected `fetch`, mirroring the
|
|
15
|
+
* sibling plugin libraries. The CLI command `assistant plugins inspect <name>`
|
|
16
|
+
* is a thin wrapper that supplies production deps and formats the result.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_PLUGIN_REF,
|
|
21
|
+
type FetchLike,
|
|
22
|
+
INSTALL_META_FILENAME,
|
|
23
|
+
type InstallMeta,
|
|
24
|
+
readInstallMeta,
|
|
25
|
+
sanitizePluginName,
|
|
26
|
+
} from "./install-from-github.js";
|
|
27
|
+
import {
|
|
28
|
+
type InstalledPluginInfo,
|
|
29
|
+
readInstalledPlugin,
|
|
30
|
+
} from "./list-installed-plugins.js";
|
|
31
|
+
import {
|
|
32
|
+
compareFingerprint,
|
|
33
|
+
type FingerprintComparison,
|
|
34
|
+
} from "./plugin-fingerprint.js";
|
|
35
|
+
import {
|
|
36
|
+
fetchMarketplaceEntries,
|
|
37
|
+
type MarketplaceEntry,
|
|
38
|
+
} from "./plugin-marketplace.js";
|
|
39
|
+
|
|
40
|
+
/** Full commit SHA (40 hex SHA-1 or 64 hex SHA-256). */
|
|
41
|
+
const FULL_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Drift classification between the installed copy and the marketplace pin.
|
|
45
|
+
*
|
|
46
|
+
* - `up-to-date` — installed commit equals the current marketplace pin.
|
|
47
|
+
* - `update-available` — installed commit differs from the pin; a newer
|
|
48
|
+
* reviewed revision is available via `plugins install --force`.
|
|
49
|
+
* - `not-installed` — no local copy; the marketplace metadata is shown as a
|
|
50
|
+
* preview of what would be installed.
|
|
51
|
+
* - `not-in-marketplace` — installed but no catalog entry claims the name, so
|
|
52
|
+
* there is no advertised remote to compare against.
|
|
53
|
+
* - `unknown-provenance` — installed and in the catalog, but no resolvable
|
|
54
|
+
* commit was recorded (an older or manually-copied install); reinstall to
|
|
55
|
+
* record provenance.
|
|
56
|
+
* - `remote-unavailable` — installed, but the marketplace could not be reached
|
|
57
|
+
* to determine the current pin (rate-limit / network); local info is shown.
|
|
58
|
+
*/
|
|
59
|
+
export type PluginUpdateStatus =
|
|
60
|
+
| "up-to-date"
|
|
61
|
+
| "update-available"
|
|
62
|
+
| "not-installed"
|
|
63
|
+
| "not-in-marketplace"
|
|
64
|
+
| "unknown-provenance"
|
|
65
|
+
| "remote-unavailable";
|
|
66
|
+
|
|
67
|
+
/** Locally installed copy of a plugin. */
|
|
68
|
+
export interface PluginLocalInfo {
|
|
69
|
+
/** Absolute path to the installed plugin directory. */
|
|
70
|
+
readonly target: string;
|
|
71
|
+
/** Resolved commit the copy was installed at; `null` when no provenance was recorded. */
|
|
72
|
+
readonly commit: string | null;
|
|
73
|
+
/** `package.json` `version`, when present. */
|
|
74
|
+
readonly version: string | null;
|
|
75
|
+
/** `package.json` `description`, when present. */
|
|
76
|
+
readonly description: string | null;
|
|
77
|
+
/** ISO-8601 install timestamp from the provenance sidecar; `null` when absent. */
|
|
78
|
+
readonly installedAt: string | null;
|
|
79
|
+
/** Source coordinates recorded at install time; `null` when no sidecar exists. */
|
|
80
|
+
readonly source: InstallMeta["source"] | null;
|
|
81
|
+
/**
|
|
82
|
+
* Local-edit state relative to the install-time fingerprint: `null` when no
|
|
83
|
+
* fingerprint was recorded (an older or manually-copied install), so
|
|
84
|
+
* modification cannot be determined.
|
|
85
|
+
*/
|
|
86
|
+
readonly localChanges: FingerprintComparison | null;
|
|
87
|
+
/** Non-fatal issues with the installed copy (e.g. malformed `package.json`). */
|
|
88
|
+
readonly issues: readonly string[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The marketplace's current pin and advertised metadata for a plugin. */
|
|
92
|
+
export interface PluginRemoteInfo {
|
|
93
|
+
/** `owner/repo` of the external plugin repository. */
|
|
94
|
+
readonly repo: string;
|
|
95
|
+
/** Repo-relative directory holding the plugin root; `""` = repo root. */
|
|
96
|
+
readonly path: string;
|
|
97
|
+
/** Pinned commit SHA the marketplace currently resolves installs to. */
|
|
98
|
+
readonly commit: string;
|
|
99
|
+
readonly description: string | null;
|
|
100
|
+
readonly homepage: string | null;
|
|
101
|
+
readonly license: string | null;
|
|
102
|
+
readonly category: string | null;
|
|
103
|
+
/** Ref of the canonical repo the marketplace manifest was read from. */
|
|
104
|
+
readonly marketplaceRef: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Resolved inspection of a single plugin. */
|
|
108
|
+
export interface PluginInspection {
|
|
109
|
+
/** Install name. Matches `assistant plugins install <name>`. */
|
|
110
|
+
readonly name: string;
|
|
111
|
+
/** Whether a copy is materialized under the workspace plugins directory. */
|
|
112
|
+
readonly installed: boolean;
|
|
113
|
+
/** Drift classification between the installed copy and the marketplace pin. */
|
|
114
|
+
readonly status: PluginUpdateStatus;
|
|
115
|
+
/** Locally installed copy; `null` when the plugin is not installed. */
|
|
116
|
+
readonly local: PluginLocalInfo | null;
|
|
117
|
+
/** Marketplace pin + metadata; `null` when no entry claims the name or it was unreachable. */
|
|
118
|
+
readonly remote: PluginRemoteInfo | null;
|
|
119
|
+
/** Marketplace fetch error message, when the catalog could not be read. */
|
|
120
|
+
readonly remoteError: string | null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Neither an installed copy nor a marketplace entry claims the name. */
|
|
124
|
+
export class PluginInspectNotFoundError extends Error {
|
|
125
|
+
constructor(readonly pluginName: string) {
|
|
126
|
+
super(
|
|
127
|
+
`Plugin "${pluginName}" is not installed and has no marketplace entry.`,
|
|
128
|
+
);
|
|
129
|
+
this.name = "PluginInspectNotFoundError";
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Options that control which plugin to inspect. */
|
|
134
|
+
export interface InspectPluginOptions {
|
|
135
|
+
/** Install name (kebab-case directory name). */
|
|
136
|
+
readonly name: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Dependencies injected by the caller. */
|
|
140
|
+
export interface InspectPluginDeps {
|
|
141
|
+
/** HTTP client. Production callers pass `globalThis.fetch.bind(globalThis)`. */
|
|
142
|
+
readonly fetch: FetchLike;
|
|
143
|
+
/** Override the workspace plugins directory. Falls back to the live workspace. */
|
|
144
|
+
readonly workspacePluginsDir?: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function readLocal(
|
|
148
|
+
entry: InstalledPluginInfo,
|
|
149
|
+
manifest: InstallMeta | null,
|
|
150
|
+
): PluginLocalInfo {
|
|
151
|
+
// The provenance commit is authoritative; fall back to the recorded ref only
|
|
152
|
+
// when it is itself a full SHA (marketplace installs always pin one), so a
|
|
153
|
+
// sidecar written before the commit could be read still yields a comparable
|
|
154
|
+
// revision instead of dropping to "unknown".
|
|
155
|
+
const commit =
|
|
156
|
+
manifest?.commit ??
|
|
157
|
+
(manifest && FULL_SHA_RE.test(manifest.source.ref)
|
|
158
|
+
? manifest.source.ref
|
|
159
|
+
: null);
|
|
160
|
+
// Compare the on-disk tree against the install-time baseline, applying the
|
|
161
|
+
// same exclusion so the sidecar is never counted as a local addition.
|
|
162
|
+
const localChanges = manifest?.fingerprint
|
|
163
|
+
? compareFingerprint(entry.target, manifest.fingerprint, [
|
|
164
|
+
INSTALL_META_FILENAME,
|
|
165
|
+
])
|
|
166
|
+
: null;
|
|
167
|
+
return {
|
|
168
|
+
target: entry.target,
|
|
169
|
+
commit,
|
|
170
|
+
version: entry.packageJson?.version ?? null,
|
|
171
|
+
description: entry.packageJson?.description ?? null,
|
|
172
|
+
installedAt: manifest?.installedAt || null,
|
|
173
|
+
source: manifest?.source ?? null,
|
|
174
|
+
localChanges,
|
|
175
|
+
issues: entry.issues,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function readRemote(
|
|
180
|
+
entry: MarketplaceEntry,
|
|
181
|
+
marketplaceRef: string,
|
|
182
|
+
): PluginRemoteInfo {
|
|
183
|
+
return {
|
|
184
|
+
repo: entry.source.repo,
|
|
185
|
+
path: entry.source.path ?? "",
|
|
186
|
+
commit: entry.source.ref,
|
|
187
|
+
description: entry.description ?? null,
|
|
188
|
+
homepage: entry.homepage ?? null,
|
|
189
|
+
license: entry.license ?? null,
|
|
190
|
+
category: entry.category ?? null,
|
|
191
|
+
marketplaceRef,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Resolve the local-vs-remote inspection for a single plugin.
|
|
197
|
+
*
|
|
198
|
+
* Throws {@link PluginInspectNotFoundError} only when the plugin is neither
|
|
199
|
+
* installed nor present in the marketplace — there is nothing to show. A
|
|
200
|
+
* marketplace fetch failure for an *installed* plugin is not fatal: the local
|
|
201
|
+
* copy is reported with `status: "remote-unavailable"`.
|
|
202
|
+
*/
|
|
203
|
+
export async function inspectPlugin(
|
|
204
|
+
opts: InspectPluginOptions,
|
|
205
|
+
deps: InspectPluginDeps,
|
|
206
|
+
): Promise<PluginInspection> {
|
|
207
|
+
const name = sanitizePluginName(opts.name);
|
|
208
|
+
const marketplaceRef = DEFAULT_PLUGIN_REF;
|
|
209
|
+
|
|
210
|
+
const entry = readInstalledPlugin(name, {
|
|
211
|
+
workspacePluginsDir: deps.workspacePluginsDir,
|
|
212
|
+
});
|
|
213
|
+
const installed = entry !== null;
|
|
214
|
+
const local = entry ? readLocal(entry, readInstallMeta(entry.target)) : null;
|
|
215
|
+
|
|
216
|
+
let remote: PluginRemoteInfo | null = null;
|
|
217
|
+
let remoteError: string | null = null;
|
|
218
|
+
try {
|
|
219
|
+
const entries = await fetchMarketplaceEntries(
|
|
220
|
+
{ fetch: deps.fetch },
|
|
221
|
+
{ ref: marketplaceRef },
|
|
222
|
+
);
|
|
223
|
+
const match = entries.find((e) => e.name === name);
|
|
224
|
+
if (match) remote = readRemote(match, marketplaceRef);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
remoteError = err instanceof Error ? err.message : String(err);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!installed && !remote) {
|
|
230
|
+
// A reachable-but-empty catalog with no local copy is a genuine not-found;
|
|
231
|
+
// a fetch failure with no local copy leaves nothing to report either.
|
|
232
|
+
throw new PluginInspectNotFoundError(name);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const status = classify(installed, local, remote, remoteError);
|
|
236
|
+
return { name, installed, status, local, remote, remoteError };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function classify(
|
|
240
|
+
installed: boolean,
|
|
241
|
+
local: PluginLocalInfo | null,
|
|
242
|
+
remote: PluginRemoteInfo | null,
|
|
243
|
+
remoteError: string | null,
|
|
244
|
+
): PluginUpdateStatus {
|
|
245
|
+
if (!installed) return "not-installed";
|
|
246
|
+
if (remoteError && !remote) return "remote-unavailable";
|
|
247
|
+
if (!remote) return "not-in-marketplace";
|
|
248
|
+
if (!local?.commit) return "unknown-provenance";
|
|
249
|
+
return local.commit.toLowerCase() === remote.commit.toLowerCase()
|
|
250
|
+
? "up-to-date"
|
|
251
|
+
: "update-available";
|
|
252
|
+
}
|
|
@@ -40,6 +40,12 @@ import { promisify } from "node:util";
|
|
|
40
40
|
|
|
41
41
|
import { ensureBun } from "../../util/bun-runtime.js";
|
|
42
42
|
import { getWorkspacePluginsDir } from "../../util/platform.js";
|
|
43
|
+
import {
|
|
44
|
+
computeContentHash,
|
|
45
|
+
computeFingerprint,
|
|
46
|
+
type Fingerprint,
|
|
47
|
+
parseFingerprint,
|
|
48
|
+
} from "./plugin-fingerprint.js";
|
|
43
49
|
import {
|
|
44
50
|
fetchMarketplaceEntries,
|
|
45
51
|
MarketplaceFetchError,
|
|
@@ -389,11 +395,25 @@ export async function installPlugin(
|
|
|
389
395
|
throw new PluginNotFoundError(name, ref, sourceLabel(source));
|
|
390
396
|
}
|
|
391
397
|
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
|
|
398
|
+
// Hash the materialized tree before the sidecar is written (so the sidecar
|
|
399
|
+
// never hashes itself) — the baseline `plugins inspect` uses to detect later
|
|
400
|
+
// local edits. The per-file fingerprint answers "which files changed"; the
|
|
401
|
+
// whole-tree content hash is a compact integrity signal mirroring skills.
|
|
402
|
+
const fingerprint = computeFingerprint(stagingDir, [INSTALL_META_FILENAME]);
|
|
403
|
+
const contentHash = computeContentHash(stagingDir, [INSTALL_META_FILENAME]);
|
|
404
|
+
|
|
405
|
+
// Record install provenance (source coordinates + resolved commit + content
|
|
406
|
+
// digests) as a sidecar before the swap so it lands atomically with the
|
|
407
|
+
// files. The external plugin loader only reads `package.json` and the
|
|
408
|
+
// `hooks/`/`tools/` dirs, so this JSON file is never mistaken for code.
|
|
409
|
+
writeInstallMeta(stagingDir, {
|
|
410
|
+
name,
|
|
411
|
+
source,
|
|
412
|
+
ref,
|
|
413
|
+
commit,
|
|
414
|
+
fingerprint,
|
|
415
|
+
contentHash,
|
|
416
|
+
});
|
|
397
417
|
|
|
398
418
|
// Atomic-ish swap: rmSync + renameSync. On POSIX the rename itself is
|
|
399
419
|
// atomic, so the only window where the target is absent is between the
|
|
@@ -412,8 +432,78 @@ export async function installPlugin(
|
|
|
412
432
|
/** Cap on any single git invocation; a shallow fetch is well under this. */
|
|
413
433
|
const GIT_TIMEOUT_MS = 120_000;
|
|
414
434
|
|
|
415
|
-
/**
|
|
416
|
-
|
|
435
|
+
/**
|
|
436
|
+
* Install-provenance sidecar written at the plugin root. Named to match the
|
|
437
|
+
* skills' sidecar (`install-meta.json`, see `src/skills/install-meta.ts`) so
|
|
438
|
+
* both subsystems share one vocabulary. The external plugin loader only reads
|
|
439
|
+
* `package.json` and the `hooks/`/`tools/` surface dirs, so a plain JSON file
|
|
440
|
+
* at the root is ignored by it.
|
|
441
|
+
*/
|
|
442
|
+
export const INSTALL_META_FILENAME = "install-meta.json";
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Which catalog manages an installed plugin. Mirrors the skill origin values
|
|
446
|
+
* (`SkillInstallMeta.origin` in `src/skills/install-meta.ts`) so the two
|
|
447
|
+
* systems keep a consistent vocabulary. `"vellum"` denotes the first-party
|
|
448
|
+
* `marketplace.json`; the union widens as new sources are supported.
|
|
449
|
+
*/
|
|
450
|
+
export type InstallOrigin = "vellum";
|
|
451
|
+
|
|
452
|
+
/** Resolved source coordinates recorded in the provenance sidecar. */
|
|
453
|
+
export interface InstallMetaSource {
|
|
454
|
+
/** Source kind. Only `github` is written today. */
|
|
455
|
+
readonly kind: string;
|
|
456
|
+
readonly owner: string;
|
|
457
|
+
readonly repo: string;
|
|
458
|
+
/** Repo-relative directory holding the plugin root; absent = repo root. */
|
|
459
|
+
readonly path?: string;
|
|
460
|
+
/** Ref the install resolved through (the pinned commit SHA for marketplace installs). */
|
|
461
|
+
readonly ref: string;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Parsed contents of the `install-meta.json` provenance sidecar — what was
|
|
466
|
+
* installed, from where, and at exactly which commit. Read by
|
|
467
|
+
* {@link readInstallMeta} for provenance reporting (e.g. `plugins inspect`).
|
|
468
|
+
*
|
|
469
|
+
* The leading fields share names (and meaning) with the skills'
|
|
470
|
+
* `SkillInstallMeta`; everything below {@link InstallMeta.name} is the
|
|
471
|
+
* plugin-specific superset that the git-backed install needs.
|
|
472
|
+
*/
|
|
473
|
+
export interface InstallMeta {
|
|
474
|
+
/** Catalog the install is managed from. */
|
|
475
|
+
readonly origin: InstallOrigin;
|
|
476
|
+
/** ISO-8601 timestamp of when the install was materialized. */
|
|
477
|
+
readonly installedAt: string;
|
|
478
|
+
/** Principal that initiated the install, when known. */
|
|
479
|
+
readonly installedBy?: string;
|
|
480
|
+
/** Set by a backfill migration when provenance was reconstructed after the fact. */
|
|
481
|
+
readonly backfilledBy?: string;
|
|
482
|
+
/** Plugin `package.json` version at install time, when present. */
|
|
483
|
+
readonly version?: string;
|
|
484
|
+
/** Registry slug, recorded when it diverges from {@link InstallMeta.name}. */
|
|
485
|
+
readonly slug?: string;
|
|
486
|
+
/** `owner/repo` the install was sourced from. */
|
|
487
|
+
readonly sourceRepo?: string;
|
|
488
|
+
/**
|
|
489
|
+
* Whole-tree `v2:` content hash — a compact integrity signal using the same
|
|
490
|
+
* scheme as the skills' `contentHash`. Complements the per-file
|
|
491
|
+
* {@link InstallMeta.fingerprint}.
|
|
492
|
+
*/
|
|
493
|
+
readonly contentHash?: string;
|
|
494
|
+
|
|
495
|
+
/** Install name. Matches the plugins directory and `plugins install <name>`. */
|
|
496
|
+
readonly name: string;
|
|
497
|
+
readonly source: InstallMetaSource;
|
|
498
|
+
/** Resolved commit SHA the source was cloned at; `null` when it could not be read at install time. */
|
|
499
|
+
readonly commit: string | null;
|
|
500
|
+
/**
|
|
501
|
+
* Per-file content digest of the materialized tree, captured at install
|
|
502
|
+
* time. `null` for older installs written before fingerprinting; callers
|
|
503
|
+
* then report local-modification state as unknown rather than clean.
|
|
504
|
+
*/
|
|
505
|
+
readonly fingerprint: Fingerprint | null;
|
|
506
|
+
}
|
|
417
507
|
|
|
418
508
|
/**
|
|
419
509
|
* Materialize an external plugin by shallow-cloning its repo at the pinned ref.
|
|
@@ -852,20 +942,59 @@ function pluginGitEnv(): NodeJS.ProcessEnv {
|
|
|
852
942
|
return env;
|
|
853
943
|
}
|
|
854
944
|
|
|
945
|
+
/** Inputs for {@link writeInstallMeta}, resolved during a fresh install. */
|
|
946
|
+
interface WriteInstallMetaParams {
|
|
947
|
+
readonly name: string;
|
|
948
|
+
readonly source: PluginFetchSource;
|
|
949
|
+
readonly ref: string;
|
|
950
|
+
readonly commit: string | null;
|
|
951
|
+
readonly fingerprint: Fingerprint;
|
|
952
|
+
readonly contentHash: string;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Read the `version` field from a staged plugin's `package.json`. Lenient — a
|
|
957
|
+
* missing or malformed manifest simply yields `undefined` so provenance is
|
|
958
|
+
* recorded without it rather than failing the install.
|
|
959
|
+
*/
|
|
960
|
+
function readStagedPackageVersion(stagingDir: string): string | undefined {
|
|
961
|
+
const pkgPath = join(stagingDir, "package.json");
|
|
962
|
+
if (!existsSync(pkgPath)) return undefined;
|
|
963
|
+
try {
|
|
964
|
+
const parsed: unknown = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
965
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
966
|
+
const version = (parsed as Record<string, unknown>).version;
|
|
967
|
+
if (typeof version === "string" && version.length > 0) return version;
|
|
968
|
+
}
|
|
969
|
+
} catch {
|
|
970
|
+
// fall through to undefined
|
|
971
|
+
}
|
|
972
|
+
return undefined;
|
|
973
|
+
}
|
|
974
|
+
|
|
855
975
|
/**
|
|
856
|
-
* Write the install-provenance sidecar into the staged plugin root,
|
|
857
|
-
* the resolved source coordinates and
|
|
858
|
-
* exactly what is installed.
|
|
859
|
-
*
|
|
976
|
+
* Write the `install-meta.json` provenance sidecar into the staged plugin root,
|
|
977
|
+
* recording the resolved source coordinates, commit, and content digests so we
|
|
978
|
+
* can later report or verify exactly what is installed. The schema is an
|
|
979
|
+
* equal-name superset of the skills' `SkillInstallMeta`.
|
|
860
980
|
*/
|
|
861
|
-
function
|
|
981
|
+
function writeInstallMeta(
|
|
862
982
|
stagingDir: string,
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
983
|
+
{
|
|
984
|
+
name,
|
|
985
|
+
source,
|
|
986
|
+
ref,
|
|
987
|
+
commit,
|
|
988
|
+
fingerprint,
|
|
989
|
+
contentHash,
|
|
990
|
+
}: WriteInstallMetaParams,
|
|
867
991
|
): void {
|
|
868
|
-
const
|
|
992
|
+
const meta: InstallMeta = {
|
|
993
|
+
origin: "vellum",
|
|
994
|
+
installedAt: new Date().toISOString(),
|
|
995
|
+
version: readStagedPackageVersion(stagingDir),
|
|
996
|
+
sourceRepo: `${source.owner}/${source.repo}`,
|
|
997
|
+
contentHash,
|
|
869
998
|
name,
|
|
870
999
|
source: {
|
|
871
1000
|
kind: "github",
|
|
@@ -874,15 +1003,79 @@ function writeInstallManifest(
|
|
|
874
1003
|
path: source.rootPath || undefined,
|
|
875
1004
|
ref,
|
|
876
1005
|
},
|
|
877
|
-
commit
|
|
878
|
-
|
|
1006
|
+
commit,
|
|
1007
|
+
fingerprint,
|
|
879
1008
|
};
|
|
880
1009
|
writeFileSync(
|
|
881
|
-
join(stagingDir,
|
|
882
|
-
`${JSON.stringify(
|
|
1010
|
+
join(stagingDir, INSTALL_META_FILENAME),
|
|
1011
|
+
`${JSON.stringify(meta, null, 2)}\n`,
|
|
883
1012
|
);
|
|
884
1013
|
}
|
|
885
1014
|
|
|
1015
|
+
/**
|
|
1016
|
+
* Read the install-provenance sidecar from an installed plugin's root.
|
|
1017
|
+
*
|
|
1018
|
+
* Lenient by design — a missing, unreadable, or malformed sidecar yields
|
|
1019
|
+
* `null` rather than throwing, mirroring {@link ./list-installed-plugins}.
|
|
1020
|
+
* Older or manually-copied installs that predate the sidecar simply report no
|
|
1021
|
+
* provenance. The resolved commit is the authoritative record of which bytes
|
|
1022
|
+
* are installed, so callers (e.g. `plugins inspect`) can compare it against the
|
|
1023
|
+
* marketplace's current pin to detect drift.
|
|
1024
|
+
*/
|
|
1025
|
+
export function readInstallMeta(pluginDir: string): InstallMeta | null {
|
|
1026
|
+
const metaPath = join(pluginDir, INSTALL_META_FILENAME);
|
|
1027
|
+
if (!existsSync(metaPath)) return null;
|
|
1028
|
+
|
|
1029
|
+
let parsed: unknown;
|
|
1030
|
+
try {
|
|
1031
|
+
parsed = JSON.parse(readFileSync(metaPath, "utf8"));
|
|
1032
|
+
} catch {
|
|
1033
|
+
return null;
|
|
1034
|
+
}
|
|
1035
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
1036
|
+
return null;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const obj = parsed as Record<string, unknown>;
|
|
1040
|
+
const src = obj.source;
|
|
1041
|
+
if (typeof src !== "object" || src === null || Array.isArray(src)) {
|
|
1042
|
+
return null;
|
|
1043
|
+
}
|
|
1044
|
+
const source = src as Record<string, unknown>;
|
|
1045
|
+
if (
|
|
1046
|
+
typeof obj.name !== "string" ||
|
|
1047
|
+
typeof source.owner !== "string" ||
|
|
1048
|
+
typeof source.repo !== "string" ||
|
|
1049
|
+
typeof source.ref !== "string"
|
|
1050
|
+
) {
|
|
1051
|
+
return null;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const optionalString = (value: unknown): string | undefined =>
|
|
1055
|
+
typeof value === "string" ? value : undefined;
|
|
1056
|
+
|
|
1057
|
+
return {
|
|
1058
|
+
origin: "vellum",
|
|
1059
|
+
installedAt: typeof obj.installedAt === "string" ? obj.installedAt : "",
|
|
1060
|
+
installedBy: optionalString(obj.installedBy),
|
|
1061
|
+
backfilledBy: optionalString(obj.backfilledBy),
|
|
1062
|
+
version: optionalString(obj.version),
|
|
1063
|
+
slug: optionalString(obj.slug),
|
|
1064
|
+
sourceRepo: optionalString(obj.sourceRepo),
|
|
1065
|
+
contentHash: optionalString(obj.contentHash),
|
|
1066
|
+
name: obj.name,
|
|
1067
|
+
source: {
|
|
1068
|
+
kind: typeof source.kind === "string" ? source.kind : "github",
|
|
1069
|
+
owner: source.owner,
|
|
1070
|
+
repo: source.repo,
|
|
1071
|
+
path: typeof source.path === "string" ? source.path : undefined,
|
|
1072
|
+
ref: source.ref,
|
|
1073
|
+
},
|
|
1074
|
+
commit: typeof obj.commit === "string" ? obj.commit : null,
|
|
1075
|
+
fingerprint: parseFingerprint(obj.fingerprint),
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
|
|
886
1079
|
/**
|
|
887
1080
|
* Recursively copy a curated adapter stub directory via the GitHub Contents API.
|
|
888
1081
|
*
|
|
@@ -12,12 +12,7 @@
|
|
|
12
12
|
* and we want both surfaces to agree on what's present.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
16
|
-
existsSync,
|
|
17
|
-
readdirSync,
|
|
18
|
-
readFileSync,
|
|
19
|
-
statSync,
|
|
20
|
-
} from "node:fs";
|
|
15
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
21
16
|
import { join } from "node:path";
|
|
22
17
|
|
|
23
18
|
import { getWorkspacePluginsDir } from "../../util/platform.js";
|
|
@@ -81,6 +76,22 @@ export function listInstalledPlugins(
|
|
|
81
76
|
return entries.map((name) => readPluginEntry(pluginsDir, name));
|
|
82
77
|
}
|
|
83
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Read a single installed plugin entry by name, or `null` when no directory
|
|
81
|
+
* for it exists under the workspace plugins directory. Parses leniently like
|
|
82
|
+
* {@link listInstalledPlugins} — a malformed `package.json` surfaces as an
|
|
83
|
+
* `issues` entry rather than throwing.
|
|
84
|
+
*/
|
|
85
|
+
export function readInstalledPlugin(
|
|
86
|
+
name: string,
|
|
87
|
+
opts: ListInstalledPluginsOptions = {},
|
|
88
|
+
): InstalledPluginInfo | null {
|
|
89
|
+
const pluginsDir = opts.workspacePluginsDir ?? getWorkspacePluginsDir();
|
|
90
|
+
const target = join(pluginsDir, name);
|
|
91
|
+
if (!existsSync(target) || !statSync(target).isDirectory()) return null;
|
|
92
|
+
return readPluginEntry(pluginsDir, name);
|
|
93
|
+
}
|
|
94
|
+
|
|
84
95
|
function readPluginEntry(
|
|
85
96
|
pluginsDir: string,
|
|
86
97
|
name: string,
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a plugin's prebuilt client-artifact descriptor from its
|
|
3
|
+
* `package.json` `vellum.artifact` block.
|
|
4
|
+
*
|
|
5
|
+
* Some plugins ship a native companion the assistant cannot build itself —
|
|
6
|
+
* e.g. a macOS app whose Swift toolchain is absent from the Linux container
|
|
7
|
+
* the daemon runs in. Those plugins publish a prebuilt, author-signed binary
|
|
8
|
+
* out-of-band (a GitHub Release) and point at it from their manifest:
|
|
9
|
+
*
|
|
10
|
+
* "vellum": {
|
|
11
|
+
* "artifact": {
|
|
12
|
+
* "url": "https://github.com/owner/repo/releases/download/v1.0.0/App.dmg",
|
|
13
|
+
* "sha256": "<64-hex>",
|
|
14
|
+
* "label": "Download for macOS"
|
|
15
|
+
* }
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* The `url` is mutable (a Release asset can be re-uploaded); the `sha256` is
|
|
19
|
+
* the integrity anchor a client verifies the download against — the same
|
|
20
|
+
* "pin the immutable hash, not the mutable pointer" philosophy the source
|
|
21
|
+
* whitelist already enforces with full commit SHAs.
|
|
22
|
+
*
|
|
23
|
+
* An artifact is only surfaced when **both** fields are well-formed. A
|
|
24
|
+
* missing block, a non-`https:` URL, or an absent / placeholder `sha256`
|
|
25
|
+
* (e.g. the empty string a release workflow fills in on its first run) all
|
|
26
|
+
* resolve to `null` — "no downloadable artifact yet" — so a client never
|
|
27
|
+
* offers an unverifiable download.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { z } from "zod";
|
|
31
|
+
|
|
32
|
+
/** A verified, downloadable client artifact declared by a plugin. */
|
|
33
|
+
export interface PluginArtifact {
|
|
34
|
+
/** HTTPS URL the artifact is downloaded from. */
|
|
35
|
+
readonly url: string;
|
|
36
|
+
/** Lowercase 64-char hex SHA-256 the download is verified against. */
|
|
37
|
+
readonly sha256: string;
|
|
38
|
+
/**
|
|
39
|
+
* Optional human label for the download affordance — useful when a plugin
|
|
40
|
+
* ships more than one artifact (e.g. "Download for macOS", "Apple Silicon").
|
|
41
|
+
* Absent (or blank) when the plugin doesn't name it; clients fall back to a
|
|
42
|
+
* generic label.
|
|
43
|
+
*/
|
|
44
|
+
readonly label?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Schema for a complete artifact descriptor. The URL must be absolute and
|
|
51
|
+
* `https:` (an artifact fetched over plaintext defeats the integrity story);
|
|
52
|
+
* the digest must be canonical lowercase hex so client-side comparison is a
|
|
53
|
+
* plain string equality with no normalization step.
|
|
54
|
+
*/
|
|
55
|
+
const PluginArtifactSchema = z.object({
|
|
56
|
+
url: z
|
|
57
|
+
.string()
|
|
58
|
+
.url()
|
|
59
|
+
.refine((u) => u.startsWith("https://"), {
|
|
60
|
+
message: "artifact url must be an https:// URL",
|
|
61
|
+
}),
|
|
62
|
+
sha256: z
|
|
63
|
+
.string()
|
|
64
|
+
.regex(SHA256_HEX_RE, "artifact sha256 must be 64 lowercase hex chars"),
|
|
65
|
+
// Optional, non-critical metadata: a malformed label must never nullify an
|
|
66
|
+
// otherwise-valid `url` + `sha256`, so a wrong-typed value falls back to
|
|
67
|
+
// `undefined` rather than failing the whole descriptor.
|
|
68
|
+
label: z.string().optional().catch(undefined),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Read `vellum.artifact` from an already-parsed `package.json` value and
|
|
73
|
+
* return it only when it is a complete, well-formed descriptor. Any shape
|
|
74
|
+
* problem — missing block, wrong types, non-https URL, placeholder/empty
|
|
75
|
+
* `sha256` — yields `null` rather than throwing, so callers can union this
|
|
76
|
+
* across sources without per-source error handling.
|
|
77
|
+
*/
|
|
78
|
+
export function parsePluginArtifact(
|
|
79
|
+
packageJson: unknown,
|
|
80
|
+
): PluginArtifact | null {
|
|
81
|
+
if (
|
|
82
|
+
typeof packageJson !== "object" ||
|
|
83
|
+
packageJson === null ||
|
|
84
|
+
Array.isArray(packageJson)
|
|
85
|
+
) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const vellum = (packageJson as Record<string, unknown>).vellum;
|
|
89
|
+
if (typeof vellum !== "object" || vellum === null || Array.isArray(vellum)) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const artifact = (vellum as Record<string, unknown>).artifact;
|
|
93
|
+
const parsed = PluginArtifactSchema.safeParse(artifact);
|
|
94
|
+
if (!parsed.success) return null;
|
|
95
|
+
// A blank or whitespace-only label is treated as absent so it never
|
|
96
|
+
// invalidates an otherwise well-formed `url` + `sha256` descriptor.
|
|
97
|
+
const label = parsed.data.label?.trim();
|
|
98
|
+
return {
|
|
99
|
+
url: parsed.data.url,
|
|
100
|
+
sha256: parsed.data.sha256,
|
|
101
|
+
...(label ? { label } : {}),
|
|
102
|
+
};
|
|
103
|
+
}
|