@vellumai/assistant 0.12.3 → 0.12.4-dev.202609231715.8da971c

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.
Files changed (229) hide show
  1. package/Dockerfile +7 -0
  2. package/README.md +12 -12
  3. package/docker/mousepad/90-vellum.gschema.override +4 -0
  4. package/node_modules/@vellumai/avatar-manifest/src/__tests__/notification-avatar.test.ts +48 -4
  5. package/node_modules/@vellumai/avatar-manifest/src/notification-avatar.ts +12 -8
  6. package/openapi.yaml +97 -4
  7. package/package.json +1 -1
  8. package/src/__tests__/approval-routes-http.test.ts +4 -2
  9. package/src/__tests__/call-routes-http.test.ts +4 -2
  10. package/src/__tests__/channel-readiness-discord.test.ts +8 -0
  11. package/src/__tests__/channel-readiness-slack-remote.test.ts +8 -0
  12. package/src/__tests__/config-loader-backfill.test.ts +1 -0
  13. package/src/__tests__/conversation-pairing.test.ts +10 -0
  14. package/src/__tests__/conversation-resurface-done.test.ts +289 -0
  15. package/src/__tests__/default-profile-catalog-fallback.test.ts +1 -0
  16. package/src/__tests__/gemini-provider.test.ts +31 -0
  17. package/src/__tests__/inference-profile-validation-jev.test.ts +17 -0
  18. package/src/__tests__/job-handler-registration-guard.test.ts +1 -0
  19. package/src/__tests__/llm-catalog-parity.test.ts +4 -0
  20. package/src/__tests__/llm-resolver-override-or-default.test.ts +43 -0
  21. package/src/__tests__/openai-responses-prompt-cache.test.ts +3 -1
  22. package/src/__tests__/platform-callback-registration.test.ts +46 -1
  23. package/src/__tests__/plugin-api-model-profiles.test.ts +7 -0
  24. package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
  25. package/src/__tests__/pricing.test.ts +33 -0
  26. package/src/__tests__/provider-error-scenarios.test.ts +46 -0
  27. package/src/__tests__/provider-platform-proxy-integration.test.ts +45 -15
  28. package/src/__tests__/retry-fallback-escalation.test.ts +22 -0
  29. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  30. package/src/__tests__/scaffold-managed-skill-tool.test.ts +116 -66
  31. package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -0
  32. package/src/__tests__/send-endpoint-busy.test.ts +4 -2
  33. package/src/__tests__/skill-load-inline-command.test.ts +35 -0
  34. package/src/__tests__/skill-load-tool.test.ts +42 -11
  35. package/src/__tests__/skill-tool-factory.test.ts +50 -2
  36. package/src/__tests__/steer-on-enqueue-question.test.ts +265 -34
  37. package/src/__tests__/validate-input.test.ts +73 -0
  38. package/src/__tests__/workspace-migration-159-rename-colliding-jev-profile-name.test.ts +197 -0
  39. package/src/api/ask-question-tool.ts +50 -0
  40. package/src/api/events/tool-result.ts +11 -0
  41. package/src/api/index.ts +7 -0
  42. package/src/api/requests/dictation.ts +2 -2
  43. package/src/api/responses/home.ts +27 -0
  44. package/src/avatar/notification-avatar.test.ts +42 -34
  45. package/src/avatar/notification-avatar.ts +1 -0
  46. package/src/calls/__tests__/call-controller.test.ts +18 -0
  47. package/src/calls/__tests__/voice-control-protocol.test.ts +6 -1
  48. package/src/calls/__tests__/voice-leg-coordinator.test.ts +42 -0
  49. package/src/calls/__tests__/voice-session-bridge.test.ts +97 -4
  50. package/src/calls/__tests__/voice-triage-escalate.test.ts +69 -11
  51. package/src/calls/call-controller.ts +3 -1
  52. package/src/calls/voice-control-protocol.ts +9 -4
  53. package/src/calls/voice-escalation-judge.ts +7 -6
  54. package/src/calls/voice-leg-coordinator.ts +15 -4
  55. package/src/calls/voice-screen-annotation.ts +44 -0
  56. package/src/calls/voice-session-bridge.ts +50 -20
  57. package/src/calls/voice-triage-escalate.ts +61 -26
  58. package/src/cli/commands/__tests__/plugins.test.ts +29 -1
  59. package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
  60. package/src/cli/commands/oauth/index.help.ts +8 -2
  61. package/src/cli/commands/oauth/request.test.ts +32 -0
  62. package/src/cli/commands/platform/index.help.ts +2 -1
  63. package/src/cli/commands/platform/index.ts +8 -2
  64. package/src/cli/commands/plugins.ts +16 -0
  65. package/src/cli/lib/__tests__/inspect-plugin.test.ts +29 -0
  66. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +92 -2
  67. package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +15 -0
  68. package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +66 -2
  69. package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +21 -0
  70. package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +65 -0
  71. package/src/cli/lib/__tests__/plugin-details.test.ts +43 -0
  72. package/src/cli/lib/__tests__/plugins-install-offline.test.ts +26 -0
  73. package/src/cli/lib/bundled-marketplace.json +4 -4
  74. package/src/cli/lib/inspect-plugin.ts +5 -0
  75. package/src/cli/lib/plugin-catalog-cache.ts +41 -4
  76. package/src/cli/lib/plugin-catalog-local.ts +9 -0
  77. package/src/cli/lib/plugin-catalog-platform.ts +48 -9
  78. package/src/cli/lib/plugin-catalog-visibility.ts +41 -0
  79. package/src/cli/lib/plugin-details.ts +10 -7
  80. package/src/cli/lib/search-plugins.ts +2 -0
  81. package/src/config/__tests__/default-profile-catalog.test.ts +61 -1
  82. package/src/config/__tests__/jev-managed-profile.test.ts +82 -0
  83. package/src/config/__tests__/platform-identity.test.ts +59 -0
  84. package/src/config/__tests__/sync-gated-profiles.test.ts +71 -0
  85. package/src/config/bundled-skills/screen-annotation/SKILL.md +6 -3
  86. package/src/config/call-site-defaults.ts +17 -11
  87. package/src/config/default-profile-catalog.ts +118 -19
  88. package/src/config/default-profile-names.ts +69 -0
  89. package/src/config/feature-flag-registry.json +25 -9
  90. package/src/config/inference-profile-validation.ts +7 -6
  91. package/src/config/llm-resolver.ts +16 -0
  92. package/src/config/platform-identity.ts +22 -5
  93. package/src/config/profile-text-generation.test.ts +9 -0
  94. package/src/config/schemas/call-site-catalog.ts +8 -1
  95. package/src/config/schemas/llm.ts +70 -9
  96. package/src/config/sidebar-done-gate.ts +27 -0
  97. package/src/config/sync-gated-profiles.ts +96 -44
  98. package/src/daemon/__tests__/auto-profile-router.test.ts +228 -0
  99. package/src/daemon/auto-profile-router.ts +220 -0
  100. package/src/daemon/conversation-agent-loop.ts +77 -4
  101. package/src/daemon/conversation-slash.ts +2 -2
  102. package/src/daemon/conversation-turn-finalize.ts +12 -0
  103. package/src/daemon/dictation-text-processing.ts +4 -4
  104. package/src/daemon/handlers/__tests__/conversation-history-search.test.ts +61 -0
  105. package/src/daemon/handlers/config-channels.ts +3 -1
  106. package/src/daemon/handlers/conversation-history.ts +11 -1
  107. package/src/daemon/handlers/conversations.ts +118 -10
  108. package/src/daemon/pkb-reminder-builder.test.ts +10 -3
  109. package/src/daemon/pkb-reminder-builder.ts +1 -1
  110. package/src/desktop/desktop-browser-endpoint.ts +1 -0
  111. package/src/home/feed-types.ts +2 -0
  112. package/src/inbound/platform-callback-registration.ts +19 -6
  113. package/src/ipc/routes/__tests__/conversation-sync-ipc-routes.test.ts +49 -1
  114. package/src/ipc/routes/conversation-sync-ipc-routes.ts +38 -2
  115. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +27 -0
  116. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +37 -1
  117. package/src/live-voice/__tests__/live-voice-vad.test.ts +34 -2
  118. package/src/live-voice/__tests__/protocol.test.ts +22 -0
  119. package/src/live-voice/continuation-judge.ts +1 -1
  120. package/src/live-voice/live-voice-session.ts +17 -14
  121. package/src/live-voice/protocol.ts +16 -5
  122. package/src/messaging/providers/gmail/client.ts +4 -0
  123. package/src/notifications/README.md +11 -3
  124. package/src/notifications/__tests__/assistant-reply-producer.test.ts +13 -0
  125. package/src/notifications/__tests__/home-feed-side-effect.test.ts +87 -3
  126. package/src/notifications/conversation-pairing.ts +23 -8
  127. package/src/notifications/home-feed-mirror.ts +39 -0
  128. package/src/notifications/home-feed-side-effect.ts +46 -19
  129. package/src/oauth/AGENTS.md +9 -1
  130. package/src/oauth/byo-connection.test.ts +28 -0
  131. package/src/permissions/question-prompter.ts +14 -4
  132. package/src/persistence/conversation-crud.ts +116 -3
  133. package/src/persistence/conversation-queries.ts +48 -3
  134. package/src/persistence/conversation-types.ts +6 -2
  135. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +228 -32
  136. package/src/persistence/embeddings/embedding-backend-shutdown.test.ts +47 -0
  137. package/src/persistence/embeddings/embedding-backend.ts +23 -41
  138. package/src/persistence/embeddings/embedding-gemini.test.ts +17 -0
  139. package/src/persistence/embeddings/embedding-gemini.ts +3 -1
  140. package/src/persistence/embeddings/embedding-local.ts +72 -17
  141. package/src/persistence/embeddings/embedding-types.ts +0 -19
  142. package/src/persistence/jobs-store.ts +229 -0
  143. package/src/persistence/migrations/383-normalize-opencode-host-connections.ts +44 -0
  144. package/src/persistence/migrations/__tests__/383-normalize-opencode-host-connections.test.ts +102 -0
  145. package/src/persistence/steps.ts +12 -0
  146. package/src/platform/sync-avatar.test.ts +29 -0
  147. package/src/plugin-api/model-profiles.ts +5 -3
  148. package/src/plugins/defaults/memory/AGENTS.md +5 -3
  149. package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +2 -0
  150. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-boundary-guard.test.ts +45 -0
  151. package/src/plugins/defaults/memory/__tests__/skill-update-receipt-job.test.ts +580 -0
  152. package/src/plugins/defaults/memory/graph/tools.ts +9 -5
  153. package/src/plugins/defaults/memory/hooks/conversation-deleted.ts +4 -0
  154. package/src/plugins/defaults/memory/job-handlers.ts +8 -2
  155. package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +5 -0
  156. package/src/plugins/defaults/memory/skill-update-receipt-job.ts +334 -0
  157. package/src/plugins/defaults/memory/substrate/skill-content.ts +1 -1
  158. package/src/plugins/defaults/memory/tools.test.ts +17 -0
  159. package/src/plugins/defaults/memory/v3/__tests__/render-injection.test.ts +3 -0
  160. package/src/providers/__tests__/connection-model-compat.test.ts +2 -0
  161. package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
  162. package/src/providers/anthropic/client.ts +2 -2
  163. package/src/providers/gemini/client.ts +17 -1
  164. package/src/providers/inference/auth.ts +25 -0
  165. package/src/providers/jev/ask.test.ts +66 -0
  166. package/src/{calls/typesafe-noul.ts → providers/jev/ask.ts} +75 -36
  167. package/src/providers/malformed-tool-call.ts +55 -0
  168. package/src/providers/model-catalog.ts +257 -4
  169. package/src/providers/openai/__tests__/chat-completions-malformed-tool-call.test.ts +105 -0
  170. package/src/providers/openai/chat-completions-provider.ts +30 -0
  171. package/src/providers/openai/codex-models.ts +2 -0
  172. package/src/providers/platform-proxy/constants.ts +5 -0
  173. package/src/providers/retry.ts +68 -36
  174. package/src/providers/search-provider-catalog.ts +10 -0
  175. package/src/providers/vellum-model-routing.test.ts +1 -0
  176. package/src/runtime/__tests__/agent-wake-archived-gate.test.ts +90 -0
  177. package/src/runtime/__tests__/question-resolution.test.ts +32 -0
  178. package/src/runtime/agent-wake.ts +9 -2
  179. package/src/runtime/auth/__tests__/middleware.test.ts +138 -0
  180. package/src/runtime/auth/middleware.ts +15 -11
  181. package/src/runtime/middleware/auth.ts +7 -1
  182. package/src/runtime/pending-interactions.ts +8 -8
  183. package/src/runtime/question-resolution.ts +54 -8
  184. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +146 -0
  185. package/src/runtime/routes/__tests__/dictation-processing.test.ts +174 -0
  186. package/src/runtime/routes/__tests__/global-search-routes.test.ts +74 -1
  187. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +60 -0
  188. package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +16 -1
  189. package/src/runtime/routes/__tests__/oauth-proxy-routes.test.ts +61 -3
  190. package/src/runtime/routes/__tests__/plugins-routes.test.ts +8 -0
  191. package/src/runtime/routes/conversation-list-routes.ts +16 -14
  192. package/src/runtime/routes/conversation-query-routes.ts +4 -0
  193. package/src/runtime/routes/conversation-routes.ts +4 -2
  194. package/src/runtime/routes/diagnostics-routes.ts +66 -229
  195. package/src/runtime/routes/global-search-routes.ts +22 -7
  196. package/src/runtime/routes/inference-provider-connection-routes.ts +25 -8
  197. package/src/runtime/routes/llm-call-sites-routes.ts +19 -10
  198. package/src/runtime/routes/oauth-proxy-passthrough.test.ts +32 -0
  199. package/src/runtime/routes/platform-routes.ts +6 -1
  200. package/src/runtime/routes/plugins-routes.ts +5 -2
  201. package/src/runtime/sync/documents-sidecar-publish.test.ts +50 -4
  202. package/src/runtime/sync/resource-sync-events.ts +11 -0
  203. package/src/runtime/sync/worker-daemon-notify.test.ts +30 -0
  204. package/src/runtime/sync/worker-daemon-notify.ts +44 -0
  205. package/src/runtime/verification-outbound-actions.ts +2 -2
  206. package/src/skills/validate-input.ts +75 -11
  207. package/src/telegram/__tests__/webhook-health.test.ts +8 -0
  208. package/src/tools/ask-question/ask-question-tool.test.ts +38 -0
  209. package/src/tools/ask-question/ask-question-tool.ts +12 -1
  210. package/src/tools/network/__tests__/web-fetch-firecrawl.test.ts +28 -1
  211. package/src/tools/network/__tests__/web-fetch-metadata.test.ts +34 -0
  212. package/src/tools/network/__tests__/web-fetch-tinyfish.test.ts +35 -1
  213. package/src/tools/network/__tests__/web-search.test.ts +96 -0
  214. package/src/tools/network/web-fetch.ts +30 -9
  215. package/src/tools/network/web-search.ts +235 -4
  216. package/src/tools/skills/execute.ts +1 -1
  217. package/src/tools/skills/format-input-schema.ts +15 -0
  218. package/src/tools/skills/load.ts +15 -43
  219. package/src/tools/skills/scaffold-managed.ts +86 -71
  220. package/src/tools/skills/skill-tool-factory.ts +2 -1
  221. package/src/tools/workflows/manage-workflows.ts +4 -2
  222. package/src/util/oauth-request-body.test.ts +5 -1
  223. package/src/util/worker-pipe.test.ts +146 -0
  224. package/src/util/worker-pipe.ts +49 -0
  225. package/src/workspace/migrations/158-repoint-opencode-host-provider-fragments.ts +163 -0
  226. package/src/workspace/migrations/159-rename-colliding-jev-profile-name.ts +344 -0
  227. package/src/workspace/migrations/__tests__/158-repoint-opencode-host-provider-fragments.test.ts +188 -0
  228. package/src/workspace/migrations/registry.ts +4 -0
  229. package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +0 -107
@@ -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", () => {
@@ -228,4 +258,64 @@ describe("mergePlatformCatalogWithBundledLocals", () => {
228
258
  expect(merged.matches[1]?.source.kind).toBe("local");
229
259
  expect(merged.ref).toBe("main");
230
260
  });
261
+ test("keeps the bundled integration when a platform row without one shadows it", () => {
262
+ const integration = {
263
+ kind: "mcp",
264
+ displayName: "Fathom",
265
+ documentationUrl: "https://example.com/docs",
266
+ verifiedAt: "2026-09-10",
267
+ verification: "documentation-only",
268
+ setup: { mode: "oauth", instructions: "Sign in." },
269
+ logo: "fathom.png",
270
+ } as const;
271
+ const platformIntegration = { ...integration, displayName: "Platform" };
272
+ const platform: PluginCatalog = {
273
+ ref: "main",
274
+ matches: [
275
+ {
276
+ name: "fathom",
277
+ path: "github:provider/fathom@pin",
278
+ category: null,
279
+ source: {
280
+ kind: "github",
281
+ repo: "provider/fathom",
282
+ ref: "0".repeat(40),
283
+ },
284
+ },
285
+ {
286
+ name: "linear",
287
+ path: "github:provider/linear@pin",
288
+ category: null,
289
+ integration: platformIntegration,
290
+ source: {
291
+ kind: "github",
292
+ repo: "provider/linear",
293
+ ref: "0".repeat(40),
294
+ },
295
+ },
296
+ ],
297
+ };
298
+ const bundled: PluginCatalog = {
299
+ ref: "bundled",
300
+ matches: ["fathom", "linear"].map((name) => ({
301
+ name,
302
+ path: `local:plugins/mcp-catalog/${name}@1.0.0`,
303
+ category: null,
304
+ integration,
305
+ source: {
306
+ kind: "local",
307
+ path: `plugins/mcp-catalog/${name}`,
308
+ version: "1.0.0",
309
+ },
310
+ })),
311
+ };
312
+
313
+ const merged = mergePlatformCatalogWithBundledLocals(platform, bundled);
314
+
315
+ const [fathom, linear] = merged.matches;
316
+ expect(fathom?.source.kind).toBe("github");
317
+ expect(fathom?.integration).toEqual(integration);
318
+ expect(linear?.source.kind).toBe("github");
319
+ expect(linear?.integration).toEqual(platformIntegration);
320
+ });
231
321
  });
@@ -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
+ });
@@ -161,14 +161,78 @@ describe("fetchPluginCatalogFromPlatform", () => {
161
161
  await expect(promise).rejects.toMatchObject({ status: 502 });
162
162
  });
163
163
 
164
- test("throws on a schema violation", async () => {
164
+ test.each([
165
+ ["a missing plugins array", {}],
166
+ ["a non-array plugins field", { plugins: "nope" }],
167
+ ["a non-object body", []],
168
+ ])("throws on an invalid envelope (%s)", async (_label, body) => {
165
169
  const promise = fetchPluginCatalogFromPlatform({
166
- fetch: platformFetch({ plugins: [{ repo: "acme/x", ref: SHA_A }] }),
170
+ fetch: platformFetch(body),
167
171
  });
168
172
  await expect(promise).rejects.toBeInstanceOf(PluginCatalogUnavailableError);
169
173
  await expect(promise).rejects.toMatchObject({ status: 502 });
170
174
  });
171
175
 
176
+ test("skips rows that fail the base row schema and keeps the rest", async () => {
177
+ const catalog = await fetchPluginCatalogFromPlatform({
178
+ fetch: platformFetch({
179
+ plugins: [
180
+ { repo: "acme/nameless", ref: SHA_A },
181
+ { name: 42, repo: "acme/numeric", ref: SHA_A },
182
+ "not-a-row",
183
+ { name: "good", repo: "acme/good", ref: SHA_B },
184
+ ],
185
+ }),
186
+ });
187
+ expect(catalog.matches.map((m) => m.name)).toEqual(["good"]);
188
+ });
189
+
190
+ test("keeps a row whose integration is invalid, without the integration", async () => {
191
+ const catalog = await fetchPluginCatalogFromPlatform({
192
+ fetch: platformFetch({
193
+ plugins: [
194
+ {
195
+ name: "snake-case",
196
+ repo: "acme/snake-case",
197
+ ref: SHA_A,
198
+ description: "kept",
199
+ integration: {
200
+ kind: "mcp",
201
+ display_name: "Snake Case",
202
+ documentation_url: "https://example.com/docs",
203
+ verified_at: "2026-09-10",
204
+ verification: "documentation-only",
205
+ setup: { mode: "oauth", instructions: "Sign in." },
206
+ logo: "snake-case.png",
207
+ },
208
+ },
209
+ {
210
+ name: "valid",
211
+ repo: "acme/valid",
212
+ ref: SHA_B,
213
+ integration: {
214
+ kind: "mcp",
215
+ displayName: "Valid",
216
+ documentationUrl: "https://example.com/docs",
217
+ verifiedAt: "2026-09-10",
218
+ verification: "documentation-only",
219
+ setup: { mode: "manual", instructions: "Paste a key." },
220
+ logo: "valid.png",
221
+ },
222
+ },
223
+ ],
224
+ }),
225
+ });
226
+
227
+ expect(catalog.matches.map((m) => m.name)).toEqual(["snake-case", "valid"]);
228
+ expect(catalog.matches[0]).toMatchObject({
229
+ name: "snake-case",
230
+ description: "kept",
231
+ });
232
+ expect(catalog.matches[0]?.integration).toBeUndefined();
233
+ expect(catalog.matches[1]?.integration?.displayName).toBe("Valid");
234
+ });
235
+
172
236
  test("throws when fetch rejects (network error / abort)", async () => {
173
237
  const promise = fetchPluginCatalogFromPlatform({
174
238
  fetch: (async () => {
@@ -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;
@@ -176,16 +176,16 @@
176
176
  "icon": "\ud83d\udcbb"
177
177
  },
178
178
  {
179
- "name": "ecommerce-wismo-supporter",
179
+ "name": "ecommerce-order-supporter",
180
180
  "description": "Resolves ecommerce Where-Is-My-Order (WISMO) support inquiries, cross-checks real-time carrier tracking milestones, flags delivery exceptions, and stages customer update drafts without helpdesk seat taxes.",
181
181
  "category": "commerce",
182
182
  "icon": "📦",
183
183
  "license": "MIT",
184
- "homepage": "https://github.com/ZeebBoyBlue/ecommerce-wismo-supporter",
184
+ "homepage": "https://github.com/ZeebBoyBlue/ecommerce-order-supporter",
185
185
  "source": {
186
186
  "source": "github",
187
- "repo": "ZeebBoyBlue/ecommerce-wismo-supporter",
188
- "ref": "9218c23d325c749500d59b6ae651f38264a43cd1"
187
+ "repo": "ZeebBoyBlue/ecommerce-order-supporter",
188
+ "ref": "35e2165a14447a897aee750a198c3f166458226b"
189
189
  }
190
190
  },
191
191
  {
@@ -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,18 +25,35 @@ 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
- /** Add bundled local packages without overriding platform-authoritative rows. */
32
+ /**
33
+ * Add bundled local packages without overriding platform-authoritative rows.
34
+ * A platform row without an integration borrows the bundled row's
35
+ * integration of the same name, so an integration the platform omits or
36
+ * serves malformed never disappears from the catalog.
37
+ */
30
38
  export function mergePlatformCatalogWithBundledLocals(
31
39
  platform: PluginCatalog,
32
40
  bundledLocal: PluginCatalog = readBundledLocalPluginCatalog(),
33
41
  ): PluginCatalog {
42
+ const bundledByName = new Map(
43
+ bundledLocal.matches.map((match) => [match.name, match]),
44
+ );
45
+ const platformMatches = platform.matches.map((match) => {
46
+ const bundledIntegration = bundledByName.get(match.name)?.integration;
47
+ if (match.integration || !bundledIntegration) {
48
+ return match;
49
+ }
50
+ return { ...match, integration: bundledIntegration };
51
+ });
34
52
  const seen = new Set(platform.matches.map((match) => match.name));
35
53
  return {
36
54
  ref: platform.ref,
37
55
  matches: [
38
- ...platform.matches,
56
+ ...platformMatches,
39
57
  ...bundledLocal.matches.filter((match) => !seen.has(match.name)),
40
58
  ].sort((a, b) => a.name.localeCompare(b.name)),
41
59
  };
@@ -50,9 +68,9 @@ export function mergePlatformCatalogWithBundledLocals(
50
68
  * fetch failure propagates so the caller can surface it (e.g. map a rate-limit
51
69
  * to 503) — no stale catalog is ever served.
52
70
  */
53
- export async function getPluginCatalog(
71
+ async function loadPluginCatalog(
54
72
  ref: string,
55
- deps: SearchPluginsDeps,
73
+ deps: PluginCatalogLoadDeps,
56
74
  ): Promise<PluginCatalog> {
57
75
  if (!arePlatformFeaturesEnabled()) {
58
76
  return { ...readBundledPluginCatalog(), ref };
@@ -69,6 +87,25 @@ export async function getPluginCatalog(
69
87
  return merged;
70
88
  }
71
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
+
72
109
  /** Invalidate the cache (for testing or forced refresh). */
73
110
  export function invalidatePluginCatalogCache(): void {
74
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
  }