fix: reject source-only plugin package installs

This commit is contained in:
Peter Steinberger
2026-05-03 16:47:43 +01:00
parent 0a2b87c986
commit 59c523c6b5
6 changed files with 187 additions and 20 deletions
+1
View File
@@ -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.
+3
View File
@@ -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
+58 -3
View File
@@ -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);
+26
View File
@@ -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 });
+98 -16
View File
@@ -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) {
+1 -1
View File
@@ -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());
}