mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-06 23:01:08 +00:00
Slash commands like /model and /new were silently ignored when the inbound message body included metadata prefix blocks (Conversation info, Sender info, timestamps) injected by buildInboundUserContextPrefix. The command detection functions (hasControlCommand, isControlCommandMessage, parseSendPolicyCommand) now call stripInboundMetadata before normalizeCommandBody so embedded slash commands are correctly recognized.
917 lines
26 KiB
TypeScript
917 lines
26 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawConfig } from "../config/config.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 } from "./command-detection.js";
|
|
import { listChatCommands } from "./commands-registry.js";
|
|
import { parseActivationCommand } from "./group-activation.js";
|
|
import { parseSendPolicyCommand } from "./send-policy.js";
|
|
import type { MsgContext } from "./templating.js";
|
|
import { installDiscordRegistryHooks } from "./test-helpers/command-auth-registry-fixture.js";
|
|
|
|
installDiscordRegistryHooks();
|
|
|
|
describe("resolveCommandAuthorization", () => {
|
|
const formatAllowFrom = ({ allowFrom }: { allowFrom: Array<string | number> }) =>
|
|
allowFrom.map((entry) => String(entry).trim()).filter(Boolean);
|
|
|
|
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 registerAllowFromPlugins(...plugins: ReturnType<typeof createAllowFromPlugin>[]) {
|
|
setActivePluginRegistry(createTestRegistry(plugins));
|
|
}
|
|
|
|
function resolveWhatsAppAuthorization(params: {
|
|
from: string;
|
|
senderId?: string;
|
|
senderE164?: string;
|
|
allowFrom: string[];
|
|
}) {
|
|
const cfg = {
|
|
channels: { whatsapp: { allowFrom: params.allowFrom } },
|
|
} as OpenClawConfig;
|
|
const ctx = {
|
|
Provider: "whatsapp",
|
|
Surface: "whatsapp",
|
|
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: "whatsapp:+999",
|
|
senderId: "",
|
|
senderE164: "+123",
|
|
allowFrom: ["+123"],
|
|
expectedSenderId: "+123",
|
|
},
|
|
{
|
|
name: "falls back from whitespace SenderId to SenderE164",
|
|
from: "whatsapp:+999",
|
|
senderId: " ",
|
|
senderE164: "+123",
|
|
allowFrom: ["+123"],
|
|
expectedSenderId: "+123",
|
|
},
|
|
{
|
|
name: "falls back to From when SenderId and SenderE164 are whitespace",
|
|
from: "whatsapp:+999",
|
|
senderId: " ",
|
|
senderE164: " ",
|
|
allowFrom: ["+999"],
|
|
expectedSenderId: "+999",
|
|
},
|
|
{
|
|
name: "falls back from un-normalizable SenderId to SenderE164",
|
|
from: "whatsapp:+999",
|
|
senderId: "wat",
|
|
senderE164: "+123",
|
|
allowFrom: ["+123"],
|
|
expectedSenderId: "+123",
|
|
},
|
|
{
|
|
name: "prefers SenderE164 when SenderId does not match allowFrom",
|
|
from: "whatsapp:120363401234567890@g.us",
|
|
senderId: "123@lid",
|
|
senderE164: "+41796666864",
|
|
allowFrom: ["+41796666864"],
|
|
expectedSenderId: "+41796666864",
|
|
},
|
|
])("$name", ({ from, senderId, senderE164, allowFrom, expectedSenderId }) => {
|
|
const auth = resolveWhatsAppAuthorization({
|
|
from,
|
|
senderId,
|
|
senderE164,
|
|
allowFrom,
|
|
});
|
|
|
|
expect(auth.senderId).toBe(expectedSenderId);
|
|
expect(auth.isAuthorizedSender).toBe(true);
|
|
});
|
|
|
|
it("uses explicit owner allowlist when allowFrom is wildcard", () => {
|
|
const cfg = {
|
|
commands: { ownerAllowFrom: ["whatsapp:+15551234567"] },
|
|
channels: { whatsapp: { allowFrom: ["*"] } },
|
|
} as OpenClawConfig;
|
|
|
|
const ownerCtx = {
|
|
Provider: "whatsapp",
|
|
Surface: "whatsapp",
|
|
From: "whatsapp:+15551234567",
|
|
SenderE164: "+15551234567",
|
|
} as MsgContext;
|
|
const ownerAuth = resolveCommandAuthorization({
|
|
ctx: ownerCtx,
|
|
cfg,
|
|
commandAuthorized: true,
|
|
});
|
|
expect(ownerAuth.senderIsOwner).toBe(true);
|
|
expect(ownerAuth.isAuthorizedSender).toBe(true);
|
|
|
|
const otherCtx = {
|
|
Provider: "whatsapp",
|
|
Surface: "whatsapp",
|
|
From: "whatsapp:+19995551234",
|
|
SenderE164: "+19995551234",
|
|
} as MsgContext;
|
|
const otherAuth = resolveCommandAuthorization({
|
|
ctx: otherCtx,
|
|
cfg,
|
|
commandAuthorized: true,
|
|
});
|
|
expect(otherAuth.senderIsOwner).toBe(false);
|
|
expect(otherAuth.isAuthorizedSender).toBe(false);
|
|
});
|
|
|
|
it("uses owner allowlist override from context when configured", () => {
|
|
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(true);
|
|
expect(auth.ownerList).toEqual(["123"]);
|
|
});
|
|
|
|
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",
|
|
ForceSenderIsOwnerFalse: true,
|
|
} 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("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(["123"]);
|
|
expect(auth.senderIsOwner).toBe(true);
|
|
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;
|
|
|
|
// User in global list but not in whatsapp-specific list
|
|
const globalUserCtx = {
|
|
Provider: "whatsapp",
|
|
Surface: "whatsapp",
|
|
From: "whatsapp:globaluser",
|
|
SenderId: "globaluser",
|
|
} as MsgContext;
|
|
|
|
const globalAuth = resolveCommandAuthorization({
|
|
ctx: globalUserCtx,
|
|
cfg,
|
|
commandAuthorized: true,
|
|
});
|
|
|
|
// Provider-specific list overrides global, so globaluser is not authorized
|
|
expect(globalAuth.isAuthorizedSender).toBe(false);
|
|
|
|
// User in whatsapp-specific list
|
|
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("does not treat conversation ids in From as sender identities", () => {
|
|
const cfg = {
|
|
commands: {
|
|
allowFrom: {
|
|
discord: ["channel:123456789012345678"],
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const auth = resolveCommandAuthorization({
|
|
ctx: {
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
ChatType: "channel",
|
|
From: "discord:channel:123456789012345678",
|
|
SenderId: "999999999999999999",
|
|
} as MsgContext,
|
|
cfg,
|
|
commandAuthorized: false,
|
|
});
|
|
|
|
expect(auth.isAuthorizedSender).toBe(false);
|
|
});
|
|
|
|
it("still falls back to From for direct messages when sender fields are absent", () => {
|
|
const cfg = {
|
|
commands: {
|
|
allowFrom: {
|
|
discord: ["123456789012345678"],
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const auth = resolveCommandAuthorization({
|
|
ctx: {
|
|
Provider: "discord",
|
|
Surface: "discord",
|
|
ChatType: "direct",
|
|
From: "discord:123456789012345678",
|
|
SenderId: " ",
|
|
SenderE164: " ",
|
|
} as MsgContext,
|
|
cfg,
|
|
commandAuthorized: false,
|
|
});
|
|
|
|
expect(auth.isAuthorizedSender).toBe(true);
|
|
});
|
|
|
|
it("does not fall back to conversation-shaped From when chat type is missing", () => {
|
|
const cfg = {
|
|
commands: {
|
|
allowFrom: {
|
|
"*": ["120363411111111111@g.us"],
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const auth = resolveCommandAuthorization({
|
|
ctx: {
|
|
Provider: "whatsapp",
|
|
Surface: "whatsapp",
|
|
From: "120363411111111111@g.us",
|
|
SenderId: " ",
|
|
SenderE164: " ",
|
|
} as MsgContext,
|
|
cfg,
|
|
commandAuthorized: false,
|
|
});
|
|
|
|
expect(auth.isAuthorizedSender).toBe(false);
|
|
});
|
|
|
|
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("fails closed when provider inference hits unresolved SecretRef allowlists", () => {
|
|
registerAllowFromPlugins(
|
|
createThrowingAllowFromPlugin(
|
|
"telegram",
|
|
"channels.telegram.botToken: unresolved SecretRef",
|
|
),
|
|
);
|
|
|
|
const cfg = {
|
|
commands: {
|
|
allowFrom: {
|
|
telegram: ["123"],
|
|
},
|
|
},
|
|
channels: {
|
|
telegram: {
|
|
allowFrom: ["123"],
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
|
|
const auth = resolveCommandAuthorization({
|
|
ctx: {
|
|
SenderId: "123",
|
|
} as MsgContext,
|
|
cfg,
|
|
commandAuthorized: false,
|
|
});
|
|
|
|
expect(auth.providerId).toBe("telegram");
|
|
expect(auth.isAuthorizedSender).toBe(false);
|
|
});
|
|
|
|
it("preserves provider resolution errors when inferred fallback allowFrom is empty", () => {
|
|
registerAllowFromPlugins(
|
|
createThrowingAllowFromPlugin(
|
|
"telegram",
|
|
"channels.telegram.botToken: unresolved SecretRef",
|
|
),
|
|
);
|
|
|
|
const auth = resolveCommandAuthorization({
|
|
ctx: {
|
|
SenderId: "123",
|
|
} as MsgContext,
|
|
cfg: {
|
|
commands: {
|
|
allowFrom: {
|
|
telegram: ["123"],
|
|
},
|
|
},
|
|
channels: {
|
|
telegram: {},
|
|
},
|
|
} as OpenClawConfig,
|
|
commandAuthorized: true,
|
|
});
|
|
|
|
expect(auth.providerId).toBeUndefined();
|
|
expect(auth.isAuthorizedSender).toBe(false);
|
|
});
|
|
|
|
it("fails closed for global commands.allowFrom when inference errors drop every provider", () => {
|
|
registerAllowFromPlugins(
|
|
createThrowingAllowFromPlugin("slack", "channels.slack.token: unresolved SecretRef"),
|
|
);
|
|
|
|
const auth = resolveCommandAuthorization({
|
|
ctx: {
|
|
SenderId: "123",
|
|
} as MsgContext,
|
|
cfg: {
|
|
commands: {
|
|
allowFrom: {
|
|
"*": ["123"],
|
|
},
|
|
},
|
|
channels: {
|
|
slack: {},
|
|
},
|
|
} as OpenClawConfig,
|
|
commandAuthorized: false,
|
|
});
|
|
|
|
expect(auth.providerId).toBeUndefined();
|
|
expect(auth.isAuthorizedSender).toBe(false);
|
|
});
|
|
it("does not let an unrelated provider resolution error poison inferred commands.allowFrom", () => {
|
|
registerAllowFromPlugins(
|
|
createAllowFromPlugin("telegram", () => ["123"]),
|
|
createThrowingAllowFromPlugin("slack", "channels.slack.token: unresolved SecretRef"),
|
|
);
|
|
|
|
const auth = resolveCommandAuthorization({
|
|
ctx: {
|
|
SenderId: "123",
|
|
} as MsgContext,
|
|
cfg: {
|
|
commands: {
|
|
allowFrom: {
|
|
telegram: ["123"],
|
|
},
|
|
},
|
|
channels: {
|
|
telegram: {
|
|
allowFrom: ["123"],
|
|
},
|
|
},
|
|
} as OpenClawConfig,
|
|
commandAuthorized: false,
|
|
});
|
|
|
|
expect(auth.providerId).toBe("telegram");
|
|
expect(auth.isAuthorizedSender).toBe(true);
|
|
});
|
|
|
|
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(["123"]);
|
|
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);
|
|
expect(String(warn.mock.calls[0]?.[0] ?? "")).toContain("Error");
|
|
expect(String(warn.mock.calls[0]?.[0] ?? "")).not.toContain("SECRET-TOKEN-123");
|
|
} finally {
|
|
warn.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
it("grants senderIsOwner for internal channel with operator.admin scope", () => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const ctx = {
|
|
Provider: "webchat",
|
|
Surface: "webchat",
|
|
GatewayClientScopes: ["operator.admin"],
|
|
} as MsgContext;
|
|
const auth = resolveCommandAuthorization({
|
|
ctx,
|
|
cfg,
|
|
commandAuthorized: true,
|
|
});
|
|
expect(auth.senderIsOwner).toBe(true);
|
|
});
|
|
|
|
it("does not grant senderIsOwner for internal channel without admin scope", () => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const ctx = {
|
|
Provider: "webchat",
|
|
Surface: "webchat",
|
|
GatewayClientScopes: ["operator.approvals"],
|
|
} as MsgContext;
|
|
const auth = resolveCommandAuthorization({
|
|
ctx,
|
|
cfg,
|
|
commandAuthorized: true,
|
|
});
|
|
expect(auth.senderIsOwner).toBe(false);
|
|
});
|
|
|
|
it("does not grant senderIsOwner for external channel even with admin scope", () => {
|
|
const cfg = {} as OpenClawConfig;
|
|
const ctx = {
|
|
Provider: "telegram",
|
|
Surface: "telegram",
|
|
From: "telegram:12345",
|
|
GatewayClientScopes: ["operator.admin"],
|
|
} as MsgContext;
|
|
const auth = resolveCommandAuthorization({
|
|
ctx,
|
|
cfg,
|
|
commandAuthorized: true,
|
|
});
|
|
expect(auth.senderIsOwner).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("control command parsing", () => {
|
|
it("requires slash for send policy", () => {
|
|
expect(parseSendPolicyCommand("/send on")).toEqual({
|
|
hasCommand: true,
|
|
mode: "allow",
|
|
});
|
|
expect(parseSendPolicyCommand("/send: on")).toEqual({
|
|
hasCommand: true,
|
|
mode: "allow",
|
|
});
|
|
expect(parseSendPolicyCommand("/send")).toEqual({ hasCommand: true });
|
|
expect(parseSendPolicyCommand("/send:")).toEqual({ hasCommand: true });
|
|
expect(parseSendPolicyCommand("send on")).toEqual({ hasCommand: false });
|
|
expect(parseSendPolicyCommand("send")).toEqual({ hasCommand: false });
|
|
});
|
|
|
|
it("requires slash for activation", () => {
|
|
expect(parseActivationCommand("/activation mention")).toEqual({
|
|
hasCommand: true,
|
|
mode: "mention",
|
|
});
|
|
expect(parseActivationCommand("/activation: mention")).toEqual({
|
|
hasCommand: true,
|
|
mode: "mention",
|
|
});
|
|
expect(parseActivationCommand("/activation:")).toEqual({
|
|
hasCommand: true,
|
|
});
|
|
expect(parseActivationCommand("activation mention")).toEqual({
|
|
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(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:")).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(false);
|
|
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("plain text")).toBe(false);
|
|
expect(hasInlineCommandTokens("http://example.com/path")).toBe(false);
|
|
expect(hasInlineCommandTokens("stop")).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);
|
|
});
|
|
|
|
it("detects commands wrapped in inbound metadata blocks", () => {
|
|
const metaWrapped = [
|
|
"Conversation info (untrusted metadata):",
|
|
"```json",
|
|
'{"message_id":"msg-abc","chat_id":"chat-123"}',
|
|
"```",
|
|
"",
|
|
"/model spark",
|
|
].join("\n");
|
|
expect(hasControlCommand(metaWrapped)).toBe(true);
|
|
});
|
|
|
|
it("detects /new command after metadata prefix", () => {
|
|
const metaWrapped = [
|
|
"Sender (untrusted metadata):",
|
|
"```json",
|
|
'{"name":"Alice","id":"user-1"}',
|
|
"```",
|
|
"",
|
|
"/new spark",
|
|
].join("\n");
|
|
expect(hasControlCommand(metaWrapped)).toBe(true);
|
|
});
|
|
|
|
it("detects /status command after timestamp + metadata prefix", () => {
|
|
const metaWrapped = [
|
|
"[Wed 2026-03-11 23:51 PDT] Conversation info (untrusted metadata):",
|
|
"```json",
|
|
'{"chat_id":"chat-123"}',
|
|
"```",
|
|
"",
|
|
"/status",
|
|
].join("\n");
|
|
expect(hasControlCommand(metaWrapped)).toBe(true);
|
|
});
|
|
});
|