diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 83d39f1609e6..4ef659db3cbb 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -551,7 +551,7 @@ methods. Treat this as feature discovery, not a full enumeration of - `plugins.list` (`operator.read`) returns the installed plugin inventory plus locally curated official picks, diagnostics, and whether the current install mode allows mutations. - `plugins.search` (`operator.read`) searches installable ClawHub code-plugin and bundle-plugin families. Pass non-empty `query` and optional `limit` from 1 to 100. - - `plugins.install` (`operator.admin`) installs either an official catalog entry with `{ source: "official", pluginId, 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. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 5c4efb896aff..990a475614a1 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -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 diff --git a/docs/tools/skills-config.md b/docs/tools/skills-config.md index c13f602f43b6..2cdfe918111c 100644 --- a/docs/tools/skills-config.md +++ b/docs/tools/skills-config.md @@ -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 diff --git a/packages/gateway-protocol/src/install-policy-warning-error-details.ts b/packages/gateway-protocol/src/install-policy-warning-error-details.ts index 452b46d78938..0c4159513c9d 100644 --- a/packages/gateway-protocol/src/install-policy-warning-error-details.ts +++ b/packages/gateway-protocol/src/install-policy-warning-error-details.ts @@ -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; } diff --git a/src/infra/install-package-dir.test.ts b/src/infra/install-package-dir.test.ts index f51a7c65f194..c757610c4efa 100644 --- a/src/infra/install-package-dir.test.ts +++ b/src/infra/install-package-dir.test.ts @@ -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"); diff --git a/src/infra/install-package-dir.ts b/src/infra/install-package-dir.ts index bda4f9f10656..ce99397cf38e 100644 --- a/src/infra/install-package-dir.ts +++ b/src/infra/install-package-dir.ts @@ -26,6 +26,9 @@ type HiddenProjectConfigFile = { hiddenPath: string; } | null; +type InstallPackageDirFailure = { ok: false; error: string }; +type InstallPackageDirSuccess = { ok: true }; + async function sanitizeManifestForNpmInstall(targetDir: string): Promise { const manifestPath = path.join(targetDir, "package.json"); const parsed = await tryReadJson(manifestPath); @@ -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; - afterInstall?: ( - installedDir: string, - ) => Promise<{ ok: true } | { ok: false; error: string; code?: string }>; -}): Promise<{ ok: true } | { ok: false; error: string; code?: string }> { + afterInstall?: (installedDir: string) => Promise; +}): Promise { 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; afterCopy?: (installedDir: string) => void | Promise; - afterInstall?: ( - installedDir: string, - ) => Promise<{ ok: true } | { ok: false; error: string; code?: string }>; -}): Promise<{ ok: true } | { ok: false; error: string; code?: string }> { + afterInstall?: (installedDir: string) => Promise; +}): Promise { const hasDeps = Object.keys(params.manifestDependencies ?? {}).length > 0; - return installPackageDir({ + return installPackageDir({ ...params, hasDeps, sourceHardlinks: hasDeps ? "package-manager" : "reject", diff --git a/src/plugins/clawhub.test.ts b/src/plugins/clawhub.test.ts index a6365b03eb9e..430d02ca5805 100644 --- a/src/plugins/clawhub.test.ts +++ b/src/plugins/clawhub.test.ts @@ -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); }); diff --git a/src/plugins/clawhub.ts b/src/plugins/clawhub.ts index ed1c693694c8..6e05bddcdd1b 100644 --- a/src/plugins/clawhub.ts +++ b/src/plugins/clawhub.ts @@ -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!; diff --git a/src/plugins/install-npm.ts b/src/plugins/install-npm.ts index dffc4ea3f078..dbd30767f53f 100644 --- a/src/plugins/install-npm.ts +++ b/src/plugins/install-npm.ts @@ -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 }; } diff --git a/src/plugins/install-shared.test.ts b/src/plugins/install-shared.test.ts new file mode 100644 index 000000000000..b8707a8f63a9 --- /dev/null +++ b/src/plugins/install-shared.test.ts @@ -0,0 +1,54 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { installPluginDirectoryIntoExtensions } from "./install-shared.js"; +import { PLUGIN_INSTALL_ERROR_CODE } from "./install-types.js"; +import { 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); + }); +}); diff --git a/src/plugins/install-shared.ts b/src/plugins/install-shared.ts index 5d73f3eb8f5b..2c900760d856 100644 --- a/src/plugins/install-shared.ts +++ b/src/plugins/install-shared.ts @@ -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({ diff --git a/src/plugins/install-types.ts b/src/plugins/install-types.ts index 50c50e5d2c63..74db31946edc 100644 --- a/src/plugins/install-types.ts +++ b/src/plugins/install-types.ts @@ -56,6 +56,7 @@ export type InstallPluginResult = error: string; code?: PluginInstallErrorCode; installPolicyWarning?: InstallPolicyWarningDetails; + npmResolution?: NpmSpecResolution; }; export type PluginInstallFailureResult = Extract; diff --git a/src/plugins/install.npm-spec.e2e.test.ts b/src/plugins/install.npm-spec.e2e.test.ts index 4222cb753e20..fcd2a48699fe 100644 --- a/src/plugins/install.npm-spec.e2e.test.ts +++ b/src/plugins/install.npm-spec.e2e.test.ts @@ -79,35 +79,47 @@ async function readJson(filePath: string): Promise { 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; diff --git a/src/plugins/management-service-featured.test.ts b/src/plugins/management-service-featured.test.ts index 664067cb6b6a..990f0ede5d29 100644 --- a/src/plugins/management-service-featured.test.ts +++ b/src/plugins/management-service-featured.test.ts @@ -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; - 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", diff --git a/src/plugins/management-service-install-policy.test.ts b/src/plugins/management-service-install-policy.test.ts new file mode 100644 index 000000000000..3a5a4b8e34b6 --- /dev/null +++ b/src/plugins/management-service-install-policy.test.ts @@ -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()), + loadInstalledPluginIndexInstallRecords: (...args: unknown[]) => mocks.installRecords(...args), +})); + +vi.mock("./uninstall.js", async (importOriginal) => ({ + ...(await importOriginal()), + applyPluginUninstallDirectoryRemoval: (...args: unknown[]) => mocks.applyUninstall(...args), +})); + +vi.mock("./official-external-plugin-catalog.js", async (importOriginal) => ({ + ...(await importOriginal()), + 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; + } + ).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", + }); + }); +}); diff --git a/src/plugins/management-service.test.ts b/src/plugins/management-service.test.ts index f9181442865e..5b13cb4e3369 100644 --- a/src/plugins/management-service.test.ts +++ b/src/plugins/management-service.test.ts @@ -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 = {}) { }; } -function metadataSnapshot(params: { - enabled: boolean; - id?: string; - name?: string; - origin?: "bundled" | "global"; - installRecord?: Record; - 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; - } - ).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"); diff --git a/src/plugins/management-service.ts b/src/plugins/management-service.ts index ddc81f124e9e..030e83537c02 100644 --- a/src/plugins/management-service.ts +++ b/src/plugins/management-service.ts @@ -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( diff --git a/src/plugins/test-helpers/management-service-fixtures.ts b/src/plugins/test-helpers/management-service-fixtures.ts new file mode 100644 index 000000000000..b7598d024b48 --- /dev/null +++ b/src/plugins/test-helpers/management-service-fixtures.ts @@ -0,0 +1,58 @@ +export function metadataSnapshot(params: { + enabled?: boolean; + id?: string; + name?: string; + origin?: "bundled" | "global"; + packageName?: string | null; + installRecord?: Record; + 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, + }; +}