mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(qqbot): gate fallback approval buttons (#87154)
QQBot fallback approval buttons now reuse the same slash-command authorization path as real commands, including access groups and default-account config merging.
Verification:
- node scripts/test-extension.mjs qqbot
- node --max-old-space-size=8192 --import tsx scripts/generate-plugin-sdk-api-baseline.ts --check && git diff --check
- pnpm lint --threads=8
- node scripts/run-vitest.mjs src/agents/agent-command.live-model-switch.test.ts
- GitHub PR checks for 7cc0f15031: passed
Thanks @eleqtrizit.
Co-authored-by: Agustin Rivera <agustin@rivera-web.com>
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
afd47fa416fe8fc5a2364078727d811a55842280d7cdf37625480de4f51ff22a plugin-sdk-api-baseline.json
|
||||
79d5b76cb0ec23311fbdd58043a15bca3353afcc5e9da91348ec5c8124d65e39 plugin-sdk-api-baseline.jsonl
|
||||
1818f0bf7e1f19917074efbeb8d6627e799f0909d019681cd8e184244de0641f plugin-sdk-api-baseline.json
|
||||
49dcff59d33606b9675962183b3e5bc10c09141ca9c75db609401efffcacf291 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -267,6 +267,11 @@ Append `?` to any command for usage help (for example `/bot-upgrade ?`).
|
||||
|
||||
Admin commands (`/bot-me`, `/bot-upgrade`, `/bot-logs`, `/bot-clear-storage`, `/bot-streaming`, `/bot-approve`) are direct-message-only and require the sender's openid in an explicit non-wildcard `allowFrom` list. A wildcard `allowFrom: ["*"]` permits chat but does not grant admin command access. Group messages match against `groupAllowFrom` first and fall back to `allowFrom`. Running an admin command in a group returns a hint rather than silently dropping.
|
||||
|
||||
When QQ Bot exec approvals use the default same-chat fallback, native approval
|
||||
button clicks follow the same explicit non-wildcard command allowlist. To grant
|
||||
approval-only access without broader command access, configure
|
||||
`channels.qqbot.execApprovals.approvers`.
|
||||
|
||||
## Engine architecture
|
||||
|
||||
QQ Bot ships as a self-contained engine inside the plugin:
|
||||
|
||||
@@ -152,6 +152,8 @@ Most channel plugins do not need approval-specific code.
|
||||
- Use `approvalCapability.render` only when a channel truly needs custom approval payloads instead of the shared renderer.
|
||||
- Use `approvalCapability.describeExecApprovalSetup` when the channel wants the disabled-path reply to explain the exact config knobs needed to enable native exec approvals. The hook receives `{ channel, channelLabel, accountId }`; named-account channels should render account-scoped paths such as `channels.<channel>.accounts.<id>.execApprovals.*` instead of top-level defaults.
|
||||
- If a channel can infer stable owner-like DM identities from existing config, use `createResolvedApproverActionAuthAdapter` from `openclaw/plugin-sdk/approval-runtime` to restrict same-chat `/approve` without adding approval-specific core logic.
|
||||
- If custom approval auth intentionally allows only same-chat fallback, return `markImplicitSameChatApprovalAuthorization({ authorized: true })` from `openclaw/plugin-sdk/approval-auth-runtime`; otherwise core treats the result as explicit approver authorization.
|
||||
- If a channel-owned native callback resolves approvals directly, use `isImplicitSameChatApprovalAuthorization(...)` before resolving so implicit fallback still goes through the channel's normal actor authorization.
|
||||
- If a channel needs native approval delivery, keep channel code focused on target normalization plus transport/presentation facts. Use `createChannelExecApprovalProfile`, `createChannelNativeOriginTargetResolver`, `createChannelApproverDmTargetResolver`, and `createApproverRestrictedNativeApprovalCapability` from `openclaw/plugin-sdk/approval-runtime`. Put the channel-specific facts behind `approvalCapability.nativeRuntime`, ideally via `createChannelApprovalNativeRuntimeAdapter(...)` or `createLazyChannelApprovalNativeRuntimeAdapter(...)`, so core can assemble the handler and own request filtering, routing, dedupe, expiry, gateway subscription, and routed-elsewhere notices. `nativeRuntime` is split into a few smaller seams:
|
||||
- `createChannelNativeOriginTargetResolver` uses the shared channel-route matcher by default for `{ to, accountId, threadId }` targets. Pass `targetsMatch` only when a channel has provider-specific equivalence rules, such as Slack timestamp prefix matching.
|
||||
- Pass `normalizeTargetForMatch` to `createChannelNativeOriginTargetResolver` when the channel needs to canonicalize provider ids before the default route matcher or a custom `targetsMatch` callback runs, while preserving the original target for delivery. Use `normalizeTarget` only when the resolved delivery target itself should be canonicalized.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseAccessGroupAllowFromEntry } from "openclaw/plugin-sdk/access-groups";
|
||||
import {
|
||||
createChannelIngressResolver,
|
||||
defineStableChannelIngressIdentity,
|
||||
@@ -133,7 +134,7 @@ async function resolveQQBotSlashCommandAuthorized(params: {
|
||||
(params.isGroup && params.groupAllowFrom && params.groupAllowFrom.length > 0
|
||||
? params.groupAllowFrom
|
||||
: params.allowFrom);
|
||||
const explicitAllowFrom = normalizeQQBotAllowFrom(rawAllowFrom).filter((entry) => entry !== "*");
|
||||
const explicitAllowFrom = normalizeQQBotCommandAllowFrom(rawAllowFrom);
|
||||
if (explicitAllowFrom.length === 0) {
|
||||
return false;
|
||||
}
|
||||
@@ -162,3 +163,24 @@ async function resolveQQBotSlashCommandAuthorized(params: {
|
||||
});
|
||||
return resolved.commandAccess.authorized;
|
||||
}
|
||||
|
||||
function normalizeQQBotCommandAllowFrom(
|
||||
rawAllowFrom: Array<string | number> | null | undefined,
|
||||
): string[] {
|
||||
const entries: string[] = [];
|
||||
for (const rawEntry of rawAllowFrom ?? []) {
|
||||
const entry = String(rawEntry).trim();
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
if (parseAccessGroupAllowFromEntry(entry)) {
|
||||
entries.push(entry);
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeQQBotSenderId(entry);
|
||||
if (normalized && normalized !== "*") {
|
||||
entries.push(normalized);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,21 @@ describe("qqbot: prefix normalization for inbound commandAuthorized", () => {
|
||||
return result.commandAccess.authorized;
|
||||
}
|
||||
|
||||
async function resolveSlashCommandAuthorized(
|
||||
rawAllowFrom: string[],
|
||||
senderId: string,
|
||||
cfg: Record<string, unknown> = {},
|
||||
): Promise<boolean> {
|
||||
return await access.resolveSlashCommandAuthorization({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
conversationId: senderId,
|
||||
isGroup: false,
|
||||
senderId,
|
||||
allowFrom: rawAllowFrom,
|
||||
});
|
||||
}
|
||||
|
||||
it("authorizes when allowFrom uses qqbot: prefix and senderId is the bare id", async () => {
|
||||
await expect(resolveInboundCommandAuthorized(["qqbot:USER123"], "USER123")).resolves.toBe(true);
|
||||
});
|
||||
@@ -66,6 +81,21 @@ describe("qqbot: prefix normalization for inbound commandAuthorized", () => {
|
||||
await expect(resolveInboundCommandAuthorized(["*"], "ANYONE")).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("authorizes slash commands from access group allowFrom entries", async () => {
|
||||
await expect(
|
||||
resolveSlashCommandAuthorized(["accessGroup:operators"], "USER123", {
|
||||
accessGroups: {
|
||||
operators: {
|
||||
type: "message.senders",
|
||||
members: {
|
||||
qqbot: ["USER123"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("denies group command auth in an open group without explicit allowlists", async () => {
|
||||
await expect(resolveInboundCommandAuthorized([], "ANYONE", { isGroup: true })).resolves.toBe(
|
||||
false,
|
||||
|
||||
@@ -183,6 +183,7 @@ export async function startGateway(ctx: CoreGatewayContext): Promise<void> {
|
||||
|
||||
const handleInteraction = createInteractionHandler(account, ctx.runtime, log, {
|
||||
getActiveCfg: () => activeCfgProvider.getActiveCfg(),
|
||||
resolveCommandAuthorized: (params) => adapters.access.resolveSlashCommandAuthorization(params),
|
||||
});
|
||||
|
||||
const connection = new GatewayConnection({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createSdkAccessAdapter } from "../../bridge/sdk-adapter.js";
|
||||
import { registerPlatformAdapter, type PlatformAdapter } from "../adapter/index.js";
|
||||
import type { InteractionEvent } from "../types.js";
|
||||
import { createInteractionHandler } from "./interaction-handler.js";
|
||||
@@ -17,13 +18,17 @@ vi.mock("../messaging/sender.js", () => ({
|
||||
|
||||
const resolveApprovalMock = vi.fn(async () => true);
|
||||
|
||||
const account: GatewayAccount = {
|
||||
accountId: "default",
|
||||
appId: "app",
|
||||
clientSecret: "secret",
|
||||
markdownSupport: false,
|
||||
config: {},
|
||||
};
|
||||
function makeAccount(config: GatewayAccount["config"] = {}): GatewayAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
appId: "app",
|
||||
clientSecret: "secret",
|
||||
markdownSupport: false,
|
||||
config,
|
||||
};
|
||||
}
|
||||
|
||||
const account = makeAccount();
|
||||
|
||||
const runtime = {} as GatewayPluginRuntime;
|
||||
|
||||
@@ -42,12 +47,13 @@ function makeRestrictedCfg(approvers: string[]): OpenClawConfig {
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function makeUnrestrictedCfg(): OpenClawConfig {
|
||||
function makeCommandAuthorizedFallbackCfg(): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
qqbot: {
|
||||
appId: "app",
|
||||
clientSecret: "secret",
|
||||
allowFrom: ["ATTACKER_OPENID"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
@@ -172,9 +178,9 @@ describe("createInteractionHandler approval buttons", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("allows approval button clicks when exec approvals are not configured", async () => {
|
||||
it("resolves fallback approval buttons from explicit command-authorized senders", async () => {
|
||||
const handler = createInteractionHandler(account, runtime, undefined, {
|
||||
getActiveCfg: () => makeUnrestrictedCfg(),
|
||||
getActiveCfg: () => makeCommandAuthorizedFallbackCfg(),
|
||||
});
|
||||
|
||||
handler(makeApprovalEvent());
|
||||
@@ -184,6 +190,152 @@ describe("createInteractionHandler approval buttons", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("delegates fallback approval button auth to the gateway command resolver", async () => {
|
||||
const access = createSdkAccessAdapter();
|
||||
const handler = createInteractionHandler(account, runtime, undefined, {
|
||||
getActiveCfg: () =>
|
||||
({
|
||||
accessGroups: {
|
||||
operators: {
|
||||
type: "message.senders",
|
||||
members: {
|
||||
qqbot: ["ATTACKER_OPENID"],
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
qqbot: {
|
||||
appId: "app",
|
||||
clientSecret: "secret",
|
||||
allowFrom: ["accessGroup:operators"],
|
||||
},
|
||||
},
|
||||
}) as OpenClawConfig,
|
||||
resolveCommandAuthorized: (params) => access.resolveSlashCommandAuthorization(params),
|
||||
});
|
||||
|
||||
handler(makeApprovalEvent());
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(resolveApprovalMock).toHaveBeenCalledWith("exec:abc12345", "allow-once"),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses merged account config for fallback button command auth", async () => {
|
||||
const handler = createInteractionHandler(account, runtime, undefined, {
|
||||
getActiveCfg: () =>
|
||||
({
|
||||
channels: {
|
||||
qqbot: {
|
||||
appId: "app",
|
||||
clientSecret: "secret",
|
||||
accounts: {
|
||||
default: {
|
||||
allowFrom: ["ATTACKER_OPENID"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as OpenClawConfig,
|
||||
});
|
||||
|
||||
handler(makeApprovalEvent());
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(resolveApprovalMock).toHaveBeenCalledWith("exec:abc12345", "allow-once"),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects fallback approval buttons from senders without explicit command auth", async () => {
|
||||
const handler = createInteractionHandler(account, runtime, undefined, {
|
||||
getActiveCfg: () =>
|
||||
({
|
||||
channels: {
|
||||
qqbot: {
|
||||
appId: "app",
|
||||
clientSecret: "secret",
|
||||
allowFrom: ["OWNER_OPENID"],
|
||||
},
|
||||
},
|
||||
}) as OpenClawConfig,
|
||||
});
|
||||
|
||||
handler(makeApprovalEvent());
|
||||
|
||||
await vi.waitFor(() => expect(acknowledgeInteractionMock).toHaveBeenCalled());
|
||||
|
||||
expect(acknowledgeInteractionMock).toHaveBeenCalledWith(
|
||||
{ appId: "app", clientSecret: "secret" },
|
||||
"interaction-1",
|
||||
0,
|
||||
{ content: "You are not authorized to approve this request." },
|
||||
);
|
||||
expect(resolveApprovalMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"no allowlist",
|
||||
{
|
||||
channels: {
|
||||
qqbot: {
|
||||
appId: "app",
|
||||
clientSecret: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"wildcard allowlist",
|
||||
{
|
||||
channels: {
|
||||
qqbot: {
|
||||
appId: "app",
|
||||
clientSecret: "secret",
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
] satisfies Array<[string, OpenClawConfig]>)(
|
||||
"rejects fallback approval buttons when %s does not grant command auth",
|
||||
async (_name, cfg) => {
|
||||
const handler = createInteractionHandler(account, runtime, undefined, {
|
||||
getActiveCfg: () => cfg,
|
||||
});
|
||||
|
||||
handler(makeApprovalEvent());
|
||||
|
||||
await vi.waitFor(() => expect(acknowledgeInteractionMock).toHaveBeenCalled());
|
||||
|
||||
expect(acknowledgeInteractionMock).toHaveBeenCalledWith(
|
||||
{ appId: "app", clientSecret: "secret" },
|
||||
"interaction-1",
|
||||
0,
|
||||
{ content: "You are not authorized to approve this request." },
|
||||
);
|
||||
expect(resolveApprovalMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects fallback approval buttons without a trusted actor id", async () => {
|
||||
const handler = createInteractionHandler(account, runtime, undefined, {
|
||||
getActiveCfg: () => makeCommandAuthorizedFallbackCfg(),
|
||||
});
|
||||
|
||||
handler(makeApprovalEvent({ group_member_openid: undefined, user_openid: undefined }));
|
||||
|
||||
await vi.waitFor(() => expect(acknowledgeInteractionMock).toHaveBeenCalled());
|
||||
|
||||
expect(acknowledgeInteractionMock).toHaveBeenCalledWith(
|
||||
{ appId: "app", clientSecret: "secret" },
|
||||
"interaction-1",
|
||||
0,
|
||||
{ content: "You are not authorized to approve this request." },
|
||||
);
|
||||
expect(resolveApprovalMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects approval button clicks when active config cannot be loaded", async () => {
|
||||
const handler = createInteractionHandler(account, runtime, undefined, {
|
||||
getActiveCfg: () => {
|
||||
|
||||
@@ -11,12 +11,17 @@
|
||||
* branches fall through to a bare ACK (backward-compatible).
|
||||
*/
|
||||
|
||||
import { isImplicitSameChatApprovalAuthorization } from "openclaw/plugin-sdk/approval-auth-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { authorizeQQBotApprovalAction } from "../../exec-approvals.js";
|
||||
import { resolveQQBotEffectivePolicies } from "../access/resolve-policy.js";
|
||||
import { getPlatformAdapter } from "../adapter/index.js";
|
||||
import { parseApprovalButtonData } from "../approval/index.js";
|
||||
import {
|
||||
resolveQQBotCommandsAllowFrom,
|
||||
resolveSlashCommandAuth,
|
||||
} from "../commands/slash-command-auth.js";
|
||||
import { getPluginVersion, getFrameworkVersion } from "../commands/slash-commands-impl.js";
|
||||
import { resolveGroupConfig, resolveMentionPatterns } from "../config/group.js";
|
||||
import { resolveAccountBase } from "../config/resolve.js";
|
||||
@@ -26,6 +31,17 @@ import type { InteractionEvent, QQBotAccountConfigView } from "../types.js";
|
||||
import { InteractionType } from "./constants.js";
|
||||
import type { GatewayAccount, GatewayPluginRuntime, EngineLogger } from "./types.js";
|
||||
|
||||
type QQBotCommandAuthorizationResolver = (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
isGroup: boolean;
|
||||
senderId: string;
|
||||
conversationId: string;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
commandsAllowFrom?: Array<string | number>;
|
||||
}) => boolean | Promise<boolean>;
|
||||
|
||||
// ============ claw_cfg snapshot ============
|
||||
|
||||
/**
|
||||
@@ -154,7 +170,10 @@ export function createInteractionHandler(
|
||||
account: GatewayAccount,
|
||||
runtime: GatewayPluginRuntime,
|
||||
log?: EngineLogger,
|
||||
options?: { getActiveCfg?: () => OpenClawConfig },
|
||||
options?: {
|
||||
getActiveCfg?: () => OpenClawConfig;
|
||||
resolveCommandAuthorized?: QQBotCommandAuthorizationResolver;
|
||||
},
|
||||
): (event: InteractionEvent) => void {
|
||||
return (event) => {
|
||||
const creds = accountToCreds(account);
|
||||
@@ -187,12 +206,13 @@ export function createInteractionHandler(
|
||||
}
|
||||
|
||||
void handleApprovalButtonInteraction({
|
||||
accountId: account.accountId,
|
||||
account,
|
||||
creds,
|
||||
event,
|
||||
getActiveCfg: options?.getActiveCfg ?? runtime.config?.current,
|
||||
log,
|
||||
parsed,
|
||||
resolveCommandAuthorized: options?.resolveCommandAuthorized,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -200,12 +220,13 @@ export function createInteractionHandler(
|
||||
// ============ Helpers ============
|
||||
|
||||
async function handleApprovalButtonInteraction(params: {
|
||||
accountId: string;
|
||||
account: GatewayAccount;
|
||||
creds: { appId: string; clientSecret: string };
|
||||
event: InteractionEvent;
|
||||
getActiveCfg?: () => OpenClawConfig | Record<string, unknown>;
|
||||
log?: EngineLogger;
|
||||
parsed: { approvalId: string; decision: "allow-once" | "allow-always" | "deny" };
|
||||
resolveCommandAuthorized?: QQBotCommandAuthorizationResolver;
|
||||
}): Promise<void> {
|
||||
if (!params.getActiveCfg) {
|
||||
await acknowledgeApprovalInteraction(params.creds, params.event, params.log, {
|
||||
@@ -230,11 +251,12 @@ async function handleApprovalButtonInteraction(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
const authorization = authorizeApprovalButtonActor({
|
||||
const authorization = await authorizeApprovalButtonActor({
|
||||
cfg,
|
||||
accountId: params.accountId,
|
||||
account: params.account,
|
||||
event: params.event,
|
||||
approvalKind: resolveApprovalKind(params.parsed.approvalId),
|
||||
resolveCommandAuthorized: params.resolveCommandAuthorized,
|
||||
});
|
||||
if (!authorization.authorized) {
|
||||
await acknowledgeApprovalInteraction(params.creds, params.event, params.log, {
|
||||
@@ -283,38 +305,102 @@ async function acknowledgeApprovalInteraction(
|
||||
}
|
||||
}
|
||||
|
||||
function authorizeApprovalButtonActor(params: {
|
||||
async function authorizeApprovalButtonActor(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
account: GatewayAccount;
|
||||
event: InteractionEvent;
|
||||
approvalKind: "exec" | "plugin";
|
||||
}): { authorized: boolean; reason?: string } {
|
||||
resolveCommandAuthorized?: QQBotCommandAuthorizationResolver;
|
||||
}): Promise<{ authorized: boolean; reason?: string }> {
|
||||
const senderIds = resolveApprovalActorSenderIds(params.event);
|
||||
if (senderIds.length === 0) {
|
||||
return authorizeQQBotApprovalAction({
|
||||
const result = authorizeQQBotApprovalAction({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
accountId: params.account.accountId,
|
||||
senderId: null,
|
||||
approvalKind: params.approvalKind,
|
||||
});
|
||||
return result.authorized && isImplicitSameChatApprovalAuthorization(result)
|
||||
? { authorized: false, reason: "You are not authorized to approve this request." }
|
||||
: result;
|
||||
}
|
||||
|
||||
let denial: { authorized: boolean; reason?: string } | undefined;
|
||||
for (const senderId of senderIds) {
|
||||
const result = authorizeQQBotApprovalAction({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
accountId: params.account.accountId,
|
||||
senderId,
|
||||
approvalKind: params.approvalKind,
|
||||
});
|
||||
if (result.authorized) {
|
||||
return result;
|
||||
if (
|
||||
!isImplicitSameChatApprovalAuthorization(result) ||
|
||||
(await isImplicitApprovalButtonActorAuthorized({
|
||||
cfg: params.cfg,
|
||||
account: params.account,
|
||||
event: params.event,
|
||||
senderId,
|
||||
resolveCommandAuthorized: params.resolveCommandAuthorized,
|
||||
}))
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
denial ??= {
|
||||
authorized: false,
|
||||
reason: "You are not authorized to approve this request.",
|
||||
};
|
||||
continue;
|
||||
}
|
||||
denial ??= result;
|
||||
}
|
||||
return denial ?? { authorized: false, reason: "You are not authorized to approve this request." };
|
||||
}
|
||||
|
||||
async function isImplicitApprovalButtonActorAuthorized(params: {
|
||||
cfg: OpenClawConfig;
|
||||
account: GatewayAccount;
|
||||
event: InteractionEvent;
|
||||
senderId: string;
|
||||
resolveCommandAuthorized?: QQBotCommandAuthorizationResolver;
|
||||
}): Promise<boolean> {
|
||||
const accountConfig = resolveApprovalButtonAccountConfig(params.cfg, params.account.accountId);
|
||||
const authInput = {
|
||||
cfg: params.cfg,
|
||||
accountId: params.account.accountId,
|
||||
senderId: params.senderId,
|
||||
isGroup: Boolean(params.event.group_openid),
|
||||
conversationId: params.event.group_openid ?? params.event.user_openid ?? params.senderId,
|
||||
allowFrom: accountConfig.allowFrom,
|
||||
groupAllowFrom: accountConfig.groupAllowFrom,
|
||||
commandsAllowFrom: resolveQQBotCommandsAllowFrom(params.cfg),
|
||||
};
|
||||
return params.resolveCommandAuthorized
|
||||
? await params.resolveCommandAuthorized(authInput)
|
||||
: resolveSlashCommandAuth(authInput);
|
||||
}
|
||||
|
||||
function resolveApprovalButtonAccountConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): QQBotAccountConfigView {
|
||||
const qqbot = readRecord(readRecord(cfg.channels)?.qqbot);
|
||||
const accounts = readRecord(qqbot?.accounts);
|
||||
if (accountId === "default") {
|
||||
return {
|
||||
...qqbot,
|
||||
...readRecord(accounts?.default),
|
||||
} as QQBotAccountConfigView;
|
||||
}
|
||||
return (readRecord(accounts?.[accountId]) ?? {}) as QQBotAccountConfigView;
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveApprovalActorSenderIds(event: InteractionEvent): string[] {
|
||||
const ids = [event.group_member_openid, event.user_openid].flatMap((value) => {
|
||||
const normalized = typeof value === "string" ? value.trim() : "";
|
||||
|
||||
68
extensions/qqbot/src/exec-approvals.test.ts
Normal file
68
extensions/qqbot/src/exec-approvals.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { isImplicitSameChatApprovalAuthorization } from "openclaw/plugin-sdk/approval-auth-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerPlatformAdapter, type PlatformAdapter } from "./engine/adapter/index.js";
|
||||
import { authorizeQQBotApprovalAction } from "./exec-approvals.js";
|
||||
|
||||
describe("authorizeQQBotApprovalAction", () => {
|
||||
beforeEach(() => {
|
||||
registerPlatformAdapter({
|
||||
validateRemoteUrl: vi.fn(async () => undefined),
|
||||
resolveSecret: vi.fn(async (value: unknown) =>
|
||||
typeof value === "string" ? value : undefined,
|
||||
),
|
||||
downloadFile: vi.fn(async () => "/tmp/file"),
|
||||
fetchMedia: vi.fn(async () => {
|
||||
throw new Error("unused");
|
||||
}),
|
||||
getTempDir: () => "/tmp",
|
||||
hasConfiguredSecret: (value: unknown) => typeof value === "string" && value.length > 0,
|
||||
normalizeSecretInputString: (value: unknown) =>
|
||||
typeof value === "string" ? value : undefined,
|
||||
resolveSecretInputString: ({ value }: { value: unknown }) =>
|
||||
typeof value === "string" ? value : undefined,
|
||||
} as PlatformAdapter);
|
||||
});
|
||||
|
||||
it("marks unconfigured exec approval fallback authorization as implicit", () => {
|
||||
const result = authorizeQQBotApprovalAction({
|
||||
cfg: {
|
||||
channels: {
|
||||
qqbot: {
|
||||
appId: "app",
|
||||
clientSecret: "secret",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
senderId: "ATTACKER_OPENID",
|
||||
approvalKind: "exec",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ authorized: true });
|
||||
expect(isImplicitSameChatApprovalAuthorization(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps configured approver authorization explicit", () => {
|
||||
const result = authorizeQQBotApprovalAction({
|
||||
cfg: {
|
||||
channels: {
|
||||
qqbot: {
|
||||
appId: "app",
|
||||
clientSecret: "secret",
|
||||
execApprovals: {
|
||||
enabled: true,
|
||||
approvers: ["OWNER_OPENID"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
senderId: "OWNER_OPENID",
|
||||
approvalKind: "exec",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ authorized: true });
|
||||
expect(isImplicitSameChatApprovalAuthorization(result)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
import { resolveApprovalApprovers } from "openclaw/plugin-sdk/approval-auth-runtime";
|
||||
import {
|
||||
markImplicitSameChatApprovalAuthorization,
|
||||
resolveApprovalApprovers,
|
||||
} from "openclaw/plugin-sdk/approval-auth-runtime";
|
||||
import {
|
||||
createChannelExecApprovalProfile,
|
||||
isChannelExecApprovalClientEnabledFromConfig,
|
||||
@@ -224,7 +227,7 @@ export function authorizeQQBotApprovalAction(params: {
|
||||
approvalKind: "exec" | "plugin";
|
||||
}): { authorized: boolean; reason?: string } {
|
||||
if (resolveQQBotExecApprovalConfig(params) === undefined) {
|
||||
return { authorized: true };
|
||||
return markImplicitSameChatApprovalAuthorization({ authorized: true });
|
||||
}
|
||||
|
||||
const authorized =
|
||||
|
||||
@@ -277,8 +277,8 @@ function extractStateUpsertPersistenceOptions(): Array<{
|
||||
if (next?.state && payload.skipMaintenance && payload.takeCacheOwnership) {
|
||||
options.push({
|
||||
state: next.state,
|
||||
...(payload.skipMaintenance ? { skipMaintenance: true } : {}),
|
||||
...(payload.takeCacheOwnership ? { takeCacheOwnership: true } : {}),
|
||||
skipMaintenance: true,
|
||||
takeCacheOwnership: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ const IMPLICIT_SAME_CHAT_APPROVAL_AUTHORIZATION = Symbol(
|
||||
"openclaw.implicitSameChatApprovalAuthorization",
|
||||
);
|
||||
|
||||
function markImplicitSameChatApprovalAuthorization(
|
||||
export function markImplicitSameChatApprovalAuthorization(
|
||||
result: ApprovalAuthorizationResult,
|
||||
): ApprovalAuthorizationResult {
|
||||
// Keep this non-enumerable to avoid changing auth payload shape.
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
export { resolveApprovalApprovers } from "./approval-approvers.js";
|
||||
export { createResolvedApproverActionAuthAdapter } from "./approval-auth-helpers.js";
|
||||
export {
|
||||
createResolvedApproverActionAuthAdapter,
|
||||
isImplicitSameChatApprovalAuthorization,
|
||||
markImplicitSameChatApprovalAuthorization,
|
||||
} from "./approval-auth-helpers.js";
|
||||
|
||||
@@ -572,6 +572,8 @@ describe("plugin-sdk subpath exports", () => {
|
||||
]);
|
||||
expectSourceMentions("approval-auth-runtime", [
|
||||
"createResolvedApproverActionAuthAdapter",
|
||||
"isImplicitSameChatApprovalAuthorization",
|
||||
"markImplicitSameChatApprovalAuthorization",
|
||||
"resolveApprovalApprovers",
|
||||
]);
|
||||
expectSourceMentions("reply-chunking", [
|
||||
|
||||
Reference in New Issue
Block a user