fix: reject manifestless plugin archives

This commit is contained in:
Shakker
2026-04-26 04:12:14 +01:00
parent 28e4cd81a9
commit f5f4477bae
4 changed files with 60 additions and 14 deletions

View File

@@ -120,6 +120,7 @@ Docs: https://docs.openclaw.ai
- Agents/subagents: keep queued subagent announces session-only when the requester has no external channel target, avoiding ambiguous multi-channel delivery failures. Fixes #59201. Thanks @larrylhollan.
- Image understanding: preserve configured provider-prefixed vision model metadata when callers request the model without the provider prefix, so custom image models keep their `input: ["text", "image"]` capability. Fixes #33185. Thanks @Kobe9312 and @vincentkoc.
- Plugins/install: restore the previous plugin index records if a concurrent config write conflict interrupts install, update, or uninstall metadata commits. Thanks @shakkernerd.
- Plugins/install: reject native plugin archives that do not include a valid `openclaw.plugin.json`, preventing manifestless archives from writing install records that later show missing-manifest diagnostics. Thanks @shakkernerd.
- Plugins/update: restore previous plugin index records if core update or channel setup hits a concurrent config write conflict after plugin metadata changes. Thanks @shakkernerd.
- Plugins/onboarding: defer channel/provider plugin install records until the owning config write commits, keeping setup failures from advancing the plugin index ahead of `openclaw.json`. Thanks @shakkernerd.
- Plugins/config: route configure and agent setup writes with pending plugin install records through the plugin index commit helper so provider onboarding metadata is not stripped by plain config writes. Thanks @shakkernerd.

View File

