From 9f3d294039d4ac8388ed4aa011ac4ceed6156a5a Mon Sep 17 00:00:00 2001 From: luyifan Date: Sat, 19 Sep 2026 14:27:13 +0800 Subject: [PATCH] fix(git-read): sanitize call-label display text --- extensions/git-read/index.ts | 14 ++- .../git-read/call-rendering.test.ts | 95 +++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 tests/extensions/git-read/call-rendering.test.ts diff --git a/extensions/git-read/index.ts b/extensions/git-read/index.ts index fe02239e..66473e3c 100644 --- a/extensions/git-read/index.ts +++ b/extensions/git-read/index.ts @@ -155,6 +155,14 @@ function displayRevision(value: string | undefined, fallback = "HEAD") { return value === undefined || value.trim() === "" ? fallback : value.trim(); } +function renderCallLine(text: string) { + return new Text( + sanitizeTerminalText(text).replace(/\s+/gu, " ").trim(), + 0, + 0, + ); +} + export default function gitReadTools(pi: ExtensionAPI) { const resultDirectories = new Set(); const rememberOutput = (outcome: GitOutcome) => { @@ -202,7 +210,7 @@ export default function gitReadTools(pi: ExtensionAPI) { }, renderCall(args) { - return new Text(`git show ${displayRevision(args.revision)}`, 0, 0); + return renderCallLine(`git show ${displayRevision(args.revision)}`); }, renderResult(result, { expanded }, theme) { @@ -247,7 +255,7 @@ export default function gitReadTools(pi: ExtensionAPI) { let text = `git diff ${from} → ${to}`; if (args.stat) text += " (stat)"; if (args.path) text += ` ${args.path}`; - return new Text(text, 0, 0); + return renderCallLine(text); }, renderResult(result, { expanded }, theme) { @@ -287,7 +295,7 @@ export default function gitReadTools(pi: ExtensionAPI) { let text = `git log ${displayRevision(args.revision)}`; if (args.file) text += ` -- ${args.file}`; if (args.limit !== undefined) text += ` -n ${args.limit}`; - return new Text(text, 0, 0); + return renderCallLine(text); }, renderResult(result, { expanded }, theme) { diff --git a/tests/extensions/git-read/call-rendering.test.ts b/tests/extensions/git-read/call-rendering.test.ts new file mode 100644 index 00000000..db37d2aa --- /dev/null +++ b/tests/extensions/git-read/call-rendering.test.ts @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { stripVTControlCharacters } from "node:util"; +import { + initTheme, + ToolExecutionComponent, + type ExtensionAPI, + type ExtensionContext, + type ToolDefinition, +} from "@earendil-works/pi-coding-agent"; +import type { TUI } from "@earendil-works/pi-tui"; +import gitRead from "../../../extensions/git-read/index.ts"; + +initTheme("dark", false); + +const tools: ToolDefinition[] = []; +gitRead({ + on() {}, + registerTool(tool: ToolDefinition) { + tools.push(tool); + }, +} as unknown as ExtensionAPI); + +const titleControl = "\u001b]0;openpi-test-title\u0007"; +const unsafe = `${titleControl}\u001b[2J\u202espoof\u202c\nnext`; + +function render(tool: ToolDefinition, args: Record) { + const component = new ToolExecutionComponent( + tool.name, + `call-${tool.name}`, + args, + { showImages: false }, + tool, + { requestRender() {} } as TUI, + process.cwd(), + ); + return component.render(160).join("\n"); +} + +for (const [name, fields] of [ + ["git_show", ["revision"]], + ["git_diff", ["from", "to", "path"]], + ["git_log", ["revision", "file"]], +] as const) { + const tool = tools.find((candidate) => candidate.name === name)!; + for (const field of fields) { + test(`${name} ${field} is terminal-safe before argument validation`, async () => { + const args = { [field]: `HEAD${unsafe}` }; + const before = structuredClone(args); + const rendered = render(tool, args); + assert.equal(rendered.includes(titleControl), false); + assert.equal(rendered.includes("\u001b[2J"), false); + assert.equal(rendered.includes("\u202e"), false); + assert.equal(rendered.includes("\u202c"), false); + const visible = stripVTControlCharacters(rendered); + assert.match(visible, /HEADspoof next/); + assert.doesNotMatch(visible, /openpi-test-title/); + assert.deepEqual(args, before); + await assert.rejects( + tool.execute("invalid-call", args, undefined, undefined, { + cwd: process.cwd(), + } as ExtensionContext), + /Invalid git revision|Invalid repository path/, + ); + assert.deepEqual( + args, + before, + "display must not clean executable arguments", + ); + }); + } +} + +test("normal git call labels keep revisions, options, and Unicode paths", () => { + const fixtures: [string, Record, string][] = [ + ["git_show", { revision: " HEAD~1 " }, "git show HEAD~1"], + ["git_show", {}, "git show HEAD"], + ["git_diff", {}, "git diff index → worktree"], + ["git_diff", { staged: true }, "git diff HEAD (staged) → worktree"], + [ + "git_diff", + { from: "main", to: "feature", stat: true, path: "文档/👩‍💻.md" }, + "git diff main → feature (stat) 文档/👩‍💻.md", + ], + [ + "git_log", + { revision: "main", file: "文档/👩‍💻.md", limit: 5 }, + "git log main -- 文档/👩‍💻.md -n 5", + ], + ]; + for (const [name, args, expected] of fixtures) { + const tool = tools.find((candidate) => candidate.name === name)!; + assert.equal(stripVTControlCharacters(render(tool, args)).trim(), expected); + } +});