Files
Moltbot/src/gateway/server-methods/exec-approval.ts

297 lines
10 KiB
TypeScript

import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js";
import {
DEFAULT_EXEC_APPROVAL_TIMEOUT_MS,
type ExecApprovalDecision,
} from "../../infra/exec-approvals.js";
import { buildSystemRunApprovalBindingV1 } from "../../infra/system-run-approval-binding.js";
import { resolveSystemRunApprovalRequestContext } from "../../infra/system-run-approval-context.js";
import type { ExecApprovalManager } from "../exec-approval-manager.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateExecApprovalRequestParams,
validateExecApprovalResolveParams,
} from "../protocol/index.js";
import type { GatewayRequestHandlers } from "./types.js";
export function createExecApprovalHandlers(
manager: ExecApprovalManager,
opts?: { forwarder?: ExecApprovalForwarder },
): GatewayRequestHandlers {
const hasApprovalClients = (context: { hasExecApprovalClients?: () => boolean }) => {
if (typeof context.hasExecApprovalClients === "function") {
return context.hasExecApprovalClients();
}
// Fail closed when no operator-scope probe is available.
return false;
};
return {
"exec.approval.request": async ({ params, respond, context, client }) => {
if (!validateExecApprovalRequestParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid exec.approval.request params: ${formatValidationErrors(
validateExecApprovalRequestParams.errors,
)}`,
),
);
return;
}
const p = params as {
id?: string;
command: string;
commandArgv?: string[];
env?: Record<string, string>;
cwd?: string;
systemRunPlanV2?: unknown;
nodeId?: string;
host?: string;
security?: string;
ask?: string;
agentId?: string;
resolvedPath?: string;
sessionKey?: string;
turnSourceChannel?: string;
turnSourceTo?: string;
turnSourceAccountId?: string;
turnSourceThreadId?: string | number;
timeoutMs?: number;
twoPhase?: boolean;
};
const twoPhase = p.twoPhase === true;
const timeoutMs =
typeof p.timeoutMs === "number" ? p.timeoutMs : DEFAULT_EXEC_APPROVAL_TIMEOUT_MS;
const explicitId = typeof p.id === "string" && p.id.trim().length > 0 ? p.id.trim() : null;
const host = typeof p.host === "string" ? p.host.trim() : "";
const nodeId = typeof p.nodeId === "string" ? p.nodeId.trim() : "";
const approvalContext = resolveSystemRunApprovalRequestContext({
host,
command: p.command,
commandArgv: p.commandArgv,
systemRunPlanV2: p.systemRunPlanV2,
cwd: p.cwd,
agentId: p.agentId,
sessionKey: p.sessionKey,
});
const effectiveCommandArgv = approvalContext.commandArgv;
const effectiveCwd = approvalContext.cwd;
const effectiveAgentId = approvalContext.agentId;
const effectiveSessionKey = approvalContext.sessionKey;
const effectiveCommandText = approvalContext.commandText;
if (host === "node" && !nodeId) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "nodeId is required for host=node"),
);
return;
}
if (
host === "node" &&
(!Array.isArray(effectiveCommandArgv) || effectiveCommandArgv.length === 0)
) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "commandArgv is required for host=node"),
);
return;
}
const systemRunBindingV1 =
host === "node"
? buildSystemRunApprovalBindingV1({
argv: effectiveCommandArgv,
cwd: effectiveCwd,
agentId: effectiveAgentId,
sessionKey: effectiveSessionKey,
env: p.env,
})
: null;
if (explicitId && manager.getSnapshot(explicitId)) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "approval id already pending"),
);
return;
}
const request = {
command: effectiveCommandText,
commandArgv: effectiveCommandArgv,
envKeys: systemRunBindingV1?.envKeys?.length ? systemRunBindingV1.envKeys : undefined,
systemRunBindingV1: systemRunBindingV1?.binding ?? null,
systemRunPlanV2: approvalContext.planV2,
cwd: effectiveCwd ?? null,
nodeId: host === "node" ? nodeId : null,
host: host || null,
security: p.security ?? null,
ask: p.ask ?? null,
agentId: effectiveAgentId ?? null,
resolvedPath: p.resolvedPath ?? null,
sessionKey: effectiveSessionKey ?? null,
turnSourceChannel:
typeof p.turnSourceChannel === "string" ? p.turnSourceChannel.trim() || null : null,
turnSourceTo: typeof p.turnSourceTo === "string" ? p.turnSourceTo.trim() || null : null,
turnSourceAccountId:
typeof p.turnSourceAccountId === "string" ? p.turnSourceAccountId.trim() || null : null,
turnSourceThreadId: p.turnSourceThreadId ?? null,
};
const record = manager.create(request, timeoutMs, explicitId);
record.requestedByConnId = client?.connId ?? null;
record.requestedByDeviceId = client?.connect?.device?.id ?? null;
record.requestedByClientId = client?.connect?.client?.id ?? null;
// Use register() to synchronously add to pending map before sending any response.
// This ensures the approval ID is valid immediately after the "accepted" response.
let decisionPromise: Promise<
import("../../infra/exec-approvals.js").ExecApprovalDecision | null
>;
try {
decisionPromise = manager.register(record, timeoutMs);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `registration failed: ${String(err)}`),
);
return;
}
context.broadcast(
"exec.approval.requested",
{
id: record.id,
request: record.request,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
},
{ dropIfSlow: true },
);
let forwardedToTargets = false;
if (opts?.forwarder) {
try {
forwardedToTargets = await opts.forwarder.handleRequested({
id: record.id,
request: record.request,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
});
} catch (err) {
context.logGateway?.error?.(`exec approvals: forward request failed: ${String(err)}`);
}
}
if (!hasApprovalClients(context) && !forwardedToTargets) {
manager.expire(record.id, "auto-expire:no-approver-clients");
}
// Only send immediate "accepted" response when twoPhase is requested.
// This preserves single-response semantics for existing callers.
if (twoPhase) {
respond(
true,
{
status: "accepted",
id: record.id,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
},
undefined,
);
}
const decision = await decisionPromise;
// Send final response with decision for callers using expectFinal:true.
respond(
true,
{
id: record.id,
decision,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
},
undefined,
);
},
"exec.approval.waitDecision": async ({ params, respond }) => {
const p = params as { id?: string };
const id = typeof p.id === "string" ? p.id.trim() : "";
if (!id) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "id is required"));
return;
}
const decisionPromise = manager.awaitDecision(id);
if (!decisionPromise) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "approval expired or not found"),
);
return;
}
// Capture snapshot before await (entry may be deleted after grace period)
const snapshot = manager.getSnapshot(id);
const decision = await decisionPromise;
// Return decision (can be null on timeout) - let clients handle via askFallback
respond(
true,
{
id,
decision,
createdAtMs: snapshot?.createdAtMs,
expiresAtMs: snapshot?.expiresAtMs,
},
undefined,
);
},
"exec.approval.resolve": async ({ params, respond, client, context }) => {
if (!validateExecApprovalResolveParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid exec.approval.resolve params: ${formatValidationErrors(
validateExecApprovalResolveParams.errors,
)}`,
),
);
return;
}
const p = params as { id: string; decision: string };
const decision = p.decision as ExecApprovalDecision;
if (decision !== "allow-once" && decision !== "allow-always" && decision !== "deny") {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "invalid decision"));
return;
}
const snapshot = manager.getSnapshot(p.id);
const resolvedBy = client?.connect?.client?.displayName ?? client?.connect?.client?.id;
const ok = manager.resolve(p.id, decision, resolvedBy ?? null);
if (!ok) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown approval id"));
return;
}
context.broadcast(
"exec.approval.resolved",
{ id: p.id, decision, resolvedBy, ts: Date.now(), request: snapshot?.request },
{ dropIfSlow: true },
);
void opts?.forwarder
?.handleResolved({
id: p.id,
decision,
resolvedBy,
ts: Date.now(),
request: snapshot?.request,
})
.catch((err) => {
context.logGateway?.error?.(`exec approvals: forward resolve failed: ${String(err)}`);
});
respond(true, { ok: true }, undefined);
},
};
}