feat(ui): review install policy warnings

This commit is contained in:
jesse-merhi
2026-08-14 12:21:36 +10:00
parent 3324794f36
commit 4cb237056e
32 changed files with 1709 additions and 212 deletions
+1 -1
View File
@@ -560,7 +560,7 @@ methods. Treat this as feature discovery, not a full enumeration of
<Accordion title="Plugin management">
- `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.
+1 -1
View File
@@ -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)
+9 -5
View File
@@ -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
+7 -5
View File
@@ -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.
@@ -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 } : {}),
};
}
@@ -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",
@@ -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)),
}),
]);
@@ -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();
@@ -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", {
+12 -1
View File
@@ -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,
+16 -16
View File
@@ -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<void> {
const manifestPath = path.join(targetDir, "package.json");
const parsed = await tryReadJson<unknown>(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<void>;
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<InstallPackageDirSuccess | TAfterInstallFailure>;
}): Promise<InstallPackageDirSuccess | InstallPackageDirFailure | TAfterInstallFailure> {
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<string, unknown>;
afterCopy?: (installedDir: string) => void | Promise<void>;
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<InstallPackageDirSuccess | TAfterInstallFailure>;
}): Promise<InstallPackageDirSuccess | InstallPackageDirFailure | TAfterInstallFailure> {
const hasDeps = Object.keys(params.manifestDependencies ?? {}).length > 0;
return installPackageDir({
return installPackageDir<TAfterInstallFailure>({
...params,
hasDeps,
sourceHardlinks: hasDeps ? "package-manager" : "reject",
@@ -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: [
+22 -4
View File
@@ -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.",
},
};
+5 -1
View File
@@ -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;
};
};
+6 -1
View File
@@ -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. */
+54
View File
@@ -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);
});
});
+5 -10
View File
@@ -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 = {
+7 -1
View File
@@ -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<InstallPluginResult, { ok: false }>;
@@ -0,0 +1,105 @@
export function configSnapshot(config: Record<string, unknown> = {}) {
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<string, unknown>;
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)}`,
},
],
},
};
+69 -106
View File
@@ -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<string, unknown> = {}) {
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<string, unknown>;
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");
+36 -3
View File
@@ -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(),
+11
View File
@@ -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.",
+20
View File
@@ -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<PluginListResult> {
@@ -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();
});
});
@@ -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);
}
@@ -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<unknown>;
@@ -34,10 +35,12 @@ type TestPluginsPage = HTMLElement & {
result: PluginListResult | null;
loading: boolean;
busy: Record<string, boolean>;
messages: Record<string, PluginRowMessage>;
activeTab: "installed" | "discover";
searchResults: PluginSearchResult[] | null;
applyMutationResult: (result: PluginMutationResult) => void;
install: (rowKey: string, request: PluginInstallRequest) => Promise<void>;
install: (request: PluginInstallRequest, installIdentity: string) => Promise<void>;
refreshCatalog: () => Promise<void>;
updateEnabled: (pluginId: string, enabled: boolean, key?: string) => Promise<void>;
uninstall: (pluginId: string, rowKey: string) => Promise<void>;
};
@@ -267,10 +270,12 @@ export async function mountPage(
export function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return { promise, resolve };
return { promise, reject, resolve };
}
export async function clickRowAction(page: TestPluginsPage, pluginSelector: string, label: string) {
+71 -4
View File
@@ -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 {
+24 -7
View File
@@ -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<void> {
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<void> {
private async install(request: PluginInstallRequest, installIdentity: string): Promise<void> {
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),
+205
View File
@@ -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();
+285 -13
View File
@@ -65,6 +65,7 @@ function createProps(overrides: Partial<PluginsViewProps> = {}): PluginsViewProp
onShowDetails: () => undefined,
onSetEnabled: () => undefined,
onInstall: () => undefined,
onDismissMessage: () => undefined,
onRequestUninstall: () => undefined,
onCancelUninstall: () => undefined,
onUninstall: () => undefined,
@@ -438,10 +439,13 @@ describe("renderPlugins", () => {
container
.querySelector<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>(".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<HTMLElement>('[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<HTMLDetailsElement>(".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<HTMLElement>(".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<HTMLElement>('[data-plugin-id="lobster"]'),
"catalog row",
);
const searchRow = expectDefined(
container.querySelector<HTMLElement>('[data-package-name="@openclaw/lobster"]'),
"search row",
);
const detail = expectDefined(
container.querySelector<HTMLElement>('[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", () => {
+216 -29
View File
@@ -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<TemplateResult | string | typeof nothing>) {
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`
<div
class="plugins-row-message plugins-row-message--warning plugins-policy-review"
role="alert"
>
<div class="plugins-policy-review__header">
<span class="plugins-policy-review__icon" aria-hidden="true">
${icons.alertTriangle}
</span>
<div>
<strong>${t("pluginsPage.policyReviewTitle")}</strong>
${findings.length > 0
? html`<span class="plugins-policy-review__reason">${details.reason}</span>`
: nothing}
<span>${reviewBody}</span>
</div>
</div>
${findings.length > 0
? html`
<section class="plugins-policy-review__findings-panel">
<strong class="plugins-policy-review__findings-heading"
>${t("pluginsPage.policyReviewFindings")}</strong
>
<ul class="plugins-policy-review__findings">
${findings.map(
(finding) => html`
<li>
<span class="plugins-policy-review__finding-content">
<span
class="plugins-policy-review__severity plugins-policy-review__severity--${finding.severity}"
>${policyFindingSeverityLabel(finding.severity)}</span
>
<span>${finding.message}</span>
</span>
</li>
`,
)}
</ul>
</section>
`
: nothing}
${findings.length > 0
? html`
<details class="plugins-policy-review__details">
<summary>
<span class="plugins-policy-review__details-chevron" aria-hidden="true"
>${icons.chevronRight}</span
>
<span>${t("pluginsPage.policyReviewTechnicalDetails")}</span>
</summary>
<div class="plugins-policy-review__details-body">
<ul>
${findings.map(
(finding) => html`
<li>
<code>${finding.ruleId}</code>
${finding.file
? html`<code
>${finding.file}${finding.line ? `:${finding.line}` : ""}</code
>`
: nothing}
${finding.evidence ? html`<span>${finding.evidence}</span>` : nothing}
</li>
`,
)}
</ul>
</div>
</details>
`
: nothing}
<p class="plugins-policy-review__scope">${t("pluginsPage.policyReviewScope")}</p>
<div class="plugins-policy-review__actions">
<button
type="button"
class="btn btn--sm"
?disabled=${busy}
@click=${() => props.onDismissMessage(messageKey)}
>
${t("pluginsPage.cancel")}
</button>
<button
type="button"
class="btn btn--sm danger"
title=${props.mutationBlockedReason ?? ""}
?disabled=${busy || !props.canMutate}
@click=${() =>
requestInstall(
props,
{
...request,
acknowledgeInstallPolicyWarning: true,
},
installIdentity,
)}
>
${busy ? t("pluginsPage.installing") : t("pluginsPage.installAnyway")}
</button>
</div>
</div>
`;
}
const role = resolvedMessage.kind === "error" ? "alert" : "status";
return html`
<div class="plugins-row-message plugins-row-message--${message.kind}" role=${role}>
<span>${message.text}</span>
${message.acknowledge
<div class="plugins-row-message plugins-row-message--${resolvedMessage.kind}" role=${role}>
<span>${resolvedMessage.text}</span>
${resolvedMessage.acknowledge
? html`
<button
type="button"
@@ -390,12 +548,18 @@ function renderRowMessage(
title=${props.mutationBlockedReason ?? ""}
?disabled=${busy || !props.canMutate}
@click=${() =>
props.onInstall(key, {
source: "clawhub",
packageName: message.acknowledge?.packageName ?? "",
...(message.acknowledge?.version ? { version: message.acknowledge.version } : {}),
acknowledgeClawHubRisk: true,
})}
requestInstall(
props,
{
source: "clawhub",
packageName: resolvedMessage.acknowledge?.packageName ?? "",
...(resolvedMessage.acknowledge?.version
? { version: resolvedMessage.acknowledge.version }
: {}),
acknowledgeClawHubRisk: true,
},
installIdentity,
)}
>
${busy ? t("pluginsPage.installing") : t("pluginsPage.acknowledgeRisk")}
</button>
@@ -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`
<button
type="button"
@@ -477,7 +647,7 @@ function renderInstallButton(
?disabled=${!props.canMutate || busy}
@click=${(event: Event) => {
event.stopPropagation();
props.onInstall(key, request);
props.onInstall(request, installIdentity);
}}
>
${busy ? t("pluginsPage.installing") : t("pluginsPage.install")}
@@ -536,7 +706,13 @@ function renderCatalogActions(
if (!plugin.installed) {
const install = plugin.install;
return install
? renderInstallButton(props, busy, rowKey, plugin.name, install)
? renderInstallButton(
props,
busy,
plugin.name,
install,
resolveInstallIdentity(props, install),
)
: html`<span class="plugins-action-note">${t("pluginsPage.unavailable")}</span>`;
}
return html`
@@ -606,7 +782,10 @@ function renderPluginRow(
includePackageName = false,
): TemplateResult {
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`
<article
class="settings-row plugins-item plugins-item--clickable"
@@ -655,7 +834,7 @@ function renderPluginRow(
${plugin.error}
</div>`
: nothing}
${renderRowMessage(key, props.messages[key], busy, props)}
${renderRowMessage(key, props.messages[key], busy, props, installIdentity)}
</article>
`;
}
@@ -785,7 +964,7 @@ function renderConnectorRow(
props: PluginsViewProps,
): TemplateResult {
const key = connectorRowKey(connector.id);
const busy = props.busy[key] ?? false;
const busy = Boolean(props.busy[key]);
const isMcp = connector.action.kind === "mcp";
const installed =
isMcp &&
@@ -872,7 +1051,9 @@ function renderClawHubResult(item: PluginSearchResult, props: PluginsViewProps):
const pkg = item.package;
const installed = findInstalledSearchPlugin(item, props.result?.plugins ?? []);
const key = clawHubRowKey(pkg.name);
const busy = props.busy[key] ?? false;
const installRequest = { source: "clawhub", packageName: pkg.name } as const;
const installIdentity = resolveInstallIdentity(props, installRequest, pkg.runtimeId);
const busy = props.busy[key] || installOperationBusy(props, installIdentity);
const artSlug = pkg.runtimeId ?? pkg.name;
return html`
<article
@@ -917,12 +1098,9 @@ function renderClawHubResult(item: PluginSearchResult, props: PluginsViewProps):
<div class="settings-row__control">
${installed
? html`${rowStateStatus(installed)}${renderCatalogActions(installed, props, busy, key)}`
: renderInstallButton(props, busy, key, pkg.displayName, {
source: "clawhub",
packageName: pkg.name,
})}
: renderInstallButton(props, busy, pkg.displayName, installRequest, installIdentity)}
</div>
${renderRowMessage(key, props.messages[key], busy, props)}
${renderRowMessage(key, props.messages[key], busy, props, installIdentity)}
</article>
`;
}
@@ -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`
<openclaw-modal-dialog
label=${plugin.name}
@@ -1100,7 +1281,13 @@ function renderDetailOverlay(props: PluginsViewProps) {
</button>
`
: 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}
</div>`
: nothing}
${renderRowMessage(key, props.messages[key], busy, props)}
${renderRowMessage(key, props.messages[key], busy, props, installIdentity)}
<div class="plugins-detail__meta">
${plugin.origin
? detailMetaRow(t("pluginsPage.detailOrigin"), originLabel(plugin.origin))
+226
View File
@@ -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;
}
}