@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.
Files changed (34) hide show
  1. package/openapi.yaml +2 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/headless-browser-navigate.test.ts +20 -30
  4. package/src/api/events/tool-result.ts +1 -0
  5. package/src/cli/lib/__tests__/plugin-artifact.test.ts +23 -0
  6. package/src/cli/lib/__tests__/plugin-details.test.ts +16 -0
  7. package/src/cli/lib/inspect-plugin.ts +15 -15
  8. package/src/cli/lib/list-installed-plugins.ts +29 -28
  9. package/src/cli/lib/plugin-artifact.ts +74 -64
  10. package/src/cli/lib/plugin-details.ts +27 -42
  11. package/src/cli/lib/plugin-marketplace.ts +6 -8
  12. package/src/cli/lib/plugin-surfaces.ts +8 -7
  13. package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +43 -1
  14. package/src/daemon/handlers/config-a2a.ts +75 -2
  15. package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
  16. package/src/providers/search-provider-catalog.ts +10 -0
  17. package/src/runtime/assistant-event-hub.ts +23 -0
  18. package/src/runtime/auth/__tests__/guard-tests.test.ts +0 -7
  19. package/src/runtime/auth/__tests__/route-policy.test.ts +1 -1
  20. package/src/runtime/pending-interactions.ts +20 -2
  21. package/src/runtime/routes/host-app-control-routes.ts +1 -0
  22. package/src/runtime/routes/host-bash-routes.ts +1 -0
  23. package/src/runtime/routes/host-browser-routes.ts +15 -0
  24. package/src/runtime/routes/host-cu-routes.ts +1 -0
  25. package/src/runtime/routes/host-file-routes.ts +1 -0
  26. package/src/runtime/routes/host-proxy-result-binding.test.ts +83 -0
  27. package/src/runtime/routes/host-proxy-result-binding.ts +25 -0
  28. package/src/runtime/routes/host-transfer-routes.ts +1 -0
  29. package/src/runtime/routes/integrations/a2a.ts +4 -1
  30. package/src/runtime/routes/ui-snapshot-routes.test.ts +1 -0
  31. package/src/tools/browser/browser-execution.ts +14 -18
  32. package/src/tools/network/__tests__/web-search.test.ts +105 -0
  33. package/src/tools/network/web-search.ts +228 -4
  34. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.5-dev.202609261222.09a30ca",
3
+ "version": "0.12.5-dev.202609261415.2575c70",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 clears live session and still continues", async () => {
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(false);
771
- // No new pin was set, BUT the live session was reset to undefined
772
- // so the follow-on Page.navigate falls back to active-tab routing
773
- // instead of any stale pin the cdp instance held at construction.
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 AND live session (regression)", async () => {
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(false);
810
- // (a) Pin store cleared.
802
+ expect(result.isError).toBe(true);
811
803
  expect(getPinnedTab(ctx.conversationId)).toBeUndefined();
812
- // (b) Live cdp session reset (the fake records every
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([undefined]);
928
+ expect(cdpSetSessionIdCalls).toEqual(["active"]);
939
929
  expect(cdpSendCalls.some((c) => c.method === "Page.navigate")).toBe(true);
940
930
  });
941
931
 
@@ -55,6 +55,7 @@ export const WebSearchProviderIdSchema = z.enum([
55
55
  "searxng",
56
56
  "tinyfish",
57
57
  "exa",
58
+ "serply",
58
59
  ]);
59
60
 
60
61
  export type WebSearchProviderId = z.infer<typeof WebSearchProviderIdSchema>;
@@ -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 json: unknown = JSON.parse(await res.text());
298
- if (typeof json !== "object" || json === null) {
299
- return null;
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 { parsePluginIcon } from "./plugin-artifact.js";
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 meta = manifest.raw;
167
- const icon =
168
- manifest.source === LEGACY_PLUGIN_MANIFEST
169
- ? parsePluginIcon(meta)
170
- : undefined;
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
- manifest.source === LEGACY_PLUGIN_MANIFEST &&
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 raw = readFileSync(pkgJsonPath, "utf8");
323
- const parsed = JSON.parse(raw) as Record<string, unknown>;
324
- if (typeof parsed.name === "string") {
325
- manifests.push({
326
- name: parsed.name,
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 raw = JSON.parse(readFileSync(metaPath, "utf8")) as Record<
349
- string,
350
- unknown
351
- >;
352
- if (typeof raw.installedAt === "string") {
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.object({
56
- url: z
57
- .string()
58
- .url()
59
- .refine((u) => u.startsWith("https://"), {
60
- message: "artifact url must be an https:// URL",
61
- }),
62
- sha256: z
63
- .string()
64
- .regex(SHA256_HEX_RE, "artifact sha256 must be 64 lowercase hex chars"),
65
- // Optional, non-critical metadata: a malformed label must never nullify an
66
- // otherwise-valid `url` + `sha256`, so a wrong-typed value falls back to
67
- // `undefined` rather than failing the whole descriptor.
68
- label: z.string().optional().catch(undefined),
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
- if (
82
- typeof packageJson !== "object" ||
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
- if (
120
- typeof packageJson !== "object" ||
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
- interface GitHubContentEntry {
65
- readonly name: string;
66
- readonly path: string;
67
- readonly type: "file" | "dir" | "symlink" | "submodule";
68
- readonly download_url: string | null;
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: normalizeLicense(selected.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 body = (await res.json()) as unknown;
369
- if (!Array.isArray(body)) {
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: unknown = JSON.parse(raw);
506
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
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: typeof meta.version === "string" ? meta.version : null,
512
- description: typeof meta.description === "string" ? meta.description : null,
513
- homepage: typeof meta.homepage === "string" ? meta.homepage : null,
514
- license: normalizeLicense(meta.license),
515
- artifact: parsePluginArtifact(parsed),
516
- icon: parsePluginIcon(parsed) ?? null,
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
- if (typeof entry !== "object" || entry === null) {
188
- return null;
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. */