Skip to content

fix(extension): chrome.alarms keepalive so MV3 SW doesn't sleep between MCP calls - #2

Merged
chrischall merged 1 commit into
mainfrom
fix/mv3-sw-keepalive
May 21, 2026
Merged

fix(extension): chrome.alarms keepalive so MV3 SW doesn't sleep between MCP calls#2
chrischall merged 1 commit into
mainfrom
fix/mv3-sw-keepalive

Conversation

@chrischall

Copy link
Copy Markdown
Owner

Summary

  • Add chrome.alarms-based keepalive to the fetchproxy MV3 service worker so the WS bridge stays reachable between bursts of MCP traffic.
  • New startKeepalive(deps) extracted into its own file with unit-test coverage; wired into maybeBoot() in background.ts.
  • Adds the "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.alarms is the documented MV3 escape hatch: a periodic alarm wakes the SW regardless of inactivity. The handler re-runs connect() (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 in packages/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. Bundled dist/background.js contains both chrome.alarms.create calls and the fetchproxy-keepalive name.
  • dist/manifest.json includes "alarms" in permissions.
  • Live: install unpacked extension at 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

…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 chrischall added the bug Something isn't working label May 21, 2026
@chrischall
chrischall enabled auto-merge May 21, 2026 17:58
@chrischall
chrischall merged commit 188dbf4 into main May 21, 2026
2 checks passed
@chrischall
chrischall deleted the fix/mv3-sw-keepalive branch May 21, 2026 17:58
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant