@vellumai/assistant 0.12.2-staging.1 → 0.12.2-staging.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/docs/desktop-browser-cli.md +33 -0
  2. package/node_modules/@vellumai/slack-text/src/index.ts +13 -8
  3. package/node_modules/@vellumai/slack-text/src/label-resolution-entities.test.ts +95 -0
  4. package/openapi.yaml +50 -4
  5. package/package.json +1 -1
  6. package/scripts/smoke-desktop-browser-cli.ts +279 -0
  7. package/src/__tests__/always-loaded-tools-guard.test.ts +5 -5
  8. package/src/__tests__/config-schema.test.ts +1 -1
  9. package/src/__tests__/config-sounds-sync.test.ts +23 -0
  10. package/src/__tests__/conversation-error.test.ts +4 -1
  11. package/src/__tests__/conversation-runtime-assembly.test.ts +33 -3
  12. package/src/__tests__/conversation-surfaces-point-at-budget.test.ts +1 -0
  13. package/src/__tests__/conversation-surfaces-point-at-capability.test.ts +1 -0
  14. package/src/__tests__/credential-routes.test.ts +38 -0
  15. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  16. package/src/__tests__/cu-unified-flow.test.ts +6 -2
  17. package/src/__tests__/external-plugin-loader.test.ts +52 -0
  18. package/src/__tests__/headless-browser-mode.test.ts +48 -0
  19. package/src/__tests__/host-cu-proxy.test.ts +185 -30
  20. package/src/__tests__/list-all-apps.test.ts +40 -8
  21. package/src/__tests__/mcp-auth-routes.test.ts +99 -0
  22. package/src/__tests__/mcp-client-auth.test.ts +54 -0
  23. package/src/__tests__/mcp-health-check.test.ts +32 -32
  24. package/src/__tests__/mcp-list-plugin-servers.test.ts +77 -32
  25. package/src/__tests__/mcp-tool-annotations-risk.test.ts +4 -2
  26. package/src/__tests__/oauth-commands-routes.test.ts +55 -0
  27. package/src/__tests__/oauth-provider-serializer.test.ts +22 -0
  28. package/src/__tests__/oauth-providers-routes.test.ts +1 -0
  29. package/src/__tests__/secret-routes-acp-guard.test.ts +59 -1
  30. package/src/__tests__/skills.test.ts +10 -0
  31. package/src/__tests__/subagent-tool-gate-mode.test.ts +51 -0
  32. package/src/__tests__/ui-channel-variants.test.ts +1 -56
  33. package/src/__tests__/user-plugin-loader.test.ts +21 -0
  34. package/src/acp/__tests__/acp-claude-oauth.test.ts +257 -6
  35. package/src/acp/__tests__/acp-credentials.test.ts +13 -0
  36. package/src/acp/__tests__/claude-token-refresh.test.ts +257 -0
  37. package/src/acp/__tests__/prepare-agent-env.test.ts +60 -1
  38. package/src/acp/acp-claude-oauth.ts +328 -14
  39. package/src/acp/acp-credentials.ts +19 -0
  40. package/src/acp/claude-token-refresh.ts +150 -0
  41. package/src/acp/prepare-agent-env.ts +21 -6
  42. package/src/apps/app-store.ts +6 -5
  43. package/src/browser/operations.ts +2 -1
  44. package/src/browser/types.ts +7 -0
  45. package/src/browser/virtual-desktop-target.ts +34 -0
  46. package/src/calls/__tests__/progress-narration.test.ts +23 -0
  47. package/src/calls/__tests__/voice-control-protocol.test.ts +69 -0
  48. package/src/calls/__tests__/voice-session-bridge.test.ts +81 -0
  49. package/src/calls/progress-narration.ts +15 -2
  50. package/src/calls/voice-control-protocol.ts +132 -0
  51. package/src/calls/voice-session-bridge.ts +56 -26
  52. package/src/calls/voice-triage-escalate.ts +3 -3
  53. package/src/cli/commands/__tests__/browser.test.ts +84 -3
  54. package/src/cli/commands/browser.help.ts +43 -0
  55. package/src/cli/commands/browser.ts +52 -8
  56. package/src/cli/commands/oauth/status.ts +5 -0
  57. package/src/cli/commands/plugins.ts +16 -5
  58. package/src/cli/lib/__tests__/install-from-github.test.ts +111 -0
  59. package/src/cli/lib/__tests__/install-from-platform.test.ts +77 -2
  60. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +45 -3
  61. package/src/cli/lib/__tests__/plugin-details.test.ts +26 -0
  62. package/src/cli/lib/__tests__/uninstall-plugin.test.ts +122 -5
  63. package/src/cli/lib/bundled-marketplace.json +13 -0
  64. package/src/cli/lib/install-from-github.ts +17 -18
  65. package/src/cli/lib/install-from-platform.ts +14 -9
  66. package/src/cli/lib/list-installed-plugins.ts +21 -35
  67. package/src/cli/lib/plugin-details.ts +21 -8
  68. package/src/cli/lib/uninstall-plugin.ts +59 -1
  69. package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +21 -0
  70. package/src/config/bundled-skills/computer-use/SKILL.md +43 -4
  71. package/src/config/bundled-skills/computer-use/TOOLS.json +3 -3
  72. package/src/config/bundled-skills/screen-annotation/SKILL.md +2 -2
  73. package/src/config/feature-flag-registry.json +11 -3
  74. package/src/config/loader.ts +1 -0
  75. package/src/config/schemas/__tests__/voice.test.ts +2 -2
  76. package/src/config/schemas/mcp.ts +9 -7
  77. package/src/config/schemas/monitoring.ts +9 -0
  78. package/src/config/schemas/services.ts +10 -0
  79. package/src/config/schemas/voice.ts +2 -2
  80. package/src/config/skills.ts +13 -21
  81. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +47 -0
  82. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +1 -0
  83. package/src/daemon/conversation-client-surface.ts +26 -0
  84. package/src/daemon/conversation-runtime-assembly.ts +19 -7
  85. package/src/daemon/conversation-surfaces.ts +6 -1
  86. package/src/daemon/conversation-tool-setup.ts +37 -43
  87. package/src/daemon/host-cu-proxy.ts +94 -31
  88. package/src/daemon/host-proxy-preactivation.ts +2 -0
  89. package/src/daemon/mcp-reload-service.ts +7 -0
  90. package/src/daemon/message-types/sync.ts +1 -0
  91. package/src/daemon/providers-setup.ts +4 -0
  92. package/src/desktop/__tests__/fake-desktop.ts +1 -0
  93. package/src/desktop/desktop-automation-lease.test.ts +208 -0
  94. package/src/desktop/desktop-automation-lease.ts +270 -0
  95. package/src/desktop/desktop-browser-client.test.ts +413 -0
  96. package/src/desktop/desktop-browser-client.ts +430 -0
  97. package/src/desktop/desktop-browser-cursor.ts +40 -0
  98. package/src/desktop/desktop-browser-endpoint.test.ts +78 -0
  99. package/src/desktop/desktop-browser-endpoint.ts +145 -0
  100. package/src/desktop/desktop-browser-operations.ts +130 -0
  101. package/src/desktop/desktop-chrome-session.test.ts +48 -0
  102. package/src/desktop/desktop-chrome-session.ts +58 -8
  103. package/src/desktop/desktop-dependencies.test.ts +29 -0
  104. package/src/desktop/desktop-dependencies.ts +26 -5
  105. package/src/desktop/desktop-display.ts +9 -0
  106. package/src/desktop/desktop-panel-config.ts +14 -1
  107. package/src/desktop/desktop-session-manager.test.ts +86 -3
  108. package/src/desktop/desktop-session-manager.ts +144 -24
  109. package/src/desktop/desktop-stream-bridge.ts +1 -1
  110. package/src/desktop/virtual-desktop-feature.ts +18 -0
  111. package/src/desktop/virtual-desktop-platform.test.ts +61 -0
  112. package/src/i18n/__tests__/i18n.test.ts +9 -5
  113. package/src/i18n/messages.ts +13 -4
  114. package/src/ipc/__tests__/browser-ipc.test.ts +230 -2
  115. package/src/ipc/__tests__/cancel-on-disconnect.test.ts +54 -0
  116. package/src/ipc/assistant-server.ts +15 -1
  117. package/src/ipc/cli-client.ts +13 -5
  118. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +143 -0
  119. package/src/live-voice/__tests__/live-voice-events.test.ts +9 -7
  120. package/src/live-voice/__tests__/live-voice-progress.test.ts +79 -0
  121. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +19 -0
  122. package/src/live-voice/__tests__/protocol.test.ts +47 -0
  123. package/src/live-voice/__tests__/session-controls.test.ts +113 -0
  124. package/src/live-voice/live-voice-metrics.ts +9 -0
  125. package/src/live-voice/live-voice-session.ts +88 -7
  126. package/src/live-voice/protocol.ts +69 -0
  127. package/src/live-voice/session-controls.ts +139 -0
  128. package/src/mcp/__tests__/credential-target.test.ts +59 -0
  129. package/src/mcp/__tests__/effective-config.test.ts +11 -0
  130. package/src/mcp/__tests__/manager-state.test.ts +128 -0
  131. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +20 -6
  132. package/src/mcp/__tests__/mcp-auth-state-target.test.ts +49 -0
  133. package/src/mcp/__tests__/plugin-mcp-oauth-cleanup.test.ts +107 -0
  134. package/src/mcp/__tests__/plugin-mcp-oauth-provider-isolation.test.ts +89 -0
  135. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +63 -11
  136. package/src/mcp/__tests__/reload-signal-emission.test.ts +39 -2
  137. package/src/mcp/client.ts +64 -16
  138. package/src/mcp/credential-target.ts +105 -0
  139. package/src/mcp/effective-config.ts +42 -15
  140. package/src/mcp/manager.ts +61 -8
  141. package/src/mcp/mcp-auth-orchestrator.ts +17 -4
  142. package/src/mcp/mcp-auth-state.ts +38 -6
  143. package/src/mcp/mcp-oauth-provider.ts +101 -33
  144. package/src/monitoring/__tests__/mount-watch.test.ts +236 -0
  145. package/src/monitoring/mount-watch.ts +348 -0
  146. package/src/monitoring/worker.ts +9 -0
  147. package/src/oauth/__tests__/seed-providers-managed.test.ts +95 -0
  148. package/src/oauth/connection-resolver.test.ts +27 -0
  149. package/src/oauth/connection-resolver.ts +25 -1
  150. package/src/oauth/provider-serializer.ts +9 -0
  151. package/src/oauth/seed-providers.ts +121 -2
  152. package/src/onboarding/checkin-event.ts +2 -1
  153. package/src/plugins/__tests__/installed-plugin-dirs.test.ts +14 -1
  154. package/src/plugins/__tests__/mcp-servers.test.ts +38 -8
  155. package/src/plugins/external-plugin-loader.ts +49 -86
  156. package/src/plugins/installed-plugin-dirs.ts +6 -4
  157. package/src/plugins/mcp-servers.ts +9 -15
  158. package/src/plugins/mtime-cache.ts +2 -1
  159. package/src/plugins/user-loader.ts +2 -1
  160. package/src/providers/inference/__tests__/endpoint-probe.test.ts +45 -0
  161. package/src/providers/inference/endpoint-probe.ts +27 -12
  162. package/src/providers/openai/responses-provider.ts +9 -2
  163. package/src/providers/opencode/client.test.ts +167 -0
  164. package/src/providers/opencode/client.ts +73 -4
  165. package/src/runtime/AGENTS.md +4 -0
  166. package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +45 -2
  167. package/src/runtime/http-server.ts +6 -6
  168. package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +16 -5
  169. package/src/runtime/routes/__tests__/apps-refresh-route.test.ts +74 -4
  170. package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
  171. package/src/runtime/routes/acp-claude-auth-routes.ts +11 -3
  172. package/src/runtime/routes/app-management-routes.ts +17 -2
  173. package/src/runtime/routes/browser-context.ts +39 -0
  174. package/src/runtime/routes/browser-routes.ts +16 -47
  175. package/src/runtime/routes/browser-tabs-routes.ts +72 -29
  176. package/src/runtime/routes/credential-routes.ts +1 -4
  177. package/src/runtime/routes/desktop-setup-routes.test.ts +16 -1
  178. package/src/runtime/routes/desktop-setup-routes.ts +7 -5
  179. package/src/runtime/routes/mcp-auth-routes.ts +106 -94
  180. package/src/runtime/routes/oauth-commands-routes.ts +14 -0
  181. package/src/runtime/routes/oauth-providers.ts +7 -0
  182. package/src/runtime/routes/plugins-routes.ts +14 -1
  183. package/src/runtime/sync/resource-sync-events.ts +4 -0
  184. package/src/tools/browser/browser-execution.ts +44 -13
  185. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +69 -0
  186. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +1 -0
  187. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +5 -0
  188. package/src/tools/computer-use/definitions.ts +3 -4
  189. package/src/tools/ui-surface/channel-variants.ts +10 -59
  190. package/src/util/abort-reasons.ts +12 -1
  191. package/src/util/plugin-manifest.ts +203 -0
  192. package/src/watch/watch-retro.ts +6 -8
  193. package/src/desktop/desktop-feature.test.ts +0 -18
  194. package/src/desktop/desktop-feature.ts +0 -19
