mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
5 Commits
v2026.5.5-
...
codex/exec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7e27b4994 | ||
|
|
454943a1a5 | ||
|
|
9d0d99c713 | ||
|
|
b0c746d726 | ||
|
|
614c42ac8e |
@@ -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
439
src/infra/exec-approval-channel-runtime.test.ts
Normal file
439
src/infra/exec-approval-channel-runtime.test.ts
Normal 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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user