diff --git a/CHANGELOG.md b/CHANGELOG.md index c8204b9cea4..d860720dc61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Exec approvals: format forwarded command text as inline/fenced monospace for safer approval scanning across channels. (#11917) - Thinking: allow xhigh for `github-copilot/gpt-5.2-codex` and `github-copilot/gpt-5.2`. (#11646) Thanks @seans-openclawbot. - Discord: support forum/media `thread create` starter messages, wire `message thread create --message`, and harden thread-create routing. (#10062) Thanks @jarvis89757. - Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh. diff --git a/src/infra/exec-approval-forwarder.test.ts b/src/infra/exec-approval-forwarder.test.ts index 60f8ad1485d..fa0c6c536fa 100644 --- a/src/infra/exec-approval-forwarder.test.ts +++ b/src/infra/exec-approval-forwarder.test.ts @@ -17,6 +17,13 @@ afterEach(() => { vi.useRealTimers(); }); +function getFirstDeliveryText(deliver: ReturnType): string { + const firstCall = deliver.mock.calls[0]?.[0] as + | { payloads?: Array<{ text?: string }> } + | undefined; + return firstCall?.payloads?.[0]?.text ?? ""; +} + describe("exec approval forwarder", () => { it("forwards to session target and resolves", async () => { vi.useFakeTimers(); @@ -73,4 +80,91 @@ describe("exec approval forwarder", () => { await vi.runAllTimersAsync(); expect(deliver).toHaveBeenCalledTimes(2); }); + + it("formats single-line commands as inline code", async () => { + vi.useFakeTimers(); + const deliver = vi.fn().mockResolvedValue([]); + const cfg = { + approvals: { + exec: { + enabled: true, + mode: "targets", + targets: [{ channel: "telegram", to: "123" }], + }, + }, + } as OpenClawConfig; + + const forwarder = createExecApprovalForwarder({ + getConfig: () => cfg, + deliver, + nowMs: () => 1000, + resolveSessionTarget: () => null, + }); + + await forwarder.handleRequested(baseRequest); + + expect(getFirstDeliveryText(deliver)).toContain("Command: `echo hello`"); + }); + + it("formats complex commands as fenced code blocks", async () => { + vi.useFakeTimers(); + const deliver = vi.fn().mockResolvedValue([]); + const cfg = { + approvals: { + exec: { + enabled: true, + mode: "targets", + targets: [{ channel: "telegram", to: "123" }], + }, + }, + } as OpenClawConfig; + + const forwarder = createExecApprovalForwarder({ + getConfig: () => cfg, + deliver, + nowMs: () => 1000, + resolveSessionTarget: () => null, + }); + + await forwarder.handleRequested({ + ...baseRequest, + request: { + ...baseRequest.request, + command: "echo `uname`\necho done", + }, + }); + + expect(getFirstDeliveryText(deliver)).toContain("Command:\n```\necho `uname`\necho done\n```"); + }); + + it("uses a longer fence when command already contains triple backticks", async () => { + vi.useFakeTimers(); + const deliver = vi.fn().mockResolvedValue([]); + const cfg = { + approvals: { + exec: { + enabled: true, + mode: "targets", + targets: [{ channel: "telegram", to: "123" }], + }, + }, + } as OpenClawConfig; + + const forwarder = createExecApprovalForwarder({ + getConfig: () => cfg, + deliver, + nowMs: () => 1000, + resolveSessionTarget: () => null, + }); + + await forwarder.handleRequested({ + ...baseRequest, + request: { + ...baseRequest.request, + command: "echo ```danger```", + }, + }); + + expect(getFirstDeliveryText(deliver)).toContain("Command:\n````\necho ```danger```\n````"); + }); }); diff --git a/src/infra/exec-approval-forwarder.ts b/src/infra/exec-approval-forwarder.ts index 8ce0748cc56..0dd657b25c0 100644 --- a/src/infra/exec-approval-forwarder.ts +++ b/src/infra/exec-approval-forwarder.ts @@ -115,9 +115,27 @@ function buildTargetKey(target: ExecApprovalForwardTarget): string { return [channel, target.to, accountId, threadId].join(":"); } +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}`]; - lines.push(`Command: ${request.request.command}`); + const command = formatApprovalCommand(request.request.command); + 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}`); }