perf(test): split allowlist and models command coverage

This commit is contained in:
Peter Steinberger
2026-04-06 04:22:08 +01:00
parent 74b22440a6
commit e29ebc0417
4 changed files with 842 additions and 526 deletions

View File

@@ -0,0 +1,616 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelPlugin } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import { formatAllowFromLowercase } from "../../plugin-sdk/allow-from.js";
import {
buildDmGroupAccountAllowlistAdapter,
buildLegacyDmAccountAllowlistAdapter,
} from "../../plugin-sdk/allowlist-config-edit.js";
import { createScopedChannelConfigAdapter } from "../../plugin-sdk/channel-config-helpers.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import {
createChannelTestPluginBase,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { handleAllowlistCommand } from "./commands-allowlist.js";
import type { HandleCommandsParams } from "./commands-types.js";
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn());
const writeConfigFileMock = vi.hoisted(() => vi.fn());
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn());
const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
vi.mock("../../config/config.js", () => ({
readConfigFileSnapshot: readConfigFileSnapshotMock,
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
writeConfigFile: writeConfigFileMock,
}));
vi.mock("../../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: readChannelAllowFromStoreMock,
addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock,
removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock,
}));
type TelegramTestSectionConfig = {
allowFrom?: string[];
groupAllowFrom?: string[];
defaultAccount?: string;
configWrites?: boolean;
accounts?: Record<string, TelegramTestSectionConfig>;
};
function normalizeTelegramAllowFromEntries(values: Array<string | number>): string[] {
return formatAllowFromLowercase({ allowFrom: values, stripPrefixRe: /^(telegram|tg):/i });
}
function resolveTelegramTestAccount(
cfg: OpenClawConfig,
accountId?: string | null,
): TelegramTestSectionConfig {
const section = cfg.channels?.telegram as TelegramTestSectionConfig | undefined;
if (!accountId || accountId === DEFAULT_ACCOUNT_ID) {
return section ?? {};
}
return {
...section,
...section?.accounts?.[accountId],
};
}
const telegramAllowlistTestPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "telegram",
label: "Telegram",
docsPath: "/channels/telegram",
capabilities: {
chatTypes: ["direct", "group", "channel", "thread"],
nativeCommands: true,
},
}),
config: createScopedChannelConfigAdapter({
sectionKey: "telegram",
listAccountIds: (cfg) => {
const channel = cfg.channels?.telegram as TelegramTestSectionConfig | undefined;
return channel?.accounts ? Object.keys(channel.accounts) : [DEFAULT_ACCOUNT_ID];
},
resolveAccount: (cfg, accountId) => resolveTelegramTestAccount(cfg, accountId),
defaultAccountId: (cfg) =>
(cfg.channels?.telegram as TelegramTestSectionConfig | undefined)?.defaultAccount ??
DEFAULT_ACCOUNT_ID,
clearBaseFields: [],
resolveAllowFrom: (account) => account.allowFrom,
formatAllowFrom: normalizeTelegramAllowFromEntries,
}),
pairing: {
idLabel: "telegramUserId",
},
allowlist: buildDmGroupAccountAllowlistAdapter({
channelId: "telegram",
resolveAccount: ({ cfg, accountId }) => resolveTelegramTestAccount(cfg, accountId),
normalize: ({ values }) => normalizeTelegramAllowFromEntries(values),
resolveDmAllowFrom: (account) => account.allowFrom,
resolveGroupAllowFrom: (account) => account.groupAllowFrom,
resolveDmPolicy: () => undefined,
resolveGroupPolicy: () => undefined,
}),
};
const whatsappAllowlistTestPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "whatsapp",
label: "WhatsApp",
docsPath: "/channels/whatsapp",
capabilities: {
chatTypes: ["direct", "group"],
nativeCommands: true,
},
}),
pairing: {
idLabel: "phone",
},
allowlist: buildDmGroupAccountAllowlistAdapter({
channelId: "whatsapp",
resolveAccount: ({ cfg }) => (cfg.channels?.whatsapp as Record<string, unknown>) ?? {},
normalize: ({ values }) => values.map((value) => String(value).trim()).filter(Boolean),
resolveDmAllowFrom: (account) => account.allowFrom,
resolveGroupAllowFrom: (account) => account.groupAllowFrom,
resolveDmPolicy: () => undefined,
resolveGroupPolicy: () => undefined,
}),
};
function createLegacyAllowlistPlugin(channelId: "discord" | "slack"): ChannelPlugin {
return {
...createChannelTestPluginBase({
id: channelId,
label: channelId === "discord" ? "Discord" : "Slack",
docsPath: `/channels/${channelId}`,
capabilities: {
chatTypes: ["direct", "group", "thread"],
nativeCommands: true,
},
}),
pairing: {
idLabel: channelId === "discord" ? "discordUserId" : "slackUserId",
},
allowlist: buildLegacyDmAccountAllowlistAdapter({
channelId,
resolveAccount: ({ cfg }) =>
(cfg.channels?.[channelId] as Record<string, unknown> | undefined) ?? {},
normalize: ({ values }) => values.map((value) => String(value).trim()).filter(Boolean),
resolveDmAllowFrom: (account) => account.allowFrom ?? account.dm?.allowFrom,
resolveGroupPolicy: () => undefined,
resolveGroupOverrides: () => undefined,
}),
};
}
function setAllowlistPluginRegistry() {
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "telegram", plugin: telegramAllowlistTestPlugin, source: "test" },
{ pluginId: "whatsapp", plugin: whatsappAllowlistTestPlugin, source: "test" },
{ pluginId: "discord", plugin: createLegacyAllowlistPlugin("discord"), source: "test" },
{ pluginId: "slack", plugin: createLegacyAllowlistPlugin("slack"), source: "test" },
]),
);
}
beforeEach(() => {
vi.clearAllMocks();
setAllowlistPluginRegistry();
readConfigFileSnapshotMock.mockImplementation(async () => {
const configPath = process.env.OPENCLAW_CONFIG_PATH;
if (!configPath) {
return { valid: false, parsed: null };
}
const parsed = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record<string, unknown>;
return { valid: true, parsed };
});
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
ok: true,
config,
}));
writeConfigFileMock.mockImplementation(async (config: unknown) => {
const configPath = process.env.OPENCLAW_CONFIG_PATH;
if (configPath) {
await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
}
});
readChannelAllowFromStoreMock.mockResolvedValue([]);
addChannelAllowFromStoreEntryMock.mockResolvedValue({ changed: true, allowFrom: [] });
removeChannelAllowFromStoreEntryMock.mockResolvedValue({ changed: true, allowFrom: [] });
});
async function withTempConfigPath<T>(
initialConfig: Record<string, unknown>,
run: (configPath: string) => Promise<T>,
): Promise<T> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-allowlist-config-"));
const configPath = path.join(dir, "openclaw.json");
const previous = process.env.OPENCLAW_CONFIG_PATH;
process.env.OPENCLAW_CONFIG_PATH = configPath;
await fs.writeFile(configPath, JSON.stringify(initialConfig, null, 2), "utf-8");
try {
return await run(configPath);
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_CONFIG_PATH;
} else {
process.env.OPENCLAW_CONFIG_PATH = previous;
}
await fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
}
}
async function readJsonFile<T>(filePath: string): Promise<T> {
return JSON.parse(await fs.readFile(filePath, "utf-8")) as T;
}
function buildAllowlistParams(
commandBody: string,
cfg: OpenClawConfig,
ctxOverrides?: {
Provider?: string;
Surface?: string;
AccountId?: string;
SenderId?: string;
From?: string;
GatewayClientScopes?: string[];
},
): HandleCommandsParams {
const provider = ctxOverrides?.Provider ?? "telegram";
return {
cfg,
ctx: {
Provider: provider,
Surface: ctxOverrides?.Surface ?? provider,
CommandSource: "text",
AccountId: ctxOverrides?.AccountId,
GatewayClientScopes: ctxOverrides?.GatewayClientScopes,
SenderId: ctxOverrides?.SenderId,
From: ctxOverrides?.From,
},
command: {
commandBodyNormalized: commandBody,
isAuthorizedSender: true,
senderIsOwner: false,
senderId: ctxOverrides?.SenderId ?? "owner",
channel: provider,
channelId: provider,
},
} as unknown as HandleCommandsParams;
}
describe("handleAllowlistCommand", () => {
it("lists config and store allowFrom entries", async () => {
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
const cfg = {
commands: { text: true },
channels: { telegram: { allowFrom: ["123", "@Alice"] } },
} as OpenClawConfig;
const result = await handleAllowlistCommand(
buildAllowlistParams("/allowlist list dm", cfg),
true,
);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toContain("Channel: telegram");
expect(result?.reply?.text).toContain("DM allowFrom (config): 123, @alice");
expect(result?.reply?.text).toContain("Paired allowFrom (store): 456");
});
it("adds allowlist entries to config and pairing stores", async () => {
const cases = [
{
name: "default account",
run: async () => {
await withTempConfigPath(
{
channels: { telegram: { allowFrom: ["123"] } },
},
async (configPath) => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
valid: true,
parsed: {
channels: { telegram: { allowFrom: ["123"] } },
},
});
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
changed: true,
allowFrom: ["123", "789"],
});
const params = buildAllowlistParams("/allowlist add dm 789", {
commands: { text: true, config: true },
channels: { telegram: { allowFrom: ["123"] } },
} as OpenClawConfig);
params.command.senderIsOwner = true;
const result = await handleAllowlistCommand(params, true);
expect(result?.shouldContinue, "default account").toBe(false);
const written = await readJsonFile<OpenClawConfig>(configPath);
expect(written.channels?.telegram?.allowFrom, "default account").toEqual([
"123",
"789",
]);
expect(addChannelAllowFromStoreEntryMock, "default account").toHaveBeenCalledWith({
channel: "telegram",
entry: "789",
accountId: "default",
});
expect(result?.reply?.text, "default account").toContain("DM allowlist added");
},
);
},
},
{
name: "selected account scope",
run: async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
valid: true,
parsed: {
channels: { telegram: { accounts: { work: { allowFrom: ["123"] } } } },
},
});
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
changed: true,
allowFrom: ["123", "789"],
});
const params = buildAllowlistParams(
"/allowlist add dm --account work 789",
{
commands: { text: true, config: true },
channels: { telegram: { accounts: { work: { allowFrom: ["123"] } } } },
} as OpenClawConfig,
{ AccountId: "work" },
);
params.command.senderIsOwner = true;
const result = await handleAllowlistCommand(params, true);
expect(result?.shouldContinue, "selected account scope").toBe(false);
expect(addChannelAllowFromStoreEntryMock, "selected account scope").toHaveBeenCalledWith({
channel: "telegram",
entry: "789",
accountId: "work",
});
},
},
] as const;
for (const testCase of cases) {
await testCase.run();
}
});
it("uses the configured default account for omitted-account list", async () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
source: "test",
plugin: {
...telegramAllowlistTestPlugin,
config: {
...telegramAllowlistTestPlugin.config,
defaultAccountId: (cfg: OpenClawConfig) =>
(cfg.channels?.telegram as TelegramTestSectionConfig | undefined)?.defaultAccount ??
DEFAULT_ACCOUNT_ID,
},
},
},
]),
);
const cfg = {
commands: { text: true, config: true },
channels: {
telegram: {
defaultAccount: "work",
accounts: { work: { allowFrom: ["123"] } },
},
},
} as OpenClawConfig;
readChannelAllowFromStoreMock.mockResolvedValueOnce([]);
const result = await handleAllowlistCommand(
buildAllowlistParams("/allowlist list dm", cfg, {
Provider: "telegram",
Surface: "telegram",
}),
true,
);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toContain("Channel: telegram (account work)");
expect(result?.reply?.text).toContain("DM allowFrom (config): 123");
});
it("blocks config-targeted edits when the target account disables writes", async () => {
const previousWriteCount = writeConfigFileMock.mock.calls.length;
const cfg = {
commands: { text: true, config: true },
channels: {
telegram: {
configWrites: true,
accounts: {
work: { configWrites: false, allowFrom: ["123"] },
},
},
},
} as OpenClawConfig;
readConfigFileSnapshotMock.mockResolvedValueOnce({
valid: true,
parsed: structuredClone(cfg),
});
const params = buildAllowlistParams("/allowlist add dm --account work --config 789", cfg, {
AccountId: "default",
Provider: "telegram",
Surface: "telegram",
});
params.command.senderIsOwner = true;
const result = await handleAllowlistCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toContain("channels.telegram.accounts.work.configWrites=true");
expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount);
});
it("honors the configured default account when gating omitted-account config edits", async () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
source: "test",
plugin: {
...telegramAllowlistTestPlugin,
config: {
...telegramAllowlistTestPlugin.config,
defaultAccountId: (cfg: OpenClawConfig) =>
(cfg.channels?.telegram as TelegramTestSectionConfig | undefined)?.defaultAccount ??
DEFAULT_ACCOUNT_ID,
},
},
},
]),
);
const previousWriteCount = writeConfigFileMock.mock.calls.length;
const cfg = {
commands: { text: true, config: true },
channels: {
telegram: {
defaultAccount: "work",
configWrites: true,
accounts: {
work: { configWrites: false, allowFrom: ["123"] },
},
},
},
} as OpenClawConfig;
readConfigFileSnapshotMock.mockResolvedValueOnce({
valid: true,
parsed: structuredClone(cfg),
});
const params = buildAllowlistParams("/allowlist add dm --config 789", cfg, {
Provider: "telegram",
Surface: "telegram",
});
params.command.senderIsOwner = true;
const result = await handleAllowlistCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toContain("channels.telegram.accounts.work.configWrites=true");
expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount);
});
it("blocks allowlist writes from authorized non-owner senders", async () => {
const cfg = {
commands: {
text: true,
config: true,
allowFrom: { telegram: ["*"] },
ownerAllowFrom: ["discord:owner-discord-id"],
},
channels: {
telegram: { allowFrom: ["*"], configWrites: true },
discord: { allowFrom: ["owner-discord-id"], configWrites: true },
},
} as OpenClawConfig;
const params = buildAllowlistParams(
"/allowlist add dm --channel discord attacker-discord-id",
cfg,
{
Provider: "telegram",
Surface: "telegram",
SenderId: "telegram-attacker",
From: "telegram-attacker",
},
);
params.command.senderIsOwner = false;
const result = await handleAllowlistCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply).toBeUndefined();
expect(writeConfigFileMock).not.toHaveBeenCalled();
expect(addChannelAllowFromStoreEntryMock).not.toHaveBeenCalled();
});
it("removes default-account entries from scoped and legacy pairing stores", async () => {
removeChannelAllowFromStoreEntryMock
.mockResolvedValueOnce({
changed: true,
allowFrom: [],
})
.mockResolvedValueOnce({
changed: true,
allowFrom: [],
});
const cfg = {
commands: { text: true, config: true },
channels: { telegram: { allowFrom: ["123"] } },
} as OpenClawConfig;
const params = buildAllowlistParams("/allowlist remove dm --store 789", cfg);
params.command.senderIsOwner = true;
const result = await handleAllowlistCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(removeChannelAllowFromStoreEntryMock).toHaveBeenNthCalledWith(1, {
channel: "telegram",
entry: "789",
accountId: "default",
});
expect(removeChannelAllowFromStoreEntryMock).toHaveBeenNthCalledWith(2, {
channel: "telegram",
entry: "789",
});
});
it("rejects blocked account ids and keeps Object.prototype clean", async () => {
delete (Object.prototype as Record<string, unknown>).allowFrom;
const cfg = {
commands: { text: true, config: true },
channels: { telegram: { allowFrom: ["123"] } },
} as OpenClawConfig;
const params = buildAllowlistParams("/allowlist add dm --account __proto__ 789", cfg);
params.command.senderIsOwner = true;
const result = await handleAllowlistCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toContain("Invalid account id");
expect((Object.prototype as Record<string, unknown>).allowFrom).toBeUndefined();
expect(writeConfigFileMock).not.toHaveBeenCalled();
});
it("removes DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => {
const cases = [
{
provider: "slack",
removeId: "U111",
initialAllowFrom: ["U111", "U222"],
expectedAllowFrom: ["U222"],
},
{
provider: "discord",
removeId: "111",
initialAllowFrom: ["111", "222"],
expectedAllowFrom: ["222"],
},
] as const;
for (const testCase of cases) {
const initialConfig = {
channels: {
[testCase.provider]: {
allowFrom: testCase.initialAllowFrom,
dm: { allowFrom: testCase.initialAllowFrom },
configWrites: true,
},
},
};
await withTempConfigPath(initialConfig, async (configPath) => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
valid: true,
parsed: structuredClone(initialConfig),
});
const cfg = {
commands: { text: true, config: true },
channels: {
[testCase.provider]: {
allowFrom: testCase.initialAllowFrom,
dm: { allowFrom: testCase.initialAllowFrom },
configWrites: true,
},
},
} as OpenClawConfig;
const params = buildAllowlistParams(`/allowlist remove dm ${testCase.removeId}`, cfg, {
Provider: testCase.provider,
Surface: testCase.provider,
});
params.command.senderIsOwner = true;
const result = await handleAllowlistCommand(params, true);
expect(result?.shouldContinue).toBe(false);
const written = await readJsonFile<OpenClawConfig>(configPath);
const channelConfig = written.channels?.[testCase.provider];
expect(channelConfig?.allowFrom).toEqual(testCase.expectedAllowFrom);
expect(channelConfig?.dm?.allowFrom).toBeUndefined();
expect(result?.reply?.text).toContain(`channels.${testCase.provider}.allowFrom`);
});
}
});
});

