Skip to content

Commit aaa3269

Browse files
fix(approvals): summarize long approval prompts
1 parent f783081 commit aaa3269

14 files changed

Lines changed: 343 additions & 26 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
1111

1212
### Fixes
1313

14+
- Control UI/approvals: summarize long exec approval command prompts, keep invalid Allow Always choices out of the modal, and preserve the full sanitized command for approval matching. Fixes #58687. Thanks @ylx1314 and @icestorms.
1415
- ACP sessions: map canonical runtime options to backend-advertised ACP config keys like Claude's `effort` while keeping persisted OpenClaw state canonical. (#79926) Thanks @InTheCloudDan.
1516
- Gateway/watch: rebuild or restage missing bundled-plugin dist and runtime-postbuild outputs before launching the Gateway from a source checkout, preventing incomplete watch-mode runtime trees. (#70805) Thanks @rubencu.
1617
- CLI/update: allow restart health probes from the previous gateway protocol during self-update, and make plugin dry-runs report exact npm target versions instead of `unknown` while preserving unchanged status.

docs/tools/exec-approvals-advanced.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,7 @@ Shared behavior:
318318
straight to plugin approvals, everything else goes to exec approvals
319319
- native Telegram approval buttons follow the same bounded exec-to-plugin fallback as `/approve`
320320
- when native `target` enables origin-chat delivery, approval prompts include the command text
321+
- long command prompts are summarized for reviewability; the full sanitized command remains bound to the pending approval record for matching and resolution
321322
- pending exec approvals expire after 30 minutes by default
322323
- if no operator UI or configured approval client can accept the request, the prompt falls back to `askFallback`
323324

src/infra/approval-view-model.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,26 @@ describe("buildPendingApprovalView", () => {
2929
}
3030
expect(view.commandAnalysis?.warningLines).toEqual(["Contains inline-eval: python -c"]);
3131
});
32+
33+
it("summarizes long exec commands for native approval views", () => {
34+
const request: ExecApprovalRequest = {
35+
id: "approval-id",
36+
createdAtMs: 1,
37+
expiresAtMs: 2,
38+
request: {
39+
command: Array.from({ length: 8 }, (_, index) => `echo line ${index + 1}\\u{A}`).join(""),
40+
host: "gateway",
41+
},
42+
};
43+
44+
const view = buildPendingApprovalView(request);
45+
46+
expect(view.approvalKind).toBe("exec");
47+
if (view.approvalKind !== "exec") {
48+
throw new Error("expected exec approval view");
49+
}
50+
expect(view.commandText).toContain("echo line 1\\u{A}");
51+
expect(view.commandText).toContain("...[truncated: showing first 5 of 9 lines");
52+
expect(view.commandText).not.toContain("echo line 8");
53+
});
3254
});

