@vellumai/assistant 0.12.3-dev.202609222113.8a21e65 → 0.12.3-dev.202609222214.c4d457d
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/package.json +1 -1
- package/src/cli/commands/__tests__/plugins.test.ts +29 -1
- package/src/cli/commands/plugins.ts +16 -0
- package/src/cli/lib/__tests__/inspect-plugin.test.ts +29 -0
- package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +32 -2
- package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +15 -0
- package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +21 -0
- package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +65 -0
- package/src/cli/lib/__tests__/plugin-details.test.ts +43 -0
- package/src/cli/lib/__tests__/plugins-install-offline.test.ts +26 -0
- package/src/cli/lib/inspect-plugin.ts +5 -0
- package/src/cli/lib/plugin-catalog-cache.ts +24 -2
- package/src/cli/lib/plugin-catalog-local.ts +9 -0
- package/src/cli/lib/plugin-catalog-visibility.ts +41 -0
- package/src/cli/lib/plugin-details.ts +10 -7
- package/src/cli/lib/search-plugins.ts +2 -0
- package/src/config/bundled-skills/screen-annotation/SKILL.md +6 -3
- package/src/config/feature-flag-registry.json +9 -1
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +8 -0
- package/src/runtime/routes/plugins-routes.ts +5 -2
package/package.json
CHANGED
|
@@ -66,6 +66,20 @@ let platformInstallCalls: Array<{ name: string; force?: boolean }> = [];
|
|
|
66
66
|
let upgradePluginCalls: UpgradePluginOptions[] = [];
|
|
67
67
|
let catalogMatches: PluginSearchMatch[] = [];
|
|
68
68
|
let catalogError: Error | null = null;
|
|
69
|
+
const CATALOG_COMMIT = "a".repeat(40);
|
|
70
|
+
|
|
71
|
+
function githubCatalogMatch(name: string): PluginSearchMatch {
|
|
72
|
+
return {
|
|
73
|
+
name,
|
|
74
|
+
path: `github:example-org/${name}@${CATALOG_COMMIT}`,
|
|
75
|
+
category: null,
|
|
76
|
+
source: {
|
|
77
|
+
kind: "github",
|
|
78
|
+
repo: `example-org/${name}`,
|
|
79
|
+
ref: CATALOG_COMMIT,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
69
83
|
|
|
70
84
|
/**
|
|
71
85
|
* Queued daemon IPC responses. The default (empty queue) is a transport
|
|
@@ -269,7 +283,10 @@ beforeEach(() => {
|
|
|
269
283
|
installPluginCalls = [];
|
|
270
284
|
platformInstallCalls = [];
|
|
271
285
|
upgradePluginCalls = [];
|
|
272
|
-
catalogMatches = [
|
|
286
|
+
catalogMatches = [
|
|
287
|
+
githubCatalogMatch("example"),
|
|
288
|
+
githubCatalogMatch("imessage"),
|
|
289
|
+
];
|
|
273
290
|
catalogError = null;
|
|
274
291
|
ipcResults = [];
|
|
275
292
|
inspectResult = null;
|
|
@@ -485,6 +502,17 @@ describe("plugins install - declared-schedules consent", () => {
|
|
|
485
502
|
]);
|
|
486
503
|
expect(r.exitCode).toBe(0);
|
|
487
504
|
});
|
|
505
|
+
|
|
506
|
+
test("does not use the outage fallback for a gated integration", async () => {
|
|
507
|
+
catalogError = new Error("catalog unavailable");
|
|
508
|
+
|
|
509
|
+
const r = await runCommand(["plugins", "install", "gamma"]);
|
|
510
|
+
|
|
511
|
+
expect(installPluginCalls).toHaveLength(0);
|
|
512
|
+
expect(platformInstallCalls).toHaveLength(0);
|
|
513
|
+
expect(r.stderr).toContain("not in the marketplace catalog");
|
|
514
|
+
expect(r.exitCode).toBe(1);
|
|
515
|
+
});
|
|
488
516
|
});
|
|
489
517
|
|
|
490
518
|
describe("plugins upgrade - declared-schedules consent (local fallback)", () => {
|
|
@@ -238,7 +238,15 @@ export function registerPluginsCommand(program: Command): void {
|
|
|
238
238
|
const platformEnabled =
|
|
239
239
|
libs.catalogLocal.arePlatformFeaturesEnabled();
|
|
240
240
|
if (platformEnabled) {
|
|
241
|
+
if (!libs.catalogLocal.isPluginCatalogEntryVisible(nameOrUrl)) {
|
|
242
|
+
console.error(
|
|
243
|
+
`Plugin "${nameOrUrl}" is not in the marketplace catalog.`,
|
|
244
|
+
);
|
|
245
|
+
process.exitCode = 1;
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
241
248
|
let match;
|
|
249
|
+
let catalogResolved = false;
|
|
242
250
|
try {
|
|
243
251
|
match = (
|
|
244
252
|
await libs.catalogCache.getPluginCatalog(
|
|
@@ -246,10 +254,18 @@ export function registerPluginsCommand(program: Command): void {
|
|
|
246
254
|
{ fetch: globalThis.fetch.bind(globalThis) },
|
|
247
255
|
)
|
|
248
256
|
).matches.find((candidate) => candidate.name === nameOrUrl);
|
|
257
|
+
catalogResolved = true;
|
|
249
258
|
} catch {
|
|
250
259
|
// The install endpoint remains authoritative and can succeed
|
|
251
260
|
// independently when catalog discovery is unavailable.
|
|
252
261
|
}
|
|
262
|
+
if (!match && catalogResolved) {
|
|
263
|
+
console.error(
|
|
264
|
+
`Plugin "${nameOrUrl}" is not in the marketplace catalog.`,
|
|
265
|
+
);
|
|
266
|
+
process.exitCode = 1;
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
253
269
|
if (match?.source.kind === "local") {
|
|
254
270
|
result = await libs.installGitHub.installPlugin(
|
|
255
271
|
{
|
|
@@ -306,6 +306,35 @@ describe("inspectPlugin", () => {
|
|
|
306
306
|
expect(result.remote?.commit).toBe(SHA_B);
|
|
307
307
|
});
|
|
308
308
|
|
|
309
|
+
test("hides an uninstalled gated integration without fetching the marketplace", async () => {
|
|
310
|
+
let fetchCalls = 0;
|
|
311
|
+
const fetch = (async () => {
|
|
312
|
+
fetchCalls += 1;
|
|
313
|
+
return new Response("unexpected request", { status: 500 });
|
|
314
|
+
}) as FetchLike;
|
|
315
|
+
|
|
316
|
+
await expect(
|
|
317
|
+
inspectPlugin(
|
|
318
|
+
{ name: "gamma" },
|
|
319
|
+
{ fetch, workspacePluginsDir: workspace },
|
|
320
|
+
),
|
|
321
|
+
).rejects.toBeInstanceOf(PluginInspectNotFoundError);
|
|
322
|
+
expect(fetchCalls).toBe(0);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("keeps an installed gated integration inspectable", async () => {
|
|
326
|
+
installPlugin(workspace, "gamma", { sidecar: null });
|
|
327
|
+
const fetch = makeFetch({ marketplace: manifestWith("gamma", SHA_B) });
|
|
328
|
+
|
|
329
|
+
const result = await inspectPlugin(
|
|
330
|
+
{ name: "gamma" },
|
|
331
|
+
{ fetch, workspacePluginsDir: workspace },
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
expect(result.installed).toBe(true);
|
|
335
|
+
expect(result.local?.description).toBe("Installed copy.");
|
|
336
|
+
});
|
|
337
|
+
|
|
309
338
|
test("previews an uninstalled bundled provider offline without fetching GitHub", async () => {
|
|
310
339
|
const savedDisablePlatform = process.env.VELLUM_DISABLE_PLATFORM;
|
|
311
340
|
const savedIsPlatform = process.env.IS_PLATFORM;
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import type { FetchLike } from "../fetch-like.js";
|
|
20
20
|
import {
|
|
21
21
|
getPluginCatalog,
|
|
22
|
+
getPluginCatalogForInstalledMetadata,
|
|
22
23
|
invalidatePluginCatalogCache,
|
|
23
24
|
mergePlatformCatalogWithBundledLocals,
|
|
24
25
|
PLUGIN_CATALOG_CACHE_TTL_MS,
|
|
@@ -102,7 +103,7 @@ describe("getPluginCatalog", () => {
|
|
|
102
103
|
|
|
103
104
|
// THEN the platform is fetched exactly once — the second call is cached
|
|
104
105
|
expect(calls()).toBe(1);
|
|
105
|
-
expect(first).
|
|
106
|
+
expect(first).toEqual(second);
|
|
106
107
|
expect(githubNames(first)).toEqual(["a"]);
|
|
107
108
|
expect(first.ref).toBe("main");
|
|
108
109
|
});
|
|
@@ -161,7 +162,10 @@ describe("getPluginCatalog", () => {
|
|
|
161
162
|
delete process.env.IS_PLATFORM;
|
|
162
163
|
|
|
163
164
|
const { fetch, calls } = platformFetch(["ignored"]);
|
|
164
|
-
const deps: SearchPluginsDeps = {
|
|
165
|
+
const deps: SearchPluginsDeps = {
|
|
166
|
+
fetch,
|
|
167
|
+
featureFlagEnabled: () => true,
|
|
168
|
+
};
|
|
165
169
|
|
|
166
170
|
// WHEN we request the catalog
|
|
167
171
|
const result = await getPluginCatalog("main", deps);
|
|
@@ -173,6 +177,32 @@ describe("getPluginCatalog", () => {
|
|
|
173
177
|
// The requested ref is echoed onto the wire contract.
|
|
174
178
|
expect(result.ref).toBe("main");
|
|
175
179
|
});
|
|
180
|
+
|
|
181
|
+
test("applies feature-flag visibility to bundled catalog entries", async () => {
|
|
182
|
+
process.env.VELLUM_DISABLE_PLATFORM = "true";
|
|
183
|
+
delete process.env.IS_PLATFORM;
|
|
184
|
+
|
|
185
|
+
const { fetch } = platformFetch(["ignored"]);
|
|
186
|
+
const result = await getPluginCatalog("main", {
|
|
187
|
+
fetch,
|
|
188
|
+
featureFlagEnabled: () => false,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(result.matches.map((match) => match.name)).not.toContain("gamma");
|
|
192
|
+
expect(result.matches.map((match) => match.name)).toContain("fathom");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("preserves hidden entries for installed-plugin metadata", async () => {
|
|
196
|
+
process.env.VELLUM_DISABLE_PLATFORM = "true";
|
|
197
|
+
delete process.env.IS_PLATFORM;
|
|
198
|
+
|
|
199
|
+
const { fetch } = platformFetch(["ignored"]);
|
|
200
|
+
const result = await getPluginCatalogForInstalledMetadata("main", {
|
|
201
|
+
fetch,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
expect(result.matches.map((match) => match.name)).toContain("gamma");
|
|
205
|
+
});
|
|
176
206
|
});
|
|
177
207
|
|
|
178
208
|
describe("mergePlatformCatalogWithBundledLocals", () => {
|
|
@@ -13,6 +13,7 @@ import bundledManifest from "../bundled-marketplace.json" with { type: "json" };
|
|
|
13
13
|
import {
|
|
14
14
|
buildBundledPluginCatalog,
|
|
15
15
|
readBundledPluginCatalog,
|
|
16
|
+
resolveBundledPluginSource,
|
|
16
17
|
} from "../plugin-catalog-local.js";
|
|
17
18
|
import { marketplaceManifestSchema } from "../plugin-marketplace.js";
|
|
18
19
|
import { marketplaceMatch } from "../search-plugins.js";
|
|
@@ -96,3 +97,17 @@ describe("readBundledPluginCatalog", () => {
|
|
|
96
97
|
expect(readBundledPluginCatalog()).toBe(readBundledPluginCatalog());
|
|
97
98
|
});
|
|
98
99
|
});
|
|
100
|
+
|
|
101
|
+
describe("resolveBundledPluginSource", () => {
|
|
102
|
+
test("hides a QA integration while its feature flag is off", () => {
|
|
103
|
+
expect(resolveBundledPluginSource("gamma", () => false)).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("resolves a QA integration while its feature flag is on", () => {
|
|
107
|
+
expect(resolveBundledPluginSource("gamma", () => true)).toEqual({
|
|
108
|
+
kind: "local",
|
|
109
|
+
path: "plugins/mcp-catalog/gamma",
|
|
110
|
+
version: "1.0.2",
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -195,6 +195,27 @@ describe("catalog-backed resolvers (bundled, offline)", () => {
|
|
|
195
195
|
await resolvePluginSourceFromCatalog("no-such-plugin", rejectingDeps),
|
|
196
196
|
).toBeNull();
|
|
197
197
|
});
|
|
198
|
+
|
|
199
|
+
test("does not resolve a QA integration while its feature flag is off", async () => {
|
|
200
|
+
expect(
|
|
201
|
+
await resolvePluginSourceFromCatalog("gamma", {
|
|
202
|
+
...rejectingDeps,
|
|
203
|
+
featureFlagEnabled: () => false,
|
|
204
|
+
}),
|
|
205
|
+
).toBeNull();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("resolves a QA integration while its feature flag is on", async () => {
|
|
209
|
+
expect(
|
|
210
|
+
await resolvePluginSourceFromCatalog("gamma", {
|
|
211
|
+
...rejectingDeps,
|
|
212
|
+
featureFlagEnabled: () => true,
|
|
213
|
+
}),
|
|
214
|
+
).toMatchObject({
|
|
215
|
+
kind: "local",
|
|
216
|
+
path: "plugins/mcp-catalog/gamma",
|
|
217
|
+
});
|
|
218
|
+
});
|
|
198
219
|
});
|
|
199
220
|
|
|
200
221
|
describe("catalog-backed resolvers (platform enabled)", () => {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
filterPluginCatalogByFeatureFlags,
|
|
5
|
+
MCP_CATALOG_QA_INTEGRATIONS_FLAG,
|
|
6
|
+
} from "../plugin-catalog-visibility.js";
|
|
7
|
+
import type { PluginCatalog, PluginSearchMatch } from "../search-plugins.js";
|
|
8
|
+
|
|
9
|
+
function match(name: string): PluginSearchMatch {
|
|
10
|
+
return {
|
|
11
|
+
name,
|
|
12
|
+
path: `local:plugins/mcp-catalog/${name}@1.0.0`,
|
|
13
|
+
category: null,
|
|
14
|
+
source: {
|
|
15
|
+
kind: "local",
|
|
16
|
+
path: `plugins/mcp-catalog/${name}`,
|
|
17
|
+
version: "1.0.0",
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function catalog(names: string[]): PluginCatalog {
|
|
23
|
+
return { ref: "main", matches: names.map(match) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("filterPluginCatalogByFeatureFlags", () => {
|
|
27
|
+
test("hides QA integrations while the flag is off", () => {
|
|
28
|
+
const input = catalog([
|
|
29
|
+
"fathom",
|
|
30
|
+
"gamma",
|
|
31
|
+
"guru",
|
|
32
|
+
"intercom",
|
|
33
|
+
"ramp",
|
|
34
|
+
"semrush",
|
|
35
|
+
"typeform",
|
|
36
|
+
"notion",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const result = filterPluginCatalogByFeatureFlags(input, (key) => {
|
|
40
|
+
expect(key).toBe(MCP_CATALOG_QA_INTEGRATIONS_FLAG);
|
|
41
|
+
return false;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(result.matches.map((entry) => entry.name)).toEqual([
|
|
45
|
+
"fathom",
|
|
46
|
+
"notion",
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("returns the full catalog while the flag is on", () => {
|
|
51
|
+
const input = catalog([
|
|
52
|
+
"fathom",
|
|
53
|
+
"gamma",
|
|
54
|
+
"guru",
|
|
55
|
+
"intercom",
|
|
56
|
+
"ramp",
|
|
57
|
+
"semrush",
|
|
58
|
+
"typeform",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const result = filterPluginCatalogByFeatureFlags(input, () => true);
|
|
62
|
+
|
|
63
|
+
expect(result).toBe(input);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -513,6 +513,49 @@ describe("getPluginDetails (bundled catalog, offline)", () => {
|
|
|
513
513
|
expect(details.ref).toBe(historicalRef);
|
|
514
514
|
});
|
|
515
515
|
|
|
516
|
+
test("hides a gated integration at an explicit historical ref", async () => {
|
|
517
|
+
const historicalRef = "a".repeat(40);
|
|
518
|
+
let fetchCalls = 0;
|
|
519
|
+
const fetch = (async () => {
|
|
520
|
+
fetchCalls += 1;
|
|
521
|
+
return new Response("unexpected request", { status: 500 });
|
|
522
|
+
}) as FetchLike;
|
|
523
|
+
|
|
524
|
+
await expect(
|
|
525
|
+
getPluginDetails(
|
|
526
|
+
{ name: "gamma", ref: historicalRef },
|
|
527
|
+
{ fetch, workspacePluginsDir: workspace },
|
|
528
|
+
),
|
|
529
|
+
).rejects.toBeInstanceOf(PluginDetailsNotFoundError);
|
|
530
|
+
expect(fetchCalls).toBe(0);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test("keeps an installed gated integration visible at an explicit historical ref", async () => {
|
|
534
|
+
const target = join(workspace, "gamma");
|
|
535
|
+
mkdirSync(target, { recursive: true });
|
|
536
|
+
writeFileSync(join(target, "README.md"), "# Installed Gamma");
|
|
537
|
+
writeFileSync(
|
|
538
|
+
join(target, "package.json"),
|
|
539
|
+
JSON.stringify({ version: "1.0.0", description: "local gamma" }),
|
|
540
|
+
);
|
|
541
|
+
let fetchCalls = 0;
|
|
542
|
+
const fetch = (async () => {
|
|
543
|
+
fetchCalls += 1;
|
|
544
|
+
return new Response("unexpected request", { status: 500 });
|
|
545
|
+
}) as FetchLike;
|
|
546
|
+
|
|
547
|
+
const details = await getPluginDetails(
|
|
548
|
+
{ name: "gamma", ref: "a".repeat(40) },
|
|
549
|
+
{ fetch, workspacePluginsDir: workspace },
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
expect(details.installed).toBe(true);
|
|
553
|
+
expect(details.readme).toBe("# Installed Gamma");
|
|
554
|
+
expect(details.description).toBe("local gamma");
|
|
555
|
+
expect(details.source).toBeNull();
|
|
556
|
+
expect(fetchCalls).toBe(0);
|
|
557
|
+
});
|
|
558
|
+
|
|
516
559
|
test("degrades gracefully when the historical marketplace fetch fails", async () => {
|
|
517
560
|
// GIVEN an installed copy and a historical ref whose marketplace fetch errors
|
|
518
561
|
const historicalRef = "a".repeat(40);
|
|
@@ -172,6 +172,17 @@ describe("plugins install by name — disable-platform mode", () => {
|
|
|
172
172
|
expect(message).toContain("not in the bundled marketplace catalog");
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
+
test("does not install a feature-gated bundled integration", async () => {
|
|
176
|
+
process.env.VELLUM_DISABLE_PLATFORM = "true";
|
|
177
|
+
delete process.env.IS_PLATFORM;
|
|
178
|
+
|
|
179
|
+
await runInstall("gamma");
|
|
180
|
+
|
|
181
|
+
expect(installPluginCalls).toHaveLength(0);
|
|
182
|
+
expect(platformInstallCalls).toHaveLength(0);
|
|
183
|
+
expect(process.exitCode).toBe(1);
|
|
184
|
+
});
|
|
185
|
+
|
|
175
186
|
test("uses the platform install endpoint when platform features are enabled", async () => {
|
|
176
187
|
delete process.env.VELLUM_DISABLE_PLATFORM;
|
|
177
188
|
delete process.env.IS_PLATFORM;
|
|
@@ -184,6 +195,21 @@ describe("plugins install by name — disable-platform mode", () => {
|
|
|
184
195
|
expect(process.exitCode).not.toBe(1);
|
|
185
196
|
});
|
|
186
197
|
|
|
198
|
+
test("does not install a name absent from a successful platform catalog", async () => {
|
|
199
|
+
delete process.env.VELLUM_DISABLE_PLATFORM;
|
|
200
|
+
delete process.env.IS_PLATFORM;
|
|
201
|
+
const errSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
202
|
+
|
|
203
|
+
await runInstall("gamma");
|
|
204
|
+
|
|
205
|
+
expect(installPluginCalls).toHaveLength(0);
|
|
206
|
+
expect(platformInstallCalls).toHaveLength(0);
|
|
207
|
+
expect(process.exitCode).toBe(1);
|
|
208
|
+
expect(String(errSpy.mock.calls[0]?.[0])).toContain(
|
|
209
|
+
"not in the marketplace catalog",
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
187
213
|
test("installs a platform-visible local package without the platform tar endpoint", async () => {
|
|
188
214
|
delete process.env.VELLUM_DISABLE_PLATFORM;
|
|
189
215
|
delete process.env.IS_PLATFORM;
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
arePlatformFeaturesEnabled,
|
|
32
32
|
readBundledLocalPluginCatalog,
|
|
33
33
|
} from "./plugin-catalog-local.js";
|
|
34
|
+
import { isPluginCatalogEntryVisible } from "./plugin-catalog-visibility.js";
|
|
34
35
|
import { DEFAULT_PLUGIN_REF } from "./plugin-constants.js";
|
|
35
36
|
import {
|
|
36
37
|
compareFingerprint,
|
|
@@ -338,6 +339,10 @@ export async function inspectPlugin(
|
|
|
338
339
|
const local = entry ? readLocal(entry, readInstallMeta(entry.target)) : null;
|
|
339
340
|
const surfaces = entry ? detectPluginSurfaces(entry.target) : null;
|
|
340
341
|
|
|
342
|
+
if (!installed && !isPluginCatalogEntryVisible(name)) {
|
|
343
|
+
throw new PluginInspectNotFoundError(name);
|
|
344
|
+
}
|
|
345
|
+
|
|
341
346
|
let remote: PluginRemoteInfo | null = null;
|
|
342
347
|
let remoteError: string | null = null;
|
|
343
348
|
const installedLocalSource = local?.source?.kind === "local";
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
readBundledPluginCatalog,
|
|
15
15
|
} from "./plugin-catalog-local.js";
|
|
16
16
|
import { fetchPluginCatalogFromPlatform } from "./plugin-catalog-platform.js";
|
|
17
|
+
import { filterPluginCatalogByFeatureFlags } from "./plugin-catalog-visibility.js";
|
|
17
18
|
import type { PluginCatalog, SearchPluginsDeps } from "./search-plugins.js";
|
|
18
19
|
|
|
19
20
|
/** How long a fetched catalog is served before a refresh is attempted. */
|
|
@@ -24,6 +25,8 @@ interface CacheEntry {
|
|
|
24
25
|
timestamp: number;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
type PluginCatalogLoadDeps = Pick<SearchPluginsDeps, "fetch">;
|
|
29
|
+
|
|
27
30
|
const cache = new Map<string, CacheEntry>();
|
|
28
31
|
|
|
29
32
|
/**
|
|
@@ -65,9 +68,9 @@ export function mergePlatformCatalogWithBundledLocals(
|
|
|
65
68
|
* fetch failure propagates so the caller can surface it (e.g. map a rate-limit
|
|
66
69
|
* to 503) — no stale catalog is ever served.
|
|
67
70
|
*/
|
|
68
|
-
|
|
71
|
+
async function loadPluginCatalog(
|
|
69
72
|
ref: string,
|
|
70
|
-
deps:
|
|
73
|
+
deps: PluginCatalogLoadDeps,
|
|
71
74
|
): Promise<PluginCatalog> {
|
|
72
75
|
if (!arePlatformFeaturesEnabled()) {
|
|
73
76
|
return { ...readBundledPluginCatalog(), ref };
|
|
@@ -84,6 +87,25 @@ export async function getPluginCatalog(
|
|
|
84
87
|
return merged;
|
|
85
88
|
}
|
|
86
89
|
|
|
90
|
+
/** Installable catalog with feature-flag visibility applied. */
|
|
91
|
+
export async function getPluginCatalog(
|
|
92
|
+
ref: string,
|
|
93
|
+
deps: SearchPluginsDeps,
|
|
94
|
+
): Promise<PluginCatalog> {
|
|
95
|
+
return filterPluginCatalogByFeatureFlags(
|
|
96
|
+
await loadPluginCatalog(ref, deps),
|
|
97
|
+
deps.featureFlagEnabled,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Category metadata for plugins already installed, including hidden entries. */
|
|
102
|
+
export async function getPluginCatalogForInstalledMetadata(
|
|
103
|
+
ref: string,
|
|
104
|
+
deps: PluginCatalogLoadDeps,
|
|
105
|
+
): Promise<PluginCatalog> {
|
|
106
|
+
return loadPluginCatalog(ref, deps);
|
|
107
|
+
}
|
|
108
|
+
|
|
87
109
|
/** Invalidate the cache (for testing or forced refresh). */
|
|
88
110
|
export function invalidatePluginCatalogCache(): void {
|
|
89
111
|
cache.clear();
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import bundledManifest from "./bundled-marketplace.json" with { type: "json" };
|
|
16
|
+
import {
|
|
17
|
+
isPluginCatalogEntryVisible,
|
|
18
|
+
type PluginCatalogFeatureFlagResolver,
|
|
19
|
+
} from "./plugin-catalog-visibility.js";
|
|
16
20
|
import {
|
|
17
21
|
type MarketplaceEntry,
|
|
18
22
|
marketplaceManifestSchema,
|
|
@@ -27,6 +31,7 @@ import {
|
|
|
27
31
|
// Re-exported so `local`-tagged CLI commands can gate on platform features
|
|
28
32
|
// without importing `platform/` directly (cli/no-daemon-internals allows lib).
|
|
29
33
|
export { arePlatformFeaturesEnabled } from "../../platform/feature-gate.js";
|
|
34
|
+
export { isPluginCatalogEntryVisible };
|
|
30
35
|
|
|
31
36
|
let memoizedEntries: readonly MarketplaceEntry[] | undefined;
|
|
32
37
|
|
|
@@ -80,6 +85,10 @@ export function readBundledLocalPluginCatalog(): PluginCatalog {
|
|
|
80
85
|
*/
|
|
81
86
|
export function resolveBundledPluginSource(
|
|
82
87
|
name: string,
|
|
88
|
+
isFeatureFlagEnabled?: PluginCatalogFeatureFlagResolver,
|
|
83
89
|
): ResolvedPluginSource | null {
|
|
90
|
+
if (!isPluginCatalogEntryVisible(name, isFeatureFlagEnabled)) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
84
93
|
return resolveMarketplaceSource(name, bundledEntries());
|
|
85
94
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { isAssistantFeatureFlagEnabled } from "../../config/assistant-feature-flags.js";
|
|
2
|
+
import type { PluginCatalog } from "./search-plugins.js";
|
|
3
|
+
|
|
4
|
+
export const MCP_CATALOG_QA_INTEGRATIONS_FLAG = "mcp-catalog-qa-integrations";
|
|
5
|
+
|
|
6
|
+
const MCP_CATALOG_QA_INTEGRATION_NAMES = new Set([
|
|
7
|
+
"gamma",
|
|
8
|
+
"guru",
|
|
9
|
+
"intercom",
|
|
10
|
+
"ramp",
|
|
11
|
+
"semrush",
|
|
12
|
+
"typeform",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
export type PluginCatalogFeatureFlagResolver = (key: string) => boolean;
|
|
16
|
+
|
|
17
|
+
export function isPluginCatalogEntryVisible(
|
|
18
|
+
name: string,
|
|
19
|
+
isFeatureFlagEnabled: PluginCatalogFeatureFlagResolver = isAssistantFeatureFlagEnabled,
|
|
20
|
+
): boolean {
|
|
21
|
+
return (
|
|
22
|
+
!MCP_CATALOG_QA_INTEGRATION_NAMES.has(name) ||
|
|
23
|
+
isFeatureFlagEnabled(MCP_CATALOG_QA_INTEGRATIONS_FLAG)
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Hide catalog entries whose OAuth flows are still being validated. Installed
|
|
29
|
+
* plugins are unaffected because this filter only applies to catalog reads.
|
|
30
|
+
*/
|
|
31
|
+
export function filterPluginCatalogByFeatureFlags(
|
|
32
|
+
catalog: PluginCatalog,
|
|
33
|
+
isFeatureFlagEnabled: PluginCatalogFeatureFlagResolver = isAssistantFeatureFlagEnabled,
|
|
34
|
+
): PluginCatalog {
|
|
35
|
+
const matches = catalog.matches.filter((match) =>
|
|
36
|
+
isPluginCatalogEntryVisible(match.name, isFeatureFlagEnabled),
|
|
37
|
+
);
|
|
38
|
+
return matches.length === catalog.matches.length
|
|
39
|
+
? catalog
|
|
40
|
+
: { ...catalog, matches };
|
|
41
|
+
}
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
findCatalogEntry,
|
|
47
47
|
resolveSourceFromMatch,
|
|
48
48
|
} from "./plugin-catalog-resolve.js";
|
|
49
|
+
import { isPluginCatalogEntryVisible } from "./plugin-catalog-visibility.js";
|
|
49
50
|
import { DEFAULT_PLUGIN_REF } from "./plugin-constants.js";
|
|
50
51
|
import { readValidatedPluginIcon } from "./plugin-icon-file.js";
|
|
51
52
|
import { fetchMarketplaceEntries } from "./plugin-marketplace.js";
|
|
@@ -156,8 +157,8 @@ export class PluginDetailsNotFoundError extends Error {
|
|
|
156
157
|
* Resolve the detail view for {@link opts.name}.
|
|
157
158
|
*
|
|
158
159
|
* Throws {@link PluginDetailsNotFoundError} when the name is neither installed
|
|
159
|
-
* locally nor present in the catalog
|
|
160
|
-
*
|
|
160
|
+
* locally nor present in the visibility-gated catalog at either the default
|
|
161
|
+
* or an explicit historical ref. A gated-catalog outage
|
|
161
162
|
* ({@link PluginCatalogUnavailableError}) degrades to the on-disk fields only
|
|
162
163
|
* when a local copy exists; with nothing installed to render it propagates so
|
|
163
164
|
* the caller can map the transient failure to a retryable 503 rather than a
|
|
@@ -400,11 +401,10 @@ async function fetchRawFile(
|
|
|
400
401
|
/**
|
|
401
402
|
* Resolve the external catalog entry claiming {@link name}.
|
|
402
403
|
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
* history / inspect carve-out.
|
|
404
|
+
* Both default and historical-ref lookups apply catalog visibility first. The
|
|
405
|
+
* default ref then reads the gated catalog used by search and install, while an
|
|
406
|
+
* explicit historical {@link ref} reads the GitHub marketplace manifest at
|
|
407
|
+
* that revision.
|
|
408
408
|
*
|
|
409
409
|
* A gated-catalog outage (fail-hard {@link PluginCatalogUnavailableError})
|
|
410
410
|
* propagates when nothing is installed — there is nothing to render and the
|
|
@@ -420,6 +420,9 @@ async function resolveCatalogEntry(
|
|
|
420
420
|
fetchFn: FetchLike,
|
|
421
421
|
installed: boolean,
|
|
422
422
|
): Promise<PluginSearchMatch | null> {
|
|
423
|
+
if (!isPluginCatalogEntryVisible(name)) {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
423
426
|
try {
|
|
424
427
|
const match =
|
|
425
428
|
ref === DEFAULT_PLUGIN_REF
|
|
@@ -19,6 +19,8 @@ import type { MarketplaceEntry } from "./plugin-marketplace.js";
|
|
|
19
19
|
export interface SearchPluginsDeps {
|
|
20
20
|
/** HTTP client. Production callers pass `globalThis.fetch.bind(globalThis)`. */
|
|
21
21
|
readonly fetch: FetchLike;
|
|
22
|
+
/** Optional feature-flag resolver for deterministic tests. */
|
|
23
|
+
readonly featureFlagEnabled?: (key: string) => boolean;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
/** Where a catalog match comes from. */
|
|
@@ -46,9 +46,12 @@ nothing, because no control is called that. Give the label on its own:
|
|
|
46
46
|
The arrow points at the middle of the control and stops just short, so what
|
|
47
47
|
you are sending someone to stays visible the whole time.
|
|
48
48
|
|
|
49
|
-
You are answered with what was drawn and the name it resolved
|
|
50
|
-
|
|
51
|
-
the
|
|
49
|
+
You are answered with what was drawn and the accessibility name it resolved
|
|
50
|
+
to. Some apps expose internal names such as `ToolbarExportControl` instead of
|
|
51
|
+
the visible label. When a returned name identifies the intended control in
|
|
52
|
+
the shared image, use that exact name as `target`; use the visible label in
|
|
53
|
+
the caption and speech. Do not invent an internal name or choose an unclear
|
|
54
|
+
candidate.
|
|
52
55
|
|
|
53
56
|
A name the surface does not carry draws nothing and comes back with the names
|
|
54
57
|
it does carry. That is the answer, not a setback: the thing is nearly always
|
|
@@ -338,6 +338,14 @@
|
|
|
338
338
|
"description": "Show the Add Server action on the MCP settings page. The MCP page itself remains visible; this flag gates only creating new Model Context Protocol server connections.",
|
|
339
339
|
"defaultEnabled": false
|
|
340
340
|
},
|
|
341
|
+
{
|
|
342
|
+
"id": "mcp-catalog-qa-integrations",
|
|
343
|
+
"scope": "assistant",
|
|
344
|
+
"key": "mcp-catalog-qa-integrations",
|
|
345
|
+
"label": "MCP Catalog QA Integrations",
|
|
346
|
+
"description": "Show Gamma, Guru, Intercom, Ramp, Semrush, and Typeform in the installable plugin catalog while their OAuth flows are validated. When off, catalog search, detail, and install-by-name cannot resolve them. Existing installed connections remain available.",
|
|
347
|
+
"defaultEnabled": false
|
|
348
|
+
},
|
|
341
349
|
{
|
|
342
350
|
"id": "os-beta",
|
|
343
351
|
"scope": "assistant",
|
|
@@ -565,7 +573,7 @@
|
|
|
565
573
|
"scope": "both",
|
|
566
574
|
"key": "sidebar-done",
|
|
567
575
|
"label": "Sidebar Done",
|
|
568
|
-
"description": "Turns archiving a chat into marking it done: the sidebar row carries a Done check instead of the overflow menu, an
|
|
576
|
+
"description": "Turns archiving a chat into marking it done: the sidebar row carries a Done check instead of the overflow menu, an All chats page lists the full history, and done chats stay findable in search by default. Off keeps today's Archive wording, the overflow menu, and search that hides archived rows unless the query asks for them.",
|
|
569
577
|
"defaultEnabled": false
|
|
570
578
|
}
|
|
571
579
|
]
|
|
@@ -165,8 +165,14 @@ const getCatalogSpy = mock(
|
|
|
165
165
|
},
|
|
166
166
|
);
|
|
167
167
|
|
|
168
|
+
const getInstalledMetadataCatalogSpy = mock(
|
|
169
|
+
(ref: string, deps: SearchPluginsDeps): Promise<PluginCatalog> =>
|
|
170
|
+
getCatalogSpy(ref, deps),
|
|
171
|
+
);
|
|
172
|
+
|
|
168
173
|
mock.module("../../../cli/lib/plugin-catalog-cache.js", () => ({
|
|
169
174
|
getPluginCatalog: getCatalogSpy,
|
|
175
|
+
getPluginCatalogForInstalledMetadata: getInstalledMetadataCatalogSpy,
|
|
170
176
|
}));
|
|
171
177
|
|
|
172
178
|
// Mock uninstallPlugin. The handler's error mapping is the wiring under
|
|
@@ -471,6 +477,7 @@ beforeEach(() => {
|
|
|
471
477
|
describe("GET /v1/plugins", () => {
|
|
472
478
|
beforeEach(() => {
|
|
473
479
|
getCatalogSpy.mockClear();
|
|
480
|
+
getInstalledMetadataCatalogSpy.mockClear();
|
|
474
481
|
// Default to an empty catalog so every installed plugin's category resolves
|
|
475
482
|
// to `null` (bucketed under "system"). Tests that exercise real categories
|
|
476
483
|
// override this.
|
|
@@ -849,6 +856,7 @@ describe("GET /v1/plugins", () => {
|
|
|
849
856
|
);
|
|
850
857
|
|
|
851
858
|
const map = await loadCategoryMapBounded();
|
|
859
|
+
expect(getInstalledMetadataCatalogSpy).toHaveBeenCalledTimes(1);
|
|
852
860
|
expect(map.get("alpha")).toBe("productivity");
|
|
853
861
|
});
|
|
854
862
|
|
|
@@ -48,7 +48,10 @@ import {
|
|
|
48
48
|
type InstalledPluginInfo,
|
|
49
49
|
listInstalledPlugins,
|
|
50
50
|
} from "../../cli/lib/list-installed-plugins.js";
|
|
51
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
getPluginCatalog,
|
|
53
|
+
getPluginCatalogForInstalledMetadata,
|
|
54
|
+
} from "../../cli/lib/plugin-catalog-cache.js";
|
|
52
55
|
import { resolvePluginSourceFromCatalog } from "../../cli/lib/plugin-catalog-resolve.js";
|
|
53
56
|
import {
|
|
54
57
|
DEFAULT_PIN_HISTORY_LIMIT,
|
|
@@ -969,7 +972,7 @@ export async function loadCategoryMapBounded(
|
|
|
969
972
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
970
973
|
try {
|
|
971
974
|
const catalog = await Promise.race([
|
|
972
|
-
|
|
975
|
+
getPluginCatalogForInstalledMetadata(DEFAULT_PLUGIN_REF, {
|
|
973
976
|
fetch: globalThis.fetch.bind(globalThis),
|
|
974
977
|
}),
|
|
975
978
|
new Promise<null>((resolve) => {
|