@@ -122,6 +122,9 @@ installs the bundled plugin directly. To install an npm package with the same
name, use an explicit scoped spec (for example `@scope/diffs`).
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`.
Native OpenClaw plugin archives must contain a valid `openclaw.plugin.json` at
the extracted plugin root; archives that only contain `package.json` are
rejected before OpenClaw writes install records.
Claude marketplace installs are also supported.

View File

@@ -117,10 +117,6 @@ async function packToArchive({
return dest;
}
function readVoiceCallArchiveBuffer(version: string): Buffer {
return fs.readFileSync(path.join(pluginFixturesDir, `voice-call-${version}.tgz`));
}
function getArchiveFixturePath(params: {
cacheKey: string;
outName: string;
@@ -140,8 +136,6 @@ function readZipperArchiveBuffer(): Buffer {
return fs.readFileSync(path.join(pluginFixturesDir, "zipper-0.0.1.zip"));
}
const VOICE_CALL_ARCHIVE_V1_BUFFER = readVoiceCallArchiveBuffer("0.0.1");
const VOICE_CALL_ARCHIVE_V2_BUFFER = readVoiceCallArchiveBuffer("0.0.2");
const ZIPPER_ARCHIVE_BUFFER = readZipperArchiveBuffer();
function expectPluginFiles(result: { targetDir: string }, stateDir: string, pluginId: string) {
@@ -430,6 +424,8 @@ async function installArchivePackageAndReturnResult(params: {
outName: string;
withDistIndex?: boolean;
flatRoot?: boolean;
writePluginManifest?: boolean;
manifestId?: string;
}) {
const stateDir = suiteTempRootTracker.makeTempDir();
const archivePath = await ensureDynamicArchiveTemplate({
@@ -437,6 +433,8 @@ async function installArchivePackageAndReturnResult(params: {
packageJson: params.packageJson,
withDistIndex: params.withDistIndex === true,
flatRoot: params.flatRoot === true,
writePluginManifest: params.writePluginManifest,
manifestId: params.manifestId,
});
const extensionsDir = path.join(stateDir, "extensions");
@@ -452,12 +450,16 @@ function buildDynamicArchiveTemplateKey(params: {
withDistIndex: boolean;
distIndexJsContent?: string;
flatRoot: boolean;
writePluginManifest?: boolean;
manifestId?: string;
}): string {
return JSON.stringify({
packageJson: params.packageJson,
withDistIndex: params.withDistIndex,
distIndexJsContent: params.distIndexJsContent ?? null,
flatRoot: params.flatRoot,
writePluginManifest: params.writePluginManifest ?? true,
manifestId: params.manifestId ?? null,
});
}
@@ -467,12 +469,16 @@ async function ensureDynamicArchiveTemplate(params: {
withDistIndex: boolean;
distIndexJsContent?: string;
flatRoot?: boolean;
writePluginManifest?: boolean;
manifestId?: string;
}): Promise<string> {
const templateKey = buildDynamicArchiveTemplateKey({
packageJson: params.packageJson,
withDistIndex: params.withDistIndex,
distIndexJsContent: params.distIndexJsContent,
flatRoot: params.flatRoot === true,
writePluginManifest: params.writePluginManifest,
manifestId: params.manifestId,
});
const cachedPath = dynamicArchiveTemplatePathCache.get(templateKey);
if (cachedPath) {
@@ -490,6 +496,18 @@ async function ensureDynamicArchiveTemplate(params: {
);
}
fs.writeFileSync(path.join(pkgDir, "package.json"), JSON.stringify(params.packageJson), "utf-8");
if (params.writePluginManifest !== false) {
const packageName =
typeof params.packageJson.name === "string" ? params.packageJson.name : "fixture-plugin";
fs.writeFileSync(
path.join(pkgDir, "openclaw.plugin.json"),
JSON.stringify({
id: params.manifestId ?? packageName,
configSchema: { type: "object", properties: {} },
}),
"utf-8",
);
}
const archivePath = await packToArchive({
pkgDir,
outDir: ensureSuiteFixtureRoot(),
@@ -578,15 +596,23 @@ beforeEach(() => {
describe("installPluginFromArchive", () => {
it("installs scoped archives, rejects duplicate installs, and allows updates", async () => {
const stateDir = suiteTempRootTracker.makeTempDir();
const archiveV1 = getArchiveFixturePath({
cacheKey: "voice-call:0.0.1",
const archiveV1 = await ensureDynamicArchiveTemplate({
outName: "voice-call-0.0.1.tgz",
buffer: VOICE_CALL_ARCHIVE_V1_BUFFER,
packageJson: {
name: "@openclaw/voice-call",
version: "0.0.1",
openclaw: { extensions: ["./dist/index.js"] },
},
withDistIndex: true,
});
const archiveV2 = getArchiveFixturePath({
cacheKey: "voice-call:0.0.2",
const archiveV2 = await ensureDynamicArchiveTemplate({
outName: "voice-call-0.0.2.tgz",
buffer: VOICE_CALL_ARCHIVE_V2_BUFFER,
packageJson: {
name: "@openclaw/voice-call",
version: "0.0.2",
openclaw: { extensions: ["./dist/index.js"] },
},
withDistIndex: true,
});
const extensionsDir = path.join(stateDir, "extensions");
@@ -620,7 +646,7 @@ describe("installPluginFromArchive", () => {
expect(manifest.version).toBe("0.0.2");
});
it("installs from a zip archive", async () => {
it("rejects native plugin zip archives without openclaw.plugin.json", async () => {
const stateDir = suiteTempRootTracker.makeTempDir();
const archivePath = getArchiveFixturePath({
cacheKey: "zipper:0.0.1",
@@ -633,7 +659,12 @@ describe("installPluginFromArchive", () => {
archivePath,
extensionsDir,
});
expectSuccessfulArchiveInstall({ result, stateDir, pluginId: "@openclaw/zipper" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("package missing valid openclaw.plugin.json");
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.MISSING_PLUGIN_MANIFEST);
}
expect(fs.existsSync(resolvePluginInstallDir("@openclaw/zipper", extensionsDir))).toBe(false);
});
it("allows archive installs with dangerous code patterns when forced unsafe install is set", async () => {

View File

@@ -51,6 +51,7 @@ export const PLUGIN_INSTALL_ERROR_CODE = {
UNKNOWN_HOST_VERSION: "unknown_host_version",
INCOMPATIBLE_HOST_VERSION: "incompatible_host_version",
MISSING_OPENCLAW_EXTENSIONS: "missing_openclaw_extensions",
MISSING_PLUGIN_MANIFEST: "missing_plugin_manifest",
EMPTY_OPENCLAW_EXTENSIONS: "empty_openclaw_extensions",
NPM_PACKAGE_NOT_FOUND: "npm_package_not_found",
PLUGIN_ID_MISMATCH: "plugin_id_mismatch",
@@ -241,6 +242,7 @@ type PackageInstallCommonParams = InstallSafetyOverrides & {
mode?: "install" | "update";
dryRun?: boolean;
expectedPluginId?: string;
requirePluginManifest?: boolean;
installPolicyRequest?: PluginInstallPolicyRequest;
};
@@ -265,6 +267,7 @@ function pickPackageInstallCommonParams(
mode: params.mode,
dryRun: params.dryRun,
expectedPluginId: params.expectedPluginId,
requirePluginManifest: params.requirePluginManifest,
installPolicyRequest: params.installPolicyRequest,
};
}
@@ -697,6 +700,13 @@ async function installPluginFromPackageDir(
// differs from the npm package name (e.g. "cognee-openclaw"), the plugin registry
// uses the manifest id as the authoritative key, so the config entry must match it.
const ocManifestResult = runtime.loadPluginManifest(params.packageDir);
if (!ocManifestResult.ok && params.requirePluginManifest) {
return {
ok: false,
error: `package missing valid openclaw.plugin.json: ${ocManifestResult.error}`,
code: PLUGIN_INSTALL_ERROR_CODE.MISSING_PLUGIN_MANIFEST,
};
}
const manifestPluginId =
ocManifestResult.ok && ocManifestResult.manifest.id
? ocManifestResult.manifest.id.trim()
@@ -882,6 +892,7 @@ export async function installPluginFromArchive(
mode,
dryRun: params.dryRun,
expectedPluginId: params.expectedPluginId,
requirePluginManifest: true,
installPolicyRequest,
}),
}),