mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
+27
-26
File diff suppressed because one or more lines are too long
@@ -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",
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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." };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user