openvibers/openvibe.extensions README.md

README.md

main 26.2 KB · 305 lines · markdown

1# OpenVibe.Extensions
2
3> OpenVibe's first-party client surfaces: today, the **OpenVibe Companion** browser extension.
4
5**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
6**Build and test:** `npm install && npm run pack` (Node 22 or newer) · **Tests:** `npm test`
7
8**Status:** alpha (roadmap Wave 18, client part). The extension builds, its tests pass, and headless
9Chrome loads the unpacked 0.9.2 build with no console or CSP errors (2026-11-07, Chrome 156 via
10CDP `Extensions.loadUnpacked`; the side panel's Agents tab draws the Dock's agent view against stubbed first parties, see the credential note below). A development build of
11the coupon part looked up codes and sent a report against a local OpenVibe.Coupons. Mozilla's
12`web-ext lint` (what addons.mozilla.org runs on upload) passes with no errors, warnings or notices
13and runs in CI; it hasn't been loaded in a running Firefox yet. It is **not published** to any
14store yet: the store zips, listing text and privacy answers are ready (`npm run pack:stores`,
15[store/](store/)), and only the owner's developer-account step is left. OpenVibe.Coupons is
16public at `https://openvibe.coupons` since 2026-10-09, so the release build talks to the live
17Coupons API. On 2026-10-22 Coupons held 169 codes for 16 of its 37 merchants, all from the DealNews feed through
18OpenVibe.Sources; it had issued no install token and received no code report.
19**Decision record:** [ADR-023](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-023-client-surfaces.md) (first-party client surfaces and their home). Roadmap §4.2 D.
20**License:** MIT.
21
22## Purpose
23
24Client code ships on a different schedule from servers: store review, browser updates and
25per-install permissions. ADR-023 gives clients their own repository.
26
27| Surface | Directory | State |
28|---|---|---|
29| OpenVibe Companion (Chromium and Firefox, Manifest V3) | `browser/` | alpha, described below (it was the OpenVibe Coupons Helper until 0.2.0) |
30| Kiosk companion (Firefox, new-tab and history bridge for the /kiosk page) | `kiosk-companion/` | Moved from OpenVibe.Live `browser-extension/openvibe-kiosk-companion/` (ADR-023, Live 9c5aa64); Live keeps a redirect README |
31| Hardware / Raspberry Pi companions (streamer and controller scripts) | `hardware/` | Moved from OpenVibe.Live `hardware/` (ADR-023, Live 9c5aa64); Live keeps a redirect README |
32| Desktop client | — | Planned by ADR-023 for the long term. Nothing exists. |
33
34## The OpenVibe Companion (`browser/`)
35
36One button in the browser for the OpenVibe sites that work with the page you are on:
37
38- **Keep on my Rent board.** On a rental listing (Craigslist, Facebook Marketplace, Zillow,
39 Apartments.com, Zumper, Airbnb and the rest) or any page, it opens
40 [openvibe.rent/board](https://openvibe.rent/board) with the listing's address and title filled in,
41 and the price and city the page states (`lib/listing.js` reads them when you press: the structured data
42 the site publishes for search engines, else its price tag), so every listing from every site sits in one
43 list with your notes and how far you got. You check them there before saving. On a rental site this is
44 the main button.
45- **Check the rent.** On a rental listing it opens [openvibe.rent/fair](https://openvibe.rent/fair) with the listing's
46 address and title, and the monthly rent in dollars, the bedrooms and the city the page states (read the same way;
47 a sale price or another currency is left out). Rent reads what is missing from the address and title (and says
48 which it read), and compares the rent with the city's median for that size from the Census Bureau.
49- **Watch this page.** It opens a new watch on [OpenVibe.Watch](https://openvibe.watch) for the page,
50 which tells you (or runs your agent) when it changes.
51- **Compare delivery apps.** On DoorDash, Uber Eats, Grubhub, Seamless, Caviar or Instacart it opens
52 [openvibe.food/delivery](https://openvibe.food/delivery) with the restaurant or store's name (read from the page
53 title, never the address or the cart), and every app's own search for it opens from there.
54- **Save its rating to OpenVibe Reviews.** On Glassdoor, Indeed, Yelp, the BBB, Trustpilot, Google Maps or
55 Tripadvisor, it reads the rating the page states (the structured data the site publishes for search engines, else
56 the stars' own label, like "4.5 star rating") and opens [openvibe.reviews/save](https://openvibe.reviews/save) with
57 the page's address, its name and the numbers. You check them against the page there and save; the rating then shows
58 beside that site's link on the OpenVibe.Reviews page, with the date. On a review site this is the main button.
59- **Try these codes on this page.** At a shop's cart or checkout, one button types the codes OpenVibe.Coupons knows
60 into the page's own coupon box, presses that box's Apply button, and reads the order total before and after. It
61 stops at the first code that lowers the total and leaves it applied. Codes reported working go first, codes
62 reported not working are never tried, and at most ten are tried. It never presses checkout, pay, order, cart or
63 remove buttons, never submits a form and never presses Enter. With no Apply button next to the box it presses
64 nothing. If you connected the extension, you can send what happened to OpenVibe.Coupons in one click.
65- **Coupon codes.** On a shop's website it shows the codes
66 [OpenVibe.Coupons](https://github.com/OpenVibers/OpenVibe.Coupons) knows for that shop:
67
68- the code, with a Copy button
69- its status (validity unknown, reported working, reported not working)
70- its confidence, or "no reports", and its expiry, or "Expiry unknown"
71- its restrictions, or "No restrictions stated"
72- how to apply it
73
74If you connected the extension, you can report whether a code worked. The codes are shown to copy;
75the Companion never rewrites links or adds referral tags.
76
77### What it can see, and what it sends
78
79| | |
80|---|---|
81| **Permissions** | `activeTab`, `identity`, `scripting`, `storage`, `alarms`, `clipboardWrite`, and Chrome-only `offscreen` and `sidePanel` in the Chrome build (Firefox's `sidebar_action` needs no permission of its own). The host permissions are five OpenVibe origins: `https://openvibe.coupons/*` (the coupon API), `https://openvibe.network/*` (the Dock's state), `https://openvibe.codes/*` (the Dock's agent view and its four controls) and `https://openvibe.chat/*` (the Dock's Chats tab), plus `https://openvibe.locker/*` (encrypted password logins). `identity` starts the Dock's one-click OAuth sign-in (`chrome.identity.launchWebAuthFlow` / Firefox's `browser.identity`). `scripting` is used for four buttons: "Try these codes on this page" injects the packaged `lib/autoapply.js`, "Save its rating to OpenVibe Reviews" injects `lib/rating.js`, and "Keep on my Rent board" and "Check the rent" inject `lib/listing.js`, into the active tab (the access `activeTab` gives after you press the button), in its top frame and the extension's isolated world. |
82| **Not requested** | `<all_urls>`, `tabs`, `cookies`, `webRequest`, `history`, optional permissions, content scripts, web-accessible resources, `externally_connectable`. |
83| **Read from the page** | The active tab's **URL and title**, when you open the popup (that's what `activeTab` grants). The page's content only when you press "Try these codes on this page": `lib/autoapply.js` finds the coupon box, its Apply button and the order total, types the codes in, and returns to the popup which codes changed the total. It makes no request, reads no cookies or storage, and sends nothing anywhere; `test/manifest.test.js` scans it for that. On a review site, "Save its rating to OpenVibe Reviews" runs `lib/rating.js`, which only reads: the page's name, its rating, the scale, how many ratings, and the city from its structured data. "Keep on my Rent board" and "Check the rent" run `lib/listing.js`, which only reads: the price and its currency, the city and region (never the street) and the bedrooms, from the structured data, else the page's price tag. Browser pages, files, IP addresses and local names are "not a site", and nothing is sent or offered for them. |
84| **Sent by the extension** | The Coupons part sends to `https://openvibe.coupons` only, and only the **hostname** (`lib/host.js` drops the path, query, fragment, port and credentials first): `GET /api/v1/merchants/resolve?host=`, the merchant id (`GET /api/v1/merchants/:id/coupons`), and, when you report, the code id plus `worked` or `failed` with an optional reason. Every request goes with `credentials: 'omit'` (no cookies), `referrerPolicy: 'no-referrer'` and `redirect: 'error'`. The Dock sends the person's own bearer to `https://openvibe.network` (its state, `/api/v1/me/dock`), `https://openvibe.chat` (Chats), and, when the Agents tab needs it, `https://openvibe.codes` (the agent view and its controls). |
85| **Hand-offs (only when you press the button)** | "Keep on my Rent board", "Check the rent", "Watch this page", "Compare delivery apps" and "Save its rating to OpenVibe Reviews" open **a new tab you see** on `https://openvibe.rent/board`, `https://openvibe.rent/fair`, `https://openvibe.watch/watches/new`, `https://openvibe.food/delivery` or `https://openvibe.reviews/save`, with the page's address and title (and, for Reviews, the rating read from it; for Rent, the price, city and bedrooms read from it) in that tab's address. `lib/handoff.js` removes the credentials, the fragment and tracking parameters (`utm_*`, `fbclid`, `gclid`, …) first. The extension makes no request for it, nothing opens on its own, and nothing is saved until you save it on that site. `platform.tabs.open()` refuses any other origin, and the permission audit allows only `https://openvibe.<tld>` hand-off origins. |
86| **Stored** | The optional `cpx_` install token in `storage.local` (never synced); for the Dock, the one-click OAuth access token in `storage.session` (memory, cleared when the browser closes) and the rotating refresh token in `storage.local` (this device, not synced). A pasted 0.7.0 `networkToken` is still accepted as a fallback until a later release removes it. |
87| **CSP** | `default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob:; connect-src https://openvibe.coupons https://openvibe.network https://openvibe.chat https://openvibe.codes; frame-src https://openvibe.live; object-src 'none'; base-uri 'none'`. The Dock draws its own styles in a `<style>` element (styles only; script stays `'self'`), the agent view shows its still from a local `blob:`, and its Live embed is the only frame. There is no inline script, no remote code, no `eval`, and no `innerHTML` in the extension's own code: the popup builds DOM nodes and sets text. |
88
89### The credential: an install token, never a session
90
91The extension works without any credential, with the API's tighter anonymous rate limits. To
92report:
93
941. On [openvibe.coupons/connect-extension](https://openvibe.coupons/connect-extension), sign in and
95 create a token for this browser. It is shown once.
962. Paste it into the extension's settings. The extension checks that the API accepts it before
97 saving it.
98
99A `cpx_` token has two scopes, `coupons.lookup` and optionally `coupons.report`. It can't submit
100codes, moderate, or read anything private, and it isn't an OpenVibe Network session. The extension
101refuses to store anything that isn't a `cpx_` token, so a Network JWT can't be pasted in by
102mistake. Revoking the token on the site takes effect on its next request, and the popup then says
103the browser was disconnected. "Disconnect" in the settings removes the token from the browser.
104
105Because the extension exposes nothing to web pages (no content scripts, no
106`externally_connectable`, no web-accessible resources) and holds no first-party credential, a
107malicious shop page can't use it to reach anyone's data. The server side of the same guarantee is
108covered in Coupons' own tests: lookups are identical for every caller, and the API ignores cookies.
109
110### The Dock in the side panel
111
112The **OpenVibe Dock** — the pill, the card with its Vibe, Agents, Chats and Activity tabs — sits in
113the browser's side panel (Chrome's `side_panel`, Firefox's `sidebar_action`), on every site. It is the
114released OpenVibe.Shared Dock 4.212.0, bundled under `browser/shared/` because the extension's CSP allows no
115remote script, and it reads and writes the same state on the Network as the Dock on a site, so it
116opens where the person left off. It hands that state to the Dock as it opens (the page's tab, chat, agent and
117unread counts) and re-reads it every 30 s, so a change on the page reaches the open side panel.
118
119The Agents tab draws the person's own desk stream the same way a site's Dock does: the Live embed, the
120task line, and the owner's **Pause/Resume**, **Curtain now**, **Mute/Unmute** and **End** controls. The
121view is read with the person's token from OpenVibe.Codes (`GET /api/v1/me/agent-view`); the extension
122allows Codes and Live's embed for exactly that. Codes accepts the Dock's read-only OAuth token (issuer
123`<network>/dock`), so the panel shows the stream; its four server controls are links to openvibe.codes,
124where the person's own session does the same thing. The fixed Chrome id is in Live's
125`AGENT_EMBED_EXTENSION_ORIGINS`, and a browser that cannot frame the embed gets the still and a
126**Watch on openvibe.live** link instead. `scripts/check-panel.js` proves both in headless Chrome.
127
128It needs the person's own OpenVibe sign-in. Press **Sign in** in the panel or the settings page: the
129Network's account chooser opens, the extension is a public OAuth client (`companion`, PKCE S256, no
130secret), and the Dock gets a read-only token (`dock`, `activity`, `chat.read`, `agent.view`) that refreshes itself.
131The access token is kept in `storage.session`; the rotating refresh token in `storage.local`, on this
132device. Sign out revokes it at the Network, and the account page (`/my`) lists it under **Signed-in
133apps**. The 0.7.0 pasted-token path is still in the settings page as a fallback until a later release
134removes it.
135
136### Layout
137
138```
139browser/
140 manifest.json MV3; Chromium (minimum_chrome_version 114) and Firefox (gecko id, strict_min_version 128)
141 panel.html / panel.js the Dock in the side panel: the Network's state, the same tabs as the Dock on a site
142 panel.css the Dock fills the panel (no floating card, no pill)
143 shared/ the released OpenVibe.Shared Dock 4.212.0, bundled: the CSP allows no remote script
144 (dock.js, dock-*.js, action-center.js, notification-*.js, agent-sheet.js,
145 agent-session.js, ov-window.js, ov-float.js, ov-composer.js, ov-glyphs.js,
146 brands.js, theme-loader.js, ov-mark.js, kit.css)
147 popup.html / popup.js DOM wiring only
148 options.html / .js connect (save a checked token), disconnect, grant the API host permission
149 lib/config.js API_ORIGIN (Coupons), NETWORK_ORIGIN and CHAT_ORIGIN (the Dock), CODES_ORIGIN (its agent view); HANDOFF_ORIGINS (Rent, Watch, Food, Reviews)
150 lib/host.js URL → hostname (everything else dropped)
151 lib/handoff.js the Rent board, Watch, Food delivery and Reviews addresses, built from a cleaned URL; which sites are rentals and review sites
152 lib/autoapply.js "Try these codes on this page": injected into the active tab only when you press it
153 lib/rating.js "Save its rating to OpenVibe Reviews": reads a review site's rating, injected only when you press it
154 lib/listing.js "Keep on my Rent board" / "Check the rent": reads a listing's price, city and bedrooms, injected only when you press
155 lib/api.js the only code that makes requests to the Coupons API
156 lib/network-api.js the Dock's state on the Network (GET/PUT /api/v1/me/dock), with the person's own token
157 lib/auth.js the Dock's one-click OAuth sign-in: PKCE, state, refresh, sign-out
158 lib/controller.js popup logic without the DOM (tested in Link)
159 lib/platform.js chrome.* / browser.*: tabs.query, tabs.create (hand-off pages only), storage.local, permissions
160 lib/format.js wording ("Expiry unknown", "No restrictions stated", …)
161 icons/ generated by scripts/icons.js
162scripts/
163 audit.js the permission audit (manifest, CSP, pages)
164 pack.js zip for loading, or --stores: the Chrome and Firefox store zips (runs the audit first)
165 check-autoapply.js lib/autoapply.js on test/fixtures/checkout-*.html in headless Chrome (CDP, no dependencies)
166 check-panel.js the unpacked extension in headless Chrome: the panel's Agents tab draws the agent view (CDP, no dependencies)
167 icons.js draws the icons (no dependencies)
168```
169
170## Tests (Link 22, no browser, no dependencies)
171
172```bash
173npm test
174```
175
176| Requirement | Test |
177|---|---|
178| The manifest permission audit fails if anything widens: extra permissions, `<all_urls>` or broader hosts, content scripts, web-accessible resources, `externally_connectable`, optional permissions, `unsafe-eval`, remote script or connect sources, any style source beyond `'unsafe-inline'`, any image beyond `'self' blob:`, any frame beyond Live, MV2. The first-party origins agree across config, host permissions and CSP. Pages have no inline or remote script. The code never uses cookies, scripting, dynamic code, HTML injection or `storage.sync`, and only `lib/api.js`, `lib/network-api.js`, `lib/auth.js` and the bundled Dock fetch. | `test/manifest.test.js` |
179| The side panel in a real headless Chrome: the unpacked extension opens `panel.html` on the Agents tab with a stubbed Network/Chat/Codes, and the agent view draws its stream frame, line, meta and Pause/Resume, Curtain now, Mute/Unmute and End, with its styles applied and no CSP refusal. Skipped when no Chrome is installed. | `test/panel.test.js` (`scripts/check-panel.js`) |
180| API client against a stub: routes; no `Authorization` without a token; never cookies, referrers or redirects; errors as codes (revoked, scope, 429, 503); timeouts; refused input. | `test/api-client.test.js` |
181| The Coupons part never sends page content or URLs beyond the hostname. The popup controller runs on checkout URLs full of personal data, and every request the stub receives is inspected. Non-shop pages send nothing. Only `tabs.query` and `storage.local` are touched, and only `cpx_` tokens are stored there. | `test/privacy.test.js` |
182| The hand-offs: nothing opens when the popup opens; a button opens one tab on openvibe.rent or openvibe.watch with the cleaned address (no credentials, fragment or tracking parameters); the Coupons API still gets the hostname only; `tabs.open` refuses other origins; rental sites lead with the board. | `test/handoff.test.js` |
183| `scripts/pack.js`: the zip is exactly `browser/`, audited and reproducible. A development build rewrites the origin in all three places. Remote `http` origins are refused. | `test/pack.test.js` |
184
185Beyond the automated tests, the coupon flow was checked by hand on 2026-09-22:
186
187- Headless Chrome 150, loaded through CDP `Extensions.loadUnpacked`, accepted the manifest and
188 rendered both pages with no console errors or CSP violations.
189- A development build then called a local OpenVibe.Coupons (its test harness) with no CORS
190 configured. It resolved a shop, listed its code, and posted a report with a `cpx_` token. The
191 report was stored with channel `extension`.
192
193On 2026-11-07 the same CDP load ran the side panel in headless Chrome 156 (now `test/panel.test.js`
194and `scripts/check-panel.js`): with a stubbed Network, Chat and Codes, `panel.html` opened on the
195Agents tab, drew the agent view's Live frame, task line and `Paused by @ada` meta, and showed
196Pause/Resume, Curtain now, Mute/Unmute and End, with the Dock's styles applied and no CSP refusal.
197The 2026-09-22 coupon flow is not in CI; the panel check is, and it skips when no Chrome is installed.
198Firefox hasn't been tried.
199
200## Packing (never published from here)
201
202```bash
203npm run pack # dist/openvibe-companion-<version>.zip (API https://openvibe.coupons)
204npm run pack:stores # dist/openvibe-companion-<version>-chrome.zip and -firefox.zip (store uploads)
205npm run lint:firefox # Mozilla web-ext lint, warnings as errors (CI runs it)
206node scripts/pack.js --api http://localhost:4850 # dist/…-dev.zip for a local Coupons (localhost/127.0.0.1 or https only)
207```
208
209Each version's zip is attached to its [GitHub release](https://github.com/OpenVibers/OpenVibe.Extensions/releases/latest).
210To load it:
211
212- **Chromium:** `chrome://extensions`, turn on Developer mode, choose "Load unpacked" and pick the
213 unzipped folder.
214- **Firefox:** `about:debugging`, "This Firefox", "Load Temporary Add-on", and pick
215 `manifest.json`.
216
217In Firefox, host permissions for MV3 extensions can be withheld. If they are, the settings page
218shows "Allow access to openvibe.coupons".
219
220## Before a store release (for the lead)
221
2221. **OpenVibe.Coupons is already public** (see its README; live at `https://openvibe.coupons` since
223 2026-10-09). The release build talks to `https://openvibe.coupons` for codes and to
224 `https://openvibe.network`, `https://openvibe.chat` and `https://openvibe.codes` for the Dock,
225 each named in the manifest and the CSP and nothing else.
2262. **Store listings:** `npm run pack:stores` builds `dist/openvibe-companion-<version>-chrome.zip`
227 (no `browser_specific_settings`) and `-firefox.zip` (no `minimum_chrome_version`), byte for byte
228 reproducible. Upload them with the fields in [store/listing.md](store/listing.md), the single
229 purpose, permission justifications and data disclosures in [store/privacy.md](store/privacy.md)
230 (written from the code; `browser_specific_settings.gecko.data_collection_permissions` declares
231 `authenticationInfo`, `browsingActivity` and `websiteContent` to match), and the shots in
232 [store/screenshots.md](store/screenshots.md). Never upload from CI.
2333. **CORS (optional):** set `COUPONS_EXTENSION_ORIGINS` on Coupons to the Chrome Web Store id
234 (`chrome-extension://<id>`) and, if wanted, `moz-extension://*`. The extension doesn't need it,
235 because its host permission covers the API. CORS only helps when a browser withholds that
236 permission, and only for lookups.
2374. **Version:** bump `browser/manifest.json` for every store upload. Stores refuse a version
238 they've already seen.
239
240## Depends on
241
242- [OpenVibe.Rent](https://github.com/OpenVibers/OpenVibe.Rent) `/board?url=&title=` and
243 [OpenVibe.Watch](https://github.com/OpenVibers/OpenVibe.Watch) `/watches/new?template=page&url=&name=`
244 (pages, not APIs: the person signs in there as usual).
245- [OpenVibe.Coupons](https://github.com/OpenVibers/OpenVibe.Coupons) public API:
246 - `GET /api/v1/merchants/resolve?host=`
247 - `GET /api/v1/merchants/:id/coupons`
248 - `POST /api/v1/coupons/:id/report`
249 - `cpx_` install tokens from `/connect-extension`
250- [OpenVibe.Codes](https://github.com/OpenVibers/OpenVibe.Codes) `/api/v1/me/agent-view` (the
251 Dock's agent view: the state, the still, and the pause, curtain, mute and end controls). Its
252 session check does not accept the Dock's `<network>/dock`-issued OAuth token yet, so the view is
253 bundled and verified against a stub but stays hidden in production until Codes or Network accepts
254 that credential.
255- [OpenVibe.Live](https://github.com/OpenVibers/OpenVibe.Live) `/embed/agent/<ags_id>?vt=…`, the
256 agent view's frame. Its `frame-ancestors` list is first-party site origins; an extension origin is
257 not on it yet.
258- No npm dependencies. Tests and scripts use Link's standard library.
259
260---
261
262Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
263
264## Locker passwords
265
266The popup unlocks Locker with its passphrase or recovery kit. A passkey belongs to
267openvibe.locker, so the popup explains this and offers **Open Locker**. Unlock keys
268live only in `storage.session` (or background memory where unavailable), never local
269or sync storage. Lock now, account changes, browser restart and 15 idle minutes
270clear them. Worker suspension does not reset the deadline.
271
272Saved logins match the current origin exactly, including scheme, hostname and port.
273**Fill** writes visible username/password inputs in the active tab's top frame;
274**Fill and sign in** also submits. Neither runs on page load. Ambiguous forms with
275more than one password input are refused. The injected function checks the origin
276again immediately before writing each input. Copy password/code clears the clipboard
27730 seconds after the latest copy, even when the popup closes; it does not read the
278clipboard and may clear something copied elsewhere during those 30 seconds.
279
280**Save this login** encrypts `{version:1,site,username,password,totp,notes}` using the
281same SDK envelope and private payload framing as Locker. Site and login details
282stay encrypted on the server. TOTP codes (including otpauth links) are computed
283locally. No password telemetry or breach lookup runs in Companion.
284
285Deployment requires Network's `locker.login.manage` scope and
286`GET /api/v1/me/keys/companion`, then Locker's `/api/v1/companion` routes. Existing
287OAuth grants need **Sign in to Companion** again to grant the new scope. The scope
288allows only own wrapped-key reads and own encrypted-login reads/creates, with live
289Network revocation checks. It grants no key-write, general-file or agent blind-fill
290capability. Chrome uses an offscreen clipboard document; Firefox uses its
291background event page with an alarm to clear even after suspension. Only the Locker host permission is added.
292
293Bundled crypto: `browser/shared/ov-locker-core.js` is the unchanged SDK 0.101.0
294browser bundle from Shared 4.156.0. `locker-payload.js` and `locker-passwords.js`
295come from Locker commit `9c2744e`; keep their framing/TOTP semantics compatible.
296Run `npm test -- locker`, `npm run pack:stores`, and `npm run lint:firefox`.
297
298Optional browser verification: install Playwright outside the package (or set
299`PLAYWRIGHT_MODULE` to an existing installation), install its Chromium build, and run
300`node test/locker-browser.js`. It checks trusted popup clicks, 12 responsive/theme
301screenshots and actual clipboard clearing with the packaged worker and no popup open.
302
303Firefox MV3 background pages are [non-persistent](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Background_scripts), so a clipboard alarm backs up its in-memory timer.
304Chrome uses the documented [offscreen clipboard context](https://developer.chrome.com/docs/extensions/reference/api/offscreen).
305