Files
moltbot/src/gateway/server-methods/plugin-approval.ts
2026-05-06 12:19:07 -07:00

211 lines
7.1 KiB
TypeScript

import { randomUUID } from "node:crypto";
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";
import {
DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS,
MAX_PLUGIN_APPROVAL_TIMEOUT_MS,
resolvePluginApprovalRequestAllowedDecisions,
} from "../../infra/plugin-approvals.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import type { ExecApprovalManager } from "../exec-approval-manager.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validatePluginApprovalRequestParams,
validatePluginApprovalResolveParams,
} from "../protocol/index.js";
import {
handleApprovalResolve,
handleApprovalWaitDecision,
handlePendingApprovalRequest,
isApprovalDecision,
} from "./approval-shared.js";
import type { GatewayRequestHandlers } from "./types.js";
export function createPluginApprovalHandlers(
manager: ExecApprovalManager<PluginApprovalRequestPayload>,
opts?: { forwarder?: ExecApprovalForwarder },
): GatewayRequestHandlers {
return {
"plugin.approval.list": async ({ respond }) => {
respond(
true,
manager.listPendingRecords().map((record) => ({
id: record.id,
request: record.request,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
})),
undefined,
);
},
"plugin.approval.request": async ({ params, client, respond, context }) => {
if (!validatePluginApprovalRequestParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid plugin.approval.request params: ${formatValidationErrors(
validatePluginApprovalRequestParams.errors,
)}`,
),
);
return;
}
const p = params as {
pluginId?: string | null;
title: string;
description: string;
severity?: string | null;
toolName?: string | null;
toolCallId?: string | null;
allowedDecisions?: string[] | null;
agentId?: string | null;
sessionKey?: string | null;
turnSourceChannel?: string | null;
turnSourceTo?: string | null;
turnSourceAccountId?: string | null;
turnSourceThreadId?: string | number | null;
timeoutMs?: number;
twoPhase?: boolean;
};
const twoPhase = p.twoPhase === true;
const timeoutMs = Math.min(
typeof p.timeoutMs === "number" ? p.timeoutMs : DEFAULT_PLUGIN_APPROVAL_TIMEOUT_MS,
MAX_PLUGIN_APPROVAL_TIMEOUT_MS,
);
const normalizeTrimmedString = (value?: string | null): string | null =>
normalizeOptionalString(value) || null;
const request: PluginApprovalRequestPayload = {
pluginId: p.pluginId ?? null,
title: p.title,
description: p.description,
severity: (p.severity as PluginApprovalRequestPayload["severity"]) ?? null,
toolName: p.toolName ?? null,
toolCallId: p.toolCallId ?? null,
...(Array.isArray(p.allowedDecisions)
? {
allowedDecisions: resolvePluginApprovalRequestAllowedDecisions({
allowedDecisions: p.allowedDecisions,
}),
}
: {}),
agentId: p.agentId ?? null,
sessionKey: p.sessionKey ?? null,
turnSourceChannel: normalizeTrimmedString(p.turnSourceChannel),
turnSourceTo: normalizeTrimmedString(p.turnSourceTo),
turnSourceAccountId: normalizeTrimmedString(p.turnSourceAccountId),
turnSourceThreadId: p.turnSourceThreadId ?? null,
};
// Always server-generate the ID — never accept plugin-provided IDs.
// Kind-prefix so /approve routing can distinguish plugin vs exec IDs deterministically.
const record = manager.create(request, timeoutMs, `plugin:${randomUUID()}`);
let decisionPromise: Promise<ExecApprovalDecision | null>;
try {
decisionPromise = manager.register(record, timeoutMs);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `registration failed: ${String(err)}`),
);
return;
}
const requestEvent = {
id: record.id,
request: record.request,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
};
await handlePendingApprovalRequest({
manager,
record,
decisionPromise,
respond,
context,
clientConnId: client?.connId,
requestEventName: "plugin.approval.requested",
requestEvent,
twoPhase,
deliverRequest: () => {
if (!opts?.forwarder?.handlePluginApprovalRequested) {
return false;
}
return opts.forwarder.handlePluginApprovalRequested(requestEvent).catch((err) => {
context.logGateway?.error?.(`plugin approvals: forward request failed: ${String(err)}`);
return false;
});
},
});
},
"plugin.approval.waitDecision": async ({ params, respond }) => {
await handleApprovalWaitDecision({
manager,
inputId: (params as { id?: string }).id,
respond,
});
},
"plugin.approval.resolve": async ({ params, respond, client, context }) => {
if (!validatePluginApprovalResolveParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid plugin.approval.resolve params: ${formatValidationErrors(
validatePluginApprovalResolveParams.errors,
)}`,
),
);
return;
}
const p = params as { id: string; decision: string };
if (!isApprovalDecision(p.decision)) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid decision"));
return;
}
const decision = p.decision;
await handleApprovalResolve({
manager,
inputId: p.id,
decision,
respond,
context,
client,
exposeAmbiguousPrefixError: false,
validateDecision: (snapshot) =>
resolvePluginApprovalRequestAllowedDecisions(snapshot.request).includes(decision)
? null
: {
message: `${decision} is unavailable for this plugin approval`,
details: {
allowedDecisions: resolvePluginApprovalRequestAllowedDecisions(snapshot.request),
},
},
resolvedEventName: "plugin.approval.resolved",
buildResolvedEvent: ({ approvalId, decision, resolvedBy, snapshot, nowMs }) => ({
id: approvalId,
decision,
resolvedBy,
ts: nowMs,
request: snapshot.request,
}),
forwardResolved: (resolvedEvent) =>
opts?.forwarder?.handlePluginApprovalResolved?.(resolvedEvent),
forwardResolvedErrorLabel: "plugin approvals: forward resolve failed",
});
},
};
}