From 59c523c6b530224f55a5c00baca761563a180e4a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 3 May 2026 16:47:43 +0100 Subject: [PATCH] fix: reject source-only plugin package installs --- CHANGELOG.md | 1 + docs/tools/plugin.md | 3 + src/plugins/discovery.test.ts | 61 ++++++++++++- src/plugins/install.test.ts | 26 ++++++ src/plugins/package-entry-resolution.ts | 114 ++++++++++++++++++++---- src/plugins/package-entrypoints.ts | 2 +- 6 files changed, 187 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed5665c10d87..6642f4936516 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - Control UI/Sessions: avoid full `sessions.list` reloads for chat-turn `sessions.changed` payloads, so large session stores no longer add multi-second delays while chat responses are being delivered. (#76676) Thanks @VACInc. - Doctor/Telegram: warn when selected Telegram quote replies can suppress `streaming.preview.toolProgress`, and document the `replyToMode` trade-off without changing runtime delivery. Fixes #73487. Thanks @GodsBoy. +- Plugins/install: reject source-only TypeScript package installs and installed plugin packages that are missing compiled runtime output, so broken npm artifacts fail at install/discovery time instead of falling through jiti and surfacing later as unavailable providers. Fixes #76720. - Discord/status: honor explicit `messages.statusReactions.enabled: true` in tool-only guild channels so queued ack reactions can progress through thinking/done lifecycle reactions instead of stopping at the initial emoji. Thanks @Marvinthebored. - Discord/native commands: compare Discord-normalized slash-command descriptions and localized descriptions during reconcile so CJK or multiline command text no longer triggers redundant startup PATCH bursts and rate-limit 429s. Fixes #76587. Thanks @zhengsx. - Agents/OpenAI: omit Chat Completions `reasoning_effort` for `gpt-5.4-mini` only when function tools are present while preserving tool-free Chat and Responses reasoning support, preventing Telegram-routed fallback runs from hanging after OpenAI rejects tool payloads. Fixes #76176. Thanks @ThisIsAdilah and @chinar-amrutkar. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 6cb1f0cb4762..0849573830bb 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -161,6 +161,9 @@ Native plugin npm packages must declare `openclaw.extensions` in `package.json`. Each entry must stay inside the package directory and resolve to a readable runtime file, or to a TypeScript source file with an inferred built JavaScript peer such as `src/index.ts` to `dist/index.js`. +Packaged installs must ship that JavaScript runtime output. The TypeScript +source fallback is for source checkouts and local development paths, not for +npm packages installed into OpenClaw's managed plugin root. Use `openclaw.runtimeExtensions` when published runtime files do not live at the same paths as the source entries. When present, `runtimeExtensions` must contain diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index 195928d58c30..31b0a51e30ba 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { bundledDistPluginFile } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; import { discoverOpenClawPlugins } from "./discovery.js"; +import { listBuiltRuntimeEntryCandidates } from "./package-entrypoints.js"; import { cleanupTrackedTempDirs, makeTrackedTempDir, @@ -198,6 +199,7 @@ function createPackagePluginWithEntry(params: { packageName: string; pluginId?: string; entryPath?: string; + writeBuiltRuntime?: boolean; }) { const entryPath = params.entryPath ?? "src/index.ts"; mkdirSafe(path.dirname(path.join(params.packageDir, entryPath))); @@ -208,6 +210,14 @@ function createPackagePluginWithEntry(params: { ...(params.pluginId ? { pluginId: params.pluginId } : {}), }); writePluginEntry(path.join(params.packageDir, entryPath)); + if (params.writeBuiltRuntime ?? listBuiltRuntimeEntryCandidates(entryPath).length > 0) { + const runtimeEntry = listBuiltRuntimeEntryCandidates(entryPath)[0]; + if (runtimeEntry) { + const runtimePath = path.join(params.packageDir, runtimeEntry.replace(/^\.\//u, "")); + mkdirSafe(path.dirname(runtimePath)); + writePluginEntry(runtimePath); + } + } } function createBundleRoot(bundleDir: string, markerPath: string, manifest?: unknown) { @@ -303,7 +313,7 @@ function expectBundleCandidateMatch(params: { async function expectRejectedPackageExtensionEntry(params: { stateDir: string; setup: (stateDir: string) => boolean | void; - expectedDiagnostic?: "escapes" | "none"; + expectedDiagnostic?: "escapes" | "none" | "runtime"; expectedId?: string; }) { if (params.setup(params.stateDir) === false) { @@ -320,6 +330,14 @@ async function expectRejectedPackageExtensionEntry(params: { expectEscapesPackageDiagnostic(result.diagnostics); return; } + if (params.expectedDiagnostic === "runtime") { + expect( + result.diagnostics.some( + (entry) => entry.level === "error" && entry.message.includes("compiled runtime output"), + ), + ).toBe(true); + return; + } expect(result.diagnostics).toEqual([]); } @@ -714,6 +732,7 @@ describe("discoverOpenClawPlugins", () => { const stateDir = makeTempDir(); const globalExt = path.join(stateDir, "extensions", "pack"); mkdirSafe(path.join(globalExt, "src")); + mkdirSafe(path.join(globalExt, "dist")); writePluginPackageManifest({ packageDir: globalExt, @@ -722,15 +741,43 @@ describe("discoverOpenClawPlugins", () => { }); writePluginEntry(path.join(globalExt, "src", "one.ts")); writePluginEntry(path.join(globalExt, "src", "two.ts")); + writePluginEntry(path.join(globalExt, "dist", "one.js")); + writePluginEntry(path.join(globalExt, "dist", "two.js")); const { candidates } = await discoverWithStateDir(stateDir, {}); expectCandidateIds(candidates, { includes: ["pack/one", "pack/two"] }); }); + it("rejects source-only TypeScript entries for installed package plugins", async () => { + const stateDir = makeTempDir(); + const pluginDir = path.join(stateDir, "extensions", "source-only-pack"); + mkdirSafe(path.join(pluginDir, "src")); + + writePluginPackageManifest({ + packageDir: pluginDir, + packageName: "@openclaw/source-only-pack", + extensions: ["./src/index.ts"], + }); + writePluginEntry(path.join(pluginDir, "src", "index.ts")); + + const result = await discoverWithStateDir(stateDir, {}); + + expectCandidatePresence(result, { absent: ["source-only-pack"] }); + expect( + result.diagnostics.some( + (entry) => + entry.level === "error" && + entry.message.includes("requires compiled runtime output") && + entry.message.includes("./dist/index.js"), + ), + ).toBe(true); + }); + it("reuses one filesystem realpath lookup per package root within a discovery run", () => { const stateDir = makeTempDir(); const packageDir = path.join(stateDir, "extensions", "pack"); mkdirSafe(path.join(packageDir, "src")); + mkdirSafe(path.join(packageDir, "dist")); writePluginPackageManifest({ packageDir, @@ -739,6 +786,8 @@ describe("discoverOpenClawPlugins", () => { }); writePluginEntry(path.join(packageDir, "src", "one.ts")); writePluginEntry(path.join(packageDir, "src", "two.ts")); + writePluginEntry(path.join(packageDir, "dist", "one.js")); + writePluginEntry(path.join(packageDir, "dist", "two.js")); const realpathSync = vi.spyOn(fs, "realpathSync"); const { candidates } = discoverOpenClawPlugins({ @@ -1049,6 +1098,7 @@ describe("discoverOpenClawPlugins", () => { "diffs", ); mkdirSafe(path.join(pluginDir, "src")); + mkdirSafe(path.join(pluginDir, "dist")); mkdirSafe(nestedDiffsDir); writePluginPackageManifest({ @@ -1062,6 +1112,11 @@ describe("discoverOpenClawPlugins", () => { "export default function () {}", "utf-8", ); + fs.writeFileSync( + path.join(pluginDir, "dist", "index.js"), + "export default function () {}", + "utf-8", + ); writePluginPackageManifest({ packageDir: path.join(pluginDir, "node_modules", "openclaw"), @@ -1322,8 +1377,8 @@ describe("discoverOpenClawPlugins", () => { }, }, { - name: "skips missing package extension entries without escape diagnostics", - expectedDiagnostic: "none" as const, + name: "rejects missing TypeScript package runtime entries without escape diagnostics", + expectedDiagnostic: "runtime" as const, setup: (stateDir: string) => { const globalExt = path.join(stateDir, "extensions", "missing-entry-pack"); mkdirSafe(globalExt); diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index cd1481d9ec8e..2416408024a4 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -930,6 +930,32 @@ describe("installPluginFromArchive", () => { } }); + it("rejects package installs when a TypeScript extension entry has no compiled runtime output", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.mkdirSync(path.join(pluginDir, "src"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "source-only-runtime-plugin", + version: "1.0.0", + openclaw: { extensions: ["./src/index.ts"] }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "src", "index.ts"), "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("requires compiled runtime output"); + expect(result.error).toContain("./dist/index.js"); + } + }); + it("rejects package installs when runtimeExtensions length does not match extensions", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true }); diff --git a/src/plugins/package-entry-resolution.ts b/src/plugins/package-entry-resolution.ts index 20a7105a0ceb..e6571d5b8c2c 100644 --- a/src/plugins/package-entry-resolution.ts +++ b/src/plugins/package-entry-resolution.ts @@ -9,7 +9,10 @@ import { resolveBoundaryPath, resolveBoundaryPathSync } from "../infra/boundary- import { normalizeOptionalString } from "../shared/string-coerce.js"; import type { PluginDiagnostic } from "./manifest-types.js"; import { getPackageManifestMetadata, type PackageManifest } from "./manifest.js"; -import { listBuiltRuntimeEntryCandidates } from "./package-entrypoints.js"; +import { + isTypeScriptPackageEntry, + listBuiltRuntimeEntryCandidates, +} from "./package-entrypoints.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; type ExtensionEntryValidation = { ok: true; exists: boolean } | { ok: false; error: string }; @@ -56,6 +59,14 @@ function resolvePackageRuntimeExtensionEntries(params: { return { ok: true, runtimeExtensions }; } +function missingCompiledRuntimeEntryMessage(params: { + label: string; + entry: string; + candidates: readonly string[]; +}): string { + return `${params.label} requires compiled runtime output for TypeScript entry ${params.entry}: expected ${params.candidates.join(", ")}`; +} + async function validatePackageExtensionEntry(params: { packageDir: string; entry: string; @@ -142,12 +153,9 @@ export async function validatePackageExtensionEntriesForInstall(params: { continue; } - if (sourceEntry.exists) { - continue; - } - let foundBuiltEntry = false; - for (const builtEntry of listBuiltRuntimeEntryCandidates(entry)) { + const builtEntryCandidates = listBuiltRuntimeEntryCandidates(entry); + for (const builtEntry of builtEntryCandidates) { const builtResult = await validatePackageExtensionEntry({ packageDir: params.packageDir, entry: builtEntry, @@ -163,9 +171,37 @@ export async function validatePackageExtensionEntriesForInstall(params: { } } - if (!foundBuiltEntry) { - return { ok: false, error: `extension entry not found: ${entry}` }; + if (foundBuiltEntry) { + continue; } + + if (sourceEntry.exists && isTypeScriptPackageEntry(entry)) { + return { + ok: false, + error: missingCompiledRuntimeEntryMessage({ + label: "package install", + entry, + candidates: builtEntryCandidates, + }), + }; + } + + if (sourceEntry.exists) { + continue; + } + + if (builtEntryCandidates.length > 0) { + return { + ok: false, + error: missingCompiledRuntimeEntryMessage({ + label: "package install", + entry, + candidates: builtEntryCandidates, + }), + }; + } + + return { ok: false, error: `extension entry not found: ${entry}` }; } const packageManifest = getPackageManifestMetadata(params.manifest); @@ -201,12 +237,9 @@ export async function validatePackageExtensionEntriesForInstall(params: { return { ok: true }; } - if (sourceEntry.exists) { - return { ok: true }; - } - let foundBuiltSetupEntry = false; - for (const builtEntry of listBuiltRuntimeEntryCandidates(setupEntry)) { + const builtSetupCandidates = listBuiltRuntimeEntryCandidates(setupEntry); + for (const builtEntry of builtSetupCandidates) { const builtResult = await validatePackageExtensionEntry({ packageDir: params.packageDir, entry: builtEntry, @@ -221,9 +254,38 @@ export async function validatePackageExtensionEntriesForInstall(params: { break; } } - if (!foundBuiltSetupEntry) { - return { ok: false, error: `setup entry not found: ${setupEntry}` }; + + if (foundBuiltSetupEntry) { + return { ok: true }; } + + if (sourceEntry.exists && isTypeScriptPackageEntry(setupEntry)) { + return { + ok: false, + error: missingCompiledRuntimeEntryMessage({ + label: "package install", + entry: setupEntry, + candidates: builtSetupCandidates, + }), + }; + } + + if (sourceEntry.exists) { + return { ok: true }; + } + + if (builtSetupCandidates.length > 0) { + return { + ok: false, + error: missingCompiledRuntimeEntryMessage({ + label: "package install", + entry: setupEntry, + candidates: builtSetupCandidates, + }), + }; + } + + return { ok: false, error: `setup entry not found: ${setupEntry}` }; } return { ok: true }; @@ -296,6 +358,10 @@ function shouldInferBuiltRuntimeEntry(origin: PluginOrigin): boolean { return origin === "config" || origin === "global"; } +function shouldRequireBuiltRuntimeEntry(origin: PluginOrigin): boolean { + return origin === "global"; +} + function resolveSafePackageEntry(params: { packageDir: string; packageRootRealPath?: string; @@ -408,7 +474,8 @@ function resolvePackageRuntimeEntrySource(params: { } if (shouldInferBuiltRuntimeEntry(params.origin)) { - for (const candidate of listBuiltRuntimeEntryCandidates(safeEntry.relativePath)) { + const builtEntryCandidates = listBuiltRuntimeEntryCandidates(safeEntry.relativePath); + for (const candidate of builtEntryCandidates) { const runtimeSource = resolveExistingPackageEntrySource({ packageDir: params.packageDir, ...(params.packageRootRealPath !== undefined @@ -423,6 +490,21 @@ function resolvePackageRuntimeEntrySource(params: { return runtimeSource; } } + if ( + shouldRequireBuiltRuntimeEntry(params.origin) && + isTypeScriptPackageEntry(safeEntry.relativePath) + ) { + params.diagnostics.push({ + level: "error", + message: missingCompiledRuntimeEntryMessage({ + label: "installed plugin package", + entry: safeEntry.relativePath, + candidates: builtEntryCandidates, + }), + source: params.sourceLabel, + }); + return null; + } } if (safeEntry.existingSource) { diff --git a/src/plugins/package-entrypoints.ts b/src/plugins/package-entrypoints.ts index 65caa11d22b1..ccfe55ebdba4 100644 --- a/src/plugins/package-entrypoints.ts +++ b/src/plugins/package-entrypoints.ts @@ -1,6 +1,6 @@ import path from "node:path"; -function isTypeScriptPackageEntry(entryPath: string): boolean { +export function isTypeScriptPackageEntry(entryPath: string): boolean { return [".ts", ".mts", ".cts"].includes(path.extname(entryPath).toLowerCase()); }