mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-20 22:40:58 +00:00
574 lines
18 KiB
TypeScript
574 lines
18 KiB
TypeScript
import type { ReplyPayload } from "../auto-reply/types.js";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import { loadConfig } from "../config/config.js";
|
|
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
|
import type {
|
|
ExecApprovalForwardingConfig,
|
|
ExecApprovalForwardTarget,
|
|
} from "../config/types.approvals.js";
|
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
|
import { normalizeAccountId, parseAgentSessionKey } from "../routing/session-key.js";
|
|
import { compileSafeRegex, testRegexWithBoundedInput } from "../security/safe-regex.js";
|
|
import { buildTelegramExecApprovalButtons } from "../telegram/approval-buttons.js";
|
|
import { sendTypingTelegram } from "../telegram/send.js";
|
|
import {
|
|
isDeliverableMessageChannel,
|
|
normalizeMessageChannel,
|
|
type DeliverableMessageChannel,
|
|
} from "../utils/message-channel.js";
|
|
import { resolveExecApprovalCommandDisplay } from "./exec-approval-command-display.js";
|
|
import { buildExecApprovalPendingReplyPayload } from "./exec-approval-reply.js";
|
|
import type {
|
|
ExecApprovalDecision,
|
|
ExecApprovalRequest,
|
|
ExecApprovalResolved,
|
|
} from "./exec-approvals.js";
|
|
import { deliverOutboundPayloads } from "./outbound/deliver.js";
|
|
import { resolveSessionDeliveryTarget } from "./outbound/targets.js";
|
|
|
|
const log = createSubsystemLogger("gateway/exec-approvals");
|
|
export type { ExecApprovalRequest, ExecApprovalResolved };
|
|
|
|
type ForwardTarget = ExecApprovalForwardTarget & { source: "session" | "target" };
|
|
|
|
type PendingApproval = {
|
|
request: ExecApprovalRequest;
|
|
targets: ForwardTarget[];
|
|
timeoutId: NodeJS.Timeout | null;
|
|
};
|
|
|
|
export type ExecApprovalForwarder = {
|
|
handleRequested: (request: ExecApprovalRequest) => Promise<boolean>;
|
|
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) => {
|
|
if (sessionKey.includes(pattern)) {
|
|
return true;
|
|
}
|
|
const regex = compileSafeRegex(pattern);
|
|
return regex ? testRegexWithBoundedInput(regex, sessionKey) : false;
|
|
});
|
|
}
|
|
|
|
function shouldForward(params: {
|
|
config?: {
|
|
enabled?: boolean;
|
|
agentFilter?: string[];
|
|
sessionFilter?: string[];
|
|
};
|
|
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 resolveChannelAccountConfig<T>(
|
|
accounts: Record<string, T> | undefined,
|
|
accountId?: string,
|
|
): T | undefined {
|
|
if (!accounts || !accountId?.trim()) {
|
|
return undefined;
|
|
}
|
|
const normalized = normalizeAccountId(accountId);
|
|
const direct = accounts[normalized];
|
|
if (direct) {
|
|
return direct;
|
|
}
|
|
const fallbackKey = Object.keys(accounts).find(
|
|
(key) => key.toLowerCase() === normalized.toLowerCase(),
|
|
);
|
|
return fallbackKey ? accounts[fallbackKey] : undefined;
|
|
}
|
|
|
|
// Discord has component-based exec approvals; skip text fallback only when the
|
|
// Discord-specific handler is enabled for the same target account.
|
|
function shouldSkipDiscordForwarding(
|
|
target: ExecApprovalForwardTarget,
|
|
cfg: OpenClawConfig,
|
|
): boolean {
|
|
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
|
|
if (channel !== "discord") {
|
|
return false;
|
|
}
|
|
const discord = cfg.channels?.discord as
|
|
| {
|
|
execApprovals?: { enabled?: boolean; approvers?: Array<string | number> };
|
|
accounts?: Record<
|
|
string,
|
|
{ execApprovals?: { enabled?: boolean; approvers?: Array<string | number> } }
|
|
>;
|
|
}
|
|
| undefined;
|
|
if (!discord) {
|
|
return false;
|
|
}
|
|
const account = resolveChannelAccountConfig(discord.accounts, target.accountId);
|
|
const execApprovals = account?.execApprovals ?? discord.execApprovals;
|
|
return Boolean(execApprovals?.enabled && (execApprovals.approvers?.length ?? 0) > 0);
|
|
}
|
|
|
|
function shouldSkipTelegramForwarding(params: {
|
|
target: ExecApprovalForwardTarget;
|
|
cfg: OpenClawConfig;
|
|
request: ExecApprovalRequest;
|
|
}): boolean {
|
|
const channel = normalizeMessageChannel(params.target.channel) ?? params.target.channel;
|
|
if (channel !== "telegram") {
|
|
return false;
|
|
}
|
|
const requestChannel = normalizeMessageChannel(params.request.request.turnSourceChannel ?? "");
|
|
if (requestChannel !== "telegram") {
|
|
return false;
|
|
}
|
|
const telegram = params.cfg.channels?.telegram;
|
|
if (!telegram) {
|
|
return false;
|
|
}
|
|
const telegramConfig = telegram as
|
|
| {
|
|
execApprovals?: { enabled?: boolean; approvers?: Array<string | number> };
|
|
accounts?: Record<
|
|
string,
|
|
{ execApprovals?: { enabled?: boolean; approvers?: Array<string | number> } }
|
|
>;
|
|
}
|
|
| undefined;
|
|
if (!telegramConfig) {
|
|
return false;
|
|
}
|
|
const accountId =
|
|
params.target.accountId?.trim() || params.request.request.turnSourceAccountId?.trim();
|
|
const account = accountId
|
|
? (resolveChannelAccountConfig<{
|
|
execApprovals?: { enabled?: boolean; approvers?: Array<string | number> };
|
|
}>(telegramConfig.accounts, accountId) as
|
|
| { execApprovals?: { enabled?: boolean; approvers?: Array<string | number> } }
|
|
| undefined)
|
|
: undefined;
|
|
const execApprovals = account?.execApprovals ?? telegramConfig.execApprovals;
|
|
return Boolean(execApprovals?.enabled && (execApprovals.approvers?.length ?? 0) > 0);
|
|
}
|
|
|
|
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 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}`);
|
|
}
|
|
const expiresIn = Math.max(0, Math.round((request.expiresAtMs - nowMs) / 1000));
|
|
lines.push(`Expires in: ${expiresIn}s`);
|
|
lines.push("Mode: foreground (interactive approvals available in this chat).");
|
|
lines.push(
|
|
"Background mode note: non-interactive runs cannot wait for chat approvals; use pre-approved policy (allow-always or ask=off).",
|
|
);
|
|
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 normalizeTurnSourceChannel(value?: string | null): DeliverableMessageChannel | undefined {
|
|
const normalized = value ? normalizeMessageChannel(value) : undefined;
|
|
return normalized && isDeliverableMessageChannel(normalized) ? normalized : undefined;
|
|
}
|
|
|
|
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",
|
|
turnSourceChannel: normalizeTurnSourceChannel(params.request.request.turnSourceChannel),
|
|
turnSourceTo: params.request.request.turnSourceTo?.trim() || undefined,
|
|
turnSourceAccountId: params.request.request.turnSourceAccountId?.trim() || undefined,
|
|
turnSourceThreadId: params.request.request.turnSourceThreadId ?? undefined,
|
|
});
|
|
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[];
|
|
buildPayload: (target: ForwardTarget) => ReplyPayload;
|
|
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 {
|
|
const payload = params.buildPayload(target);
|
|
if (
|
|
channel === "telegram" &&
|
|
payload.channelData &&
|
|
typeof payload.channelData === "object" &&
|
|
!Array.isArray(payload.channelData) &&
|
|
payload.channelData.execApproval
|
|
) {
|
|
const threadId =
|
|
typeof target.threadId === "number"
|
|
? target.threadId
|
|
: typeof target.threadId === "string"
|
|
? Number.parseInt(target.threadId, 10)
|
|
: undefined;
|
|
await sendTypingTelegram(target.to, {
|
|
cfg: params.cfg,
|
|
accountId: target.accountId,
|
|
...(Number.isFinite(threadId) ? { messageThreadId: threadId } : {}),
|
|
}).catch(() => {});
|
|
}
|
|
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 buildRequestPayloadForTarget(
|
|
_cfg: OpenClawConfig,
|
|
request: ExecApprovalRequest,
|
|
nowMsValue: number,
|
|
target: ForwardTarget,
|
|
): ReplyPayload {
|
|
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
|
|
if (channel === "telegram") {
|
|
const payload = buildExecApprovalPendingReplyPayload({
|
|
approvalId: request.id,
|
|
approvalSlug: request.id.slice(0, 8),
|
|
approvalCommandId: request.id,
|
|
command: resolveExecApprovalCommandDisplay(request.request).commandText,
|
|
cwd: request.request.cwd ?? undefined,
|
|
host: request.request.host === "node" ? "node" : "gateway",
|
|
nodeId: request.request.nodeId ?? undefined,
|
|
expiresAtMs: request.expiresAtMs,
|
|
nowMs: nowMsValue,
|
|
});
|
|
const buttons = buildTelegramExecApprovalButtons(request.id);
|
|
if (!buttons) {
|
|
return payload;
|
|
}
|
|
return {
|
|
...payload,
|
|
channelData: {
|
|
...payload.channelData,
|
|
telegram: {
|
|
buttons,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
return { text: buildRequestMessage(request, nowMsValue) };
|
|
}
|
|
|
|
function resolveForwardTargets(params: {
|
|
cfg: OpenClawConfig;
|
|
config?: ExecApprovalForwardingConfig;
|
|
request: ExecApprovalRequest;
|
|
resolveSessionTarget: (params: {
|
|
cfg: OpenClawConfig;
|
|
request: ExecApprovalRequest;
|
|
}) => ExecApprovalForwardTarget | null;
|
|
}): ForwardTarget[] {
|
|
const mode = normalizeMode(params.config?.mode);
|
|
const targets: ForwardTarget[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
if (mode === "session" || mode === "both") {
|
|
const sessionTarget = params.resolveSessionTarget({
|
|
cfg: params.cfg,
|
|
request: params.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 = 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;
|
|
}
|
|
|
|
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): Promise<boolean> => {
|
|
const cfg = getConfig();
|
|
const config = cfg.approvals?.exec;
|
|
const filteredTargets = [
|
|
...(shouldForward({ config, request })
|
|
? resolveForwardTargets({
|
|
cfg,
|
|
config,
|
|
request,
|
|
resolveSessionTarget,
|
|
})
|
|
: []),
|
|
].filter(
|
|
(target) =>
|
|
!shouldSkipDiscordForwarding(target, cfg) &&
|
|
!shouldSkipTelegramForwarding({ target, cfg, request }),
|
|
);
|
|
|
|
if (filteredTargets.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
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,
|
|
buildPayload: () => ({ text: expiredText }),
|
|
deliver,
|
|
});
|
|
})();
|
|
}, expiresInMs);
|
|
timeoutId.unref?.();
|
|
|
|
const pendingEntry: PendingApproval = { request, targets: filteredTargets, timeoutId };
|
|
pending.set(request.id, pendingEntry);
|
|
|
|
if (pending.get(request.id) !== pendingEntry) {
|
|
return false;
|
|
}
|
|
void deliverToTargets({
|
|
cfg,
|
|
targets: filteredTargets,
|
|
buildPayload: (target) => buildRequestPayloadForTarget(cfg, request, nowMs(), target),
|
|
deliver,
|
|
shouldSend: () => pending.get(request.id) === pendingEntry,
|
|
}).catch((err) => {
|
|
log.error(`exec approvals: failed to deliver request ${request.id}: ${String(err)}`);
|
|
});
|
|
return true;
|
|
};
|
|
|
|
const handleResolved = async (resolved: ExecApprovalResolved) => {
|
|
const entry = pending.get(resolved.id);
|
|
if (entry) {
|
|
if (entry.timeoutId) {
|
|
clearTimeout(entry.timeoutId);
|
|
}
|
|
pending.delete(resolved.id);
|
|
}
|
|
const cfg = getConfig();
|
|
let targets = entry?.targets;
|
|
|
|
if (!targets && resolved.request) {
|
|
const request: ExecApprovalRequest = {
|
|
id: resolved.id,
|
|
request: resolved.request,
|
|
createdAtMs: resolved.ts,
|
|
expiresAtMs: resolved.ts,
|
|
};
|
|
const config = cfg.approvals?.exec;
|
|
targets = [
|
|
...(shouldForward({ config, request })
|
|
? resolveForwardTargets({
|
|
cfg,
|
|
config,
|
|
request,
|
|
resolveSessionTarget,
|
|
})
|
|
: []),
|
|
].filter(
|
|
(target) =>
|
|
!shouldSkipDiscordForwarding(target, cfg) &&
|
|
!shouldSkipTelegramForwarding({ target, cfg, request }),
|
|
);
|
|
}
|
|
if (!targets || targets.length === 0) {
|
|
return;
|
|
}
|
|
const text = buildResolvedMessage(resolved);
|
|
await deliverToTargets({ cfg, targets, buildPayload: () => ({ 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);
|
|
}
|