diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 758317dbc9b8..7842dda6acd8 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -560,7 +560,7 @@ methods. Treat this as feature discovery, not a full enumeration of - `plugins.list` (`operator.read`) returns the installed plugin inventory plus locally curated official picks, diagnostics, and whether the current install mode allows mutations. - `plugins.search` (`operator.read`) searches installable ClawHub code-plugin and bundle-plugin families. Pass non-empty `query` and optional `limit` from 1 to 100. - - `plugins.install` (`operator.admin`) installs either an official catalog entry with `{ source: "official", pluginId }` or a ClawHub package with `{ source: "clawhub", packageName, version?, acknowledgeClawHubRisk? }`. ClawHub installs preserve Gateway trust, integrity, and install-policy checks. Successful installs require a Gateway restart. + - `plugins.install` (`operator.admin`) installs either an official catalog entry with `{ source: "official", pluginId, acknowledgeInstallPolicyWarning? }` or a ClawHub package with `{ source: "clawhub", packageName, version?, acknowledgeClawHubRisk?, acknowledgeInstallPolicyWarning? }`. When install policy returns `warn`, the error `details` include `installPolicyCode: "install_policy_warning_acknowledgement_required"`, the target, reason, and optional findings. After review, retrying the same action with `acknowledgeInstallPolicyWarning: true` approves every warning in that install invocation; each warning is freshly evaluated before installation continues. `block` and policy failures remain terminal. ClawHub installs preserve Gateway trust and integrity checks. Successful installs require a Gateway restart. - `plugins.setEnabled` (`operator.admin`) changes one installed plugin's enabled policy with `{ pluginId, enabled }`. The response includes the updated catalog entry, restart metadata, and any slot-selection warnings. - `plugins.uninstall` (`operator.admin`) removes one externally installed plugin with `{ pluginId }`: config references, the install record, and managed files. Bundled plugins cannot be uninstalled, only disabled. The response lists the removal actions and always requires a Gateway restart. diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 5204d046c29a..a1f37928cd3f 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -340,7 +340,7 @@ Plugins run in-process with the Gateway - treat them as trusted code. - npm and git plugin installs run package-manager dependency convergence only during the explicit install/update flow. Local paths and archives are treated as self-contained packages; OpenClaw copies/references them without running `npm install`. - Prefer pinned exact versions (`@scope/pkg@1.2.3`) and inspect the unpacked code before enabling. - `security.installPolicy` lets operators run a trusted local command to return `allow`, `warn`, or `block` for skill and plugin installs. It runs after source material is staged but before install continues and applies to ClawHub skills too. - - A `warn` result stops before commit. Interactive CLI commands ask the operator to type the plugin or skill name using the same wording as suspicious ClawHub releases, then re-evaluate policy before continuing. An over-4,000-character rendered review fails closed before prompting. Declined and non-interactive direct CLI commands can use `--acknowledge-install-policy-warning` as explicit approval after review for every warning in that command invocation. Gateway-backed and automatic installs cannot approve warnings themselves. Use an equivalent direct CLI command when one exists; otherwise, change `security.installPolicy` to return `allow` for the reviewed request before retrying the managed flow. Every approved warning is re-evaluated before continuing. `block` and policy failures remain terminal. Neither `--force` nor the deprecated plugin install/update flag `--dangerously-force-unsafe-install` approves policy warnings. + - A `warn` result stops before commit. Interactive CLI commands ask the operator to type the plugin or skill name using the same wording as suspicious ClawHub releases, then re-evaluate policy before continuing. An over-4,000-character rendered review fails closed before prompting. Declined and non-interactive direct CLI commands can use `--acknowledge-install-policy-warning` as explicit approval after review for every warning in that command invocation. The Control UI exposes the same invocation-wide approval through **Install anyway** for plugin installs. Other Gateway-backed and automatic installs remain blocked when they have no operator-confirmation flow. Every approved warning is re-evaluated before continuing. `block` and policy failures remain terminal. Neither `--force` nor the deprecated plugin install/update flag `--dangerously-force-unsafe-install` approves policy warnings. Details: [Plugins](/tools/plugin) diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 844d99cb1144..9cc34b7f5f55 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -153,11 +153,15 @@ copy as suspicious ClawHub releases; policy is then re-evaluated. Reviewed non-interactive direct CLI commands can use `--acknowledge-install-policy-warning`. That flag approves every warning for the command invocation; each warning is still re-evaluated before the install continues. -Gateway-backed and automatic installs remain blocked on warnings because they -have no operator-confirmation flow. When an equivalent direct plugin or skill -command exists, use that command to review and approve the warning. Otherwise, -change `security.installPolicy` to return `allow` for the reviewed request, then -retry the managed flow. Neither `--force` nor the deprecated plugin +The Control UI shows the structured warning and offers **Install anyway**. That +action resends the same plugin request with `acknowledgeInstallPolicyWarning: +true`, approving every warning encountered during that install invocation; +each warning is still re-evaluated before installation continues. Other +Gateway-backed and automatic installs remain blocked when they have no +operator-confirmation flow. When an equivalent direct plugin or skill command +exists, use that command to review and approve the warning. Otherwise, change +`security.installPolicy` to return `allow` for the reviewed request, then retry +the managed flow. Neither `--force` nor the deprecated plugin install/update flag `--dangerously-force-unsafe-install` approves a policy warning. Plugin `before_install` hooks run later, and only in OpenClaw processes where plugin diff --git a/docs/tools/skills-config.md b/docs/tools/skills-config.md index 1a1bbd7e287f..0356d2254449 100644 --- a/docs/tools/skills-config.md +++ b/docs/tools/skills-config.md @@ -199,11 +199,13 @@ then run policy again before continuing. Declined and non-interactive commands on the direct CLI may use `--acknowledge-install-policy-warning` as explicit approval after review for every warning in that command invocation; every approved warning is re-evaluated before continuing. -Gateway-backed and automatic installs remain blocked on warnings because they -have no operator-confirmation flow. Use an equivalent direct plugin or skill -command to review and approve the warning when one exists. Otherwise, change -`security.installPolicy` to return `allow` for the reviewed request, then retry -the managed flow. `--force` does not approve policy warnings. A `block`, +The Control UI can review and approve warnings for its plugin install request; +that approval covers every warning in the invocation, and each warning is +still re-evaluated. Other Gateway-backed and automatic installs remain blocked +when they have no operator-confirmation flow. Use an equivalent direct plugin +or skill command to review and approve the warning when one exists. Otherwise, +change `security.installPolicy` to return `allow` for the reviewed request, +then retry the managed flow. `--force` does not approve policy warnings. A `block`, non-zero exit, timeout, invalid JSON, non-object response, missing or invalid protocol version or decision, or missing or empty `warn`/`block` reason always fails closed. diff --git a/packages/gateway-protocol/src/install-policy-warning-error-details.ts b/packages/gateway-protocol/src/install-policy-warning-error-details.ts new file mode 100644 index 000000000000..90f0414a8bb6 --- /dev/null +++ b/packages/gateway-protocol/src/install-policy-warning-error-details.ts @@ -0,0 +1,104 @@ +import { + asProtocolRecord, + normalizeOptionalProtocolString, +} from "./protocol-value-normalization.js"; + +export const INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED = + "install_policy_warning_acknowledgement_required" as const; + +type InstallPolicyWarningErrorFinding = { + ruleId: string; + severity: "info" | "warn" | "critical"; + message: string; + file?: string; + line?: number; + evidence?: string; +}; + +export type InstallPolicyWarningErrorDetails = { + installPolicyCode: typeof INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED; + targetName: string; + targetType: "skill" | "plugin"; + requestMode: "install" | "update"; + reason: string; + findings?: InstallPolicyWarningErrorFinding[]; +}; + +function readFinding(value: unknown): InstallPolicyWarningErrorFinding | undefined { + const record = asProtocolRecord(value); + if (!record) { + return undefined; + } + const ruleId = normalizeOptionalProtocolString(record.ruleId); + const message = normalizeOptionalProtocolString(record.message); + const severity = record.severity; + if ( + !ruleId || + !message || + (severity !== "info" && severity !== "warn" && severity !== "critical") + ) { + return undefined; + } + const file = normalizeOptionalProtocolString(record.file); + const evidence = normalizeOptionalProtocolString(record.evidence); + const line = record.line; + if ( + (record.file !== undefined && !file) || + (record.evidence !== undefined && !evidence) || + (line !== undefined && (typeof line !== "number" || !Number.isSafeInteger(line) || line <= 0)) + ) { + return undefined; + } + return { + ruleId, + severity, + message, + ...(file ? { file } : {}), + ...(line !== undefined ? { line } : {}), + ...(evidence ? { evidence } : {}), + }; +} + +export function readInstallPolicyWarningErrorDetails( + value: unknown, +): InstallPolicyWarningErrorDetails | undefined { + const record = asProtocolRecord(value); + if (!record) { + return undefined; + } + const targetName = normalizeOptionalProtocolString(record.targetName); + const reason = normalizeOptionalProtocolString(record.reason); + const targetType = record.targetType; + const requestMode = record.requestMode; + if ( + record.installPolicyCode !== INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED || + !targetName || + !reason || + (targetType !== "skill" && targetType !== "plugin") || + (requestMode !== "install" && requestMode !== "update") + ) { + return undefined; + } + let findings: InstallPolicyWarningErrorFinding[] | undefined; + if (record.findings !== undefined) { + if (!Array.isArray(record.findings)) { + return undefined; + } + findings = []; + for (const findingValue of record.findings) { + const finding = readFinding(findingValue); + if (!finding) { + return undefined; + } + findings.push(finding); + } + } + return { + installPolicyCode: INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED, + targetName, + targetType, + requestMode, + reason, + ...(findings ? { findings } : {}), + }; +} diff --git a/packages/gateway-protocol/src/plugins-validators.test.ts b/packages/gateway-protocol/src/plugins-validators.test.ts index eaeedbfb9001..cbb0227369f2 100644 --- a/packages/gateway-protocol/src/plugins-validators.test.ts +++ b/packages/gateway-protocol/src/plugins-validators.test.ts @@ -31,9 +31,23 @@ describe("plugin lifecycle protocol validators", () => { packageName: "memory-plus", version: "2.1.0", acknowledgeClawHubRisk: true, + acknowledgeInstallPolicyWarning: true, }), ).toBe(true); - expect(validatePluginsInstallParams({ source: "official", pluginId: "workboard" })).toBe(true); + expect( + validatePluginsInstallParams({ + source: "official", + pluginId: "workboard", + acknowledgeInstallPolicyWarning: true, + }), + ).toBe(true); + expect( + validatePluginsInstallParams({ + source: "official", + pluginId: "workboard", + acknowledgeInstallPolicyWarning: false, + }), + ).toBe(false); expect( validatePluginsInstallParams({ source: "official", diff --git a/packages/gateway-protocol/src/schema/plugins.ts b/packages/gateway-protocol/src/schema/plugins.ts index 529cebf05b18..8f75c086b3a5 100644 --- a/packages/gateway-protocol/src/schema/plugins.ts +++ b/packages/gateway-protocol/src/schema/plugins.ts @@ -174,10 +174,12 @@ export const PluginsInstallParamsSchema = Type.Union([ packageName: NonEmptyString, version: Type.Optional(NonEmptyString), acknowledgeClawHubRisk: Type.Optional(Type.Boolean()), + acknowledgeInstallPolicyWarning: Type.Optional(Type.Literal(true)), }), closedObject({ source: Type.Literal("official"), pluginId: NonEmptyString, + acknowledgeInstallPolicyWarning: Type.Optional(Type.Literal(true)), }), ]); diff --git a/src/cli/install-policy-warning-acknowledgement.test.ts b/src/cli/install-policy-warning-acknowledgement.test.ts index 5b26e554d979..6f527b42b0cc 100644 --- a/src/cli/install-policy-warning-acknowledgement.test.ts +++ b/src/cli/install-policy-warning-acknowledgement.test.ts @@ -47,6 +47,7 @@ describe("resolveInstallPolicyWarningAcknowledgementCliOptions", () => { targetName: "demo\npkg", targetType: "plugin", requestMode: fixture.requestMode, + reason: "Policy warning", }), ).resolves.toEqual({ status: "approved" }); @@ -66,6 +67,7 @@ describe("resolveInstallPolicyWarningAcknowledgementCliOptions", () => { targetName: "demo", targetType: "skill", requestMode: "install", + reason: "Policy warning", }), ).resolves.toEqual({ status: "declined" }); }); @@ -104,6 +106,7 @@ describe("resolveInstallPolicyWarningAcknowledgementCliOptions", () => { targetName: "demo", targetType: "plugin", requestMode: "install", + reason: "Policy warning", }), ).resolves.toEqual({ status: "approved" }); await expect( @@ -111,6 +114,7 @@ describe("resolveInstallPolicyWarningAcknowledgementCliOptions", () => { targetName: "demo-dependency", targetType: "plugin", requestMode: "install", + reason: "Dependency policy warning", }), ).resolves.toEqual({ status: "approved" }); expect(promptTextMock).not.toHaveBeenCalled(); diff --git a/src/gateway/server-methods/plugins.test.ts b/src/gateway/server-methods/plugins.test.ts index cf00b34ef201..346a1ca739ca 100644 --- a/src/gateway/server-methods/plugins.test.ts +++ b/src/gateway/server-methods/plugins.test.ts @@ -9,6 +9,17 @@ const managementMocks = vi.hoisted(() => { readonly code?: string; readonly version?: string; readonly warning?: string; + readonly installPolicyWarning?: { + targetName: string; + targetType: "skill" | "plugin"; + requestMode: "install" | "update"; + reason: string; + findings?: Array<{ + ruleId: string; + severity: "info" | "warn" | "critical"; + message: string; + }>; + }; constructor( message: string, @@ -17,6 +28,7 @@ const managementMocks = vi.hoisted(() => { code?: string; version?: string; warning?: string; + installPolicyWarning?: ManagedPluginLifecycleError["installPolicyWarning"]; }, ) { super(message); @@ -24,6 +36,7 @@ const managementMocks = vi.hoisted(() => { this.code = details?.code; this.version = details?.version; this.warning = details?.warning; + this.installPolicyWarning = details?.installPolicyWarning; } } return { @@ -298,6 +311,70 @@ describe("plugin management Gateway handlers", () => { }); }); + it("forwards invocation-wide install policy acknowledgement", async () => { + managementMocks.install.mockResolvedValue({ + plugin: { ...workboard, id: "diffs", name: "Diffs", enabled: true, state: "enabled" }, + }); + + await callHandler("plugins.install", { + source: "official", + pluginId: "diffs", + acknowledgeInstallPolicyWarning: true, + }); + + expect(managementMocks.install).toHaveBeenCalledWith({ + request: { + source: "official", + pluginId: "diffs", + acknowledgeInstallPolicyWarning: true, + }, + }); + }); + + it("returns tokenless structured install policy warning details", async () => { + managementMocks.install.mockRejectedValue( + new managementMocks.ManagedPluginLifecycleError("Review required", { + installPolicyWarning: { + targetName: "diffs", + targetType: "plugin", + requestMode: "install", + reason: "Review the staged package", + findings: [ + { + ruleId: "suspicious-script", + severity: "warn", + message: "The package contains an install script.", + }, + ], + }, + }), + ); + + const result = await callHandler("plugins.install", { + source: "official", + pluginId: "diffs", + }); + + expect(result.error).toMatchObject({ + code: "INVALID_REQUEST", + details: { + installPolicyCode: "install_policy_warning_acknowledgement_required", + targetName: "diffs", + targetType: "plugin", + requestMode: "install", + reason: "Review the staged package", + findings: [ + { + ruleId: "suspicious-script", + severity: "warn", + message: "The package contains an install script.", + }, + ], + }, + }); + expect(result.error).not.toHaveProperty("details.acknowledgementToken"); + }); + it("returns structured ClawHub acknowledgement details", async () => { managementMocks.install.mockRejectedValue( new managementMocks.ManagedPluginLifecycleError("Review required", { diff --git a/src/gateway/server-methods/plugins.ts b/src/gateway/server-methods/plugins.ts index 77a8f7535233..8ed2e5810dba 100644 --- a/src/gateway/server-methods/plugins.ts +++ b/src/gateway/server-methods/plugins.ts @@ -11,6 +11,10 @@ import { validatePluginsSetEnabledParams, validatePluginsUninstallParams, } from "../../../packages/gateway-protocol/src/index.js"; +import { + INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED, + readInstallPolicyWarningErrorDetails, +} from "../../../packages/gateway-protocol/src/install-policy-warning-error-details.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { searchInstallablePluginPackages } from "../../plugins/catalog-search.js"; @@ -127,13 +131,20 @@ export const pluginsHandlers: GatewayRequestHandlers = { lifecycleError?.code && isClawHubTrustErrorCode(lifecycleError.code) ? lifecycleError.code : undefined; - const details = lifecycleError + const trustDetails = lifecycleError ? buildClawHubTrustErrorDetails({ ...(trustCode ? { code: trustCode } : {}), ...(lifecycleError.version ? { version: lifecycleError.version } : {}), ...(lifecycleError.warning ? { warning: lifecycleError.warning } : {}), }) : undefined; + const installPolicyDetails = lifecycleError?.installPolicyWarning + ? readInstallPolicyWarningErrorDetails({ + installPolicyCode: INSTALL_POLICY_WARNING_ACKNOWLEDGEMENT_REQUIRED, + ...lifecycleError.installPolicyWarning, + }) + : undefined; + const details = installPolicyDetails ?? trustDetails; respond( false, undefined, diff --git a/src/infra/install-package-dir.ts b/src/infra/install-package-dir.ts index 7b756c067af1..1076904abdca 100644 --- a/src/infra/install-package-dir.ts +++ b/src/infra/install-package-dir.ts @@ -26,6 +26,9 @@ type HiddenProjectConfigFile = { hiddenPath: string; } | null; +type InstallPackageDirFailure = { ok: false; error: string }; +type InstallPackageDirSuccess = { ok: true }; + async function sanitizeManifestForNpmInstall(targetDir: string): Promise { const manifestPath = path.join(targetDir, "package.json"); const parsed = await tryReadJson(manifestPath); @@ -215,7 +218,9 @@ export function resolvePackageDirInstallTransaction( * Update mode backs up the existing target, runs optional validation hooks, * and rolls back when copy, dependency install, or validation fails. */ -export async function installPackageDir(params: { +export async function installPackageDir< + TAfterInstallFailure extends InstallPackageDirFailure = InstallPackageDirFailure, +>(params: { sourceDir: string; targetDir: string; mode: "install" | "update"; @@ -226,10 +231,8 @@ export async function installPackageDir(params: { sourceHardlinks?: InstallSourceHardlinks; depsLogMessage: string; afterCopy?: (installedDir: string) => void | Promise; - afterInstall?: ( - installedDir: string, - ) => Promise<{ ok: true } | { ok: false; error: string; code?: string }>; -}): Promise<{ ok: true } | { ok: false; error: string; code?: string }> { + afterInstall?: (installedDir: string) => Promise; +}): Promise { const deferCommit = isPackageDirInstallCommitDeferred(params); params.logger?.info?.(`Installing to ${params.targetDir}…`); const installBaseDir = path.dirname(params.targetDir); @@ -279,10 +282,6 @@ export async function installPackageDir(params: { } return { ok: false as const, error }; }; - const failWithCode = async (paramsLocal: { error: string; code?: string }, cause?: unknown) => { - const failed = await fail(paramsLocal.error, cause); - return paramsLocal.code ? { ...failed, code: paramsLocal.code } : failed; - }; const restoreBackup = async () => { if (!backupDir) { return; @@ -354,7 +353,8 @@ export async function installPackageDir(params: { try { const postInstallResult = await params.afterInstall(stageDir); if (!postInstallResult.ok) { - return await failWithCode(postInstallResult); + const failed = await fail(postInstallResult.error); + return { ...postInstallResult, error: failed.error }; } } catch (err) { return await fail(`post-install validation failed: ${String(err)}`, err); @@ -458,7 +458,9 @@ export async function installPackageDir(params: { * Installs a manifest-backed package directory while deriving whether npm * dependencies must be installed and which hardlink policy is safe to use. */ -export async function installPackageDirWithManifestDeps(params: { +export async function installPackageDirWithManifestDeps< + TAfterInstallFailure extends InstallPackageDirFailure = InstallPackageDirFailure, +>(params: { sourceDir: string; targetDir: string; mode: "install" | "update"; @@ -468,12 +470,10 @@ export async function installPackageDirWithManifestDeps(params: { depsLogMessage: string; manifestDependencies?: Record; afterCopy?: (installedDir: string) => void | Promise; - afterInstall?: ( - installedDir: string, - ) => Promise<{ ok: true } | { ok: false; error: string; code?: string }>; -}): Promise<{ ok: true } | { ok: false; error: string; code?: string }> { + afterInstall?: (installedDir: string) => Promise; +}): Promise { const hasDeps = Object.keys(params.manifestDependencies ?? {}).length > 0; - return installPackageDir({ + return installPackageDir({ ...params, hasDeps, sourceHardlinks: hasDeps ? "package-manager" : "reject", diff --git a/src/plugins/install-security-scan.runtime.test.ts b/src/plugins/install-security-scan.runtime.test.ts index 763906826fdd..fe0e1ba22490 100644 --- a/src/plugins/install-security-scan.runtime.test.ts +++ b/src/plugins/install-security-scan.runtime.test.ts @@ -341,6 +341,7 @@ describe("legacy file install scan compatibility", () => { expect(result).toBeUndefined(); expect(onInstallPolicyWarning).toHaveBeenCalledWith({ + reason: "review this plugin", targetName: "payload", targetType: "plugin", requestMode: "install", @@ -458,6 +459,12 @@ describe("legacy file install scan compatibility", () => { expect(result?.blocked).toEqual({ code: "security_scan_blocked", + installPolicyWarning: { + reason: "review this plugin", + requestMode: "install", + targetName: "payload", + targetType: "plugin", + }, reason: expectedInstallPolicyNotice({ decision: "warn", guidance: [ diff --git a/src/plugins/install-security-scan.runtime.ts b/src/plugins/install-security-scan.runtime.ts index 45bd23109174..8a8c4c298d91 100644 --- a/src/plugins/install-security-scan.runtime.ts +++ b/src/plugins/install-security-scan.runtime.ts @@ -17,7 +17,10 @@ import { import { isPathInside } from "../security/scan-paths.js"; import { getGlobalHookRunner } from "./hook-runner-global.js"; import { createBeforeInstallHookPayload } from "./install-policy-context.js"; -import type { InstallSafetyOverrides } from "./install-security-scan.types.js"; +import type { + InstallPolicyWarningDetails, + InstallSafetyOverrides, +} from "./install-security-scan.types.js"; type InstallScanLogger = { warn?: (message: string) => void; @@ -109,6 +112,7 @@ export type InstallSecurityScanResult = { blocked?: { code?: "security_scan_blocked" | "security_scan_failed"; reason: string; + installPolicyWarning?: InstallPolicyWarningDetails; }; }; @@ -821,10 +825,18 @@ async function runOperatorInstallPolicy(params: { logPolicyResult(result); return undefined; } + const installPolicyWarning: InstallPolicyWarningDetails = { + targetName: params.targetName, + targetType: params.targetType, + requestMode: params.requestMode, + reason: result.warning.reason, + ...(result.findings?.length ? { findings: result.findings } : {}), + }; if (!params.onInstallPolicyWarning) { return { blocked: { code: "security_scan_blocked", + installPolicyWarning, reason: formatInstallPolicyNotice({ decision: "warn", findings: result.findings, @@ -838,9 +850,7 @@ async function runOperatorInstallPolicy(params: { } logPolicyResult(result); const acknowledgement = await params.onInstallPolicyWarning({ - targetName: params.targetName, - targetType: params.targetType, - requestMode: params.requestMode, + ...installPolicyWarning, }); if (acknowledgement.status === "approved") { const reevaluated = await evaluatePolicy(); @@ -866,6 +876,13 @@ async function runOperatorInstallPolicy(params: { return { blocked: { code: "security_scan_blocked", + installPolicyWarning: { + targetName: params.targetName, + targetType: params.targetType, + requestMode: params.requestMode, + reason: reevaluated.warning.reason, + ...(reevaluated.findings?.length ? { findings: reevaluated.findings } : {}), + }, reason: formatInstallPolicyNotice({ decision: "warn", findings: reevaluated.findings, @@ -888,6 +905,7 @@ async function runOperatorInstallPolicy(params: { return { blocked: { code: "security_scan_blocked", + installPolicyWarning, reason: "Install cancelled: the install policy warning was not approved.", }, }; diff --git a/src/plugins/install-security-scan.ts b/src/plugins/install-security-scan.ts index 40885f9c0a5f..143223456989 100644 --- a/src/plugins/install-security-scan.ts +++ b/src/plugins/install-security-scan.ts @@ -6,7 +6,10 @@ import type { InstallPolicySource, } from "../security/install-policy.js"; export type { InstallSafetyOverrides } from "./install-security-scan.types.js"; -import type { InstallSafetyOverrides } from "./install-security-scan.types.js"; +import type { + InstallPolicyWarningDetails, + InstallSafetyOverrides, +} from "./install-security-scan.types.js"; type InstallScanLogger = { warn?: (message: string) => void; @@ -17,6 +20,7 @@ export type InstallSecurityScanResult = { blocked?: { code?: "security_scan_blocked" | "security_scan_failed"; reason: string; + installPolicyWarning?: InstallPolicyWarningDetails; }; }; diff --git a/src/plugins/install-security-scan.types.ts b/src/plugins/install-security-scan.types.ts index 9a39a7935ea5..e62da4b2cf68 100644 --- a/src/plugins/install-security-scan.types.ts +++ b/src/plugins/install-security-scan.types.ts @@ -1,12 +1,17 @@ // Defines plugin install security scan result types. import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { InstallPolicyFinding } from "../security/install-policy.js"; -export type InstallPolicyWarningAcknowledgementRequest = { +export type InstallPolicyWarningDetails = { targetName: string; targetType: "skill" | "plugin"; requestMode: "install" | "update"; + reason: string; + findings?: InstallPolicyFinding[]; }; +export type InstallPolicyWarningAcknowledgementRequest = InstallPolicyWarningDetails; + type InstallPolicyWarningAcknowledgementResult = { status: "approved" } | { status: "declined" }; /** Overrides that intentionally loosen install safety policy for trusted/operator paths. */ diff --git a/src/plugins/install-shared.test.ts b/src/plugins/install-shared.test.ts new file mode 100644 index 000000000000..04dc8709ad1a --- /dev/null +++ b/src/plugins/install-shared.test.ts @@ -0,0 +1,54 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { installPluginDirectoryIntoExtensions } from "./install-shared.js"; +import { PLUGIN_INSTALL_ERROR_CODE } from "./install-types.js"; +import { createSyncSuiteTempRootTracker } from "./test-helpers/fs-fixtures.js"; + +describe("installPluginDirectoryIntoExtensions", () => { + const tempRoots = createSyncSuiteTempRootTracker("openclaw-install-shared"); + + afterAll(() => tempRoots.cleanup()); + + it("preserves structured warnings returned by a staged dependency scan", async () => { + const fixtureRoot = tempRoots.makeTempDir(); + const sourceDir = path.join(fixtureRoot, "source"); + const targetDir = path.join(fixtureRoot, "extensions", "demo"); + fs.mkdirSync(sourceDir, { recursive: true }); + fs.writeFileSync(path.join(sourceDir, "index.js"), "export default {};\n"); + const installPolicyWarning = { + targetName: "demo", + targetType: "plugin" as const, + requestMode: "install" as const, + reason: "Review the installed dependency tree", + }; + + const result = await installPluginDirectoryIntoExtensions({ + sourceDir, + targetDir, + pluginId: "demo", + extensions: ["index.js"], + logger: {}, + timeoutMs: 1_000, + mode: "install", + dryRun: false, + copyErrorPrefix: "failed to copy plugin", + hasDeps: false, + depsLogMessage: "Installing dependencies…", + afterInstall: async () => ({ + ok: false, + error: installPolicyWarning.reason, + code: PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED, + installPolicyWarning, + }), + }); + + expect(result).toEqual({ + ok: false, + error: installPolicyWarning.reason, + code: PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED, + installPolicyWarning, + }); + expect(fs.existsSync(targetDir)).toBe(false); + }); +}); diff --git a/src/plugins/install-shared.ts b/src/plugins/install-shared.ts index 90fc644e4b69..2cd91b3e5c49 100644 --- a/src/plugins/install-shared.ts +++ b/src/plugins/install-shared.ts @@ -226,6 +226,9 @@ function buildBlockedInstallResult(params: { return { ok: false, error: params.blocked.reason, + ...(params.blocked.installPolicyWarning + ? { installPolicyWarning: params.blocked.installPolicyWarning } + : {}), ...(params.blocked.code === "security_scan_failed" ? { code: PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_FAILED } : params.blocked.code === "security_scan_blocked" @@ -437,11 +440,7 @@ export async function installPluginDirectoryIntoExtensions(params: { if (!postInstallResult) { return { ok: true as const }; } - return { - ok: false as const, - error: postInstallResult.error, - ...(postInstallResult.code ? { code: postInstallResult.code } : {}), - }; + return postInstallResult; }, }; const installRes = await runtime.installPackageDir( @@ -450,11 +449,7 @@ export async function installPluginDirectoryIntoExtensions(params: { : packageInstallParams, ); if (!installRes.ok) { - return { - ok: false, - error: installRes.error, - ...(installRes.code ? { code: installRes.code as PluginInstallErrorCode } : {}), - }; + return installRes; } const result = { diff --git a/src/plugins/install-types.ts b/src/plugins/install-types.ts index 70dc8081c44b..50c50e5d2c63 100644 --- a/src/plugins/install-types.ts +++ b/src/plugins/install-types.ts @@ -2,6 +2,7 @@ import type { NpmIntegrityDrift, NpmSpecResolution } from "../infra/install-sour import type { InstallPolicySource } from "../security/install-policy.js"; import type { PluginInstallArtifactInspection } from "./install-artifact-inspection.js"; import type { InstallSafetyOverrides } from "./install-security-scan.js"; +import type { InstallPolicyWarningDetails } from "./install-security-scan.types.js"; import type { PackageManifest as PluginPackageManifest, PluginManifestSetup } from "./manifest.js"; export type PluginInstallLogger = { @@ -50,7 +51,12 @@ export type InstallPluginResult = npmResolution?: NpmSpecResolution; integrityDrift?: NpmIntegrityDrift; } - | { ok: false; error: string; code?: PluginInstallErrorCode }; + | { + ok: false; + error: string; + code?: PluginInstallErrorCode; + installPolicyWarning?: InstallPolicyWarningDetails; + }; export type PluginInstallFailureResult = Extract; diff --git a/src/plugins/management-service.test-helpers.ts b/src/plugins/management-service.test-helpers.ts new file mode 100644 index 000000000000..38b39daf540c --- /dev/null +++ b/src/plugins/management-service.test-helpers.ts @@ -0,0 +1,105 @@ +export function configSnapshot(config: Record = {}) { + return { + snapshot: { + valid: true, + parsed: {}, + path: "/tmp/openclaw.json", + sourceConfig: config, + hash: "base-hash", + }, + writeOptions: { + expectedConfigPath: "/tmp/openclaw.json", + includeFileHashesForWrite: { "/tmp/plugins.json": "include-hash" }, + includeFileTargetsForWrite: { "/tmp/plugins.json": "/tmp/plugins.json" }, + }, + }; +} + +export function metadataSnapshot(params: { + enabled: boolean; + id?: string; + name?: string; + origin?: "bundled" | "global"; + installRecord?: Record; + icon?: string; +}) { + const id = params.id ?? "workboard"; + const origin = params.origin ?? "bundled"; + const installRecord = + params.installRecord ?? + (origin === "global" ? { source: "path", installPath: `/tmp/${id}` } : undefined); + const manifest = { + id, + name: params.name ?? "Workboard", + description: "Coordinate agent work in a shared board.", + catalog: { featured: true, order: 10 }, + ...(params.icon ? { icon: params.icon } : {}), + channels: [], + providers: [], + cliBackends: [], + skills: [], + hooks: [], + origin, + rootDir: `/tmp/${id}`, + source: `/tmp/${id}/index.ts`, + manifestPath: `/tmp/${id}/openclaw.plugin.json`, + }; + return { + index: { + plugins: [ + { + pluginId: id, + ...(origin === "global" ? { installOwner: id } : {}), + packageName: `@openclaw/${id}`, + origin, + enabled: params.enabled, + rootDir: `/tmp/${id}`, + }, + ], + installRecords: installRecord ? { [id]: installRecord } : {}, + }, + byPluginId: new Map([[id, manifest]]), + plugins: [manifest], + diagnostics: [], + normalizePluginId: (pluginId: string) => pluginId, + }; +} + +export function emptyMetadataSnapshot() { + return { + index: { plugins: [], installRecords: {} }, + byPluginId: new Map(), + plugins: [], + diagnostics: [], + normalizePluginId: (pluginId: string) => pluginId, + }; +} + +export const hostedDiffsEntry = { + name: "@openclaw/diffs", + version: "2.0.0", + description: "Hosted description", + openclaw: { + plugin: { id: "diffs", label: "Hosted Diffs" }, + install: { clawhubSpec: "clawhub:@openclaw/diffs", defaultChoice: "clawhub" }, + }, +}; + +// Mirrors the ClawHub feed: package identity is remote, while runtime metadata stays local. +export const hostedFeedDiffsEntry = { + id: "@openclaw/diffs", + title: "Diffs", + state: "available", + featured: true, + publisher: { id: "openclaw", trust: "official" }, + install: { + candidates: [ + { + sourceRef: "public-clawhub", + package: "@openclaw/diffs", + version: "2026.6.11", + integrity: `sha256:${"a".repeat(64)}`, + }, + ], + }, +}; diff --git a/src/plugins/management-service.test.ts b/src/plugins/management-service.test.ts index 71ea0917fc94..69bdc6bd289d 100644 --- a/src/plugins/management-service.test.ts +++ b/src/plugins/management-service.test.ts @@ -1,5 +1,12 @@ import { expectDefined } from "@openclaw/normalization-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + configSnapshot, + emptyMetadataSnapshot, + hostedDiffsEntry, + hostedFeedDiffsEntry, + metadataSnapshot, +} from "./management-service.test-helpers.js"; const mocks = vi.hoisted(() => ({ applyUninstall: vi.fn(), @@ -107,83 +114,6 @@ const { uninstallManagedPlugin, } = await import("./management-service.js"); -function configSnapshot(config: Record = {}) { - return { - snapshot: { - valid: true, - parsed: {}, - path: "/tmp/openclaw.json", - sourceConfig: config, - hash: "base-hash", - }, - writeOptions: { - expectedConfigPath: "/tmp/openclaw.json", - includeFileHashesForWrite: { "/tmp/plugins.json": "include-hash" }, - includeFileTargetsForWrite: { "/tmp/plugins.json": "/tmp/plugins.json" }, - }, - }; -} - -function metadataSnapshot(params: { - enabled: boolean; - id?: string; - name?: string; - origin?: "bundled" | "global"; - installRecord?: Record; - icon?: string; -}) { - const id = params.id ?? "workboard"; - const origin = params.origin ?? "bundled"; - const installRecord = - params.installRecord ?? - (origin === "global" ? { source: "path", installPath: `/tmp/${id}` } : undefined); - const manifest = { - id, - name: params.name ?? "Workboard", - description: "Coordinate agent work in a shared board.", - catalog: { featured: true, order: 10 }, - ...(params.icon ? { icon: params.icon } : {}), - channels: [], - providers: [], - cliBackends: [], - skills: [], - hooks: [], - origin, - rootDir: `/tmp/${id}`, - source: `/tmp/${id}/index.ts`, - manifestPath: `/tmp/${id}/openclaw.plugin.json`, - }; - return { - index: { - plugins: [ - { - pluginId: id, - ...(origin === "global" ? { installOwner: id } : {}), - packageName: `@openclaw/${id}`, - origin, - enabled: params.enabled, - rootDir: `/tmp/${id}`, - }, - ], - installRecords: installRecord ? { [id]: installRecord } : {}, - }, - byPluginId: new Map([[id, manifest]]), - plugins: [manifest], - diagnostics: [], - normalizePluginId: (pluginId: string) => pluginId, - }; -} - -function emptyMetadataSnapshot() { - return { - index: { plugins: [], installRecords: {} }, - byPluginId: new Map(), - plugins: [], - diagnostics: [], - normalizePluginId: (pluginId: string) => pluginId, - }; -} - function mockHostedOfficialCatalog(entries: unknown[]) { mocks.officialCatalog.mockResolvedValue({ source: "hosted", @@ -209,35 +139,6 @@ function mockClawHubInstall(pluginId: string, packageName: string, targetDir?: s }); } -const hostedDiffsEntry = { - name: "@openclaw/diffs", - version: "2.0.0", - description: "Hosted description", - openclaw: { - plugin: { id: "diffs", label: "Hosted Diffs" }, - install: { clawhubSpec: "clawhub:@openclaw/diffs", defaultChoice: "clawhub" }, - }, -}; - -// Mirrors the ClawHub feed: package identity is remote, while runtime metadata stays local. -const hostedFeedDiffsEntry = { - id: "@openclaw/diffs", - title: "Diffs", - state: "available", - featured: true, - publisher: { id: "openclaw", trust: "official" }, - install: { - candidates: [ - { - sourceRef: "public-clawhub", - package: "@openclaw/diffs", - version: "2026.6.11", - integrity: `sha256:${"a".repeat(64)}`, - }, - ], - }, -}; - describe("plugin management service", () => { beforeEach(() => { clearManagedPluginOfficialCatalogCache(); @@ -802,6 +703,68 @@ describe("plugin management service", () => { ); }); + it("approves every install-policy warning in an acknowledged Gateway install", async () => { + mocks.readConfig.mockResolvedValue(configSnapshot()); + mockHostedOfficialCatalog([hostedFeedDiffsEntry]); + mocks.clawhubInstall.mockImplementation(async (params: unknown) => { + const callback = expectDefined( + ( + params as { + onInstallPolicyWarning?: (request: { + targetName: string; + targetType: "plugin"; + requestMode: "install"; + reason: string; + }) => Promise<{ status: "approved" | "declined" }>; + } + ).onInstallPolicyWarning, + "install policy acknowledgement callback", + ); + await expect( + callback({ + targetName: "diffs", + targetType: "plugin", + requestMode: "install", + reason: "Review package metadata", + }), + ).resolves.toEqual({ status: "approved" }); + await expect( + callback({ + targetName: "diffs", + targetType: "plugin", + requestMode: "install", + reason: "Review installed dependencies", + }), + ).resolves.toEqual({ status: "approved" }); + return { + ok: true, + pluginId: "diffs", + targetDir: "/tmp/extensions/diffs", + extensions: ["index.js"], + packageName: "@openclaw/diffs", + clawhub: { + source: "clawhub", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "@openclaw/diffs", + clawhubFamily: "code-plugin", + }, + }; + }); + mocks.persistInstall.mockResolvedValue({}); + mocks.metadata.mockReturnValue( + metadataSnapshot({ enabled: true, id: "diffs", name: "Diffs", origin: "global" }), + ); + + await installManagedPlugin({ + request: { + source: "official", + pluginId: "diffs", + acknowledgeInstallPolicyWarning: true, + }, + env: {}, + }); + }); + it("removes only the newly installed managed target after persistence conflicts", async () => { const env = { HOME: "/tmp/openclaw-managed-install-conflict-home" }; const conflict = new Error("config changed during plugin install"); diff --git a/src/plugins/management-service.ts b/src/plugins/management-service.ts index e7723609aa20..630aa5d9c63a 100644 --- a/src/plugins/management-service.ts +++ b/src/plugins/management-service.ts @@ -42,6 +42,7 @@ import { } from "./install-persistence.js"; import { commitPluginInstallRecordsWithConfig } from "./install-record-commit.js"; import type { InstallSafetyOverrides } from "./install-security-scan.js"; +import type { InstallPolicyWarningDetails } from "./install-security-scan.types.js"; import type { PluginInstallLogger } from "./install-types.js"; import { installPluginFromNpmPackArchive, @@ -129,8 +130,9 @@ type ManagedPluginInstallRequest = packageName: string; version?: string; acknowledgeClawHubRisk?: boolean; + acknowledgeInstallPolicyWarning?: true; } - | { source: "official"; pluginId: string }; + | { source: "official"; pluginId: string; acknowledgeInstallPolicyWarning?: true }; export type ManagedPluginSourceInstallRequest = | { @@ -192,10 +194,24 @@ type ManagedPluginSourceInstallResult = npmResolution?: NpmSpecResolution; clawhub?: ClawHubPluginInstallRecordFields; } - | { ok: false; error: string; code?: string; version?: string; warning?: string }; + | { + ok: false; + error: string; + code?: string; + version?: string; + warning?: string; + installPolicyWarning?: InstallPolicyWarningDetails; + }; type SourceInstallerResult = - | { ok: false; error: string; code?: string; version?: string; warning?: string } + | { + ok: false; + error: string; + code?: string; + version?: string; + warning?: string; + installPolicyWarning?: InstallPolicyWarningDetails; + } | { ok: true; pluginId: string; @@ -209,6 +225,7 @@ export class ManagedPluginLifecycleError extends Error { readonly code?: string; readonly version?: string; readonly warning?: string; + readonly installPolicyWarning?: InstallPolicyWarningDetails; constructor( message: string, @@ -217,6 +234,7 @@ export class ManagedPluginLifecycleError extends Error { code?: string; version?: string; warning?: string; + installPolicyWarning?: InstallPolicyWarningDetails; cause?: unknown; }, ) { @@ -226,6 +244,7 @@ export class ManagedPluginLifecycleError extends Error { this.code = details?.code; this.version = details?.version; this.warning = details?.warning; + this.installPolicyWarning = details?.installPolicyWarning; } } @@ -848,6 +867,9 @@ export async function listManagedPlugins(params: { } const kind = normalizeKinds(entry.kind); const install = resolveCatalogInstallAction({ entry, pluginId }); + const clawhubPackageName = resolveCatalogPackageSourceIdentities(entry).find( + (identity) => identity.source === "clawhub", + )?.packageName; const description = normalizeOptionalString(entry.description); const version = normalizeOptionalString(entry.version); const featuredAt = @@ -855,6 +877,7 @@ export async function listManagedPlugins(params: { plugins.push({ id: pluginId, name: resolveOfficialExternalPluginLabel(entry), + ...(clawhubPackageName ? { packageName: clawhubPackageName } : {}), ...(description ? { description } : {}), ...(version ? { version } : {}), ...(kind ? { kind } : {}), @@ -993,6 +1016,7 @@ function throwInstallFailure(result: { code?: string; version?: string; warning?: string; + installPolicyWarning?: InstallPolicyWarningDetails; }): never { const unavailable = !result.code || @@ -1004,6 +1028,7 @@ function throwInstallFailure(result: { code: result.code, version: result.version, warning: result.warning, + installPolicyWarning: result.installPolicyWarning, cause: result, }); } @@ -1085,6 +1110,7 @@ function throwPersistenceFailureWithCleanupWarnings(error: unknown, warnings: st code: error.code, version: error.version, warning: [error.warning, cleanupWarning].filter(Boolean).join("\n"), + installPolicyWarning: error.installPolicyWarning, cause: error, }); } @@ -1450,6 +1476,13 @@ export async function installManagedPlugin(params: { env, logger: installLogger, persistenceLogger: installLogger, + ...(params.request.acknowledgeInstallPolicyWarning + ? { + safetyOverrides: { + onInstallPolicyWarning: async () => ({ status: "approved" as const }), + }, + } + : {}), cleanupOnPersistenceFailure: true, invalidateRuntimeCache: false, runtime: createSilentRuntime(), diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 2799e11f5f0b..4e9b73f84e54 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -2894,6 +2894,17 @@ export const en: TranslationMap = { installNamed: "Install {name}", acknowledgeRisk: "Acknowledge risk and install", defaultRiskWarning: "Review the ClawHub warning before installing this plugin.", + policyReviewTitle: "Security review needed", + policyReviewBodyKnown: "Policy warnings: {count}. Not installed.", + policyReviewBodyReason: "{reason} Not installed.", + policyReviewFindings: "Findings", + policyReviewSeverityInfo: "Info", + policyReviewSeverityWarn: "Warning", + policyReviewSeverityCritical: "Critical", + policyReviewTechnicalDetails: "Details", + policyReviewScope: + "Install anyway approves every install-policy warning encountered during this install. Each warning is checked again before installation continues.", + installAnyway: "Install anyway", connectToChange: "Connect to the gateway to change plugins.", adminRequired: "Browsing only. Plugin changes require operator.admin access.", changesDisabled: "Browsing only. This gateway does not allow plugin changes.", diff --git a/ui/src/lib/plugins/index.ts b/ui/src/lib/plugins/index.ts index bd70559dd4d8..4539a0f6c406 100644 --- a/ui/src/lib/plugins/index.ts +++ b/ui/src/lib/plugins/index.ts @@ -23,6 +23,26 @@ export type PluginInstallRequest = PluginsInstallParams; export type PluginMutationResult = PluginsInstallResult | PluginsSetEnabledResult; type PluginUninstallResult = PluginsUninstallResult; +export function resolvePluginInstallIdentity( + request: PluginInstallRequest, + plugins: readonly PluginCatalogItem[], + runtimeId?: string, +): string { + if (request.source === "official") { + return `plugin:${request.pluginId}`; + } + const catalogEntry = + plugins.find( + (plugin) => + plugin.packageName === request.packageName || + (plugin.install?.source === "clawhub" && + plugin.install.packageName === request.packageName), + ) ?? (runtimeId ? plugins.find((plugin) => plugin.id === runtimeId) : undefined); + return catalogEntry || runtimeId + ? `plugin:${catalogEntry?.id ?? runtimeId}` + : `clawhub:${request.packageName}`; +} + export const CLAWHUB_BROWSE_URL = "https://clawhub.ai/plugins"; export function loadPluginCatalog(client: GatewayBrowserClient): Promise { diff --git a/ui/src/pages/plugins/install-policy-warning.test.ts b/ui/src/pages/plugins/install-policy-warning.test.ts new file mode 100644 index 000000000000..d82192d4b775 --- /dev/null +++ b/ui/src/pages/plugins/install-policy-warning.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { GatewayRequestError } from "../../api/gateway.ts"; +import { readPluginInstallPolicyWarning } from "./install-policy-warning.ts"; + +describe("readPluginInstallPolicyWarning", () => { + it("parses structured policy warnings", () => { + const error = new GatewayRequestError({ + code: "INVALID_REQUEST", + message: "Install requires approval", + details: { + installPolicyCode: "install_policy_warning_acknowledgement_required", + targetName: " openclaw-kitchen-sink-fixture ", + targetType: "plugin", + requestMode: "install", + reason: " ClawScan found issues to review. ", + findings: [ + { + ruleId: "semgrep-finding", + severity: "warn", + message: "Semgrep found a risky command.", + file: "index.ts", + line: 12, + }, + ], + futureField: true, + }, + }); + + expect(readPluginInstallPolicyWarning(error)).toEqual({ + installPolicyCode: "install_policy_warning_acknowledgement_required", + targetName: "openclaw-kitchen-sink-fixture", + targetType: "plugin", + requestMode: "install", + reason: "ClawScan found issues to review.", + findings: [ + { + ruleId: "semgrep-finding", + severity: "warn", + message: "Semgrep found a risky command.", + file: "index.ts", + line: 12, + }, + ], + }); + }); + + it("rejects malformed policy warning details", () => { + const error = new GatewayRequestError({ + code: "INVALID_REQUEST", + message: "Install requires approval", + details: { + installPolicyCode: "install_policy_warning_acknowledgement_required", + targetName: "fixture", + targetType: "plugin", + requestMode: "install", + reason: "Review required.", + findings: [{ ruleId: "finding", severity: "warn", message: 42 }], + }, + }); + + expect(readPluginInstallPolicyWarning(error)).toBeUndefined(); + }); +}); diff --git a/ui/src/pages/plugins/install-policy-warning.ts b/ui/src/pages/plugins/install-policy-warning.ts new file mode 100644 index 000000000000..b531b27b884d --- /dev/null +++ b/ui/src/pages/plugins/install-policy-warning.ts @@ -0,0 +1,16 @@ +import { + type InstallPolicyWarningErrorDetails, + readInstallPolicyWarningErrorDetails, +} from "../../../../packages/gateway-protocol/src/install-policy-warning-error-details.js"; +import { GatewayRequestError } from "../../api/gateway.ts"; + +export type PluginInstallPolicyWarningDetails = InstallPolicyWarningErrorDetails; + +export function readPluginInstallPolicyWarning( + error: unknown, +): InstallPolicyWarningErrorDetails | undefined { + if (!(error instanceof GatewayRequestError)) { + return undefined; + } + return readInstallPolicyWarningErrorDetails(error.details); +} diff --git a/ui/src/pages/plugins/plugins-page.test-support.ts b/ui/src/pages/plugins/plugins-page.test-support.ts index 893742c71be6..78fa7e7f9302 100644 --- a/ui/src/pages/plugins/plugins-page.test-support.ts +++ b/ui/src/pages/plugins/plugins-page.test-support.ts @@ -19,6 +19,7 @@ import { type ApplicationContextProvider, } from "../../test-helpers/application-context.ts"; import type { PluginsRouteData } from "./plugins-page.ts"; +import type { PluginRowMessage } from "./view.ts"; import "./plugins-page.ts"; type RequestHandler = (method: string, params: unknown) => Promise; @@ -34,10 +35,12 @@ type TestPluginsPage = HTMLElement & { result: PluginListResult | null; loading: boolean; busy: Record; + messages: Record; activeTab: "installed" | "discover"; searchResults: PluginSearchResult[] | null; applyMutationResult: (result: PluginMutationResult) => void; - install: (rowKey: string, request: PluginInstallRequest) => Promise; + install: (request: PluginInstallRequest, installIdentity: string) => Promise; + refreshCatalog: () => Promise; updateEnabled: (pluginId: string, enabled: boolean, key?: string) => Promise; uninstall: (pluginId: string, rowKey: string) => Promise; }; @@ -267,10 +270,12 @@ export async function mountPage( export function deferred() { let resolve!: (value: T) => void; - const promise = new Promise((nextResolve) => { + let reject!: (reason?: unknown) => void; + const promise = new Promise((nextResolve, nextReject) => { resolve = nextResolve; + reject = nextReject; }); - return { promise, resolve }; + return { promise, reject, resolve }; } export async function clickRowAction(page: TestPluginsPage, pluginSelector: string, label: string) { diff --git a/ui/src/pages/plugins/plugins-page.test.ts b/ui/src/pages/plugins/plugins-page.test.ts index 433f0c3830cd..f9c2639c29e7 100644 --- a/ui/src/pages/plugins/plugins-page.test.ts +++ b/ui/src/pages/plugins/plugins-page.test.ts @@ -2,12 +2,14 @@ import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GatewayRequestError } from "../../api/gateway.ts"; import type { ApplicationContext } from "../../app/context.ts"; import { i18n } from "../../i18n/index.ts"; import { createRuntimeConfigCapability } from "../../lib/config/runtime-config-capability.ts"; import type { PluginInstallRequest, PluginListResult, + PluginMutationResult, PluginSearchResult, } from "../../lib/plugins/index.ts"; import { waitForFast } from "../../test-helpers/wait-for.ts"; @@ -199,6 +201,68 @@ describe("PluginsPage", () => { ); }); + it("owns install-policy reviews by install identity across row aliases", async () => { + let installCalls = 0; + const { client } = createClient(async (method) => { + if (method !== "plugins.install") { + throw new Error(`Unexpected method ${method}`); + } + installCalls += 1; + if (installCalls <= 2) { + throw new GatewayRequestError({ + code: "INVALID_REQUEST", + message: "install requires review", + details: { + installPolicyCode: "install_policy_warning_acknowledgement_required", + targetName: "@openclaw/lobster", + targetType: "plugin", + requestMode: "install", + reason: `Review this plugin (${installCalls}).`, + }, + }); + } + return { + ok: true, + plugin: createPlugin({ id: "lobster", name: "Lobster", installed: true }), + restartRequired: false, + } satisfies PluginMutationResult; + }); + const harness = createGateway(client); + const { page } = await mountPage( + createContext(harness.gateway), + createPluginsRouteData(harness.gateway), + ); + const installIdentity = "plugin:lobster"; + const catalogRequest = { + source: "official", + pluginId: "lobster", + } satisfies PluginInstallRequest; + const searchRequest = { + source: "clawhub", + packageName: "@openclaw/lobster", + } satisfies PluginInstallRequest; + page.messages["plugin:workboard"] = { kind: "success", text: "Unrelated message." }; + + await page.install(catalogRequest, installIdentity); + expect(page.messages[installIdentity]?.installPolicyWarning?.details.reason).toBe( + "Review this plugin (1).", + ); + + await page.install(searchRequest, installIdentity); + expect(page.messages[installIdentity]?.installPolicyWarning?.details.reason).toBe( + "Review this plugin (2).", + ); + + await page.install( + { ...searchRequest, acknowledgeInstallPolicyWarning: true }, + installIdentity, + ); + + expect(page.messages[installIdentity]?.installPolicyWarning).toBeUndefined(); + expect(page.messages[installIdentity]?.kind).toBe("success"); + expect(page.messages["plugin:workboard"]?.text).toBe("Unrelated message."); + }); + it("debounces two-character ClawHub searches and cancels stale input", async () => { vi.useFakeTimers(); const { client, request } = createClient(async (method) => { @@ -414,10 +478,13 @@ describe("PluginsPage", () => { runtimeConfig.patchForm(["pending"], true); if (action === "install") { - await page.install("search:example-plugin", { - source: "clawhub", - packageName: "example-plugin", - } as PluginInstallRequest); + await page.install( + { + source: "clawhub", + packageName: "example-plugin", + } as PluginInstallRequest, + "clawhub:example-plugin", + ); } else if (action === "enable") { await page.updateEnabled("workboard", true); } else { diff --git a/ui/src/pages/plugins/plugins-page.ts b/ui/src/pages/plugins/plugins-page.ts index 353666edd85b..62a76cdbcf6c 100644 --- a/ui/src/pages/plugins/plugins-page.ts +++ b/ui/src/pages/plugins/plugins-page.ts @@ -51,6 +51,7 @@ import { import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; import { fetchPluginIconBlobUrl } from "./icon-loader.ts"; +import { readPluginInstallPolicyWarning } from "./install-policy-warning.ts"; import { PLUGINS_HUB_PANEL_ID, pluginsHubTabs, type PluginsHubTab } from "./plugins-hub.ts"; import type { ConnectorSuggestion } from "./presentation.ts"; import { pluginArtPath } from "./presentation.ts"; @@ -736,6 +737,7 @@ class PluginsPage extends OpenClawLightDomElement { text: formatUiError(error), }); }, + options: { preserveMessageWhilePending?: boolean } = {}, ): Promise { const scope = this.gateway.capture(); if (!scope || !this.canMutate() || this.busy[rowKey]) { @@ -746,7 +748,9 @@ class PluginsPage extends OpenClawLightDomElement { const isCurrent = () => this.gateway.isCurrent(scope) && this.mutationTokens.get(rowKey) === mutationToken; this.setBusy(rowKey, true); - this.setMessage(rowKey, null); + if (!options.preserveMessageWhilePending) { + this.setMessage(rowKey, null); + } try { const mutation = await runPluginConfigMutation( this.context.runtimeConfig, @@ -769,23 +773,32 @@ class PluginsPage extends OpenClawLightDomElement { } } - private async install(rowKey: string, request: PluginInstallRequest): Promise { + private async install(request: PluginInstallRequest, installIdentity: string): Promise { await this.runPluginMutation( - rowKey, + installIdentity, (client) => installPlugin(client, request), async (result, refreshError, client) => { this.applyMutationResult(result); this.setMessage( - rowKey, + installIdentity, committedMutationMessage(mutationSuccessMessage("installed", result), refreshError), ); await this.refreshCatalogAfterMutation(client); }, (error) => { + const policyWarning = readPluginInstallPolicyWarning(error); + if (policyWarning) { + this.setMessage(installIdentity, { + kind: "warning", + text: policyWarning.reason, + installPolicyWarning: { details: policyWarning, request }, + }); + return; + } const trust = readPluginInstallTrustError(error); const packageName = request.source === "clawhub" ? request.packageName : null; if (packageName && pluginInstallNeedsRiskAcknowledgement(error)) { - this.setMessage(rowKey, { + this.setMessage(installIdentity, { kind: "error", text: trust?.warning ?? t("pluginsPage.defaultRiskWarning"), acknowledge: { @@ -795,11 +808,14 @@ class PluginsPage extends OpenClawLightDomElement { }); return; } - this.setMessage(rowKey, { + this.setMessage(installIdentity, { kind: "error", text: formatUiError(error), }); }, + { + preserveMessageWhilePending: request.acknowledgeInstallPolicyWarning === true, + }, ); } @@ -1029,7 +1045,8 @@ class PluginsPage extends OpenClawLightDomElement { }, onSetEnabled: (pluginId, enabled, rowKey) => void this.updateEnabled(pluginId, enabled, rowKey), - onInstall: (rowKey, request) => void this.install(rowKey, request), + onInstall: (request, installIdentity) => void this.install(request, installIdentity), + onDismissMessage: (rowKey) => this.setMessage(rowKey, null), onRequestUninstall: (rowKey) => this.setPendingRemoval(rowKey, true), onCancelUninstall: (rowKey) => this.setPendingRemoval(rowKey, false), onUninstall: (pluginId, rowKey) => void this.uninstall(pluginId, rowKey), diff --git a/ui/src/pages/plugins/plugins.e2e.test.ts b/ui/src/pages/plugins/plugins.e2e.test.ts index fd9bc2df1b1a..d806e9d3b8c7 100644 --- a/ui/src/pages/plugins/plugins.e2e.test.ts +++ b/ui/src/pages/plugins/plugins.e2e.test.ts @@ -74,6 +74,17 @@ const lobsterPlugin = { install: { source: "clawhub", packageName: "@openclaw/lobster" }, } satisfies PluginCatalogItem; +const installedLobsterPlugin = { + ...lobsterPlugin, + packageName: "@openclaw/lobster", + version: "2026.8.10", + origin: "global", + installed: true, + enabled: true, + state: "enabled", + removable: true, +} satisfies PluginCatalogItem; + const remoteIconPlugin = { id: "remote-icon", name: "FireCrawl", @@ -138,6 +149,22 @@ const calendarSearchResponse = { ], } satisfies PluginsSearchResult; +const lobsterSearchResponse = { + results: [ + { + score: 1, + package: { + name: "@openclaw/lobster", + displayName: "Lobster", + family: "code-plugin", + channel: "official", + isOfficial: true, + runtimeId: "lobster", + }, + }, + ], +} satisfies PluginsSearchResult; + const uninstallResult = { ok: true, pluginId: "calendar-plus", @@ -151,6 +178,37 @@ const installResult = { restartRequired: true, } satisfies PluginMutationResult; +const installPolicyWarning = { + installPolicyCode: "install_policy_warning_acknowledgement_required", + targetName: "@openclaw/lobster", + targetType: "plugin", + requestMode: "install", + reason: "ClawScan found issues to review.", + findings: [ + { + ruleId: "semgrep-finding", + severity: "warn", + message: "Semgrep found a risky command.", + file: "index.ts", + line: 12, + }, + ], +}; + +const changedInstallPolicyWarning = { + ...installPolicyWarning, + reason: "ClawScan returned a changed warning after the fresh check.", + findings: [ + { + ruleId: "dependency-finding", + severity: "critical", + message: "The freshly checked warning changed and requires review.", + file: "package-lock.json", + line: 24, + }, + ], +}; + const enableWorkboardResult = { ok: true, plugin: workboardEnabled, @@ -639,6 +697,153 @@ describeControlUiE2e("Control UI Plugins mocked Gateway E2E", () => { } }); + it("reviews an install policy warning before sending an acknowledged retry", async () => { + const context = await newContext(); + const page = await context.newPage(); + const gateway = await installMockGateway(page, { + featureMethods: pluginMethods, + methodResponses: { + ...pluginMethodResponses(), + "plugins.search": lobsterSearchResponse, + }, + }); + + try { + await page.goto(`${server.baseUrl}settings/plugins`); + await page.getByRole("tab", { name: /^Discover/u }).click(); + const row = page.locator('[data-plugin-id="lobster"]'); + await row.waitFor({ state: "visible" }); + + await gateway.deferNext("plugins.install"); + await row.getByRole("button", { name: "Install Lobster", exact: true }).click(); + expect(requestParams(await gateway.waitForRequest("plugins.install"))).toEqual({ + source: "clawhub", + packageName: "@openclaw/lobster", + }); + await gateway.rejectDeferred("plugins.install", { + code: "INVALID_REQUEST", + message: "raw terminal install-policy output", + details: installPolicyWarning, + }); + + const review = row.getByRole("alert"); + await review.waitFor({ state: "visible" }); + expect(await review.textContent()).toContain("Security review needed"); + await review + .getByText("ClawScan found issues to review.", { exact: true }) + .waitFor({ state: "visible" }); + expect(await review.textContent()).toContain("Policy warnings: 1"); + expect(await review.textContent()).toContain("Not installed"); + expect(await review.textContent()).toContain( + "Install anyway approves every install-policy warning encountered during this install", + ); + expect(await review.textContent()).toContain("Warning"); + expect(await review.textContent()).toContain("Semgrep found a risky command."); + expect(await review.textContent()).not.toContain("raw terminal install-policy output"); + await page.getByRole("searchbox", { name: "Search plugins" }).fill("lobster"); + await gateway.waitForRequest("plugins.search"); + const searchRow = page.locator('[data-package-name="@openclaw/lobster"]'); + const searchReview = searchRow.getByRole("alert"); + await searchReview.waitFor({ state: "visible" }); + expect( + await searchRow.getByRole("button", { name: "Install Lobster", exact: true }).count(), + ).toBe(0); + await captureScreenshot(page, "09-policy-review-desktop.png"); + + await page.setViewportSize(mobileViewport); + await expect + .poll(() => + page.evaluate( + () => + Math.max(document.documentElement.scrollWidth, document.body.scrollWidth) - + window.innerWidth, + ), + ) + .toBeLessThanOrEqual(1); + await review.waitFor({ state: "visible" }); + await captureScreenshot(page, "09-policy-review-mobile.png"); + + const installCountBeforeCancel = (await gateway.getRequests("plugins.install")).length; + await searchReview.getByRole("button", { name: "Cancel", exact: true }).click(); + await review.waitFor({ state: "detached" }); + await searchReview.waitFor({ state: "detached" }); + expect((await gateway.getRequests("plugins.install")).length).toBe(installCountBeforeCancel); + await page.setViewportSize(desktopViewport); + + const installCountBeforeSecondAttempt = (await gateway.getRequests("plugins.install")).length; + await gateway.deferNext("plugins.install"); + await row.getByRole("button", { name: "Install Lobster", exact: true }).click(); + await waitForNextRequest(gateway, "plugins.install", installCountBeforeSecondAttempt); + await gateway.rejectDeferred("plugins.install", { + code: "INVALID_REQUEST", + message: "raw terminal install-policy output", + details: installPolicyWarning, + }); + await review.waitFor({ state: "visible" }); + + const installCountBeforeRetry = (await gateway.getRequests("plugins.install")).length; + await gateway.deferNext("plugins.install"); + await searchReview.getByRole("button", { name: "Install anyway", exact: true }).click(); + const retry = await waitForNextRequest(gateway, "plugins.install", installCountBeforeRetry); + expect(requestParams(retry)).toEqual({ + source: "clawhub", + packageName: "@openclaw/lobster", + acknowledgeInstallPolicyWarning: true, + }); + const pendingRetry = review.getByRole("button", { name: "Installing…", exact: true }); + await pendingRetry.waitFor({ state: "visible" }); + expect(await pendingRetry.isDisabled()).toBe(true); + expect(await review.textContent()).toContain("Semgrep found a risky command."); + await gateway.rejectDeferred("plugins.install", { + code: "INVALID_REQUEST", + message: "raw dependency policy output", + details: changedInstallPolicyWarning, + }); + + await review.waitFor({ state: "visible" }); + expect(await review.textContent()).toContain("Critical"); + expect(await review.textContent()).toContain( + "The freshly checked warning changed and requires review.", + ); + expect(await review.textContent()).not.toContain("raw dependency policy output"); + await captureScreenshot(page, "10-dependency-policy-review-desktop.png"); + + const installCountBeforeSecondRetry = (await gateway.getRequests("plugins.install")).length; + await gateway.deferNext("plugins.install"); + await review.getByRole("button", { name: "Install anyway", exact: true }).click(); + const secondRetry = await waitForNextRequest( + gateway, + "plugins.install", + installCountBeforeSecondRetry, + ); + expect(requestParams(secondRetry)).toEqual({ + source: "clawhub", + packageName: "@openclaw/lobster", + acknowledgeInstallPolicyWarning: true, + }); + + await gateway.setMethodResponse( + "plugins.list", + inventory([workboardDisabled, installedLobsterPlugin, remoteIconPlugin]), + ); + await gateway.resolveDeferred("plugins.install", { + ok: true, + plugin: installedLobsterPlugin, + restartRequired: true, + } satisfies PluginMutationResult); + await page + .locator('[data-plugin-id="lobster"][data-plugin-status="enabled"]') + .waitFor({ state: "visible" }); + await review.waitFor({ state: "detached" }); + await searchReview.waitFor({ state: "detached" }); + expect(await page.getByRole("button", { name: "Install anyway", exact: true }).count()).toBe( + 0, + ); + } finally { + await context.close(); + } + }); + it("keeps plugin mutations unavailable to read-only operators while browse and search work", async () => { const context = await newContext(); const page = await context.newPage(); diff --git a/ui/src/pages/plugins/view.test.ts b/ui/src/pages/plugins/view.test.ts index 13c1a3d6fce0..47d45d43929f 100644 --- a/ui/src/pages/plugins/view.test.ts +++ b/ui/src/pages/plugins/view.test.ts @@ -65,6 +65,7 @@ function createProps(overrides: Partial = {}): PluginsViewProp onShowDetails: () => undefined, onSetEnabled: () => undefined, onInstall: () => undefined, + onDismissMessage: () => undefined, onRequestUninstall: () => undefined, onCancelUninstall: () => undefined, onUninstall: () => undefined, @@ -438,10 +439,13 @@ describe("renderPlugins", () => { container .querySelector('[data-plugin-id="tavily"] .plugins-install') ?.click(); - expect(onInstall).toHaveBeenCalledWith(pluginRowKey("tavily"), { - source: "official", - pluginId: "tavily", - }); + expect(onInstall).toHaveBeenCalledWith( + { + source: "official", + pluginId: "tavily", + }, + pluginRowKey("tavily"), + ); }); it("renders featured plugins newest-featured first", () => { @@ -575,10 +579,13 @@ describe("renderPlugins", () => { expect(normalizedText(result)).toContain("149.3K"); expect(normalizedText(result)).toContain("Code plugin"); result?.querySelector('[aria-label="Install Calendar Plus"]')?.click(); - expect(onInstall).toHaveBeenCalledWith(clawHubKey("@openclaw/calendar-plus"), { - source: "clawhub", - packageName: "@openclaw/calendar-plus", - }); + expect(onInstall).toHaveBeenCalledWith( + { + source: "clawhub", + packageName: "@openclaw/calendar-plus", + }, + clawHubKey("@openclaw/calendar-plus"), + ); }); it("keeps discovery available while disabling all read-only mutations", () => { @@ -651,12 +658,277 @@ describe("renderPlugins", () => { expect(row?.getAttribute("aria-busy")).toBe("false"); expect(row?.querySelector('[role="alert"]')?.textContent).toContain("Review required."); row?.querySelector(".plugins-row-message button")?.click(); - expect(onInstall).toHaveBeenCalledWith(key, { - source: "clawhub", - packageName, - version: "2.0.0", - acknowledgeClawHubRisk: true, + expect(onInstall).toHaveBeenCalledWith( + { + source: "clawhub", + packageName, + version: "2.0.0", + acknowledgeClawHubRisk: true, + }, + key, + ); + }); + + it("renders install policy findings with cancel and acknowledged retry actions", () => { + const plugin = createPlugin({ + id: "kitchen-sink", + name: "OpenClaw Kitchen Sink", + installed: false, + enabled: false, + state: "disabled", + install: { source: "official", pluginId: "kitchen-sink" }, }); + const key = pluginRowKey(plugin.id); + const onInstall = vi.fn(); + const onDismissMessage = vi.fn(); + const onShowDetails = vi.fn(); + const request = { source: "official" as const, pluginId: "kitchen-sink" }; + const container = mount( + createProps({ + activeTab: "discover", + result: createResult([plugin]), + messages: { + [key]: { + kind: "warning", + text: "ClawScan found issues to review.", + installPolicyWarning: { + request, + details: { + installPolicyCode: "install_policy_warning_acknowledgement_required", + targetName: "openclaw-kitchen-sink-fixture", + targetType: "plugin", + requestMode: "install", + reason: "ClawScan found issues to review.", + findings: [ + { + ruleId: "informational-finding", + severity: "info", + message: "The package declares a network integration.", + }, + { + ruleId: "semgrep-finding", + severity: "warn", + message: "Semgrep found a risky command.", + file: "index.ts", + line: 12, + }, + { + ruleId: "critical-finding", + severity: "critical", + message: "The package executes an untrusted binary.", + }, + ], + }, + }, + }, + }, + onInstall, + onDismissMessage, + onShowDetails, + }), + ); + + const row = expectDefined( + container.querySelector('[data-plugin-id="kitchen-sink"]'), + "kitchen sink plugin row", + ); + const alert = expectDefined(row.querySelector('[role="alert"]'), "install policy warning"); + expect(normalizedText(alert)).toContain("Security review needed"); + expect(normalizedText(alert)).toContain("Policy warnings: 3"); + expect(normalizedText(alert)).toContain("Not installed"); + expect(normalizedText(alert)).toContain( + "Install anyway approves every install-policy warning encountered during this install", + ); + expect(normalizedText(alert)).toContain("Findings"); + expect(normalizedText(alert)).toContain("Info The package declares a network integration."); + expect(normalizedText(alert)).toContain("Warning Semgrep found a risky command."); + expect(normalizedText(alert)).toContain("Critical The package executes an untrusted binary."); + expect(normalizedText(alert)).toContain("Semgrep found a risky command."); + expect(normalizedText(alert.querySelector(".plugins-policy-review__reason"))).toBe( + "ClawScan found issues to review.", + ); + const technicalDetails = expectDefined( + alert.querySelector(".plugins-policy-review__details"), + "install policy scan details", + ); + expect(technicalDetails.open).toBe(false); + expect(normalizedText(technicalDetails.querySelector("summary"))).toBe("Details"); + expect( + technicalDetails?.querySelector(".plugins-policy-review__details-chevron svg"), + ).not.toBeNull(); + expect(normalizedText(technicalDetails)).not.toContain("ClawScan found issues to review."); + expect(normalizedText(technicalDetails)).toContain("semgrep-finding"); + expect(normalizedText(technicalDetails)).toContain("index.ts:12"); + technicalDetails.querySelector("summary")?.click(); + expect(technicalDetails.open).toBe(true); + expect(onShowDetails).not.toHaveBeenCalled(); + technicalDetails.querySelector(".plugins-policy-review__details-body")?.click(); + expect(onShowDetails).not.toHaveBeenCalled(); + + actionButton(alert, "Cancel")?.click(); + expect(onDismissMessage).toHaveBeenCalledWith(key); + + actionButton(alert, "Install anyway")?.click(); + expect(onInstall).toHaveBeenCalledWith( + { + ...request, + acknowledgeInstallPolicyWarning: true, + }, + key, + ); + }); + + it("shares one install-policy review across catalog, search, and detail aliases", () => { + const plugin = createPlugin({ + id: "lobster", + name: "Lobster", + packageName: "@openclaw/lobster", + installed: false, + enabled: false, + state: "disabled", + install: { source: "official", pluginId: "lobster" }, + }); + const identity = pluginRowKey(plugin.id); + const request = { source: "official", pluginId: "lobster" } as const; + const onInstall = vi.fn(); + const onDismissMessage = vi.fn(); + const container = mount( + createProps({ + activeTab: "discover", + query: "lobster", + result: createResult([plugin]), + detailPluginId: plugin.id, + searchResults: [ + { + score: 1, + package: { + name: "@openclaw/lobster", + displayName: "Lobster", + family: "code-plugin", + channel: "official", + isOfficial: true, + runtimeId: "lobster", + }, + }, + ], + messages: { + [identity]: { + kind: "warning", + text: "Review this plugin.", + installPolicyWarning: { + request, + details: { + installPolicyCode: "install_policy_warning_acknowledgement_required", + targetName: "@openclaw/lobster", + targetType: "plugin", + requestMode: "install", + reason: "Review this plugin.", + }, + }, + }, + }, + onInstall, + onDismissMessage, + }), + ); + + const catalogRow = expectDefined( + container.querySelector('[data-plugin-id="lobster"]'), + "catalog row", + ); + const searchRow = expectDefined( + container.querySelector('[data-package-name="@openclaw/lobster"]'), + "search row", + ); + const detail = expectDefined( + container.querySelector('[data-detail-plugin-id="lobster"]'), + "detail", + ); + for (const surface of [catalogRow, searchRow, detail]) { + expect(normalizedText(surface.querySelector('[role="alert"]'))).toContain( + "Review this plugin.", + ); + expect(actionButton(surface, "Install Lobster")).toBeNull(); + } + + actionButton(searchRow, "Install anyway")?.click(); + expect(onInstall).toHaveBeenCalledWith( + { ...request, acknowledgeInstallPolicyWarning: true }, + identity, + ); + actionButton(detail, "Cancel")?.click(); + expect(onDismissMessage).toHaveBeenCalledWith(identity); + }); + + it("preserves a search-only runtime identity when installing", () => { + const onInstall = vi.fn(); + const container = mount( + createProps({ + activeTab: "discover", + query: "lobster", + result: createResult([]), + searchResults: [ + { + score: 1, + package: { + name: "@openclaw/lobster", + displayName: "Lobster", + family: "code-plugin", + channel: "official", + isOfficial: true, + runtimeId: "lobster", + }, + }, + ], + onInstall, + }), + ); + + actionButton(container, "Install Lobster")?.click(); + expect(onInstall).toHaveBeenCalledWith( + { source: "clawhub", packageName: "@openclaw/lobster" }, + "plugin:lobster", + ); + }); + + it("keeps the not-installed outcome visible for reason-only policy warnings", () => { + const plugin = createPlugin({ + id: "reason-only", + name: "Reason Only", + installed: false, + enabled: false, + state: "disabled", + install: { source: "official", pluginId: "reason-only" }, + }); + const key = pluginRowKey(plugin.id); + const container = mount( + createProps({ + activeTab: "discover", + result: createResult([plugin]), + messages: { + [key]: { + kind: "warning", + text: "Review this package source.", + installPolicyWarning: { + request: { source: "official", pluginId: "reason-only" }, + details: { + installPolicyCode: "install_policy_warning_acknowledgement_required", + targetName: "reason-only", + targetType: "plugin", + requestMode: "install", + reason: "Review this package source.", + }, + }, + }, + }, + }), + ); + + const alert = expectDefined( + container.querySelector('[data-plugin-id="reason-only"] [role="alert"]'), + "reason-only install policy warning", + ); + expect(normalizedText(alert)).toContain("Review this package source. Not installed."); }); it("correlates installed ClawHub packages without a search runtime id", () => { diff --git a/ui/src/pages/plugins/view.ts b/ui/src/pages/plugins/view.ts index 179ac4b26b8a..485f6f11d4e9 100644 --- a/ui/src/pages/plugins/view.ts +++ b/ui/src/pages/plugins/view.ts @@ -23,11 +23,13 @@ import { EXTERNAL_LINK_TARGET, buildExternalLinkRel } from "../../lib/external-l import "../../styles/plugins.css"; import { CLAWHUB_BROWSE_URL, + resolvePluginInstallIdentity, type PluginCatalogItem, type PluginInstallRequest, type PluginListResult, type PluginSearchResult, } from "../../lib/plugins/index.ts"; +import type { PluginInstallPolicyWarningDetails } from "./install-policy-warning.ts"; import { CONNECTOR_GROUP_ORDER, CONNECTOR_SUGGESTIONS, @@ -45,11 +47,32 @@ export type PluginsTab = "installed" | "discover"; export type InstalledFilter = "all" | "enabled" | "disabled" | "issues"; export type PluginRowMessage = { - kind: "success" | "error"; + kind: "success" | "error" | "warning"; text: string; acknowledge?: { packageName: string; version?: string }; + installPolicyWarning?: { + details: PluginInstallPolicyWarningDetails; + request: PluginInstallRequest; + }; }; +type PluginInstallPolicyFinding = NonNullable< + PluginInstallPolicyWarningDetails["findings"] +>[number]; + +function policyFindingSeverityLabel(severity: PluginInstallPolicyFinding["severity"]): string { + switch (severity) { + case "info": + return t("pluginsPage.policyReviewSeverityInfo"); + case "warn": + return t("pluginsPage.policyReviewSeverityWarn"); + case "critical": + return t("pluginsPage.policyReviewSeverityCritical"); + } + const unreachableSeverity: never = severity; + return unreachableSeverity; +} + type PluginsViewProps = { connected: boolean; loading: boolean; @@ -80,7 +103,8 @@ type PluginsViewProps = { onIconError: (pluginId: string) => void; onShowDetails: (pluginId: string | null) => void; onSetEnabled: (pluginId: string, enabled: boolean, rowKey: string) => void; - onInstall: (rowKey: string, request: PluginInstallRequest) => void; + onInstall: (request: PluginInstallRequest, installIdentity: string) => void; + onDismissMessage: (rowKey: string) => void; onRequestUninstall: (rowKey: string) => void; onCancelUninstall: (rowKey: string) => void; onUninstall: (pluginId: string, rowKey: string) => void; @@ -132,6 +156,18 @@ function clawHubRowKey(packageName: string): string { return `clawhub:${packageName}`; } +function resolveInstallIdentity( + props: PluginsViewProps, + request: PluginInstallRequest, + runtimeId?: string, +): string { + return resolvePluginInstallIdentity(request, props.result?.plugins ?? [], runtimeId); +} + +function installOperationBusy(props: PluginsViewProps, identity: string | undefined): boolean { + return identity ? Boolean(props.busy[identity]) : false; +} + export function connectorRowKey(connectorId: string): string { return `connector:${connectorId}`; } @@ -355,6 +391,16 @@ function originLabel(origin: string): string { } } +function requestInstall( + props: PluginsViewProps, + request: PluginInstallRequest, + installIdentity?: string, +) { + if (installIdentity) { + props.onInstall(request, installIdentity); + } +} + /** Dot-separated plain-text meta line under a row description. */ function renderMetaLine(parts: ReadonlyArray) { const visible = parts.filter((part) => part !== nothing && part !== ""); @@ -374,15 +420,127 @@ function renderRowMessage( message: PluginRowMessage | undefined, busy: boolean, props: PluginsViewProps, + installIdentity?: string, ) { - if (!message) { + const messageKey = message ? key : (installIdentity ?? key); + const resolvedMessage = + message ?? (installIdentity ? props.messages[installIdentity] : undefined); + if (!resolvedMessage) { return nothing; } - const role = message.kind === "error" ? "alert" : "status"; + if (resolvedMessage.installPolicyWarning) { + const { details, request } = resolvedMessage.installPolicyWarning; + const findings = details.findings ?? []; + const reviewBody = + findings.length === 0 + ? t("pluginsPage.policyReviewBodyReason", { reason: details.reason }) + : t("pluginsPage.policyReviewBodyKnown", { count: String(findings.length) }); + return html` + + `; + } + const role = resolvedMessage.kind === "error" ? "alert" : "status"; return html` -
- ${message.text} - ${message.acknowledge +
+ ${resolvedMessage.text} + ${resolvedMessage.acknowledge ? html` @@ -408,7 +572,9 @@ function renderRowMessage( /** Ignore activations bubbling from interactive children so rows stay clickable. */ function fromInteractiveChild(event: Event): boolean { return Boolean( - (event.target as HTMLElement | null)?.closest("button, a, input, label, form, [role='menu']"), + (event.target as HTMLElement | null)?.closest( + "button, a, input, label, form, summary, .plugins-policy-review, [role='menu']", + ), ); } @@ -464,10 +630,14 @@ function renderRemoveButton( function renderInstallButton( props: PluginsViewProps, busy: boolean, - key: string, name: string, request: PluginInstallRequest, + installIdentity: string, ) { + const installMessage = props.messages[installIdentity]; + if (installMessage?.installPolicyWarning || installMessage?.acknowledge) { + return nothing; + } return html`
- ${renderRowMessage(key, props.messages[key], busy, props)} + ${renderRowMessage(key, props.messages[key], busy, props, installIdentity)} `; } @@ -1045,7 +1223,10 @@ function renderDetailOverlay(props: PluginsViewProps) { return nothing; } const key = pluginRowKey(plugin.id); - const busy = props.busy[key] ?? false; + const installIdentity = plugin.install + ? resolveInstallIdentity(props, plugin.install) + : undefined; + const busy = props.busy[key] || installOperationBusy(props, installIdentity); return html` ` : plugin.install - ? renderInstallButton(props, busy, key, plugin.name, plugin.install) + ? renderInstallButton( + props, + busy, + plugin.name, + plugin.install, + resolveInstallIdentity(props, plugin.install), + ) : nothing} ${plugin.removable ? html` @@ -1123,7 +1310,7 @@ function renderDetailOverlay(props: PluginsViewProps) { ${plugin.error}
` : nothing} - ${renderRowMessage(key, props.messages[key], busy, props)} + ${renderRowMessage(key, props.messages[key], busy, props, installIdentity)}
${plugin.origin ? detailMetaRow(t("pluginsPage.detailOrigin"), originLabel(plugin.origin)) diff --git a/ui/src/styles/plugins.css b/ui/src/styles/plugins.css index 29a4b8a8816d..f00e9e878d6d 100644 --- a/ui/src/styles/plugins.css +++ b/ui/src/styles/plugins.css @@ -462,6 +462,223 @@ h3.plugins-subheader { color: var(--danger); } +.plugins-row-message--warning { + --plugins-policy-review-indent: calc(24px + var(--space-3)); + + align-items: stretch; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-3) 0 0; + border-top: 1px solid color-mix(in srgb, var(--warn) 28%, var(--border)); + border-radius: 0; + background: transparent; + color: var(--text); + font-size: var(--control-ui-text-sm); + white-space: normal; +} + +.plugins-policy-review__header { + display: grid; + grid-template-columns: 24px minmax(0, 1fr); + align-items: start; + gap: var(--space-3); +} + +.plugins-policy-review__header > div { + display: grid; + gap: 1px; +} + +.plugins-policy-review__icon { + display: inline-flex; + width: 24px; + height: 24px; + align-items: center; + justify-content: center; + color: var(--warn); +} + +.plugins-policy-review__icon svg { + width: 18px; + height: 18px; +} + +.plugins-policy-review__header strong { + color: var(--text-strong); + font-size: var(--control-ui-text-md); + line-height: 1.3; +} + +.plugins-policy-review__header span { + color: var(--muted); +} + +.plugins-policy-review__findings-panel { + display: grid; + gap: var(--space-1); + margin-left: var(--plugins-policy-review-indent); +} + +.plugins-policy-review__findings-heading { + color: var(--text-strong); + font-weight: 600; +} + +.plugins-policy-review__findings { + display: grid; + margin: 0; + padding: 0; + list-style: none; +} + +.plugins-policy-review__findings li { + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + align-items: start; + gap: var(--space-2); + padding: 0; + color: var(--text); +} + +.plugins-policy-review__findings li::before { + width: 7px; + height: 7px; + margin-top: 6px; + border-radius: var(--radius-full); + background: var(--warn); + content: ""; + justify-self: center; +} + +.plugins-policy-review__findings li + li { + margin-top: var(--space-2); +} + +.plugins-policy-review__finding-content { + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: baseline; + gap: var(--space-2); +} + +.plugins-policy-review__severity { + font-size: var(--control-ui-text-xs); + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.plugins-policy-review__severity--info { + color: var(--muted); +} + +.plugins-policy-review__severity--warn { + color: var(--warn); +} + +.plugins-policy-review__severity--critical { + color: var(--danger); +} + +.plugins-policy-review__details { + margin-left: var(--plugins-policy-review-indent); + color: var(--muted); + font-size: var(--control-ui-text-xs); +} + +.plugins-policy-review__details summary { + display: flex; + width: fit-content; + min-height: 44px; + align-items: center; + gap: var(--space-2); + cursor: var(--cursor-action); + font-weight: 600; + list-style: none; +} + +.plugins-policy-review__details summary::-webkit-details-marker { + display: none; +} + +.plugins-policy-review__details summary::marker { + content: ""; +} + +.plugins-policy-review__details-chevron { + display: inline-flex; + width: 18px; + height: 18px; + color: var(--muted); + transition: transform var(--duration-fast) var(--ease-in-out); +} + +.plugins-policy-review__details-chevron svg { + width: 18px; + height: 18px; +} + +.plugins-policy-review__details[open] .plugins-policy-review__details-chevron { + transform: rotate(90deg); +} + +.plugins-policy-review__details summary:hover { + color: var(--text); +} + +.plugins-policy-review__details summary:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +.plugins-policy-review__details-body { + display: grid; + gap: var(--space-2); + margin: var(--space-2) 0 0; +} + +.plugins-policy-review__details-body ul { + display: grid; + gap: var(--space-2); + margin: 0; + padding: 0; + list-style: none; +} + +.plugins-policy-review__details-body li { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: var(--space-2); + overflow-wrap: anywhere; + color: var(--text); +} + +.plugins-policy-review__details code { + font-size: inherit; +} + +.plugins-policy-review__scope { + margin: 0 0 0 var(--plugins-policy-review-indent); + color: var(--muted); + font-size: var(--control-ui-text-xs); +} + +.plugins-policy-review__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-3); + padding: var(--space-2) 0 0 var(--plugins-policy-review-indent); + border-top: 1px solid color-mix(in srgb, var(--warn) 20%, var(--border)); +} + +.plugins-policy-review__actions .btn { + flex: 0 0 auto; + min-height: 44px; +} + .plugins-row-message .btn { flex: 0 0 auto; color: var(--text-strong); @@ -699,4 +916,13 @@ h3.plugins-subheader { align-items: stretch; flex-direction: column; } + + .plugins-row-message--warning { + padding: var(--space-2) 0 0; + } + + .plugins-policy-review__actions, + .plugins-policy-review__actions .btn { + flex: 1 1 0; + } }