Compare commits

..
Author SHA1 Message Date
golemandClaude Opus 5.5 269e721fe8 fix: limit the prompt template to 1,000 characters
storage.sync rejects any item over 8192 bytes, and the options page
writes every field in one set(). An oversized template therefore made
every later save fail, including changes to unrelated settings, with
only a brief "Could not save settings".

Cap the template at 1,000 UTF-16 units: maxlength on the input, and
mergeSettings falls back to the default for a longer stored value, as it
does for other out-of-range settings. Even a template of pasted control
characters (six bytes each once JSON-escaped) stays under the quota; a
test pins that arithmetic and another ties the HTML maxlength to the
constant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 02:23:03 -06:00
10 changed files with 213 additions and 48 deletions
+1
View File
@@ -74,6 +74,7 @@ Open `about:addons`, select this extension, and choose the **Options** tab
guarantee. guarantee.
- **Open in** — a new tab, a new background tab, or a new window. - **Open in** — a new tab, a new background tab, or a new window.
- **Prompt template** — wraps your text, for example `Explain simply: {query}`. - **Prompt template** — wraps your text, for example `Explain simply: {query}`.
Up to 1,000 characters.
Restore defaults resets extension preferences to Simple mode with translation Restore defaults resets extension preferences to Simple mode with translation
hidden; it does not change Firefox's search engine or shortcut settings. hidden; it does not change Firefox's search engine or shortcut settings.
+1
View File
@@ -13,6 +13,7 @@ const TITLES = {
}; };
export function getContext(info) { export function getContext(info) {
if (info.inputType === 'password') return null;
if (typeof info.selectionText === 'string' && info.selectionText.trim()) { if (typeof info.selectionText === 'string' && info.selectionText.trim()) {
return { kind: 'selection', source: info.selectionText.trim() }; return { kind: 'selection', source: info.selectionText.trim() };
} }
+62 -3
View File
@@ -12,6 +12,9 @@ const BASE = "https://chatgpt.com/";
export const MAX_URL_CHARS = 8000; export const MAX_URL_CHARS = 8000;
// Fraction of the kept text within which a whitespace cut is preferred.
const WHITESPACE_WINDOW = 0.9;
export function applyTemplate(template, query) { export function applyTemplate(template, query) {
if (query === "") { if (query === "") {
return ""; return "";
@@ -39,9 +42,65 @@ function addOptionalParams(url, settings) {
return url; return url;
} }
// Encode a complete prompt without silently shortening it; prepareAction owns function lastWhitespaceIndex(text) {
// the length budget and asks before shortening. q goes first, matching the for (let i = text.length - 1; i >= 0; i -= 1) {
// most-cited working examples. if (/\s/.test(text[i])) {
return i;
}
}
return -1;
}
// Returns the longest prefix of `prompt` whose finished URL fits the budget.
// Slicing happens by CODE POINT on the decoded string, so a surrogate pair can
// never be split and a percent-escape can never be severed (encoding happens
// afterwards, via URLSearchParams).
function fitToBudget(prompt, settings) {
const codePoints = Array.from(prompt);
const urlLengthFor = (count) => {
const url = new URL(BASE);
url.searchParams.set("q", codePoints.slice(0, count).join(""));
addOptionalParams(url, settings);
return url.href.length;
};
if (urlLengthFor(codePoints.length) <= MAX_URL_CHARS) {
return prompt;
}
let low = 0;
let high = codePoints.length;
while (low < high) {
const mid = Math.floor((low + high + 1) / 2);
if (urlLengthFor(mid) <= MAX_URL_CHARS) {
low = mid;
} else {
high = mid - 1;
}
}
const kept = codePoints.slice(0, low).join("");
const boundary = Math.floor(kept.length * WHITESPACE_WINDOW);
const cut = lastWhitespaceIndex(kept);
return cut >= boundary && cut > 0 ? kept.slice(0, cut) : kept;
}
export function buildChatGptUrl(settings, rawQuery) {
const query = String(rawQuery ?? "").trim();
const prompt = fitToBudget(applyTemplate(settings.promptTemplate, query), settings);
// q first, matching the most-cited working examples. Total serialized length
// is order-independent, so measuring without q above is sound.
const url = new URL(BASE);
if (prompt !== "") {
url.searchParams.set("q", prompt);
}
addOptionalParams(url, settings);
return url.href;
}
// Encode a complete prompt without silently shortening it.
export function serializePrompt(settings, prompt) { export function serializePrompt(settings, prompt) {
const url = new URL(BASE); const url = new URL(BASE);
if (prompt) url.searchParams.set("q", prompt); if (prompt) url.searchParams.set("q", prompt);
+6
View File
@@ -3,6 +3,12 @@
export const OPEN_IN_VALUES = ["new-tab", "background-tab", "new-window"]; export const OPEN_IN_VALUES = ["new-tab", "background-tab", "new-window"];
// In UTF-16 units, as maxlength counts. Keeps the template far inside
// storage.sync's 8192-byte item quota even when every character is a pasted
// control character (six bytes once JSON-escaped), and far beyond any template
// the 8,000-character URL budget could send.
export const MAX_TEMPLATE_CHARS = 1000;
export const DEFAULT_SETTINGS = { export const DEFAULT_SETTINGS = {
contextMenuMode: "simple", contextMenuMode: "simple",
showTranslate: false, showTranslate: false,
+2 -2
View File
@@ -1,4 +1,4 @@
import { DEFAULT_SETTINGS, OPEN_IN_VALUES } from "./defaults.js"; import { DEFAULT_SETTINGS, MAX_TEMPLATE_CHARS, OPEN_IN_VALUES } from "./defaults.js";
export function normalizeLanguage(value) { export function normalizeLanguage(value) {
if (typeof value !== "string") return ""; if (typeof value !== "string") return "";
@@ -28,7 +28,7 @@ export function mergeSettings(stored) {
if (OPEN_IN_VALUES.includes(stored.openIn)) { if (OPEN_IN_VALUES.includes(stored.openIn)) {
merged.openIn = stored.openIn; merged.openIn = stored.openIn;
} }
if (typeof stored.promptTemplate === "string") { if (typeof stored.promptTemplate === "string" && stored.promptTemplate.length <= MAX_TEMPLATE_CHARS) {
merged.promptTemplate = stored.promptTemplate; merged.promptTemplate = stored.promptTemplate;
} }
return merged; return merged;
+2 -2
View File
@@ -80,8 +80,8 @@
<div class="field"> <div class="field">
<label for="promptTemplate">Prompt template</label> <label for="promptTemplate">Prompt template</label>
<input type="text" id="promptTemplate" spellcheck="false" /> <input type="text" id="promptTemplate" spellcheck="false" maxlength="1000" />
<p class="hint"><code>{query}</code> is replaced with your selected text. Used by Ask normally, Simple mode, the toolbar button, and the keyboard shortcut. Other advanced actions use their own prompts.</p> <p class="hint"><code>{query}</code> is replaced with your selected text. Used by Ask normally, Simple mode, the toolbar button, and the keyboard shortcut. Other advanced actions use their own prompts. Up to 1,000 characters.</p>
</div> </div>
<div class="actions"> <div class="actions">
+1
View File
@@ -9,6 +9,7 @@ test('context prioritizes selection over link and excludes unrelated targets', (
assert.deepEqual(actions.getContext({selectionText:' ',linkUrl:'https://example.com'}), {kind:'link',source:'https://example.com'}); assert.deepEqual(actions.getContext({selectionText:' ',linkUrl:'https://example.com'}), {kind:'link',source:'https://example.com'});
assert.equal(actions.getContext({mediaType:'image',pageUrl:'https://example.com'}), null); assert.equal(actions.getContext({mediaType:'image',pageUrl:'https://example.com'}), null);
assert.equal(actions.getContext({editable:true,pageUrl:'https://example.com'}), null); assert.equal(actions.getContext({editable:true,pageUrl:'https://example.com'}), null);
assert.equal(actions.getContext({selectionText:'secret',inputType:'password'}), null);
assert.equal(actions.getContext({pageUrl:'https://example.com'}).kind, 'page'); assert.equal(actions.getContext({pageUrl:'https://example.com'}).kind, 'page');
}); });
test('selection actions and optional translation reflect settings', () => { test('selection actions and optional translation reflect settings', () => {
+110 -28
View File
@@ -2,86 +2,168 @@ import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { DEFAULT_SETTINGS } from "../lib/defaults.js"; import { DEFAULT_SETTINGS } from "../lib/defaults.js";
import { applyTemplate, serializePrompt } from "../lib/build-url.js"; import { buildChatGptUrl, MAX_URL_CHARS } from "../lib/build-url.js";
const withSettings = (overrides) => ({ ...DEFAULT_SETTINGS, ...overrides }); const withSettings = (overrides) => ({ ...DEFAULT_SETTINGS, ...overrides });
const queryOf = (url) => new URL(url).searchParams.get("q"); const queryOf = (url) => new URL(url).searchParams.get("q");
const paramsOf = (url) => [...new URL(url).searchParams.keys()]; const paramsOf = (url) => [...new URL(url).searchParams.keys()];
test("defaults produce only the q parameter on the root path", () => { test("defaults produce only the q parameter on the root path", () => {
assert.equal(serializePrompt(DEFAULT_SETTINGS, "hello"), "https://chatgpt.com/?q=hello"); assert.equal(
buildChatGptUrl(DEFAULT_SETTINGS, "hello"),
"https://chatgpt.com/?q=hello",
);
}); });
test("an empty prompt omits q entirely", () => { test("an empty or whitespace-only query omits q entirely", () => {
assert.equal(serializePrompt(DEFAULT_SETTINGS, ""), "https://chatgpt.com/"); for (const raw of ["", " ", "\n\t", null, undefined]) {
for (const template of ["{query}", "Explain: {query}", "Be concise:", ""]) { const url = buildChatGptUrl(DEFAULT_SETTINGS, raw);
assert.equal(applyTemplate(template, ""), "", `template=${JSON.stringify(template)}`); assert.equal(paramsOf(url).includes("q"), false, `raw=${JSON.stringify(raw)}`);
assert.equal(url, "https://chatgpt.com/");
} }
}); });
test("legacy model values never add a model URL parameter", () => { test("legacy model values never add a model URL parameter", () => {
assert.equal(serializePrompt(withSettings({ model: "gpt-5.6-sol" }), "hi"), assert.equal(buildChatGptUrl(withSettings({ model: "gpt-5.6-sol" }), "hi"),
serializePrompt(DEFAULT_SETTINGS, "hi")); buildChatGptUrl(DEFAULT_SETTINGS, "hi"));
}); });
test("temporaryChat and webSearch each appear only when enabled", () => { test("temporaryChat and webSearch each appear only when enabled", () => {
assert.deepEqual(paramsOf(serializePrompt(DEFAULT_SETTINGS, "hi")), ["q"]); assert.equal(paramsOf(buildChatGptUrl(DEFAULT_SETTINGS, "hi")).includes("temporary-chat"), false);
assert.equal(paramsOf(buildChatGptUrl(DEFAULT_SETTINGS, "hi")).includes("hints"), false);
const temp = new URL(serializePrompt(withSettings({ temporaryChat: true }), "hi")); const temp = new URL(buildChatGptUrl(withSettings({ temporaryChat: true }), "hi"));
assert.equal(temp.searchParams.get("temporary-chat"), "true"); assert.equal(temp.searchParams.get("temporary-chat"), "true");
const search = new URL(serializePrompt(withSettings({ webSearch: true }), "hi")); const search = new URL(buildChatGptUrl(withSettings({ webSearch: true }), "hi"));
assert.equal(search.searchParams.get("hints"), "search"); assert.equal(search.searchParams.get("hints"), "search");
}); });
test("all URL-affecting settings combine, q first", () => { test("all three URL-affecting settings combine, q first", () => {
const settings = withSettings({ temporaryChat: true, webSearch: true, promptTemplate: "Explain: {query}" }); const url = buildChatGptUrl(
const url = serializePrompt(settings, applyTemplate(settings.promptTemplate, "entropy")); withSettings({
temporaryChat: true,
webSearch: true,
promptTemplate: "Explain: {query}",
}),
"entropy",
);
assert.deepEqual(paramsOf(url), ["q", "temporary-chat", "hints"]); assert.deepEqual(paramsOf(url), ["q", "temporary-chat", "hints"]);
assert.equal(queryOf(url), "Explain: entropy"); assert.equal(queryOf(url), "Explain: entropy");
}); });
test("openIn never influences the URL", () => { test("openIn never influences the URL", () => {
const base = serializePrompt(DEFAULT_SETTINGS, "hi"); const base = buildChatGptUrl(DEFAULT_SETTINGS, "hi");
for (const openIn of ["new-tab", "background-tab", "new-window"]) { for (const openIn of ["new-tab", "background-tab", "new-window"]) {
assert.equal(serializePrompt(withSettings({ openIn }), "hi"), base); assert.equal(buildChatGptUrl(withSettings({ openIn }), "hi"), base);
} }
}); });
test("special characters survive encoding round-trip", () => { test("special characters survive encoding round-trip", () => {
const raw = 'a & b # c ? d = e + f "g" <h> 100% 😀 日本語\nnewline'; const raw = 'a & b # c ? d = e + f "g" <h> 100% 😀 日本語\nnewline';
const url = serializePrompt(DEFAULT_SETTINGS, raw); const url = buildChatGptUrl(DEFAULT_SETTINGS, raw);
assert.equal(queryOf(url), raw); assert.equal(queryOf(url), raw);
assert.equal(url.includes(" "), false); assert.equal(url.includes(" "), false);
assert.equal(url.includes("#"), false); assert.equal(url.includes("#"), false);
}); });
test("the template replaces every {query} occurrence", () => { test("the template replaces every {query} occurrence", () => {
assert.equal(applyTemplate("{query} / {query}", "x"), "x / x"); const url = buildChatGptUrl(withSettings({ promptTemplate: "{query} / {query}" }), "x");
assert.equal(queryOf(url), "x / x");
}); });
test("a template without {query} is treated as a prefix, never dropping the query", () => { test("a template without {query} is treated as a prefix, never dropping the query", () => {
assert.equal(applyTemplate("Be concise:", "why"), "Be concise: why"); const url = buildChatGptUrl(withSettings({ promptTemplate: "Be concise:" }), "why");
assert.equal(queryOf(url), "Be concise: why");
}); });
test("an empty or whitespace-only template yields the bare query", () => { test("an empty or whitespace-only template yields the bare query", () => {
for (const template of ["", " "]) { for (const promptTemplate of ["", " "]) {
assert.equal(applyTemplate(template, "why"), "why"); assert.equal(queryOf(buildChatGptUrl(withSettings({ promptTemplate }), "why")), "why");
} }
}); });
test("a non-string template degrades to the bare query", () => { test("a non-string template degrades to the bare query", () => {
assert.equal(applyTemplate(null, "why"), "why"); assert.equal(queryOf(buildChatGptUrl(withSettings({ promptTemplate: null }), "why")), "why");
});
test("an over-long prompt is truncated to fit the URL budget", () => {
const url = buildChatGptUrl(DEFAULT_SETTINGS, "x".repeat(20000));
assert.ok(url.length <= MAX_URL_CHARS, `length was ${url.length}`);
assert.ok(queryOf(url).length > 0);
assert.ok(queryOf(url).length < 20000);
});
test("truncation accounts for the other parameters", () => {
const settings = withSettings({ temporaryChat: true, webSearch: true });
const url = buildChatGptUrl(settings, "x".repeat(20000));
assert.ok(url.length <= MAX_URL_CHARS, `length was ${url.length}`);
assert.equal(new URL(url).searchParams.get("hints"), "search");
});
test("truncation never splits a surrogate pair", () => {
const url = buildChatGptUrl(DEFAULT_SETTINGS, "😀".repeat(9000));
assert.ok(url.length <= MAX_URL_CHARS);
const lonelySurrogate = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
assert.equal(lonelySurrogate.test(queryOf(url)), false);
});
test("truncation never leaves a severed percent-escape", () => {
const url = buildChatGptUrl(DEFAULT_SETTINGS, "😀 ".repeat(9000));
assert.equal(/%[0-9A-Fa-f]?$/.test(url), false);
assert.doesNotThrow(() => decodeURIComponent(new URL(url).search.slice(3)));
});
test("truncation prefers a whitespace boundary, so no word is cut in half", () => {
const url = buildChatGptUrl(DEFAULT_SETTINGS, "lorem ipsum ".repeat(2000));
const tokens = queryOf(url).trim().split(/\s+/);
assert.ok(tokens.length > 1);
for (const token of tokens) {
assert.ok(token === "lorem" || token === "ipsum", `truncated mid-word: ${token}`);
}
});
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 // String.prototype.replaceAll gives $&, $`, $', and $$ special meaning in the
// REPLACEMENT string, and `query` (arbitrary page-selected text) is exactly // REPLACEMENT string, and `query` (arbitrary page-selected text) is exactly
// that argument. These selections are ordinary shell one-liners, not // that argument. These selections are ordinary shell one-liners, not
// adversarial input, and must round-trip byte-identically into `q`. // adversarial input, and must round-trip byte-identically into `q`.
for (const raw of ["awk print $& here", "shell $` and $' here", "PID is $$ in bash"]) { test("a selection containing $& survives the default {query} template byte-identically", () => {
test(`a selection containing ${raw.match(/\$./g).join(" and ")} survives templating byte-identically`, () => { const raw = "awk print $& here";
assert.equal(applyTemplate("{query}", raw), raw); const url = buildChatGptUrl(DEFAULT_SETTINGS, raw);
assert.equal(applyTemplate("Explain: {query}", raw), `Explain: ${raw}`); assert.equal(queryOf(url), raw);
assert.equal(queryOf(serializePrompt(DEFAULT_SETTINGS, applyTemplate("{query}", raw))), 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}`);
}); });
}
-11
View File
@@ -23,17 +23,6 @@ test('URLs are validated and preserved whole, never shortened', () => {
assert.equal(out.kind,'ready'); assert.equal(out.kind,'ready');
assert.ok(new URL(out.url).searchParams.get('q').endsWith(source)); assert.ok(new URL(out.url).searchParams.get('q').endsWith(source));
}); });
test('shortened selections end on a whole word and decode cleanly', () => {
assert.equal(typeof prepareAction, 'function');
const out=prepareAction(req({source:'lorem ipsum '.repeat(2000)}));
assert.equal(out.kind,'shortened');
const words=new URL(out.url).searchParams.get('q').replace('[Selected text shortened]','').trim().split(/\s+/);
assert.ok(words.length>1);
for (const word of words) assert.ok(word==='lorem'||word==='ipsum',`cut mid-word: ${word}`);
const emoji=prepareAction(req({source:'😀 '.repeat(9000)}));
assert.equal(emoji.kind,'shortened');
assert.doesNotThrow(()=>decodeURIComponent(new URL(emoji.url).search.slice(1)));
});
test('long selections preserve instructions, repetition and Unicode with explicit consent', () => { test('long selections preserve instructions, repetition and Unicode with explicit consent', () => {
assert.equal(typeof prepareAction, 'function'); assert.equal(typeof prepareAction, 'function');
const settings={...DEFAULT_SETTINGS,promptTemplate:'START {query} AGAIN {query} END',temporaryChat:true,webSearch:true}; const settings={...DEFAULT_SETTINGS,promptTemplate:'START {query} AGAIN {query} END',temporaryChat:true,webSearch:true};
+27 -1
View File
@@ -1,7 +1,9 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { DEFAULT_SETTINGS } from "../lib/defaults.js"; import { readFileSync } from "node:fs";
import { DEFAULT_SETTINGS, MAX_TEMPLATE_CHARS } from "../lib/defaults.js";
import { mergeSettings } from "../lib/settings.js"; import { mergeSettings } from "../lib/settings.js";
test("empty object yields the defaults", () => { test("empty object yields the defaults", () => {
@@ -67,6 +69,30 @@ test("wrong types fall back to the default", () => {
assert.equal(merged.promptTemplate, DEFAULT_SETTINGS.promptTemplate); assert.equal(merged.promptTemplate, DEFAULT_SETTINGS.promptTemplate);
}); });
test("a prompt template over the length limit falls back to the default", () => {
assert.equal(typeof MAX_TEMPLATE_CHARS, "number");
const atLimit = "x".repeat(MAX_TEMPLATE_CHARS);
assert.equal(mergeSettings({ promptTemplate: atLimit }).promptTemplate, atLimit);
assert.equal(mergeSettings({ promptTemplate: `${atLimit}x` }).promptTemplate,
DEFAULT_SETTINGS.promptTemplate);
});
// storage.sync rejects any item over 8192 bytes (key plus JSON value), and the
// options page writes every field in one set(), so one oversized template would
// make every later save fail. The worst character is a pasted control
// character, which JSON escapes to six bytes.
test("a template at the limit fits storage.sync's per-item quota in the worst case", () => {
const worst = "\u0001".repeat(MAX_TEMPLATE_CHARS);
const bytes = new TextEncoder().encode("promptTemplate" + JSON.stringify(worst)).length;
assert.ok(bytes <= 8192, `${bytes} bytes`);
});
test("the options page enforces the same template limit", () => {
const html = readFileSync(new URL("../options/options.html", import.meta.url), "utf8");
const input = html.match(/<input[^>]*id="promptTemplate"[^>]*>/)?.[0] ?? "";
assert.equal(input.match(/maxlength="(\d+)"/)?.[1], String(MAX_TEMPLATE_CHARS));
});
test("an out-of-range openIn falls back to the default", () => { test("an out-of-range openIn falls back to the default", () => {
assert.equal(mergeSettings({ openIn: "teleport" }).openIn, DEFAULT_SETTINGS.openIn); assert.equal(mergeSettings({ openIn: "teleport" }).openIn, DEFAULT_SETTINGS.openIn);
assert.equal(mergeSettings({ openIn: "" }).openIn, DEFAULT_SETTINGS.openIn); assert.equal(mergeSettings({ openIn: "" }).openIn, DEFAULT_SETTINGS.openIn);