import { hasApprovalTurnSourceRoute } from "../../infra/approval-turn-source.js"; import { resolveExecApprovalCommandDisplay, sanitizeExecApprovalDisplayText, } from "../../infra/exec-approval-command-display.js"; import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js"; import { DEFAULT_EXEC_APPROVAL_TIMEOUT_MS, resolveExecApprovalAllowedDecisions, resolveExecApprovalRequestAllowedDecisions, type ExecApprovalDecision, type ExecApprovalRequest, type ExecApprovalResolved, } from "../../infra/exec-approvals.js"; import { buildSystemRunApprovalBinding, buildSystemRunApprovalEnvBinding, } 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, validateExecApprovalGetParams, validateExecApprovalRequestParams, validateExecApprovalResolveParams, } from "../protocol/index.js"; import type { GatewayRequestHandlers } from "./types.js"; const APPROVAL_NOT_FOUND_DETAILS = { reason: ErrorCodes.APPROVAL_NOT_FOUND, } as const; const APPROVAL_ALLOW_ALWAYS_UNAVAILABLE_DETAILS = { reason: "APPROVAL_ALLOW_ALWAYS_UNAVAILABLE", } as const; type ExecApprovalIosPushDelivery = { handleRequested?: (request: ExecApprovalRequest) => Promise; handleResolved?: (resolved: ExecApprovalResolved) => Promise; handleExpired?: (request: ExecApprovalRequest) => Promise; }; function resolvePendingApprovalRecord(manager: ExecApprovalManager, inputId: string) { const resolvedId = manager.lookupPendingId(inputId); if (resolvedId.kind === "none") { return { ok: false as const, response: "missing" as const }; } if (resolvedId.kind === "ambiguous") { return { ok: false as const, response: { code: ErrorCodes.INVALID_REQUEST, message: "ambiguous approval id prefix; use the full id", }, }; } const snapshot = manager.getSnapshot(resolvedId.id); if (!snapshot || snapshot.resolvedAtMs !== undefined) { return { ok: false as const, response: "missing" as const }; } return { ok: true as const, approvalId: resolvedId.id, snapshot }; } export function createExecApprovalHandlers( manager: ExecApprovalManager, opts?: { forwarder?: ExecApprovalForwarder; iosPushDelivery?: ExecApprovalIosPushDelivery }, ): GatewayRequestHandlers { return { "exec.approval.get": async ({ params, respond }) => { if (!validateExecApprovalGetParams(params)) { respond( false, undefined, errorShape( ErrorCodes.INVALID_REQUEST, `invalid exec.approval.get params: ${formatValidationErrors( validateExecApprovalGetParams.errors, )}`, ), ); return; } const p = params as { id: string }; const resolved = resolvePendingApprovalRecord(manager, p.id); if (!resolved.ok) { if (resolved.response === "missing") { respond( false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id", { details: APPROVAL_NOT_FOUND_DETAILS, }), ); return; } respond(false, undefined, errorShape(resolved.response.code, resolved.response.message)); return; } const { commandText, commandPreview } = resolveExecApprovalCommandDisplay( resolved.snapshot.request, ); respond( true, { id: resolved.approvalId, commandText, commandPreview, allowedDecisions: resolveExecApprovalRequestAllowedDecisions(resolved.snapshot.request), host: resolved.snapshot.request.host ?? null, nodeId: resolved.snapshot.request.nodeId ?? null, agentId: resolved.snapshot.request.agentId ?? null, expiresAtMs: resolved.snapshot.expiresAtMs, }, undefined, ); }, "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; cwd?: string; systemRunPlan?: 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, systemRunPlan: p.systemRunPlan, 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" && !approvalContext.plan) { respond( false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "systemRunPlan is required for host=node"), ); return; } if (!effectiveCommandText) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "command is required")); 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 envBinding = buildSystemRunApprovalEnvBinding(p.env); const systemRunBinding = host === "node" ? buildSystemRunApprovalBinding({ 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: sanitizeExecApprovalDisplayText(effectiveCommandText), commandPreview: host === "node" || !approvalContext.commandPreview ? undefined : sanitizeExecApprovalDisplayText(approvalContext.commandPreview), commandArgv: host === "node" ? undefined : effectiveCommandArgv, envKeys: envBinding.envKeys.length > 0 ? envBinding.envKeys : undefined, systemRunBinding: systemRunBinding?.binding ?? null, systemRunPlan: approvalContext.plan, cwd: effectiveCwd ?? null, nodeId: host === "node" ? nodeId : null, host: host || null, security: p.security ?? null, ask: p.ask ?? null, allowedDecisions: resolveExecApprovalAllowedDecisions({ 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; } const requestEvent: ExecApprovalRequest = { id: record.id, request: record.request, createdAtMs: record.createdAtMs, expiresAtMs: record.expiresAtMs, }; context.broadcast("exec.approval.requested", requestEvent, { dropIfSlow: true }); const hasExecApprovalClients = context.hasExecApprovalClients?.(client?.connId) ?? false; const hasTurnSourceRoute = hasApprovalTurnSourceRoute({ turnSourceChannel: record.request.turnSourceChannel, turnSourceAccountId: record.request.turnSourceAccountId, }); let forwarded = false; if (opts?.forwarder) { try { forwarded = await opts.forwarder.handleRequested(requestEvent); } catch (err) { context.logGateway?.error?.(`exec approvals: forward request failed: ${String(err)}`); } } let deliveredToIosPush = false; if (opts?.iosPushDelivery?.handleRequested) { try { deliveredToIosPush = await opts.iosPushDelivery.handleRequested(requestEvent); } catch (err) { context.logGateway?.error?.(`exec approvals: iOS push request failed: ${String(err)}`); } } if (!hasExecApprovalClients && !forwarded && !hasTurnSourceRoute && !deliveredToIosPush) { manager.expire(record.id, "no-approval-route"); respond( true, { id: record.id, decision: null, createdAtMs: record.createdAtMs, expiresAtMs: record.expiresAtMs, }, undefined, ); return; } // 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; if (decision === null) { void opts?.iosPushDelivery?.handleExpired?.(requestEvent).catch((err) => { context.logGateway?.error?.(`exec approvals: iOS push expire failed: ${String(err)}`); }); } // 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 resolved = resolvePendingApprovalRecord(manager, p.id); if (!resolved.ok) { if (resolved.response === "missing") { respond( false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id", { details: APPROVAL_NOT_FOUND_DETAILS, }), ); return; } respond(false, undefined, errorShape(resolved.response.code, resolved.response.message)); return; } const approvalId = resolved.approvalId; const snapshot = resolved.snapshot; const allowedDecisions = resolveExecApprovalRequestAllowedDecisions(snapshot?.request); if (snapshot && !allowedDecisions.includes(decision)) { respond( false, undefined, errorShape( ErrorCodes.INVALID_REQUEST, "allow-always is unavailable because the effective policy requires approval every time", { details: APPROVAL_ALLOW_ALWAYS_UNAVAILABLE_DETAILS, }, ), ); return; } const resolvedBy = client?.connect?.client?.displayName ?? client?.connect?.client?.id; const ok = manager.resolve(approvalId, decision, resolvedBy ?? null); if (!ok) { respond( false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown or expired approval id", { details: APPROVAL_NOT_FOUND_DETAILS, }), ); return; } const resolvedEvent: ExecApprovalResolved = { id: approvalId, decision, resolvedBy, ts: Date.now(), request: snapshot?.request, }; context.broadcast("exec.approval.resolved", resolvedEvent, { dropIfSlow: true }); void opts?.forwarder?.handleResolved(resolvedEvent).catch((err) => { context.logGateway?.error?.(`exec approvals: forward resolve failed: ${String(err)}`); }); void opts?.iosPushDelivery?.handleResolved?.(resolvedEvent).catch((err) => { context.logGateway?.error?.(`exec approvals: iOS push resolve failed: ${String(err)}`); }); respond(true, { ok: true }, undefined); }, }; }