mirror of
https://git.zavage.net/Zavage-Software/search-with-chatgpt-powered-by-openai-extension.git
synced 2026-08-10 13:40:29 -06:00
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.
841 lines
47 KiB
Markdown
841 lines
47 KiB
Markdown
# 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.
|