fix(extension): chrome.alarms keepalive so MV3 SW doesn't sleep between MCP calls - #2
Merged
Merged
Conversation
…en MCP calls The MV3 service worker idles out after ~30s without activity, taking the WebSocket to the host with it. Symptom seen in live testing: every opentable_get_profile / opentable_list_reservations call after a brief pause times out, and opening chrome://extensions → Inspect on the SW "fixes" it because the DevTools attachment pins the worker. chrome.alarms is the documented MV3 escape hatch: a periodic alarm wakes the SW regardless of inactivity, and the handler re-runs connect() (which is idempotent — no-op when the WS is open, reconnect when it isn't). The alarm fires every 0.4 minutes (~24s) — comfortably below the eviction window. The new permission is "alarms" and is only used for this single keepalive. The keepalive logic lives in a small, testable `startKeepalive(deps)` function so it can be exercised in vitest without a real chrome runtime. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chrischall
enabled auto-merge
May 21, 2026 17:58
6 tasks
2 tasks
chrischall
added a commit
that referenced
this pull request
Aug 2, 2026
…184) Three fixes that came out of debugging a real fetchproxy-backed MCP (`signupgenius-mcp`), where a wrong-host cookie read cost hours because nothing in the stack pointed at it. ## 1. `Session.missing` — partial lifts stop looking like clean ones A partial lift is the most dangerous failure `bootstrap()` can produce: the call succeeds, the maps look populated, and the consumer builds a session that fails much later somewhere unrelated. That is exactly what happened. An MCP declared `['accessToken','cfid','cftoken']` and read at the **apex** host, where only `accessToken` exists — the other two live on `www`. `bootstrap()` reported success. The MCP then failed on a completely different endpoint with *"You are no longer logged in"*, and the actual cause (wrong `storageSubdomain`) took hours to find. `Session.missing.{cookies,localStorage,sessionStorage}` reports declared keys the browser did not return, so a caller can fail loudly at the point of the lift. Verified against the original bug with a live apex read: ``` got cookies: accessToken MISSING : cfid, cftoken ``` ## 2. A scope diff is not a version mismatch Widening an MCP's declared scope is rejected by the extension's gate #2 until the user re-approves. That arrives as a `protocol` error, which inherited the blanket hint below — sending people to chase a version problem that does not exist. ``` before: cookie keys not in declared set: refreshToken — extension/server version mismatch — update both. after: cookie keys not in declared set: refreshToken — the declared scope changed since you paired. Revoke this MCP in the Transporter extension popup, then re-run to re-approve the new scope. ``` Genuine protocol errors keep the version hint. ## 3. `fpx pair --subdomain` `pair` proves the bridge with a `HEAD /` through a matching tab, and the fetch tab-matcher is strict-prefix **by design** — a fetch inherits the tab's origin context, so loosening it would let requests route through a different-origin tab. The consequence is that a profile declaring the apex cannot pair against a `www.` tab, which is the common case for sites that only ever serve `www`. Rather than weaken the matcher, let the user aim at the host they actually have open, mirroring `--storage-subdomain` on the read verbs. A no-tab failure without `--subdomain` now names the escape hatch. ## Notes - **Minor bump.** `Session.missing` is new API surface; the doc comment forward-references `1.8.0+`. - Adding a required field to `Session` is technically observable to anyone doing an exhaustive `toEqual` on a bootstrap result — one in-repo test needed updating for exactly that reason. - 1076 tests pass; `typecheck` and `build` clean. ## Follow-up Closes nothing, but the audit that produced these fixes is filed as #183 (`createSessionLifter` — making renewable session lifts the default shape). That issue also carries the fleet audit: 4 repos are affected by the one-shot capture pattern, 4 already hand-rolled the renewable shape. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
chrischall
added a commit
that referenced
this pull request
Aug 2, 2026
Addresses both nits from the auto-review of #184. ## 1. The scope-diff hint missed five of eight cases #184 special-cased the "widened scope needs a re-pair" error so it stops claiming an extension/server version mismatch. But it enumerated buckets: ```ts /\b(cookie|localStorage|sessionStorage) keys not in declared set:/ ``` Gate #2 actually rejects in **eight** distinct wordings, and the other five kept the misleading hint: | Message | Before | After | |---|---|---| | `cookie keys not in declared set:` | ✅ | ✅ | | `{local,session}Storage keys not in declared set:` | ✅ | ✅ | | `IndexedDB keys not in declared set:` | ❌ | ✅ | | `read_dom names not in declared set:` | ❌ | ✅ | | `{bucket}Storage pointer (…) not in declared set [` | ❌ | ✅ | | `(host, path, headerName) not in declared captureHeaders` | ❌ | ✅ | | `(origin, database, store) not in declared indexedDbScopes` | ❌ | ✅ | | `graphql_query name not in declared graphqlOps:` | ❌ | ✅ | Now matches on `not in declared`, since every one of those means the same thing and enumerating them is exactly how the first cut missed five. Covered by an `it.each` over all eight wordings. ## 2. README `--subdomain` landed in the built-in help in #184 but not the command table in `packages/cli/README.md`. Added, with a one-line note on *why* it exists (the fetch tab-matcher is strict-prefix by design). 1084 tests pass; typecheck clean. Closes #185 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
chrischall
added a commit
that referenced
this pull request
Aug 2, 2026
🤖 I have created a release *beep* *boop* --- ## [1.9.0](v1.8.0...v1.9.0) (2026-08-02) ### Features * **bootstrap:** add createSessionLifter for renewable session lifts ([#191](#191)) ([b2c7049](b2c7049)) ### Bug Fixes * **cli:** catch every gate-[#2](#2) scope rejection, not just three ([#187](#187)) ([b2ecced](b2ecced)), closes [#185](#185) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
chrischall
added a commit
that referenced
this pull request
Aug 3, 2026
Closes #195. ## The problem Gate #2 refuses a request whenever an MCP widens its declared scope, until the user re-approves. #187 taught the **CLI** to explain that — but MCPs don't go through the CLI. They consume the bridge via `@fetchproxy/bootstrap`, catch failures, and re-wrap them in their own copy, so the remedy never survived: ``` SignUpGenius auth: no SIGNUPGENIUS_USER_KEY or SIGNUPGENIUS_EMAIL/PASSWORD set, and fetchproxy lift failed: cookie keys not in declared set: refreshToken ``` Structurally accurate and useless — it names the symptom, points at auth config that isn't the problem, and omits the one action that fixes it. This is a routine event: `signupgenius-mcp` widened its scope twice in one afternoon. ## The fix `FetchproxyScopeError` carries the guidance on `.hint`, mirroring `FetchproxyBridgeDownError` — a shape consumers **already surface verbatim**, so adopting it needs no new habit: ``` SignUpGenius auth: fetchproxy lift failed: cookie keys not in declared set: refreshToken. the declared scope changed since you paired, so the extension is refusing the request. Revoke this MCP in the Transporter extension popup, then re-run — you will be asked to approve the new scope. This is not a version problem and does not need an update. ``` It extends `FetchproxyProtocolError`, so every existing catch site and `classifyBridgeError()` keep working unchanged. ## Two things done deliberately **All eight sites** that turn an extension `ok:false` into a throw now route through `protocolErrorFrom()`. Typing it at one site and leaving seven others would recreate the same partial-coverage bug in a new place. **The factory matches `not in declared`** rather than enumerating the nine wordings — enumerating them is exactly how the first cut of the CLI fix missed five (#188). The CLI drops its own regex and reads the typed error: the same knowledge in one place instead of two copies free to drift. ## Verification 1105 tests, typecheck and build clean. The CLI tests now construct errors via `protocolErrorFrom()` — the way the server actually builds them — rather than hand-rolling a `FetchproxyProtocolError` that no longer represents the real path. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
chrome.alarms-based keepalive to the fetchproxy MV3 service worker so the WS bridge stays reachable between bursts of MCP traffic.startKeepalive(deps)extracted into its own file with unit-test coverage; wired intomaybeBoot()in background.ts."alarms"permission to the Chrome manifest.Why
Live-testing opentable-mcp against fetchproxy 0.4.0 surfaced an MV3 idle-eviction bug: after ~30s without traffic, Chrome was killing the service worker, taking the WS to the host with it. Every subsequent MCP tool call would hang until the user opened
chrome://extensions→ "Inspect service worker" — the DevTools attachment pinning the worker is the only thing that "fixed" it.chrome.alarmsis the documented MV3 escape hatch: a periodic alarm wakes the SW regardless of inactivity. The handler re-runsconnect()(already idempotent — no-op on an open WS, reconnect on a closed one). Period is 0.4 min (~24s) — comfortably below the eviction window.The new permission (
"alarms") is used solely for this single keepalive; no scheduled payload, no other consumers. Documented inpackages/extension-chrome/README.md.Test plan
npm test— 439/439 passing (5 new keepalive tests cover: alarm registration, period bound, fire→ensureConnected, name filter, exception isolation).npm run build --workspaces— protocol/server/bootstrap/extension-core/extension-chrome all build clean. Bundleddist/background.jscontains bothchrome.alarms.createcalls and thefetchproxy-keepalivename.dist/manifest.jsonincludes"alarms"in permissions.packages/extension-chrome/dist/, sign into opentable.com, leave the SW idle for >60s without DevTools open, then run an MCP tool call — should round-trip without re-pair / re-open.🤖 Generated with Claude Code