Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix: route spaced plugin slash commands
  • Loading branch information
steipete committed Jul 6, 2026
commit ec58a24f875d00b99187c61e40175fb3338ae463
31 changes: 0 additions & 31 deletions extensions/device-pair/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1015,37 +1015,6 @@ describe("device-pair /pair default setup code", () => {
expect(requireText(result)).toContain("prefer gateway.tailscale.mode=serve");
});

it("rejects loopback-only setup with non-mutating Tailscale Serve guidance", async () => {
const command = registerPairCommand({
config: {
gateway: {
bind: "loopback",
auth: {
mode: "token",
token: "gateway-token",
},
},
},
pluginConfig: {
publicUrl: undefined,
},
});
const result = await command.handler(
createCommandContext({
channel: "webchat",
args: "",
commandBody: "/pair",
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
}),
);
const text = requireText(result);

expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled();
expect(text).toContain("Keep gateway.bind=loopback");
expect(text).toContain("plugins.entries.device-pair.config.publicUrl/gateway.remote.url");
expect(text).not.toContain("gateway.bind=lan");
});

it("uses Tailscale Serve MagicDNS as a secure setup url", async () => {
vi.mocked(resolveTailnetHostWithRunner).mockResolvedValueOnce("gateway.tailnet.ts.net");
const command = registerPairCommand({
Expand Down
2 changes: 1 addition & 1 deletion extensions/device-pair/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ async function resolveGatewayUrl(api: OpenClawPluginApi): Promise<ResolveUrlResu

return {
error:
"Gateway is only bound to loopback. Keep gateway.bind=loopback when Control UI is behind Tailscale Serve; configure gateway.tailscale.mode=serve or plugins.entries.device-pair.config.publicUrl/gateway.remote.url with the Tailscale Serve HTTPS URL for mobile pairing.",
"Gateway is only bound to loopback. Set gateway.bind=lan, enable tailscale serve, or configure plugins.entries.device-pair.config.publicUrl.",
};
}

Expand Down
8 changes: 0 additions & 8 deletions src/agents/tools/gateway-tool-guard-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,14 +373,6 @@ describe("gateway config mutation guard coverage", () => {
);
});

it("blocks gateway.bind exposure changes via config.patch", () => {
expectBlocked({ gateway: { bind: "loopback" } }, { gateway: { bind: "tailnet" } });
});

it("blocks gateway.bind exposure changes via config.apply", () => {
expectBlockedApply({ gateway: { bind: "loopback" } }, { gateway: { bind: "tailnet" } });
});

it("blocks global tools policy rewrites via config.patch", () => {
expectBlocked(
{ tools: { allow: ["read"] } },
Expand Down
28 changes: 25 additions & 3 deletions src/auto-reply/command-control.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/** 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";
Expand All @@ -17,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[] = [];
Expand Down Expand Up @@ -1148,15 +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(true);
expect(shouldComputeCommandAuthorized("/ pair qr")).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, {
Expand Down
17 changes: 13 additions & 4 deletions src/auto-reply/command-detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -87,17 +88,25 @@ export function hasInlineCommandTokens(text?: string): boolean {
if (!body.trim()) {
return false;
}
if (/^\/\s+[a-z]/i.test(body.trimStart())) {
return true;
}
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)
);
}
166 changes: 0 additions & 166 deletions src/auto-reply/reply/commands-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,17 @@
// Tests plugin command dispatch and plugin-scoped command aliases.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { clearPluginCommands, pluginCommands } from "../../plugins/command-registry-state.js";
import { handlePluginCommand } from "./commands-plugin.js";
import type { HandleCommandsParams } from "./commands-types.js";

const matchPluginCommandMock = vi.hoisted(() => vi.fn());
const executePluginCommandMock = vi.hoisted(() => vi.fn());
const getCurrentPluginMetadataSnapshotMock = vi.hoisted(() => vi.fn());

vi.mock("../../plugins/commands.js", () => ({
matchPluginCommand: matchPluginCommandMock,
executePluginCommand: executePluginCommandMock,
}));

vi.mock("../../plugins/current-plugin-metadata-snapshot.js", () => ({
getCurrentPluginMetadataSnapshot: getCurrentPluginMetadataSnapshotMock,
}));

function buildPluginParams(
commandBodyNormalized: string,
cfg: OpenClawConfig,
Expand Down Expand Up @@ -48,24 +42,9 @@ function buildPluginParams(
} as unknown as HandleCommandsParams;
}

function mockDevicePairRuntimeSlashPlugin() {
getCurrentPluginMetadataSnapshotMock.mockReturnValue({
plugins: [
{
id: "device-pair",
origin: "bundled",
enabledByDefault: true,
commandAliases: [{ name: "pair", kind: "runtime-slash" }],
},
],
});
}

describe("handlePluginCommand", () => {
beforeEach(() => {
vi.clearAllMocks();
clearPluginCommands();
getCurrentPluginMetadataSnapshotMock.mockReturnValue(undefined);
});

it("dispatches registered plugin commands with gateway scopes and session metadata", async () => {
Expand Down Expand Up @@ -213,149 +192,4 @@ describe("handlePluginCommand", () => {
});
expect(handler).toHaveBeenCalledTimes(1);
});

it.each(["/pair qr", "/ pair qr"])(
"does not let manifest-declared plugin slash commands fall through to the agent: %s",
async (commandBody) => {
matchPluginCommandMock.mockReturnValue(null);
mockDevicePairRuntimeSlashPlugin();

const result = await handlePluginCommand(
buildPluginParams(commandBody, {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig),
true,
);

expect(result).toEqual({
shouldContinue: false,
reply: {
text:
"Plugin command /pair is declared by the device-pair plugin, but no runtime handler is registered " +
"in this gateway process. Try again after plugins finish loading or restart the gateway.",
},
});
expect(executePluginCommandMock).not.toHaveBeenCalled();
},
);

it.each([
[
"global plugin disablement",
{
enabled: false,
},
],
[
"denylist",
{
deny: ["device-pair"],
},
],
[
"allowlist omission",
{
allow: ["browser"],
},
],
[
"per-plugin disablement",
{
entries: {
"device-pair": {
enabled: false,
},
},
},
],
] as const)(
"does not reserve manifest slash commands when plugin policy blocks %s",
async (_name, plugins) => {
matchPluginCommandMock.mockReturnValue(null);
mockDevicePairRuntimeSlashPlugin();

const result = await handlePluginCommand(
buildPluginParams("/pair qr", {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
plugins,
} as OpenClawConfig),
true,
);

expect(result).toBeNull();
expect(executePluginCommandMock).not.toHaveBeenCalled();
},
);

it("does not let manifest runtime aliases reserve built-in command names", async () => {
matchPluginCommandMock.mockReturnValue(null);
getCurrentPluginMetadataSnapshotMock.mockReturnValue({
plugins: [
{
id: "status-shadow",
origin: "workspace",
commandAliases: [{ name: "status", kind: "runtime-slash" }],
},
],
});

const result = await handlePluginCommand(
buildPluginParams("/status", {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
plugins: {
entries: {
"status-shadow": { enabled: true },
},
},
} as OpenClawConfig),
true,
);

expect(result).toBeNull();
expect(executePluginCommandMock).not.toHaveBeenCalled();
});

it("requires authorization before returning manifest runtime handler guidance", async () => {
matchPluginCommandMock.mockReturnValue(null);
mockDevicePairRuntimeSlashPlugin();
const params = buildPluginParams("/pair qr", {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig);
params.command.isAuthorizedSender = false;
params.ctx.CommandSource = "native";

const result = await handlePluginCommand(params, true);

expect(result).toEqual({
shouldContinue: false,
reply: { text: "You are not authorized to use this command." },
});
expect(executePluginCommandMock).not.toHaveBeenCalled();
});

it("does not fail closed when a registered runtime command declines the message", async () => {
matchPluginCommandMock.mockReturnValue(null);
pluginCommands.set("/pair", {
name: "pair",
description: "Pair a device",
pluginId: "device-pair",
handler: async () => ({ text: "ok" }),
});
mockDevicePairRuntimeSlashPlugin();

const result = await handlePluginCommand(
buildPluginParams("/pair unexpected-args", {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig),
true,
);

expect(result).toBeNull();
expect(executePluginCommandMock).not.toHaveBeenCalled();
});
});
Loading