fix(server): release only our own extension claim, and stop guessing scoped names - #219
Conversation
…scoped names Three nits from #218, the post-merge review of #213. **The claim cleanup could drop someone else's claim.** The abandon and refusal paths cleared `extensionClaim` unconditionally, but the close handler may have released this socket's claim already and a newer connection may hold it by now. A closes, B claims, A resumes and nulls B's claim, C sails past the guard — and B and C both reach `extensionWs`. The interleaving the claim exists to prevent, reintroduced by its own cleanup. All four sites now check `=== ws` first. **The test helper's waiters were keyed by nothing.** `waitForServerHello` drained every waiter on each hello and dropped the ones whose `mcpId` did not match, so a caller waiting on two MCPs could only settle by timing out. No current caller does; the next one would have paid for it. Keyed by `mcpId` now. **`serverNameFromPinFile` guessed where it cannot know.** `_` is legal on both sides of a scope's `/`, so `@my_org/tool-mcp` and `@my/org_tool-mcp` share the stem `@my_org_tool-mcp` and neither is recoverable — the docblock claimed otherwise. It now translates only the unambiguous case and shows the stem otherwise, and `clear` accepts a stem as well as a name, so everything `list` prints stays usable rather than being legible and rejected at once. Closes #218 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Claude finished @chrischall's task in 3m 57s —— View job Review of PR #219All three follow-ups from #218 are addressed correctly:
One 🟡 nit left inline: Note: I could not execute Verdict: warn |
| async function clearPin(nameOrStem: string, identityDir: string): Promise<boolean> { | ||
| try { | ||
| return await clearExtensionPin(nameOrStem, identityDir); | ||
| } catch { | ||
| // Not a legal server name — try it as the stem the listing showed. | ||
| const pins = await listPins(identityDir); | ||
| const match = pins.find((p) => p.pinFile === nameOrStem); | ||
| if (!match) throw new UsageError(`no extension pin for ${nameOrStem}`); | ||
| await unlink(match.file); | ||
| return true; | ||
| } | ||
| } |
There was a problem hiding this comment.
🟡 Nit: The bare catch here conflates two different failure modes from clearExtensionPin: (1) nameOrStem isn't a legal server name (safeIdentityFileBase throws synchronously), which is the case this fallback is meant to handle, and (2) a legitimate filesystem error from unlink on an otherwise-valid name (e.g. EACCES), which clearExtensionPin rethrows (extension-trust.ts:231-234).
For a scoped name like @fetchproxy/example-mcp, case 2 is silently masked: the fallback looks up pins.find(p => p.pinFile === nameOrStem), but the stored pinFile is the underscore form (@fetchproxy_example-mcp), so a slash-form nameOrStem never matches. match is undefined, and the real error (a permission problem, say) is discarded in favor of UsageError('no extension pin for @fetchproxy/example-mcp') — EXIT.USAGE with a misleading "nothing to clear" message, instead of the actual EXIT.BRIDGE "unexpected error" that would correctly point at the filesystem issue.
(For unscoped names this mostly self-corrects — the retried unlink(match.file) on the fallback path hits the same real error and it propagates uncaught — but that doesn't hold for the scoped case.)
Might be worth narrowing the catch to the "unsafe name" case specifically, e.g. by checking the error message/type from safeIdentityFileBase, rather than catching anything clearExtensionPin throws.
|
🟡 Auto-review verdict: warn — The three targeted fixes (claim-release race in host.ts, mcpId-keyed test waiters, non-guessing scoped-name recovery in trust.ts) are all correctly implemented and covered by tests, with one narrow edge-case nit in the new clearPin fallback. |
…n" (#221) The one nit from #220, on a fresh branch off main — #219 merged while I was writing the fix. `clearPin` reached its stem fallback by catching *any* throw from `clearExtensionPin`, on the assumption that a throw meant "that wasn't a legal server name". It also catches the failures that matter. An EACCES on the identity directory came back as: ``` no extension pin for @fetchproxy/example-mcp ``` — and because the stem lookup can never match a slash-form name, that is the message *every* scoped MCP gets for a permissions problem. It sends the reader looking for a file that is sitting right there, at the moment they are already locked out by a pin. It now asks first whether the argument is a legal server name (`safeIdentityFileBase`, newly exported from `@fetchproxy/server` for it) and only falls back to the stem when it is not. Everything else propagates as itself. ## Testing The new test chmods the identity directory to `0500` and asserts the EACCES surfaces. I checked it discriminates rather than assuming: against the previous implementation it fails with exactly the misleading message above — ``` AssertionError: expected [Function] to throw error matching /EACCES|EPERM/ but got 'no extension pin for @fetchproxy/example-mcp' ``` Skipped when running as root, where the directory mode is advisory. 1233 tests, three consecutive clean runs, `tsc -b` across all six workspaces. Closes #220 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
🤖 I have created a release *beep* *boop* --- ## [2.0.0](v1.11.0...v2.0.0) (2026-08-06) ### ⚠ BREAKING CHANGES * **protocol:** bind the ephemeral key into the ready signature ([#222](#222)) ### Features * **protocol:** add write_cookies, the one verb that can repair a rotated session ([#211](#211)) ([b2557c2](b2557c2)) * **protocol:** bind the ephemeral key into the ready signature ([#222](#222)) ([c13aeed](c13aeed)) * **server:** let a request name the tab that relays it ([#207](#207)) ([c5d3f4d](c5d3f4d)) * **server:** pin the extension's identity, and verify it on the peer path ([#213](#213)) ([0eeced7](0eeced7)) ### Bug Fixes * **cli:** let a real filesystem error be itself, not "no extension pin" ([#221](#221)) ([c87a864](c87a864)), closes [#220](#220) * **cli:** validate --via-tab before connecting, like the request URL ([#210](#210)) ([959fcc5](959fcc5)) * **extension:** reattach the write_cookies doc block, and name the writable cookies as writable ([#215](#215)) ([2730c4a](2730c4a)) * **extension:** use the guarded caps local for the cookie heading ([#217](#217)) ([f95c832](f95c832)) * **server:** release only our own extension claim, and stop guessing scoped names ([#219](#219)) ([3d90a64](3d90a64)), closes [#218](#218) * **server:** type no-tab rejections so they stop reading as version mismatches ([#205](#205)) ([dc30bd9](dc30bd9)) ### Refactor * **server:** drop the concatBytes imports the signature change orphaned ([#224](#224)) ([4985ba7](4985ba7)), closes [#223](#223) --- 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>
The three nits from #218 — the post-merge review of #213 — on a fresh branch off the squashed main.
The claim cleanup could drop someone else's claim
extensionClaimexists so a second extension hello cannot interleave with the awaited pin read. Its own cleanup could defeat it: the abandon and refusal paths cleared the claim unconditionally, but the close handler may have released this socket's claim already and a newer connection may hold it by now.Self-healing and improbable — it needs a close inside a file read plus two reconnects — but it is the exact interleaving the claim was added to prevent, so all four sites now check
=== wsbefore releasing.The test helper's waiters were keyed by nothing
waitForServerHellodrained every waiter on each hello and dropped the ones whosemcpIddidn't match, so a caller waiting on two MCPs could only settle by timing out. No current caller waits on two ids; the next person to write one would have paid for it with a 5-second mystery. Keyed bymcpIdnow.serverNameFromPinFileguessed where it cannot know_is legal on both sides of a scope's/(SAFE_SCOPED), so@my_org/tool-mcpand@my/org_tool-mcpshare the stem@my_org_tool-mcpand neither is recoverable from it. My docblock claimed the mapping was unambiguous; it isn't, and printing a guess names a package that may not exist.It now translates only the unambiguous case (leading
@, exactly one_) and shows the stem otherwise — and because a bare stem is the one stringclearused to reject,clearnow accepts a stem as well as a server name. Everythinglistprints stays usable, rather than being legible and rejected at the same time.Testing
1232 tests, three consecutive clean runs,
tsc -bacross all six workspaces. New test covers the ambiguous-stem path end to end: list shows the stem, does not show a fabricated name, andcleartakes the stem.Closes #218