Skip to content

Commit 7e81f15

Browse files
authored
fix: keep heredoc exec bodies out of command summaries (#99379)
1 parent dd972b8 commit 7e81f15

3 files changed

Lines changed: 168 additions & 2 deletions

File tree

src/agents/tool-display-exec-shell.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ export function unwrapShellWrapper(command: string): string {
237237
return inner ? (stripOuterQuotes(inner) ?? command) : command;
238238
}
239239

240-
function scanTopLevelChars(
240+
export function scanTopLevelChars(
241241
command: string,
242242
visit: (char: string, index: number) => boolean | void,
243243
): void {

src/agents/tool-display-exec.ts

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
firstPositional,
1313
optionValue,
1414
positionalArgs,
15+
scanTopLevelChars,
1516
splitShellWords,
1617
splitTopLevelPipes,
1718
splitTopLevelStages,
@@ -299,6 +300,115 @@ function summarizePipeline(stage: string): string {
299300
return summarizeKnownExec(trimLeadingEnv(splitShellWords(stage)));
300301
}
301302

303+
type HeredocTerminator = {
304+
value: string;
305+
stripLeadingTabs: boolean;
306+
};
307+
308+
function collectHeredocTerminators(commandLine: string): HeredocTerminator[] {
309+
const terminators: HeredocTerminator[] = [];
310+
scanTopLevelChars(commandLine, (char, index) => {
311+
if (char !== "<" || commandLine[index + 1] !== "<" || commandLine[index + 2] === "<") {
312+
return true;
313+
}
314+
315+
const stripLeadingTabs = commandLine[index + 2] === "-";
316+
const parsed = parseHeredocTerminator(commandLine, index + (stripLeadingTabs ? 3 : 2));
317+
if (parsed) {
318+
terminators.push({ value: parsed, stripLeadingTabs });
319+
}
320+
return true;
321+
});
322+
return terminators;
323+
}
324+
325+
function parseHeredocTerminator(commandLine: string, rawStart: number): string | undefined {
326+
let start = rawStart;
327+
while (/\s/u.test(commandLine[start] ?? "")) {
328+
start += 1;
329+
}
330+
331+
let value = "";
332+
let quote: '"' | "'" | undefined;
333+
334+
for (let index = start; index < commandLine.length; index += 1) {
335+
const char = commandLine[index] ?? "";
336+
337+
if (quote) {
338+
if (char === quote) {
339+
quote = undefined;
340+
continue;
341+
}
342+
if (quote === '"' && char === "\\" && index + 1 < commandLine.length) {
343+
index += 1;
344+
value += commandLine[index] ?? "";
345+
continue;
346+
}
347+
value += char;
348+
continue;
349+
}
350+
351+
if (/[\s;&|<>]/u.test(char)) {
352+
break;
353+
}
354+
if (char === "'" || char === '"') {
355+
quote = char;
356+
continue;
357+
}
358+
if (char === "\\" && index + 1 < commandLine.length) {
359+
index += 1;
360+
value += commandLine[index] ?? "";
361+
continue;
362+
}
363+
value += char;
364+
}
365+
366+
return value || undefined;
367+
}
368+
369+
function commandWithoutHeredocBodies(command: string): string | undefined {
370+
if (!command.includes("\n")) {
371+
return undefined;
372+
}
373+
374+
const lines = command.split(/\r?\n/u);
375+
const summaryLines: string[] = [];
376+
let foundHeredoc = false;
377+
378+
for (let index = 0; index < lines.length; index += 1) {
379+
const line = lines[index] ?? "";
380+
summaryLines.push(line);
381+
382+
const terminators = collectHeredocTerminators(line);
383+
if (terminators.length === 0) {
384+
continue;
385+
}
386+
foundHeredoc = true;
387+
388+
for (const terminator of terminators) {
389+
index += 1;
390+
while (index < lines.length) {
391+
const candidate = terminator.stripLeadingTabs
392+
? (lines[index] ?? "").replace(/^\t+/u, "")
393+
: (lines[index] ?? "");
394+
if (candidate === terminator.value) {
395+
break;
396+
}
397+
index += 1;
398+
}
399+
}
400+
}
401+
402+
if (!foundHeredoc) {
403+
return undefined;
404+
}
405+
406+
return summaryLines
407+
.map((line) => line.trim())
408+
.filter(Boolean)
409+
.join("; ");
410+
}
411+
302412
type ExecSummary = {
303413
text: string;
304414
chdirPath?: string;
@@ -362,7 +472,8 @@ function summarizeExecCommand(command: string): ExecSummary | undefined {
362472
return chdirPath ? { text: "", chdirPath } : undefined;
363473
}
364474

365-
const stages = splitTopLevelStages(cleaned);
475+
const summaryCommand = commandWithoutHeredocBodies(cleaned) ?? cleaned;
476+
const stages = splitTopLevelStages(summaryCommand);
366477
if (stages.length === 0) {
367478
return undefined;
368479
}

src/agents/tool-display.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,61 @@ describe("tool display details", () => {
464464
expect(nodeShortCheckDetail).toContain("check js syntax for /tmp/test.js");
465465
});
466466

467+
it("does not split heredoc body content into exec stages", () => {
468+
const detail = formatToolDetail(
469+
resolveToolDisplay({
470+
name: "exec",
471+
args: {
472+
command: [
473+
"python3 <<'PY'",
474+
"const slugify = () => 'court-mix';",
475+
"if (true) console.log('a') && console.log('b');",
476+
"cat <<YAML",
477+
"- uses: subosito/flutter-action@v2",
478+
"YAML",
479+
"PY",
480+
].join("\n"),
481+
workdir: "/Users/example/.openclaw/workspace",
482+
},
483+
detailMode: "explain",
484+
}),
485+
);
486+
487+
expect(detail).toBe("run python3 inline script (heredoc) (agent)");
488+
});
489+
490+
it("keeps command stages after a heredoc terminator", () => {
491+
const detail = formatToolDetail(
492+
resolveToolDisplay({
493+
name: "exec",
494+
args: {
495+
command: ["python3 <<'PY'", "print('body && not a command')", "PY", "npm test"].join(
496+
"\n",
497+
),
498+
},
499+
detailMode: "explain",
500+
}),
501+
);
502+
503+
expect(detail).toBe("run python3 inline script (heredoc) → run tests");
504+
});
505+
506+
it("matches shell-quoted heredoc terminators before keeping later stages", () => {
507+
const detail = formatToolDetail(
508+
resolveToolDisplay({
509+
name: "exec",
510+
args: {
511+
command: ["python3 <<\\PY", "print('body && not a command')", "PY", "npm test"].join(
512+
"\n",
513+
),
514+
},
515+
detailMode: "explain",
516+
}),
517+
);
518+
519+
expect(detail).toBe("run python3 inline script (heredoc) → run tests");
520+
});
521+
467522
it("appends node name to exec detail when node is set", () => {
468523
const detail = formatToolDetail(
469524
resolveToolDisplay({

0 commit comments

Comments
 (0)