mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-24 15:41:40 +00:00
fix: exclude workspace shadows from channel setup catalog lookups
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
100
src/commands/channel-setup/trusted-catalog.ts
Normal file
100
src/commands/channel-setup/trusted-catalog.ts
Normal 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];
|
||||
});
|
||||
}
|
||||
297
src/commands/channel-setup/workspace-shadow-bypass.test.ts
Normal file
297
src/commands/channel-setup/workspace-shadow-bypass.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
168
src/flows/channel-setup.test.ts
Normal file
168
src/flows/channel-setup.test.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user