src/infra/approval-view-model.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
ResolvedApprovalView,
1010
} from "./approval-view-model.types.js";
1111
import { resolveExecApprovalCommandDisplay } from "./exec-approval-command-display.js";
12+
import { summarizeExecApprovalCommandForPrompt } from "./exec-approval-command-summary.js";
1213
import { buildExecApprovalActionDescriptors } from "./exec-approval-reply.js";
1314
import {
1415
resolveExecApprovalRequestAllowedDecisions,
@@ -62,6 +63,7 @@ function buildExecViewBase<TPhase extends ApprovalPhase>(
6263
phase: TPhase,
6364
): ExecApprovalViewBase & { phase: TPhase } {
6465
const { commandText, commandPreview } = resolveExecApprovalCommandDisplay(request.request);
66+
const commandSummary = summarizeExecApprovalCommandForPrompt(commandText);
6567
return {
6668
approvalId: request.id,
6769
approvalKind: "exec",
@@ -73,7 +75,7 @@ function buildExecViewBase<TPhase extends ApprovalPhase>(
7375
agentId: request.request.agentId ?? null,
7476
warningText: request.request.warningText ?? null,
7577
commandAnalysis: request.request.commandAnalysis ?? null,
76-
commandText,
78+
commandText: commandSummary.text,
7779
commandPreview,
7880
cwd: request.request.cwd ?? null,
7981
envKeys: request.request.envKeys ?? undefined,
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, it } from "vitest";
2+
import { summarizeExecApprovalCommandForPrompt } from "./exec-approval-command-summary.js";
3+
4+
describe("summarizeExecApprovalCommandForPrompt", () => {
5+
it("leaves short commands unchanged", () => {
6+
expect(summarizeExecApprovalCommandForPrompt("echo hello")).toEqual({
7+
text: "echo hello",
8+
truncated: false,
9+
totalLineCount: 1,
10+
shownLineCount: 1,
11+
hiddenLineCount: 0,
12+
totalCharCount: 10,
13+
hiddenCharCount: 0,
14+
});
15+
});
16+
17+
it("shows only the first logical lines and keeps escaped newline markers visible", () => {
18+
const command = [
19+
"line 1\\u{A}",
20+
"line 2\\u{A}",
21+
"line 3\\u{A}",
22+
"line 4\\u{A}",
23+
"line 5\\u{A}",
24+
"line 6\\u{A}",
25+
"line 7",
26+
].join("");
27+
28+
const summary = summarizeExecApprovalCommandForPrompt(command, {
29+
maxLines: 3,
30+
maxChars: 1_000,
31+
});
32+
33+
expect(summary.text).toBe(
34+
"line 1\\u{A}\nline 2\\u{A}\nline 3\\u{A}\n...[truncated: showing first 3 of 7 lines; 43 chars hidden]",
35+
);
36+
expect(summary.truncated).toBe(true);
37+
expect(summary.hiddenLineCount).toBe(4);
38+
expect(summary.text).not.toContain("line 6");
39+
});
40+
41+
it("bounds long single-line commands", () => {
42+
const command = `python -c "${"x".repeat(200)}"`;
43+
44+
const summary = summarizeExecApprovalCommandForPrompt(command, {
45+
maxLines: 5,
46+
maxChars: 40,
47+
});
48+
49+
expect(summary.text).toMatch(/^python -c "x+/u);
50+
expect(summary.text).toContain("...[truncated:");
51+
expect(summary.text).toContain("chars hidden");
52+
expect(summary.text).not.toContain("x".repeat(80));
53+
});
54+
});
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
export type ExecApprovalCommandPromptSummary = {
2+
text: string;
3+
truncated: boolean;
4+
totalLineCount: number;
5+
shownLineCount: number;
6+
hiddenLineCount: number;
7+
totalCharCount: number;
8+
hiddenCharCount: number;
9+
};
10+
11+
export const EXEC_APPROVAL_PROMPT_COMMAND_MAX_LINES = 5;
12+
export const EXEC_APPROVAL_PROMPT_COMMAND_MAX_CHARS = 1_200;
13+
14+
const LOGICAL_LINE_BREAK_PATTERN =
15+
/\\u\{D\}\\u\{A\}|\\u\{A\}|\\u\{D\}|\\u\{2028\}|\\u\{2029\}|\r\n?|\n/gu;
16+
17+
function positiveLimit(value: number | undefined, fallback: number): number {
18+
return Number.isSafeInteger(value) && typeof value === "number" && value > 0 ? value : fallback;
19+
}
20+
21+
function plural(count: number, singular: string): string {
22+
return count === 1 ? singular : `${singular}s`;
23+
}
24+
25+
function splitDisplayLines(text: string): string[] {
26+
const lines: string[] = [];
27+
let cursor = 0;
28+
for (const match of text.matchAll(LOGICAL_LINE_BREAK_PATTERN)) {
29+
const index = match.index;
30+
if (typeof index !== "number") {
31+
continue;
32+
}
33+
const marker = match[0];
34+
const markerText = marker.startsWith("\\u{") ? marker : "";
35+
lines.push(text.slice(cursor, index) + markerText);
36+
cursor = index + marker.length;
37+
}
38+
lines.push(text.slice(cursor));
39+
return lines;
40+
}
41+
42+
function buildTruncationMarker(params: {
43+
totalLineCount: number;
44+
shownLineCount: number;
45+
hiddenLineCount: number;
46+
hiddenCharCount: number;
47+
}): string {
48+
const details: string[] = [];
49+
if (params.hiddenLineCount > 0) {
50+
details.push(`showing first ${params.shownLineCount} of ${params.totalLineCount} lines`);
51+
}
52+
if (params.hiddenCharCount > 0) {
53+
details.push(`${params.hiddenCharCount} ${plural(params.hiddenCharCount, "char")} hidden`);
54+
}
55+
return `...[truncated: ${details.join("; ")}]`;
56+
}
57+
58+
export function summarizeExecApprovalCommandForPrompt(
59+
commandText: string,
60+
options?: { maxLines?: number; maxChars?: number },
61+
): ExecApprovalCommandPromptSummary {
62+
const maxLines = positiveLimit(options?.maxLines, EXEC_APPROVAL_PROMPT_COMMAND_MAX_LINES);
63+
const maxChars = positiveLimit(options?.maxChars, EXEC_APPROVAL_PROMPT_COMMAND_MAX_CHARS);
64+
const lines = splitDisplayLines(commandText);
65+
const shownLines = lines.slice(0, maxLines);
66+
const lineTruncated = lines.length > shownLines.length;
67+
if (!lineTruncated && commandText.length <= maxChars) {
68+
return {
69+
text: commandText,
70+
truncated: false,
71+
totalLineCount: lines.length,
72+
shownLineCount: lines.length,
73+
hiddenLineCount: 0,
74+
totalCharCount: commandText.length,
75+
hiddenCharCount: 0,
76+
};
77+
}
78+
let text = shownLines.join("\n");
79+
const totalText = lines.join("\n");
80+
if (text.length > maxChars) {
81+
text = text.slice(0, maxChars).trimEnd();
82+
}
83+
const hiddenLineCount = lineTruncated ? lines.length - shownLines.length : 0;
84+
const hiddenCharCount = Math.max(0, totalText.length - text.length);
85+
const truncated = hiddenLineCount > 0 || hiddenCharCount > 0;
86+
if (!truncated) {
87+
return {
88+
text,
89+
truncated: false,
90+
totalLineCount: lines.length,
91+
shownLineCount: lines.length,
92+
hiddenLineCount: 0,
93+
totalCharCount: totalText.length,
94+
hiddenCharCount: 0,
95+
};
96+
}
97+
const marker = buildTruncationMarker({
98+
totalLineCount: lines.length,
99+
shownLineCount: shownLines.length,
100+
hiddenLineCount,
101+
hiddenCharCount,
102+
});
103+
return {
104+
text: text ? `${text}\n${marker}` : marker,
105+
truncated: true,
106+
totalLineCount: lines.length,
107+
shownLineCount: shownLines.length,
108+
hiddenLineCount,
109+
totalCharCount: totalText.length,
110+
hiddenCharCount,
111+
};
112+
}

src/infra/exec-approval-forwarder.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,23 @@ describe("exec approval forwarder", () => {
600600
expect(text).toContain("- Contains inline-eval: python3 -c");
601601
});
602602

603+
it("summarizes long commands in fallback delivery text", () => {
604+
const text = buildExecApprovalRequestMessage(
605+
{
606+
...baseRequest,
607+
request: {
608+
...baseRequest.request,
609+
command: Array.from({ length: 8 }, (_, index) => `echo line ${index + 1}\\u{A}`).join(""),
610+
},
611+
},
612+
1000,
613+
);
614+
615+
expect(text).toContain("echo line 1\\u{A}");
616+
expect(text).toContain("...[truncated: showing first 5 of 9 lines");
617+
expect(text).not.toContain("echo line 8");
618+
});
619+
603620
it("omits allow-always from forwarded fallback text when ask=always", async () => {
604621
vi.useFakeTimers();
605622
const { deliver, forwarder } = createForwarder({ cfg: TARGETS_CFG });

src/infra/exec-approval-forwarder.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
resolveExecApprovalCommandDisplay,
2929
sanitizeExecApprovalWarningText,
3030
} from "./exec-approval-command-display.js";
31+
import { summarizeExecApprovalCommandForPrompt } from "./exec-approval-command-summary.js";
3132
import { formatExecApprovalExpiresIn } from "./exec-approval-reply.js";
3233
import {
3334
resolveExecApprovalRequestAllowedDecisions,
@@ -250,7 +251,9 @@ export function buildExecApprovalRequestMessage(request: ExecApprovalRequest, no
250251
}
251252
}
252253
const command = formatApprovalCommand(
253-
resolveExecApprovalCommandDisplay(request.request).commandText,
254+
summarizeExecApprovalCommandForPrompt(
255+
resolveExecApprovalCommandDisplay(request.request).commandText,
256+
).text,
254257
);
255258
if (command.inline) {
256259
lines.push(`Command: ${command.text}`);

src/infra/exec-approval-reply.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,19 @@ describe("exec approval reply helpers", () => {
302302
expect(payload.text).not.toContain("C:\\Users\\alice");
303303
});
304304

305+
it("summarizes long commands in pending reply payloads", () => {
306+
const payload = buildExecApprovalPendingReplyPayload({
307+
approvalId: "req-long",
308+
approvalSlug: "slug-long",
309+
command: Array.from({ length: 8 }, (_, index) => `echo line ${index + 1}\\u{A}`).join(""),
310+
host: "gateway",
311+
});
312+
313+
expect(payload.text).toContain("echo line 1\\u{A}");
314+
expect(payload.text).toContain("...[truncated: showing first 5 of 9 lines");
315+
expect(payload.text).not.toContain("echo line 8");
316+
});
317+
305318
it("omits allow-always actions when the effective policy requires approval every time", () => {
306319
const payload = buildExecApprovalPendingReplyPayload({
307320
approvalId: "req-ask-always",

src/infra/exec-approval-reply.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
normalizeOptionalString,
77
} from "../shared/string-coerce.js";
88
import { formatApprovalDisplayPath } from "./approval-display-paths.js";
9+
import { summarizeExecApprovalCommandForPrompt } from "./exec-approval-command-summary.js";
910
import {
1011
describeNativeExecApprovalClientSetup,
1112
listNativeExecApprovalClientLabels,
@@ -305,8 +306,9 @@ export function buildExecApprovalPendingReplyPayload(
305306
lines.push("Run:");
306307
lines.push(buildFence(primaryAction.command, "txt"));
307308
}
309+
const commandSummary = summarizeExecApprovalCommandForPrompt(params.command).text;
308310
lines.push("Pending command:");
309-
lines.push(buildFence(params.command, "sh"));
311+
lines.push(buildFence(commandSummary, "sh"));
310312
const secondaryFence = buildApprovalCommandFence(secondaryActions);
311313
if (secondaryFence) {
312314
lines.push("Other options:");

0 commit comments

Comments
 (0)