diff --git a/CHANGELOG.md b/CHANGELOG.md index 81cc91c6e68..a05917182f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- fix(qqbot): keep private commands off framework surface [AI]. (#77212) Thanks @pgondhi987. - Memory/wiki: preserve representation from both corpora in `corpus=all` searches while backfilling unused result capacity, so memory hits are not starved by numerically higher wiki integer scores. Fixes #77337. Thanks @hclsys. - Telegram: clean up tool-only draft previews after assistant message boundaries so transient `Surfacing...` tool-status bubbles do not linger when no matching final preview arrives. Thanks @BunsDev. - Cron: surface failed isolated-run diagnostics in `cron show`, status, and run history when requested tools are unavailable, so blocked cron runs report the actual tool-policy failure instead of a misleading green result. Fixes #75763. Thanks @RyanSandoval. diff --git a/extensions/qqbot/src/bridge/commands/framework-registration.ts b/extensions/qqbot/src/bridge/commands/framework-registration.ts index d2b4691fe79..8d712a31c7f 100644 --- a/extensions/qqbot/src/bridge/commands/framework-registration.ts +++ b/extensions/qqbot/src/bridge/commands/framework-registration.ts @@ -1,14 +1,14 @@ /** - * Register all `requireAuth: true` slash commands with the framework via + * Register slash commands that are allowed on the framework surface via * `api.registerCommand`. * * Routing through the framework lets `resolveCommandAuthorization()` apply * `commands.allowFrom.qqbot` precedence and the `qqbot:` prefix normalization * before any QQBot command handler runs. * - * This module is intentionally thin: it wires the engine-side command - * registry (`getFrameworkCommands`) to the framework registration surface via - * the three single-responsibility helpers in this directory. + * This module is intentionally thin: it wires the engine-side command registry + * (`getFrameworkCommands`) to the framework registration surface via the three + * single-responsibility helpers in this directory. */ import type { OpenClawPluginApi, PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry"; diff --git a/extensions/qqbot/src/command-auth.test.ts b/extensions/qqbot/src/command-auth.test.ts index d0cb4cb93dd..41cbb9db559 100644 --- a/extensions/qqbot/src/command-auth.test.ts +++ b/extensions/qqbot/src/command-auth.test.ts @@ -8,10 +8,8 @@ * "qqbot:" in channel.allowFrom matches the inbound event.senderId "". * Verified against the normalization logic in the gateway.ts inbound path. * - * Note: commands.allowFrom.qqbot precedence over channel allowFrom is enforced - * by the framework's resolveCommandAuthorization(). QQBot routes requireAuth:true - * commands through the framework (api.registerCommand), so that behavior is - * covered by the framework's own tests rather than duplicated here. + * Note: framework command authorization precedence is covered by the + * framework's own tests rather than duplicated here. */ import { describe, expect, it } from "vitest"; diff --git a/extensions/qqbot/src/engine/commands/slash-commands-impl.test.ts b/extensions/qqbot/src/engine/commands/slash-commands-impl.test.ts index e108f1be424..3c5d79b2ad0 100644 --- a/extensions/qqbot/src/engine/commands/slash-commands-impl.test.ts +++ b/extensions/qqbot/src/engine/commands/slash-commands-impl.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { resolveQQBotCommandsAllowFrom, resolveSlashCommandAuth } from "./slash-command-auth.js"; import { getWrittenQQBotConfig, installCommandRuntime } from "./slash-command-test-support.js"; import { getFrameworkCommands, matchSlashCommand } from "./slash-commands-impl.js"; -import type { SlashCommandContext } from "./slash-commands.js"; +import { SlashCommandRegistry, type SlashCommandContext } from "./slash-commands.js"; function createStreamingContext(overrides: Partial = {}): SlashCommandContext { return { @@ -29,8 +29,39 @@ function createStreamingContext(overrides: Partial = {}): S } describe("QQBot framework slash commands", () => { - it("routes bot-approve through the auth-gated framework registry", () => { - expect(getFrameworkCommands().map((command) => command.name)).toContain("bot-approve"); + it("exposes private-only admin commands with private-chat metadata", () => { + const commands = getFrameworkCommands(); + const names = commands.map((command) => command.name); + + expect(names).toEqual( + expect.arrayContaining(["bot-approve", "bot-clear-storage", "bot-logs", "bot-streaming"]), + ); + for (const commandName of ["bot-approve", "bot-clear-storage", "bot-logs", "bot-streaming"]) { + expect(commands.find((command) => command.name === commandName)?.c2cOnly).toBe(true); + } + }); + + it("preserves private-only auth metadata for framework registration", () => { + const registry = new SlashCommandRegistry(); + registry.register({ + name: "private-admin", + description: "private admin command", + requireAuth: true, + c2cOnly: true, + handler: () => "ok", + }); + registry.register({ + name: "shared-admin", + description: "shared admin command", + requireAuth: true, + handler: () => "ok", + }); + + const commands = registry.getFrameworkCommands(); + + expect(commands.map((command) => command.name)).toEqual(["private-admin", "shared-admin"]); + expect(commands.find((command) => command.name === "private-admin")?.c2cOnly).toBe(true); + expect(commands.find((command) => command.name === "shared-admin")?.c2cOnly).toBeUndefined(); }); it("routes bot-streaming through the auth-gated framework registry", () => { diff --git a/extensions/qqbot/src/engine/commands/slash-commands-impl.ts b/extensions/qqbot/src/engine/commands/slash-commands-impl.ts index 51c49e5a16e..80b07e2c087 100644 --- a/extensions/qqbot/src/engine/commands/slash-commands-impl.ts +++ b/extensions/qqbot/src/engine/commands/slash-commands-impl.ts @@ -32,8 +32,8 @@ export function initCommands(port: CommandsPort): void { } /** - * Return all commands that require authorization, for registration with the - * framework via api.registerCommand() in registerFull(). + * Return commands that may be registered with the framework via + * api.registerCommand() in registerFull(). */ export function getFrameworkCommands(): QQBotFrameworkCommand[] { return registry.getFrameworkCommands(); diff --git a/extensions/qqbot/src/engine/commands/slash-commands.ts b/extensions/qqbot/src/engine/commands/slash-commands.ts index 09bf943ab9c..df74780c057 100644 --- a/extensions/qqbot/src/engine/commands/slash-commands.ts +++ b/extensions/qqbot/src/engine/commands/slash-commands.ts @@ -100,8 +100,8 @@ function lc(s: string): string { * Slash command registry. * * Maintains two maps: - * - `commands` — pre-dispatch commands (requireAuth: false) - * - `frameworkCommands` — auth-gated commands (requireAuth: true) + * - `commands` — QQBot message-flow commands + * - `frameworkCommands` — auth-gated commands that are safe on the framework surface */ export class SlashCommandRegistry { private readonly commands = new Map(); @@ -113,14 +113,15 @@ export class SlashCommandRegistry { // Always register in the pre-dispatch map so QQ message-flow slash // commands can match and execute directly (with requireAuth gating). this.commands.set(key, cmd); - // Auth-gated commands are additionally exposed to the framework command - // surface (api.registerCommand) for CLI / control-plane invocation. + // Auth-gated commands are exposed to the framework command surface. + // Private-chat-only metadata is preserved so the bridge can enforce the + // same routing restriction before dispatching handlers. if (cmd.requireAuth) { this.frameworkCommands.set(key, cmd); } } - /** Return all auth-gated commands for framework registration. */ + /** Return all commands that may be registered on the framework surface. */ getFrameworkCommands(): QQBotFrameworkCommand[] { return Array.from(this.frameworkCommands.values()).map((cmd) => ({ name: cmd.name,