From e29ebc04173b85b88b69ca422f9da3e01b17b9a2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Apr 2026 04:22:08 +0100 Subject: [PATCH] perf(test): split allowlist and models command coverage --- .../reply/commands-allowlist.test.ts | 616 ++++++++++++++++++ src/auto-reply/reply/commands-models.test.ts | 225 +++++++ src/auto-reply/reply/commands.test-harness.ts | 2 +- src/auto-reply/reply/commands.test.ts | 525 --------------- 4 files changed, 842 insertions(+), 526 deletions(-) create mode 100644 src/auto-reply/reply/commands-allowlist.test.ts create mode 100644 src/auto-reply/reply/commands-models.test.ts diff --git a/src/auto-reply/reply/commands-allowlist.test.ts b/src/auto-reply/reply/commands-allowlist.test.ts new file mode 100644 index 00000000000..d2aa5a9fa98 --- /dev/null +++ b/src/auto-reply/reply/commands-allowlist.test.ts @@ -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; +}; + +function normalizeTelegramAllowFromEntries(values: Array): 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) ?? {}, + 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 | 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; + 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( + initialConfig: Record, + run: (configPath: string) => Promise, +): Promise { + 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(filePath: string): Promise { + 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(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).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).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(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`); + }); + } + }); +}); diff --git a/src/auto-reply/reply/commands-models.test.ts b/src/auto-reply/reply/commands-models.test.ts new file mode 100644 index 00000000000..024332f3972 --- /dev/null +++ b/src/auto-reply/reply/commands-models.test.ts @@ -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 "); + }); + + 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 ", + "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"); + }); +}); diff --git a/src/auto-reply/reply/commands.test-harness.ts b/src/auto-reply/reply/commands.test-harness.ts index 806e36895c8..60bc8f5657f 100644 --- a/src/auto-reply/reply/commands.test-harness.ts +++ b/src/auto-reply/reply/commands.test-harness.ts @@ -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( diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index f2113473684..683108cb3d0 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -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(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).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).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(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 "); - }); - - 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 ", - "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();