Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
496c2c3ea4 | ||
|
|
17bccc1ade | ||
|
|
8fcc93c541 | ||
|
|
51bf4d5c01 | ||
|
|
d7821e3dc7 | ||
|
|
1106f4e315 | ||
|
|
dc5bd8e00d | ||
|
|
5548216645 | ||
|
|
5e56503244 | ||
|
|
b42b11755c | ||
|
|
6bedd700e8 | ||
|
|
22fc754636 | ||
|
|
f683f2e841 | ||
|
|
5365ec881e | ||
|
|
c49f6415af | ||
|
|
b59648d900 | ||
|
|
3d5ce6a9f4 | ||
|
|
a005b642ca | ||
|
|
f057c66155 | ||
|
|
fb5b7c5539 | ||
|
|
5edb288681 | ||
|
|
457d979fb2 | ||
|
|
db4f65f5e6 |
@@ -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
@@ -1,2 +1,4 @@
|
||||
.idea
|
||||
|
||||
.idea/
|
||||
node_modules/
|
||||
web-ext-artifacts/
|
||||
npm-debug.log*
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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/
|
||||
## Options
|
||||
|
||||
# Privacy Policy
|
||||
Open `about:addons`, select this extension, and choose the **Options** tab
|
||||
(**Preferences** on macOS and Linux). Available settings:
|
||||
|
||||
This extension collects zero user data.
|
||||
- **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.
|
||||
|
||||
@@ -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.)
|
||||
});
|
||||
@@ -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.0–22.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 2024–2025; no 2026 source positively confirms. Maintained tooling grades it experimental. |
|
||||
| Valid `model` slugs | **Uncertain** | No authoritative list; sightings rot (`gpt-4`, `o1-preview`, `gpt-4o`, `gpt-5-thinking`, `auto`, `instant`, `thinking`). |
|
||||
| `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.5–2 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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}",
|
||||
};
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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());
|
||||
});
|
||||
Generated
+4095
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user