Plugins: add install force flag

This commit is contained in:
Gustavo Madeira Santana
2026-04-03 17:34:19 -04:00
parent 3fd29e549d
commit d456b5f996
7 changed files with 185 additions and 1 deletions

View File

@@ -16,6 +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.
### Fixes

View File

@@ -308,7 +308,7 @@ Manage extensions and their config:
- `openclaw plugins list` — discover plugins (use `--json` for machine output).
- `openclaw plugins inspect <id>` — show details for a plugin (`info` is an alias).
- `openclaw plugins install <path|.tgz|npm-spec|plugin@marketplace>` — install a plugin (or add a plugin path to `plugins.load.paths`).
- `openclaw plugins install <path|.tgz|npm-spec|plugin@marketplace>` — install a plugin (or add a plugin path to `plugins.load.paths`; use `--force` to overwrite an existing install target).
- `openclaw plugins marketplace list <marketplace>` — list marketplace entries before install.
- `openclaw plugins enable <id>` / `disable <id>` — toggle `plugins.entries.<id>.enabled`.
- `openclaw plugins doctor` — report plugin load errors.

View File

@@ -48,6 +48,7 @@ capabilities.
```bash
openclaw plugins install <package> # ClawHub first, then npm
openclaw plugins install clawhub:<package> # ClawHub only
openclaw plugins install <package> --force # overwrite existing install
openclaw plugins install <package> --pin # pin version
openclaw plugins install <package> --dangerously-force-unsafe-install
openclaw plugins install <path> # local path
@@ -58,6 +59,10 @@ openclaw plugins install <plugin> --marketplace <name> # marketplace (explicit)
Bare package names are checked against ClawHub first, then npm. Security note:
treat plugin installs like running code. Prefer pinned versions.
`--force` reuses the existing install target and overwrites an already-installed
plugin or hook pack in place. Use it when you are intentionally reinstalling
the same id from a new local path, archive, ClawHub package, or npm artifact.
`--dangerously-force-unsafe-install` is a break-glass option for false positives
in the built-in dangerous-code scanner. It allows the install to continue even
when the built-in scanner reports `critical` findings, but it does **not**

View File

@@ -209,6 +209,7 @@ openclaw plugins doctor # diagnostics
openclaw plugins install <package> # install (ClawHub first, then npm)
openclaw plugins install clawhub:<pkg> # install from ClawHub only
openclaw plugins install <spec> --force # overwrite existing install
openclaw plugins install <path> # install from local path
openclaw plugins install -l <path> # link (no copy) for dev
openclaw plugins install <spec> --dangerously-force-unsafe-install
@@ -220,6 +221,8 @@ openclaw plugins enable <id>
openclaw plugins disable <id>
```
`--force` overwrites an existing installed plugin or hook pack in place.
`--dangerously-force-unsafe-install` is a break-glass override for false
positives from the built-in dangerous-code scanner. It allows plugin installs
and plugin updates to continue past built-in `critical` findings, but it still

View File

