import type { OpenClawConfig } from "../config/config.js"; import type { ExecApprovalForwardingConfig, ExecApprovalForwardTarget, } from "../config/types.approvals.js"; import type { ExecApprovalDecision } from "./exec-approvals.js"; import { loadConfig } from "../config/config.js"; import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; import { isDeliverableMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js"; import { deliverOutboundPayloads } from "./outbound/deliver.js"; import { resolveSessionDeliveryTarget } from "./outbound/targets.js"; const log = createSubsystemLogger("gateway/exec-approvals"); export type ExecApprovalRequest = { id: string; request: { command: string; cwd?: string | null; host?: string | null; security?: string | null; ask?: string | null; agentId?: string | null; resolvedPath?: string | null; sessionKey?: string | null; }; createdAtMs: number; expiresAtMs: number; }; export type ExecApprovalResolved = { id: string; decision: ExecApprovalDecision; resolvedBy?: string | null; ts: number; }; type ForwardTarget = ExecApprovalForwardTarget & { source: "session" | "target" }; type PendingApproval = { request: ExecApprovalRequest; targets: ForwardTarget[]; timeoutId: NodeJS.Timeout | null; }; export type ExecApprovalForwarder = { handleRequested: (request: ExecApprovalRequest) => Promise; handleResolved: (resolved: ExecApprovalResolved) => Promise; stop: () => void; }; export type ExecApprovalForwarderDeps = { getConfig?: () => OpenClawConfig; deliver?: typeof deliverOutboundPayloads; nowMs?: () => number; resolveSessionTarget?: (params: { cfg: OpenClawConfig; request: ExecApprovalRequest; }) => ExecApprovalForwardTarget | null; }; const DEFAULT_MODE = "session" as const; function normalizeMode(mode?: ExecApprovalForwardingConfig["mode"]) { return mode ?? DEFAULT_MODE; } function matchSessionFilter(sessionKey: string, patterns: string[]): boolean { return patterns.some((pattern) => { try { return sessionKey.includes(pattern) || new RegExp(pattern).test(sessionKey); } catch { return sessionKey.includes(pattern); } }); } function shouldForward(params: { config?: ExecApprovalForwardingConfig; request: ExecApprovalRequest; }): boolean { const config = params.config; if (!config?.enabled) { return false; } if (config.agentFilter?.length) { const agentId = params.request.request.agentId ?? parseAgentSessionKey(params.request.request.sessionKey)?.agentId; if (!agentId) { return false; } if (!config.agentFilter.includes(agentId)) { return false; } } if (config.sessionFilter?.length) { const sessionKey = params.request.request.sessionKey; if (!sessionKey) { return false; } if (!matchSessionFilter(sessionKey, config.sessionFilter)) { return false; } } return true; } function buildTargetKey(target: ExecApprovalForwardTarget): string { const channel = normalizeMessageChannel(target.channel) ?? target.channel; const accountId = target.accountId ?? ""; const threadId = target.threadId ?? ""; return [channel, target.to, accountId, threadId].join(":"); } function buildRequestMessage(request: ExecApprovalRequest, nowMs: number) { const lines: string[] = ["🔒 Exec approval required", `ID: ${request.id}`]; lines.push(`Command: ${request.request.command}`); if (request.request.cwd) { lines.push(`CWD: ${request.request.cwd}`); } 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}`); } const expiresIn = Math.max(0, Math.round((request.expiresAtMs - nowMs) / 1000)); lines.push(`Expires in: ${expiresIn}s`); lines.push("Reply with: /approve allow-once|allow-always|deny"); return lines.join("\n"); } function decisionLabel(decision: ExecApprovalDecision): string { if (decision === "allow-once") { return "allowed once"; } if (decision === "allow-always") { return "allowed always"; } return "denied"; } 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 defaultResolveSessionTarget(params: { cfg: OpenClawConfig; request: ExecApprovalRequest; }): ExecApprovalForwardTarget | null { const sessionKey = params.request.request.sessionKey?.trim(); if (!sessionKey) { return null; } const parsed = parseAgentSessionKey(sessionKey); const agentId = parsed?.agentId ?? params.request.request.agentId ?? "main"; const storePath = resolveStorePath(params.cfg.session?.store, { agentId }); const store = loadSessionStore(storePath); const entry = store[sessionKey]; if (!entry) { return null; } const target = resolveSessionDeliveryTarget({ entry, requestedChannel: "last" }); if (!target.channel || !target.to) { return null; } if (!isDeliverableMessageChannel(target.channel)) { return null; } return { channel: target.channel, to: target.to, accountId: target.accountId, threadId: target.threadId, }; } async function deliverToTargets(params: { cfg: OpenClawConfig; targets: ForwardTarget[]; text: string; deliver: typeof deliverOutboundPayloads; 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 { await params.deliver({ cfg: params.cfg, channel, to: target.to, accountId: target.accountId, threadId: target.threadId, payloads: [{ text: params.text }], }); } catch (err) { log.error(`exec approvals: failed to deliver to ${channel}:${target.to}: ${String(err)}`); } }); await Promise.allSettled(deliveries); } export function createExecApprovalForwarder( deps: ExecApprovalForwarderDeps = {}, ): ExecApprovalForwarder { const getConfig = deps.getConfig ?? loadConfig; const deliver = deps.deliver ?? deliverOutboundPayloads; const nowMs = deps.nowMs ?? Date.now; const resolveSessionTarget = deps.resolveSessionTarget ?? defaultResolveSessionTarget; const pending = new Map(); const handleRequested = async (request: ExecApprovalRequest) => { const cfg = getConfig(); const config = cfg.approvals?.exec; if (!shouldForward({ config, request })) { return; } const mode = normalizeMode(config?.mode); const targets: ForwardTarget[] = []; const seen = new Set(); if (mode === "session" || mode === "both") { const sessionTarget = resolveSessionTarget({ cfg, request }); 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 = config?.targets ?? []; for (const target of explicitTargets) { const key = buildTargetKey(target); if (seen.has(key)) { continue; } seen.add(key); targets.push({ ...target, source: "target" }); } } if (targets.length === 0) { return; } const expiresInMs = Math.max(0, request.expiresAtMs - nowMs()); const timeoutId = setTimeout(() => { void (async () => { const entry = pending.get(request.id); if (!entry) { return; } pending.delete(request.id); const expiredText = buildExpiredMessage(request); await deliverToTargets({ cfg, targets: entry.targets, text: expiredText, deliver }); })(); }, expiresInMs); timeoutId.unref?.(); const pendingEntry: PendingApproval = { request, targets, timeoutId }; pending.set(request.id, pendingEntry); if (pending.get(request.id) !== pendingEntry) { return; } const text = buildRequestMessage(request, nowMs()); await deliverToTargets({ cfg, targets, text, deliver, shouldSend: () => pending.get(request.id) === pendingEntry, }); }; const handleResolved = async (resolved: ExecApprovalResolved) => { const entry = pending.get(resolved.id); if (!entry) { return; } if (entry.timeoutId) { clearTimeout(entry.timeoutId); } pending.delete(resolved.id); const cfg = getConfig(); const text = buildResolvedMessage(resolved); await deliverToTargets({ cfg, targets: entry.targets, text, deliver }); }; const stop = () => { for (const entry of pending.values()) { if (entry.timeoutId) { clearTimeout(entry.timeoutId); } } pending.clear(); }; return { handleRequested, handleResolved, stop }; } export function shouldForwardExecApproval(params: { config?: ExecApprovalForwardingConfig; request: ExecApprovalRequest; }): boolean { return shouldForward(params); }