diff --git a/src/commands/channel-setup/channel-plugin-resolution.ts b/src/commands/channel-setup/channel-plugin-resolution.ts index 6768ea48bcf..2ab4c6d734b 100644 --- a/src/commands/channel-setup/channel-plugin-resolution.ts +++ b/src/commands/channel-setup/channel-plugin-resolution.ts @@ -1,6 +1,5 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { - getChannelPluginCatalogEntry, listChannelPluginCatalogEntries, type ChannelPluginCatalogEntry, } from "../../channels/plugins/catalog.js"; @@ -8,7 +7,6 @@ import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/ind import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import type { ChannelId } from "../../channels/plugins/types.public.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { normalizePluginsConfig, resolveEnableState } from "../../plugins/config-state.js"; import type { RuntimeEnv } from "../../runtime.js"; import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; @@ -17,6 +15,10 @@ import { ensureChannelSetupPluginInstalled, loadChannelSetupPluginRegistrySnapshotForChannel, } from "./plugin-install.js"; +import { + getTrustedChannelPluginCatalogEntry, + listTrustedChannelPluginCatalogEntries, +} from "./trusted-catalog.js"; type ChannelPluginSnapshot = { channels: Array<{ plugin: ChannelPlugin }>; @@ -55,8 +57,13 @@ export function resolveCatalogChannelEntry(raw: string, cfg: OpenClawConfig | nu if (!trimmed) { return undefined; } - const workspaceDir = cfg ? resolveWorkspaceDir(cfg) : undefined; - return listChannelPluginCatalogEntries({ workspaceDir }).find((entry) => { + const entries = cfg + ? listTrustedChannelPluginCatalogEntries({ + cfg, + workspaceDir: resolveWorkspaceDir(cfg), + }) + : listChannelPluginCatalogEntries({ excludeWorkspace: true }); + return entries.find((entry) => { if (normalizeOptionalLowercaseString(entry.id) === trimmed) { return true; } @@ -76,74 +83,6 @@ function findScopedChannelPlugin( ); } -function isTrustedWorkspaceChannelCatalogEntry( - entry: ChannelPluginCatalogEntry | undefined, - cfg: OpenClawConfig, -): boolean { - if (entry?.origin !== "workspace") { - return true; - } - if (!entry.pluginId) { - return false; - } - const plugins = cfg.plugins; - if (plugins?.enabled === false) { - return false; - } - const pluginEntry = plugins?.entries?.[entry.pluginId]; - if (pluginEntry?.enabled === false) { - return false; - } - if (plugins?.deny?.length) { - return resolveEnableState(entry.pluginId, "workspace", normalizePluginsConfig(cfg.plugins)) - .enabled; - } - if (plugins?.allow?.includes(entry.pluginId)) { - return true; - } - if (pluginEntry?.enabled === true && !plugins?.allow?.length) { - return true; - } - return resolveEnableState(entry.pluginId, "workspace", normalizePluginsConfig(cfg.plugins)) - .enabled; -} - -function resolveTrustedCatalogEntry(params: { - rawChannel?: string | null; - channelId?: ChannelId; - cfg: OpenClawConfig; - workspaceDir?: string; - catalogEntry?: ChannelPluginCatalogEntry; -}): ChannelPluginCatalogEntry | undefined { - if (isTrustedWorkspaceChannelCatalogEntry(params.catalogEntry, params.cfg)) { - return params.catalogEntry; - } - if (params.rawChannel) { - const trimmed = normalizeOptionalLowercaseString(params.rawChannel); - if (!trimmed) { - return undefined; - } - return listChannelPluginCatalogEntries({ - workspaceDir: params.workspaceDir, - excludeWorkspace: true, - }).find((entry) => { - if (normalizeOptionalLowercaseString(entry.id) === trimmed) { - return true; - } - return (entry.meta.aliases ?? []).some( - (alias) => normalizeOptionalLowercaseString(alias) === trimmed, - ); - }); - } - if (!params.channelId) { - return undefined; - } - return getChannelPluginCatalogEntry(params.channelId, { - workspaceDir: params.workspaceDir, - excludeWorkspace: true, - }); -} - function loadScopedChannelPlugin(params: { cfg: OpenClawConfig; runtime: RuntimeEnv; @@ -173,20 +112,14 @@ export async function resolveInstallableChannelPlugin(params: { const supports = params.supports ?? (() => true); let nextCfg = params.cfg; const workspaceDir = resolveWorkspaceDir(nextCfg); - const unresolvedCatalogEntry = + const catalogEntry = (params.rawChannel ? resolveCatalogChannelEntry(params.rawChannel, nextCfg) : undefined) ?? (params.channelId - ? getChannelPluginCatalogEntry(params.channelId, { + ? getTrustedChannelPluginCatalogEntry(params.channelId, { + cfg: nextCfg, workspaceDir, }) : undefined); - const catalogEntry = resolveTrustedCatalogEntry({ - rawChannel: params.rawChannel, - channelId: params.channelId, - cfg: nextCfg, - workspaceDir, - catalogEntry: unresolvedCatalogEntry, - }); const channelId = params.channelId ?? resolveResolvedChannelId({ diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index 74775e9ef51..730bb422bf6 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -20,6 +20,7 @@ import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-hel let channelsAddCommand: typeof import("./channels/add.js").channelsAddCommand; const catalogMocks = vi.hoisted(() => ({ + getChannelPluginCatalogEntry: vi.fn(), listChannelPluginCatalogEntries: vi.fn((): ChannelPluginCatalogEntry[] => []), })); @@ -41,6 +42,7 @@ const pluginInstallRecordCommitMocks = vi.hoisted(() => ({ })); vi.mock("../channels/plugins/catalog.js", () => ({ + getChannelPluginCatalogEntry: catalogMocks.getChannelPluginCatalogEntry, listChannelPluginCatalogEntries: catalogMocks.listChannelPluginCatalogEntries, })); @@ -279,6 +281,8 @@ describe("channelsAddCommand", () => { runtime.log.mockClear(); runtime.error.mockClear(); runtime.exit.mockClear(); + catalogMocks.getChannelPluginCatalogEntry.mockClear(); + catalogMocks.getChannelPluginCatalogEntry.mockReturnValue(undefined); catalogMocks.listChannelPluginCatalogEntries.mockClear(); catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([]); discoveryMocks.isCatalogChannelInstalled.mockClear(); @@ -544,6 +548,103 @@ describe("channelsAddCommand", () => { expectExternalChatEnabledConfigWrite(); }); + it("falls back from untrusted workspace catalog shadows when adding by alias", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); + setActivePluginRegistry(createTestRegistry()); + const workspaceEntry: ChannelPluginCatalogEntry = { + ...createExternalChatCatalogEntry(), + pluginId: "evil-external-chat-shadow", + origin: "workspace", + meta: { + ...createExternalChatCatalogEntry().meta, + aliases: ["ext"], + }, + install: { + npmSpec: "evil-external-chat-shadow", + }, + }; + const trustedEntry: ChannelPluginCatalogEntry = { + ...createExternalChatCatalogEntry(), + origin: "bundled", + meta: { + ...createExternalChatCatalogEntry().meta, + aliases: ["ext"], + }, + }; + catalogMocks.listChannelPluginCatalogEntries.mockImplementation( + ({ excludeWorkspace }: { excludeWorkspace?: boolean } = {}) => + excludeWorkspace ? [trustedEntry] : [workspaceEntry], + ); + registerExternalChatSetupPlugin("@vendor/external-chat-plugin"); + + await channelsAddCommand( + { + channel: "ext", + account: "default", + token: "tenant-scoped", + }, + runtime, + { hasFlags: true }, + ); + + expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledWith( + expect.objectContaining({ entry: trustedEntry, promptInstall: false }), + ); + expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( + expect.objectContaining({ pluginId: "@vendor/external-chat-plugin" }), + ); + expectExternalChatEnabledConfigWrite(); + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.exit).not.toHaveBeenCalled(); + }); + + it("keeps explicitly trusted workspace catalog ownership when adding by alias", async () => { + const workspaceEntry: ChannelPluginCatalogEntry = { + ...createExternalChatCatalogEntry(), + pluginId: "trusted-external-chat-shadow", + origin: "workspace", + meta: { + ...createExternalChatCatalogEntry().meta, + aliases: ["ext"], + }, + install: { + npmSpec: "trusted-external-chat-shadow", + }, + }; + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: { + plugins: { + enabled: true, + allow: ["trusted-external-chat-shadow"], + }, + }, + }); + setActivePluginRegistry(createTestRegistry()); + catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([workspaceEntry]); + registerExternalChatSetupPlugin("trusted-external-chat-shadow"); + + await channelsAddCommand( + { + channel: "ext", + account: "default", + token: "tenant-scoped", + }, + runtime, + { hasFlags: true }, + ); + + expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledWith( + expect.objectContaining({ entry: workspaceEntry, promptInstall: false }), + ); + expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( + expect.objectContaining({ pluginId: "trusted-external-chat-shadow" }), + ); + expectExternalChatEnabledConfigWrite(); + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.exit).not.toHaveBeenCalled(); + }); + it("commits channel setup plugin install records with the guarded config write", async () => { configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 319e63b2c65..ff331c997ad 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -47,9 +47,19 @@ async function resolveCatalogChannelEntry(raw: string, cfg: OpenClawConfig | nul if (!trimmed) { return undefined; } - const { listChannelPluginCatalogEntries } = await import("../../channels/plugins/catalog.js"); - const workspaceDir = cfg ? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)) : undefined; - return listChannelPluginCatalogEntries({ workspaceDir }).find((entry) => { + const entries = cfg + ? await import("../channel-setup/trusted-catalog.js").then( + ({ listTrustedChannelPluginCatalogEntries }) => + listTrustedChannelPluginCatalogEntries({ + cfg, + workspaceDir: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)), + }), + ) + : await import("../../channels/plugins/catalog.js").then( + ({ listChannelPluginCatalogEntries }) => + listChannelPluginCatalogEntries({ excludeWorkspace: true }), + ); + return entries.find((entry) => { if (normalizeOptionalLowercaseString(entry.id) === trimmed) { return true; }