mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(plugins): prefer bundled plugin ids over bare npm specs
This commit is contained in:
@@ -48,6 +48,10 @@ Security note: treat plugin installs like running code. Prefer pinned versions.
|
||||
Npm specs are **registry-only** (package name + optional version/tag). Git/URL/file
|
||||
specs are rejected. Dependency installs run with `--ignore-scripts` for safety.
|
||||
|
||||
If a bare install spec matches a bundled plugin id (for example `diffs`), OpenClaw
|
||||
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`.
|
||||
|
||||
Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
|
||||
|
||||
@@ -6,7 +6,11 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loadConfig, writeConfigFile } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { resolveArchiveKind } from "../infra/archive.js";
|
||||
import { findBundledPluginByNpmSpec } from "../plugins/bundled-sources.js";
|
||||
import {
|
||||
type BundledPluginSource,
|
||||
findBundledPluginByNpmSpec,
|
||||
findBundledPluginByPluginId,
|
||||
} from "../plugins/bundled-sources.js";
|
||||
import { enablePluginInConfig } from "../plugins/enable.js";
|
||||
import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js";
|
||||
import { recordPluginInstall } from "../plugins/installs.js";
|
||||
@@ -159,19 +163,53 @@ function isPackageNotFoundInstallError(message: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when npm downloaded a package successfully but it is not a valid
|
||||
* OpenClaw plugin (e.g. `diffs` resolves to the unrelated npm package
|
||||
* `diffs@0.1.1` instead of `@openclaw/diffs`).
|
||||
* See: https://github.com/openclaw/openclaw/issues/32019
|
||||
*/
|
||||
function isNotAnOpenClawPluginError(message: string): boolean {
|
||||
const lower = message.toLowerCase();
|
||||
return (
|
||||
lower.includes("missing openclaw.extensions") || lower.includes("openclaw.extensions is empty")
|
||||
);
|
||||
function isBareNpmPackageName(spec: string): boolean {
|
||||
const trimmed = spec.trim();
|
||||
return /^[a-z0-9][a-z0-9-._~]*$/.test(trimmed);
|
||||
}
|
||||
|
||||
async function installBundledPluginSource(params: {
|
||||
config: OpenClawConfig;
|
||||
rawSpec: string;
|
||||
bundledSource: BundledPluginSource;
|
||||
warning: string;
|
||||
}) {
|
||||
const existing = params.config.plugins?.load?.paths ?? [];
|
||||
const mergedPaths = Array.from(new Set([...existing, params.bundledSource.localPath]));
|
||||
let next: OpenClawConfig = {
|
||||
...params.config,
|
||||
plugins: {
|
||||
...params.config.plugins,
|
||||
load: {
|
||||
...params.config.plugins?.load,
|
||||
paths: mergedPaths,
|
||||
},
|
||||
entries: {
|
||||
...params.config.plugins?.entries,
|
||||
[params.bundledSource.pluginId]: {
|
||||
...(params.config.plugins?.entries?.[params.bundledSource.pluginId] as
|
||||
| object
|
||||
| undefined),
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
next = recordPluginInstall(next, {
|
||||
pluginId: params.bundledSource.pluginId,
|
||||
source: "path",
|
||||
spec: params.rawSpec,
|
||||
sourcePath: params.bundledSource.localPath,
|
||||
installPath: params.bundledSource.localPath,
|
||||
});
|
||||
const slotResult = applySlotSelectionForPlugin(next, params.bundledSource.pluginId);
|
||||
next = slotResult.config;
|
||||
await writeConfigFile(next);
|
||||
logSlotWarnings(slotResult.warnings);
|
||||
defaultRuntime.log(theme.warn(params.warning));
|
||||
defaultRuntime.log(`Installed plugin: ${params.bundledSource.pluginId}`);
|
||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||
}
|
||||
export function registerPluginsCli(program: Command) {
|
||||
const plugins = program
|
||||
.command("plugins")
|
||||
@@ -633,59 +671,38 @@ export function registerPluginsCli(program: Command) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const bundledByPluginId = isBareNpmPackageName(raw)
|
||||
? findBundledPluginByPluginId({ pluginId: raw })
|
||||
: undefined;
|
||||
if (bundledByPluginId) {
|
||||
await installBundledPluginSource({
|
||||
config: cfg,
|
||||
rawSpec: raw,
|
||||
bundledSource: bundledByPluginId,
|
||||
warning: `Using bundled plugin "${bundledByPluginId.pluginId}" from ${shortenHomePath(bundledByPluginId.localPath)} for bare install spec "${raw}". To install an npm package with the same name, use a scoped package name (for example @scope/${raw}).`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await installPluginFromNpmSpec({
|
||||
spec: raw,
|
||||
logger: createPluginInstallLogger(),
|
||||
});
|
||||
if (!result.ok) {
|
||||
const isNpmNotFound = isPackageNotFoundInstallError(result.error);
|
||||
const isNotPlugin = isNotAnOpenClawPluginError(result.error);
|
||||
const bundledFallback =
|
||||
isNpmNotFound || isNotPlugin ? findBundledPluginByNpmSpec({ spec: raw }) : undefined;
|
||||
const bundledFallback = isPackageNotFoundInstallError(result.error)
|
||||
? findBundledPluginByNpmSpec({ spec: raw })
|
||||
: undefined;
|
||||
if (!bundledFallback) {
|
||||
defaultRuntime.error(result.error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const existing = cfg.plugins?.load?.paths ?? [];
|
||||
const mergedPaths = Array.from(new Set([...existing, bundledFallback.localPath]));
|
||||
let next: OpenClawConfig = {
|
||||
...cfg,
|
||||
plugins: {
|
||||
...cfg.plugins,
|
||||
load: {
|
||||
...cfg.plugins?.load,
|
||||
paths: mergedPaths,
|
||||
},
|
||||
entries: {
|
||||
...cfg.plugins?.entries,
|
||||
[bundledFallback.pluginId]: {
|
||||
...(cfg.plugins?.entries?.[bundledFallback.pluginId] as object | undefined),
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
next = recordPluginInstall(next, {
|
||||
pluginId: bundledFallback.pluginId,
|
||||
source: "path",
|
||||
spec: raw,
|
||||
sourcePath: bundledFallback.localPath,
|
||||
installPath: bundledFallback.localPath,
|
||||
await installBundledPluginSource({
|
||||
config: cfg,
|
||||
rawSpec: raw,
|
||||
bundledSource: bundledFallback,
|
||||
warning: `npm package unavailable for ${raw}; using bundled plugin at ${shortenHomePath(bundledFallback.localPath)}.`,
|
||||
});
|
||||
const slotResult = applySlotSelectionForPlugin(next, bundledFallback.pluginId);
|
||||
next = slotResult.config;
|
||||
await writeConfigFile(next);
|
||||
logSlotWarnings(slotResult.warnings);
|
||||
defaultRuntime.log(
|
||||
theme.warn(
|
||||
isNpmNotFound
|
||||
? `npm package unavailable for ${raw}; using bundled plugin at ${shortenHomePath(bundledFallback.localPath)}.`
|
||||
: `npm package "${raw}" is not a valid OpenClaw plugin; using bundled plugin at ${shortenHomePath(bundledFallback.localPath)}.`,
|
||||
),
|
||||
);
|
||||
defaultRuntime.log(`Installed plugin: ${bundledFallback.pluginId}`);
|
||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||
return;
|
||||
}
|
||||
// Ensure config validation sees newly installed plugin(s) even if the cache was warmed at startup.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { findBundledPluginByNpmSpec, resolveBundledPluginSources } from "./bundled-sources.js";
|
||||
import {
|
||||
findBundledPluginByNpmSpec,
|
||||
findBundledPluginByPluginId,
|
||||
resolveBundledPluginSources,
|
||||
} from "./bundled-sources.js";
|
||||
|
||||
const discoverOpenClawPluginsMock = vi.fn();
|
||||
const loadPluginManifestMock = vi.fn();
|
||||
@@ -95,25 +99,25 @@ describe("bundled plugin sources", () => {
|
||||
expect(missing).toBeUndefined();
|
||||
});
|
||||
|
||||
it("finds bundled source by plugin id when npm spec does not match (#32019)", () => {
|
||||
it("finds bundled source by plugin id", () => {
|
||||
discoverOpenClawPluginsMock.mockReturnValue({
|
||||
candidates: [
|
||||
{
|
||||
origin: "bundled",
|
||||
rootDir: "/app/extensions/diffs",
|
||||
packageName: "@openclaw/diffs",
|
||||
packageManifest: {},
|
||||
packageManifest: { install: { npmSpec: "@openclaw/diffs" } },
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
loadPluginManifestMock.mockReturnValue({ ok: true, manifest: { id: "diffs" } });
|
||||
|
||||
// Searching by unscoped name "diffs" should match by pluginId even though
|
||||
// the npmSpec is "@openclaw/diffs".
|
||||
const resolved = findBundledPluginByNpmSpec({ spec: "diffs" });
|
||||
const resolved = findBundledPluginByPluginId({ pluginId: "diffs" });
|
||||
const missing = findBundledPluginByPluginId({ pluginId: "not-found" });
|
||||
|
||||
expect(resolved?.pluginId).toBe("diffs");
|
||||
expect(resolved?.localPath).toBe("/app/extensions/diffs");
|
||||
expect(missing).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,3 +64,15 @@ export function findBundledPluginByNpmSpec(params: {
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function findBundledPluginByPluginId(params: {
|
||||
pluginId: string;
|
||||
workspaceDir?: string;
|
||||
}): BundledPluginSource | undefined {
|
||||
const targetPluginId = params.pluginId.trim();
|
||||
if (!targetPluginId) {
|
||||
return undefined;
|
||||
}
|
||||
const bundled = resolveBundledPluginSources({ workspaceDir: params.workspaceDir });
|
||||
return bundled.get(targetPluginId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user