mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(security): bind approvals to resolved artifacts
This commit is contained in:
@@ -551,7 +551,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, installPolicyWarningAcknowledgement? }` or a ClawHub package with `{ source: "clawhub", packageName, version?, acknowledgeClawHubRisk?, installPolicyWarningAcknowledgement? }`. ClawHub installs preserve Gateway trust, integrity, and install-policy checks. When install policy warns, the request fails before commit with structured `error.details` containing `installPolicyCode`, target metadata, `reason`, optional `findings`, and a server-issued `acknowledgementToken`. After showing those details, a client may retry with that token as `installPolicyWarningAcknowledgement`. The Gateway consumes the token once and only for the same install request and resolved artifact; policy then re-evaluates the staged source and continues only when the warning is unchanged. A block or changed/later warning remains terminal for that request and is returned with its own structured details and token. Successful installs require a Gateway restart.
|
||||
- `plugins.install` (`operator.admin`) installs either an official catalog entry with `{ source: "official", pluginId, installPolicyWarningAcknowledgement? }` or a ClawHub package with `{ source: "clawhub", packageName, version?, acknowledgeClawHubRisk?, installPolicyWarningAcknowledgement? }`. ClawHub installs preserve Gateway trust, integrity, and install-policy checks. When install policy warns and the Gateway can bind the request to an immutable resolved artifact, the request fails before commit with structured `error.details` containing `installPolicyCode`, target metadata, `reason`, optional `findings`, and a server-issued `acknowledgementToken`. After showing those details, a client may retry with that token as `installPolicyWarningAcknowledgement`. The Gateway consumes the token once and only for the same install request and resolved artifact; policy then re-evaluates the staged source and continues only when the warning is unchanged. A changed or later warning fails that request with its own structured details and, when the artifact remains immutably resolved, a fresh token for another reviewed retry. A block or a warning without immutable resolution metadata is terminal and does not include an acknowledgement token. 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.
|
||||
|
||||
|
||||
+13
-10
@@ -153,19 +153,22 @@ copy as suspicious ClawHub releases; policy is then re-evaluated. Reviewed
|
||||
non-interactive direct CLI commands can use `--acknowledge-install-policy-warning`.
|
||||
That flag is consumed by the first warning in one command; a later warning
|
||||
fails closed and requires interactive review.
|
||||
Gateway `plugins.install` clients receive structured warning details and may
|
||||
make one explicit retry with the returned `acknowledgementToken` as
|
||||
Gateway `plugins.install` clients receive structured warning details when the
|
||||
Gateway can bind the request to an immutable resolved artifact, and may make
|
||||
one explicit retry with the returned `acknowledgementToken` as
|
||||
`installPolicyWarningAcknowledgement`. The Gateway consumes that server-issued
|
||||
token once and only for the same install request and resolved artifact. OpenClaw
|
||||
re-evaluates the staged source and continues only when the warning is unchanged.
|
||||
A block, a changed warning, or a warning from a later package or dependency scan
|
||||
stops the request before commit and returns its own details. Other 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 install/update
|
||||
flag `--dangerously-force-unsafe-install` approves a policy warning. Plugin
|
||||
A changed or later warning stops the request before commit and receives a fresh
|
||||
token when the artifact remains immutably resolved. A block or a warning without
|
||||
immutable resolution metadata is terminal and has no acknowledgement token.
|
||||
Other 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 install/update flag `--dangerously-force-unsafe-install` approves a
|
||||
policy warning. Plugin
|
||||
`before_install` hooks run later, and only in OpenClaw processes where plugin
|
||||
hooks are loaded, so use `security.installPolicy` for operator-owned install
|
||||
decisions instead. `--acknowledge-install-policy-warning` does not override a
|
||||
|
||||
@@ -201,13 +201,15 @@ approval after review;
|
||||
every approved warning is re-evaluated before continuing.
|
||||
The flag is consumed by the first warning in one command; a later warning
|
||||
fails closed and requires interactive review.
|
||||
Gateway `plugins.install` clients receive structured warning details and may
|
||||
make one explicit retry with the returned `acknowledgementToken` as
|
||||
Gateway `plugins.install` clients receive structured warning details when the
|
||||
Gateway can bind the request to an immutable resolved artifact, and may make
|
||||
one explicit retry with the returned `acknowledgementToken` as
|
||||
`installPolicyWarningAcknowledgement`. The Gateway consumes that server-issued
|
||||
token once and only for the same install request and resolved artifact. OpenClaw
|
||||
re-evaluates the staged source and continues only when the warning is unchanged. A block, a
|
||||
changed warning, or a warning from a later package or dependency scan stops the
|
||||
request before commit and returns its own details. Other
|
||||
re-evaluates the staged source and continues only when the warning is unchanged.
|
||||
A changed or later warning stops the request before commit and receives a fresh
|
||||
token when the artifact remains immutably resolved. A block or a warning without
|
||||
immutable resolution metadata is terminal and has no acknowledgement token. Other
|
||||
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
|
||||
|
||||
@@ -89,8 +89,8 @@ export function readInstallPolicyWarningErrorDetails(
|
||||
return undefined;
|
||||
}
|
||||
findings = [];
|
||||
for (const value of record.findings) {
|
||||
const finding = readFinding(value);
|
||||
for (const findingValue of record.findings) {
|
||||
const finding = readFinding(findingValue);
|
||||
if (!finding) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -277,6 +277,49 @@ describe("installPackageDir", () => {
|
||||
).resolves.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("preserves caller-owned failure details from staged post-install validation", async () => {
|
||||
await fixtureRootTracker.setup();
|
||||
const fixtureRoot = await fixtureRootTracker.make("case");
|
||||
const sourceDir = path.join(fixtureRoot, "source");
|
||||
const installBaseDir = path.join(fixtureRoot, "plugins");
|
||||
const targetDir = path.join(installBaseDir, "demo");
|
||||
await fs.mkdir(sourceDir, { recursive: true });
|
||||
await fs.writeFile(path.join(sourceDir, "marker.txt"), "new");
|
||||
const installPolicyWarning = {
|
||||
targetName: "demo",
|
||||
targetType: "plugin" as const,
|
||||
requestMode: "install" as const,
|
||||
reason: "Review the installed dependency tree",
|
||||
};
|
||||
|
||||
const result = await installPackageDir({
|
||||
sourceDir,
|
||||
targetDir,
|
||||
mode: "install",
|
||||
timeoutMs: 1_000,
|
||||
copyErrorPrefix: "failed to copy plugin",
|
||||
hasDeps: false,
|
||||
depsLogMessage: "Installing deps…",
|
||||
afterInstall: async () => ({
|
||||
ok: false as const,
|
||||
error: installPolicyWarning.reason,
|
||||
code: "security_scan_blocked",
|
||||
installPolicyWarning,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: installPolicyWarning.reason,
|
||||
code: "security_scan_blocked",
|
||||
installPolicyWarning,
|
||||
});
|
||||
await expectMissingPath(targetDir);
|
||||
await expect(
|
||||
listMatchingDirs(installBaseDir, ".openclaw-install-stage-"),
|
||||
).resolves.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("restores the original install if publish rename fails", async () => {
|
||||
await fixtureRootTracker.setup();
|
||||
const fixtureRoot = await fixtureRootTracker.make("case");
|
||||
|
||||
@@ -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);
|
||||
@@ -168,7 +171,9 @@ async function resolveInstallPublishTarget(params: {
|
||||
* 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";
|
||||
@@ -179,10 +184,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> {
|
||||
params.logger?.info?.(`Installing to ${params.targetDir}…`);
|
||||
const installBaseDir = path.dirname(params.targetDir);
|
||||
let initialInstallBaseRealPath: string;
|
||||
@@ -231,10 +234,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;
|
||||
@@ -306,7 +305,10 @@ 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);
|
||||
// The caller owns post-install failure metadata. Preserve the exact
|
||||
// result so domain details survive cleanup and rollback.
|
||||
return { ...postInstallResult, error: failed.error };
|
||||
}
|
||||
} catch (err) {
|
||||
return await fail(`post-install validation failed: ${String(err)}`, err);
|
||||
@@ -378,7 +380,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";
|
||||
@@ -388,12 +392,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",
|
||||
|
||||
@@ -299,6 +299,7 @@ type InstallSuccess = {
|
||||
type InstallFailure = {
|
||||
code?: string;
|
||||
error: string;
|
||||
integrity?: string;
|
||||
ok: false;
|
||||
version?: string;
|
||||
warning?: string;
|
||||
@@ -1794,7 +1795,11 @@ describe("installPluginFromClawHub", () => {
|
||||
baseUrl: "https://clawhub.ai",
|
||||
});
|
||||
|
||||
expect(expectInstallFailure(result).error).toBe("bad archive");
|
||||
expect(expectInstallFailure(result)).toMatchObject({
|
||||
error: "bad archive",
|
||||
integrity: DEMO_ARCHIVE_INTEGRITY,
|
||||
version: "2026.3.22",
|
||||
});
|
||||
expect(archiveCleanupMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ type ClawHubInstallFailure = {
|
||||
ok: false;
|
||||
error: string;
|
||||
code?: ClawHubInstallErrorCode;
|
||||
integrity?: string;
|
||||
warning?: string;
|
||||
version?: string;
|
||||
};
|
||||
@@ -1466,7 +1467,11 @@ export async function installPluginFromClawHub(
|
||||
},
|
||||
});
|
||||
if (!installResult.ok) {
|
||||
return { ...installResult, version: versionState.version };
|
||||
return {
|
||||
...installResult,
|
||||
integrity: archive.integrity,
|
||||
version: versionState.version,
|
||||
};
|
||||
}
|
||||
|
||||
const pkg = detail.package!;
|
||||
|
||||
@@ -246,7 +246,7 @@ export async function installPluginFromNpmSpec(
|
||||
}),
|
||||
});
|
||||
if (preflightPolicyResult) {
|
||||
return preflightPolicyResult;
|
||||
return { ...preflightPolicyResult, npmResolution };
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(policyTempDir, { recursive: true, force: true });
|
||||
@@ -287,5 +287,5 @@ export async function installPluginFromNpmSpec(
|
||||
sourceFamily: "npm",
|
||||
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
|
||||
});
|
||||
return result;
|
||||
return result.ok ? result : { ...result, npmResolution };
|
||||
}
|
||||
|
||||
@@ -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 { createSuiteTempRootTracker } from "./test-helpers/fs-fixtures.js";
|
||||
|
||||
describe("installPluginDirectoryIntoExtensions", () => {
|
||||
const tempRoots = createSuiteTempRootTracker("openclaw-install-shared");
|
||||
|
||||
afterAll(() => tempRoots.cleanup());
|
||||
|
||||
it("preserves structured warnings returned by the 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);
|
||||
});
|
||||
});
|
||||
@@ -432,19 +432,11 @@ 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;
|
||||
},
|
||||
});
|
||||
if (!installRes.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: installRes.error,
|
||||
...(installRes.code ? { code: installRes.code as PluginInstallErrorCode } : {}),
|
||||
};
|
||||
return installRes;
|
||||
}
|
||||
|
||||
return buildDirectoryInstallResult({
|
||||
|
||||
@@ -56,6 +56,7 @@ export type InstallPluginResult =
|
||||
error: string;
|
||||
code?: PluginInstallErrorCode;
|
||||
installPolicyWarning?: InstallPolicyWarningDetails;
|
||||
npmResolution?: NpmSpecResolution;
|
||||
};
|
||||
|
||||
export type PluginInstallFailureResult = Extract<InstallPluginResult, { ok: false }>;
|
||||
|
||||
@@ -79,35 +79,47 @@ async function readJson<T>(filePath: string): Promise<T> {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
|
||||
}
|
||||
|
||||
const installedPackageTreePolicySource = `
|
||||
function installPolicySource(
|
||||
decision: "block" | "warn",
|
||||
sourcePathKind: "directory" | "file" = "directory",
|
||||
): string {
|
||||
const subject = sourcePathKind === "directory" ? "installed package tree" : "npm metadata";
|
||||
const reason = `${decision === "block" ? "blocked" : "review"} ${subject}`;
|
||||
return `
|
||||
let input = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => { input += chunk; });
|
||||
process.stdin.on("end", () => {
|
||||
const request = JSON.parse(input);
|
||||
if (request.sourcePathKind === "directory") {
|
||||
if (request.sourcePathKind === "${sourcePathKind}") {
|
||||
process.stdout.write(JSON.stringify({
|
||||
protocolVersion: 1,
|
||||
decision: "block",
|
||||
reason: "blocked installed package tree",
|
||||
decision: "${decision}",
|
||||
reason: "${reason}",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
process.stdout.write(JSON.stringify({ protocolVersion: 1, decision: "allow" }));
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
async function createInstalledPackageTreePolicyExec(rootDir: string) {
|
||||
async function createInstalledPackageTreePolicyExec(
|
||||
rootDir: string,
|
||||
decision: "block" | "warn" = "block",
|
||||
sourcePathKind: "directory" | "file" = "directory",
|
||||
) {
|
||||
const policySource = installPolicySource(decision, sourcePathKind);
|
||||
if (process.platform === "win32") {
|
||||
return { command: process.execPath, args: ["-e", installedPackageTreePolicySource] };
|
||||
return { command: process.execPath, args: ["-e", policySource] };
|
||||
}
|
||||
const command = path.join(rootDir, "install-policy.cjs");
|
||||
await fs.writeFile(command, `#!${process.execPath}\n${installedPackageTreePolicySource}`, "utf8");
|
||||
await fs.writeFile(command, `#!${process.execPath}\n${policySource}`, "utf8");
|
||||
await fs.chmod(command, 0o700);
|
||||
return { command, args: [] };
|
||||
}
|
||||
|
||||
function configWithInstalledPackageTreeBlockPolicy(exec: {
|
||||
function configWithInstalledPackageTreePolicy(exec: {
|
||||
command: string;
|
||||
args: string[];
|
||||
}): OpenClawConfig {
|
||||
@@ -742,15 +754,14 @@ describe("installPluginFromNpmSpec e2e", () => {
|
||||
]);
|
||||
|
||||
const result = await installNpmPlugin({
|
||||
config: configWithInstalledPackageTreeBlockPolicy(policyExec),
|
||||
config: configWithInstalledPackageTreePolicy(policyExec),
|
||||
spec: `${blockedPlugin}@1.0.0`,
|
||||
npmRoot,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
|
||||
expect(result.error).toContain("blocked by install policy: blocked installed package tree");
|
||||
expect(result.error).toContain("blocked installed package tree");
|
||||
}
|
||||
const projectRoot = pluginNpmProjectRoot(npmRoot, blockedPlugin);
|
||||
try {
|
||||
@@ -772,6 +783,60 @@ describe("installPluginFromNpmSpec e2e", () => {
|
||||
).rejects.toHaveProperty("code", "ENOENT");
|
||||
});
|
||||
|
||||
it("preserves npm resolution on installed package policy warnings", async () => {
|
||||
const { rootDir, npmRoot } = await makeInstallFixture("npm-plugin-policy-warning-e2e");
|
||||
const policyExec = await createInstalledPackageTreePolicyExec(rootDir, "warn");
|
||||
const packageName = uniquePackageName("warning-plugin");
|
||||
const packageVersion = await packPlugin({ packageName, rootDir });
|
||||
await useStaticRegistry([
|
||||
{ packageName, latest: packageVersion.version, versions: [packageVersion] },
|
||||
]);
|
||||
|
||||
const result = await installNpmPlugin({
|
||||
config: configWithInstalledPackageTreePolicy(policyExec),
|
||||
spec: `${packageName}@latest`,
|
||||
npmRoot,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
throw new Error("expected installed package policy warning");
|
||||
}
|
||||
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
|
||||
expect(result.installPolicyWarning?.reason).toBe("review installed package tree");
|
||||
expect(result.npmResolution).toMatchObject({
|
||||
resolvedSpec: `${packageName}@${packageVersion.version}`,
|
||||
integrity: packageVersion.integrity,
|
||||
});
|
||||
await expect(
|
||||
fs.lstat(path.join(pluginNpmProjectRoot(npmRoot, packageName), "node_modules", packageName)),
|
||||
).rejects.toHaveProperty("code", "ENOENT");
|
||||
});
|
||||
|
||||
it("preserves npm resolution on preflight policy warnings", async () => {
|
||||
const { rootDir, npmRoot } = await makeInstallFixture("npm-plugin-preflight-warning-e2e");
|
||||
const policyExec = await createInstalledPackageTreePolicyExec(rootDir, "warn", "file");
|
||||
const packageName = uniquePackageName("preflight-warning-plugin");
|
||||
const packageVersion = await packPlugin({ packageName, rootDir });
|
||||
await useStaticRegistry([
|
||||
{ packageName, latest: packageVersion.version, versions: [packageVersion] },
|
||||
]);
|
||||
|
||||
const result = await installNpmPlugin({
|
||||
config: configWithInstalledPackageTreePolicy(policyExec),
|
||||
spec: `${packageName}@latest`,
|
||||
npmRoot,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
throw new Error("expected preflight policy warning");
|
||||
}
|
||||
expect(result.installPolicyWarning?.reason).toBe("review npm metadata");
|
||||
expect(result.npmResolution).toMatchObject({
|
||||
resolvedSpec: `${packageName}@${packageVersion.version}`,
|
||||
integrity: packageVersion.integrity,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the legacy npm peer mode inside the plugin project when npm cannot plan third-party peers", async () => {
|
||||
const { rootDir, npmRoot } = await makeInstallFixture("npm-plugin-peer-plan-fallback-e2e");
|
||||
const blockedPlugin = uniquePackageName("missing-peer-plugin");
|
||||
@@ -831,15 +896,14 @@ describe("installPluginFromNpmSpec e2e", () => {
|
||||
});
|
||||
|
||||
const result = await installNpmPlugin({
|
||||
config: configWithInstalledPackageTreeBlockPolicy(policyExec),
|
||||
config: configWithInstalledPackageTreePolicy(policyExec),
|
||||
spec: `${blockedPlugin}@1.0.0`,
|
||||
npmRoot,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
|
||||
expect(result.error).toContain("blocked by install policy: blocked installed package tree");
|
||||
expect(result.error).toContain("blocked installed package tree");
|
||||
}
|
||||
const rootManifest = await readJson<{
|
||||
dependencies?: Record<string, string>;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
emptyMetadataSnapshot,
|
||||
metadataSnapshot,
|
||||
} from "./test-helpers/management-service-fixtures.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
metadata: vi.fn(),
|
||||
@@ -19,64 +23,6 @@ vi.mock("./official-external-plugin-catalog.js", async (importOriginal) => ({
|
||||
const { clearManagedPluginOfficialCatalogCache, listManagedPlugins, resolveManagedPluginIconUrl } =
|
||||
await import("./management-service.js");
|
||||
|
||||
function metadataSnapshot(params: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
origin?: "bundled" | "global";
|
||||
packageName?: string | null;
|
||||
installRecord?: Record<string, unknown>;
|
||||
featured?: boolean;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
}) {
|
||||
const id = params.id ?? "workboard";
|
||||
const packageName =
|
||||
params.packageName === null ? undefined : (params.packageName ?? `@openclaw/${id}`);
|
||||
const manifest = {
|
||||
id,
|
||||
name: params.name ?? "Workboard",
|
||||
description: params.description ?? "Coordinate agent work in a shared board.",
|
||||
catalog: { featured: params.featured ?? true, order: 10 },
|
||||
...(params.icon ? { icon: params.icon } : {}),
|
||||
channels: [],
|
||||
providers: [],
|
||||
cliBackends: [],
|
||||
skills: [],
|
||||
hooks: [],
|
||||
origin: params.origin ?? "bundled",
|
||||
rootDir: `/tmp/${id}`,
|
||||
source: `/tmp/${id}/index.ts`,
|
||||
manifestPath: `/tmp/${id}/openclaw.plugin.json`,
|
||||
};
|
||||
return {
|
||||
index: {
|
||||
plugins: [
|
||||
{
|
||||
pluginId: id,
|
||||
...(packageName ? { packageName } : {}),
|
||||
origin: params.origin ?? "bundled",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
installRecords: params.installRecord ? { [id]: params.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 hostedCatalog(entries: unknown[]) {
|
||||
return {
|
||||
source: "hosted",
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { InstallPolicyWarningDetails } from "./install-security-scan.types.js";
|
||||
import {
|
||||
expectOneShotInstallPolicyWarningAcknowledgement,
|
||||
officialDiffsWarningRequest,
|
||||
} from "./test-helpers/install-policy-warning.js";
|
||||
import { metadataSnapshot } from "./test-helpers/management-service-fixtures.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
applyUninstall: vi.fn(),
|
||||
clawhubInstall: vi.fn(),
|
||||
installRecords: vi.fn(),
|
||||
metadata: vi.fn(),
|
||||
npmInstall: vi.fn(),
|
||||
officialCatalog: vi.fn(),
|
||||
persistInstall: vi.fn(),
|
||||
preflight: vi.fn(),
|
||||
providerAuthChoices: vi.fn(),
|
||||
readConfig: vi.fn(),
|
||||
recommendedInstalls: vi.fn(),
|
||||
refreshRegistry: vi.fn(),
|
||||
replaceConfig: vi.fn(),
|
||||
selectWriteOptions: vi.fn((writeOptions: unknown) => writeOptions),
|
||||
slotSelection: vi.fn((config: unknown): { config: unknown; warnings: string[] } => ({
|
||||
config,
|
||||
warnings: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
assertConfigWriteAllowedInCurrentMode: () => undefined,
|
||||
readConfigFileSnapshotForWrite: () => mocks.readConfig(),
|
||||
replaceConfigFile: (params: unknown) => mocks.replaceConfig(params),
|
||||
}));
|
||||
|
||||
vi.mock("./install-persistence.js", () => ({
|
||||
persistPluginInstall: (...args: unknown[]) => mocks.persistInstall(...args),
|
||||
resolveInstallConfigMutationPreflights: (...args: unknown[]) => mocks.preflight(...args),
|
||||
selectInstallMutationWriteOptions: (writeOptions: unknown) =>
|
||||
mocks.selectWriteOptions(writeOptions),
|
||||
}));
|
||||
|
||||
vi.mock("./slot-selection.js", () => ({
|
||||
applySlotSelectionForPlugin: (config: unknown) => mocks.slotSelection(config),
|
||||
}));
|
||||
|
||||
vi.mock("./registry-refresh.js", () => ({
|
||||
refreshPluginRegistryAfterConfigMutation: (...args: unknown[]) => mocks.refreshRegistry(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./plugin-metadata-snapshot.js", () => ({
|
||||
loadPluginMetadataSnapshot: (...args: unknown[]) => mocks.metadata(...args),
|
||||
resolvePluginMetadataSnapshot: (...args: unknown[]) => mocks.metadata(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./clawhub.js", () => ({
|
||||
installPluginFromClawHub: (...args: unknown[]) => mocks.clawhubInstall(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./install.js", () => ({
|
||||
installPluginFromNpmSpec: (...args: unknown[]) => mocks.npmInstall(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./installed-plugin-index-records.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./installed-plugin-index-records.js")>()),
|
||||
loadInstalledPluginIndexInstallRecords: (...args: unknown[]) => mocks.installRecords(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./uninstall.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./uninstall.js")>()),
|
||||
applyPluginUninstallDirectoryRemoval: (...args: unknown[]) => mocks.applyUninstall(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./official-external-plugin-catalog.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./official-external-plugin-catalog.js")>()),
|
||||
loadConfiguredHostedOfficialExternalPluginCatalogEntries: (...args: unknown[]) =>
|
||||
mocks.officialCatalog(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./provider-auth-choices.js", () => ({
|
||||
resolveManifestProviderAuthChoices: (...args: unknown[]) => mocks.providerAuthChoices(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./recommended-tool-installs.js", () => ({
|
||||
listRecommendedToolInstalls: (...args: unknown[]) => mocks.recommendedInstalls(...args),
|
||||
}));
|
||||
|
||||
const {
|
||||
clearManagedPluginOfficialCatalogCache,
|
||||
installManagedPlugin,
|
||||
ManagedPluginLifecycleError,
|
||||
} = await import("./management-service.js");
|
||||
|
||||
function configSnapshot() {
|
||||
return {
|
||||
snapshot: {
|
||||
valid: true,
|
||||
parsed: {},
|
||||
path: "/tmp/openclaw.json",
|
||||
sourceConfig: {},
|
||||
hash: "base-hash",
|
||||
},
|
||||
writeOptions: {
|
||||
expectedConfigPath: "/tmp/openclaw.json",
|
||||
includeFileHashesForWrite: { "/tmp/plugins.json": "include-hash" },
|
||||
includeFileTargetsForWrite: { "/tmp/plugins.json": "/tmp/plugins.json" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mockHostedOfficialCatalog(entries: unknown[]) {
|
||||
mocks.officialCatalog.mockResolvedValue({
|
||||
source: "hosted",
|
||||
entries,
|
||||
feed: { schemaVersion: 1, id: "test", generatedAt: "now", sequence: 1, entries: [] },
|
||||
metadata: { url: "https://clawhub.ai/feed", status: 200, checksum: "hash" },
|
||||
});
|
||||
}
|
||||
|
||||
function mockClawHubInstall(pluginId: string, packageName: string) {
|
||||
mocks.clawhubInstall.mockResolvedValue({
|
||||
ok: true,
|
||||
pluginId,
|
||||
targetDir: `/tmp/extensions/${pluginId}`,
|
||||
extensions: ["index.js"],
|
||||
packageName,
|
||||
clawhub: {
|
||||
source: "clawhub",
|
||||
clawhubUrl: "https://clawhub.ai",
|
||||
clawhubPackage: packageName,
|
||||
clawhubFamily: "code-plugin",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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 install-policy acknowledgements", () => {
|
||||
beforeEach(() => {
|
||||
clearManagedPluginOfficialCatalogCache();
|
||||
for (const mock of Object.values(mocks)) {
|
||||
if (typeof mock === "function" && "mockReset" in mock) {
|
||||
mock.mockReset();
|
||||
}
|
||||
}
|
||||
mocks.selectWriteOptions.mockImplementation((writeOptions) => writeOptions);
|
||||
mocks.preflight.mockReturnValue({
|
||||
hookMutation: { mode: "allowed" },
|
||||
pluginMutation: { mode: "allowed" },
|
||||
});
|
||||
mocks.slotSelection.mockImplementation((config) => ({ config, warnings: [] }));
|
||||
mocks.installRecords.mockResolvedValue({});
|
||||
mocks.applyUninstall.mockResolvedValue({ directoryRemoved: true, warnings: [] });
|
||||
mocks.providerAuthChoices.mockReturnValue([]);
|
||||
mocks.recommendedInstalls.mockReturnValue([]);
|
||||
mockHostedOfficialCatalog([]);
|
||||
});
|
||||
|
||||
it("threads hosted ClawHub candidate integrity into official installs", async () => {
|
||||
mocks.readConfig.mockResolvedValue(configSnapshot());
|
||||
mockHostedOfficialCatalog([hostedFeedDiffsEntry]);
|
||||
mockClawHubInstall("diffs", "@openclaw/diffs");
|
||||
mocks.persistInstall.mockResolvedValue({});
|
||||
mocks.metadata.mockReturnValue(
|
||||
metadataSnapshot({ enabled: true, id: "diffs", name: "Diffs", origin: "global" }),
|
||||
);
|
||||
|
||||
await installManagedPlugin({
|
||||
request: {
|
||||
...officialDiffsWarningRequest,
|
||||
installPolicyWarningAcknowledgement: {
|
||||
...officialDiffsWarningRequest.installPolicyWarningAcknowledgement,
|
||||
warnings: [...officialDiffsWarningRequest.installPolicyWarningAcknowledgement.warnings],
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(mocks.clawhubInstall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spec: "clawhub:@openclaw/diffs@2026.6.11",
|
||||
expectedPluginId: "diffs",
|
||||
expectedIntegrity: `sha256-${Buffer.from("a".repeat(64), "hex").toString("base64")}`,
|
||||
}),
|
||||
);
|
||||
await expectOneShotInstallPolicyWarningAcknowledgement(mocks.clawhubInstall);
|
||||
});
|
||||
|
||||
it("acknowledges each reviewed install-policy warning once", async () => {
|
||||
mocks.readConfig.mockResolvedValue(configSnapshot());
|
||||
mockHostedOfficialCatalog([hostedFeedDiffsEntry]);
|
||||
mockClawHubInstall("diffs", "@openclaw/diffs");
|
||||
mocks.persistInstall.mockResolvedValue({});
|
||||
mocks.metadata.mockReturnValue(
|
||||
metadataSnapshot({ enabled: true, id: "diffs", name: "Diffs", origin: "global" }),
|
||||
);
|
||||
const firstWarning: InstallPolicyWarningDetails = expectDefined(
|
||||
officialDiffsWarningRequest.installPolicyWarningAcknowledgement.warnings[0],
|
||||
"first approved warning",
|
||||
);
|
||||
const secondWarning: InstallPolicyWarningDetails = {
|
||||
...firstWarning,
|
||||
reason: "Review the dependency warning",
|
||||
};
|
||||
|
||||
await installManagedPlugin({
|
||||
request: {
|
||||
...officialDiffsWarningRequest,
|
||||
installPolicyWarningAcknowledgement: {
|
||||
...officialDiffsWarningRequest.installPolicyWarningAcknowledgement,
|
||||
warnings: [firstWarning, secondWarning],
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
const call = expectDefined(mocks.clawhubInstall.mock.calls[0], "clawhub install call");
|
||||
const acknowledge = expectDefined(
|
||||
(
|
||||
call[0] as {
|
||||
onInstallPolicyWarning?: (request: { warning: typeof firstWarning }) => Promise<boolean>;
|
||||
}
|
||||
).onInstallPolicyWarning,
|
||||
"install-policy acknowledgement callback",
|
||||
);
|
||||
expect(await acknowledge({ warning: firstWarning })).toBe(true);
|
||||
expect(await acknowledge({ warning: secondWarning })).toBe(true);
|
||||
expect(await acknowledge({ warning: firstWarning })).toBe(false);
|
||||
});
|
||||
|
||||
it("pins reviewed npm warnings to the first resolved version and integrity", async () => {
|
||||
const warning = {
|
||||
targetName: "npm-demo",
|
||||
targetType: "plugin" as const,
|
||||
requestMode: "install" as const,
|
||||
reason: "Review this npm package",
|
||||
};
|
||||
const npmResolution = {
|
||||
name: "@openclaw/npm-demo",
|
||||
version: "1.2.3",
|
||||
resolvedSpec: "@openclaw/npm-demo@1.2.3",
|
||||
integrity: "sha512-reviewed",
|
||||
resolvedAt: "2026-08-10T00:00:00.000Z",
|
||||
};
|
||||
mocks.readConfig.mockResolvedValue(configSnapshot());
|
||||
mockHostedOfficialCatalog([
|
||||
{
|
||||
name: "@openclaw/npm-demo",
|
||||
openclaw: {
|
||||
plugin: { id: "npm-demo" },
|
||||
install: { npmSpec: "@openclaw/npm-demo", defaultChoice: "npm" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
mocks.npmInstall
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: warning.reason,
|
||||
installPolicyWarning: warning,
|
||||
npmResolution,
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: false, error: "stop after inspecting the pinned retry" });
|
||||
|
||||
let firstFailure: unknown;
|
||||
try {
|
||||
await installManagedPlugin({
|
||||
request: { source: "official", pluginId: "npm-demo" },
|
||||
env: {},
|
||||
});
|
||||
} catch (error) {
|
||||
firstFailure = error;
|
||||
}
|
||||
expect(firstFailure).toBeInstanceOf(ManagedPluginLifecycleError);
|
||||
if (!(firstFailure instanceof ManagedPluginLifecycleError)) {
|
||||
throw new Error("expected managed plugin lifecycle failure");
|
||||
}
|
||||
const resolvedRequest = expectDefined(
|
||||
firstFailure.installPolicyResolvedRequest,
|
||||
"pinned npm install request",
|
||||
);
|
||||
expect(resolvedRequest).toMatchObject({
|
||||
source: "official",
|
||||
spec: npmResolution.resolvedSpec,
|
||||
expectedIntegrity: npmResolution.integrity,
|
||||
});
|
||||
|
||||
await expect(
|
||||
installManagedPlugin({
|
||||
request: {
|
||||
source: "official",
|
||||
pluginId: "npm-demo",
|
||||
installPolicyWarningAcknowledgement: {
|
||||
warnings: [warning],
|
||||
resolvedRequest,
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
}),
|
||||
).rejects.toThrow("stop after inspecting the pinned retry");
|
||||
expect(mocks.npmInstall).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
spec: npmResolution.resolvedSpec,
|
||||
expectedIntegrity: npmResolution.integrity,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps npm warnings terminal when immutable resolution metadata is incomplete", async () => {
|
||||
const warning = {
|
||||
targetName: "npm-demo",
|
||||
targetType: "plugin" as const,
|
||||
requestMode: "install" as const,
|
||||
reason: "Review this npm package",
|
||||
};
|
||||
mocks.readConfig.mockResolvedValue(configSnapshot());
|
||||
mockHostedOfficialCatalog([
|
||||
{
|
||||
name: "@openclaw/npm-demo",
|
||||
openclaw: {
|
||||
plugin: { id: "npm-demo" },
|
||||
install: { npmSpec: "@openclaw/npm-demo", defaultChoice: "npm" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
mocks.npmInstall.mockResolvedValue({
|
||||
ok: false,
|
||||
error: warning.reason,
|
||||
installPolicyWarning: warning,
|
||||
npmResolution: {
|
||||
name: "@openclaw/npm-demo",
|
||||
version: "1.2.3",
|
||||
resolvedSpec: "@openclaw/npm-demo@1.2.3",
|
||||
resolvedAt: "2026-08-10T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
let failure: unknown;
|
||||
try {
|
||||
await installManagedPlugin({
|
||||
request: { source: "official", pluginId: "npm-demo" },
|
||||
env: {},
|
||||
});
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeInstanceOf(ManagedPluginLifecycleError);
|
||||
if (!(failure instanceof ManagedPluginLifecycleError)) {
|
||||
throw new Error("expected managed plugin lifecycle failure");
|
||||
}
|
||||
expect(failure.installPolicyWarning).toEqual(warning);
|
||||
expect(failure.installPolicyResolvedRequest).toBeUndefined();
|
||||
expect(failure.message).toContain("immutable artifact resolution metadata");
|
||||
});
|
||||
|
||||
it("pins reviewed ClawHub warnings to the downloaded archive integrity", async () => {
|
||||
const warning = {
|
||||
targetName: "demo",
|
||||
targetType: "plugin" as const,
|
||||
requestMode: "install" as const,
|
||||
reason: "Review this ClawHub package",
|
||||
};
|
||||
mocks.readConfig.mockResolvedValue(configSnapshot());
|
||||
mocks.clawhubInstall.mockResolvedValue({
|
||||
ok: false,
|
||||
error: warning.reason,
|
||||
installPolicyWarning: warning,
|
||||
version: "1.2.3",
|
||||
integrity: "sha256-reviewed",
|
||||
});
|
||||
|
||||
let failure: unknown;
|
||||
try {
|
||||
await installManagedPlugin({
|
||||
request: { source: "clawhub", packageName: "community/demo" },
|
||||
env: {},
|
||||
});
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
expect(failure).toBeInstanceOf(ManagedPluginLifecycleError);
|
||||
if (!(failure instanceof ManagedPluginLifecycleError)) {
|
||||
throw new Error("expected managed plugin lifecycle failure");
|
||||
}
|
||||
expect(failure.installPolicyResolvedRequest).toMatchObject({
|
||||
source: "clawhub",
|
||||
spec: "clawhub:community/demo@1.2.3",
|
||||
expectedIntegrity: "sha256-reviewed",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
expectOneShotInstallPolicyWarningAcknowledgement,
|
||||
officialDiffsWarningRequest,
|
||||
} from "./test-helpers/install-policy-warning.js";
|
||||
emptyMetadataSnapshot,
|
||||
metadataSnapshot,
|
||||
} from "./test-helpers/management-service-fixtures.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
applyUninstall: vi.fn(),
|
||||
@@ -128,60 +128,6 @@ function configSnapshot(config: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function metadataSnapshot(params: {
|
||||
enabled: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
origin?: "bundled" | "global";
|
||||
installRecord?: Record<string, unknown>;
|
||||
icon?: string;
|
||||
}) {
|
||||
const id = params.id ?? "workboard";
|
||||
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: params.origin ?? "bundled",
|
||||
rootDir: `/tmp/${id}`,
|
||||
source: `/tmp/${id}/index.ts`,
|
||||
manifestPath: `/tmp/${id}/openclaw.plugin.json`,
|
||||
};
|
||||
return {
|
||||
index: {
|
||||
plugins: [
|
||||
{
|
||||
pluginId: id,
|
||||
packageName: `@openclaw/${id}`,
|
||||
origin: params.origin ?? "bundled",
|
||||
enabled: params.enabled,
|
||||
},
|
||||
],
|
||||
installRecords: params.installRecord ? { [id]: params.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",
|
||||
@@ -778,69 +724,6 @@ describe("plugin management service", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("threads hosted ClawHub candidate integrity into official installs", async () => {
|
||||
mocks.readConfig.mockResolvedValue(configSnapshot());
|
||||
mockHostedOfficialCatalog([hostedFeedDiffsEntry]);
|
||||
mockClawHubInstall("diffs", "@openclaw/diffs");
|
||||
mocks.persistInstall.mockResolvedValue({});
|
||||
mocks.metadata.mockReturnValue(
|
||||
metadataSnapshot({ enabled: true, id: "diffs", name: "Diffs", origin: "global" }),
|
||||
);
|
||||
|
||||
await installManagedPlugin({
|
||||
request: officialDiffsWarningRequest,
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(mocks.clawhubInstall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
spec: "clawhub:@openclaw/diffs@2026.6.11",
|
||||
expectedPluginId: "diffs",
|
||||
expectedIntegrity: `sha256-${Buffer.from("a".repeat(64), "hex").toString("base64")}`,
|
||||
}),
|
||||
);
|
||||
await expectOneShotInstallPolicyWarningAcknowledgement(mocks.clawhubInstall);
|
||||
});
|
||||
|
||||
it("acknowledges each reviewed install-policy warning once", async () => {
|
||||
mocks.readConfig.mockResolvedValue(configSnapshot());
|
||||
mockHostedOfficialCatalog([hostedFeedDiffsEntry]);
|
||||
mockClawHubInstall("diffs", "@openclaw/diffs");
|
||||
mocks.persistInstall.mockResolvedValue({});
|
||||
mocks.metadata.mockReturnValue(
|
||||
metadataSnapshot({ enabled: true, id: "diffs", name: "Diffs", origin: "global" }),
|
||||
);
|
||||
const firstWarning = expectDefined(
|
||||
officialDiffsWarningRequest.installPolicyWarningAcknowledgement.warnings[0],
|
||||
"first approved warning",
|
||||
);
|
||||
const secondWarning = { ...firstWarning, reason: "Review the dependency warning" };
|
||||
|
||||
await installManagedPlugin({
|
||||
request: {
|
||||
...officialDiffsWarningRequest,
|
||||
installPolicyWarningAcknowledgement: {
|
||||
...officialDiffsWarningRequest.installPolicyWarningAcknowledgement,
|
||||
warnings: [firstWarning, secondWarning],
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
const call = expectDefined(mocks.clawhubInstall.mock.calls[0], "clawhub install call");
|
||||
const acknowledge = expectDefined(
|
||||
(
|
||||
call[0] as {
|
||||
onInstallPolicyWarning?: (request: { warning: typeof firstWarning }) => Promise<boolean>;
|
||||
}
|
||||
).onInstallPolicyWarning,
|
||||
"install-policy acknowledgement callback",
|
||||
);
|
||||
expect(await acknowledge({ warning: firstWarning })).toBe(true);
|
||||
expect(await acknowledge({ warning: secondWarning })).toBe(true);
|
||||
expect(await acknowledge({ warning: firstWarning })).toBe(false);
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -202,9 +202,11 @@ type ManagedPluginSourceInstallResult =
|
||||
ok: false;
|
||||
error: string;
|
||||
code?: string;
|
||||
integrity?: string;
|
||||
version?: string;
|
||||
warning?: string;
|
||||
installPolicyWarning?: InstallPolicyWarningDetails;
|
||||
npmResolution?: NpmSpecResolution;
|
||||
};
|
||||
|
||||
type SourceInstallerResult =
|
||||
@@ -212,9 +214,11 @@ type SourceInstallerResult =
|
||||
ok: false;
|
||||
error: string;
|
||||
code?: string;
|
||||
integrity?: string;
|
||||
version?: string;
|
||||
warning?: string;
|
||||
installPolicyWarning?: InstallPolicyWarningDetails;
|
||||
npmResolution?: NpmSpecResolution;
|
||||
}
|
||||
| {
|
||||
ok: true;
|
||||
@@ -1006,47 +1010,79 @@ function buildClawHubSpec(packageName: string, version?: string): string {
|
||||
function pinInstallPolicyResolvedRequest(
|
||||
request: ManagedPluginSourceInstallRequest,
|
||||
version: string | undefined,
|
||||
): ManagedPluginSourceInstallRequest {
|
||||
if (request.source !== "clawhub" || !version) {
|
||||
integrity: string | undefined,
|
||||
npmResolution: NpmSpecResolution | undefined,
|
||||
): ManagedPluginSourceInstallRequest | undefined {
|
||||
if (request.source === "official" || request.source === "npm") {
|
||||
if (!npmResolution?.resolvedSpec || !npmResolution.integrity) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...request,
|
||||
spec: npmResolution.resolvedSpec,
|
||||
expectedIntegrity: npmResolution.integrity,
|
||||
};
|
||||
}
|
||||
if (request.source !== "clawhub") {
|
||||
return request;
|
||||
}
|
||||
if (!version || !integrity) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = parseClawHubPluginSpec(request.spec);
|
||||
return parsed ? { ...request, spec: buildClawHubSpec(parsed.name, version) } : request;
|
||||
return parsed
|
||||
? {
|
||||
...request,
|
||||
spec: buildClawHubSpec(parsed.name, version),
|
||||
expectedIntegrity: integrity,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function throwInstallFailure(
|
||||
result: {
|
||||
error: string;
|
||||
code?: string;
|
||||
integrity?: string;
|
||||
version?: string;
|
||||
warning?: string;
|
||||
installPolicyWarning?: InstallPolicyWarningDetails;
|
||||
npmResolution?: NpmSpecResolution;
|
||||
},
|
||||
resolvedRequest?: ManagedPluginSourceInstallRequest,
|
||||
installPolicyAcknowledgedWarnings?: InstallPolicyWarningDetails[],
|
||||
): never {
|
||||
const installPolicyResolvedRequest =
|
||||
result.installPolicyWarning && resolvedRequest
|
||||
? pinInstallPolicyResolvedRequest(
|
||||
resolvedRequest,
|
||||
result.version,
|
||||
result.integrity,
|
||||
result.npmResolution,
|
||||
)
|
||||
: undefined;
|
||||
const resolutionUnavailable =
|
||||
result.installPolicyWarning && resolvedRequest && !installPolicyResolvedRequest;
|
||||
const unavailable =
|
||||
!result.code ||
|
||||
result.code === CLAWHUB_INSTALL_ERROR_CODE.ARTIFACT_UNAVAILABLE ||
|
||||
result.code === CLAWHUB_INSTALL_ERROR_CODE.ARTIFACT_DOWNLOAD_UNAVAILABLE ||
|
||||
result.code === CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE;
|
||||
throw new ManagedPluginLifecycleError(result.error, {
|
||||
kind: unavailable ? "unavailable" : "invalid-request",
|
||||
code: result.code,
|
||||
version: result.version,
|
||||
warning: result.warning,
|
||||
installPolicyWarning: result.installPolicyWarning,
|
||||
...(installPolicyAcknowledgedWarnings?.length ? { installPolicyAcknowledgedWarnings } : {}),
|
||||
...(result.installPolicyWarning && resolvedRequest
|
||||
? {
|
||||
installPolicyResolvedRequest: pinInstallPolicyResolvedRequest(
|
||||
resolvedRequest,
|
||||
result.version,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
cause: result,
|
||||
});
|
||||
throw new ManagedPluginLifecycleError(
|
||||
resolutionUnavailable
|
||||
? `${result.error}\nOpenClaw could not bind this approval to immutable artifact resolution metadata, so the warning remains terminal.`
|
||||
: result.error,
|
||||
{
|
||||
kind: unavailable ? "unavailable" : "invalid-request",
|
||||
code: result.code,
|
||||
version: result.version,
|
||||
warning: result.warning,
|
||||
installPolicyWarning: result.installPolicyWarning,
|
||||
...(installPolicyAcknowledgedWarnings?.length ? { installPolicyAcknowledgedWarnings } : {}),
|
||||
...(installPolicyResolvedRequest ? { installPolicyResolvedRequest } : {}),
|
||||
cause: result,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function installRecordOwnsTarget(
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
export function metadataSnapshot(params: {
|
||||
enabled?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
origin?: "bundled" | "global";
|
||||
packageName?: string | null;
|
||||
installRecord?: Record<string, unknown>;
|
||||
featured?: boolean;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
}) {
|
||||
const id = params.id ?? "workboard";
|
||||
const packageName =
|
||||
params.packageName === null ? undefined : (params.packageName ?? `@openclaw/${id}`);
|
||||
const manifest = {
|
||||
id,
|
||||
name: params.name ?? "Workboard",
|
||||
description: params.description ?? "Coordinate agent work in a shared board.",
|
||||
catalog: { featured: params.featured ?? true, order: 10 },
|
||||
...(params.icon ? { icon: params.icon } : {}),
|
||||
channels: [],
|
||||
providers: [],
|
||||
cliBackends: [],
|
||||
skills: [],
|
||||
hooks: [],
|
||||
origin: params.origin ?? "bundled",
|
||||
rootDir: `/tmp/${id}`,
|
||||
source: `/tmp/${id}/index.ts`,
|
||||
manifestPath: `/tmp/${id}/openclaw.plugin.json`,
|
||||
};
|
||||
return {
|
||||
index: {
|
||||
plugins: [
|
||||
{
|
||||
pluginId: id,
|
||||
...(packageName ? { packageName } : {}),
|
||||
origin: params.origin ?? "bundled",
|
||||
enabled: params.enabled ?? true,
|
||||
},
|
||||
],
|
||||
installRecords: params.installRecord ? { [id]: params.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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user