From 3fa2300ba1361a2797d357bf8778652fcd9b2ab7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Mar 2026 16:32:17 +0000 Subject: [PATCH] perf: reduce plugin runtime startup overhead --- extensions/discord/src/directory-config.ts | 28 ++++-- extensions/discord/src/runtime-api.ts | 2 +- extensions/slack/src/channel-actions.ts | 2 +- extensions/slack/src/directory-config.ts | 28 ++++-- extensions/slack/src/group-policy.ts | 23 ++--- .../slack/src/message-action-dispatch.ts | 2 +- extensions/slack/src/runtime-api.ts | 17 ++-- package.json | 12 +++ scripts/lib/plugin-sdk-entrypoints.json | 3 + src/plugin-sdk/channel-status.ts | 10 +++ src/plugin-sdk/param-readers.ts | 6 ++ src/plugin-sdk/slack-targets.ts | 9 +- src/plugins/command-registry-state.ts | 74 +++++++++++++++ src/plugins/commands.ts | 90 +++---------------- src/plugins/install-security-scan.runtime.ts | 86 ++++++++++++++++++ src/plugins/install-security-scan.ts | 26 ++++++ src/plugins/install.ts | 61 +++---------- src/plugins/loader.ts | 2 +- 18 files changed, 309 insertions(+), 172 deletions(-) create mode 100644 src/plugin-sdk/channel-status.ts create mode 100644 src/plugin-sdk/param-readers.ts create mode 100644 src/plugins/command-registry-state.ts create mode 100644 src/plugins/install-security-scan.runtime.ts create mode 100644 src/plugins/install-security-scan.ts diff --git a/extensions/discord/src/directory-config.ts b/extensions/discord/src/directory-config.ts index 84b9c9691c3..74d8b725b70 100644 --- a/extensions/discord/src/directory-config.ts +++ b/extensions/discord/src/directory-config.ts @@ -1,15 +1,28 @@ +import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { - listInspectedDirectoryEntriesFromSources, + listResolvedDirectoryEntriesFromSources, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; -import { inspectDiscordAccount, type InspectedDiscordAccount } from "./account-inspect.js"; +import { mergeDiscordAccountConfig, resolveDefaultDiscordAccountId } from "./accounts.js"; + +function resolveDiscordDirectoryConfigAccount( + cfg: DirectoryConfigParams["cfg"], + accountId?: string | null, +) { + const resolvedAccountId = normalizeAccountId(accountId ?? resolveDefaultDiscordAccountId(cfg)); + const config = mergeDiscordAccountConfig(cfg, resolvedAccountId); + return { + accountId: resolvedAccountId, + config, + dm: config.dm, + }; +} export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfigParams) { - return listInspectedDirectoryEntriesFromSources({ + return listResolvedDirectoryEntriesFromSources({ ...params, kind: "user", - inspectAccount: (cfg, accountId) => - inspectDiscordAccount({ cfg, accountId }) as InspectedDiscordAccount | null, + resolveAccount: (cfg, accountId) => resolveDiscordDirectoryConfigAccount(cfg, accountId), resolveSources: (account) => { const allowFrom = account.config.allowFrom ?? account.config.dm?.allowFrom ?? []; const guildUsers = Object.values(account.config.guilds ?? {}).flatMap((guild) => [ @@ -27,11 +40,10 @@ export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfi } export async function listDiscordDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - return listInspectedDirectoryEntriesFromSources({ + return listResolvedDirectoryEntriesFromSources({ ...params, kind: "group", - inspectAccount: (cfg, accountId) => - inspectDiscordAccount({ cfg, accountId }) as InspectedDiscordAccount | null, + resolveAccount: (cfg, accountId) => resolveDiscordDirectoryConfigAccount(cfg, accountId), resolveSources: (account) => Object.values(account.config.guilds ?? {}).map((guild) => Object.keys(guild.channels ?? {})), normalizeId: (raw) => { diff --git a/extensions/discord/src/runtime-api.ts b/extensions/discord/src/runtime-api.ts index 3bbdfd0276c..ca01c4aa709 100644 --- a/extensions/discord/src/runtime-api.ts +++ b/extensions/discord/src/runtime-api.ts @@ -4,7 +4,7 @@ export { PAIRING_APPROVED_MESSAGE, projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, -} from "openclaw/plugin-sdk/discord"; +} from "openclaw/plugin-sdk/channel-status"; export { buildChannelConfigSchema, getChatChannelMeta, diff --git a/extensions/slack/src/channel-actions.ts b/extensions/slack/src/channel-actions.ts index 4502ddb36a4..7b6880cecd2 100644 --- a/extensions/slack/src/channel-actions.ts +++ b/extensions/slack/src/channel-actions.ts @@ -5,10 +5,10 @@ import { } from "openclaw/plugin-sdk/channel-contract"; import type { SlackActionContext } from "./action-runtime.js"; import { handleSlackAction } from "./action-runtime.js"; +import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; import { handleSlackMessageAction } from "./message-action-dispatch.js"; import { extractSlackToolSend, listSlackMessageActions } from "./message-actions.js"; import { createSlackMessageToolBlocksSchema } from "./message-tool-schema.js"; -import { isSlackInteractiveRepliesEnabled } from "./runtime-api.js"; import { resolveSlackChannelId } from "./targets.js"; type SlackActionInvoke = ( diff --git a/extensions/slack/src/directory-config.ts b/extensions/slack/src/directory-config.ts index 5a9486fc15e..0bf3cf35e1c 100644 --- a/extensions/slack/src/directory-config.ts +++ b/extensions/slack/src/directory-config.ts @@ -1,16 +1,29 @@ +import { normalizeAccountId } from "openclaw/plugin-sdk/account-resolution"; import { - listInspectedDirectoryEntriesFromSources, + listResolvedDirectoryEntriesFromSources, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; -import { inspectSlackAccount, type InspectedSlackAccount } from "./account-inspect.js"; +import { mergeSlackAccountConfig, resolveDefaultSlackAccountId } from "./accounts.js"; import { parseSlackTarget } from "./targets.js"; +function resolveSlackDirectoryConfigAccount( + cfg: DirectoryConfigParams["cfg"], + accountId?: string | null, +) { + const resolvedAccountId = normalizeAccountId(accountId ?? resolveDefaultSlackAccountId(cfg)); + const config = mergeSlackAccountConfig(cfg, resolvedAccountId); + return { + accountId: resolvedAccountId, + config, + dm: config.dm, + }; +} + export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigParams) { - return listInspectedDirectoryEntriesFromSources({ + return listResolvedDirectoryEntriesFromSources({ ...params, kind: "user", - inspectAccount: (cfg, accountId) => - inspectSlackAccount({ cfg, accountId }) as InspectedSlackAccount | null, + resolveAccount: (cfg, accountId) => resolveSlackDirectoryConfigAccount(cfg, accountId), resolveSources: (account) => { const allowFrom = account.config.allowFrom ?? account.dm?.allowFrom ?? []; const channelUsers = Object.values(account.config.channels ?? {}).flatMap( @@ -32,11 +45,10 @@ export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigP } export async function listSlackDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - return listInspectedDirectoryEntriesFromSources({ + return listResolvedDirectoryEntriesFromSources({ ...params, kind: "group", - inspectAccount: (cfg, accountId) => - inspectSlackAccount({ cfg, accountId }) as InspectedSlackAccount | null, + resolveAccount: (cfg, accountId) => resolveSlackDirectoryConfigAccount(cfg, accountId), resolveSources: (account) => [Object.keys(account.config.channels ?? {})], normalizeId: (raw) => { const normalized = parseSlackTarget(raw, { defaultKind: "channel" }); diff --git a/extensions/slack/src/group-policy.ts b/extensions/slack/src/group-policy.ts index b77a63c7a81..b6464381034 100644 --- a/extensions/slack/src/group-policy.ts +++ b/extensions/slack/src/group-policy.ts @@ -1,3 +1,4 @@ +import { normalizeAccountId } from "openclaw/plugin-sdk/account-resolution"; import type { ChannelGroupContext } from "openclaw/plugin-sdk/channel-contract"; import { resolveToolsBySender, @@ -5,7 +6,7 @@ import { type GroupToolPolicyConfig, } from "openclaw/plugin-sdk/channel-policy"; import { normalizeHyphenSlug } from "openclaw/plugin-sdk/core"; -import { inspectSlackAccount } from "./account-inspect.js"; +import { mergeSlackAccountConfig, resolveDefaultSlackAccountId } from "./accounts.js"; type SlackChannelPolicyEntry = { requireMention?: boolean; @@ -16,12 +17,14 @@ type SlackChannelPolicyEntry = { function resolveSlackChannelPolicyEntry( params: ChannelGroupContext, ): SlackChannelPolicyEntry | undefined { - const account = inspectSlackAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); - const channels = (account.channels ?? {}) as Record; - if (Object.keys(channels).length === 0) { + const accountId = normalizeAccountId( + params.accountId ?? resolveDefaultSlackAccountId(params.cfg), + ); + const channels = mergeSlackAccountConfig(params.cfg, accountId).channels as + | Record + | undefined; + const channelMap = channels ?? {}; + if (Object.keys(channelMap).length === 0) { return undefined; } const channelId = params.groupId?.trim(); @@ -35,11 +38,11 @@ function resolveSlackChannelPolicyEntry( normalizedName, ].filter(Boolean); for (const candidate of candidates) { - if (candidate && channels[candidate]) { - return channels[candidate]; + if (candidate && channelMap[candidate]) { + return channelMap[candidate]; } } - return channels["*"]; + return channelMap["*"]; } function resolveSenderToolsEntry( diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index 372ae915700..27053f726ac 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -1,9 +1,9 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract"; import { normalizeInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime"; +import { readNumberParam, readStringParam } from "openclaw/plugin-sdk/param-readers"; import { parseSlackBlocksInput } from "./blocks-input.js"; import { buildSlackInteractiveBlocks } from "./blocks-render.js"; -import { readNumberParam, readStringParam } from "./runtime-api.js"; type SlackActionInvoke = ( action: Record, diff --git a/extensions/slack/src/runtime-api.ts b/extensions/slack/src/runtime-api.ts index 5dac68be756..31da0160338 100644 --- a/extensions/slack/src/runtime-api.ts +++ b/extensions/slack/src/runtime-api.ts @@ -1,19 +1,15 @@ export { buildComputedAccountStatusSnapshot, - DEFAULT_ACCOUNT_ID, - looksLikeSlackTargetId, - normalizeSlackMessagingTarget, PAIRING_APPROVED_MESSAGE, projectCredentialSnapshotFields, resolveConfiguredFromRequiredCredentialStatuses, - type ChannelPlugin, - type OpenClawConfig, - type SlackAccountConfig, -} from "openclaw/plugin-sdk/slack"; +} from "openclaw/plugin-sdk/channel-status"; +export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; export { - listSlackDirectoryGroupsFromConfig, - listSlackDirectoryPeersFromConfig, -} from "./directory-config.js"; + looksLikeSlackTargetId, + normalizeSlackMessagingTarget, +} from "openclaw/plugin-sdk/slack-targets"; +export type { ChannelPlugin, OpenClawConfig, SlackAccountConfig } from "openclaw/plugin-sdk/slack"; export { buildChannelConfigSchema, getChatChannelMeta, @@ -26,4 +22,3 @@ export { SlackConfigSchema, withNormalizedTimestamp, } from "openclaw/plugin-sdk/slack-core"; -export { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; diff --git a/package.json b/package.json index 85fb6c3276d..4fc460f401b 100644 --- a/package.json +++ b/package.json @@ -449,6 +449,10 @@ "types": "./dist/plugin-sdk/provider-web-search.d.ts", "default": "./dist/plugin-sdk/provider-web-search.js" }, + "./plugin-sdk/param-readers": { + "types": "./dist/plugin-sdk/param-readers.d.ts", + "default": "./dist/plugin-sdk/param-readers.js" + }, "./plugin-sdk/provider-zai-endpoint": { "types": "./dist/plugin-sdk/provider-zai-endpoint.d.ts", "default": "./dist/plugin-sdk/provider-zai-endpoint.js" @@ -461,6 +465,10 @@ "types": "./dist/plugin-sdk/signal.d.ts", "default": "./dist/plugin-sdk/signal.js" }, + "./plugin-sdk/channel-status": { + "types": "./dist/plugin-sdk/channel-status.d.ts", + "default": "./dist/plugin-sdk/channel-status.js" + }, "./plugin-sdk/slack": { "types": "./dist/plugin-sdk/slack.d.ts", "default": "./dist/plugin-sdk/slack.js" @@ -469,6 +477,10 @@ "types": "./dist/plugin-sdk/slack-core.d.ts", "default": "./dist/plugin-sdk/slack-core.js" }, + "./plugin-sdk/slack-targets": { + "types": "./dist/plugin-sdk/slack-targets.d.ts", + "default": "./dist/plugin-sdk/slack-targets.js" + }, "./plugin-sdk/status-helpers": { "types": "./dist/plugin-sdk/status-helpers.d.ts", "default": "./dist/plugin-sdk/status-helpers.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 270da1bf8c8..09ebb1a44fd 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -102,11 +102,14 @@ "provider-stream", "provider-usage", "provider-web-search", + "param-readers", "provider-zai-endpoint", "secret-input", "signal", + "channel-status", "slack", "slack-core", + "slack-targets", "status-helpers", "speech", "state-paths", diff --git a/src/plugin-sdk/channel-status.ts b/src/plugin-sdk/channel-status.ts new file mode 100644 index 00000000000..a816f715d53 --- /dev/null +++ b/src/plugin-sdk/channel-status.ts @@ -0,0 +1,10 @@ +export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; +export { + projectCredentialSnapshotFields, + resolveConfiguredFromCredentialStatuses, + resolveConfiguredFromRequiredCredentialStatuses, +} from "../channels/account-snapshot-fields.js"; +export { + buildComputedAccountStatusSnapshot, + buildTokenChannelStatusSummary, +} from "./status-helpers.js"; diff --git a/src/plugin-sdk/param-readers.ts b/src/plugin-sdk/param-readers.ts new file mode 100644 index 00000000000..82fc6e84dfc --- /dev/null +++ b/src/plugin-sdk/param-readers.ts @@ -0,0 +1,6 @@ +export { + readNumberParam, + readStringArrayParam, + readStringOrNumberParam, + readStringParam, +} from "../agents/tools/common.js"; diff --git a/src/plugin-sdk/slack-targets.ts b/src/plugin-sdk/slack-targets.ts index 20ea56e44d1..f04ca2de1be 100644 --- a/src/plugin-sdk/slack-targets.ts +++ b/src/plugin-sdk/slack-targets.ts @@ -1,6 +1,5 @@ export { - parseSlackTarget, - resolveSlackChannelId, - type SlackTarget, - type SlackTargetKind, -} from "../../extensions/slack/api.js"; + looksLikeSlackTargetId, + normalizeSlackMessagingTarget, +} from "../channels/plugins/normalize/slack.js"; +export { parseSlackTarget, resolveSlackChannelId } from "../../extensions/slack/src/targets.js"; diff --git a/src/plugins/command-registry-state.ts b/src/plugins/command-registry-state.ts new file mode 100644 index 00000000000..386b23cd37e --- /dev/null +++ b/src/plugins/command-registry-state.ts @@ -0,0 +1,74 @@ +import { resolveGlobalSingleton } from "../shared/global-singleton.js"; +import type { OpenClawPluginCommandDefinition } from "./types.js"; + +export type RegisteredPluginCommand = OpenClawPluginCommandDefinition & { + pluginId: string; + pluginName?: string; + pluginRoot?: string; +}; + +type PluginCommandState = { + pluginCommands: Map; + registryLocked: boolean; +}; + +const PLUGIN_COMMAND_STATE_KEY = Symbol.for("openclaw.pluginCommandsState"); + +const state = resolveGlobalSingleton(PLUGIN_COMMAND_STATE_KEY, () => ({ + pluginCommands: new Map(), + registryLocked: false, +})); + +export const pluginCommands = state.pluginCommands; + +export function isPluginCommandRegistryLocked(): boolean { + return state.registryLocked; +} + +export function setPluginCommandRegistryLocked(locked: boolean): void { + state.registryLocked = locked; +} + +export function clearPluginCommands(): void { + pluginCommands.clear(); +} + +export function clearPluginCommandsForPlugin(pluginId: string): void { + for (const [key, cmd] of pluginCommands.entries()) { + if (cmd.pluginId === pluginId) { + pluginCommands.delete(key); + } + } +} + +function resolvePluginNativeName( + command: OpenClawPluginCommandDefinition, + provider?: string, +): string { + const providerName = provider?.trim().toLowerCase(); + const providerOverride = providerName ? command.nativeNames?.[providerName] : undefined; + if (typeof providerOverride === "string" && providerOverride.trim()) { + return providerOverride.trim(); + } + const defaultOverride = command.nativeNames?.default; + if (typeof defaultOverride === "string" && defaultOverride.trim()) { + return defaultOverride.trim(); + } + return command.name; +} + +export function getPluginCommandSpecs(provider?: string): Array<{ + name: string; + description: string; + acceptsArgs: boolean; +}> { + const providerName = provider?.trim().toLowerCase(); + if (providerName && providerName !== "telegram" && providerName !== "discord") { + return []; + } + return Array.from(pluginCommands.values()).map((cmd) => ({ + name: resolvePluginNativeName(cmd, provider), + description: cmd.description, + acceptsArgs: cmd.acceptsArgs ?? false, + })); +} diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index 85d73d7cabc..dee74852eac 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -8,7 +8,15 @@ import { parseExplicitTargetForChannel } from "../channels/plugins/target-parsing.js"; import type { OpenClawConfig } from "../config/config.js"; import { logVerbose } from "../globals.js"; -import { resolveGlobalSingleton } from "../shared/global-singleton.js"; +import { + clearPluginCommands, + clearPluginCommandsForPlugin, + getPluginCommandSpecs, + isPluginCommandRegistryLocked, + pluginCommands, + setPluginCommandRegistryLocked, + type RegisteredPluginCommand, +} from "./command-registry-state.js"; import { detachPluginConversationBinding, getCurrentPluginConversationBinding, @@ -20,26 +28,6 @@ import type { PluginCommandResult, } from "./types.js"; -type RegisteredPluginCommand = OpenClawPluginCommandDefinition & { - pluginId: string; - pluginName?: string; - pluginRoot?: string; -}; - -type PluginCommandState = { - pluginCommands: Map; - registryLocked: boolean; -}; - -const PLUGIN_COMMAND_STATE_KEY = Symbol.for("openclaw.pluginCommandsState"); - -const state = resolveGlobalSingleton(PLUGIN_COMMAND_STATE_KEY, () => ({ - pluginCommands: new Map(), - registryLocked: false, -})); - -const pluginCommands = state.pluginCommands; - // Maximum allowed length for command arguments (defense in depth) const MAX_ARGS_LENGTH = 4096; @@ -181,7 +169,7 @@ export function registerPluginCommand( opts?: { pluginName?: string; pluginRoot?: string }, ): CommandRegistrationResult { // Prevent registration while commands are being processed - if (state.registryLocked) { + if (isPluginCommandRegistryLocked()) { return { ok: false, error: "Cannot register commands while processing is in progress" }; } @@ -225,24 +213,7 @@ export function registerPluginCommand( return { ok: true }; } -/** - * Clear all registered plugin commands. - * Called during plugin reload. - */ -export function clearPluginCommands(): void { - pluginCommands.clear(); -} - -/** - * Clear plugin commands for a specific plugin. - */ -export function clearPluginCommandsForPlugin(pluginId: string): void { - for (const [key, cmd] of pluginCommands.entries()) { - if (cmd.pluginId === pluginId) { - pluginCommands.delete(key); - } - } -} +export { clearPluginCommands, clearPluginCommandsForPlugin, getPluginCommandSpecs }; /** * Check if a command body matches a registered plugin command. @@ -460,7 +431,7 @@ export async function executePluginCommand(params: { }; // Lock registry during execution to prevent concurrent modifications - state.registryLocked = true; + setPluginCommandRegistryLocked(true); try { const result = await command.handler(ctx); logVerbose( @@ -473,7 +444,7 @@ export async function executePluginCommand(params: { // Don't leak internal error details - return a safe generic message return { text: "⚠️ Command failed. Please try again later." }; } finally { - state.registryLocked = false; + setPluginCommandRegistryLocked(false); } } @@ -493,45 +464,10 @@ export function listPluginCommands(): Array<{ })); } -function resolvePluginNativeName( - command: OpenClawPluginCommandDefinition, - provider?: string, -): string { - const providerName = provider?.trim().toLowerCase(); - const providerOverride = providerName ? command.nativeNames?.[providerName] : undefined; - if (typeof providerOverride === "string" && providerOverride.trim()) { - return providerOverride.trim(); - } - const defaultOverride = command.nativeNames?.default; - if (typeof defaultOverride === "string" && defaultOverride.trim()) { - return defaultOverride.trim(); - } - return command.name; -} - function listPluginInvocationNames(command: OpenClawPluginCommandDefinition): string[] { return listPluginInvocationKeys(command); } -/** - * Get plugin command specs for native command registration (e.g., Telegram). - */ -export function getPluginCommandSpecs(provider?: string): Array<{ - name: string; - description: string; - acceptsArgs: boolean; -}> { - const providerName = provider?.trim().toLowerCase(); - if (providerName && providerName !== "telegram" && providerName !== "discord") { - return []; - } - return Array.from(pluginCommands.values()).map((cmd) => ({ - name: resolvePluginNativeName(cmd, provider), - description: cmd.description, - acceptsArgs: cmd.acceptsArgs ?? false, - })); -} - export const __testing = { resolveBindingConversationFromCommand, }; diff --git a/src/plugins/install-security-scan.runtime.ts b/src/plugins/install-security-scan.runtime.ts new file mode 100644 index 00000000000..13eaff45267 --- /dev/null +++ b/src/plugins/install-security-scan.runtime.ts @@ -0,0 +1,86 @@ +import path from "node:path"; +import { extensionUsesSkippedScannerPath, isPathInside } from "../security/scan-paths.js"; +import { scanDirectoryWithSummary } from "../security/skill-scanner.js"; + +type InstallScanLogger = { + warn?: (message: string) => void; +}; + +function buildCriticalDetails(params: { + findings: Array<{ file: string; line: number; message: string; severity: string }>; +}) { + return params.findings + .filter((finding) => finding.severity === "critical") + .map((finding) => `${finding.message} (${finding.file}:${finding.line})`) + .join("; "); +} + +export async function scanBundleInstallSourceRuntime(params: { + logger: InstallScanLogger; + pluginId: string; + sourceDir: string; +}) { + try { + const scanSummary = await scanDirectoryWithSummary(params.sourceDir); + if (scanSummary.critical > 0) { + params.logger.warn?.( + `WARNING: Bundle "${params.pluginId}" contains dangerous code patterns: ${buildCriticalDetails({ findings: scanSummary.findings })}`, + ); + return; + } + if (scanSummary.warn > 0) { + params.logger.warn?.( + `Bundle "${params.pluginId}" has ${scanSummary.warn} suspicious code pattern(s). Run "openclaw security audit --deep" for details.`, + ); + } + } catch (err) { + params.logger.warn?.( + `Bundle "${params.pluginId}" code safety scan failed (${String(err)}). Installation continues; run "openclaw security audit --deep" after install.`, + ); + } +} + +export async function scanPackageInstallSourceRuntime(params: { + extensions: string[]; + logger: InstallScanLogger; + packageDir: string; + pluginId: string; +}) { + const forcedScanEntries: string[] = []; + for (const entry of params.extensions) { + const resolvedEntry = path.resolve(params.packageDir, entry); + if (!isPathInside(params.packageDir, resolvedEntry)) { + params.logger.warn?.( + `extension entry escapes plugin directory and will not be scanned: ${entry}`, + ); + continue; + } + if (extensionUsesSkippedScannerPath(entry)) { + params.logger.warn?.( + `extension entry is in a hidden/node_modules path and will receive targeted scan coverage: ${entry}`, + ); + } + forcedScanEntries.push(resolvedEntry); + } + + try { + const scanSummary = await scanDirectoryWithSummary(params.packageDir, { + includeFiles: forcedScanEntries, + }); + if (scanSummary.critical > 0) { + params.logger.warn?.( + `WARNING: Plugin "${params.pluginId}" contains dangerous code patterns: ${buildCriticalDetails({ findings: scanSummary.findings })}`, + ); + return; + } + if (scanSummary.warn > 0) { + params.logger.warn?.( + `Plugin "${params.pluginId}" has ${scanSummary.warn} suspicious code pattern(s). Run "openclaw security audit --deep" for details.`, + ); + } + } catch (err) { + params.logger.warn?.( + `Plugin "${params.pluginId}" code safety scan failed (${String(err)}). Installation continues; run "openclaw security audit --deep" after install.`, + ); + } +} diff --git a/src/plugins/install-security-scan.ts b/src/plugins/install-security-scan.ts new file mode 100644 index 00000000000..da976a6fc0e --- /dev/null +++ b/src/plugins/install-security-scan.ts @@ -0,0 +1,26 @@ +type InstallScanLogger = { + warn?: (message: string) => void; +}; + +async function loadInstallSecurityScanRuntime() { + return await import("./install-security-scan.runtime.js"); +} + +export async function scanBundleInstallSource(params: { + logger: InstallScanLogger; + pluginId: string; + sourceDir: string; +}) { + const { scanBundleInstallSourceRuntime } = await loadInstallSecurityScanRuntime(); + await scanBundleInstallSourceRuntime(params); +} + +export async function scanPackageInstallSource(params: { + extensions: string[]; + logger: InstallScanLogger; + packageDir: string; + pluginId: string; +}) { + const { scanPackageInstallSourceRuntime } = await loadInstallSecurityScanRuntime(); + await scanPackageInstallSourceRuntime(params); +} diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 1ae80c40a1e..e0e8b270060 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -28,11 +28,11 @@ import { installFromNpmSpecArchiveWithInstaller, } from "../infra/npm-pack-install.js"; import { validateRegistryNpmSpec } from "../infra/npm-registry-spec.js"; -import { extensionUsesSkippedScannerPath, isPathInside } from "../security/scan-paths.js"; -import * as skillScanner from "../security/skill-scanner.js"; +import { isPathInside } from "../security/scan-paths.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; import { resolveRuntimeServiceVersion } from "../version.js"; import { detectBundleManifestFormat, loadBundleManifest } from "./bundle-manifest.js"; +import { scanBundleInstallSource, scanPackageInstallSource } from "./install-security-scan.js"; import { getPackageManifestMetadata, loadPluginManifest, @@ -385,20 +385,11 @@ async function installBundleFromSourceDir( } try { - const scanSummary = await skillScanner.scanDirectoryWithSummary(params.sourceDir); - if (scanSummary.critical > 0) { - const criticalDetails = scanSummary.findings - .filter((f) => f.severity === "critical") - .map((f) => `${f.message} (${f.file}:${f.line})`) - .join("; "); - logger.warn?.( - `WARNING: Bundle "${pluginId}" contains dangerous code patterns: ${criticalDetails}`, - ); - } else if (scanSummary.warn > 0) { - logger.warn?.( - `Bundle "${pluginId}" has ${scanSummary.warn} suspicious code pattern(s). Run "openclaw security audit --deep" for details.`, - ); - } + await scanBundleInstallSource({ + sourceDir: params.sourceDir, + pluginId, + logger, + }); } catch (err) { logger.warn?.( `Bundle "${pluginId}" code safety scan failed (${String(err)}). Installation continues; run "openclaw security audit --deep" after install.`, @@ -557,41 +548,13 @@ async function installPluginFromPackageDir( code: PLUGIN_INSTALL_ERROR_CODE.INCOMPATIBLE_HOST_VERSION, }; } - - const packageDir = path.resolve(params.packageDir); - const forcedScanEntries: string[] = []; - for (const entry of extensions) { - const resolvedEntry = path.resolve(packageDir, entry); - if (!isPathInside(packageDir, resolvedEntry)) { - logger.warn?.(`extension entry escapes plugin directory and will not be scanned: ${entry}`); - continue; - } - if (extensionUsesSkippedScannerPath(entry)) { - logger.warn?.( - `extension entry is in a hidden/node_modules path and will receive targeted scan coverage: ${entry}`, - ); - } - forcedScanEntries.push(resolvedEntry); - } - - // Scan plugin source for dangerous code patterns (warn-only; never blocks install) try { - const scanSummary = await skillScanner.scanDirectoryWithSummary(params.packageDir, { - includeFiles: forcedScanEntries, + await scanPackageInstallSource({ + packageDir: params.packageDir, + pluginId, + logger, + extensions, }); - if (scanSummary.critical > 0) { - const criticalDetails = scanSummary.findings - .filter((f) => f.severity === "critical") - .map((f) => `${f.message} (${f.file}:${f.line})`) - .join("; "); - logger.warn?.( - `WARNING: Plugin "${pluginId}" contains dangerous code patterns: ${criticalDetails}`, - ); - } else if (scanSummary.warn > 0) { - logger.warn?.( - `Plugin "${pluginId}" has ${scanSummary.warn} suspicious code pattern(s). Run "openclaw security audit --deep" for details.`, - ); - } } catch (err) { logger.warn?.( `Plugin "${pluginId}" code safety scan failed (${String(err)}). Installation continues; run "openclaw security audit --deep" after install.`, diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 30d3bab0e6c..a864969d6a1 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -15,7 +15,7 @@ import { } from "../memory/prompt-section.js"; import { resolveUserPath } from "../utils.js"; import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js"; -import { clearPluginCommands } from "./commands.js"; +import { clearPluginCommands } from "./command-registry-state.js"; import { applyTestPluginDefaults, normalizePluginsConfig,