Spaces:
Paused
Paused
| 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<void>; | |
| handleResolved: (resolved: ExecApprovalResolved) => Promise<void>; | |
| 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 <id> 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<string, PendingApproval>(); | |
| 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<string>(); | |
| 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); | |
| } | |