From d7821e3dc7edde6c9ce1d7f18a747fb65c217d59 Mon Sep 17 00:00:00 2001 From: golem Date: Sat, 1 Aug 2026 23:15:39 -0600 Subject: [PATCH] fix: stop $ sequences in selected text corrupting the prompt --- lib/build-url.js | 7 ++++++- test/build-url.test.js | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/lib/build-url.js b/lib/build-url.js index e27e356..10e3e08 100644 --- a/lib/build-url.js +++ b/lib/build-url.js @@ -21,7 +21,12 @@ function applyTemplate(template, query) { } const tpl = typeof template === "string" ? template : ""; if (tpl.includes("{query}")) { - return tpl.replaceAll("{query}", query); + // Replacer function, not a replacement string: String.prototype.replaceAll + // gives $&, $`, $', and $$ special meaning in a replacement STRING, and + // `query` is arbitrary page-selected text that can contain any of those + // (shell one-liners routinely do). The function form disables that + // interpretation entirely. Do not "simplify" this back to the string form. + return tpl.replaceAll("{query}", () => query); } const trimmed = tpl.trim(); return trimmed === "" ? query : `${trimmed} ${query}`; diff --git a/test/build-url.test.js b/test/build-url.test.js index aa3202f..1c8311f 100644 --- a/test/build-url.test.js +++ b/test/build-url.test.js @@ -133,3 +133,43 @@ test("a query needing no truncation is left byte-identical", () => { const raw = "a modest question about thermodynamics"; assert.equal(queryOf(buildChatGptUrl(DEFAULT_SETTINGS, raw)), raw); }); + +// String.prototype.replaceAll gives $&, $`, $', and $$ special meaning in the +// REPLACEMENT string, and `query` (arbitrary page-selected text) is exactly +// that argument. These selections are ordinary shell one-liners, not +// adversarial input, and must round-trip byte-identically into `q`. +test("a selection containing $& survives the default {query} template byte-identically", () => { + const raw = "awk print $& here"; + const url = buildChatGptUrl(DEFAULT_SETTINGS, raw); + assert.equal(queryOf(url), raw); +}); + +test("a selection containing $& survives a prefix template byte-identically", () => { + const raw = "awk print $& here"; + const url = buildChatGptUrl(withSettings({ promptTemplate: "Explain: {query}" }), raw); + assert.equal(queryOf(url), `Explain: ${raw}`); +}); + +test("a selection containing $` and $' survives the default {query} template byte-identically", () => { + const raw = "shell $` and $' here"; + const url = buildChatGptUrl(DEFAULT_SETTINGS, raw); + assert.equal(queryOf(url), raw); +}); + +test("a selection containing $` and $' survives a prefix template byte-identically", () => { + const raw = "shell $` and $' here"; + const url = buildChatGptUrl(withSettings({ promptTemplate: "Explain: {query}" }), raw); + assert.equal(queryOf(url), `Explain: ${raw}`); +}); + +test("a selection containing $$ survives the default {query} template byte-identically", () => { + const raw = "PID is $$ in bash"; + const url = buildChatGptUrl(DEFAULT_SETTINGS, raw); + assert.equal(queryOf(url), raw); +}); + +test("a selection containing $$ survives a prefix template byte-identically", () => { + const raw = "PID is $$ in bash"; + const url = buildChatGptUrl(withSettings({ promptTemplate: "Explain: {query}" }), raw); + assert.equal(queryOf(url), `Explain: ${raw}`); +});