mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
fix: constrain channel setup catalog resolution
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user