Repository navigation
feat(extension): per-identity pairing + non-blocking scope growth + connection dot - #97
Conversation
Part 1: key the pending-pair queue by (identityHash + scopeHash) not mcpId, so concurrent instances of the same MCP identity produce one approval covering all. Part 2: granted scope = approved ∩ declared, with a non-blocking "scope-update" offer when an MCP gains capabilities — keeps serving the approved scope instead of force-blocking a re-pair. Both preserve granted ≤ approved. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per-entry presence dot (green=connected/grey=offline), driven by the set of connected identityHashes (>=1 live session) with live updates via a connections-changed event. Same popup as Parts 1-2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… connection dot Part A: pure scope hashing/intersection helpers. Part 1: composite-keyed pending-pair (one approval per identity+scope). Part 2: granted = approved ∩ declared + non-blocking scope-update offer. Part 3: connection-status dot from live sessions. TDD task-by-task; security invariant granted ≤ approved. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…pproval Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n an MCP gains capabilities When an already-trusted MCP declares more than its approved scope, instead of blocking (needs-pair), the decision now: - returns auto-trust with granted = intersect(approved, declared) - signals pendingScopeUpdate when declared ⊃ approved so onServerHello can queue a dismissible scope-update offer in the popup The request handler enforces granted (not declared) capabilities via mcpCapabilities, which is set from result.capabilities (the intersection). PendingPairRecord split into PendingPairRecord|PendingScopeUpdateRecord union; onApproval handles both; onScopeUpdateDismiss records dismissed scopeHashes to suppress re-queuing until the declared scope changes again. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ression Add scope-update mode to PopupState with [Grant] and [Keep as is] buttons. [Grant] writes approvedPair → background onApproval → trust.put with declared scope (no ECDH needed, MCP already connected). [Keep as is] writes dismissedScopeUpdate → background onScopeUpdateDismiss → stores dismissed scopeHash so the same declared scope is not re-queued for that identity until the declared scope changes. A new capability set after dismiss IS queued again (different scopeHash). Bootstrap wires both pair and scope-update pending record kinds with removePendingAndContinue shared helper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… live sessions Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Claude finished @chrischall's task in 5m 36s —— View job Claude PR Review — complete
Summary — verdict: warnSolid, well-scoped work that wires three cohesive changes (per-identity pairing dedup, non-blocking scope growth, connection-status dot) onto the existing pair flow. The central security invariant — granted = approved ∩ declared, request handler enforces granted — is preserved cleanly: No 🔴 Important findings — three 🟡 nits posted inline:
Couple of observations for follow-up (not findings):
|
| import { | ||
| sameCapabilitySet, | ||
| sameScopeArrays, | ||
| sameCaptureHeaders, | ||
| sameIndexedDbScopes, | ||
| sameStoragePointers, | ||
| scopeHash, | ||
| intersectScope, | ||
| isScopeSubset, | ||
| } from './lib/scope.js'; | ||
| import { loadOrCreateExtensionIdentity, type ExtensionIdentity } from './extension-identity.js'; |
There was a problem hiding this comment.
🟡 Nit — unused imports. The five same* helpers (sameCapabilitySet, sameScopeArrays, sameCaptureHeaders, sameIndexedDbScopes, sameStoragePointers) are imported here but never referenced anywhere else in background.ts after the Part 2 refactor — they were used by the old "tighten / widen → needs-pair" path, which is now replaced by intersectScope + isScopeSubset. They're still used inside scope.ts itself (by isScopeSubset), so the export is justified, but the import in this file is dead.
tsc doesn't catch this because the workspace doesn't enable noUnusedLocals. Dropping these from the import list keeps the surface tight.
| import { | |
| sameCapabilitySet, | |
| sameScopeArrays, | |
| sameCaptureHeaders, | |
| sameIndexedDbScopes, | |
| sameStoragePointers, | |
| scopeHash, | |
| intersectScope, | |
| isScopeSubset, | |
| } from './lib/scope.js'; | |
| import { loadOrCreateExtensionIdentity, type ExtensionIdentity } from './extension-identity.js'; | |
| import { | |
| scopeHash, | |
| intersectScope, | |
| isScopeSubset, | |
| } from './lib/scope.js'; |
| const currentEntry = existing[pendingKey]; | ||
| if (currentEntry && currentEntry.kind === 'pair') { | ||
| // Collapse: add this mcpId to the waiting set (dedup). | ||
| if (!currentEntry.mcpIds.includes(result.mcpId)) { | ||
| currentEntry.mcpIds.push(result.mcpId); | ||
| } | ||
| currentEntry.sessionNonces[result.mcpId] = sessionNonceB64; | ||
| } else if (!currentEntry) { | ||
| // New entry. | ||
| const pending: PendingPairRecord = { | ||
| key: pendingKey, | ||
| kind: 'pair', | ||
| identityHash: result.identityHash, | ||
| serverName: result.serverName, | ||
| version: result.version, | ||
| mcpIds: [result.mcpId], | ||
| sessionNonces: { [result.mcpId]: sessionNonceB64 }, | ||
| domains: [...result.domains], | ||
| capabilities: [...result.capabilities], | ||
| cookieKeys: [...result.cookieKeys], | ||
| localStorageKeys: [...result.localStorageKeys], | ||
| sessionStorageKeys: [...result.sessionStorageKeys], | ||
| captureHeaders: [...result.captureHeaders], | ||
| indexedDbScopes: [...result.indexedDbScopes], | ||
| localStoragePointers: [...result.localStoragePointers], | ||
| sessionStoragePointers: [...result.sessionStoragePointers], | ||
| ...(result.previousScope ? { previousScope: result.previousScope } : {}), | ||
| pairCode: result.pairCode, | ||
| identityX25519Pub: result.identityX25519Pub, | ||
| identityEd25519Pub: result.identityEd25519Pub, | ||
| }; | ||
| existing[pendingKey] = pending; | ||
| } |
There was a problem hiding this comment.
🟡 Nit — needs-pair silently dropped when colliding with a scope-update entry at the same key.
The branch only handles two cases — currentEntry.kind === 'pair' and !currentEntry. If currentEntry exists with kind === 'scope-update', neither branch fires and the new needs-pair record is silently lost (no entry written, no pair-pending sent at line 1013 since that's also gated on entry.kind === 'pair'). The MCP then hangs waiting for a ReadyFrame that will never arrive.
Repro sequence (narrow but reachable):
- Trust exists for identity
Iwith scopeS₀. - MCP A connects, scope grew → auto-trust with intersection +
scope-updatequeued at keyI:hash(S_declared). - User revokes trust for
Ivia the popup before deciding the scope-update. - MCP A's WS drops and reconnects. Hello → no trust record → needs-pair with key
I:hash(S_declared)— same key. - Neither branch fires; needs-pair drops.
Suggested fix: if currentEntry.kind === 'scope-update', replace it with the new pair record (the auto-trust path it represented is no longer valid — there's no trust record any more).
| approvedIndexedDbScopes: IndexedDbScopeDecl[]; | ||
| approvedLocalStoragePointers: StoragePointerDecl[]; | ||
| approvedSessionStoragePointers: StoragePointerDecl[]; | ||
| sessionNonce: Uint8Array; |
There was a problem hiding this comment.
🟡 Nit — sessionNonce field is dead. pendingScopeUpdate.sessionNonce is populated at line 361 but never read anywhere in the consumer at lines 851-929 (scope-update is a non-blocking offer — no ECDH / ReadyFrame is derived from it, since the session is already live). Safe to drop both the field on the type and the assignment in the result.
…d code) (#99) Follow-up to #97 — those review nits were committed *after* #97 auto-merged, so they were orphaned. Cherry-picked onto fresh `main`. - **🟡→bug: needs-pair dropped on key collision with a queued `scope-update`.** The pending-queue write only handled `kind:'pair'` and no-entry; a `scope-update` at the same `${identityHash}:${scopeHash}` caused silent fallthrough (record dropped, no `pair-pending` → MCP hangs; reachable by revoking trust mid scope-update then reconnecting). Extracted `applyNeedsPairRecord` so a needs-pair **supersedes** a queued scope-update (trust is gone, the offer is moot), unioning `mcpIds`. New TDD test covers it. - **🟡 dead `same*` imports** removed from `background.ts` (still exported from `scope.ts`). - **🟡 dead `pendingScopeUpdate.sessionNonce`** field + assignment removed (never read). extension-core: **157 tests** (incl. the new supersede test), `tsc -b` clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
🤖 I have created a release *beep* *boop* --- ## [0.12.0](v0.11.1...v0.12.0) (2026-06-03) ### Features * **extension:** per-identity pairing + non-blocking scope growth + connection dot ([#97](#97)) ([daf4046](daf4046)) ### Bug Fixes * **extension:** address [#97](#97) review nits (supersede-on-collision + dead code) ([#99](#99)) ([1a98d25](1a98d25)) --- 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>
Bumps `@fetchproxy/server` 0.11.1 → 0.13.0. ## What this delivers - **v0.13.0 — host failover.** A connected peer re-elects itself to host when the current host tab dies ([fetchproxy#100](chrischall/fetchproxy#100)). The bridge no longer goes dark when the hosting tab is closed or crashes. - **v0.12.0 — per-identity pairing + connection dot.** Per-identity pairing, non-blocking scope growth, and a connection-status indicator in the extension ([fetchproxy#97](chrischall/fetchproxy#97)). ## Why `enhancement` and not `dependencies` `@fetchproxy/server` is first-party (chrischall/fetchproxy) — per the repo's release-notes convention, bumps to packages we own that ship real product improvements get `enhancement`/`feat:` so they drive a release and land under Features, not hidden under Dependencies. ## Verification - `npm install` → resolves `@fetchproxy/server@0.13.0` - `npm run build` → typecheck + bundle clean - `npm test` → 142/142 pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Three cohesive changes to
@fetchproxy/extension-core's pairing/trust flow, motivated by a real outage:musescore-mcp0.3.0 added a capability, which force-blocked a re-pair across two concurrent instances and hung the whole bridge. Spec + plan indocs/superpowers/.🔒 Invariant preserved throughout: granted scope ≤ approved scope; a capability is never granted without an explicit
[Grant].Part A — scope helpers (
src/lib/scope.ts)scopeHash(order-independent),intersectScope,isScopeSubset, plus thesame*equality helpers moved out ofbackground.ts(DRY). Pure + unit-tested.Part 1 — per-identity pairing
pendingPairis now keyed by(identityHash + scopeHash)with anmcpIds[]set (+sessionNoncesmap) instead of per-mcpId. Concurrent instances of the same identity+scope collapse into one approval; approving trusts the identity once and replays a session to every waitingmcpId. Legacy pending records migrate on read.Part 2 — non-blocking scope growth ("update or remain paired")
The hello decision now computes
granted = approved ∩ declaredand auto-trusts on the intersection instead of blocking when an MCP declares new capabilities. A dismissiblescope-updateoffer ([Grant]/[Keep as is]) is queued; the request handler enforces the granted capability set, so an ungranted verb errors cleanly instead of hanging.[Keep as is]is suppressed (per-identity dismissedscopeHash) until the declared scope changes again. This would have prevented the musescore outage.Part 3 — connection-status dot (independent)
Each trusted MCP in the popup shows a green/grey presence dot, driven by the set of connected
identityHashes (≥1 live session), with live updates via aconnections-changedbroadcast +get-connected-identitiesquery.Tests / status
extension-core: 156 passing,tsc -bclean. Branch touches onlydocs/+packages/extension-core(nopackages/serverchanges).Note:
packages/serverhas pre-existing test failures in this environment unrelated to this branch (it doesn't touchserver); flagging in case CI surfaces them.🤖 Generated with Claude Code