fix(plugins): preserve install force semantics

This commit is contained in:
Gustavo Madeira Santana
2026-04-03 17:51:00 -04:00
parent d456b5f996
commit 28ae50b615
7 changed files with 175 additions and 20 deletions

View File

@@ -16,7 +16,7 @@ Docs: https://docs.openclaw.ai
- Tests/runtime: trim local unit-test import/runtime fan-out across browser, WhatsApp, cron, task, and reply flows so owner suites start faster with lower shared-worker overhead while preserving the same focused behavior coverage. (#60249) Thanks @shakkernerd.
- Tests/secrets runtime: restore split secrets suite cache and env isolation cleanup so broader runs do not leak stale plugin or provider snapshot state. (#60395) Thanks @shakkernerd.
- Providers/Ollama: add bundled Ollama Web Search provider for key-free web_search via your configured Ollama host and `ollama signin`. (#59318) Thanks @BruceMacD.
- Plugins/install: add `openclaw plugins install --force` to overwrite existing plugin and hook-pack install targets without using the dangerous-code override flag.
- Plugins/install: add `openclaw plugins install --force` to overwrite existing plugin and hook-pack install targets without using the dangerous-code override flag. (#60544) Thanks @gumadeiras.
### Fixes

View File

@@ -162,6 +162,9 @@ Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
openclaw plugins install -l ./my-plugin
```
`--force` is not supported with `--link` because linked installs reuse the
source path instead of copying over a managed install target.
Use `--pin` on npm installs to save the resolved exact spec (`name@version`) in
`plugins.installs` while keeping the default behavior unpinned.

View File

@@ -222,6 +222,8 @@ openclaw plugins disable <id>
```
`--force` overwrites an existing installed plugin or hook pack in place.
It is not supported with `--link`, which reuses the source path instead of
copying over a managed install target.
`--dangerously-force-unsafe-install` is a break-glass override for false
positives from the built-in dangerous-code scanner. It allows plugin installs

View File

@@ -110,6 +110,16 @@ describe("plugins cli install", () => {
expect(installPluginFromMarketplace).not.toHaveBeenCalled();
});
it("exits when --force is combined with --link", async () => {
await expect(
runPluginsCommand(["plugins", "install", "./plugin", "--link", "--force"]),
).rejects.toThrow("__exit__:1");
expect(runtimeErrors.at(-1)).toContain("`--force` is not supported with `--link`.");
expect(installPluginFromMarketplace).not.toHaveBeenCalled();
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
});
it("exits when marketplace install fails", async () => {
await expect(
runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo"]),

View File

@@ -283,6 +283,10 @@ export async function runPluginInstallCommand(params: {
return defaultRuntime.exit(1);
}
}
if (opts.link && opts.force) {
defaultRuntime.error("`--force` is not supported with `--link`.");
return defaultRuntime.exit(1);
}
const requestResolution = resolvePluginInstallRequestContext({
rawSpec: raw,
marketplace: opts.marketplace,

View File

@@ -207,12 +207,14 @@ async function installFromDirWithWarnings(params: {
pluginDir: string;
extensionsDir: string;
dangerouslyForceUnsafeInstall?: boolean;
mode?: "install" | "update";
}) {
const warnings: string[] = [];
const result = await installPluginFromDir({
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
dirPath: params.pluginDir,
extensionsDir: params.extensionsDir,
mode: params.mode,
logger: {
info: () => {},
warn: (msg: string) => warnings.push(msg),
@@ -1005,6 +1007,75 @@ describe("installPluginFromArchive", () => {
).toBe(true);
});
it("reports install mode to before_install when force-style update runs against a missing target", async () => {
const handler = vi.fn().mockReturnValue({});
initializeGlobalHookRunner(createMockPluginRegistry([{ hookName: "before_install", handler }]));
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "fresh-force-plugin",
version: "1.0.0",
openclaw: { extensions: ["index.js"] },
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n");
const { result } = await installFromDirWithWarnings({
pluginDir,
extensionsDir,
mode: "update",
});
expect(result.ok).toBe(true);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0]?.[0]).toMatchObject({
request: {
kind: "plugin-dir",
mode: "install",
},
});
});
it("reports update mode to before_install when replacing an existing target", async () => {
const handler = vi.fn().mockReturnValue({});
initializeGlobalHookRunner(createMockPluginRegistry([{ hookName: "before_install", handler }]));
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
const existingTargetDir = resolvePluginInstallDir("replace-force-plugin", extensionsDir);
fs.mkdirSync(existingTargetDir, { recursive: true });
fs.writeFileSync(
path.join(existingTargetDir, "package.json"),
JSON.stringify({ version: "0.9.0" }),
);
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "replace-force-plugin",
version: "1.0.0",
openclaw: { extensions: ["index.js"] },
}),
);
fs.writeFileSync(path.join(pluginDir, "index.js"), "export {};\n");
const { result } = await installFromDirWithWarnings({
pluginDir,
extensionsDir,
mode: "update",
});
expect(result.ok).toBe(true);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0]?.[0]).toMatchObject({
request: {
kind: "plugin-dir",
mode: "update",
},
});
});
it("scans extension entry files in hidden directories", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.mkdirSync(path.join(pluginDir, ".hidden"), { recursive: true });

View File

@@ -283,6 +283,7 @@ async function installPluginDirectoryIntoExtensions(params: {
manifestName?: string;
version?: string;
extensions: string[];
targetDir?: string;
extensionsDir?: string;
logger: PluginInstallLogger;
timeoutMs: number;
@@ -295,20 +296,19 @@ async function installPluginDirectoryIntoExtensions(params: {
nameEncoder?: (pluginId: string) => string;
}): Promise<InstallPluginResult> {
const runtime = await loadPluginInstallRuntime();
const extensionsDir = params.extensionsDir
? resolveUserPath(params.extensionsDir)
: path.join(CONFIG_DIR, "extensions");
const targetDirResult = await runtime.resolveCanonicalInstallTarget({
baseDir: extensionsDir,
id: params.pluginId,
invalidNameMessage: "invalid plugin name: path traversal detected",
boundaryLabel: "extensions directory",
nameEncoder: params.nameEncoder,
});
if (!targetDirResult.ok) {
return { ok: false, error: targetDirResult.error };
let targetDir = params.targetDir;
if (!targetDir) {
const targetDirResult = await resolvePluginInstallTarget({
runtime,
pluginId: params.pluginId,
extensionsDir: params.extensionsDir,
nameEncoder: params.nameEncoder,
});
if (!targetDirResult.ok) {
return { ok: false, error: targetDirResult.error };
}
targetDir = targetDirResult.targetDir;
}
const targetDir = targetDirResult.targetDir;
const availability = await runtime.ensureInstallTargetAvailable({
mode: params.mode,
targetDir,
@@ -372,6 +372,35 @@ export function resolvePluginInstallDir(pluginId: string, extensionsDir?: string
return targetDirResult.path;
}
async function resolvePluginInstallTarget(params: {
runtime: Awaited<ReturnType<typeof loadPluginInstallRuntime>>;
pluginId: string;
extensionsDir?: string;
nameEncoder?: (pluginId: string) => string;
}): Promise<{ ok: true; targetDir: string } | { ok: false; error: string }> {
const extensionsDir = params.extensionsDir
? resolveUserPath(params.extensionsDir)
: path.join(CONFIG_DIR, "extensions");
return await params.runtime.resolveCanonicalInstallTarget({
baseDir: extensionsDir,
id: params.pluginId,
invalidNameMessage: "invalid plugin name: path traversal detected",
boundaryLabel: "extensions directory",
nameEncoder: params.nameEncoder,
});
}
async function resolveEffectiveInstallMode(params: {
runtime: Awaited<ReturnType<typeof loadPluginInstallRuntime>>;
requestedMode: "install" | "update";
targetPath: string;
}): Promise<"install" | "update"> {
if (params.requestedMode !== "update") {
return "install";
}
return (await params.runtime.fileExists(params.targetPath)) ? "update" : "install";
}
async function installBundleFromSourceDir(
params: {
sourceDir: string;
@@ -409,6 +438,20 @@ async function installBundleFromSourceDir(
};
}
const targetDirResult = await resolvePluginInstallTarget({
runtime,
pluginId,
extensionsDir: params.extensionsDir,
});
if (!targetDirResult.ok) {
return { ok: false, error: targetDirResult.error };
}
const effectiveMode = await resolveEffectiveInstallMode({
runtime,
requestedMode: mode,
targetPath: targetDirResult.targetDir,
});
try {
const scanResult = await runtime.scanBundleInstallSource({
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
@@ -417,7 +460,7 @@ async function installBundleFromSourceDir(
logger,
requestKind: params.installPolicyRequest?.kind,
requestedSpecifier: params.installPolicyRequest?.requestedSpecifier,
mode,
mode: effectiveMode,
version: manifestRes.manifest.version,
});
if (scanResult?.blocked) {
@@ -437,10 +480,11 @@ async function installBundleFromSourceDir(
manifestName: manifestRes.manifest.name,
version: manifestRes.manifest.version,
extensions: [],
targetDir: targetDirResult.targetDir,
extensionsDir: params.extensionsDir,
logger,
timeoutMs,
mode,
mode: effectiveMode,
dryRun,
copyErrorPrefix: "failed to copy plugin bundle",
hasDeps: false,
@@ -588,6 +632,21 @@ async function installPluginFromPackageDir(
code: PLUGIN_INSTALL_ERROR_CODE.INCOMPATIBLE_HOST_VERSION,
};
}
const targetDirResult = await resolvePluginInstallTarget({
runtime,
pluginId,
extensionsDir: params.extensionsDir,
nameEncoder: encodePluginInstallDirName,
});
if (!targetDirResult.ok) {
return { ok: false, error: targetDirResult.error };
}
const effectiveMode = await resolveEffectiveInstallMode({
runtime,
requestedMode: mode,
targetPath: targetDirResult.targetDir,
});
try {
const scanResult = await runtime.scanPackageInstallSource({
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
@@ -597,7 +656,7 @@ async function installPluginFromPackageDir(
extensions,
requestKind: params.installPolicyRequest?.kind,
requestedSpecifier: params.installPolicyRequest?.requestedSpecifier,
mode,
mode: effectiveMode,
packageName: pkgName || undefined,
manifestId: manifestPluginId,
version: typeof manifest.version === "string" ? manifest.version : undefined,
@@ -620,10 +679,11 @@ async function installPluginFromPackageDir(
manifestName: pkgName || undefined,
version: typeof manifest.version === "string" ? manifest.version : undefined,
extensions,
targetDir: targetDirResult.targetDir,
extensionsDir: params.extensionsDir,
logger,
timeoutMs,
mode,
mode: effectiveMode,
dryRun,
copyErrorPrefix: "failed to copy plugin",
hasDeps: Object.keys(deps).length > 0,
@@ -747,9 +807,14 @@ export async function installPluginFromFile(params: {
return { ok: false, error: pluginIdError };
}
const targetFile = path.join(extensionsDir, `${safeFileName(pluginId)}${path.extname(filePath)}`);
const effectiveMode = await resolveEffectiveInstallMode({
runtime,
requestedMode: mode,
targetPath: targetFile,
});
const availability = await runtime.ensureInstallTargetAvailable({
mode,
mode: effectiveMode,
targetDir: targetFile,
alreadyExistsError: `plugin already exists: ${targetFile} (delete it first)`,
});
@@ -766,7 +831,7 @@ export async function installPluginFromFile(params: {
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
filePath,
logger,
mode,
mode: effectiveMode,
pluginId,
requestedSpecifier: installPolicyRequest.requestedSpecifier,
});