Skip to main content

lean_ctx/core/
mcp_roots.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::path::Path;
3
4mod derive;
5pub use derive::{derive_project_root_from, derive_project_root_from_cwd};
6
7/// Parse a `file://` URI to a validated local path string.
8/// Rejects non-file URIs, null bytes, `..` traversal, and non-directory paths.
9/// Returns a canonicalized absolute path.
10pub fn uri_to_path(uri: &str) -> Option<String> {
11    let raw = uri.strip_prefix("file://")?;
12    if raw.contains("%00") {
13        return None;
14    }
15    let decoded = percent_decode(raw);
16    if decoded.is_empty() || decoded.contains('\0') {
17        return None;
18    }
19    // Windows `file:///C:/path` URIs strip to `/C:/path`, which is NOT an
20    // absolute Windows path (no drive prefix at the start) and would be rejected
21    // below — so on Windows+Cursor every workspace root failed to parse and the
22    // session fell back to the home dir as project root (GL discussion #273:
23    // "MCP root misconfigured (resolves to C:/Users/<user>)"). Drop the single
24    // leading slash before the drive letter so it parses as `C:/path`. POSIX
25    // paths keep their leading slash (on Unix `/C:/x` is a legitimate path).
26    #[cfg(windows)]
27    let decoded = if has_leading_slash_drive(&decoded) {
28        decoded[1..].to_string()
29    } else {
30        decoded
31    };
32    let path = Path::new(&decoded);
33    if !path.is_absolute() {
34        return None;
35    }
36    let canonical = crate::core::pathutil::safe_canonicalize_or_self(path);
37    let s = canonical.to_string_lossy().to_string();
38    if s.is_empty() {
39        return None;
40    }
41    Some(s)
42}
43
44fn percent_decode(s: &str) -> String {
45    let mut out = String::with_capacity(s.len());
46    let mut chars = s.bytes();
47    while let Some(b) = chars.next() {
48        if b == b'%' {
49            let hi = chars.next().and_then(hex_val);
50            let lo = chars.next().and_then(hex_val);
51            if let (Some(h), Some(l)) = (hi, lo) {
52                let byte = h << 4 | l;
53                if byte == 0 {
54                    continue;
55                }
56                out.push(byte as char);
57            } else {
58                out.push('%');
59            }
60        } else {
61            out.push(b as char);
62        }
63    }
64    out
65}
66
67fn hex_val(b: u8) -> Option<u8> {
68    match b {
69        b'0'..=b'9' => Some(b - b'0'),
70        b'a'..=b'f' => Some(b - b'a' + 10),
71        b'A'..=b'F' => Some(b - b'A' + 10),
72        _ => None,
73    }
74}
75
76/// True for a `file://` URI path of the form `/C:/…` — a leading slash, an ASCII
77/// drive letter, then a colon. This shape comes from a Windows
78/// `file:///C:/path` URI and is not an absolute Windows path until the leading
79/// slash is removed. Pure predicate so the logic is unit-tested on every
80/// platform, even though it is only wired into [`uri_to_path`] on Windows
81/// (`allow(dead_code)` elsewhere keeps `-D warnings` clean).
82#[cfg_attr(not(windows), allow(dead_code))]
83fn has_leading_slash_drive(p: &str) -> bool {
84    let b = p.as_bytes();
85    b.len() >= 3 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':'
86}
87
88pub(super) fn has_project_marker(dir: &Path) -> bool {
89    crate::core::pathutil::has_project_marker(dir)
90}
91
92/// Select the best project root from MCP client roots.
93/// Only considers paths that are existing directories.
94/// Prefers roots with project markers (.git, Cargo.toml, etc.).
95/// Falls back to the first valid directory if none have markers — but never
96/// accepts a broad/unsafe root (HOME, filesystem root, agent sandbox dirs),
97/// which would otherwise contaminate sessions across projects.
98pub fn best_root_from_uris(uris: &[String]) -> Option<String> {
99    root_selection_from_uris(uris).map(|selection| selection.chosen_root)
100}
101
102/// [`best_root_from_uris`] with the other candidates, so a choice among
103/// several roots can be named (AP04-G7).
104pub fn root_selection_from_uris(
105    uris: &[String],
106) -> Option<crate::core::root_selection::RootSelection> {
107    crate::core::root_selection::select_with_preferences(
108        uris.iter().filter_map(|u| uri_to_path(u)).collect(),
109    )
110}
111
112/// Pick the best project root from a list of candidate directory paths.
113///
114/// Prefers a path with a project marker (`.git`, `Cargo.toml`, …); otherwise
115/// falls back to the first *safe* directory. A caller that reports its workspace
116/// root as HOME (some do) must not turn HOME into the project root — that is the
117/// root cause of cross-project session contamination — so a broad/unsafe root is
118/// never accepted as a marker-less fallback. A root the user chose with
119/// `lean-ctx project use` wins when it is among the candidates
120/// (`core::root_selection`).
121#[cfg(test)]
122fn best_root_from_paths(paths: Vec<String>) -> Option<String> {
123    crate::core::root_selection::select_with_preferences(paths).map(|s| s.chosen_root)
124}
125
126/// Filter and validate URIs to existing directories only.
127pub fn valid_dir_paths_from_uris(uris: &[String]) -> Vec<String> {
128    uris.iter()
129        .filter_map(|u| uri_to_path(u))
130        .filter(|p| Path::new(p).is_dir())
131        .collect()
132}
133
134/// Detect project root from IDE-specific environment variables.
135///
136/// Priority (first match wins):
137///   LEAN_CTX_PROJECT_ROOT — explicit override (user/CI)
138///   CURSOR_PROJECT_DIR    — Cursor ≥ 3.7
139///   CLAUDE_PROJECT_DIR    — Claude Code
140///   WINDSURF_PROJECT_DIR  — Windsurf (Codeium)
141///   TRAE_PROJECT_DIR      — Trae (ByteDance)
142///   KIRO_PROJECT_DIR      — AWS Kiro
143///   CODEX_SANDBOX_DIR     — Codex desktop sandbox
144///   GIT_WORK_TREE         — git worktree env
145pub fn root_from_env() -> Option<String> {
146    const VARS: &[&str] = &[
147        "LEAN_CTX_PROJECT_ROOT",
148        "CURSOR_PROJECT_DIR",
149        "CLAUDE_PROJECT_DIR",
150        "WINDSURF_PROJECT_DIR",
151        "TRAE_PROJECT_DIR",
152        "KIRO_PROJECT_DIR",
153        "CODEX_SANDBOX_DIR",
154        "GIT_WORK_TREE",
155    ];
156    for var in VARS {
157        if let Ok(val) = std::env::var(var) {
158            let trimmed = val.trim().to_string();
159            if !trimmed.is_empty()
160                && Path::new(&trimmed).is_dir()
161                && !crate::core::pathutil::is_broad_or_unsafe_root(Path::new(&trimmed))
162            {
163                return Some(trimmed);
164            }
165        }
166    }
167    None
168}
169
170/// The session's project root from the host env and the server's working dir.
171///
172/// `LEAN_CTX_PROJECT_ROOT` is a pin. Placed in a user-global agent config (as
173/// builds before the pin heal did) it names one project for *every* session,
174/// so a Codex session started in repo B silently ran against repo A. When the
175/// server was started inside a different real project, that working directory
176/// is this session's project and outranks the pin. Host-specific signals
177/// (`CLAUDE_PROJECT_DIR`, workspace folders, …) are session-scoped already and
178/// keep precedence; a cwd inside the pinned tree keeps the pin.
179pub fn session_root(env_root: Option<String>, cwd_root: Option<String>) -> Option<String> {
180    let pin = std::env::var("LEAN_CTX_PROJECT_ROOT")
181        .ok()
182        .map(|v| v.trim().to_string());
183    let env_is_pin = env_root.is_some() && env_root == pin;
184    reconcile_session_root(env_root, env_is_pin, cwd_root)
185}
186
187/// The CLI's project under a `LEAN_CTX_PROJECT_ROOT` pin: the same rule as a
188/// session's — a command run inside another real project acts for that
189/// project, not for the pinned one (AP04-G10).
190pub(crate) fn reconcile_pinned_root(pin: String, cwd_root: Option<String>) -> String {
191    reconcile_session_root(Some(pin.clone()), true, cwd_root).unwrap_or(pin)
192}
193
194fn reconcile_session_root(
195    env_root: Option<String>,
196    env_is_pin: bool,
197    cwd_root: Option<String>,
198) -> Option<String> {
199    let cwd_root =
200        cwd_root.filter(|cwd| !crate::core::pathutil::is_broad_or_unsafe_root(Path::new(cwd)));
201    if let (true, Some(pin), Some(cwd)) = (env_is_pin, env_root.as_deref(), cwd_root.as_deref()) {
202        let pin_path = crate::core::pathutil::safe_canonicalize_or_self(Path::new(pin));
203        let cwd_path = crate::core::pathutil::safe_canonicalize_or_self(Path::new(cwd));
204        let unrelated = !cwd_path.starts_with(&pin_path) && !pin_path.starts_with(&cwd_path);
205        if unrelated && has_project_marker(&cwd_path) {
206            tracing::warn!(
207                "LEAN_CTX_PROJECT_ROOT pins {pin}, but this session started in project {cwd} — \
208                 using {cwd}. Remove the pin from the agent's global MCP config \
209                 (`lean-ctx doctor --fix`)."
210            );
211            return cwd_root;
212        }
213    }
214    env_root.or(cwd_root)
215}
216
217/// The project root the operator pinned with `LEAN_CTX_PROJECT_ROOT`.
218///
219/// The IDE variables in [`root_from_env`] are hints a host injects and may
220/// name an agent or sandbox directory; this one is set on purpose and is a
221/// boundary. `Some` only under the conditions `root_from_env` accepts it.
222pub fn pinned_root() -> Option<String> {
223    let pinned = std::env::var("LEAN_CTX_PROJECT_ROOT").ok()?;
224    let pinned = pinned.trim();
225    (!pinned.is_empty()
226        && Path::new(pinned).is_dir()
227        && !crate::core::pathutil::is_broad_or_unsafe_root(Path::new(pinned)))
228    .then(|| pinned.to_string())
229}
230
231/// Split a `WORKSPACE_FOLDER_PATHS` value into individual paths.
232///
233/// Cursor separates entries with `,` (observed). We also tolerate the OS
234/// path-list delimiter for robustness — `;` on Windows (never `:`, which is part
235/// of `C:` drive specs) and `:` on Unix (never part of a POSIX path).
236fn split_workspace_paths(raw: &str) -> Vec<String> {
237    let delims: &[char] = if cfg!(windows) {
238        &[',', ';']
239    } else {
240        &[',', ':']
241    };
242    raw.split(delims)
243        .map(str::trim)
244        .filter(|s| !s.is_empty())
245        .map(ToString::to_string)
246        .collect()
247}
248
249/// Best project root from the IDE-injected `WORKSPACE_FOLDER_PATHS` env var.
250///
251/// Cursor declares the MCP `roots` capability but does NOT implement
252/// `roots/list` (it answers `-32601 Method not found`) and launches stdio MCP
253/// servers with `cwd = /`. Without this signal the project root falls back to an
254/// unsafe directory and relative tool paths resolve against the wrong tree
255/// (#699). This variable is the Cursor-sanctioned way to learn the active
256/// workspace folder(s). The same broad/unsafe-root guards as MCP roots apply.
257///
258/// Returned as the selection, with the other candidates, so a choice among
259/// several folders can be named (AP04-G7).
260pub fn workspace_selection_from_env() -> Option<crate::core::root_selection::RootSelection> {
261    for var in ["WORKSPACE_FOLDER_PATHS", "VSCODE_WORKSPACE_FOLDER"] {
262        if let Ok(raw) = std::env::var(var)
263            && let Some(selection) =
264                crate::core::root_selection::select_with_preferences(split_workspace_paths(&raw))
265        {
266            return Some(selection);
267        }
268    }
269    None
270}
271
272/// All valid, safe workspace directories from `WORKSPACE_FOLDER_PATHS`.
273///
274/// Used to register the sibling folders of a multi-root workspace as extra
275/// trusted roots, so explicit paths into them are not rejected by the path jail.
276pub fn workspace_roots_from_env() -> Vec<String> {
277    for var in ["WORKSPACE_FOLDER_PATHS", "VSCODE_WORKSPACE_FOLDER"] {
278        if let Ok(raw) = std::env::var(var) {
279            let paths: Vec<String> = split_workspace_paths(&raw)
280                .into_iter()
281                .filter(|p| Path::new(p).is_dir())
282                .filter(|p| !crate::core::pathutil::is_broad_or_unsafe_root(Path::new(p)))
283                .collect();
284            if !paths.is_empty() {
285                return paths;
286            }
287        }
288    }
289    Vec::new()
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[cfg(unix)]
297    #[test]
298    fn parse_file_uri_unix() {
299        assert_eq!(
300            uri_to_path("file:///home/user/project"),
301            Some("/home/user/project".to_string())
302        );
303    }
304
305    #[cfg(unix)]
306    #[test]
307    fn parse_file_uri_windows() {
308        assert_eq!(
309            uri_to_path("file:///C:/Users/dev/project"),
310            Some("/C:/Users/dev/project".to_string())
311        );
312    }
313
314    #[cfg(unix)]
315    #[test]
316    fn parse_file_uri_with_spaces() {
317        assert_eq!(
318            uri_to_path("file:///home/user/my%20project"),
319            Some("/home/user/my project".to_string())
320        );
321    }
322
323    #[test]
324    fn parse_non_file_uri_returns_none() {
325        assert!(uri_to_path("https://example.com").is_none());
326        assert!(uri_to_path("").is_none());
327    }
328
329    #[test]
330    fn detects_leading_slash_windows_drive() {
331        // GL #273: the `/C:/…` shape (from a Windows `file:///C:/…` URI) must be
332        // recognised so the leading slash can be stripped. Runs on every
333        // platform so Linux CI guards the logic the Windows-only wiring uses.
334        assert!(has_leading_slash_drive("/C:/Users/dev"));
335        assert!(has_leading_slash_drive("/c:/proj"));
336        assert!(has_leading_slash_drive("/Z:"));
337        // POSIX paths, already-stripped drives, UNC shares and root stay intact.
338        assert!(!has_leading_slash_drive("/home/user/proj"));
339        assert!(!has_leading_slash_drive("C:/already"));
340        assert!(!has_leading_slash_drive("//server/share"));
341        assert!(!has_leading_slash_drive("/"));
342        assert!(!has_leading_slash_drive("/1:/x"));
343    }
344
345    #[cfg(windows)]
346    #[test]
347    fn parse_file_uri_windows_drive_strips_leading_slash() {
348        // GL #273: Cursor on Windows reports roots as `file:///C:/…`; these must
349        // parse to an absolute `C:/` path instead of being rejected (which left
350        // the session falling back to the home dir as project root).
351        let got = uri_to_path("file:///C:/Users/dev/project").expect("windows drive uri");
352        assert!(
353            !got.starts_with('/'),
354            "leading slash must be stripped: {got}"
355        );
356        assert!(
357            got.to_ascii_lowercase().starts_with("c:"),
358            "drive prefix must survive: {got}"
359        );
360    }
361
362    #[cfg(windows)]
363    #[test]
364    fn parse_file_uri_windows_percent_encoded_colon() {
365        // Some clients percent-encode the drive colon (`C%3A`).
366        let got = uri_to_path("file:///C%3A/Users/dev/project").expect("encoded colon uri");
367        assert!(
368            !got.starts_with('/'),
369            "leading slash must be stripped: {got}"
370        );
371        assert!(got.to_ascii_lowercase().starts_with("c:"), "got: {got}");
372    }
373
374    #[test]
375    fn rejects_null_bytes() {
376        assert!(uri_to_path("file:///tmp/evil%00path").is_none());
377    }
378
379    #[test]
380    fn rejects_relative_uri() {
381        assert!(uri_to_path("file://relative/path").is_none());
382    }
383
384    #[test]
385    fn canonicalizes_traversal() {
386        let tmp = tempfile::tempdir().unwrap();
387        let sub = tmp.path().join("a").join("b");
388        std::fs::create_dir_all(&sub).unwrap();
389        let traversal = format!("file://{}/a/b/../..", tmp.path().display());
390        let result = uri_to_path(&traversal);
391        assert!(result.is_some());
392        let resolved = result.unwrap();
393        assert!(
394            !resolved.contains(".."),
395            "should be canonicalized: {resolved}"
396        );
397    }
398
399    #[test]
400    fn best_root_prefers_marker() {
401        let tmp = tempfile::tempdir().unwrap();
402        let with_marker = tmp.path().join("has_git");
403        let without = tmp.path().join("plain");
404        std::fs::create_dir_all(&with_marker).unwrap();
405        std::fs::create_dir_all(&without).unwrap();
406        std::fs::create_dir(with_marker.join(".git")).unwrap();
407
408        let uris = vec![
409            format!("file://{}", without.display()),
410            format!("file://{}", with_marker.display()),
411        ];
412        let result = best_root_from_uris(&uris).unwrap();
413        assert!(result.contains("has_git"));
414    }
415
416    #[test]
417    fn best_root_falls_back_to_first_existing_dir() {
418        let tmp = tempfile::tempdir().unwrap();
419        let a = tmp.path().join("dir_a");
420        let b = tmp.path().join("dir_b");
421        std::fs::create_dir_all(&a).unwrap();
422        std::fs::create_dir_all(&b).unwrap();
423
424        let uris = vec![
425            format!("file://{}", a.display()),
426            format!("file://{}", b.display()),
427        ];
428        let result = best_root_from_uris(&uris).unwrap();
429        assert!(result.contains("dir_a"));
430    }
431
432    #[test]
433    fn best_root_skips_nonexistent() {
434        let uris = vec!["file:///nonexistent_abc_123".to_string()];
435        assert!(best_root_from_uris(&uris).is_none());
436    }
437
438    #[test]
439    fn best_root_empty_returns_none() {
440        assert!(best_root_from_uris(&[]).is_none());
441    }
442
443    #[test]
444    fn env_override_returns_none_when_unset() {
445        let _ = root_from_env();
446    }
447
448    #[test]
449    fn best_root_rejects_home_without_marker() {
450        let _home = crate::core::data_dir::test_env_lock();
451        // A client reporting HOME as its workspace root must NOT turn HOME into
452        // the project root (root cause of cross-project session contamination).
453        if let Some(home) = dirs::home_dir() {
454            let uris = vec![format!("file://{}", home.display())];
455            assert_eq!(
456                best_root_from_uris(&uris),
457                None,
458                "HOME must never be accepted as a marker-less project root"
459            );
460        }
461    }
462
463    #[test]
464    fn best_root_prefers_safe_dir_over_home() {
465        let _home = crate::core::data_dir::test_env_lock();
466        if let Some(home) = dirs::home_dir() {
467            let tmp = tempfile::tempdir().unwrap();
468            let safe = tmp.path().join("real_project");
469            std::fs::create_dir_all(&safe).unwrap();
470            let uris = vec![
471                format!("file://{}", home.display()),
472                format!("file://{}", safe.display()),
473            ];
474            let result = best_root_from_uris(&uris).unwrap();
475            assert!(result.contains("real_project"));
476        }
477    }
478
479    #[test]
480    fn best_root_rejects_filesystem_root() {
481        let uris = vec!["file:///".to_string()];
482        assert!(best_root_from_uris(&uris).is_none());
483    }
484
485    #[test]
486    fn all_paths_from_uris() {
487        let tmp = tempfile::tempdir().unwrap();
488        let a = tmp.path().join("project_a");
489        let b = tmp.path().join("project_b");
490        std::fs::create_dir_all(&a).unwrap();
491        std::fs::create_dir_all(&b).unwrap();
492        std::fs::create_dir(a.join(".git")).unwrap();
493
494        let uris = vec![
495            format!("file://{}", a.display()),
496            format!("file://{}", b.display()),
497        ];
498
499        let paths: Vec<String> = uris.iter().filter_map(|u| uri_to_path(u)).collect();
500        assert_eq!(paths.len(), 2);
501        assert!(paths[0].contains("project_a"));
502        assert!(paths[1].contains("project_b"));
503
504        let best = best_root_from_uris(&uris).unwrap();
505        assert!(best.contains("project_a"));
506    }
507
508    #[test]
509    fn split_workspace_paths_comma_separated() {
510        assert_eq!(
511            split_workspace_paths("/home/u/proj-a,/home/u/proj-b"),
512            vec!["/home/u/proj-a".to_string(), "/home/u/proj-b".to_string()]
513        );
514    }
515
516    #[test]
517    fn split_workspace_paths_trims_and_drops_empty() {
518        assert_eq!(
519            split_workspace_paths(" /a , , /b ,"),
520            vec!["/a".to_string(), "/b".to_string()]
521        );
522    }
523
524    #[cfg(unix)]
525    #[test]
526    fn split_workspace_paths_unix_colon_delimiter() {
527        // Unix path-list delimiter is ':'; POSIX paths never contain it.
528        assert_eq!(
529            split_workspace_paths("/a:/b"),
530            vec!["/a".to_string(), "/b".to_string()]
531        );
532    }
533
534    #[test]
535    fn best_root_from_paths_prefers_marker_over_first() {
536        let tmp = tempfile::tempdir().unwrap();
537        let plain = tmp.path().join("plain");
538        let marked = tmp.path().join("marked");
539        std::fs::create_dir_all(&plain).unwrap();
540        std::fs::create_dir_all(&marked).unwrap();
541        std::fs::create_dir(marked.join(".git")).unwrap();
542        let got = best_root_from_paths(vec![
543            plain.to_string_lossy().to_string(),
544            marked.to_string_lossy().to_string(),
545        ])
546        .unwrap();
547        assert!(got.contains("marked"), "marker dir must win: {got}");
548    }
549
550    #[test]
551    fn best_root_from_paths_filters_nonexistent() {
552        let tmp = tempfile::tempdir().unwrap();
553        let safe = tmp.path().join("real_proj");
554        std::fs::create_dir_all(&safe).unwrap();
555        let got = best_root_from_paths(vec![
556            "/nonexistent_xyz_987".to_string(),
557            safe.to_string_lossy().to_string(),
558        ])
559        .unwrap();
560        assert!(got.contains("real_proj"));
561    }
562
563    #[test]
564    fn best_root_from_paths_empty_returns_none() {
565        assert!(best_root_from_paths(vec![]).is_none());
566        assert!(best_root_from_paths(vec!["/nonexistent_abc".to_string()]).is_none());
567    }
568
569    #[test]
570    fn workspace_env_value_picks_marker_root() {
571        // Mirrors Cursor's `WORKSPACE_FOLDER_PATHS` (comma-separated multi-root):
572        // the folder carrying a project marker must win over a sibling.
573        let tmp = tempfile::tempdir().unwrap();
574        let a = tmp.path().join("ws_a");
575        let b = tmp.path().join("ws_b");
576        std::fs::create_dir_all(&a).unwrap();
577        std::fs::create_dir_all(&b).unwrap();
578        std::fs::write(b.join("Cargo.toml"), "[package]").unwrap();
579        let raw = format!("{},{}", a.display(), b.display());
580        let got = best_root_from_paths(split_workspace_paths(&raw)).unwrap();
581        assert!(got.contains("ws_b"), "marker workspace must win: {got}");
582    }
583
584    #[test]
585    fn stale_global_pin_yields_to_the_sessions_real_project() {
586        let tmp = tempfile::tempdir().unwrap();
587        let pinned = tmp.path().join("thinkery");
588        let actual = tmp.path().join("lean-ctx");
589        for dir in [&pinned, &actual] {
590            std::fs::create_dir_all(dir.join(".git")).unwrap();
591        }
592        let s = |p: &std::path::Path| p.to_string_lossy().to_string();
593
594        // Pin from a global config + server started in another real project.
595        assert_eq!(
596            reconcile_session_root(Some(s(&pinned)), true, Some(s(&actual))),
597            Some(s(&actual))
598        );
599        // A host-specific (session-scoped) env root is never overridden.
600        assert_eq!(
601            reconcile_session_root(Some(s(&pinned)), false, Some(s(&actual))),
602            Some(s(&pinned))
603        );
604        // A cwd inside the pinned tree keeps the pin (monorepo subdir).
605        let sub = pinned.join("crates/a");
606        std::fs::create_dir_all(sub.join(".git")).unwrap();
607        assert_eq!(
608            reconcile_session_root(Some(s(&pinned)), true, Some(s(&sub))),
609            Some(s(&pinned))
610        );
611        // A markerless cwd is no evidence against the pin.
612        let bare = tmp.path().join("scratch");
613        std::fs::create_dir_all(&bare).unwrap();
614        assert_eq!(
615            reconcile_session_root(Some(s(&pinned)), true, Some(s(&bare))),
616            Some(s(&pinned))
617        );
618        assert_eq!(
619            reconcile_session_root(None, false, Some(s(&actual))),
620            Some(s(&actual))
621        );
622    }
623
624    #[test]
625    fn workspace_env_readers_do_not_panic() {
626        // Smoke test: both env readers tolerate the variable being set or unset.
627        let _ = workspace_selection_from_env();
628        let _ = workspace_roots_from_env();
629    }
630}