mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
feat(ui): review install policy warnings
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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");
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user