Check ClawHub trust before plugin installs (#81307)

Merged via squash.

Prepared head SHA: 273fd7c20e
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
Reviewed-by: @jesse-merhi
This commit is contained in:
Jesse Merhi
2026-05-13 16:31:52 +10:00
committed by GitHub
parent cf68115e6e
commit 87eb450047
23 changed files with 832 additions and 43 deletions
+1
View File
@@ -134,6 +134,7 @@ Docs: https://docs.openclaw.ai
- Codex app-server: mirror native Codex subagent spawn lifecycle events into Task Registry so app-server child agents appear in task/status surfaces without relying on transcript text. (#79512) Thanks @mbelinky.
- Gateway: expose optional `isHeartbeat` metadata on agent event payloads so clients can distinguish scheduled heartbeat runs from ordinary chat runs. (#80610) Thanks @medns.
- Agents: add `agents.defaults.runRetries` and `agents.list[].runRetries` config for embedded Pi runner retry loop limits. (#80661) Thanks @medns.
- Plugins/ClawHub: check exact-release trust before ClawHub plugin install/update downloads, require confirmation for risky releases, and add `--acknowledge-clawhub-risk` for reviewed automation. (#81307) Thanks @jesse-merhi.
### Fixes
+11
View File
@@ -83,6 +83,7 @@ openclaw plugins install git:github.com/<owner>/<repo> # git repo
openclaw plugins install git:github.com/<owner>/<repo>@<ref>
openclaw plugins install <package> --force # overwrite existing install
openclaw plugins install <package> --pin # pin version
openclaw plugins install <package> --acknowledge-clawhub-risk
openclaw plugins install <package> --dangerously-force-unsafe-install
openclaw plugins install <path> # local path
openclaw plugins install <plugin>@<marketplace> # marketplace
@@ -135,6 +136,12 @@ is available, then fall back to `latest`.
If a plugin you published on ClawHub is blocked by a registry scan, use the publisher steps in [ClawHub](/clawhub/security).
</Accordion>
<Accordion title="--acknowledge-clawhub-risk">
ClawHub installs check the selected release trust record before downloading the package. If ClawHub reports a risky scan status, risky moderation state, download block, or registry reason, OpenClaw shows the trust details and asks for confirmation before continuing.
Use `--acknowledge-clawhub-risk` only after reviewing the ClawHub warning and deciding to continue without an interactive prompt. Pending or stale clean trust records warn but do not require acknowledgement.
</Accordion>
<Accordion title="Hook packs and npm specs">
`plugins install` is also the install surface for hook packs that expose `openclaw.hooks` in `package.json`. Use `openclaw hooks` for filtered hook visibility and per-hook enablement, not package installation.
@@ -324,6 +331,7 @@ openclaw plugins update <id-or-npm-spec>
openclaw plugins update --all
openclaw plugins update <id-or-npm-spec> --dry-run
openclaw plugins update @openclaw/voice-call
openclaw plugins update openclaw-codex-app-server --acknowledge-clawhub-risk
openclaw plugins update openclaw-codex-app-server --dangerously-force-unsafe-install
```
@@ -351,6 +359,9 @@ Updates apply to tracked plugin installs in the managed plugin index and tracked
<Accordion title="--dangerously-force-unsafe-install on update">
`--dangerously-force-unsafe-install` is also available on `plugins update` as a break-glass override for built-in dangerous-code scan false positives during plugin updates. It still does not bypass plugin `before_install` policy blocks or scan-failure blocking, and it only applies to plugin updates, not hook-pack updates.
</Accordion>
<Accordion title="--acknowledge-clawhub-risk on update">
ClawHub-backed plugin updates run the same exact-release trust check as installs before downloading the replacement package. Use `--acknowledge-clawhub-risk` for reviewed automation that should continue when the selected ClawHub release has a risky trust warning.
</Accordion>
</AccordionGroup>
### Inspect
+5
View File
@@ -27,6 +27,7 @@ openclaw update --tag main
openclaw update --dry-run
openclaw update --no-restart
openclaw update --yes
openclaw update --acknowledge-clawhub-risk
openclaw update --json
openclaw --update
```
@@ -44,6 +45,10 @@ openclaw --update
when npm plugin artifact drift is detected during post-update plugin sync.
- `--timeout <seconds>`: per-step timeout (default is 1800s).
- `--yes`: skip confirmation prompts (for example downgrade confirmation).
- `--acknowledge-clawhub-risk`: continue post-update ClawHub plugin sync when
the selected plugin release has a ClawHub trust warning. Without this flag,
interactive runs ask before downloading risky ClawHub plugin releases and
non-interactive runs fail closed.
`openclaw update` does not have a `--verbose` flag. Use `--dry-run` to preview
the planned channel/tag/install/restart actions, `--json` for machine-readable
+10
View File
@@ -543,8 +543,10 @@ openclaw plugins install -l <path> # link (no copy) for dev
openclaw plugins install <plugin> --marketplace <source>
openclaw plugins install <plugin> --marketplace https://github.com/<owner>/<repo>
openclaw plugins install <spec> --pin # record exact resolved npm spec
openclaw plugins install <spec> --acknowledge-clawhub-risk
openclaw plugins install <spec> --dangerously-force-unsafe-install
openclaw plugins update <id-or-npm-spec> # update one plugin
openclaw plugins update <id-or-npm-spec> --acknowledge-clawhub-risk
openclaw plugins update <id-or-npm-spec> --dangerously-force-unsafe-install
openclaw plugins update --all # update all
openclaw plugins uninstall <id> # remove config and plugin index records
@@ -603,6 +605,14 @@ beta release exists. Exact versions and explicit tags stay pinned.
`--pin` is npm-only. It is not supported with `--marketplace`, because
marketplace installs persist marketplace source metadata instead of an npm spec.
ClawHub-backed plugin installs and updates check the exact target release's
ClawHub trust record before download. If ClawHub reports risk for that release,
OpenClaw prints the package, version, scan status, moderation state, and reason
codes, then requires confirmation before continuing. Non-interactive automation
must pass `--acknowledge-clawhub-risk` after reviewing that warning. The flag
acknowledges ClawHub registry risk only; it does not bypass plugin
`before_install` hook policy blocks or the built-in dangerous-code scanner.
`--dangerously-force-unsafe-install` is a break-glass override for false
positives from the built-in dangerous-code scanner. It allows plugin installs
and plugin updates to continue past built-in `critical` findings, but it still
+28
View File
@@ -0,0 +1,28 @@
import type { ClawHubRiskAcknowledgementRequest } from "../plugins/clawhub.js";
import { promptYesNo } from "./prompt.js";
export type ClawHubRiskAcknowledgementCliOptions = {
acknowledgeClawHubRisk?: boolean;
};
function canPromptForClawHubRisk(): boolean {
return process.stdin.isTTY && process.stdout.isTTY;
}
export function resolveClawHubRiskAcknowledgementCliOptions(params: {
acknowledgeClawHubRisk?: boolean;
action: "installing" | "updating";
}): ClawHubRiskAcknowledgementCliOptions & {
onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => Promise<boolean>;
} {
return {
acknowledgeClawHubRisk: params.acknowledgeClawHubRisk,
onClawHubRisk:
params.acknowledgeClawHubRisk || !canPromptForClawHubRisk()
? undefined
: async (request) =>
await promptYesNo(
`Continue ${params.action} ClawHub package "${request.packageName}@${request.version}" despite this warning?`,
),
};
}
+27
View File
@@ -620,6 +620,33 @@ describe("plugins cli install", () => {
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
});
it("passes ClawHub risk acknowledgement to explicit ClawHub installs", async () => {
loadConfig.mockReturnValue(createEmptyPluginConfig());
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
installPluginFromClawHub.mockResolvedValue(
createClawHubInstallResult({
pluginId: "demo",
packageName: "demo",
version: "1.2.3",
channel: "official",
}),
);
enablePluginInConfig.mockReturnValue({ config: createEnabledPluginConfig("demo") });
applyExclusiveSlotSelection.mockReturnValue({
config: createEnabledPluginConfig("demo"),
warnings: [],
});
await runPluginsCommand(["plugins", "install", "clawhub:demo", "--acknowledge-clawhub-risk"]);
expect(installPluginFromClawHub).toHaveBeenCalledWith(
expect.objectContaining({
spec: "clawhub:demo",
acknowledgeClawHubRisk: true,
}),
);
});
it("passes the active profile extensions dir to ClawHub installs", async () => {
const extensionsDir = useProfileExtensionsDir();
const cfg = createEmptyPluginConfig();
+34 -3
View File
@@ -18,10 +18,19 @@ import { applyParentDefaultHelpAction } from "./program/parent-default-help.js";
export type PluginUpdateOptions = {
all?: boolean;
acknowledgeClawhubRisk?: boolean;
dryRun?: boolean;
dangerouslyForceUnsafeInstall?: boolean;
};
type CommanderClawHubRiskOptions = Record<string, unknown> & {
acknowledgeClawhubRisk?: boolean;
};
function normalizeCommanderClawHubRiskOption(opts: CommanderClawHubRiskOptions): boolean {
return opts.acknowledgeClawhubRisk === true || opts.acknowledgeClawHubRisk === true;
}
export type PluginMarketplaceListOptions = {
json?: boolean;
};
@@ -253,6 +262,11 @@ export function registerPluginsCli(program: Command) {
"Bypass built-in dangerous-code install blocking (plugin hooks may still block)",
false,
)
.option(
"--acknowledge-clawhub-risk",
"Acknowledge ClawHub release trust warnings without prompting",
false,
)
.option(
"--marketplace <source>",
"Install a Claude marketplace plugin from a local repo/path or git/GitHub source",
@@ -260,7 +274,7 @@ export function registerPluginsCli(program: Command) {
.action(
async (
raw: string,
opts: {
opts: CommanderClawHubRiskOptions & {
dangerouslyForceUnsafeInstall?: boolean;
force?: boolean;
link?: boolean;
@@ -272,7 +286,13 @@ export function registerPluginsCli(program: Command) {
"install command",
async () => {
const { runPluginInstallCommand } = await import("./plugins-install-command.js");
await runPluginInstallCommand({ raw, opts });
await runPluginInstallCommand({
raw,
opts: {
...opts,
acknowledgeClawHubRisk: normalizeCommanderClawHubRiskOption(opts),
},
});
},
{ command: "install" },
);
@@ -290,9 +310,20 @@ export function registerPluginsCli(program: Command) {
"Bypass built-in dangerous-code update blocking for plugins (plugin hooks may still block)",
false,
)
.option(
"--acknowledge-clawhub-risk",
"Acknowledge ClawHub release trust warnings without prompting",
false,
)
.action(async (id: string | undefined, opts: PluginUpdateOptions) => {
const { runPluginUpdateCommand } = await import("./plugins-update-command.js");
await runPluginUpdateCommand({ id, opts });
await runPluginUpdateCommand({
id,
opts: {
...opts,
acknowledgeClawHubRisk: normalizeCommanderClawHubRiskOption(opts),
},
});
});
plugins
+29
View File
@@ -210,6 +210,35 @@ describe("plugins cli update", () => {
expect(updateParams.dangerouslyForceUnsafeInstall).toBe(true);
});
it("passes ClawHub risk acknowledgement to plugin updates", async () => {
const config = createTrackedPluginConfig({
pluginId: "openclaw-codex-app-server",
spec: "openclaw-codex-app-server@beta",
});
loadConfig.mockReturnValue(config);
setInstalledPluginIndexInstallRecords(config.plugins?.installs ?? {});
updateNpmInstalledPlugins.mockResolvedValue({
config,
changed: false,
outcomes: [],
});
await runPluginsCommand([
"plugins",
"update",
"openclaw-codex-app-server",
"--acknowledge-clawhub-risk",
]);
expect(updateNpmInstalledPlugins).toHaveBeenCalledWith(
expect.objectContaining({
config,
pluginIds: ["openclaw-codex-app-server"],
acknowledgeClawHubRisk: true,
}),
);
});
it("writes updated config when updater reports changes", async () => {
const cfg = {
plugins: {
+6
View File
@@ -33,6 +33,7 @@ import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { theme } from "../terminal/theme.js";
import { shortenHomePath } from "../utils.js";
import { resolveClawHubRiskAcknowledgementCliOptions } from "./clawhub-risk-acknowledgement.js";
import { formatCliCommand } from "./command-format.js";
import { looksLikeLocalInstallSpec } from "./install-spec.js";
import { resolvePinnedNpmInstallRecordForCli } from "./npm-resolution.js";
@@ -558,6 +559,7 @@ export async function loadConfigForInstall(
export async function runPluginInstallCommand(params: {
raw: string;
opts: InstallSafetyOverrides & {
acknowledgeClawHubRisk?: boolean;
force?: boolean;
link?: boolean;
pin?: boolean;
@@ -941,6 +943,10 @@ export async function runPluginInstallCommand(params: {
if (clawhubSpec) {
const result = await installPluginFromClawHub({
...safetyOverrides,
...resolveClawHubRiskAcknowledgementCliOptions({
acknowledgeClawHubRisk: opts.acknowledgeClawHubRisk,
action: "installing",
}),
mode: installMode,
spec: raw,
extensionsDir,
+11 -1
View File
@@ -13,6 +13,7 @@ import {
import { updateNpmInstalledPlugins } from "../plugins/update.js";
import { defaultRuntime } from "../runtime.js";
import { theme } from "../terminal/theme.js";
import { resolveClawHubRiskAcknowledgementCliOptions } from "./clawhub-risk-acknowledgement.js";
import { commitPluginInstallRecordsWithConfig } from "./plugins-install-record-commit.js";
import { refreshPluginRegistryAfterConfigMutation } from "./plugins-registry-refresh.js";
import { logPluginUpdateOutcomes } from "./plugins-update-outcomes.js";
@@ -24,7 +25,12 @@ import { promptYesNo } from "./prompt.js";
export async function runPluginUpdateCommand(params: {
id?: string;
opts: { all?: boolean; dryRun?: boolean; dangerouslyForceUnsafeInstall?: boolean };
opts: {
all?: boolean;
acknowledgeClawHubRisk?: boolean;
dryRun?: boolean;
dangerouslyForceUnsafeInstall?: boolean;
};
}) {
assertConfigWriteAllowedInCurrentMode();
@@ -62,6 +68,10 @@ export async function runPluginUpdateCommand(params: {
specOverrides: pluginSelection.specOverrides,
dryRun: params.opts.dryRun,
dangerouslyForceUnsafeInstall: params.opts.dangerouslyForceUnsafeInstall,
...resolveClawHubRiskAcknowledgementCliOptions({
acknowledgeClawHubRisk: params.opts.acknowledgeClawHubRisk,
action: "updating",
}),
logger,
onIntegrityDrift: async (drift) => {
const specLabel = drift.resolvedSpec ?? drift.spec;
+45 -2
View File
@@ -405,14 +405,14 @@ describe("update-cli", () => {
const syncPluginCall = (index = 0) => {
const calls = syncPluginsForUpdateChannel.mock.calls as unknown as Array<
[{ channel?: string; config?: OpenClawConfig }]
[Record<string, unknown> & { channel?: string; config?: OpenClawConfig }]
>;
return calls[index]?.[0];
};
const npmPluginUpdateCall = (index = 0) => {
const calls = updateNpmInstalledPlugins.mock.calls as unknown as Array<
[{ config?: OpenClawConfig; timeoutMs?: number }]
[Record<string, unknown> & { config?: OpenClawConfig; timeoutMs?: number }]
>;
return calls[index]?.[0];
};
@@ -930,6 +930,33 @@ describe("update-cli", () => {
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
});
it("carries ClawHub risk acknowledgement into post-core resume", async () => {
const { entrypoints } = setupUpdatedRootRefresh({
gatewayUpdateImpl: async (root) =>
makeOkUpdateResult({
mode: "git",
root,
before: { sha: "old-sha", version: "2026.4.26" },
after: { sha: "new-sha", version: "2026.4.27" },
}),
});
await updateCommand({
channel: "dev",
yes: true,
restart: false,
acknowledgeClawHubRisk: true,
});
expect(spawnCall()?.[1]).toEqual([
entrypoints[0],
"update",
"--no-restart",
"--yes",
"--acknowledge-clawhub-risk",
]);
});
it("keeps downgrade post-update work in the current process", async () => {
const downgradedRoot = createCaseDir("openclaw-downgraded-root");
setupUpdatedRootRefresh({
@@ -2892,6 +2919,22 @@ describe("update-cli", () => {
expect(updateCall?.syncOfficialPluginInstalls).toBe(true);
});
it("forwards ClawHub risk acknowledgement to post-update plugin work", async () => {
const tempDir = createCaseDir("openclaw-update");
mockPackageInstallStatus(tempDir);
await updateCommand({
channel: "beta",
yes: true,
restart: false,
acknowledgeClawHubRisk: true,
});
expect(syncPluginCall()?.acknowledgeClawHubRisk).toBe(true);
expect(npmPluginUpdateCall()?.acknowledgeClawHubRisk).toBe(true);
expect(lastNpmPluginUpdateCall()?.acknowledgeClawHubRisk).toBe(true);
});
it("persists channel and runs post-update work after switching from package to git", async () => {
const tempDir = createCaseDir("openclaw-update");
const gitRoot = path.join(tempDir, "..", "openclaw");
+27 -4
View File
@@ -31,6 +31,21 @@ function inheritedUpdateTimeout(
return inheritOptionFromParent<string>(command, "timeout");
}
type CommanderUpdateOptions = Record<string, unknown> & {
acknowledgeClawhubRisk?: boolean;
channel?: string;
dryRun?: boolean;
json?: boolean;
restart?: boolean;
tag?: string;
timeout?: string;
yes?: boolean;
};
function normalizeCommanderClawHubRiskOption(opts: CommanderUpdateOptions): boolean {
return opts.acknowledgeClawhubRisk === true || opts.acknowledgeClawHubRisk === true;
}
export function registerUpdateCli(program: Command) {
program.enablePositionalOptions();
const update = program
@@ -46,6 +61,11 @@ export function registerUpdateCli(program: Command) {
)
.option("--timeout <seconds>", "Timeout for each update step in seconds (default: 1800)")
.option("--yes", "Skip confirmation prompts (non-interactive)", false)
.option(
"--acknowledge-clawhub-risk",
"Acknowledge ClawHub release trust warnings during post-update plugin sync",
false,
)
.addHelpText("after", () => {
const examples = [
["openclaw update", "Update a source checkout (git)"],
@@ -57,6 +77,7 @@ export function registerUpdateCli(program: Command) {
["openclaw update --no-restart", "Update without restarting the service"],
["openclaw update --json", "Output result as JSON"],
["openclaw update --yes", "Non-interactive (accept downgrade prompts)"],
["openclaw update --acknowledge-clawhub-risk", "Acknowledge ClawHub plugin trust warnings"],
["openclaw update wizard", "Interactive update wizard"],
["openclaw --update", "Shorthand for openclaw update"],
] as const;
@@ -75,6 +96,7 @@ ${theme.heading("Switch channels:")}
${theme.heading("Non-interactive:")}
- Use --yes to accept downgrade prompts
- Use --acknowledge-clawhub-risk only after reviewing ClawHub plugin trust warnings
- Combine with --channel/--tag/--no-restart/--json/--timeout as needed
- Use --dry-run to preview actions without writing config/installing/restarting
@@ -89,16 +111,17 @@ ${theme.heading("Notes:")}
${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.openclaw.ai/cli/update")}`;
})
.action(async (opts) => {
.action(async (opts: CommanderUpdateOptions) => {
try {
await updateCommand({
json: Boolean(opts.json),
restart: Boolean(opts.restart),
dryRun: Boolean(opts.dryRun),
channel: opts.channel as string | undefined,
tag: opts.tag as string | undefined,
timeout: opts.timeout as string | undefined,
channel: opts.channel,
tag: opts.tag,
timeout: opts.timeout,
yes: Boolean(opts.yes),
acknowledgeClawHubRisk: normalizeCommanderClawHubRiskOption(opts),
});
} catch (err) {
defaultRuntime.error(String(err));
+1
View File
@@ -32,6 +32,7 @@ export type UpdateCommandOptions = {
tag?: string;
timeout?: string;
yes?: boolean;
acknowledgeClawHubRisk?: boolean;
};
export type UpdateStatusOptions = {
+33
View File
@@ -55,6 +55,7 @@ import {
resolvePnpmGlobalDirFromGlobalRoot,
} from "../../infra/update-global.js";
import { runGatewayUpdate, type UpdateRunResult } from "../../infra/update-runner.js";
import type { ClawHubRiskAcknowledgementRequest } from "../../plugins/clawhub.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js";
import {
loadInstalledPluginIndexInstallRecords,
@@ -173,6 +174,29 @@ type MissingPluginInstallPayload = {
type PostUpdatePluginWarning = NonNullable<PostCorePluginUpdateResult["warnings"]>[number];
function resolveUpdateClawHubRiskAcknowledgementOptions(opts: UpdateCommandOptions): {
acknowledgeClawHubRisk?: boolean;
onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => Promise<boolean>;
} {
if (opts.acknowledgeClawHubRisk) {
return { acknowledgeClawHubRisk: true };
}
if (opts.json || !process.stdin.isTTY) {
return {};
}
return {
onClawHubRisk: async (request) => {
const ok = await confirm({
message: stylePromptMessage(
`Continue updating ClawHub package "${request.packageName}@${request.version}" despite this warning?`,
),
initialValue: false,
});
return !isCancel(ok) && ok;
},
};
}
function pickUpdateQuip(): string {
return UPDATE_QUIPS[Math.floor(Math.random() * UPDATE_QUIPS.length)] ?? "Update complete.";
}
@@ -1185,6 +1209,9 @@ export async function updatePluginsAfterCoreUpdate(params: {
}
const warnings: PostUpdatePluginWarning[] = [];
const clawHubRiskAcknowledgementOptions = resolveUpdateClawHubRiskAcknowledgementOptions(
params.opts,
);
const pluginInstallRecords =
params.pluginInstallRecords ?? (await loadInstalledPluginIndexInstallRecords());
const syncConfig = withPluginInstallRecords(
@@ -1198,6 +1225,7 @@ export async function updatePluginsAfterCoreUpdate(params: {
externalizedBundledPluginBridges: await listPersistedBundledPluginLocationBridges({
workspaceDir: params.root,
}),
...clawHubRiskAcknowledgementOptions,
logger: pluginLogger,
});
for (const error of syncResult.summary.errors) {
@@ -1271,6 +1299,7 @@ export async function updatePluginsAfterCoreUpdate(params: {
disableOnFailure: true,
logger: pluginLogger,
onIntegrityDrift: onPluginIntegrityDrift,
...clawHubRiskAcknowledgementOptions,
});
pluginConfig = repairResult.config;
pluginsChanged ||= repairResult.changed;
@@ -1291,6 +1320,7 @@ export async function updatePluginsAfterCoreUpdate(params: {
disableOnFailure: true,
logger: pluginLogger,
onIntegrityDrift: onPluginIntegrityDrift,
...clawHubRiskAcknowledgementOptions,
});
pluginConfig = npmResult.config;
pluginsChanged ||= npmResult.changed;
@@ -1949,6 +1979,9 @@ async function continuePostCoreUpdateInFreshProcess(params: {
if (params.opts.yes) {
argv.push("--yes");
}
if (params.opts.acknowledgeClawHubRisk) {
argv.push("--acknowledge-clawhub-risk");
}
if (params.opts.timeout) {
argv.push("--timeout", params.opts.timeout);
}
@@ -506,6 +506,7 @@ async function installCandidate(params: {
const { candidate } = params;
const extensionsDir = resolveDefaultPluginExtensionsDir();
const changes: string[] = [];
const warnings: string[] = [];
const clawhubSpecs = candidate.clawhubSpec
? resolveClawHubInstallSpecsForUpdateChannel({
spec: candidate.clawhubSpec,
@@ -526,6 +527,9 @@ async function installCandidate(params: {
extensionsDir,
expectedPluginId: candidate.pluginId,
mode: "install",
logger: {
warn: (message) => warnings.push(message),
},
});
if (clawhubResult.ok) {
const pluginId = clawhubResult.pluginId;
@@ -540,7 +544,7 @@ async function installCandidate(params: {
},
},
changes: [`Installed missing configured plugin "${pluginId}" from ${clawhubInstallSpec}.`],
warnings: [],
warnings,
};
}
if (!npmInstallSpec || !shouldFallbackClawHubToNpm(clawhubResult)) {
@@ -548,6 +552,7 @@ async function installCandidate(params: {
records: params.records,
changes: [],
warnings: [
...warnings,
`Failed to install missing configured plugin "${candidate.pluginId}" from ${clawhubInstallSpec}: ${clawhubResult.error}`,
],
};
@@ -561,6 +566,7 @@ async function installCandidate(params: {
records: params.records,
changes: [],
warnings: [
...warnings,
`Failed to install missing configured plugin "${candidate.pluginId}": missing npm spec.`,
],
};
@@ -580,6 +586,7 @@ async function installCandidate(params: {
records: params.records,
changes: [],
warnings: [
...warnings,
`Failed to install missing configured plugin "${candidate.pluginId}" from ${npmInstallSpec}: ${result.error}`,
],
};
@@ -601,7 +608,7 @@ async function installCandidate(params: {
...changes,
`Installed missing configured plugin "${pluginId}" from ${npmInstallSpec}.`,
],
warnings: [],
warnings,
};
}
@@ -127,6 +127,7 @@ type NpmSpecInstallCall = {
type ClawHubInstallCall = {
expectedPluginId?: string;
mode?: string;
onClawHubRisk?: unknown;
spec?: string;
timeoutMs?: number;
};
@@ -368,6 +369,7 @@ describe("ensureOnboardingPluginInstalled", () => {
expect(clawHubCall.expectedPluginId).toBe("demo-plugin");
expect(clawHubCall.mode).toBe("install");
expect(clawHubCall.timeoutMs).toBe(300_000);
expect(typeof clawHubCall.onClawHubRisk).toBe("function");
expect(update).toHaveBeenCalledWith("Downloading");
expect(stop).toHaveBeenCalledWith("Installed Demo Provider plugin");
const [, recordUpdate] = readFirstMockCall(recordPluginInstall, "recordPluginInstall") as [
@@ -889,6 +889,14 @@ async function installPluginFromClawHubSpecWithProgress(params: {
logInstallWarningWithSpacing(params.runtime, message);
},
},
onClawHubRisk: async (request) => {
animated.stop();
progress.stop("Review ClawHub warning");
return await params.prompter.confirm({
message: `Continue installing ClawHub package "${sanitizeTerminalText(request.packageName)}@${sanitizeTerminalText(request.version)}" despite this warning?`,
initialValue: false,
});
},
}),
ONBOARDING_PLUGIN_INSTALL_WATCHDOG_TIMEOUT_MS,
);
+57 -12
View File
@@ -330,30 +330,75 @@ describe("clawhub helpers", () => {
requestedUrl = input instanceof Request ? input.url : String(input);
return new Response(
JSON.stringify({
releaseId: "rel_demo",
state: "approved",
reasonCode: "clean",
createdAt: 1774256733107,
scanState: "clean",
moderationState: "approved",
package: {
name: "@openclaw/diagnostics-otel",
displayName: "Diagnostics",
family: "code-plugin",
},
release: {
id: "rel_demo",
version: "2026.3.22",
},
trust: {
scanStatus: "clean",
moderationState: null,
blockedFromDownload: false,
reasons: [],
pending: false,
stale: true,
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
},
}),
).resolves.toEqual({
releaseId: "rel_demo",
state: "approved",
reasonCode: "clean",
createdAt: 1774256733107,
scanState: "clean",
moderationState: "approved",
package: {
name: "@openclaw/diagnostics-otel",
displayName: "Diagnostics",
family: "code-plugin",
},
release: {
id: "rel_demo",
version: "2026.3.22",
},
trust: {
scanStatus: "clean",
moderationState: null,
blockedFromDownload: false,
reasons: [],
pending: false,
stale: true,
},
});
expect(new URL(requestedUrl).pathname).toBe(
"/api/v1/packages/%40openclaw%2Fdiagnostics-otel/versions/2026.3.22/security",
);
});
it("rejects malformed package security reports", async () => {
await expect(
fetchClawHubPackageSecurity({
name: "@openclaw/diagnostics-otel",
version: "2026.3.22",
fetchImpl: async () =>
new Response(
JSON.stringify({
trust: {
scanStatus: "clean",
moderationState: null,
blockedFromDownload: false,
reasons: "clean",
pending: false,
stale: false,
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
}),
).rejects.toThrow("expected reasons to be a string array");
});
it("downloads package archives to sanitized temp paths and cleans them up", async () => {
const archive = await downloadClawHubPackageArchive({
name: "@hyf/zai-external-alpha",
+140 -18
View File
@@ -62,14 +62,14 @@ export type ClawHubArtifactScanState =
| "not-run"
| (string & {});
export type ClawHubArtifactModerationState = "approved" | "quarantined" | "revoked" | (string & {});
export type ClawHubPackageSecurityState =
| "pending"
| "approved"
| "limited"
| "quarantined"
| "rejected"
| "revoked"
| (string & {});
export type ClawHubPackageSecurityTrust = {
scanStatus?: ClawHubArtifactScanState | null;
moderationState?: ClawHubArtifactModerationState | null;
blockedFromDownload: boolean;
reasons: string[];
pending: boolean;
stale: boolean;
};
export type ClawHubResolvedArtifact =
| {
source: "clawhub";
@@ -116,15 +116,16 @@ export type ClawHubPackageArtifactResolverResponse = {
artifact?: ClawHubResolvedArtifact | null;
};
export type ClawHubPackageSecurityResponse = {
packageId?: string | null;
releaseId?: string | null;
state: ClawHubPackageSecurityState;
reasonCode?: string | null;
moderatorNote?: string | null;
actorId?: string | null;
createdAt?: number | null;
scanState?: ClawHubArtifactScanState | null;
moderationState?: ClawHubArtifactModerationState | null;
package?: {
name?: string | null;
displayName?: string | null;
family?: ClawHubPackageFamily | (string & {}) | null;
} | null;
release?: {
id?: string | null;
version?: string | null;
} | null;
trust: ClawHubPackageSecurityTrust;
};
export type ClawHubPackageClawPackSummary = {
available: boolean;
@@ -636,6 +637,126 @@ async function fetchJson<T>(params: ClawHubRequestParams): Promise<T> {
return (await response.json()) as T;
}
function isJsonObject(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function optionalStringField(
source: Record<string, unknown>,
field: string,
context: string,
): string | null | undefined {
const value = source[field];
if (value === undefined || value === null || typeof value === "string") {
return value;
}
throw new Error(`Malformed ClawHub ${context}: expected ${field} to be a string or null.`);
}
function requiredBooleanField(
source: Record<string, unknown>,
field: string,
context: string,
): boolean {
const value = source[field];
if (typeof value === "boolean") {
return value;
}
throw new Error(`Malformed ClawHub ${context}: expected ${field} to be a boolean.`);
}
function requiredStringArrayField(
source: Record<string, unknown>,
field: string,
context: string,
): string[] {
const value = source[field];
if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) {
return value;
}
throw new Error(`Malformed ClawHub ${context}: expected ${field} to be a string array.`);
}
function parseOptionalSecurityPackage(value: unknown): ClawHubPackageSecurityResponse["package"] {
if (value === undefined || value === null) {
return value;
}
if (!isJsonObject(value)) {
throw new Error(
"Malformed ClawHub security response: expected package to be an object or null.",
);
}
const result: NonNullable<ClawHubPackageSecurityResponse["package"]> = {};
const name = optionalStringField(value, "name", "security package");
const displayName = optionalStringField(value, "displayName", "security package");
const family = optionalStringField(value, "family", "security package");
if (name !== undefined) {
result.name = name;
}
if (displayName !== undefined) {
result.displayName = displayName;
}
if (family !== undefined) {
result.family = family;
}
return result;
}
function parseOptionalSecurityRelease(value: unknown): ClawHubPackageSecurityResponse["release"] {
if (value === undefined || value === null) {
return value;
}
if (!isJsonObject(value)) {
throw new Error(
"Malformed ClawHub security response: expected release to be an object or null.",
);
}
const result: NonNullable<ClawHubPackageSecurityResponse["release"]> = {};
const id = optionalStringField(value, "id", "security release");
const version = optionalStringField(value, "version", "security release");
if (id !== undefined) {
result.id = id;
}
if (version !== undefined) {
result.version = version;
}
return result;
}
function parseClawHubPackageSecurityResponse(value: unknown): ClawHubPackageSecurityResponse {
if (!isJsonObject(value)) {
throw new Error("Malformed ClawHub security response: expected an object.");
}
const trust = value.trust;
if (!isJsonObject(trust)) {
throw new Error("Malformed ClawHub security response: expected trust to be an object.");
}
const parsedTrust: ClawHubPackageSecurityTrust = {
blockedFromDownload: requiredBooleanField(trust, "blockedFromDownload", "security trust"),
reasons: requiredStringArrayField(trust, "reasons", "security trust"),
pending: requiredBooleanField(trust, "pending", "security trust"),
stale: requiredBooleanField(trust, "stale", "security trust"),
};
const scanStatus = optionalStringField(trust, "scanStatus", "security trust");
const moderationState = optionalStringField(trust, "moderationState", "security trust");
if (scanStatus !== undefined) {
parsedTrust.scanStatus = scanStatus;
}
if (moderationState !== undefined) {
parsedTrust.moderationState = moderationState;
}
const result: ClawHubPackageSecurityResponse = { trust: parsedTrust };
const parsedPackage = parseOptionalSecurityPackage(value.package);
const parsedRelease = parseOptionalSecurityRelease(value.release);
if (parsedPackage !== undefined) {
result.package = parsedPackage;
}
if (parsedRelease !== undefined) {
result.release = parsedRelease;
}
return result;
}
export function resolveClawHubBaseUrl(baseUrl?: string): string {
return normalizeBaseUrl(baseUrl);
}
@@ -768,7 +889,7 @@ export async function fetchClawHubPackageSecurity(params: {
timeoutMs?: number;
fetchImpl?: FetchLike;
}): Promise<ClawHubPackageSecurityResponse> {
return await fetchJson<ClawHubPackageSecurityResponse>({
const response = await fetchJson<unknown>({
baseUrl: params.baseUrl,
path: `/api/v1/packages/${encodeURIComponent(params.name)}/versions/${encodeURIComponent(
params.version,
@@ -777,6 +898,7 @@ export async function fetchClawHubPackageSecurity(params: {
timeoutMs: params.timeoutMs,
fetchImpl: params.fetchImpl,
});
return parseClawHubPackageSecurityResponse(response);
}
export async function fetchClawHubPackageReadiness(params: {
+132
View File
@@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const parseClawHubPluginSpecMock = vi.fn();
const fetchClawHubPackageDetailMock = vi.fn();
const fetchClawHubPackageArtifactMock = vi.fn();
const fetchClawHubPackageSecurityMock = vi.fn();
const fetchClawHubPackageVersionMock = vi.fn();
const downloadClawHubPackageArchiveMock = vi.fn();
const archiveCleanupMock = vi.fn();
@@ -23,6 +24,7 @@ vi.mock("../infra/clawhub.js", async () => {
parseClawHubPluginSpec: (...args: unknown[]) => parseClawHubPluginSpecMock(...args),
fetchClawHubPackageDetail: (...args: unknown[]) => fetchClawHubPackageDetailMock(...args),
fetchClawHubPackageArtifact: (...args: unknown[]) => fetchClawHubPackageArtifactMock(...args),
fetchClawHubPackageSecurity: (...args: unknown[]) => fetchClawHubPackageSecurityMock(...args),
fetchClawHubPackageVersion: (...args: unknown[]) => fetchClawHubPackageVersionMock(...args),
downloadClawHubPackageArchive: (...args: unknown[]) =>
downloadClawHubPackageArchiveMock(...args),
@@ -142,6 +144,8 @@ function expectClawHubInstallFlow(params: {
expect(packageVersionCall().version).toBe(params.version);
expect(packageArtifactCall().name).toBe("demo");
expect(packageArtifactCall().version).toBe(params.version);
expect(packageSecurityCall().name).toBe("demo");
expect(packageSecurityCall().version).toBe(params.version);
expect(archiveInstallCall().archivePath).toBe(params.archivePath);
}
@@ -211,6 +215,10 @@ function packageArtifactCall(callIndex = 0): PackageLookupCall {
return mockCallArg(fetchClawHubPackageArtifactMock, callIndex) as PackageLookupCall;
}
function packageSecurityCall(callIndex = 0): PackageLookupCall {
return mockCallArg(fetchClawHubPackageSecurityMock, callIndex) as PackageLookupCall;
}
function archiveDownloadCall(callIndex = 0): PackageLookupCall {
return mockCallArg(downloadClawHubPackageArchiveMock, callIndex) as PackageLookupCall;
}
@@ -250,6 +258,7 @@ describe("installPluginFromClawHub", () => {
parseClawHubPluginSpecMock.mockReset();
fetchClawHubPackageDetailMock.mockReset();
fetchClawHubPackageArtifactMock.mockReset();
fetchClawHubPackageSecurityMock.mockReset();
fetchClawHubPackageVersionMock.mockReset();
downloadClawHubPackageArchiveMock.mockReset();
archiveCleanupMock.mockReset();
@@ -289,6 +298,24 @@ describe("installPluginFromClawHub", () => {
fetchClawHubPackageArtifactMock.mockImplementation((params) =>
fetchClawHubPackageVersionMock(params),
);
fetchClawHubPackageSecurityMock.mockResolvedValue({
package: {
name: "demo",
displayName: "Demo",
family: "code-plugin",
},
release: {
version: "2026.3.22",
},
trust: {
scanStatus: "clean",
moderationState: null,
blockedFromDownload: false,
reasons: [],
pending: false,
stale: false,
},
});
downloadClawHubPackageArchiveMock.mockResolvedValue({
archivePath: "/tmp/clawhub-demo/archive.zip",
integrity: DEMO_ARCHIVE_INTEGRITY,
@@ -331,6 +358,111 @@ describe("installPluginFromClawHub", () => {
expect(archiveCleanupMock).toHaveBeenCalledTimes(1);
});
it("requires acknowledgement before downloading risky ClawHub releases", async () => {
fetchClawHubPackageSecurityMock.mockResolvedValueOnce({
trust: {
scanStatus: "malicious",
moderationState: "quarantined",
blockedFromDownload: true,
reasons: ["manual_moderation"],
pending: false,
stale: false,
},
});
const logger = createLoggerSpies();
const result = await installPluginFromClawHub({
spec: "clawhub:demo",
baseUrl: "https://clawhub.ai",
logger,
});
const failure = expectInstallFailure(result);
expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED);
expect(failure.error).toContain("--acknowledge-clawhub-risk");
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('ClawHub trust warning for "demo@2026.3.22"'),
);
expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled();
expect(installPluginFromArchiveMock).not.toHaveBeenCalled();
});
it("continues after a risky ClawHub release is acknowledged", async () => {
const onClawHubRisk = vi.fn(async () => true);
fetchClawHubPackageSecurityMock.mockResolvedValueOnce({
trust: {
scanStatus: "suspicious",
moderationState: null,
blockedFromDownload: false,
reasons: ["payload_strings"],
pending: false,
stale: false,
},
});
const result = await installPluginFromClawHub({
spec: "clawhub:demo",
baseUrl: "https://clawhub.ai",
onClawHubRisk,
});
expectSuccessfulClawHubInstall(result);
expect(onClawHubRisk).toHaveBeenCalledWith(
expect.objectContaining({
packageName: "demo",
version: "2026.3.22",
warning: expect.stringContaining("payload_strings"),
}),
);
expect(downloadClawHubPackageArchiveMock).toHaveBeenCalled();
});
it("warns for stale clean ClawHub trust without requiring acknowledgement", async () => {
const onClawHubRisk = vi.fn(async () => false);
const logger = createLoggerSpies();
fetchClawHubPackageSecurityMock.mockResolvedValueOnce({
trust: {
scanStatus: "clean",
moderationState: null,
blockedFromDownload: false,
reasons: [],
pending: false,
stale: true,
},
});
const result = await installPluginFromClawHub({
spec: "clawhub:demo",
baseUrl: "https://clawhub.ai",
logger,
onClawHubRisk,
});
expectSuccessfulClawHubInstall(result);
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("stale=true"));
expect(onClawHubRisk).not.toHaveBeenCalled();
});
it("stops when the ClawHub security response is unavailable", async () => {
fetchClawHubPackageSecurityMock.mockRejectedValueOnce(
new ClawHubRequestError({
path: "/api/v1/packages/demo/versions/2026.3.22/security",
status: 404,
body: "not found",
}),
);
const result = await installPluginFromClawHub({
spec: "clawhub:demo",
baseUrl: "https://clawhub.ai",
});
const failure = expectInstallFailure(result);
expect(failure.code).toBe(CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE);
expect(failure.error).toContain("ClawHub release trust check failed");
expect(downloadClawHubPackageArchiveMock).not.toHaveBeenCalled();
});
it("marks official source-linked OpenClaw packages as trusted for install scanning", async () => {
fetchClawHubPackageDetailMock.mockResolvedValueOnce({
package: {
+141
View File
@@ -15,6 +15,7 @@ import {
downloadClawHubPackageArchive,
fetchClawHubPackageArtifact,
fetchClawHubPackageDetail,
fetchClawHubPackageSecurity,
fetchClawHubPackageVersion,
normalizeClawHubSha256Integrity,
normalizeClawHubSha256Hex,
@@ -27,6 +28,7 @@ import {
type ClawHubPackageCompatibility,
type ClawHubPackageDetail,
type ClawHubPackageClawPackSummary,
type ClawHubPackageSecurityTrust,
type ClawHubResolvedArtifact,
type ClawHubPackageVersion,
} from "../infra/clawhub.js";
@@ -49,6 +51,8 @@ export const CLAWHUB_INSTALL_ERROR_CODE = {
INCOMPATIBLE_GATEWAY: "incompatible_gateway",
MISSING_ARCHIVE_INTEGRITY: "missing_archive_integrity",
ARCHIVE_INTEGRITY_MISMATCH: "archive_integrity_mismatch",
CLAWHUB_SECURITY_UNAVAILABLE: "clawhub_security_unavailable",
CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED: "clawhub_risk_acknowledgement_required",
} as const;
export type ClawHubInstallErrorCode =
@@ -59,6 +63,13 @@ type PluginInstallLogger = {
warn?: (message: string) => void;
};
export type ClawHubRiskAcknowledgementRequest = {
packageName: string;
version: string;
trust: ClawHubPackageSecurityTrust;
warning: string;
};
type ClawHubInstallFailure = {
ok: false;
error: string;
@@ -349,6 +360,121 @@ function mapClawHubRequestError(
return buildClawHubInstallFailure(formatErrorMessage(error));
}
const CLAWHUB_RISK_SCAN_STATUSES = new Set(["malicious", "suspicious"]);
const CLAWHUB_RISK_MODERATION_STATES = new Set(["blocked", "quarantined", "revoked"]);
const CLAWHUB_NON_RISK_REASONS = new Set([
"pending",
"pending_scan",
"scan_pending",
"stale",
"stale_scan",
]);
function normalizeClawHubTrustToken(value: string | null | undefined): string {
return normalizeOptionalString(value)?.toLowerCase() ?? "";
}
function resolveClawHubRiskReasons(trust: ClawHubPackageSecurityTrust): string[] {
const reasons: string[] = [];
if (trust.blockedFromDownload) {
reasons.push("blocked from download");
}
const scanStatus = normalizeClawHubTrustToken(trust.scanStatus);
if (CLAWHUB_RISK_SCAN_STATUSES.has(scanStatus)) {
reasons.push(`scan status ${scanStatus}`);
}
const moderationState = normalizeClawHubTrustToken(trust.moderationState);
if (CLAWHUB_RISK_MODERATION_STATES.has(moderationState)) {
reasons.push(`moderation state ${moderationState}`);
}
for (const reason of trust.reasons) {
const normalized = normalizeClawHubTrustToken(reason);
if (normalized && !CLAWHUB_NON_RISK_REASONS.has(normalized)) {
reasons.push(reason);
}
}
return reasons;
}
function formatClawHubTrustWarning(params: {
packageName: string;
version: string;
trust: ClawHubPackageSecurityTrust;
riskReasons: readonly string[];
}): string {
const details = [
`scan=${params.trust.scanStatus ?? "unknown"}`,
`moderation=${params.trust.moderationState ?? "none"}`,
`blockedFromDownload=${String(params.trust.blockedFromDownload)}`,
`pending=${String(params.trust.pending)}`,
`stale=${String(params.trust.stale)}`,
`reasons=${params.trust.reasons.length ? params.trust.reasons.join(", ") : "none"}`,
];
const riskSuffix =
params.riskReasons.length > 0 ? ` Risk signals: ${params.riskReasons.join(", ")}.` : "";
return `ClawHub trust warning for "${params.packageName}@${params.version}": ${details.join("; ")}.${riskSuffix}`;
}
async function ensureClawHubPackageTrustAcknowledged(params: {
packageName: string;
version: string;
baseUrl?: string;
token?: string;
timeoutMs?: number;
acknowledgeClawHubRisk?: boolean;
onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise<boolean>;
logger?: PluginInstallLogger;
}): Promise<ClawHubInstallFailure | null> {
let trust: ClawHubPackageSecurityTrust;
try {
const security = await fetchClawHubPackageSecurity({
name: params.packageName,
version: params.version,
baseUrl: params.baseUrl,
token: params.token,
timeoutMs: params.timeoutMs,
});
trust = security.trust;
} catch (error) {
return buildClawHubInstallFailure(
`ClawHub release trust check failed for "${params.packageName}@${params.version}": ${formatErrorMessage(error)}`,
CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_SECURITY_UNAVAILABLE,
);
}
const riskReasons = resolveClawHubRiskReasons(trust);
if (riskReasons.length === 0 && !trust.pending && !trust.stale) {
return null;
}
const warning = formatClawHubTrustWarning({
packageName: params.packageName,
version: params.version,
trust,
riskReasons,
});
params.logger?.warn?.(warning);
if (riskReasons.length === 0 || params.acknowledgeClawHubRisk) {
return null;
}
const acknowledged = params.onClawHubRisk
? await params.onClawHubRisk({
packageName: params.packageName,
version: params.version,
trust,
warning,
})
: false;
if (acknowledged) {
return null;
}
return buildClawHubInstallFailure(
`ClawHub release "${params.packageName}@${params.version}" has trust warnings. Review the package and rerun with --acknowledge-clawhub-risk to continue.`,
CLAWHUB_INSTALL_ERROR_CODE.CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED,
);
}
function isMissingArtifactResolverRoute(error: unknown): boolean {
return (
error instanceof ClawHubRequestError &&
@@ -1055,6 +1181,8 @@ export async function installPluginFromClawHub(
timeoutMs?: number;
dryRun?: boolean;
expectedPluginId?: string;
acknowledgeClawHubRisk?: boolean;
onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise<boolean>;
},
): Promise<
| ({
@@ -1125,6 +1253,19 @@ export async function installPluginFromClawHub(
compatibility: versionState.compatibility,
logger: params.logger,
});
const trustFailure = await ensureClawHubPackageTrustAcknowledged({
packageName: canonicalPackageName,
version: versionState.version,
baseUrl: params.baseUrl,
token: params.token,
timeoutMs: params.timeoutMs,
acknowledgeClawHubRisk: params.acknowledgeClawHubRisk,
onClawHubRisk: params.onClawHubRisk,
logger: params.logger,
});
if (trustFailure) {
return trustFailure;
}
let archive;
try {
+53
View File
@@ -2397,6 +2397,54 @@ describe("updateNpmInstalledPlugins", () => {
});
});
it("forwards ClawHub risk acknowledgement inputs to dry-run and live ClawHub updates", async () => {
const onClawHubRisk = vi.fn(async () => true);
const config = createClawHubInstallConfig({
pluginId: "demo",
installPath: "/tmp/demo",
clawhubUrl: "https://clawhub.ai",
clawhubPackage: "demo",
clawhubFamily: "code-plugin",
clawhubChannel: "official",
});
installPluginFromClawHubMock.mockResolvedValue({
ok: true,
pluginId: "demo",
targetDir: "/tmp/demo",
version: "1.2.4",
clawhub: {
source: "clawhub",
clawhubUrl: "https://clawhub.ai",
clawhubPackage: "demo",
clawhubFamily: "code-plugin",
clawhubChannel: "official",
integrity: "sha256-next",
resolvedAt: "2026-03-22T00:00:00.000Z",
},
});
for (const dryRun of [true, false]) {
installPluginFromClawHubMock.mockClear();
await updateNpmInstalledPlugins({
config,
pluginIds: ["demo"],
acknowledgeClawHubRisk: true,
onClawHubRisk,
...(dryRun ? { dryRun: true } : {}),
});
expect(installPluginFromClawHubMock).toHaveBeenCalledWith(
expect.objectContaining({
spec: "clawhub:demo",
acknowledgeClawHubRisk: true,
onClawHubRisk,
...(dryRun ? { dryRun: true } : {}),
}),
);
}
});
it("migrates legacy unscoped install keys when a scoped npm package updates", async () => {
installPluginFromNpmSpecMock.mockResolvedValue({
ok: true,
@@ -2947,9 +2995,12 @@ describe("syncPluginsForUpdateChannel", () => {
clawhubPackage: "legacy-chat",
}),
);
const onClawHubRisk = vi.fn(async () => true);
const result = await syncPluginsForUpdateChannel({
channel: "stable",
acknowledgeClawHubRisk: true,
onClawHubRisk,
externalizedBundledPluginBridges: [
{
bundledPluginId: "legacy-chat",
@@ -2983,6 +3034,8 @@ describe("syncPluginsForUpdateChannel", () => {
expect(clawHubInstallCall()?.baseUrl).toBe("https://clawhub.ai");
expect(clawHubInstallCall()?.mode).toBe("update");
expect(clawHubInstallCall()?.expectedPluginId).toBe("legacy-chat");
expect(clawHubInstallCall()?.acknowledgeClawHubRisk).toBe(true);
expect(clawHubInstallCall()?.onClawHubRisk).toBe(onClawHubRisk);
expect(installPluginFromNpmSpecMock).not.toHaveBeenCalled();
expect(result.changed).toBe(true);
expect(result.summary.switchedToClawHub).toEqual(["legacy-chat"]);
+22 -1
View File
@@ -20,7 +20,11 @@ import type { UpdateChannel } from "../infra/update-channels.js";
import { resolveUserPath } from "../utils.js";
import { resolveBundledPluginSources } from "./bundled-sources.js";
import { buildClawHubPluginInstallRecordFields } from "./clawhub-install-records.js";
import { CLAWHUB_INSTALL_ERROR_CODE, installPluginFromClawHub } from "./clawhub.js";
import {
CLAWHUB_INSTALL_ERROR_CODE,
installPluginFromClawHub,
type ClawHubRiskAcknowledgementRequest,
} from "./clawhub.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js";
import {
getExternalizedBundledPluginLegacyPathSuffix,
@@ -848,6 +852,8 @@ export async function updateNpmInstalledPlugins(params: {
dangerouslyForceUnsafeInstall?: boolean;
specOverrides?: Record<string, string>;
onIntegrityDrift?: (params: PluginUpdateIntegrityDriftParams) => boolean | Promise<boolean>;
acknowledgeClawHubRisk?: boolean;
onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise<boolean>;
}): Promise<PluginUpdateSummary> {
const logger = params.logger ?? {};
const installs = params.config.plugins?.installs ?? {};
@@ -866,6 +872,10 @@ export async function updateNpmInstalledPlugins(params: {
ranNpmInstaller = true;
return await installPluginFromNpmSpec(installParams);
};
const clawHubRiskAcknowledgementOptions = {
...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}),
...(params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}),
};
const recordFailure = (pluginId: string, message: string) => {
if (params.disableOnFailure && !params.dryRun) {
@@ -1145,6 +1155,7 @@ export async function updateNpmInstalledPlugins(params: {
dryRun: true,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
...clawHubRiskAcknowledgementOptions,
logger,
})
: record.source === "git"
@@ -1232,6 +1243,7 @@ export async function updateNpmInstalledPlugins(params: {
dryRun: true,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
...clawHubRiskAcknowledgementOptions,
logger,
});
}
@@ -1345,6 +1357,7 @@ export async function updateNpmInstalledPlugins(params: {
timeoutMs: params.timeoutMs,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
...clawHubRiskAcknowledgementOptions,
logger,
})
: record.source === "git"
@@ -1428,6 +1441,7 @@ export async function updateNpmInstalledPlugins(params: {
timeoutMs: params.timeoutMs,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
expectedPluginId: pluginId,
...clawHubRiskAcknowledgementOptions,
logger,
});
}
@@ -1569,6 +1583,8 @@ export async function syncPluginsForUpdateChannel(params: {
env?: NodeJS.ProcessEnv;
logger?: PluginUpdateLogger;
externalizedBundledPluginBridges?: readonly ExternalizedBundledPluginBridge[];
acknowledgeClawHubRisk?: boolean;
onClawHubRisk?: (request: ClawHubRiskAcknowledgementRequest) => boolean | Promise<boolean>;
}): Promise<PluginChannelSyncResult> {
const env = params.env ?? process.env;
const logger = params.logger ?? {};
@@ -1588,6 +1604,10 @@ export async function syncPluginsForUpdateChannel(params: {
const loadHelpers = buildLoadPathHelpers(next.plugins?.load?.paths ?? [], env);
let installs = next.plugins?.installs ?? {};
let changed = false;
const clawHubRiskAcknowledgementOptions = {
...(params.acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}),
...(params.onClawHubRisk ? { onClawHubRisk: params.onClawHubRisk } : {}),
};
if (params.channel === "dev") {
for (const [pluginId, record] of Object.entries(installs)) {
@@ -1700,6 +1720,7 @@ export async function syncPluginsForUpdateChannel(params: {
...(bridge.clawhubUrl ? { baseUrl: bridge.clawhubUrl } : {}),
mode: "update",
expectedPluginId: targetPluginId,
...clawHubRiskAcknowledgementOptions,
logger,
});
if (!result.ok && npmSpec && shouldFallbackClawHubBridgeToNpm(result)) {