Compare commits

...
Author SHA1 Message Date
zavage b987685d89 Merge pull request 'chore: release 1.2.0' (#14) from develop into main
Reviewed-on: https://git.zavage.net/Zavage-Software/search-with-chatgpt-powered-by-openai-extension/pulls/14
2026-10-18 02:55:38 -06:00
zavage 3f4b64374b Merge pull request 'chore: prepare the 1.2.0 release' (#13) from chore/release-1.2.0 into develop
Reviewed-on: https://git.zavage.net/Zavage-Software/search-with-chatgpt-powered-by-openai-extension/pulls/13
2026-10-18 02:52:32 -06:00
golem cf5e397d53 Merge remote-tracking branch 'origin/develop' into chore/release-1.2.0 2026-10-18 02:36:50 -06:00
golemandClaude Opus 5.5 8052c87c23 chore: prepare the 1.2.0 release
Version 1.2.0 (manifest "1.2"): the release adds features without
breaking changes, and 1.1.0 already exists in the changelog. The
lockfile's root version, still 1.0.0, moves with it.

Turn Unreleased into the 1.2.0 section, fold in the unheaded bullets
beneath it, add the template-limit entry, note that 1.1.0 never reached
addons.mozilla.org, and fix the stray ")" in the 1.0.0 heading. The
manifest description now covers the menu, toolbar button and shortcut.

Record in the design spec that ChatGPT auto-submits ?q= prompts again as
of 2026-10-18, and what that means for Appendix B's selection-smuggling
argument.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 02:27:54 -06:00
zavage 9f8a295a4f Merge pull request 'fix: limit the prompt template to 1,000 characters' (#12) from fix/template-length-limit into develop
Reviewed-on: https://git.zavage.net/Zavage-Software/search-with-chatgpt-powered-by-openai-extension/pulls/12
2026-10-18 02:26:43 -06:00
zavage 5a12663c92 Merge pull request 'refactor: remove the unused URL shortener' (#11) from refactor/remove-unused-shortener into develop
Reviewed-on: https://git.zavage.net/Zavage-Software/search-with-chatgpt-powered-by-openai-extension/pulls/11
2026-10-18 02:26:37 -06:00
zavage 384eca7589 Merge pull request 'ci: add a WIP-title gate to extension CI' (#10) from ci/8-wip-gate into develop
Reviewed-on: https://git.zavage.net/Zavage-Software/search-with-chatgpt-powered-by-openai-extension/pulls/10
2026-10-18 02:25:53 -06:00
zavage d96277ebd9 Merge pull request 'fix: cap huge selections before shortening' (#9) from fix/long-selection-cap into develop
Reviewed-on: https://git.zavage.net/Zavage-Software/search-with-chatgpt-powered-by-openai-extension/pulls/9
2026-10-18 02:25:44 -06:00
golemandClaude Opus 5.5 cc68fe98b5 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 <noreply@anthropic.com>
2026-10-18 02:13:06 -06:00
golemandClaude Opus 5.5 39a3ac74f4 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 <noreply@anthropic.com>
2026-10-18 02:09:04 -06:00
golemandClaude Opus 5.5 cb7da3a538 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 <noreply@anthropic.com>
2026-10-18 02:08:12 -06:00
golemandClaude Opus 5.5 519131c21f 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>
2026-10-18 01:32:22 -06:00
12 changed files with 143 additions and 195 deletions
+18
View File
@@ -2,13 +2,31 @@ name: Firefox Extension CI
on: on:
pull_request: pull_request:
# edited: removing the WIP: prefix starts the real run.
types: [opened, synchronize, reopened, edited]
push: push:
branches: branches:
- develop - develop
- main - 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: ${{ github.workflow }}-${{ github.ref }}
# Cancel superseded PR runs, never a push run: main's push uploads the package.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs: jobs:
validate: 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: >-
github.event_name != 'pull_request' || (
!startsWith(github.event.pull_request.title, 'WIP:') &&
!startsWith(github.event.pull_request.title, '[WIP]')
)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out repository - name: Check out repository
+16 -10
View File
@@ -2,28 +2,34 @@
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version). All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version).
## Unreleased ## [1.2.0] (2026-10-18)
The first update on addons.mozilla.org since 1.0. It also delivers everything
in 1.1.0, which was never published there.
- Add an Ask ChatGPT toolbar button: use selected text or open a blank chat, - Add an Ask ChatGPT toolbar button: use selected text or open a blank chat,
honoring saved prompt and tab/window preferences. No new permissions. honoring saved prompt and tab/window preferences. No new permissions.
Requires Firefox 112 or newer, like the other new entry points.
- Add Off / Simple / Advanced context-menu modes with migration from the old - Add Off / Simple / Advanced context-menu modes with migration from the old
visibility preference. Advanced provides selection presets and link/page URL visibility preference. Advanced provides selection presets and link/page URL
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
signing in and that an unsent prompt needs Enter or Send. signing in and that an unsent prompt needs Enter or Send.
- Limit the prompt template to 1,000 characters, so an oversized template can
no longer stop every other setting from saving.
* Options instructions now show the configured keyboard shortcut, update when - Show the configured keyboard shortcut in the options instructions, updating
it changes, and indicate when no shortcut is assigned. Added a button to when it changes and indicating when none is assigned, with a button to open
open Firefox’s shortcut editor where supported. Firefox’s shortcut editor where supported.
* Shortened the context menu label to “Ask ChatGPT”. - Shorten the context menu label to “Ask ChatGPT”.
* Added quick usage instructions and guidance for changing the search keyword - Add quick usage instructions and guidance for changing the search keyword
and default engine in Firefox to the options panel. and default engine in Firefox to the options panel.
* Added a “Feedback or report a bug” link to the options panel and README. - Add a “Feedback or report a bug” link to the options panel and README.
## [1.1.0] (2026-07-30) ## [1.1.0] (2026-07-30)
@@ -44,7 +50,7 @@ Adds an options panel and two new ways to reach ChatGPT.
* Permissions added: storage, menus, activeTab. None of them adds a line to * Permissions added: storage, menus, activeTab. None of them adds a line to
Firefox's install prompt. Firefox's install prompt.
## [1.0.0]) (2024-06-09) ## [1.0.0] (2024-06-09)
First release of software. First release of software.
@@ -96,6 +96,21 @@ send. The hypothesis that address-bar navigation might still be favoured
(`Sec-Fetch-Site: none` being the most-trusted value) **does not hold in (`Sec-Fetch-Site: none` being the most-trusted value) **does not hold in
practice**. practice**.
**Correction, 2026-10-18: `q` auto-submits again.**
- **Signed out:** in headless Firefox 157, `chatgpt.com/?q=` sent the prompt
and ChatGPT answered with no keypress. This happened even when an unrelated
page navigated to the URL (`Sec-Fetch-Site: cross-site`).
- **Maintainer's Firefox:** the same day, the maintainer reported that prompts
were sent without pressing Enter.
- **A week earlier:** on 2026-10-12 the signed-out path showed a verification
error and the signed-in path still needed Enter
(`docs/toolbar-verification.md`).
So this changed within a week and may differ between accounts. Treat both
behaviours as possible. The copy rule below still applies, and the README
already says ChatGPT decides whether a prompt is sent. Appendix B covers what
this means for selection smuggling.
No copy in the options panel, README, or AMO listing may say the query is No copy in the options panel, README, or AMO listing may say the query is
*searched*, *sent*, or *asked* — only that ChatGPT opens **with the query filled *searched*, *sent*, or *asked* — only that ChatGPT opens **with the query filled
in**. The two-keypress behaviour is an upstream ChatGPT change, not a defect in in**. The two-keypress behaviour is an upstream ChatGPT change, not a defect in
@@ -698,7 +713,7 @@ by running commands against copies under `/tmp`; the repo itself was unmodified.
| Parameter | Rating | Basis | | Parameter | Rating | Basis |
|---|---|---| |---|---|---|
| `q` prefills | **Solid** | Dated 2026 sources; maintained tooling; OpenAI's own extension uses it; a live probe returned HTTP 200 with all parameters preserved. | | `q` prefills | **Solid** | Dated 2026 sources; maintained tooling; OpenAI's own extension uses it; a live probe returned HTTP 200 with all parameters preserved. |
| `q` auto-submits | **Refuted, and confirmed by first-hand test** | Prefill-only in every dated report since 2025-11-11. Tenable TRA-2025-22 documents OpenAI gating auto-submit on `Sec-Fetch-Site`. Reproduced 2026-07-29 in the maintainer's logged-in Firefox via this extension's own search-provider path. | | `q` auto-submits | **Varies; auto-submitting again as of 2026-10-18** | Prefill-only in every dated report from 2025-11-11, when Tenable TRA-2025-22 documented OpenAI gating auto-submit on `Sec-Fetch-Site`, and reproduced 2026-07-29 in the maintainer's logged-in Firefox. On 2026-10-18 it auto-submitted signed out, including after a cross-site navigation, and the maintainer reported the same in their own Firefox. See the correction under the `q` section. |
| `model` honoured alongside `q` | **Uncertain** | An unresolved 2024-12-28 report says `q` forces GPT-4o regardless; counter-evidence exists from 2024–2025; no 2026 source positively confirms. Maintained tooling grades it experimental. | | `model` honoured alongside `q` | **Uncertain** | An unresolved 2024-12-28 report says `q` forces GPT-4o regardless; counter-evidence exists from 2024–2025; no 2026 source positively confirms. Maintained tooling grades it experimental. |
| Valid `model` slugs | **Uncertain** | No authoritative list; sightings rot (`gpt-4`, `o1-preview`, `gpt-4o`, `gpt-5-thinking`, `auto`, `instant`, `thinking`). | | Valid `model` slugs | **Uncertain** | No authoritative list; sightings rot (`gpt-4`, `o1-preview`, `gpt-4o`, `gpt-5-thinking`, `auto`, `instant`, `thinking`). |
| `temporary-chat=true` | **Uncertain** | Community-confirmed originally; multiple reports of it not applying or reverting after the first message; OpenAI reworking the feature. | | `temporary-chat=true` | **Uncertain** | Community-confirmed originally; multiple reports of it not applying or reverting after the first message; OpenAI reworking the feature. |
@@ -790,6 +805,16 @@ composer until a human presses Enter. **Auto-submit deletes that checkpoint.**
This is also why the context menu and shortcut remain safe *as long as they only This is also why the context menu and shortcut remain safe *as long as they only
prefill*. prefill*.
> **2026-10-18:** ChatGPT itself now auto-submits `?q=` prompts, at least for
> some accounts (see the correction under the `q` section). While that holds,
> the composer checkpoint above is gone for the context menu, toolbar button and
> shortcut alike: hidden text in a selection can reach an answered prompt
> without review. The extension still adds no submission of its own, and this
> appendix's rejection stands. 1.2.0 ships with the behaviour known. Possible
> mitigations, such as framing the default Ask prompt as data the way the preset
> actions already do, or an option to review page-sourced text first, are open
> follow-ups rather than part of this design.
**`webNavigation.transitionType` is not a security boundary.** The signal looks **`webNavigation.transitionType` is not a security boundary.** The signal looks
right — a `gpt` search emits `transitionType: "generated"` with right — a `gpt` search emits `transitionType: "generated"` with
`transitionQualifiers: ["from_address_bar"]`, and content cannot forge those `transitionQualifiers: ["from_address_bar"]`, and content cannot forge those
-1
View File
@@ -13,7 +13,6 @@ 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() };
} }
+3 -62
View File
@@ -12,9 +12,6 @@ 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 "";
@@ -42,65 +39,9 @@ function addOptionalParams(url, settings) {
return url; return url;
} }
function lastWhitespaceIndex(text) { // Encode a complete prompt without silently shortening it; prepareAction owns
for (let i = text.length - 1; i >= 0; i -= 1) { // the length budget and asks before shortening. q goes first, matching the
if (/\s/.test(text[i])) { // most-cited working examples.
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);
+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);
+2 -2
View File
@@ -1,10 +1,10 @@
{ {
"name": "Search with ChatGPT powered by OpenAI!", "name": "Search with ChatGPT powered by OpenAI!",
"description": "Adds a ChatGPT search engine", "description": "Adds a ChatGPT search engine, plus a right-click menu, toolbar button and shortcut that open ChatGPT with your selected text.",
"manifest_version": 2, "manifest_version": 2,
"author": "Mathew Guest <mat@zavage.net>", "author": "Mathew Guest <mat@zavage.net>",
"homepage_url": "https://zavage-software.com/portfolio/search-with-ChatGPT", "homepage_url": "https://zavage-software.com/portfolio/search-with-ChatGPT",
"version": "1.1", "version": "1.2",
"icons": { "icons": {
"16": "icons/powered-by-openai-icon-16x16.png", "16": "icons/powered-by-openai-icon-16x16.png",
"32": "icons/powered-by-openai-icon-32x32.png", "32": "icons/powered-by-openai-icon-32x32.png",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "search-with-chatgpt-powered-by-openai-extension", "name": "search-with-chatgpt-powered-by-openai-extension",
"version": "1.0.0", "version": "1.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "search-with-chatgpt-powered-by-openai-extension", "name": "search-with-chatgpt-powered-by-openai-extension",
"version": "1.0.0", "version": "1.2.0",
"devDependencies": { "devDependencies": {
"web-ext": "10.5.0" "web-ext": "10.5.0"
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "search-with-chatgpt-powered-by-openai-extension", "name": "search-with-chatgpt-powered-by-openai-extension",
"version": "1.1.0", "version": "1.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Development tooling for the Search with ChatGPT Firefox extension.", "description": "Development tooling for the Search with ChatGPT Firefox extension.",
-1
View File
@@ -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.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', () => {
+28 -110
View File
@@ -2,168 +2,86 @@ 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 { buildChatGptUrl, MAX_URL_CHARS } from "../lib/build-url.js"; import { applyTemplate, serializePrompt } 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( assert.equal(serializePrompt(DEFAULT_SETTINGS, "hello"), "https://chatgpt.com/?q=hello");
buildChatGptUrl(DEFAULT_SETTINGS, "hello"),
"https://chatgpt.com/?q=hello",
);
}); });
test("an empty or whitespace-only query omits q entirely", () => { test("an empty prompt omits q entirely", () => {
for (const raw of ["", " ", "\n\t", null, undefined]) { assert.equal(serializePrompt(DEFAULT_SETTINGS, ""), "https://chatgpt.com/");
const url = buildChatGptUrl(DEFAULT_SETTINGS, raw); for (const template of ["{query}", "Explain: {query}", "Be concise:", ""]) {
assert.equal(paramsOf(url).includes("q"), false, `raw=${JSON.stringify(raw)}`); assert.equal(applyTemplate(template, ""), "", `template=${JSON.stringify(template)}`);
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(buildChatGptUrl(withSettings({ model: "gpt-5.6-sol" }), "hi"), assert.equal(serializePrompt(withSettings({ model: "gpt-5.6-sol" }), "hi"),
buildChatGptUrl(DEFAULT_SETTINGS, "hi")); serializePrompt(DEFAULT_SETTINGS, "hi"));
}); });
test("temporaryChat and webSearch each appear only when enabled", () => { test("temporaryChat and webSearch each appear only when enabled", () => {
assert.equal(paramsOf(buildChatGptUrl(DEFAULT_SETTINGS, "hi")).includes("temporary-chat"), false); assert.deepEqual(paramsOf(serializePrompt(DEFAULT_SETTINGS, "hi")), ["q"]);
assert.equal(paramsOf(buildChatGptUrl(DEFAULT_SETTINGS, "hi")).includes("hints"), false);
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"); 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"); assert.equal(search.searchParams.get("hints"), "search");
}); });
test("all three URL-affecting settings combine, q first", () => { test("all URL-affecting settings combine, q first", () => {
const url = buildChatGptUrl( const settings = withSettings({ temporaryChat: true, webSearch: true, promptTemplate: "Explain: {query}" });
withSettings({ const url = serializePrompt(settings, applyTemplate(settings.promptTemplate, "entropy"));
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 = buildChatGptUrl(DEFAULT_SETTINGS, "hi"); const base = serializePrompt(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(buildChatGptUrl(withSettings({ openIn }), "hi"), base); assert.equal(serializePrompt(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 = buildChatGptUrl(DEFAULT_SETTINGS, raw); const url = serializePrompt(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", () => {
const url = buildChatGptUrl(withSettings({ promptTemplate: "{query} / {query}" }), "x"); assert.equal(applyTemplate("{query} / {query}", "x"), "x / 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", () => {
const url = buildChatGptUrl(withSettings({ promptTemplate: "Be concise:" }), "why"); assert.equal(applyTemplate("Be concise:", "why"), "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 promptTemplate of ["", " "]) { for (const template of ["", " "]) {
assert.equal(queryOf(buildChatGptUrl(withSettings({ promptTemplate }), "why")), "why"); assert.equal(applyTemplate(template, "why"), "why");
} }
}); });
test("a non-string template degrades to the bare query", () => { test("a non-string template degrades to the bare query", () => {
assert.equal(queryOf(buildChatGptUrl(withSettings({ promptTemplate: null }), "why")), "why"); assert.equal(applyTemplate(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`.
test("a selection containing $& survives the default {query} template byte-identically", () => { for (const raw of ["awk print $& here", "shell $` and $' here", "PID is $$ in bash"]) {
const raw = "awk print $& here"; test(`a selection containing ${raw.match(/\$./g).join(" and ")} survives templating byte-identically`, () => {
const url = buildChatGptUrl(DEFAULT_SETTINGS, raw); assert.equal(applyTemplate("{query}", raw), raw);
assert.equal(queryOf(url), raw); assert.equal(applyTemplate("Explain: {query}", raw), `Explain: ${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}`);
}); });
}
+31
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', () => {
@@ -23,6 +24,17 @@ 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};
@@ -38,3 +50,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]'));
});