From 49ae7ec065a07bca60e4e4b7d6d1d16f143a500b Mon Sep 17 00:00:00 2001 From: brokemac79 Date: Mon, 6 Jul 2026 02:55:58 +0100 Subject: [PATCH] [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 --- src/auto-reply/command-control.test.ts | 34 ++++++++++++++++++++++++-- src/auto-reply/command-detection.ts | 14 ++++++++++- src/plugins/commands.test.ts | 15 ++++++++++++ src/plugins/commands.ts | 11 ++++++--- ui/src/e2e/chat-flow.e2e.test.ts | 8 ++++++ 5 files changed, 75 insertions(+), 7 deletions(-) diff --git a/src/auto-reply/command-control.test.ts b/src/auto-reply/command-control.test.ts index 0b49a51982cf..ef43782721b4 100644 --- a/src/auto-reply/command-control.test.ts +++ b/src/auto-reply/command-control.test.ts @@ -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 }) => { 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, { diff --git a/src/auto-reply/command-detection.ts b/src/auto-reply/command-detection.ts index f1a19d413169..6b050e681a93 100644 --- a/src/auto-reply/command-detection.ts +++ b/src/auto-reply/command-detection.ts @@ -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) + ); } diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index 30ef66f4bd4f..53b34c893e90 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -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: { diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index 06d1558e2108..0f72bdbc9d9b 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -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]; diff --git a/ui/src/e2e/chat-flow.e2e.test.ts b/ui/src/e2e/chat-flow.e2e.test.ts index a860fdb703dd..cbce112d712f 100644 --- a/ui/src/e2e/chat-flow.e2e.test.ts +++ b/ui/src/e2e/chat-flow.e2e.test.ts @@ -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); }