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:
scoootscooob
2026-03-30 15:49:02 -07:00
committed by GitHub
parent e7e15b92bd
commit 9ff57ac479
35 changed files with 3606 additions and 2136 deletions

View File

@@ -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

View File

@@ -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 = {

View 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",
},
]);
});
});

View 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,
};
}

View File

@@ -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 });
});
});

View File

@@ -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,
};
}

View 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" }],
});
});
});

View 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;
},
};
}

View File

@@ -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({

View File

@@ -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) {

View File

@@ -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", () => {

View File

@@ -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,
};
}

View File

@@ -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";