mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-01 04:11:03 +00:00
fix: make same-chat approvals work across channels
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { hasApprovalTurnSourceRoute } from "../../infra/approval-turn-source.js";
|
||||
import { sanitizeExecApprovalDisplayText } from "../../infra/exec-approval-command-display.js";
|
||||
import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js";
|
||||
import {
|
||||
@@ -188,6 +189,9 @@ export function createExecApprovalHandlers(
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
const hasExecApprovalClients = context.hasExecApprovalClients?.(client?.connId) ?? false;
|
||||
const hasTurnSourceRoute = hasApprovalTurnSourceRoute({
|
||||
turnSourceChannel: record.request.turnSourceChannel,
|
||||
});
|
||||
let forwarded = false;
|
||||
if (opts?.forwarder) {
|
||||
try {
|
||||
@@ -202,7 +206,7 @@ export function createExecApprovalHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasExecApprovalClients && !forwarded) {
|
||||
if (!hasExecApprovalClients && !forwarded && !hasTurnSourceRoute) {
|
||||
manager.expire(record.id, "no-approval-route");
|
||||
respond(
|
||||
true,
|
||||
|
||||
@@ -163,6 +163,52 @@ describe("createPluginApprovalHandlers", () => {
|
||||
expect(hasExecApprovalClients).toHaveBeenCalledWith("backend-conn-42");
|
||||
});
|
||||
|
||||
it("keeps plugin approvals pending when the originating chat can handle /approve directly", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const respond = vi.fn();
|
||||
const opts = createMockOptions(
|
||||
"plugin.approval.request",
|
||||
{
|
||||
title: "Sensitive action",
|
||||
description: "Desc",
|
||||
twoPhase: true,
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceTo: "C123",
|
||||
},
|
||||
{
|
||||
respond,
|
||||
context: {
|
||||
broadcast: vi.fn(),
|
||||
logGateway: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
|
||||
hasExecApprovalClients: () => false,
|
||||
} as unknown as GatewayRequestHandlerOptions["context"],
|
||||
},
|
||||
);
|
||||
|
||||
const requestPromise = handlers["plugin.approval.request"](opts);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ status: "accepted", id: expect.any(String) }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
const acceptedCall = respond.mock.calls.find(
|
||||
(call) => (call[1] as Record<string, unknown>)?.status === "accepted",
|
||||
);
|
||||
const approvalId = (acceptedCall?.[1] as Record<string, unknown>)?.id as string;
|
||||
manager.resolve(approvalId, "allow-once");
|
||||
|
||||
await requestPromise;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid severity value", async () => {
|
||||
const handlers = createPluginApprovalHandlers(manager);
|
||||
const opts = createMockOptions("plugin.approval.request", {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { hasApprovalTurnSourceRoute } from "../../infra/approval-turn-source.js";
|
||||
import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js";
|
||||
import type { ExecApprovalDecision } from "../../infra/exec-approvals.js";
|
||||
import type { PluginApprovalRequestPayload } from "../../infra/plugin-approvals.js";
|
||||
@@ -121,7 +122,10 @@ export function createPluginApprovalHandlers(
|
||||
}
|
||||
|
||||
const hasApprovalClients = context.hasExecApprovalClients?.(client?.connId) ?? false;
|
||||
if (!hasApprovalClients && !forwarded) {
|
||||
const hasTurnSourceRoute = hasApprovalTurnSourceRoute({
|
||||
turnSourceChannel: record.request.turnSourceChannel,
|
||||
});
|
||||
if (!hasApprovalClients && !forwarded && !hasTurnSourceRoute) {
|
||||
manager.expire(record.id, "no-approval-route");
|
||||
respond(
|
||||
true,
|
||||
|
||||
@@ -987,6 +987,45 @@ describe("exec approval handlers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps approvals pending when the originating chat can handle /approve directly", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { manager, handlers, forwarder, respond, context } =
|
||||
createForwardingExecApprovalFixture();
|
||||
const expireSpy = vi.spyOn(manager, "expire");
|
||||
|
||||
const requestPromise = requestExecApproval({
|
||||
handlers,
|
||||
respond,
|
||||
context,
|
||||
params: {
|
||||
twoPhase: true,
|
||||
timeoutMs: 60_000,
|
||||
id: "approval-chat-route",
|
||||
host: "gateway",
|
||||
turnSourceChannel: "slack",
|
||||
turnSourceTo: "D123",
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ status: "accepted", id: "approval-chat-route" }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
expect(forwarder.handleRequested).toHaveBeenCalledTimes(1);
|
||||
expect(expireSpy).not.toHaveBeenCalled();
|
||||
|
||||
manager.resolve("approval-chat-route", "allow-once");
|
||||
await requestPromise;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps approvals pending when no approver clients but forwarding accepted the request", async () => {
|
||||
const { manager, handlers, forwarder, respond, context } =
|
||||
createForwardingExecApprovalFixture();
|
||||
|
||||
19
src/infra/approval-turn-source.test.ts
Normal file
19
src/infra/approval-turn-source.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasApprovalTurnSourceRoute } from "./approval-turn-source.js";
|
||||
|
||||
describe("hasApprovalTurnSourceRoute", () => {
|
||||
it("accepts operator UI turn sources", () => {
|
||||
expect(hasApprovalTurnSourceRoute({ turnSourceChannel: "webchat" })).toBe(true);
|
||||
expect(hasApprovalTurnSourceRoute({ turnSourceChannel: "tui" })).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts deliverable chat channels", () => {
|
||||
expect(hasApprovalTurnSourceRoute({ turnSourceChannel: "slack" })).toBe(true);
|
||||
expect(hasApprovalTurnSourceRoute({ turnSourceChannel: "discord" })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects missing or unknown turn sources", () => {
|
||||
expect(hasApprovalTurnSourceRoute({ turnSourceChannel: undefined })).toBe(false);
|
||||
expect(hasApprovalTurnSourceRoute({ turnSourceChannel: "unknown-channel" })).toBe(false);
|
||||
});
|
||||
});
|
||||
16
src/infra/approval-turn-source.ts
Normal file
16
src/infra/approval-turn-source.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
INTERNAL_MESSAGE_CHANNEL,
|
||||
isDeliverableMessageChannel,
|
||||
normalizeMessageChannel,
|
||||
} from "../utils/message-channel.js";
|
||||
|
||||
export function hasApprovalTurnSourceRoute(params: { turnSourceChannel?: string | null }): boolean {
|
||||
const channel = normalizeMessageChannel(params.turnSourceChannel);
|
||||
if (!channel) {
|
||||
return false;
|
||||
}
|
||||
if (channel === INTERNAL_MESSAGE_CHANNEL || channel === "tui") {
|
||||
return true;
|
||||
}
|
||||
return isDeliverableMessageChannel(channel);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const loadConfigMock = vi.hoisted(() => vi.fn());
|
||||
const getChannelPluginMock = vi.hoisted(() => vi.fn());
|
||||
const listChannelPluginsMock = vi.hoisted(() => vi.fn());
|
||||
const isDeliverableMessageChannelMock = vi.hoisted(() => vi.fn());
|
||||
const normalizeMessageChannelMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
type ExecApprovalSurfaceModule = typeof import("./exec-approval-surface.js");
|
||||
@@ -15,10 +16,14 @@ async function loadExecApprovalSurfaceModule() {
|
||||
loadConfigMock.mockReset();
|
||||
getChannelPluginMock.mockReset();
|
||||
listChannelPluginsMock.mockReset();
|
||||
isDeliverableMessageChannelMock.mockReset();
|
||||
normalizeMessageChannelMock.mockReset();
|
||||
normalizeMessageChannelMock.mockImplementation((value?: string | null) =>
|
||||
typeof value === "string" ? value.trim().toLowerCase() : undefined,
|
||||
);
|
||||
isDeliverableMessageChannelMock.mockImplementation(
|
||||
(value?: string) => value === "slack" || value === "discord" || value === "telegram",
|
||||
);
|
||||
vi.doMock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
return {
|
||||
@@ -32,6 +37,7 @@ async function loadExecApprovalSurfaceModule() {
|
||||
}));
|
||||
vi.doMock("../utils/message-channel.js", () => ({
|
||||
INTERNAL_MESSAGE_CHANNEL: "web",
|
||||
isDeliverableMessageChannel: (...args: unknown[]) => isDeliverableMessageChannelMock(...args),
|
||||
normalizeMessageChannel: (...args: unknown[]) => normalizeMessageChannelMock(...args),
|
||||
}));
|
||||
({ hasConfiguredExecApprovalDmRoute, resolveExecApprovalInitiatingSurfaceState } =
|
||||
@@ -146,6 +152,14 @@ describe("resolveExecApprovalInitiatingSurfaceState", () => {
|
||||
channelLabel: "Signal",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats deliverable chat channels without a custom adapter as enabled", () => {
|
||||
expect(resolveExecApprovalInitiatingSurfaceState({ channel: "slack" })).toEqual({
|
||||
kind: "enabled",
|
||||
channel: "slack",
|
||||
channelLabel: "Slack",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasConfiguredExecApprovalDmRoute", () => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import { loadConfig, type OpenClawConfig } from "../config/config.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../utils/message-channel.js";
|
||||
import {
|
||||
INTERNAL_MESSAGE_CHANNEL,
|
||||
isDeliverableMessageChannel,
|
||||
normalizeMessageChannel,
|
||||
} from "../utils/message-channel.js";
|
||||
|
||||
export type ExecApprovalInitiatingSurfaceState =
|
||||
| { kind: "enabled"; channel: string | undefined; channelLabel: string }
|
||||
@@ -41,6 +45,9 @@ export function resolveExecApprovalInitiatingSurfaceState(params: {
|
||||
if (state) {
|
||||
return { ...state, channel, channelLabel };
|
||||
}
|
||||
if (isDeliverableMessageChannel(channel)) {
|
||||
return { kind: "enabled", channel, channelLabel };
|
||||
}
|
||||
return { kind: "unsupported", channel, channelLabel };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user