fix: use JSON5 parser for plugin manifest loading (#57734) [AI-assisted]

Plugin install fails when a third-party plugin manifest (openclaw.plugin.json)
contains JSON5 features such as trailing commas, comments, or unquoted keys.
The strict JSON.parse used in loadPluginManifest and loadBundleManifestRaw
rejects these files during the config validation step of writeConfigFile,
preventing the install from completing.

- Replace JSON.parse with JSON5.parse in manifest.ts and bundle-manifest.ts
- Add JSON5 tolerance tests covering trailing commas, comments, unquoted keys
- Verified zero regression across manifest-registry, bundle-manifest, install,
  installs, and io.write-config test suites (101 tests pass)

AI-assisted: code fix and test generation verified by automated test runs
This commit is contained in:
jackybzhou
2026-04-01 22:34:20 +08:00
committed by masonxhuang
parent b0f94a227b
commit 6681c8d404
3 changed files with 97 additions and 2 deletions

View File

@@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import JSON5 from "json5";
import { matchBoundaryFileOpenFailure, openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { isRecord } from "../utils.js";
import { DEFAULT_PLUGIN_ENTRY_CANDIDATES, PLUGIN_MANIFEST_FILENAME } from "./manifest.js";
@@ -117,7 +118,7 @@ function loadBundleManifestFile(params: {
});
}
try {
const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown;
const raw = JSON5.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown;
if (!isRecord(raw)) {
return { ok: false, error: "plugin manifest must be an object", manifestPath };
}

View File

@@ -0,0 +1,93 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { loadPluginManifest } from "./manifest.js";
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
const tempDirs: string[] = [];
function makeTempDir() {
return makeTrackedTempDir("openclaw-manifest-json5", tempDirs);
}
afterEach(() => {
cleanupTrackedTempDirs(tempDirs);
});
describe("loadPluginManifest JSON5 tolerance", () => {
it("parses a standard JSON manifest without issues", () => {
const dir = makeTempDir();
const manifest = {
id: "demo",
configSchema: { type: "object" },
};
fs.writeFileSync(
path.join(dir, "openclaw.plugin.json"),
JSON.stringify(manifest, null, 2),
"utf-8",
);
const result = loadPluginManifest(dir, false);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.manifest.id).toBe("demo");
}
});
it("parses a manifest with trailing commas", () => {
const dir = makeTempDir();
const json5Content = `{
"id": "hindsight",
"configSchema": {
"type": "object",
"properties": {
"apiKey": { "type": "string" },
},
},
}`;
fs.writeFileSync(path.join(dir, "openclaw.plugin.json"), json5Content, "utf-8");
const result = loadPluginManifest(dir, false);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.manifest.id).toBe("hindsight");
}
});
it("parses a manifest with single-line comments", () => {
const dir = makeTempDir();
const json5Content = `{
// Plugin identifier
"id": "commented-plugin",
"configSchema": { "type": "object" }
}`;
fs.writeFileSync(path.join(dir, "openclaw.plugin.json"), json5Content, "utf-8");
const result = loadPluginManifest(dir, false);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.manifest.id).toBe("commented-plugin");
}
});
it("parses a manifest with unquoted property names", () => {
const dir = makeTempDir();
const json5Content = `{
id: "unquoted-keys",
configSchema: { type: "object" }
}`;
fs.writeFileSync(path.join(dir, "openclaw.plugin.json"), json5Content, "utf-8");
const result = loadPluginManifest(dir, false);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.manifest.id).toBe("unquoted-keys");
}
});
it("still rejects completely invalid syntax", () => {
const dir = makeTempDir();
fs.writeFileSync(path.join(dir, "openclaw.plugin.json"), "not json at all {{{}}", "utf-8");
const result = loadPluginManifest(dir, false);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("failed to parse plugin manifest");
}
});
});

View File

@@ -1,5 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import JSON5 from "json5";
import { MANIFEST_KEY } from "../compat/legacy-names.js";
import { matchBoundaryFileOpenFailure, openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { isRecord } from "../utils.js";
@@ -273,7 +274,7 @@ export function loadPluginManifest(
}
let raw: unknown;
try {
raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown;
raw = JSON5.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown;
} catch (err) {
return {
ok: false,