@@ -86,6 +86,21 @@ describe("plugins cli install", () => {
resetPluginsCliTestState();
});
it("shows the force overwrite option in install help", async () => {
const { Command } = await import("commander");
const { registerPluginsCli } = await import("./plugins-cli.js");
const program = new Command();
registerPluginsCli(program);
const pluginsCommand = program.commands.find((command) => command.name() === "plugins");
const installCommand = pluginsCommand?.commands.find((command) => command.name() === "install");
const helpText = installCommand?.helpInformation() ?? "";
expect(helpText).toContain("--force");
expect(helpText).toContain("Overwrite an existing installed plugin or");
expect(helpText).toContain("hook pack");
});
it("exits when --marketplace is combined with --link", async () => {
await expect(
runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo", "--link"]),
@@ -197,6 +212,20 @@ describe("plugins cli install", () => {
expect(runtimeLogs.some((line) => line.includes("Installed plugin: alpha"))).toBe(true);
});
it("passes force through as overwrite mode for marketplace installs", async () => {
await expect(
runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo", "--force"]),
).rejects.toThrow("__exit__:1");
expect(installPluginFromMarketplace).toHaveBeenCalledWith(
expect.objectContaining({
marketplace: "local/repo",
plugin: "alpha",
mode: "update",
}),
);
});
it("installs ClawHub plugins and persists source metadata", async () => {
const cfg = {
plugins: {
@@ -256,6 +285,41 @@ describe("plugins cli install", () => {
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
});
it("passes force through as overwrite mode for ClawHub installs", async () => {
const cfg = {
plugins: {
entries: {},
},
} as OpenClawConfig;
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
installPluginFromClawHub.mockResolvedValue(
createClawHubInstallResult({
pluginId: "demo",
packageName: "demo",
version: "1.2.3",
channel: "official",
}),
);
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(enabledCfg);
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
await runPluginsCommand(["plugins", "install", "clawhub:demo", "--force"]);
expect(installPluginFromClawHub).toHaveBeenCalledWith(
expect.objectContaining({
spec: "clawhub:demo",
mode: "update",
}),
);
});
it("prefers ClawHub before npm for bare plugin specs", async () => {
const cfg = {
plugins: {
@@ -417,6 +481,48 @@ describe("plugins cli install", () => {
);
});
it("passes force through as overwrite mode for npm installs", async () => {
const cfg = {
plugins: {
entries: {},
},
} as OpenClawConfig;
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
installPluginFromClawHub.mockResolvedValue({
ok: false,
error: "ClawHub /api/v1/packages/demo failed (404): Package not found",
code: "package_not_found",
});
installPluginFromNpmSpec.mockResolvedValue({
ok: true,
pluginId: "demo",
targetDir: cliInstallPath("demo"),
version: "1.2.3",
npmResolution: {
packageName: "demo",
resolvedVersion: "1.2.3",
tarballUrl: "https://registry.npmjs.org/demo/-/demo-1.2.3.tgz",
},
});
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(enabledCfg);
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
await runPluginsCommand(["plugins", "install", "demo", "--force"]);
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "demo",
mode: "update",
}),
);
});
it("does not fall back to npm when ClawHub rejects a real package", async () => {
installPluginFromClawHub.mockResolvedValue({
ok: false,
@@ -486,4 +592,53 @@ describe("plugins cli install", () => {
expect(writeConfigFile).toHaveBeenCalledWith(installedCfg);
expect(runtimeLogs.some((line) => line.includes("Installed hook pack: demo-hooks"))).toBe(true);
});
it("passes force through as overwrite mode for hook-pack npm fallback installs", async () => {
const cfg = {} as OpenClawConfig;
const installedCfg = {
hooks: {
internal: {
installs: {
"demo-hooks": {
source: "npm",
spec: "@acme/demo-hooks@1.2.3",
},
},
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(cfg);
installPluginFromClawHub.mockResolvedValue({
ok: false,
error: "ClawHub /api/v1/packages/@acme/demo-hooks failed (404): Package not found",
code: "package_not_found",
});
installPluginFromNpmSpec.mockResolvedValue({
ok: false,
error: "package.json missing openclaw.plugin.json",
});
installHooksFromNpmSpec.mockResolvedValue({
ok: true,
hookPackId: "demo-hooks",
hooks: ["command-audit"],
targetDir: "/tmp/hooks/demo-hooks",
version: "1.2.3",
npmResolution: {
name: "@acme/demo-hooks",
spec: "@acme/demo-hooks@1.2.3",
integrity: "sha256-demo",
},
});
recordHookInstall.mockReturnValue(installedCfg);
await runPluginsCommand(["plugins", "install", "@acme/demo-hooks", "--force"]);
expect(installHooksFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@acme/demo-hooks",
mode: "update",
}),
);
});
});

View File

@@ -770,6 +770,7 @@ export function registerPluginsCli(program: Command) {
"Path (.ts/.js/.zip/.tgz/.tar.gz), npm package spec, or marketplace plugin name",
)
.option("-l, --link", "Link a local path instead of copying", false)
.option("--force", "Overwrite an existing installed plugin or hook pack", false)
.option("--pin", "Record npm installs as exact resolved <name>@<version>", false)
.option(
"--dangerously-force-unsafe-install",
@@ -785,6 +786,7 @@ export function registerPluginsCli(program: Command) {
raw: string,
opts: {
dangerouslyForceUnsafeInstall?: boolean;
force?: boolean;
link?: boolean;
pin?: boolean;
marketplace?: string;

View File

@@ -38,6 +38,10 @@ import {
} from "./plugins-command-helpers.js";
import { persistHookPackInstall, persistPluginInstall } from "./plugins-install-persist.js";
function resolveInstallMode(force?: boolean): "install" | "update" {
return force ? "update" : "install";
}
async function installBundledPluginSource(params: {
config: OpenClawConfig;
rawSpec: string;
@@ -71,6 +75,7 @@ async function installBundledPluginSource(params: {
async function tryInstallHookPackFromLocalPath(params: {
config: OpenClawConfig;
resolvedPath: string;
installMode: "install" | "update";
link?: boolean;
}): Promise<{ ok: true } | { ok: false; error: string }> {
if (params.link) {
@@ -122,6 +127,7 @@ async function tryInstallHookPackFromLocalPath(params: {
const result = await installHooksFromPath({
path: params.resolvedPath,
mode: params.installMode,
logger: createHookPackInstallLogger(),
});
if (!result.ok) {
@@ -145,11 +151,13 @@ async function tryInstallHookPackFromLocalPath(params: {
async function tryInstallHookPackFromNpmSpec(params: {
config: OpenClawConfig;
installMode: "install" | "update";
spec: string;
pin?: boolean;
}): Promise<{ ok: true } | { ok: false; error: string }> {
const result = await installHooksFromNpmSpec({
spec: params.spec,
mode: params.installMode,
logger: createHookPackInstallLogger(),
});
if (!result.ok) {
@@ -245,6 +253,7 @@ export async function loadConfigForInstall(
export async function runPluginInstallCommand(params: {
raw: string;
opts: InstallSafetyOverrides & {
force?: boolean;
link?: boolean;
pin?: boolean;
marketplace?: string;
@@ -290,11 +299,13 @@ export async function runPluginInstallCommand(params: {
if (!cfg) {
return defaultRuntime.exit(1);
}
const installMode = resolveInstallMode(opts.force);
if (opts.marketplace) {
const result = await installPluginFromMarketplace({
dangerouslyForceUnsafeInstall: opts.dangerouslyForceUnsafeInstall,
marketplace: opts.marketplace,
mode: installMode,
plugin: raw,
logger: createPluginInstallLogger(),
});
@@ -329,6 +340,7 @@ export async function runPluginInstallCommand(params: {
if (!probe.ok) {
const hookFallback = await tryInstallHookPackFromLocalPath({
config: cfg,
installMode,
resolvedPath: resolved,
link: true,
});
@@ -366,12 +378,14 @@ export async function runPluginInstallCommand(params: {
const result = await installPluginFromPath({
dangerouslyForceUnsafeInstall: opts.dangerouslyForceUnsafeInstall,
mode: installMode,
path: resolved,
logger: createPluginInstallLogger(),
});
if (!result.ok) {
const hookFallback = await tryInstallHookPackFromLocalPath({
config: cfg,
installMode,
resolvedPath: resolved,
});
if (hookFallback.ok) {
@@ -437,6 +451,7 @@ export async function runPluginInstallCommand(params: {
if (clawhubSpec) {
const result = await installPluginFromClawHub({
dangerouslyForceUnsafeInstall: opts.dangerouslyForceUnsafeInstall,
mode: installMode,
spec: raw,
logger: createPluginInstallLogger(),
});
@@ -472,6 +487,7 @@ export async function runPluginInstallCommand(params: {
if (preferredClawHubSpec) {
const clawhubResult = await installPluginFromClawHub({
dangerouslyForceUnsafeInstall: opts.dangerouslyForceUnsafeInstall,
mode: installMode,
spec: preferredClawHubSpec,
logger: createPluginInstallLogger(),
});
@@ -506,6 +522,7 @@ export async function runPluginInstallCommand(params: {
const result = await installPluginFromNpmSpec({
dangerouslyForceUnsafeInstall: opts.dangerouslyForceUnsafeInstall,
mode: installMode,
spec: raw,
logger: createPluginInstallLogger(),
});
@@ -518,6 +535,7 @@ export async function runPluginInstallCommand(params: {
if (!bundledFallbackPlan) {
const hookFallback = await tryInstallHookPackFromNpmSpec({
config: cfg,
installMode,
spec: raw,
pin: opts.pin,
});