@vellumai/assistant 0.12.2-staging.2 → 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.
- package/docs/desktop-browser-cli.md +33 -0
- package/openapi.yaml +17 -5
- package/package.json +1 -1
- package/scripts/smoke-desktop-browser-cli.ts +279 -0
- package/src/__tests__/config-schema.test.ts +1 -1
- package/src/__tests__/config-sounds-sync.test.ts +23 -0
- package/src/__tests__/conversation-error.test.ts +4 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +6 -4
- package/src/__tests__/external-plugin-loader.test.ts +52 -0
- package/src/__tests__/headless-browser-mode.test.ts +48 -0
- package/src/__tests__/host-cu-proxy.test.ts +116 -18
- package/src/__tests__/list-all-apps.test.ts +40 -8
- package/src/__tests__/mcp-auth-routes.test.ts +99 -0
- package/src/__tests__/mcp-client-auth.test.ts +54 -0
- package/src/__tests__/mcp-health-check.test.ts +32 -32
- package/src/__tests__/mcp-list-plugin-servers.test.ts +77 -32
- package/src/__tests__/mcp-tool-annotations-risk.test.ts +4 -2
- package/src/__tests__/skills.test.ts +10 -0
- package/src/__tests__/user-plugin-loader.test.ts +21 -0
- package/src/apps/app-store.ts +6 -5
- package/src/browser/operations.ts +2 -1
- package/src/browser/types.ts +7 -0
- package/src/browser/virtual-desktop-target.ts +34 -0
- package/src/calls/__tests__/progress-narration.test.ts +23 -0
- package/src/calls/__tests__/voice-control-protocol.test.ts +7 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +62 -0
- package/src/calls/progress-narration.ts +15 -2
- package/src/calls/voice-control-protocol.ts +34 -4
- package/src/calls/voice-session-bridge.ts +23 -0
- package/src/cli/commands/__tests__/browser.test.ts +84 -3
- package/src/cli/commands/browser.help.ts +43 -0
- package/src/cli/commands/browser.ts +52 -8
- package/src/cli/commands/plugins.ts +16 -5
- package/src/cli/lib/__tests__/install-from-github.test.ts +111 -0
- package/src/cli/lib/__tests__/install-from-platform.test.ts +77 -2
- package/src/cli/lib/__tests__/list-installed-plugins.test.ts +45 -3
- package/src/cli/lib/__tests__/plugin-details.test.ts +26 -0
- package/src/cli/lib/__tests__/uninstall-plugin.test.ts +122 -5
- package/src/cli/lib/bundled-marketplace.json +13 -0
- package/src/cli/lib/install-from-github.ts +17 -18
- package/src/cli/lib/install-from-platform.ts +14 -9
- package/src/cli/lib/list-installed-plugins.ts +21 -35
- package/src/cli/lib/plugin-details.ts +21 -8
- package/src/cli/lib/uninstall-plugin.ts +59 -1
- package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +21 -0
- package/src/config/bundled-skills/computer-use/SKILL.md +30 -0
- package/src/config/bundled-skills/computer-use/TOOLS.json +2 -2
- package/src/config/bundled-skills/screen-annotation/SKILL.md +2 -2
- package/src/config/feature-flag-registry.json +2 -2
- package/src/config/schemas/__tests__/voice.test.ts +2 -2
- package/src/config/schemas/mcp.ts +9 -7
- package/src/config/schemas/monitoring.ts +9 -0
- package/src/config/schemas/voice.ts +2 -2
- package/src/config/skills.ts +13 -21
- package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +47 -0
- package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +1 -0
- package/src/daemon/conversation-client-surface.ts +26 -0
- package/src/daemon/conversation-runtime-assembly.ts +1 -1
- package/src/daemon/conversation-tool-setup.ts +31 -29
- package/src/daemon/host-cu-proxy.ts +51 -26
- package/src/daemon/host-proxy-preactivation.ts +2 -0
- package/src/daemon/mcp-reload-service.ts +7 -0
- package/src/daemon/message-types/sync.ts +1 -0
- package/src/daemon/providers-setup.ts +4 -0
- package/src/desktop/__tests__/fake-desktop.ts +1 -0
- package/src/desktop/desktop-automation-lease.test.ts +208 -0
- package/src/desktop/desktop-automation-lease.ts +270 -0
- package/src/desktop/desktop-browser-client.test.ts +413 -0
- package/src/desktop/desktop-browser-client.ts +430 -0
- package/src/desktop/desktop-browser-cursor.ts +40 -0
- package/src/desktop/desktop-browser-endpoint.test.ts +78 -0
- package/src/desktop/desktop-browser-endpoint.ts +145 -0
- package/src/desktop/desktop-browser-operations.ts +130 -0
- package/src/desktop/desktop-chrome-session.test.ts +48 -0
- package/src/desktop/desktop-chrome-session.ts +58 -8
- package/src/desktop/desktop-dependencies.test.ts +29 -0
- package/src/desktop/desktop-dependencies.ts +26 -5
- package/src/desktop/desktop-display.ts +9 -0
- package/src/desktop/desktop-panel-config.ts +14 -1
- package/src/desktop/desktop-session-manager.test.ts +86 -3
- package/src/desktop/desktop-session-manager.ts +144 -24
- package/src/desktop/desktop-stream-bridge.ts +1 -1
- package/src/desktop/virtual-desktop-feature.ts +18 -0
- package/src/desktop/virtual-desktop-platform.test.ts +61 -0
- package/src/i18n/__tests__/i18n.test.ts +9 -5
- package/src/i18n/messages.ts +13 -4
- package/src/ipc/__tests__/browser-ipc.test.ts +230 -2
- package/src/ipc/__tests__/cancel-on-disconnect.test.ts +54 -0
- package/src/ipc/assistant-server.ts +15 -1
- package/src/ipc/cli-client.ts +13 -5
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +22 -0
- package/src/live-voice/__tests__/live-voice-events.test.ts +3 -2
- package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +19 -0
- package/src/live-voice/__tests__/protocol.test.ts +11 -3
- package/src/live-voice/__tests__/session-controls.test.ts +34 -0
- package/src/live-voice/live-voice-metrics.ts +9 -0
- package/src/live-voice/live-voice-session.ts +3 -0
- package/src/live-voice/protocol.ts +10 -1
- package/src/live-voice/session-controls.ts +28 -0
- package/src/mcp/__tests__/credential-target.test.ts +59 -0
- package/src/mcp/__tests__/effective-config.test.ts +11 -0
- package/src/mcp/__tests__/manager-state.test.ts +128 -0
- package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +20 -6
- package/src/mcp/__tests__/mcp-auth-state-target.test.ts +49 -0
- package/src/mcp/__tests__/plugin-mcp-oauth-cleanup.test.ts +107 -0
- package/src/mcp/__tests__/plugin-mcp-oauth-provider-isolation.test.ts +89 -0
- package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +63 -11
- package/src/mcp/__tests__/reload-signal-emission.test.ts +39 -2
- package/src/mcp/client.ts +64 -16
- package/src/mcp/credential-target.ts +105 -0
- package/src/mcp/effective-config.ts +42 -15
- package/src/mcp/manager.ts +61 -8
- package/src/mcp/mcp-auth-orchestrator.ts +17 -4
- package/src/mcp/mcp-auth-state.ts +38 -6
- package/src/mcp/mcp-oauth-provider.ts +101 -33
- package/src/monitoring/__tests__/mount-watch.test.ts +236 -0
- package/src/monitoring/mount-watch.ts +348 -0
- package/src/monitoring/worker.ts +9 -0
- package/src/oauth/seed-providers.ts +11 -0
- package/src/onboarding/checkin-event.ts +2 -1
- package/src/plugins/__tests__/installed-plugin-dirs.test.ts +14 -1
- package/src/plugins/__tests__/mcp-servers.test.ts +38 -8
- package/src/plugins/external-plugin-loader.ts +49 -86
- package/src/plugins/installed-plugin-dirs.ts +6 -4
- package/src/plugins/mcp-servers.ts +9 -15
- package/src/plugins/mtime-cache.ts +2 -1
- package/src/plugins/user-loader.ts +2 -1
- package/src/providers/inference/__tests__/endpoint-probe.test.ts +45 -0
- package/src/providers/inference/endpoint-probe.ts +27 -12
- package/src/providers/openai/responses-provider.ts +9 -2
- package/src/providers/opencode/client.test.ts +167 -0
- package/src/providers/opencode/client.ts +73 -4
- package/src/runtime/AGENTS.md +4 -0
- package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +45 -2
- package/src/runtime/http-server.ts +6 -6
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
- package/src/runtime/routes/browser-context.ts +39 -0
- package/src/runtime/routes/browser-routes.ts +16 -47
- package/src/runtime/routes/browser-tabs-routes.ts +72 -29
- package/src/runtime/routes/desktop-setup-routes.test.ts +16 -1
- package/src/runtime/routes/desktop-setup-routes.ts +7 -5
- package/src/runtime/routes/mcp-auth-routes.ts +106 -94
- package/src/runtime/routes/plugins-routes.ts +14 -1
- package/src/runtime/sync/resource-sync-events.ts +4 -0
- package/src/tools/browser/browser-execution.ts +44 -13
- package/src/tools/browser/cdp-client/__tests__/factory.test.ts +69 -0
- package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +1 -0
- package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +5 -0
- package/src/tools/computer-use/definitions.ts +2 -3
- package/src/util/abort-reasons.ts +12 -1
- package/src/util/plugin-manifest.ts +203 -0
- package/src/desktop/desktop-feature.test.ts +0 -18
- package/src/desktop/desktop-feature.ts +0 -19
|
@@ -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 {
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
//
|
|
864
|
-
//
|
|
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 (
|
|
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
|
|
1561
|
-
* missing or malformed manifest
|
|
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
|
|
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
|
|
1571
|
-
if (
|
|
1572
|
-
|
|
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:
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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:
|
|
187
|
-
version:
|
|
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
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
//
|
|
247
|
-
|
|
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 {
|
|
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",
|
|
@@ -37,6 +37,36 @@ The tree is walked to a limited depth to keep steps fast, and says when it was
|
|
|
37
37
|
cut off. If the element you need is not in it, call `computer_use_observe` with
|
|
38
38
|
`full_tree: true`.
|
|
39
39
|
|
|
40
|
+
## Scripting apps (macOS)
|
|
41
|
+
|
|
42
|
+
Reach for `computer_use_run_applescript` first when an app can be driven by
|
|
43
|
+
script. It does not take the cursor.
|
|
44
|
+
|
|
45
|
+
**Try the app's own scripting dictionary before anything else.** Ask it for the
|
|
46
|
+
state you want, not for the clicks that would produce that state. Finder, Mail,
|
|
47
|
+
Music, Notes, Safari, Terminal and many third-party apps have one, and where
|
|
48
|
+
there is a dictionary the whole task is usually a sentence with no window to
|
|
49
|
+
open, no tree to read and nothing to click:
|
|
50
|
+
|
|
51
|
+
```applescript
|
|
52
|
+
tell application "Finder"
|
|
53
|
+
set target of front window to desktop
|
|
54
|
+
set current view of front window to list view
|
|
55
|
+
set sort column of list view options of front window to name column
|
|
56
|
+
end tell
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**System Events menu clicking is the fallback**, for apps with no dictionary
|
|
60
|
+
entry for what you need: `click menu item "Split Clip" of menu "Modify" of menu
|
|
61
|
+
bar 1` inside `tell application "System Events" to tell process "iMovie"`. It is
|
|
62
|
+
UI automation in a script's clothes: it still depends on the menu sitting where
|
|
63
|
+
you expect and on the app being frontmost. A menu item that needs a selection or
|
|
64
|
+
a playhead position does nothing when that context is missing, so set it up
|
|
65
|
+
first, and read `enabled of menu item` when unsure.
|
|
66
|
+
|
|
67
|
+
Click and type for everything a script cannot reach. `host_bash` is for shell
|
|
68
|
+
commands, not for driving apps.
|
|
69
|
+
|
|
40
70
|
## Typing is not sending
|
|
41
71
|
|
|
42
72
|
Pressing enter in a chat, email or form usually sends or submits it, and that
|
|
@@ -269,7 +269,7 @@
|
|
|
269
269
|
},
|
|
270
270
|
{
|
|
271
271
|
"name": "computer_use_run_applescript",
|
|
272
|
-
"description": "Run an AppleScript
|
|
272
|
+
"description": "Run an AppleScript on the Mac. Try the target app's own scripting dictionary first, asking it for the state you want rather than for the clicks that would produce it; fall back to System Events menu clicking for apps with no dictionary entry for what you need. Prefer this over click and type when the app supports it; it does not move the cursor. The result is the script's return value, and the accessibility tree may not change even when the script worked. Never use 'do shell script' inside AppleScript (blocked for security).",
|
|
273
273
|
"category": "computer-use",
|
|
274
274
|
"risk": "medium",
|
|
275
275
|
"input_schema": {
|
|
@@ -281,7 +281,7 @@
|
|
|
281
281
|
},
|
|
282
282
|
"reasoning": {
|
|
283
283
|
"type": "string",
|
|
284
|
-
"description": "
|
|
284
|
+
"description": "What this script does"
|
|
285
285
|
},
|
|
286
286
|
"target_client_id": {
|
|
287
287
|
"type": "string",
|
|
@@ -8,11 +8,11 @@ metadata:
|
|
|
8
8
|
display-name: "Screen Annotation"
|
|
9
9
|
category: "system"
|
|
10
10
|
activation-hints:
|
|
11
|
-
- "User asks where
|
|
11
|
+
- "User asks where a control is, or to be walked through doing something themselves, in an app they share"
|
|
12
12
|
- "User wants to be shown, not have it done for them"
|
|
13
13
|
- "The answer is a place on the user's screen"
|
|
14
14
|
avoid-when:
|
|
15
|
-
- "User
|
|
15
|
+
- "User asks the assistant to do it (click, type, edit), even on a call: use computer-use"
|
|
16
16
|
- "Nothing is being shared, so there is no surface to point at"
|
|
17
17
|
---
|
|
18
18
|
|