From 483fba41b9f9fb57964f31b90a2ddacb185d54d7 Mon Sep 17 00:00:00 2001 From: Lucas Czekaj Date: Sat, 24 Jan 2026 12:56:40 -0800 Subject: [PATCH] feat(discord): add exec approval forwarding to DMs (#1621) * feat(discord): add exec approval forwarding to DMs Add support for forwarding exec approval requests to Discord DMs, allowing users to approve/deny command execution via interactive buttons. Features: - New DiscordExecApprovalHandler that connects to gateway and listens for exec.approval.requested/resolved events - Sends DMs with embeds showing command details and 3 buttons: Allow once, Always allow, Deny - Configurable via channels.discord.execApprovals with: - enabled: boolean - approvers: Discord user IDs to notify - agentFilter: only forward for specific agents - sessionFilter: only forward for matching session patterns - Updates message embed when approval is resolved or expires Also fixes exec completion routing: when async exec completes after approval, the heartbeat now uses a specialized prompt to ensure the model relays the result to the user instead of responding HEARTBEAT_OK. Co-Authored-By: Claude Opus 4.5 * feat: generic exec approvals forwarding (#1621) (thanks @czekaj) --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + docs/tools/exec-approvals.md | 30 + docs/tools/slash-commands.md | 1 + src/auto-reply/commands-registry.data.ts | 7 + src/auto-reply/reply/commands-approve.test.ts | 83 +++ src/auto-reply/reply/commands-approve.ts | 101 ++++ src/auto-reply/reply/commands-core.ts | 2 + src/config/types.approvals.ts | 29 + src/config/types.clawdbot.ts | 2 + src/config/types.discord.ts | 13 + src/config/types.ts | 1 + src/config/zod-schema.approvals.ts | 28 + src/config/zod-schema.providers-core.ts | 9 + src/config/zod-schema.ts | 2 + src/discord/monitor/exec-approvals.test.ts | 199 +++++++ src/discord/monitor/exec-approvals.ts | 549 ++++++++++++++++++ src/discord/monitor/provider.ts | 43 +- src/gateway/server-methods/exec-approval.ts | 21 +- src/gateway/server.impl.ts | 6 +- src/infra/exec-approval-forwarder.test.ts | 77 +++ src/infra/exec-approval-forwarder.ts | 282 +++++++++ src/infra/heartbeat-runner.ts | 39 +- 22 files changed, 1511 insertions(+), 14 deletions(-) create mode 100644 src/auto-reply/reply/commands-approve.test.ts create mode 100644 src/auto-reply/reply/commands-approve.ts create mode 100644 src/config/types.approvals.ts create mode 100644 src/config/zod-schema.approvals.ts create mode 100644 src/discord/monitor/exec-approvals.test.ts create mode 100644 src/discord/monitor/exec-approvals.ts create mode 100644 src/infra/exec-approval-forwarder.test.ts create mode 100644 src/infra/exec-approval-forwarder.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ab2509ae9..124bd7617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.clawd.bot - Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround). - Docs: add verbose installer troubleshooting guidance. - Docs: update Fly.io guide notes. +- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands ### Fixes - Web UI: hide internal `message_id` hints in chat bubbles. diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index 79a58aa47..ec350f9d9 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -157,6 +157,36 @@ Actions: - **Always allow** → add to allowlist + run - **Deny** → block +## Approval forwarding to chat channels + +You can forward exec approval prompts to any chat channel (including plugin channels) and approve +them with `/approve`. This uses the normal outbound delivery pipeline. + +Config: +```json5 +{ + approvals: { + exec: { + enabled: true, + mode: "session", // "session" | "targets" | "both" + agentFilter: ["main"], + sessionFilter: ["discord"], // substring or regex + targets: [ + { channel: "slack", to: "U12345678" }, + { channel: "telegram", to: "123456789" } + ] + } + } +} +``` + +Reply in chat: +``` +/approve allow-once +/approve allow-always +/approve deny +``` + ### macOS IPC flow ``` Gateway -> Node Service (WS) diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 804edc244..b4a6e4d44 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -61,6 +61,7 @@ Text + native (when enabled): - `/skill [input]` (run a skill by name) - `/status` (show current status; includes provider usage/quota for the current model provider when available) - `/allowlist` (list/add/remove allowlist entries) +- `/approve allow-once|allow-always|deny` (resolve exec approval prompts) - `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size) - `/whoami` (show your sender id; alias: `/id`) - `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session) diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 536a64ea4..87d06b9d0 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -164,6 +164,13 @@ function buildChatCommands(): ChatCommandDefinition[] { acceptsArgs: true, scope: "text", }), + defineChatCommand({ + key: "approve", + nativeName: "approve", + description: "Approve or deny exec requests.", + textAlias: "/approve", + acceptsArgs: true, + }), defineChatCommand({ key: "context", nativeName: "context", diff --git a/src/auto-reply/reply/commands-approve.test.ts b/src/auto-reply/reply/commands-approve.test.ts new file mode 100644 index 000000000..ec2695372 --- /dev/null +++ b/src/auto-reply/reply/commands-approve.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { ClawdbotConfig } from "../../config/config.js"; +import type { MsgContext } from "../templating.js"; +import { buildCommandContext, handleCommands } from "./commands.js"; +import { parseInlineDirectives } from "./directive-handling.js"; +import { callGateway } from "../../gateway/call.js"; + +vi.mock("../../gateway/call.js", () => ({ + callGateway: vi.fn(), +})); + +function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial) { + const ctx = { + Body: commandBody, + CommandBody: commandBody, + CommandSource: "text", + CommandAuthorized: true, + Provider: "whatsapp", + Surface: "whatsapp", + ...ctxOverrides, + } as MsgContext; + + const command = buildCommandContext({ + ctx, + cfg, + isGroup: false, + triggerBodyNormalized: commandBody.trim().toLowerCase(), + commandAuthorized: true, + }); + + return { + ctx, + cfg, + command, + directives: parseInlineDirectives(commandBody), + elevated: { enabled: true, allowed: true, failures: [] }, + sessionKey: "agent:main:main", + workspaceDir: "/tmp", + defaultGroupActivation: () => "mention", + resolvedVerboseLevel: "off" as const, + resolvedReasoningLevel: "off" as const, + resolveDefaultThinkingLevel: async () => undefined, + provider: "whatsapp", + model: "test-model", + contextTokens: 0, + isGroup: false, + }; +} + +describe("/approve command", () => { + it("rejects invalid usage", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as ClawdbotConfig; + const params = buildParams("/approve", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Usage: /approve"); + }); + + it("submits approval", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as ClawdbotConfig; + const params = buildParams("/approve abc allow-once", cfg, { SenderId: "123" }); + + const mockCallGateway = vi.mocked(callGateway); + mockCallGateway.mockResolvedValueOnce({ ok: true }); + + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Exec approval allow-once submitted"); + expect(mockCallGateway).toHaveBeenCalledWith( + expect.objectContaining({ + method: "exec.approval.resolve", + params: { id: "abc", decision: "allow-once" }, + }), + ); + }); +}); diff --git a/src/auto-reply/reply/commands-approve.ts b/src/auto-reply/reply/commands-approve.ts new file mode 100644 index 000000000..a34e4b31c --- /dev/null +++ b/src/auto-reply/reply/commands-approve.ts @@ -0,0 +1,101 @@ +import { callGateway } from "../../gateway/call.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; +import { logVerbose } from "../../globals.js"; +import type { CommandHandler } from "./commands-types.js"; + +const COMMAND = "/approve"; + +const DECISION_ALIASES: Record = { + allow: "allow-once", + once: "allow-once", + "allow-once": "allow-once", + allowonce: "allow-once", + always: "allow-always", + "allow-always": "allow-always", + allowalways: "allow-always", + deny: "deny", + reject: "deny", + block: "deny", +}; + +type ParsedApproveCommand = + | { ok: true; id: string; decision: "allow-once" | "allow-always" | "deny" } + | { ok: false; error: string }; + +function parseApproveCommand(raw: string): ParsedApproveCommand | null { + const trimmed = raw.trim(); + if (!trimmed.toLowerCase().startsWith(COMMAND)) return null; + const rest = trimmed.slice(COMMAND.length).trim(); + if (!rest) { + return { ok: false, error: "Usage: /approve allow-once|allow-always|deny" }; + } + const tokens = rest.split(/\s+/).filter(Boolean); + if (tokens.length < 2) { + return { ok: false, error: "Usage: /approve allow-once|allow-always|deny" }; + } + + const first = tokens[0].toLowerCase(); + const second = tokens[1].toLowerCase(); + + if (DECISION_ALIASES[first]) { + return { + ok: true, + decision: DECISION_ALIASES[first], + id: tokens.slice(1).join(" ").trim(), + }; + } + if (DECISION_ALIASES[second]) { + return { + ok: true, + decision: DECISION_ALIASES[second], + id: tokens[0], + }; + } + return { ok: false, error: "Usage: /approve allow-once|allow-always|deny" }; +} + +function buildResolvedByLabel(params: Parameters[0]): string { + const channel = params.command.channel; + const sender = params.command.senderId ?? "unknown"; + return `${channel}:${sender}`; +} + +export const handleApproveCommand: CommandHandler = async (params, allowTextCommands) => { + if (!allowTextCommands) return null; + const normalized = params.command.commandBodyNormalized; + const parsed = parseApproveCommand(normalized); + if (!parsed) return null; + if (!params.command.isAuthorizedSender) { + logVerbose( + `Ignoring /approve from unauthorized sender: ${params.command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + + if (!parsed.ok) { + return { shouldContinue: false, reply: { text: parsed.error } }; + } + + const resolvedBy = buildResolvedByLabel(params); + try { + await callGateway({ + method: "exec.approval.resolve", + params: { id: parsed.id, decision: parsed.decision }, + clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + clientDisplayName: `Chat approval (${resolvedBy})`, + mode: GATEWAY_CLIENT_MODES.BACKEND, + }); + } catch (err) { + return { + shouldContinue: false, + reply: { + text: `❌ Failed to submit approval: ${String(err)}`, + }, + }; + } + + return { + shouldContinue: false, + reply: { text: `✅ Exec approval ${parsed.decision} submitted for ${parsed.id}.` }, + }; +}; diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index 5cf40dfb2..a54f90b2b 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -14,6 +14,7 @@ import { handleWhoamiCommand, } from "./commands-info.js"; import { handleAllowlistCommand } from "./commands-allowlist.js"; +import { handleApproveCommand } from "./commands-approve.js"; import { handleSubagentsCommand } from "./commands-subagents.js"; import { handleModelsCommand } from "./commands-models.js"; import { handleTtsCommands } from "./commands-tts.js"; @@ -45,6 +46,7 @@ const HANDLERS: CommandHandler[] = [ handleCommandsListCommand, handleStatusCommand, handleAllowlistCommand, + handleApproveCommand, handleContextCommand, handleWhoamiCommand, handleSubagentsCommand, diff --git a/src/config/types.approvals.ts b/src/config/types.approvals.ts new file mode 100644 index 000000000..d86d05b8e --- /dev/null +++ b/src/config/types.approvals.ts @@ -0,0 +1,29 @@ +export type ExecApprovalForwardingMode = "session" | "targets" | "both"; + +export type ExecApprovalForwardTarget = { + /** Channel id (e.g. "discord", "slack", or plugin channel id). */ + channel: string; + /** Destination id (channel id, user id, etc. depending on channel). */ + to: string; + /** Optional account id for multi-account channels. */ + accountId?: string; + /** Optional thread id to reply inside a thread. */ + threadId?: string | number; +}; + +export type ExecApprovalForwardingConfig = { + /** Enable forwarding exec approvals to chat channels. Default: false. */ + enabled?: boolean; + /** Delivery mode (session=origin chat, targets=config targets, both=both). Default: session. */ + mode?: ExecApprovalForwardingMode; + /** Only forward approvals for these agent IDs. Omit = all agents. */ + agentFilter?: string[]; + /** Only forward approvals matching these session key patterns (substring or regex). */ + sessionFilter?: string[]; + /** Explicit delivery targets (used when mode includes targets). */ + targets?: ExecApprovalForwardTarget[]; +}; + +export type ApprovalsConfig = { + exec?: ExecApprovalForwardingConfig; +}; diff --git a/src/config/types.clawdbot.ts b/src/config/types.clawdbot.ts index 1a95ca2e9..c11857642 100644 --- a/src/config/types.clawdbot.ts +++ b/src/config/types.clawdbot.ts @@ -1,4 +1,5 @@ import type { AgentBinding, AgentsConfig } from "./types.agents.js"; +import type { ApprovalsConfig } from "./types.approvals.js"; import type { AuthConfig } from "./types.auth.js"; import type { DiagnosticsConfig, LoggingConfig, SessionConfig, WebConfig } from "./types.base.js"; import type { BrowserConfig } from "./types.browser.js"; @@ -84,6 +85,7 @@ export type ClawdbotConfig = { audio?: AudioConfig; messages?: MessagesConfig; commands?: CommandsConfig; + approvals?: ApprovalsConfig; session?: SessionConfig; web?: WebConfig; channels?: ChannelsConfig; diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index f2fc68ffa..ae434dd15 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -72,6 +72,17 @@ export type DiscordActionConfig = { channels?: boolean; }; +export type DiscordExecApprovalConfig = { + /** Enable exec approval forwarding to Discord DMs. Default: false. */ + enabled?: boolean; + /** Discord user IDs to receive approval prompts. Required if enabled. */ + approvers?: Array; + /** Only forward approvals for these agent IDs. Omit = all agents. */ + agentFilter?: string[]; + /** Only forward approvals matching these session key patterns (substring or regex). */ + sessionFilter?: string[]; +}; + export type DiscordAccountConfig = { /** Optional display name for this account (used in CLI/UI lists). */ name?: string; @@ -124,6 +135,8 @@ export type DiscordAccountConfig = { guilds?: Record; /** Heartbeat visibility settings for this channel. */ heartbeat?: ChannelHeartbeatVisibilityConfig; + /** Exec approval forwarding configuration. */ + execApprovals?: DiscordExecApprovalConfig; }; export type DiscordConfig = { diff --git a/src/config/types.ts b/src/config/types.ts index 767b1d915..fd2b88e5f 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -2,6 +2,7 @@ export * from "./types.agent-defaults.js"; export * from "./types.agents.js"; +export * from "./types.approvals.js"; export * from "./types.auth.js"; export * from "./types.base.js"; export * from "./types.browser.js"; diff --git a/src/config/zod-schema.approvals.ts b/src/config/zod-schema.approvals.ts new file mode 100644 index 000000000..e5276d19a --- /dev/null +++ b/src/config/zod-schema.approvals.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +const ExecApprovalForwardTargetSchema = z + .object({ + channel: z.string().min(1), + to: z.string().min(1), + accountId: z.string().optional(), + threadId: z.union([z.string(), z.number()]).optional(), + }) + .strict(); + +const ExecApprovalForwardingSchema = z + .object({ + enabled: z.boolean().optional(), + mode: z.union([z.literal("session"), z.literal("targets"), z.literal("both")]).optional(), + agentFilter: z.array(z.string()).optional(), + sessionFilter: z.array(z.string()).optional(), + targets: z.array(ExecApprovalForwardTargetSchema).optional(), + }) + .strict() + .optional(); + +export const ApprovalsSchema = z + .object({ + exec: ExecApprovalForwardingSchema, + }) + .strict() + .optional(); diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index d67e9420b..37a0825b6 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -244,6 +244,15 @@ export const DiscordAccountSchema = z dm: DiscordDmSchema.optional(), guilds: z.record(z.string(), DiscordGuildSchema.optional()).optional(), heartbeat: ChannelHeartbeatVisibilitySchema, + execApprovals: z + .object({ + enabled: z.boolean().optional(), + approvers: z.array(z.union([z.string(), z.number()])).optional(), + agentFilter: z.array(z.string()).optional(), + sessionFilter: z.array(z.string()).optional(), + }) + .strict() + .optional(), }) .strict(); diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index b8233d14c..2b16a7f02 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { ToolsSchema } from "./zod-schema.agent-runtime.js"; +import { ApprovalsSchema } from "./zod-schema.approvals.js"; import { AgentsSchema, AudioSchema, BindingsSchema, BroadcastSchema } from "./zod-schema.agents.js"; import { HexColorSchema, ModelsConfigSchema } from "./zod-schema.core.js"; import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-schema.hooks.js"; @@ -220,6 +221,7 @@ export const ClawdbotSchema = z .optional(), messages: MessagesSchema, commands: CommandsSchema, + approvals: ApprovalsSchema, session: SessionSchema, cron: z .object({ diff --git a/src/discord/monitor/exec-approvals.test.ts b/src/discord/monitor/exec-approvals.test.ts new file mode 100644 index 000000000..044b3eb97 --- /dev/null +++ b/src/discord/monitor/exec-approvals.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; +import { + buildExecApprovalCustomId, + parseExecApprovalData, + type ExecApprovalRequest, + DiscordExecApprovalHandler, +} from "./exec-approvals.js"; +import type { DiscordExecApprovalConfig } from "../../config/types.discord.js"; + +describe("buildExecApprovalCustomId", () => { + it("encodes approval id and action", () => { + const customId = buildExecApprovalCustomId("abc-123", "allow-once"); + expect(customId).toBe("execapproval:id=abc-123;action=allow-once"); + }); + + it("encodes special characters in approval id", () => { + const customId = buildExecApprovalCustomId("abc=123;test", "deny"); + expect(customId).toBe("execapproval:id=abc%3D123%3Btest;action=deny"); + }); +}); + +describe("parseExecApprovalData", () => { + it("parses valid data", () => { + const result = parseExecApprovalData({ id: "abc-123", action: "allow-once" }); + expect(result).toEqual({ approvalId: "abc-123", action: "allow-once" }); + }); + + it("parses encoded data", () => { + const result = parseExecApprovalData({ + id: "abc%3D123%3Btest", + action: "allow-always", + }); + expect(result).toEqual({ approvalId: "abc=123;test", action: "allow-always" }); + }); + + it("rejects invalid action", () => { + const result = parseExecApprovalData({ id: "abc-123", action: "invalid" }); + expect(result).toBeNull(); + }); + + it("rejects missing id", () => { + const result = parseExecApprovalData({ action: "deny" }); + expect(result).toBeNull(); + }); + + it("rejects missing action", () => { + const result = parseExecApprovalData({ id: "abc-123" }); + expect(result).toBeNull(); + }); + + it("rejects null/undefined input", () => { + expect(parseExecApprovalData(null as any)).toBeNull(); + expect(parseExecApprovalData(undefined as any)).toBeNull(); + }); + + it("accepts all valid actions", () => { + expect(parseExecApprovalData({ id: "x", action: "allow-once" })?.action).toBe("allow-once"); + expect(parseExecApprovalData({ id: "x", action: "allow-always" })?.action).toBe("allow-always"); + expect(parseExecApprovalData({ id: "x", action: "deny" })?.action).toBe("deny"); + }); +}); + +describe("roundtrip encoding", () => { + it("encodes and decodes correctly", () => { + const approvalId = "test-approval-with=special;chars&more"; + const action = "allow-always" as const; + const customId = buildExecApprovalCustomId(approvalId, action); + + // Parse the key=value pairs from the custom ID + const parts = customId.split(";"); + const data: Record = {}; + for (const part of parts) { + const match = part.match(/^([^:]+:)?([^=]+)=(.+)$/); + if (match) { + data[match[2]] = match[3]; + } + } + + const result = parseExecApprovalData(data); + expect(result).toEqual({ approvalId, action }); + }); +}); + +describe("DiscordExecApprovalHandler.shouldHandle", () => { + function createHandler(config: DiscordExecApprovalConfig) { + return new DiscordExecApprovalHandler({ + token: "test-token", + accountId: "default", + config, + cfg: {}, + }); + } + + function createRequest( + overrides: Partial = {}, + ): ExecApprovalRequest { + return { + id: "test-id", + request: { + command: "echo hello", + cwd: "/home/user", + host: "gateway", + agentId: "test-agent", + sessionKey: "agent:test-agent:discord:123", + ...overrides, + }, + createdAtMs: Date.now(), + expiresAtMs: Date.now() + 60000, + }; + } + + it("returns false when disabled", () => { + const handler = createHandler({ enabled: false, approvers: ["123"] }); + expect(handler.shouldHandle(createRequest())).toBe(false); + }); + + it("returns false when no approvers", () => { + const handler = createHandler({ enabled: true, approvers: [] }); + expect(handler.shouldHandle(createRequest())).toBe(false); + }); + + it("returns true with minimal config", () => { + const handler = createHandler({ enabled: true, approvers: ["123"] }); + expect(handler.shouldHandle(createRequest())).toBe(true); + }); + + it("filters by agent ID", () => { + const handler = createHandler({ + enabled: true, + approvers: ["123"], + agentFilter: ["allowed-agent"], + }); + expect(handler.shouldHandle(createRequest({ agentId: "allowed-agent" }))).toBe(true); + expect(handler.shouldHandle(createRequest({ agentId: "other-agent" }))).toBe(false); + expect(handler.shouldHandle(createRequest({ agentId: null }))).toBe(false); + }); + + it("filters by session key substring", () => { + const handler = createHandler({ + enabled: true, + approvers: ["123"], + sessionFilter: ["discord"], + }); + expect(handler.shouldHandle(createRequest({ sessionKey: "agent:test:discord:123" }))).toBe( + true, + ); + expect(handler.shouldHandle(createRequest({ sessionKey: "agent:test:telegram:123" }))).toBe( + false, + ); + expect(handler.shouldHandle(createRequest({ sessionKey: null }))).toBe(false); + }); + + it("filters by session key regex", () => { + const handler = createHandler({ + enabled: true, + approvers: ["123"], + sessionFilter: ["^agent:.*:discord:"], + }); + expect(handler.shouldHandle(createRequest({ sessionKey: "agent:test:discord:123" }))).toBe( + true, + ); + expect(handler.shouldHandle(createRequest({ sessionKey: "other:test:discord:123" }))).toBe( + false, + ); + }); + + it("combines agent and session filters", () => { + const handler = createHandler({ + enabled: true, + approvers: ["123"], + agentFilter: ["my-agent"], + sessionFilter: ["discord"], + }); + expect( + handler.shouldHandle( + createRequest({ + agentId: "my-agent", + sessionKey: "agent:my-agent:discord:123", + }), + ), + ).toBe(true); + expect( + handler.shouldHandle( + createRequest({ + agentId: "other-agent", + sessionKey: "agent:other:discord:123", + }), + ), + ).toBe(false); + expect( + handler.shouldHandle( + createRequest({ + agentId: "my-agent", + sessionKey: "agent:my-agent:telegram:123", + }), + ), + ).toBe(false); + }); +}); diff --git a/src/discord/monitor/exec-approvals.ts b/src/discord/monitor/exec-approvals.ts new file mode 100644 index 000000000..4b5d5ea3a --- /dev/null +++ b/src/discord/monitor/exec-approvals.ts @@ -0,0 +1,549 @@ +import { Button, type ButtonInteraction, type ComponentData } from "@buape/carbon"; +import { ButtonStyle, Routes } from "discord-api-types/v10"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { GatewayClient } from "../../gateway/client.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; +import type { EventFrame } from "../../gateway/protocol/index.js"; +import type { ExecApprovalDecision } from "../../infra/exec-approvals.js"; +import { createDiscordClient } from "../send.shared.js"; +import { logDebug, logError } from "../../logger.js"; +import type { DiscordExecApprovalConfig } from "../../config/types.discord.js"; +import type { RuntimeEnv } from "../../runtime.js"; + +const EXEC_APPROVAL_KEY = "execapproval"; + +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 PendingApproval = { + discordMessageId: string; + discordChannelId: string; + timeoutId: NodeJS.Timeout; +}; + +function encodeCustomIdValue(value: string): string { + return encodeURIComponent(value); +} + +function decodeCustomIdValue(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +export function buildExecApprovalCustomId( + approvalId: string, + action: ExecApprovalDecision, +): string { + return [`${EXEC_APPROVAL_KEY}:id=${encodeCustomIdValue(approvalId)}`, `action=${action}`].join( + ";", + ); +} + +export function parseExecApprovalData( + data: ComponentData, +): { approvalId: string; action: ExecApprovalDecision } | null { + if (!data || typeof data !== "object") return null; + const coerce = (value: unknown) => + typeof value === "string" || typeof value === "number" ? String(value) : ""; + const rawId = coerce(data.id); + const rawAction = coerce(data.action); + if (!rawId || !rawAction) return null; + const action = rawAction as ExecApprovalDecision; + if (action !== "allow-once" && action !== "allow-always" && action !== "deny") { + return null; + } + return { + approvalId: decodeCustomIdValue(rawId), + action, + }; +} + +function formatExecApprovalEmbed(request: ExecApprovalRequest) { + const commandText = request.request.command; + const commandPreview = + commandText.length > 1000 ? `${commandText.slice(0, 1000)}...` : commandText; + const expiresIn = Math.max(0, Math.round((request.expiresAtMs - Date.now()) / 1000)); + + const fields: Array<{ name: string; value: string; inline: boolean }> = [ + { + name: "Command", + value: `\`\`\`\n${commandPreview}\n\`\`\``, + inline: false, + }, + ]; + + if (request.request.cwd) { + fields.push({ + name: "Working Directory", + value: request.request.cwd, + inline: true, + }); + } + + if (request.request.host) { + fields.push({ + name: "Host", + value: request.request.host, + inline: true, + }); + } + + if (request.request.agentId) { + fields.push({ + name: "Agent", + value: request.request.agentId, + inline: true, + }); + } + + return { + title: "Exec Approval Required", + description: "A command needs your approval.", + color: 0xffa500, // Orange + fields, + footer: { text: `Expires in ${expiresIn}s | ID: ${request.id}` }, + timestamp: new Date().toISOString(), + }; +} + +function formatResolvedEmbed( + request: ExecApprovalRequest, + decision: ExecApprovalDecision, + resolvedBy?: string | null, +) { + const commandText = request.request.command; + const commandPreview = commandText.length > 500 ? `${commandText.slice(0, 500)}...` : commandText; + + const decisionLabel = + decision === "allow-once" + ? "Allowed (once)" + : decision === "allow-always" + ? "Allowed (always)" + : "Denied"; + + const color = decision === "deny" ? 0xed4245 : decision === "allow-always" ? 0x5865f2 : 0x57f287; + + return { + title: `Exec Approval: ${decisionLabel}`, + description: resolvedBy ? `Resolved by ${resolvedBy}` : "Resolved", + color, + fields: [ + { + name: "Command", + value: `\`\`\`\n${commandPreview}\n\`\`\``, + inline: false, + }, + ], + footer: { text: `ID: ${request.id}` }, + timestamp: new Date().toISOString(), + }; +} + +function formatExpiredEmbed(request: ExecApprovalRequest) { + const commandText = request.request.command; + const commandPreview = commandText.length > 500 ? `${commandText.slice(0, 500)}...` : commandText; + + return { + title: "Exec Approval: Expired", + description: "This approval request has expired.", + color: 0x99aab5, // Gray + fields: [ + { + name: "Command", + value: `\`\`\`\n${commandPreview}\n\`\`\``, + inline: false, + }, + ], + footer: { text: `ID: ${request.id}` }, + timestamp: new Date().toISOString(), + }; +} + +export type DiscordExecApprovalHandlerOpts = { + token: string; + accountId: string; + config: DiscordExecApprovalConfig; + gatewayUrl?: string; + cfg: ClawdbotConfig; + runtime?: RuntimeEnv; + onResolve?: (id: string, decision: ExecApprovalDecision) => Promise; +}; + +export class DiscordExecApprovalHandler { + private gatewayClient: GatewayClient | null = null; + private pending = new Map(); + private requestCache = new Map(); + private opts: DiscordExecApprovalHandlerOpts; + private started = false; + + constructor(opts: DiscordExecApprovalHandlerOpts) { + this.opts = opts; + } + + shouldHandle(request: ExecApprovalRequest): boolean { + const config = this.opts.config; + if (!config.enabled) return false; + if (!config.approvers || config.approvers.length === 0) return false; + + // Check agent filter + if (config.agentFilter?.length) { + if (!request.request.agentId) return false; + if (!config.agentFilter.includes(request.request.agentId)) return false; + } + + // Check session filter (substring match) + if (config.sessionFilter?.length) { + const session = request.request.sessionKey; + if (!session) return false; + const matches = config.sessionFilter.some((p) => { + try { + return session.includes(p) || new RegExp(p).test(session); + } catch { + return session.includes(p); + } + }); + if (!matches) return false; + } + + return true; + } + + async start(): Promise { + if (this.started) return; + this.started = true; + + const config = this.opts.config; + if (!config.enabled) { + logDebug("discord exec approvals: disabled"); + return; + } + + if (!config.approvers || config.approvers.length === 0) { + logDebug("discord exec approvals: no approvers configured"); + return; + } + + logDebug("discord exec approvals: starting handler"); + + this.gatewayClient = new GatewayClient({ + url: this.opts.gatewayUrl ?? "ws://127.0.0.1:18789", + clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + clientDisplayName: "Discord Exec Approvals", + mode: GATEWAY_CLIENT_MODES.BACKEND, + scopes: ["operator.approvals"], + onEvent: (evt) => this.handleGatewayEvent(evt), + onHelloOk: () => { + logDebug("discord exec approvals: connected to gateway"); + }, + onConnectError: (err) => { + logError(`discord exec approvals: connect error: ${err.message}`); + }, + onClose: (code, reason) => { + logDebug(`discord exec approvals: gateway closed: ${code} ${reason}`); + }, + }); + + this.gatewayClient.start(); + } + + async stop(): Promise { + if (!this.started) return; + this.started = false; + + // Clear all pending timeouts + for (const pending of this.pending.values()) { + clearTimeout(pending.timeoutId); + } + this.pending.clear(); + this.requestCache.clear(); + + this.gatewayClient?.stop(); + this.gatewayClient = null; + + logDebug("discord exec approvals: stopped"); + } + + private handleGatewayEvent(evt: EventFrame): void { + if (evt.event === "exec.approval.requested") { + const request = evt.payload as ExecApprovalRequest; + void this.handleApprovalRequested(request); + } else if (evt.event === "exec.approval.resolved") { + const resolved = evt.payload as ExecApprovalResolved; + void this.handleApprovalResolved(resolved); + } + } + + private async handleApprovalRequested(request: ExecApprovalRequest): Promise { + if (!this.shouldHandle(request)) return; + + logDebug(`discord exec approvals: received request ${request.id}`); + + this.requestCache.set(request.id, request); + + const { rest, request: discordRequest } = createDiscordClient( + { token: this.opts.token, accountId: this.opts.accountId }, + this.opts.cfg, + ); + + const embed = formatExecApprovalEmbed(request); + + // Build action rows with buttons + const components = [ + { + type: 1, // ACTION_ROW + components: [ + { + type: 2, // BUTTON + style: ButtonStyle.Success, + label: "Allow once", + custom_id: buildExecApprovalCustomId(request.id, "allow-once"), + }, + { + type: 2, // BUTTON + style: ButtonStyle.Primary, + label: "Always allow", + custom_id: buildExecApprovalCustomId(request.id, "allow-always"), + }, + { + type: 2, // BUTTON + style: ButtonStyle.Danger, + label: "Deny", + custom_id: buildExecApprovalCustomId(request.id, "deny"), + }, + ], + }, + ]; + + const approvers = this.opts.config.approvers ?? []; + + for (const approver of approvers) { + const userId = String(approver); + try { + // Create DM channel + const dmChannel = (await discordRequest( + () => + rest.post(Routes.userChannels(), { + body: { recipient_id: userId }, + }) as Promise<{ id: string }>, + "dm-channel", + )) as { id: string }; + + if (!dmChannel?.id) { + logError(`discord exec approvals: failed to create DM for user ${userId}`); + continue; + } + + // Send message with embed and buttons + const message = (await discordRequest( + () => + rest.post(Routes.channelMessages(dmChannel.id), { + body: { + embeds: [embed], + components, + }, + }) as Promise<{ id: string; channel_id: string }>, + "send-approval", + )) as { id: string; channel_id: string }; + + if (!message?.id) { + logError(`discord exec approvals: failed to send message to user ${userId}`); + continue; + } + + // Set up timeout + const timeoutMs = Math.max(0, request.expiresAtMs - Date.now()); + const timeoutId = setTimeout(() => { + void this.handleApprovalTimeout(request.id); + }, timeoutMs); + + this.pending.set(request.id, { + discordMessageId: message.id, + discordChannelId: dmChannel.id, + timeoutId, + }); + + logDebug(`discord exec approvals: sent approval ${request.id} to user ${userId}`); + } catch (err) { + logError(`discord exec approvals: failed to notify user ${userId}: ${String(err)}`); + } + } + } + + private async handleApprovalResolved(resolved: ExecApprovalResolved): Promise { + const pending = this.pending.get(resolved.id); + if (!pending) return; + + clearTimeout(pending.timeoutId); + this.pending.delete(resolved.id); + + const request = this.requestCache.get(resolved.id); + this.requestCache.delete(resolved.id); + + if (!request) return; + + logDebug(`discord exec approvals: resolved ${resolved.id} with ${resolved.decision}`); + + await this.updateMessage( + pending.discordChannelId, + pending.discordMessageId, + formatResolvedEmbed(request, resolved.decision, resolved.resolvedBy), + ); + } + + private async handleApprovalTimeout(approvalId: string): Promise { + const pending = this.pending.get(approvalId); + if (!pending) return; + + this.pending.delete(approvalId); + + const request = this.requestCache.get(approvalId); + this.requestCache.delete(approvalId); + + if (!request) return; + + logDebug(`discord exec approvals: timeout for ${approvalId}`); + + await this.updateMessage( + pending.discordChannelId, + pending.discordMessageId, + formatExpiredEmbed(request), + ); + } + + private async updateMessage( + channelId: string, + messageId: string, + embed: ReturnType, + ): Promise { + try { + const { rest, request: discordRequest } = createDiscordClient( + { token: this.opts.token, accountId: this.opts.accountId }, + this.opts.cfg, + ); + + await discordRequest( + () => + rest.patch(Routes.channelMessage(channelId, messageId), { + body: { + embeds: [embed], + components: [], // Remove buttons + }, + }), + "update-approval", + ); + } catch (err) { + logError(`discord exec approvals: failed to update message: ${String(err)}`); + } + } + + async resolveApproval(approvalId: string, decision: ExecApprovalDecision): Promise { + if (!this.gatewayClient) { + logError("discord exec approvals: gateway client not connected"); + return false; + } + + logDebug(`discord exec approvals: resolving ${approvalId} with ${decision}`); + + try { + await this.gatewayClient.request("exec.approval.resolve", { + id: approvalId, + decision, + }); + logDebug(`discord exec approvals: resolved ${approvalId} successfully`); + return true; + } catch (err) { + logError(`discord exec approvals: resolve failed: ${String(err)}`); + return false; + } + } +} + +export type ExecApprovalButtonContext = { + handler: DiscordExecApprovalHandler; +}; + +export class ExecApprovalButton extends Button { + label = "execapproval"; + customId = `${EXEC_APPROVAL_KEY}:seed=1`; + style = ButtonStyle.Primary; + private ctx: ExecApprovalButtonContext; + + constructor(ctx: ExecApprovalButtonContext) { + super(); + this.ctx = ctx; + } + + async run(interaction: ButtonInteraction, data: ComponentData): Promise { + const parsed = parseExecApprovalData(data); + if (!parsed) { + try { + await interaction.update({ + content: "This approval is no longer valid.", + components: [], + }); + } catch { + // Interaction may have expired + } + return; + } + + const decisionLabel = + parsed.action === "allow-once" + ? "Allowed (once)" + : parsed.action === "allow-always" + ? "Allowed (always)" + : "Denied"; + + // Update the message immediately to show the decision + try { + await interaction.update({ + content: `Submitting decision: **${decisionLabel}**...`, + components: [], // Remove buttons + }); + } catch { + // Interaction may have expired, try to continue anyway + } + + const ok = await this.ctx.handler.resolveApproval(parsed.approvalId, parsed.action); + + if (!ok) { + try { + await interaction.followUp({ + content: + "Failed to submit approval decision. The request may have expired or already been resolved.", + ephemeral: true, + }); + } catch { + // Interaction may have expired + } + } + // On success, the handleApprovalResolved event will update the message with the final result + } +} + +export function createExecApprovalButton(ctx: ExecApprovalButtonContext): Button { + return new ExecApprovalButton(ctx); +} diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index 2ee33aaea..0599d104e 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -37,6 +37,7 @@ import { createDiscordCommandArgFallbackButton, createDiscordNativeCommand, } from "./native-command.js"; +import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js"; export type MonitorDiscordOpts = { token?: string; @@ -406,6 +407,31 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { }), ); + // Initialize exec approvals handler if enabled + const execApprovalsConfig = discordCfg.execApprovals ?? {}; + const execApprovalsHandler = execApprovalsConfig.enabled + ? new DiscordExecApprovalHandler({ + token, + accountId: account.accountId, + config: execApprovalsConfig, + cfg, + runtime, + }) + : null; + + const components = [ + createDiscordCommandArgFallbackButton({ + cfg, + discordConfig: discordCfg, + accountId: account.accountId, + sessionPrefix, + }), + ]; + + if (execApprovalsHandler) { + components.push(createExecApprovalButton({ handler: execApprovalsHandler })); + } + const client = new Client( { baseUrl: "http://localhost", @@ -418,14 +444,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { { commands, listeners: [], - components: [ - createDiscordCommandArgFallbackButton({ - cfg, - discordConfig: discordCfg, - accountId: account.accountId, - sessionPrefix, - }), - ], + components, }, [ new GatewayPlugin({ @@ -510,6 +529,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`); + // Start exec approvals handler after client is ready + if (execApprovalsHandler) { + await execApprovalsHandler.start(); + } + const gateway = client.getPlugin("gateway"); const gatewayEmitter = getDiscordGatewayEmitter(gateway); const stopGatewayLogging = attachDiscordGatewayLogging({ @@ -575,6 +599,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { if (helloTimeoutId) clearTimeout(helloTimeoutId); gatewayEmitter?.removeListener("debug", onGatewayDebug); abortSignal?.removeEventListener("abort", onAbort); + if (execApprovalsHandler) { + await execApprovalsHandler.stop(); + } } } diff --git a/src/gateway/server-methods/exec-approval.ts b/src/gateway/server-methods/exec-approval.ts index 48663b96c..572afc58f 100644 --- a/src/gateway/server-methods/exec-approval.ts +++ b/src/gateway/server-methods/exec-approval.ts @@ -1,4 +1,5 @@ import type { ExecApprovalDecision } from "../../infra/exec-approvals.js"; +import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js"; import type { ExecApprovalManager } from "../exec-approval-manager.js"; import { ErrorCodes, @@ -9,7 +10,10 @@ import { } from "../protocol/index.js"; import type { GatewayRequestHandlers } from "./types.js"; -export function createExecApprovalHandlers(manager: ExecApprovalManager): GatewayRequestHandlers { +export function createExecApprovalHandlers( + manager: ExecApprovalManager, + opts?: { forwarder?: ExecApprovalForwarder }, +): GatewayRequestHandlers { return { "exec.approval.request": async ({ params, respond, context }) => { if (!validateExecApprovalRequestParams(params)) { @@ -69,6 +73,16 @@ export function createExecApprovalHandlers(manager: ExecApprovalManager): Gatewa }, { dropIfSlow: true }, ); + void opts?.forwarder + ?.handleRequested({ + id: record.id, + request: record.request, + createdAtMs: record.createdAtMs, + expiresAtMs: record.expiresAtMs, + }) + .catch((err) => { + context.logGateway?.error?.(`exec approvals: forward request failed: ${String(err)}`); + }); const decision = await decisionPromise; respond( true, @@ -112,6 +126,11 @@ export function createExecApprovalHandlers(manager: ExecApprovalManager): Gatewa { id: p.id, decision, resolvedBy, ts: Date.now() }, { dropIfSlow: true }, ); + void opts?.forwarder + ?.handleResolved({ id: p.id, decision, resolvedBy, ts: Date.now() }) + .catch((err) => { + context.logGateway?.error?.(`exec approvals: forward resolve failed: ${String(err)}`); + }); respond(true, { ok: true }, undefined); }, }; diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index a3759d183..f9ad41cbc 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -43,6 +43,7 @@ import { import { startGatewayDiscovery } from "./server-discovery-runtime.js"; import { ExecApprovalManager } from "./exec-approval-manager.js"; import { createExecApprovalHandlers } from "./server-methods/exec-approval.js"; +import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js"; import type { startBrowserControlServerIfEnabled } from "./server-browser.js"; import { createChannelManager } from "./server-channels.js"; import { createAgentEventHandler } from "./server-chat.js"; @@ -396,7 +397,10 @@ export async function startGatewayServer( void cron.start().catch((err) => logCron.error(`failed to start: ${String(err)}`)); const execApprovalManager = new ExecApprovalManager(); - const execApprovalHandlers = createExecApprovalHandlers(execApprovalManager); + const execApprovalForwarder = createExecApprovalForwarder(); + const execApprovalHandlers = createExecApprovalHandlers(execApprovalManager, { + forwarder: execApprovalForwarder, + }); const canvasHostServerPort = (canvasHostServer as CanvasHostServer | null)?.port; diff --git a/src/infra/exec-approval-forwarder.test.ts b/src/infra/exec-approval-forwarder.test.ts new file mode 100644 index 000000000..422e22c48 --- /dev/null +++ b/src/infra/exec-approval-forwarder.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { createExecApprovalForwarder } from "./exec-approval-forwarder.js"; + +const baseRequest = { + id: "req-1", + request: { + command: "echo hello", + agentId: "main", + sessionKey: "agent:main:main", + }, + createdAtMs: 1000, + expiresAtMs: 6000, +}; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("exec approval forwarder", () => { + it("forwards to session target and resolves", async () => { + vi.useFakeTimers(); + const deliver = vi.fn().mockResolvedValue([]); + const cfg = { + approvals: { exec: { enabled: true, mode: "session" } }, + } as ClawdbotConfig; + + const forwarder = createExecApprovalForwarder({ + getConfig: () => cfg, + deliver, + nowMs: () => 1000, + resolveSessionTarget: () => ({ channel: "slack", to: "U1" }), + }); + + await forwarder.handleRequested(baseRequest); + expect(deliver).toHaveBeenCalledTimes(1); + + await forwarder.handleResolved({ + id: baseRequest.id, + decision: "allow-once", + resolvedBy: "slack:U1", + ts: 2000, + }); + expect(deliver).toHaveBeenCalledTimes(2); + + await vi.runAllTimersAsync(); + expect(deliver).toHaveBeenCalledTimes(2); + }); + + it("forwards to explicit targets and expires", async () => { + vi.useFakeTimers(); + const deliver = vi.fn().mockResolvedValue([]); + const cfg = { + approvals: { + exec: { + enabled: true, + mode: "targets", + targets: [{ channel: "telegram", to: "123" }], + }, + }, + } as ClawdbotConfig; + + const forwarder = createExecApprovalForwarder({ + getConfig: () => cfg, + deliver, + nowMs: () => 1000, + resolveSessionTarget: () => null, + }); + + await forwarder.handleRequested(baseRequest); + expect(deliver).toHaveBeenCalledTimes(1); + + await vi.runAllTimersAsync(); + expect(deliver).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts new file mode 100644 index 000000000..2fbf53ae6 --- /dev/null +++ b/src/infra/exec-approval-forwarder.ts @@ -0,0 +1,282 @@ +import type { ClawdbotConfig } 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 { parseAgentSessionKey } from "../routing/session-key.js"; +import { isDeliverableMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js"; +import type { ExecApprovalDecision } 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 = { + 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?: () => ClawdbotConfig; + deliver?: typeof deliverOutboundPayloads; + nowMs?: () => number; + resolveSessionTarget?: (params: { + cfg: ClawdbotConfig; + 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: ClawdbotConfig; + 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: ClawdbotConfig; + 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); +} diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index d02bcc581..71c41394a 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -35,6 +35,7 @@ import { } from "../config/sessions.js"; import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js"; import { formatErrorMessage } from "../infra/errors.js"; +import { peekSystemEvents } from "../infra/system-events.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { getQueueSize } from "../process/command-queue.js"; import { CommandLane } from "../process/lanes.js"; @@ -88,6 +89,14 @@ export type HeartbeatSummary = { const DEFAULT_HEARTBEAT_TARGET = "last"; const ACTIVE_HOURS_TIME_PATTERN = /^([01]\d|2[0-3]|24):([0-5]\d)$/; +// Prompt used when an async exec has completed and the result should be relayed to the user. +// This overrides the standard heartbeat prompt to ensure the model responds with the exec result +// instead of just "HEARTBEAT_OK". +const EXEC_EVENT_PROMPT = + "An async command you ran earlier has completed. The result is shown in the system messages above. " + + "Please relay the command output to the user in a helpful way. If the command succeeded, share the relevant output. " + + "If it failed, explain what went wrong."; + function resolveActiveHoursTimezone(cfg: ClawdbotConfig, raw?: string): string { const trimmed = raw?.trim(); if (!trimmed || trimmed === "user") { @@ -453,11 +462,13 @@ export async function runHeartbeatOnce(opts: { // Skip heartbeat if HEARTBEAT.md exists but has no actionable content. // This saves API calls/costs when the file is effectively empty (only comments/headers). + // EXCEPTION: Don't skip for exec events - they have pending system events to process. + const isExecEventReason = opts.reason === "exec-event"; const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME); try { const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8"); - if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent)) { + if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) && !isExecEventReason) { emitHeartbeatEvent({ status: "skipped", reason: "empty-heartbeat-file", @@ -483,12 +494,20 @@ export async function runHeartbeatOnce(opts: { : { showOk: false, showAlerts: true, useIndicator: true }; const { sender } = resolveHeartbeatSenderContext({ cfg, entry, delivery }); const responsePrefix = resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix; - const prompt = resolveHeartbeatPrompt(cfg, heartbeat); + + // Check if this is an exec event with pending exec completion system events. + // If so, use a specialized prompt that instructs the model to relay the result + // instead of the standard heartbeat prompt with "reply HEARTBEAT_OK". + const isExecEvent = opts.reason === "exec-event"; + const pendingEvents = isExecEvent ? peekSystemEvents(sessionKey) : []; + const hasExecCompletion = pendingEvents.some((evt) => evt.includes("Exec finished")); + + const prompt = hasExecCompletion ? EXEC_EVENT_PROMPT : resolveHeartbeatPrompt(cfg, heartbeat); const ctx = { Body: prompt, From: sender, To: sender, - Provider: "heartbeat", + Provider: hasExecCompletion ? "exec-event" : "heartbeat", SessionKey: sessionKey, }; if (!visibility.showAlerts && !visibility.showOk && !visibility.useIndicator) { @@ -558,7 +577,19 @@ export async function runHeartbeatOnce(opts: { const ackMaxChars = resolveHeartbeatAckMaxChars(cfg, heartbeat); const normalized = normalizeHeartbeatReply(replyPayload, responsePrefix, ackMaxChars); - const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia; + // For exec completion events, don't skip even if the response looks like HEARTBEAT_OK. + // The model should be responding with exec results, not ack tokens. + // Also, if normalized.text is empty due to token stripping but we have exec completion, + // fall back to the original reply text. + const execFallbackText = + hasExecCompletion && !normalized.text.trim() && replyPayload.text?.trim() + ? replyPayload.text.trim() + : null; + if (execFallbackText) { + normalized.text = execFallbackText; + normalized.shouldSkip = false; + } + const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia && !hasExecCompletion; if (shouldSkipMain && reasoningPayloads.length === 0) { await restoreHeartbeatUpdatedAt({ storePath,