| |
| import { afterEach, describe, expect, it, vi } from "vitest"; |
| import type { OpenClawConfig } from "../config/config.js"; |
| import { clearPluginCommands, registerPluginCommand } from "../plugins/commands.js"; |
| import { setActivePluginRegistry } from "../plugins/runtime.js"; |
| import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; |
| import { resolveCommandAuthorization } from "./command-auth.js"; |
| import { |
| hasControlCommand, |
| hasInlineCommandTokens, |
| shouldComputeCommandAuthorized, |
| } from "./command-detection.js"; |
| import { listChatCommands } from "./commands-registry.js"; |
| import { parseActivationCommand } from "./group-activation.js"; |
| import { markInboundContextLabel } from "./reply/inbound-context-marker.js"; |
| import { resolveAuthorizedSessionResetCommand } from "./reply/session-reset-command.js"; |
| import { parseSendPolicyCommand } from "./send-policy.js"; |
| import type { MsgContext } from "./templating.js"; |
| import { installDiscordRegistryHooks } from "./test-helpers/command-auth-registry-fixture.js"; |
|
|
| installDiscordRegistryHooks(); |
|
|
| afterEach(() => { |
| clearPluginCommands(); |
| }); |
|
|
| describe("resolveCommandAuthorization", () => { |
| const formatAllowFrom = ({ allowFrom }: { allowFrom: Array<string | number> }) => { |
| const values: string[] = []; |
| for (const entry of allowFrom) { |
| const value = String(entry).trim(); |
| if (value) { |
| values.push(value); |
| } |
| } |
| return values; |
| }; |
|
|
| function createAllowFromPlugin( |
| id: string, |
| resolveAllowFrom: () => Array<string | number> | undefined, |
| ) { |
| return { |
| pluginId: id, |
| plugin: { |
| ...createOutboundTestPlugin({ |
| id, |
| outbound: { deliveryMode: "direct" }, |
| }), |
| config: { |
| listAccountIds: () => ["default"], |
| resolveAccount: () => ({}), |
| resolveAllowFrom, |
| formatAllowFrom, |
| }, |
| }, |
| source: "test" as const, |
| }; |
| } |
|
|
| function createThrowingAllowFromPlugin(id: string, error: string) { |
| return createAllowFromPlugin(id, () => { |
| throw new Error(error); |
| }); |
| } |
|
|
| function createOwnerEnforcingAllowFromPlugin( |
| id: string, |
| resolveAllowFrom: () => Array<string | number> | undefined, |
| ) { |
| const entry = createAllowFromPlugin(id, resolveAllowFrom); |
| return { |
| ...entry, |
| plugin: { |
| ...entry.plugin, |
| commands: { enforceOwnerForCommands: true }, |
| }, |
| }; |
| } |
|
|
| function registerAllowFromPlugins(...plugins: ReturnType<typeof createAllowFromPlugin>[]) { |
| setActivePluginRegistry(createTestRegistry(plugins)); |
| } |
|
|
| function resolveTestChannelAuthorization(params: { |
| from: string; |
| senderId?: string; |
| senderE164?: string; |
| allowFrom: string[]; |
| }) { |
| registerAllowFromPlugins(createAllowFromPlugin("mobilechat", () => params.allowFrom)); |
| const cfg = { |
| channels: { mobilechat: { allowFrom: params.allowFrom } }, |
| } as OpenClawConfig; |
| const ctx = { |
| Provider: "mobilechat", |
| Surface: "mobilechat", |
| From: params.from, |
| SenderId: params.senderId, |
| SenderE164: params.senderE164, |
| } as MsgContext; |
| return resolveCommandAuthorization({ |
| ctx, |
| cfg, |
| commandAuthorized: true, |
| }); |
| } |
|
|
| it.each([ |
| { |
| name: "falls back from empty SenderId to SenderE164", |
| from: "mobilechat:+999", |
| senderId: "", |
| senderE164: "+123", |
| allowFrom: ["+123"], |
| expectedSenderId: "+123", |
| }, |
| { |
| name: "falls back from whitespace SenderId to SenderE164", |
| from: "mobilechat:+999", |
| senderId: " ", |
| senderE164: "+123", |
| allowFrom: ["+123"], |
| expectedSenderId: "+123", |
| }, |
| { |
| name: "falls back to From when SenderId and SenderE164 are whitespace", |
| from: "+999", |
| senderId: " ", |
| senderE164: " ", |
| allowFrom: ["+999"], |
| expectedSenderId: "+999", |
| }, |
| { |
| name: "falls back from un-normalizable SenderId to SenderE164", |
| from: "mobilechat:+999", |
| senderId: "wat", |
| senderE164: "+123", |
| allowFrom: ["+123"], |
| expectedSenderId: "+123", |
| }, |
| { |
| name: "prefers SenderE164 when SenderId does not match allowFrom", |
| from: "mobilechat:group:room-1", |
| senderId: "opaque-user", |
| senderE164: "+41796666864", |
| allowFrom: ["+41796666864"], |
| expectedSenderId: "+41796666864", |
| }, |
| ])("$name", ({ from, senderId, senderE164, allowFrom, expectedSenderId }) => { |
| const auth = resolveTestChannelAuthorization({ |
| from, |
| senderId, |
| senderE164, |
| allowFrom, |
| }); |
|
|
| expect(auth.senderId).toBe(expectedSenderId); |
| expect(auth.isAuthorizedSender).toBe(true); |
| }); |
|
|
| it.each([ |
| { |
| name: "uses explicit owner allowlist when allowFrom is wildcard", |
| channelConfig: { allowFrom: ["*"] }, |
| }, |
| { |
| name: "uses explicit owner allowlist when allowFrom is empty", |
| channelConfig: {}, |
| }, |
| ])("$name", ({ channelConfig }) => { |
| const cfg = { |
| commands: { ownerAllowFrom: ["whatsapp:+15551234567"] }, |
| channels: { whatsapp: channelConfig }, |
| } as OpenClawConfig; |
| const resolveSender = (senderId: string) => |
| resolveCommandAuthorization({ |
| ctx: { |
| Provider: "whatsapp", |
| Surface: "whatsapp", |
| From: `whatsapp:${senderId}`, |
| SenderE164: senderId, |
| } as MsgContext, |
| cfg, |
| commandAuthorized: true, |
| }); |
|
|
| const ownerAuth = resolveSender("+15551234567"); |
| expect(ownerAuth.senderIsOwner).toBe(true); |
| expect(ownerAuth.isAuthorizedSender).toBe(true); |
|
|
| const otherAuth = resolveSender("+19995551234"); |
| expect(otherAuth.senderIsOwner).toBe(false); |
| expect(otherAuth.isAuthorizedSender).toBe(false); |
| }); |
|
|
| it("rejects wildcard channel senders when the plugin enforces owner-only commands", () => { |
| setActivePluginRegistry( |
| createTestRegistry([ |
| { |
| pluginId: "discord", |
| plugin: { |
| ...createOutboundTestPlugin({ |
| id: "discord", |
| outbound: { deliveryMode: "direct" }, |
| }), |
| commands: { enforceOwnerForCommands: true }, |
| config: { |
| listAccountIds: () => ["default"], |
| resolveAccount: () => ({}), |
| resolveAllowFrom: () => ["*"], |
| formatAllowFrom, |
| }, |
| }, |
| source: "test", |
| }, |
| ]), |
| ); |
| const cfg = { |
| channels: { discord: { allowFrom: ["*"] } }, |
| } as OpenClawConfig; |
|
|
| const auth = resolveCommandAuthorization({ |
| ctx: { |
| Provider: "discord", |
| Surface: "discord", |
| ChatType: "direct", |
| From: "discord:123", |
| SenderId: "123", |
| } as MsgContext, |
| cfg, |
| commandAuthorized: true, |
| }); |
|
|
| expect(auth.senderIsOwner).toBe(false); |
| expect(auth.isAuthorizedSender).toBe(false); |
| }); |
|
|
| it("rejects channel-validated native commands when plugin owner enforcement has no owner allowlist", () => { |
| setActivePluginRegistry( |
| createTestRegistry([ |
| { |
| pluginId: "discord", |
| plugin: { |
| ...createOutboundTestPlugin({ |
| id: "discord", |
| outbound: { deliveryMode: "direct" }, |
| }), |
| commands: { enforceOwnerForCommands: true }, |
| config: { |
| listAccountIds: () => ["default"], |
| resolveAccount: () => ({}), |
| resolveAllowFrom: () => ["*"], |
| formatAllowFrom, |
| }, |
| }, |
| source: "test", |
| }, |
| ]), |
| ); |
| const cfg = { |
| channels: { discord: { allowFrom: ["*"] } }, |
| } as OpenClawConfig; |
|
|
| const auth = resolveCommandAuthorization({ |
| ctx: { |
| Provider: "discord", |
| Surface: "discord", |
| ChatType: "direct", |
| From: "discord:123", |
| SenderId: "123", |
| CommandSource: "native", |
| } as MsgContext, |
| cfg, |
| commandAuthorized: true, |
| }); |
|
|
| expect(auth.senderIsOwner).toBe(false); |
| expect(auth.isAuthorizedSender).toBe(false); |
| }); |
|
|
| it("uses context owner candidates for command authorization without granting owner status", () => { |
| setActivePluginRegistry( |
| createTestRegistry([ |
| { |
| pluginId: "discord", |
| plugin: createOutboundTestPlugin({ |
| id: "discord", |
| outbound: { deliveryMode: "direct" }, |
| }), |
| source: "test", |
| }, |
| ]), |
| ); |
| const cfg = { |
| channels: { discord: {} }, |
| } as OpenClawConfig; |
|
|
| const ctx = { |
| Provider: "discord", |
| Surface: "discord", |
| From: "discord:123", |
| SenderId: "123", |
| OwnerAllowFrom: ["discord:123"], |
| } as MsgContext; |
|
|
| const auth = resolveCommandAuthorization({ |
| ctx, |
| cfg, |
| commandAuthorized: true, |
| }); |
|
|
| expect(auth.senderIsOwner).toBe(false); |
| expect(auth.ownerList).toEqual([]); |
| expect(auth.isAuthorizedSender).toBe(true); |
| }); |
|
|
| it("suppresses inherited owner status when the context forbids it", () => { |
| const cfg = { |
| channels: { telegram: { allowFrom: ["owner-123"] } }, |
| } as OpenClawConfig; |
|
|
| const auth = resolveCommandAuthorization({ |
| ctx: { |
| Provider: "exec-event", |
| Surface: "telegram", |
| OriginatingChannel: "telegram", |
| From: "owner-123", |
| To: "owner-123", |
| } as MsgContext, |
| cfg, |
| commandAuthorized: true, |
| }); |
|
|
| expect(auth.senderIsOwner).toBe(false); |
| }); |
|
|
| it("does not infer a provider from channel allowlists for webchat command contexts", () => { |
| const cfg = { |
| channels: { whatsapp: { allowFrom: ["+15551234567"] } }, |
| } as OpenClawConfig; |
|
|
| const ctx = { |
| Provider: "webchat", |
| Surface: "webchat", |
| OriginatingChannel: "webchat", |
| SenderId: "openclaw-control-ui", |
| } as MsgContext; |
|
|
| const auth = resolveCommandAuthorization({ |
| ctx, |
| cfg, |
| commandAuthorized: true, |
| }); |
|
|
| expect(auth.providerId).toBeUndefined(); |
| expect(auth.isAuthorizedSender).toBe(true); |
| }); |
|
|
| it.each([ |
| { |
| name: "does not apply channel-prefixed owner wildcards to webchat command contexts", |
| owner: "discord:*", |
| provider: "webchat", |
| expectedProvider: undefined, |
| expectedOwner: false, |
| }, |
| { |
| name: "does not apply channel-prefixed owner identities to webchat command contexts", |
| owner: "discord:123456789012345678", |
| provider: "webchat", |
| expectedProvider: undefined, |
| expectedOwner: false, |
| }, |
| { |
| name: "applies channel-prefixed owner identities to matching providers", |
| owner: "discord:123456789012345678", |
| provider: "discord", |
| expectedProvider: "discord", |
| expectedOwner: true, |
| }, |
| { |
| name: "leaves retired channel-qualified owner migration to Doctor", |
| owner: "discord:user:123456789012345678", |
| provider: "discord", |
| expectedProvider: "discord", |
| expectedOwner: false, |
| }, |
| { |
| name: "does not apply channel-prefixed owner wildcards to mismatched providers", |
| owner: "telegram:*", |
| provider: "discord", |
| expectedProvider: "discord", |
| expectedOwner: false, |
| }, |
| ] as const)("$name", ({ owner, provider, expectedProvider, expectedOwner }) => { |
| const webchat = provider === "webchat"; |
| const auth = resolveCommandAuthorization({ |
| ctx: { |
| Provider: provider, |
| Surface: provider, |
| ...(webchat |
| ? { OriginatingChannel: "webchat", GatewayClientScopes: ["operator.write"] } |
| : { From: "discord:123456789012345678" }), |
| SenderId: "123456789012345678", |
| } as MsgContext, |
| cfg: { commands: { ownerAllowFrom: [owner] } } as OpenClawConfig, |
| commandAuthorized: true, |
| }); |
|
|
| expect(auth.providerId).toBe(expectedProvider); |
| expect(auth.senderIsOwner).toBe(expectedOwner); |
| }); |
|
|
| it("preserves third-party native owner identities outside the bundled Doctor migration", () => { |
| registerAllowFromPlugins(createAllowFromPlugin("custom-chat", () => [])); |
| const auth = resolveCommandAuthorization({ |
| ctx: { Provider: "custom-chat", SenderId: "user:123" }, |
| cfg: { commands: { ownerAllowFrom: ["custom-chat:user:123"] } }, |
| commandAuthorized: true, |
| }); |
| expect(auth.senderIsOwner).toBe(true); |
| }); |
|
|
| it("preserves external channel command auth in mixed webchat contexts", () => { |
| const cfg = { |
| commands: { allowFrom: { whatsapp: ["+15551234567"] } }, |
| channels: { whatsapp: { allowFrom: ["+15551234567"] } }, |
| } as OpenClawConfig; |
|
|
| const auth = resolveCommandAuthorization({ |
| ctx: { |
| Provider: "webchat", |
| Surface: "whatsapp", |
| OriginatingChannel: "whatsapp", |
| From: "whatsapp:+19995551234", |
| SenderE164: "+19995551234", |
| } as MsgContext, |
| cfg, |
| commandAuthorized: true, |
| }); |
|
|
| expect(auth.providerId).toBe("whatsapp"); |
| expect(auth.isAuthorizedSender).toBe(false); |
| }); |
|
|
| it("falls back to channel allowFrom when provider allowlist resolution throws", () => { |
| registerAllowFromPlugins( |
| createThrowingAllowFromPlugin("telegram", "channels.telegram.botToken: unresolved SecretRef"), |
| ); |
| const cfg = { |
| channels: { telegram: { allowFrom: ["123"] } }, |
| } as OpenClawConfig; |
|
|
| const auth = resolveCommandAuthorization({ |
| ctx: { |
| Provider: "telegram", |
| Surface: "telegram", |
| From: "telegram:123", |
| SenderId: "123", |
| } as MsgContext, |
| cfg, |
| commandAuthorized: true, |
| }); |
|
|
| expect(auth.ownerList).toEqual([]); |
| expect(auth.senderIsOwner).toBe(false); |
| expect(auth.isAuthorizedSender).toBe(true); |
| }); |
|
|
| describe("commands.allowFrom", () => { |
| const commandsAllowFromConfig = { |
| commands: { |
| allowFrom: { |
| "*": ["user123"], |
| }, |
| }, |
| channels: { whatsapp: { allowFrom: ["+different"] } }, |
| } as OpenClawConfig; |
|
|
| function makeWhatsAppContext(senderId: string): MsgContext { |
| return { |
| Provider: "whatsapp", |
| Surface: "whatsapp", |
| From: `whatsapp:${senderId}`, |
| SenderId: senderId, |
| } as MsgContext; |
| } |
|
|
| function makeDiscordContext(senderId: string, fromOverride?: string): MsgContext { |
| return { |
| Provider: "discord", |
| Surface: "discord", |
| From: fromOverride ?? `discord:${senderId}`, |
| SenderId: senderId, |
| } as MsgContext; |
| } |
|
|
| function resolveWithCommandsAllowFrom(senderId: string, commandAuthorized: boolean) { |
| return resolveCommandAuthorization({ |
| ctx: makeWhatsAppContext(senderId), |
| cfg: commandsAllowFromConfig, |
| commandAuthorized, |
| }); |
| } |
|
|
| it("uses commands.allowFrom global list when configured", () => { |
| const authorizedAuth = resolveWithCommandsAllowFrom("user123", true); |
|
|
| expect(authorizedAuth.isAuthorizedSender).toBe(true); |
|
|
| const unauthorizedAuth = resolveWithCommandsAllowFrom("otheruser", true); |
|
|
| expect(unauthorizedAuth.isAuthorizedSender).toBe(false); |
| }); |
|
|
| it("ignores commandAuthorized when commands.allowFrom is configured", () => { |
| const authorizedAuth = resolveWithCommandsAllowFrom("user123", false); |
|
|
| expect(authorizedAuth.isAuthorizedSender).toBe(true); |
|
|
| const unauthorizedAuth = resolveWithCommandsAllowFrom("otheruser", false); |
|
|
| expect(unauthorizedAuth.isAuthorizedSender).toBe(false); |
| }); |
|
|
| it("uses commands.allowFrom provider-specific list over global", () => { |
| const cfg = { |
| commands: { |
| allowFrom: { |
| "*": ["globaluser"], |
| whatsapp: ["+15551234567"], |
| }, |
| }, |
| channels: { whatsapp: { allowFrom: ["*"] } }, |
| } as OpenClawConfig; |
|
|
| |
| const globalUserCtx = { |
| Provider: "whatsapp", |
| Surface: "whatsapp", |
| From: "whatsapp:globaluser", |
| SenderId: "globaluser", |
| } as MsgContext; |
|
|
| const globalAuth = resolveCommandAuthorization({ |
| ctx: globalUserCtx, |
| cfg, |
| commandAuthorized: true, |
| }); |
|
|
| |
| expect(globalAuth.isAuthorizedSender).toBe(false); |
|
|
| |
| const whatsappUserCtx = { |
| Provider: "whatsapp", |
| Surface: "whatsapp", |
| From: "whatsapp:+15551234567", |
| SenderE164: "+15551234567", |
| } as MsgContext; |
|
|
| const whatsappAuth = resolveCommandAuthorization({ |
| ctx: whatsappUserCtx, |
| cfg, |
| commandAuthorized: true, |
| }); |
|
|
| expect(whatsappAuth.isAuthorizedSender).toBe(true); |
| }); |
|
|
| it("falls back to channel allowFrom when commands.allowFrom not set", () => { |
| const cfg = { |
| channels: { whatsapp: { allowFrom: ["+15551234567"] } }, |
| } as OpenClawConfig; |
|
|
| const authorizedCtx = { |
| Provider: "whatsapp", |
| Surface: "whatsapp", |
| From: "whatsapp:+15551234567", |
| SenderE164: "+15551234567", |
| } as MsgContext; |
|
|
| const auth = resolveCommandAuthorization({ |
| ctx: authorizedCtx, |
| cfg, |
| commandAuthorized: true, |
| }); |
|
|
| expect(auth.isAuthorizedSender).toBe(true); |
| }); |
|
|
| it("allows all senders when commands.allowFrom includes wildcard", () => { |
| const cfg = { |
| commands: { |
| allowFrom: { |
| "*": ["*"], |
| }, |
| }, |
| channels: { whatsapp: { allowFrom: ["+specific"] } }, |
| } as OpenClawConfig; |
|
|
| const anyUserCtx = { |
| Provider: "whatsapp", |
| Surface: "whatsapp", |
| From: "whatsapp:anyuser", |
| SenderId: "anyuser", |
| } as MsgContext; |
|
|
| const auth = resolveCommandAuthorization({ |
| ctx: anyUserCtx, |
| cfg, |
| commandAuthorized: true, |
| }); |
|
|
| expect(auth.isAuthorizedSender).toBe(true); |
| }); |
|
|
| it("requires owner identity before commands.allowFrom when the plugin enforces owner-only commands", () => { |
| registerAllowFromPlugins(createOwnerEnforcingAllowFromPlugin("telegram", () => ["*"])); |
| const cfg = { |
| commands: { |
| allowFrom: { |
| "*": ["*"], |
| }, |
| }, |
| channels: { telegram: { allowFrom: ["*"] } }, |
| } as OpenClawConfig; |
|
|
| const auth = resolveCommandAuthorization({ |
| ctx: { |
| Provider: "telegram", |
| Surface: "telegram", |
| ChatType: "group", |
| From: "telegram:999", |
| SenderId: "999", |
| CommandSource: "native", |
| } as MsgContext, |
| cfg, |
| commandAuthorized: true, |
| }); |
|
|
| expect(auth.senderIsOwner).toBe(false); |
| expect(auth.isAuthorizedSender).toBe(false); |
| }); |
|
|
| it("keeps commands.allowFrom available to non-owner command users when an owner allowlist is configured", () => { |
| const cfg = { |
| commands: { |
| ownerAllowFrom: ["discord:owner"], |
| allowFrom: { |
| discord: ["helper"], |
| }, |
| }, |
| channels: { discord: { allowFrom: ["*"] } }, |
| } as OpenClawConfig; |
|
|
| const auth = resolveCommandAuthorization({ |
| ctx: { |
| Provider: "discord", |
| Surface: "discord", |
| ChatType: "group", |
| From: "discord:helper", |
| SenderId: "helper", |
| CommandSource: "native", |
| } as MsgContext, |
| cfg, |
| commandAuthorized: true, |
| }); |
|
|
| expect(auth.senderIsOwner).toBe(false); |
| expect(auth.isAuthorizedSender).toBe(true); |
| }); |
|
|
| it.each([ |
| { |
| name: "does not treat conversation ids in From as sender identities", |
| provider: "discord", |
| chatType: "channel", |
| from: "discord:channel:123456789012345678", |
| senderId: "999999999999999999", |
| senderE164: undefined, |
| allowFrom: { discord: ["channel:123456789012345678"] }, |
| expected: false, |
| }, |
| { |
| name: "still falls back to From for direct messages when sender fields are absent", |
| provider: "discord", |
| chatType: "direct", |
| from: "discord:123456789012345678", |
| senderId: " ", |
| senderE164: " ", |
| allowFrom: { discord: ["123456789012345678"] }, |
| expected: true, |
| }, |
| { |
| name: "does not fall back to conversation-shaped From when chat type is missing", |
| provider: "whatsapp", |
| chatType: undefined, |
| from: "demo:group:room-1", |
| senderId: " ", |
| senderE164: " ", |
| allowFrom: { "*": ["demo:group:room-1"] }, |
| expected: false, |
| }, |
| ] as const)( |
| "$name", |
| ({ provider, chatType, from, senderId, senderE164, allowFrom, expected }) => { |
| const auth = resolveCommandAuthorization({ |
| ctx: { |
| Provider: provider, |
| Surface: provider, |
| ChatType: chatType, |
| From: from, |
| SenderId: senderId, |
| SenderE164: senderE164, |
| } as MsgContext, |
| cfg: { commands: { allowFrom } } as unknown as OpenClawConfig, |
| commandAuthorized: false, |
| }); |
|
|
| expect(auth.isAuthorizedSender).toBe(expected); |
| }, |
| ); |
|
|
| it("normalizes Discord commands.allowFrom prefixes and mentions", () => { |
| const cfg = { |
| commands: { |
| allowFrom: { |
| discord: ["user:123", "<@!456>", "pk:member-1"], |
| }, |
| }, |
| } as OpenClawConfig; |
|
|
| const userAuth = resolveCommandAuthorization({ |
| ctx: makeDiscordContext("123"), |
| cfg, |
| commandAuthorized: false, |
| }); |
|
|
| expect(userAuth.isAuthorizedSender).toBe(true); |
|
|
| const mentionAuth = resolveCommandAuthorization({ |
| ctx: makeDiscordContext("456"), |
| cfg, |
| commandAuthorized: false, |
| }); |
|
|
| expect(mentionAuth.isAuthorizedSender).toBe(true); |
|
|
| const pkAuth = resolveCommandAuthorization({ |
| ctx: makeDiscordContext("member-1", "discord:999"), |
| cfg, |
| commandAuthorized: false, |
| }); |
|
|
| expect(pkAuth.isAuthorizedSender).toBe(true); |
|
|
| const deniedAuth = resolveCommandAuthorization({ |
| ctx: makeDiscordContext("other"), |
| cfg, |
| commandAuthorized: false, |
| }); |
|
|
| expect(deniedAuth.isAuthorizedSender).toBe(false); |
| }); |
| it.each([ |
| { |
| name: "fails closed when provider inference hits unresolved SecretRef allowlists", |
| failingProvider: "telegram", |
| allowKey: "telegram", |
| channelMode: "configured", |
| validTelegram: false, |
| commandAuthorized: false, |
| expectedProvider: "telegram", |
| expectedAuthorized: false, |
| }, |
| { |
| name: "preserves provider resolution errors when inferred fallback allowFrom is empty", |
| failingProvider: "telegram", |
| allowKey: "telegram", |
| channelMode: "empty", |
| validTelegram: false, |
| commandAuthorized: true, |
| expectedProvider: undefined, |
| expectedAuthorized: false, |
| }, |
| { |
| name: "fails closed for global commands.allowFrom when inference errors drop every provider", |
| failingProvider: "slack", |
| allowKey: "*", |
| channelMode: "empty", |
| validTelegram: false, |
| commandAuthorized: false, |
| expectedProvider: undefined, |
| expectedAuthorized: false, |
| }, |
| { |
| name: "does not let an unrelated provider resolution error poison inferred commands.allowFrom", |
| failingProvider: "slack", |
| allowKey: "telegram", |
| channelMode: "configured", |
| validTelegram: true, |
| commandAuthorized: false, |
| expectedProvider: "telegram", |
| expectedAuthorized: true, |
| }, |
| ] as const)( |
| "$name", |
| ({ |
| failingProvider, |
| allowKey, |
| channelMode, |
| validTelegram, |
| commandAuthorized, |
| expectedProvider, |
| expectedAuthorized, |
| }) => { |
| registerAllowFromPlugins( |
| ...(validTelegram ? [createAllowFromPlugin("telegram", () => ["123"])] : []), |
| createThrowingAllowFromPlugin( |
| failingProvider, |
| `channels.${failingProvider}.${failingProvider === "telegram" ? "botToken" : "token"}: unresolved SecretRef`, |
| ), |
| ); |
| const channelId = validTelegram ? "telegram" : failingProvider; |
| const channelConfig = channelMode === "configured" ? { allowFrom: ["123"] } : {}; |
| const params = { |
| ctx: { SenderId: "123", commandText: "/reset", rawText: "/reset" } as MsgContext, |
| cfg: { |
| commands: { allowFrom: { [allowKey]: ["123"] } }, |
| channels: { [channelId]: channelConfig }, |
| } as OpenClawConfig, |
| commandAuthorized, |
| }; |
| const auth = resolveCommandAuthorization(params); |
| const reset = resolveAuthorizedSessionResetCommand({ |
| ...params, |
| agentId: "main", |
| isGroup: false, |
| }); |
|
|
| expect(auth.providerId).toBe(expectedProvider); |
| expect(auth.isAuthorizedSender).toBe(expectedAuthorized); |
| expect(reset.resetAuthorized).toBe(expectedAuthorized); |
| expect(reset.resetCommand.matchedResetTriggerLower).toBe( |
| expectedAuthorized ? "/reset" : undefined, |
| ); |
| }, |
| ); |
|
|
| it("preserves default-account allowFrom on SecretRef fallback", () => { |
| registerAllowFromPlugins( |
| createThrowingAllowFromPlugin( |
| "telegram", |
| "channels.telegram.botToken: unresolved SecretRef", |
| ), |
| ); |
|
|
| const auth = resolveCommandAuthorization({ |
| ctx: { |
| Provider: "telegram", |
| Surface: "telegram", |
| SenderId: "123", |
| } as MsgContext, |
| cfg: { |
| channels: { |
| telegram: { |
| accounts: { |
| default: { |
| allowFrom: ["123"], |
| }, |
| }, |
| }, |
| }, |
| } as OpenClawConfig, |
| commandAuthorized: true, |
| }); |
|
|
| expect(auth.ownerList).toEqual([]); |
| expect(auth.senderIsOwner).toBe(false); |
| expect(auth.isAuthorizedSender).toBe(true); |
| }); |
|
|
| it("treats undefined allowFrom as an open channel, not a resolution failure", () => { |
| registerAllowFromPlugins(createAllowFromPlugin("discord", () => undefined)); |
|
|
| const auth = resolveCommandAuthorization({ |
| ctx: { |
| Provider: "discord", |
| Surface: "discord", |
| SenderId: "123", |
| } as MsgContext, |
| cfg: { |
| channels: { |
| discord: {}, |
| }, |
| } as OpenClawConfig, |
| commandAuthorized: true, |
| }); |
|
|
| expect(auth.isAuthorizedSender).toBe(true); |
| }); |
|
|
| it("does not log raw resolution messages from thrown allowFrom errors", () => { |
| registerAllowFromPlugins(createThrowingAllowFromPlugin("telegram", "SECRET-TOKEN-123")); |
|
|
| const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); |
| try { |
| resolveCommandAuthorization({ |
| ctx: { |
| Provider: "telegram", |
| Surface: "telegram", |
| SenderId: "123", |
| } as MsgContext, |
| cfg: { |
| channels: { |
| telegram: { |
| allowFrom: ["123"], |
| }, |
| }, |
| } as OpenClawConfig, |
| commandAuthorized: true, |
| }); |
| expect(warn).toHaveBeenCalledTimes(1); |
| const warning = String(warn.mock.calls[0]?.[0] ?? ""); |
| expect(warning).toContain("Error"); |
| expect(warning).not.toContain("SECRET-TOKEN-123"); |
| } finally { |
| warn.mockRestore(); |
| } |
| }); |
| }); |
|
|
| it.each([ |
| { |
| name: "grants senderIsOwner for internal channel with operator.admin scope", |
| provider: "webchat", |
| scope: "operator.admin", |
| expectedOwner: true, |
| }, |
| { |
| name: "does not grant senderIsOwner for internal channel without admin scope", |
| provider: "webchat", |
| scope: "operator.approvals", |
| expectedOwner: false, |
| }, |
| { |
| name: "does not grant senderIsOwner for external channel even with admin scope", |
| provider: "telegram", |
| scope: "operator.admin", |
| expectedOwner: false, |
| }, |
| ] as const)("$name", ({ provider, scope, expectedOwner }) => { |
| const auth = resolveCommandAuthorization({ |
| ctx: { |
| Provider: provider, |
| Surface: provider, |
| ...(provider === "telegram" ? { From: "telegram:12345" } : {}), |
| GatewayClientScopes: [scope], |
| } as MsgContext, |
| cfg: {} as OpenClawConfig, |
| commandAuthorized: true, |
| }); |
| expect(auth.senderIsOwner).toBe(expectedOwner); |
| }); |
| }); |
|
|
| describe("control command parsing", () => { |
| function expectCases( |
| parse: (value: string) => unknown, |
| cases: readonly (readonly [string, unknown])[], |
| ) { |
| cases.forEach(([value, expected]) => expect(parse(value)).toEqual(expected)); |
| } |
|
|
| it("requires slash for send policy", () => { |
| expectCases(parseSendPolicyCommand, [ |
| ["/send on", { hasCommand: true, mode: "allow" }], |
| ["/send: on", { hasCommand: true, mode: "allow" }], |
| ["/send", { hasCommand: true }], |
| ["/send:", { hasCommand: true }], |
| ["send on", { hasCommand: false }], |
| ["send", { hasCommand: false }], |
| ]); |
| }); |
|
|
| it("requires slash for activation", () => { |
| expectCases(parseActivationCommand, [ |
| ["/activation mention", { hasCommand: true, mode: "mention" }], |
| ["/activation: mention", { hasCommand: true, mode: "mention" }], |
| ["/activation:", { hasCommand: true }], |
| ["activation mention", { hasCommand: false }], |
| ]); |
| }); |
|
|
| it("treats bare commands as non-control", () => { |
| expect(hasControlCommand("send")).toBe(false); |
| expect(hasControlCommand("help")).toBe(false); |
| expect(hasControlCommand("/commands")).toBe(true); |
| expect(hasControlCommand("/commands:")).toBe(true); |
| expect(hasControlCommand("commands")).toBe(false); |
| expect(hasControlCommand("/status")).toBe(true); |
| expect(hasControlCommand("/STATUS")).toBe(true); |
| expect(hasControlCommand("/status:")).toBe(true); |
| expect(hasControlCommand("/status plugins")).toBe(true); |
| expect(hasControlCommand("/STATUS plugins")).toBe(true); |
| expect(hasControlCommand("status")).toBe(false); |
| expect(hasControlCommand("usage")).toBe(false); |
|
|
| for (const command of listChatCommands()) { |
| for (const alias of command.textAliases) { |
| expect(hasControlCommand(alias)).toBe(true); |
| expect(hasControlCommand(`${alias}:`)).toBe(true); |
| } |
| } |
| expect(hasControlCommand("/compact")).toBe(true); |
| expect(hasControlCommand("/COMPACT keep CaseSensitivePath")).toBe(true); |
| expect(hasControlCommand("/compact:")).toBe(true); |
| expect(hasControlCommand("compact")).toBe(false); |
| }); |
|
|
| it("respects disabled config/debug commands", () => { |
| const cfg = { commands: { config: false, debug: false } }; |
| expect(hasControlCommand("/config show", cfg)).toBe(false); |
| expect(hasControlCommand("/debug show", cfg)).toBe(false); |
| }); |
|
|
| it("requires commands to be the full message", () => { |
| expect(hasControlCommand("hello /status")).toBe(false); |
| expect(hasControlCommand("/status please")).toBe(true); |
| expect(hasControlCommand("prefix /send on")).toBe(false); |
| expect(hasControlCommand("/send on")).toBe(true); |
| }); |
|
|
| it("detects inline command tokens", () => { |
| expect(hasInlineCommandTokens("hello /status")).toBe(true); |
| expect(hasInlineCommandTokens("hey /think high")).toBe(true); |
| expect(hasInlineCommandTokens("/ pair qr")).toBe(false); |
| expect(hasInlineCommandTokens("hey / pair qr")).toBe(false); |
| expect(hasInlineCommandTokens("option A / B")).toBe(false); |
| expect(hasInlineCommandTokens("plain text")).toBe(false); |
| expect(hasInlineCommandTokens("http://example.com/path")).toBe(false); |
| expect(hasInlineCommandTokens("stop")).toBe(false); |
| }); |
|
|
| it("detects spaced syntax only for active registered plugin commands", () => { |
| expect(shouldComputeCommandAuthorized("/ pair qr")).toBe(false); |
|
|
| registerPluginCommand("device-pair", { |
| name: "pair", |
| description: "Pair command", |
| acceptsArgs: true, |
| handler: async () => ({ text: "ok" }), |
| }); |
|
|
| expect(shouldComputeCommandAuthorized("/ pair qr")).toBe(true); |
| expect(shouldComputeCommandAuthorized("@openclaw / pair qr")).toBe(true); |
| expect(shouldComputeCommandAuthorized("hey / pair qr")).toBe(true); |
|
|
| clearPluginCommands(); |
| expect(shouldComputeCommandAuthorized("/ pair qr")).toBe(false); |
| }); |
|
|
| it("ignores telegram commands addressed to other bots", () => { |
| expect( |
| hasControlCommand("/help@otherbot", undefined, { |
| botUsername: "openclaw", |
| }), |
| ).toBe(false); |
| expect( |
| hasControlCommand("/help@openclaw", undefined, { |
| botUsername: "openclaw", |
| }), |
| ).toBe(true); |
| }); |
|
|
| function expectCommandAfterMetadata(label: string, json: string, command: string) { |
| expect(hasControlCommand([label, "```json", json, "```", "", command].join("\n"))).toBe(true); |
| } |
|
|
| it("detects commands wrapped in inbound metadata blocks", () => { |
| expectCommandAfterMetadata( |
| markInboundContextLabel("Conversation info:"), |
| '{"message_id":"msg-abc","chat_id":"chat-123"}', |
| "/model spark", |
| ); |
| }); |
|
|
| it("detects /new command after metadata prefix", () => { |
| expectCommandAfterMetadata( |
| markInboundContextLabel("Sender:"), |
| '{"name":"Alice","id":"user-1"}', |
| "/new spark", |
| ); |
| }); |
|
|
| it("detects /status command after timestamp + metadata prefix", () => { |
| expectCommandAfterMetadata( |
| `[Wed 2026-03-11 23:51 PDT] ${markInboundContextLabel("Conversation info:")}`, |
| '{"chat_id":"chat-123"}', |
| "/status", |
| ); |
| }); |
| }); |
|
|