Skip to content

Commit 9f4de35

Browse files
Warn on malformed plugin manifests and log swallowed load errors (#924)
* Warn on malformed plugin manifests and log swallowed load errors * Warn on malformed data-only manifests and accept Claude metadata A broken native manifest was treated as missing, so data-only plugins inferred kind with no warning. Claude marketplace metadata is name and description, not a corbits schema, so validating it produced false id and kind warnings on untrusted loads.
1 parent 55d3d3e commit 9f4de35

8 files changed

Lines changed: 342 additions & 32 deletions

File tree

‎src/plugins/data-only.ts‎

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { readFile } from "node:fs/promises";
22
import { basename, join } from "node:path";
3-
import { parsePluginManifest, type PluginManifest } from "./manifest.js";
3+
import { type } from "arktype";
4+
import { PluginManifestSchema, type PluginManifest } from "./manifest.js";
45
import type {
56
CommandDefinition,
67
CommandPlugin,
@@ -25,13 +26,45 @@ export interface DataOnlyPlugin {
2526
commandPlugin?: CommandPlugin;
2627
}
2728

28-
async function readManifestJson(dir: string): Promise<PluginManifest | null> {
29+
function isENOENT(err: unknown): boolean {
30+
return (
31+
typeof err === "object" &&
32+
err !== null &&
33+
"code" in err &&
34+
(err as { code?: unknown }).code === "ENOENT"
35+
);
36+
}
37+
38+
function errorText(err: unknown): string {
39+
return err instanceof Error ? err.message : String(err);
40+
}
41+
42+
async function readManifestJson(
43+
dir: string,
44+
onWarning: (msg: string) => void,
45+
): Promise<PluginManifest | null> {
46+
const manifestPath = join(dir, "manifest.json");
47+
let raw: string;
2948
try {
30-
const raw = await readFile(join(dir, "manifest.json"), "utf8");
31-
return parsePluginManifest(JSON.parse(raw));
32-
} catch {
49+
raw = await readFile(manifestPath, "utf8");
50+
} catch (err) {
51+
if (isENOENT(err)) return null;
52+
onWarning(`failed to read ${manifestPath}: ${errorText(err)}`);
53+
return null;
54+
}
55+
let parsed: unknown;
56+
try {
57+
parsed = JSON.parse(raw);
58+
} catch (err) {
59+
onWarning(`failed to parse ${manifestPath}: ${errorText(err)}`);
60+
return null;
61+
}
62+
const result = PluginManifestSchema(parsed);
63+
if (result instanceof type.errors) {
64+
onWarning(`invalid plugin manifest at ${manifestPath}: ${result.summary}`);
3365
return null;
3466
}
67+
return result as PluginManifest;
3568
}
3669

3770
// Claude Code marketplace plugins self-describe via `.claude-plugin/plugin.json`
@@ -106,7 +139,7 @@ export async function loadDataOnlyPlugin(
106139

107140
const [nativeManifest, claudeManifest, agents, commands, skillCmds] =
108141
await Promise.all([
109-
readManifestJson(pluginDir),
142+
readManifestJson(pluginDir, onWarning),
110143
readClaudePluginManifest(pluginDir),
111144
loadDataOnlyAgentPlugin(pluginDir, { cwd, onWarning }),
112145
loadDataOnlyCommands(pluginDir, { onWarning }),

‎src/plugins/loader.test.ts‎

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import { defined } from "../../tests/helpers/defined.js";
22
import { describe, test, expect } from "bun:test";
3-
import { dedupePluginModules, type PluginModule } from "./loader.js";
3+
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { createPluginLoadDiagnostics } from "./diagnostics.js";
7+
import {
8+
dedupePluginModules,
9+
loadPluginEntry,
10+
loadPluginsFromPaths,
11+
type PluginModule,
12+
} from "./loader.js";
413
import { isPluginModuleEnabled } from "./register.js";
514
import { disablePluginSettings } from "./uninstall.js";
615

@@ -101,3 +110,135 @@ describe("isPluginModuleEnabled with dedupe shadowing", () => {
101110
expect(isPluginModuleEnabled(user, {})).toBe(false);
102111
});
103112
});
113+
114+
async function makeJsPlugin(files: Record<string, string>): Promise<string> {
115+
const dir = await mkdtemp(join(tmpdir(), "manifest-plugin-"));
116+
for (const [rel, body] of Object.entries(files)) {
117+
const abs = join(dir, rel);
118+
await mkdir(join(abs, ".."), { recursive: true });
119+
await writeFile(abs, body);
120+
}
121+
return dir;
122+
}
123+
124+
describe("readManifestJson malformed vs missing", () => {
125+
test("malformed manifest.json warns with path and parse error", async () => {
126+
const dir = await makeJsPlugin({
127+
"index.js": "export {};\n",
128+
"manifest.json": "{not-json",
129+
});
130+
const warnings: string[] = [];
131+
await loadPluginEntry(dir, { onWarning: (msg) => warnings.push(msg) });
132+
const manifestPath = join(dir, "manifest.json");
133+
expect(warnings.length).toBeGreaterThan(0);
134+
expect(warnings.some((w) => w.includes(manifestPath))).toBe(true);
135+
expect(
136+
warnings.some(
137+
(w) => w.includes(manifestPath) && w.includes("failed to parse"),
138+
),
139+
).toBe(true);
140+
});
141+
142+
test("invalid manifest.json schema warns with path and validation error", async () => {
143+
const dir = await makeJsPlugin({
144+
"index.js": "export {};\n",
145+
"manifest.json": JSON.stringify({ id: "x", name: "X" }),
146+
});
147+
const warnings: string[] = [];
148+
await loadPluginEntry(dir, { onWarning: (msg) => warnings.push(msg) });
149+
const manifestPath = join(dir, "manifest.json");
150+
expect(warnings.some((w) => w.includes(manifestPath))).toBe(true);
151+
expect(
152+
warnings.some((w) => w.includes(manifestPath) && w.includes("kind")),
153+
).toBe(true);
154+
});
155+
156+
test("missing manifest.json stays silent", async () => {
157+
const dir = await makeJsPlugin({
158+
"index.js": "export {};\n",
159+
});
160+
const warnings: string[] = [];
161+
await loadPluginEntry(dir, { onWarning: (msg) => warnings.push(msg) });
162+
expect(warnings).toEqual([]);
163+
});
164+
165+
test("malformed .claude-plugin/manifest.json warns on metadata-only load", async () => {
166+
const dir = await makeJsPlugin({
167+
".claude-plugin/manifest.json": "{not-json",
168+
});
169+
const diag = createPluginLoadDiagnostics();
170+
const cwd = await mkdtemp(join(tmpdir(), "manifest-cwd-"));
171+
const mods = await loadPluginsFromPaths([dir], cwd, {
172+
isPluginTrusted: () => false,
173+
diagnostics: diag,
174+
});
175+
expect(mods).toEqual([]);
176+
const manifestPath = join(dir, ".claude-plugin", "manifest.json");
177+
expect(diag.warnings.some((w) => w.includes(manifestPath))).toBe(true);
178+
expect(
179+
diag.warnings.some(
180+
(w) => w.includes(manifestPath) && w.includes("failed to parse"),
181+
),
182+
).toBe(true);
183+
});
184+
185+
test("missing manifest on metadata-only load stays silent", async () => {
186+
const dir = await mkdtemp(join(tmpdir(), "manifest-empty-"));
187+
const diag = createPluginLoadDiagnostics();
188+
const cwd = await mkdtemp(join(tmpdir(), "manifest-cwd-"));
189+
const mods = await loadPluginsFromPaths([dir], cwd, {
190+
isPluginTrusted: () => false,
191+
diagnostics: diag,
192+
});
193+
expect(mods).toEqual([]);
194+
expect(diag.warnings).toEqual([]);
195+
});
196+
197+
test("malformed native manifest.json on data-only plugin warns and does not silently infer kind", async () => {
198+
const dir = await makeJsPlugin({
199+
"agents/a.md": "---\nname: a\n---\nbody\n",
200+
"manifest.json": "{not-json",
201+
});
202+
const warnings: string[] = [];
203+
const mod = await loadPluginEntry(dir, {
204+
onWarning: (msg) => warnings.push(msg),
205+
});
206+
const manifestPath = join(dir, "manifest.json");
207+
expect(
208+
warnings.some(
209+
(w) => w.includes(manifestPath) && w.includes("failed to parse"),
210+
),
211+
).toBe(true);
212+
expect(mod).not.toBeNull();
213+
expect(mod?.agentPlugin).toBeDefined();
214+
});
215+
216+
test("Claude-format .claude-plugin/manifest.json does not warn missing id/kind on metadata-only load", async () => {
217+
const dir = await makeJsPlugin({
218+
".claude-plugin/manifest.json": JSON.stringify({
219+
name: "cmo",
220+
description: "Marketing ops",
221+
}),
222+
});
223+
const diag = createPluginLoadDiagnostics();
224+
const cwd = await mkdtemp(join(tmpdir(), "manifest-cwd-"));
225+
const mods = await loadPluginsFromPaths([dir], cwd, {
226+
isPluginTrusted: () => false,
227+
diagnostics: diag,
228+
});
229+
expect(
230+
diag.warnings.some((w) => w.includes("invalid plugin manifest")),
231+
).toBe(false);
232+
expect(
233+
diag.warnings.some(
234+
(w) =>
235+
w.includes(join(dir, ".claude-plugin", "manifest.json")) &&
236+
(w.includes("id") || w.includes("kind")),
237+
),
238+
).toBe(false);
239+
const mod = mods.find((m) => m.manifest?.id === "cmo");
240+
expect(mod?.metadataOnly).toBe(true);
241+
expect(mod?.manifest?.name).toBe("cmo");
242+
expect(mod?.manifest?.description).toBe("Marketing ops");
243+
});
244+
});

0 commit comments

Comments
 (0)