@vellumai/assistant 0.8.9-staging.2 → 0.8.9-staging.3
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/docs/activation-funnel-telemetry.md +310 -0
- package/package.json +1 -1
- package/src/__tests__/activation-early-marking.test.ts +120 -0
- package/src/__tests__/agent-loop-output-hooks.test.ts +13 -13
- package/src/__tests__/approval-cascade.test.ts +1 -1
- package/src/__tests__/compaction-direct.test.ts +32 -18
- package/src/__tests__/compaction-events.test.ts +2 -2
- package/src/__tests__/compaction.benchmark.test.ts +1 -1
- package/src/__tests__/context-overflow-reducer.test.ts +5 -5
- package/src/__tests__/context-window-manager-compact-retry.test.ts +1 -1
- package/src/__tests__/conversation-abort-tool-results.test.ts +1 -1
- package/src/__tests__/conversation-confirmation-signals.test.ts +1 -1
- package/src/__tests__/conversation-error.test.ts +15 -1
- package/src/__tests__/conversation-history-web-search.test.ts +5 -0
- package/src/__tests__/conversation-media-retry.test.ts +1 -1
- package/src/__tests__/conversation-process-app-control-preactivation.test.ts +40 -0
- package/src/__tests__/conversation-process-callsite.test.ts +1 -1
- package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -1
- package/src/__tests__/conversation-queue.test.ts +1 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +71 -0
- package/src/__tests__/conversation-slash-queue.test.ts +1 -1
- package/src/__tests__/conversation-slash-unknown.test.ts +1 -1
- package/src/__tests__/conversation-speed-override.test.ts +1 -1
- package/src/__tests__/conversation-surfaces-activation-emit.test.ts +395 -0
- package/src/__tests__/conversation-surfaces-app-control.test.ts +44 -0
- package/src/__tests__/conversation-undo.test.ts +2 -2
- package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -1
- package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
- package/src/__tests__/cu-unified-flow.test.ts +36 -0
- package/src/__tests__/history-repair-hook.test.ts +2 -0
- package/src/__tests__/memory-retrieval-hook.test.ts +1 -2
- package/src/__tests__/persist-unsendable-image-downscale.test.ts +145 -0
- package/src/__tests__/persist-unsendable-image.test.ts +97 -1
- package/src/__tests__/post-turn-tool-result-truncation.test.ts +69 -0
- package/src/__tests__/skill-feature-flags-integration.test.ts +5 -7
- package/src/__tests__/title-generate-hook.test.ts +2 -0
- package/src/__tests__/web-fetch.test.ts +45 -0
- package/src/acp/__tests__/helpers/acp-config-stub.ts +0 -2
- package/src/acp/resolve-agent.test.ts +0 -56
- package/src/acp/resolve-agent.ts +10 -38
- package/src/agent/loop.ts +13 -27
- package/src/cli/lib/__tests__/install-from-github.test.ts +232 -29
- package/src/cli/lib/__tests__/plugin-details.test.ts +28 -19
- package/src/cli/lib/__tests__/plugin-marketplace.test.ts +57 -7
- package/src/cli/lib/__tests__/search-plugins.test.ts +17 -10
- package/src/cli/lib/install-from-github.ts +258 -41
- package/src/cli/lib/plugin-details.ts +20 -13
- package/src/cli/lib/plugin-marketplace.ts +23 -5
- package/src/cli/lib/search-plugins.ts +14 -8
- package/src/config/acp-defaults.ts +3 -3
- package/src/config/acp-schema.ts +1 -7
- package/src/config/bundled-skills/acp/SKILL.md +4 -17
- package/src/config/bundled-skills/acp/TOOLS.json +2 -2
- package/src/config/feature-flag-registry.json +3 -18
- package/src/context/post-turn-tool-result-truncation.ts +39 -1
- package/src/daemon/conversation-agent-loop-handlers.ts +8 -1
- package/src/daemon/conversation-agent-loop.ts +78 -78
- package/src/daemon/conversation-error.ts +31 -4
- package/src/daemon/conversation-history.ts +1 -1
- package/src/daemon/conversation-media-retry.ts +19 -6
- package/src/daemon/conversation-messaging.ts +17 -0
- package/src/daemon/conversation-process.ts +14 -5
- package/src/daemon/conversation-queue-manager.ts +8 -0
- package/src/daemon/conversation-runtime-assembly.ts +37 -1
- package/src/daemon/conversation-surfaces.ts +141 -3
- package/src/daemon/conversation.ts +48 -13
- package/src/daemon/persist-unsendable-image.ts +62 -25
- package/src/daemon/process-message.ts +1 -1
- package/src/memory/__tests__/activation-session-store.test.ts +41 -0
- package/src/memory/__tests__/onboarding-events-store.test.ts +80 -0
- package/src/memory/activation-session-store.ts +43 -0
- package/src/memory/db-init.ts +4 -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 +2 -0
- package/src/memory/onboarding-events-store.ts +66 -18
- package/src/memory/schema/infrastructure.ts +13 -0
- package/src/messaging/providers/telegram-bot/api.ts +14 -5
- package/src/notifications/adapters/telegram.ts +7 -1
- package/src/plugin-api/constants.ts +2 -2
- package/src/plugin-api/index.ts +2 -2
- package/src/plugin-api/types.ts +19 -5
- package/src/plugins/defaults/compaction/compact.ts +24 -15
- package/src/plugins/defaults/compaction/context-overflow-reducer.ts +4 -4
- package/src/plugins/defaults/compaction/manager-store.ts +1 -1
- package/src/{context → plugins/defaults/compaction}/window-manager.ts +12 -12
- package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +12 -18
- package/src/prompts/system-prompt.ts +61 -10
- package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +37 -2
- package/src/providers/openai/__tests__/vision-not-supported.test.ts +75 -0
- package/src/providers/openai/chat-completions-provider.ts +25 -0
- package/src/runtime/routes/__tests__/stt-routes.test.ts +112 -0
- package/src/runtime/routes/acp-routes.test.ts +3 -20
- package/src/runtime/routes/conversation-routes.ts +2 -0
- package/src/runtime/routes/playground/__tests__/force-compact.test.ts +1 -1
- package/src/runtime/routes/stt-routes.ts +45 -12
- package/src/runtime/routes/workspace-routes.ts +50 -15
- 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/list-agents.test.ts +2 -18
- package/src/tools/acp/list-agents.ts +3 -15
- package/src/tools/acp/spawn.test.ts +0 -10
- package/src/tools/browser/browser-execution.ts +12 -2
- package/src/tools/network/web-fetch.ts +65 -24
- package/src/tools/ui-surface/definitions.ts +7 -0
- package/src/acp/feature-gate.test.ts +0 -48
- package/src/acp/feature-gate.ts +0 -34
|
@@ -18,6 +18,11 @@ import {
|
|
|
18
18
|
const MANIFEST_URL_PREFIX =
|
|
19
19
|
"https://api.github.com/repos/vellum-ai/vellum-assistant/contents/experimental/plugins/marketplace.json";
|
|
20
20
|
|
|
21
|
+
// External marketplace refs must be full commit SHAs (immutable). Fixtures use
|
|
22
|
+
// realistic 40-char hex object names rather than tags/branches.
|
|
23
|
+
const CAVEMAN_SHA = "63a91ecadbf4c4719a4602a5abb00883f9966034";
|
|
24
|
+
const NESTED_SHA = "0123456789abcdef0123456789abcdef01234567";
|
|
25
|
+
|
|
21
26
|
/** Serve `body` (any value) as the raw manifest file at the manifest URL. */
|
|
22
27
|
function manifestFetch(body: unknown, status = 200, raw?: string): FetchLike {
|
|
23
28
|
return (async (input: RequestInfo | URL) => {
|
|
@@ -41,7 +46,7 @@ const VALID_MANIFEST = {
|
|
|
41
46
|
source: {
|
|
42
47
|
source: "github",
|
|
43
48
|
repo: "JuliusBrussee/caveman",
|
|
44
|
-
ref:
|
|
49
|
+
ref: CAVEMAN_SHA,
|
|
45
50
|
},
|
|
46
51
|
description: "Ultra-compressed communication mode.",
|
|
47
52
|
category: "productivity",
|
|
@@ -52,7 +57,7 @@ const VALID_MANIFEST = {
|
|
|
52
57
|
source: "github",
|
|
53
58
|
repo: "acme/monorepo",
|
|
54
59
|
path: "packages/nested",
|
|
55
|
-
ref:
|
|
60
|
+
ref: NESTED_SHA,
|
|
56
61
|
},
|
|
57
62
|
},
|
|
58
63
|
],
|
|
@@ -71,7 +76,7 @@ describe("fetchMarketplaceEntries", () => {
|
|
|
71
76
|
expect(entries[0]!.source).toEqual({
|
|
72
77
|
source: "github",
|
|
73
78
|
repo: "JuliusBrussee/caveman",
|
|
74
|
-
ref:
|
|
79
|
+
ref: CAVEMAN_SHA,
|
|
75
80
|
});
|
|
76
81
|
expect(entries[1]!.source.path).toBe("packages/nested");
|
|
77
82
|
});
|
|
@@ -123,6 +128,51 @@ describe("fetchMarketplaceEntries", () => {
|
|
|
123
128
|
).rejects.toBeInstanceOf(MarketplaceFetchError);
|
|
124
129
|
});
|
|
125
130
|
|
|
131
|
+
test("rejects a mutable tag or branch ref (must be a full commit SHA)", async () => {
|
|
132
|
+
// GIVEN an entry whose source pins a mutable tag rather than a commit SHA
|
|
133
|
+
const fetch = manifestFetch({
|
|
134
|
+
name: "x",
|
|
135
|
+
plugins: [
|
|
136
|
+
{
|
|
137
|
+
name: "caveman",
|
|
138
|
+
source: {
|
|
139
|
+
source: "github",
|
|
140
|
+
repo: "JuliusBrussee/caveman",
|
|
141
|
+
ref: "v1.8.2",
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// WHEN / THEN the mutable ref is rejected: a retag could repoint it at
|
|
148
|
+
// attacker code the daemon later dynamically imports.
|
|
149
|
+
await expect(
|
|
150
|
+
fetchMarketplaceEntries({ fetch }, { ref: "main" }),
|
|
151
|
+
).rejects.toBeInstanceOf(MarketplaceFetchError);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("rejects an abbreviated commit SHA", async () => {
|
|
155
|
+
// GIVEN an entry whose source uses a short (ambiguous, non-pinning) SHA
|
|
156
|
+
const fetch = manifestFetch({
|
|
157
|
+
name: "x",
|
|
158
|
+
plugins: [
|
|
159
|
+
{
|
|
160
|
+
name: "caveman",
|
|
161
|
+
source: {
|
|
162
|
+
source: "github",
|
|
163
|
+
repo: "JuliusBrussee/caveman",
|
|
164
|
+
ref: "63a91ec",
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// WHEN / THEN only a full object name pins the install immutably
|
|
171
|
+
await expect(
|
|
172
|
+
fetchMarketplaceEntries({ fetch }, { ref: "main" }),
|
|
173
|
+
).rejects.toBeInstanceOf(MarketplaceFetchError);
|
|
174
|
+
});
|
|
175
|
+
|
|
126
176
|
test("rejects a manifest entry missing a pinned ref", async () => {
|
|
127
177
|
// GIVEN an entry whose source omits the required ref
|
|
128
178
|
const fetch = manifestFetch({
|
|
@@ -169,7 +219,7 @@ describe("resolveMarketplaceSource", () => {
|
|
|
169
219
|
source: {
|
|
170
220
|
source: "github",
|
|
171
221
|
repo: "JuliusBrussee/caveman",
|
|
172
|
-
ref:
|
|
222
|
+
ref: CAVEMAN_SHA,
|
|
173
223
|
},
|
|
174
224
|
},
|
|
175
225
|
{
|
|
@@ -178,7 +228,7 @@ describe("resolveMarketplaceSource", () => {
|
|
|
178
228
|
source: "github",
|
|
179
229
|
repo: "acme/monorepo",
|
|
180
230
|
path: "packages/nested",
|
|
181
|
-
ref:
|
|
231
|
+
ref: NESTED_SHA,
|
|
182
232
|
},
|
|
183
233
|
},
|
|
184
234
|
];
|
|
@@ -193,7 +243,7 @@ describe("resolveMarketplaceSource", () => {
|
|
|
193
243
|
owner: "JuliusBrussee",
|
|
194
244
|
repo: "caveman",
|
|
195
245
|
path: "",
|
|
196
|
-
ref:
|
|
246
|
+
ref: CAVEMAN_SHA,
|
|
197
247
|
});
|
|
198
248
|
});
|
|
199
249
|
|
|
@@ -207,7 +257,7 @@ describe("resolveMarketplaceSource", () => {
|
|
|
207
257
|
owner: "acme",
|
|
208
258
|
repo: "monorepo",
|
|
209
259
|
path: "packages/nested",
|
|
210
|
-
ref:
|
|
260
|
+
ref: NESTED_SHA,
|
|
211
261
|
});
|
|
212
262
|
});
|
|
213
263
|
|
|
@@ -279,7 +279,7 @@ describe("searchPlugins", () => {
|
|
|
279
279
|
source: {
|
|
280
280
|
source: "github",
|
|
281
281
|
repo: "JuliusBrussee/caveman",
|
|
282
|
-
ref: "
|
|
282
|
+
ref: "63a91ecadbf4c4719a4602a5abb00883f9966034",
|
|
283
283
|
},
|
|
284
284
|
description: "Ultra-compressed communication mode.",
|
|
285
285
|
},
|
|
@@ -311,12 +311,12 @@ describe("searchPlugins", () => {
|
|
|
311
311
|
expect(result.matches).toEqual([
|
|
312
312
|
{
|
|
313
313
|
name: "caveman",
|
|
314
|
-
path: "github:JuliusBrussee/caveman@
|
|
314
|
+
path: "github:JuliusBrussee/caveman@63a91ecadbf4c4719a4602a5abb00883f9966034",
|
|
315
315
|
description: "Ultra-compressed communication mode.",
|
|
316
316
|
source: {
|
|
317
317
|
kind: "github",
|
|
318
318
|
repo: "JuliusBrussee/caveman",
|
|
319
|
-
ref: "
|
|
319
|
+
ref: "63a91ecadbf4c4719a4602a5abb00883f9966034",
|
|
320
320
|
},
|
|
321
321
|
},
|
|
322
322
|
{
|
|
@@ -337,7 +337,7 @@ describe("searchPlugins", () => {
|
|
|
337
337
|
source: {
|
|
338
338
|
source: "github",
|
|
339
339
|
repo: "JuliusBrussee/caveman",
|
|
340
|
-
ref: "
|
|
340
|
+
ref: "63a91ecadbf4c4719a4602a5abb00883f9966034",
|
|
341
341
|
},
|
|
342
342
|
},
|
|
343
343
|
],
|
|
@@ -357,8 +357,8 @@ describe("searchPlugins", () => {
|
|
|
357
357
|
expect(result.matches).toEqual([]);
|
|
358
358
|
});
|
|
359
359
|
|
|
360
|
-
test("
|
|
361
|
-
// GIVEN
|
|
360
|
+
test("a marketplace entry owns a name shared by an in-repo stub dir", async () => {
|
|
361
|
+
// GIVEN a marketplace entry named "caveman"
|
|
362
362
|
const manifest = {
|
|
363
363
|
name: "vellum-assistant",
|
|
364
364
|
plugins: [
|
|
@@ -367,11 +367,13 @@ describe("searchPlugins", () => {
|
|
|
367
367
|
source: {
|
|
368
368
|
source: "github",
|
|
369
369
|
repo: "JuliusBrussee/caveman",
|
|
370
|
-
ref: "
|
|
370
|
+
ref: "63a91ecadbf4c4719a4602a5abb00883f9966034",
|
|
371
371
|
},
|
|
372
372
|
},
|
|
373
373
|
],
|
|
374
374
|
};
|
|
375
|
+
// AND a same-named in-repo directory, which is caveman's adapter stub
|
|
376
|
+
// rather than a standalone first-party plugin
|
|
375
377
|
const fetch: FetchLike = (async (input: RequestInfo | URL) => {
|
|
376
378
|
const url = typeof input === "string" ? input : input.toString();
|
|
377
379
|
if (url.includes("marketplace.json")) {
|
|
@@ -394,12 +396,17 @@ describe("searchPlugins", () => {
|
|
|
394
396
|
// WHEN we search
|
|
395
397
|
const result = await searchPlugins({ query: "caveman" }, { fetch });
|
|
396
398
|
|
|
397
|
-
// THEN
|
|
399
|
+
// THEN the name surfaces once, as the external marketplace entry — the
|
|
400
|
+
// stub dir is its adapter overlay, not a competing first-party listing
|
|
398
401
|
expect(result.matches).toEqual([
|
|
399
402
|
{
|
|
400
403
|
name: "caveman",
|
|
401
|
-
path: "
|
|
402
|
-
source: {
|
|
404
|
+
path: "github:JuliusBrussee/caveman@63a91ecadbf4c4719a4602a5abb00883f9966034",
|
|
405
|
+
source: {
|
|
406
|
+
kind: "github",
|
|
407
|
+
repo: "JuliusBrussee/caveman",
|
|
408
|
+
ref: "63a91ecadbf4c4719a4602a5abb00883f9966034",
|
|
409
|
+
},
|
|
403
410
|
},
|
|
404
411
|
]);
|
|
405
412
|
});
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* fetched with a shallow `git` clone at that ref — one network operation
|
|
10
10
|
* regardless of repo size, immune to GitHub's unauthenticated API
|
|
11
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}).
|
|
12
17
|
* 2. Otherwise the first-party convention
|
|
13
18
|
* `vellum-ai/vellum-assistant/experimental/plugins/<name>/` at the
|
|
14
19
|
* configured ref, fetched via the GitHub Contents API (a small handful of
|
|
@@ -29,14 +34,16 @@ import {
|
|
|
29
34
|
existsSync,
|
|
30
35
|
mkdirSync,
|
|
31
36
|
readdirSync,
|
|
37
|
+
readFileSync,
|
|
32
38
|
renameSync,
|
|
33
39
|
rmSync,
|
|
34
40
|
statSync,
|
|
35
41
|
writeFileSync,
|
|
36
42
|
} from "node:fs";
|
|
37
|
-
import { dirname, join } from "node:path";
|
|
43
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
38
44
|
import { promisify } from "node:util";
|
|
39
45
|
|
|
46
|
+
import { ensureBun } from "../../util/bun-runtime.js";
|
|
40
47
|
import { getWorkspacePluginsDir } from "../../util/platform.js";
|
|
41
48
|
import {
|
|
42
49
|
fetchMarketplaceEntries,
|
|
@@ -82,6 +89,18 @@ export type GitRunner = (
|
|
|
82
89
|
opts: { readonly cwd: string },
|
|
83
90
|
) => Promise<{ readonly stdout: string }>;
|
|
84
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
|
+
|
|
85
104
|
/** Options that control which plugin to install and how. */
|
|
86
105
|
export interface InstallPluginOptions {
|
|
87
106
|
readonly name: string;
|
|
@@ -100,6 +119,8 @@ export interface InstallPluginDeps {
|
|
|
100
119
|
readonly workspacePluginsDir?: string;
|
|
101
120
|
/** Override the git runner used to clone external plugin sources. Falls back to {@link defaultGitRunner}. */
|
|
102
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;
|
|
103
124
|
}
|
|
104
125
|
|
|
105
126
|
/** Successful install result. */
|
|
@@ -123,6 +144,22 @@ export class InvalidPluginNameError extends Error {
|
|
|
123
144
|
}
|
|
124
145
|
}
|
|
125
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
|
+
|
|
126
163
|
/** A plugin with the same name is already installed and `--force` was not passed. */
|
|
127
164
|
export class PluginAlreadyInstalledError extends Error {
|
|
128
165
|
constructor(
|
|
@@ -204,40 +241,18 @@ function firstPartySource(name: string, ref: string): PluginFetchSource {
|
|
|
204
241
|
};
|
|
205
242
|
}
|
|
206
243
|
|
|
207
|
-
/**
|
|
208
|
-
* Probe whether a first-party plugin directory exists at the given source.
|
|
209
|
-
*
|
|
210
|
-
* A transient listing failure resolves to `false` so a marketplace-claimed
|
|
211
|
-
* name still reaches its external source — the rare collision guarantee gives
|
|
212
|
-
* way to keeping the common external-only install path working under flaky
|
|
213
|
-
* network conditions.
|
|
214
|
-
*/
|
|
215
|
-
async function firstPartyPluginExists(
|
|
216
|
-
source: PluginFetchSource,
|
|
217
|
-
fetchFn: FetchLike,
|
|
218
|
-
): Promise<boolean> {
|
|
219
|
-
try {
|
|
220
|
-
const entries = await listDir(
|
|
221
|
-
source.owner,
|
|
222
|
-
source.repo,
|
|
223
|
-
source.rootPath,
|
|
224
|
-
source.ref,
|
|
225
|
-
fetchFn,
|
|
226
|
-
);
|
|
227
|
-
return entries !== null && entries.length > 0;
|
|
228
|
-
} catch {
|
|
229
|
-
return false;
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
244
|
/**
|
|
234
245
|
* Resolve a plugin name to concrete GitHub coordinates.
|
|
235
246
|
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
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.
|
|
241
256
|
*
|
|
242
257
|
* A missing or malformed manifest degrades to first-party resolution — the
|
|
243
258
|
* whitelist is supplementary and must never block installing a first-party
|
|
@@ -259,12 +274,7 @@ async function resolvePluginSource(
|
|
|
259
274
|
// Degrade to first-party resolution below.
|
|
260
275
|
}
|
|
261
276
|
|
|
262
|
-
|
|
263
|
-
if (!resolved) return firstParty;
|
|
264
|
-
|
|
265
|
-
if (await firstPartyPluginExists(firstParty, fetchFn)) {
|
|
266
|
-
return firstParty;
|
|
267
|
-
}
|
|
277
|
+
if (!resolved) return firstPartySource(name, marketplaceRef);
|
|
268
278
|
|
|
269
279
|
return {
|
|
270
280
|
kind: "external",
|
|
@@ -363,6 +373,14 @@ export async function installPlugin(
|
|
|
363
373
|
);
|
|
364
374
|
fileCount = cloned.fileCount;
|
|
365
375
|
commit = cloned.commit;
|
|
376
|
+
// An external clone is often a foreign-ecosystem plugin (e.g. a Claude
|
|
377
|
+
// Code plugin) that the Vellum loader can't run as-is. When we curate an
|
|
378
|
+
// adapter stub for it, overlay the stub and run its transform so the
|
|
379
|
+
// materialized tree is a valid Vellum plugin. Raw clones (no stub) are
|
|
380
|
+
// left untouched.
|
|
381
|
+
if (fileCount > 0) {
|
|
382
|
+
await applyAdapterStub(name, marketplaceRef, stagingDir, deps);
|
|
383
|
+
}
|
|
366
384
|
} else {
|
|
367
385
|
fileCount = await copyDir(
|
|
368
386
|
source.owner,
|
|
@@ -372,6 +390,27 @@ export async function installPlugin(
|
|
|
372
390
|
stagingDir,
|
|
373
391
|
deps.fetch,
|
|
374
392
|
);
|
|
393
|
+
// We only land in the first-party branch for this name when the
|
|
394
|
+
// marketplace lookup returned no claim. A *healthy* marketplace that
|
|
395
|
+
// claims the name routes to the external+adapter branch above; reaching
|
|
396
|
+
// here for a directory that is actually an adapter stub (declares a
|
|
397
|
+
// `scripts.postinstall`) therefore means the marketplace failed to load
|
|
398
|
+
// (rate-limit / 5xx / malformed) and we degraded past it. A stub has no
|
|
399
|
+
// hooks/tools of its own — it only transforms an external clone — so
|
|
400
|
+
// installing it alone would materialize a non-functional plugin. Fail
|
|
401
|
+
// loudly and retryably instead of silently shipping a broken plugin.
|
|
402
|
+
// Genuine first-party plugins (no postinstall) install normally.
|
|
403
|
+
if (
|
|
404
|
+
fileCount > 0 &&
|
|
405
|
+
resolvePostinstallScript(name, stagingDir) !== null
|
|
406
|
+
) {
|
|
407
|
+
throw new PluginPostinstallError(
|
|
408
|
+
name,
|
|
409
|
+
"resolved to a first-party adapter stub, but its marketplace entry " +
|
|
410
|
+
"could not be read to locate the external source it adapts — the " +
|
|
411
|
+
"marketplace lookup likely failed transiently. Retry the install.",
|
|
412
|
+
);
|
|
413
|
+
}
|
|
375
414
|
}
|
|
376
415
|
} catch (err) {
|
|
377
416
|
rmSync(stagingDir, { recursive: true, force: true });
|
|
@@ -446,7 +485,7 @@ async function copyExternalViaGit(
|
|
|
446
485
|
// transient GitHub outage — is retryable, so map it to a 503.
|
|
447
486
|
if (isGitRefNotFound(err)) return { fileCount: 0, commit: null };
|
|
448
487
|
throw new PluginSourceUnavailableError(
|
|
449
|
-
`git clone failed for ${sourceLabel(source)} @ ${source.ref}: ${
|
|
488
|
+
`git clone failed for ${sourceLabel(source)} @ ${source.ref}: ${subprocessErrorText(err)}`,
|
|
450
489
|
503,
|
|
451
490
|
);
|
|
452
491
|
}
|
|
@@ -462,6 +501,18 @@ async function copyExternalViaGit(
|
|
|
462
501
|
commit = null;
|
|
463
502
|
}
|
|
464
503
|
|
|
504
|
+
// Defense in depth: external marketplace refs are full commit SHAs (the
|
|
505
|
+
// manifest schema rejects mutable tags/branches), so the checked-out
|
|
506
|
+
// commit must equal the requested ref. If it ever diverges, refuse the
|
|
507
|
+
// install rather than materialize and `import()` unexpected code.
|
|
508
|
+
if (commit && commit.toLowerCase() !== source.ref.toLowerCase()) {
|
|
509
|
+
throw new PluginSourceUnavailableError(
|
|
510
|
+
`git checkout of ${sourceLabel(source)} resolved to ${commit}, ` +
|
|
511
|
+
`which does not match the pinned commit ${source.ref}`,
|
|
512
|
+
502,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
465
516
|
const srcRoot = source.rootPath
|
|
466
517
|
? join(cloneDir, source.rootPath)
|
|
467
518
|
: cloneDir;
|
|
@@ -476,6 +527,172 @@ async function copyExternalViaGit(
|
|
|
476
527
|
}
|
|
477
528
|
}
|
|
478
529
|
|
|
530
|
+
/** Cap on a postinstall adapter; the curated transforms are fast and file-only. */
|
|
531
|
+
const POSTINSTALL_TIMEOUT_MS = 60_000;
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Overlay our curated adapter stub onto a freshly cloned external plugin and
|
|
535
|
+
* run its postinstall transform, returning whether a transform ran.
|
|
536
|
+
*
|
|
537
|
+
* The stub lives at `experimental/plugins/<name>/` in our own repo and carries
|
|
538
|
+
* a `package.json` (with a `scripts.postinstall` adapter command) plus the
|
|
539
|
+
* adapter script it names. We fetch it via the Contents API — a couple of
|
|
540
|
+
* small files, well within the rate limit — and copy it over the clone, which
|
|
541
|
+
* deliberately overwrites the clone's `package.json` so the postinstall we run
|
|
542
|
+
* is ours, never the upstream repo's lifecycle script. Absent a stub (the
|
|
543
|
+
* common case for a plugin already in Vellum shape), nothing is overlaid and
|
|
544
|
+
* the clone is installed as-is.
|
|
545
|
+
*
|
|
546
|
+
* On any adapter failure the error propagates so {@link installPlugin} rolls
|
|
547
|
+
* back staging — better to fail loudly than ship a half-transformed plugin.
|
|
548
|
+
*/
|
|
549
|
+
async function applyAdapterStub(
|
|
550
|
+
name: string,
|
|
551
|
+
ref: string,
|
|
552
|
+
stagingDir: string,
|
|
553
|
+
deps: InstallPluginDeps,
|
|
554
|
+
): Promise<boolean> {
|
|
555
|
+
const stubFileCount = await copyDir(
|
|
556
|
+
PLUGIN_SOURCE_OWNER,
|
|
557
|
+
PLUGIN_SOURCE_REPO,
|
|
558
|
+
`${PLUGIN_SOURCE_PATH_PREFIX}/${name}`,
|
|
559
|
+
ref,
|
|
560
|
+
stagingDir,
|
|
561
|
+
deps.fetch,
|
|
562
|
+
);
|
|
563
|
+
if (stubFileCount === 0) return false;
|
|
564
|
+
|
|
565
|
+
const script = resolvePostinstallScript(name, stagingDir);
|
|
566
|
+
if (script === null) return false;
|
|
567
|
+
|
|
568
|
+
const run = deps.runPostinstall ?? defaultPostinstallRunner;
|
|
569
|
+
try {
|
|
570
|
+
await run({ cwd: stagingDir, script });
|
|
571
|
+
} catch (err) {
|
|
572
|
+
throw new PluginPostinstallError(name, subprocessErrorText(err));
|
|
573
|
+
}
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Resolve the absolute path of the adapter script named by the (overlaid stub)
|
|
579
|
+
* `package.json`'s `scripts.postinstall`, or `null` when there is no stub
|
|
580
|
+
* package.json / postinstall script.
|
|
581
|
+
*
|
|
582
|
+
* Curated adapters declare a single `bun <script>` invocation; bun is resolved
|
|
583
|
+
* via {@link ensureBun} at execution time (see {@link defaultPostinstallRunner})
|
|
584
|
+
* so the `bun` token marks the convention without hard-coding the binary path.
|
|
585
|
+
* Anything else — extra args, a shell pipeline, a non-script file — is rejected
|
|
586
|
+
* rather than executed, and the script path is constrained to a file inside the
|
|
587
|
+
* staging dir so a stub can never escape it.
|
|
588
|
+
*/
|
|
589
|
+
function resolvePostinstallScript(
|
|
590
|
+
name: string,
|
|
591
|
+
stagingDir: string,
|
|
592
|
+
): string | null {
|
|
593
|
+
const pkgPath = join(stagingDir, "package.json");
|
|
594
|
+
if (!existsSync(pkgPath)) return null;
|
|
595
|
+
|
|
596
|
+
let parsed: unknown;
|
|
597
|
+
try {
|
|
598
|
+
parsed = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
599
|
+
} catch {
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const scripts =
|
|
604
|
+
typeof parsed === "object" && parsed !== null && "scripts" in parsed
|
|
605
|
+
? (parsed as { scripts?: unknown }).scripts
|
|
606
|
+
: undefined;
|
|
607
|
+
const command =
|
|
608
|
+
typeof scripts === "object" && scripts !== null && "postinstall" in scripts
|
|
609
|
+
? (scripts as { postinstall?: unknown }).postinstall
|
|
610
|
+
: undefined;
|
|
611
|
+
if (typeof command !== "string" || command.trim() === "") return null;
|
|
612
|
+
|
|
613
|
+
const match = /^bun\s+(\S+)$/.exec(command.trim());
|
|
614
|
+
if (!match) {
|
|
615
|
+
throw new PluginPostinstallError(
|
|
616
|
+
name,
|
|
617
|
+
`unsupported postinstall command ${JSON.stringify(command)} — ` +
|
|
618
|
+
"curated adapters must be a single `bun <script>` invocation",
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
let rel = match[1]!;
|
|
623
|
+
if (rel.startsWith("./")) rel = rel.slice(2);
|
|
624
|
+
if (!/\.(?:ts|mts|cts|mjs|cjs|js)$/.test(rel)) {
|
|
625
|
+
throw new PluginPostinstallError(
|
|
626
|
+
name,
|
|
627
|
+
`postinstall script ${JSON.stringify(rel)} must be a ` +
|
|
628
|
+
".ts/.mts/.cts/.mjs/.cjs/.js file",
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
for (const segment of rel.split("/")) {
|
|
632
|
+
assertSafeFilename("postinstall script segment", segment);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const abs = resolve(stagingDir, rel);
|
|
636
|
+
if (
|
|
637
|
+
abs !== resolve(stagingDir) &&
|
|
638
|
+
!abs.startsWith(`${resolve(stagingDir)}${sep}`)
|
|
639
|
+
) {
|
|
640
|
+
throw new PluginPostinstallError(
|
|
641
|
+
name,
|
|
642
|
+
`postinstall script ${JSON.stringify(rel)} escapes the plugin directory`,
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
if (!existsSync(abs)) {
|
|
646
|
+
throw new PluginPostinstallError(
|
|
647
|
+
name,
|
|
648
|
+
`postinstall script ${JSON.stringify(rel)} was not found in the plugin`,
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
return abs;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Production postinstall runner: executes the adapter with a real `bun` binary
|
|
656
|
+
* resolved via {@link ensureBun}, under a stripped environment and a timeout.
|
|
657
|
+
*
|
|
658
|
+
* `process.execPath` is unusable here: inside a `bun build --compile` binary it
|
|
659
|
+
* is the compiled assistant app, not the bun CLI (see `util/bun-runtime.ts`),
|
|
660
|
+
* so passing the adapter script to it would launch the daemon rather than
|
|
661
|
+
* interpret the script. `ensureBun()` locates (or downloads) a standalone bun
|
|
662
|
+
* the same way every other subsystem that spawns bun does. The minimal env
|
|
663
|
+
* (bun's dir + standard bins, `HOME` only) keeps the adapter from inheriting
|
|
664
|
+
* surprising config while still finding the runtime.
|
|
665
|
+
*/
|
|
666
|
+
export const defaultPostinstallRunner: PostinstallRunner = async ({
|
|
667
|
+
cwd,
|
|
668
|
+
script,
|
|
669
|
+
}) => {
|
|
670
|
+
const bun = await ensureBun();
|
|
671
|
+
await execFileAsync(bun, [script], {
|
|
672
|
+
cwd,
|
|
673
|
+
encoding: "utf8",
|
|
674
|
+
timeout: POSTINSTALL_TIMEOUT_MS,
|
|
675
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
676
|
+
env: pluginPostinstallEnv(bun),
|
|
677
|
+
});
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
function pluginPostinstallEnv(bun: string): NodeJS.ProcessEnv {
|
|
681
|
+
const env: NodeJS.ProcessEnv = {
|
|
682
|
+
PATH: [
|
|
683
|
+
dirname(bun),
|
|
684
|
+
"/opt/homebrew/bin",
|
|
685
|
+
"/usr/local/bin",
|
|
686
|
+
"/usr/bin",
|
|
687
|
+
"/bin",
|
|
688
|
+
]
|
|
689
|
+
.filter(Boolean)
|
|
690
|
+
.join(":"),
|
|
691
|
+
};
|
|
692
|
+
if (process.env.HOME) env.HOME = process.env.HOME;
|
|
693
|
+
return env;
|
|
694
|
+
}
|
|
695
|
+
|
|
479
696
|
/**
|
|
480
697
|
* Recursively copy regular files from `srcRoot` into `destDir`, skipping the
|
|
481
698
|
* top-level `.git` directory and any symlinks. Returns the file count.
|
|
@@ -509,7 +726,7 @@ function copyTreeSkippingGit(srcRoot: string, destDir: string): number {
|
|
|
509
726
|
|
|
510
727
|
/** True when a git fetch failed because the repo or ref is unreachable. */
|
|
511
728
|
function isGitRefNotFound(err: unknown): boolean {
|
|
512
|
-
const text =
|
|
729
|
+
const text = subprocessErrorText(err).toLowerCase();
|
|
513
730
|
return [
|
|
514
731
|
"could not find remote ref",
|
|
515
732
|
"couldn't find remote ref",
|
|
@@ -523,7 +740,7 @@ function isGitRefNotFound(err: unknown): boolean {
|
|
|
523
740
|
}
|
|
524
741
|
|
|
525
742
|
/** Extract a stderr/message blob from a spawn error for classification/logging. */
|
|
526
|
-
function
|
|
743
|
+
function subprocessErrorText(err: unknown): string {
|
|
527
744
|
if (err instanceof Error) {
|
|
528
745
|
const withStreams = err as Error & { stderr?: unknown };
|
|
529
746
|
const stderr =
|
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
* `package.json` fields the manifest doesn't carry.
|
|
15
15
|
*
|
|
16
16
|
* Name-collision precedence matches {@link ./search-plugins} and
|
|
17
|
-
* {@link ./install-from-github}: a
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* {@link ./install-from-github}: a marketplace entry owns its name, so the
|
|
18
|
+
* detail page advertises the external source the catalog and installer use. A
|
|
19
|
+
* same-named `experimental/plugins/<name>/` directory is that plugin's adapter
|
|
20
|
+
* stub, not a standalone first-party plugin, so it does not override the claim.
|
|
20
21
|
*
|
|
21
22
|
* Designed for direct programmatic use with an injected `fetch`, mirroring the
|
|
22
23
|
* sibling plugin libraries.
|
|
@@ -130,22 +131,28 @@ export async function getPluginDetails(
|
|
|
130
131
|
const local = readLocalPlugin(pluginsDir, name);
|
|
131
132
|
|
|
132
133
|
const marketplaceEntry = await findMarketplaceEntry(name, ref, fetchFn);
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
134
|
+
|
|
135
|
+
// A marketplace entry owns its name (the same-named in-repo directory, if
|
|
136
|
+
// any, is its adapter stub) — so only probe the first-party directory when
|
|
137
|
+
// the name is unclaimed, which also spares a GitHub request in the common
|
|
138
|
+
// external case.
|
|
139
|
+
const firstPartyEntries =
|
|
140
|
+
marketplaceEntry === null
|
|
141
|
+
? await listDirSafe(
|
|
142
|
+
PLUGIN_SOURCE_OWNER,
|
|
143
|
+
PLUGIN_SOURCE_REPO,
|
|
144
|
+
`${PLUGIN_SOURCE_PATH_PREFIX}/${name}`,
|
|
145
|
+
ref,
|
|
146
|
+
fetchFn,
|
|
147
|
+
)
|
|
148
|
+
: null;
|
|
140
149
|
const firstPartyExists = firstPartyEntries !== null;
|
|
141
150
|
|
|
142
151
|
if (!local.installed && !firstPartyExists && !marketplaceEntry) {
|
|
143
152
|
throw new PluginDetailsNotFoundError(name, ref);
|
|
144
153
|
}
|
|
145
154
|
|
|
146
|
-
|
|
147
|
-
// honouring a marketplace claim — the same precedence the catalog applies.
|
|
148
|
-
const useExternal = !firstPartyExists && marketplaceEntry !== null;
|
|
155
|
+
const useExternal = marketplaceEntry !== null;
|
|
149
156
|
|
|
150
157
|
const source: PluginMatchSource = useExternal
|
|
151
158
|
? {
|