[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:
brokemac79
2026-07-06 02:55:58 +01:00
committed by GitHub
parent e596e2850d
commit 49ae7ec065
5 changed files with 75 additions and 7 deletions

View File

@@ -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, {

View File

@@ -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)
);
}

View File

@@ -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: {

View File

@@ -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];

View File

@@ -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);
}