diff --git a/CHANGELOG.md b/CHANGELOG.md index 401238695f1..f134b0f7b8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 61ac4c19e85..fa67e1f8638 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -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. diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index c8e14a0e275..9d39062a779 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -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 { 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 () => { diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 1b565a86b29..74b6b7756f3 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -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, }), }),