@vellumai/assistant 0.12.5-dev.202609261222.09a30ca → 0.12.5-dev.202609261415.2575c70
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/openapi.yaml +2 -0
- package/package.json +1 -1
- package/src/__tests__/headless-browser-navigate.test.ts +20 -30
- package/src/api/events/tool-result.ts +1 -0
- package/src/cli/lib/__tests__/plugin-artifact.test.ts +23 -0
- package/src/cli/lib/__tests__/plugin-details.test.ts +16 -0
- package/src/cli/lib/inspect-plugin.ts +15 -15
- package/src/cli/lib/list-installed-plugins.ts +29 -28
- package/src/cli/lib/plugin-artifact.ts +74 -64
- package/src/cli/lib/plugin-details.ts +27 -42
- package/src/cli/lib/plugin-marketplace.ts +6 -8
- package/src/cli/lib/plugin-surfaces.ts +8 -7
- package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +43 -1
- package/src/daemon/handlers/config-a2a.ts +75 -2
- package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
- package/src/providers/search-provider-catalog.ts +10 -0
- package/src/runtime/assistant-event-hub.ts +23 -0
- package/src/runtime/auth/__tests__/guard-tests.test.ts +0 -7
- package/src/runtime/auth/__tests__/route-policy.test.ts +1 -1
- package/src/runtime/pending-interactions.ts +20 -2
- package/src/runtime/routes/host-app-control-routes.ts +1 -0
- package/src/runtime/routes/host-bash-routes.ts +1 -0
- package/src/runtime/routes/host-browser-routes.ts +15 -0
- package/src/runtime/routes/host-cu-routes.ts +1 -0
- package/src/runtime/routes/host-file-routes.ts +1 -0
- package/src/runtime/routes/host-proxy-result-binding.test.ts +83 -0
- package/src/runtime/routes/host-proxy-result-binding.ts +25 -0
- package/src/runtime/routes/host-transfer-routes.ts +1 -0
- package/src/runtime/routes/integrations/a2a.ts +4 -1
- package/src/runtime/routes/ui-snapshot-routes.test.ts +1 -0
- package/src/tools/browser/browser-execution.ts +14 -18
- package/src/tools/network/__tests__/web-search.test.ts +105 -0
- package/src/tools/network/web-search.ts +228 -4
- package/src/util/plugin-manifest.ts +5 -7
package/openapi.yaml
CHANGED
|
@@ -21572,6 +21572,7 @@ paths:
|
|
|
21572
21572
|
- searxng
|
|
21573
21573
|
- tinyfish
|
|
21574
21574
|
- exa
|
|
21575
|
+
- serply
|
|
21575
21576
|
resultCount:
|
|
21576
21577
|
type: number
|
|
21577
21578
|
durationMs:
|
|
@@ -22191,6 +22192,7 @@ paths:
|
|
|
22191
22192
|
- searxng
|
|
22192
22193
|
- tinyfish
|
|
22193
22194
|
- exa
|
|
22195
|
+
- serply
|
|
22194
22196
|
resultCount:
|
|
22195
22197
|
type: number
|
|
22196
22198
|
durationMs:
|
package/package.json
CHANGED
|
@@ -233,6 +233,7 @@ describe("executeBrowserNavigate", () => {
|
|
|
233
233
|
resetMockPage();
|
|
234
234
|
resetCdp();
|
|
235
235
|
__resetPinnedTabsForTests();
|
|
236
|
+
mockExtensionAvailable = false;
|
|
236
237
|
});
|
|
237
238
|
|
|
238
239
|
// ── Input validation ───────────────────────────────────────────
|
|
@@ -606,6 +607,13 @@ describe("executeBrowserNavigate", () => {
|
|
|
606
607
|
const routeCallsBefore = mockPage.route.mock.calls.length;
|
|
607
608
|
const unrouteCallsBefore = mockPage.unroute.mock.calls.length;
|
|
608
609
|
|
|
610
|
+
cdpSendHandler = (method, params) => {
|
|
611
|
+
if (method === "Vellum.createTab") {
|
|
612
|
+
return { tabId: "42" };
|
|
613
|
+
}
|
|
614
|
+
return defaultCdpHandler(method, params);
|
|
615
|
+
};
|
|
616
|
+
|
|
609
617
|
const result = await executeBrowserNavigate(
|
|
610
618
|
{ url: "https://example.com/page" },
|
|
611
619
|
extensionCtx,
|
|
@@ -643,6 +651,9 @@ describe("executeBrowserNavigate", () => {
|
|
|
643
651
|
// navigateAndWait returns a private final URL (simulating a
|
|
644
652
|
// server-side redirect).
|
|
645
653
|
cdpSendHandler = (method, params) => {
|
|
654
|
+
if (method === "Vellum.createTab") {
|
|
655
|
+
return { tabId: "42" };
|
|
656
|
+
}
|
|
646
657
|
if (method === "Page.navigate") {
|
|
647
658
|
return { frameId: "f1" };
|
|
648
659
|
}
|
|
@@ -745,13 +756,7 @@ describe("executeBrowserNavigate", () => {
|
|
|
745
756
|
expect(cdpDisposed).toBe(true);
|
|
746
757
|
});
|
|
747
758
|
|
|
748
|
-
test("new_tab: true on extension path with no tabId in response
|
|
749
|
-
// Defensive: dispatcher returns success but no tabId. The
|
|
750
|
-
// executor logs a warn, resets the live cdp session to undefined
|
|
751
|
-
// (so the follow-on Page.navigate routes to the active tab
|
|
752
|
-
// instead of any stale pin the cdp instance was constructed
|
|
753
|
-
// with), and proceeds. The navigate still runs (degraded
|
|
754
|
-
// behaviour but not a hard failure).
|
|
759
|
+
test("new_tab: true on extension path with no tabId in response fails closed", async () => {
|
|
755
760
|
parseUrlResult = new URL("https://example.com/page");
|
|
756
761
|
mockExtensionAvailable = true;
|
|
757
762
|
|
|
@@ -767,25 +772,13 @@ describe("executeBrowserNavigate", () => {
|
|
|
767
772
|
{ ...ctx },
|
|
768
773
|
);
|
|
769
774
|
|
|
770
|
-
expect(result.isError).toBe(
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
expect(cdpSetSessionIdCalls).toEqual([undefined]);
|
|
775
|
-
// Page.navigate still ran.
|
|
776
|
-
expect(cdpSendCalls.some((c) => c.method === "Page.navigate")).toBe(true);
|
|
775
|
+
expect(result.isError).toBe(true);
|
|
776
|
+
expect(String(result.content)).toContain("returned no tabId");
|
|
777
|
+
expect(cdpSetSessionIdCalls).toEqual([]);
|
|
778
|
+
expect(cdpSendCalls.some((c) => c.method === "Page.navigate")).toBe(false);
|
|
777
779
|
});
|
|
778
780
|
|
|
779
|
-
test("new_tab: true with no tabId in response clears a pre-existing stale pin
|
|
780
|
-
// Regression for the Codex round-2 findings (P2 + round-3 P1):
|
|
781
|
-
// when Vellum.createTab returns a malformed response with no
|
|
782
|
-
// tabId, the executor falls back to active-tab routing — but
|
|
783
|
-
// (a) the pin store still held the stale pin (round-2 P2 fix
|
|
784
|
-
// added clearPinnedTab), and (b) the LIVE cdp instance was
|
|
785
|
-
// already constructed with that stale cdpSessionId, so the
|
|
786
|
-
// follow-on Page.navigate would still route to the dead tab
|
|
787
|
-
// unless we reset the session on the cdp instance too (round-3
|
|
788
|
-
// P1 fix added cdp.setCdpSessionId(undefined)).
|
|
781
|
+
test("new_tab: true with no tabId in response clears a pre-existing stale pin and fails closed", async () => {
|
|
789
782
|
const { setPinnedTab, getPinnedTab } =
|
|
790
783
|
await import("../tools/browser/pinned-tabs.js");
|
|
791
784
|
setPinnedTab(ctx.conversationId, "stale-pinned-tab-id");
|
|
@@ -806,12 +799,9 @@ describe("executeBrowserNavigate", () => {
|
|
|
806
799
|
{ ...ctx },
|
|
807
800
|
);
|
|
808
801
|
|
|
809
|
-
expect(result.isError).toBe(
|
|
810
|
-
// (a) Pin store cleared.
|
|
802
|
+
expect(result.isError).toBe(true);
|
|
811
803
|
expect(getPinnedTab(ctx.conversationId)).toBeUndefined();
|
|
812
|
-
|
|
813
|
-
// setCdpSessionId arg; expect exactly one call with undefined).
|
|
814
|
-
expect(cdpSetSessionIdCalls).toEqual([undefined]);
|
|
804
|
+
expect(cdpSetSessionIdCalls).toEqual([]);
|
|
815
805
|
});
|
|
816
806
|
|
|
817
807
|
test("new_tab: true on LOCAL path is a no-op (Playwright manages its own isolated browser)", async () => {
|
|
@@ -935,7 +925,7 @@ describe("executeBrowserNavigate", () => {
|
|
|
935
925
|
false,
|
|
936
926
|
);
|
|
937
927
|
expect(getPinnedTab(ctx.conversationId)).toBeUndefined();
|
|
938
|
-
expect(cdpSetSessionIdCalls).toEqual([
|
|
928
|
+
expect(cdpSetSessionIdCalls).toEqual(["active"]);
|
|
939
929
|
expect(cdpSendCalls.some((c) => c.method === "Page.navigate")).toBe(true);
|
|
940
930
|
});
|
|
941
931
|
|
|
@@ -123,6 +123,23 @@ describe("parsePluginArtifact", () => {
|
|
|
123
123
|
});
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
+
test("ignores a malformed icon beside a valid artifact", () => {
|
|
127
|
+
expect(
|
|
128
|
+
parsePluginArtifact({
|
|
129
|
+
vellum: {
|
|
130
|
+
artifact: {
|
|
131
|
+
url: "https://example.com/App.dmg",
|
|
132
|
+
sha256: VALID_SHA,
|
|
133
|
+
},
|
|
134
|
+
icon: 42,
|
|
135
|
+
},
|
|
136
|
+
}),
|
|
137
|
+
).toEqual({
|
|
138
|
+
url: "https://example.com/App.dmg",
|
|
139
|
+
sha256: VALID_SHA,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
126
143
|
test.each([
|
|
127
144
|
["no vellum block", { name: "x" }],
|
|
128
145
|
["no artifact block", { vellum: {} }],
|
|
@@ -202,6 +219,12 @@ describe("parsePluginIcon", () => {
|
|
|
202
219
|
expect(parsePluginIcon({ vellum: { icon: sixteen } })).toBe(sixteen);
|
|
203
220
|
});
|
|
204
221
|
|
|
222
|
+
test("ignores a malformed artifact beside a valid icon", () => {
|
|
223
|
+
expect(
|
|
224
|
+
parsePluginIcon({ vellum: { artifact: "App.dmg", icon: "🚀" } }),
|
|
225
|
+
).toBe("🚀");
|
|
226
|
+
});
|
|
227
|
+
|
|
205
228
|
test.each([
|
|
206
229
|
["no vellum block", { name: "x" }],
|
|
207
230
|
["no icon field", { vellum: {} }],
|
|
@@ -309,6 +309,22 @@ describe("getPluginDetails (bundled catalog, offline)", () => {
|
|
|
309
309
|
expect(details.icon).toBeNull();
|
|
310
310
|
});
|
|
311
311
|
|
|
312
|
+
test("reads a legacy object-form license from an installed package.json", async () => {
|
|
313
|
+
const target = join(workspace, "caveman");
|
|
314
|
+
mkdirSync(target, { recursive: true });
|
|
315
|
+
writeFileSync(
|
|
316
|
+
join(target, "package.json"),
|
|
317
|
+
JSON.stringify({ version: "2.0.0", license: { type: "MIT" } }),
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
const details = await getPluginDetails(
|
|
321
|
+
{ name: "caveman" },
|
|
322
|
+
{ fetch: makeFetch({}), workspacePluginsDir: workspace },
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
expect(details.license).toBe("MIT");
|
|
326
|
+
});
|
|
327
|
+
|
|
312
328
|
test("reads metadata from an installed standard-only plugin.json", async () => {
|
|
313
329
|
const target = join(workspace, "caveman");
|
|
314
330
|
mkdirSync(target, { recursive: true });
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* is a thin wrapper that supplies production deps and formats the result.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
|
|
19
21
|
import { PRESERVED_ENTRIES } from "../../plugins/plugin-tree-walk.js";
|
|
20
22
|
import type { FetchLike } from "./fetch-like.js";
|
|
21
23
|
import {
|
|
@@ -50,6 +52,14 @@ import type { PluginCatalog, PluginSearchMatch } from "./search-plugins.js";
|
|
|
50
52
|
/** Full commit SHA (40 hex SHA-1 or 64 hex SHA-256). */
|
|
51
53
|
const FULL_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
|
|
52
54
|
|
|
55
|
+
const GitHubCommitResponseSchema = z.object({
|
|
56
|
+
commit: z.object({
|
|
57
|
+
committer: z.object({
|
|
58
|
+
date: z.string(),
|
|
59
|
+
}),
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
|
|
53
63
|
/**
|
|
54
64
|
* Drift classification between the installed copy and the marketplace pin.
|
|
55
65
|
*
|
|
@@ -294,23 +304,13 @@ export async function fetchCommitDate(
|
|
|
294
304
|
if (!res.ok) {
|
|
295
305
|
return null;
|
|
296
306
|
}
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const commit = (json as Record<string, unknown>).commit;
|
|
302
|
-
if (typeof commit !== "object" || commit === null) {
|
|
303
|
-
return null;
|
|
304
|
-
}
|
|
305
|
-
const committer = (commit as Record<string, unknown>).committer;
|
|
306
|
-
if (typeof committer !== "object" || committer === null) {
|
|
307
|
-
return null;
|
|
308
|
-
}
|
|
309
|
-
const date = (committer as Record<string, unknown>).date;
|
|
310
|
-
if (typeof date !== "string") {
|
|
307
|
+
const parsed = GitHubCommitResponseSchema.safeParse(
|
|
308
|
+
JSON.parse(await res.text()),
|
|
309
|
+
);
|
|
310
|
+
if (!parsed.success) {
|
|
311
311
|
return null;
|
|
312
312
|
}
|
|
313
|
-
const ms = Date.parse(date);
|
|
313
|
+
const ms = Date.parse(parsed.data.commit.committer.date);
|
|
314
314
|
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
|
315
315
|
} catch {
|
|
316
316
|
return null;
|
|
@@ -16,12 +16,14 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
|
16
16
|
import { dirname, join } from "node:path";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
|
|
19
21
|
import { getWorkspacePluginsDir } from "../../util/platform.js";
|
|
20
22
|
import {
|
|
21
23
|
LEGACY_PLUGIN_MANIFEST,
|
|
22
24
|
readPluginManifest,
|
|
23
25
|
} from "../../util/plugin-manifest.js";
|
|
24
|
-
import {
|
|
26
|
+
import { PluginPackageJsonSchema } from "./plugin-artifact.js";
|
|
25
27
|
import { readValidatedPluginIcon } from "./plugin-icon-file.js";
|
|
26
28
|
|
|
27
29
|
/**
|
|
@@ -39,6 +41,15 @@ const DEFAULT_PLUGINS_DIR = join(
|
|
|
39
41
|
"defaults",
|
|
40
42
|
);
|
|
41
43
|
|
|
44
|
+
const DefaultPluginManifestSchema = z.object({
|
|
45
|
+
name: z.string(),
|
|
46
|
+
version: z.string().optional().catch(undefined),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const InstallMetadataSchema = z.object({
|
|
50
|
+
installedAt: z.string(),
|
|
51
|
+
});
|
|
52
|
+
|
|
42
53
|
/** Minimal manifest fields surfaced to the CLI. */
|
|
43
54
|
export interface PluginPackageMetadata {
|
|
44
55
|
readonly name?: string;
|
|
@@ -163,23 +174,17 @@ function readPluginEntry(
|
|
|
163
174
|
issues.push(err instanceof Error ? err.message : String(err));
|
|
164
175
|
return { name, target, packageJson: null, issues, ...iconFields };
|
|
165
176
|
}
|
|
166
|
-
const
|
|
167
|
-
const
|
|
168
|
-
manifest.source === LEGACY_PLUGIN_MANIFEST
|
|
169
|
-
?
|
|
170
|
-
:
|
|
177
|
+
const parsedPackageJson = PluginPackageJsonSchema.safeParse(manifest.raw);
|
|
178
|
+
const legacyMetadata =
|
|
179
|
+
manifest.source === LEGACY_PLUGIN_MANIFEST && parsedPackageJson.success
|
|
180
|
+
? parsedPackageJson.data
|
|
181
|
+
: null;
|
|
171
182
|
const packageJson: PluginPackageMetadata = {
|
|
172
183
|
name: manifest.name,
|
|
173
184
|
version: manifest.version,
|
|
174
185
|
description: manifest.description,
|
|
175
|
-
peerDependencies:
|
|
176
|
-
|
|
177
|
-
typeof meta.peerDependencies === "object" &&
|
|
178
|
-
meta.peerDependencies !== null &&
|
|
179
|
-
!Array.isArray(meta.peerDependencies)
|
|
180
|
-
? (meta.peerDependencies as Record<string, string>)
|
|
181
|
-
: undefined,
|
|
182
|
-
...(icon ? { icon } : {}),
|
|
186
|
+
peerDependencies: legacyMetadata?.peerDependencies,
|
|
187
|
+
...(legacyMetadata?.icon ? { icon: legacyMetadata.icon } : {}),
|
|
183
188
|
};
|
|
184
189
|
|
|
185
190
|
return { name, target, packageJson, issues, ...iconFields };
|
|
@@ -319,14 +324,11 @@ function readDefaultPluginManifests(): readonly DefaultPluginManifest[] {
|
|
|
319
324
|
continue;
|
|
320
325
|
}
|
|
321
326
|
try {
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
version:
|
|
328
|
-
typeof parsed.version === "string" ? parsed.version : undefined,
|
|
329
|
-
});
|
|
327
|
+
const parsed = DefaultPluginManifestSchema.safeParse(
|
|
328
|
+
JSON.parse(readFileSync(pkgJsonPath, "utf8")),
|
|
329
|
+
);
|
|
330
|
+
if (parsed.success) {
|
|
331
|
+
manifests.push(parsed.data);
|
|
330
332
|
}
|
|
331
333
|
} catch {
|
|
332
334
|
// Skip malformed entries — lenient like listInstalledPlugins.
|
|
@@ -345,12 +347,11 @@ function getPluginInstallDate(plugin: AllPluginInfo): number {
|
|
|
345
347
|
const metaPath = join(plugin.target, "install-meta.json");
|
|
346
348
|
try {
|
|
347
349
|
if (existsSync(metaPath)) {
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const ms = Date.parse(raw.installedAt);
|
|
350
|
+
const parsed = InstallMetadataSchema.safeParse(
|
|
351
|
+
JSON.parse(readFileSync(metaPath, "utf8")),
|
|
352
|
+
);
|
|
353
|
+
if (parsed.success) {
|
|
354
|
+
const ms = Date.parse(parsed.data.installedAt);
|
|
354
355
|
if (Number.isFinite(ms)) {
|
|
355
356
|
return ms;
|
|
356
357
|
}
|
|
@@ -52,21 +52,76 @@ const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
|
|
|
52
52
|
* the digest must be canonical lowercase hex so client-side comparison is a
|
|
53
53
|
* plain string equality with no normalization step.
|
|
54
54
|
*/
|
|
55
|
-
const PluginArtifactSchema = z
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
55
|
+
const PluginArtifactSchema = z
|
|
56
|
+
.object({
|
|
57
|
+
url: z
|
|
58
|
+
.string()
|
|
59
|
+
.url()
|
|
60
|
+
.refine((u) => u.startsWith("https://"), {
|
|
61
|
+
message: "artifact url must be an https:// URL",
|
|
62
|
+
}),
|
|
63
|
+
sha256: z
|
|
64
|
+
.string()
|
|
65
|
+
.regex(SHA256_HEX_RE, "artifact sha256 must be 64 lowercase hex chars"),
|
|
66
|
+
// Optional, non-critical metadata: a malformed label must never nullify an
|
|
67
|
+
// otherwise-valid `url` + `sha256`, so a wrong-typed value falls back to
|
|
68
|
+
// `undefined` rather than failing the whole descriptor.
|
|
69
|
+
label: z
|
|
70
|
+
.string()
|
|
71
|
+
.transform((label) => label.trim())
|
|
72
|
+
.optional()
|
|
73
|
+
.catch(undefined),
|
|
74
|
+
})
|
|
75
|
+
.transform(({ label, ...artifact }) =>
|
|
76
|
+
label ? { ...artifact, label } : artifact,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
/** Upper bound on `vellum.icon`, in Unicode code points. */
|
|
80
|
+
const MAX_ICON_CODE_POINTS = 16;
|
|
81
|
+
|
|
82
|
+
const PluginIconSchema = z
|
|
83
|
+
.string()
|
|
84
|
+
.transform((icon) => icon.trim())
|
|
85
|
+
.refine(
|
|
86
|
+
(icon) => {
|
|
87
|
+
const codePoints = [...icon].length;
|
|
88
|
+
return codePoints >= 1 && codePoints <= MAX_ICON_CODE_POINTS;
|
|
89
|
+
},
|
|
90
|
+
{ message: "plugin icon must contain 1 to 16 Unicode code points" },
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const LicenseSchema = z
|
|
94
|
+
.union([
|
|
95
|
+
z.string(),
|
|
96
|
+
z.object({ type: z.string() }).transform(({ type }) => type),
|
|
97
|
+
z.undefined(),
|
|
98
|
+
])
|
|
99
|
+
.catch(undefined);
|
|
100
|
+
|
|
101
|
+
export const PluginPackageJsonSchema = z
|
|
102
|
+
.object({
|
|
103
|
+
name: z.string().optional().catch(undefined),
|
|
104
|
+
version: z.string().optional().catch(undefined),
|
|
105
|
+
description: z.string().optional().catch(undefined),
|
|
106
|
+
homepage: z.string().optional().catch(undefined),
|
|
107
|
+
license: LicenseSchema.optional(),
|
|
108
|
+
peerDependencies: z
|
|
109
|
+
.record(z.string(), z.string())
|
|
110
|
+
.optional()
|
|
111
|
+
.catch(undefined),
|
|
112
|
+
vellum: z
|
|
113
|
+
.object({
|
|
114
|
+
artifact: PluginArtifactSchema.optional().catch(undefined),
|
|
115
|
+
icon: PluginIconSchema.optional().catch(undefined),
|
|
116
|
+
})
|
|
117
|
+
.optional()
|
|
118
|
+
.catch(undefined),
|
|
119
|
+
})
|
|
120
|
+
.transform(({ vellum, ...packageJson }) => ({
|
|
121
|
+
...packageJson,
|
|
122
|
+
artifact: vellum?.artifact,
|
|
123
|
+
icon: vellum?.icon,
|
|
124
|
+
}));
|
|
70
125
|
|
|
71
126
|
/**
|
|
72
127
|
* Read `vellum.artifact` from an already-parsed `package.json` value and
|
|
@@ -78,36 +133,10 @@ const PluginArtifactSchema = z.object({
|
|
|
78
133
|
export function parsePluginArtifact(
|
|
79
134
|
packageJson: unknown,
|
|
80
135
|
): PluginArtifact | null {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
packageJson === null ||
|
|
84
|
-
Array.isArray(packageJson)
|
|
85
|
-
) {
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
const vellum = (packageJson as Record<string, unknown>).vellum;
|
|
89
|
-
if (typeof vellum !== "object" || vellum === null || Array.isArray(vellum)) {
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
const parsed = PluginArtifactSchema.safeParse(
|
|
93
|
-
(vellum as Record<string, unknown>).artifact,
|
|
94
|
-
);
|
|
95
|
-
if (!parsed.success) {
|
|
96
|
-
return null;
|
|
97
|
-
}
|
|
98
|
-
// A blank or whitespace-only label is treated as absent so it never
|
|
99
|
-
// invalidates an otherwise well-formed `url` + `sha256` descriptor.
|
|
100
|
-
const label = parsed.data.label?.trim();
|
|
101
|
-
return {
|
|
102
|
-
url: parsed.data.url,
|
|
103
|
-
sha256: parsed.data.sha256,
|
|
104
|
-
...(label ? { label } : {}),
|
|
105
|
-
};
|
|
136
|
+
const parsed = PluginPackageJsonSchema.safeParse(packageJson);
|
|
137
|
+
return parsed.success ? (parsed.data.artifact ?? null) : null;
|
|
106
138
|
}
|
|
107
139
|
|
|
108
|
-
/** Upper bound on `vellum.icon`, in Unicode code points. */
|
|
109
|
-
const MAX_ICON_CODE_POINTS = 16;
|
|
110
|
-
|
|
111
140
|
/**
|
|
112
141
|
* Read `vellum.icon` from an already-parsed `package.json` value and return
|
|
113
142
|
* it only when it is a short, non-empty glyph (an emoji or similar). The
|
|
@@ -116,25 +145,6 @@ const MAX_ICON_CODE_POINTS = 16;
|
|
|
116
145
|
* type, empty, or too long — yields `undefined` rather than throwing.
|
|
117
146
|
*/
|
|
118
147
|
export function parsePluginIcon(packageJson: unknown): string | undefined {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
packageJson === null ||
|
|
122
|
-
Array.isArray(packageJson)
|
|
123
|
-
) {
|
|
124
|
-
return undefined;
|
|
125
|
-
}
|
|
126
|
-
const vellum = (packageJson as Record<string, unknown>).vellum;
|
|
127
|
-
if (typeof vellum !== "object" || vellum === null || Array.isArray(vellum)) {
|
|
128
|
-
return undefined;
|
|
129
|
-
}
|
|
130
|
-
const icon = (vellum as Record<string, unknown>).icon;
|
|
131
|
-
if (typeof icon !== "string") {
|
|
132
|
-
return undefined;
|
|
133
|
-
}
|
|
134
|
-
const trimmed = icon.trim();
|
|
135
|
-
const codePoints = [...trimmed].length;
|
|
136
|
-
if (codePoints < 1 || codePoints > MAX_ICON_CODE_POINTS) {
|
|
137
|
-
return undefined;
|
|
138
|
-
}
|
|
139
|
-
return trimmed;
|
|
148
|
+
const parsed = PluginPackageJsonSchema.safeParse(packageJson);
|
|
149
|
+
return parsed.success ? parsed.data.icon : undefined;
|
|
140
150
|
}
|
|
@@ -32,15 +32,16 @@
|
|
|
32
32
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
33
33
|
import { join } from "node:path";
|
|
34
34
|
|
|
35
|
+
import { z } from "zod";
|
|
36
|
+
|
|
35
37
|
import { getWorkspacePluginsDir } from "../../util/platform.js";
|
|
36
38
|
import { readPluginManifest } from "../../util/plugin-manifest.js";
|
|
37
39
|
import { readBundledPluginFile } from "./bundled-plugin-packages.js";
|
|
38
40
|
import type { FetchLike } from "./fetch-like.js";
|
|
39
41
|
import { sanitizePluginName } from "./install-from-github.js";
|
|
40
42
|
import {
|
|
41
|
-
parsePluginArtifact,
|
|
42
|
-
parsePluginIcon,
|
|
43
43
|
type PluginArtifact,
|
|
44
|
+
PluginPackageJsonSchema,
|
|
44
45
|
} from "./plugin-artifact.js";
|
|
45
46
|
import {
|
|
46
47
|
findCatalogEntry,
|
|
@@ -61,12 +62,16 @@ import {
|
|
|
61
62
|
const README_RE = /^readme(\.md|\.markdown)?$/i;
|
|
62
63
|
|
|
63
64
|
/** Entry shape returned by the GitHub Contents API for a directory listing. */
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
65
|
+
const GitHubContentEntrySchema = z.object({
|
|
66
|
+
name: z.string(),
|
|
67
|
+
path: z.string(),
|
|
68
|
+
type: z.enum(["file", "dir", "symlink", "submodule"]),
|
|
69
|
+
download_url: z.string().nullable(),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const GitHubContentListingSchema = z.array(GitHubContentEntrySchema);
|
|
73
|
+
|
|
74
|
+
type GitHubContentEntry = z.infer<typeof GitHubContentEntrySchema>;
|
|
70
75
|
|
|
71
76
|
/** The subset of `package.json` fields the detail view surfaces. */
|
|
72
77
|
interface PluginManifestFields {
|
|
@@ -249,11 +254,14 @@ function readLocalPlugin(pluginsDir: string, name: string): LocalPlugin {
|
|
|
249
254
|
manifest = parseManifest(readFileSync(packagePath, "utf8"));
|
|
250
255
|
} else {
|
|
251
256
|
const selected = readPluginManifest(target);
|
|
257
|
+
const parsedPackageJson = PluginPackageJsonSchema.safeParse(selected.raw);
|
|
252
258
|
manifest = {
|
|
253
259
|
version: selected.version ?? null,
|
|
254
260
|
description: selected.description ?? null,
|
|
255
261
|
homepage: selected.homepage ?? null,
|
|
256
|
-
license:
|
|
262
|
+
license: parsedPackageJson.success
|
|
263
|
+
? (parsedPackageJson.data.license ?? null)
|
|
264
|
+
: null,
|
|
257
265
|
artifact: null,
|
|
258
266
|
icon: null,
|
|
259
267
|
};
|
|
@@ -365,11 +373,8 @@ async function listDirSafe(
|
|
|
365
373
|
if (!res.ok) {
|
|
366
374
|
return null;
|
|
367
375
|
}
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
return null;
|
|
371
|
-
}
|
|
372
|
-
return body as readonly GitHubContentEntry[];
|
|
376
|
+
const parsed = GitHubContentListingSchema.safeParse(await res.json());
|
|
377
|
+
return parsed.success ? parsed.data : null;
|
|
373
378
|
} catch {
|
|
374
379
|
return null;
|
|
375
380
|
}
|
|
@@ -502,36 +507,16 @@ function safeParseManifest(raw: string): PluginManifestFields {
|
|
|
502
507
|
|
|
503
508
|
/** Extract the surfaced fields from a `package.json` body. Throws on bad JSON. */
|
|
504
509
|
function parseManifest(raw: string): PluginManifestFields {
|
|
505
|
-
const parsed
|
|
506
|
-
if (
|
|
510
|
+
const parsed = PluginPackageJsonSchema.safeParse(JSON.parse(raw));
|
|
511
|
+
if (!parsed.success) {
|
|
507
512
|
return emptyManifest();
|
|
508
513
|
}
|
|
509
|
-
const meta = parsed as Record<string, unknown>;
|
|
510
514
|
return {
|
|
511
|
-
version:
|
|
512
|
-
description:
|
|
513
|
-
homepage:
|
|
514
|
-
license:
|
|
515
|
-
artifact:
|
|
516
|
-
icon:
|
|
515
|
+
version: parsed.data.version ?? null,
|
|
516
|
+
description: parsed.data.description ?? null,
|
|
517
|
+
homepage: parsed.data.homepage ?? null,
|
|
518
|
+
license: parsed.data.license ?? null,
|
|
519
|
+
artifact: parsed.data.artifact ?? null,
|
|
520
|
+
icon: parsed.data.icon ?? null,
|
|
517
521
|
};
|
|
518
522
|
}
|
|
519
|
-
|
|
520
|
-
/**
|
|
521
|
-
* `package.json#license` is usually an SPDX string but the legacy object form
|
|
522
|
-
* `{ "type": "MIT" }` still appears in the wild — surface its `type`.
|
|
523
|
-
*/
|
|
524
|
-
function normalizeLicense(value: unknown): string | null {
|
|
525
|
-
if (typeof value === "string") {
|
|
526
|
-
return value;
|
|
527
|
-
}
|
|
528
|
-
if (
|
|
529
|
-
typeof value === "object" &&
|
|
530
|
-
value !== null &&
|
|
531
|
-
"type" in value &&
|
|
532
|
-
typeof (value as { type: unknown }).type === "string"
|
|
533
|
-
) {
|
|
534
|
-
return (value as { type: string }).type;
|
|
535
|
-
}
|
|
536
|
-
return null;
|
|
537
|
-
}
|
|
@@ -183,15 +183,13 @@ const fetchedMarketplaceManifestSchema = marketplaceManifestBaseSchema.extend({
|
|
|
183
183
|
plugins: z.array(z.unknown()),
|
|
184
184
|
});
|
|
185
185
|
|
|
186
|
+
const marketplaceSourceDiscriminatorSchema = z.object({
|
|
187
|
+
source: z.object({ source: z.unknown() }),
|
|
188
|
+
});
|
|
189
|
+
|
|
186
190
|
function readSourceDiscriminator(entry: unknown): unknown {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
const source = (entry as Record<string, unknown>).source;
|
|
191
|
-
if (typeof source !== "object" || source === null) {
|
|
192
|
-
return null;
|
|
193
|
-
}
|
|
194
|
-
return (source as Record<string, unknown>).source;
|
|
191
|
+
const parsed = marketplaceSourceDiscriminatorSchema.safeParse(entry);
|
|
192
|
+
return parsed.success ? parsed.data.source.source : null;
|
|
195
193
|
}
|
|
196
194
|
|
|
197
195
|
/** A single reviewed plugin entry. */
|