mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix: reject source-only plugin package installs
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,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());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user