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 <noreply@anthropic.com>
This commit is contained in:
golem
2026-10-18 01:32:22 -06:00
co-authored by Claude Opus 5.5
parent 99bb58f7b7
commit 519131c21f
3 changed files with 36 additions and 3 deletions
+2
View File
@@ -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. actions, with optional translation and a preferred language or language dialog.
- Preserve dedicated action instructions when shortening selections; ask before - Preserve dedicated action instructions when shortening selections; ask before
sending shortened text and reject URLs that cannot be sent intact. 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 - Add accessible action dialogs with cancellation and retry, remove the temporary
model/effort URL probe, and document URL sharing. No new permissions. model/effort URL probe, and document URL sharing. No new permissions.
- Show up front, in the options panel and README, that ChatGPT may require - Show up front, in the options panel and README, that ChatGPT may require
+12 -1
View File
@@ -25,12 +25,23 @@ export function prepareAction(request) {
} }
const resolved = { ...request, language }; const resolved = { ...request, language };
const serialize = text => serializePrompt(request.settings, renderPrompt(resolved, text)); const serialize = text => serializePrompt(request.settings, renderPrompt(resolved, text));
// 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); const url = serialize(source);
if (url.length <= MAX_URL_CHARS) return { kind: 'ready', url }; 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.'); 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]'; 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.'); 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; let low = 0, high = points.length;
while (low < high) { while (low < high) {
const mid = Math.ceil((low + high) / 2); const mid = Math.ceil((low + high) / 2);
+20
View File
@@ -1,6 +1,7 @@
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 { 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 { prepareAction } = await import('../lib/prepare-action.js').catch(()=>({}));
const req = (overrides={}) => ({actionId:'ask',source:'Hello',settings:{...DEFAULT_SETTINGS},...overrides}); const req = (overrides={}) => ({actionId:'ask',source:'Hello',settings:{...DEFAULT_SETTINGS},...overrides});
test('ready actions preserve source and hints, translation asks only when needed', () => { 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)})); 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/); 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]'));
});