Skip to content

fix(server): release only our own extension claim, and stop guessing scoped names - #219

Merged
chrischall merged 1 commit into
mainfrom
claude/pin-followups
Aug 5, 2026
Merged

fix(server): release only our own extension claim, and stop guessing scoped names#219
chrischall merged 1 commit into
mainfrom
claude/pin-followups

Conversation

@chrischall

Copy link
Copy Markdown
Owner

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

extensionClaim exists 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.

A claims → A closes (handler clears A's claim) → B claims
        → A's handler resumes, clears B's claim → C passes the guard
        → B and C both reach `extensionWs = ws`

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 === ws before releasing.

The test helper's waiters were keyed by nothing

waitForServerHello drained every waiter on each hello and dropped the ones whose mcpId didn'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 by mcpId now.

serverNameFromPinFile guessed where it cannot know

_ is legal on both sides of a scope's / (SAFE_SCOPED), so @my_org/tool-mcp and @my/org_tool-mcp share the stem @my_org_tool-mcp and 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 string clear used to reject, clear now accepts a stem as well as a server name. Everything list prints stays usable, rather than being legible and rejected at the same time.

Testing

1232 tests, three consecutive clean runs, tsc -b across all six workspaces. New test covers the ambiguous-stem path end to end: list shows the stem, does not show a fabricated name, and clear takes the stem.

Closes #218

…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>
@chrischall chrischall added the bug Something isn't working label Aug 5, 2026
@github-actions github-actions Bot added the auto-review Trigger Claude + Copilot review on this PR label Aug 5, 2026
@claude

claude Bot commented Aug 5, 2026

Copy link
Copy Markdown

Claude finished @chrischall's task in 3m 57s —— View job


Review of PR #219

All three follow-ups from #218 are addressed correctly:

  • host.ts — all four extensionClaim = null sites (unreadable-pin, refused, dead-on-arrival, and the close handler at host.ts:475) now guard with if (extensionClaim === ws) before clearing, exactly matching the interleaving described in the PR body. Verified by reading each site: packages/server/src/host.ts:226,239,256,475.
  • mock-extension.tshelloWaiters is now a Map<mcpId, wake[]> instead of a flat array, so a hello for one mcpId no longer drains/drops waiters for a different one. Drained and deleted correctly on match.
  • trust.tsserverNameFromPinFile no longer guesses on >1 underscore (ambiguous scoped stems are shown as-is), and clear now accepts that stem via the new clearPin fallback so nothing list prints becomes unusable. New test (trust.test.ts:139-161) exercises the ambiguous-stem path end to end and matches the described behavior.

One 🟡 nit left inline: clearPin's fallback catch in trust.ts:109-120 treats any error from clearExtensionPin as "not a legal name" and retries via stem lookup. For a scoped name, a genuine filesystem error (e.g. a permission-denied unlink) gets masked into a misleading "no extension pin for X" (EXIT.USAGE) instead of surfacing the real error (EXIT.BRIDGE), because the stem lookup for a slash-form name never matches the underscore-form pinFile. Narrow, edge-case, but a real behavior change worth a second look.

Note: I could not execute npm test / tsc -b in this sandboxed review environment (commands required interactive approval unavailable here), so the PR's own testing claims (1232 tests, tsc -b across six workspaces) are unverified by me — flagging as an open question, not a finding.

Verdict: warn

Comment on lines +109 to +120
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;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

🟡 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.
📋 Tracking follow-ups: #220

@chrischall chrischall added the ready-to-merge Owner has reviewed; arm auto-merge to land when CI is green label Aug 5, 2026
@chrischall
chrischall enabled auto-merge (squash) August 5, 2026 23:53
@chrischall
chrischall merged commit 3d90a64 into main Aug 5, 2026
18 checks passed
@chrischall
chrischall deleted the claude/pin-followups branch August 5, 2026 23:54
chrischall added a commit that referenced this pull request Aug 5, 2026
…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>
chrischall added a commit that referenced this pull request Aug 6, 2026
🤖 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto-review Trigger Claude + Copilot review on this PR bug Something isn't working ready-to-merge Owner has reviewed; arm auto-merge to land when CI is green

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Auto-review follow-ups for PR #213: feat(server): pin the extension's identity, and verify it on the peer path

1 participant