From ea2e713e46c952d81f0dc1d49508057ac48267ac Mon Sep 17 00:00:00 2001 From: Cedric Stephan Date: Mon, 27 Jul 2026 14:37:38 +0200 Subject: [PATCH] fix(vscode): scope episode dashboard to workspace root Fixes #1270 --- .../editor_registry/writers/install/vscode.rs | 25 ++++++++++- rust/src/core/pathutil.rs | 45 +++++++++++++++++-- rust/src/server/mod.rs | 30 +++++-------- vscode-extension/src/cli-commands.ts | 6 ++- vscode-extension/src/dashboard-panel.ts | 2 + 5 files changed, 82 insertions(+), 26 deletions(-) diff --git a/rust/src/core/editor_registry/writers/install/vscode.rs b/rust/src/core/editor_registry/writers/install/vscode.rs index 1273f00e57..c4e9be8ef9 100644 --- a/rust/src/core/editor_registry/writers/install/vscode.rs +++ b/rust/src/core/editor_registry/writers/install/vscode.rs @@ -5,12 +5,21 @@ use super::super::shared::*; use super::super::{WriteAction, WriteOptions, WriteResult}; use crate::core::editor_registry::types::EditorTarget; +fn vscode_mcp_entry(binary: &str) -> Value { + serde_json::json!({ + "type": "stdio", + "command": binary, + "args": [], + "env": { "LEAN_CTX_PROJECT_ROOT": "${workspaceFolder}" } + }) +} + pub(crate) fn write_vscode_mcp( target: &EditorTarget, binary: &str, opts: WriteOptions, ) -> Result { - let desired = serde_json::json!({ "type": "stdio", "command": binary, "args": [] }); + let desired = vscode_mcp_entry(binary); if target.config_path.exists() { let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?; @@ -64,7 +73,7 @@ pub(crate) fn write_vscode_mcp_fresh( note: Option, ) -> Result { let content = serde_json::to_string_pretty(&serde_json::json!({ - "servers": { "lean-ctx": { "type": "stdio", "command": binary, "args": [] } } + "servers": { "lean-ctx": vscode_mcp_entry(binary) } })) .map_err(|e| e.to_string())?; crate::config_io::write_atomic_with_backup(path, &content)?; @@ -78,6 +87,18 @@ pub(crate) fn write_vscode_mcp_fresh( }) } +#[cfg(test)] +mod tests { + use super::vscode_mcp_entry; + + #[test] + fn vscode_mcp_entry_scopes_server_to_workspace_folder() { + let entry = vscode_mcp_entry("/bin/lean-ctx"); + + assert_eq!(entry["env"]["LEAN_CTX_PROJECT_ROOT"], "${workspaceFolder}"); + } +} + // --------------------------------------------------------------------------- // Augment VS Code extension writer // diff --git a/rust/src/core/pathutil.rs b/rust/src/core/pathutil.rs index 773f96679d..b841ffc054 100644 --- a/rust/src/core/pathutil.rs +++ b/rust/src/core/pathutil.rs @@ -243,17 +243,46 @@ pub fn is_agent_config_dir(dir: &Path) -> bool { .any(|name| s.ends_with(&format!("/{name}")) || s.contains(&format!("/{name}/"))) } +fn is_wsl_windows_user_profile(dir: &Path) -> bool { + let normalized = dir.to_string_lossy(); + if !normalized.starts_with("/mnt/") { + return false; + } + + let mut components = normalized + .split('/') + .filter(|component| !component.is_empty()); + let (Some(mount), Some(drive), Some(users), Some(username)) = ( + components.next(), + components.next(), + components.next(), + components.next(), + ) else { + return false; + }; + + mount.eq_ignore_ascii_case("mnt") + && drive.len() == 1 + && drive.as_bytes()[0].is_ascii_alphabetic() + && users.eq_ignore_ascii_case("users") + && !username.is_empty() + && components.next().is_none() +} + /// Returns `true` if the directory is too broad to be a valid project root. -/// Rejects the home directory, filesystem root, `.` (bare CWD), and agent/IDE -/// config directories ([`AGENT_CONFIG_DIRS`]). Used to prevent adopting a bogus -/// project root and writing project-scoped data into the global `~/.lean-ctx/` -/// data directory. +/// Rejects home directories (including WSL-mounted Windows profiles), filesystem +/// root, `.` (bare CWD), and agent/IDE config directories +/// ([`AGENT_CONFIG_DIRS`]). Used to prevent adopting a bogus project root and +/// writing project-scoped data into the global `~/.lean-ctx/` data directory. pub fn is_broad_or_unsafe_root(dir: &Path) -> bool { if let Some(home) = dirs::home_dir() && dir == home { return true; } + if is_wsl_windows_user_profile(dir) { + return true; + } let s = dir.to_string_lossy(); if s == "/" || s == "\\" || s == "." { return true; @@ -818,6 +847,14 @@ mod tests { } } + #[test] + fn broad_root_rejects_wsl_windows_user_profile() { + assert!(is_broad_or_unsafe_root(Path::new("/mnt/c/Users/dev"))); + assert!(!is_broad_or_unsafe_root(Path::new( + "/mnt/c/Users/dev/projects/my-app" + ))); + } + #[test] fn broad_root_rejects_filesystem_root() { assert!(is_broad_or_unsafe_root(Path::new("/"))); diff --git a/rust/src/server/mod.rs b/rust/src/server/mod.rs index 1a32f2fe28..09aa0a9e23 100644 --- a/rust/src/server/mod.rs +++ b/rust/src/server/mod.rs @@ -55,15 +55,6 @@ pub fn build_claude_code_static_instructions_for_test() -> String { crate::instructions::claude_code_static_instructions_for_test() } -fn is_home_or_agent_dir(dir: &std::path::Path) -> bool { - if let Some(home) = dirs::home_dir() - && dir == home - { - return true; - } - crate::core::pathutil::is_agent_config_dir(dir) -} - fn git_toplevel_from(dir: &std::path::Path) -> Option { std::process::Command::new("git") .args(["rev-parse", "--show-toplevel"]) @@ -87,7 +78,7 @@ pub fn derive_project_root_from_cwd() -> Option { let cwd = std::env::current_dir().ok()?; let canonical = crate::core::pathutil::safe_canonicalize_or_self(&cwd); - if is_home_or_agent_dir(&canonical) { + if crate::core::pathutil::is_broad_or_unsafe_root(&canonical) { return git_toplevel_from(&canonical); } @@ -125,10 +116,11 @@ use crate::core::pathutil::is_broad_or_unsafe_root; /// itself, but contains child directories that do. In this case, use the /// parent as jail root and auto-allow all child projects via LEAN_CTX_ALLOW_PATH. fn detect_multi_root_workspace(dir: &std::path::Path) -> Option { - // Never enumerate the home dir or macOS TCC-protected dirs (Documents/Desktop/ - // Downloads): read_dir there triggers a macOS privacy prompt (#356), and a real - // project under them is already handled upstream via has_project_marker. - if crate::core::pathutil::is_tcc_sensitive_home_dir(dir) { + // Never enumerate broad roots or macOS TCC-protected dirs: read_dir there + // can scan an entire user profile or trigger a macOS privacy prompt. + if crate::core::pathutil::is_broad_or_unsafe_root(dir) + || crate::core::pathutil::is_tcc_sensitive_home_dir(dir) + { return None; } let entries = std::fs::read_dir(dir).ok()?; @@ -245,20 +237,20 @@ mod tests { } #[test] - fn home_dir_detected_as_agent_dir() { + fn home_dir_detected_as_broad_root() { if let Some(home) = dirs::home_dir() { - assert!(is_home_or_agent_dir(&home)); + assert!(is_broad_or_unsafe_root(&home)); } } #[test] fn agent_dirs_detected() { let claude = std::path::PathBuf::from("/home/user/.claude"); - assert!(is_home_or_agent_dir(&claude)); + assert!(is_broad_or_unsafe_root(&claude)); let codex = std::path::PathBuf::from("/home/user/.codex"); - assert!(is_home_or_agent_dir(&codex)); + assert!(is_broad_or_unsafe_root(&codex)); let project = std::path::PathBuf::from("/home/user/projects/myapp"); - assert!(!is_home_or_agent_dir(&project)); + assert!(!is_broad_or_unsafe_root(&project)); } #[test] diff --git a/vscode-extension/src/cli-commands.ts b/vscode-extension/src/cli-commands.ts index c9be987e68..13ed406e14 100644 --- a/vscode-extension/src/cli-commands.ts +++ b/vscode-extension/src/cli-commands.ts @@ -138,7 +138,11 @@ export async function cmdConfigureMcp(): Promise { if (!config.servers) { config.servers = {}; } - config.servers["lean-ctx"] = { type: "stdio", command: resolveBinaryPath() }; + config.servers["lean-ctx"] = { + type: "stdio", + command: resolveBinaryPath(), + env: { LEAN_CTX_PROJECT_ROOT: "${workspaceFolder}" }, + }; fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`); vscode.window.showInformationMessage( diff --git a/vscode-extension/src/dashboard-panel.ts b/vscode-extension/src/dashboard-panel.ts index 6656a15f60..3de6e0a5d3 100644 --- a/vscode-extension/src/dashboard-panel.ts +++ b/vscode-extension/src/dashboard-panel.ts @@ -111,6 +111,7 @@ export async function cmdDashboard( const token = randomToken(); const bin = resolveBinaryPath(); const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const projectArg = cwd ? [`--project=${cwd}`] : []; let spawnError: string | undefined; try { @@ -122,6 +123,7 @@ export async function cmdDashboard( "--host=127.0.0.1", `--port=${port}`, `--auth-token=${token}`, + ...projectArg, ], { cwd, env: { ...process.env, NO_COLOR: "1" } } );