From 41e16a883b8117ee4715198db4b01174b7554abe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Apr 2026 12:22:00 +0900 Subject: [PATCH] fix(cli): honor unsafe override for linked installs --- src/cli/plugins-cli-test-helpers.ts | 10 ++ src/cli/plugins-cli.install.test.ts | 153 ++++++++++++++++++++++++++++ src/cli/plugins-install-command.ts | 11 +- src/hooks/install.ts | 4 +- 4 files changed, 176 insertions(+), 2 deletions(-) diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index 5616591fe38..355c2467bb7 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -32,6 +32,7 @@ export const resolveMarketplaceInstallShortcut: Mock ({ vi.mock("../plugins/manifest-registry.js", () => ({ clearPluginManifestRegistryCache: () => clearPluginManifestRegistryCache(), + loadPluginManifestRegistry: ((...args: unknown[]) => + invokeMock(loadPluginManifestRegistry, ...args)) as ( + ...args: unknown[] + ) => unknown, })); vi.mock("../plugins/status.js", () => ({ @@ -333,6 +338,7 @@ export function resetPluginsCliTestState() { enablePluginInConfig.mockReset(); recordPluginInstall.mockReset(); clearPluginManifestRegistryCache.mockReset(); + loadPluginManifestRegistry.mockReset(); buildPluginSnapshotReport.mockReset(); buildPluginDiagnosticsReport.mockReset(); buildPluginCompatibilityNotices.mockReset(); @@ -385,6 +391,10 @@ export function resetPluginsCliTestState() { recordPluginInstall.mockImplementation( ((cfg: OpenClawConfig) => cfg) as (...args: unknown[]) => unknown, ); + loadPluginManifestRegistry.mockReturnValue({ + plugins: [], + diagnostics: [], + }); const defaultPluginReport = { plugins: [], diagnostics: [], diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index 108d285b525..0c33f404c16 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; import { installedPluginRoot } from "../../test/helpers/bundled-plugin-paths.js"; import type { OpenClawConfig } from "../config/config.js"; @@ -6,10 +9,12 @@ import { buildPluginDiagnosticsReport, clearPluginManifestRegistryCache, enablePluginInConfig, + installHooksFromPath, installHooksFromNpmSpec, installPluginFromClawHub, installPluginFromMarketplace, installPluginFromNpmSpec, + installPluginFromPath, loadConfig, readConfigFileSnapshot, parseClawHubPluginSpec, @@ -491,6 +496,154 @@ describe("plugins cli install", () => { ); }); + it("passes dangerous force unsafe install to linked path probe installs", async () => { + const cfg = { + plugins: { + entries: {}, + }, + } as OpenClawConfig; + const enabledCfg = createEnabledPluginConfig("demo"); + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-link-")); + + loadConfig.mockReturnValue(cfg); + installPluginFromPath.mockResolvedValueOnce({ + ok: true, + pluginId: "demo", + targetDir: tmpRoot, + version: "1.2.3", + extensions: ["./dist/index.js"], + }); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + recordPluginInstall.mockReturnValue(enabledCfg); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + try { + await runPluginsCommand([ + "plugins", + "install", + tmpRoot, + "--link", + "--dangerously-force-unsafe-install", + ]); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + + expect(installPluginFromPath).toHaveBeenCalledWith( + expect.objectContaining({ + path: tmpRoot, + dryRun: true, + dangerouslyForceUnsafeInstall: true, + }), + ); + }); + + it("passes dangerous force unsafe install to linked hook-pack probe fallback", async () => { + const cfg = {} as OpenClawConfig; + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hook-link-")); + const installedCfg = { + hooks: { + internal: { + installs: { + "demo-hooks": { + source: "path", + sourcePath: tmpRoot, + installPath: tmpRoot, + }, + }, + }, + }, + } as OpenClawConfig; + + loadConfig.mockReturnValue(cfg); + installPluginFromPath.mockResolvedValueOnce({ + ok: false, + error: "plugin install probe failed", + }); + installHooksFromPath.mockResolvedValueOnce({ + ok: true, + hookPackId: "demo-hooks", + hooks: ["command-audit"], + targetDir: tmpRoot, + version: "1.2.3", + }); + recordHookInstall.mockReturnValue(installedCfg); + + try { + await runPluginsCommand([ + "plugins", + "install", + tmpRoot, + "--link", + "--dangerously-force-unsafe-install", + ]); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + + expect(installHooksFromPath).toHaveBeenCalledWith( + expect.objectContaining({ + path: tmpRoot, + dryRun: true, + dangerouslyForceUnsafeInstall: true, + }), + ); + }); + + it("passes dangerous force unsafe install to local hook-pack fallback installs", async () => { + const cfg = {} as OpenClawConfig; + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hook-install-")); + const installedCfg = { + hooks: { + internal: { + installs: { + "demo-hooks": { + source: "path", + sourcePath: tmpRoot, + installPath: tmpRoot, + }, + }, + }, + }, + } as OpenClawConfig; + + loadConfig.mockReturnValue(cfg); + installPluginFromPath.mockResolvedValueOnce({ + ok: false, + error: "plugin install failed", + }); + installHooksFromPath.mockResolvedValueOnce({ + ok: true, + hookPackId: "demo-hooks", + hooks: ["command-audit"], + targetDir: tmpRoot, + version: "1.2.3", + }); + recordHookInstall.mockReturnValue(installedCfg); + + try { + await runPluginsCommand([ + "plugins", + "install", + tmpRoot, + "--dangerously-force-unsafe-install", + ]); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } + + expect(installHooksFromPath).toHaveBeenCalledWith( + expect.objectContaining({ + path: tmpRoot, + mode: "install", + dangerouslyForceUnsafeInstall: true, + }), + ); + }); + it("passes force through as overwrite mode for npm installs", async () => { const cfg = { plugins: { diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index ae252e239d5..21e6f8bdb85 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -76,6 +76,7 @@ async function tryInstallHookPackFromLocalPath(params: { config: OpenClawConfig; resolvedPath: string; installMode: "install" | "update"; + dangerouslyForceUnsafeInstall?: boolean; link?: boolean; }): Promise<{ ok: true } | { ok: false; error: string }> { if (params.link) { @@ -88,6 +89,7 @@ async function tryInstallHookPackFromLocalPath(params: { } const probe = await installHooksFromPath({ + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, path: params.resolvedPath, dryRun: true, }); @@ -126,6 +128,7 @@ async function tryInstallHookPackFromLocalPath(params: { } const result = await installHooksFromPath({ + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, path: params.resolvedPath, mode: params.installMode, logger: createHookPackInstallLogger(), @@ -340,10 +343,15 @@ export async function runPluginInstallCommand(params: { if (opts.link) { const existing = cfg.plugins?.load?.paths ?? []; const merged = Array.from(new Set([...existing, resolved])); - const probe = await installPluginFromPath({ path: resolved, dryRun: true }); + const probe = await installPluginFromPath({ + dangerouslyForceUnsafeInstall: opts.dangerouslyForceUnsafeInstall, + path: resolved, + dryRun: true, + }); if (!probe.ok) { const hookFallback = await tryInstallHookPackFromLocalPath({ config: cfg, + dangerouslyForceUnsafeInstall: opts.dangerouslyForceUnsafeInstall, installMode, resolvedPath: resolved, link: true, @@ -389,6 +397,7 @@ export async function runPluginInstallCommand(params: { if (!result.ok) { const hookFallback = await tryInstallHookPackFromLocalPath({ config: cfg, + dangerouslyForceUnsafeInstall: opts.dangerouslyForceUnsafeInstall, installMode, resolvedPath: resolved, }); diff --git a/src/hooks/install.ts b/src/hooks/install.ts index 586d26ed168..f1b7aa9313a 100644 --- a/src/hooks/install.ts +++ b/src/hooks/install.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { MANIFEST_KEY } from "../compat/legacy-names.js"; import { resolveSafeInstallDir, unscopedPackageName } from "../infra/install-safe-path.js"; import { type NpmIntegrityDrift, type NpmSpecResolution } from "../infra/install-source-utils.js"; +import type { InstallSafetyOverrides } from "../plugins/install-security-scan.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; import { parseFrontmatter } from "./frontmatter.js"; @@ -45,7 +46,7 @@ export type HookNpmIntegrityDriftParams = { const defaultLogger: HookInstallLogger = {}; -type HookInstallForwardParams = { +type HookInstallForwardParams = InstallSafetyOverrides & { hooksDir?: string; timeoutMs?: number; logger?: HookInstallLogger; @@ -60,6 +61,7 @@ type HookPathInstallParams = { path: string } & HookInstallForwardParams; function buildHookInstallForwardParams(params: HookInstallForwardParams): HookInstallForwardParams { return { + dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall, hooksDir: params.hooksDir, timeoutMs: params.timeoutMs, logger: params.logger,