From 519131c21ff81d7be8dced32b3fee729dcadebf5 Mon Sep 17 00:00:00 2001 From: golem Date: Sun, 18 Oct 2026 01:32:22 -0600 Subject: [PATCH 1/5] fix: cap huge selections before shortening The shortcut and toolbar read window.getSelection() without a limit, so a select-all on a very large page handed prepareAction millions of characters. Every binary-search probe re-encoded the whole candidate prefix: about 1.1 s for 5M characters, paid again by the dialog. Each code point adds at least one character to the URL, so nothing past the first MAX_URL_CHARS can fit. Cap there before any encoding. A capped source never takes the ready path: renderPrompt trims, so a prefix of mostly whitespace could fit and silently drop the rest without consent. Output is unchanged: 2,000 random cases matched develop exactly. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 ++ lib/prepare-action.js | 17 ++++++++++++++--- test/prepare-action.test.js | 20 ++++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08b2120..507b5e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ All notable changes to this project will be documented in this file. See [commit actions, with optional translation and a preferred language or language dialog. - Preserve dedicated action instructions when shortening selections; ask before sending shortened text and reject URLs that cannot be sent intact. +- Stay responsive with very large selections from the shortcut or toolbar: only + the part that can fit in the ChatGPT link is processed. - Add accessible action dialogs with cancellation and retry, remove the temporary model/effort URL probe, and document URL sharing. No new permissions. - Show up front, in the options panel and README, that ChatGPT may require diff --git a/lib/prepare-action.js b/lib/prepare-action.js index 8726a2c..53ee9f7 100644 --- a/lib/prepare-action.js +++ b/lib/prepare-action.js @@ -25,12 +25,23 @@ export function prepareAction(request) { } const resolved = { ...request, language }; const serialize = text => serializePrompt(request.settings, renderPrompt(resolved, text)); - const url = serialize(source); - if (url.length <= MAX_URL_CHARS) return { kind: 'ready', url }; + // Each code point adds at least one URL character, so none past the first + // MAX_URL_CHARS can fit. Capping first keeps a select-all on a huge page from + // costing seconds of re-encoding. A capped source is never sent as ready: its + // kept prefix may trim down to something that fits and silently drop the rest. + const points = []; + let capped = false; + for (const point of source) { + if (points.length === MAX_URL_CHARS) { capped = true; break; } + points.push(point); + } + if (!capped) { + const url = serialize(source); + if (url.length <= MAX_URL_CHARS) return { kind: 'ready', url }; + } if (isUrl) return error('This URL is too long to send intact. No part of it has been sent.'); const marker = '\n[Selected text shortened]'; if (serialize(marker).length >= MAX_URL_CHARS) return error('The prompt instructions are too long. Shorten your prompt template and try again.'); - const points = Array.from(source); let low = 0, high = points.length; while (low < high) { const mid = Math.ceil((low + high) / 2); diff --git a/test/prepare-action.test.js b/test/prepare-action.test.js index 840b339..759d353 100644 --- a/test/prepare-action.test.js +++ b/test/prepare-action.test.js @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { DEFAULT_SETTINGS } from '../lib/defaults.js'; +import { MAX_URL_CHARS } from '../lib/build-url.js'; const { prepareAction } = await import('../lib/prepare-action.js').catch(()=>({})); const req = (overrides={}) => ({actionId:'ask',source:'Hello',settings:{...DEFAULT_SETTINGS},...overrides}); test('ready actions preserve source and hints, translation asks only when needed', () => { @@ -38,3 +39,22 @@ test('long selections preserve instructions, repetition and Unicode with explici const translated=prepareAction(req({actionId:'translate',language:'Spanish',source:'x'.repeat(20000)})); assert.equal(translated.kind,'shortened');assert.match(new URL(translated.url).searchParams.get('q'), /Spanish/); }); +test('huge selections are capped before shortening, with identical output', () => { + assert.equal(typeof prepareAction, 'function'); + // Select-all on a very large page; shortening this took over a second uncapped. + const source='😀 word '.repeat(625000); + const started=performance.now(); + const out=prepareAction(req({source})); + const elapsed=performance.now()-started; + assert.equal(out.kind,'shortened'); + assert.ok(elapsed<250,`took ${elapsed.toFixed(0)} ms`); + const capped=Array.from(source).slice(0,MAX_URL_CHARS).join(''); + assert.deepEqual(prepareAction(req({source:capped})),out); +}); +test('a selection too long to send whole always needs consent, even if its start would fit', () => { + assert.equal(typeof prepareAction, 'function'); + // The first MAX_URL_CHARS code points trim down to "a"; sending just that would drop the rest unasked. + const out=prepareAction(req({source:'a'+' '.repeat(MAX_URL_CHARS)+'b'})); + assert.equal(out.kind,'shortened'); + assert.ok(new URL(out.url).searchParams.get('q').includes('[Selected text shortened]')); +}); From cb7da3a5387d8876c2bf17955fb65812edafb4ae Mon Sep 17 00:00:00 2001 From: golem Date: Sun, 18 Oct 2026 02:08:12 -0600 Subject: [PATCH 2/5] ci: add a WIP-title gate to extension CI Every push to an open PR ran the full validate job, and pull_request lacked the edited type, so removing WIP: would not start a run. pr-flow therefore refused to open PRs early. Skip validate on PRs titled WIP: or [WIP], add edited so un-WIP starts the run, add workflow_dispatch for on-demand runs, and cancel superseded PR runs (never push runs, since main's push uploads the package). The workflow name and job id are unchanged, so the required check context stays "Firefox Extension CI / validate (pull_request)". Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/firefox-extension.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.gitea/workflows/firefox-extension.yml b/.gitea/workflows/firefox-extension.yml index 2fac9ef..8ca8953 100644 --- a/.gitea/workflows/firefox-extension.yml +++ b/.gitea/workflows/firefox-extension.yml @@ -2,13 +2,29 @@ name: Firefox Extension CI on: pull_request: + # edited: removing the WIP: prefix starts the real run. + types: [opened, synchronize, reopened, edited] push: branches: - develop - main + workflow_dispatch: + +concurrency: + group: ${{ gitea.workflow }}-${{ gitea.ref }} + # Cancel superseded PR runs, never a push run: main's push uploads the package. + cancel-in-progress: ${{ gitea.event_name == 'pull_request' }} jobs: validate: + # WIP gate: skip PRs titled WIP: so early PRs cost no run per push. Gate on + # the title and nothing else, since a skipped job reports success on the + # required check (see ~/.claude/docs/gitea-ci-pattern.md). + if: >- + gitea.event_name != 'pull_request' || ( + !startsWith(gitea.event.pull_request.title, 'WIP:') && + !startsWith(gitea.event.pull_request.title, '[WIP]') + ) runs-on: ubuntu-latest steps: - name: Check out repository From 39a3ac74f4203c48e60e8afb4a0fe1467cb08171 Mon Sep 17 00:00:00 2001 From: golem Date: Sun, 18 Oct 2026 02:09:04 -0600 Subject: [PATCH 3/5] ci: use the github context for the WIP gate Gitea treats github.* as an alias for gitea.*. The pattern was canaried in this form, and pr-flow only detects a gate written this way. Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/firefox-extension.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/firefox-extension.yml b/.gitea/workflows/firefox-extension.yml index 8ca8953..4a5c567 100644 --- a/.gitea/workflows/firefox-extension.yml +++ b/.gitea/workflows/firefox-extension.yml @@ -10,10 +10,12 @@ on: - main workflow_dispatch: +# The gate and concurrency use the github.* context, Gitea's alias for gitea.*: +# it is the form the pattern was canaried with and the one pr-flow detects. concurrency: - group: ${{ gitea.workflow }}-${{ gitea.ref }} + group: ${{ github.workflow }}-${{ github.ref }} # Cancel superseded PR runs, never a push run: main's push uploads the package. - cancel-in-progress: ${{ gitea.event_name == 'pull_request' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: validate: @@ -21,9 +23,9 @@ jobs: # the title and nothing else, since a skipped job reports success on the # required check (see ~/.claude/docs/gitea-ci-pattern.md). if: >- - gitea.event_name != 'pull_request' || ( - !startsWith(gitea.event.pull_request.title, 'WIP:') && - !startsWith(gitea.event.pull_request.title, '[WIP]') + github.event_name != 'pull_request' || ( + !startsWith(github.event.pull_request.title, 'WIP:') && + !startsWith(github.event.pull_request.title, '[WIP]') ) runs-on: ubuntu-latest steps: From cc68fe98b5ae88e066992e88bc57f38d288754c1 Mon Sep 17 00:00:00 2001 From: golem Date: Sun, 18 Oct 2026 02:13:06 -0600 Subject: [PATCH 4/5] refactor: remove the unused URL shortener buildChatGptUrl and its fitToBudget helpers have had no production caller since prepareAction took over routing; the 2026-10-05 plan kept them only for existing tests until then. They also shortened silently, against the ask-before-shortening rule, so they were a trap to reuse. Port the tests that guard live code onto applyTemplate and serializePrompt, including the $&, $`, $' and $$ regressions (checked to fail against the string-replacement form), and cover prepareAction's whole-word cut, which nothing tested before. Also drop getContext's inputType password check. menus.OnClickData has no inputType, so it never fired; Firefox already withholds password fields from selectionText via mozIsTextField(true) in SelectionUtils (bug 565717). Co-Authored-By: Claude Opus 5.5 --- lib/actions.js | 1 - lib/build-url.js | 65 +---------------- test/actions.test.js | 1 - test/build-url.test.js | 140 ++++++++---------------------------- test/prepare-action.test.js | 11 +++ 5 files changed, 43 insertions(+), 175 deletions(-) diff --git a/lib/actions.js b/lib/actions.js index 48737ee..442c0ad 100644 --- a/lib/actions.js +++ b/lib/actions.js @@ -13,7 +13,6 @@ const TITLES = { }; export function getContext(info) { - if (info.inputType === 'password') return null; if (typeof info.selectionText === 'string' && info.selectionText.trim()) { return { kind: 'selection', source: info.selectionText.trim() }; } diff --git a/lib/build-url.js b/lib/build-url.js index 5e836e8..a9f11b8 100644 --- a/lib/build-url.js +++ b/lib/build-url.js @@ -12,9 +12,6 @@ const BASE = "https://chatgpt.com/"; 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) { if (query === "") { return ""; @@ -42,65 +39,9 @@ function addOptionalParams(url, settings) { return url; } -function lastWhitespaceIndex(text) { - for (let i = text.length - 1; i >= 0; i -= 1) { - 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. +// Encode a complete prompt without silently shortening it; prepareAction owns +// the length budget and asks before shortening. q goes first, matching the +// most-cited working examples. export function serializePrompt(settings, prompt) { const url = new URL(BASE); if (prompt) url.searchParams.set("q", prompt); diff --git a/test/actions.test.js b/test/actions.test.js index 193ac02..fef2432 100644 --- a/test/actions.test.js +++ b/test/actions.test.js @@ -9,7 +9,6 @@ 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.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({selectionText:'secret',inputType:'password'}), null); assert.equal(actions.getContext({pageUrl:'https://example.com'}).kind, 'page'); }); test('selection actions and optional translation reflect settings', () => { diff --git a/test/build-url.test.js b/test/build-url.test.js index 667ef4c..cf513ac 100644 --- a/test/build-url.test.js +++ b/test/build-url.test.js @@ -2,168 +2,86 @@ import test from "node:test"; import assert from "node:assert/strict"; import { DEFAULT_SETTINGS } from "../lib/defaults.js"; -import { buildChatGptUrl, MAX_URL_CHARS } from "../lib/build-url.js"; +import { applyTemplate, serializePrompt } from "../lib/build-url.js"; const withSettings = (overrides) => ({ ...DEFAULT_SETTINGS, ...overrides }); const queryOf = (url) => new URL(url).searchParams.get("q"); const paramsOf = (url) => [...new URL(url).searchParams.keys()]; test("defaults produce only the q parameter on the root path", () => { - assert.equal( - buildChatGptUrl(DEFAULT_SETTINGS, "hello"), - "https://chatgpt.com/?q=hello", - ); + assert.equal(serializePrompt(DEFAULT_SETTINGS, "hello"), "https://chatgpt.com/?q=hello"); }); -test("an empty or whitespace-only query omits q entirely", () => { - for (const raw of ["", " ", "\n\t", null, undefined]) { - const url = buildChatGptUrl(DEFAULT_SETTINGS, raw); - assert.equal(paramsOf(url).includes("q"), false, `raw=${JSON.stringify(raw)}`); - assert.equal(url, "https://chatgpt.com/"); +test("an empty prompt omits q entirely", () => { + assert.equal(serializePrompt(DEFAULT_SETTINGS, ""), "https://chatgpt.com/"); + for (const template of ["{query}", "Explain: {query}", "Be concise:", ""]) { + assert.equal(applyTemplate(template, ""), "", `template=${JSON.stringify(template)}`); } }); test("legacy model values never add a model URL parameter", () => { - assert.equal(buildChatGptUrl(withSettings({ model: "gpt-5.6-sol" }), "hi"), - buildChatGptUrl(DEFAULT_SETTINGS, "hi")); + assert.equal(serializePrompt(withSettings({ model: "gpt-5.6-sol" }), "hi"), + serializePrompt(DEFAULT_SETTINGS, "hi")); }); test("temporaryChat and webSearch each appear only when enabled", () => { - assert.equal(paramsOf(buildChatGptUrl(DEFAULT_SETTINGS, "hi")).includes("temporary-chat"), false); - assert.equal(paramsOf(buildChatGptUrl(DEFAULT_SETTINGS, "hi")).includes("hints"), false); + assert.deepEqual(paramsOf(serializePrompt(DEFAULT_SETTINGS, "hi")), ["q"]); - const temp = new URL(buildChatGptUrl(withSettings({ temporaryChat: true }), "hi")); + const temp = new URL(serializePrompt(withSettings({ temporaryChat: true }), "hi")); assert.equal(temp.searchParams.get("temporary-chat"), "true"); - const search = new URL(buildChatGptUrl(withSettings({ webSearch: true }), "hi")); + const search = new URL(serializePrompt(withSettings({ webSearch: true }), "hi")); assert.equal(search.searchParams.get("hints"), "search"); }); -test("all three URL-affecting settings combine, q first", () => { - const url = buildChatGptUrl( - withSettings({ - temporaryChat: true, - webSearch: true, - promptTemplate: "Explain: {query}", - }), - "entropy", - ); +test("all URL-affecting settings combine, q first", () => { + const settings = withSettings({ temporaryChat: true, webSearch: true, promptTemplate: "Explain: {query}" }); + const url = serializePrompt(settings, applyTemplate(settings.promptTemplate, "entropy")); assert.deepEqual(paramsOf(url), ["q", "temporary-chat", "hints"]); assert.equal(queryOf(url), "Explain: entropy"); }); test("openIn never influences the URL", () => { - const base = buildChatGptUrl(DEFAULT_SETTINGS, "hi"); + const base = serializePrompt(DEFAULT_SETTINGS, "hi"); for (const openIn of ["new-tab", "background-tab", "new-window"]) { - assert.equal(buildChatGptUrl(withSettings({ openIn }), "hi"), base); + assert.equal(serializePrompt(withSettings({ openIn }), "hi"), base); } }); test("special characters survive encoding round-trip", () => { const raw = 'a & b # c ? d = e + f "g" 100% 😀 日本語\nnewline'; - const url = buildChatGptUrl(DEFAULT_SETTINGS, raw); + const url = serializePrompt(DEFAULT_SETTINGS, raw); assert.equal(queryOf(url), raw); assert.equal(url.includes(" "), false); assert.equal(url.includes("#"), false); }); test("the template replaces every {query} occurrence", () => { - const url = buildChatGptUrl(withSettings({ promptTemplate: "{query} / {query}" }), "x"); - assert.equal(queryOf(url), "x / x"); + assert.equal(applyTemplate("{query} / {query}", "x"), "x / x"); }); test("a template without {query} is treated as a prefix, never dropping the query", () => { - const url = buildChatGptUrl(withSettings({ promptTemplate: "Be concise:" }), "why"); - assert.equal(queryOf(url), "Be concise: why"); + assert.equal(applyTemplate("Be concise:", "why"), "Be concise: why"); }); test("an empty or whitespace-only template yields the bare query", () => { - for (const promptTemplate of ["", " "]) { - assert.equal(queryOf(buildChatGptUrl(withSettings({ promptTemplate }), "why")), "why"); + for (const template of ["", " "]) { + assert.equal(applyTemplate(template, "why"), "why"); } }); test("a non-string template degrades to the bare query", () => { - 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])|(? { - 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); + assert.equal(applyTemplate(null, "why"), "why"); }); // 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}`); -}); +for (const raw of ["awk print $& here", "shell $` and $' here", "PID is $$ in bash"]) { + test(`a selection containing ${raw.match(/\$./g).join(" and ")} survives templating byte-identically`, () => { + assert.equal(applyTemplate("{query}", raw), raw); + assert.equal(applyTemplate("Explain: {query}", raw), `Explain: ${raw}`); + assert.equal(queryOf(serializePrompt(DEFAULT_SETTINGS, applyTemplate("{query}", raw))), raw); + }); +} diff --git a/test/prepare-action.test.js b/test/prepare-action.test.js index 840b339..6d232a7 100644 --- a/test/prepare-action.test.js +++ b/test/prepare-action.test.js @@ -23,6 +23,17 @@ test('URLs are validated and preserved whole, never shortened', () => { assert.equal(out.kind,'ready'); 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', () => { assert.equal(typeof prepareAction, 'function'); const settings={...DEFAULT_SETTINGS,promptTemplate:'START {query} AGAIN {query} END',temporaryChat:true,webSearch:true}; From 269e721fe851b609b05bfeb3cf8ab677f96878a8 Mon Sep 17 00:00:00 2001 From: golem Date: Sun, 18 Oct 2026 02:23:03 -0600 Subject: [PATCH 5/5] 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 --- README.md | 1 + lib/defaults.js | 6 ++++++ lib/settings.js | 4 ++-- options/options.html | 4 ++-- test/settings.test.js | 28 +++++++++++++++++++++++++++- 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e2dba84..48e80c4 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ Open `about:addons`, select this extension, and choose the **Options** tab guarantee. - **Open in** — a new tab, a new background tab, or a new window. - **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 hidden; it does not change Firefox's search engine or shortcut settings. diff --git a/lib/defaults.js b/lib/defaults.js index fcf5e57..fc8e59c 100644 --- a/lib/defaults.js +++ b/lib/defaults.js @@ -3,6 +3,12 @@ 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 = { contextMenuMode: "simple", showTranslate: false, diff --git a/lib/settings.js b/lib/settings.js index 978b926..068a1ba 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -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) { if (typeof value !== "string") return ""; @@ -28,7 +28,7 @@ export function mergeSettings(stored) { if (OPEN_IN_VALUES.includes(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; } return merged; diff --git a/options/options.html b/options/options.html index 127b569..0b852e5 100644 --- a/options/options.html +++ b/options/options.html @@ -80,8 +80,8 @@
- -

{query} 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.

+ +

{query} 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.

diff --git a/test/settings.test.js b/test/settings.test.js index 58b6e13..0f7fb34 100644 --- a/test/settings.test.js +++ b/test/settings.test.js @@ -1,7 +1,9 @@ import test from "node:test"; 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"; 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); }); +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(/]*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", () => { assert.equal(mergeSettings({ openIn: "teleport" }).openIn, DEFAULT_SETTINGS.openIn); assert.equal(mergeSettings({ openIn: "" }).openIn, DEFAULT_SETTINGS.openIn);