Author SHA1 Message Date
zavage 496c2c3ea4 Merge branch 'develop' into feat/extension-options-panel 2026-09-07 21:59:14 -06:00
golem 17bccc1ade fix: derive the model maxlength and document the frame-focus cases
maxlength was hard-coded to 200 in the markup while MAX_MODEL_CHARS lives
in lib/defaults.js; lowering the constant would have left the input
accepting values mergeSettings() silently discards. It is now set from the
constant at render time.

Also records the hasFocus() gate's four manual frame cases in AGENTS.md,
including the findbar trade-off, and notes that fitToBudget()'s budget
guarantee assumes mergeSettings-validated input.
2026-08-01 23:21:45 -06:00
golem 8fcc93c541 doc: record the $-pattern and frame-focus corrections
The spec prescribed replaceAll's string form and the plan hard-coded it,
so the $-substitution defect originated in the design documents rather
than in execution. Both now specify the function replacer.

Also replaces the spec's frame-reporting idea with the document.hasFocus()
gate that shipped; frame provenance was only ever needed for auto-submit,
which Appendix B rejects.
2026-08-01 23:17:23 -06:00
golem 51bf4d5c01 fix: prefer the focused frame, bound model length, drop dead flag 2026-08-01 23:15:46 -06:00
golem d7821e3dc7 fix: stop $ sequences in selected text corrupting the prompt 2026-08-01 23:15:39 -06:00
golem 1106f4e315 doc: correct the README npm test description 2026-08-01 23:00:23 -06:00
golemandClaude Opus 5 dc5bd8e00d doc: document options panel, entry points, and the ChatGPT prefill limitation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:53:26 -06:00
golem 5548216645 doc: sync plan options-panel code with the shipped fixes
Records the doctype, the render() activeElement guard, and the blur in the
Restore handler. storage.onChanged fires for this page's own writes, so
every autosave re-entered the listener and could overwrite in-progress
typing; the guard closes that.
2026-08-01 22:51:15 -06:00
golem 5e56503244 fix: add doctype and stop self-echoed saves overwriting active edits 2026-08-01 22:47:20 -06:00
golem b42b11755c feat: add options panel embedded in about:addons 2026-08-01 22:35:17 -06:00
golem 6bedd700e8 doc: record the omitted strict_min_version and rejection guards
The plan pinned strict_min_version to 112.0 for the ES-module background
floor, but the existing data_collection_permissions key requires Firefox
140 desktop / 142 Android, so addons-linter warned and warningsAsErrors
failed the build. Measured: 112.0 -> 2 warnings, 140.0 -> 1, 142.0 ->
clean, omitted -> clean. 142.0 was rejected because it locks out ESR 140.
Omitting excludes nobody and degrades gracefully below 112.

Also syncs the plan's background.js block with the rejection guards added
in 22fc754, and reworks two of those comments for clarity.
2026-08-01 22:33:29 -06:00
golem 22fc754636 fix: swallow rejections on the tab-open and menu-registration paths 2026-08-01 22:30:37 -06:00
golem f683f2e841 feat: add context menu and keyboard shortcut entry points 2026-08-01 22:21:06 -06:00
golem 5365ec881e feat: add pure ChatGPT URL builder with unit tests 2026-08-01 22:04:31 -06:00
golem c49f6415af feat: add settings defaults and validation with unit tests 2026-08-01 21:59:47 -06:00
golemandClaude Opus 5 b59648d900 doc: add implementation plan for the options panel
Five tasks, each ending in an independently testable deliverable: tooling
plus settings validation, the pure URL builder, manifest and background
wiring, the options panel, then docs and release metadata.

Complete code for every file, 27 unit tests written out in full, and exact
commands with expected output. Global Constraints carries the spec's
verified values verbatim -- the exact permission set, strict_min_version
112.0, MAX_URL_CHARS 8000, and the copy rules that keep user-facing text
honest about ChatGPT only prefilling.

Records the traps verification found so an implementer cannot walk into
them: executeScript resolving silently to [null] on parent-process about:
pages, commands.getAll() reporting a shortcut registered when Firefox has
overridden it, and node --test exiting 0 with zero tests discovered.

Closes with an out-of-scope list so a well-meaning implementer does not
re-add auto-submit or omnibox, and a pre-PR reminder about the open AMO
data-collection question.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:00:35 -06:00
golemandClaude Opus 5 3d5ce6a9f4 doc: revise options spec after empirical verification
Two verification passes, driving real Firefox 153.0.1 under Xvfb rather than
reading docs, changed several load-bearing decisions.

Added back: the keyboard shortcut now acts on selected text. activeTab adds
no install-prompt line (confirmed by running Firefox's own
formatPermissionStrings: ["activeTab","storage","menus"] yields msgs: []),
and ext-commands.js grants activeTab before firing onCommand. Records the
three failure shapes of executeScript, one of which resolves silently to
[null] on parent-process about: pages, and that commands.getAll() reports a
suggested_key as registered even when Firefox has silently overridden it.

Rejected with evidence, in Appendix B: auto-submitting the prompt. The
scheme authenticated navigation provenance when auto-submit needs text
provenance, and a hostile page can smuggle ~16 KB of invisible instructions
into info.selectionText. webNavigation.transitionType is not a security
boundary either -- the marker is consumed on commit, so an uncommitted
urlbar navigation leaves it armed for the next navigation to inherit
(demonstrated at 10.47s), and its freshness guard is dead code. Prefill-only
keeps the human checkpoint that makes the context menu safe.

Also rejected: a bundled extension page as search_url (Firefox refuses to
install), and browser.omnibox -- which works and needs no permissions, but
moves the transmitter of search terms from Firefox to extension code, the
change that puts data_collection_permissions ["none"] at risk. Declined to
minimise AMO exposure.