View File

@@ -0,0 +1,225 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { buildTelegramModelsProviderChannelData } from "../../../test/helpers/channels/command-contract.js";
import type { ChannelPlugin } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import {
createChannelTestPluginBase,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { handleModelsCommand } from "./commands-models.js";
import type { HandleCommandsParams } from "./commands-types.js";
vi.mock("../../agents/model-catalog.js", () => ({
loadModelCatalog: vi.fn(async () => [
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" },
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet" },
{ provider: "openai", id: "gpt-4.1", name: "GPT-4.1" },
{ provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 Mini" },
{ provider: "google", id: "gemini-2.0-flash", name: "Gemini Flash" },
]),
}));
const telegramModelsTestPlugin: ChannelPlugin = {
...createChannelTestPluginBase({
id: "telegram",
label: "Telegram",
docsPath: "/channels/telegram",
capabilities: {
chatTypes: ["direct", "group", "channel", "thread"],
reactions: true,
threads: true,
media: true,
polls: true,
nativeCommands: true,
blockStreaming: true,
},
}),
commands: {
buildModelsProviderChannelData: buildTelegramModelsProviderChannelData,
},
};
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
plugin: telegramModelsTestPlugin,
source: "test",
},
]),
);
});
function buildModelsParams(
commandBody: string,
cfg: OpenClawConfig,
surface: string,
options?: {
authorized?: boolean;
agentId?: string;
sessionKey?: string;
},
): HandleCommandsParams {
const params = {
cfg,
ctx: {
Provider: surface,
Surface: surface,
CommandSource: "text",
},
command: {
commandBodyNormalized: commandBody,
isAuthorizedSender: true,
senderId: "owner",
},
sessionKey: "agent:main:main",
provider: "anthropic",
model: "claude-opus-4-5",
} as unknown as HandleCommandsParams;
if (options?.authorized === false) {
params.command.isAuthorizedSender = false;
params.command.senderId = "unauthorized";
}
if (options?.agentId) {
params.agentId = options.agentId;
}
if (options?.sessionKey) {
params.sessionKey = options.sessionKey;
}
return params;
}
describe("handleModelsCommand", () => {
const cfg = {
commands: { text: true },
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
} as OpenClawConfig;
it.each(["discord", "whatsapp"])("lists providers on %s text surfaces", async (surface) => {
const result = await handleModelsCommand(buildModelsParams("/models", cfg, surface), true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toContain("Providers:");
expect(result?.reply?.text).toContain("anthropic");
expect(result?.reply?.text).toContain("Use: /models <provider>");
});
it("rejects unauthorized /models commands", async () => {
const result = await handleModelsCommand(
buildModelsParams("/models", cfg, "discord", { authorized: false }),
true,
);
expect(result).toEqual({ shouldContinue: false });
});
it("lists providers on telegram with buttons", async () => {
const result = await handleModelsCommand(buildModelsParams("/models", cfg, "telegram"), true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toBe("Select a provider:");
const buttons = (result?.reply?.channelData as { telegram?: { buttons?: unknown[][] } })
?.telegram?.buttons;
expect(buttons).toBeDefined();
expect(buttons?.length).toBeGreaterThan(0);
});
it("handles provider pagination all mode and unknown providers", async () => {
const cases = [
{
name: "lists provider models with pagination hints",
command: "/models anthropic",
includes: [
"Models (anthropic",
"page 1/",
"anthropic/claude-opus-4-5",
"Switch: /model <provider/model>",
"All: /models anthropic all",
],
excludes: [],
},
{
name: "ignores page argument when all flag is present",
command: "/models anthropic 3 all",
includes: ["Models (anthropic", "page 1/1", "anthropic/claude-opus-4-5"],
excludes: ["Page out of range"],
},
{
name: "errors on out-of-range pages",
command: "/models anthropic 4",
includes: ["Page out of range", "valid: 1-"],
excludes: [],
},
{
name: "handles unknown providers",
command: "/models not-a-provider",
includes: ["Unknown provider", "Available providers"],
excludes: [],
},
] as const;
for (const testCase of cases) {
const result = await handleModelsCommand(
buildModelsParams(testCase.command, cfg, "discord"),
true,
);
expect(result?.shouldContinue, testCase.name).toBe(false);
for (const expected of testCase.includes) {
expect(result?.reply?.text, `${testCase.name}: ${expected}`).toContain(expected);
}
for (const blocked of testCase.excludes) {
expect(result?.reply?.text, `${testCase.name}: !${blocked}`).not.toContain(blocked);
}
}
});
it("lists configured models outside the curated catalog", async () => {
const customCfg = {
commands: { text: true },
agents: {
defaults: {
model: {
primary: "localai/ultra-chat",
fallbacks: ["anthropic/claude-opus-4-5"],
},
imageModel: "visionpro/studio-v1",
},
},
} as OpenClawConfig;
const providerList = await handleModelsCommand(
buildModelsParams("/models", customCfg, "discord"),
true,
);
expect(providerList?.reply?.text).toContain("localai");
expect(providerList?.reply?.text).toContain("visionpro");
const result = await handleModelsCommand(
buildModelsParams("/models localai", customCfg, "discord"),
true,
);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toContain("Models (localai");
expect(result?.reply?.text).toContain("localai/ultra-chat");
expect(result?.reply?.text).not.toContain("Unknown provider");
});
it("threads the routed agent through /models replies", async () => {
const scopedCfg = {
commands: { text: true },
agents: {
defaults: { model: { primary: "anthropic/claude-opus-4-5" } },
list: [{ id: "support", model: "localai/ultra-chat" }],
},
} as OpenClawConfig;
const result = await handleModelsCommand(
buildModelsParams("/models", scopedCfg, "discord", {
agentId: "support",
sessionKey: "agent:support:main",
}),
true,
);
expect(result?.reply?.text).toContain("localai");
});
});

View File

@@ -1,7 +1,7 @@
import type { OpenClawConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
import { buildCommandContext } from "./commands-context.js";
import type { HandleCommandsParams } from "./commands-types.js";
import { buildCommandContext } from "./commands.js";
import { parseInlineDirectives } from "./directive-handling.js";
export function buildCommandTestParams(

View File

@@ -2061,531 +2061,6 @@ function buildPolicyParams(
return params;
}
describe("handleCommands /allowlist", () => {
beforeEach(() => {
vi.clearAllMocks();
setMinimalChannelPluginRegistryForTests();
});
it("lists config + store allowFrom entries", async () => {
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
const cfg = {
commands: { text: true },
channels: { telegram: { allowFrom: ["123", "@Alice"] } },
} as OpenClawConfig;
const params = buildPolicyParams("/allowlist list dm", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Channel: telegram");
expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice");
expect(result.reply?.text).toContain("Paired allowFrom (store): 456");
});
it("adds allowlist entries to config and pairing stores", async () => {
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
ok: true,
config,
}));
const cases = [
{
name: "default account",
run: async () => {
await withTempConfigPath(
{
channels: { telegram: { allowFrom: ["123"] } },
},
async (configPath) => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
valid: true,
parsed: {
channels: { telegram: { allowFrom: ["123"] } },
},
});
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
changed: true,
allowFrom: ["123", "789"],
});
const params = buildPolicyParams("/allowlist add dm 789", {
commands: { text: true, config: true },
channels: { telegram: { allowFrom: ["123"] } },
} as OpenClawConfig);
params.command.senderIsOwner = true;
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
const written = await readJsonFile<OpenClawConfig>(configPath);
expect(written.channels?.telegram?.allowFrom, "default account").toEqual([
"123",
"789",
]);
expect(addChannelAllowFromStoreEntryMock, "default account").toHaveBeenCalledWith({
channel: "telegram",
entry: "789",
accountId: "default",
});
expect(result.reply?.text, "default account").toContain("DM allowlist added");
},
);
},
},
{
name: "selected account scope",
run: async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
valid: true,
parsed: {
channels: { telegram: { accounts: { work: { allowFrom: ["123"] } } } },
},
});
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
changed: true,
allowFrom: ["123", "789"],
});
const params = buildPolicyParams(
"/allowlist add dm --account work 789",
{
commands: { text: true, config: true },
channels: { telegram: { accounts: { work: { allowFrom: ["123"] } } } },
} as OpenClawConfig,
{
AccountId: "work",
},
);
params.command.senderIsOwner = true;
const result = await handleCommands(params);
expect(result.shouldContinue, "selected account scope").toBe(false);
expect(addChannelAllowFromStoreEntryMock, "selected account scope").toHaveBeenCalledWith({
channel: "telegram",
entry: "789",
accountId: "work",
});
},
},
] as const;
for (const testCase of cases) {
await testCase.run();
}
});
it("uses the configured default account for omitted-account /allowlist list", async () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
source: "test",
plugin: {
...telegramCommandTestPlugin,
config: {
...telegramCommandTestPlugin.config,
defaultAccountId: (cfg: OpenClawConfig) =>
(cfg.channels?.telegram as { defaultAccount?: string } | undefined)
?.defaultAccount ?? DEFAULT_ACCOUNT_ID,
},
},
},
]),
);
const cfg = {
commands: { text: true, config: true },
channels: {
telegram: {
defaultAccount: "work",
accounts: { work: { allowFrom: ["123"] } },
},
},
} as OpenClawConfig;
readChannelAllowFromStoreMock.mockResolvedValueOnce([]);
const params = buildPolicyParams("/allowlist list dm", cfg, {
Provider: "telegram",
Surface: "telegram",
AccountId: undefined,
});
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Channel: telegram (account work)");
expect(result.reply?.text).toContain("DM allowFrom (config): 123");
});
it("blocks config-targeted /allowlist edits when the target account disables writes", async () => {
const previousWriteCount = writeConfigFileMock.mock.calls.length;
const cfg = {
commands: { text: true, config: true },
channels: {
telegram: {
configWrites: true,
accounts: {
work: { configWrites: false, allowFrom: ["123"] },
},
},
},
} as OpenClawConfig;
readConfigFileSnapshotMock.mockResolvedValueOnce({
valid: true,
parsed: structuredClone(cfg),
});
const params = buildPolicyParams("/allowlist add dm --account work --config 789", cfg, {
AccountId: "default",
Provider: "telegram",
Surface: "telegram",
});
params.command.senderIsOwner = true;
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("channels.telegram.accounts.work.configWrites=true");
expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount);
});
it("honors the configured default account when gating omitted-account /allowlist config edits", async () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
source: "test",
plugin: {
...telegramCommandTestPlugin,
config: {
...telegramCommandTestPlugin.config,
defaultAccountId: (cfg: OpenClawConfig) =>
(cfg.channels?.telegram as { defaultAccount?: string } | undefined)
?.defaultAccount ?? DEFAULT_ACCOUNT_ID,
},
},
},
]),
);
const previousWriteCount = writeConfigFileMock.mock.calls.length;
const cfg = {
commands: { text: true, config: true },
channels: {
telegram: {
defaultAccount: "work",
configWrites: true,
accounts: {
work: { configWrites: false, allowFrom: ["123"] },
},
},
},
} as OpenClawConfig;
readConfigFileSnapshotMock.mockResolvedValueOnce({
valid: true,
parsed: structuredClone(cfg),
});
const params = buildPolicyParams("/allowlist add dm --config 789", cfg, {
Provider: "telegram",
Surface: "telegram",
AccountId: undefined,
});
params.command.senderIsOwner = true;
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("channels.telegram.accounts.work.configWrites=true");
expect(writeConfigFileMock.mock.calls.length).toBe(previousWriteCount);
});
it("blocks allowlist writes from authorized non-owner senders, including cross-channel targets", async () => {
const cfg = {
commands: {
text: true,
config: true,
allowFrom: { telegram: ["*"] },
ownerAllowFrom: ["discord:owner-discord-id"],
},
channels: {
telegram: { allowFrom: ["*"], configWrites: true },
discord: { allowFrom: ["owner-discord-id"], configWrites: true },
},
} as OpenClawConfig;
const params = buildPolicyParams(
"/allowlist add dm --channel discord attacker-discord-id",
cfg,
{
Provider: "telegram",
Surface: "telegram",
SenderId: "telegram-attacker",
From: "telegram-attacker",
},
);
expect(params.command.isAuthorizedSender).toBe(true);
expect(params.command.senderIsOwner).toBe(false);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply).toBeUndefined();
expect(writeConfigFileMock).not.toHaveBeenCalled();
expect(addChannelAllowFromStoreEntryMock).not.toHaveBeenCalled();
});
it("removes default-account entries from scoped and legacy pairing stores", async () => {
removeChannelAllowFromStoreEntryMock
.mockResolvedValueOnce({
changed: true,
allowFrom: [],
})
.mockResolvedValueOnce({
changed: true,
allowFrom: [],
});
const cfg = {
commands: { text: true, config: true },
channels: { telegram: { allowFrom: ["123"] } },
} as OpenClawConfig;
const params = buildPolicyParams("/allowlist remove dm --store 789", cfg);
params.command.senderIsOwner = true;
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(removeChannelAllowFromStoreEntryMock).toHaveBeenNthCalledWith(1, {
channel: "telegram",
entry: "789",
accountId: "default",
});
expect(removeChannelAllowFromStoreEntryMock).toHaveBeenNthCalledWith(2, {
channel: "telegram",
entry: "789",
});
});
it("rejects blocked account ids and keeps Object.prototype clean", async () => {
delete (Object.prototype as Record<string, unknown>).allowFrom;
const cfg = {
commands: { text: true, config: true },
channels: { telegram: { allowFrom: ["123"] } },
} as OpenClawConfig;
const params = buildPolicyParams("/allowlist add dm --account __proto__ 789", cfg);
params.command.senderIsOwner = true;
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Invalid account id");
expect((Object.prototype as Record<string, unknown>).allowFrom).toBeUndefined();
expect(writeConfigFileMock).not.toHaveBeenCalled();
});
it("removes DM allowlist entries from canonical allowFrom and deletes legacy dm.allowFrom", async () => {
const cases = [
{
provider: "slack",
removeId: "U111",
initialAllowFrom: ["U111", "U222"],
expectedAllowFrom: ["U222"],
},
{
provider: "discord",
removeId: "111",
initialAllowFrom: ["111", "222"],
expectedAllowFrom: ["222"],
},
] as const;
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
ok: true,
config,
}));
for (const testCase of cases) {
const initialConfig = {
channels: {
[testCase.provider]: {
allowFrom: testCase.initialAllowFrom,
dm: { allowFrom: testCase.initialAllowFrom },
configWrites: true,
},
},
};
await withTempConfigPath(initialConfig, async (configPath) => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
valid: true,
parsed: structuredClone(initialConfig),
});
const cfg = {
commands: { text: true, config: true },
channels: {
[testCase.provider]: {
allowFrom: testCase.initialAllowFrom,
dm: { allowFrom: testCase.initialAllowFrom },
configWrites: true,
},
},
} as OpenClawConfig;
const params = buildPolicyParams(`/allowlist remove dm ${testCase.removeId}`, cfg, {
Provider: testCase.provider,
Surface: testCase.provider,
});
params.command.senderIsOwner = true;
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
const written = await readJsonFile<OpenClawConfig>(configPath);
const channelConfig = written.channels?.[testCase.provider];
expect(channelConfig?.allowFrom).toEqual(testCase.expectedAllowFrom);
expect(channelConfig?.dm?.allowFrom).toBeUndefined();
expect(result.reply?.text).toContain(`channels.${testCase.provider}.allowFrom`);
});
}
});
});
describe("/models command", () => {
const cfg = {
commands: { text: true },
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
} as unknown as OpenClawConfig;
it.each(["discord", "whatsapp"])("lists providers on %s (text)", async (surface) => {
const params = buildPolicyParams("/models", cfg, { Provider: surface, Surface: surface });
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Providers:");
expect(result.reply?.text).toContain("anthropic");
expect(result.reply?.text).toContain("Use: /models <provider>");
});
it("rejects unauthorized /models commands", async () => {
const params = buildPolicyParams("/models", cfg, { Provider: "discord", Surface: "discord" });
const result = await handleCommands({
...params,
command: {
...params.command,
isAuthorizedSender: false,
senderId: "unauthorized",
},
});
expect(result).toEqual({ shouldContinue: false });
});
it("lists providers on telegram (buttons)", async () => {
const params = buildPolicyParams("/models", cfg, { Provider: "telegram", Surface: "telegram" });
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toBe("Select a provider:");
const buttons = (result.reply?.channelData as { telegram?: { buttons?: unknown[][] } })
?.telegram?.buttons;
expect(buttons).toBeDefined();
expect(buttons?.length).toBeGreaterThan(0);
});
it("handles provider model pagination, all mode, and unknown providers", async () => {
const cases = [
{
name: "lists provider models with pagination hints",
command: "/models anthropic",
includes: [
"Models (anthropic",
"page 1/",
"anthropic/claude-opus-4-5",
"Switch: /model <provider/model>",
"All: /models anthropic all",
],
excludes: [],
},
{
name: "ignores page argument when all flag is present",
command: "/models anthropic 3 all",
includes: ["Models (anthropic", "page 1/1", "anthropic/claude-opus-4-5"],
excludes: ["Page out of range"],
},
{
name: "errors on out-of-range pages",
command: "/models anthropic 4",
includes: ["Page out of range", "valid: 1-"],
excludes: [],
},
{
name: "handles unknown providers",
command: "/models not-a-provider",
includes: ["Unknown provider", "Available providers"],
excludes: [],
},
] as const;
for (const testCase of cases) {
// Use discord surface for deterministic text-based output assertions.
const result = await handleCommands(
buildPolicyParams(testCase.command, cfg, {
Provider: "discord",
Surface: "discord",
}),
);
expect(result.shouldContinue, testCase.name).toBe(false);
for (const expected of testCase.includes) {
expect(result.reply?.text, `${testCase.name}: ${expected}`).toContain(expected);
}
for (const blocked of testCase.excludes ?? []) {
expect(result.reply?.text, `${testCase.name}: !${blocked}`).not.toContain(blocked);
}
}
});
it("lists configured models outside the curated catalog", async () => {
const customCfg = {
commands: { text: true },
agents: {
defaults: {
model: {
primary: "localai/ultra-chat",
fallbacks: ["anthropic/claude-opus-4-5"],
},
imageModel: "visionpro/studio-v1",
},
},
} as unknown as OpenClawConfig;
// Use discord surface for text-based output tests
const providerList = await handleCommands(
buildPolicyParams("/models", customCfg, { Surface: "discord" }),
);
expect(providerList.reply?.text).toContain("localai");
expect(providerList.reply?.text).toContain("visionpro");
const result = await handleCommands(
buildPolicyParams("/models localai", customCfg, { Surface: "discord" }),
);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Models (localai");
expect(result.reply?.text).toContain("localai/ultra-chat");
expect(result.reply?.text).not.toContain("Unknown provider");
});
it("threads the routed agent through /models replies", async () => {
const scopedCfg = {
commands: { text: true },
agents: {
defaults: { model: { primary: "anthropic/claude-opus-4-5" } },
list: [{ id: "support", model: "localai/ultra-chat" }],
},
} as unknown as OpenClawConfig;
const params = buildPolicyParams("/models", scopedCfg, {
Provider: "discord",
Surface: "discord",
});
const result = await handleCommands({
...params,
agentId: "support",
sessionKey: "agent:support:main",
});
expect(result.reply?.text).toContain("localai");
});
});
describe("handleCommands plugin commands", () => {
it("dispatches registered plugin commands with gateway scopes and session metadata", async () => {
const { clearPluginCommands, registerPluginCommand } = await loadPluginCommands();