@vellumai/assistant 0.12.4-staging.3 → 0.12.4-staging.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.4-staging.3",
3
+ "version": "0.12.4-staging.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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).toBe(second);
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 = { fetch };
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
  /** Add bundled local packages without overriding platform-authoritative rows. */
@@ -50,9 +53,9 @@ export function mergePlatformCatalogWithBundledLocals(
50
53
  * fetch failure propagates so the caller can surface it (e.g. map a rate-limit
51
54
  * to 503) — no stale catalog is ever served.
52
55
  */
53
- export async function getPluginCatalog(
56
+ async function loadPluginCatalog(
54
57
  ref: string,
55
- deps: SearchPluginsDeps,
58
+ deps: PluginCatalogLoadDeps,
56
59
  ): Promise<PluginCatalog> {
57
60
  if (!arePlatformFeaturesEnabled()) {
58
61
  return { ...readBundledPluginCatalog(), ref };
@@ -69,6 +72,25 @@ export async function getPluginCatalog(
69
72
  return merged;
70
73
  }
71
74
 
75
+ /** Installable catalog with feature-flag visibility applied. */
76
+ export async function getPluginCatalog(
77
+ ref: string,
78
+ deps: SearchPluginsDeps,
79
+ ): Promise<PluginCatalog> {
80
+ return filterPluginCatalogByFeatureFlags(
81
+ await loadPluginCatalog(ref, deps),
82
+ deps.featureFlagEnabled,
83
+ );
84
+ }
85
+
86
+ /** Category metadata for plugins already installed, including hidden entries. */
87
+ export async function getPluginCatalogForInstalledMetadata(
88
+ ref: string,
89
+ deps: PluginCatalogLoadDeps,
90
+ ): Promise<PluginCatalog> {
91
+ return loadPluginCatalog(ref, deps);
92
+ }
93
+
72
94
  /** Invalidate the cache (for testing or forced refresh). */
73
95
  export function invalidatePluginCatalogCache(): void {
74
96
  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 (gated at the default ref, the GitHub
160
- * marketplace at an explicit historical ref). A gated-catalog outage
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
- * The default ref reads the gated catalog (the same source search / install
404
- * use). An explicit historical {@link ref} reads the GitHub marketplace
405
- * manifest at that revision — the gated catalog is ref-agnostic, so honoring a
406
- * reviewed/rolled-back revision is inherently a git lookup, matching the pin
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. */
@@ -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",
@@ -557,7 +565,7 @@
557
565
  "scope": "both",
558
566
  "key": "sidebar-done",
559
567
  "label": "Sidebar Done",
560
- "description": "Turns archiving a chat into marking it done: the sidebar row carries a Done check instead of the overflow menu, an Old 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.",
568
+ "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.",
561
569
  "defaultEnabled": false
562
570
  }
563
571
  ]
@@ -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 { getPluginCatalog } from "../../cli/lib/plugin-catalog-cache.js";
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
- getPluginCatalog(DEFAULT_PLUGIN_REF, {
975
+ getPluginCatalogForInstalledMetadata(DEFAULT_PLUGIN_REF, {
973
976
  fetch: globalThis.fetch.bind(globalThis),
974
977
  }),
975
978
  new Promise<null>((resolve) => {