mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:50:43 +00:00
CLI/plugins: stop forced-unsafe installs from falling back to hook packs (#58909)
Merged via squash.
Prepared head SHA: 7cf146efb6
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
Reviewed-by: @hxy91819
This commit is contained in:
@@ -43,6 +43,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram/native commands: keep Telegram command-sync cache process-local so gateway restarts re-register the menu instead of trusting stale on-disk sync state after Telegram cleared commands out-of-band. (#66730) Thanks @nightq.
|
||||
- Audio/self-hosted STT: restore `models.providers.*.request.allowPrivateNetwork` for audio transcription so private or LAN speech-to-text endpoints stop tripping SSRF blocks after the v2026.4.14 regression. (#66692) Thanks @jhsmith409.
|
||||
- QQBot/cron: guard against undefined `event.content` in `parseFaceTags` and `filterInternalMarkers` so cron-triggered agent turns with no content payload no longer crash with `TypeError: Cannot read properties of undefined (reading 'startsWith')`. (#66302) Thanks @xinmotlanthua.
|
||||
- CLI/plugins: stop `--dangerously-force-unsafe-install` plugin installs from falling back to hook-pack installs after security scan failures, while still preserving non-security fallback behavior for real hook packs. (#58909) Thanks @hxy91819.
|
||||
|
||||
## 2026.4.14
|
||||
|
||||
@@ -1041,6 +1042,8 @@ Docs: https://docs.openclaw.ai
|
||||
- ACPX/runtime: repair `queue owner unavailable` session recovery by replacing dead named sessions and resuming the backend session when ACPX exposes a stable session id, so the first ACP prompt no longer inherits a dead handle. (#58669) Thanks @neeravmakwana
|
||||
- ACPX/runtime: retry dead-session queue-owner repair without `--resume-session` when the reported ACPX session id is stale, so recovery still creates a fresh named session instead of failing session init. Thanks @obviyus.
|
||||
- Tools/web_search (Kimi): replay native Moonshot `$web_search` arguments verbatim, disable thinking for `kimi-k2.5`, and add Moonshot region/model setup prompts so bundled Kimi web search works again. (#59356) Thanks @Innocent-children.
|
||||
- Auth/OpenAI Codex: persist plugin-refreshed OAuth credentials to `auth-profiles.json` before returning them, so rotated Codex refresh tokens survive restart and stop falling into `refresh_token_reused` loops. (#53082)
|
||||
- Discord/gateway: hand reconnect ownership back to Carbon, keep runtime status aligned with close/reconnect state, and force-stop sockets that open without reaching READY so Discord monitors recover promptly instead of waiting on stale health timeouts. (#59019) Thanks @obviyus
|
||||
|
||||
## 2026.3.31
|
||||
|
||||
|
||||
@@ -252,6 +252,8 @@ vi.mock("./prompt.js", () => ({
|
||||
vi.mock("../plugins/install.js", () => ({
|
||||
PLUGIN_INSTALL_ERROR_CODE: {
|
||||
NPM_PACKAGE_NOT_FOUND: "npm_package_not_found",
|
||||
SECURITY_SCAN_BLOCKED: "security_scan_blocked",
|
||||
SECURITY_SCAN_FAILED: "security_scan_failed",
|
||||
},
|
||||
installPluginFromNpmSpec: ((
|
||||
...args: Parameters<(typeof import("../plugins/install.js"))["installPluginFromNpmSpec"]>
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
buildPluginDiagnosticsReport,
|
||||
clearPluginManifestRegistryCache,
|
||||
enablePluginInConfig,
|
||||
installHooksFromPath,
|
||||
installHooksFromNpmSpec,
|
||||
installHooksFromPath,
|
||||
installPluginFromClawHub,
|
||||
installPluginFromMarketplace,
|
||||
installPluginFromNpmSpec,
|
||||
@@ -678,6 +678,289 @@ describe("plugins cli install", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the install logger to the --link dry-run probe", async () => {
|
||||
const localPluginDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-link-plugin-"));
|
||||
const cfg = {
|
||||
plugins: {
|
||||
entries: {},
|
||||
load: {
|
||||
paths: [],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const enabledCfg = createEnabledPluginConfig("demo");
|
||||
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
installPluginFromPath.mockImplementation(async (...args: unknown[]) => {
|
||||
const [params] = args as [
|
||||
{
|
||||
logger?: { warn?: (message: string) => void };
|
||||
path: string;
|
||||
dryRun?: boolean;
|
||||
dangerouslyForceUnsafeInstall?: boolean;
|
||||
},
|
||||
];
|
||||
params.logger?.warn?.(
|
||||
'WARNING: Plugin "demo" forced despite dangerous code patterns via --dangerously-force-unsafe-install: index.js:1',
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
pluginId: "demo",
|
||||
targetDir: localPluginDir,
|
||||
version: "1.0.0",
|
||||
extensions: [],
|
||||
};
|
||||
});
|
||||
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
|
||||
recordPluginInstall.mockReturnValue(enabledCfg);
|
||||
applyExclusiveSlotSelection.mockReturnValue({
|
||||
config: enabledCfg,
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
try {
|
||||
await runPluginsCommand([
|
||||
"plugins",
|
||||
"install",
|
||||
localPluginDir,
|
||||
"--link",
|
||||
"--dangerously-force-unsafe-install",
|
||||
]);
|
||||
} finally {
|
||||
fs.rmSync(localPluginDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(installPluginFromPath).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: localPluginDir,
|
||||
dryRun: true,
|
||||
dangerouslyForceUnsafeInstall: true,
|
||||
logger: expect.objectContaining({
|
||||
info: expect.any(Function),
|
||||
warn: expect.any(Function),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
runtimeLogs.some((line) =>
|
||||
line.includes(
|
||||
"forced despite dangerous code patterns via --dangerously-force-unsafe-install",
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not fall back to hook pack for local path when dangerous force unsafe install is set", async () => {
|
||||
const localPluginDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-local-plugin-"));
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const pluginInstallError = "plugin blocked by security scan";
|
||||
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
installPluginFromPath.mockResolvedValue({
|
||||
ok: false,
|
||||
error: pluginInstallError,
|
||||
code: "security_scan_blocked",
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
runPluginsCommand([
|
||||
"plugins",
|
||||
"install",
|
||||
localPluginDir,
|
||||
"--dangerously-force-unsafe-install",
|
||||
]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
} finally {
|
||||
fs.rmSync(localPluginDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(installHooksFromPath).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain(pluginInstallError);
|
||||
});
|
||||
|
||||
it("does not fall back to hook pack for local path when security scan fails under dangerous force unsafe install", async () => {
|
||||
const localPluginDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-local-plugin-"));
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const pluginInstallError = "plugin security scan failed";
|
||||
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
installPluginFromPath.mockResolvedValue({
|
||||
ok: false,
|
||||
error: pluginInstallError,
|
||||
code: "security_scan_failed",
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
runPluginsCommand([
|
||||
"plugins",
|
||||
"install",
|
||||
localPluginDir,
|
||||
"--dangerously-force-unsafe-install",
|
||||
]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
} finally {
|
||||
fs.rmSync(localPluginDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(installHooksFromPath).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain(pluginInstallError);
|
||||
});
|
||||
|
||||
it("does not fall back to hook pack for npm installs when dangerous force unsafe install is set", async () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const pluginInstallError = "plugin blocked by security scan";
|
||||
|
||||
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: false,
|
||||
error: pluginInstallError,
|
||||
code: "security_scan_blocked",
|
||||
});
|
||||
|
||||
await expect(
|
||||
runPluginsCommand(["plugins", "install", "demo", "--dangerously-force-unsafe-install"]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(installHooksFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain(pluginInstallError);
|
||||
});
|
||||
|
||||
it("does not fall back to hook pack for npm installs when security scan fails under dangerous force unsafe install", async () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const pluginInstallError = "plugin security scan failed";
|
||||
|
||||
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: false,
|
||||
error: pluginInstallError,
|
||||
code: "security_scan_failed",
|
||||
});
|
||||
|
||||
await expect(
|
||||
runPluginsCommand(["plugins", "install", "demo", "--dangerously-force-unsafe-install"]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(installHooksFromNpmSpec).not.toHaveBeenCalled();
|
||||
expect(runtimeErrors.at(-1)).toContain(pluginInstallError);
|
||||
});
|
||||
|
||||
it("still falls back to local hook pack when dangerous force unsafe install is set for non-security errors", async () => {
|
||||
const localHookDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-local-hook-pack-"));
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const installedCfg = {
|
||||
hooks: {
|
||||
internal: {
|
||||
installs: {
|
||||
"demo-hooks": {
|
||||
source: "path",
|
||||
sourcePath: localHookDir,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
loadConfig.mockReturnValue(cfg);
|
||||
installPluginFromPath.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "package.json missing openclaw.plugin.json",
|
||||
code: "missing_openclaw_extensions",
|
||||
});
|
||||
installHooksFromPath.mockResolvedValue({
|
||||
ok: true,
|
||||
hookPackId: "demo-hooks",
|
||||
hooks: ["command-audit"],
|
||||
targetDir: "/tmp/hooks/demo-hooks",
|
||||
version: "1.2.3",
|
||||
});
|
||||
recordHookInstall.mockReturnValue(installedCfg);
|
||||
|
||||
try {
|
||||
await runPluginsCommand([
|
||||
"plugins",
|
||||
"install",
|
||||
localHookDir,
|
||||
"--dangerously-force-unsafe-install",
|
||||
]);
|
||||
} finally {
|
||||
fs.rmSync(localHookDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
expect(installHooksFromPath).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: localHookDir,
|
||||
}),
|
||||
);
|
||||
expect(runtimeLogs.some((line) => line.includes("Installed hook pack: demo-hooks"))).toBe(true);
|
||||
});
|
||||
|
||||
it("still falls back to npm hook pack when dangerous force unsafe install is set for non-security errors", 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",
|
||||
code: "missing_openclaw_extensions",
|
||||
});
|
||||
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",
|
||||
"--dangerously-force-unsafe-install",
|
||||
]);
|
||||
|
||||
expect(installHooksFromNpmSpec).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spec: "@acme/demo-hooks",
|
||||
}),
|
||||
);
|
||||
expect(runtimeLogs.some((line) => line.includes("Installed hook pack: demo-hooks"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not fall back to npm when ClawHub rejects a real package", async () => {
|
||||
installPluginFromClawHub.mockResolvedValue({
|
||||
ok: false,
|
||||
|
||||
@@ -9,7 +9,11 @@ import { extractErrorCode, formatErrorMessage } from "../infra/errors.js";
|
||||
import { type BundledPluginSource, findBundledPluginSource } from "../plugins/bundled-sources.js";
|
||||
import { formatClawHubSpecifier, installPluginFromClawHub } from "../plugins/clawhub.js";
|
||||
import type { InstallSafetyOverrides } from "../plugins/install-security-scan.js";
|
||||
import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js";
|
||||
import {
|
||||
PLUGIN_INSTALL_ERROR_CODE,
|
||||
installPluginFromNpmSpec,
|
||||
installPluginFromPath,
|
||||
} from "../plugins/install.js";
|
||||
import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js";
|
||||
import {
|
||||
installPluginFromMarketplace,
|
||||
@@ -191,6 +195,17 @@ async function tryInstallHookPackFromNpmSpec(params: {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function shouldExitOnForcedUnsafeInstall(params: {
|
||||
forceUnsafeInstall: boolean;
|
||||
code?: string;
|
||||
}): boolean {
|
||||
return (
|
||||
params.forceUnsafeInstall &&
|
||||
(params.code === PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED ||
|
||||
params.code === PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED)
|
||||
);
|
||||
}
|
||||
|
||||
function isAllowedBundledRecoveryIssue(
|
||||
issue: { path?: string; message?: string },
|
||||
request: PluginInstallRequestContext,
|
||||
@@ -345,6 +360,7 @@ export async function runPluginInstallCommand(params: {
|
||||
}
|
||||
|
||||
const resolved = request.resolvedPath ?? request.normalizedSpec;
|
||||
const forceUnsafeInstall = opts.dangerouslyForceUnsafeInstall === true;
|
||||
|
||||
if (fs.existsSync(resolved)) {
|
||||
if (opts.link) {
|
||||
@@ -352,10 +368,16 @@ export async function runPluginInstallCommand(params: {
|
||||
const merged = Array.from(new Set([...existing, resolved]));
|
||||
const probe = await installPluginFromPath({
|
||||
...safetyOverrides,
|
||||
mode: installMode,
|
||||
path: resolved,
|
||||
dryRun: true,
|
||||
logger: createPluginInstallLogger(),
|
||||
});
|
||||
if (!probe.ok) {
|
||||
if (shouldExitOnForcedUnsafeInstall({ forceUnsafeInstall, code: probe.code })) {
|
||||
defaultRuntime.error(probe.error);
|
||||
return defaultRuntime.exit(1);
|
||||
}
|
||||
const hookFallback = await tryInstallHookPackFromLocalPath({
|
||||
config: cfg,
|
||||
installMode,
|
||||
@@ -402,6 +424,10 @@ export async function runPluginInstallCommand(params: {
|
||||
logger: createPluginInstallLogger(),
|
||||
});
|
||||
if (!result.ok) {
|
||||
if (shouldExitOnForcedUnsafeInstall({ forceUnsafeInstall, code: result.code })) {
|
||||
defaultRuntime.error(result.error);
|
||||
return defaultRuntime.exit(1);
|
||||
}
|
||||
const hookFallback = await tryInstallHookPackFromLocalPath({
|
||||
config: cfg,
|
||||
installMode,
|
||||
@@ -547,6 +573,10 @@ export async function runPluginInstallCommand(params: {
|
||||
logger: createPluginInstallLogger(),
|
||||
});
|
||||
if (!result.ok) {
|
||||
if (shouldExitOnForcedUnsafeInstall({ forceUnsafeInstall, code: result.code })) {
|
||||
defaultRuntime.error(result.error);
|
||||
return defaultRuntime.exit(1);
|
||||
}
|
||||
const bundledFallbackPlan = resolveBundledInstallPlanForNpmFailure({
|
||||
rawSpec: raw,
|
||||
code: result.code,
|
||||
|
||||
Reference in New Issue
Block a user