fix(approvals): bind native requests to channel accounts (#121673)

Native approval delivery and resolution now stay bound to the originating or explicitly targeted channel account. Unbound requests fail closed across multiple eligible accounts; trusted reviewer-less SDK callers remain compatible.

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Ayaan Zaidi
2026-08-11 01:42:41 +05:30
committed by GitHub
parent aa2a0006b1
commit 9935ca3b30
62 changed files with 1930 additions and 734 deletions
File diff suppressed because one or more lines are too long
+29 -2
View File
@@ -109,6 +109,33 @@ describe("createDiscordNativeApprovalAdapter", () => {
).toBe(true);
});
it("reports each configured account as a raw candidate for coordinator selection", () => {
const cfg = {
commands: { ownerAllowFrom: ["discord:123"] },
channels: {
discord: {
accounts: {
default: { token: "token-default", execApprovals: { enabled: true } },
ops: { token: "token-ops", execApprovals: { enabled: true } },
},
},
},
} as const;
const request = {
id: "approval-unbound",
request: { command: "pwd", turnSourceChannel: "discord" },
createdAtMs: 1,
expiresAtMs: 2,
} as const;
expect(
shouldHandleDiscordApprovalRequest({ cfg: cfg as never, accountId: "default", request }),
).toBe(true);
expect(
shouldHandleDiscordApprovalRequest({ cfg: cfg as never, accountId: "ops", request }),
).toBe(true);
});
it("describes the correct Discord exec-approval setup path", () => {
const text = getDiscordApprovalCapability().describeExecApprovalSetup?.({
channel: "discord",
@@ -299,7 +326,7 @@ describe("createDiscordNativeApprovalAdapter", () => {
const target = await adapter.native?.resolveOriginTarget?.({
cfg: NATIVE_DELIVERY_CFG as never,
accountId: "main",
accountId: "default",
approvalKind: "plugin",
request: {
id: "abc",
@@ -347,7 +374,7 @@ describe("createDiscordNativeApprovalAdapter", () => {
const target = await adapter.native?.resolveOriginTarget?.({
cfg: NATIVE_DELIVERY_CFG as never,
accountId: "main",
accountId: "default",
approvalKind: "plugin",
request: {
id: "abc",
+32 -27
View File
@@ -1,5 +1,5 @@
// Discord plugin module implements approval shared behavior.
import { doesApprovalRequestMatchChannelAccount } from "openclaw/plugin-sdk/approval-native-runtime";
import { doesApprovalRequestSelectChannelAccount } from "openclaw/plugin-sdk/approval-native-runtime";
import type {
ExecApprovalRequest,
PluginApprovalRequest,
@@ -8,7 +8,7 @@ import type {
DiscordExecApprovalConfig,
OpenClawConfig,
} from "openclaw/plugin-sdk/config-contracts";
import { resolveDiscordAccount } from "./accounts.js";
import { resolveDefaultDiscordAccountId, resolveDiscordAccount } from "./accounts.js";
import {
isChannelExecApprovalClientEnabledFromConfig,
matchesApprovalRequestFilters,
@@ -17,41 +17,46 @@ import { getDiscordExecApprovalApprovers } from "./exec-approvals.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
function isDiscordApprovalAccountEligible(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: ApprovalRequest;
configOverride?: DiscordExecApprovalConfig | null;
}): boolean {
const account = resolveDiscordAccount(params);
const config = params.configOverride ?? account.config.execApprovals;
return (
account.enabled &&
isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: getDiscordExecApprovalApprovers(params).length,
}) &&
matchesApprovalRequestFilters({
request: params.request.request,
agentFilter: config?.agentFilter,
sessionFilter: config?.sessionFilter,
})
);
}
export function shouldHandleDiscordApprovalRequest(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: ApprovalRequest;
configOverride?: DiscordExecApprovalConfig | null;
}): boolean {
const config =
params.configOverride ??
resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }).config.execApprovals;
const approvers = getDiscordExecApprovalApprovers({
cfg: params.cfg,
accountId: params.accountId,
configOverride: params.configOverride,
});
const accountId = params.accountId ?? resolveDefaultDiscordAccountId(params.cfg);
if (
!doesApprovalRequestMatchChannelAccount({
cfg: params.cfg,
request: params.request,
!doesApprovalRequestSelectChannelAccount({
...params,
channel: "discord",
accountId: params.accountId,
defaultAccountId: resolveDefaultDiscordAccountId(params.cfg),
eligibleAccountIds: isDiscordApprovalAccountEligible({ ...params, accountId })
? [accountId]
: [],
})
) {
return false;
}
if (
!isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: approvers.length,
})
) {
return false;
}
return matchesApprovalRequestFilters({
request: params.request.request,
agentFilter: config?.agentFilter,
sessionFilter: config?.sessionFilter,
});
return isDiscordApprovalAccountEligible(params);
}
@@ -141,7 +141,7 @@ describe("discord exec approval monitor helpers", () => {
await button.run(interaction, { kind: approvalKind, id: "abc", action: "allow-once" });
expect(interaction["acknowledge"]).toHaveBeenCalled();
expect(resolveApproval).toHaveBeenCalledWith("abc", approvalKind, "allow-once");
expect(resolveApproval).toHaveBeenCalledWith("abc", approvalKind, "allow-once", "123");
expect(JSON.stringify(editReply.mock.calls[0]?.[0])).toContain("Approval resolved");
expect(interaction["followUp"]).not.toHaveBeenCalled();
},
@@ -262,7 +262,7 @@ describe("discord exec approval monitor helpers", () => {
});
expect(ctx.getApprovers()).toEqual(["123"]);
await expect(ctx.resolveApproval("abc", approvalKind, "allow-once")).resolves.toEqual({
await expect(ctx.resolveApproval("abc", approvalKind, "allow-once", "123")).resolves.toEqual({
ok: true,
resolution,
});
@@ -272,7 +272,8 @@ describe("discord exec approval monitor helpers", () => {
approvalKind,
decision: "allow-once",
channel: "discord",
senderId: "default",
accountId: "default",
senderId: "123",
gatewayUrl: "ws://127.0.0.1:18789",
});
},
@@ -286,7 +287,7 @@ describe("discord exec approval monitor helpers", () => {
config: { enabled: true, approvers: ["123"] },
});
await expect(ctx.resolveApproval("abc", "exec", "allow-once")).resolves.toEqual({
await expect(ctx.resolveApproval("abc", "exec", "allow-once", "123")).resolves.toEqual({
ok: false,
reason: "error",
});
@@ -304,7 +305,7 @@ describe("discord exec approval monitor helpers", () => {
config: { enabled: true, approvers: ["123"] },
});
await expect(ctx.resolveApproval("abc", "plugin", "allow-once")).resolves.toEqual({
await expect(ctx.resolveApproval("abc", "plugin", "allow-once", "123")).resolves.toEqual({
ok: false,
reason: "not-found",
});
@@ -318,7 +319,7 @@ describe("discord exec approval monitor helpers", () => {
config: { enabled: true, approvers: ["123"] },
});
await expect(ctx.resolveApproval("abc", "exec", "allow-once")).resolves.toEqual({
await expect(ctx.resolveApproval("abc", "exec", "allow-once", "123")).resolves.toEqual({
ok: false,
reason: "error",
});
@@ -32,6 +32,7 @@ type ExecApprovalButtonContext = {
approvalId: string,
approvalKind: PendingApprovalView["approvalKind"],
decision: ExecApprovalDecision,
senderId: string,
) => Promise<ExecApprovalResolveResult>;
};
@@ -141,6 +142,7 @@ class ExecApprovalButton extends Button {
parsed.approvalId,
parsed.approvalKind,
parsed.action,
userId,
);
if (!result.ok) {
try {
@@ -197,7 +199,7 @@ export function createDiscordExecApprovalButtonContext(params: {
accountId: params.accountId,
configOverride: params.config,
}),
resolveApproval: async (approvalId, approvalKind, decision) => {
resolveApproval: async (approvalId, approvalKind, decision, senderId) => {
try {
const resolution = await resolveApprovalOverGateway({
cfg: params.cfg,
@@ -205,7 +207,8 @@ export function createDiscordExecApprovalButtonContext(params: {
approvalKind,
decision,
channel: "discord",
senderId: params.accountId,
accountId: params.accountId,
senderId,
gatewayUrl: params.gatewayUrl,
});
return { ok: true, resolution };
@@ -165,6 +165,7 @@ describe("maybeHandleGoogleChatApprovalCardClick", () => {
approvalKind: "exec",
decision: "allow-once",
channel: "googlechat",
accountId: "default",
senderId: "users/123",
});
expect(updateGoogleChatMessage).toHaveBeenCalledWith({
@@ -87,6 +87,7 @@ export async function maybeHandleGoogleChatApprovalCardClick(params: {
approvalKind: consumed.approvalKind,
decision: consumed.decision,
channel: "googlechat",
accountId: params.target.account.accountId,
senderId: actor,
});
await updateGoogleChatMessage({
@@ -543,6 +543,7 @@ export async function maybeResolveIMessageApprovalPollVote(params: {
approvalKind: target.approvalKind,
decision,
channel: "imessage",
accountId: params.accountId,
senderId: event.actorHandle,
gatewayUrl: params.gatewayUrl,
...(params.gatewayRuntime ? { gatewayRuntime: params.gatewayRuntime } : {}),
@@ -774,6 +774,7 @@ describe("iMessage approval reactions", () => {
approvalId: "exec-self",
decision: "allow-once",
channel: "imessage",
accountId: "default",
senderId: "+15551230000",
gatewayRuntime,
}),
@@ -857,6 +858,7 @@ describe("iMessage approval reactions", () => {
approvalKind: "exec",
decision: "allow-once",
channel: "imessage",
accountId: "default",
senderId: "+15551230000",
gatewayUrl: undefined,
});
@@ -901,6 +903,7 @@ describe("iMessage approval reactions", () => {
approvalKind: "exec",
decision: "allow-once",
channel: "imessage",
accountId: "default",
senderId: "+15551230000",
gatewayUrl: undefined,
});
@@ -959,6 +962,7 @@ describe("iMessage approval reactions", () => {
approvalKind: "exec",
decision: "allow-once",
channel: "imessage",
accountId: "default",
senderId: "+15551230000",
gatewayUrl: undefined,
});
@@ -1027,6 +1031,7 @@ describe("iMessage approval reactions", () => {
approvalKind: "plugin",
decision: "allow-once",
channel: "imessage",
accountId: "default",
senderId: "+15551230000",
gatewayUrl: undefined,
});
@@ -1067,6 +1072,7 @@ describe("iMessage approval reactions", () => {
approvalKind: "exec",
decision: "deny",
channel: "imessage",
accountId: "default",
senderId: "+15551239999",
gatewayUrl: undefined,
});
@@ -694,6 +694,7 @@ export async function handleIMessageApprovalReaction(params: {
approvalKind: target.approvalKind,
decision: target.decision,
channel: "imessage",
accountId: params.accountId,
senderId: event.actorHandle,
gatewayUrl: params.gatewayUrl,
...(params.gatewayRuntime ? { gatewayRuntime: params.gatewayRuntime } : {}),
+18 -3
View File
@@ -422,7 +422,7 @@ describe("matrix exec approvals", () => {
).toBe(true);
});
it("rejects unbound foreign-channel approvals in multi-account matrix configs", () => {
it("reports each eligible foreign-channel account as a raw route candidate", () => {
const cfg = buildMultiAccountMatrixConfig({});
const request = makeForeignChannelApprovalRequest({ id: "req-4" });
@@ -432,14 +432,29 @@ describe("matrix exec approvals", () => {
accountId: "default",
request,
}),
).toBe(false);
).toBe(true);
expect(
shouldHandleMatrixExecApprovalRequest({
cfg,
accountId: "ops",
request,
}),
).toBe(false);
).toBe(true);
});
it("reports each eligible same-channel account as a raw route candidate", () => {
const cfg = buildMultiAccountMatrixConfig({});
const request: MatrixExecApprovalRequest = {
id: "req-same-channel-unbound",
request: { command: "echo hi", turnSourceChannel: "matrix" },
createdAtMs: 0,
expiresAtMs: 1000,
};
expect(shouldHandleMatrixExecApprovalRequest({ cfg, accountId: "default", request })).toBe(
true,
);
expect(shouldHandleMatrixExecApprovalRequest({ cfg, accountId: "ops", request })).toBe(true);
});
it("allows unbound foreign-channel approvals when only one matrix account can handle them", () => {
+31 -60
View File
@@ -7,18 +7,16 @@ import {
isChannelExecApprovalTargetRecipient,
matchesApprovalRequestFilters,
} from "openclaw/plugin-sdk/approval-client-runtime";
import { resolveApprovalRequestChannelAccountId } from "openclaw/plugin-sdk/approval-native-runtime";
import { doesApprovalRequestSelectChannelAccount } from "openclaw/plugin-sdk/approval-native-runtime";
import type {
ExecApprovalRequest,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { getMatrixApprovalAuthApprovers } from "./approval-auth.js";
import { normalizeMatrixApproverId } from "./approval-ids.js";
import { listMatrixAccountIds, resolveMatrixAccount } from "./matrix/accounts.js";
import { resolveDefaultMatrixAccountId, resolveMatrixAccount } from "./matrix/accounts.js";
import type { CoreConfig } from "./types.js";
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
@@ -44,45 +42,31 @@ function resolveMatrixExecApprovalConfig(params: {
};
}
function countMatrixExecApprovalEligibleAccounts(params: {
function isMatrixExecApprovalAccountEligible(params: {
cfg: OpenClawConfig;
accountId: string;
request: ApprovalRequest;
approvalKind: ApprovalKind;
}): number {
return listMatrixAccountIds(params.cfg).filter((accountId) => {
const account = resolveMatrixAccount({ cfg: params.cfg, accountId });
if (!account.enabled || !account.configured) {
return false;
}
const config = resolveMatrixExecApprovalConfig({
cfg: params.cfg,
accountId,
});
const filters = config?.enabled
? {
agentFilter: config.agentFilter,
sessionFilter: config.sessionFilter,
}
: {
agentFilter: undefined,
sessionFilter: undefined,
};
return (
isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: getMatrixApprovalApprovers({
cfg: params.cfg,
accountId,
approvalKind: params.approvalKind,
}).length,
}) &&
matchesApprovalRequestFilters({
request: params.request.request,
agentFilter: filters.agentFilter,
sessionFilter: filters.sessionFilter,
})
);
}).length;
}): boolean {
const account = resolveMatrixAccount(params);
if (!account.enabled || !account.configured) {
return false;
}
const config = resolveMatrixExecApprovalConfig(params);
const filters = config?.enabled
? { agentFilter: config.agentFilter, sessionFilter: config.sessionFilter }
: { agentFilter: undefined, sessionFilter: undefined };
return (
isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: getMatrixApprovalApprovers(params).length,
}) &&
matchesApprovalRequestFilters({
request: params.request.request,
agentFilter: filters.agentFilter,
sessionFilter: filters.sessionFilter,
})
);
}
function matchesMatrixRequestAccount(params: {
@@ -91,28 +75,15 @@ function matchesMatrixRequestAccount(params: {
request: ApprovalRequest;
approvalKind: ApprovalKind;
}): boolean {
const turnSourceChannel = normalizeLowercaseStringOrEmpty(
params.request.request.turnSourceChannel,
);
const boundAccountId = resolveApprovalRequestChannelAccountId({
cfg: params.cfg,
request: params.request,
const accountId = params.accountId ?? resolveDefaultMatrixAccountId(params.cfg);
return doesApprovalRequestSelectChannelAccount({
...params,
channel: "matrix",
defaultAccountId: resolveDefaultMatrixAccountId(params.cfg),
eligibleAccountIds: isMatrixExecApprovalAccountEligible({ ...params, accountId })
? [accountId]
: [],
});
if (turnSourceChannel && turnSourceChannel !== "matrix" && !boundAccountId) {
return (
countMatrixExecApprovalEligibleAccounts({
cfg: params.cfg,
request: params.request,
approvalKind: params.approvalKind,
}) <= 1
);
}
return (
!boundAccountId ||
!params.accountId ||
normalizeAccountId(boundAccountId) === normalizeAccountId(params.accountId)
);
}
export function getMatrixExecApprovalApprovers(params: {
@@ -193,6 +193,7 @@ describe("matrix approval reactions", () => {
approvalKind: "exec",
decision: "allow-once",
channel: "matrix",
accountId: "default",
senderId: "@owner:example.org",
});
expect(core.system.enqueueSystemEvent).not.toHaveBeenCalled();
@@ -263,6 +264,7 @@ describe("matrix approval reactions", () => {
approvalKind: "exec",
decision: "deny",
channel: "matrix",
accountId: "default",
senderId: "@owner:example.org",
});
expect(core.system.enqueueSystemEvent).not.toHaveBeenCalled();
@@ -291,6 +293,7 @@ describe("matrix approval reactions", () => {
approvalKind: "exec",
decision: "allow-once",
channel: "matrix",
accountId: "default",
senderId: "@owner:example.org",
});
expect(core.system.enqueueSystemEvent).not.toHaveBeenCalled();
@@ -327,6 +330,7 @@ describe("matrix approval reactions", () => {
approvalKind: "plugin",
decision: "allow-once",
channel: "matrix",
accountId: "default",
senderId: "@owner:example.org",
});
expect(core.system.enqueueSystemEvent).not.toHaveBeenCalled();
@@ -129,6 +129,7 @@ async function maybeResolveMatrixApprovalReaction(params: {
approvalKind: params.target.approvalKind,
decision: params.target.decision,
channel: "matrix",
accountId: params.accountId,
senderId: params.senderId,
});
// Retire every delivered anchor; losing surfaces also need the canonical
@@ -88,6 +88,8 @@ describe("QQBot built-in platform adapter", () => {
approvalId: "exec:looks-like-exec/1",
approvalKind: "plugin",
decision: "allow-once",
accountId: "default",
senderId: "owner",
});
expect(mocks.resolveApprovalOverGateway).toHaveBeenCalledWith({
@@ -95,6 +97,9 @@ describe("QQBot built-in platform adapter", () => {
approvalId: "exec:looks-like-exec/1",
approvalKind: "plugin",
decision: "allow-once",
channel: "qqbot",
accountId: "default",
senderId: "owner",
clientDisplayName: "QQBot Approval Handler",
});
expect(result).toBe(canonicalLoserResult);
+18 -16
View File
@@ -1,3 +1,18 @@
import type { ApprovalResolveResult } from "openclaw/plugin-sdk/approval-gateway-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/secret-input";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import {
registerPlatformAdapter,
registerPlatformAdapterFactory,
hasPlatformAdapter,
type PlatformAdapter,
} from "../engine/adapter/index.js";
import type { FetchMediaOptions, FetchMediaResult } from "../engine/adapter/types.js";
/**
* Bootstrap the PlatformAdapter for the built-in version.
*
@@ -22,22 +37,6 @@
* statically at the top level so they work reliably in both production and
* vitest (which resolves bare specifiers via `resolve.alias`, not Node CJS).
*/
import type { ApprovalResolveResult } from "openclaw/plugin-sdk/approval-gateway-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/secret-input";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import {
registerPlatformAdapter,
registerPlatformAdapterFactory,
hasPlatformAdapter,
type PlatformAdapter,
} from "../engine/adapter/index.js";
import type { FetchMediaOptions, FetchMediaResult } from "../engine/adapter/types.js";
import { getBridgeLogger } from "./logger.js";
const loadReadRemoteMediaBuffer = createLazyRuntimeNamedExport(
@@ -113,6 +112,9 @@ function createBuiltinAdapter(): PlatformAdapter {
approvalId: params.approvalId,
approvalKind: params.approvalKind,
decision: params.decision,
channel: "qqbot",
accountId: params.accountId,
senderId: params.senderId,
clientDisplayName: "QQBot Approval Handler",
});
} catch (err) {
@@ -51,6 +51,8 @@ export interface PlatformAdapter {
approvalId: string;
approvalKind: "exec" | "plugin";
decision: "allow-once" | "allow-always" | "deny";
accountId: string;
senderId: string;
}): Promise<ApprovalResolveResult>;
}
@@ -46,11 +46,14 @@ const appliedApprovalResult = {
const resolveApprovalMock = vi.fn(
async (): Promise<ApprovalResolveResult> => appliedApprovalResult,
);
const expectedApprovalResolve = {
approvalId: "exec:abc12345",
approvalKind: "exec",
decision: "allow-once",
} as const;
const expectedApprovalResolve = (senderId = "ATTACKER_OPENID") =>
({
approvalId: "exec:abc12345",
approvalKind: "exec",
decision: "allow-once",
accountId: "default",
senderId,
}) as const;
function makeAccount(config: GatewayAccount["config"] = {}): GatewayAccount {
return {
@@ -190,7 +193,7 @@ describe("createInteractionHandler approval buttons", () => {
handler(makeApprovalEvent({ group_member_openid: "OWNER_OPENID" }));
await waitForQqInteraction(() =>
expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve),
expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve("OWNER_OPENID")),
);
});
@@ -217,6 +220,8 @@ describe("createInteractionHandler approval buttons", () => {
approvalId: "exec:looks-like-exec/1",
approvalKind: "plugin",
decision: "deny",
accountId: "default",
senderId: "OWNER_OPENID",
}),
);
});
@@ -340,7 +345,7 @@ describe("createInteractionHandler approval buttons", () => {
);
await waitForQqInteraction(() =>
expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve),
expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve("OWNER_OPENID")),
);
});
@@ -352,7 +357,7 @@ describe("createInteractionHandler approval buttons", () => {
handler(makeApprovalEvent());
await waitForQqInteraction(() =>
expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve),
expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve()),
);
});
@@ -418,7 +423,10 @@ describe("createInteractionHandler approval buttons", () => {
handler(makeApprovalEvent());
await waitForQqInteraction(() =>
expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve),
expect(resolveApprovalMock).toHaveBeenCalledWith({
...expectedApprovalResolve(),
accountId: "bot2",
}),
);
});
@@ -449,7 +457,7 @@ describe("createInteractionHandler approval buttons", () => {
handler(makeApprovalEvent());
await waitForQqInteraction(() =>
expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve),
expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve()),
);
});
@@ -474,7 +482,7 @@ describe("createInteractionHandler approval buttons", () => {
handler(makeApprovalEvent());
await waitForQqInteraction(() =>
expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve),
expect(resolveApprovalMock).toHaveBeenCalledWith(expectedApprovalResolve()),
);
});
@@ -290,7 +290,11 @@ async function handleApprovalButtonInteraction(params: {
}
try {
const result = await adapter.resolveApproval(params.parsed);
const result = await adapter.resolveApproval({
...params.parsed,
accountId: params.account.accountId,
senderId: authorization.senderId,
});
const canonicalDecision =
"decision" in result.approval ? `, decision=${result.approval.decision}` : "";
const canonicalOutcome = formatCanonicalApprovalOutcome(result.approval);
@@ -379,21 +383,13 @@ async function authorizeApprovalButtonActor(params: {
event: InteractionEvent;
approvalKind: "exec" | "plugin";
resolveCommandAuthorized?: QQBotCommandAuthorizationResolver;
}): Promise<{ authorized: boolean; reason?: string }> {
}): Promise<{ authorized: true; senderId: string } | { authorized: false; reason?: string }> {
const senderIds = resolveApprovalActorSenderIds(params.event);
if (senderIds.length === 0) {
const result = authorizeQQBotApprovalAction({
cfg: params.cfg,
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;
return { authorized: false, reason: "You are not authorized to approve this request." };
}
let denial: { authorized: boolean; reason?: string } | undefined;
let denial: { authorized: false; reason?: string } | undefined;
for (const senderId of senderIds) {
const result = authorizeQQBotApprovalAction({
cfg: params.cfg,
@@ -412,7 +408,7 @@ async function authorizeApprovalButtonActor(params: {
resolveCommandAuthorized: params.resolveCommandAuthorized,
}))
) {
return result;
return { authorized: true, senderId };
}
denial ??= {
authorized: false,
@@ -420,7 +416,7 @@ async function authorizeApprovalButtonActor(params: {
};
continue;
}
denial ??= result;
denial ??= { authorized: false, ...(result.reason ? { reason: result.reason } : {}) };
}
return denial ?? { authorized: false, reason: "You are not authorized to approve this request." };
}
+29 -1
View File
@@ -3,7 +3,7 @@ import { isImplicitSameChatApprovalAuthorization } from "openclaw/plugin-sdk/app
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";
import { authorizeQQBotApprovalAction, matchesQQBotApprovalAccount } from "./exec-approvals.js";
describe("authorizeQQBotApprovalAction", () => {
beforeEach(() => {
@@ -66,4 +66,32 @@ describe("authorizeQQBotApprovalAction", () => {
expect(result).toEqual({ authorized: true });
expect(isImplicitSameChatApprovalAuthorization(result)).toBe(false);
});
it("reports each configured account as a raw route candidate", () => {
const cfg = {
channels: {
qqbot: {
accounts: {
default: {
appId: "default-app",
clientSecret: "default-secret",
execApprovals: { enabled: true, approvers: ["OWNER"] },
},
ops: {
appId: "ops-app",
clientSecret: "ops-secret",
execApprovals: { enabled: true, approvers: ["OWNER"] },
},
},
},
},
} as OpenClawConfig;
const request = {
id: "req-unbound",
request: { command: "echo hi", turnSourceChannel: "qqbot" },
};
expect(matchesQQBotApprovalAccount({ cfg, accountId: "default", request })).toBe(true);
expect(matchesQQBotApprovalAccount({ cfg, accountId: "ops", request })).toBe(true);
});
});
+36 -95
View File
@@ -8,18 +8,14 @@ import {
isChannelExecApprovalClientEnabledFromConfig,
matchesApprovalRequestFilters,
} from "openclaw/plugin-sdk/approval-client-runtime";
import { resolveApprovalRequestChannelAccountId } from "openclaw/plugin-sdk/approval-native-runtime";
import { doesApprovalRequestSelectChannelAccount } from "openclaw/plugin-sdk/approval-native-runtime";
import type {
ExecApprovalRequest,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/approval-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { listQQBotAccountIds, resolveQQBotAccount } from "./bridge/config.js";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveDefaultQQBotAccountId, resolveQQBotAccount } from "./bridge/config.js";
import type { QQBotExecApprovalConfig } from "./types.js";
function normalizeApproverId(value: string | number): string | undefined {
@@ -54,32 +50,28 @@ function getQQBotExecApprovalApprovers(params: {
});
}
function countQQBotExecApprovalEligibleAccounts(params: {
function isQQBotExecApprovalAccountEligible(params: {
cfg: OpenClawConfig;
accountId: string;
request: ExecApprovalRequest | PluginApprovalRequest;
}): number {
return listQQBotAccountIds(params.cfg).filter((accountId) => {
const account = resolveQQBotAccount(params.cfg, accountId);
if (!account.enabled || account.secretSource === "none") {
return false;
}
const config = resolveQQBotExecApprovalConfig({
cfg: params.cfg,
accountId,
});
return (
isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: getQQBotExecApprovalApprovers({ cfg: params.cfg, accountId }).length,
}) &&
matchesApprovalRequestFilters({
request: params.request.request,
agentFilter: config?.agentFilter,
sessionFilter: config?.sessionFilter,
fallbackAgentIdFromSessionKey: true,
})
);
}).length;
}): boolean {
const account = resolveQQBotAccount(params.cfg, params.accountId);
if (!account.enabled || account.secretSource === "none") {
return false;
}
const config = resolveQQBotExecApprovalConfig(params);
return (
isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: getQQBotExecApprovalApprovers(params).length,
}) &&
matchesApprovalRequestFilters({
request: params.request.request,
agentFilter: config?.agentFilter,
sessionFilter: config?.sessionFilter,
fallbackAgentIdFromSessionKey: true,
})
);
}
function matchesQQBotRequestAccount(params: {
@@ -87,81 +79,30 @@ function matchesQQBotRequestAccount(params: {
accountId?: string | null;
request: ExecApprovalRequest | PluginApprovalRequest;
}): boolean {
const turnSourceChannel = normalizeLowercaseStringOrEmpty(
params.request.request.turnSourceChannel,
);
const boundAccountId = resolveApprovalRequestChannelAccountId({
cfg: params.cfg,
request: params.request,
const accountId = params.accountId ?? resolveDefaultQQBotAccountId(params.cfg);
return doesApprovalRequestSelectChannelAccount({
...params,
channel: "qqbot",
defaultAccountId: resolveDefaultQQBotAccountId(params.cfg),
eligibleAccountIds: isQQBotExecApprovalAccountEligible({ ...params, accountId })
? [accountId]
: [],
});
if (turnSourceChannel && turnSourceChannel !== "qqbot" && !boundAccountId) {
return (
countQQBotExecApprovalEligibleAccounts({
cfg: params.cfg,
request: params.request,
}) <= 1
);
}
return (
!boundAccountId ||
!params.accountId ||
normalizeAccountId(boundAccountId) === normalizeAccountId(params.accountId)
);
}
/**
* Count QQBot accounts that could actually deliver a native approval
* message — i.e. accounts that are enabled and have resolvable secrets.
* Disabled or unconfigured accounts never spawn a handler, so they
* must not contribute to the single-account shortcut in the fallback
* ownership check below.
*/
function countQQBotFallbackEligibleAccounts(cfg: OpenClawConfig): number {
return listQQBotAccountIds(cfg).filter((accountId) => {
const account = resolveQQBotAccount(cfg, accountId);
return account.enabled && account.secretSource !== "none";
}).length;
}
/**
* Fallback account-ownership check — applied when `execApprovals` is NOT
* configured for any QQBot account. In this mode every enabled account
* handler would otherwise race to deliver the same approval to its own
* openid namespace, so we must enforce per-account isolation.
*
* Rules:
* - If the request carries a bound account (via `turnSourceAccountId`
* or session binding), only the handler whose `accountId` matches it
* delivers the approval. This is strict: a handler with an unknown
* `accountId` (null/undefined) must not claim a bound request.
* - If no account is bound, only deliver when there is a single
* *eligible* QQBot account (enabled + secret resolved). Disabled or
* unconfigured accounts never deliver anyway, so they shouldn't
* block the remaining single account from handling the approval.
* Multiple eligible accounts cannot safely race because openids are
* account-scoped — cross-account delivery hits the QQ Bot API with
* a mismatched token and fails.
*/
function matchesQQBotFallbackRequestAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: ExecApprovalRequest | PluginApprovalRequest;
}): boolean {
const boundAccountId = resolveApprovalRequestChannelAccountId({
cfg: params.cfg,
request: params.request,
const accountId = params.accountId ?? resolveDefaultQQBotAccountId(params.cfg);
const account = resolveQQBotAccount(params.cfg, accountId);
return doesApprovalRequestSelectChannelAccount({
...params,
channel: "qqbot",
defaultAccountId: resolveDefaultQQBotAccountId(params.cfg),
eligibleAccountIds: account.enabled && account.secretSource !== "none" ? [accountId] : [],
});
if (boundAccountId) {
if (!params.accountId) {
return false;
}
return normalizeAccountId(boundAccountId) === normalizeAccountId(params.accountId);
}
return countQQBotFallbackEligibleAccounts(params.cfg) <= 1;
}
/**
@@ -761,6 +761,7 @@ describe("Signal approval reactions", () => {
approvalKind: "plugin",
decision: "allow-once",
channel: "signal",
accountId: "default",
senderId: "+15551230000",
gatewayUrl: undefined,
});
@@ -822,6 +823,7 @@ describe("Signal approval reactions", () => {
approvalKind: "exec",
decision: "allow-once",
channel: "signal",
accountId: "default",
senderId: "+15551230000",
gatewayUrl: undefined,
});
@@ -917,6 +917,7 @@ export async function maybeResolveSignalApprovalReaction(params: {
approvalKind: target.approvalKind,
decision: target.decision,
channel: "signal",
accountId: params.accountId,
senderId: actorId,
gatewayUrl: params.gatewayUrl,
});
+46 -37
View File
@@ -5,7 +5,7 @@ import {
} from "openclaw/plugin-sdk/approval-client-runtime";
import {
createNativeApprovalChannelRouteGates,
doesApprovalRequestMatchChannelAccount,
doesApprovalRequestSelectChannelAccount,
resolveApprovalRequestSessionConversation,
} from "openclaw/plugin-sdk/approval-native-runtime";
import type {
@@ -311,29 +311,19 @@ function shouldHandleSlackPluginViaNativeClientConfig(params: {
request: SlackNativeApprovalRequest;
}): boolean {
if (
!doesApprovalRequestMatchChannelAccount({
cfg: params.cfg,
request: params.request,
!doesApprovalRequestSelectChannelAccount({
...params,
channel: "slack",
accountId: params.accountId,
defaultAccountId: resolveDefaultSlackAccountId(params.cfg),
eligibleAccountIds: listSlackNativeApprovalEligibleAccountIds({
...params,
approvalKind: "plugin",
}),
})
) {
return false;
}
const config = resolveSlackNativeApprovalConfig(params);
if (
!isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: getSlackApprovalApprovers(params).length,
})
) {
return false;
}
return matchesSlackNativeApprovalFilters({
request: params.request,
agentFilter: config?.agentFilter,
sessionFilter: config?.sessionFilter,
});
return isSlackNativeApprovalAccountEligible({ ...params, approvalKind: "plugin" });
}
function matchesSlackNativeApprovalFilters(params: {
@@ -348,6 +338,35 @@ function matchesSlackNativeApprovalFilters(params: {
});
}
function isSlackNativeApprovalAccountEligible(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: SlackNativeApprovalRequest;
approvalKind: SlackApprovalKind;
}): boolean {
const config = resolveSlackNativeApprovalConfig(params);
const approverCount =
params.approvalKind === "exec"
? getSlackExecApprovalApprovers(params).length
: getSlackApprovalApprovers(params).length;
return (
isSlackApprovalTransportEnabled(params) &&
isChannelExecApprovalClientEnabledFromConfig({ enabled: config?.enabled, approverCount }) &&
matchesSlackNativeApprovalFilters({
request: params.request,
agentFilter: config?.agentFilter,
sessionFilter: config?.sessionFilter,
})
);
}
function listSlackNativeApprovalEligibleAccountIds(
params: Parameters<typeof isSlackNativeApprovalAccountEligible>[0],
): string[] {
const accountId = params.accountId ?? resolveDefaultSlackAccountId(params.cfg);
return isSlackNativeApprovalAccountEligible({ ...params, accountId }) ? [accountId] : [];
}
function isAnyForwardedSlackExplicitTargetEligible(params: {
cfg: OpenClawConfig;
accountId?: string | null;
@@ -435,29 +454,19 @@ export function shouldHandleSlackNativeApprovalRequest(params: {
);
}
if (
!doesApprovalRequestMatchChannelAccount({
cfg: params.cfg,
request: params.request,
!doesApprovalRequestSelectChannelAccount({
...params,
channel: "slack",
accountId: params.accountId,
defaultAccountId: resolveDefaultSlackAccountId(params.cfg),
eligibleAccountIds: listSlackNativeApprovalEligibleAccountIds({
...params,
approvalKind: "exec",
}),
})
) {
return false;
}
const config = resolveSlackNativeApprovalConfig(params);
if (
!isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: getSlackExecApprovalApprovers(params).length,
})
) {
return false;
}
return matchesSlackNativeApprovalFilters({
request: params.request,
agentFilter: config?.agentFilter,
sessionFilter: config?.sessionFilter,
});
return isSlackNativeApprovalAccountEligible({ ...params, approvalKind: "exec" });
}
export function resolveEnterpriseApprovalTeamId(
@@ -107,6 +107,50 @@ async function resolvePluginOriginTarget(sessionKey: string) {
}
describe("slack native approval adapter", () => {
it("reports each configured account as a raw route candidate", () => {
const cfg = {
channels: {
slack: {
accounts: {
default: {
botToken: "xoxb-default",
appToken: "xapp-default",
execApprovals: { enabled: true, approvers: ["U123APPROVER"] },
},
ops: {
botToken: "xoxb-ops",
appToken: "xapp-ops",
execApprovals: { enabled: true, approvers: ["U123APPROVER"] },
},
},
},
},
} as OpenClawConfig;
const request = {
id: "req-unbound",
request: { command: "echo hi", turnSourceChannel: "slack" },
createdAtMs: 0,
expiresAtMs: 1000,
};
expect(
slackApprovalCapability.nativeRuntime?.availability.shouldHandle({
cfg,
accountId: "default",
approvalKind: "exec",
request,
}),
).toBe(true);
expect(
slackApprovalCapability.nativeRuntime?.availability.shouldHandle({
cfg,
accountId: "ops",
approvalKind: "exec",
request,
}),
).toBe(true);
});
it("subscribes the native runtime to exec and plugin approval events", () => {
expect(slackApprovalCapability.nativeRuntime?.eventKinds).toEqual(["exec", "plugin"]);
});
-8
View File
@@ -4,7 +4,6 @@ import {
createChannelExecApprovalProfile,
isChannelExecApprovalTargetRecipient,
} from "openclaw/plugin-sdk/approval-client-runtime";
import { doesApprovalRequestMatchChannelAccount } from "openclaw/plugin-sdk/approval-native-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { normalizeStringifiedOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveSlackAccount } from "./accounts.js";
@@ -70,13 +69,6 @@ const slackExecApprovalProfile = createChannelExecApprovalProfile({
resolveApprovers: getSlackExecApprovalApprovers,
normalizeSenderId: normalizeSlackApproverId,
isTargetRecipient: isSlackExecApprovalTargetRecipient,
matchesRequestAccount: (params) =>
doesApprovalRequestMatchChannelAccount({
cfg: params.cfg,
request: params.request,
channel: "slack",
accountId: params.accountId,
}),
});
export const isSlackExecApprovalClientEnabled = slackExecApprovalProfile.isClientEnabled;
@@ -657,6 +657,7 @@ async function handleSlackApprovalInteraction(params: {
approvalKind: params.approval.approvalKind,
decision: params.approval.decision,
channel: "slack",
accountId: params.ctx.accountId,
senderId: params.parsed.userId,
});
const terminalLabel = resolveSlackApprovalTerminalLabel(result.approval);
@@ -752,6 +753,7 @@ async function handleSlackLegacyApprovalInteraction(params: {
approvalId: parsedApproval.approvalId,
decision: parsedApproval.decision,
channel: "slack",
accountId: params.ctx.accountId,
senderId: params.parsed.userId,
resolveMethod,
});
@@ -1559,6 +1559,7 @@ describe("registerSlackInteractionEvents", () => {
decision: "allow-once",
senderId: "U123",
channel: "slack",
accountId: "default",
});
expect(resolvePluginConversationBindingApprovalMock).not.toHaveBeenCalled();
expect(dispatchPluginInteractiveHandlerMock).not.toHaveBeenCalled();
@@ -1687,6 +1688,7 @@ describe("registerSlackInteractionEvents", () => {
decision: "allow-once",
senderId: "U123",
channel: "slack",
accountId: "default",
});
expectRecordFields(chatUpdateCall(app), {
channel: "C1",
@@ -1890,6 +1892,7 @@ describe("registerSlackInteractionEvents", () => {
decision: "allow-always",
senderId: "U123OWNER",
channel: "slack",
accountId: "default",
});
expect(resolvePluginConversationBindingApprovalMock).not.toHaveBeenCalled();
expect(dispatchPluginInteractiveHandlerMock).not.toHaveBeenCalled();
@@ -1969,6 +1972,7 @@ describe("registerSlackInteractionEvents", () => {
senderId: "U123OWNER",
resolveMethod: "plugin",
channel: "slack",
accountId: "default",
});
expect(resolvePluginConversationBindingApprovalMock).not.toHaveBeenCalled();
expect(dispatchPluginInteractiveHandlerMock).not.toHaveBeenCalled();
@@ -2042,6 +2046,7 @@ describe("registerSlackInteractionEvents", () => {
decision: "allow-once",
senderId: "U123OWNER",
channel: "slack",
accountId: "default",
};
expect(resolveApprovalOverGatewayMock).toHaveBeenNthCalledWith(1, {
...expectedCommon,
@@ -2121,6 +2126,7 @@ describe("registerSlackInteractionEvents", () => {
senderId: "U999EXEC",
resolveMethod: "exec",
channel: "slack",
accountId: "default",
});
expect(resolvePluginConversationBindingApprovalMock).not.toHaveBeenCalled();
expect(dispatchPluginInteractiveHandlerMock).not.toHaveBeenCalled();
@@ -100,6 +100,7 @@ export function createTelegramCallbackApprovalRuntime(params: {
approvalKind: approvalCallback.approvalKind,
decision: approvalCallback.decision,
channel: "telegram",
accountId,
senderId,
})) as ApprovalResolveResult;
@@ -197,6 +198,7 @@ export function createTelegramCallbackApprovalRuntime(params: {
approvalId: approvalCallback.approvalId,
decision: approvalCallback.decision,
channel: "telegram",
accountId,
senderId,
resolveMethod: approvalKind,
});
+40 -4
View File
@@ -273,7 +273,7 @@ describe("telegram exec approvals", () => {
).toBe(true);
});
it("rejects unbound foreign-channel approvals in multi-account telegram configs", () => {
it("reports each eligible foreign-channel account as a raw route candidate", () => {
const cfg = buildMultiAccountTelegramConfig({});
const request = makeForeignChannelApprovalRequest({ id: "req-3" });
@@ -283,14 +283,33 @@ describe("telegram exec approvals", () => {
accountId: "default",
request,
}),
).toBe(false);
).toBe(true);
expect(
shouldHandleTelegramExecApprovalRequest({
cfg,
accountId: "ops",
request,
}),
).toBe(false);
).toBe(true);
});
it("reports each eligible same-channel account as a raw route candidate", () => {
const cfg = buildMultiAccountTelegramConfig({});
const request: TelegramExecApprovalRequest = {
id: "req-same-channel-unbound",
request: {
command: "echo hi",
turnSourceChannel: "telegram",
sessionKey: "agent:ops:missing",
},
createdAtMs: 0,
expiresAtMs: 1000,
};
expect(shouldHandleTelegramExecApprovalRequest({ cfg, accountId: "default", request })).toBe(
true,
);
expect(shouldHandleTelegramExecApprovalRequest({ cfg, accountId: "ops", request })).toBe(true);
});
it("allows unbound foreign-channel approvals when only one telegram account can handle them", () => {
@@ -384,8 +403,18 @@ describe("telegram exec approvals", () => {
});
it("preserves unscoped telegram targets when mixed with scoped target accountIds", () => {
const baseCfg = buildMultiAccountTelegramConfig({});
const cfg = {
...buildMultiAccountTelegramConfig({}),
...baseCfg,
channels: {
telegram: {
...baseCfg.channels?.telegram,
accounts: {
...baseCfg.channels?.telegram?.accounts,
other: telegramAccount("other", { enabled: true, approvers: ["123"] }),
},
},
},
approvals: {
exec: {
enabled: true,
@@ -421,6 +450,13 @@ describe("telegram exec approvals", () => {
request,
}),
).toBe(true);
expect(
shouldHandleTelegramExecApprovalRequest({
cfg,
accountId: "other",
request,
}),
).toBe(false);
});
it("ignores disabled telegram accounts when checking foreign-channel ambiguity", () => {
+27 -93
View File
@@ -6,7 +6,7 @@ import {
isChannelExecApprovalTargetRecipient,
matchesApprovalRequestFilters,
} from "openclaw/plugin-sdk/approval-client-runtime";
import { resolveApprovalRequestChannelAccountId } from "openclaw/plugin-sdk/approval-native-runtime";
import { doesApprovalRequestSelectChannelAccount } from "openclaw/plugin-sdk/approval-native-runtime";
import type {
ExecApprovalRequest,
PluginApprovalRequest,
@@ -14,12 +14,8 @@ import type {
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { TelegramExecApprovalConfig } from "openclaw/plugin-sdk/config-contracts";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveDefaultTelegramAccountId, resolveTelegramAccount } from "./accounts.js";
import { normalizeTelegramChatId, resolveTelegramTargetChatType } from "./targets.js";
function normalizeApproverId(value: string | number): string {
@@ -83,73 +79,27 @@ export function isTelegramExecApprovalTargetRecipient(params: {
});
}
function countTelegramExecApprovalEligibleAccounts(params: {
function isTelegramExecApprovalAccountEligible(params: {
cfg: OpenClawConfig;
accountId: string;
request: ExecApprovalRequest | PluginApprovalRequest;
}): number {
return listTelegramAccountIds(params.cfg).filter((accountId) => {
const account = resolveTelegramAccount({ cfg: params.cfg, accountId });
if (!account.enabled || account.tokenSource === "none") {
return false;
}
const config = resolveTelegramExecApprovalConfig({
cfg: params.cfg,
accountId,
});
return (
isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: getTelegramExecApprovalApprovers({ cfg: params.cfg, accountId }).length,
}) &&
matchesApprovalRequestFilters({
request: params.request.request,
agentFilter: config?.agentFilter,
sessionFilter: config?.sessionFilter,
fallbackAgentIdFromSessionKey: true,
})
);
}).length;
}
function isExecApprovalRequest(
request: ExecApprovalRequest | PluginApprovalRequest,
): request is ExecApprovalRequest {
return "command" in request.request;
}
function isTargetForwardingMode(mode?: string): boolean {
return mode === "targets" || mode === "both";
}
function matchesExplicitTelegramForwardTargetAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
request: ExecApprovalRequest | PluginApprovalRequest;
}): boolean | undefined {
const forwardingConfig = isExecApprovalRequest(params.request)
? params.cfg.approvals?.exec
: params.cfg.approvals?.plugin;
if (!forwardingConfig?.enabled || !isTargetForwardingMode(forwardingConfig.mode)) {
return undefined;
}): boolean {
const account = resolveTelegramAccount(params);
if (!account.enabled || account.tokenSource === "none") {
return false;
}
const telegramTargets = (forwardingConfig.targets ?? []).filter(
(target) => normalizeLowercaseStringOrEmpty(target.channel) === "telegram",
);
if (telegramTargets.some((target) => !normalizeOptionalString(target.accountId))) {
return undefined;
}
const scopedTelegramAccountIds = telegramTargets
.map((target) => normalizeOptionalString(target.accountId))
.filter((accountId): accountId is string => Boolean(accountId));
if (scopedTelegramAccountIds.length === 0) {
return undefined;
}
const normalizedAccountId = params.accountId ? normalizeAccountId(params.accountId) : "";
const config = resolveTelegramExecApprovalConfig(params);
return (
Boolean(normalizedAccountId) &&
scopedTelegramAccountIds.some(
(accountId) => normalizeAccountId(accountId) === normalizedAccountId,
)
isChannelExecApprovalClientEnabledFromConfig({
enabled: config?.enabled,
approverCount: getTelegramExecApprovalApprovers(params).length,
}) &&
matchesApprovalRequestFilters({
request: params.request.request,
agentFilter: config?.agentFilter,
sessionFilter: config?.sessionFilter,
fallbackAgentIdFromSessionKey: true,
})
);
}
@@ -158,31 +108,15 @@ function matchesTelegramRequestAccount(params: {
accountId?: string | null;
request: ExecApprovalRequest | PluginApprovalRequest;
}): boolean {
const explicitTargetMatch = matchesExplicitTelegramForwardTargetAccount(params);
if (explicitTargetMatch !== undefined) {
return explicitTargetMatch;
}
const turnSourceChannel = normalizeLowercaseStringOrEmpty(
params.request.request.turnSourceChannel,
);
const boundAccountId = resolveApprovalRequestChannelAccountId({
cfg: params.cfg,
request: params.request,
const accountId = params.accountId ?? resolveDefaultTelegramAccountId(params.cfg);
return doesApprovalRequestSelectChannelAccount({
...params,
channel: "telegram",
defaultAccountId: resolveDefaultTelegramAccountId(params.cfg),
eligibleAccountIds: isTelegramExecApprovalAccountEligible({ ...params, accountId })
? [accountId]
: [],
});
if (turnSourceChannel && turnSourceChannel !== "telegram" && !boundAccountId) {
return (
countTelegramExecApprovalEligibleAccounts({
cfg: params.cfg,
request: params.request,
}) <= 1
);
}
return (
!boundAccountId ||
!params.accountId ||
normalizeAccountId(boundAccountId) === normalizeAccountId(params.accountId)
);
}
const telegramExecApprovalProfile = createChannelExecApprovalProfile({
@@ -178,6 +178,7 @@ describe("WhatsApp approval reactions", () => {
approvalKind: "plugin",
decision: "allow-once",
channel: "whatsapp",
accountId: "default",
senderId: "+15551230000",
gatewayUrl: undefined,
});
@@ -255,6 +256,7 @@ describe("WhatsApp approval reactions", () => {
approvalKind: "exec",
decision: "allow-once",
channel: "whatsapp",
accountId: "default",
senderId: "+15551230001",
gatewayUrl: undefined,
});
@@ -320,6 +322,7 @@ describe("WhatsApp approval reactions", () => {
approvalKind: "exec",
decision: "allow-once",
channel: "whatsapp",
accountId: "default",
senderId: testCase.actorId,
gatewayUrl: undefined,
});
@@ -518,6 +518,7 @@ export async function maybeResolveWhatsAppApprovalReaction(params: {
approvalKind: target.approvalKind,
decision: target.decision,
channel: "whatsapp",
accountId: params.accountId,
senderId: actorId,
gatewayUrl: params.gatewayUrl,
});
@@ -6,6 +6,8 @@ import {
validateApprovalHistoryResult,
validateApprovalPresentation,
validateApprovalResolveParams,
validateExecApprovalResolveParams,
validatePluginApprovalResolveParams,
validateApprovalResolveResult,
} from "./index.js";
@@ -101,6 +103,32 @@ describe("unified approval protocol validators", () => {
).toBe(false);
});
it("accepts only complete channel reviewer facts on every resolve surface", () => {
const reviewer = { channel: "telegram", accountId: "ops", senderId: "owner" };
expect(
validateApprovalResolveParams({
id: execRecord.id,
kind: "exec",
decision: "deny",
reviewer,
}),
).toBe(true);
expect(
validateExecApprovalResolveParams({ id: execRecord.id, decision: "deny", reviewer }),
).toBe(true);
expect(
validatePluginApprovalResolveParams({ id: pluginRecord.id, decision: "deny", reviewer }),
).toBe(true);
expect(
validateApprovalResolveParams({
id: execRecord.id,
kind: "exec",
decision: "deny",
reviewer: { channel: "telegram", accountId: "ops" },
}),
).toBe(false);
});
it("validates pending and every fail-closed terminal state", () => {
const pending = { ...execRecord, status: "pending" } as const;
const allowed = {
@@ -243,10 +243,17 @@ export const ApprovalHistoryResultSchema = closedObject({
});
/** Reviewer decision for one approval identified by its exact full id. */
export const ApprovalChannelReviewerSchema = closedObject({
channel: NonEmptyString,
accountId: NonEmptyString,
senderId: NonEmptyString,
});
export const ApprovalResolveParamsSchema = closedObject({
id: ApprovalRecordCommonFields.id,
kind: ApprovalKindSchema,
decision: ApprovalDecisionSchema,
reviewer: Type.Optional(ApprovalChannelReviewerSchema),
});
/** First-answer outcome plus the canonical recorded state returned to all contenders. */
@@ -315,6 +322,7 @@ export type ApprovalGetParams = Static<typeof ApprovalGetParamsSchema>;
export type ApprovalGetResult = Static<typeof ApprovalGetResultSchema>;
export type ApprovalHistoryParams = Static<typeof ApprovalHistoryParamsSchema>;
export type ApprovalHistoryResult = Static<typeof ApprovalHistoryResultSchema>;
export type ApprovalChannelReviewer = Static<typeof ApprovalChannelReviewerSchema>;
export type ApprovalResolveParams = Static<typeof ApprovalResolveParamsSchema>;
export type ApprovalResolveResult = Static<typeof ApprovalResolveResultSchema>;
export type AllowedApprovalSnapshot = Static<typeof AllowedApprovalSnapshotSchema>;
@@ -1,6 +1,7 @@
import type { Static } from "typebox";
// Gateway Protocol schema module defines protocol validation shapes.
import { Type } from "typebox";
import { ApprovalChannelReviewerSchema } from "./approvals.js";
import { closedObject } from "./closed-object.js";
import { NonEmptyString } from "./primitives.js";
@@ -311,6 +312,7 @@ export const ExecApprovalRequestParamsSchema = closedObject({
export const ExecApprovalResolveParamsSchema = closedObject({
id: NonEmptyString,
decision: NonEmptyString,
reviewer: Type.Optional(ApprovalChannelReviewerSchema),
});
// Owner-local wire types derived directly from local schema consts so the
@@ -1,6 +1,7 @@
import type { Static } from "typebox";
// Gateway Protocol schema module defines protocol validation shapes.
import { Type } from "typebox";
import { ApprovalChannelReviewerSchema } from "./approvals.js";
import { closedObject } from "./closed-object.js";
import { NonEmptyString } from "./primitives.js";
@@ -56,6 +57,7 @@ export const PluginApprovalRequestParamsSchema = closedObject({
export const PluginApprovalResolveParamsSchema = closedObject({
id: NonEmptyString,
decision: NonEmptyString,
reviewer: Type.Optional(ApprovalChannelReviewerSchema),
});
// Owner-local wire types derived directly from local schema consts so the
+2 -2
View File
@@ -255,7 +255,7 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
// -2: remove unused WhatsApp-specific ack policy exports from channel-feedback.
// -7: retire unused and duplicate inbound-dispatch compatibility exports.
// +7: restore still-existing deprecated inbound-dispatch compatibility re-exports.
4847,
4848,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
@@ -312,7 +312,7 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
// -1: remove the unused WhatsApp-specific ack policy helper.
// -10: collapse inbound-dispatch callable aliases and wrappers.
// +7: restore still-existing deprecated inbound-dispatch callable re-exports.
2917,
2918,
env,
),
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
+10 -1
View File
@@ -43,7 +43,9 @@ function expectApprovalResolverCall(params: {
}) {
const request = approvalResolverRequest(params.callIndex ?? 0);
expect(request).toHaveProperty("cfg");
expect(request).toHaveProperty("senderId");
const hasReviewer = Object.hasOwn(request, "channel");
expect(Object.hasOwn(request, "accountId")).toBe(hasReviewer);
expect(Object.hasOwn(request, "senderId")).toBe(hasReviewer);
expect(request.approvalId).toBe(params.id);
expect(request.decision).toBe(params.decision ?? "allow-once");
expect(request.resolveMethod).toBe(
@@ -364,6 +366,7 @@ describe("handleApproveCommand", () => {
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toContain("Approval allow-once submitted");
expectApprovalResolverCall({ method: "exec.approval.resolve", id: "abc12345" });
expect(approvalResolverRequest()).toMatchObject({ channel: "telegram", accountId: "work" });
});
it.each([
@@ -740,6 +743,12 @@ describe("handleApproveCommand", () => {
method: "exec.approval.resolve",
id: "abc",
});
const request = approvalResolverRequest(
resolveApprovalOverGatewayMock.mock.calls.length - 1,
);
expect(request).not.toHaveProperty("channel");
expect(request).not.toHaveProperty("accountId");
expect(request).not.toHaveProperty("senderId");
}
}
});
+7 -1
View File
@@ -202,7 +202,13 @@ export async function handleApproveCommandFromContext(
cfg: params.cfg,
approvalId: parsed.id,
decision: parsed.decision,
senderId: params.command.senderId,
...(approvalCapability?.authorizeActorAction
? {
channel: params.command.channel,
accountId: effectiveAccountId,
senderId: params.command.senderId,
}
: {}),
resolveMethod,
clientDisplayName: `Chat approval (${resolvedBy})`,
});
@@ -1423,6 +1423,8 @@ describe("dispatchReplyFromConfig", () => {
channelLabel: channel === "discord" ? "Discord" : "Signal",
accountId: "default",
requestGateway: async <T>() => ({ ok: true }) as T,
shouldHandle: () => true,
classifyRoute: () => "unbound",
})
: undefined;
reporter?.start();
@@ -1471,6 +1473,8 @@ describe("dispatchReplyFromConfig", () => {
channelLabel: "Signal",
accountId: "default",
requestGateway: async <T>() => ({ ok: true }) as T,
shouldHandle: () => true,
classifyRoute: () => "unbound",
});
reporter.start();
try {
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { prepareApprovalChannelCustody } from "./approval-channel-custody.js";
const mocks = vi.hoisted(() => ({
authorize: vi.fn(),
listAccountIds: vi.fn(),
defaultAccountId: vi.fn(),
}));
vi.mock("../channels/plugins/index.js", () => ({
getLoadedChannelPlugin: () => ({
config: {
listAccountIds: mocks.listAccountIds,
defaultAccountId: mocks.defaultAccountId,
},
}),
resolveChannelApprovalCapability: () => ({ authorizeActorAction: mocks.authorize }),
}));
const reviewer = (accountId: string) => ({
channel: "telegram",
accountId,
senderId: "owner",
});
const request = (payload: {
command: string;
turnSourceChannel?: string;
turnSourceAccountId?: string;
}) => ({ id: "approval-1", request: payload, createdAtMs: 1, expiresAtMs: 2 });
describe("prepareApprovalChannelCustody", () => {
beforeEach(() => {
mocks.authorize.mockReset().mockReturnValue({ authorized: true });
mocks.listAccountIds.mockReset().mockReturnValue(["default", "ops"]);
mocks.defaultAccountId.mockReset().mockReturnValue("default");
});
it("authorizes only the account recorded by the request source", () => {
const approval = request({
command: "printf approval",
turnSourceChannel: "telegram",
turnSourceAccountId: "ops",
});
expect(
prepareApprovalChannelCustody({
cfg: {},
approvalKind: "exec",
reviewer: reviewer("ops"),
})?.authorizes(approval),
).toBe(true);
expect(
prepareApprovalChannelCustody({
cfg: {},
approvalKind: "exec",
reviewer: reviewer("default"),
})?.authorizes(approval),
).toBe(false);
});
it("unions explicit scoped targets with the documented default account", () => {
const cfg: OpenClawConfig = {
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [
{ channel: "telegram", to: "1" },
{ channel: "telegram", to: "2", accountId: "ops" },
],
},
},
};
mocks.listAccountIds.mockReturnValue(["default", "ops", "other"]);
for (const accountId of ["default", "ops"]) {
expect(
prepareApprovalChannelCustody({
cfg,
approvalKind: "exec",
reviewer: reviewer(accountId),
})?.authorizes(request({ command: "printf approval" })),
).toBe(true);
}
expect(
prepareApprovalChannelCustody({
cfg,
approvalKind: "exec",
reviewer: reviewer("other"),
})?.authorizes(request({ command: "printf approval" })),
).toBe(false);
});
it("allows an unbound request only for one actor-authorized account", () => {
mocks.authorize.mockImplementation(({ accountId }) => ({ authorized: accountId === "ops" }));
expect(
prepareApprovalChannelCustody({
cfg: {},
approvalKind: "exec",
reviewer: reviewer("ops"),
})?.authorizes(request({ command: "printf approval" })),
).toBe(true);
mocks.authorize.mockReturnValue({ authorized: true });
expect(
prepareApprovalChannelCustody({
cfg: {},
approvalKind: "exec",
reviewer: reviewer("ops"),
})?.authorizes(request({ command: "printf approval" })),
).toBe(false);
});
});
+62
View File
@@ -0,0 +1,62 @@
import type { ApprovalChannelReviewer } from "../../packages/gateway-protocol/src/index.js";
import {
getLoadedChannelPlugin,
resolveChannelApprovalCapability,
} from "../channels/plugins/index.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
doesApprovalRequestSelectChannelAccount,
type ApprovalRequestLike,
} from "../infra/approval-request-account-binding.js";
import type { ChannelApprovalKind } from "../infra/approval-types.js";
type PreparedApprovalChannelCustody = {
resolverId: string;
authorizes: (request: ApprovalRequestLike) => boolean;
};
export function prepareApprovalChannelCustody(params: {
cfg: OpenClawConfig;
approvalKind: ChannelApprovalKind;
reviewer: ApprovalChannelReviewer;
}): PreparedApprovalChannelCustody | null {
const channel = params.reviewer.channel.trim().toLowerCase();
const accountId = params.reviewer.accountId.trim();
const senderId = params.reviewer.senderId.trim();
if (!channel || !accountId || !senderId) {
return null;
}
const plugin = getLoadedChannelPlugin(channel);
const capability = resolveChannelApprovalCapability(plugin);
const authorizeActorAction = capability?.authorizeActorAction;
if (!plugin || !authorizeActorAction) {
return null;
}
const isActorAuthorized = (candidateAccountId: string) =>
authorizeActorAction({
cfg: params.cfg,
accountId: candidateAccountId,
senderId,
action: "approve",
approvalKind: params.approvalKind,
}).authorized;
if (!isActorAuthorized(accountId)) {
return null;
}
const eligibleAccountIds = plugin.config.listAccountIds(params.cfg).filter(isActorAuthorized);
if (!eligibleAccountIds.includes(accountId)) {
return null;
}
return {
resolverId: `${channel}:${accountId}`,
authorizes: (request) =>
doesApprovalRequestSelectChannelAccount({
cfg: params.cfg,
request,
channel,
accountId,
defaultAccountId: plugin.config.defaultAccountId?.(params.cfg) ?? "",
eligibleAccountIds,
}),
};
}
@@ -0,0 +1,169 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js";
import type {
ExecApprovalIdLookupResult,
ExecApprovalManager,
ExecApprovalRecord,
} from "../exec-approval-manager.js";
import { ADMIN_SCOPE, APPROVALS_SCOPE } from "../method-scopes.js";
import type { GatewayClient, RespondFn } from "./types.js";
const APPROVAL_NOT_FOUND_DETAILS = {
reason: ErrorCodes.APPROVAL_NOT_FOUND,
remediation: "Re-request the action; pending approvals are cleared after expiry or restart.",
} as const;
type PendingApprovalLookupError =
| "missing"
| { code: (typeof ErrorCodes)["INVALID_REQUEST"]; message: string };
export type ApprovalRecordLookupResult<TPayload> =
| { ok: true; approvalId: string; snapshot: ExecApprovalRecord<TPayload> }
| { ok: false; response: PendingApprovalLookupError };
function normalizeApprovalIdentity(value: string | null | undefined): string | null {
return normalizeOptionalString(value) ?? null;
}
export function normalizeApprovalIdentities(
values: readonly string[] | null | undefined,
): string[] {
const normalized = new Set<string>();
for (const value of values ?? []) {
const identity = normalizeApprovalIdentity(value);
if (identity) {
normalized.add(identity);
}
}
return [...normalized];
}
export function isApprovalRecordVisibleToClient<TPayload>(params: {
record: ExecApprovalRecord<TPayload>;
client: GatewayClient | null;
}): boolean {
const scopes = Array.isArray(params.client?.connect?.scopes) ? params.client.connect.scopes : [];
if (scopes.includes(ADMIN_SCOPE)) {
return true;
}
const requestedByDeviceId = normalizeApprovalIdentity(params.record.requestedByDeviceId);
const requestedByClientId = normalizeApprovalIdentity(params.record.requestedByClientId);
const hasApprovalsScope = scopes.includes(APPROVALS_SCOPE);
if (hasApprovalsScope && params.client?.internal?.approvalRuntime === true) {
return true;
}
const approvalReviewerDeviceIds = normalizeApprovalIdentities(
params.record.approvalReviewerDeviceIds,
);
const clientDeviceId = normalizeApprovalIdentity(params.client?.connect?.device?.id);
if (hasApprovalsScope && clientDeviceId && approvalReviewerDeviceIds.includes(clientDeviceId)) {
return true;
}
// Legacy adapters retain exact requester connection/device authority.
if (requestedByDeviceId) {
return requestedByDeviceId === clientDeviceId;
}
const requestedByConnId = normalizeApprovalIdentity(params.record.requestedByConnId);
if (requestedByConnId) {
return requestedByConnId === normalizeApprovalIdentity(params.client?.connId);
}
if (requestedByClientId || approvalReviewerDeviceIds.length > 0) {
return false;
}
// Pre-binding pending approvals remain operable after upgrades and restarts.
return true;
}
export function listVisiblePendingApprovalRequests<TPayload>(params: {
manager: ExecApprovalManager<TPayload>;
client?: GatewayClient | null;
}): Array<{ id: string; request: TPayload; createdAtMs: number; expiresAtMs: number }> {
return params.manager
.listPendingRecords()
.filter((record) => isApprovalRecordVisibleToClient({ record, client: params.client ?? null }))
.map(({ id, request, createdAtMs, expiresAtMs }) => ({
id,
request,
createdAtMs,
expiresAtMs,
}));
}
function resolveLookupError(params: {
resolvedId: ExecApprovalIdLookupResult;
exposeAmbiguousPrefixError?: boolean;
}): PendingApprovalLookupError {
if (
params.resolvedId.kind === "none" ||
(params.resolvedId.kind === "ambiguous" && !params.exposeAmbiguousPrefixError)
) {
return "missing";
}
return {
code: ErrorCodes.INVALID_REQUEST,
message: "ambiguous approval id prefix; use the full id",
};
}
function resolveApprovalRecordForState<TPayload>(
params: {
manager: ExecApprovalManager<TPayload>;
inputId: string;
client?: GatewayClient | null;
exposeAmbiguousPrefixError?: boolean;
recordFilter?: (record: ExecApprovalRecord<TPayload>) => boolean;
},
expectedState: "pending" | "resolved",
): ApprovalRecordLookupResult<TPayload> {
const resolvedId = params.manager.lookupApprovalId(params.inputId, {
includeResolved: expectedState === "resolved",
filter: (record) =>
isApprovalRecordVisibleToClient({ record, client: params.client ?? null }) &&
(params.recordFilter?.(record) ?? true),
});
if (resolvedId.kind !== "exact" && resolvedId.kind !== "prefix") {
return { ok: false, response: resolveLookupError({ ...params, resolvedId }) };
}
const snapshot = params.manager.getSnapshot(resolvedId.id);
const isResolved = snapshot?.resolvedAtMs !== undefined;
return !snapshot || isResolved !== (expectedState === "resolved")
? { ok: false, response: "missing" }
: { ok: true, approvalId: resolvedId.id, snapshot };
}
export function resolvePendingApprovalRecord<TPayload>(params: {
manager: ExecApprovalManager<TPayload>;
inputId: string;
client?: GatewayClient | null;
exposeAmbiguousPrefixError?: boolean;
recordFilter?: (record: ExecApprovalRecord<TPayload>) => boolean;
}): ApprovalRecordLookupResult<TPayload> {
return resolveApprovalRecordForState(params, "pending");
}
export function resolveResolvedApprovalRecord<TPayload>(
params: Parameters<typeof resolvePendingApprovalRecord<TPayload>>[0],
): ApprovalRecordLookupResult<TPayload> {
return resolveApprovalRecordForState(params, "resolved");
}
export function respondUnknownOrExpiredApproval(respond: RespondFn): void {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id", {
details: APPROVAL_NOT_FOUND_DETAILS,
}),
);
}
export function respondPendingApprovalLookupError(params: {
respond: RespondFn;
response: PendingApprovalLookupError;
}): void {
if (params.response === "missing") {
respondUnknownOrExpiredApproval(params.respond);
return;
}
params.respond(false, undefined, errorShape(params.response.code, params.response.message));
}
@@ -19,11 +19,16 @@ import {
import type { GatewayClient, GatewayRequestContext } from "./types.js";
const hasApprovalTurnSourceRouteMock = vi.hoisted(() => vi.fn(() => true));
const prepareApprovalChannelCustodyMock = vi.hoisted(() => vi.fn());
vi.mock("../../infra/approval-turn-source.js", () => ({
hasApprovalTurnSourceRoute: hasApprovalTurnSourceRouteMock,
}));
vi.mock("../approval-channel-custody.js", () => ({
prepareApprovalChannelCustody: prepareApprovalChannelCustodyMock,
}));
type ApprovalClientLookup = NonNullable<GatewayRequestContext["getApprovalClientConnIds"]>;
function createApprovalClient(params: {
@@ -1524,6 +1529,40 @@ describe("handlePendingApprovalRequest", () => {
expect(manager.getSnapshot(record.id)?.decision).toBe("allow-once");
});
it("filters legacy prefix candidates by channel custody before resolving", async () => {
const manager = new ExecApprovalManager();
const owned = manager.create({ command: "owned" }, 60_000, "approval-prefix-owned");
const foreign = manager.create({ command: "foreign" }, 60_000, "approval-prefix-foreign");
void manager.register(owned, 60_000);
void manager.register(foreign, 60_000);
prepareApprovalChannelCustodyMock.mockReturnValueOnce({
resolverId: "telegram:ops",
authorizes: (request: { request: { command: string } }) =>
request.request.command === "owned",
});
const respond = vi.fn();
await handleApprovalResolve({
approvalKind: "exec",
manager,
inputId: "approval-prefix",
decision: "deny",
reviewer: { channel: "telegram", accountId: "ops", senderId: "owner" },
respond,
context: {
broadcast: vi.fn(),
broadcastToConnIds: vi.fn(),
getRuntimeConfig: () => ({}),
} as unknown as GatewayRequestContext,
client: null,
exposeAmbiguousPrefixError: true,
});
expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined);
expect(manager.getSnapshot(owned.id)?.decision).toBe("deny");
expect(manager.getSnapshot(foreign.id)?.decision).toBeUndefined();
});
it("targets resolved approval events to visible approval clients when available", async () => {
const manager = new ExecApprovalManager();
const record = manager.create(
+57 -210
View File
@@ -2,23 +2,35 @@
// decision payloads, turn-source routing, and gateway error responses.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js";
import type { ValidationError } from "../../../packages/gateway-protocol/src/index.js";
import type {
ApprovalChannelReviewer,
ValidationError,
} from "../../../packages/gateway-protocol/src/index.js";
import { hasApprovalTurnSourceRoute } from "../../infra/approval-turn-source.js";
import type { ExecApprovalDecision } from "../../infra/exec-approvals.js";
import type {
ExecApprovalIdLookupResult,
ExecApprovalManager,
ExecApprovalRecord,
} from "../exec-approval-manager.js";
import { ADMIN_SCOPE, APPROVALS_SCOPE } from "../method-scopes.js";
import type { ExecApprovalRequestPayload } from "../../infra/exec-approvals.js";
import type { PluginApprovalRequestPayload } from "../../infra/plugin-approvals.js";
import { prepareApprovalChannelCustody } from "../approval-channel-custody.js";
import type { ExecApprovalManager, ExecApprovalRecord } from "../exec-approval-manager.js";
import {
type ApprovalRecordLookupResult,
isApprovalRecordVisibleToClient,
normalizeApprovalIdentities,
resolvePendingApprovalRecord,
resolveResolvedApprovalRecord,
respondPendingApprovalLookupError,
respondUnknownOrExpiredApproval,
} from "./approval-record-lookup.js";
import { buildWaitResponse, type WaitReasonResolver } from "./approval-wait-response.js";
import type { GatewayClient, GatewayRequestContext, RespondFn } from "./types.js";
import { assertValidParams } from "./validation.js";
const APPROVAL_NOT_FOUND_DETAILS = {
reason: ErrorCodes.APPROVAL_NOT_FOUND,
remediation: "Re-request the action; pending approvals are cleared after expiry or restart.",
} as const;
export {
isApprovalRecordVisibleToClient,
listVisiblePendingApprovalRequests,
resolvePendingApprovalRecord,
respondPendingApprovalLookupError,
} from "./approval-record-lookup.js";
const APPROVAL_ALREADY_RESOLVED_DETAILS = {
reason: "APPROVAL_ALREADY_RESOLVED",
@@ -30,13 +42,6 @@ function resolveRecordedApprovalDecision<TPayload>(
return record.decision ?? record.consumedDecision;
}
type PendingApprovalLookupError =
| "missing"
| {
code: (typeof ErrorCodes)["INVALID_REQUEST"];
message: string;
};
type ApprovalTurnSourceFields = {
turnSourceChannel?: string | null;
turnSourceAccountId?: string | null;
@@ -57,18 +62,12 @@ type ResolvedApprovalEvent<TPayload> = {
request: TPayload;
};
type PendingApprovalListEntry<TPayload> = {
id: string;
request: TPayload;
createdAtMs: number;
expiresAtMs: number;
};
type ApprovalRequestDeliveryRoute = "approval-client" | "forwarder" | "turn-source" | "none";
type ApprovalResolveParams = {
id: string;
decision: string;
reviewer?: ApprovalChannelReviewer;
};
type ApprovalResolveParamsValidator<TParams extends ApprovalResolveParams> = ((
@@ -77,17 +76,6 @@ type ApprovalResolveParamsValidator<TParams extends ApprovalResolveParams> = ((
errors?: ValidationError[] | null;
};
type ApprovalRecordLookupResult<TPayload> =
| {
ok: true;
approvalId: string;
snapshot: ExecApprovalRecord<TPayload>;
}
| {
ok: false;
response: PendingApprovalLookupError;
};
function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> {
return typeof value === "object" && value !== null && "then" in value;
}
@@ -96,113 +84,6 @@ function isApprovalDecision(value: string): value is ExecApprovalDecision {
return value === "allow-once" || value === "allow-always" || value === "deny";
}
function respondUnknownOrExpiredApproval(respond: RespondFn): void {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id", {
details: APPROVAL_NOT_FOUND_DETAILS,
}),
);
}
function resolvePendingApprovalLookupError(params: {
resolvedId: ExecApprovalIdLookupResult;
exposeAmbiguousPrefixError?: boolean;
}): PendingApprovalLookupError {
if (params.resolvedId.kind === "none") {
return "missing";
}
if (params.resolvedId.kind === "ambiguous" && !params.exposeAmbiguousPrefixError) {
return "missing";
}
return {
code: ErrorCodes.INVALID_REQUEST,
message: "ambiguous approval id prefix; use the full id",
};
}
function normalizeApprovalIdentity(value: string | null | undefined): string | null {
return normalizeOptionalString(value) ?? null;
}
function normalizeApprovalIdentities(values: readonly string[] | null | undefined): string[] {
const normalized = new Set<string>();
for (const value of values ?? []) {
const identity = normalizeApprovalIdentity(value);
if (identity) {
normalized.add(identity);
}
}
return [...normalized];
}
/** Checks whether a client can observe or resolve an approval record. */
export function isApprovalRecordVisibleToClient<TPayload>(params: {
record: ExecApprovalRecord<TPayload>;
client: GatewayClient | null;
}): boolean {
const scopes = Array.isArray(params.client?.connect?.scopes) ? params.client.connect.scopes : [];
if (scopes.includes(ADMIN_SCOPE)) {
return true;
}
const requestedByDeviceId = normalizeApprovalIdentity(params.record.requestedByDeviceId);
const requestedByClientId = normalizeApprovalIdentity(params.record.requestedByClientId);
const hasApprovalsScope = scopes.includes(APPROVALS_SCOPE);
if (hasApprovalsScope && params.client?.internal?.approvalRuntime === true) {
return true;
}
const approvalReviewerDeviceIds = normalizeApprovalIdentities(
params.record.approvalReviewerDeviceIds,
);
const clientDeviceId = normalizeApprovalIdentity(params.client?.connect?.device?.id);
if (hasApprovalsScope && clientDeviceId && approvalReviewerDeviceIds.includes(clientDeviceId)) {
return true;
}
// Shipped legacy adapters retain exact requester connection/device authority.
// Unified durable methods apply their separate record authorization after lookup.
if (requestedByDeviceId) {
return requestedByDeviceId === clientDeviceId;
}
const requestedByConnId = normalizeApprovalIdentity(params.record.requestedByConnId);
if (requestedByConnId) {
return requestedByConnId === normalizeApprovalIdentity(params.client?.connId);
}
if (requestedByClientId || approvalReviewerDeviceIds.length > 0) {
return false;
}
// Unbound approvals predate requester metadata and remain visible so pending
// work can still be resolved after upgrades or gateway restarts.
return true;
}
/** Returns only pending approval requests the connected client is allowed to see. */
export function listVisiblePendingApprovalRequests<TPayload>(params: {
manager: ExecApprovalManager<TPayload>;
client?: GatewayClient | null;
}): PendingApprovalListEntry<TPayload>[] {
return params.manager
.listPendingRecords()
.filter((record) =>
isApprovalRecordVisibleToClient({
record,
client: params.client ?? null,
}),
)
.map((record) => ({
id: record.id,
request: record.request,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
}));
}
/** Binds the current gateway client identity onto a newly-created approval record. */
export function bindApprovalRequesterMetadata<TPayload>(params: {
record: ExecApprovalRecord<TPayload>;
@@ -274,7 +155,11 @@ export function resolveApprovalDecisionParams<TParams extends ApprovalResolvePar
validate: ApprovalResolveParamsValidator<TParams>;
methodName: string;
respond: RespondFn;
}): { inputId: string; decision: ExecApprovalDecision } | null {
}): {
inputId: string;
decision: ExecApprovalDecision;
reviewer?: ApprovalChannelReviewer;
} | null {
const rawParams = params.rawParams;
if (!assertValidParams(rawParams, params.validate, params.methodName, params.respond)) {
return null;
@@ -286,6 +171,7 @@ export function resolveApprovalDecisionParams<TParams extends ApprovalResolvePar
return {
inputId: rawParams.id,
decision: rawParams.decision,
...(rawParams.reviewer ? { reviewer: rawParams.reviewer } : {}),
};
}
@@ -335,70 +221,6 @@ export function broadcastApprovalResolvedEvent<TPayload>(params: {
params.context.broadcast(eventName, params.event, { dropIfSlow: true });
}
/** Finds a pending approval by full id or prefix after applying client visibility rules. */
export function resolvePendingApprovalRecord<TPayload>(params: {
manager: ExecApprovalManager<TPayload>;
inputId: string;
client?: GatewayClient | null;
exposeAmbiguousPrefixError?: boolean;
}): ApprovalRecordLookupResult<TPayload> {
return resolveApprovalRecordForState(params, "pending");
}
function resolveResolvedApprovalRecord<TPayload>(params: {
manager: ExecApprovalManager<TPayload>;
inputId: string;
client?: GatewayClient | null;
exposeAmbiguousPrefixError?: boolean;
}): ApprovalRecordLookupResult<TPayload> {
return resolveApprovalRecordForState(params, "resolved");
}
function resolveApprovalRecordForState<TPayload>(
params: {
manager: ExecApprovalManager<TPayload>;
inputId: string;
client?: GatewayClient | null;
exposeAmbiguousPrefixError?: boolean;
},
expectedState: "pending" | "resolved",
): ApprovalRecordLookupResult<TPayload> {
const resolvedId = params.manager.lookupApprovalId(params.inputId, {
includeResolved: expectedState === "resolved",
filter: (record) =>
isApprovalRecordVisibleToClient({
record,
client: params.client ?? null,
}),
});
if (resolvedId.kind !== "exact" && resolvedId.kind !== "prefix") {
return {
ok: false,
response: resolvePendingApprovalLookupError({
resolvedId,
exposeAmbiguousPrefixError: params.exposeAmbiguousPrefixError,
}),
};
}
const snapshot = params.manager.getSnapshot(resolvedId.id);
const isResolved = snapshot?.resolvedAtMs !== undefined;
if (!snapshot || isResolved !== (expectedState === "resolved")) {
return { ok: false, response: "missing" };
}
return { ok: true, approvalId: resolvedId.id, snapshot };
}
export function respondPendingApprovalLookupError(params: {
respond: RespondFn;
response: PendingApprovalLookupError;
}): void {
if (params.response === "missing") {
respondUnknownOrExpiredApproval(params.respond);
return;
}
params.respond(false, undefined, errorShape(params.response.code, params.response.message));
}
export async function handleApprovalWaitDecision<TPayload>(params: {
manager: ExecApprovalManager<TPayload>;
inputId: unknown;
@@ -629,7 +451,9 @@ function respondRepeatedApprovalResolution<TPayload>(
}
/** Resolves a pending approval and broadcasts the final decision exactly once. */
export async function handleApprovalResolve<TPayload>(params: {
export async function handleApprovalResolve<
TPayload extends ExecApprovalRequestPayload | PluginApprovalRequestPayload,
>(params: {
approvalKind: "exec" | "plugin";
manager: ExecApprovalManager<TPayload>;
inputId: string;
@@ -637,6 +461,7 @@ export async function handleApprovalResolve<TPayload>(params: {
respond: RespondFn;
context: GatewayRequestContext;
client: GatewayClient | null;
reviewer?: ApprovalChannelReviewer;
exposeAmbiguousPrefixError?: boolean;
validateDecision?: (snapshot: ExecApprovalRecord<TPayload>) =>
| {
@@ -650,6 +475,7 @@ export async function handleApprovalResolve<TPayload>(params: {
decision: ExecApprovalDecision;
resolvedBy: string | null;
snapshot: ExecApprovalRecord<TPayload>;
resolver?: { kind: "channel"; id: string };
}) => boolean;
forwardResolved?: (event: ResolvedApprovalEvent<TPayload>) => Promise<void> | void;
forwardResolvedErrorLabel?: string;
@@ -658,6 +484,20 @@ export async function handleApprovalResolve<TPayload>(params: {
errorLabel: string;
}>;
}): Promise<void> {
const custody = params.reviewer
? prepareApprovalChannelCustody({
cfg: params.context.getRuntimeConfig(),
approvalKind: params.approvalKind,
reviewer: params.reviewer,
})
: null;
if (params.reviewer && !custody) {
respondUnknownOrExpiredApproval(params.respond);
return;
}
const recordFilter = custody
? (record: ExecApprovalRecord<TPayload>) => custody.authorizes(record)
: undefined;
let resolved: ApprovalRecordLookupResult<TPayload>;
try {
resolved = resolvePendingApprovalRecord({
@@ -665,6 +505,7 @@ export async function handleApprovalResolve<TPayload>(params: {
inputId: params.inputId,
client: params.client,
exposeAmbiguousPrefixError: params.exposeAmbiguousPrefixError,
recordFilter,
});
} catch (err) {
respondApprovalStorageUnavailable({ ...params, operation: "resolve", error: err });
@@ -678,6 +519,7 @@ export async function handleApprovalResolve<TPayload>(params: {
inputId: params.inputId,
client: params.client,
exposeAmbiguousPrefixError: params.exposeAmbiguousPrefixError,
recordFilter,
});
} catch (err) {
respondApprovalStorageUnavailable({ ...params, operation: "resolve", error: err });
@@ -707,6 +549,7 @@ export async function handleApprovalResolve<TPayload>(params: {
const resolvedBy =
params.client?.connect?.client?.displayName ?? params.client?.connect?.client?.id ?? null;
const resolver = custody ? ({ kind: "channel", id: custody.resolverId } as const) : undefined;
let ok: boolean;
try {
ok = params.resolveRecord
@@ -715,8 +558,12 @@ export async function handleApprovalResolve<TPayload>(params: {
decision: params.decision,
resolvedBy,
snapshot: resolved.snapshot,
resolver,
})
: params.manager.resolve(resolved.approvalId, params.decision, resolvedBy);
: resolver
? params.manager.resolveDetailed(resolved.approvalId, params.decision, resolver, resolvedBy)
.outcome === "resolved"
: params.manager.resolve(resolved.approvalId, params.decision, resolvedBy);
} catch (err) {
respondApprovalStorageUnavailable({ ...params, operation: "resolve", error: err });
return;
@@ -38,6 +38,12 @@ import { cancelRunBoundExecApprovals } from "./approval-run-cancellation.js";
import { createApprovalHandlers } from "./approval.js";
import type { GatewayRequestHandlerOptions } from "./types.js";
const prepareApprovalChannelCustodyMock = vi.hoisted(() => vi.fn());
vi.mock("../approval-channel-custody.js", () => ({
prepareApprovalChannelCustody: prepareApprovalChannelCustodyMock,
}));
const tempDirs: string[] = [];
type OperatorApprovalDatabase = Pick<OpenClawStateKyselyDatabase, "operator_approvals">;
const managersForCleanup: Array<{
@@ -315,6 +321,47 @@ describe("unified approval handlers", () => {
);
});
it("checks live channel custody before the canonical resolution CAS", async () => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
const pending = registerExec(managers.exec, {
id: "channel-custody-cas",
request: { turnSourceChannel: "telegram", turnSourceAccountId: "ops" },
reviewerDeviceIds: [],
});
prepareApprovalChannelCustodyMock.mockReturnValue({
resolverId: "telegram:ops",
authorizes: (request: { request: ExecApprovalRequestPayload }) =>
request.request.turnSourceAccountId === "ops",
});
const handlers = createApprovalHandlers({
execApprovalManager: managers.exec,
pluginApprovalManager: managers.plugin,
databaseOptions,
});
const response = await invoke({
handlers,
method: "approval.resolve",
body: {
id: pending.record.id,
kind: "exec",
decision: "deny",
reviewer: { channel: "telegram", accountId: "ops", senderId: "owner" },
},
client: createClient({ internal: true }),
});
expect(response.result).toMatchObject({
applied: true,
approval: { status: "denied", decision: "deny" },
});
expect(getOperatorApproval({ id: pending.record.id, databaseOptions })?.resolver).toEqual({
kind: "channel",
id: "telegram:ops",
});
});
it("returns mapped terminal history with attribution and a next cursor", async () => {
const databaseOptions = createDatabaseOptions();
const managers = createManagers(databaseOptions);
+31 -3
View File
@@ -22,6 +22,7 @@ import type {
import type { PluginApprovalRequestPayload } from "../../infra/plugin-approvals.js";
import type { SystemAgentApprovalRequestPayload } from "../../infra/system-agent-approvals.js";
import type { OpenClawStateDatabaseOptions } from "../../state/openclaw-state-db.js";
import { prepareApprovalChannelCustody } from "../approval-channel-custody.js";
import { normalizeControlUiBasePath } from "../control-ui-shared.js";
import type { ExecApprovalManager, ExecApprovalRecord } from "../exec-approval-manager.js";
import {
@@ -357,6 +358,13 @@ export function createApprovalHandlers(
},
"approval.resolve": async ({ params: rawParams, respond, client, context }) => {
const validParams = validateApprovalResolveParams(rawParams);
const resolveParams = validParams ? (rawParams as ApprovalResolveParams) : null;
const hasReviewer = isRecord(rawParams) && "reviewer" in rawParams;
if (hasReviewer && !resolveParams?.reviewer) {
respondApprovalNotFound(respond);
return;
}
const id = readExactApprovalId(rawParams);
let record: OperatorApprovalRecord | null;
try {
@@ -380,6 +388,26 @@ export function createApprovalHandlers(
respondApprovalNotFound(respond);
return;
}
const custody = resolveParams?.reviewer
? prepareApprovalChannelCustody({
cfg: context.getRuntimeConfig(),
approvalKind: record.kind === "plugin" ? "plugin" : "exec",
reviewer: resolveParams.reviewer,
})
: null;
const liveRecord =
record.kind === "exec"
? params.execApprovalManager.getLiveSnapshot(record.id)
: record.kind === "plugin"
? params.pluginApprovalManager.getLiveSnapshot(record.id)
: undefined;
if (
resolveParams?.reviewer &&
(!custody || !liveRecord || !custody.authorizes(liveRecord))
) {
respondApprovalNotFound(respond);
return;
}
if (record.status !== "pending") {
// Durable terminal state outlives the process-local waiter. Every later
// surface receives the same winner without re-opening execution rights.
@@ -394,10 +422,10 @@ export function createApprovalHandlers(
respond(true, { applied: false, approval }, undefined);
return;
}
const resolver = resolveApprovalResolver(client);
const resolver = custody
? ({ kind: "channel", id: custody.resolverId } as const)
: resolveApprovalResolver(client);
const localResolvedBy = resolveLegacyApprovalLabel(client);
const validParams = validateApprovalResolveParams(rawParams);
const resolveParams = validParams ? (rawParams as ApprovalResolveParams) : null;
const requestedDecision = resolveParams?.decision ?? null;
const decisionAllowed =
requestedDecision === "deny" ||
+11 -5
View File
@@ -441,7 +441,7 @@ export function createExecApprovalHandlers(
if (!resolveParams) {
return;
}
const { inputId, decision } = resolveParams;
const { inputId, decision, reviewer } = resolveParams;
let autoReviewResolution = false;
await handleApprovalResolve({
approvalKind: "exec",
@@ -451,6 +451,7 @@ export function createExecApprovalHandlers(
respond,
context,
client,
reviewer,
exposeAmbiguousPrefixError: true,
validateDecision: (snapshot) => {
const autoReviewIdentity =
@@ -481,10 +482,15 @@ export function createExecApprovalHandlers(
details: APPROVAL_ALLOW_ALWAYS_UNAVAILABLE_DETAILS,
};
},
resolveRecord: ({ approvalId, decision: decisionLocal, resolvedBy }) =>
autoReviewResolution
? manager.resolveAutoReview(approvalId, resolvedBy)
: manager.resolve(approvalId, decisionLocal, resolvedBy),
resolveRecord: ({ approvalId, decision: decisionLocal, resolvedBy, resolver }) => {
if (autoReviewResolution) {
return manager.resolveAutoReview(approvalId, resolvedBy);
}
return resolver
? manager.resolveDetailed(approvalId, decisionLocal, resolver, resolvedBy).outcome ===
"resolved"
: manager.resolve(approvalId, decisionLocal, resolvedBy);
},
forwardResolved: (resolvedEvent) => opts?.forwarder?.handleResolved(resolvedEvent),
forwardResolvedErrorLabel: "exec approvals: forward resolve failed",
extraResolvedHandlers: opts?.iosPushDelivery?.handleResolved
@@ -187,7 +187,7 @@ export function createPluginApprovalHandlers(
if (!resolveParams) {
return;
}
const { inputId, decision } = resolveParams;
const { inputId, decision, reviewer } = resolveParams;
await handleApprovalResolve({
approvalKind: "plugin",
manager,
@@ -196,6 +196,7 @@ export function createPluginApprovalHandlers(
respond,
context,
client,
reviewer,
exposeAmbiguousPrefixError: false,
validateDecision: (snapshot) =>
resolveCanonicalPluginApprovalRequestAllowedDecisions(snapshot.request).includes(decision)
+110
View File
@@ -38,6 +38,19 @@ function requireFirstMockCall<T>(mock: { mock: { calls: T[][] } }): T[] {
return call;
}
function withApprovalAccountContext<T>(run: () => T): T {
return withGatewayNativeApprovalRuntime(
{
request: async <TResult>(method: string, params: Record<string, unknown>) =>
(await hoisted.clientRequest(method, params)) as TResult,
requestRoute: vi.fn(),
routeCoordinator: {} as never,
subscribe: vi.fn(),
},
run,
);
}
describe("resolveApprovalOverGateway", () => {
beforeEach(() => {
hoisted.clientRequest.mockReset().mockResolvedValue({
@@ -77,6 +90,49 @@ describe("resolveApprovalOverGateway", () => {
expect(result).toEqual({ applied: true, approval: recordedApproval });
});
it("sends complete reviewer facts directly to the canonical owner", async () => {
await expect(
withApprovalAccountContext(() =>
resolveApprovalOverGateway({
cfg: {} as never,
approvalId: "approval-1",
approvalKind: "exec",
decision: "deny",
channel: "telegram",
accountId: "ops",
senderId: "owner",
}),
),
).resolves.toEqual({ applied: true, approval: recordedApproval });
expect(hoisted.clientRequest).toHaveBeenCalledWith("approval.resolve", {
id: "approval-1",
kind: "exec",
decision: "deny",
reviewer: { channel: "telegram", accountId: "ops", senderId: "owner" },
});
});
it.each([
{ channel: "telegram" },
{ accountId: "ops" },
{ senderId: "owner" },
{ channel: "telegram", accountId: "ops" },
{ channel: "telegram", senderId: "owner" },
{ accountId: "ops", senderId: "owner" },
])("rejects partial reviewer identity: %j", async (reviewer) => {
await expect(
resolveApprovalOverGateway({
cfg: {} as never,
approvalId: "approval-1",
approvalKind: "exec",
decision: "deny",
...reviewer,
}),
).rejects.toThrow("channel approval resolution requires channel, account, and sender identity");
expect(hoisted.clientRequest).not.toHaveBeenCalled();
expect(hoisted.withOperatorApprovalsGatewayClient).not.toHaveBeenCalled();
});
it.each([
["signal", "Signal"],
["whatsapp", "WhatsApp"],
@@ -95,6 +151,7 @@ describe("resolveApprovalOverGateway", () => {
approvalKind: "exec",
decision: "deny",
channel,
accountId: "default",
senderId: "owner",
});
@@ -114,6 +171,7 @@ describe("resolveApprovalOverGateway", () => {
approvalKind: "exec",
decision: "deny",
channel: "external-chat",
accountId: "default",
senderId: "owner",
});
@@ -190,6 +248,58 @@ describe("resolveApprovalOverGateway", () => {
expect(hoisted.withOperatorApprovalsGatewayClient).not.toHaveBeenCalled();
});
it("sends channel custody to an injected canonical runtime", async () => {
const injectedRequest = vi.fn(async () => ({ applied: true, approval: recordedApproval }));
const scopedRequest = vi.fn(async (method: string) => {
if (method === "exec.approval.list") {
return [
{
id: "approval-1",
request: {
command: "printf approval",
turnSourceChannel: "imessage",
turnSourceAccountId: "personal",
},
},
];
}
return { applied: true, approval: recordedApproval };
});
const runtime = {
request: scopedRequest,
requestRoute: vi.fn(),
routeCoordinator: { doesAccountHandleRequest: () => true } as never,
subscribe: vi.fn(),
} satisfies GatewayNativeApprovalRuntime;
await expect(
withGatewayNativeApprovalRuntime(runtime, () =>
resolveApprovalOverGateway({
cfg: {} as never,
approvalId: "approval-1",
approvalKind: "exec",
decision: "deny",
channel: "imessage",
accountId: "personal",
senderId: "owner",
gatewayRuntime: { request: injectedRequest },
}),
),
).resolves.toEqual({ applied: true, approval: recordedApproval });
expect(injectedRequest).toHaveBeenCalledWith(
"approval.resolve",
{
id: "approval-1",
kind: "exec",
decision: "deny",
reviewer: { channel: "imessage", accountId: "personal", senderId: "owner" },
},
{ clientDisplayName: "iMessage approval (owner)" },
);
expect(scopedRequest).not.toHaveBeenCalled();
});
it("preserves protocol-valid boundary whitespace in canonical approval ids", async () => {
const approvalId = "\uFEFF";
+16 -2
View File
@@ -1,5 +1,6 @@
// Resolves exec and plugin approvals through the gateway client.
import type {
ApprovalChannelReviewer,
ApprovalDecision,
ApprovalKind,
ApprovalResolveParams,
@@ -18,6 +19,7 @@ type ResolveApprovalOverGatewayBaseParams = {
approvalId: string;
decision: ApprovalDecision;
channel?: string;
accountId?: string | null;
senderId?: string | null;
gatewayUrl?: string;
clientDisplayName?: string;
@@ -106,14 +108,23 @@ export async function resolveApprovalOverGateway(
if (typeof approvalId !== "string" || !isWellFormedApprovalId(approvalId)) {
throw new Error("approval resolution requires an approval id");
}
const senderId = params.senderId?.trim() || "unknown";
const senderId = params.senderId?.trim();
const channel = params.channel?.trim();
const accountId = params.accountId?.trim();
const hasReviewerIdentity = Boolean(channel || accountId || senderId);
if (hasReviewerIdentity && (!channel || !accountId || !senderId)) {
throw new Error("channel approval resolution requires channel, account, and sender identity");
}
const reviewer: ApprovalChannelReviewer | undefined =
channel && accountId && senderId ? { channel, accountId, senderId } : undefined;
// Channel manifests own operator-facing labels; using their generated metadata
// keeps approval clients aligned without importing plugin runtime or hardcoding ids.
const channelLabel = channel ? (findChatChannelLabel(channel) ?? channel) : undefined;
const clientDisplayName =
params.clientDisplayName ??
(channelLabel ? `${channelLabel} approval (${senderId})` : `Approval (${senderId})`);
(channelLabel
? `${channelLabel} approval (${senderId ?? "unknown"})`
: `Approval (${senderId ?? "unknown"})`);
const canonicalGatewayRuntime = (params as CanonicalResolveApprovalOverGatewayParams)
.gatewayRuntime;
@@ -124,6 +135,7 @@ export async function resolveApprovalOverGateway(
id: approvalId,
kind: canonicalKind,
decision: params.decision,
...(reviewer ? { reviewer } : {}),
},
{ clientDisplayName },
);
@@ -140,6 +152,7 @@ export async function resolveApprovalOverGateway(
id: approvalId,
kind: canonicalKind,
decision: params.decision,
...(reviewer ? { reviewer } : {}),
};
return await gatewayClient.request<ApprovalResolveResult>("approval.resolve", resolveParams);
}
@@ -150,6 +163,7 @@ export async function resolveApprovalOverGateway(
await gatewayClient.request(method, {
id: approvalId,
decision: params.decision,
...(reviewer ? { reviewer } : {}),
});
};
if (legacyMethod === "plugin" || (!legacyMethod && approvalId.startsWith("plugin:"))) {
@@ -6,11 +6,18 @@ import {
} from "./approval-native-route-coordinator.js";
const approvalRouteReporters: Array<ReturnType<typeof createApprovalNativeRouteReporterRaw>> = [];
const defaultRouteSelector = {
shouldHandle: () => true,
classifyRoute: () => "unbound" as const,
};
function createApprovalNativeRouteReporter(
params: Parameters<typeof createApprovalNativeRouteReporterRaw>[0],
params: Omit<
Parameters<typeof createApprovalNativeRouteReporterRaw>[0],
"shouldHandle" | "classifyRoute"
>,
) {
const reporter = createApprovalNativeRouteReporterRaw(params);
const reporter = createApprovalNativeRouteReporterRaw({ ...params, ...defaultRouteSelector });
approvalRouteReporters.push(reporter);
return reporter;
}
@@ -28,16 +35,200 @@ function createGatewayRequestMock() {
}
describe("createApprovalNativeRouteReporter", () => {
it("keeps the local approval route visible when an unbound request has multiple runtimes", () => {
const coordinator = createApprovalNativeRouteCoordinator();
const first = coordinator.createReporter({
...defaultRouteSelector,
handledKinds: new Set(["exec"]),
channel: "telegram",
accountId: "default",
requestGateway: createGatewayRequestMock(),
});
const second = coordinator.createReporter({
...defaultRouteSelector,
handledKinds: new Set(["exec"]),
channel: "telegram",
accountId: "ops",
requestGateway: createGatewayRequestMock(),
});
first.start();
second.start();
expect(coordinator.hasActiveRuntime({ approvalKind: "exec", channel: "telegram" })).toBe(false);
expect(
coordinator.hasActiveRuntime({
approvalKind: "exec",
channel: "telegram",
accountId: "ops",
}),
).toBe(true);
coordinator.close();
});
it("selects the sole eligible runtime for an unbound request", () => {
const coordinator = createApprovalNativeRouteCoordinator();
const requestGateway = createGatewayRequestMock();
const createReporter = (accountId: string, eligible: boolean) =>
coordinator.createReporter({
handledKinds: new Set(["exec"]),
channel: "telegram",
accountId,
requestGateway,
shouldHandle: () => eligible,
classifyRoute: () => "unbound",
});
const defaultReporter = createReporter("default", true);
const opsReporter = createReporter("ops", false);
defaultReporter.start();
opsReporter.start();
const request = {
id: "approval-filtered",
request: { command: "echo hi", turnSourceChannel: "telegram" },
createdAtMs: 0,
expiresAtMs: Date.now() + 60_000,
} as const;
expect(defaultReporter.selectRequest({ approvalKind: "exec", request })).toEqual({
kind: "selected",
});
expect(opsReporter.selectRequest({ approvalKind: "exec", request })).toEqual({
kind: "ineligible",
});
coordinator.close();
});
it("keeps each channel's sole eligible runtime independent", () => {
const coordinator = createApprovalNativeRouteCoordinator();
const requestGateway = createGatewayRequestMock();
const createReporter = (channel: string) =>
coordinator.createReporter({
...defaultRouteSelector,
handledKinds: new Set(["exec"]),
channel,
accountId: "default",
requestGateway,
});
const telegramReporter = createReporter("telegram");
const matrixReporter = createReporter("matrix");
telegramReporter.start();
matrixReporter.start();
const request = {
id: "approval-two-channels",
request: { command: "echo hi" },
createdAtMs: 0,
expiresAtMs: Date.now() + 60_000,
} as const;
expect(telegramReporter.selectRequest({ approvalKind: "exec", request })).toEqual({
kind: "selected",
});
expect(matrixReporter.selectRequest({ approvalKind: "exec", request })).toEqual({
kind: "selected",
});
coordinator.close();
});
it("fails an unbound multi-account route visibly and keeps the owner snapshot sticky", async () => {
const coordinator = createApprovalNativeRouteCoordinator();
const requestGateway = createGatewayRequestMock();
const createReporter = (accountId: string) =>
coordinator.createReporter({
...defaultRouteSelector,
handledKinds: new Set(["exec"]),
channel: "telegram",
accountId,
requestGateway,
});
const first = createReporter("default");
const second = createReporter("ops");
first.start();
second.start();
const request = {
id: "deadbeef-1234-4567-89ab-cdef01234567",
request: {
command: "echo hi",
turnSourceChannel: "telegram",
turnSourceTo: "chat:123",
},
createdAtMs: 0,
expiresAtMs: Date.now() + 60_000,
} as const;
expect(first.selectRequest({ approvalKind: "exec", request })).toEqual({
kind: "ambiguous-owner",
});
expect(second.selectRequest({ approvalKind: "exec", request })).toEqual({
kind: "ambiguous-owner",
});
await first.reportSkipped({ approvalKind: "exec", request, reason: "ambiguous-owner" });
await second.reportSkipped({ approvalKind: "exec", request, reason: "ambiguous-owner" });
expect(requestGateway).toHaveBeenCalledTimes(1);
expect(requestGateway).toHaveBeenCalledWith(
"send",
expect.objectContaining({
channel: "telegram",
to: "chat:123",
message:
"Approval required, but multiple channel accounts can handle this request. Open the Control UI or terminal UI to approve it.",
}),
);
expect(requestGateway).not.toHaveBeenCalledWith(
"send",
expect.objectContaining({ message: expect.stringContaining("/approve") }),
);
const late = createReporter("late");
late.start();
expect(late.selectRequest({ approvalKind: "exec", request })).toEqual({ kind: "ineligible" });
coordinator.close();
});
it("selects every eligible explicit owner and no unrelated account", () => {
const coordinator = createApprovalNativeRouteCoordinator();
const requestGateway = createGatewayRequestMock();
const createReporter = (accountId: string, eligible: boolean) =>
coordinator.createReporter({
handledKinds: new Set(["exec"]),
channel: "telegram",
accountId,
requestGateway,
shouldHandle: () => eligible,
classifyRoute: () => "bound-or-explicit",
});
const first = createReporter("default", true);
const second = createReporter("ops", true);
const unrelated = createReporter("other", false);
first.start();
second.start();
unrelated.start();
const request = {
id: "approval-explicit-owners",
request: { command: "echo hi" },
createdAtMs: 0,
expiresAtMs: Date.now() + 60_000,
} as const;
expect(first.selectRequest({ approvalKind: "exec", request })).toEqual({ kind: "selected" });
expect(second.selectRequest({ approvalKind: "exec", request })).toEqual({ kind: "selected" });
expect(unrelated.selectRequest({ approvalKind: "exec", request })).toEqual({
kind: "ineligible",
});
coordinator.close();
});
it("isolates active routes and cleanup between Gateway instances", () => {
const first = createApprovalNativeRouteCoordinator();
const second = createApprovalNativeRouteCoordinator();
const firstReporter = first.createReporter({
...defaultRouteSelector,
handledKinds: new Set(["exec"]),
channel: "telegram",
accountId: "default",
requestGateway: createGatewayRequestMock(),
});
const secondReporter = second.createReporter({
...defaultRouteSelector,
handledKinds: new Set(["exec"]),
channel: "discord",
accountId: "default",
@@ -72,6 +263,7 @@ describe("createApprovalNativeRouteReporter", () => {
const coordinator = createApprovalNativeRouteCoordinator();
const requestGateway = createGatewayRequestMock();
const reporter = coordinator.createReporter({
...defaultRouteSelector,
handledKinds: new Set(["exec"]),
channel: "telegram",
accountId: "default",
@@ -91,18 +283,19 @@ describe("createApprovalNativeRouteReporter", () => {
reporter.start();
coordinator.close();
reporter.start();
reporter.observeRequest({ approvalKind: "exec", request });
await reporter.reportSkipped({ approvalKind: "exec", request });
reporter.selectRequest({ approvalKind: "exec", request });
await reporter.reportSkipped({ approvalKind: "exec", request, reason: "ineligible" });
const lateReporter = coordinator.createReporter({
...defaultRouteSelector,
handledKinds: new Set(["exec"]),
channel: "telegram",
accountId: "default",
requestGateway,
});
lateReporter.start();
lateReporter.observeRequest({ approvalKind: "exec", request });
await lateReporter.reportSkipped({ approvalKind: "exec", request });
lateReporter.selectRequest({ approvalKind: "exec", request });
await lateReporter.reportSkipped({ approvalKind: "exec", request, reason: "ineligible" });
expect(coordinator.hasActiveRuntime({ approvalKind: "exec", channel: "telegram" })).toBe(false);
expect(requestGateway).not.toHaveBeenCalled();
@@ -123,7 +316,7 @@ describe("createApprovalNativeRouteReporter", () => {
});
reporter.start();
reporter.observeRequest({
reporter.selectRequest({
approvalKind: "exec",
request: {
id: "approval-long",
@@ -137,13 +330,9 @@ describe("createApprovalNativeRouteReporter", () => {
},
});
expect(setTimeoutSpy).toHaveBeenCalledTimes(1);
const cleanupCall = setTimeoutSpy.mock.calls[0];
if (cleanupCall === undefined) {
throw new Error("expected cleanup timeout call");
}
const [cleanupCallback, cleanupDelayMs] = cleanupCall;
expect(cleanupDelayMs).toBe(5 * 60_000);
const cleanupCall = setTimeoutSpy.mock.calls.find(([, delay]) => delay === 5 * 60_000);
expect(cleanupCall).toBeDefined();
const [cleanupCallback] = cleanupCall ?? [];
expect(cleanupCallback).toBeTypeOf("function");
} finally {
vi.useRealTimers();
@@ -174,7 +363,7 @@ describe("createApprovalNativeRouteReporter", () => {
requestGateway,
});
reporter.start();
reporter.observeRequest({
reporter.selectRequest({
approvalKind: "exec",
request,
});
@@ -252,11 +441,11 @@ describe("createApprovalNativeRouteReporter", () => {
originReporter.start();
otherReporter.start();
originReporter.observeRequest({
originReporter.selectRequest({
approvalKind: "exec",
request,
});
otherReporter.observeRequest({
otherReporter.selectRequest({
approvalKind: "exec",
request,
});
@@ -336,7 +525,7 @@ describe("createApprovalNativeRouteReporter", () => {
requestGateway,
});
reporter.start();
reporter.observeRequest({
reporter.selectRequest({
approvalKind: "exec",
request,
});
+224 -47
View File
@@ -1,4 +1,3 @@
import { expectDefined } from "@openclaw/normalization-core";
// Coordinates native approval delivery routing and notices.
import {
normalizeLowercaseStringOrEmpty,
@@ -10,10 +9,12 @@ import type {
} from "./approval-native-delivery.js";
import {
describeApprovalDeliveryDestination,
resolveAmbiguousApprovalRouteNoticeText,
resolveApprovalDeliveryFailedNoticeText,
resolveApprovalRoutedElsewhereNoticeText,
} from "./approval-native-route-notice.js";
import { buildChannelApprovalNativeTargetKey } from "./approval-native-target-key.js";
import type { ApprovalRequestChannelRouteClass } from "./approval-request-account-binding.js";
import type { ChannelApprovalKind } from "./approval-types.js";
import type { ExecApprovalRequest } from "./exec-approvals.js";
import type { PluginApprovalRequest } from "./plugin-approvals.js";
@@ -32,8 +33,12 @@ type ApprovalRouteRuntimeRecord = {
channelLabel?: string;
accountId?: string | null;
requestGateway: GatewayRequestFn;
shouldHandle: (request: ApprovalRequest) => boolean;
classifyRoute: (request: ApprovalRequest) => ApprovalRequestChannelRouteClass;
};
type ApprovalRouteSkipReason = "ambiguous-owner" | "ineligible" | "owner-unavailable";
type ApprovalRouteReport = {
runtimeId: string;
request: ApprovalRequest;
@@ -43,15 +48,24 @@ type ApprovalRouteReport = {
deliveryPlan: ChannelApprovalNativeDeliveryPlan;
deliveredTargets: readonly ChannelApprovalNativePlannedTarget[];
requestGateway: GatewayRequestFn;
skipReason?: ApprovalRouteSkipReason;
};
type PendingApprovalRouteNotice = {
request: ApprovalRequest;
approvalKind: ChannelApprovalKind;
expectedRuntimeIds: Set<string>;
reports: Map<string, ApprovalRouteReport>;
cleanupTimeout: NodeJS.Timeout | null;
finalized: boolean;
cleanupTimeout: NodeJS.Timeout;
};
type ApprovalRouteSelectionVerdict =
| { kind: "selected" }
| { kind: ApprovalRouteSkipReason }
| { kind: "selector-error"; error: unknown };
type ApprovalRouteSelection = {
verdicts: Map<string, ApprovalRouteSelectionVerdict>;
cleanupTimeout: NodeJS.Timeout;
};
type RouteNoticeTarget = {
@@ -64,6 +78,7 @@ type RouteNoticeTarget = {
type ApprovalNativeRouteCoordinatorState = {
activeRuntimes: Map<string, ApprovalRouteRuntimeRecord>;
pendingNotices: Map<string, PendingApprovalRouteNotice>;
selections: Map<string, ApprovalRouteSelection>;
runtimeSeq: number;
closed: boolean;
};
@@ -72,11 +87,115 @@ function createApprovalNativeRouteCoordinatorState(): ApprovalNativeRouteCoordin
return {
activeRuntimes: new Map(),
pendingNotices: new Map(),
selections: new Map(),
runtimeSeq: 0,
closed: false,
};
}
function clearApprovalRouteSelection(
state: ApprovalNativeRouteCoordinatorState,
approvalId: string,
): void {
const selection = state.selections.get(approvalId);
if (!selection) {
return;
}
state.selections.delete(approvalId);
clearTimeout(selection.cleanupTimeout);
}
function routeGroupKey(runtime: ApprovalRouteRuntimeRecord): string {
return normalizeChannel(runtime.channel) || runtime.runtimeId;
}
function createApprovalRouteSelection(
state: ApprovalNativeRouteCoordinatorState,
params: { request: ApprovalRequest; approvalKind: ChannelApprovalKind },
): ApprovalRouteSelection {
const runtimes = Array.from(state.activeRuntimes.values()).filter((runtime) =>
runtime.handledKinds.has(params.approvalKind),
);
const verdicts = new Map<string, ApprovalRouteSelectionVerdict>();
const groups = new Map<string, ApprovalRouteRuntimeRecord[]>();
for (const runtime of runtimes) {
const key = routeGroupKey(runtime);
groups.set(key, [...(groups.get(key) ?? []), runtime]);
}
const selectedRuntimeIds = new Set<string>();
for (const group of groups.values()) {
const candidates: ApprovalRouteRuntimeRecord[] = [];
for (const runtime of group) {
try {
if (runtime.shouldHandle(params.request)) {
candidates.push(runtime);
}
} catch (error) {
verdicts.set(runtime.runtimeId, { kind: "selector-error", error });
}
}
let routeClass: ApprovalRequestChannelRouteClass;
try {
routeClass = group[0]?.classifyRoute(params.request) ?? "unbound";
} catch (error) {
for (const runtime of group) {
verdicts.set(runtime.runtimeId, { kind: "selector-error", error });
}
continue;
}
if (routeClass === "bound-or-explicit") {
if (candidates.length === 0) {
for (const runtime of group) {
if (!verdicts.has(runtime.runtimeId)) {
verdicts.set(runtime.runtimeId, { kind: "owner-unavailable" });
}
}
continue;
}
for (const runtime of candidates) {
selectedRuntimeIds.add(runtime.runtimeId);
}
} else if (routeClass === "unbound" && candidates.length === 1) {
const [candidate] = candidates;
if (candidate) {
selectedRuntimeIds.add(candidate.runtimeId);
}
} else if (routeClass === "unbound" && candidates.length > 1) {
for (const runtime of candidates) {
verdicts.set(runtime.runtimeId, { kind: "ambiguous-owner" });
}
}
}
for (const runtime of runtimes) {
if (selectedRuntimeIds.has(runtime.runtimeId)) {
verdicts.set(runtime.runtimeId, { kind: "selected" });
} else if (!verdicts.has(runtime.runtimeId)) {
verdicts.set(runtime.runtimeId, { kind: "ineligible" });
}
}
const timeoutMs = Math.min(Math.max(0, params.request.expiresAtMs - Date.now()), 0x7fffffff);
const cleanupTimeout = setTimeout(() => {
clearApprovalRouteSelection(state, params.request.id);
}, timeoutMs);
cleanupTimeout.unref?.();
const selection: ApprovalRouteSelection = {
verdicts,
cleanupTimeout,
};
state.selections.set(params.request.id, selection);
return selection;
}
function resolveApprovalRouteSelection(
state: ApprovalNativeRouteCoordinatorState,
params: { request: ApprovalRequest; approvalKind: ChannelApprovalKind },
): ApprovalRouteSelection {
return state.selections.get(params.request.id) ?? createApprovalRouteSelection(state, params);
}
const defaultCoordinatorState = createApprovalNativeRouteCoordinatorState();
const MAX_APPROVAL_ROUTE_NOTICE_TTL_MS = 5 * 60_000;
@@ -93,9 +212,7 @@ function clearPendingApprovalRouteNotice(
return;
}
state.pendingNotices.delete(approvalId);
if (entry.cleanupTimeout) {
clearTimeout(entry.cleanupTimeout);
}
clearTimeout(entry.cleanupTimeout);
}
function createPendingApprovalRouteNotice(
@@ -103,7 +220,6 @@ function createPendingApprovalRouteNotice(
params: {
request: ApprovalRequest;
approvalKind: ChannelApprovalKind;
expectedRuntimeIds?: Iterable<string>;
},
): PendingApprovalRouteNotice {
const timeoutMs = Math.min(
@@ -111,19 +227,14 @@ function createPendingApprovalRouteNotice(
MAX_APPROVAL_ROUTE_NOTICE_TTL_MS,
);
const cleanupTimeout = setTimeout(() => {
clearPendingApprovalRouteNotice(state, params.request.id);
void maybeFinalizeApprovalRouteNotice(state, params.request.id, { force: true });
}, timeoutMs);
cleanupTimeout.unref?.();
return {
request: params.request,
approvalKind: params.approvalKind,
// Snapshot siblings at first observation time so already-running runtimes
// can still aggregate one notice, while late-starting runtimes that cannot
// replay old gateway events never block the quorum.
expectedRuntimeIds: new Set(params.expectedRuntimeIds ?? []),
reports: new Map(),
cleanupTimeout,
finalized: false,
};
}
@@ -192,6 +303,7 @@ function resolveApprovalRouteNotice(params: {
approvalKind: ChannelApprovalKind;
request: ApprovalRequest;
reports: readonly ApprovalRouteReport[];
missingSelectedRuntime: boolean;
}): { requestGateway: GatewayRequestFn; target: RouteNoticeTarget; text: string } | null {
const explicitTarget = resolveRouteNoticeTargetFromRequest(params.request);
const originChannel = normalizeChannel(
@@ -215,17 +327,33 @@ function resolveApprovalRouteNotice(params: {
}
const originAccountId = normalizeOptionalString(target.accountId);
const deliveredAnyTarget = params.reports.some((report) => report.deliveredTargets.length > 0);
if (!deliveredAnyTarget && params.reports.some(hasPlannedNativeTargets)) {
const ambiguousOwner = params.reports.some((report) => report.skipReason === "ambiguous-owner");
const requiresManualFallback =
ambiguousOwner || params.reports.some((report) => report.skipReason === "owner-unavailable");
if (
!deliveredAnyTarget &&
(params.reports.some(hasPlannedNativeTargets) ||
requiresManualFallback ||
params.missingSelectedRuntime)
) {
const requestGateway =
params.reports.find((report) => params.state.activeRuntimes.has(report.runtimeId))
?.requestGateway ??
params.reports[0]?.requestGateway ??
Array.from(params.state.activeRuntimes.values())[0]?.requestGateway;
if (!requestGateway) {
return null;
}
return {
requestGateway:
params.reports.find((report) => params.state.activeRuntimes.has(report.runtimeId))
?.requestGateway ?? expectDefined(params.reports[0], "reports entry at 0").requestGateway,
requestGateway,
target,
text: resolveApprovalDeliveryFailedNoticeText({
approvalId: params.request.id,
approvalKind: params.approvalKind,
allowedDecisions: readAllowedDecisionStrings(params.request),
}),
text: ambiguousOwner
? resolveAmbiguousApprovalRouteNoticeText()
: resolveApprovalDeliveryFailedNoticeText({
approvalId: params.request.id,
approvalKind: params.approvalKind,
allowedDecisions: readAllowedDecisionStrings(params.request),
}),
};
}
@@ -308,7 +436,7 @@ function hasActiveApprovalNativeRouteRuntimeForState(
): boolean {
const channel = normalizeChannel(params.channel);
const accountId = normalizeOptionalString(params.accountId);
return Array.from(state.activeRuntimes.values()).some((runtime) => {
const matchingRuntimes = Array.from(state.activeRuntimes.values()).filter((runtime) => {
if (!runtime.handledKinds.has(params.approvalKind)) {
return false;
}
@@ -320,30 +448,43 @@ function hasActiveApprovalNativeRouteRuntimeForState(
accountId === undefined || runtimeAccountId === undefined || runtimeAccountId === accountId
);
});
return accountId === undefined ? matchingRuntimes.length === 1 : matchingRuntimes.length > 0;
}
async function maybeFinalizeApprovalRouteNotice(
state: ApprovalNativeRouteCoordinatorState,
approvalId: string,
options?: { force?: boolean },
): Promise<void> {
const entry = state.pendingNotices.get(approvalId);
if (!entry || entry.finalized) {
if (!entry) {
return;
}
for (const runtimeId of entry.expectedRuntimeIds) {
if (!entry.reports.has(runtimeId)) {
return;
const selection = state.selections.get(approvalId);
if (!selection) {
return;
}
if (!options?.force) {
for (const runtimeId of selection.verdicts.keys()) {
if (!entry.reports.has(runtimeId)) {
return;
}
}
}
const missingSelectedRuntime = Array.from(selection.verdicts).some(
([runtimeId, verdict]) => verdict.kind === "selected" && !entry.reports.has(runtimeId),
);
if (!options?.force && missingSelectedRuntime) {
return;
}
entry.finalized = true;
// Only runtimes observed with the request can block finalization; later runtimes must not delay it.
const reports = Array.from(entry.reports.values());
const notice = resolveApprovalRouteNotice({
state,
approvalKind: entry.approvalKind,
request: entry.request,
reports,
missingSelectedRuntime,
});
clearPendingApprovalRouteNotice(state, approvalId);
if (!notice) {
@@ -371,6 +512,8 @@ export function createApprovalNativeRouteReporter(params: {
channelLabel?: string;
accountId?: string | null;
requestGateway: GatewayRequestFn;
shouldHandle: (request: ApprovalRequest) => boolean;
classifyRoute: (request: ApprovalRequest) => ApprovalRequestChannelRouteClass;
}) {
return createApprovalNativeRouteReporterForState(defaultCoordinatorState, params);
}
@@ -383,6 +526,8 @@ function createApprovalNativeRouteReporterForState(
channelLabel?: string;
accountId?: string | null;
requestGateway: GatewayRequestFn;
shouldHandle: (request: ApprovalRequest) => boolean;
classifyRoute: (request: ApprovalRequest) => ApprovalRequestChannelRouteClass;
},
) {
const runtimeId = `native-approval-route:${++state.runtimeSeq}`;
@@ -393,18 +538,21 @@ function createApprovalNativeRouteReporterForState(
request: ApprovalRequest;
deliveryPlan: ChannelApprovalNativeDeliveryPlan;
deliveredTargets: readonly ChannelApprovalNativePlannedTarget[];
skipReason?: ApprovalRouteSkipReason;
}): Promise<void> => {
if (state.closed || !registered || !params.handledKinds.has(payload.approvalKind)) {
return;
}
const selection = resolveApprovalRouteSelection(state, payload);
if (!selection.verdicts.has(runtimeId)) {
return;
}
const entry =
state.pendingNotices.get(payload.request.id) ??
createPendingApprovalRouteNotice(state, {
request: payload.request,
approvalKind: payload.approvalKind,
expectedRuntimeIds: [runtimeId],
});
entry.expectedRuntimeIds.add(runtimeId);
entry.reports.set(runtimeId, {
runtimeId,
request: payload.request,
@@ -414,27 +562,38 @@ function createApprovalNativeRouteReporterForState(
deliveryPlan: payload.deliveryPlan,
deliveredTargets: payload.deliveredTargets,
requestGateway: params.requestGateway,
skipReason: payload.skipReason,
});
state.pendingNotices.set(payload.request.id, entry);
await maybeFinalizeApprovalRouteNotice(state, payload.request.id);
};
return {
observeRequest(payload: { approvalKind: ChannelApprovalKind; request: ApprovalRequest }): void {
if (state.closed || !registered || !params.handledKinds.has(payload.approvalKind)) {
return;
selectRequest(payload: {
approvalKind: ChannelApprovalKind;
request: ApprovalRequest;
}): ApprovalRouteSelectionVerdict {
if (state.closed || !params.handledKinds.has(payload.approvalKind)) {
return { kind: "ineligible" };
}
if (!registered) {
try {
return params.shouldHandle(payload.request)
? { kind: "selected" }
: { kind: "ineligible" };
} catch (error) {
return { kind: "selector-error", error };
}
}
const selection = resolveApprovalRouteSelection(state, payload);
const entry =
state.pendingNotices.get(payload.request.id) ??
createPendingApprovalRouteNotice(state, {
request: payload.request,
approvalKind: payload.approvalKind,
expectedRuntimeIds: Array.from(state.activeRuntimes.values())
.filter((runtime) => runtime.handledKinds.has(payload.approvalKind))
.map((runtime) => runtime.runtimeId),
});
entry.expectedRuntimeIds.add(runtimeId);
state.pendingNotices.set(payload.request.id, entry);
return selection.verdicts.get(runtimeId) ?? { kind: "ineligible" };
},
start(): void {
if (state.closed || registered) {
@@ -447,12 +606,15 @@ function createApprovalNativeRouteReporterForState(
channelLabel: params.channelLabel,
accountId: params.accountId,
requestGateway: params.requestGateway,
shouldHandle: params.shouldHandle,
classifyRoute: params.classifyRoute,
});
registered = true;
},
async reportSkipped(paramsValue: {
approvalKind: ChannelApprovalKind;
request: ApprovalRequest;
reason: ApprovalRouteSkipReason;
}): Promise<void> {
await report({
approvalKind: paramsValue.approvalKind,
@@ -463,6 +625,7 @@ function createApprovalNativeRouteReporterForState(
notifyOriginWhenDmOnly: false,
},
deliveredTargets: [],
skipReason: paramsValue.reason,
});
},
async reportDelivery(paramsLocal: {
@@ -473,20 +636,31 @@ function createApprovalNativeRouteReporterForState(
}): Promise<void> {
await report(paramsLocal);
},
completeRequest(approvalId: string): void {
clearApprovalRouteSelection(state, approvalId);
clearPendingApprovalRouteNotice(state, approvalId);
},
async stop(): Promise<void> {
if (!registered) {
return;
}
for (const entry of Array.from(state.pendingNotices.values())) {
const selection = state.selections.get(entry.request.id);
if (selection?.verdicts.has(runtimeId) && !entry.reports.has(runtimeId)) {
await report({
request: entry.request,
approvalKind: entry.approvalKind,
deliveryPlan: { targets: [], originTarget: null, notifyOriginWhenDmOnly: false },
deliveredTargets: [],
skipReason:
selection.verdicts.get(runtimeId)?.kind === "selected"
? "owner-unavailable"
: "ineligible",
});
}
}
registered = false;
state.activeRuntimes.delete(runtimeId);
for (const entry of state.pendingNotices.values()) {
entry.expectedRuntimeIds.delete(runtimeId);
if (entry.expectedRuntimeIds.size === 0) {
clearPendingApprovalRouteNotice(state, entry.request.id);
continue;
}
await maybeFinalizeApprovalRouteNotice(state, entry.request.id);
}
},
};
}
@@ -510,6 +684,9 @@ export function createApprovalNativeRouteCoordinator(): ApprovalNativeRouteCoord
for (const approvalId of Array.from(state.pendingNotices.keys())) {
clearPendingApprovalRouteNotice(state, approvalId);
}
for (const approvalId of Array.from(state.selections.keys())) {
clearApprovalRouteSelection(state, approvalId);
}
state.activeRuntimes.clear();
},
};
@@ -2,9 +2,16 @@
import { describe, expect, it } from "vitest";
import {
describeApprovalDeliveryDestination,
resolveAmbiguousApprovalRouteNoticeText,
resolveApprovalRoutedElsewhereNoticeText,
} from "./approval-native-route-notice.js";
it("routes ambiguous ownership to an account-independent approval surface", () => {
expect(resolveAmbiguousApprovalRouteNoticeText()).toBe(
"Approval required, but multiple channel accounts can handle this request. Open the Control UI or terminal UI to approve it.",
);
});
describe("describeApprovalDeliveryDestination", () => {
it("labels approver-DM-only delivery as channel DMs", () => {
expect(
@@ -29,6 +29,11 @@ export function resolveApprovalRoutedElsewhereNoticeText(
)}, not this chat.`;
}
/** Builds the recovery notice when no channel account uniquely owns the approval. */
export function resolveAmbiguousApprovalRouteNoticeText(): string {
return "Approval required, but multiple channel accounts can handle this request. Open the Control UI or terminal UI to approve it.";
}
/** Builds the fallback slash-command notice when native approval delivery fails. */
export function resolveApprovalDeliveryFailedNoticeText(params: {
approvalId: string;
+33 -12
View File
@@ -14,6 +14,7 @@ import type {
ChannelNativeApprovalTransportSpec,
PreparedChannelNativeApprovalTarget,
} from "./approval-native-runtime-types.js";
import { classifyApprovalRequestChannelRoute } from "./approval-request-account-binding.js";
import { resolveApprovalRequestKind, type ChannelApprovalKind } from "./approval-types.js";
import {
createExecApprovalChannelRuntime,
@@ -203,6 +204,13 @@ export function createChannelNativeApprovalRuntime<
channel: adapter.channel,
channelLabel: adapter.channelLabel,
accountId: adapter.accountId,
shouldHandle: (request) => adapter.shouldHandle(request as TRequest),
classifyRoute: (request) =>
classifyApprovalRequestChannelRoute({
cfg: adapter.cfg,
request,
channel: adapter.channel ?? "",
}),
requestGateway: async <T>(method: string, params: Record<string, unknown>): Promise<T> => {
if (gatewayRuntime) {
if (method !== "send") {
@@ -231,31 +239,44 @@ export function createChannelNativeApprovalRuntime<
isConfigured: adapter.isConfigured,
shouldHandle: (request) => {
const approvalKind = resolveApprovalKind(request);
routeReporter.observeRequest({
const selection = routeReporter.selectRequest({
approvalKind,
request,
});
let shouldHandle: boolean;
try {
shouldHandle = adapter.shouldHandle(request);
} catch (error) {
if (selection.kind === "selected") {
return true;
}
if (selection.kind === "selector-error") {
void routeReporter.reportSkipped({
approvalKind,
request,
reason: "ineligible",
});
throw error;
}
if (shouldHandle) {
return shouldHandle;
throw selection.error;
}
void routeReporter.reportSkipped({
approvalKind,
request,
reason: selection.kind,
});
return false;
},
finalizeResolved: adapter.finalizeResolved,
finalizeExpired: adapter.finalizeExpired,
finalizeResolved: async (params) => {
try {
await adapter.finalizeResolved(params);
} finally {
routeReporter.completeRequest(params.request.id);
}
},
finalizeExpired: adapter.finalizeExpired
? async (params) => {
try {
await adapter.finalizeExpired?.(params);
} finally {
routeReporter.completeRequest(params.request.id);
}
}
: undefined,
onStopped: adapter.onStopped,
beforeGatewayClientStart: () => {
routeReporter.start();
@@ -356,8 +377,8 @@ export function createChannelNativeApprovalRuntime<
}
},
async stop() {
await routeReporter.stop();
await runtime.stop();
await routeReporter.stop();
},
};
}
+116 -1
View File
@@ -11,10 +11,93 @@ import {
sessionDeliveryOrigin,
} from "../utils/delivery-context.shared.js";
import { normalizeMessageChannel } from "../utils/message-channel.js";
import { matchesApprovalRequestFilters } from "./approval-request-filters.js";
import type { ExecApprovalRequest } from "./exec-approvals.js";
import type { PluginApprovalRequest } from "./plugin-approvals.js";
type ApprovalRequestLike = ExecApprovalRequest | PluginApprovalRequest;
export type ApprovalRequestLike = {
id: string;
request: ExecApprovalRequest["request"] | PluginApprovalRequest["request"];
createdAtMs: number;
expiresAtMs: number;
};
function resolveApprovalForwardAccountIds(params: {
cfg: OpenClawConfig;
request: ApprovalRequestLike;
channel?: string | null;
defaultAccountId?: string | null;
}): string[] {
const forwarding =
"command" in params.request.request ? params.cfg.approvals?.exec : params.cfg.approvals?.plugin;
const channel = normalizeOptionalChannel(params.channel);
if (!forwarding?.enabled || (forwarding.mode !== "targets" && forwarding.mode !== "both")) {
return [];
}
if (
!matchesApprovalRequestFilters({
request: params.request.request,
agentFilter: forwarding.agentFilter,
sessionFilter: forwarding.sessionFilter,
})
) {
return [];
}
const accountIds = (forwarding.targets ?? []).flatMap((target) => {
if (normalizeOptionalChannel(target.channel) !== channel) {
return [];
}
const accountId = normalizeOptionalAccountId(target.accountId ?? params.defaultAccountId);
return accountId ? [accountId] : [];
});
return accountIds;
}
function hasApprovalForwardTarget(params: {
cfg: OpenClawConfig;
request: ApprovalRequestLike;
channel?: string | null;
}): boolean {
const forwarding =
"command" in params.request.request ? params.cfg.approvals?.exec : params.cfg.approvals?.plugin;
if (
!forwarding?.enabled ||
(forwarding.mode !== "targets" && forwarding.mode !== "both") ||
!matchesApprovalRequestFilters({
request: params.request.request,
agentFilter: forwarding.agentFilter,
sessionFilter: forwarding.sessionFilter,
})
) {
return false;
}
const channel = normalizeOptionalChannel(params.channel);
return (forwarding.targets ?? []).some(
(target) => normalizeOptionalChannel(target.channel) === channel,
);
}
export type ApprovalRequestChannelRouteClass = "bound-or-explicit" | "unbound";
/** Classifies whether native delivery has named channel-account owners. */
export function classifyApprovalRequestChannelRoute(params: {
cfg: OpenClawConfig;
request: ApprovalRequestLike;
channel: string;
defaultAccountId?: string | null;
}): ApprovalRequestChannelRouteClass {
const expectedChannel = normalizeOptionalChannel(params.channel);
if (!expectedChannel) {
return "unbound";
}
if (resolveApprovalRequestChannelAccountId(params)) {
return "bound-or-explicit";
}
if (hasApprovalForwardTarget(params)) {
return "bound-or-explicit";
}
return "unbound";
}
type ApprovalRequestSessionBinding = {
channel?: string;
@@ -152,3 +235,35 @@ export function doesApprovalRequestMatchChannelAccount(params: {
const boundAccountId = sessionBinding?.accountId;
return !expectedAccountId || !boundAccountId || expectedAccountId === boundAccountId;
}
/** Selects the one channel account that owns a native approval request. */
export function doesApprovalRequestSelectChannelAccount(params: {
cfg: OpenClawConfig;
request: ApprovalRequestLike;
channel: string;
accountId?: string | null;
defaultAccountId: string;
eligibleAccountIds: readonly string[];
}): boolean {
const accountId =
normalizeOptionalAccountId(params.accountId) ??
normalizeOptionalAccountId(params.defaultAccountId);
if (!accountId) {
return false;
}
const boundAccountId = resolveApprovalRequestChannelAccountId(params);
if (accountId === normalizeOptionalAccountId(boundAccountId)) {
return true;
}
const forwardAccountIds = resolveApprovalForwardAccountIds(params);
if (forwardAccountIds.includes(accountId)) {
return true;
}
if (boundAccountId || forwardAccountIds.length > 0) {
return false;
}
const eligibleAccountIds = params.eligibleAccountIds
.map(normalizeOptionalAccountId)
.filter((candidate): candidate is string => Boolean(candidate));
return eligibleAccountIds.length === 1 && eligibleAccountIds[0] === accountId;
}
@@ -13,6 +13,7 @@ import {
import { withTempDir } from "../test-helpers/temp-dir.js";
import {
doesApprovalRequestMatchChannelAccount,
doesApprovalRequestSelectChannelAccount,
resolveApprovalRequestAccountId,
resolveApprovalRequestChannelAccountId,
} from "./approval-request-account-binding.js";
@@ -65,6 +66,122 @@ const baseRequest: ExecApprovalRequest = {
expiresAtMs: 6000,
};
describe("native approval account selection", () => {
it("selects only the sole eligible account when no owner is recorded", () => {
expect(
doesApprovalRequestSelectChannelAccount({
cfg: {},
request: baseRequest,
channel: "telegram",
accountId: "default",
defaultAccountId: "default",
eligibleAccountIds: ["default"],
}),
).toBe(true);
expect(
doesApprovalRequestSelectChannelAccount({
cfg: {},
request: baseRequest,
channel: "telegram",
accountId: "default",
defaultAccountId: "default",
eligibleAccountIds: ["default", "ops"],
}),
).toBe(false);
});
it("selects the recorded account even when several accounts are eligible", () => {
const request = buildRequest({
turnSourceChannel: "telegram",
turnSourceAccountId: "ops",
});
expect(
doesApprovalRequestSelectChannelAccount({
cfg: {},
request,
channel: "telegram",
accountId: "ops",
defaultAccountId: "default",
eligibleAccountIds: ["default", "ops"],
}),
).toBe(true);
expect(
doesApprovalRequestSelectChannelAccount({
cfg: {},
request,
channel: "telegram",
accountId: "default",
defaultAccountId: "default",
eligibleAccountIds: ["default", "ops"],
}),
).toBe(false);
});
it("maps unscoped explicit targets to default and preserves scoped targets", () => {
const cfg = {
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [
{ channel: "telegram", to: "owner" },
{ channel: "telegram", to: "ops-owner", accountId: "ops" },
],
},
},
} as OpenClawConfig;
for (const [accountId, selected] of [
["default", true],
["ops", true],
["other", false],
] as const) {
expect(
doesApprovalRequestSelectChannelAccount({
cfg,
request: baseRequest,
channel: "telegram",
accountId,
defaultAccountId: "default",
eligibleAccountIds: ["default", "ops", "other"],
}),
).toBe(selected);
}
});
it("selects the source account and explicit targets in both mode", () => {
const cfg = {
approvals: {
exec: {
enabled: true,
mode: "both",
targets: [{ channel: "telegram", accountId: "audit" }],
},
},
} as OpenClawConfig;
const request = buildRequest({
turnSourceChannel: "telegram",
turnSourceAccountId: "ops",
});
for (const [accountId, selected] of [
["ops", true],
["audit", true],
["other", false],
] as const) {
expect(
doesApprovalRequestSelectChannelAccount({
cfg,
request,
channel: "telegram",
accountId,
defaultAccountId: "default",
eligibleAccountIds: ["ops", "audit", "other"],
}),
).toBe(selected);
}
});
});
type SessionEntryFixture = Partial<SessionEntry> & {
origin?: SessionOrigin;
lastChannel?: string;
@@ -154,6 +154,31 @@ describe("createNativeApprovalMessagingTargetResolvers", () => {
});
describe("createNativeApprovalChannelRouteGates", () => {
it("rejects an unbound session route when multiple accounts are eligible", () => {
const cfg = {
approvals: { exec: { enabled: true, mode: "session" } },
} satisfies OpenClawConfig;
const request = {
...matrixExecRequest,
request: { ...matrixExecRequest.request, turnSourceAccountId: undefined },
};
for (const accountId of ["default", "ops"]) {
expect(
createMatrixRouteGates({
accountIds: ["default", "ops"],
enabledAccounts: ["default", "ops"],
}).shouldHandleApprovalRequest({ cfg, accountId, request }),
).toBe(false);
}
expect(
createMatrixRouteGates({
accountIds: ["default", "ops"],
enabledAccounts: ["ops"],
}).shouldHandleApprovalRequest({ cfg, accountId: "ops", request }),
).toBe(true);
});
it("separates session-native and explicit target routing by approval family", () => {
const gates = createMatrixRouteGates();
const cfg = {
+18 -26
View File
@@ -7,7 +7,7 @@ import type {
ExecApprovalForwardingConfig,
ExecApprovalForwardingMode,
} from "../config/types.approvals.js";
import { doesApprovalRequestMatchChannelAccount } from "../infra/approval-request-account-binding.js";
import { doesApprovalRequestSelectChannelAccount } from "../infra/approval-request-account-binding.js";
import { matchesApprovalRequestFilters } from "../infra/approval-request-filters.js";
import {
getExecApprovalReplyMetadata,
@@ -545,16 +545,6 @@ function isSessionApprovalEligibleViaForwarding(
if (!matchesForwardingFilters({ config: forwarding.config, request: params.request })) {
return false;
}
if (
!doesApprovalRequestMatchChannelAccount({
cfg: params.cfg,
request: params.request,
channel: params.channel,
accountId: params.accountId,
})
) {
return false;
}
return params.hasOriginOrSessionTarget({
cfg: params.cfg,
accountId: params.accountId,
@@ -695,21 +685,7 @@ export function createNativeApprovalChannelRouteGates<TTarget extends NativeAppr
}
const normalizedAccountId = normalizeAccountId(accountId);
const defaultAccountId = normalizeAccountId(params.resolveDefaultAccountId(input.cfg));
if (normalizedAccountId === defaultAccountId) {
return true;
}
const enabledAccountIds = params
.listAccountIds(input.cfg)
.filter((candidateAccountId) =>
params.isTransportEnabled({
cfg: input.cfg,
accountId: candidateAccountId,
}),
)
.map((candidateAccountId) => normalizeAccountId(candidateAccountId));
// Unscoped targets are safe for a non-default account only when exactly
// one enabled account can receive them; otherwise they would be ambiguous.
return enabledAccountIds.length === 1 && enabledAccountIds[0] === normalizedAccountId;
return normalizedAccountId === defaultAccountId;
};
const hasMatchingChannelTarget = (input: {
@@ -797,6 +773,22 @@ export function createNativeApprovalChannelRouteGates<TTarget extends NativeAppr
approvalKind: ApprovalKind;
request: ApprovalRequest;
}): boolean => {
const accountId = input.accountId ?? params.resolveDefaultAccountId(input.cfg);
const eligibleAccountIds = params.isTransportEnabled({ cfg: input.cfg, accountId })
? [accountId]
: [];
if (
!doesApprovalRequestSelectChannelAccount({
cfg: input.cfg,
request: input.request,
channel: params.channel,
accountId: input.accountId,
defaultAccountId: params.resolveDefaultAccountId(input.cfg),
eligibleAccountIds,
})
) {
return false;
}
return isSessionApprovalEligibleViaForwarding({
...input,
channel: params.channel,
@@ -26,6 +26,7 @@ export {
export { buildChannelApprovalNativeTargetKey } from "../infra/approval-native-target-key.js";
export {
doesApprovalRequestMatchChannelAccount,
doesApprovalRequestSelectChannelAccount,
resolveApprovalRequestAccountId,
resolveApprovalRequestChannelAccountId,
} from "../infra/approval-request-account-binding.js";