Compare commits
22
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 |
@@ -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).
|
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)
|
## [1.0.0]) (2024-06-09)
|
||||||
|
|
||||||
First release of software.
|
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
|
This extension adds ChatGPT as a configurable Firefox search provider. Type
|
||||||
in the browser. Default prefix is "gpt <query term>" or can be set
|
`gpt <query>` in the address bar to open the query in a new ChatGPT session.
|
||||||
in the settings. Does not require login, or API key, or use custom servers
|
It uses no API key, custom server, or intermediary handler.
|
||||||
or code. Simply queries your url bar into a new ChatGPT session.
|
|
||||||
|
|
||||||
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
|
See `LICENSE.txt` for licensing terms.
|
||||||
* https://git-repos.zavage.net/zavage-software/search-with-chatgpt-powered-by-openai-extension
|
|
||||||
|
|
||||||
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,
|
"manifest_version": 2,
|
||||||
"author": "Mathew Guest <mat@zavage.net>",
|
"author": "Mathew Guest <mat@zavage.net>",
|
||||||
"homepage_url": "https://zavage-software.com/portfolio/search-with-ChatGPT",
|
"homepage_url": "https://zavage-software.com/portfolio/search-with-ChatGPT",
|
||||||
"version": "1.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": {
|
"chrome_settings_overrides": {
|
||||||
"search_provider": {
|
"search_provider": {
|
||||||
"name": "ChatGPT",
|
"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