Compare commits

...

5 Commits

Author SHA1 Message Date
scoootscooob
e7e27b4994 fix(exec): clean up failed approval deliveries 2026-03-30 08:26:08 -07:00
scoootscooob
454943a1a5 fix(exec): handle approval runtime races 2026-03-30 04:07:11 -07:00
scoootscooob
9d0d99c713 fix(exec): guard approval expiration callbacks 2026-03-30 03:55:39 -07:00
scoootscooob
b0c746d726 fix(exec): harden shared approval runtime 2026-03-30 03:48:09 -07:00
scoootscooob
614c42ac8e fix(exec): add shared approval runtime 2026-03-30 03:39:50 -07:00
8 changed files with 1653 additions and 307 deletions

View File

@@ -303,6 +303,7 @@ Docs: https://docs.openclaw.ai
- Security/path resolution: prefer non-user-writable absolute helper binaries for OpenClaw CLI, ffmpeg, and OpenSSL resolution so PATH hijacks cannot replace trusted helpers with attacker-controlled executables.
- Security/gateway command scopes: require `operator.admin` before Telegram target writeback and Talk Voice `/voice set` config writes persist through gateway message flows.
- Security/OpenShell mirror: exclude workspace `hooks/` from mirror sync so untrusted sandbox files cannot become trusted host hooks on gateway startup.
- Exec approvals/channels: unify Discord and Telegram exec approval runtime handling, move approval buttons onto the shared interactive reply model, and fix Telegram approval buttons and typed `/approve` commands so configured approvers can resolve requests reliably again. (#57516) Thanks @scoootscooob.
## 2026.3.24-beta.2

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,439 @@
import type { GatewayClient } from "../gateway/client.js";
import type { PluginApprovalRequest, PluginApprovalResolved } from "./plugin-approvals.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
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,
});
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

@@ -8,6 +8,7 @@ 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";