A six-stage hardening pass: 1,668 → 2,847 tests, 55 new real-browser E2E
specs, coverage 41.8% → 59.2% (the old 56% figure was inflated — see
below). Around 40 defects fixed, three of them data-loss class.
⚠️ Four user-visible behaviour changes: bookmark cleanup no longer deletes
links that are merely unreachable; "delete bookmarks" now also deletes them
from Chrome and syncs that to all your devices; the command palette requires
a real user gesture before it will fetch anything; OPEN_URL rejects
addresses outside its allowlist.
🌐 Twelve languages are now properly supported: ~5,500 strings that had
been falling back to English are translated, and the layout uses logical
direction properties so Arabic gets real RTL.
🚨 Data safety (three irreversible defects)
- Offline, "one-click cleanup" offered to delete the entire bookmark
library. The health check separates
dead (the server answered 4xx/5xx)
from timeout/error (could not be reached — which is what offline, CORS and
rate limiting produce for perfectly live links), but the cleanup dialog
received all three and pre-selected everything. Scan → cleanup → confirm was
four clicks from an empty library, with a green "cleaned N items" as the only
feedback. Only confirmed-dead links reach the dialog now; unreachable ones stay
visible in the table and remain individually removable.
- Backup restore was entirely broken. Import validation required
message.id to be a string when the real primary key is a number, so any
backup containing chat history failed to restore — a feature that exists
precisely for users who have already lost data.
- The cleanup dialog never pre-selected anything, and fixing that alone would
have deleted every copy of each duplicate. Seeding lived in a callback a
controlled dialog never fires, so Confirm was permanently disabled; and
findDuplicates returns EVERY bookmark sharing a URL, so select-all would
have left the user with none.
🛡️ Security
- Bookmarks, browsing history and open tabs were readable by any script on
any granted site, with no user gesture. The command palette's six fetch
messages are legitimately sent by a content script and therefore cannot use
the extension-page sender gate — and nothing else checked that the palette had
ever been opened. The palette can only be opened by a Chrome keyboard command
routed through the background, which is an unforgeable gesture signal, so the
background now issues a short-lived per-tab session at exactly that moment.
Session state lives in
chrome.storage.session, not an in-process map (the
worker is evicted after ~30s idle), and the TTL is absolute rather than
sliding, so a compromised page cannot keep one gesture alive by polling.
- After revoking site access, an orphaned content script could keep reading
bookmarks and history. Chrome stops future injections but does not kill the
already-injected instance. Host access is now a precondition of the session.
- A prompt-injection escape. Marker neutralization matched two literal
strings, so case variants, internal whitespace, attributes and zero-width
characters passed through — any page (or a bookmark title) could close the
untrusted-data region early.
OPEN_URL accepted any address, including chrome:// internals, file://
and javascript:. SWITCH_TO_TAB could focus any tab in any window and
could be used to probe how many tabs the session had opened.
🐛 Fixes
- Switching models mid-conversation collapsed the context budget to 10K,
silently truncating what was actually sent.
- A keep-alive alarm never stopped. After the worker was evicted mid-stream,
chrome.alarms survived (the browser owns it) while the in-process map that
decides whether keep-alive is needed did not — so a 30-second alarm woke the
worker and broadcast a heartbeat to every tab, permanently.
- Local TTS replayed another tab's audio; background runs never released
after the offscreen producer died.
- Streams could end as blank "successes" (200 with an empty or
non-conforming body), half-open SSE streams truncated silently, and a
cloud error with a non-JSON body rendered no error banner at all.
- The local runtime's seven diagnosable failure states (not running, wrong
URL, no model…) only ever reached the dashboard's detect button; chat showed a
generic failure.
- Bookmark de-duplication failed for any URL whose query contained
~ ( ) ! ' * or an encoded space — stripping a tracking parameter re-encoded
the whole query, so the same page with and without utm_* produced different
keys.
- Pasting a large document froze the side panel (quadratic backtracking in
the Markdown link parser, run synchronously on every keystroke).
- Copy → paste-import dropped links whose title contained a line break.
- A backward clock made two TTLs never expire (elapsed time went negative,
and a negative number is never
>= TTL), one of which guards the
delete-all-data confirmation token.
✨ Added
- Editing a saved tab finally works. The entry point had shipped in three
places since v2.3 and answered every click with "coming soon"; the service
layer had no way to update a saved tab at all.
- Batch actions in collections and read-later are reachable — selection mode
previously had no entry point, so the whole batch code path was dead.
🌐 Internationalization
- ~5,500 strings translated; twelve languages no longer mix in English.
- Real RTL for Arabic: nothing in the tree had ever set
dir and the HTML
entry hardcoded lang="zh-CN". Direction now follows the active language and
the layout uses logical properties so it actually mirrors.
🧹 Platform consistency
- Removed
ConnectionTracker's setInterval heartbeat (MV3 hard rule 3, and
silence never meant a dead port anyway); deleted 183 lines of self-declared
parked dead code; cleared the dead fields and Dexie index left by the
bookmark-AI removal.
- New Dexie v13 / v14: v9 had declared a UNIQUE index over a column existing
rows already carried, so a profile holding duplicates aborted the upgrade and
could never be opened again — taking chats, settings and API keys with it —
and because Dexie builds a version's indexes before running its callback, no
later version could repair it. v13 de-duplicates, v14 restores the constraint.
🧪 Testing and engineering
pnpm gate: an eight-stage local merge gate (three typechecks, lint, docs,
full suite with coverage, build, real-browser E2E), ~5 minutes; --fast runs
in ~20 seconds.
- A real MV3 E2E harness (Playwright, throwaway profile per run), 55 specs.
MV3 hard rules 1 and 3 had been comment-only discipline; they now have guards.
- The coverage gate had never run in five months: the config used Jest's
thresholds.global syntax, which vitest reads as a glob that matches nothing
(verified: coverage at 0% still exited 0). entrypoints/** — 47% of the
source — was also outside the measurement, so the old numbers were inflated by
12–16 points.
- Large-dataset fixtures and
pnpm bench baselines; two history hot paths
optimized against them (100k-row sort 47–53ms → 25–28ms; domain grouping
14.0ms → 0.33ms).
🐛 Fixes (detail)
- Switching models mid-conversation no longer collapses the context budget to
10K. The model context window the budget needs had exactly one source: the
ai-settings store's
availableModels — a volatile, provider-scoped,
network-populated cache. Switching provider emptied it, and saveSettings also
deleted the Dexie cache row for the provider being switched to, which is
precisely the row loadSettings restores from, so reopening the side panel
stayed cold as well. With a cold cache the window resolved to undefined and
assembleChatContext fell back to its 10,000-token default: the usage ring
showed — with a fixed 4.5K reserve, and real sends were silently truncated
— not merely the gauge. The selected model's window / max output tokens are now
stamped onto the model slot at selection time (saveSettings is the single
choke point every model-picker funnels through), and the budget resolves in
three tiers: live catalog → stamped slot value → vendored LiteLLM table. The
incoming provider's model cache is no longer deleted (staleness is already
handled by the read path's baseUrl check). The conservative 10K fallback still
applies when no window is known at all.
- The collection "⋯" menu no longer jumps to the top-left corner on hover.
The action bar holding the trigger was only
display:flex under group-hover /
group-focus-within, while the menu popup is portalled to body. Moving the
pointer onto a menu item lost both hover and focus-within (focus lives outside
the DOM subtree), so the bar went display:none, the anchor collapsed to zero
size, and floating-ui re-anchored the popup at the viewport origin. The bar is
now pinned open while the menu is open, and released only once the exit
animation completes (onOpenChangeComplete).
📝 Documentation
- Docs reconciled with the code. The canonical documents (ARCHITECTURE /
AI_ENGINEERING / MV3_PLATFORM_CONTRACT) still described the deleted agent
framework (
AgentRegistry / AgentExecutor / agent-handlers) as current; the
roadmap listed the already-delivered Phase 2/3 as future work while
contradicting the connector retirement; FEATURE_AUDIT and BOOKMARKS_DESIGN
marked removed bookmark AI features as "implemented". All rewritten against the
actual code, and four agent-framework documents archived.