The spec prescribed replaceAll's string form and the plan hard-coded it, so the $-substitution defect originated in the design documents rather than in execution. Both now specify the function replacer. Also replaces the spec's frame-reporting idea with the document.hasFocus() gate that shipped; frame provenance was only ever needed for auto-submit, which Appendix B rejects.
47 KiB
Extension Options Panel — Design
- Date: 2026-07-29
- Status: Approved for planning
- Branch:
feat/extension-options-panel(cut fromdevelop@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.
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
- Ship a working options panel embedded in
about:addons. - Ship two new entry points whose behaviour the panel controls.
- Add no permission-warning lines to the install prompt.
- Keep
data_collection_permissions: ["none"]accurate. - Introduce unit testing for the pure logic, without new runtime dependencies.
- Never promise behaviour that ChatGPT does not reliably deliver.
Non-goals
- Making the
gpt <query>search provider configurable. The declarativesearch_urlis 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 assearch_url(Firefox refuses to install), andbrowser.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.
- Moving the
gptkeyword tobrowser.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.mdbrowser scope). - User-facing error notifications. The
notificationspermission 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 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.
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.
Manifest additions
"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 nomodelparameter 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
-
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: selectingawk print $& hereproducedawk print {query} here, andPID is $$ in bashproducedPID 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).
- If
-
Measure the non-
qbudget. Build aURLfromBASE, appendmodel,temporary-chat, andhintsper the rules below, and recordbaseLength = url.href.length. -
Fit the prompt. If
prompt !== "", find the largest number of code points ofprompt(via binary search overArray.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. -
Assemble in final order. Build a fresh
URLand set parameters in this order, matching the most-cited working examples:q(if non-empty), thenmodel,temporary-chat,hints.Measuring in step 2 without
qand assembling withqfirst 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. -
Return
url.href.
Parameter rules
q— set only when the fitted prompt is non-empty. Never emitq=with an empty value.model— set tosettings.model.trim()only when that is non-empty.temporary-chat— set to the string"true"only whentemporaryChatis true.hints— set to the string"search"only whenwebSearchis 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
- Trigger. Two entry points, both producing a query string:
-
browser.menus.onClickedsuppliesinfo.selectionTextdirectly. No injection, noactiveTabinvolved. -
browser.commands.onCommandreads the selection itself: resolve the active tab withtabs.query({active: true, currentWindow: true}), thentabs.executeScriptreturningwindow.getSelection().toString(). Read onlytab.idfrom the query result — readingtab.urlortab.titlewould require thetabspermission, which does carry an install warning.Pass both
allFrames: true(a selection inside an iframe is invisible to the top frame) andmatchAboutBlank: true(needed forabout:blankframes), 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 independentSelection, 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 anexecuteScriptresult 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.
executeScriptdoes not only throw — on parent-processabout:pages such asabout:addonsandabout:preferencesit resolves silently to[null], soresults[0].textis a TypeError waiting to happen. If nothing usable comes back the query is empty and a blank chat opens.
-
settings = await load()—load()already pipes the rawstorage.sync.get()result throughmergeSettings(), so callers always receive a complete, validated settings object and never need to merge again.url = buildChatGptUrl(settings, query).- 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.openInnever 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
qparameter 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 modelempty and whitespace-only → nomodelkey
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+Gwith 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 underabout:addons→ gear → Manage Extension Shortcuts and can be rebound- The shortcut must be verified by actually pressing it.
commands.getAll()reports asuggested_keyas registered even when Firefox has silently overridden it — measured:Ctrl+Shift+Y(Downloads on Linux) showed as registered and never fired. A greengetAll()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
openInvalues 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/*.zipshows notest/ordocs/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:
"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:
"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_JSONwarning on every run, and - fails outright on Node 22.0–22.6, since detection only became default in
22.7.
engines.nodesays>=22and 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.json1.0→1.1;package.json1.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. |
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. 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
- 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
/searchpath's silent move to 404 in mid-2025 is the precedent. modelmay never work alongsideq. An unresolved report saysqforces 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.Alt+Shift+Gmay 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.- 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.
The shortcut's selection-reading depends on aResolved, positive. Firefox'scommandsinvocation conferringactiveTab.ext-commands.jscallstabManager.addActiveTabPermission(tabTracker.activeTab)before firing the event, and a real synthesized keypress in Firefox 153.0.1 read page text withactiveTabas the only host-ish permission. No longer a risk.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 arguewebsiteContentapplies. 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 thegptkeyword tobrowser.omnibox, leaving Firefox as the transmitter for the address-bar path. If AMO ever requires declaringsearchTerms, 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 nomin_manifest_versionontype. - MV2
background: {scripts}withouttypefails onimportwith"SyntaxError: import declarations may only appear at top level of a module". options_ui.open_in_tab: falserenders insideabout:addons(inline-options-browser), gated onextensions.htmlaboutaddons.inline-options.enabled, which shipstrue.- 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, andactiveTabare all absent;tabsis 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.syncthrows only for a temporary add-on ID (enforceNoTemporaryAddon); the explicitgecko.idsatisfies it. No account check exists anywhere in the path — the backing store is a plain profile-localstorage-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+Giskey_findAgainandCtrl+Shift+Giskey_findPrevious.Alt+Shift+Gis unused bybrowser-sets.incand DevTools.tabs.create/windows.createdeclare no permissions; onlyTab.url/title/favIconUrlrequiretabs.commandsisversion_added: falseon Firefox for Android.options_ui.browser_styleis deprecated in MV3 only (default flipped in Firefox 115, unsupported in 118). Legal in MV2, where it defaults totrue.
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 inlib/producedDANGEROUS_EVALand exit 1. - web-ext's default ignore list includes
'**/.*'and'**/.*/**/*', so dotfiles never ship. Confirmed:.gitea/and a planted.agents/note.mdwere both absent from a build with no dotfile entries inignoreFiles. test/anddocs/do ship by default. With only"test/**", emptytest/anddocs/directory entries remained in the ZIP; adding the bare names removed them.- The current shipped ZIP contains only
manifest.json,LICENSE.txt, andicons/— packaging hygiene is a property to preserve, not a bug to fix. node --testwith no"type"field: passes on Node 26.5.0 but warnsMODULE_TYPELESS_PACKAGE_JSON; with--no-experimental-detect-module(pre-22.7 behaviour) it fails with"Cannot use import statement outside a module"..mjsfor tests only moves the warning to the typelesslib/*.js.- Adding
"type": "module":node --testclean,web-ext lint0/0/0,web-ext buildsucceeded. node --testwith 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 favouredResolved 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.Sec-Fetch-Site: nonebucket for auto-submit.- Whether Firefox enforces Chrome-style
storage.syncwrite-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+Gcollides 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
commandskeyboard shortcut confersactiveTab. Firefox'sext-commands.jscallstabManager.addActiveTabPermission(tabTracker.activeTab)before firingonCommand. A synthesized real keypress read page text withpermissions: ["activeTab", "menus"]and nothing else; the identical call from a no-gesture background path threwMissing host permission for the tab. executeScriptfails in three shapes, one silent: two distinct throws plus a resolution to[null]on parent-processabout:pages.suggested_keyloses silently to Firefox's built-ins whilecommands.getAll()still reports it registered.Ctrl+Shift+Ynever fired.activeTabadds no install-prompt line — re-confirmed by running Firefox's ownExtensionData.formatPermissionStringsinside 153.0.1:["activeTab", "storage", "menus"]yieldsmsgs: [], byte-identical to requesting nothing.search_urlcannot be an extension page. Schema and addons-linter constrain it tohttps://orhttp://localhost; Firefox 153.0.1 refuses to install ("Extension is invalid") formoz-extension://, relative, and root-relative paths.browser.omniboxworks with zero permissions and can reuse thegptkeyword alongside the registered engine, linting clean. Declined for policy exposure, not capability — see Rejected alternatives.permissions.request()works from the inlineabout:addonsoptions 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 withpermissions.request may only be called from a user input handler, and the gesture does not surviveruntime.sendMessagedelegation 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_permissionswill 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.