fix: constrain channel setup catalog resolution

This commit is contained in:
jesse-merhi
2026-04-29 13:09:20 +10:00
parent d1b4dbffc3
commit ef08f59b9f
3 changed files with 128 additions and 84 deletions

View File

@@ -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({

View File

@@ -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,

View File

@@ -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;
}