diff --git a/CHANGELOG.md b/CHANGELOG.md index 6194a9709b7..1dfdcf6e4cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1041,6 +1041,9 @@ 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 +- 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.3.31 diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index 2e5581b5f56..5ff154f612f 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -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"]> diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index 6a9c552aad4..dc5d4eb546d 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -11,6 +11,7 @@ import { enablePluginInConfig, installHooksFromPath, installHooksFromNpmSpec, + installHooksFromPath, installPluginFromClawHub, installPluginFromMarketplace, installPluginFromNpmSpec, @@ -678,6 +679,235 @@ 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 (params: { + 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 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("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, diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index ea341e73c7e..adce3241291 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -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,