From 28ae50b615e96706766459698689a0d247ec13a2 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 3 Apr 2026 17:51:00 -0400 Subject: [PATCH] fix(plugins): preserve install force semantics --- CHANGELOG.md | 2 +- docs/cli/plugins.md | 3 + docs/tools/plugin.md | 2 + src/cli/plugins-cli.install.test.ts | 10 +++ src/cli/plugins-install-command.ts | 4 ++ src/plugins/install.test.ts | 71 +++++++++++++++++++ src/plugins/install.ts | 103 +++++++++++++++++++++++----- 7 files changed, 175 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a59639e1602..9b32e0f5cf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 9571325ed2a..0583de90b5b 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -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. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index c67b1a65669..a4ecc95e237 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -222,6 +222,8 @@ openclaw plugins disable ``` `--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 diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index ee403243653..108d285b525 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -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"]), diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index 82bba1344cd..ae252e239d5 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -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, diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 0078f9e7857..1b9df3fce79 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -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 }); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 4f50a10b513..67f9caf9a8f 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -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 { 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>; + 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>; + 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, });