@@ -93,6 +93,46 @@ describe("listInstalledPlugins", () => {
93
93
  expect(result[0]!.packageJson?.icon).toBe("🚀");
94
94
  });
95
95
 
96
+ test("lists metadata from a standard-only plugin.json", () => {
97
+ mkdirSync(join(pluginsDir, "standard"));
98
+ writeFileSync(
99
+ join(pluginsDir, "standard", "plugin.json"),
100
+ JSON.stringify({
101
+ $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
102
+ name: "standard",
103
+ version: "3.0.0",
104
+ description: "standard plugin",
105
+ }),
106
+ );
107
+
108
+ const result = listInstalledPlugins({ workspacePluginsDir: pluginsDir });
109
+
110
+ expect(result[0]!.packageJson).toEqual({
111
+ name: "standard",
112
+ version: "3.0.0",
113
+ description: "standard plugin",
114
+ peerDependencies: undefined,
115
+ });
116
+ expect(result[0]!.issues).toEqual([]);
117
+ });
118
+
119
+ test("does not validate an unselected plugin.json beside package.json", () => {
120
+ mkdirSync(join(pluginsDir, "legacy"));
121
+ writeFileSync(
122
+ join(pluginsDir, "legacy", "package.json"),
123
+ JSON.stringify({ name: "legacy", version: "1.0.0" }),
124
+ );
125
+ writeFileSync(
126
+ join(pluginsDir, "legacy", "plugin.json"),
127
+ "{ foreign and malformed }",
128
+ );
129
+
130
+ const result = listInstalledPlugins({ workspacePluginsDir: pluginsDir });
131
+
132
+ expect(result[0]!.packageJson?.version).toBe("1.0.0");
133
+ expect(result[0]!.issues).toEqual([]);
134
+ });
135
+
96
136
  test.each([
97
137
  ["missing vellum block", { name: "p", version: "1.0.0" }],
98
138
  ["non-string icon", { name: "p", version: "1.0.0", vellum: { icon: 42 } }],
@@ -120,7 +160,9 @@ describe("listInstalledPlugins", () => {
120
160
  const result = listInstalledPlugins({ workspacePluginsDir: pluginsDir });
121
161
  expect(result).toHaveLength(1);
122
162
  expect(result[0]!.packageJson).toBeNull();
123
- expect(result[0]!.issues).toEqual(["missing package.json"]);
163
+ expect(result[0]!.issues[0]).toContain(
164
+ "missing package.json and plugin.json",
165
+ );
124
166
  });
125
167
 
126
168
  test("reports malformed JSON as an issue rather than failing", () => {
@@ -130,7 +172,7 @@ describe("listInstalledPlugins", () => {
130
172
  const result = listInstalledPlugins({ workspacePluginsDir: pluginsDir });
131
173
  expect(result).toHaveLength(1);
132
174
  expect(result[0]!.packageJson).toBeNull();
133
- expect(result[0]!.issues[0]).toMatch(/invalid JSON/);
175
+ expect(result[0]!.issues[0]).toContain("could not be read or parsed");
134
176
  });
135
177
 
136
178
  test("reports non-object package.json as an issue", () => {
@@ -143,7 +185,7 @@ describe("listInstalledPlugins", () => {
143
185
  const result = listInstalledPlugins({ workspacePluginsDir: pluginsDir });
144
186
  expect(result).toHaveLength(1);
145
187
  expect(result[0]!.packageJson).toBeNull();
146
- expect(result[0]!.issues).toContain("package.json is not an object");
188
+ expect(result[0]!.issues[0]).toContain("failed schema validation");
147
189
  });
148
190
 
149
191
  test("skips hidden entries and non-directories", () => {
@@ -302,6 +302,32 @@ describe("getPluginDetails (bundled catalog, offline)", () => {
302
302
  expect(details.icon).toBeNull();
303
303
  });
304
304
 
305
+ test("reads metadata from an installed standard-only plugin.json", async () => {
306
+ const target = join(workspace, "caveman");
307
+ mkdirSync(target, { recursive: true });
308
+ writeFileSync(
309
+ join(target, "plugin.json"),
310
+ JSON.stringify({
311
+ $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
312
+ name: "caveman",
313
+ version: "4.0.0",
314
+ description: "Installed standard plugin",
315
+ homepage: "https://example.com/plugin",
316
+ license: "MIT",
317
+ }),
318
+ );
319
+
320
+ const details = await getPluginDetails(
321
+ { name: "caveman" },
322
+ { fetch: makeFetch({}), workspacePluginsDir: workspace },
323
+ );
324
+
325
+ expect(details.version).toBe("4.0.0");
326
+ expect(details.description).toBe("Installed standard plugin");
327
+ expect(details.homepage).toBe("https://example.com/plugin");
328
+ expect(details.license).toBe("MIT");
329
+ });
330
+
305
331
  test("surfaces the installed copy's vellum.icon", async () => {
306
332
  // GIVEN an installed copy whose package.json declares vellum.icon
307
333
  const target = join(workspace, "caveman");
@@ -21,15 +21,34 @@ import {
21
21
  } from "node:fs";
22
22
  import { tmpdir } from "node:os";
23
23
  import { join } from "node:path";
24
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
24
+ import {
25
+ afterEach,
26
+ beforeEach,
27
+ describe,
28
+ expect,
29
+ jest,
30
+ mock,
31
+ test,
32
+ } from "bun:test";
33
+
34
+ const deletePluginMcpOAuthCredentials = jest.fn(async () => ({
35
+ ok: true,
36
+ unreachable: false,
37
+ matchedKeys: [] as string[],
38
+ failedKeys: [] as string[],
39
+ }));
40
+
41
+ mock.module("../../../mcp/mcp-oauth-provider.js", () => ({
42
+ deletePluginMcpOAuthCredentials,
43
+ }));
25
44
 
26
45
  import { resetHookCacheForTests } from "../../../hooks/hook-loader.js";
27
46
  import { getWorkspacePluginsDir } from "../../../util/platform.js";
28
47
  import { InvalidPluginNameError } from "../install-from-github.js";
29
- import {
30
- PluginNotInstalledError,
31
- uninstallPlugin,
32
- } from "../uninstall-plugin.js";
48
+ const { PluginNotInstalledError, uninstallPlugin } =
49
+ await import("../uninstall-plugin.js");
50
+ const { PLUGIN_UNINSTALL_WARNING_KEYS } =
51
+ await import("../uninstall-plugin.js");
33
52
 
34
53
  let pluginsDir: string;
35
54
 
@@ -38,6 +57,13 @@ beforeEach(() => {
38
57
  rmSync(pluginsDir, { recursive: true, force: true });
39
58
  mkdirSync(pluginsDir, { recursive: true });
40
59
  resetHookCacheForTests();
60
+ deletePluginMcpOAuthCredentials.mockReset();
61
+ deletePluginMcpOAuthCredentials.mockImplementation(async () => ({
62
+ ok: true,
63
+ unreachable: false,
64
+ matchedKeys: [],
65
+ failedKeys: [],
66
+ }));
41
67
  });
42
68
 
43
69
  afterEach(() => {
@@ -164,6 +190,97 @@ describe("uninstallPlugin", () => {
164
190
  expect(existsSync(marker)).toBe(false);
165
191
  });
166
192
 
193
+ test("preserves the plugin when credential deletion fails", async () => {
194
+ const target = writePlugin("cleanup-fails");
195
+ deletePluginMcpOAuthCredentials.mockImplementationOnce(async () => ({
196
+ ok: false,
197
+ unreachable: false,
198
+ matchedKeys: ["mcp-plugin/v1/key"],
199
+ failedKeys: ["mcp-plugin/v1/key"],
200
+ }));
201
+
202
+ await expect(
203
+ uninstallPlugin({
204
+ name: "cleanup-fails",
205
+ workspacePluginsDir: pluginsDir,
206
+ }),
207
+ ).rejects.toThrow("MCP OAuth credentials could not be deleted");
208
+ expect(existsSync(target)).toBe(true);
209
+ });
210
+
211
+ test("preserves an MCP plugin when credential storage is unavailable", async () => {
212
+ const target = writePlugin("offline-mcp");
213
+ writeFileSync(join(target, "mcp.json"), JSON.stringify({ mcpServers: {} }));
214
+ deletePluginMcpOAuthCredentials.mockImplementationOnce(async () => ({
215
+ ok: false,
216
+ unreachable: true,
217
+ matchedKeys: [],
218
+ failedKeys: [],
219
+ }));
220
+
221
+ await expect(
222
+ uninstallPlugin({
223
+ name: "offline-mcp",
224
+ workspacePluginsDir: pluginsDir,
225
+ }),
226
+ ).rejects.toThrow("credential storage is unavailable");
227
+ expect(existsSync(target)).toBe(true);
228
+ });
229
+
230
+ test("uses the install fingerprint as MCP evidence when the file was removed", async () => {
231
+ const target = writePlugin("recorded-mcp");
232
+ writeFileSync(
233
+ join(target, "install-meta.json"),
234
+ JSON.stringify({
235
+ name: "recorded-mcp",
236
+ source: {
237
+ kind: "github",
238
+ owner: "example",
239
+ repo: "plugin",
240
+ ref: "main",
241
+ },
242
+ fingerprint: {
243
+ algorithm: "sha256",
244
+ files: { "mcp.json": "digest" },
245
+ },
246
+ }),
247
+ );
248
+ deletePluginMcpOAuthCredentials.mockImplementationOnce(async () => ({
249
+ ok: false,
250
+ unreachable: true,
251
+ matchedKeys: [],
252
+ failedKeys: [],
253
+ }));
254
+
255
+ await expect(
256
+ uninstallPlugin({
257
+ name: "recorded-mcp",
258
+ workspacePluginsDir: pluginsDir,
259
+ }),
260
+ ).rejects.toThrow("credential storage is unavailable");
261
+ expect(existsSync(target)).toBe(true);
262
+ });
263
+
264
+ test("removes a non-MCP plugin offline and returns a visible warning", async () => {
265
+ const target = writePlugin("offline-simple");
266
+ deletePluginMcpOAuthCredentials.mockImplementationOnce(async () => ({
267
+ ok: false,
268
+ unreachable: true,
269
+ matchedKeys: [],
270
+ failedKeys: [],
271
+ }));
272
+
273
+ const result = await uninstallPlugin({
274
+ name: "offline-simple",
275
+ workspacePluginsDir: pluginsDir,
276
+ });
277
+
278
+ expect(existsSync(target)).toBe(false);
279
+ expect(result.warnings).toEqual([
280
+ PLUGIN_UNINSTALL_WARNING_KEYS.MCP_OAUTH_CREDENTIALS_UNCHECKED,
281
+ ]);
282
+ });
283
+
167
284
  test.each([
168
285
  ["../escape"],
169
286
  ["/abs/path"],
@@ -135,6 +135,19 @@
135
135
  "homepage": "https://github.com/topoteretes/cognee-integrations/tree/main/integrations/vellum-assistant",
136
136
  "icon": "\ud83d\udd78\ufe0f"
137
137
  },
138
+ {
139
+ "name": "contractor-voice-invoicer",
140
+ "description": "Converts 20-second truck voice memos and job photos into itemized client invoices with custom labor rates, material markups, and one-click accounting staging.",
141
+ "category": "commerce",
142
+ "icon": "🔧",
143
+ "license": "MIT",
144
+ "homepage": "https://github.com/ZeebBoyBlue/contractor-voice-invoicer",
145
+ "source": {
146
+ "source": "github",
147
+ "repo": "ZeebBoyBlue/contractor-voice-invoicer",
148
+ "ref": "31e21ea1aa90b3ba462ed7208988931411226daa"
149
+ }
150
+ },
138
151
  {
139
152
  "name": "creatorland",
140
153
  "source": {
@@ -52,6 +52,10 @@ import {
52
52
  getWorkspacePluginsDir,
53
53
  isWindows,
54
54
  } from "../../util/platform.js";
55
+ import {
56
+ getPluginManifestInstallAction,
57
+ readPluginManifest,
58
+ } from "../../util/plugin-manifest.js";
55
59
  import type { FetchLike } from "./fetch-like.js";
56
60
  import {
57
61
  type DependencyInstaller,
@@ -860,16 +864,18 @@ export async function materializePluginTree(
860
864
  // stub for it, overlay the stub and run its transform so the materialized
861
865
  // tree is a valid Vellum plugin. Raw clones (no stub) are left untouched,
862
866
  // except for a minimal package.json synthesis when the upstream repo shipped
863
- // none — the Vellum loader hard-requires one and would silently skip the
864
- // plugin without it. The synthesis is deterministic (name + fixed version +
865
- // fixed peer-dep range), so it produces the same bytes on initial install
867
+ // no recognized manifest. The synthesis is deterministic (name + fixed
868
+ // version + fixed peer-dep range), so it produces the same bytes on initial install
866
869
  // and upgrade re-materialization; the fingerprint is computed after
867
870
  // materialization, so the synthesized file is present in both baselines and
868
871
  // the comparison stays clean.
869
872
  if (cloned.fileCount > 0 && opts.stubRef !== null) {
870
873
  await applyAdapterStub(opts.name, opts.stubRef, opts.destDir, deps);
871
874
  }
872
- if (cloned.fileCount > 0 && !existsSync(join(opts.destDir, "package.json"))) {
875
+ if (
876
+ cloned.fileCount > 0 &&
877
+ getPluginManifestInstallAction(opts.destDir) === "synthesize-legacy"
878
+ ) {
873
879
  synthesizeMinimalPackageJson(opts.name, opts.destDir);
874
880
  }
875
881
  return cloned;
@@ -1557,22 +1563,15 @@ interface WriteInstallMetaParams {
1557
1563
  }
1558
1564
 
1559
1565
  /**
1560
- * Read the `version` field from a staged plugin's `package.json`. Lenient — a
1561
- * missing or malformed manifest simply yields `undefined` so provenance is
1566
+ * Read the `version` field from a staged plugin's selected manifest. A
1567
+ * missing or malformed manifest yields `undefined` so provenance is
1562
1568
  * recorded without it rather than failing the install.
1563
1569
  */
1564
- function readStagedPackageVersion(stagingDir: string): string | undefined {
1565
- const pkgPath = join(stagingDir, "package.json");
1566
- if (!existsSync(pkgPath)) {
1567
- return undefined;
1568
- }
1570
+ function readStagedManifestVersion(stagingDir: string): string | undefined {
1569
1571
  try {
1570
- const parsed: unknown = JSON.parse(readFileSync(pkgPath, "utf8"));
1571
- if (typeof parsed === "object" && parsed !== null) {
1572
- const version = (parsed as Record<string, unknown>).version;
1573
- if (typeof version === "string" && version.length > 0) {
1574
- return version;
1575
- }
1572
+ const version = readPluginManifest(stagingDir).version;
1573
+ if (version && version.length > 0) {
1574
+ return version;
1576
1575
  }
1577
1576
  } catch {
1578
1577
  // fall through to undefined
@@ -1602,7 +1601,7 @@ function writeInstallMeta(
1602
1601
  const meta: InstallMeta = {
1603
1602
  origin: "vellum",
1604
1603
  installedAt: new Date().toISOString(),
1605
- version: readStagedPackageVersion(stagingDir),
1604
+ version: readStagedManifestVersion(stagingDir),
1606
1605
  sourceRepo: `${source.owner}/${source.repo}`,
1607
1606
  contentHash,
1608
1607
  author: "user",
@@ -8,9 +8,9 @@
8
8
  * and extracts it into the plugin's install directory — it does NOT clone the
9
9
  * plugin from GitHub itself, and it does NOT emit `plugin_installed` telemetry
10
10
  * (hitting this endpoint *is* the recorded install; the server rejects that
11
- * event type on the usual ingest path). A tarball that ships no
12
- * `package.json` gets the same synthesized manifest the GitHub clone path
13
- * writes, so the loader still registers the plugin's skills.
11
+ * event type on the usual ingest path). A tarball that ships neither supported
12
+ * manifest gets the same synthesized manifest the GitHub clone path writes, so
13
+ * the loader still registers the plugin's skills.
14
14
  *
15
15
  * GET {PLATFORM_BASE_URL}/v1/plugins/{name}/install/
16
16
  *
@@ -36,6 +36,7 @@ import {
36
36
  import { getPlatformBaseUrl } from "../../config/env.js";
37
37
  import { getExistingDeviceId } from "../../util/device-id.js";
38
38
  import { getWorkspacePluginsDir } from "../../util/platform.js";
39
+ import { getPluginManifestInstallAction } from "../../util/plugin-manifest.js";
39
40
  import { APP_VERSION } from "../../version.js";
40
41
  import type { FetchLike } from "./fetch-like.js";
41
42
  import {
@@ -205,12 +206,16 @@ export async function installPluginFromPlatform(
205
206
  throw new PluginNotFoundError(name, meta.ref ?? "", meta.repo ?? name);
206
207
  }
207
208
 
208
- // Same default as the GitHub clone path: a marketplace tarball that
209
- // ships skills/mcp.json but no Vellum package.json still has to load.
210
- // The loader skips a directory with no manifest, so synthesize one
211
- // before the fingerprint and swap.
212
- if (!existsSync(join(stagingDir, "package.json"))) {
213
- synthesizeMinimalPackageJson(name, stagingDir);
209
+ // Same default as the GitHub clone path: a marketplace tarball without a
210
+ // recognized manifest still has to load. A claimed standard manifest is
211
+ // validated before the fingerprint and swap.
212
+ try {
213
+ if (getPluginManifestInstallAction(stagingDir) === "synthesize-legacy") {
214
+ synthesizeMinimalPackageJson(name, stagingDir);
215
+ }
216
+ } catch (err) {
217
+ rmSync(stagingDir, { recursive: true, force: true });
218
+ throw err;
214
219
  }
215
220
 
216
221
  await confirmStagedOrAbort(name, stagingDir, deps.confirmStaged);
@@ -17,6 +17,10 @@ import { dirname, join } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
18
 
19
19
  import { getWorkspacePluginsDir } from "../../util/platform.js";
20
+ import {
21
+ LEGACY_PLUGIN_MANIFEST,
22
+ readPluginManifest,
23
+ } from "../../util/plugin-manifest.js";
20
24
  import { parsePluginIcon } from "./plugin-artifact.js";
21
25
  import { readValidatedPluginIcon } from "./plugin-icon-file.js";
22
26
 
@@ -51,10 +55,13 @@ export interface InstalledPluginInfo {
51
55
  readonly name: string;
52
56
  /** Absolute path to the plugin directory. */
53
57
  readonly target: string;
54
- /** Parsed `package.json` content, when present and parseable. */
58
+ /**
59
+ * Metadata from the selected manifest. The API exposes this field as
60
+ * `packageJson`.
61
+ */
55
62
  readonly packageJson: PluginPackageMetadata | null;
56
63
  /**
57
- * Non-fatal issues with this entry (missing `package.json`, malformed
64
+ * Non-fatal issues with this entry (missing or malformed selected manifest,
58
65
  * JSON, unexpected type, etc.). Empty when the entry parses cleanly.
59
66
  */
60
67
  readonly issues: readonly string[];
@@ -143,51 +150,30 @@ function readPluginEntry(
143
150
  name: string,
144
151
  ): InstalledPluginInfo {
145
152
  const target = join(pluginsDir, name);
146
- const pkgJsonPath = join(target, "package.json");
147
153
  const issues: string[] = [];
148
154
 
149
155
  // Icon validation is independent of package.json parsing, so resolve it
150
156
  // once and attach to every return below (including error paths).
151
157
  const iconFields = pluginIconFields(target);
152
158
 
153
- if (!existsSync(pkgJsonPath)) {
154
- issues.push("missing package.json");
155
- return { name, target, packageJson: null, issues, ...iconFields };
156
- }
157
-
158
- let raw: string;
159
- try {
160
- raw = readFileSync(pkgJsonPath, "utf8");
161
- } catch (err) {
162
- issues.push(
163
- `package.json unreadable: ${err instanceof Error ? err.message : String(err)}`,
164
- );
165
- return { name, target, packageJson: null, issues, ...iconFields };
166
- }
167
-
168
- let parsed: unknown;
159
+ let manifest: ReturnType<typeof readPluginManifest>;
169
160
  try {
170
- parsed = JSON.parse(raw);
161
+ manifest = readPluginManifest(target);
171
162
  } catch (err) {
172
- issues.push(
173
- `package.json invalid JSON: ${err instanceof Error ? err.message : String(err)}`,
174
- );
163
+ issues.push(err instanceof Error ? err.message : String(err));
175
164
  return { name, target, packageJson: null, issues, ...iconFields };
176
165
  }
177
-
178
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
179
- issues.push("package.json is not an object");
180
- return { name, target, packageJson: null, issues, ...iconFields };
181
- }
182
-
183
- const meta = parsed as Record<string, unknown>;
184
- const icon = parsePluginIcon(meta);
166
+ const meta = manifest.raw;
167
+ const icon =
168
+ manifest.source === LEGACY_PLUGIN_MANIFEST
169
+ ? parsePluginIcon(meta)
170
+ : undefined;
185
171
  const packageJson: PluginPackageMetadata = {
186
- name: typeof meta.name === "string" ? meta.name : undefined,
187
- version: typeof meta.version === "string" ? meta.version : undefined,
188
- description:
189
- typeof meta.description === "string" ? meta.description : undefined,
172
+ name: manifest.name,
173
+ version: manifest.version,
174
+ description: manifest.description,
190
175
  peerDependencies:
176
+ manifest.source === LEGACY_PLUGIN_MANIFEST &&
191
177
  typeof meta.peerDependencies === "object" &&
192
178
  meta.peerDependencies !== null &&
193
179
  !Array.isArray(meta.peerDependencies)
@@ -33,6 +33,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
33
33
  import { join } from "node:path";
34
34
 
35
35
  import { getWorkspacePluginsDir } from "../../util/platform.js";
36
+ import { readPluginManifest } from "../../util/plugin-manifest.js";
36
37
  import type { FetchLike } from "./fetch-like.js";
37
38
  import { sanitizePluginName } from "./install-from-github.js";
38
39
  import {
@@ -230,22 +231,34 @@ interface LocalPlugin {
230
231
  readonly readme: string | null;
231
232
  }
232
233
 
233
- /** Read an installed copy's `package.json` + README off disk, if present. */
234
+ /** Read an installed copy's selected manifest and README off disk, if present. */
234
235
  function readLocalPlugin(pluginsDir: string, name: string): LocalPlugin {
235
236
  const target = join(pluginsDir, name);
236
237
  if (!existsSync(target)) {
237
238
  return { installed: false, manifest: emptyManifest(), readme: null };
238
239
  }
239
240
 
240
- const pkgPath = join(target, "package.json");
241
241
  let manifest = emptyManifest();
242
- if (existsSync(pkgPath)) {
243
- try {
244
- manifest = parseManifest(readFileSync(pkgPath, "utf8"));
245
- } catch {
246
- // A malformed local manifest degrades to empty fields — the entry is
247
- // still "installed", we just have nothing extra to surface from it.
242
+ try {
243
+ const packagePath = join(target, "package.json");
244
+ if (existsSync(packagePath)) {
245
+ // Detail metadata is intentionally lenient. An installed legacy package
246
+ // may omit the loader-required name while still carrying useful fields.
247
+ manifest = parseManifest(readFileSync(packagePath, "utf8"));
248
+ } else {
249
+ const selected = readPluginManifest(target);
250
+ manifest = {
251
+ version: selected.version ?? null,
252
+ description: selected.description ?? null,
253
+ homepage: selected.homepage ?? null,
254
+ license: normalizeLicense(selected.license),
255
+ artifact: null,
256
+ icon: null,
257
+ };
248
258
  }
259
+ } catch {
260
+ // A malformed local manifest degrades to empty fields. The entry is still
261
+ // installed, but it has no manifest metadata to surface.
249
262
  }
250
263
 
251
264
  return { installed: true, manifest, readme: readLocalReadme(target) };
@@ -15,10 +15,13 @@ import { existsSync, rmSync, statSync } from "node:fs";
15
15
  import { join } from "node:path";
16
16
 
17
17
  import { runShutdownHook } from "../../hooks/hook-loader.js";
18
+ import { MESSAGE_KEYS, t } from "../../i18n/index.js";
18
19
  import { isPluginDisabled } from "../../plugins/disabled-state.js";
20
+ import { PLUGIN_MCP_MANIFEST } from "../../plugins/mcp-servers.js";
19
21
  import { getWorkspacePluginsDir } from "../../util/platform.js";
20
22
  import {
21
23
  InvalidPluginNameError,
24
+ readInstallMeta,
22
25
  sanitizePluginName,
23
26
  } from "./install-from-github.js";
24
27
 
@@ -40,11 +43,27 @@ export interface UninstallPluginOptions {
40
43
  readonly workspacePluginsDir?: string;
41
44
  }
42
45
 
46
+ export const PLUGIN_UNINSTALL_WARNING_KEYS = {
47
+ MCP_OAUTH_CREDENTIALS_UNCHECKED:
48
+ MESSAGE_KEYS.PLUGIN_MCP_OAUTH_CREDENTIALS_UNCHECKED,
49
+ } as const;
50
+
51
+ export type PluginUninstallWarningKey =
52
+ (typeof PLUGIN_UNINSTALL_WARNING_KEYS)[keyof typeof PLUGIN_UNINSTALL_WARNING_KEYS];
53
+
54
+ export function resolvePluginUninstallWarning(
55
+ key: PluginUninstallWarningKey,
56
+ ): string {
57
+ return t(key);
58
+ }
59
+
43
60
  /** Result of a successful uninstall. */
44
61
  export interface UninstallPluginResult {
45
62
  readonly name: string;
46
63
  /** Absolute path that was removed. */
47
64
  readonly target: string;
65
+ /** Stable keys for non-fatal cleanup limitations. */
66
+ readonly warnings?: PluginUninstallWarningKey[];
48
67
  }
49
68
 
50
69
  /**
@@ -87,6 +106,41 @@ export async function uninstallPlugin(
87
106
  throw new PluginNotInstalledError(name, target);
88
107
  }
89
108
 
109
+ const hasCurrentMcpManifest = existsSync(join(target, PLUGIN_MCP_MANIFEST));
110
+ const recordedFiles = readInstallMeta(target)?.fingerprint?.files;
111
+ const hasRecordedMcpManifest =
112
+ recordedFiles !== undefined &&
113
+ Object.hasOwn(recordedFiles, PLUGIN_MCP_MANIFEST);
114
+ const warnings: PluginUninstallWarningKey[] = [];
115
+ const { deletePluginMcpOAuthCredentials } =
116
+ await import("../../mcp/mcp-oauth-provider.js");
117
+ let credentialsReachable = true;
118
+ let cleanup:
119
+ | Awaited<ReturnType<typeof deletePluginMcpOAuthCredentials>>
120
+ | undefined;
121
+ try {
122
+ cleanup = await deletePluginMcpOAuthCredentials(name);
123
+ credentialsReachable = !cleanup.unreachable;
124
+ } catch {
125
+ credentialsReachable = false;
126
+ }
127
+ if (cleanup && !cleanup.unreachable && !cleanup.ok) {
128
+ throw new Error(
129
+ `Plugin "${name}" was not removed because its MCP OAuth credentials could not be deleted.`,
130
+ );
131
+ }
132
+
133
+ if (!credentialsReachable) {
134
+ if (hasCurrentMcpManifest || hasRecordedMcpManifest) {
135
+ throw new Error(
136
+ `Plugin "${name}" was not removed because credential storage is unavailable and its MCP OAuth credentials could not be checked.`,
137
+ );
138
+ }
139
+ warnings.push(
140
+ PLUGIN_UNINSTALL_WARNING_KEYS.MCP_OAUTH_CREDENTIALS_UNCHECKED,
141
+ );
142
+ }
143
+
90
144
  // Skip the shutdown hook when the plugin is disabled. A `.disabled` plugin
91
145
  // is never loaded — no hooks, tools, or init — so its shutdown was never
92
146
  // paired with an init. Running it on uninstall would be the first and only
@@ -98,7 +152,11 @@ export async function uninstallPlugin(
98
152
  }
99
153
 
100
154
  rmSync(target, { recursive: true, force: true });
101
- return { name, target };
155
+ return {
156
+ name,
157
+ target,
158
+ ...(warnings.length > 0 && { warnings }),
159
+ };
102
160
  }
103
161
 
104
162
  export { InvalidPluginNameError };
@@ -94,6 +94,27 @@ describe("discoverPluginResidentSkills (via loadSkillCatalog)", () => {
94
94
  expect(skill!.owner).toEqual({ kind: "plugin", id: "demo-matched" });
95
95
  });
96
96
 
97
+ test("surfaces a skill from a standard-only plugin", () => {
98
+ const pluginDir = join(getWorkspacePluginsDir(), "standard-plugin");
99
+ const skillDir = join(pluginDir, "skills", "qa-standard-skill");
100
+ mkdirSync(skillDir, { recursive: true });
101
+ writeFileSync(
102
+ join(pluginDir, "plugin.json"),
103
+ JSON.stringify({
104
+ $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
105
+ name: "standard-plugin",
106
+ }),
107
+ );
108
+ writeFileSync(
109
+ join(skillDir, "SKILL.md"),
110
+ "---\nname: qa-standard-skill\ndescription: Standard skill.\n---\n\nBody.\n",
111
+ );
112
+
113
+ const skill = skillById("qa-standard-skill");
114
+
115
+ expect(skill?.owner).toEqual({ kind: "plugin", id: "standard-plugin" });
116
+ });
117
+
97
118
  test("skips a plugin directory that has no package.json", () => {
98
119
  writePlugin(
99
120
  "no-manifest",