mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 21:26:09 +00:00
[codex] Fail closed pair slash command routing (#98262)
* fix: keep pair qr from widening gateway bind * fix: honor plugin activation for slash reservations * fix: authorize spaced slash commands * fix: keep reserved commands out of manifest reservations * fix: avoid manifest fail-closed for declined commands * fix: gate manifest command fallback by auth * fix: keep runtime command probe internal * fix: scope spaced slash authorization * fix: route spaced plugin slash commands * docs: note spaced plugin command routing * docs: note spaced plugin command routing * docs: split command routing changelog follow-up --------- Co-authored-by: brokemac79 <255583030+brokemac79@users.noreply.github.com> Co-authored-by: Peter Steinberger <58493+steipete@users.noreply.github.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -1,10 +1,15 @@
|
||||
/** Tests command-control detection and authorization trigger heuristics. */
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
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 } from "./command-detection.js";
|
||||
import {
|
||||
hasControlCommand,
|
||||
hasInlineCommandTokens,
|
||||
shouldComputeCommandAuthorized,
|
||||
} from "./command-detection.js";
|
||||
import { listChatCommands } from "./commands-registry.js";
|
||||
import { parseActivationCommand } from "./group-activation.js";
|
||||
import { parseSendPolicyCommand } from "./send-policy.js";
|
||||
@@ -13,6 +18,10 @@ import { installDiscordRegistryHooks } from "./test-helpers/command-auth-registr
|
||||
|
||||
installDiscordRegistryHooks();
|
||||
|
||||
afterEach(() => {
|
||||
clearPluginCommands();
|
||||
});
|
||||
|
||||
describe("resolveCommandAuthorization", () => {
|
||||
const formatAllowFrom = ({ allowFrom }: { allowFrom: Array<string | number> }) => {
|
||||
const values: string[] = [];
|
||||
@@ -1144,11 +1153,32 @@ describe("control command parsing", () => {
|
||||
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, {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
normalizeOptionalLowercaseString,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import { matchPluginCommand } from "../plugins/commands.js";
|
||||
import { listChatCommands, listChatCommandsForConfig } from "./commands-registry-list.js";
|
||||
import { normalizeCommandBody } from "./commands-registry-normalize.js";
|
||||
import type { CommandNormalizeOptions } from "./commands-registry.types.js";
|
||||
@@ -90,11 +91,22 @@ export function hasInlineCommandTokens(text?: string): boolean {
|
||||
return /(?:^|\s)[/!][a-z]/i.test(body);
|
||||
}
|
||||
|
||||
function hasSpacedPluginCommand(text?: string): boolean {
|
||||
const commandBody = text?.match(/(?:^|\s)(\/\s+[a-z][\s\S]*)/i)?.[1];
|
||||
// Only active registered commands affect ingress authorization and mention gating.
|
||||
// This keeps spaced syntax aligned with canonical `/name` command ownership.
|
||||
return commandBody ? matchPluginCommand(commandBody) !== null : false;
|
||||
}
|
||||
|
||||
/** Returns true when a message may need command authorization metadata. */
|
||||
export function shouldComputeCommandAuthorized(
|
||||
text?: string,
|
||||
cfg?: OpenClawConfig,
|
||||
options?: CommandNormalizeOptions,
|
||||
): boolean {
|
||||
return isControlCommandMessage(text, cfg, options) || hasInlineCommandTokens(text);
|
||||
return (
|
||||
isControlCommandMessage(text, cfg, options) ||
|
||||
hasInlineCommandTokens(text) ||
|
||||
hasSpacedPluginCommand(text)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -480,6 +480,21 @@ describe("registerPluginCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("matches plugin slash commands when users insert whitespace after the slash", () => {
|
||||
registerPluginCommand("device-pair", {
|
||||
name: "pair",
|
||||
description: "Pair command",
|
||||
acceptsArgs: true,
|
||||
handler: async () => ({ text: "ok" }),
|
||||
});
|
||||
|
||||
expectCommandMatch("/ pair qr", {
|
||||
name: "pair",
|
||||
pluginId: "device-pair",
|
||||
args: "qr",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports provider-specific native command aliases", () => {
|
||||
const result = registerVoiceCommandForTest({
|
||||
nativeNames: {
|
||||
|
||||
@@ -73,10 +73,13 @@ export function matchPluginCommand(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract command name and args
|
||||
const spaceIndex = trimmed.indexOf(" ");
|
||||
const commandName = spaceIndex === -1 ? trimmed : trimmed.slice(0, spaceIndex);
|
||||
const args = spaceIndex === -1 ? undefined : trimmed.slice(spaceIndex + 1).trim();
|
||||
// Accept whitespace after the slash so `/ pair qr` keeps `/pair` ownership.
|
||||
const commandMatch = trimmed.match(/^\/\s*([^\s]+)(?:\s+([\s\S]*))?$/);
|
||||
if (!commandMatch) {
|
||||
return null;
|
||||
}
|
||||
const commandName = `/${commandMatch[1]}`;
|
||||
const args = commandMatch[2]?.trim();
|
||||
|
||||
const key = normalizeLowercaseStringOrEmpty(commandName);
|
||||
const alternateKeys = [key];
|
||||
|
||||
@@ -239,6 +239,14 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
|
||||
await gateway.emitChatFinal({ runId, text: "Harness verified." });
|
||||
|
||||
await page.getByText("Harness verified.").waitFor({ timeout: 10_000 });
|
||||
|
||||
const spacedPairCommand = "/ pair qr";
|
||||
await page.locator(".agent-chat__composer-combobox textarea").fill(spacedPairCommand);
|
||||
await page.getByRole("button", { name: "Send message" }).click();
|
||||
|
||||
const commandRequests = await waitForRequests(gateway, "chat.send", 2);
|
||||
const commandParams = requireRecord(commandRequests[1]?.params);
|
||||
expect(commandParams.message).toBe(spacedPairCommand);
|
||||
} finally {
|
||||
await closeBrowserContext(context);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user