fix: exclude workspace shadows from channel setup catalog lookups

This commit is contained in:
zsx
2026-04-09 22:46:39 +08:00
committed by Mason Huang
parent 65b781f9ae
commit 1fede43b94
8 changed files with 654 additions and 11 deletions

View File

@@ -194,6 +194,7 @@ Docs: https://docs.openclaw.ai
- Cron/isolated: resolve auth profiles without treating every isolated run as a brand-new auth session, so profile-based providers (for example OpenRouter) keep a stable credential choice instead of rotating or ignoring stored keys. (#62783) Thanks @neeravmakwana.
- CLI/tasks: `openclaw tasks cancel` now records operator cancellation for CLI runtime tasks instead of returning "Task runtime does not support cancellation yet", so stuck `running` CLI tasks can be cleared. (#62419) Thanks @neeravmakwana.
- Sessions/context: resolve context window limits using the active provider plus model (not bare model id alone) when persisting session usage, applying inline directives, and sizing memory-flush / preflight compaction thresholds, so duplicate model ids across providers no longer leak the wrong `contextTokens` into the session store or `/status`. (#62472) Thanks @neeravmakwana.
- Channels/setup: exclude workspace shadow entries from channel setup catalog lookups and align trust checks with auto-enable so workspace-scoped overrides no longer bypass the trusted catalog. (`GHSA-82qx-6vj7-p8m2`) Thanks @zsxsoft.
## 2026.4.5

View File

@@ -1,8 +1,5 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import {
listChannelPluginCatalogEntries,
type ChannelPluginCatalogEntry,
} from "../../channels/plugins/catalog.js";
import { type ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js";
import { isChannelVisibleInSetup } from "../../channels/plugins/exposure.js";
import type { ChannelMeta, ChannelPlugin } from "../../channels/plugins/types.js";
import { listChatChannels } from "../../channels/registry.js";
@@ -10,6 +7,10 @@ import type { OpenClawConfig } from "../../config/config.js";
import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js";
import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js";
import type { ChannelChoice } from "../onboard-types.js";
import {
listSetupDiscoveryChannelPluginCatalogEntries,
listTrustedChannelPluginCatalogEntries,
} from "./trusted-catalog.js";
type ChannelCatalogEntry = {
id: ChannelChoice;
@@ -75,14 +76,25 @@ export function resolveChannelSetupEntries(params: {
env: params.env,
});
const installedPluginIds = new Set(params.installedPlugins.map((plugin) => plugin.id));
const catalogEntries = listChannelPluginCatalogEntries({ workspaceDir });
const installedCatalogEntries = catalogEntries.filter(
// Discovery keeps workspace-only install candidates visible, while the
// installed bucket must still reflect what setup can safely auto-load.
const installedCatalogEntriesSource = listTrustedChannelPluginCatalogEntries({
cfg: params.cfg,
workspaceDir,
env: params.env,
});
const installableCatalogEntriesSource = listSetupDiscoveryChannelPluginCatalogEntries({
cfg: params.cfg,
workspaceDir,
env: params.env,
});
const installedCatalogEntries = installedCatalogEntriesSource.filter(
(entry) =>
!installedPluginIds.has(entry.id) &&
manifestInstalledIds.has(entry.id as ChannelChoice) &&
shouldShowChannelInSetup(entry.meta),
);
const installableCatalogEntries = catalogEntries.filter(
const installableCatalogEntries = installableCatalogEntriesSource.filter(
(entry) =>
!installedPluginIds.has(entry.id) &&
!manifestInstalledIds.has(entry.id as ChannelChoice) &&

View File

@@ -455,6 +455,9 @@ describe("ensureChannelSetupPluginInstalled", () => {
includeSetupOnlyChannelPlugins: true,
}),
);
expect(getChannelPluginCatalogEntry).toHaveBeenCalledWith("telegram", {
workspaceDir: "/tmp/openclaw-workspace",
});
});
it("keeps full reloads when the active plugin registry is already populated", () => {
@@ -547,6 +550,65 @@ describe("ensureChannelSetupPluginInstalled", () => {
activate: false,
}),
);
expect(getChannelPluginCatalogEntry).toHaveBeenCalledWith("telegram", {
workspaceDir: "/tmp/openclaw-workspace",
});
});
it("falls back to the bundled plugin for untrusted workspace shadows", () => {
const runtime = makeRuntime();
const cfg: OpenClawConfig = {};
getChannelPluginCatalogEntry
.mockReturnValueOnce({ pluginId: "evil-telegram-shadow", origin: "workspace" })
.mockReturnValueOnce({ pluginId: "@openclaw/telegram-plugin", origin: "bundled" });
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "telegram",
workspaceDir: "/tmp/openclaw-workspace",
});
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["@openclaw/telegram-plugin"],
}),
);
expect(getChannelPluginCatalogEntry).toHaveBeenNthCalledWith(1, "telegram", {
workspaceDir: "/tmp/openclaw-workspace",
});
expect(getChannelPluginCatalogEntry).toHaveBeenNthCalledWith(2, "telegram", {
workspaceDir: "/tmp/openclaw-workspace",
excludeWorkspace: true,
});
});
it("keeps trusted workspace overrides scoped during setup reloads", () => {
const runtime = makeRuntime();
const cfg: OpenClawConfig = {
plugins: {
enabled: true,
allow: ["trusted-telegram-shadow"],
},
};
getChannelPluginCatalogEntry.mockReturnValue({
pluginId: "trusted-telegram-shadow",
origin: "workspace",
});
loadChannelSetupPluginRegistrySnapshotForChannel({
cfg,
runtime,
channel: "telegram",
workspaceDir: "/tmp/openclaw-workspace",
});
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["trusted-telegram-shadow"],
}),
);
expect(getChannelPluginCatalogEntry).toHaveBeenCalledTimes(1);
});
it("does not scope by raw channel id when no trusted plugin mapping exists", () => {

View File

@@ -1,7 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { getChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js";
import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js";
import { resolveBundledInstallPlanForCatalogEntry } from "../../cli/plugin-install-plan.js";
import type { OpenClawConfig } from "../../config/config.js";
@@ -22,6 +21,7 @@ import type { PluginRegistry } from "../../plugins/registry.js";
import { getActivePluginChannelRegistry } from "../../plugins/runtime.js";
import type { RuntimeEnv } from "../../runtime.js";
import type { WizardPrompter } from "../../wizard/prompts.js";
import { getTrustedChannelPluginCatalogEntry } from "./trusted-catalog.js";
type InstallChoice = "npm" | "local" | "skip";
@@ -274,7 +274,8 @@ function resolveScopedChannelPluginId(params: {
return explicitPluginId;
}
return (
getChannelPluginCatalogEntry(params.channel, {
getTrustedChannelPluginCatalogEntry(params.channel, {
cfg: params.cfg,
workspaceDir: params.workspaceDir,
})?.pluginId ?? resolveUniqueManifestScopedChannelPluginId(params)
);

View File

@@ -0,0 +1,100 @@
import {
getChannelPluginCatalogEntry,
listChannelPluginCatalogEntries,
type ChannelPluginCatalogEntry,
} from "../../channels/plugins/catalog.js";
import type { OpenClawConfig } from "../../config/config.js";
import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js";
import { normalizePluginsConfig, resolveEnableState } from "../../plugins/config-state.js";
function resolveEffectiveTrustConfig(cfg: OpenClawConfig, env?: NodeJS.ProcessEnv): OpenClawConfig {
return applyPluginAutoEnable({
config: cfg,
env: env ?? process.env,
}).config;
}
function isTrustedWorkspaceChannelCatalogEntry(
entry: ChannelPluginCatalogEntry | undefined,
cfg: OpenClawConfig,
env?: NodeJS.ProcessEnv,
): boolean {
if (entry?.origin !== "workspace") {
return true;
}
if (!entry.pluginId) {
return false;
}
const effectiveConfig = resolveEffectiveTrustConfig(cfg, env);
return resolveEnableState(
entry.pluginId,
"workspace",
normalizePluginsConfig(effectiveConfig.plugins),
).enabled;
}
export function getTrustedChannelPluginCatalogEntry(
channelId: string,
params: {
cfg: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
},
): ChannelPluginCatalogEntry | undefined {
const candidate = getChannelPluginCatalogEntry(channelId, {
workspaceDir: params.workspaceDir,
});
if (isTrustedWorkspaceChannelCatalogEntry(candidate, params.cfg, params.env)) {
return candidate;
}
return getChannelPluginCatalogEntry(channelId, {
workspaceDir: params.workspaceDir,
excludeWorkspace: true,
});
}
export function listTrustedChannelPluginCatalogEntries(params: {
cfg: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): ChannelPluginCatalogEntry[] {
const unfiltered = listChannelPluginCatalogEntries({
workspaceDir: params.workspaceDir,
});
const fallbackById = new Map(
listChannelPluginCatalogEntries({
workspaceDir: params.workspaceDir,
excludeWorkspace: true,
}).map((entry) => [entry.id, entry]),
);
return unfiltered.flatMap((entry) => {
if (isTrustedWorkspaceChannelCatalogEntry(entry, params.cfg, params.env)) {
return [entry];
}
const fallback = fallbackById.get(entry.id);
return fallback ? [fallback] : [];
});
}
export function listSetupDiscoveryChannelPluginCatalogEntries(params: {
cfg: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): ChannelPluginCatalogEntry[] {
const unfiltered = listChannelPluginCatalogEntries({
workspaceDir: params.workspaceDir,
});
const fallbackById = new Map(
listChannelPluginCatalogEntries({
workspaceDir: params.workspaceDir,
excludeWorkspace: true,
}).map((entry) => [entry.id, entry]),
);
return unfiltered.flatMap((entry) => {
if (isTrustedWorkspaceChannelCatalogEntry(entry, params.cfg, params.env)) {
return [entry];
}
const fallback = fallbackById.get(entry.id);
return fallback ? [fallback] : [entry];
});
}

View File

@@ -0,0 +1,297 @@
/**
* Regression tests for GHSA-2qrv-rc5x-2g2h incomplete-fix bypass.
*
* The original fix added trusted fallback behavior to two call sites in
* channel-plugin-resolution.ts. Three other setup-flow call sites were
* missed. These tests verify setup discovery falls back from untrusted
* workspace shadows without hiding trusted workspace plugins.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
// ---------------------------------------------------------------------------
// Mocks (hoisted to module top level)
// ---------------------------------------------------------------------------
const listChannelPluginCatalogEntries = vi.hoisted(() => vi.fn((_opts?: unknown): unknown[] => []));
const listChatChannels = vi.hoisted(() => vi.fn((): unknown[] => []));
const loadPluginManifestRegistry = vi.hoisted(() => vi.fn());
const applyPluginAutoEnable = vi.hoisted(() =>
vi.fn(({ config }: { config: unknown }) => ({
config: config as never,
changes: [] as string[],
autoEnabledReasons: {},
})),
);
const getChannelPluginCatalogEntry = vi.hoisted(() => vi.fn());
vi.mock("../../channels/plugins/catalog.js", () => ({
listChannelPluginCatalogEntries: (opts?: unknown) => listChannelPluginCatalogEntries(opts),
getChannelPluginCatalogEntry: (...args: unknown[]) =>
getChannelPluginCatalogEntry(...(args as [string, Record<string, unknown>])),
}));
vi.mock("../../channels/registry.js", () => ({
listChatChannels: () => listChatChannels(),
}));
vi.mock("../../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry: (...a: unknown[]) => loadPluginManifestRegistry(...a),
}));
vi.mock("../../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: (a: unknown) => applyPluginAutoEnable(a as { config: unknown }),
}));
vi.mock("../../plugins/loader.js", () => ({
loadOpenClawPlugins: vi.fn(),
}));
import { resolveChannelSetupEntries } from "./discovery.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks();
loadPluginManifestRegistry.mockReturnValue({ plugins: [], diagnostics: [] });
listChatChannels.mockReturnValue([]);
});
// ---------------------------------------------------------------------------
// Regression: resolveChannelSetupEntries (discovery.ts)
// ---------------------------------------------------------------------------
describe("resolveChannelSetupEntries workspace shadow exclusion (GHSA-2qrv-rc5x-2g2h)", () => {
it("falls back to the bundled entry for untrusted workspace shadows", () => {
const workspaceEntry = {
id: "telegram",
pluginId: "evil-telegram-shadow",
origin: "workspace",
meta: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram",
docsPath: "/",
blurb: "t",
order: 1,
},
install: { npmSpec: "evil-telegram-shadow" },
};
const bundledEntry = {
id: "telegram",
pluginId: "@openclaw/telegram",
origin: "bundled",
meta: workspaceEntry.meta,
install: { npmSpec: "@openclaw/telegram" },
};
listChannelPluginCatalogEntries.mockImplementation((opts?: unknown) =>
(opts as { excludeWorkspace?: boolean } | undefined)?.excludeWorkspace
? [bundledEntry]
: [workspaceEntry],
);
resolveChannelSetupEntries({
cfg: {} as never,
env: process.env,
installedPlugins: [],
});
const fallbackCall = listChannelPluginCatalogEntries.mock.calls.find(
([opts]) => (opts as { excludeWorkspace?: boolean } | undefined)?.excludeWorkspace === true,
);
expect(fallbackCall).toBeTruthy();
});
it("still returns bundled-origin entries", () => {
const bundledEntry = {
id: "telegram",
pluginId: "@openclaw/telegram",
origin: "bundled",
meta: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram",
docsPath: "/",
blurb: "t",
order: 1,
},
install: { npmSpec: "@openclaw/telegram" },
};
listChannelPluginCatalogEntries.mockReturnValue([bundledEntry]);
const result = resolveChannelSetupEntries({
cfg: {} as never,
env: process.env,
installedPlugins: [],
});
const allIds = [
...result.installedCatalogEntries.map((e: { id: string }) => e.id),
...result.installableCatalogEntries.map((e: { id: string }) => e.id),
];
expect(allIds).toContain("telegram");
});
it("keeps trusted workspace channel plugins visible in setup", () => {
const workspaceEntry = {
id: "telegram",
pluginId: "trusted-telegram-shadow",
origin: "workspace",
meta: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram",
docsPath: "/",
blurb: "t",
order: 1,
},
install: { npmSpec: "trusted-telegram-shadow" },
};
listChannelPluginCatalogEntries.mockReturnValue([workspaceEntry]);
loadPluginManifestRegistry.mockReturnValue({
plugins: [{ id: "trusted-telegram-shadow", channels: ["telegram"] }],
diagnostics: [],
});
const result = resolveChannelSetupEntries({
cfg: {
plugins: {
enabled: true,
allow: ["trusted-telegram-shadow"],
},
} as never,
env: process.env,
installedPlugins: [],
});
expect(
result.installedCatalogEntries.map((entry: { pluginId?: string }) => entry.pluginId),
).toEqual(["trusted-telegram-shadow"]);
});
it("treats auto-enabled workspace channel plugins as trusted during setup discovery", () => {
const workspaceEntry = {
id: "telegram",
pluginId: "trusted-telegram-shadow",
origin: "workspace",
meta: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram",
docsPath: "/",
blurb: "t",
order: 1,
},
install: { npmSpec: "trusted-telegram-shadow" },
};
listChannelPluginCatalogEntries.mockReturnValue([workspaceEntry]);
applyPluginAutoEnable.mockImplementation(({ config }: { config: unknown }) => ({
config: {
...(config as Record<string, unknown>),
plugins: {
enabled: true,
allow: ["trusted-telegram-shadow"],
},
} as never,
changes: ["trusted-telegram-shadow"] as string[],
autoEnabledReasons: {
"trusted-telegram-shadow": ["channel configured"],
},
}));
loadPluginManifestRegistry.mockReturnValue({
plugins: [{ id: "trusted-telegram-shadow", channels: ["telegram"] }],
diagnostics: [],
});
const result = resolveChannelSetupEntries({
cfg: {
channels: {
telegram: { token: "existing-token" },
},
} as never,
env: process.env,
installedPlugins: [],
});
expect(
result.installedCatalogEntries.map((entry: { pluginId?: string }) => entry.pluginId),
).toEqual(["trusted-telegram-shadow"]);
});
it("keeps workspace-only install candidates visible until the user trusts them", () => {
const workspaceEntry = {
id: "my-cool-plugin",
pluginId: "my-cool-plugin",
origin: "workspace",
meta: {
id: "my-cool-plugin",
label: "My Cool Plugin",
selectionLabel: "My Cool Plugin",
docsPath: "/",
blurb: "t",
order: 1,
},
install: { npmSpec: "my-cool-plugin" },
};
listChannelPluginCatalogEntries.mockImplementation((opts?: unknown) =>
(opts as { excludeWorkspace?: boolean } | undefined)?.excludeWorkspace
? []
: [workspaceEntry],
);
const result = resolveChannelSetupEntries({
cfg: {} as never,
env: process.env,
installedPlugins: [],
});
expect(
result.installableCatalogEntries.map((entry: { pluginId?: string }) => entry.pluginId),
).toEqual(["my-cool-plugin"]);
});
it("does not surface untrusted workspace-only entries as installed", () => {
const workspaceEntry = {
id: "my-cool-plugin",
pluginId: "my-cool-plugin",
origin: "workspace",
meta: {
id: "my-cool-plugin",
label: "My Cool Plugin",
selectionLabel: "My Cool Plugin",
docsPath: "/",
blurb: "t",
order: 1,
},
install: { npmSpec: "my-cool-plugin" },
};
listChannelPluginCatalogEntries.mockImplementation((opts?: unknown) =>
(opts as { excludeWorkspace?: boolean } | undefined)?.excludeWorkspace
? []
: [workspaceEntry],
);
applyPluginAutoEnable.mockImplementation(({ config }: { config: unknown }) => ({
config: {
...(config as Record<string, unknown>),
plugins: {},
} as never,
changes: [] as string[],
autoEnabledReasons: {},
}));
loadPluginManifestRegistry.mockReturnValue({
plugins: [{ id: "my-cool-plugin", channels: ["my-cool-plugin"] }],
diagnostics: [],
});
const result = resolveChannelSetupEntries({
cfg: {
channels: {
"my-cool-plugin": { token: "existing-token" },
},
} as never,
env: process.env,
installedPlugins: [],
});
expect(result.installedCatalogEntries).toEqual([]);
expect(result.installableCatalogEntries).toEqual([]);
});
});

View File

@@ -0,0 +1,168 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const resolveAgentWorkspaceDir = vi.hoisted(() =>
vi.fn((_cfg?: unknown, _agentId?: unknown) => "/tmp/openclaw-workspace"),
);
const resolveDefaultAgentId = vi.hoisted(() => vi.fn((_cfg?: unknown) => "default"));
const listChannelPluginCatalogEntries = vi.hoisted(() => vi.fn((_opts?: unknown): unknown[] => []));
const getChannelPluginCatalogEntry = vi.hoisted(() =>
vi.fn((_id?: unknown, _opts?: unknown) => undefined),
);
const getChannelSetupPlugin = vi.hoisted(() => vi.fn((_channel?: unknown) => undefined));
const listChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => []));
const loadChannelSetupPluginRegistrySnapshotForChannel = vi.hoisted(() =>
vi.fn((_params?: unknown) => ({ channels: [], channelSetups: [] })),
);
const collectChannelStatus = vi.hoisted(() =>
vi.fn(async (_params?: unknown) => ({
installedPlugins: [],
catalogEntries: [],
installedCatalogEntries: [],
statusByChannel: new Map(),
statusLines: [],
})),
);
const isChannelConfigured = vi.hoisted(() => vi.fn((_cfg?: unknown, _channel?: unknown) => true));
vi.mock("../agents/agent-scope.js", () => ({
resolveAgentWorkspaceDir: (cfg?: unknown, agentId?: unknown) =>
resolveAgentWorkspaceDir(cfg, agentId),
resolveDefaultAgentId: (cfg?: unknown) => resolveDefaultAgentId(cfg),
}));
vi.mock("../channels/plugins/catalog.js", () => ({
listChannelPluginCatalogEntries: (opts?: unknown) => listChannelPluginCatalogEntries(opts),
getChannelPluginCatalogEntry: (id?: unknown, opts?: unknown) =>
getChannelPluginCatalogEntry(id, opts),
}));
vi.mock("../channels/plugins/setup-registry.js", () => ({
getChannelSetupPlugin: (channel?: unknown) => getChannelSetupPlugin(channel),
listChannelSetupPlugins: () => listChannelSetupPlugins(),
}));
vi.mock("../channels/registry.js", () => ({
listChatChannels: () => [],
}));
vi.mock("../commands/channel-setup/discovery.js", () => ({
resolveChannelSetupEntries: vi.fn(),
shouldShowChannelInSetup: () => true,
}));
vi.mock("../commands/channel-setup/plugin-install.js", () => ({
ensureChannelSetupPluginInstalled: vi.fn(),
loadChannelSetupPluginRegistrySnapshotForChannel: (params?: unknown) =>
loadChannelSetupPluginRegistrySnapshotForChannel(params),
}));
vi.mock("../commands/channel-setup/registry.js", () => ({
resolveChannelSetupWizardAdapterForPlugin: () => undefined,
}));
vi.mock("../config/channel-configured.js", () => ({
isChannelConfigured: (cfg?: unknown, channel?: unknown) => isChannelConfigured(cfg, channel),
}));
vi.mock("./channel-setup.prompts.js", () => ({
maybeConfigureDmPolicies: vi.fn(),
promptConfiguredAction: vi.fn(),
promptRemovalAccountId: vi.fn(),
formatAccountLabel: vi.fn(),
}));
vi.mock("./channel-setup.status.js", () => ({
collectChannelStatus: (params?: unknown) => collectChannelStatus(params),
noteChannelPrimer: vi.fn(),
noteChannelStatus: vi.fn(),
resolveChannelSelectionNoteLines: vi.fn(() => []),
resolveChannelSetupSelectionContributions: vi.fn(() => []),
resolveQuickstartDefault: vi.fn(() => undefined),
}));
import { setupChannels } from "./channel-setup.js";
describe("setupChannels workspace shadow exclusion", () => {
beforeEach(() => {
vi.clearAllMocks();
resolveAgentWorkspaceDir.mockReturnValue("/tmp/openclaw-workspace");
resolveDefaultAgentId.mockReturnValue("default");
listChannelPluginCatalogEntries.mockReturnValue([
{
id: "telegram",
pluginId: "@openclaw/telegram-plugin",
},
]);
getChannelSetupPlugin.mockReturnValue(undefined);
listChannelSetupPlugins.mockReturnValue([]);
loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue({
channels: [],
channelSetups: [],
});
collectChannelStatus.mockResolvedValue({
installedPlugins: [],
catalogEntries: [],
installedCatalogEntries: [],
statusByChannel: new Map(),
statusLines: [],
});
isChannelConfigured.mockReturnValue(true);
});
it("preloads configured external plugins from the bundled fallback for untrusted shadows", async () => {
listChannelPluginCatalogEntries.mockImplementation((opts?: unknown) =>
(opts as { excludeWorkspace?: boolean } | undefined)?.excludeWorkspace
? [{ id: "telegram", pluginId: "@openclaw/telegram-plugin", origin: "bundled" }]
: [{ id: "telegram", pluginId: "evil-telegram-shadow", origin: "workspace" }],
);
await setupChannels(
{} as never,
{} as never,
{
confirm: vi.fn(async () => false),
note: vi.fn(async () => undefined),
} as never,
);
const fallbackCall = listChannelPluginCatalogEntries.mock.calls.find(
([opts]) => (opts as { excludeWorkspace?: boolean } | undefined)?.excludeWorkspace === true,
);
expect(fallbackCall).toBeTruthy();
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
pluginId: "@openclaw/telegram-plugin",
workspaceDir: "/tmp/openclaw-workspace",
}),
);
});
it("keeps trusted workspace overrides eligible during preload", async () => {
listChannelPluginCatalogEntries.mockReturnValue([
{ id: "telegram", pluginId: "trusted-telegram-shadow", origin: "workspace" },
]);
await setupChannels(
{
plugins: {
enabled: true,
allow: ["trusted-telegram-shadow"],
},
} as never,
{} as never,
{
confirm: vi.fn(async () => false),
note: vi.fn(async () => undefined),
} as never,
);
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
expect.objectContaining({
channel: "telegram",
pluginId: "trusted-telegram-shadow",
workspaceDir: "/tmp/openclaw-workspace",
}),
);
});
});

View File

@@ -1,5 +1,4 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js";
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import {
getChannelSetupPlugin,
@@ -17,6 +16,7 @@ import {
loadChannelSetupPluginRegistrySnapshotForChannel,
} from "../commands/channel-setup/plugin-install.js";
import { resolveChannelSetupWizardAdapterForPlugin } from "../commands/channel-setup/registry.js";
import { listTrustedChannelPluginCatalogEntries } from "../commands/channel-setup/trusted-catalog.js";
import type {
ChannelSetupConfiguredResult,
ChannelSetupResult,
@@ -147,7 +147,9 @@ export async function setupChannels(
const preloadConfiguredExternalPlugins = () => {
// Keep setup memory bounded by snapshot-loading only configured external plugins.
const workspaceDir = resolveWorkspaceDir();
for (const entry of listChannelPluginCatalogEntries({ workspaceDir })) {
// Security: keep trusted workspace overrides eligible during setup while
// falling back from untrusted workspace shadows to the non-workspace entry.
for (const entry of listTrustedChannelPluginCatalogEntries({ cfg: next, workspaceDir })) {
const channel = entry.id as ChannelChoice;
if (getVisibleChannelPlugin(channel)) {
continue;