Skip to content

Commit 13bede1

Browse files
committed
fix(gateway): skip pending workspace pins from another window
Every Cursor window shares one access key, so a parked X-Mcpmux-Workspace could pin a brand-new session onto a folder that window never had open. Refuse the pending header unless it is in the claiming request's candidate set. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent f03e7c7 commit 13bede1

3 files changed

Lines changed: 67 additions & 8 deletions

File tree

crates/mcpmux-gateway/src/mcp/oauth_middleware.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -313,9 +313,11 @@ pub async fn mcp_oauth_middleware(
313313
.remember_pending_workspace(&client_id, ws);
314314
}
315315
(Some(sid), None) => {
316-
services
317-
.session_roots
318-
.apply_pending_workspace(&client_id, sid);
316+
services.session_roots.apply_pending_workspace(
317+
&client_id,
318+
sid,
319+
workspace_set_header.as_deref(),
320+
);
319321
}
320322
_ => {}
321323
}

crates/mcpmux-gateway/src/services/session_roots.rs

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -380,14 +380,40 @@ impl SessionRootsRegistry {
380380
///
381381
/// Returns `true` when a pending path was applied. The pending entry is
382382
/// kept so a later session from the same client can reuse it.
383-
pub fn apply_pending_workspace(&self, client_id: &str, session_id: &str) -> bool {
383+
///
384+
/// The pending slot is keyed by `client_id`, and every Cursor window shares
385+
/// one access key — so the parked path may belong to a *different* window
386+
/// than the session now claiming it. `reported_set` is the claiming
387+
/// request's own `X-Mcpmux-Workspace-Set`; together with any set already
388+
/// stored for the session it decides membership, and a non-member is
389+
/// refused rather than pinned onto the wrong workspace.
390+
pub fn apply_pending_workspace(
391+
&self,
392+
client_id: &str,
393+
session_id: &str,
394+
reported_set: Option<&str>,
395+
) -> bool {
384396
let Some(path) = self
385397
.pending_by_client
386398
.get(client_id)
387399
.map(|value| value.clone())
388400
else {
389401
return false;
390402
};
403+
let reported = reported_set.map(parse_candidate_set).unwrap_or_default();
404+
let outside_reported = !reported.is_empty() && !reported.contains(&path);
405+
if outside_reported || !self.is_candidate(session_id, &path) {
406+
warn!(
407+
%session_id,
408+
pending_root = %path,
409+
reported_candidates = ?reported,
410+
session_candidates = ?self.get_candidates(session_id),
411+
"[SessionRoots] held X-Mcpmux-Workspace names a folder this window does \
412+
not have open — pin skipped (the value was parked by another window \
413+
sharing this access key)",
414+
);
415+
return false;
416+
}
391417
self.set_pinned(session_id, &path, PinSource::WorkspaceHeader);
392418
true
393419
}
@@ -862,7 +888,7 @@ mod tests {
862888

863889
reg.remember_pending_workspace("client-1", pin_in);
864890
assert!(reg.get_pinned("sess-new").is_none());
865-
assert!(reg.apply_pending_workspace("client-1", "sess-new"));
891+
assert!(reg.apply_pending_workspace("client-1", "sess-new", None));
866892
assert_eq!(reg.get("sess-new"), Some(vec![pin_norm.to_string()]));
867893

868894
reg.set(
@@ -882,10 +908,39 @@ mod tests {
882908
fn empty_pending_header_does_not_pin() {
883909
let reg = SessionRootsRegistry::default();
884910
reg.remember_pending_workspace("client-1", " ");
885-
assert!(!reg.apply_pending_workspace("client-1", "sess-1"));
911+
assert!(!reg.apply_pending_workspace("client-1", "sess-1", None));
886912
assert!(reg.get_pinned("sess-1").is_none());
887913
}
888914

915+
#[test]
916+
fn pending_header_from_another_window_does_not_pin() {
917+
// Observed live: every Cursor window shares one access key, so window A
918+
// parked its own folder in the client-keyed pending slot and window B's
919+
// brand-new session claimed it — pinning B onto A's workspace even
920+
// though B never had that folder open.
921+
let reg = SessionRootsRegistry::default();
922+
#[cfg(windows)]
923+
let other_window = "d:\\delta";
924+
#[cfg(not(windows))]
925+
let other_window = "/repos/delta";
926+
reg.remember_pending_workspace("client-1", other_window);
927+
928+
// Refused against the claiming request's own reported set.
929+
let reported = CANDIDATES.join(",");
930+
assert!(!reg.apply_pending_workspace("client-1", "sess-b", Some(&reported)));
931+
assert!(reg.get_pinned("sess-b").is_none());
932+
933+
// Refused against a set already stored for the session.
934+
reg.set_candidates("sess-c", &reported);
935+
assert!(!reg.apply_pending_workspace("client-1", "sess-c", None));
936+
assert!(reg.get_pinned("sess-c").is_none());
937+
938+
// A member of the set still pins — the gate is membership, not a block.
939+
reg.remember_pending_workspace("client-1", CANDIDATES[0]);
940+
assert!(reg.apply_pending_workspace("client-1", "sess-c", Some(&reported)));
941+
assert_eq!(reg.get_pinned("sess-c").as_deref(), Some(CANDIDATES[0]));
942+
}
943+
889944
#[test]
890945
fn set_pinned_clears_last_resolution() {
891946
let reg = SessionRootsRegistry::default();

docs/planning/window-scoped-workspace-pin.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Window-Scoped Workspace Pin
22

3-
**Last Updated:** Aug 20, 2026
3+
**Last Updated:** Aug 21, 2026
44
**Status:** Phases 1–4 shipped, then patched same night after a field-confirmed cross-window leak on the shared global bridge process (decision 4b). Chosen from a six-option brainstorm as the highest value-per-line fix for the empty-`${workspaceFolder}` residual. Phase 1 load-bearing check passed in-process: unprivileged `netstat2` socket→PID lookup resolves a loopback peer to this process — see "second incident" below for why that check didn't catch the leak.
55
**Branch:** `root-resolution`
66
**Depends on:** [`cursor-workspace-routing-bridge.md`](./cursor-workspace-routing-bridge.md) Phases 1–3 (shipped) — this reuses the bridge's `X-Mcpmux-Workspace` / `X-Mcpmux-Workspace-Set` headers and the `set_workspace_root` escape hatch rather than replacing any of them
@@ -23,6 +23,8 @@ Tonight's gateway log, one machine, roughly six Cursor windows:
2323

2424
759 manual pins for six windows is not 759 ambiguities. It is the same handful of answers re-supplied every time a session churns, because [`SessionRootsRegistry`](../../crates/mcpmux-gateway/src/services/session_roots.rs) keys `pinned` by `session_id` and nothing else. The only coarser key available today is `pending_by_client`, keyed by `client_id` — and `client_id` is per-API-key, not per-window, which is why it collapses six windows into two values. So a pin has exactly one lifetime available to it: the session. Every reconnect, every auth bounce, every `mcp-remote` respawn throws the answer away and asks again.
2525

26+
`apply_pending_workspace` now refuses to apply a parked header that is not a member of the claiming request's `X-Mcpmux-Workspace-Set` (or the session's already-stored candidate set). That stops window A's initialize from pinning window B onto A's folder when they share one access key. An absent set still does not block (same doctrine as `is_candidate`). Re-keying the pending slot by `WindowKey` would close the remaining no-set case; not done here.
27+
2628
### The dead end that isn't
2729

2830
[`cursor-workspace-routing-bridge.md`](./cursor-workspace-routing-bridge.md) Scope/Out records gateway-side process introspection as: *"Dead end — the gateway sees a TCP connection over streamable HTTP, not a spawned child process; there's no PID to walk."*
@@ -233,7 +235,7 @@ Closes the ways a remembered pin could outlive its truth.
233235

234236
| File | Note |
235237
| ---- | ---- |
236-
| [`crates/mcpmux-gateway/src/services/session_roots.rs`](../../crates/mcpmux-gateway/src/services/session_roots.rs) | `pinned` is session-keyed; `pending_by_client` is client-keyed (2 values for 6 windows). The gap this doc fills sits between them |
238+
| [`crates/mcpmux-gateway/src/services/session_roots.rs`](../../crates/mcpmux-gateway/src/services/session_roots.rs) | `pinned` is session-keyed; `pending_by_client` is client-keyed (2 values for 6 windows). `apply_pending_workspace` now refuses a parked path that is not in the claiming window's candidate set. |
237239
| [`crates/mcpmux-gateway/src/mcp/oauth_middleware.rs`](../../crates/mcpmux-gateway/src/mcp/oauth_middleware.rs) | Header hold-then-pin, the empty-header warn, and the set-header constraint all live here — as will the window-key memoization |
238240
| [`crates/mcpmux-gateway/src/server/mod.rs`](../../crates/mcpmux-gateway/src/server/mod.rs) | `into_make_service_with_connect_info::<SocketAddr>()` is already wired; `restrict_management_to_loopback` is prior art for trusting the peer socket over a spoofable header |
239241
| [`crates/mcpmux-core/src/domain/workspace_binding.rs`](../../crates/mcpmux-core/src/domain/workspace_binding.rs) | `normalize_workspace_root()` / `expand_home_tilde()` — the shared-filesystem assumption that the HA OS `~/helm` case violates |

0 commit comments

Comments
 (0)