From b40e28f76e204e72a72f32dfb13885ab6a69d8f0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Apr 2026 04:58:21 +0100 Subject: [PATCH] perf(test): split reply command coverage --- src/auto-reply/reply/commands-approve.test.ts | 999 ++++++++++++++++++ src/auto-reply/reply/commands-parse.test.ts | 57 + src/auto-reply/reply/commands-plugin.test.ts | 75 ++ src/auto-reply/reply/commands-tts.test.ts | 19 +- src/auto-reply/reply/commands.test.ts | 929 ++-------------- 5 files changed, 1218 insertions(+), 861 deletions(-) create mode 100644 src/auto-reply/reply/commands-approve.test.ts create mode 100644 src/auto-reply/reply/commands-parse.test.ts create mode 100644 src/auto-reply/reply/commands-plugin.test.ts diff --git a/src/auto-reply/reply/commands-approve.test.ts b/src/auto-reply/reply/commands-approve.test.ts new file mode 100644 index 00000000000..3c31a3ec760 --- /dev/null +++ b/src/auto-reply/reply/commands-approve.test.ts @@ -0,0 +1,999 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChannelPlugin } from "../../channels/plugins/types.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { resolveApprovalApprovers } from "../../plugin-sdk/approval-approvers.js"; +import { + createApproverRestrictedNativeApprovalAdapter, + createResolvedApproverActionAuthAdapter, +} from "../../plugin-sdk/approval-runtime.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; +import { + createChannelTestPluginBase, + createTestRegistry, +} from "../../test-utils/channel-plugins.js"; +import { handleApproveCommand } from "./commands-approve.js"; +import type { HandleCommandsParams } from "./commands-types.js"; + +const callGatewayMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../gateway/call.js", () => ({ + callGateway: callGatewayMock, +})); + +vi.mock("../../globals.js", () => ({ + logVerbose: vi.fn(), +})); + +function normalizeDiscordDirectApproverId(value: string | number): string | undefined { + const normalized = String(value) + .trim() + .replace(/^(discord|user|pk):/i, "") + .replace(/^<@!?(\d+)>$/, "$1") + .toLowerCase(); + return normalized || undefined; +} + +function getDiscordExecApprovalApproversForTests(params: { cfg: OpenClawConfig }): string[] { + const discord = params.cfg.channels?.discord; + return resolveApprovalApprovers({ + explicit: discord?.execApprovals?.approvers, + allowFrom: discord?.allowFrom, + extraAllowFrom: discord?.dm?.allowFrom, + defaultTo: discord?.defaultTo, + normalizeApprover: normalizeDiscordDirectApproverId, + normalizeDefaultTo: (value) => normalizeDiscordDirectApproverId(value), + }); +} + +const discordNativeApprovalAdapterForTests = createApproverRestrictedNativeApprovalAdapter({ + channel: "discord", + channelLabel: "Discord", + listAccountIds: () => [DEFAULT_ACCOUNT_ID], + hasApprovers: ({ cfg }) => getDiscordExecApprovalApproversForTests({ cfg }).length > 0, + isExecAuthorizedSender: ({ cfg, senderId }) => { + const normalizedSenderId = + senderId === undefined || senderId === null + ? undefined + : normalizeDiscordDirectApproverId(senderId); + return Boolean( + normalizedSenderId && + getDiscordExecApprovalApproversForTests({ cfg }).includes(normalizedSenderId), + ); + }, + isNativeDeliveryEnabled: ({ cfg }) => + Boolean(cfg.channels?.discord?.execApprovals?.enabled) && + getDiscordExecApprovalApproversForTests({ cfg }).length > 0, + resolveNativeDeliveryMode: ({ cfg }) => cfg.channels?.discord?.execApprovals?.target ?? "dm", +}); + +const discordApproveTestPlugin: ChannelPlugin = { + ...createChannelTestPluginBase({ + id: "discord", + label: "Discord", + docsPath: "/channels/discord", + capabilities: { + chatTypes: ["direct", "group", "thread"], + reactions: true, + threads: true, + nativeCommands: true, + }, + }), + auth: discordNativeApprovalAdapterForTests.auth, +}; + +const slackApproveTestPlugin: ChannelPlugin = { + ...createChannelTestPluginBase({ + id: "slack", + label: "Slack", + docsPath: "/channels/slack", + capabilities: { + chatTypes: ["direct", "group", "thread"], + reactions: true, + threads: true, + nativeCommands: true, + }, + }), +}; + +const signalApproveTestPlugin: ChannelPlugin = { + ...createChannelTestPluginBase({ + id: "signal", + label: "Signal", + docsPath: "/channels/signal", + capabilities: { + chatTypes: ["direct", "group"], + reactions: true, + media: true, + nativeCommands: true, + }, + }), + auth: createResolvedApproverActionAuthAdapter({ + channelLabel: "Signal", + resolveApprovers: ({ cfg, accountId }) => { + const signal = accountId ? cfg.channels?.signal?.accounts?.[accountId] : cfg.channels?.signal; + return resolveApprovalApprovers({ + allowFrom: signal?.allowFrom, + defaultTo: signal?.defaultTo, + normalizeApprover: (value) => String(value).trim() || undefined, + }); + }, + }), +}; + +type TelegramTestAccountConfig = { + enabled?: boolean; + allowFrom?: Array; + execApprovals?: { + enabled?: boolean; + approvers?: string[]; + target?: "dm" | "channel" | "both"; + }; +}; + +type TelegramTestSectionConfig = TelegramTestAccountConfig & { + defaultAccount?: string; + accounts?: Record; +}; + +function listConfiguredTelegramAccountIds(cfg: OpenClawConfig): string[] { + const channel = cfg.channels?.telegram as TelegramTestSectionConfig | undefined; + const accountIds = Object.keys(channel?.accounts ?? {}); + if (accountIds.length > 0) { + return accountIds; + } + if (!channel) { + return []; + } + const { accounts: _accounts, defaultAccount: _defaultAccount, ...base } = channel; + return Object.values(base).some((value) => value !== undefined) ? [DEFAULT_ACCOUNT_ID] : []; +} + +function resolveTelegramTestAccount( + cfg: OpenClawConfig, + accountId?: string | null, +): TelegramTestAccountConfig { + const resolvedAccountId = normalizeAccountId(accountId); + const channel = cfg.channels?.telegram as TelegramTestSectionConfig | undefined; + const scoped = channel?.accounts?.[resolvedAccountId]; + const base = resolvedAccountId === DEFAULT_ACCOUNT_ID ? channel : undefined; + return { + ...base, + ...scoped, + enabled: + typeof scoped?.enabled === "boolean" + ? scoped.enabled + : typeof channel?.enabled === "boolean" + ? channel.enabled + : true, + }; +} + +function stripTelegramInternalPrefixes(value: string): string { + let trimmed = value.trim(); + let strippedTelegramPrefix = false; + while (true) { + const next = (() => { + if (/^(telegram|tg):/i.test(trimmed)) { + strippedTelegramPrefix = true; + return trimmed.replace(/^(telegram|tg):/i, "").trim(); + } + if (strippedTelegramPrefix && /^group:/i.test(trimmed)) { + return trimmed.replace(/^group:/i, "").trim(); + } + return trimmed; + })(); + if (next === trimmed) { + return trimmed; + } + trimmed = next; + } +} + +function normalizeTelegramDirectApproverId(value: string | number): string | undefined { + const normalized = stripTelegramInternalPrefixes(String(value)); + if (!normalized || normalized.startsWith("-")) { + return undefined; + } + return normalized; +} + +function getTelegramExecApprovalApprovers(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): string[] { + const account = resolveTelegramTestAccount(params.cfg, params.accountId); + return resolveApprovalApprovers({ + explicit: account.execApprovals?.approvers, + allowFrom: account.allowFrom, + normalizeApprover: normalizeTelegramDirectApproverId, + }); +} + +function isTelegramExecApprovalTargetRecipient(params: { + cfg: OpenClawConfig; + senderId?: string | null; + accountId?: string | null; +}): boolean { + const senderId = params.senderId?.trim(); + const execApprovals = params.cfg.approvals?.exec; + if ( + !senderId || + execApprovals?.enabled !== true || + (execApprovals.mode !== "targets" && execApprovals.mode !== "both") + ) { + return false; + } + const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined; + return (execApprovals.targets ?? []).some((target) => { + if (target.channel?.trim().toLowerCase() !== "telegram") { + return false; + } + if (accountId && target.accountId && normalizeAccountId(target.accountId) !== accountId) { + return false; + } + const to = target.to ? normalizeTelegramDirectApproverId(target.to) : undefined; + return Boolean(to && to === senderId); + }); +} + +function isTelegramExecApprovalAuthorizedSender(params: { + cfg: OpenClawConfig; + accountId?: string | null; + senderId?: string | null; +}): boolean { + const senderId = params.senderId ? normalizeTelegramDirectApproverId(params.senderId) : undefined; + if (!senderId) { + return false; + } + return ( + getTelegramExecApprovalApprovers(params).includes(senderId) || + isTelegramExecApprovalTargetRecipient(params) + ); +} + +function isTelegramExecApprovalClientEnabled(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): boolean { + const config = resolveTelegramTestAccount(params.cfg, params.accountId).execApprovals; + return Boolean(config?.enabled && getTelegramExecApprovalApprovers(params).length > 0); +} + +function resolveTelegramExecApprovalTarget(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): "dm" | "channel" | "both" { + return resolveTelegramTestAccount(params.cfg, params.accountId).execApprovals?.target ?? "dm"; +} + +const telegramNativeApprovalAdapter = createApproverRestrictedNativeApprovalAdapter({ + channel: "telegram", + channelLabel: "Telegram", + listAccountIds: listConfiguredTelegramAccountIds, + hasApprovers: ({ cfg, accountId }) => + getTelegramExecApprovalApprovers({ cfg, accountId }).length > 0, + isExecAuthorizedSender: isTelegramExecApprovalAuthorizedSender, + isPluginAuthorizedSender: ({ cfg, accountId, senderId }) => { + const normalizedSenderId = senderId?.trim(); + return Boolean( + normalizedSenderId && + getTelegramExecApprovalApprovers({ cfg, accountId }).includes(normalizedSenderId), + ); + }, + isNativeDeliveryEnabled: isTelegramExecApprovalClientEnabled, + resolveNativeDeliveryMode: resolveTelegramExecApprovalTarget, + requireMatchingTurnSourceChannel: true, +}); + +const telegramApproveTestPlugin: 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, + }, + config: { + listAccountIds: listConfiguredTelegramAccountIds, + resolveAccount: (cfg, accountId) => resolveTelegramTestAccount(cfg, accountId), + defaultAccountId: (cfg) => + (cfg.channels?.telegram as TelegramTestSectionConfig | undefined)?.defaultAccount ?? + DEFAULT_ACCOUNT_ID, + }, + }), + auth: telegramNativeApprovalAdapter.auth, + approvalCapability: { + resolveApproveCommandBehavior: ({ cfg, accountId, senderId, approvalKind }) => { + if (approvalKind !== "exec") { + return undefined; + } + if (isTelegramExecApprovalClientEnabled({ cfg, accountId })) { + return undefined; + } + if (isTelegramExecApprovalTargetRecipient({ cfg, accountId, senderId })) { + return undefined; + } + if ( + isTelegramExecApprovalAuthorizedSender({ cfg, accountId, senderId }) && + !getTelegramExecApprovalApprovers({ cfg, accountId }).includes(senderId?.trim() ?? "") + ) { + return undefined; + } + return { + kind: "reply", + text: "❌ Telegram exec approvals are not enabled for this bot account.", + } as const; + }, + }, +}; + +function setApprovePluginRegistry(): void { + setActivePluginRegistry( + createTestRegistry([ + { pluginId: "discord", plugin: discordApproveTestPlugin, source: "test" }, + { pluginId: "slack", plugin: slackApproveTestPlugin, source: "test" }, + { pluginId: "signal", plugin: signalApproveTestPlugin, source: "test" }, + { pluginId: "telegram", plugin: telegramApproveTestPlugin, source: "test" }, + ]), + ); +} + +function buildApproveParams( + commandBodyNormalized: string, + cfg: OpenClawConfig, + ctxOverrides?: { + Provider?: string; + Surface?: string; + SenderId?: string; + GatewayClientScopes?: string[]; + AccountId?: string; + }, +): HandleCommandsParams { + const provider = ctxOverrides?.Provider ?? "whatsapp"; + return { + cfg, + ctx: { + Provider: provider, + Surface: ctxOverrides?.Surface ?? provider, + CommandSource: "text", + SenderId: ctxOverrides?.SenderId, + GatewayClientScopes: ctxOverrides?.GatewayClientScopes, + AccountId: ctxOverrides?.AccountId, + }, + command: { + commandBodyNormalized, + isAuthorizedSender: true, + senderId: ctxOverrides?.SenderId ?? "owner", + channel: provider, + channelId: provider, + }, + } as unknown as HandleCommandsParams; +} + +describe("handleApproveCommand", () => { + beforeEach(() => { + vi.clearAllMocks(); + setApprovePluginRegistry(); + }); + + function createTelegramApproveCfg( + execApprovals: { + enabled: true; + approvers: string[]; + target: "dm"; + } | null = { enabled: true, approvers: ["123"], target: "dm" }, + ): OpenClawConfig { + return { + commands: { text: true }, + channels: { + telegram: { + allowFrom: ["*"], + ...(execApprovals ? { execApprovals } : {}), + }, + }, + } as OpenClawConfig; + } + + function createDiscordApproveCfg( + execApprovals: { + enabled: boolean; + approvers: string[]; + target: "dm" | "channel" | "both"; + } | null = { enabled: true, approvers: ["123"], target: "channel" }, + ): OpenClawConfig { + return { + commands: { text: true }, + channels: { + discord: { + allowFrom: ["*"], + ...(execApprovals ? { execApprovals } : {}), + }, + }, + } as OpenClawConfig; + } + + it("rejects invalid usage", async () => { + const result = await handleApproveCommand( + buildApproveParams("/approve", { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig), + true, + ); + expect(result?.shouldContinue).toBe(false); + expect(result?.reply?.text).toContain("Usage: /approve"); + }); + + it("submits approval", async () => { + callGatewayMock.mockResolvedValue({ ok: true }); + const result = await handleApproveCommand( + buildApproveParams( + "/approve abc allow-once", + { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig, + { SenderId: "123" }, + ), + true, + ); + + expect(result?.shouldContinue).toBe(false); + expect(result?.reply?.text).toContain("Approval allow-once submitted"); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "abc", decision: "allow-once" }, + }), + ); + }); + + it("accepts bare approve text for Slack-style manual approvals", async () => { + callGatewayMock.mockResolvedValue({ ok: true }); + const result = await handleApproveCommand( + buildApproveParams( + "approve abc allow-once", + { + commands: { text: true }, + channels: { slack: { allowFrom: ["*"] } }, + } as OpenClawConfig, + { + Provider: "slack", + Surface: "slack", + SenderId: "U123", + }, + ), + true, + ); + + expect(result?.shouldContinue).toBe(false); + expect(result?.reply?.text).toContain("Approval allow-once submitted"); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "abc", decision: "allow-once" }, + }), + ); + }); + + it("accepts Telegram /approve from configured approvers even when chat access is otherwise blocked", async () => { + const params = buildApproveParams("/approve abc12345 allow-once", createTelegramApproveCfg(), { + Provider: "telegram", + Surface: "telegram", + SenderId: "123", + }); + params.command.isAuthorizedSender = false; + callGatewayMock.mockResolvedValue({ ok: true }); + + const result = await handleApproveCommand(params, true); + expect(result?.shouldContinue).toBe(false); + expect(result?.reply?.text).toContain("Approval allow-once submitted"); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "abc12345", decision: "allow-once" }, + }), + ); + }); + + it("honors the configured default account for omitted-account /approve auth", async () => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "telegram", + plugin: telegramApproveTestPlugin, + source: "test", + }, + ]), + ); + callGatewayMock.mockResolvedValue({ ok: true }); + const params = buildApproveParams( + "/approve abc12345 allow-once", + { + commands: { text: true }, + channels: { + telegram: { + defaultAccount: "work", + allowFrom: ["*"], + accounts: { + work: { + execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, + }, + }, + }, + }, + } as OpenClawConfig, + { + Provider: "telegram", + Surface: "telegram", + SenderId: "123", + AccountId: undefined, + }, + ); + params.command.isAuthorizedSender = false; + + const result = await handleApproveCommand(params, true); + expect(result?.shouldContinue).toBe(false); + expect(result?.reply?.text).toContain("Approval allow-once submitted"); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "abc12345", decision: "allow-once" }, + }), + ); + }); + + it("accepts Signal /approve from configured approvers even when chat access is otherwise blocked", async () => { + const params = buildApproveParams( + "/approve abc12345 allow-once", + { + commands: { text: true }, + channels: { + signal: { + allowFrom: ["+15551230000"], + }, + }, + } as OpenClawConfig, + { + Provider: "signal", + Surface: "signal", + SenderId: "+15551230000", + }, + ); + params.command.isAuthorizedSender = false; + callGatewayMock.mockResolvedValue({ ok: true }); + + const result = await handleApproveCommand(params, true); + expect(result?.shouldContinue).toBe(false); + expect(result?.reply?.text).toContain("Approval allow-once submitted"); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "abc12345", decision: "allow-once" }, + }), + ); + }); + + it("does not treat implicit default approval auth as a bypass for unauthorized senders", async () => { + const params = buildApproveParams( + "/approve abc12345 allow-once", + { + commands: { text: true }, + } as OpenClawConfig, + { + Provider: "webchat", + Surface: "webchat", + SenderId: "123", + }, + ); + params.command.isAuthorizedSender = false; + + const result = await handleApproveCommand(params, true); + expect(result?.shouldContinue).toBe(false); + expect(result?.reply).toBeUndefined(); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("does not treat implicit same-chat approval auth as a bypass for unauthorized senders", async () => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "slack", + plugin: { + ...createChannelTestPluginBase({ id: "slack", label: "Slack" }), + auth: { + authorizeActorAction: () => ({ authorized: true }), + getActionAvailabilityState: () => ({ kind: "disabled" }), + }, + }, + source: "test", + }, + ]), + ); + const params = buildApproveParams( + "/approve abc12345 allow-once", + { + commands: { text: true }, + channels: { slack: { allowFrom: ["*"] } }, + } as OpenClawConfig, + { + Provider: "slack", + Surface: "slack", + SenderId: "U123", + }, + ); + params.command.isAuthorizedSender = false; + + const result = await handleApproveCommand(params, true); + expect(result?.shouldContinue).toBe(false); + expect(result?.reply).toBeUndefined(); + expect(callGatewayMock).not.toHaveBeenCalled(); + }); + + it("accepts Telegram /approve from exec target recipients when native approvals are disabled", async () => { + const params = buildApproveParams( + "/approve abc12345 allow-once", + { + commands: { text: true }, + approvals: { + exec: { + enabled: true, + mode: "targets", + targets: [{ channel: "telegram", to: "123" }], + }, + }, + channels: { + telegram: { + allowFrom: ["*"], + }, + }, + } as OpenClawConfig, + { + Provider: "telegram", + Surface: "telegram", + SenderId: "123", + }, + ); + params.command.isAuthorizedSender = false; + callGatewayMock.mockResolvedValue({ ok: true }); + + const result = await handleApproveCommand(params, true); + expect(result?.shouldContinue).toBe(false); + expect(result?.reply?.text).toContain("Approval allow-once submitted"); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "abc12345", decision: "allow-once" }, + }), + ); + }); + + it("requires configured Discord approvers for exec approvals", async () => { + for (const testCase of [ + { + name: "discord no approver policy", + cfg: createDiscordApproveCfg(null), + senderId: "123", + expectedText: "not authorized to approve", + expectedGatewayCalls: 0, + }, + { + name: "discord non approver", + cfg: createDiscordApproveCfg({ enabled: true, approvers: ["999"], target: "channel" }), + senderId: "123", + expectedText: "not authorized to approve", + expectedGatewayCalls: 0, + }, + { + name: "discord approver with rich client disabled", + cfg: createDiscordApproveCfg({ enabled: false, approvers: ["123"], target: "channel" }), + senderId: "123", + expectedText: "Approval allow-once submitted", + expectedGatewayCalls: 1, + expectedMethod: "exec.approval.resolve", + }, + { + name: "discord approver", + cfg: createDiscordApproveCfg({ enabled: true, approvers: ["123"], target: "channel" }), + senderId: "123", + expectedText: "Approval allow-once submitted", + expectedGatewayCalls: 1, + expectedMethod: "exec.approval.resolve", + }, + ] as const) { + callGatewayMock.mockReset(); + if (testCase.expectedGatewayCalls > 0) { + callGatewayMock.mockResolvedValue({ ok: true }); + } + const result = await handleApproveCommand( + buildApproveParams("/approve abc12345 allow-once", testCase.cfg, { + Provider: "discord", + Surface: "discord", + SenderId: testCase.senderId, + }), + true, + ); + expect(result?.shouldContinue, testCase.name).toBe(false); + expect(result?.reply?.text, testCase.name).toContain(testCase.expectedText); + expect(callGatewayMock, testCase.name).toHaveBeenCalledTimes(testCase.expectedGatewayCalls); + if ("expectedMethod" in testCase) { + expect(callGatewayMock, testCase.name).toHaveBeenCalledWith( + expect.objectContaining({ + method: testCase.expectedMethod, + params: { id: "abc12345", decision: "allow-once" }, + }), + ); + } + } + }); + + it("rejects legacy unprefixed plugin approval fallback on Discord before exec fallback", async () => { + for (const testCase of [ + { + name: "discord legacy plugin approval with exec approvals disabled", + cfg: createDiscordApproveCfg(null), + senderId: "123", + }, + { + name: "discord legacy plugin approval for non approver", + cfg: createDiscordApproveCfg({ enabled: true, approvers: ["999"], target: "channel" }), + senderId: "123", + }, + ] as const) { + callGatewayMock.mockReset(); + callGatewayMock.mockResolvedValue({ ok: true }); + const result = await handleApproveCommand( + buildApproveParams("/approve legacy-plugin-123 allow-once", testCase.cfg, { + Provider: "discord", + Surface: "discord", + SenderId: testCase.senderId, + }), + true, + ); + expect(result?.shouldContinue, testCase.name).toBe(false); + expect(result?.reply?.text, testCase.name).toContain("not authorized to approve"); + expect(callGatewayMock, testCase.name).not.toHaveBeenCalled(); + } + }); + + it("preserves legacy unprefixed plugin approval fallback on Discord", async () => { + callGatewayMock.mockRejectedValueOnce(new Error("unknown or expired approval id")); + callGatewayMock.mockResolvedValueOnce({ ok: true }); + const result = await handleApproveCommand( + buildApproveParams( + "/approve legacy-plugin-123 allow-once", + createDiscordApproveCfg({ enabled: true, approvers: ["123"], target: "channel" }), + { + Provider: "discord", + Surface: "discord", + SenderId: "123", + }, + ), + true, + ); + + expect(result?.shouldContinue).toBe(false); + expect(result?.reply?.text).toContain("Approval allow-once submitted"); + expect(callGatewayMock).toHaveBeenCalledTimes(2); + expect(callGatewayMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + method: "plugin.approval.resolve", + params: { id: "legacy-plugin-123", decision: "allow-once" }, + }), + ); + }); + + it("returns the underlying not-found error for plugin-only approval routing", async () => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "matrix", + plugin: { + ...createChannelTestPluginBase({ id: "matrix", label: "Matrix" }), + auth: { + authorizeActorAction: ({ approvalKind }: { approvalKind: "exec" | "plugin" }) => + approvalKind === "plugin" + ? { authorized: true } + : { + authorized: false, + reason: "❌ You are not authorized to approve exec requests on Matrix.", + }, + }, + }, + source: "test", + }, + ]), + ); + callGatewayMock.mockRejectedValueOnce(new Error("unknown or expired approval id")); + + const result = await handleApproveCommand( + buildApproveParams( + "/approve abc123 allow-once", + { + commands: { text: true }, + channels: { matrix: { allowFrom: ["*"] } }, + } as OpenClawConfig, + { + Provider: "matrix", + Surface: "matrix", + SenderId: "123", + }, + ), + true, + ); + + expect(result?.shouldContinue).toBe(false); + expect(result?.reply?.text).toContain("Failed to submit approval"); + expect(result?.reply?.text).toContain("unknown or expired approval id"); + expect(callGatewayMock).toHaveBeenCalledTimes(1); + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + method: "plugin.approval.resolve", + params: { id: "abc123", decision: "allow-once" }, + }), + ); + }); + + it("requires configured Discord approvers for plugin approvals", async () => { + for (const testCase of [ + { + name: "discord plugin non approver", + cfg: createDiscordApproveCfg({ enabled: false, approvers: ["999"], target: "channel" }), + senderId: "123", + expectedText: "not authorized to approve plugin requests", + expectedGatewayCalls: 0, + }, + { + name: "discord plugin approver", + cfg: createDiscordApproveCfg({ enabled: false, approvers: ["123"], target: "channel" }), + senderId: "123", + expectedText: "Approval allow-once submitted", + expectedGatewayCalls: 1, + }, + ] as const) { + callGatewayMock.mockReset(); + if (testCase.expectedGatewayCalls > 0) { + callGatewayMock.mockResolvedValue({ ok: true }); + } + const result = await handleApproveCommand( + buildApproveParams("/approve plugin:abc123 allow-once", testCase.cfg, { + Provider: "discord", + Surface: "discord", + SenderId: testCase.senderId, + }), + true, + ); + expect(result?.shouldContinue, testCase.name).toBe(false); + expect(result?.reply?.text, testCase.name).toContain(testCase.expectedText); + expect(callGatewayMock, testCase.name).toHaveBeenCalledTimes(testCase.expectedGatewayCalls); + if (testCase.expectedGatewayCalls > 0) { + expect(callGatewayMock, testCase.name).toHaveBeenCalledWith( + expect.objectContaining({ + method: "plugin.approval.resolve", + params: { id: "plugin:abc123", decision: "allow-once" }, + }), + ); + } + } + }); + + it("rejects unauthorized or invalid Telegram /approve variants", async () => { + for (const testCase of [ + { + name: "different bot mention", + cfg: createTelegramApproveCfg(), + commandBody: "/approve@otherbot abc12345 allow-once", + ctx: { + Provider: "telegram", + Surface: "telegram", + SenderId: "123", + }, + expectedText: "targets a different Telegram bot", + expectGatewayCalls: 0, + }, + { + name: "unknown approval id", + cfg: createTelegramApproveCfg(), + commandBody: "/approve abc12345 allow-once", + ctx: { + Provider: "telegram", + Surface: "telegram", + SenderId: "123", + }, + setup: () => callGatewayMock.mockRejectedValue(new Error("unknown or expired approval id")), + expectedText: "unknown or expired approval id", + expectGatewayCalls: 2, + }, + { + name: "telegram disabled native delivery reports the channel-disabled message", + cfg: createTelegramApproveCfg(null), + commandBody: "/approve abc12345 allow-once", + ctx: { + Provider: "telegram", + Surface: "telegram", + SenderId: "123", + }, + expectedText: "Telegram exec approvals are not enabled", + expectGatewayCalls: 0, + }, + { + name: "non approver", + cfg: createTelegramApproveCfg({ enabled: true, approvers: ["999"], target: "dm" }), + commandBody: "/approve abc12345 allow-once", + ctx: { + Provider: "telegram", + Surface: "telegram", + SenderId: "123", + }, + expectedText: "not authorized to approve", + expectGatewayCalls: 0, + }, + ] as const) { + callGatewayMock.mockReset(); + testCase.setup?.(); + const result = await handleApproveCommand( + buildApproveParams(testCase.commandBody, testCase.cfg, testCase.ctx), + true, + ); + expect(result?.shouldContinue, testCase.name).toBe(false); + expect(result?.reply?.text, testCase.name).toContain(testCase.expectedText); + expect(callGatewayMock, testCase.name).toHaveBeenCalledTimes(testCase.expectGatewayCalls); + } + }); + + it("enforces gateway approval scopes", async () => { + const cfg = { + commands: { text: true }, + } as OpenClawConfig; + for (const testCase of [ + { + scopes: ["operator.write"], + expectedText: "requires operator.approvals", + expectedGatewayCalls: 0, + }, + { + scopes: ["operator.approvals"], + expectedText: "Approval allow-once submitted", + expectedGatewayCalls: 1, + }, + { + scopes: ["operator.admin"], + expectedText: "Approval allow-once submitted", + expectedGatewayCalls: 1, + }, + ] as const) { + callGatewayMock.mockReset(); + callGatewayMock.mockResolvedValue({ ok: true }); + const result = await handleApproveCommand( + buildApproveParams("/approve abc allow-once", cfg, { + Provider: "webchat", + Surface: "webchat", + GatewayClientScopes: [...testCase.scopes], + }), + true, + ); + + expect(result?.shouldContinue, String(testCase.scopes)).toBe(false); + expect(result?.reply?.text, String(testCase.scopes)).toContain(testCase.expectedText); + expect(callGatewayMock, String(testCase.scopes)).toHaveBeenCalledTimes( + testCase.expectedGatewayCalls, + ); + if (testCase.expectedGatewayCalls > 0) { + expect(callGatewayMock, String(testCase.scopes)).toHaveBeenLastCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "abc", decision: "allow-once" }, + }), + ); + } + } + }); +}); diff --git a/src/auto-reply/reply/commands-parse.test.ts b/src/auto-reply/reply/commands-parse.test.ts new file mode 100644 index 00000000000..a7904c0045b --- /dev/null +++ b/src/auto-reply/reply/commands-parse.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { parseConfigCommand } from "./config-commands.js"; +import { parseDebugCommand } from "./debug-commands.js"; + +describe("config/debug command parsing", () => { + it("parses config/debug command actions and JSON payloads", () => { + const cases: Array<{ + parse: (input: string) => unknown; + input: string; + expected: unknown; + }> = [ + { parse: parseConfigCommand, input: "/config", expected: { action: "show" } }, + { + parse: parseConfigCommand, + input: "/config show", + expected: { action: "show", path: undefined }, + }, + { + parse: parseConfigCommand, + input: "/config show foo.bar", + expected: { action: "show", path: "foo.bar" }, + }, + { + parse: parseConfigCommand, + input: "/config get foo.bar", + expected: { action: "show", path: "foo.bar" }, + }, + { + parse: parseConfigCommand, + input: "/config unset foo.bar", + expected: { action: "unset", path: "foo.bar" }, + }, + { + parse: parseConfigCommand, + input: '/config set foo={"a":1}', + expected: { action: "set", path: "foo", value: { a: 1 } }, + }, + { parse: parseDebugCommand, input: "/debug", expected: { action: "show" } }, + { parse: parseDebugCommand, input: "/debug show", expected: { action: "show" } }, + { parse: parseDebugCommand, input: "/debug reset", expected: { action: "reset" } }, + { + parse: parseDebugCommand, + input: "/debug unset foo.bar", + expected: { action: "unset", path: "foo.bar" }, + }, + { + parse: parseDebugCommand, + input: '/debug set foo={"a":1}', + expected: { action: "set", path: "foo", value: { a: 1 } }, + }, + ]; + + for (const testCase of cases) { + expect(testCase.parse(testCase.input)).toEqual(testCase.expected); + } + }); +}); diff --git a/src/auto-reply/reply/commands-plugin.test.ts b/src/auto-reply/reply/commands-plugin.test.ts new file mode 100644 index 00000000000..4fba6858029 --- /dev/null +++ b/src/auto-reply/reply/commands-plugin.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { handlePluginCommand } from "./commands-plugin.js"; +import type { HandleCommandsParams } from "./commands-types.js"; + +const matchPluginCommandMock = vi.hoisted(() => vi.fn()); +const executePluginCommandMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../plugins/commands.js", () => ({ + matchPluginCommand: matchPluginCommandMock, + executePluginCommand: executePluginCommandMock, +})); + +function buildPluginParams( + commandBodyNormalized: string, + cfg: OpenClawConfig, +): HandleCommandsParams { + return { + cfg, + ctx: { + Provider: "whatsapp", + Surface: "whatsapp", + CommandSource: "text", + GatewayClientScopes: ["operator.write", "operator.pairing"], + AccountId: undefined, + }, + command: { + commandBodyNormalized, + isAuthorizedSender: true, + senderId: "owner", + channel: "whatsapp", + channelId: "whatsapp", + from: "test-user", + to: "test-bot", + }, + sessionKey: "agent:main:whatsapp:direct:test-user", + sessionEntry: { + sessionId: "session-plugin-command", + updatedAt: Date.now(), + }, + } as unknown as HandleCommandsParams; +} + +describe("handlePluginCommand", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("dispatches registered plugin commands with gateway scopes and session metadata", async () => { + matchPluginCommandMock.mockReturnValue({ + command: { name: "card" }, + args: "", + }); + executePluginCommandMock.mockResolvedValue({ text: "from plugin" }); + + const result = await handlePluginCommand( + buildPluginParams("/card", { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as OpenClawConfig), + true, + ); + + expect(result?.shouldContinue).toBe(false); + expect(result?.reply?.text).toBe("from plugin"); + expect(executePluginCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ + gatewayClientScopes: ["operator.write", "operator.pairing"], + sessionKey: "agent:main:whatsapp:direct:test-user", + sessionId: "session-plugin-command", + commandBody: "/card", + }), + ); + }); +}); diff --git a/src/auto-reply/reply/commands-tts.test.ts b/src/auto-reply/reply/commands-tts.test.ts index e89325cc637..b316846f8f7 100644 --- a/src/auto-reply/reply/commands-tts.test.ts +++ b/src/auto-reply/reply/commands-tts.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; const ttsMocks = vi.hoisted(() => ({ getResolvedSpeechProviderConfig: vi.fn(), @@ -34,9 +35,12 @@ const { handleTtsCommands } = await import("./commands-tts.js"); const PRIMARY_TTS_PROVIDER = "acme-speech"; const FALLBACK_TTS_PROVIDER = "backup-speech"; -function buildTtsParams(commandBodyNormalized: string): Parameters[0] { +function buildTtsParams( + commandBodyNormalized: string, + cfg: OpenClawConfig = {}, +): Parameters[0] { return { - cfg: {}, + cfg, command: { commandBodyNormalized, isAuthorizedSender: true, @@ -174,4 +178,15 @@ describe("handleTtsCommands status fallback reporting", () => { `Attempt details: ${PRIMARY_TTS_PROVIDER}:failed(provider_error) 65ms, ${FALLBACK_TTS_PROVIDER}:success(ok) 175ms`, ); }); + + it("treats bare /tts as status", async () => { + const result = await handleTtsCommands( + buildTtsParams("/tts", { + messages: { tts: { prefsPath: "/tmp/tts.json" } }, + } as OpenClawConfig), + true, + ); + expect(result?.shouldContinue).toBe(false); + expect(result?.reply?.text).toContain("TTS status"); + }); }); diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 683108cb3d0..c63012bb337 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -193,32 +193,6 @@ vi.mock("../../config/config.js", async () => { }; }); -const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn()); -const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn()); -const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn()); - -vi.mock("../../pairing/pairing-store.js", async () => { - const actual = await vi.importActual( - "../../pairing/pairing-store.js", - ); - return { - ...actual, - readChannelAllowFromStore: readChannelAllowFromStoreMock, - addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock, - removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock, - }; -}); - -vi.mock("../../channels/plugins/pairing.js", async () => { - const actual = await vi.importActual( - "../../channels/plugins/pairing.js", - ); - return { - ...actual, - listPairingChannels: () => ["telegram"], - }; -}); - vi.mock("../../agents/model-catalog.js", () => ({ loadModelCatalog: vi.fn(async () => [ { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" }, @@ -259,6 +233,28 @@ vi.mock("../../gateway/call.js", () => ({ callGateway: callGatewayMock, })); +vi.mock("../../channels/plugins/binding-targets.js", () => ({ + resetConfiguredBindingTargetInPlace: vi.fn().mockResolvedValue({ ok: false, skipped: true }), +})); + +vi.mock("../commands-registry.js", () => ({ + shouldHandleTextCommands: (params: { + cfg: { commands?: { text?: boolean } }; + commandSource?: string; + surface?: string; + }) => { + if (params.commandSource === "native") { + return true; + } + if (params.cfg.commands?.text !== false) { + return true; + } + return !["discord", "telegram", "slack", "signal"].includes( + String(params.surface ?? "").toLowerCase(), + ); + }, +})); + import type { HandleCommandsParams } from "./commands-types.js"; // Avoid expensive workspace scans during /context tests. @@ -275,13 +271,35 @@ vi.mock("./commands-context-report.js", () => ({ }, })); +vi.mock("./commands-handlers.runtime.js", async () => { + const lazyNamedHandler = (modulePath: string, exportName: TName) => { + return async (...args: Parameters) => { + const loaded = (await import(modulePath)) as Record< + TName, + import("./commands-types.js").CommandHandler + >; + return await loaded[exportName](...args); + }; + }; + return { + loadCommandHandlers: () => [ + lazyNamedHandler("./commands-bash.js", "handleBashCommand"), + lazyNamedHandler("./commands-session.js", "handleActivationCommand"), + lazyNamedHandler("./commands-approve.js", "handleApproveCommand"), + lazyNamedHandler("./commands-info.js", "handleContextCommand"), + lazyNamedHandler("./commands-info.js", "handleWhoamiCommand"), + lazyNamedHandler("./commands-plugins.js", "handlePluginsCommand"), + lazyNamedHandler("./commands-config.js", "handleConfigCommand"), + lazyNamedHandler("./commands-config.js", "handleDebugCommand"), + lazyNamedHandler("./commands-compact.js", "handleCompactCommand"), + lazyNamedHandler("./commands-session.js", "handleAbortTrigger"), + ], + }; +}); + const { abortEmbeddedPiRun, compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js"); -const { handleCompactCommand } = await import("./commands-compact.js"); -const { extractMessageText } = await import("./commands-subagents.js"); const { buildCommandTestParams } = await import("./commands.test-harness.js"); -const { parseConfigCommand } = await import("./config-commands.js"); -const { parseDebugCommand } = await import("./debug-commands.js"); const { parseInlineDirectives } = await import("./directive-handling.js"); const { buildCommandContext, handleCommands } = await import("./commands.js"); @@ -289,10 +307,6 @@ async function loadInternalHooks() { return await import("../../hooks/internal-hooks.js"); } -async function loadPluginCommands() { - return await import("../../plugins/commands.js"); -} - async function loadBashCommandTesting() { return await import("./bash-command.js"); } @@ -634,9 +648,6 @@ beforeEach(() => { } 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( @@ -804,103 +815,16 @@ describe("handleCommands gating", () => { }); describe("/approve command", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - function createTelegramApproveCfg( - execApprovals: { - enabled: true; - approvers: string[]; - target: "dm"; - } | null = { enabled: true, approvers: ["123"], target: "dm" }, - ): OpenClawConfig { - return { + it("accepts Telegram command mentions for /approve", async () => { + const cfg = { commands: { text: true }, channels: { telegram: { allowFrom: ["*"], - ...(execApprovals ? { execApprovals } : {}), + execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, }, }, } as OpenClawConfig; - } - - function createDiscordApproveCfg( - execApprovals: { - enabled: boolean; - approvers: string[]; - target: "dm" | "channel" | "both"; - } | null = { enabled: true, approvers: ["123"], target: "channel" }, - ): OpenClawConfig { - return { - commands: { text: true }, - channels: { - discord: { - allowFrom: ["*"], - ...(execApprovals ? { execApprovals } : {}), - }, - }, - } as OpenClawConfig; - } - - it("rejects invalid usage", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildParams("/approve", cfg); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Usage: /approve"); - }); - - it("submits approval", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildParams("/approve abc allow-once", cfg, { SenderId: "123" }); - - callGatewayMock.mockResolvedValue({ ok: true }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Approval allow-once submitted"); - expect(callGatewayMock).toHaveBeenCalledWith( - expect.objectContaining({ - method: "exec.approval.resolve", - params: { id: "abc", decision: "allow-once" }, - }), - ); - }); - - it("accepts bare approve text for Slack-style manual approvals", async () => { - const cfg = { - commands: { text: true }, - channels: { slack: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildParams("approve abc allow-once", cfg, { - Provider: "slack", - Surface: "slack", - SenderId: "U123", - }); - - callGatewayMock.mockResolvedValue({ ok: true }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Approval allow-once submitted"); - expect(callGatewayMock).toHaveBeenCalledWith( - expect.objectContaining({ - method: "exec.approval.resolve", - params: { id: "abc", decision: "allow-once" }, - }), - ); - }); - - it("accepts Telegram command mentions for /approve", async () => { - const cfg = createTelegramApproveCfg(); const params = buildParams("/approve@bot abc12345 allow-once", cfg, { BotUsername: "bot", Provider: "telegram", @@ -920,614 +844,38 @@ describe("/approve command", () => { }), ); }); - - it("accepts Telegram /approve from configured approvers even when chat access is otherwise blocked", async () => { - const cfg = createTelegramApproveCfg(); - const params = buildParams("/approve abc12345 allow-once", cfg, { - Provider: "telegram", - Surface: "telegram", - SenderId: "123", - }); - params.command.isAuthorizedSender = false; - - callGatewayMock.mockResolvedValue({ ok: true }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Approval allow-once submitted"); - expect(callGatewayMock).toHaveBeenCalledWith( - expect.objectContaining({ - method: "exec.approval.resolve", - params: { id: "abc12345", decision: "allow-once" }, - }), - ); - }); - - it("honors the configured default account for omitted-account /approve auth", async () => { - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "telegram", - plugin: { - ...telegramCommandTestPlugin, - config: { - ...telegramCommandTestPlugin.config, - defaultAccountId: (cfg: OpenClawConfig) => - (cfg.channels?.telegram as { defaultAccount?: string } | undefined) - ?.defaultAccount ?? DEFAULT_ACCOUNT_ID, - }, - }, - source: "test", - }, - ]), - ); - - const cfg = { - commands: { text: true }, - channels: { - telegram: { - defaultAccount: "work", - allowFrom: ["*"], - accounts: { - work: { - execApprovals: { enabled: true, approvers: ["123"], target: "dm" }, - }, - }, - }, - }, - } as OpenClawConfig; - const params = buildParams("/approve abc12345 allow-once", cfg, { - Provider: "telegram", - Surface: "telegram", - SenderId: "123", - AccountId: undefined, - }); - params.command.isAuthorizedSender = false; - - callGatewayMock.mockResolvedValue({ ok: true }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Approval allow-once submitted"); - expect(callGatewayMock).toHaveBeenCalledWith( - expect.objectContaining({ - method: "exec.approval.resolve", - params: { id: "abc12345", decision: "allow-once" }, - }), - ); - }); - - it("accepts Signal /approve from configured approvers even when chat access is otherwise blocked", async () => { - const cfg = { - commands: { text: true }, - channels: { - signal: { - allowFrom: ["+15551230000"], - }, - }, - } as OpenClawConfig; - const params = buildParams("/approve abc12345 allow-once", cfg, { - Provider: "signal", - Surface: "signal", - SenderId: "+15551230000", - }); - params.command.isAuthorizedSender = false; - - callGatewayMock.mockResolvedValue({ ok: true }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Approval allow-once submitted"); - expect(callGatewayMock).toHaveBeenCalledWith( - expect.objectContaining({ - method: "exec.approval.resolve", - params: { id: "abc12345", decision: "allow-once" }, - }), - ); - }); - - it("does not treat implicit default approval auth as a bypass for unauthorized senders", async () => { - const cfg = { - commands: { text: true }, - } as OpenClawConfig; - const params = buildParams("/approve abc12345 allow-once", cfg, { - Provider: "webchat", - Surface: "webchat", - SenderId: "123", - }); - params.command.isAuthorizedSender = false; - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply).toBeUndefined(); - expect(callGatewayMock).not.toHaveBeenCalled(); - }); - - it("does not treat implicit same-chat approval auth as a bypass for unauthorized senders", async () => { - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "slack", - plugin: { - ...createChannelTestPluginBase({ id: "slack", label: "Slack" }), - auth: { - authorizeActorAction: () => ({ authorized: true }), - getActionAvailabilityState: () => ({ kind: "disabled" }), - }, - }, - source: "test", - }, - ]), - ); - const params = buildParams( - "/approve abc12345 allow-once", - { - commands: { text: true }, - channels: { slack: { allowFrom: ["*"] } }, - } as OpenClawConfig, - { - Provider: "slack", - Surface: "slack", - SenderId: "U123", - }, - ); - params.command.isAuthorizedSender = false; - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply).toBeUndefined(); - expect(callGatewayMock).not.toHaveBeenCalled(); - }); - - it("accepts Telegram /approve from exec target recipients when native approvals are disabled", async () => { - const cfg = { - commands: { text: true }, - approvals: { - exec: { - enabled: true, - mode: "targets", - targets: [{ channel: "telegram", to: "123" }], - }, - }, - channels: { - telegram: { - allowFrom: ["*"], - }, - }, - } as OpenClawConfig; - const params = buildParams("/approve abc12345 allow-once", cfg, { - Provider: "telegram", - Surface: "telegram", - SenderId: "123", - }); - params.command.isAuthorizedSender = false; - - callGatewayMock.mockResolvedValue({ ok: true }); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Approval allow-once submitted"); - expect(callGatewayMock).toHaveBeenCalledWith( - expect.objectContaining({ - method: "exec.approval.resolve", - params: { id: "abc12345", decision: "allow-once" }, - }), - ); - }); - - it("requires configured Discord approvers for exec approvals", async () => { - for (const testCase of [ - { - name: "discord no approver policy", - cfg: createDiscordApproveCfg(null), - senderId: "123", - expectedText: "not authorized to approve", - setup: undefined, - expectedGatewayCalls: 0, - }, - { - name: "discord non approver", - cfg: createDiscordApproveCfg({ enabled: true, approvers: ["999"], target: "channel" }), - senderId: "123", - expectedText: "not authorized to approve", - setup: undefined, - expectedGatewayCalls: 0, - }, - { - name: "discord approver with rich client disabled", - cfg: createDiscordApproveCfg({ enabled: false, approvers: ["123"], target: "channel" }), - senderId: "123", - expectedText: "Approval allow-once submitted", - setup: () => callGatewayMock.mockResolvedValue({ ok: true }), - expectedGatewayCalls: 1, - expectedMethod: "exec.approval.resolve", - }, - { - name: "discord approver", - cfg: createDiscordApproveCfg({ enabled: true, approvers: ["123"], target: "channel" }), - senderId: "123", - expectedText: "Approval allow-once submitted", - setup: () => callGatewayMock.mockResolvedValue({ ok: true }), - expectedGatewayCalls: 1, - expectedMethod: "exec.approval.resolve", - }, - ] as const) { - callGatewayMock.mockReset(); - testCase.setup?.(); - const params = buildParams("/approve abc12345 allow-once", testCase.cfg, { - Provider: "discord", - Surface: "discord", - SenderId: testCase.senderId, - }); - - const result = await handleCommands(params); - expect(result.shouldContinue, testCase.name).toBe(false); - expect(result.reply?.text, testCase.name).toContain(testCase.expectedText); - expect(callGatewayMock, testCase.name).toHaveBeenCalledTimes(testCase.expectedGatewayCalls); - if ("expectedMethod" in testCase) { - expect(callGatewayMock, testCase.name).toHaveBeenCalledWith( - expect.objectContaining({ - method: testCase.expectedMethod, - params: { id: "abc12345", decision: "allow-once" }, - }), - ); - } - } - }); - - it("rejects legacy unprefixed plugin approval fallback on Discord before exec fallback", async () => { - for (const testCase of [ - { - name: "discord legacy plugin approval with exec approvals disabled", - cfg: createDiscordApproveCfg(null), - senderId: "123", - }, - { - name: "discord legacy plugin approval for non approver", - cfg: createDiscordApproveCfg({ enabled: true, approvers: ["999"], target: "channel" }), - senderId: "123", - }, - ] as const) { - callGatewayMock.mockReset(); - callGatewayMock.mockResolvedValue({ ok: true }); - const params = buildParams("/approve legacy-plugin-123 allow-once", testCase.cfg, { - Provider: "discord", - Surface: "discord", - SenderId: testCase.senderId, - }); - - const result = await handleCommands(params); - expect(result.shouldContinue, testCase.name).toBe(false); - expect(result.reply?.text, testCase.name).toContain("not authorized to approve"); - expect(callGatewayMock, testCase.name).not.toHaveBeenCalled(); - } - }); - - it("preserves legacy unprefixed plugin approval fallback on Discord", async () => { - callGatewayMock.mockRejectedValueOnce(new Error("unknown or expired approval id")); - callGatewayMock.mockResolvedValueOnce({ ok: true }); - const params = buildParams( - "/approve legacy-plugin-123 allow-once", - createDiscordApproveCfg({ enabled: true, approvers: ["123"], target: "channel" }), - { - Provider: "discord", - Surface: "discord", - SenderId: "123", - }, - ); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Approval allow-once submitted"); - expect(callGatewayMock).toHaveBeenCalledTimes(2); - expect(callGatewayMock).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - method: "plugin.approval.resolve", - params: { id: "legacy-plugin-123", decision: "allow-once" }, - }), - ); - }); - - it("returns the underlying not-found error for plugin-only approval routing", async () => { - setActivePluginRegistry( - createTestRegistry([ - { - pluginId: "matrix", - plugin: { - ...createChannelTestPluginBase({ id: "matrix", label: "Matrix" }), - auth: { - authorizeActorAction: ({ approvalKind }: { approvalKind: "exec" | "plugin" }) => - approvalKind === "plugin" - ? { authorized: true } - : { - authorized: false, - reason: "❌ You are not authorized to approve exec requests on Matrix.", - }, - }, - }, - source: "test", - }, - ]), - ); - callGatewayMock.mockRejectedValueOnce(new Error("unknown or expired approval id")); - const params = buildParams( - "/approve abc123 allow-once", - { - commands: { text: true }, - channels: { matrix: { allowFrom: ["*"] } }, - } as OpenClawConfig, - { - Provider: "matrix", - Surface: "matrix", - SenderId: "123", - }, - ); - - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("Failed to submit approval"); - expect(result.reply?.text).toContain("unknown or expired approval id"); - expect(callGatewayMock).toHaveBeenCalledTimes(1); - expect(callGatewayMock).toHaveBeenCalledWith( - expect.objectContaining({ - method: "plugin.approval.resolve", - params: { id: "abc123", decision: "allow-once" }, - }), - ); - }); - - it("requires configured Discord approvers for plugin approvals", async () => { - for (const testCase of [ - { - name: "discord plugin non approver", - cfg: createDiscordApproveCfg({ enabled: false, approvers: ["999"], target: "channel" }), - senderId: "123", - expectedText: "not authorized to approve plugin requests", - expectedGatewayCalls: 0, - }, - { - name: "discord plugin approver", - cfg: createDiscordApproveCfg({ enabled: false, approvers: ["123"], target: "channel" }), - senderId: "123", - expectedText: "Approval allow-once submitted", - expectedGatewayCalls: 1, - }, - ] as const) { - callGatewayMock.mockReset(); - callGatewayMock.mockResolvedValue({ ok: true }); - const params = buildParams("/approve plugin:abc123 allow-once", testCase.cfg, { - Provider: "discord", - Surface: "discord", - SenderId: testCase.senderId, - }); - - const result = await handleCommands(params); - expect(result.shouldContinue, testCase.name).toBe(false); - expect(result.reply?.text, testCase.name).toContain(testCase.expectedText); - expect(callGatewayMock, testCase.name).toHaveBeenCalledTimes(testCase.expectedGatewayCalls); - if (testCase.expectedGatewayCalls > 0) { - expect(callGatewayMock, testCase.name).toHaveBeenCalledWith( - expect.objectContaining({ - method: "plugin.approval.resolve", - params: { id: "plugin:abc123", decision: "allow-once" }, - }), - ); - } - } - }); - - it("rejects unauthorized or invalid Telegram /approve variants", async () => { - for (const testCase of [ - { - name: "different bot mention", - cfg: createTelegramApproveCfg(), - commandBody: "/approve@otherbot abc12345 allow-once", - ctx: { - BotUsername: "bot", - Provider: "telegram", - Surface: "telegram", - SenderId: "123", - }, - setup: undefined, - expectedText: "targets a different Telegram bot", - expectGatewayCalls: 0, - }, - { - name: "unknown approval id", - cfg: createTelegramApproveCfg(), - commandBody: "/approve abc12345 allow-once", - ctx: { - Provider: "telegram", - Surface: "telegram", - SenderId: "123", - }, - setup: () => callGatewayMock.mockRejectedValue(new Error("unknown or expired approval id")), - expectedText: "unknown or expired approval id", - expectGatewayCalls: 2, - }, - { - name: "telegram disabled native delivery reports the channel-disabled message", - cfg: createTelegramApproveCfg(null), - commandBody: "/approve abc12345 allow-once", - ctx: { - Provider: "telegram", - Surface: "telegram", - SenderId: "123", - }, - setup: undefined, - expectedText: "Telegram exec approvals are not enabled", - expectGatewayCalls: 0, - }, - { - name: "non approver", - cfg: createTelegramApproveCfg({ enabled: true, approvers: ["999"], target: "dm" }), - commandBody: "/approve abc12345 allow-once", - ctx: { - Provider: "telegram", - Surface: "telegram", - SenderId: "123", - }, - setup: undefined, - expectedText: "not authorized to approve", - expectGatewayCalls: 0, - }, - ] as const) { - callGatewayMock.mockReset(); - testCase.setup?.(); - const params = buildParams(testCase.commandBody, testCase.cfg, testCase.ctx); - - const result = await handleCommands(params); - expect(result.shouldContinue, testCase.name).toBe(false); - expect(result.reply?.text, testCase.name).toContain(testCase.expectedText); - expect(callGatewayMock, testCase.name).toHaveBeenCalledTimes(testCase.expectGatewayCalls); - } - }); - - it("enforces gateway approval scopes", async () => { - const cfg = { - commands: { text: true }, - } as OpenClawConfig; - const cases = [ - { - scopes: ["operator.write"], - expectedText: "requires operator.approvals", - expectedGatewayCalls: 0, - }, - { - scopes: ["operator.approvals"], - expectedText: "Approval allow-once submitted", - expectedGatewayCalls: 1, - }, - { - scopes: ["operator.admin"], - expectedText: "Approval allow-once submitted", - expectedGatewayCalls: 1, - }, - ] as const; - for (const testCase of cases) { - callGatewayMock.mockReset(); - callGatewayMock.mockResolvedValue({ ok: true }); - const params = buildParams("/approve abc allow-once", cfg, { - Provider: "webchat", - Surface: "webchat", - GatewayClientScopes: [...testCase.scopes], - }); - - const result = await handleCommands(params); - expect(result.shouldContinue, String(testCase.scopes)).toBe(false); - expect(result.reply?.text, String(testCase.scopes)).toContain(testCase.expectedText); - expect(callGatewayMock, String(testCase.scopes)).toHaveBeenCalledTimes( - testCase.expectedGatewayCalls, - ); - if (testCase.expectedGatewayCalls > 0) { - expect(callGatewayMock, String(testCase.scopes)).toHaveBeenLastCalledWith( - expect.objectContaining({ - method: "exec.approval.resolve", - params: { id: "abc", decision: "allow-once" }, - }), - ); - } - } - }); }); describe("/compact command", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns null when command is not /compact", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildParams("/status", cfg); - - const result = await handleCompactCommand( + it("keeps handleCommands wired to the direct compact handler", async () => { + const params = buildParams( + "/compact: focus on decisions", { - ...params, - }, - true, - ); - - expect(result).toBeNull(); - expect(vi.mocked(compactEmbeddedPiSession)).not.toHaveBeenCalled(); - }); - - it("rejects unauthorized /compact commands", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildParams("/compact", cfg); - - const result = await handleCompactCommand( + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: "/tmp/openclaw-session-store.json" }, + } as OpenClawConfig, { - ...params, - command: { - ...params.command, - isAuthorizedSender: false, - senderId: "unauthorized", - }, + From: "+15550001", + To: "+15550002", }, - true, ); - - expect(result).toEqual({ shouldContinue: false }); - expect(vi.mocked(compactEmbeddedPiSession)).not.toHaveBeenCalled(); - }); - - it("routes manual compaction with explicit trigger and context metadata", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - session: { store: "/tmp/openclaw-session-store.json" }, - } as OpenClawConfig; - const params = buildParams("/compact: focus on decisions", cfg, { - From: "+15550001", - To: "+15550002", - }); - const agentDir = "/tmp/openclaw-agent-compact"; vi.mocked(compactEmbeddedPiSession).mockResolvedValueOnce({ ok: true, compacted: false, }); - const result = await handleCompactCommand( - { - ...params, - agentDir, - sessionEntry: { - sessionId: "session-1", - updatedAt: Date.now(), - groupId: "group-1", - groupChannel: "#general", - space: "workspace-1", - spawnedBy: "agent:main:parent", - totalTokens: 12345, - }, - }, - true, - ); - - expect(result?.shouldContinue).toBe(false); - expect(vi.mocked(compactEmbeddedPiSession)).toHaveBeenCalledOnce(); - expect(vi.mocked(compactEmbeddedPiSession)).toHaveBeenCalledWith( - expect.objectContaining({ + const result = await handleCommands({ + ...params, + agentDir: "/tmp/openclaw-agent-compact", + sessionEntry: { sessionId: "session-1", - sessionKey: "agent:main:main", - allowGatewaySubagentBinding: true, - trigger: "manual", - customInstructions: "focus on decisions", - messageChannel: "whatsapp", - groupId: "group-1", - groupChannel: "#general", - groupSpace: "workspace-1", - spawnedBy: "agent:main:parent", - agentDir, - }), - ); + updatedAt: Date.now(), + }, + }); + + expect(result.shouldContinue).toBe(false); + expect(vi.mocked(compactEmbeddedPiSession)).toHaveBeenCalledOnce(); }); }); @@ -1568,80 +916,6 @@ describe("abort trigger command", () => { }); }); -describe("parseConfigCommand", () => { - it("parses config/debug command actions and JSON payloads", () => { - const cases: Array<{ - parse: (input: string) => unknown; - input: string; - expected: unknown; - }> = [ - { parse: parseConfigCommand, input: "/config", expected: { action: "show" } }, - { - parse: parseConfigCommand, - input: "/config show", - expected: { action: "show", path: undefined }, - }, - { - parse: parseConfigCommand, - input: "/config show foo.bar", - expected: { action: "show", path: "foo.bar" }, - }, - { - parse: parseConfigCommand, - input: "/config get foo.bar", - expected: { action: "show", path: "foo.bar" }, - }, - { - parse: parseConfigCommand, - input: "/config unset foo.bar", - expected: { action: "unset", path: "foo.bar" }, - }, - { - parse: parseConfigCommand, - input: '/config set foo={"a":1}', - expected: { action: "set", path: "foo", value: { a: 1 } }, - }, - { parse: parseDebugCommand, input: "/debug", expected: { action: "show" } }, - { parse: parseDebugCommand, input: "/debug show", expected: { action: "show" } }, - { parse: parseDebugCommand, input: "/debug reset", expected: { action: "reset" } }, - { - parse: parseDebugCommand, - input: "/debug unset foo.bar", - expected: { action: "unset", path: "foo.bar" }, - }, - { - parse: parseDebugCommand, - input: '/debug set foo={"a":1}', - expected: { action: "set", path: "foo", value: { a: 1 } }, - }, - ]; - - for (const testCase of cases) { - expect(testCase.parse(testCase.input)).toEqual(testCase.expected); - } - }); -}); - -describe("extractMessageText", () => { - it("preserves user markers and sanitizes assistant markers", () => { - const cases = [ - { - message: { role: "user", content: "Here [Tool Call: foo (ID: 1)] ok" }, - expectedText: "Here [Tool Call: foo (ID: 1)] ok", - }, - { - message: { role: "assistant", content: "Here [Tool Call: foo (ID: 1)] ok" }, - expectedText: "Here ok", - }, - ] as const; - - for (const testCase of cases) { - const result = extractMessageText(testCase.message); - expect(result?.text).toBe(testCase.expectedText); - } - }); -}); - describe("handleCommands owner gating for privileged show commands", () => { it("enforces owner gating for /config show and /debug show", async () => { const cases = [ @@ -2061,55 +1335,6 @@ function buildPolicyParams( return params; } -describe("handleCommands plugin commands", () => { - it("dispatches registered plugin commands with gateway scopes and session metadata", async () => { - const { clearPluginCommands, registerPluginCommand } = await loadPluginCommands(); - clearPluginCommands(); - let receivedCtx: - | { - gatewayClientScopes?: string[]; - sessionKey?: string; - sessionId?: string; - } - | undefined; - const result = registerPluginCommand("test-plugin", { - name: "card", - description: "Test card", - handler: async (ctx) => { - receivedCtx = ctx; - return { text: "from plugin" }; - }, - }); - expect(result.ok).toBe(true); - - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - } as OpenClawConfig; - const params = buildParams("/card", cfg, { - GatewayClientScopes: ["operator.write", "operator.pairing"], - }); - params.sessionKey = "agent:main:whatsapp:direct:test-user"; - params.sessionEntry = { - sessionId: "session-plugin-command", - updatedAt: Date.now(), - }; - - // Keep the full scope-forwarding chain covered: - // chat.send -> MsgContext.GatewayClientScopes -> plugin ctx.gatewayClientScopes. - const commandResult = await handleCommands(params); - - expect(commandResult.shouldContinue).toBe(false); - expect(commandResult.reply?.text).toBe("from plugin"); - expect(receivedCtx).toMatchObject({ - gatewayClientScopes: ["operator.write", "operator.pairing"], - sessionKey: "agent:main:whatsapp:direct:test-user", - sessionId: "session-plugin-command", - }); - clearPluginCommands(); - }); -}); - describe("handleCommands identity", () => { it("returns sender details for /whoami", async () => { const cfg = { @@ -2215,17 +1440,3 @@ describe("handleCommands context", () => { } }); }); - -describe("handleCommands /tts", () => { - it("returns status for bare /tts on text command surfaces", async () => { - const cfg = { - commands: { text: true }, - channels: { whatsapp: { allowFrom: ["*"] } }, - messages: { tts: { prefsPath: path.join(testWorkspaceDir, "tts.json") } }, - } as OpenClawConfig; - const params = buildParams("/tts", cfg); - const result = await handleCommands(params); - expect(result.shouldContinue).toBe(false); - expect(result.reply?.text).toContain("TTS status"); - }); -});