import type { ReplyPayload } from "../auto-reply/types.js"; import { getLoadedChannelPlugin, resolveChannelApprovalAdapter, } from "../channels/plugins/index.js"; import { getRuntimeConfig } from "../config/config.js"; import type { ExecApprovalForwardingConfig, ExecApprovalForwardTarget, } from "../config/types.approvals.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { buildApprovalPendingReplyPayload, buildApprovalResolvedReplyPayload, buildPluginApprovalPendingReplyPayload, buildPluginApprovalResolvedReplyPayload, } from "../plugin-sdk/approval-renderers.js"; import { channelRouteDedupeKey } from "../plugin-sdk/channel-route.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { isDeliverableMessageChannel, normalizeMessageChannel, type DeliverableMessageChannel, } from "../utils/message-channel.js"; import { matchesApprovalRequestFilters } from "./approval-request-filters.js"; import { resolveExecApprovalCommandDisplay } from "./exec-approval-command-display.js"; import { formatExecApprovalExpiresIn } from "./exec-approval-reply.js"; import { resolveExecApprovalRequestAllowedDecisions, type ExecApprovalRequest, type ExecApprovalResolved, } from "./exec-approvals.js"; import { approvalDecisionLabel, buildPluginApprovalExpiredMessage, buildPluginApprovalRequestMessage, type PluginApprovalRequest, type PluginApprovalResolved, } from "./plugin-approvals.js"; const log = createSubsystemLogger("gateway/exec-approvals"); export type { ExecApprovalRequest, ExecApprovalResolved }; type DeliverOutboundPayloads = typeof import("./outbound/deliver.js").deliverOutboundPayloads; type MaybePromise = T | Promise; type ResolveSessionTargetFn = (params: { cfg: OpenClawConfig; request: ExecApprovalRequest; }) => MaybePromise; type ApprovalKind = "exec" | "plugin"; type ForwardTarget = ExecApprovalForwardTarget & { source: "session" | "target" }; type ApprovalRouteRequest = { agentId?: string | null; sessionKey?: string | null; turnSourceChannel?: string | null; turnSourceTo?: string | null; turnSourceAccountId?: string | null; turnSourceThreadId?: string | number | null; }; type PendingApproval = { routeRequest: TRouteRequest; targets: ForwardTarget[]; timeoutId: NodeJS.Timeout | null; }; type ApprovalRenderContext = { cfg: OpenClawConfig; target: ForwardTarget; routeRequest: TRouteRequest; }; type ApprovalPendingRenderContext< TRequest, TRouteRequest extends ApprovalRouteRequest, > = ApprovalRenderContext & { request: TRequest; nowMs: number; }; type ApprovalResolvedRenderContext< TResolved, TRouteRequest extends ApprovalRouteRequest, > = ApprovalRenderContext & { resolved: TResolved; }; type ApprovalStrategy< TRequest, TResolved, TRouteRequest extends ApprovalRouteRequest = ApprovalRouteRequest, > = { kind: ApprovalKind; config: (cfg: OpenClawConfig) => ExecApprovalForwardingConfig | undefined; getRequestId: (request: TRequest) => string; getResolvedId: (resolved: TResolved) => string; getExpiresAtMs: (request: TRequest) => number; getRouteRequestFromRequest: (request: TRequest) => TRouteRequest; getRouteRequestFromResolved: (resolved: TResolved) => TRouteRequest | null; buildExpiredText: (request: TRequest) => string; buildPendingPayload: ( params: ApprovalPendingRenderContext, ) => ReplyPayload; buildResolvedPayload: ( params: ApprovalResolvedRenderContext, ) => ReplyPayload; }; type ApprovalRouteRequestFields = { agentId?: string | null; sessionKey?: string | null; turnSourceChannel?: string | null; turnSourceTo?: string | null; turnSourceAccountId?: string | null; turnSourceThreadId?: string | number | null; }; export type ExecApprovalForwarder = { handleRequested: (request: ExecApprovalRequest) => Promise; handleResolved: (resolved: ExecApprovalResolved) => Promise; handlePluginApprovalRequested?: (request: PluginApprovalRequest) => Promise; handlePluginApprovalResolved?: (resolved: PluginApprovalResolved) => Promise; stop: () => void; }; export type ExecApprovalForwarderDeps = { getConfig?: () => OpenClawConfig; deliver?: DeliverOutboundPayloads; nowMs?: () => number; resolveSessionTarget?: ResolveSessionTargetFn; }; const DEFAULT_MODE = "session" as const; const SYNTHETIC_APPROVAL_REQUEST_ID = "__approval-routing__"; let execApprovalForwarderRuntimePromise: Promise< typeof import("./exec-approval-forwarder.runtime.js") > | null = null; function loadExecApprovalForwarderRuntime() { execApprovalForwarderRuntimePromise ??= import("./exec-approval-forwarder.runtime.js"); return execApprovalForwarderRuntimePromise; } function normalizeMode(mode?: ExecApprovalForwardingConfig["mode"]) { return mode ?? DEFAULT_MODE; } function shouldForwardRoute(params: { config?: { enabled?: boolean; agentFilter?: string[]; sessionFilter?: string[]; }; routeRequest: ApprovalRouteRequest; }): boolean { const config = params.config; if (!config?.enabled) { return false; } return matchesApprovalRequestFilters({ request: params.routeRequest, agentFilter: config.agentFilter, sessionFilter: config.sessionFilter, fallbackAgentIdFromSessionKey: true, }); } function buildTargetKey(target: ExecApprovalForwardTarget): string { const channel = normalizeMessageChannel(target.channel) ?? target.channel; return channelRouteDedupeKey({ channel, to: target.to, accountId: target.accountId, threadId: target.threadId, }); } function buildSyntheticApprovalRequest(routeRequest: ApprovalRouteRequest): ExecApprovalRequest { return { id: SYNTHETIC_APPROVAL_REQUEST_ID, request: { command: "", agentId: routeRequest.agentId ?? null, sessionKey: routeRequest.sessionKey ?? null, turnSourceChannel: routeRequest.turnSourceChannel ?? null, turnSourceTo: routeRequest.turnSourceTo ?? null, turnSourceAccountId: routeRequest.turnSourceAccountId ?? null, turnSourceThreadId: routeRequest.turnSourceThreadId ?? null, }, createdAtMs: 0, expiresAtMs: 0, }; } function shouldSkipForwardingFallback(params: { approvalKind: "exec" | "plugin"; target: ExecApprovalForwardTarget; cfg: OpenClawConfig; routeRequest: ApprovalRouteRequest; }): boolean { const channel = normalizeMessageChannel(params.target.channel) ?? params.target.channel; if (!channel) { return false; } const adapter = resolveChannelApprovalAdapter(getLoadedChannelPlugin(channel)); return ( adapter?.delivery?.shouldSuppressForwardingFallback?.({ cfg: params.cfg, approvalKind: params.approvalKind, target: params.target, request: buildSyntheticApprovalRequest(params.routeRequest), }) ?? false ); } function formatApprovalCommand(command: string): { inline: boolean; text: string } { if (!command.includes("\n") && !command.includes("`")) { return { inline: true, text: `\`${command}\`` }; } let fence = "```"; while (command.includes(fence)) { fence += "`"; } return { inline: false, text: `${fence}\n${command}\n${fence}` }; } function buildRequestMessage(request: ExecApprovalRequest, nowMs: number) { const allowedDecisions = resolveExecApprovalRequestAllowedDecisions(request.request); const decisionText = allowedDecisions.join("|"); const lines: string[] = ["🔒 Exec approval required", `ID: ${request.id}`]; const command = formatApprovalCommand( resolveExecApprovalCommandDisplay(request.request).commandText, ); if (command.inline) { lines.push(`Command: ${command.text}`); } else { lines.push("Command:"); lines.push(command.text); } if (request.request.cwd) { lines.push(`CWD: ${request.request.cwd}`); } if (request.request.nodeId) { lines.push(`Node: ${request.request.nodeId}`); } if (Array.isArray(request.request.envKeys) && request.request.envKeys.length > 0) { lines.push(`Env overrides: ${request.request.envKeys.join(", ")}`); } if (request.request.host) { lines.push(`Host: ${request.request.host}`); } if (request.request.agentId) { lines.push(`Agent: ${request.request.agentId}`); } if (request.request.security) { lines.push(`Security: ${request.request.security}`); } if (request.request.ask) { lines.push(`Ask: ${request.request.ask}`); } lines.push(`Expires in: ${formatExecApprovalExpiresIn(request.expiresAtMs, nowMs)}`); lines.push("Mode: foreground (interactive approvals available in this chat)."); lines.push( allowedDecisions.includes("allow-always") ? "Background mode note: non-interactive runs cannot wait for chat approvals; use pre-approved policy (allow-always or ask=off)." : "Background mode note: non-interactive runs cannot wait for chat approvals; the effective policy still requires per-run approval unless ask=off.", ); lines.push(`Reply with: /approve ${decisionText}`); if (!allowedDecisions.includes("allow-always")) { lines.push( "Allow Always is unavailable because the effective policy requires approval every time.", ); } return lines.join("\n"); } const decisionLabel = approvalDecisionLabel; function buildResolvedMessage(resolved: ExecApprovalResolved) { const base = `✅ Exec approval ${decisionLabel(resolved.decision)}.`; const by = resolved.resolvedBy ? ` Resolved by ${resolved.resolvedBy}.` : ""; return `${base}${by} ID: ${resolved.id}`; } function buildExpiredMessage(request: ExecApprovalRequest) { return `⏱️ Exec approval expired. ID: ${request.id}`; } function normalizeTurnSourceChannel(value?: string | null): DeliverableMessageChannel | undefined { const normalized = value ? normalizeMessageChannel(value) : undefined; return normalized && isDeliverableMessageChannel(normalized) ? normalized : undefined; } function extractApprovalRouteRequest( request: ApprovalRouteRequestFields | null | undefined, ): ApprovalRouteRequest | null { if (!request) { return null; } return { agentId: request.agentId ?? null, sessionKey: request.sessionKey ?? null, turnSourceChannel: request.turnSourceChannel ?? null, turnSourceTo: request.turnSourceTo ?? null, turnSourceAccountId: request.turnSourceAccountId ?? null, turnSourceThreadId: request.turnSourceThreadId ?? null, }; } function defaultResolveSessionTarget(params: { cfg: OpenClawConfig; request: ExecApprovalRequest; }): Promise { return loadExecApprovalForwarderRuntime().then(({ resolveExecApprovalSessionTarget }) => { const resolvedTarget = resolveExecApprovalSessionTarget({ cfg: params.cfg, request: params.request, turnSourceChannel: normalizeTurnSourceChannel(params.request.request.turnSourceChannel), turnSourceTo: normalizeOptionalString(params.request.request.turnSourceTo), turnSourceAccountId: normalizeOptionalString(params.request.request.turnSourceAccountId), turnSourceThreadId: params.request.request.turnSourceThreadId ?? undefined, }); if (!resolvedTarget?.channel || !resolvedTarget.to) { return null; } const channel = resolvedTarget.channel; if (!isDeliverableMessageChannel(channel)) { return null; } return { channel, to: resolvedTarget.to, accountId: resolvedTarget.accountId, threadId: resolvedTarget.threadId, }; }); } async function deliverToTargets(params: { cfg: OpenClawConfig; targets: ForwardTarget[]; buildPayload: (target: ForwardTarget) => ReplyPayload; deliver: DeliverOutboundPayloads; beforeDeliver?: (target: ForwardTarget, payload: ReplyPayload) => Promise | void; shouldSend?: () => boolean; }) { const deliveries = params.targets.map(async (target) => { if (params.shouldSend && !params.shouldSend()) { return; } const channel = normalizeMessageChannel(target.channel) ?? target.channel; if (!isDeliverableMessageChannel(channel)) { return; } try { const payload = params.buildPayload(target); await params.beforeDeliver?.(target, payload); await params.deliver({ cfg: params.cfg, channel, to: target.to, accountId: target.accountId, threadId: target.threadId, payloads: [payload], }); } catch (err) { log.error(`exec approvals: failed to deliver to ${channel}:${target.to}: ${String(err)}`); } }); await Promise.allSettled(deliveries); } function buildApprovalRenderPayload(params: { target: ForwardTarget; renderParams: TParams; resolveRenderer: ( adapter: ReturnType | undefined, ) => ((params: TParams) => ReplyPayload | null) | undefined; buildFallback: () => ReplyPayload; }): ReplyPayload { const channel = normalizeMessageChannel(params.target.channel) ?? params.target.channel; const adapterPayload = channel ? params.resolveRenderer(resolveChannelApprovalAdapter(getLoadedChannelPlugin(channel)))?.( params.renderParams, ) : null; return adapterPayload ?? params.buildFallback(); } function buildExecPendingPayload(params: { cfg: OpenClawConfig; request: ExecApprovalRequest; target: ForwardTarget; nowMs: number; }): ReplyPayload { return buildApprovalRenderPayload({ target: params.target, renderParams: params, resolveRenderer: (adapter) => adapter?.render?.exec?.buildPendingPayload, buildFallback: () => buildApprovalPendingReplyPayload({ approvalId: params.request.id, approvalSlug: params.request.id.slice(0, 8), text: buildRequestMessage(params.request, params.nowMs), agentId: params.request.request.agentId ?? null, allowedDecisions: resolveExecApprovalRequestAllowedDecisions(params.request.request), sessionKey: params.request.request.sessionKey ?? null, }), }); } function buildExecResolvedPayload(params: { cfg: OpenClawConfig; resolved: ExecApprovalResolved; target: ForwardTarget; }): ReplyPayload { return buildApprovalRenderPayload({ target: params.target, renderParams: params, resolveRenderer: (adapter) => adapter?.render?.exec?.buildResolvedPayload, buildFallback: () => buildApprovalResolvedReplyPayload({ approvalId: params.resolved.id, approvalSlug: params.resolved.id.slice(0, 8), text: buildResolvedMessage(params.resolved), }), }); } function buildPluginPendingPayload(params: { cfg: OpenClawConfig; request: PluginApprovalRequest; target: ForwardTarget; nowMs: number; }): ReplyPayload { return buildApprovalRenderPayload({ target: params.target, renderParams: params, resolveRenderer: (adapter) => adapter?.render?.plugin?.buildPendingPayload, buildFallback: () => buildPluginApprovalPendingReplyPayload({ request: params.request, nowMs: params.nowMs, text: buildPluginApprovalRequestMessage(params.request, params.nowMs), }), }); } function buildPluginResolvedPayload(params: { cfg: OpenClawConfig; resolved: PluginApprovalResolved; target: ForwardTarget; }): ReplyPayload { return buildApprovalRenderPayload({ target: params.target, renderParams: params, resolveRenderer: (adapter) => adapter?.render?.plugin?.buildResolvedPayload, buildFallback: () => buildPluginApprovalResolvedReplyPayload({ resolved: params.resolved, }), }); } async function resolveForwardTargets(params: { cfg: OpenClawConfig; config?: ExecApprovalForwardingConfig; routeRequest: ApprovalRouteRequest; resolveSessionTarget: ResolveSessionTargetFn; }): Promise { const mode = normalizeMode(params.config?.mode); const targets: ForwardTarget[] = []; const seen = new Set(); if (mode === "session" || mode === "both") { const sessionTarget = await params.resolveSessionTarget({ cfg: params.cfg, request: buildSyntheticApprovalRequest(params.routeRequest), }); if (sessionTarget) { const key = buildTargetKey(sessionTarget); if (!seen.has(key)) { seen.add(key); targets.push({ ...sessionTarget, source: "session" }); } } } if (mode === "targets" || mode === "both") { const explicitTargets = params.config?.targets ?? []; for (const target of explicitTargets) { const key = buildTargetKey(target); if (seen.has(key)) { continue; } seen.add(key); targets.push({ ...target, source: "target" }); } } return targets; } function createApprovalHandlers< TRequest, TResolved, TRouteRequest extends ApprovalRouteRequest = ApprovalRouteRequest, >(params: { strategy: ApprovalStrategy; getConfig: () => OpenClawConfig; deliver: DeliverOutboundPayloads; nowMs: () => number; resolveSessionTarget: ResolveSessionTargetFn; }) { const pending = new Map>(); const handleRequested = async (request: TRequest): Promise => { const cfg = params.getConfig(); const config = params.strategy.config(cfg); const requestId = params.strategy.getRequestId(request); const routeRequest = params.strategy.getRouteRequestFromRequest(request); const filteredTargets = [ ...(shouldForwardRoute({ config, routeRequest }) ? await resolveForwardTargets({ cfg, config, routeRequest, resolveSessionTarget: params.resolveSessionTarget, }) : []), ].filter( (target) => !shouldSkipForwardingFallback({ approvalKind: params.strategy.kind, target, cfg, routeRequest, }), ); if (filteredTargets.length === 0) { return false; } const expiresInMs = Math.max(0, params.strategy.getExpiresAtMs(request) - params.nowMs()); const timeoutId = setTimeout(() => { void (async () => { const entry = pending.get(requestId); if (!entry) { return; } pending.delete(requestId); await deliverToTargets({ cfg, targets: entry.targets, buildPayload: () => ({ text: params.strategy.buildExpiredText(request) }), deliver: params.deliver, }); })(); }, expiresInMs); timeoutId.unref?.(); const pendingEntry: PendingApproval = { routeRequest, targets: filteredTargets, timeoutId, }; pending.set(requestId, pendingEntry); if (pending.get(requestId) !== pendingEntry) { return false; } void deliverToTargets({ cfg, targets: filteredTargets, buildPayload: (target) => params.strategy.buildPendingPayload({ cfg, request, target, routeRequest, nowMs: params.nowMs(), }), beforeDeliver: async (target, payload) => { const channel = normalizeMessageChannel(target.channel) ?? target.channel; if (!channel) { return; } await getLoadedChannelPlugin(channel)?.outbound?.beforeDeliverPayload?.({ cfg, target, payload, hint: { kind: "approval-pending", approvalKind: params.strategy.kind, }, }); }, deliver: params.deliver, shouldSend: () => pending.get(requestId) === pendingEntry, }).catch((err) => { log.error( `${params.strategy.kind} approvals: failed to deliver request ${requestId}: ${String(err)}`, ); }); return true; }; const handleResolved = async (resolved: TResolved) => { const resolvedId = params.strategy.getResolvedId(resolved); const entry = pending.get(resolvedId); if (entry?.timeoutId) { clearTimeout(entry.timeoutId); } if (entry) { pending.delete(resolvedId); } const cfg = params.getConfig(); let targets = entry?.targets; if (!targets) { const routeRequest = params.strategy.getRouteRequestFromResolved(resolved); if (routeRequest) { const config = params.strategy.config(cfg); targets = [ ...(shouldForwardRoute({ config, routeRequest }) ? await resolveForwardTargets({ cfg, config, routeRequest, resolveSessionTarget: params.resolveSessionTarget, }) : []), ].filter( (target) => !shouldSkipForwardingFallback({ approvalKind: params.strategy.kind, target, cfg, routeRequest, }), ); } } if (!targets?.length) { return; } await deliverToTargets({ cfg, targets, buildPayload: (target) => params.strategy.buildResolvedPayload({ cfg, resolved, target, routeRequest: entry?.routeRequest ?? params.strategy.getRouteRequestFromResolved(resolved) ?? ({} as TRouteRequest), }), deliver: params.deliver, }); }; const stop = () => { for (const entry of pending.values()) { if (entry.timeoutId) { clearTimeout(entry.timeoutId); } } pending.clear(); }; return { handleRequested, handleResolved, stop }; } function createApprovalStrategy< TRequest extends { id: string; request: ApprovalRouteRequestFields; expiresAtMs: number }, TResolved extends { id: string; request?: ApprovalRouteRequestFields | null }, >(params: { kind: ApprovalKind; config: (cfg: OpenClawConfig) => ExecApprovalForwardingConfig | undefined; buildExpiredText: (request: TRequest) => string; buildPendingPayload: ( params: ApprovalPendingRenderContext, ) => ReplyPayload; buildResolvedPayload: ( params: ApprovalResolvedRenderContext, ) => ReplyPayload; }): ApprovalStrategy { return { kind: params.kind, config: params.config, getRequestId: (request) => request.id, getResolvedId: (resolved) => resolved.id, getExpiresAtMs: (request) => request.expiresAtMs, getRouteRequestFromRequest: (request) => extractApprovalRouteRequest(request.request) ?? {}, getRouteRequestFromResolved: (resolved) => extractApprovalRouteRequest(resolved.request), buildExpiredText: params.buildExpiredText, buildPendingPayload: params.buildPendingPayload, buildResolvedPayload: params.buildResolvedPayload, }; } const execApprovalStrategy = createApprovalStrategy({ kind: "exec", config: (cfg) => cfg.approvals?.exec, buildExpiredText: buildExpiredMessage, buildPendingPayload: ({ cfg, request, target, nowMs }) => buildExecPendingPayload({ cfg, request, target, nowMs, }), buildResolvedPayload: ({ cfg, resolved, target }) => buildExecResolvedPayload({ cfg, resolved, target, }), }); const pluginApprovalStrategy = createApprovalStrategy< PluginApprovalRequest, PluginApprovalResolved >({ kind: "plugin", config: (cfg) => cfg.approvals?.plugin, buildExpiredText: buildPluginApprovalExpiredMessage, buildPendingPayload: ({ cfg, request, target, nowMs }) => buildPluginPendingPayload({ cfg, request, target, nowMs, }), buildResolvedPayload: ({ cfg, resolved, target }) => buildPluginResolvedPayload({ cfg, resolved, target, }), }); export function createExecApprovalForwarder( deps: ExecApprovalForwarderDeps = {}, ): ExecApprovalForwarder { const getConfig = deps.getConfig ?? getRuntimeConfig; const deliver = deps.deliver ?? (async (params) => { const { deliverOutboundPayloads } = await loadExecApprovalForwarderRuntime(); return deliverOutboundPayloads(params); }); const nowMs = deps.nowMs ?? Date.now; const resolveSessionTarget = deps.resolveSessionTarget ?? defaultResolveSessionTarget; const execHandlers = createApprovalHandlers({ strategy: execApprovalStrategy, getConfig, deliver, nowMs, resolveSessionTarget, }); const pluginHandlers = createApprovalHandlers({ strategy: pluginApprovalStrategy, getConfig, deliver, nowMs, resolveSessionTarget, }); return { handleRequested: execHandlers.handleRequested, handleResolved: execHandlers.handleResolved, handlePluginApprovalRequested: pluginHandlers.handleRequested, handlePluginApprovalResolved: pluginHandlers.handleResolved, stop: () => { execHandlers.stop(); pluginHandlers.stop(); }, }; } export function shouldForwardExecApproval(params: { config?: ExecApprovalForwardingConfig; request: ExecApprovalRequest; }): boolean { return shouldForwardRoute({ config: params.config, routeRequest: execApprovalStrategy.getRouteRequestFromRequest(params.request), }); }