297 lines
10 KiB
TypeScript
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);
|
|
},
|
|
};
|
|
}
|