diff --git a/CHANGELOG.md b/CHANGELOG.md index f500d2bc055..9efc8d182d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/packages: reject blank `openclaw.runtimeExtensions` entries instead of silently ignoring them and falling back to inferred TypeScript runtime entries. Thanks @vincentkoc. - Doctor/plugins: remove stale managed npm plugin shadow entries from the managed package lock as well as `package.json` and `node_modules`, so future npm operations do not keep referencing repaired bundled-plugin shadows. Thanks @vincentkoc. - Plugins/runtime state: keep the key being registered when namespace eviction runs in the same millisecond as existing entries, so `register` and `registerIfAbsent` do not report success while evicting their own fresh value. Thanks @vincentkoc. - Control UI/Talk: make failed Talk startup errors dismissable and clear the stale Talk error state when dismissed, so missing realtime voice provider configuration does not leave a permanent chat banner. Fixes #77071. Thanks @ijoshdavis. diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index a5f8a483c34..7536b0d8c31 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -928,6 +928,34 @@ describe("discoverOpenClawPlugins", () => { ).toBe(true); }); + it("rejects blank package runtimeExtensions before falling back to inferred entries", async () => { + const stateDir = makeTempDir(); + const pluginDir = path.join(stateDir, "extensions", "runtime-blank-pack"); + mkdirSafe(path.join(pluginDir, "src")); + mkdirSafe(path.join(pluginDir, "dist")); + + writePluginPackageManifest({ + packageDir: pluginDir, + packageName: "@openclaw/runtime-blank-pack", + extensions: ["./src/index.ts"], + runtimeExtensions: [" "], + }); + writePluginEntry(path.join(pluginDir, "src", "index.ts")); + writePluginEntry(path.join(pluginDir, "dist", "index.js")); + + const result = await discoverWithStateDir(stateDir, {}); + + expectCandidatePresence(result, { absent: ["runtime-blank-pack"] }); + expect( + result.diagnostics.some( + (entry) => + entry.level === "error" && + entry.message.includes("openclaw.runtimeExtensions[0]") && + entry.message.includes("non-empty string"), + ), + ).toBe(true); + }); + it("infers built dist entries for installed TypeScript package plugins", async () => { const stateDir = makeTempDir(); const pluginDir = path.join(stateDir, "extensions", "built-peer-pack"); diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 2416408024a..a74f63ef93b 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -985,6 +985,37 @@ describe("installPluginFromArchive", () => { } }); + it("rejects package installs when runtimeExtensions contains a blank entry", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.mkdirSync(path.join(pluginDir, "src"), { recursive: true }); + fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "runtime-blank-plugin", + version: "1.0.0", + openclaw: { + extensions: ["./src/index.ts"], + runtimeExtensions: [" "], + }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "src", "index.ts"), "export {};\n"); + fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};\n"); + + const result = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS); + expect(result.error).toContain("openclaw.runtimeExtensions[0]"); + expect(result.error).toContain("non-empty string"); + } + }); + it("rejects package installs when runtimeSetupEntry is missing", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); fs.mkdirSync(path.join(pluginDir, "src"), { recursive: true }); diff --git a/src/plugins/package-entry-resolution.ts b/src/plugins/package-entry-resolution.ts index f117562abd6..b92f55ea393 100644 --- a/src/plugins/package-entry-resolution.ts +++ b/src/plugins/package-entry-resolution.ts @@ -21,6 +21,8 @@ type RuntimeExtensionsResolution = | { ok: true; runtimeExtensions: string[] } | { ok: false; error: string }; +type PackageManifestStringList = { ok: true; entries: string[] } | { ok: false; error: string }; + function runtimeExtensionsLengthMismatchMessage(params: { runtimeExtensionsLength: number; extensionsLength: number; @@ -31,11 +33,25 @@ function runtimeExtensionsLengthMismatchMessage(params: { ); } -function normalizePackageManifestStringList(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; +function readPackageManifestStringList(params: { + fieldName: string; + value: unknown; +}): PackageManifestStringList { + if (!Array.isArray(params.value)) { + return { ok: true, entries: [] }; } - return value.map((entry) => normalizeOptionalString(entry) ?? "").filter(Boolean); + const entries: string[] = []; + for (const [index, entry] of params.value.entries()) { + const normalized = normalizeOptionalString(entry); + if (!normalized) { + return { + ok: false, + error: `package.json ${params.fieldName}[${index}] must be a non-empty string`, + }; + } + entries.push(normalized); + } + return { ok: true, entries }; } function resolvePackageRuntimeExtensionEntries(params: { @@ -43,7 +59,14 @@ function resolvePackageRuntimeExtensionEntries(params: { extensions: readonly string[]; }): RuntimeExtensionsResolution { const packageManifest = getPackageManifestMetadata(params.manifest ?? undefined); - const runtimeExtensions = normalizePackageManifestStringList(packageManifest?.runtimeExtensions); + const runtimeExtensionsResult = readPackageManifestStringList({ + fieldName: "openclaw.runtimeExtensions", + value: packageManifest?.runtimeExtensions, + }); + if (!runtimeExtensionsResult.ok) { + return runtimeExtensionsResult; + } + const runtimeExtensions = runtimeExtensionsResult.entries; if (runtimeExtensions.length === 0) { return { ok: true, runtimeExtensions: [] }; }