fix: make same-chat approvals work across channels

This commit is contained in:
Peter Steinberger
2026-03-30 06:35:04 +09:00
parent 1ca01b738b
commit 574d3c5213
11 changed files with 169 additions and 15 deletions

View File

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

View File

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

View File

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

View File

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