mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-02 06:40:23 +00:00
refactor(exec): unify channel approvals and restore routing/auth (#57838)
* fix(exec): add shared approval runtime * fix(exec): harden shared approval runtime * fix(exec): guard approval expiration callbacks * fix(exec): handle approval runtime races * fix(exec): clean up failed approval deliveries * fix(exec): restore channel approval routing * fix(exec): scope telegram legacy approval fallback * refactor(exec): centralize native approval delivery * fix(exec): harden approval auth and account routing * test(exec): align telegram approval auth assertions * fix(exec): align approval rebase followups * fix(exec): clarify plugin approval not-found errors * fix(exec): fall back to session-bound telegram accounts * fix(exec): detect structured telegram approval misses * test(exec): align discord approval auth coverage * fix(exec): ignore discord dm origin channel routes * fix(telegram): skip self-authored message echoes * fix(exec): keep implicit approval auth non-explicit
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
isTelegramExecApprovalAuthorizedSender,
|
||||
isTelegramExecApprovalClientEnabled,
|
||||
} from "../../../extensions/telegram/api.js";
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
import { ErrorCodes } from "../../gateway/protocol/index.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
@@ -70,6 +74,17 @@ function buildResolvedByLabel(params: Parameters<CommandHandler>[0]): string {
|
||||
return `${channel}:${sender}`;
|
||||
}
|
||||
|
||||
function isAuthorizedTelegramExecSender(params: Parameters<CommandHandler>[0]): boolean {
|
||||
if (params.command.channel !== "telegram") {
|
||||
return false;
|
||||
}
|
||||
return isTelegramExecApprovalAuthorizedSender({
|
||||
cfg: params.cfg,
|
||||
accountId: params.ctx.AccountId,
|
||||
senderId: params.command.senderId,
|
||||
});
|
||||
}
|
||||
|
||||
function readErrorCode(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value : null;
|
||||
}
|
||||
@@ -112,29 +127,55 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
if (!parsed.ok) {
|
||||
return { shouldContinue: false, reply: { text: parsed.error } };
|
||||
}
|
||||
|
||||
const isPluginId = parsed.id.startsWith("plugin:");
|
||||
const telegramExecAuthorizedSender = isAuthorizedTelegramExecSender(params);
|
||||
const execApprovalAuthorization = resolveApprovalCommandAuthorization({
|
||||
cfg: params.cfg,
|
||||
channel: params.command.channel,
|
||||
accountId: params.ctx.AccountId,
|
||||
senderId: params.command.senderId,
|
||||
kind: "exec",
|
||||
});
|
||||
const pluginApprovalAuthorization = resolveApprovalCommandAuthorization({
|
||||
cfg: params.cfg,
|
||||
channel: params.command.channel,
|
||||
accountId: params.ctx.AccountId,
|
||||
senderId: params.command.senderId,
|
||||
kind: "plugin",
|
||||
});
|
||||
const hasExplicitApprovalAuthorization =
|
||||
(execApprovalAuthorization.explicit && execApprovalAuthorization.authorized) ||
|
||||
(pluginApprovalAuthorization.explicit && pluginApprovalAuthorization.authorized);
|
||||
if (!params.command.isAuthorizedSender && !hasExplicitApprovalAuthorization) {
|
||||
logVerbose(
|
||||
`Ignoring /approve from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
|
||||
if (!parsed.ok) {
|
||||
return { shouldContinue: false, reply: { text: parsed.error } };
|
||||
if (
|
||||
params.command.channel === "telegram" &&
|
||||
!isPluginId &&
|
||||
!telegramExecAuthorizedSender &&
|
||||
!isTelegramExecApprovalClientEnabled({ cfg: params.cfg, accountId: params.ctx.AccountId })
|
||||
) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "❌ Telegram exec approvals are not enabled for this bot account." },
|
||||
};
|
||||
}
|
||||
const isPluginId = parsed.id.startsWith("plugin:");
|
||||
const approvalAuthorization = resolveApprovalCommandAuthorization({
|
||||
cfg: params.cfg,
|
||||
channel: params.command.channel,
|
||||
accountId: params.ctx.AccountId,
|
||||
senderId: params.command.senderId,
|
||||
kind: isPluginId ? "plugin" : "exec",
|
||||
});
|
||||
if (!approvalAuthorization.authorized) {
|
||||
|
||||
if (isPluginId && !pluginApprovalAuthorization.authorized) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: approvalAuthorization.reason ?? "❌ You are not authorized to approve this request.",
|
||||
text:
|
||||
pluginApprovalAuthorization.reason ??
|
||||
"❌ You are not authorized to approve this request.",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -160,7 +201,7 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm
|
||||
};
|
||||
|
||||
// Plugin approval IDs are kind-prefixed (`plugin:<uuid>`); route directly when detected.
|
||||
// Unprefixed IDs try exec first, then fall back to plugin for backward compat.
|
||||
// Unprefixed IDs try the authorized path first, then fall back for backward compat.
|
||||
if (isPluginId) {
|
||||
try {
|
||||
await callApprovalMethod("plugin.approval.resolve");
|
||||
@@ -170,19 +211,12 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm
|
||||
reply: { text: `❌ Failed to submit approval: ${String(err)}` },
|
||||
};
|
||||
}
|
||||
} else {
|
||||
} else if (execApprovalAuthorization.authorized) {
|
||||
try {
|
||||
await callApprovalMethod("exec.approval.resolve");
|
||||
} catch (err) {
|
||||
if (isApprovalNotFoundError(err)) {
|
||||
const pluginFallbackAuthorization = resolveApprovalCommandAuthorization({
|
||||
cfg: params.cfg,
|
||||
channel: params.command.channel,
|
||||
accountId: params.ctx.AccountId,
|
||||
senderId: params.command.senderId,
|
||||
kind: "plugin",
|
||||
});
|
||||
if (!pluginFallbackAuthorization.authorized) {
|
||||
if (!pluginApprovalAuthorization.authorized) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `❌ Failed to submit approval: ${String(err)}` },
|
||||
@@ -203,6 +237,29 @@ export const handleApproveCommand: CommandHandler = async (params, allowTextComm
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if (pluginApprovalAuthorization.authorized) {
|
||||
try {
|
||||
await callApprovalMethod("plugin.approval.resolve");
|
||||
} catch (err) {
|
||||
if (isApprovalNotFoundError(err)) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `❌ Failed to submit approval: ${String(err)}` },
|
||||
};
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `❌ Failed to submit approval: ${String(err)}` },
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text:
|
||||
execApprovalAuthorization.reason ?? "❌ You are not authorized to approve this request.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -514,6 +514,48 @@ export type ChannelApprovalDeliveryAdapter = {
|
||||
}) => boolean;
|
||||
};
|
||||
|
||||
export type ChannelApprovalKind = "exec" | "plugin";
|
||||
|
||||
export type ChannelApprovalNativeSurface = "origin" | "approver-dm";
|
||||
|
||||
export type ChannelApprovalNativeTarget = {
|
||||
to: string;
|
||||
threadId?: string | number | null;
|
||||
};
|
||||
|
||||
export type ChannelApprovalNativeDeliveryPreference = ChannelApprovalNativeSurface | "both";
|
||||
|
||||
export type ChannelApprovalNativeRequest = ExecApprovalRequest | PluginApprovalRequest;
|
||||
|
||||
export type ChannelApprovalNativeDeliveryCapabilities = {
|
||||
enabled: boolean;
|
||||
preferredSurface: ChannelApprovalNativeDeliveryPreference;
|
||||
supportsOriginSurface: boolean;
|
||||
supportsApproverDmSurface: boolean;
|
||||
notifyOriginWhenDmOnly?: boolean;
|
||||
};
|
||||
|
||||
export type ChannelApprovalNativeAdapter = {
|
||||
describeDeliveryCapabilities: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
approvalKind: ChannelApprovalKind;
|
||||
request: ChannelApprovalNativeRequest;
|
||||
}) => ChannelApprovalNativeDeliveryCapabilities;
|
||||
resolveOriginTarget?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
approvalKind: ChannelApprovalKind;
|
||||
request: ChannelApprovalNativeRequest;
|
||||
}) => ChannelApprovalNativeTarget | null | Promise<ChannelApprovalNativeTarget | null>;
|
||||
resolveApproverDmTargets?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
approvalKind: ChannelApprovalKind;
|
||||
request: ChannelApprovalNativeRequest;
|
||||
}) => ChannelApprovalNativeTarget[] | Promise<ChannelApprovalNativeTarget[]>;
|
||||
};
|
||||
|
||||
export type ChannelApprovalRenderAdapter = {
|
||||
exec?: {
|
||||
buildPendingPayload?: (params: {
|
||||
@@ -546,6 +588,7 @@ export type ChannelApprovalRenderAdapter = {
|
||||
export type ChannelApprovalAdapter = {
|
||||
delivery?: ChannelApprovalDeliveryAdapter;
|
||||
render?: ChannelApprovalRenderAdapter;
|
||||
native?: ChannelApprovalNativeAdapter;
|
||||
};
|
||||
|
||||
export type ChannelAllowlistAdapter = {
|
||||
|
||||
147
src/infra/approval-native-delivery.test.ts
Normal file
147
src/infra/approval-native-delivery.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChannelApprovalNativeAdapter } from "../channels/plugins/types.adapters.js";
|
||||
import { resolveChannelNativeApprovalDeliveryPlan } from "./approval-native-delivery.js";
|
||||
|
||||
const execRequest = {
|
||||
id: "approval-1",
|
||||
request: {
|
||||
command: "uname -a",
|
||||
},
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 120_000,
|
||||
};
|
||||
|
||||
describe("resolveChannelNativeApprovalDeliveryPlan", () => {
|
||||
it("prefers the origin surface when configured and available", async () => {
|
||||
const adapter: ChannelApprovalNativeAdapter = {
|
||||
describeDeliveryCapabilities: () => ({
|
||||
enabled: true,
|
||||
preferredSurface: "origin",
|
||||
supportsOriginSurface: true,
|
||||
supportsApproverDmSurface: true,
|
||||
}),
|
||||
resolveOriginTarget: async () => ({ to: "origin-chat", threadId: "42" }),
|
||||
resolveApproverDmTargets: async () => [{ to: "approver-1" }],
|
||||
};
|
||||
|
||||
const plan = await resolveChannelNativeApprovalDeliveryPlan({
|
||||
cfg: {} as never,
|
||||
approvalKind: "exec",
|
||||
request: execRequest,
|
||||
adapter,
|
||||
});
|
||||
|
||||
expect(plan.notifyOriginWhenDmOnly).toBe(false);
|
||||
expect(plan.targets).toEqual([
|
||||
{
|
||||
surface: "origin",
|
||||
target: { to: "origin-chat", threadId: "42" },
|
||||
reason: "preferred",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to approver DMs when origin delivery is unavailable", async () => {
|
||||
const adapter: ChannelApprovalNativeAdapter = {
|
||||
describeDeliveryCapabilities: () => ({
|
||||
enabled: true,
|
||||
preferredSurface: "origin",
|
||||
supportsOriginSurface: true,
|
||||
supportsApproverDmSurface: true,
|
||||
}),
|
||||
resolveOriginTarget: async () => null,
|
||||
resolveApproverDmTargets: async () => [{ to: "approver-1" }, { to: "approver-2" }],
|
||||
};
|
||||
|
||||
const plan = await resolveChannelNativeApprovalDeliveryPlan({
|
||||
cfg: {} as never,
|
||||
approvalKind: "exec",
|
||||
request: execRequest,
|
||||
adapter,
|
||||
});
|
||||
|
||||
expect(plan.targets).toEqual([
|
||||
{
|
||||
surface: "approver-dm",
|
||||
target: { to: "approver-1" },
|
||||
reason: "fallback",
|
||||
},
|
||||
{
|
||||
surface: "approver-dm",
|
||||
target: { to: "approver-2" },
|
||||
reason: "fallback",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("requests an origin redirect notice when DM-only delivery has an origin context", async () => {
|
||||
const adapter: ChannelApprovalNativeAdapter = {
|
||||
describeDeliveryCapabilities: () => ({
|
||||
enabled: true,
|
||||
preferredSurface: "approver-dm",
|
||||
supportsOriginSurface: true,
|
||||
supportsApproverDmSurface: true,
|
||||
notifyOriginWhenDmOnly: true,
|
||||
}),
|
||||
resolveOriginTarget: async () => ({ to: "origin-chat" }),
|
||||
resolveApproverDmTargets: async () => [{ to: "approver-1" }],
|
||||
};
|
||||
|
||||
const plan = await resolveChannelNativeApprovalDeliveryPlan({
|
||||
cfg: {} as never,
|
||||
approvalKind: "plugin",
|
||||
request: {
|
||||
...execRequest,
|
||||
id: "plugin:approval-1",
|
||||
request: {
|
||||
title: "Plugin approval",
|
||||
description: "Needs access",
|
||||
},
|
||||
},
|
||||
adapter,
|
||||
});
|
||||
|
||||
expect(plan.originTarget).toEqual({ to: "origin-chat" });
|
||||
expect(plan.notifyOriginWhenDmOnly).toBe(true);
|
||||
expect(plan.targets).toEqual([
|
||||
{
|
||||
surface: "approver-dm",
|
||||
target: { to: "approver-1" },
|
||||
reason: "preferred",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("dedupes duplicate origin and DM targets when both surfaces converge", async () => {
|
||||
const adapter: ChannelApprovalNativeAdapter = {
|
||||
describeDeliveryCapabilities: () => ({
|
||||
enabled: true,
|
||||
preferredSurface: "both",
|
||||
supportsOriginSurface: true,
|
||||
supportsApproverDmSurface: true,
|
||||
}),
|
||||
resolveOriginTarget: async () => ({ to: "shared-chat" }),
|
||||
resolveApproverDmTargets: async () => [{ to: "shared-chat" }, { to: "approver-2" }],
|
||||
};
|
||||
|
||||
const plan = await resolveChannelNativeApprovalDeliveryPlan({
|
||||
cfg: {} as never,
|
||||
approvalKind: "exec",
|
||||
request: execRequest,
|
||||
adapter,
|
||||
});
|
||||
|
||||
expect(plan.targets).toEqual([
|
||||
{
|
||||
surface: "origin",
|
||||
target: { to: "shared-chat" },
|
||||
reason: "preferred",
|
||||
},
|
||||
{
|
||||
surface: "approver-dm",
|
||||
target: { to: "approver-2" },
|
||||
reason: "preferred",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
134
src/infra/approval-native-delivery.ts
Normal file
134
src/infra/approval-native-delivery.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type {
|
||||
ChannelApprovalKind,
|
||||
ChannelApprovalNativeAdapter,
|
||||
ChannelApprovalNativeSurface,
|
||||
ChannelApprovalNativeTarget,
|
||||
} from "../channels/plugins/types.adapters.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { ExecApprovalRequest } from "./exec-approvals.js";
|
||||
import type { PluginApprovalRequest } from "./plugin-approvals.js";
|
||||
|
||||
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
|
||||
|
||||
export type ChannelApprovalNativePlannedTarget = {
|
||||
surface: ChannelApprovalNativeSurface;
|
||||
target: ChannelApprovalNativeTarget;
|
||||
reason: "preferred" | "fallback";
|
||||
};
|
||||
|
||||
export type ChannelApprovalNativeDeliveryPlan = {
|
||||
targets: ChannelApprovalNativePlannedTarget[];
|
||||
originTarget: ChannelApprovalNativeTarget | null;
|
||||
notifyOriginWhenDmOnly: boolean;
|
||||
};
|
||||
|
||||
function buildTargetKey(target: ChannelApprovalNativeTarget): string {
|
||||
return `${target.to}:${target.threadId ?? ""}`;
|
||||
}
|
||||
|
||||
function dedupeTargets(
|
||||
targets: ChannelApprovalNativePlannedTarget[],
|
||||
): ChannelApprovalNativePlannedTarget[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: ChannelApprovalNativePlannedTarget[] = [];
|
||||
for (const target of targets) {
|
||||
const key = buildTargetKey(target.target);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
deduped.push(target);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export async function resolveChannelNativeApprovalDeliveryPlan(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
approvalKind: ChannelApprovalKind;
|
||||
request: ApprovalRequest;
|
||||
adapter?: ChannelApprovalNativeAdapter | null;
|
||||
}): Promise<ChannelApprovalNativeDeliveryPlan> {
|
||||
const adapter = params.adapter;
|
||||
if (!adapter) {
|
||||
return {
|
||||
targets: [],
|
||||
originTarget: null,
|
||||
notifyOriginWhenDmOnly: false,
|
||||
};
|
||||
}
|
||||
|
||||
const capabilities = adapter.describeDeliveryCapabilities({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
approvalKind: params.approvalKind,
|
||||
request: params.request,
|
||||
});
|
||||
if (!capabilities.enabled) {
|
||||
return {
|
||||
targets: [],
|
||||
originTarget: null,
|
||||
notifyOriginWhenDmOnly: false,
|
||||
};
|
||||
}
|
||||
|
||||
const originTarget =
|
||||
capabilities.supportsOriginSurface && adapter.resolveOriginTarget
|
||||
? ((await adapter.resolveOriginTarget({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
approvalKind: params.approvalKind,
|
||||
request: params.request,
|
||||
})) ?? null)
|
||||
: null;
|
||||
const approverDmTargets =
|
||||
capabilities.supportsApproverDmSurface && adapter.resolveApproverDmTargets
|
||||
? await adapter.resolveApproverDmTargets({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
approvalKind: params.approvalKind,
|
||||
request: params.request,
|
||||
})
|
||||
: [];
|
||||
|
||||
const plannedTargets: ChannelApprovalNativePlannedTarget[] = [];
|
||||
const preferOrigin =
|
||||
capabilities.preferredSurface === "origin" || capabilities.preferredSurface === "both";
|
||||
const preferApproverDm =
|
||||
capabilities.preferredSurface === "approver-dm" || capabilities.preferredSurface === "both";
|
||||
|
||||
if (preferOrigin && originTarget) {
|
||||
plannedTargets.push({
|
||||
surface: "origin",
|
||||
target: originTarget,
|
||||
reason: "preferred",
|
||||
});
|
||||
}
|
||||
|
||||
if (preferApproverDm) {
|
||||
for (const target of approverDmTargets) {
|
||||
plannedTargets.push({
|
||||
surface: "approver-dm",
|
||||
target,
|
||||
reason: "preferred",
|
||||
});
|
||||
}
|
||||
} else if (!originTarget) {
|
||||
for (const target of approverDmTargets) {
|
||||
plannedTargets.push({
|
||||
surface: "approver-dm",
|
||||
target,
|
||||
reason: "fallback",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
targets: dedupeTargets(plannedTargets),
|
||||
originTarget,
|
||||
notifyOriginWhenDmOnly:
|
||||
capabilities.preferredSurface === "approver-dm" &&
|
||||
capabilities.notifyOriginWhenDmOnly === true &&
|
||||
originTarget !== null,
|
||||
};
|
||||
}
|
||||
@@ -21,7 +21,7 @@ describe("resolveApprovalCommandAuthorization", () => {
|
||||
senderId: "U123",
|
||||
kind: "exec",
|
||||
}),
|
||||
).toEqual({ authorized: true });
|
||||
).toEqual({ authorized: true, explicit: false });
|
||||
});
|
||||
|
||||
it("delegates to the channel approval override when present", () => {
|
||||
@@ -47,7 +47,7 @@ describe("resolveApprovalCommandAuthorization", () => {
|
||||
senderId: "123",
|
||||
kind: "exec",
|
||||
}),
|
||||
).toEqual({ authorized: true });
|
||||
).toEqual({ authorized: true, explicit: true });
|
||||
|
||||
expect(
|
||||
resolveApprovalCommandAuthorization({
|
||||
@@ -57,6 +57,25 @@ describe("resolveApprovalCommandAuthorization", () => {
|
||||
senderId: "123",
|
||||
kind: "plugin",
|
||||
}),
|
||||
).toEqual({ authorized: false, reason: "plugin denied" });
|
||||
).toEqual({ authorized: false, reason: "plugin denied", explicit: true });
|
||||
});
|
||||
|
||||
it("keeps disabled approval availability implicit even when same-chat auth returns allow", () => {
|
||||
getChannelPluginMock.mockReturnValue({
|
||||
auth: {
|
||||
authorizeActorAction: () => ({ authorized: true }),
|
||||
getActionAvailabilityState: () => ({ kind: "disabled" }),
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveApprovalCommandAuthorization({
|
||||
cfg: {} as never,
|
||||
channel: "slack",
|
||||
accountId: "work",
|
||||
senderId: "U123",
|
||||
kind: "exec",
|
||||
}),
|
||||
).toEqual({ authorized: true, explicit: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,24 +2,42 @@ import { getChannelPlugin } from "../channels/plugins/index.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { normalizeMessageChannel } from "../utils/message-channel.js";
|
||||
|
||||
export type ApprovalCommandAuthorization = {
|
||||
authorized: boolean;
|
||||
reason?: string;
|
||||
explicit: boolean;
|
||||
};
|
||||
|
||||
export function resolveApprovalCommandAuthorization(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel?: string | null;
|
||||
accountId?: string | null;
|
||||
senderId?: string | null;
|
||||
kind: "exec" | "plugin";
|
||||
}): { authorized: boolean; reason?: string } {
|
||||
}): ApprovalCommandAuthorization {
|
||||
const channel = normalizeMessageChannel(params.channel);
|
||||
if (!channel) {
|
||||
return { authorized: true };
|
||||
return { authorized: true, explicit: false };
|
||||
}
|
||||
return (
|
||||
getChannelPlugin(channel)?.auth?.authorizeActorAction?.({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
senderId: params.senderId,
|
||||
action: "approve",
|
||||
approvalKind: params.kind,
|
||||
}) ?? { authorized: true }
|
||||
);
|
||||
const channelPlugin = getChannelPlugin(channel);
|
||||
const resolved = channelPlugin?.auth?.authorizeActorAction?.({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
senderId: params.senderId,
|
||||
action: "approve",
|
||||
approvalKind: params.kind,
|
||||
});
|
||||
if (!resolved) {
|
||||
return { authorized: true, explicit: false };
|
||||
}
|
||||
const availability = channelPlugin?.auth?.getActionAvailabilityState?.({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
action: "approve",
|
||||
});
|
||||
return {
|
||||
authorized: resolved.authorized,
|
||||
reason: resolved.reason,
|
||||
explicit: resolved.authorized ? availability?.kind !== "disabled" : true,
|
||||
};
|
||||
}
|
||||
|
||||
437
src/infra/exec-approval-channel-runtime.test.ts
Normal file
437
src/infra/exec-approval-channel-runtime.test.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayClient } from "../gateway/client.js";
|
||||
import type { PluginApprovalRequest, PluginApprovalResolved } from "./plugin-approvals.js";
|
||||
|
||||
const mockGatewayClientStarts = vi.hoisted(() => vi.fn());
|
||||
const mockGatewayClientStops = vi.hoisted(() => vi.fn());
|
||||
const mockGatewayClientRequests = vi.hoisted(() => vi.fn(async () => ({ ok: true })));
|
||||
const mockCreateOperatorApprovalsGatewayClient = vi.hoisted(() => vi.fn());
|
||||
const loggerMocks = vi.hoisted(() => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/operator-approvals-client.js", () => ({
|
||||
createOperatorApprovalsGatewayClient: mockCreateOperatorApprovalsGatewayClient,
|
||||
}));
|
||||
|
||||
vi.mock("../logging/subsystem.js", () => ({
|
||||
createSubsystemLogger: () => loggerMocks,
|
||||
}));
|
||||
|
||||
let createExecApprovalChannelRuntime: typeof import("./exec-approval-channel-runtime.js").createExecApprovalChannelRuntime;
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockGatewayClientStarts.mockReset();
|
||||
mockGatewayClientStops.mockReset();
|
||||
mockGatewayClientRequests.mockReset();
|
||||
mockGatewayClientRequests.mockResolvedValue({ ok: true });
|
||||
loggerMocks.debug.mockReset();
|
||||
loggerMocks.error.mockReset();
|
||||
mockCreateOperatorApprovalsGatewayClient.mockReset().mockImplementation(async () => ({
|
||||
start: mockGatewayClientStarts,
|
||||
stop: mockGatewayClientStops,
|
||||
request: mockGatewayClientRequests,
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ createExecApprovalChannelRuntime } = await import("./exec-approval-channel-runtime.js"));
|
||||
});
|
||||
|
||||
describe("createExecApprovalChannelRuntime", () => {
|
||||
it("does not connect when the adapter is not configured", async () => {
|
||||
const runtime = createExecApprovalChannelRuntime({
|
||||
label: "test/exec-approvals",
|
||||
clientDisplayName: "Test Exec Approvals",
|
||||
cfg: {} as never,
|
||||
isConfigured: () => false,
|
||||
shouldHandle: () => true,
|
||||
deliverRequested: async () => [],
|
||||
finalizeResolved: async () => undefined,
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
|
||||
expect(mockCreateOperatorApprovalsGatewayClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tracks pending requests and only expires the matching approval id", async () => {
|
||||
vi.useFakeTimers();
|
||||
const finalizedExpired = vi.fn(async () => undefined);
|
||||
const finalizedResolved = vi.fn(async () => undefined);
|
||||
const runtime = createExecApprovalChannelRuntime({
|
||||
label: "test/exec-approvals",
|
||||
clientDisplayName: "Test Exec Approvals",
|
||||
cfg: {} as never,
|
||||
nowMs: () => 1000,
|
||||
isConfigured: () => true,
|
||||
shouldHandle: () => true,
|
||||
deliverRequested: async (request) => [{ id: request.id }],
|
||||
finalizeResolved: finalizedResolved,
|
||||
finalizeExpired: finalizedExpired,
|
||||
});
|
||||
|
||||
await runtime.handleRequested({
|
||||
id: "abc",
|
||||
request: {
|
||||
command: "echo abc",
|
||||
},
|
||||
createdAtMs: 1000,
|
||||
expiresAtMs: 2000,
|
||||
});
|
||||
await runtime.handleRequested({
|
||||
id: "xyz",
|
||||
request: {
|
||||
command: "echo xyz",
|
||||
},
|
||||
createdAtMs: 1000,
|
||||
expiresAtMs: 2000,
|
||||
});
|
||||
|
||||
await runtime.handleExpired("abc");
|
||||
|
||||
expect(finalizedExpired).toHaveBeenCalledTimes(1);
|
||||
expect(finalizedExpired).toHaveBeenCalledWith({
|
||||
request: expect.objectContaining({ id: "abc" }),
|
||||
entries: [{ id: "abc" }],
|
||||
});
|
||||
expect(finalizedResolved).not.toHaveBeenCalled();
|
||||
|
||||
await runtime.handleResolved({
|
||||
id: "xyz",
|
||||
decision: "allow-once",
|
||||
ts: 1500,
|
||||
});
|
||||
|
||||
expect(finalizedResolved).toHaveBeenCalledTimes(1);
|
||||
expect(finalizedResolved).toHaveBeenCalledWith({
|
||||
request: expect.objectContaining({ id: "xyz" }),
|
||||
resolved: expect.objectContaining({ id: "xyz", decision: "allow-once" }),
|
||||
entries: [{ id: "xyz" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("finalizes approvals that resolve while delivery is still in flight", async () => {
|
||||
const pendingDelivery = createDeferred<Array<{ id: string }>>();
|
||||
const finalizeResolved = vi.fn(async () => undefined);
|
||||
const runtime = createExecApprovalChannelRuntime<
|
||||
{ id: string },
|
||||
PluginApprovalRequest,
|
||||
PluginApprovalResolved
|
||||
>({
|
||||
label: "test/plugin-approvals",
|
||||
clientDisplayName: "Test Plugin Approvals",
|
||||
cfg: {} as never,
|
||||
eventKinds: ["plugin"],
|
||||
isConfigured: () => true,
|
||||
shouldHandle: () => true,
|
||||
deliverRequested: async () => pendingDelivery.promise,
|
||||
finalizeResolved,
|
||||
});
|
||||
|
||||
const requestPromise = runtime.handleRequested({
|
||||
id: "plugin:abc",
|
||||
request: {
|
||||
title: "Plugin approval",
|
||||
description: "Let plugin proceed",
|
||||
},
|
||||
createdAtMs: 1000,
|
||||
expiresAtMs: 2000,
|
||||
});
|
||||
await runtime.handleResolved({
|
||||
id: "plugin:abc",
|
||||
decision: "allow-once",
|
||||
ts: 1500,
|
||||
});
|
||||
|
||||
pendingDelivery.resolve([{ id: "plugin:abc" }]);
|
||||
await requestPromise;
|
||||
|
||||
expect(finalizeResolved).toHaveBeenCalledWith({
|
||||
request: expect.objectContaining({ id: "plugin:abc" }),
|
||||
resolved: expect.objectContaining({ id: "plugin:abc", decision: "allow-once" }),
|
||||
entries: [{ id: "plugin:abc" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("routes gateway requests through the shared client", async () => {
|
||||
const runtime = createExecApprovalChannelRuntime({
|
||||
label: "test/exec-approvals",
|
||||
clientDisplayName: "Test Exec Approvals",
|
||||
cfg: {} as never,
|
||||
isConfigured: () => true,
|
||||
shouldHandle: () => true,
|
||||
deliverRequested: async () => [],
|
||||
finalizeResolved: async () => undefined,
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
await runtime.request("exec.approval.resolve", { id: "abc", decision: "deny" });
|
||||
|
||||
expect(mockGatewayClientStarts).toHaveBeenCalledTimes(1);
|
||||
expect(mockGatewayClientRequests).toHaveBeenCalledWith("exec.approval.resolve", {
|
||||
id: "abc",
|
||||
decision: "deny",
|
||||
});
|
||||
});
|
||||
|
||||
it("can retry start after gateway client creation fails", async () => {
|
||||
const boom = new Error("boom");
|
||||
mockCreateOperatorApprovalsGatewayClient.mockRejectedValueOnce(boom).mockResolvedValueOnce({
|
||||
start: mockGatewayClientStarts,
|
||||
stop: mockGatewayClientStops,
|
||||
request: mockGatewayClientRequests,
|
||||
});
|
||||
const runtime = createExecApprovalChannelRuntime({
|
||||
label: "test/exec-approvals",
|
||||
clientDisplayName: "Test Exec Approvals",
|
||||
cfg: {} as never,
|
||||
isConfigured: () => true,
|
||||
shouldHandle: () => true,
|
||||
deliverRequested: async () => [],
|
||||
finalizeResolved: async () => undefined,
|
||||
});
|
||||
|
||||
await expect(runtime.start()).rejects.toThrow("boom");
|
||||
await runtime.start();
|
||||
|
||||
expect(mockCreateOperatorApprovalsGatewayClient).toHaveBeenCalledTimes(2);
|
||||
expect(mockGatewayClientStarts).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not leave a gateway client running when stop wins the startup race", async () => {
|
||||
const pendingClient = createDeferred<GatewayClient>();
|
||||
mockCreateOperatorApprovalsGatewayClient.mockReturnValueOnce(pendingClient.promise);
|
||||
const runtime = createExecApprovalChannelRuntime({
|
||||
label: "test/exec-approvals",
|
||||
clientDisplayName: "Test Exec Approvals",
|
||||
cfg: {} as never,
|
||||
isConfigured: () => true,
|
||||
shouldHandle: () => true,
|
||||
deliverRequested: async () => [],
|
||||
finalizeResolved: async () => undefined,
|
||||
});
|
||||
|
||||
const startPromise = runtime.start();
|
||||
const stopPromise = runtime.stop();
|
||||
pendingClient.resolve({
|
||||
start: mockGatewayClientStarts,
|
||||
stop: mockGatewayClientStops,
|
||||
request: mockGatewayClientRequests as GatewayClient["request"],
|
||||
} as unknown as GatewayClient);
|
||||
await startPromise;
|
||||
await stopPromise;
|
||||
|
||||
expect(mockGatewayClientStarts).not.toHaveBeenCalled();
|
||||
expect(mockGatewayClientStops).toHaveBeenCalledTimes(1);
|
||||
await expect(runtime.request("exec.approval.resolve", { id: "abc" })).rejects.toThrow(
|
||||
"gateway client not connected",
|
||||
);
|
||||
});
|
||||
|
||||
it("logs async request handling failures from gateway events", async () => {
|
||||
const runtime = createExecApprovalChannelRuntime<
|
||||
{ id: string },
|
||||
PluginApprovalRequest,
|
||||
PluginApprovalResolved
|
||||
>({
|
||||
label: "test/plugin-approvals",
|
||||
clientDisplayName: "Test Plugin Approvals",
|
||||
cfg: {} as never,
|
||||
eventKinds: ["plugin"],
|
||||
isConfigured: () => true,
|
||||
shouldHandle: () => true,
|
||||
deliverRequested: async () => {
|
||||
throw new Error("deliver failed");
|
||||
},
|
||||
finalizeResolved: async () => undefined,
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
const clientParams = mockCreateOperatorApprovalsGatewayClient.mock.calls[0]?.[0] as
|
||||
| { onEvent?: (evt: { event: string; payload: unknown }) => void }
|
||||
| undefined;
|
||||
|
||||
clientParams?.onEvent?.({
|
||||
event: "plugin.approval.requested",
|
||||
payload: {
|
||||
id: "plugin:abc",
|
||||
request: {
|
||||
title: "Plugin approval",
|
||||
description: "Let plugin proceed",
|
||||
},
|
||||
createdAtMs: 1000,
|
||||
expiresAtMs: 2000,
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(loggerMocks.error).toHaveBeenCalledWith(
|
||||
"error handling approval request: deliver failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("logs async expiration handling failures", async () => {
|
||||
vi.useFakeTimers();
|
||||
const runtime = createExecApprovalChannelRuntime<
|
||||
{ id: string },
|
||||
PluginApprovalRequest,
|
||||
PluginApprovalResolved
|
||||
>({
|
||||
label: "test/plugin-approvals",
|
||||
clientDisplayName: "Test Plugin Approvals",
|
||||
cfg: {} as never,
|
||||
nowMs: () => 1000,
|
||||
eventKinds: ["plugin"],
|
||||
isConfigured: () => true,
|
||||
shouldHandle: () => true,
|
||||
deliverRequested: async (request) => [{ id: request.id }],
|
||||
finalizeResolved: async () => undefined,
|
||||
finalizeExpired: async () => {
|
||||
throw new Error("expire failed");
|
||||
},
|
||||
});
|
||||
|
||||
await runtime.handleRequested({
|
||||
id: "plugin:abc",
|
||||
request: {
|
||||
title: "Plugin approval",
|
||||
description: "Let plugin proceed",
|
||||
},
|
||||
createdAtMs: 1000,
|
||||
expiresAtMs: 1001,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(loggerMocks.error).toHaveBeenCalledWith(
|
||||
"error handling approval expiration: expire failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("subscribes to plugin approval events when requested", async () => {
|
||||
const deliverRequested = vi.fn(async (request) => [{ id: request.id }]);
|
||||
const finalizeResolved = vi.fn(async () => undefined);
|
||||
const runtime = createExecApprovalChannelRuntime<
|
||||
{ id: string },
|
||||
PluginApprovalRequest,
|
||||
PluginApprovalResolved
|
||||
>({
|
||||
label: "test/plugin-approvals",
|
||||
clientDisplayName: "Test Plugin Approvals",
|
||||
cfg: {} as never,
|
||||
eventKinds: ["plugin"],
|
||||
isConfigured: () => true,
|
||||
shouldHandle: () => true,
|
||||
deliverRequested,
|
||||
finalizeResolved,
|
||||
});
|
||||
|
||||
await runtime.start();
|
||||
const clientParams = mockCreateOperatorApprovalsGatewayClient.mock.calls[0]?.[0] as
|
||||
| { onEvent?: (evt: { event: string; payload: unknown }) => void }
|
||||
| undefined;
|
||||
expect(clientParams?.onEvent).toBeTypeOf("function");
|
||||
|
||||
clientParams?.onEvent?.({
|
||||
event: "plugin.approval.requested",
|
||||
payload: {
|
||||
id: "plugin:abc",
|
||||
request: {
|
||||
title: "Plugin approval",
|
||||
description: "Let plugin proceed",
|
||||
},
|
||||
createdAtMs: 1000,
|
||||
expiresAtMs: 2000,
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(deliverRequested).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "plugin:abc",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
clientParams?.onEvent?.({
|
||||
event: "plugin.approval.resolved",
|
||||
payload: {
|
||||
id: "plugin:abc",
|
||||
decision: "allow-once",
|
||||
ts: 1500,
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(finalizeResolved).toHaveBeenCalledWith({
|
||||
request: expect.objectContaining({ id: "plugin:abc" }),
|
||||
resolved: expect.objectContaining({ id: "plugin:abc", decision: "allow-once" }),
|
||||
entries: [{ id: "plugin:abc" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("clears pending state when delivery throws", async () => {
|
||||
const deliverRequested = vi
|
||||
.fn<() => Promise<Array<{ id: string }>>>()
|
||||
.mockRejectedValueOnce(new Error("deliver failed"))
|
||||
.mockResolvedValueOnce([{ id: "abc" }]);
|
||||
const finalizeResolved = vi.fn(async () => undefined);
|
||||
const runtime = createExecApprovalChannelRuntime({
|
||||
label: "test/delivery-failure",
|
||||
clientDisplayName: "Test Delivery Failure",
|
||||
cfg: {} as never,
|
||||
isConfigured: () => true,
|
||||
shouldHandle: () => true,
|
||||
deliverRequested,
|
||||
finalizeResolved,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runtime.handleRequested({
|
||||
id: "abc",
|
||||
request: {
|
||||
command: "echo abc",
|
||||
},
|
||||
createdAtMs: 1000,
|
||||
expiresAtMs: 2000,
|
||||
}),
|
||||
).rejects.toThrow("deliver failed");
|
||||
|
||||
await runtime.handleRequested({
|
||||
id: "abc",
|
||||
request: {
|
||||
command: "echo abc",
|
||||
},
|
||||
createdAtMs: 1000,
|
||||
expiresAtMs: 2000,
|
||||
});
|
||||
await runtime.handleResolved({
|
||||
id: "abc",
|
||||
decision: "allow-once",
|
||||
ts: 1500,
|
||||
});
|
||||
|
||||
expect(finalizeResolved).toHaveBeenCalledWith({
|
||||
request: expect.objectContaining({ id: "abc" }),
|
||||
resolved: expect.objectContaining({ id: "abc", decision: "allow-once" }),
|
||||
entries: [{ id: "abc" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
285
src/infra/exec-approval-channel-runtime.ts
Normal file
285
src/infra/exec-approval-channel-runtime.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { GatewayClient } from "../gateway/client.js";
|
||||
import { createOperatorApprovalsGatewayClient } from "../gateway/operator-approvals-client.js";
|
||||
import type { EventFrame } from "../gateway/protocol/index.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import type { ExecApprovalRequest, ExecApprovalResolved } from "./exec-approvals.js";
|
||||
import type { PluginApprovalRequest, PluginApprovalResolved } from "./plugin-approvals.js";
|
||||
|
||||
type ApprovalRequestEvent = ExecApprovalRequest | PluginApprovalRequest;
|
||||
type ApprovalResolvedEvent = ExecApprovalResolved | PluginApprovalResolved;
|
||||
|
||||
export type ExecApprovalChannelRuntimeEventKind = "exec" | "plugin";
|
||||
|
||||
type PendingApprovalEntry<
|
||||
TPending,
|
||||
TRequest extends ApprovalRequestEvent,
|
||||
TResolved extends ApprovalResolvedEvent,
|
||||
> = {
|
||||
request: TRequest;
|
||||
entries: TPending[];
|
||||
timeoutId: NodeJS.Timeout | null;
|
||||
delivering: boolean;
|
||||
pendingResolution: TResolved | null;
|
||||
};
|
||||
|
||||
export type ExecApprovalChannelRuntimeAdapter<
|
||||
TPending,
|
||||
TRequest extends ApprovalRequestEvent = ExecApprovalRequest,
|
||||
TResolved extends ApprovalResolvedEvent = ExecApprovalResolved,
|
||||
> = {
|
||||
label: string;
|
||||
clientDisplayName: string;
|
||||
cfg: OpenClawConfig;
|
||||
gatewayUrl?: string;
|
||||
eventKinds?: readonly ExecApprovalChannelRuntimeEventKind[];
|
||||
isConfigured: () => boolean;
|
||||
shouldHandle: (request: TRequest) => boolean;
|
||||
deliverRequested: (request: TRequest) => Promise<TPending[]>;
|
||||
finalizeResolved: (params: {
|
||||
request: TRequest;
|
||||
resolved: TResolved;
|
||||
entries: TPending[];
|
||||
}) => Promise<void>;
|
||||
finalizeExpired?: (params: {
|
||||
request: TRequest;
|
||||
entries: TPending[];
|
||||
}) => Promise<void>;
|
||||
nowMs?: () => number;
|
||||
};
|
||||
|
||||
export type ExecApprovalChannelRuntime<
|
||||
TRequest extends ApprovalRequestEvent = ExecApprovalRequest,
|
||||
TResolved extends ApprovalResolvedEvent = ExecApprovalResolved,
|
||||
> = {
|
||||
start: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
handleRequested: (request: TRequest) => Promise<void>;
|
||||
handleResolved: (resolved: TResolved) => Promise<void>;
|
||||
handleExpired: (approvalId: string) => Promise<void>;
|
||||
request: <T = unknown>(method: string, params: Record<string, unknown>) => Promise<T>;
|
||||
};
|
||||
|
||||
export function createExecApprovalChannelRuntime<
|
||||
TPending,
|
||||
TRequest extends ApprovalRequestEvent = ExecApprovalRequest,
|
||||
TResolved extends ApprovalResolvedEvent = ExecApprovalResolved,
|
||||
>(
|
||||
adapter: ExecApprovalChannelRuntimeAdapter<TPending, TRequest, TResolved>,
|
||||
): ExecApprovalChannelRuntime<TRequest, TResolved> {
|
||||
const log = createSubsystemLogger(adapter.label);
|
||||
const nowMs = adapter.nowMs ?? Date.now;
|
||||
const eventKinds = new Set<ExecApprovalChannelRuntimeEventKind>(adapter.eventKinds ?? ["exec"]);
|
||||
const pending = new Map<string, PendingApprovalEntry<TPending, TRequest, TResolved>>();
|
||||
let gatewayClient: GatewayClient | null = null;
|
||||
let started = false;
|
||||
let shouldRun = false;
|
||||
let startPromise: Promise<void> | null = null;
|
||||
|
||||
const spawn = (label: string, promise: Promise<void>): void => {
|
||||
void promise.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log.error(`${label}: ${message}`);
|
||||
});
|
||||
};
|
||||
|
||||
const clearPendingEntry = (
|
||||
approvalId: string,
|
||||
): PendingApprovalEntry<TPending, TRequest, TResolved> | null => {
|
||||
const entry = pending.get(approvalId);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
pending.delete(approvalId);
|
||||
if (entry.timeoutId) {
|
||||
clearTimeout(entry.timeoutId);
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
|
||||
const handleExpired = async (approvalId: string): Promise<void> => {
|
||||
const entry = clearPendingEntry(approvalId);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
log.debug(`expired ${approvalId}`);
|
||||
await adapter.finalizeExpired?.({
|
||||
request: entry.request,
|
||||
entries: entry.entries,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRequested = async (request: TRequest): Promise<void> => {
|
||||
if (!adapter.shouldHandle(request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug(`received request ${request.id}`);
|
||||
const existing = pending.get(request.id);
|
||||
if (existing?.timeoutId) {
|
||||
clearTimeout(existing.timeoutId);
|
||||
}
|
||||
const entry: PendingApprovalEntry<TPending, TRequest, TResolved> = {
|
||||
request,
|
||||
entries: [],
|
||||
timeoutId: null,
|
||||
delivering: true,
|
||||
pendingResolution: null,
|
||||
};
|
||||
pending.set(request.id, entry);
|
||||
let entries: TPending[];
|
||||
try {
|
||||
entries = await adapter.deliverRequested(request);
|
||||
} catch (err) {
|
||||
if (pending.get(request.id) === entry) {
|
||||
clearPendingEntry(request.id);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const current = pending.get(request.id);
|
||||
if (current !== entry) {
|
||||
return;
|
||||
}
|
||||
if (!entries.length) {
|
||||
pending.delete(request.id);
|
||||
return;
|
||||
}
|
||||
entry.entries = entries;
|
||||
entry.delivering = false;
|
||||
if (entry.pendingResolution) {
|
||||
pending.delete(request.id);
|
||||
log.debug(`resolved ${entry.pendingResolution.id} with ${entry.pendingResolution.decision}`);
|
||||
await adapter.finalizeResolved({
|
||||
request: entry.request,
|
||||
resolved: entry.pendingResolution,
|
||||
entries: entry.entries,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutMs = Math.max(0, request.expiresAtMs - nowMs());
|
||||
const timeoutId = setTimeout(() => {
|
||||
spawn("error handling approval expiration", handleExpired(request.id));
|
||||
}, timeoutMs);
|
||||
timeoutId.unref?.();
|
||||
entry.timeoutId = timeoutId;
|
||||
};
|
||||
|
||||
const handleResolved = async (resolved: TResolved): Promise<void> => {
|
||||
const entry = pending.get(resolved.id);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
if (entry.delivering) {
|
||||
entry.pendingResolution = resolved;
|
||||
return;
|
||||
}
|
||||
const finalizedEntry = clearPendingEntry(resolved.id);
|
||||
if (!finalizedEntry) {
|
||||
return;
|
||||
}
|
||||
log.debug(`resolved ${resolved.id} with ${resolved.decision}`);
|
||||
await adapter.finalizeResolved({
|
||||
request: finalizedEntry.request,
|
||||
resolved,
|
||||
entries: finalizedEntry.entries,
|
||||
});
|
||||
};
|
||||
|
||||
const handleGatewayEvent = (evt: EventFrame): void => {
|
||||
if (evt.event === "exec.approval.requested" && eventKinds.has("exec")) {
|
||||
spawn("error handling approval request", handleRequested(evt.payload as TRequest));
|
||||
return;
|
||||
}
|
||||
if (evt.event === "plugin.approval.requested" && eventKinds.has("plugin")) {
|
||||
spawn("error handling approval request", handleRequested(evt.payload as TRequest));
|
||||
return;
|
||||
}
|
||||
if (evt.event === "exec.approval.resolved" && eventKinds.has("exec")) {
|
||||
spawn("error handling approval resolved", handleResolved(evt.payload as TResolved));
|
||||
return;
|
||||
}
|
||||
if (evt.event === "plugin.approval.resolved" && eventKinds.has("plugin")) {
|
||||
spawn("error handling approval resolved", handleResolved(evt.payload as TResolved));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
async start(): Promise<void> {
|
||||
if (started) {
|
||||
return;
|
||||
}
|
||||
if (startPromise) {
|
||||
await startPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
shouldRun = true;
|
||||
startPromise = (async () => {
|
||||
if (!adapter.isConfigured()) {
|
||||
log.debug("disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await createOperatorApprovalsGatewayClient({
|
||||
config: adapter.cfg,
|
||||
gatewayUrl: adapter.gatewayUrl,
|
||||
clientDisplayName: adapter.clientDisplayName,
|
||||
onEvent: handleGatewayEvent,
|
||||
onHelloOk: () => {
|
||||
log.debug("connected to gateway");
|
||||
},
|
||||
onConnectError: (err) => {
|
||||
log.error(`connect error: ${err.message}`);
|
||||
},
|
||||
onClose: (code, reason) => {
|
||||
log.debug(`gateway closed: ${code} ${reason}`);
|
||||
},
|
||||
});
|
||||
|
||||
if (!shouldRun) {
|
||||
client.stop();
|
||||
return;
|
||||
}
|
||||
client.start();
|
||||
gatewayClient = client;
|
||||
started = true;
|
||||
})().finally(() => {
|
||||
startPromise = null;
|
||||
});
|
||||
|
||||
await startPromise;
|
||||
},
|
||||
|
||||
async stop(): Promise<void> {
|
||||
shouldRun = false;
|
||||
if (startPromise) {
|
||||
await startPromise.catch(() => {});
|
||||
}
|
||||
if (!started && !gatewayClient) {
|
||||
return;
|
||||
}
|
||||
started = false;
|
||||
for (const entry of pending.values()) {
|
||||
if (entry.timeoutId) {
|
||||
clearTimeout(entry.timeoutId);
|
||||
}
|
||||
}
|
||||
pending.clear();
|
||||
gatewayClient?.stop();
|
||||
gatewayClient = null;
|
||||
log.debug("stopped");
|
||||
},
|
||||
|
||||
handleRequested,
|
||||
handleResolved,
|
||||
handleExpired,
|
||||
|
||||
async request<T = unknown>(method: string, params: Record<string, unknown>): Promise<T> {
|
||||
if (!gatewayClient) {
|
||||
throw new Error(`${adapter.label}: gateway client not connected`);
|
||||
}
|
||||
return (await gatewayClient.request(method, params)) as T;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import {
|
||||
buildExecApprovalActionDescriptors,
|
||||
buildExecApprovalCommandText,
|
||||
buildExecApprovalInteractiveReply,
|
||||
buildExecApprovalPendingReplyPayload,
|
||||
buildExecApprovalUnavailableReplyPayload,
|
||||
getExecApprovalApproverDmNoticeText,
|
||||
getExecApprovalReplyMetadata,
|
||||
parseExecApprovalCommandText,
|
||||
} from "./exec-approval-reply.js";
|
||||
|
||||
describe("exec approval reply helpers", () => {
|
||||
@@ -166,6 +170,77 @@ describe("exec approval reply helpers", () => {
|
||||
expect(payload.text).toContain("Expires in: 30m");
|
||||
});
|
||||
|
||||
it("builds shared exec approval action descriptors and interactive replies", () => {
|
||||
expect(
|
||||
buildExecApprovalActionDescriptors({
|
||||
approvalCommandId: "req-1",
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
decision: "allow-once",
|
||||
label: "Allow Once",
|
||||
style: "success",
|
||||
command: "/approve req-1 allow-once",
|
||||
},
|
||||
{
|
||||
decision: "allow-always",
|
||||
label: "Allow Always",
|
||||
style: "primary",
|
||||
command: "/approve req-1 always",
|
||||
},
|
||||
{
|
||||
decision: "deny",
|
||||
label: "Deny",
|
||||
style: "danger",
|
||||
command: "/approve req-1 deny",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
buildExecApprovalInteractiveReply({
|
||||
approvalCommandId: "req-1",
|
||||
}),
|
||||
).toEqual({
|
||||
blocks: [
|
||||
{
|
||||
type: "buttons",
|
||||
buttons: [
|
||||
{ label: "Allow Once", value: "/approve req-1 allow-once", style: "success" },
|
||||
{ label: "Allow Always", value: "/approve req-1 always", style: "primary" },
|
||||
{ label: "Deny", value: "/approve req-1 deny", style: "danger" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("builds and parses shared exec approval command text", () => {
|
||||
expect(
|
||||
buildExecApprovalCommandText({
|
||||
approvalCommandId: "req-1",
|
||||
decision: "allow-always",
|
||||
}),
|
||||
).toBe("/approve req-1 always");
|
||||
|
||||
expect(parseExecApprovalCommandText("/approve req-1 deny")).toEqual({
|
||||
approvalId: "req-1",
|
||||
decision: "deny",
|
||||
});
|
||||
expect(parseExecApprovalCommandText("/approve@clover req-1 allow-once")).toEqual({
|
||||
approvalId: "req-1",
|
||||
decision: "allow-once",
|
||||
});
|
||||
expect(parseExecApprovalCommandText(" /approve req-1 always")).toEqual({
|
||||
approvalId: "req-1",
|
||||
decision: "allow-always",
|
||||
});
|
||||
expect(parseExecApprovalCommandText("/approve req-1 allow-always")).toEqual({
|
||||
approvalId: "req-1",
|
||||
decision: "allow-always",
|
||||
});
|
||||
expect(parseExecApprovalCommandText("/approve req-1 maybe")).toBeNull();
|
||||
});
|
||||
|
||||
it("builds unavailable payloads for approver DMs", () => {
|
||||
expect(
|
||||
buildExecApprovalUnavailableReplyPayload({
|
||||
|
||||
@@ -14,6 +14,13 @@ export type ExecApprovalReplyMetadata = {
|
||||
allowedDecisions?: readonly ExecApprovalReplyDecision[];
|
||||
};
|
||||
|
||||
export type ExecApprovalActionDescriptor = {
|
||||
decision: ExecApprovalReplyDecision;
|
||||
label: string;
|
||||
style: NonNullable<InteractiveReplyButton["style"]>;
|
||||
command: string;
|
||||
};
|
||||
|
||||
export type ExecApprovalPendingReplyParams = {
|
||||
warningText?: string;
|
||||
approvalId: string;
|
||||
@@ -36,40 +43,71 @@ export type ExecApprovalUnavailableReplyParams = {
|
||||
|
||||
const DEFAULT_ALLOWED_DECISIONS = ["allow-once", "allow-always", "deny"] as const;
|
||||
|
||||
function buildApprovalDecisionCommandValue(params: {
|
||||
approvalId: string;
|
||||
export function buildExecApprovalCommandText(params: {
|
||||
approvalCommandId: string;
|
||||
decision: ExecApprovalReplyDecision;
|
||||
}): string {
|
||||
return `/approve ${params.approvalId} ${params.decision === "allow-always" ? "always" : params.decision}`;
|
||||
return `/approve ${params.approvalCommandId} ${params.decision === "allow-always" ? "always" : params.decision}`;
|
||||
}
|
||||
|
||||
export function buildExecApprovalActionDescriptors(params: {
|
||||
approvalCommandId: string;
|
||||
allowedDecisions?: readonly ExecApprovalReplyDecision[];
|
||||
}): ExecApprovalActionDescriptor[] {
|
||||
const approvalCommandId = params.approvalCommandId.trim();
|
||||
if (!approvalCommandId) {
|
||||
return [];
|
||||
}
|
||||
const allowedDecisions = params.allowedDecisions ?? DEFAULT_ALLOWED_DECISIONS;
|
||||
const descriptors: ExecApprovalActionDescriptor[] = [];
|
||||
if (allowedDecisions.includes("allow-once")) {
|
||||
descriptors.push({
|
||||
decision: "allow-once",
|
||||
label: "Allow Once",
|
||||
style: "success",
|
||||
command: buildExecApprovalCommandText({
|
||||
approvalCommandId,
|
||||
decision: "allow-once",
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (allowedDecisions.includes("allow-always")) {
|
||||
descriptors.push({
|
||||
decision: "allow-always",
|
||||
label: "Allow Always",
|
||||
style: "primary",
|
||||
command: buildExecApprovalCommandText({
|
||||
approvalCommandId,
|
||||
decision: "allow-always",
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (allowedDecisions.includes("deny")) {
|
||||
descriptors.push({
|
||||
decision: "deny",
|
||||
label: "Deny",
|
||||
style: "danger",
|
||||
command: buildExecApprovalCommandText({
|
||||
approvalCommandId,
|
||||
decision: "deny",
|
||||
}),
|
||||
});
|
||||
}
|
||||
return descriptors;
|
||||
}
|
||||
|
||||
function buildApprovalInteractiveButtons(
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[],
|
||||
approvalId: string,
|
||||
): InteractiveReplyButton[] {
|
||||
const buttons: InteractiveReplyButton[] = [];
|
||||
if (allowedDecisions.includes("allow-once")) {
|
||||
buttons.push({
|
||||
label: "Allow Once",
|
||||
value: buildApprovalDecisionCommandValue({ approvalId, decision: "allow-once" }),
|
||||
style: "success",
|
||||
});
|
||||
}
|
||||
if (allowedDecisions.includes("allow-always")) {
|
||||
buttons.push({
|
||||
label: "Allow Always",
|
||||
value: buildApprovalDecisionCommandValue({ approvalId, decision: "allow-always" }),
|
||||
style: "primary",
|
||||
});
|
||||
}
|
||||
if (allowedDecisions.includes("deny")) {
|
||||
buttons.push({
|
||||
label: "Deny",
|
||||
value: buildApprovalDecisionCommandValue({ approvalId, decision: "deny" }),
|
||||
style: "danger",
|
||||
});
|
||||
}
|
||||
return buttons;
|
||||
return buildExecApprovalActionDescriptors({
|
||||
approvalCommandId: approvalId,
|
||||
allowedDecisions,
|
||||
}).map((descriptor) => ({
|
||||
label: descriptor.label,
|
||||
value: descriptor.command,
|
||||
style: descriptor.style,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildApprovalInteractiveReply(params: {
|
||||
@@ -83,10 +121,37 @@ export function buildApprovalInteractiveReply(params: {
|
||||
return buttons.length > 0 ? { blocks: [{ type: "buttons", buttons }] } : undefined;
|
||||
}
|
||||
|
||||
export function buildExecApprovalInteractiveReply(params: {
|
||||
approvalCommandId: string;
|
||||
allowedDecisions?: readonly ExecApprovalReplyDecision[];
|
||||
}): InteractiveReply | undefined {
|
||||
return buildApprovalInteractiveReply({
|
||||
approvalId: params.approvalCommandId,
|
||||
allowedDecisions: params.allowedDecisions,
|
||||
});
|
||||
}
|
||||
|
||||
export function getExecApprovalApproverDmNoticeText(): string {
|
||||
return "Approval required. I sent approval DMs to the approvers for this account.";
|
||||
}
|
||||
|
||||
export function parseExecApprovalCommandText(
|
||||
raw: string,
|
||||
): { approvalId: string; decision: ExecApprovalReplyDecision } | null {
|
||||
const trimmed = raw.trim();
|
||||
const match = trimmed.match(
|
||||
/^\/approve(?:@[^\s]+)?\s+([A-Za-z0-9][A-Za-z0-9._:-]*)\s+(allow-once|allow-always|always|deny)\b/i,
|
||||
);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const rawDecision = match[2].toLowerCase();
|
||||
return {
|
||||
approvalId: match[1],
|
||||
decision: rawDecision === "always" ? "allow-always" : (rawDecision as ExecApprovalReplyDecision),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatExecApprovalExpiresIn(expiresAtMs: number, nowMs: number): string {
|
||||
const totalSeconds = Math.max(0, Math.round((expiresAtMs - nowMs) / 1000));
|
||||
if (totalSeconds < 60) {
|
||||
|
||||
@@ -59,9 +59,22 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => {
|
||||
isNativeDeliveryEnabled: ({ accountId }) => accountId !== "disabled",
|
||||
resolveNativeDeliveryMode: ({ accountId }) =>
|
||||
accountId === "channel-only" ? "channel" : "dm",
|
||||
resolveOriginTarget: () => ({ to: "origin-chat" }),
|
||||
resolveApproverDmTargets: () => [{ to: "approver-1" }],
|
||||
});
|
||||
const getActionAvailabilityState = adapter.auth.getActionAvailabilityState;
|
||||
const hasConfiguredDmRoute = adapter.delivery.hasConfiguredDmRoute;
|
||||
const nativeCapabilities = adapter.native?.describeDeliveryCapabilities({
|
||||
cfg: {} as never,
|
||||
accountId: "channel-only",
|
||||
approvalKind: "exec",
|
||||
request: {
|
||||
id: "approval-1",
|
||||
request: { command: "pwd" },
|
||||
createdAtMs: 0,
|
||||
expiresAtMs: 10_000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
getActionAvailabilityState({
|
||||
@@ -78,6 +91,13 @@ describe("createApproverRestrictedNativeApprovalAdapter", () => {
|
||||
}),
|
||||
).toEqual({ kind: "disabled" });
|
||||
expect(hasConfiguredDmRoute({ cfg: {} as never })).toBe(true);
|
||||
expect(nativeCapabilities).toEqual({
|
||||
enabled: true,
|
||||
preferredSurface: "origin",
|
||||
supportsOriginSurface: true,
|
||||
supportsApproverDmSurface: true,
|
||||
notifyOriginWhenDmOnly: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses forwarding fallback only for matching native-delivery surfaces", () => {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { ExecApprovalRequest } from "../infra/exec-approvals.js";
|
||||
import type { PluginApprovalRequest } from "../infra/plugin-approvals.js";
|
||||
import type { OpenClawConfig } from "./config-runtime.js";
|
||||
import { normalizeMessageChannel } from "./routing.js";
|
||||
|
||||
type ApprovalKind = "exec" | "plugin";
|
||||
type NativeApprovalDeliveryMode = "dm" | "channel" | "both";
|
||||
type NativeApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
|
||||
type NativeApprovalTarget = { to: string; threadId?: string | number | null };
|
||||
type NativeApprovalSurface = "origin" | "approver-dm";
|
||||
|
||||
type ApprovalAdapterParams = {
|
||||
cfg: OpenClawConfig;
|
||||
@@ -30,8 +35,25 @@ export function createApproverRestrictedNativeApprovalAdapter(params: {
|
||||
}) => NativeApprovalDeliveryMode;
|
||||
requireMatchingTurnSourceChannel?: boolean;
|
||||
resolveSuppressionAccountId?: (params: DeliverySuppressionParams) => string | undefined;
|
||||
resolveOriginTarget?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
approvalKind: ApprovalKind;
|
||||
request: NativeApprovalRequest;
|
||||
}) => NativeApprovalTarget | null | Promise<NativeApprovalTarget | null>;
|
||||
resolveApproverDmTargets?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
approvalKind: ApprovalKind;
|
||||
request: NativeApprovalRequest;
|
||||
}) => NativeApprovalTarget[] | Promise<NativeApprovalTarget[]>;
|
||||
notifyOriginWhenDmOnly?: boolean;
|
||||
}) {
|
||||
const pluginSenderAuth = params.isPluginAuthorizedSender ?? params.isExecAuthorizedSender;
|
||||
const normalizePreferredSurface = (
|
||||
mode: NativeApprovalDeliveryMode,
|
||||
): NativeApprovalSurface | "both" =>
|
||||
mode === "channel" ? "origin" : mode === "dm" ? "approver-dm" : "both";
|
||||
|
||||
return {
|
||||
auth: {
|
||||
@@ -103,5 +125,31 @@ export function createApproverRestrictedNativeApprovalAdapter(params: {
|
||||
return params.isNativeDeliveryEnabled({ cfg: input.cfg, accountId });
|
||||
},
|
||||
},
|
||||
native:
|
||||
params.resolveOriginTarget || params.resolveApproverDmTargets
|
||||
? {
|
||||
describeDeliveryCapabilities: ({
|
||||
cfg,
|
||||
accountId,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
approvalKind: ApprovalKind;
|
||||
request: NativeApprovalRequest;
|
||||
}) => ({
|
||||
enabled:
|
||||
params.hasApprovers({ cfg, accountId }) &&
|
||||
params.isNativeDeliveryEnabled({ cfg, accountId }),
|
||||
preferredSurface: normalizePreferredSurface(
|
||||
params.resolveNativeDeliveryMode({ cfg, accountId }),
|
||||
),
|
||||
supportsOriginSurface: Boolean(params.resolveOriginTarget),
|
||||
supportsApproverDmSurface: Boolean(params.resolveApproverDmTargets),
|
||||
notifyOriginWhenDmOnly: params.notifyOriginWhenDmOnly ?? false,
|
||||
}),
|
||||
resolveOriginTarget: params.resolveOriginTarget,
|
||||
resolveApproverDmTargets: params.resolveApproverDmTargets,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ export * from "../infra/diagnostic-flags.js";
|
||||
export * from "../infra/env.js";
|
||||
export * from "../infra/errors.js";
|
||||
export * from "../infra/exec-approval-command-display.ts";
|
||||
export * from "../infra/exec-approval-channel-runtime.ts";
|
||||
export * from "../infra/exec-approval-reply.ts";
|
||||
export * from "../infra/exec-approval-session-target.ts";
|
||||
export * from "../infra/exec-approvals.ts";
|
||||
export * from "../infra/approval-native-delivery.ts";
|
||||
export * from "../infra/plugin-approvals.ts";
|
||||
export * from "../infra/fetch.js";
|
||||
export * from "../infra/file-lock.js";
|
||||
|
||||
Reference in New Issue
Block a user