New Risk 6 records the residual AMO question and that it should be asked
before submission, since a forced searchTerms declaration would add the
install line this design exists to avoid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 07:51:43 -06:00
zavage a005b642ca Merge pull request 'build: add CLAUDE.md pointing at AGENTS.md' (#3) from build/add-claude-code-guidance into develop
Firefox Extension CI / validate (push) Canceled after 0s
Reviewed-on: https://git.zavage.net/Zavage-Software/search-with-chatgpt-powered-by-openai-extension/pulls/3
2026-07-29 22:38:50 -06:00
golemandClaude Opus 5 f057c66155 doc: add extension options panel design spec
Design for an options panel reachable from about:addons, configuring a
context-menu entry on selected text and an Alt+Shift+G shortcut. Adds
storage and menus permissions only, both in Firefox's no-prompt class, so
the install dialog gains no lines and data_collection_permissions stays
"none". The gpt <query> search provider is deliberately untouched.

Technical claims were verified rather than assumed: MV2 background
type:"module" works from Firefox 112 (tested in 153.0.1), so no background
HTML shim is needed; package.json needs "type":"module" or node --test
fails on Node 22.0-22.6; the ChatGPT model/hints/temporary-chat params are
undocumented and unreliable, so every label is written at hint strength.
q= no longer auto-submits, only prefills.

Also excludes docs/ and test/ from the packaged ZIP. Verified: without
this the spec doc itself shipped inside the extension, and a bare glob
alone leaves empty directory entries behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 20:35:25 -06:00
golemandClaude Opus 5 fb5b7c5539 build: add CLAUDE.md pointing at AGENTS.md
Claude Code reads CLAUDE.md by convention. Rather than duplicate the
contributor guidance already in AGENTS.md, this is a thin pointer so
there is a single source of truth and no drift between the two.

Because web-ext-config.mjs uses sourceDir "." and packages by exclusion,
CLAUDE.md is added to ignoreFiles alongside AGENTS.md and README.md so it
is not shipped inside the extension ZIP.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 19:03:15 -06:00
zavage 5edb288681 Merge pull request 'GPT-2: add Firefox development foundation' (#2) from build/firefox-development-foundation into develop
Reviewed-on: https://git.zavage.net/Zavage-Software/search-with-chatgpt-powered-by-openai-extension/pulls/2
2026-07-29 16:18:17 -06:00
golem 457d979fb2 build: add Firefox development foundation 2026-07-29 16:13:35 -06:00
22 changed files with 7518 additions and 24 deletions
+36
View File
@@ -0,0 +1,36 @@
name: Firefox Extension CI
on:
pull_request:
push:
branches:
- develop
- main
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Install dependencies
run: npm ci
- name: Lint and build
run: npm test
- name: Upload Firefox package
if: ${{ gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' }}
uses: actions/upload-artifact@v4
with:
name: firefox-extension-${{ gitea.sha }}
path: web-ext-artifacts/*.zip
if-no-files-found: error
retention-days: 30
+4 -2
View File
@@ -1,2 +1,4 @@
.idea
.idea/
node_modules/
web-ext-artifacts/
npm-debug.log*
+92
View File
@@ -0,0 +1,92 @@
# Repository Guidelines
## Project Structure & Module Organization
This is a Firefox WebExtension (Manifest V2) with npm-based development tooling.
- `manifest.json` defines the extension metadata, the ChatGPT search provider,
the background script, the options panel, and the keyboard command.
- `lib/` holds pure logic with no browser dependencies: `defaults.js`,
`build-url.js`, and `settings.js`. This is the unit-tested core.
- `background/background.js` is an ES-module background script that registers
the context menu and the keyboard command.
- `options/` is the options panel rendered inline in `about:addons`.
- `test/` holds `node:test` unit tests. Excluded from the packaged ZIP.
- `icons/` contains size-specific PNG artwork named `powered-by-openai-icon-<size>x<size>.png`.
- `web-ext-config.mjs` controls linting, local execution, and ZIP packaging.
- `docs/superpowers/` holds specs and plans. Excluded from the packaged ZIP.
- `.gitea/workflows/firefox-extension.yml` validates pull requests and protected branches.
- `README.md`, `CHANGELOG.md`, and `LICENSE.txt` cover usage, releases, and licensing.
## Browser Scope
Firefox Desktop is the only current implementation and release target. Do not add Chrome, Brave, Chromium, or other browser packaging—or modify the sibling Chrome repository—unless a task explicitly requests it. Future ports remain desirable, so prefer standard WebExtension APIs where they preserve Firefox behavior and isolate unavoidable Firefox-specific code.
## Build, Test, and Development Commands
- `npm ci` installs the exact dependencies from `package-lock.json`; use Node.js 22 or newer.
- `npm run dev` launches a temporary Firefox profile with automatic extension reload.
- `npm run lint` runs strict Mozilla extension validation.
- `npm run test:unit` runs the `node:test` unit tests over `test/`.
- `npm run build` creates the unsigned ZIP under `web-ext-artifacts/`.
- `npm test` runs lint, unit tests, and build together; run it before every pull request.
## Coding Style & Naming Conventions
Use two-space indentation in JSON, YAML, and JavaScript configuration. Preserve logical key grouping in `manifest.json`. Use lowercase, hyphenated filenames and include dimensions in icon names. Keep changes focused and avoid adding runtime dependencies without a concrete need.
## Testing Guidelines
Unit tests cover the pure logic in `lib/` via `node:test`; automated checks
otherwise cover manifest validation and packaging, not browser behaviour. Note
`node --test` exits 0 when it discovers no test files, so confirm a non-zero
test count rather than trusting the exit code alone.
After `npm test`, use `npm run dev` and confirm:
- `gpt <query>` opens ChatGPT, the provider stays non-default, and Firefox
search settings can configure it.
- The Options/Preferences tab renders all five settings plus Restore defaults,
in both light and dark themes and at a narrow window width, and values
persist across a restart.
- The right-click **Ask ChatGPT** item appears only when text is selected.
- `Alt+Shift+G` acts on the selection and opens a blank chat when nothing is
selected or on a restricted page such as `about:config`. Press the key to test
this — `commands.getAll()` reports a shortcut as registered even when Firefox
has silently overridden it. The injected snippet is gated on
`document.hasFocus()`, so cover all four frame cases:
- Select in the top frame → the top-frame text arrives.
- Select inside an iframe → the iframe text arrives.
- Select in the top frame, *then* select inside an iframe → the iframe text
arrives. `hasFocus()` is true for a focused frame and all its ancestors, so
if the stale top-frame text arrives instead, exclude ancestors by also
checking that `document.activeElement` is not the frame element.
- Press `Ctrl+F`, find a term, then press the shortcut without clicking back
into the page → a blank chat is expected. Firefox leaves focus in the
findbar, so no content frame reports focus. This is a deliberate trade: a
stale cross-origin frame silently seeding the prompt was the worse failure.
Inspect ZIP contents whenever packaging rules or assets change; `test/` and
`docs/` must never appear, not even as empty directory entries.
Gitea Actions runs checks for every pull request and pushes to `develop` and
`main`. Only successful `main` pushes upload an unsigned build artifact; CI does
not publish to AMO.
## Commit & Pull Request Guidelines
Recent history uses short, lowercase prefixes such as `doc:` and `build:` (for example, `doc: fix git url`). Follow `<type>: <concise summary>` and keep each commit focused.
Pull requests should explain the user-visible effect, list manual Firefox checks, and link the relevant issue. Include screenshots for icons or browser-visible metadata. Explicitly call out permission, search URL, privacy, and packaging changes.
## Security & Privacy
Do not commit credentials or API keys. New permissions, telemetry, intermediary servers, or data collection require explicit justification and matching privacy documentation.
Permissions are deliberately limited to `storage`, `menus`, and `activeTab`,
which add no line to Firefox's install prompt. Do not add `tabs`,
`notifications`, `webNavigation`, or any host permission without re-reading
`docs/superpowers/specs/2026-07-29-extension-options-design.md` — each was
considered and rejected there, and Appendix B records a feature rejected on
security grounds that must not be revived without clearing the bar documented
with it.
+19
View File
@@ -2,6 +2,25 @@
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).
## [1.1.0] (2026-07-30)
Adds an options panel and two new ways to reach ChatGPT.
* Options panel in `about:addons` with five settings: model (experimental),
temporary chat, web-search hint, where results open, and a prompt template.
* Right-click selected text and choose "Ask ChatGPT" to open ChatGPT with that
text filled in.
* Keyboard shortcut `Alt+Shift+G` does the same for the current selection, and
opens an empty chat when nothing is selected. Rebindable via Manage Extension
Shortcuts.
* Documented that ChatGPT no longer submits prompts passed in a URL, so a
second Enter is needed. This is an upstream change to chatgpt.com.
* `gpt <query>` address-bar search is unchanged.
* The options panel, right-click item, and keyboard shortcut require Firefox
112 or newer. Older versions keep the `gpt <query>` search unchanged.
* Permissions added: storage, menus, activeTab. None of them adds a line to
Firefox's install prompt.
## [1.0.0]) (2024-06-09)
First release of software.
+7
View File
@@ -0,0 +1,7 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
See `AGENTS.md` for all repository guidance: project structure, browser scope,
build/test/development commands, coding style, testing, commit and pull request
conventions, and security and privacy expectations.
+93 -14
View File
@@ -1,20 +1,99 @@
Search with ChatGPT Powered by OpenAI Firefox Extension
=======================================================
# Search with ChatGPT Powered by OpenAI Firefox Extension
Extensions adds ChatGPT as a search engine configurable option
in the browser. Default prefix is "gpt <query term>" or can be set
in the settings. Does not require login, or API key, or use custom servers
or code. Simply queries your url bar into a new ChatGPT session.
This extension adds ChatGPT as a configurable Firefox search provider. Type
`gpt <query>` in the address bar to open the query in a new ChatGPT session.
It uses no API key, custom server, or intermediary handler.
Unofficial - Provided by Zavage Software Inc.
This is an unofficial extension provided by Zavage Software Inc.
See LICENSE.txt.
- [Firefox Add-ons listing](https://addons.mozilla.org/en-US/firefox/addon/search-with-chatgpt/)
- [Project repository](https://git-repos.zavage.net/zavage-software/search-with-chatgpt-powered-by-openai-extension)
- [Zavage Software](https://zavage-software.com)
* https://zavage-software.com
* https://git-repos.zavage.net/zavage-software/search-with-chatgpt-powered-by-openai-extension
See `LICENSE.txt` for licensing terms.
Firefox extension page: https://addons.mozilla.org/en-US/firefox/addon/search-with-chatgpt/
# Privacy Policy
## Options
This extension collects zero user data.
Open `about:addons`, select this extension, and choose the **Options** tab
(**Preferences** on macOS and Linux). Available settings:
- **Model** *(experimental)* — a model slug such as `auto`. ChatGPT may ignore
it and use your account default; leave blank to always use the account
default.
- **Request a temporary chat** — asks ChatGPT not to save the conversation.
ChatGPT may not honour this; check its own indicator before relying on it.
- **Ask ChatGPT to use web search** — a hint to ChatGPT's interface, not a
guarantee.
- **Open in** — a new tab, a new background tab, or a new window.
- **Prompt template** — wraps your text, for example `Explain simply: {query}`.
These settings apply to the new entry points: selecting text and choosing
**Ask ChatGPT** from the right-click menu, or pressing
<kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>G</kbd>. Rebind the shortcut under
**Manage Extension Shortcuts** in the Add-ons gear menu.
The `gpt <query>` address-bar search is unchanged and does not read these
settings.
The options panel, the right-click item, and the keyboard shortcut require
Firefox 112 or newer. On older versions the extension still installs and the
`gpt <query>` address-bar search still works; only these newer features are
inactive.
## Why you press Enter twice
ChatGPT no longer submits prompts that are passed to it in a URL — it fills them
into the composer and waits. So `gpt <query>` opens ChatGPT with your query
filled in, and you press Enter there to send it.
This is a change OpenAI made to chatgpt.com, not a fault in this extension, and
it is outside our control. It was a deliberate security measure: automatically
running a prompt that arrived in a link let malicious links execute instructions
in a signed-in ChatGPT session. The extra keypress is the checkpoint that
prevents that. No API key, server, or intermediary is involved either way.
## Browser Support
Firefox Desktop is the current development and release target. Chrome, Brave,
other Chromium browsers, and additional WebExtension platforms may be evaluated
after the Firefox feature set is more substantial; they are not current targets.
## Development
Install Node.js 22 or newer, Firefox, and the locked development dependencies:
```sh
npm ci
```
Available commands:
- `npm run dev` launches the extension in a temporary Firefox profile and
reloads it when files change.
- `npm run lint` validates the extension and treats warnings as errors.
- `npm run test:unit` runs the `node:test` unit tests over `test/`.
- `npm run build` creates
`web-ext-artifacts/search-with-chatgpt-firefox-<version>.zip`.
- `npm test` runs the same lint, unit test, and build checks used by Gitea
Actions.
The generated ZIP is unsigned. Use `npm run dev` for local testing; signing and
submission to Mozilla Add-ons remain a separate release step.
Before submitting a change, confirm that `gpt <query>` opens
`https://chatgpt.com?q=<query>`, the provider remains non-default, and it can be
configured in Firefox search settings.
## Privacy Policy
This extension has no server, no API key, and no intermediary. Nothing is
reported to Zavage Software.
- Your settings are stored in your own browser profile. If you use Firefox Sync
with **Add-ons** enabled, they sync end-to-end encrypted through your own
Mozilla account.
- When you choose **Ask ChatGPT** or press the shortcut, the text you selected
is placed in the chatgpt.com URL the extension opens for you. It goes nowhere
else.
- The extension reads a page's selected text only at the moment you invoke it,
and never reads page addresses or titles.
+120
View File
@@ -0,0 +1,120 @@
import { load } from "../lib/settings.js";
import { buildChatGptUrl } from "../lib/build-url.js";
const MENU_ID = "ask-chatgpt-selection";
const COMMAND_ID = "ask-chatgpt";
// Reads the user's selection for the keyboard-shortcut path. The context menu
// does not need this — it receives info.selectionText directly.
//
// A commands invocation confers activeTab (ext-commands.js grants it before
// firing onCommand), so executeScript works with no host permission.
//
// Every frame keeps its own independent Selection, and a selection made in a
// non-focused frame is not cleared when the user selects elsewhere. Without a
// filter, a stale selection in an unfocused (possibly cross-origin) iframe
// could win over — or be pulled alongside — the selection the user actually
// made, with the winner among multiple non-empty results decided by an
// executeScript ordering that isn't specified. The injected snippet is
// therefore gated on document.hasFocus(), which is true for the focused
// document and all of its ancestors: the top frame wins when the user
// selected there, and an iframe's selection is only picked up when it is
// itself the focused frame.
//
// Failure has three measured shapes, all handled here: a thrown
// "Missing host permission for the tab", a thrown variant naming frames, and a
// SILENT resolution to [null] on parent-process about: pages. Never index into
// a result without a null check.
async function readSelection() {
let tabs;
try {
tabs = await browser.tabs.query({ active: true, currentWindow: true });
} catch {
return "";
}
// Only tab.id may be read. Reading tab.url or tab.title would require the
// "tabs" permission, which does add an install-prompt warning line.
const tabId = tabs?.[0]?.id;
if (typeof tabId !== "number") {
return "";
}
let results;
try {
results = await browser.tabs.executeScript(tabId, {
code: "document.hasFocus() ? window.getSelection().toString() : ''",
allFrames: true,
matchAboutBlank: true,
});
} catch {
return "";
}
if (!Array.isArray(results)) {
return "";
}
for (const result of results) {
if (typeof result === "string" && result.trim() !== "") {
return result;
}
}
return "";
}
async function openChatGpt(query) {
try {
const settings = await load();
const url = buildChatGptUrl(settings, query);
if (settings.openIn === "new-window") {
try {
await browser.windows.create({ url });
return;
} catch {
// Fall through to a tab rather than doing nothing.
}
}
await browser.tabs.create({ url, active: settings.openIn !== "background-tab" });
} catch {
// Nothing useful to say and nowhere useful to say it: the user expects a
// tab or nothing at all. Both call sites invoke this without awaiting, so
// without this catch a failure would log an unhandled rejection.
}
}
// removeAll() first, so a background event page waking up and re-running this
// module cannot throw on a duplicate menu id.
async function registerMenu() {
await browser.menus.removeAll();
browser.menus.create({
id: MENU_ID,
title: 'Ask ChatGPT about "%s"',
contexts: ["selection"],
});
}
browser.menus.onClicked.addListener((info) => {
if (info.menuItemId !== MENU_ID) {
return;
}
openChatGpt(info.selectionText ?? "");
});
// browser.commands does not exist on Firefox for Android. This extension is
// desktop-only, but guard rather than throwing at startup.
if (browser.commands) {
browser.commands.onCommand.addListener(async (command) => {
if (command !== COMMAND_ID) {
return;
}
openChatGpt(await readSelection());
});
}
registerMenu().catch(() => {
// menus.removeAll() is promise-based and can reject; a failed re-registration
// cannot be retried usefully from here. (menus.create() is callback-based and
// cannot reject, so it needs no guard.)
});
-7
View File
@@ -1,7 +0,0 @@
#!/bin/bash
zip -r chatgpt-search-engine-extension.zip \
LICENSE.txt \
manifest.json \
icons/favicon.png
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,840 @@
# Extension Options Panel — Design
- **Date:** 2026-07-29
- **Status:** Approved for planning
- **Branch:** `feat/extension-options-panel` (cut from `develop` @ `5edb288`)
## Summary
Add an options panel to the extension, reachable from `about:addons` → the
extension's detail card → the **Options** tab (Windows) / **Preferences** tab
(macOS, Linux). The panel configures two new entry points that this change also
introduces: a context-menu item on selected text, and a keyboard shortcut that
does the same for the current selection, falling back to a fresh empty chat when
nothing is selected.
The existing `gpt <query>` address-bar search provider is **not** modified and
does not read the settings. See [Why the search provider is untouched](#why-the-search-provider-is-untouched).
New permissions: `storage`, `menus`, and `activeTab`. All three are in Firefox's
no-prompt class, so the install dialog gains no new lines and
`data_collection_permissions: ["none"]` remains truthful.
## Goals
1. Ship a working options panel embedded in `about:addons`.
2. Ship two new entry points whose behaviour the panel controls.
3. Add no permission-warning lines to the install prompt.
4. Keep `data_collection_permissions: ["none"]` accurate.
5. Introduce unit testing for the pure logic, without new runtime dependencies.
6. Never promise behaviour that ChatGPT does not reliably deliver.
## Non-goals
- Making the `gpt <query>` search provider configurable. The declarative
`search_url` is fixed at install time, and the three routes around that were
each rejected: host permissions plus navigation interception (conflicts with
goals 3 and 4), a bundled extension page as `search_url` (Firefox refuses to
install), and `browser.omnibox` (works, but moves the transmitter of search
terms from Firefox to extension code — see Risk 6). `gpt <query>` keeps
behaving exactly as it does today.
- Auto-submitting the prompt on the user's behalf. Rejected on security grounds
after dedicated adversarial testing; see
[Appendix B](#appendix-b-why-auto-submit-was-rejected).
- Moving the `gpt` keyword to `browser.omnibox`. Deliberately declined to
minimise AMO data-collection exposure, not for lack of a working mechanism.
- A toolbar button.
- Named/multiple prompt templates with add-remove-reorder UI. One template only.
- Migrating to Manifest V3.
- Chrome or other-browser packaging (`AGENTS.md` browser scope).
- User-facing error notifications. The `notifications` permission *does*
generate an install warning, so every failure path degrades silently instead.
## Background and constraints
The extension is currently manifest-only: no JavaScript exists, and the
manifest declares no `background`, `content_scripts`, or
`web_accessible_resources` key. Its single feature is
`chrome_settings_overrides.search_provider` with
`search_url: "https://chatgpt.com?q={searchTerms}"`.
### Why the search provider is untouched
`chrome_settings_overrides.search_provider` is declarative and fixed at install
time. No WebExtension API can rewrite an installed engine's URL. Making
`gpt <query>` honour the settings would require intercepting the navigation it
starts, which needs host access to `chatgpt.com` plus `webRequest` or `tabs`
all of which add install-prompt lines. Rejected in favour of goals 3 and 4.
`is_default` stays `false`. Setting it to `true` triggers a second, separate
consent doorhanger (`webext-default-search-description`) outside any permission
list, plus an allowlist requirement.
### ChatGPT URL parameters are undocumented
None of `q`, `model`, `hints`, or `temporary-chat` is documented by OpenAI.
There is no contract and no support channel. The design is therefore resilient
by construction: the URL is built from a template with no runtime dependency on
any parameter being honoured, each parameter is independently removable so a
user can bisect when ChatGPT ignores one, and all user-facing copy is written at
hint strength. See [Appendix A](#appendix-a-verification-findings) for the
evidence behind each parameter's confidence rating.
The one parameter with quasi-official standing is `q`, because OpenAI's own
"ChatGPT search" browser extension uses `https://chatgpt.com/?q=`. Build on it;
treat everything else as best-effort.
**`q` prefills but does not submit — confirmed for this extension.** Auto-submit
was real historically but has been prefill-only since approximately November
2025, after OpenAI gated it on the `Sec-Fetch-Site` header as a prompt-injection
mitigation.
Verified on 2026-07-29 in the maintainer's own logged-in Firefox profile, on
this extension's actual navigation path: the prompt lands in the composer and
waits. The user presses Enter twice — once to leave the address bar, once to
send. The hypothesis that address-bar navigation might still be favoured
(`Sec-Fetch-Site: none` being the most-trusted value) **does not hold in
practice**.
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
in**. The two-keypress behaviour is an upstream ChatGPT change, not a defect in
this extension, and is documented as such rather than worked around; see
[Rejected alternatives](#rejected-alternatives).
## Architecture
Manifest stays **V2**. Mozilla has not announced any MV2 deprecation and
commits to at least 12 months' notice. The repo is MV2 today and has no Chrome
target, so migration is an orthogonal change. The code is nonetheless kept
MV3-portable: ES-module background, `persistent: false`, no `browser_style: true`.
ES modules work in an MV2 background script via
`"background": {"scripts": [...], "type": "module"}`, supported since Firefox
112 and verified empirically in Firefox 153.0.1. No background HTML shim is
needed. The options page is a normal extension page and can use
`<script type="module">` in both the embedded and standalone surfaces.
`strict_min_version` is **omitted**, as it is today. The functional floor is
Firefox 112 — above `storage.sync` quota enforcement (79), `menus` (55),
`options_ui` (55), and `commands` (48) — but it cannot be declared. The
existing `data_collection_permissions` key requires Firefox 140 (desktop) and
142 (Android), so `addons-linter` emits `KEY_FIREFOX_UNSUPPORTED_BY_MIN_VERSION`
for any `strict_min_version` below those, and under `warningsAsErrors: true`
that fails the build. Measured against `web-ext lint` 10.5.0: `112.0` → 2
warnings, `140.0` → 1 warning (Android), `142.0` → clean, omitted → clean.
Pinning `142.0` would lint clean but lock out Firefox 140, 141, and ESR 140 —
the current ESR, which supports `data_collection_permissions` and is what
Debian and many enterprise deployments ship. Omitting the key excludes nobody
and degrades gracefully below 112: `chrome_settings_overrides` is declarative,
so the `gpt` keyword keeps working and only the context menu and shortcut are
inert. Firefox 112 shipped April 2023.
### File layout
```
manifest.json
background/
background.js # registers menu + command, handles clicks. Imports lib/.
lib/
defaults.js # DEFAULT_SETTINGS — single source of truth
build-url.js # PURE: (settings, rawQuery) -> url string
settings.js # mergeSettings() pure; load()/save() wrap browser.storage
# load() -> storage.sync.get() piped through mergeSettings()
# save() -> storage.sync.set(), debounced by the caller
options/
options.html
options.css
options.js
test/
build-url.test.js
settings.test.js
docs/superpowers/specs/
2026-07-29-extension-options-design.md
```
`lib/build-url.js` and `lib/defaults.js` have zero browser dependencies, so
`node:test` imports them directly with no stubbing. `lib/settings.js` separates
the pure `mergeSettings()` from the `browser.storage` I/O for the same reason;
only `mergeSettings()` is unit-tested.
`lib/` ships inside the ZIP because the background script imports it at runtime.
`test/` and `docs/` must not ship — see [Packaging](#packaging).
### Manifest additions
```json
"permissions": ["storage", "menus", "activeTab"],
"background": {
"scripts": ["background/background.js"],
"type": "module",
"persistent": false
},
"options_ui": {
"page": "options/options.html",
"open_in_tab": false,
"browser_style": false
},
"commands": {
"ask-chatgpt": {
"suggested_key": { "default": "Alt+Shift+G" },
"description": "Ask ChatGPT about the selected text"
}
}
```
`browser_specific_settings.gecko` is unchanged — the existing `id` and
`data_collection_permissions` stay exactly as they are, and no
`strict_min_version` is added (see above).
`browser_style: false` is set **explicitly**. Omitting it is not equivalent — it
defaults to `true` in MV2. Setting it false means the panel renders identically
if the extension ever moves to MV3, where `browser_style: true` is unsupported.
`menus` is the canonical Firefox permission name; `contextMenus` is a
cross-browser alias. Use `menus` and access the API as `browser.menus`.
`Alt+Shift+G` is deliberate. `Ctrl+Shift+G` is Firefox's Find Previous — it
would validate, display in `about:addons`, and never fire.
## Settings
Stored as individual keys in `browser.storage.sync`.
| Key | Type | Default | Maps to |
|---|---|---|---|
| `model` | string | `""` | `model=<value>`, omitted when empty |
| `temporaryChat` | boolean | `false` | `temporary-chat=true` when set |
| `webSearch` | boolean | `false` | `hints=search` when set |
| `openIn` | `"new-tab"` \| `"background-tab"` \| `"new-window"` | `"new-tab"` | tab/window creation |
| `promptTemplate` | string | `"{query}"` | wraps the selection |
Defaults reproduce today's behaviour exactly: an empty `model` and both
toggles off mean the only parameter sent is `q`.
### Model
A **free-text field**, not a dropdown. No authoritative slug list exists and
every published one has rotted (`gpt-4``o1-preview``gpt-4o`
`gpt-5-thinking``auto`/`instant`/`thinking`). A curated dropdown would be
wrong within a quarter, and each stale entry fails *silently* to the account
default.
- Label: **Model (experimental)**
- Placeholder: `auto`
- Help text: "ChatGPT may ignore this and use your account default. Leave blank
to always use your account default."
- Default is empty, not `auto`, so the default configuration sends no `model`
parameter and matches current behaviour.
### Temporary chat
- Label: **Request a temporary chat**
- Help text: "ChatGPT may not honour this. Check ChatGPT's own indicator before
sending anything sensitive."
- Default **off**.
- The words *private*, *incognito*, and *secure* must not appear near this
setting. Reports indicate chats can revert to permanent after the first
message, and OpenAI is actively reworking the feature's semantics.
### Web search
- Label: **Ask ChatGPT to use web search (hint only)**
- Help text: "A hint to ChatGPT's interface, not a guarantee."
- Default **off**. The value is hard-coded to `search`; no mode dropdown.
### Open in
- Label: **Open in**
- Options: `A new tab` / `A new background tab` / `A new window`
- Default `A new tab`.
### Prompt template
- Label: **Prompt template**
- Default `{query}`
- Help text: "`{query}` is replaced with your selected text."
### Panel behaviour
Auto-save on change with a transient "Saved" indicator; no Save button. Writes
are debounced at 400 ms so typing in a text field cannot approach any
write-rate quota.
A **Restore defaults** button resets every key to `DEFAULT_SETTINGS`.
The panel subscribes to `browser.storage.sync.onChanged` and updates its
controls live, so a remote sync landing while the panel is open does not get
clobbered by stale field values.
The embedded surface inside `about:addons` is narrow and inherits that page's
layout. The panel must therefore be responsive down to roughly 400 px, must not
depend on window sizing, and must remain usable as a standalone page — the
`extensions.htmlaboutaddons.inline-options.enabled` pref can degrade it to a
tab. Because `browser_style` is off, the panel is hand-styled and **must
support dark theme via `prefers-color-scheme`**; `about:addons` follows the
system theme.
## URL construction
`buildChatGptUrl(settings, rawQuery) -> string` in `lib/build-url.js`. Pure.
```
BASE = "https://chatgpt.com/"
MAX_URL_CHARS = 8000
```
The root path is mandatory. `chatgpt.com/search?q=` began returning 404 in
mid-2025; the root form is what OpenAI's own extension uses.
### Algorithm
1. **Build the prompt.** Let `q = String(rawQuery ?? "").trim()`.
- If `q === ""``prompt = ""` (the template is ignored entirely).
- Else if the template contains `{query}` → replace **every** occurrence
(`replaceAll`). Pass a **replacer function**, not the string:
`tpl.replaceAll("{query}", () => q)`. In the string form, `$&`, `` $` ``,
`$'`, and `$$` are substitution patterns *in the replacement*, and the
replacement here is arbitrary page-selected text. Corrected during
execution after this was measured on the default `{query}` template:
selecting `awk print $& here` produced `awk print {query} here`, and
`PID is $$ in bash` produced `PID is $ in bash`. Shell and regex snippets
are a headline use case, so this fired on ordinary input for every user.
- Else if the trimmed template is empty → `prompt = q`.
- Else → `prompt = trimmedTemplate + " " + q` (template treated as a prefix;
the selection is never silently discarded).
2. **Measure the non-`q` budget.** Build a `URL` from `BASE`, append `model`,
`temporary-chat`, and `hints` per the rules below, and record
`baseLength = url.href.length`.
3. **Fit the prompt.** If `prompt !== ""`, find the largest number of *code
points* of `prompt` (via binary search over `Array.from(prompt)`, so
surrogate pairs are never split) whose resulting full URL length is
`<= MAX_URL_CHARS`. Truncation operates on the decoded string and encoding
happens afterwards, so a percent-escape can never be severed. If the kept
text contains whitespace within its final 10%, cut at the last such
whitespace instead, for a cleaner break.
4. **Assemble in final order.** Build a fresh `URL` and set parameters in this
order, matching the most-cited working examples: `q` (if non-empty), then
`model`, `temporary-chat`, `hints`.
Measuring in step 2 without `q` and assembling with `q` first is safe: total
serialized length is independent of parameter order, since reordering only
swaps which separator is `?` and which are `&`, never how many there are.
5. Return `url.href`.
### Parameter rules
- `q` — set only when the fitted prompt is non-empty. Never emit `q=` with an
empty value.
- `model` — set to `settings.model.trim()` only when that is non-empty.
- `temporary-chat` — set to the string `"true"` only when `temporaryChat` is true.
- `hints` — set to the string `"search"` only when `webSearch` is true.
`q` is the parameter to send. ChatGPT canonicalises it to `prompt=` internally;
`prompt=` is the first fallback to try should `q` ever stop working. Never send
both.
## Data flow
1. **Trigger.** Two entry points, both producing a query string:
- `browser.menus.onClicked` supplies `info.selectionText` directly. No
injection, no `activeTab` involved.
- `browser.commands.onCommand` reads the selection itself: resolve the active
tab with `tabs.query({active: true, currentWindow: true})`, then
`tabs.executeScript` returning `window.getSelection().toString()`. Read
**only** `tab.id` from the query result — reading `tab.url` or `tab.title`
would require the `tabs` permission, which *does* carry an install warning.
Pass **both** `allFrames: true` (a selection inside an iframe is invisible
to the top frame) and `matchAboutBlank: true` (needed for `about:blank`
frames), then take the first non-empty result.
Corrected during execution: gate the injected snippet on
`document.hasFocus()` —
`"document.hasFocus() ? window.getSelection().toString() : ''"`. Each frame
owns an independent `Selection`, and a selection made in a frame is *not*
cleared when the user selects elsewhere, so without the gate a stale
selection in an unfocused (possibly cross-origin) frame can win, decided by
an `executeScript` result ordering that is not specified. `hasFocus()` is
true for the focused document and all its ancestors, so the top frame wins
when the user selected there and an iframe is used only when it is itself
focused. This supersedes the earlier idea of having the snippet report its
own frame — that was only ever needed to establish provenance for
auto-submit, which Appendix B rejects.
**Null-check every element of the result array.** `executeScript` does not
only throw — on parent-process `about:` pages such as `about:addons` and
`about:preferences` it resolves *silently* to `[null]`, so
`results[0].text` is a TypeError waiting to happen. If nothing usable comes
back the query is empty and a blank chat opens.
2. `settings = await load()` — `load()` already pipes the raw
`storage.sync.get()` result through `mergeSettings()`, so callers always
receive a complete, validated settings object and never need to merge again.
3. `url = buildChatGptUrl(settings, query)`.
4. Open per `settings.openIn`:
- `new-tab` → `browser.tabs.create({ url, active: true })`
- `background-tab` → `browser.tabs.create({ url, active: false })`
- `new-window` → `browser.windows.create({ url })`
`tabs.create` and `windows.create` require no permissions. The created `Tab`
object's `url`/`title` must not be read — that *would* require the `tabs`
permission, which carries an install warning.
The menu item is registered with `contexts: ["selection"]`, so Firefox shows it
only when text is selected.
## Error handling
Every path resolves to something useful. No path shows an error to the user.
| Case | Behaviour |
|---|---|
| Selection empty or whitespace | Omit `q` entirely; open a blank chat with the other settings applied. |
| `executeScript` fails on a restricted page (`about:`, `addons.mozilla.org`, `accounts.firefox.com`, `view-source:`, the PDF viewer) | Failure has **three** measured shapes, all of which must be handled: a thrown `Missing host permission for the tab`, a thrown variant naming frames, and a **silent resolution to `[null]`** on parent-process `about:` pages. Catch the throws, null-check the array, and treat all three as an empty selection → open a blank chat. The shortcut must never appear broken just because the user pressed it on a Firefox page. |
| Prompt exceeds the URL budget | Truncate per step 3 above. Never sever a percent-escape or split a surrogate pair. |
| `storage.sync` rejects (quota, or a sanitized backend error) | `try`/`catch` → fall back to `DEFAULT_SETTINGS`. Never block opening ChatGPT. Do not branch on error type: Firefox sanitizes all non-quota backend errors to `"An unexpected error occurred"`. |
| Fresh or signed-out profile returns `{}` | `mergeSettings` layers stored values over `DEFAULT_SETTINGS`, so `{}` yields defaults. |
| Template lacks `{query}` | Treat as a prefix and append. |
| Template has several `{query}` | Replace all occurrences. |
| `model` blank or whitespace | Omit the parameter. |
| Stored settings corrupt or partial | `mergeSettings` drops unknown keys and falls back to default on wrong type or out-of-range enum. |
| Background event page wakes and re-registers | `menus.removeAll()` before `menus.create()`, so re-registration is idempotent. |
| `windows.create` fails | Fall back to `tabs.create`. |
| `browser.commands` undefined | Guard before subscribing. The API does not exist on Firefox for Android. |
`storage.sync` needs no capability probe: it works with no Mozilla account,
silently behaving as local storage, and syncs later if the user signs in and has
Add-ons sync enabled. The `gecko.id` (already present) is required and **must
never change** — synced data is keyed by extension ID.
## Testing
### Unit — `node --test`
`test/build-url.test.js`:
- Defaults + a simple query → exactly `https://chatgpt.com/?q=hello`
- Each parameter appears only when its setting is enabled
- All four URL-affecting settings together (`model`, `temporaryChat`,
`webSearch`, `promptTemplate`), with parameters in the specified order.
`openIn` never influences the URL and must not appear in these assertions.
- Encoding of `&`, `#`, `?`, `=`, `+`, spaces, newlines, emoji, CJK
- `{query}` substitution: present, absent (prefix fallback), multiple occurrences,
empty template
- Empty / whitespace-only query → no `q` parameter at all
- Truncation: result `<= MAX_URL_CHARS`; no severed percent-escape; no split
surrogate pair (assert with an emoji at the boundary); whitespace-boundary
preference
- `model` empty and whitespace-only → no `model` key
`test/settings.test.js` — `mergeSettings` with: `{}`, `null`, `undefined`,
missing keys, unknown keys, wrong types, out-of-range `openIn` value.
### Manual
Retain the three existing regression checks from `AGENTS.md`: `gpt <query>`
opens ChatGPT, the provider stays non-default, and it remains configurable in
Firefox search settings.
Add:
- The Options/Preferences tab appears on the extension's detail card in
`about:addons`, and all five settings controls plus **Restore defaults**
render correctly — **in both light and dark theme**, and at a narrow width
- The page is also usable standalone via `browser.runtime.openOptionsPage()`
- Settings persist across a browser restart
- The context-menu item appears only when text is selected, and is absent otherwise
- `Alt+Shift+G` with text selected opens ChatGPT with that text, matching the
context menu; with nothing selected it opens a blank chat; on a restricted
page (`about:config`, an AMO page) it opens a blank chat rather than failing
visibly. It appears under `about:addons` → gear → Manage Extension Shortcuts
and can be rebound
- **The shortcut must be verified by actually pressing it.**
`commands.getAll()` reports a `suggested_key` as registered even when Firefox
has silently overridden it — measured: `Ctrl+Shift+Y` (Downloads on Linux)
showed as registered and never fired. A green `getAll()` is not evidence the
shortcut works.
- A selection inside an iframe is picked up by the shortcut, not just top-level
page text
- Each of the three `openIn` values behaves correctly
- **A packaged build's install prompt gains no new lines.** This cannot be
checked with `npm run dev` — install prompts are suppressed for temporary
installs. Use a packaged build.
- `unzip -l web-ext-artifacts/*.zip` shows no `test/` or `docs/` entries, not
even empty directory entries
### Parameter reality check
One pass in a real, logged-in Firefox profile to record which parameters are
actually honoured on this extension's navigation path: does `q` prefill only or
also submit; is `model` respected alongside `q`; does `temporary-chat` produce a
temporary chat and does it survive the first message; does `hints=search` engage
web search.
No further desk research substitutes for this. **The outcome adjusts the help
text, not the architecture** — every label is already written at hint strength,
so a negative result requires no redesign.
## Packaging
`web-ext-config.mjs` — add to `ignoreFiles`:
```js
"docs", "docs/**", "test", "test/**",
```
Both forms per directory are required. A glob alone removes the files but leaves
an **empty directory entry** in the ZIP; web-ext's own code pairs the bare name
with the glob for exactly this reason.
`.gitea/` and `.agents/` need **no** entries — web-ext ignores all dotfiles and
dot-directories by default. `web-ext-artifacts/**` is likewise already redundant;
it stays as-is to keep this diff focused.
`web-ext lint` scans `lib/` and `test/` with the **same** file filter as
`build`. With `lint.warningsAsErrors: true`, one addons-linter warning in a test
file is a hard CI failure. Adding `test/` to `ignoreFiles` fixes linting and
packaging together — it is one lever, not two.
`web-ext-config.mjs` must keep its `.mjs` extension. web-ext rejects a `.js`
config file outright.
## Tooling
`package.json`:
```jsonc
"type": "module", // NEW — required, see below
"scripts": {
"test:unit": "node --test", // NEW
"test": "npm run lint && npm run test:unit && npm run build"
}
```
`"type": "module"` is **required**, not stylistic. Without it, `node --test`
importing an ES-module `lib/*.js` relies on Node's module-syntax detection,
which:
- emits a permanent `MODULE_TYPELESS_PACKAGE_JSON` warning on every run, and
- **fails outright on Node 22.022.6**, since detection only became default in
22.7. `engines.node` says `>=22` and CI pins the bare major `"22"`, leaving
the patch to runner resolution — so without this field the project declares
support for a range in which its own suite does not run.
Using `.mjs` for test files only does **not** fix this; the typeless
`lib/*.js` is the ambiguous file. Adding `"type": "module"` was verified not to
break `web-ext lint` or `web-ext build` on web-ext 10.5.0, which never consults
the consumer package's `type` field.
Ordering is lint → unit → build, so unit failures prevent a ZIP from being
produced. The existing `.gitea` workflow calls `npm test` and needs no edit.
`node --test` **exits 0 when it discovers zero test files.** CI would go green
on a misconfigured path, so acceptance must assert a non-zero test count, not
merely a zero exit code.
Note for implementers: local Node here is v26.5.0 while CI pins 22. Local green
does not imply CI green.
## Documentation changes
- **README** — an Options section listing the five settings and how to reach the
panel (referring to "Options (Windows) / Preferences (macOS, Linux)", since
Firefox's label is platform-dependent). Privacy section amended: settings are
stored in the user's own browser profile and, only if that user has Firefox
Sync with Add-ons enabled, synced end-to-end encrypted through their own
Mozilla account. Do **not** write "never leaves your device" unconditionally.
Nothing reaches Zavage Software, so the zero-data-collection claim stands.
Revisit the bare sentence "This extension collects zero user data." It is true
in the plain-English sense and `data_collection_permissions: ["none"]` is
retained, but AMO's definition of transmission is broader (Policy 6, 6.2.2.1),
and the context menu does put selected text into a chatgpt.com URL. Prefer
wording that is specific about what happens — the extension sends the text you
chose to the ChatGPT URL you asked it to open, stores your settings in your own
profile, and reports nothing to Zavage Software — over a blanket claim a
reviewer could read differently. See Risk 6.
- **README** — document the two-keypress behaviour plainly as an upstream
ChatGPT limitation: ChatGPT stopped auto-submitting prompts passed by URL, so
the query arrives typed into the composer and the user presses Enter to send.
State that this is outside the extension's control and that no API key,
server, or intermediary is involved. Do not describe it as a bug or promise a
fix.
- **AGENTS.md** — update Project Structure for the new directories, Build/Test
commands for `test:unit`, and the Testing Guidelines manual checklist.
- **CHANGELOG.md** — entry for the release.
- **Version** — `manifest.json` `1.0` → `1.1`; `package.json` `1.0.0` → `1.1.0`.
## Rejected alternatives
| Alternative | Why rejected |
|---|---|
| Rewrite the `gpt` keyword's navigation to honour settings | Needs host access to `chatgpt.com` plus `webRequest`/`tabs`; adds install-prompt lines. |
| Point `search_url` at a bundled extension page that redirects | Firefox requires an http(s) search URL; also a visible redirect flash. Not pursued. |
| ~~Keyboard shortcut reads the page selection~~ | **No longer rejected — now in scope.** Initially dropped believing `activeTab` carried a permission cost. Verification showed `activeTab` is in Firefox's `OptionalPermissionNoPrompt` class and adds no install warning, and reading a selection on an explicit gesture to build the requested URL transmits nothing, so `data_collection_permissions: ["none"]` holds. The lighter-looking alternatives are all worse: a declared `<all_urls>` content script is a real host permission with a warning. |
| `background.page` HTML shim for ES modules | Works, but unnecessary once `background.type: "module"` was confirmed for MV2. |
| `background.scripts` without `type` | Classic scripts; an `import` statement fails with a compile-time `SyntaxError` and silently kills the script. |
| Curated model dropdown | Slug lists rot within a quarter and every stale entry fails silently to the account default. |
| Migrate to MV3 now | Orthogonal, larger diff. Mozilla has announced no MV2 sunset and promises ≥12 months' notice. |
| Toast/notification on failure | The `notifications` permission adds an install warning. All failure paths degrade silently instead. |
| Content script on `chatgpt.com` that presses Enter for the user | **Rejected on security grounds after dedicated adversarial testing — see [Appendix B](#appendix-b-why-auto-submit-was-rejected). Not a future path; the minimum bar is recorded there so it cannot be revived cheaply.** Three independent attack lenses returned "not viable". The decisive result is that the design authenticates the wrong thing: proving the extension started the navigation is easy, but auto-submit needs to prove the *user authored the prompt*, and a hostile page can smuggle ~16 KB of invisible text into `info.selectionText`. **Decision: document the two-keypress behaviour as an upstream ChatGPT limitation instead.** |
| Opt-in auto-submit via `optional_permissions` | The permission machinery works — verified, `permissions.request()` succeeds from the inline `about:addons` panel and the Firefox install dialog is provably unchanged. It was the *safety* that failed, not the plumbing. Also: an optional host permission is visible on the AMO listing page before install, so it was never free. |
| `browser.omnibox` to make `gpt <query>` honour the settings | **Technically viable and verified working** — needs no permissions, reuses the same `gpt` keyword, lints clean. Rejected for policy exposure: it moves the transmitter of search terms from Firefox to extension code, which is the specific change that puts `data_collection_permissions: ["none"]` at risk (AMO 6.2.2.1 classes search terms as personal data). Declining it keeps Firefox as the transmitter. Revisit only after AMO answers the declaration question. |
| `search_url` pointing at a bundled extension page | Impossible, not merely discouraged. The Firefox schema and addons-linter both constrain `search_url` to `https://` or `http://localhost`, and Firefox 153.0.1 **refuses to install the extension entirely** ("Extension is invalid") for `moz-extension://`, a relative path, or a root-relative path. `search_url_post_params` only changes the method; `search_form` is scheme-restricted and unsupported on Firefox. |
## Risks
1. **Any ChatGPT parameter may stop working without notice.** Mitigated by
hint-strength copy everywhere, independently removable parameters, and no
architectural dependence on any of them. The `/search` path's silent move to
404 in mid-2025 is the precedent.
2. **`model` may never work alongside `q`.** An unresolved report says `q`
forces the account default; no 2026 source positively confirms otherwise. The
setting is labelled experimental and defaults to empty, so the default
configuration is unaffected either way.
3. **`Alt+Shift+G` may collide with an OS or window-manager hotkey.** Verified
clear of Firefox's own keyset and DevTools only; no registry of other add-ons'
shortcuts exists. Mitigated by the context menu remaining a full-featured
path and by documenting Manage Extension Shortcuts.
4. **MV2's long-term future is not guaranteed.** The strongest Mozilla
commitment is dated March 2024 and nothing newer restates it. Mitigated by
keeping the code MV3-portable.
5. ~~The shortcut's selection-reading depends on a `commands` invocation
conferring `activeTab`.~~ **Resolved, positive.** Firefox's
`ext-commands.js` calls `tabManager.addActiveTabPermission(tabTracker.activeTab)`
before firing the event, and a real synthesized keypress in Firefox 153.0.1
read page text with `activeTab` as the only host-ish permission. No longer a
risk.
6. **`data_collection_permissions: ["none"]` carries residual policy risk for
the selection paths.** AMO Policy 6 defines transmission as data handled
outside the add-on or local browser, and 6.2.2.1 names search terms as
personal data. Once extension code reads page text and puts it in a
chatgpt.com URL, a reviewer could argue `websiteContent` applies. The
mitigating reading is 6.2.2.2's implicit consent — a user-initiated action
through a clearly labelled control — but 6.2.2.2(5) is explicitly
discretionary and no published Mozilla guidance resolves it. This design
deliberately minimises exposure by **not** moving the `gpt` keyword to
`browser.omnibox`, leaving Firefox as the transmitter for the address-bar
path. If AMO ever requires declaring `searchTerms`, the install prompt gains
"The developer says this extension collects: search terms" — which would
conflict with this design's no-new-install-lines goal. Worth asking AMO
before submission, not at submission.
## Appendix A: Verification findings
Established 2026-07-29 by parallel research with adversarial verification;
64 findings, one overturned by its skeptic. Browser claims were verified by
loading real test extensions into **Firefox 153.0.1**. Repo claims were verified
by running commands against copies under `/tmp`; the repo itself was unmodified.
### Confirmed empirically in Firefox 153.0.1
- MV2 `background: {scripts, type: "module"}` loads real ES modules. Supported
since Firefox 112 (`bug 1811443`); Firefox's manifest schema puts no
`min_manifest_version` on `type`.
- MV2 `background: {scripts}` **without** `type` fails on `import` with
`"SyntaxError: import declarations may only appear at top level of a module"`.
- `options_ui.open_in_tab: false` renders inside `about:addons`
(`inline-options-browser`), gated on
`extensions.htmlaboutaddons.inline-options.enabled`, which ships `true`.
- The tab label is platform-dependent:
`preferences-addon-button = { PLATFORM() -> [windows] Options *[other] Preferences }`.
- Options pages support `<script type="module">` in both surfaces.
### Confirmed from Firefox source
- `PERMISSIONS_WITH_MESSAGE` (26 entries) is the authoritative list of API
permissions that add a warning line. `storage`, `menus`, and `activeTab` are
all absent; `tabs` is present. **Caveat established by the overturned finding:**
it is one of five independent install-prompt inputs (API permissions, host
permissions, `DATA_COLLECTION_PERMISSIONS`, site permissions, unsigned
warning), so it is not a complete account of install-time warnings.
- A fresh install **always** shows a prompt; the no-prompt early-return is gated
on `type == "update"`. This extension's prompt today is "Add …" + the
data-collection line + the private-windows checkbox — unchanged by this work.
- Install prompts are suppressed for temporary/unpacked installs.
- `storage.sync` throws only for a *temporary* add-on ID
(`enforceNoTemporaryAddon`); the explicit `gecko.id` satisfies it. No account
check exists anywhere in the path — the backing store is a plain profile-local
`storage-sync-v2.sqlite`. Quotas: 102400 total / 8192 per item / 512 items.
Non-quota backend errors are sanitized to `"An unexpected error occurred"`.
- Sync transport is per-extension encrypted BSOs over the user's own Firefox
Account; extension IDs are hidden behind random GUIDs.
- `Ctrl+G` is `key_findAgain` and `Ctrl+Shift+G` is `key_findPrevious`.
`Alt+Shift+G` is unused by `browser-sets.inc` and DevTools.
- `tabs.create` / `windows.create` declare no permissions; only
`Tab.url`/`title`/`favIconUrl` require `tabs`.
- `commands` is `version_added: false` on Firefox for Android.
- `options_ui.browser_style` is deprecated in **MV3 only** (default flipped in
Firefox 115, unsupported in 118). Legal in MV2, where it defaults to `true`.
### Confirmed empirically against the repo
- `npx web-ext lint` → 0 errors / 0 warnings / 0 notices, exit 0. Green baseline.
- lint and build share one `FileFilter`; `eval()` planted in `lib/` produced
`DANGEROUS_EVAL` and exit 1.
- web-ext's default ignore list includes `'**/.*'` and `'**/.*/**/*'`, so
dotfiles never ship. Confirmed: `.gitea/` and a planted `.agents/note.md` were
both absent from a build with no dotfile entries in `ignoreFiles`.
- `test/` and `docs/` **do** ship by default. With only `"test/**"`, empty
`test/` and `docs/` directory entries remained in the ZIP; adding the bare
names removed them.
- The current shipped ZIP contains only `manifest.json`, `LICENSE.txt`, and
`icons/` — packaging hygiene is a property to preserve, not a bug to fix.
- `node --test` with no `"type"` field: passes on Node 26.5.0 but warns
`MODULE_TYPELESS_PACKAGE_JSON`; with `--no-experimental-detect-module` (pre-22.7
behaviour) it **fails** with `"Cannot use import statement outside a module"`.
- `.mjs` for tests only moves the warning to the typeless `lib/*.js`.
- Adding `"type": "module"`: `node --test` clean, `web-ext lint` 0/0/0,
`web-ext build` succeeded.
- `node --test` with zero discovered tests: `tests 0`, exit **0**.
- Local Node v26.5.0, npm 12.0.1, web-ext 10.5.0. CI pins node-version `"22"`.
### ChatGPT parameters — confidence ratings
| 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` 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. |
| `model` honoured alongside `q` | **Uncertain** | An unresolved 2024-12-28 report says `q` forces GPT-4o regardless; counter-evidence exists from 20242025; 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`). |
| `temporary-chat=true` | **Uncertain** | Community-confirmed originally; multiple reports of it not applying or reverting after the first message; OpenAI reworking the feature. |
| `hints=search` is a hint only | **Confirmed** | Multiple independent sources; "a frontend user interface hint, not a backend trigger". |
| `hints=search` still functions | **Uncertain** | Freshest positive source is provably recycling stale material; freshest negative is 2025-11-11. Costs nothing to append and degrades to a normal chat. |
| Stable set of `hints` values | **Refuted** | Published value sets contradict each other; only `search` has cross-source corroboration. |
| Officially documented by OpenAI | **Refuted** | No documentation for any parameter; primary community threads have zero staff replies. |
| `/search` path | **Refuted** | Began returning 404 mid-2025. |
| `q` canonicalises to `prompt=` | **Confirmed** | Dated 2026 reports; `prompt=` is the fallback if `q` dies. |
| Combining all four | **Structurally safe** | Nothing errors or gets stripped. But "the URL loads" is not "the parameter was honoured". |
### Unresolved
- ~~Whether Firefox search-provider navigations land in the favoured
`Sec-Fetch-Site: none` bucket for auto-submit.~~ **Resolved 2026-07-29,
negative.** Tested in the maintainer's logged-in Firefox on this extension's
own path: the prompt only prefills. Being in the most-trusted navigation
bucket does not restore auto-submit.
- Whether Firefox enforces Chrome-style `storage.sync` write-rate quotas. The
constants exist in the schema but no rate-limiting logic appears in the Rust
backend. Made moot by debouncing writes.
- Whether `Alt+Shift+G` collides with OS-level or other-add-on shortcuts.
- Whether AMO accepts `required: ["none"]` once extension code reads page text
(Risk 6). Ask AMO; 6.2.2.2(5) is discretionary and unresolved in published
guidance.
### Round two (2026-07-30)
A second verification pass, again with adversarial challenge. Browser claims
tested by driving real Firefox 153.0.1 under Xvfb via Marionette — real
keypresses, real address-bar input, real permission doorhangers — not headless
approximations.
- **A `commands` keyboard shortcut confers `activeTab`.** Firefox's
`ext-commands.js` calls `tabManager.addActiveTabPermission(tabTracker.activeTab)`
before firing `onCommand`. A synthesized real keypress read page text with
`permissions: ["activeTab", "menus"]` and nothing else; the identical call
from a no-gesture background path threw `Missing host permission for the tab`.
- **`executeScript` fails in three shapes**, one silent: two distinct throws plus
a resolution to `[null]` on parent-process `about:` pages.
- **`suggested_key` loses silently to Firefox's built-ins** while
`commands.getAll()` still reports it registered. `Ctrl+Shift+Y` never fired.
- **`activeTab` adds no install-prompt line** — re-confirmed by running
Firefox's own `ExtensionData.formatPermissionStrings` inside 153.0.1:
`["activeTab", "storage", "menus"]` yields `msgs: []`, byte-identical to
requesting nothing.
- **`search_url` cannot be an extension page.** Schema and addons-linter
constrain it to `https://` or `http://localhost`; Firefox 153.0.1 refuses to
install ("Extension is invalid") for `moz-extension://`, relative, and
root-relative paths.
- **`browser.omnibox` works with zero permissions** and can reuse the `gpt`
keyword alongside the registered engine, linting clean. Declined for policy
exposure, not capability — see [Rejected alternatives](#rejected-alternatives).
- **`permissions.request()` works from the inline `about:addons` options page.**
Bug 1382953 was fixed in Firefox 61. The grant does not reload the inline
page. It must be the **first statement** in the click handler: awaiting a real
API call first loses the gesture and rejects with `permissions.request may
only be called from a user input handler`, and the gesture does not survive
`runtime.sendMessage` delegation to the background.
- **An optional permission cannot reach the Firefox install dialog** — the
install prompt's info object has no top-level `optionalPermissions`, the only
key the string formatter reads for that section. Verified with real XPI
installs plus an order-reversed control.
- **But the AMO listing page does change.** With an optional host permission the
listing gains "Optional permissions: Access your data for chatgpt.com",
*before* install. Confirmed against a live MV2 control (SponsorBlock). Note
publishing the already-committed `data_collection_permissions` will surface
that card by itself, independent of anything here.
## Appendix B: Why auto-submit was rejected
Recorded in full so the feature cannot be revived without re-clearing the same
bar. Three adversarial lenses — prompt injection, permission integrity, AMO
policy — independently returned "not viable".
**The design authenticated the wrong invariant.** "Did the user initiate this
navigation?" is provable. "Did the user *author this text*?" is what auto-submit
needs. For the two entry points this spec builds, the text comes from the page
DOM — so extension-initiated provenance is not merely insufficient, it is
inverted: the paths the extension can prove it started are exactly the paths
where a hostile page supplies the payload.
**Selection smuggling (confirmed empirically).** A hostile page can place ~16 KB
of invisible instructions into `menus.onClicked`'s `info.selectionText` while the
user sees only innocuous bait text. The user's click is genuine; the prompt is
the attacker's. The only thing standing between an attacker-chosen prompt and
the user's logged-in ChatGPT account is that the text sits visibly in the
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
prefill*.
**`webNavigation.transitionType` is not a security boundary.** The signal looks
right — a `gpt` search emits `transitionType: "generated"` with
`transitionQualifiers: ["from_address_bar"]`, and content cannot forge those
values directly. But the marker is stashed per-tab and consumed only when a
document *commits*, so a urlbar navigation that never commits (HTTP 204, a
download, an external-protocol handler, a cancelled load) leaves it armed and
**the next navigation to commit inherits it, whoever started it.** Demonstrated:
a page navigating itself via its own inline timer was reported as `typed` +
`["from_address_bar"]` **10.47 seconds** after the urlbar action, against a
control of `link` + `[]`. The intended 5-second freshness guard is dead code —
`data.time - Date.now() > RECENT_DATA_THRESHOLD` is always false (inverted
subtraction, and a microsecond-scaled constant compared against a millisecond
delta) — so the stale marker never expires and an attacker need win no race: it
arms a timer on blur and waits. Separately, `webNavigation` needs no host
permission to see full URLs, so granting it would expose the user's entire
real-time browsing history for a gate that does not hold.
**Same-tab substitution defeats the pending-record scheme (confirmed).** Even
restricted to omnibox-typed text, `omnibox`'s default disposition is the current
tab. The outgoing hostile document receives `beforeunload` — a deterministic
signal that the extension's navigation has begun, not a race — arms
`setTimeout(…, 0)`, and sets `location.href` to `chatgpt.com/?q=EVIL`. Measured
beacon sequence: `ext-navigate-start[q=HONEST]` → `beforeunload fired` →
`timer0: set location OK` → `chat-loaded[q=EVIL-TIMER]`. The extension believes
it navigated the tab to the user's prompt; the document actually there is the
attacker's, and the pending record is still armed. (Opener-based substitution is
dead in Firefox 153 — chatgpt.com sends `COOP: same-origin`, and cross-origin
location writes are denied regardless.)
**What was *not* the problem.** The plumbing all works: `permissions.request()`
from the inline panel, the unchanged Firefox install dialog, optional
data-collection permissions in Firefox 153. The rejection is about safety, and
secondarily about AMO policy — not about feasibility.
**Minimum bar if ever revisited.** Auto-submit may only ever apply to text the
extension can attest the user typed into browser chrome (an `omnibox`
`onInputEntered` value — a page can neither write the omnibox, fire the event,
nor observe it). Never to `info.selectionText`. It must open a **new** tab, never
navigate the current one. The pending map must stay in memory only — never
persisted, because tab ids restart low at browser start, creating a replay
hazard. Authorization must be re-validated synchronously at the instant of
submit against the live `location.search`, never latched, since chatgpt.com is a
client-routed SPA and the content script survives same-document navigation.
Single attempt, ~1.52 s timeout, `allFrames: false` with an explicit top-frame
assertion, abandon permanently on any navigation event, never retry, never a
fallback chain. And the AMO declaration question (Risk 6) must be answered
first, because it decides whether the feature is compatible with a clean install
prompt at all.
+108
View File
@@ -0,0 +1,108 @@
// Pure URL construction. No browser APIs, so node:test imports this directly.
//
// Every ChatGPT parameter below is undocumented by OpenAI and has broken before.
// This module's contract is only that it emits a well-formed URL; whether
// ChatGPT honours model / temporary-chat / hints is out of our control, which is
// why each is independently omittable and every label is hint-strength.
//
// The root path is mandatory: chatgpt.com/search began returning 404 in
// mid-2025, and the root form is what OpenAI's own extension uses.
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;
function applyTemplate(template, query) {
if (query === "") {
return "";
}
const tpl = typeof template === "string" ? template : "";
if (tpl.includes("{query}")) {
// Replacer function, not a replacement string: String.prototype.replaceAll
// gives $&, $`, $', and $$ special meaning in a replacement STRING, and
// `query` is arbitrary page-selected text that can contain any of those
// (shell one-liners routinely do). The function form disables that
// interpretation entirely. Do not "simplify" this back to the string form.
return tpl.replaceAll("{query}", () => query);
}
const trimmed = tpl.trim();
return trimmed === "" ? query : `${trimmed} ${query}`;
}
function addOptionalParams(url, settings) {
const model = typeof settings.model === "string" ? settings.model.trim() : "";
if (model !== "") {
url.searchParams.set("model", model);
}
if (settings.temporaryChat) {
url.searchParams.set("temporary-chat", "true");
}
if (settings.webSearch) {
url.searchParams.set("hints", "search");
}
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.
// The guarantee assumes `settings` came through mergeSettings(): only `q` is
// ever trimmed, so a model longer than MAX_MODEL_CHARS could blow the budget on
// its own and leave no room for `q` at all. Every real caller goes via load().
// 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;
}
+20
View File
@@ -0,0 +1,20 @@
// Single source of truth for the settings shape and their defaults.
// Defaults reproduce the extension's pre-1.1 behaviour exactly: an empty model
// and both toggles off mean the only URL parameter sent is `q`.
export const OPEN_IN_VALUES = ["new-tab", "background-tab", "new-window"];
// An unbounded model string can, on its own, exceed MAX_URL_CHARS in
// lib/build-url.js — fitToBudget() only ever trims `q`, so a long enough
// model silently pushes the whole URL over budget and drops the query
// entirely with no signal to the user. 200 is generous for any real model
// slug while keeping the URL budget meaningful.
export const MAX_MODEL_CHARS = 200;
export const DEFAULT_SETTINGS = {
model: "",
temporaryChat: false,
webSearch: false,
openIn: "new-tab",
promptTemplate: "{query}",
};
+45
View File
@@ -0,0 +1,45 @@
import { DEFAULT_SETTINGS, OPEN_IN_VALUES, MAX_MODEL_CHARS } from "./defaults.js";
// Pure. Layers stored values over the defaults, dropping unknown keys and
// falling back on wrong types or out-of-range values. A fresh or signed-out
// profile returns {} from storage, which yields the defaults.
export function mergeSettings(stored) {
const merged = { ...DEFAULT_SETTINGS };
if (stored === null || typeof stored !== "object") {
return merged;
}
if (typeof stored.model === "string" && stored.model.length <= MAX_MODEL_CHARS) {
merged.model = stored.model;
}
if (typeof stored.temporaryChat === "boolean") {
merged.temporaryChat = stored.temporaryChat;
}
if (typeof stored.webSearch === "boolean") {
merged.webSearch = stored.webSearch;
}
if (OPEN_IN_VALUES.includes(stored.openIn)) {
merged.openIn = stored.openIn;
}
if (typeof stored.promptTemplate === "string") {
merged.promptTemplate = stored.promptTemplate;
}
return merged;
}
// storage.sync works with no Mozilla account (it behaves as local storage and
// syncs later if the user signs in), so no capability probe is needed. Any
// rejection falls back to defaults rather than blocking the user's action.
// Firefox sanitises all non-quota backend errors to a single generic string, so
// there is nothing useful to branch on.
export async function load() {
try {
const stored = await browser.storage.sync.get(Object.keys(DEFAULT_SETTINGS));
return mergeSettings(stored);
} catch {
return mergeSettings(null);
}
}
export async function save(partial) {
await browser.storage.sync.set(partial);
}
+43 -1
View File
@@ -4,7 +4,49 @@
"manifest_version": 2,
"author": "Mathew Guest <mat@zavage.net>",
"homepage_url": "https://zavage-software.com/portfolio/search-with-ChatGPT",
"version": "1.0",
"version": "1.1",
"icons": {
"16": "icons/powered-by-openai-icon-16x16.png",
"32": "icons/powered-by-openai-icon-32x32.png",
"48": "icons/powered-by-openai-icon-48x48.png",
"64": "icons/powered-by-openai-icon-64x64.png",
"128": "icons/powered-by-openai-icon-128x128.png"
},
"browser_specific_settings": {
"gecko": {
"id": "{24644f0c-bfcb-4ebd-a16b-bc8a7d154288}",
"data_collection_permissions": {
"required": [
"none"
]
}
}
},
"permissions": [
"storage",
"menus",
"activeTab"
],
"background": {
"scripts": [
"background/background.js"
],
"type": "module",
"persistent": false
},
"options_ui": {
"page": "options/options.html",
"open_in_tab": false,
"browser_style": false
},
"commands": {
"ask-chatgpt": {
"suggested_key": {
"default": "Alt+Shift+G"
},
"description": "Ask ChatGPT about the selected text"
}
},
"chrome_settings_overrides": {
"search_provider": {
"name": "ChatGPT",
+127
View File
@@ -0,0 +1,127 @@
:root {
--fg: #15141a;
--fg-dim: #5b5b66;
--bg: transparent;
--border: #8f8f9d;
--field-bg: #ffffff;
--accent: #0060df;
}
@media (prefers-color-scheme: dark) {
:root {
--fg: #fbfbfe;
--fg-dim: #b1b1bd;
--border: #8f8f9d;
--field-bg: #2b2a33;
--accent: #0df;
}
}
body {
margin: 0;
padding: 0.5rem 0;
color: var(--fg);
background: var(--bg);
font: message-box;
font-size: 1rem;
line-height: 1.4;
max-width: 42rem;
}
.field {
margin-bottom: 1.15rem;
}
label {
display: block;
font-weight: 600;
margin-bottom: 0.3rem;
}
.checkbox label {
font-weight: 400;
display: flex;
align-items: baseline;
gap: 0.45rem;
}
input[type="text"],
select {
width: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 0.4rem 0.5rem;
color: var(--fg);
background: var(--field-bg);
border: 1px solid var(--border);
border-radius: 4px;
font: inherit;
}
input[type="text"]:focus-visible,
select:focus-visible,
button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.hint {
margin: 0.3rem 0 0;
color: var(--fg-dim);
font-size: 0.85rem;
}
.tag {
font-weight: 400;
font-size: 0.75rem;
color: var(--fg-dim);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0 0.3rem;
vertical-align: 1px;
}
.actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-top: 1.5rem;
}
button {
padding: 0.35rem 0.8rem;
color: var(--fg);
background: var(--field-bg);
border: 1px solid var(--border);
border-radius: 4px;
font: inherit;
cursor: pointer;
}
#status {
color: var(--fg-dim);
font-size: 0.85rem;
}
.note {
margin-top: 1.75rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
color: var(--fg-dim);
font-size: 0.85rem;
}
code,
kbd {
font-family: monospace;
font-size: 0.9em;
}
/* The inline about:addons surface can be very narrow. */
@media (max-width: 26rem) {
.actions {
align-items: flex-start;
flex-direction: column;
}
}
+63
View File
@@ -0,0 +1,63 @@
<!doctype html>
<!-- Rendered inline inside about:addons. Keep it narrow-friendly and do not
rely on window sizing; a pref can degrade this to a standalone tab. -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Search with ChatGPT — Options</title>
<link rel="stylesheet" href="options.css" />
<form id="settings" autocomplete="off">
<div class="field">
<label for="model">Model <span class="tag">experimental</span></label>
<!-- maxlength is set from MAX_MODEL_CHARS in options.js, so the input and
mergeSettings() can never disagree about the bound. -->
<input type="text" id="model" placeholder="auto" spellcheck="false" />
<p class="hint">
ChatGPT may ignore this and use your account default. Leave blank to
always use your account default.
</p>
</div>
<div class="field checkbox">
<label><input type="checkbox" id="temporaryChat" /> Request a temporary chat</label>
<p class="hint">
ChatGPT may not honour this. Check ChatGPT's own indicator before sending
anything sensitive.
</p>
</div>
<div class="field checkbox">
<label><input type="checkbox" id="webSearch" /> Ask ChatGPT to use web search (hint only)</label>
<p class="hint">A hint to ChatGPT's interface, not a guarantee.</p>
</div>
<div class="field">
<label for="openIn">Open in</label>
<select id="openIn">
<option value="new-tab">A new tab</option>
<option value="background-tab">A new background tab</option>
<option value="new-window">A new window</option>
</select>
</div>
<div class="field">
<label for="promptTemplate">Prompt template</label>
<input type="text" id="promptTemplate" spellcheck="false" />
<p class="hint"><code>{query}</code> is replaced with your selected text.</p>
</div>
<div class="actions">
<button type="button" id="restore">Restore defaults</button>
<span id="status" role="status" aria-live="polite"></span>
</div>
</form>
<p class="note">
Selecting text and choosing <strong>Ask ChatGPT</strong> — or pressing
<kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>G</kbd> — opens ChatGPT with your text
filled in. ChatGPT no longer submits prompts that arrive from a link, so press
Enter there to send. Change the shortcut under
<em>Manage Extension Shortcuts</em> in Add-ons.
</p>
<script type="module" src="options.js"></script>
+94
View File
@@ -0,0 +1,94 @@
import { DEFAULT_SETTINGS, MAX_MODEL_CHARS } from "../lib/defaults.js";
import { load, save } from "../lib/settings.js";
const SAVE_DEBOUNCE_MS = 400;
const STATUS_CLEAR_MS = 1500;
const field = (id) => document.getElementById(id);
let saveTimer = null;
let statusTimer = null;
function render(settings) {
for (const [id, value] of Object.entries(settings)) {
const element = field(id);
// Never overwrite the control the user is actively editing — this page's
// own saves echo back through storage.onChanged (see below).
if (element === document.activeElement) {
continue;
}
if (typeof value === "boolean") {
element.checked = value;
} else {
element.value = value;
}
}
}
function collect() {
return {
model: field("model").value,
temporaryChat: field("temporaryChat").checked,
webSearch: field("webSearch").checked,
openIn: field("openIn").value,
promptTemplate: field("promptTemplate").value,
};
}
function showStatus(message) {
field("status").textContent = message;
clearTimeout(statusTimer);
statusTimer = setTimeout(() => {
field("status").textContent = "";
}, STATUS_CLEAR_MS);
}
async function persist(settings) {
try {
await save(settings);
showStatus("Saved");
} catch {
// storage.sync sanitises its errors, so there is nothing specific to report.
showStatus("Could not save settings");
}
}
// Debounced so typing in a text field cannot approach any write-rate quota.
function scheduleSave() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => persist(collect()), SAVE_DEBOUNCE_MS);
}
// Derived rather than hard-coded in the markup: if the two ever disagreed, the
// input would accept a model that mergeSettings() then silently discards,
// blanking the setting with no explanation.
field("model").maxLength = MAX_MODEL_CHARS;
// Module scripts are deferred, so the DOM is already parsed here.
render(await load());
for (const id of Object.keys(DEFAULT_SETTINGS)) {
field(id).addEventListener("input", scheduleSave);
field(id).addEventListener("change", scheduleSave);
}
field("restore").addEventListener("click", () => {
clearTimeout(saveTimer);
// Restoring defaults is an explicit user action, so it should reset every
// field even if one currently has focus. Blur first so render()'s
// activeElement guard (see below) has nothing to skip.
document.activeElement?.blur();
render(DEFAULT_SETTINGS);
persist({ ...DEFAULT_SETTINGS });
});
// browser.storage.onChanged fires for every write to the "sync" area, not just
// a remote sync landing on another device — this page's own debounced
// autosaves and its Restore-defaults save re-enter this listener too, which
// render()'s activeElement guard makes safe to react to unconditionally.
browser.storage.onChanged.addListener(async (changes, area) => {
if (area !== "sync") {
return;
}
render(await load());
});
+4095
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "search-with-chatgpt-powered-by-openai-extension",
"version": "1.1.0",
"private": true,
"type": "module",
"description": "Development tooling for the Search with ChatGPT Firefox extension.",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "web-ext run",
"lint": "web-ext lint",
"test:unit": "node --test",
"build": "web-ext build",
"test": "npm run lint && npm run test:unit && npm run build"
},
"devDependencies": {
"web-ext": "10.5.0"
}
}
+175
View File
@@ -0,0 +1,175 @@
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";
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",
);
});
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("model is sent only when non-empty after trimming", () => {
assert.equal(queryOf(buildChatGptUrl(withSettings({ model: "auto" }), "hi")), "hi");
assert.equal(new URL(buildChatGptUrl(withSettings({ model: "auto" }), "hi")).searchParams.get("model"), "auto");
assert.equal(new URL(buildChatGptUrl(withSettings({ model: " auto " }), "hi")).searchParams.get("model"), "auto");
for (const model of ["", " "]) {
assert.equal(paramsOf(buildChatGptUrl(withSettings({ model }), "hi")).includes("model"), false);
}
});
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);
const temp = new URL(buildChatGptUrl(withSettings({ temporaryChat: true }), "hi"));
assert.equal(temp.searchParams.get("temporary-chat"), "true");
const search = new URL(buildChatGptUrl(withSettings({ webSearch: true }), "hi"));
assert.equal(search.searchParams.get("hints"), "search");
});
test("all four URL-affecting settings combine, q first", () => {
const url = buildChatGptUrl(
withSettings({
model: "auto",
temporaryChat: true,
webSearch: true,
promptTemplate: "Explain: {query}",
}),
"entropy",
);
assert.deepEqual(paramsOf(url), ["q", "model", "temporary-chat", "hints"]);
assert.equal(queryOf(url), "Explain: entropy");
});
test("openIn never influences the URL", () => {
const base = buildChatGptUrl(DEFAULT_SETTINGS, "hi");
for (const openIn of ["new-tab", "background-tab", "new-window"]) {
assert.equal(buildChatGptUrl(withSettings({ openIn }), "hi"), base);
}
});
test("special characters survive encoding round-trip", () => {
const raw = 'a & b # c ? d = e + f "g" <h> 100% 😀 日本語\nnewline';
const url = buildChatGptUrl(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");
});
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");
});
test("an empty or whitespace-only template yields the bare query", () => {
for (const promptTemplate of ["", " "]) {
assert.equal(queryOf(buildChatGptUrl(withSettings({ promptTemplate }), "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({ model: "some-long-model-slug", 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("model"), "some-long-model-slug");
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
// 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}`);
});
+94
View File
@@ -0,0 +1,94 @@
import test from "node:test";
import assert from "node:assert/strict";
import { DEFAULT_SETTINGS, MAX_MODEL_CHARS } from "../lib/defaults.js";
import { mergeSettings } from "../lib/settings.js";
test("empty object yields the defaults", () => {
assert.deepEqual(mergeSettings({}), DEFAULT_SETTINGS);
});
test("null and undefined yield the defaults", () => {
assert.deepEqual(mergeSettings(null), DEFAULT_SETTINGS);
assert.deepEqual(mergeSettings(undefined), DEFAULT_SETTINGS);
});
test("a non-object yields the defaults", () => {
assert.deepEqual(mergeSettings("nonsense"), DEFAULT_SETTINGS);
assert.deepEqual(mergeSettings(42), DEFAULT_SETTINGS);
});
test("valid stored values are preserved", () => {
assert.deepEqual(
mergeSettings({
model: "auto",
temporaryChat: true,
webSearch: true,
openIn: "new-window",
promptTemplate: "Explain: {query}",
}),
{
model: "auto",
temporaryChat: true,
webSearch: true,
openIn: "new-window",
promptTemplate: "Explain: {query}",
},
);
});
test("missing keys fall back individually", () => {
const merged = mergeSettings({ model: "auto" });
assert.equal(merged.model, "auto");
assert.equal(merged.temporaryChat, DEFAULT_SETTINGS.temporaryChat);
assert.equal(merged.promptTemplate, DEFAULT_SETTINGS.promptTemplate);
});
test("unknown keys are dropped", () => {
const merged = mergeSettings({ model: "auto", nope: "gone", openInn: "typo" });
assert.deepEqual(Object.keys(merged).sort(), Object.keys(DEFAULT_SETTINGS).sort());
assert.equal("nope" in merged, false);
});
test("wrong types fall back to the default", () => {
const merged = mergeSettings({
model: 123,
temporaryChat: "yes",
webSearch: 1,
promptTemplate: [],
});
assert.equal(merged.model, DEFAULT_SETTINGS.model);
assert.equal(merged.temporaryChat, DEFAULT_SETTINGS.temporaryChat);
assert.equal(merged.webSearch, DEFAULT_SETTINGS.webSearch);
assert.equal(merged.promptTemplate, DEFAULT_SETTINGS.promptTemplate);
});
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);
});
test("each documented openIn value is accepted", () => {
for (const value of ["new-tab", "background-tab", "new-window"]) {
assert.equal(mergeSettings({ openIn: value }).openIn, value);
}
});
test("mergeSettings does not mutate DEFAULT_SETTINGS", () => {
mergeSettings({ model: "mutated" });
assert.equal(DEFAULT_SETTINGS.model, "");
});
// An unbounded model string can, on its own, push the finished URL over
// MAX_URL_CHARS (lib/build-url.js), which silently drops `q` — the user's
// selection vanishes with no signal. mergeSettings() must bound `model` the
// same way it already bounds an out-of-range openIn: fall back to the default.
test("a model at exactly MAX_MODEL_CHARS is accepted", () => {
const model = "m".repeat(MAX_MODEL_CHARS);
assert.equal(mergeSettings({ model }).model, model);
});
test("a model one character over MAX_MODEL_CHARS falls back to the default", () => {
const model = "m".repeat(MAX_MODEL_CHARS + 1);
assert.equal(mergeSettings({ model }).model, DEFAULT_SETTINGS.model);
});
+27
View File
@@ -0,0 +1,27 @@
export default {
sourceDir: ".",
artifactsDir: "web-ext-artifacts",
ignoreFiles: [
"AGENTS.md",
"CHANGELOG.md",
"CLAUDE.md",
"README.md",
"package.json",
"package-lock.json",
"web-ext-config.mjs",
// Directories need both the bare name and the glob: the glob alone removes
// the files but leaves an empty directory entry in the ZIP.
"docs",
"docs/**",
"test",
"test/**",
"web-ext-artifacts/**",
],
build: {
filename: "search-with-chatgpt-firefox-{version}.zip",
overwriteDest: true,
},
lint: {
warningsAsErrors: true,
},
};