Skip to content

Commit 83c3e16

Browse files
committed
fix(gateway): close PR 11 review gaps on pins, reconnect, and probe
Empty headers no longer keep a sibling window's pin, and window pins now require a one-folder set. Also lock reconnect_fresh, quote the hook path, and stop the env-probe from dumping secrets. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 03d98b2 commit 83c3e16

7 files changed

Lines changed: 300 additions & 53 deletions

File tree

crates/mcpmux-gateway/src/cursor_hook.rs

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,20 @@ fn script_path() -> Result<PathBuf, String> {
4747
Ok(cursor_dir()?.join("hooks").join(SCRIPT_NAME))
4848
}
4949

50+
fn shell_quote(path: &Path) -> String {
51+
let raw = path.to_string_lossy();
52+
#[cfg(windows)]
53+
{
54+
format!("\"{}\"", raw.replace('"', "\"\""))
55+
}
56+
#[cfg(not(windows))]
57+
{
58+
format!("'{}'", raw.replace('\'', "'\\''"))
59+
}
60+
}
61+
5062
fn hook_command(script: &Path) -> String {
51-
format!("node {}", script.display())
63+
format!("node {}", shell_quote(script))
5264
}
5365

5466
fn managed_entry(script: &Path) -> Value {
@@ -308,3 +320,92 @@ pub fn uninstall() -> CursorHookResult {
308320
manual_entry: manual_entry_text(&script),
309321
}
310322
}
323+
324+
#[cfg(test)]
325+
mod tests {
326+
use super::*;
327+
use serde_json::Value;
328+
use std::path::PathBuf;
329+
330+
fn script() -> PathBuf {
331+
PathBuf::from("/Users/Test User/.cursor/hooks/mcpmux-workspace-context.js")
332+
}
333+
334+
#[test]
335+
fn hook_command_quotes_spaces() {
336+
let cmd = hook_command(&script());
337+
assert!(
338+
cmd.contains("'") || cmd.contains('"'),
339+
"path with a space must be quoted: {cmd}"
340+
);
341+
assert!(cmd.contains(SCRIPT_NAME));
342+
}
343+
344+
#[test]
345+
fn merge_preserves_unrelated_hooks() {
346+
let existing = r#"{
347+
"version": 1,
348+
"hooks": {
349+
"preToolUse": [
350+
{ "command": "npx wakatime-hook", "matcher": ".*" }
351+
]
352+
}
353+
}"#;
354+
let out = merge_pre_tool_use(Some(existing), &script()).unwrap();
355+
let v: Value = serde_json::from_str(&out).unwrap();
356+
let list = v["hooks"]["preToolUse"].as_array().unwrap();
357+
assert_eq!(list.len(), 2);
358+
assert_eq!(list[0]["command"], "npx wakatime-hook");
359+
assert!(list[1]["command"].as_str().unwrap().contains(SCRIPT_NAME));
360+
}
361+
362+
#[test]
363+
fn merge_replaces_managed_entry_once() {
364+
let existing = merge_pre_tool_use(None, &script()).unwrap();
365+
let again = merge_pre_tool_use(Some(&existing), &script()).unwrap();
366+
let v: Value = serde_json::from_str(&again).unwrap();
367+
assert_eq!(v["hooks"]["preToolUse"].as_array().unwrap().len(), 1);
368+
}
369+
370+
#[test]
371+
fn merge_rejects_jsonc_and_non_object_shapes() {
372+
let script = script();
373+
assert!(merge_pre_tool_use(Some("{ // comment\n}"), &script).is_err());
374+
assert!(merge_pre_tool_use(Some("[1]"), &script).is_err());
375+
assert!(merge_pre_tool_use(Some(r#"{ "hooks": [] }"#), &script).is_err());
376+
assert!(merge_pre_tool_use(Some(r#"{ "hooks": { "preToolUse": {} } }"#), &script).is_err());
377+
}
378+
379+
#[test]
380+
fn uninstall_keeps_other_entries() {
381+
let existing = merge_pre_tool_use(
382+
Some(r#"{ "hooks": { "preToolUse": [{ "command": "other" }] } }"#),
383+
&script(),
384+
)
385+
.unwrap();
386+
let removed = remove_managed_entry(&existing, &script()).unwrap();
387+
let v: Value = serde_json::from_str(&removed).unwrap();
388+
let list = v["hooks"]["preToolUse"].as_array().unwrap();
389+
assert_eq!(list.len(), 1);
390+
assert_eq!(list[0]["command"], "other");
391+
}
392+
393+
#[test]
394+
fn write_with_backup_copies_existing() {
395+
let tmp = std::env::temp_dir().join(format!(
396+
"mcpmux-hook-{}-{}",
397+
std::process::id(),
398+
std::time::SystemTime::now()
399+
.duration_since(std::time::UNIX_EPOCH)
400+
.unwrap()
401+
.as_nanos()
402+
));
403+
std::fs::create_dir_all(&tmp).unwrap();
404+
let path = tmp.join("hooks.json");
405+
std::fs::write(&path, "{}").unwrap();
406+
let bak = write_with_backup(&path, "{\"ok\":true}").unwrap();
407+
assert!(bak.is_some());
408+
assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"ok\":true}");
409+
let _ = std::fs::remove_dir_all(&tmp);
410+
}
411+
}

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

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -481,37 +481,40 @@ fn attach_window_identity(services: &ServiceContainer, session_id: &str, peer: O
481481

482482
/// Promote an explicit session pin to the window, or inherit a remembered one.
483483
///
484-
/// The empty-header warn only fires when inheritance also failed — a reused
485-
/// window pin is the recovery, not another prompt.
484+
/// An empty header is a failed substitution, not "reuse the last pin." Drop
485+
/// the session claim and skip window inherit so a sibling window on the
486+
/// shared `mcp-session-id` cannot keep resolving through the previous root.
486487
fn resolve_window_pin(
487488
services: &ServiceContainer,
488489
session_id: &str,
489490
empty_workspace_header: bool,
490491
trace_id: &str,
491492
) {
492-
if services.session_roots.session_pin(session_id).is_some() {
493-
services.session_roots.promote_pin_to_window(session_id);
494-
return;
495-
}
496-
if services
497-
.session_roots
498-
.inherit_window_pin(session_id)
499-
.is_some()
500-
{
501-
return;
502-
}
503493
if empty_workspace_header {
494+
services.session_roots.forget_empty_header_claim(session_id);
504495
warn!(
505496
trace_id = %trace_id,
506497
session_id,
507-
"[SessionRoots] X-Mcpmux-Workspace present but empty — pin skipped \
498+
"[SessionRoots] X-Mcpmux-Workspace present but empty — pin cleared \
508499
(Cursor did not substitute ${{workspaceFolder}} before spawning \
509500
mcp-remote, which then expanded the unresolved literal to empty). \
510501
Affects editor and Agents windows alike; recover with \
511502
mcpmux_set_workspace_root, or install a per-repo static header to \
512503
avoid substitution entirely — \
513504
see docs/manual/cursor-workspace-bridge.md Fallback",
514505
);
506+
return;
507+
}
508+
if services.session_roots.session_pin(session_id).is_some() {
509+
services.session_roots.promote_pin_to_window(session_id);
510+
return;
511+
}
512+
if services
513+
.session_roots
514+
.inherit_window_pin(session_id)
515+
.is_some()
516+
{
517+
return;
515518
}
516519
}
517520

crates/mcpmux-gateway/src/pool/service.rs

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -277,19 +277,24 @@ impl PoolService {
277277
}
278278
}
279279

280+
/// Per-(space, server) lock shared by connect and reconnect_fresh.
281+
fn connect_lock(&self, space_id: Uuid, server_id: &str) -> Arc<tokio::sync::Mutex<()>> {
282+
self.connect_locks
283+
.entry((space_id, server_id.to_string()))
284+
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
285+
.clone()
286+
}
287+
280288
/// Connect a server for a space
281289
pub async fn connect_server(&self, ctx: &ConnectionContext) -> ConnectionResult {
282-
let key = (ctx.space_id, ctx.server_id.to_string());
283-
284-
// Single-flight per key: the late caller waits, then reuses the
285-
// winner's instance via the health check below instead of racing it.
286-
// (Clone the Arc out so the DashMap entry guard drops before .await.)
287-
let connect_lock = self
288-
.connect_locks
289-
.entry(key.clone())
290-
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
291-
.clone();
290+
let connect_lock = self.connect_lock(ctx.space_id, &ctx.server_id);
292291
let _connect_guard = connect_lock.lock().await;
292+
self.connect_server_locked(ctx).await
293+
}
294+
295+
/// Body of [`Self::connect_server`] after the per-key lock is held.
296+
async fn connect_server_locked(&self, ctx: &ConnectionContext) -> ConnectionResult {
297+
let key = (ctx.space_id, ctx.server_id.to_string());
293298

294299
// Check for existing instance. Clone the Arc out of the DashMap so
295300
// no shard guard is held across the reconnect `.await` below.
@@ -482,8 +487,10 @@ impl PoolService {
482487
/// `reconnect_after_oauth` and will not mis-route stdio backends to HTTP.
483488
pub async fn reconnect_fresh(&self, ctx: &ConnectionContext) -> ConnectionResult {
484489
let started = Instant::now();
490+
let connect_lock = self.connect_lock(ctx.space_id, &ctx.server_id);
491+
let _connect_guard = connect_lock.lock().await;
485492
self.remove_instance(ctx.space_id, &ctx.server_id);
486-
let result = self.connect_server(ctx).await;
493+
let result = self.connect_server_locked(ctx).await;
487494
info!(
488495
server_id = %ctx.server_id,
489496
space_id = %ctx.space_id,

0 commit comments

Comments
 (0)