Skip to main content

lean_ctx_server/tools/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2pub mod admitted_sources;
3pub mod autonomy;
4pub mod ctx_agent;
5pub mod ctx_architecture;
6pub mod ctx_artifacts;
7pub mod ctx_callgraph;
8#[allow(dead_code, unreachable_pub)]
9pub mod ctx_compile;
10pub mod ctx_compose;
11pub mod ctx_compress;
12pub mod ctx_compress_memory;
13pub mod ctx_context;
14pub mod ctx_control;
15pub mod ctx_cost;
16pub mod ctx_dedup;
17pub mod ctx_delta;
18pub mod ctx_discover;
19pub mod ctx_doc;
20pub mod ctx_edit;
21pub mod ctx_execute;
22pub mod ctx_expand;
23pub mod ctx_explore;
24pub mod ctx_feedback;
25pub mod ctx_gain;
26pub mod ctx_glob;
27pub mod ctx_graph;
28pub mod ctx_graph_diagram;
29pub mod ctx_graph_diff;
30pub mod ctx_graph_primitives;
31pub mod ctx_handoff;
32pub mod ctx_heatmap;
33pub mod ctx_impact;
34pub mod ctx_index;
35pub mod ctx_knowledge;
36pub mod ctx_knowledge_relations;
37pub mod ctx_metrics;
38pub mod ctx_multi_read;
39pub mod ctx_multi_repo;
40pub mod ctx_outline;
41pub mod ctx_overview;
42pub mod ctx_pack;
43pub mod ctx_package;
44pub mod ctx_patch;
45pub mod ctx_plan;
46pub mod ctx_prefetch;
47pub mod ctx_preload;
48pub mod ctx_proof;
49pub mod ctx_provider;
50pub mod ctx_read;
51pub mod ctx_refactor;
52pub mod ctx_response;
53pub mod ctx_search;
54pub mod ctx_semantic_search;
55pub mod ctx_session;
56pub mod ctx_share;
57pub mod ctx_shell;
58pub mod ctx_skillify;
59pub mod ctx_smart_read;
60pub mod ctx_smells;
61pub mod ctx_summary;
62pub mod ctx_symbol;
63pub mod ctx_tools;
64pub mod ctx_transcript_compact;
65pub mod ctx_tree;
66pub mod ctx_verify;
67pub mod ctx_work_graph;
68pub(crate) mod edit_io;
69pub(crate) mod edit_recovery;
70pub(crate) mod graph_meta;
71pub mod knowledge_shared;
72pub(crate) mod output_format;
73pub mod registered;
74pub use lean_ctx::core::search_hook;
75pub use lean_ctx::core::search_kernel;
76#[cfg(test)]
77mod store_admission_tests;
78pub mod walk_guard;
79
80mod server;
81mod server_lifecycle;
82pub use server_lifecycle::shutdown_stage;
83mod server_metrics;
84mod server_paths;
85pub(crate) mod startup;
86
87pub use server::*;
88pub use startup::create_server;
89
90#[cfg(test)]
91mod resolve_path_tests {
92    use super::startup::canonicalize_path;
93    use super::*;
94
95    fn create_git_root(path: &std::path::Path) -> String {
96        std::fs::create_dir_all(path.join(".git")).unwrap();
97        canonicalize_path(path)
98    }
99
100    #[cfg(not(feature = "no-jail"))]
101    #[tokio::test]
102    #[allow(clippy::await_holding_lock)]
103    async fn resolve_path_can_reroot_to_trusted_startup_root_when_session_root_is_stale() {
104        // #991: serialize via `isolated_data_dir` (holds `test_env_lock`) so the
105        // reroot test running in parallel (e.g. the `remove_var` in
106        // `..._without_opt_in`) between set and `resolve_path` — which would
107        // silently disable rerooting and flake this assertion. Cleaned up at the
108        // end so the opt-in never leaks to other tests.
109        let _iso = lean_ctx::core::data_dir::isolated_data_dir();
110        let tmp = tempfile::tempdir().unwrap();
111        let stale = tmp.path().join("stale");
112        let real = tmp.path().join("real");
113        std::fs::create_dir_all(&stale).unwrap();
114        let real_root = create_git_root(&real);
115        std::fs::write(real.join("a.txt"), "ok").unwrap();
116
117        let server = LeanCtxServer::new_with_startup(
118            None,
119            Some(real.as_path()),
120            SessionMode::Personal,
121            "default",
122            "default",
123        );
124        {
125            let mut session = server.session.write().await;
126            session.project_root = Some(stale.to_string_lossy().to_string());
127            session.shell_cwd = Some(stale.to_string_lossy().to_string());
128        }
129
130        let out = server
131            .resolve_path(&real.join("a.txt").to_string_lossy())
132            .await
133            .unwrap();
134
135        assert!(out.ends_with("/a.txt"));
136
137        let session = server.session.read().await;
138        assert_eq!(session.project_root.as_deref(), Some(real_root.as_str()));
139        assert_eq!(session.shell_cwd.as_deref(), Some(real_root.as_str()));
140        drop(session);
141    }
142    #[tokio::test]
143    #[allow(clippy::await_holding_lock)]
144    async fn resolve_path_reroots_from_markerless_stale_root_to_real_project() {
145        // Universal auto-reroot: if session.project_root is markerless (stale),
146        // accessing a path in a real project triggers reroot instead of rejection.
147        let _iso = lean_ctx::core::data_dir::isolated_data_dir();
148        let tmp = tempfile::tempdir().unwrap();
149        let stale = tmp.path().join("stale");
150        let root = tmp.path().join("root");
151        let other = tmp.path().join("other");
152        std::fs::create_dir_all(&stale).unwrap();
153        create_git_root(&root);
154        let _other_value = create_git_root(&other);
155        std::fs::write(other.join("b.txt"), "no").unwrap();
156
157        let server = LeanCtxServer::new_with_startup(
158            None,
159            Some(root.as_path()),
160            SessionMode::Personal,
161            "default",
162            "default",
163        );
164        {
165            let mut session = server.session.write().await;
166            session.project_root = Some(stale.to_string_lossy().to_string());
167            session.shell_cwd = Some(stale.to_string_lossy().to_string());
168        }
169
170        let resolved = server
171            .resolve_path(&other.join("b.txt").to_string_lossy())
172            .await
173            .unwrap();
174        assert!(
175            resolved.contains("b.txt"),
176            "should resolve after reroot: {resolved}"
177        );
178
179        let session = server.session.read().await;
180        let actual = std::path::Path::new(session.project_root.as_deref().unwrap_or(""))
181            .canonicalize()
182            .unwrap_or_default();
183        let expected = other.canonicalize().unwrap_or(other.clone());
184        assert_eq!(actual, expected, "should have rerooted to real project");
185    }
186
187    /// AP04 review S4: a re-root goes through the session switch — the call
188    /// continues in the corrected project's own session with a request
189    /// project to match, and the stale session's data stays behind.
190    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
191    #[allow(clippy::await_holding_lock)]
192    async fn a_reroot_moves_the_call_and_leaves_the_stale_sessions_data_behind() {
193        let _iso = lean_ctx::core::data_dir::isolated_data_dir();
194        let tmp = tempfile::tempdir().unwrap();
195        let stale = tmp.path().join("stale");
196        std::fs::create_dir_all(&stale).unwrap();
197        let repo = tmp.path().join("repo");
198        let repo_root = create_git_root(&repo);
199        std::fs::write(repo.join("a.txt"), "a").unwrap();
200        let server = LeanCtxServer::new_with_startup(
201            None,
202            None,
203            SessionMode::Personal,
204            "default",
205            "default",
206        );
207        {
208            let mut session = server.session.write().await;
209            session.project_root = Some(stale.to_string_lossy().to_string());
210            session.shell_cwd = Some(stale.to_string_lossy().to_string());
211            session.record_manual_evidence("ap04:stale", None);
212        }
213
214        let call = async {
215            server.session.pin();
216            server
217                .resolve_path(&repo.join("a.txt").to_string_lossy())
218                .await
219                .unwrap();
220            let project = lean_ctx::core::policy::runtime::REQUEST_PROJECT
221                .with(|slot| slot.borrow().clone())
222                .map(|project| project.to_string_lossy().into_owned());
223            let bound = server.session.read().await;
224            assert_eq!(bound.project_root.as_deref(), Some(repo_root.as_str()));
225            assert_eq!(project, bound.project_root);
226            assert!(bound.evidence.iter().all(|e| e.key != "ap04:stale"));
227        };
228        server
229            .session
230            .scope(
231                lean_ctx::core::policy::runtime::REQUEST_PROJECT
232                    .scope(std::cell::RefCell::new(Some(stale.clone())), call),
233            )
234            .await;
235    }
236
237    /// AP04 review S3: a path one client of a shared session names never
238    /// re-roots that session, not even away from a markerless root.
239    #[tokio::test]
240    #[allow(clippy::await_holding_lock)]
241    async fn resolve_path_never_reroots_a_shared_session() {
242        let _iso = lean_ctx::core::data_dir::isolated_data_dir();
243        let tmp = tempfile::tempdir().unwrap();
244        let markerless = tmp.path().join("shared");
245        std::fs::create_dir_all(&markerless).unwrap();
246        let repo = tmp.path().join("repo");
247        create_git_root(&repo);
248        std::fs::write(repo.join("a.txt"), "a").unwrap();
249        let server =
250            LeanCtxServer::new_shared_with_context(&markerless.to_string_lossy(), "ap04-s3", "s3");
251        let root = server.session.read().await.project_root.clone();
252        assert!(root.is_some());
253
254        let _ = server
255            .resolve_path(&repo.join("a.txt").to_string_lossy())
256            .await;
257
258        assert_eq!(server.session.read().await.project_root, root);
259    }
260
261    /// AP04-G6: the outermost marker above a path can be `~` itself (a
262    /// dotfiles repository); a markerless session root is never rerooted to it.
263    #[tokio::test]
264    #[allow(clippy::await_holding_lock)]
265    async fn resolve_path_never_reroots_to_the_home_directory() {
266        let _iso = lean_ctx::core::data_dir::isolated_data_dir();
267        let tmp = tempfile::tempdir().unwrap();
268        let home = tmp.path().join("home");
269        create_git_root(&home);
270        let home = home.canonicalize().unwrap();
271        let stale = tmp.path().join("stale");
272        std::fs::create_dir_all(&stale).unwrap();
273        std::fs::create_dir_all(home.join("notes")).unwrap();
274        std::fs::write(home.join("notes/a.md"), "notes").unwrap();
275        let home_guard = lean_ctx::test_env::VarGuard::set("HOME", &home);
276
277        let server = LeanCtxServer::new_with_startup(
278            None,
279            None,
280            SessionMode::Personal,
281            "default",
282            "default",
283        );
284        let stale = stale.to_string_lossy().to_string();
285        {
286            let mut session = server.session.write().await;
287            session.project_root = Some(stale.clone());
288            session.shell_cwd = Some(stale.clone());
289        }
290        let _ = server
291            .resolve_path(&home.join("notes/a.md").to_string_lossy())
292            .await;
293        drop(home_guard);
294
295        let session = server.session.read().await;
296        assert_eq!(session.project_root.as_deref(), Some(stale.as_str()));
297    }
298
299    #[cfg(not(feature = "no-jail"))]
300    #[tokio::test]
301    #[allow(clippy::await_holding_lock)]
302    async fn resolve_path_auto_registers_language_cache_and_serves_the_same_call() {
303        // #899: reading dependency source in a language cache (here a Go module
304        // cache outside the project) registers the cache as a read-only root
305        // and resolves in the same call — no "retry", no config edit. Writes
306        // into the cache stay denied.
307        let _iso = lean_ctx::core::data_dir::isolated_data_dir();
308        let tmp = tempfile::tempdir().unwrap();
309        let root = tmp.path().join("project");
310        create_git_root(&root);
311        let dep = tmp.path().join("go/pkg/mod/example.com/lib@v1");
312        std::fs::create_dir_all(&dep).unwrap();
313        let file = dep.join("lib.go");
314        std::fs::write(&file, "package lib").unwrap();
315
316        let server = LeanCtxServer::new_with_startup(
317            None,
318            Some(root.as_path()),
319            SessionMode::Personal,
320            "default",
321            "default",
322        );
323        {
324            let mut session = server.session.write().await;
325            session.project_root = Some(root.to_string_lossy().to_string());
326            session.shell_cwd = Some(root.to_string_lossy().to_string());
327        }
328
329        let ok = server
330            .resolve_path(&file.to_string_lossy())
331            .await
332            .unwrap_or_else(|e| panic!("first read of a cache file must resolve, got: {e}"));
333        assert!(ok.ends_with("/lib.go"), "resolves the cache file: {ok}");
334        assert!(
335            lean_ctx::core::pathjail::is_read_only_path(&file),
336            "the auto-registered cache stays read-only"
337        );
338
339        // A second read takes the ordinary path through the read allow-list.
340        let again = server
341            .resolve_path(&file.to_string_lossy())
342            .await
343            .unwrap_or_else(|e| panic!("second read must resolve, got: {e}"));
344        assert_eq!(again, ok);
345    }
346
347    #[cfg(not(feature = "no-jail"))]
348    #[tokio::test]
349    #[allow(clippy::await_holding_lock)]
350    async fn resolve_path_auto_reroots_from_agent_config_dir_without_opt_in() {
351        // #580: the MCP server is launched from an agent/IDE config dir
352        // (~/.copilot-style) and wrongly adopts it as the root. With no
353        // `allow_auto_reroot` opt-in and no trusted startup root, the first
354        // absolute path into a real project must still correct the root — an
355        // agent config dir is never a real jail boundary.
356        let _iso = lean_ctx::core::data_dir::isolated_data_dir();
357        let tmp = tempfile::tempdir().unwrap();
358        let agent = tmp.path().join(".copilot");
359        let real = tmp.path().join("repo");
360        std::fs::create_dir_all(&agent).unwrap();
361        let real_root = create_git_root(&real);
362        std::fs::write(real.join("a.txt"), "ok").unwrap();
363
364        let server = LeanCtxServer::new_with_startup(
365            None,
366            None,
367            SessionMode::Personal,
368            "default",
369            "default",
370        );
371        {
372            let mut session = server.session.write().await;
373            session.project_root = Some(agent.to_string_lossy().to_string());
374            session.shell_cwd = Some(agent.to_string_lossy().to_string());
375        }
376
377        let out = server
378            .resolve_path(&real.join("a.txt").to_string_lossy())
379            .await
380            .unwrap();
381        assert!(
382            out.ends_with("/a.txt"),
383            "agent-dir jail must auto-correct: {out}"
384        );
385
386        let session = server.session.read().await;
387        assert_eq!(session.project_root.as_deref(), Some(real_root.as_str()));
388    }
389
390    #[cfg(not(feature = "no-jail"))]
391    #[tokio::test]
392    #[allow(clippy::await_holding_lock)]
393    async fn resolve_path_auto_reroots_from_markerless_client_cwd_without_opt_in() {
394        // VS Code/WSL can launch the MCP server from /mnt/c/Users while the
395        // workspace lives under /mnt/d. That markerless cwd must not become a
396        // permanent PathJail root when the request points at a real project.
397        let _iso = lean_ctx::core::data_dir::isolated_data_dir();
398        let tmp = tempfile::tempdir().unwrap();
399        let client_cwd = tmp.path().join("Users").join("user");
400        let real = tmp.path().join("workspaces").join("lean-ctx");
401        std::fs::create_dir_all(&client_cwd).unwrap();
402        let real_root = create_git_root(&real);
403        std::fs::write(real.join("rust.rs"), "ok").unwrap();
404
405        let server = LeanCtxServer::new_with_startup(
406            None,
407            None,
408            SessionMode::Personal,
409            "default",
410            "default",
411        );
412        {
413            let mut session = server.session.write().await;
414            session.project_root = Some(client_cwd.to_string_lossy().to_string());
415            session.shell_cwd = Some(client_cwd.to_string_lossy().to_string());
416        }
417
418        let out = server
419            .resolve_path(&real.join("rust.rs").to_string_lossy())
420            .await
421            .unwrap();
422        assert!(
423            out.ends_with("/rust.rs"),
424            "markerless client cwd must auto-correct: {out}"
425        );
426
427        let session = server.session.read().await;
428        assert_eq!(session.project_root.as_deref(), Some(real_root.as_str()));
429    }
430
431    #[cfg(not(feature = "no-jail"))]
432    #[tokio::test]
433    #[allow(clippy::await_holding_lock)]
434    async fn resolve_path_keeps_a_pinned_markerless_root_inside_a_checkout() {
435        // A directory pinned with LEAN_CTX_PROJECT_ROOT that carries no marker
436        // and lies inside a checkout stays the root: neither a path below it nor
437        // one in the surrounding checkout may widen the jail to that checkout.
438        let _iso = lean_ctx::core::data_dir::isolated_data_dir();
439        let tmp = tempfile::tempdir().unwrap();
440        let checkout = tmp.path().join("checkout");
441        create_git_root(&checkout);
442        let pinned = checkout.join("runs").join("one");
443        std::fs::create_dir_all(&pinned).unwrap();
444        std::fs::write(pinned.join("note.md"), "ok").unwrap();
445        std::fs::write(checkout.join("outside.md"), "no").unwrap();
446        let pinned_root = pinned.to_string_lossy().to_string();
447        lean_ctx::test_env::set_var("LEAN_CTX_PROJECT_ROOT", &pinned_root);
448
449        let server = LeanCtxServer::new_with_startup(
450            None,
451            None,
452            SessionMode::Personal,
453            "default",
454            "default",
455        );
456        {
457            let mut session = server.session.write().await;
458            session.project_root = Some(pinned_root.clone());
459            session.shell_cwd = Some(pinned_root.clone());
460        }
461
462        let inside = server
463            .resolve_path(&pinned.join("note.md").to_string_lossy())
464            .await;
465        let outside = server
466            .resolve_path(&checkout.join("outside.md").to_string_lossy())
467            .await;
468        let root = server.session.read().await.project_root.clone();
469        lean_ctx::test_env::remove_var("LEAN_CTX_PROJECT_ROOT");
470
471        assert!(inside.is_ok_and(|path| path.ends_with("/note.md")));
472        assert!(outside.is_err(), "a path above the pin is refused");
473        assert_eq!(root.as_deref(), Some(pinned_root.as_str()));
474    }
475
476    #[cfg(not(feature = "no-jail"))]
477    #[tokio::test]
478    #[allow(clippy::await_holding_lock)]
479    async fn resolve_path_auto_reroots_even_when_extra_root_allows_path() {
480        let _iso = lean_ctx::core::data_dir::isolated_data_dir();
481        let tmp = tempfile::tempdir().unwrap();
482        let client_cwd = tmp.path().join("Users").join("user");
483        let real = tmp.path().join("workspaces").join("lean-ctx");
484        std::fs::create_dir_all(&client_cwd).unwrap();
485        let real_root = create_git_root(&real);
486        std::fs::write(real.join("rust.rs"), "ok").unwrap();
487
488        let server = LeanCtxServer::new_with_startup(
489            None,
490            None,
491            SessionMode::Personal,
492            "default",
493            "default",
494        );
495        {
496            let mut session = server.session.write().await;
497            session.project_root = Some(client_cwd.to_string_lossy().to_string());
498            session.shell_cwd = Some(client_cwd.to_string_lossy().to_string());
499            session.extra_roots.push(real_root.clone());
500        }
501
502        let out = server
503            .resolve_path(&real.join("rust.rs").to_string_lossy())
504            .await
505            .unwrap();
506        assert!(
507            out.ends_with("/rust.rs"),
508            "extra root must not suppress markerless-cwd reroot: {out}"
509        );
510
511        let session = server.session.read().await;
512        assert_eq!(session.project_root.as_deref(), Some(real_root.as_str()));
513        assert_eq!(session.shell_cwd.as_deref(), Some(real_root.as_str()));
514    }
515
516    #[cfg(not(feature = "no-jail"))]
517    #[tokio::test]
518    #[allow(clippy::await_holding_lock)]
519    async fn resolve_path_markerless_root_still_blocks_markerless_escape() {
520        // #649 must not weaken PathJail: from a markerless client cwd, an absolute
521        // path that derives NO project marker stays blocked and the root is
522        // unchanged. Only rerooting *to a real project* is permitted.
523        let _iso = lean_ctx::core::data_dir::isolated_data_dir();
524        let tmp = tempfile::tempdir().unwrap();
525        let client_cwd = tmp.path().join("Users").join("user");
526        let loose = tmp.path().join("workspaces").join("loose");
527        std::fs::create_dir_all(&client_cwd).unwrap();
528        std::fs::create_dir_all(&loose).unwrap();
529        std::fs::write(loose.join("data.txt"), "no").unwrap();
530
531        let server = LeanCtxServer::new_with_startup(
532            None,
533            None,
534            SessionMode::Personal,
535            "default",
536            "default",
537        );
538        {
539            let mut session = server.session.write().await;
540            session.project_root = Some(client_cwd.to_string_lossy().to_string());
541            session.shell_cwd = Some(client_cwd.to_string_lossy().to_string());
542        }
543
544        let err = server
545            .resolve_path(&loose.join("data.txt").to_string_lossy())
546            .await
547            .unwrap_err();
548        assert!(err.contains("path escapes project root"), "got: {err}");
549
550        let session = server.session.read().await;
551        assert_eq!(
552            session.project_root.as_deref(),
553            Some(client_cwd.to_string_lossy().as_ref())
554        );
555    }
556
557    #[cfg(not(feature = "no-jail"))]
558    #[tokio::test]
559    #[allow(clippy::await_holding_lock)]
560    async fn resolve_path_agent_dir_still_blocks_markerless_escape() {
561        // The agent-dir bypass only reroots to a *real* project (one carrying a
562        // marker). A markerless absolute path outside the jail stays blocked —
563        // PathJail enforcement is unchanged, only the root *choice* is corrected.
564        let _iso = lean_ctx::core::data_dir::isolated_data_dir();
565        let tmp = tempfile::tempdir().unwrap();
566        let agent = tmp.path().join(".copilot");
567        let loose = tmp.path().join("loose");
568        std::fs::create_dir_all(&agent).unwrap();
569        std::fs::create_dir_all(&loose).unwrap();
570        std::fs::write(loose.join("data.txt"), "no").unwrap();
571
572        let server = LeanCtxServer::new_with_startup(
573            None,
574            None,
575            SessionMode::Personal,
576            "default",
577            "default",
578        );
579        {
580            let mut session = server.session.write().await;
581            session.project_root = Some(agent.to_string_lossy().to_string());
582            session.shell_cwd = Some(agent.to_string_lossy().to_string());
583        }
584
585        let err = server
586            .resolve_path(&loose.join("data.txt").to_string_lossy())
587            .await
588            .unwrap_err();
589        assert!(err.contains("path escapes project root"), "got: {err}");
590
591        let session = server.session.read().await;
592        assert_eq!(
593            session.project_root.as_deref(),
594            Some(agent.to_string_lossy().as_ref())
595        );
596    }
597
598    #[tokio::test]
599    #[allow(clippy::await_holding_lock)]
600    async fn startup_creates_fresh_session_scoped_to_workspace() {
601        let _lock = lean_ctx::core::data_dir::test_env_lock();
602        let _data = tempfile::tempdir().unwrap();
603        let _tmp = tempfile::tempdir().unwrap();
604
605        lean_ctx::test_env::set_var("LEAN_CTX_DATA_DIR", _data.path());
606
607        let repo_b = _tmp.path().join("repo-b");
608        let root_b = create_git_root(&repo_b);
609
610        let server = LeanCtxServer::new_with_startup(
611            None,
612            Some(repo_b.as_path()),
613            SessionMode::Personal,
614            "default",
615            "default",
616        );
617        lean_ctx::test_env::remove_var("LEAN_CTX_DATA_DIR");
618
619        let session = server.session.read().await;
620        // #1388: Personal mode creates a fresh PID-qualified session.
621        // The workspace root is detected from startup_cwd, but task
622        // starts empty (no cross-session state inheritance).
623        assert_eq!(session.project_root.as_deref(), Some(root_b.as_str()));
624        assert_eq!(session.shell_cwd.as_deref(), Some(root_b.as_str()));
625        assert!(session.task.is_none());
626    }
627
628    #[tokio::test]
629    #[allow(clippy::await_holding_lock)]
630    async fn startup_creates_fresh_session_for_new_workspace_and_preserves_subdir_cwd() {
631        let _lock = lean_ctx::core::data_dir::test_env_lock();
632        let _data = tempfile::tempdir().unwrap();
633        let _tmp = tempfile::tempdir().unwrap();
634
635        lean_ctx::test_env::set_var("LEAN_CTX_DATA_DIR", _data.path());
636
637        let repo_a = _tmp.path().join("repo-a");
638        let repo_b = _tmp.path().join("repo-b");
639        let repo_b_src = repo_b.join("src");
640        let root_a = create_git_root(&repo_a);
641        let root_b = create_git_root(&repo_b);
642        std::fs::create_dir_all(&repo_b_src).unwrap();
643        let repo_b_src_value = canonicalize_path(&repo_b_src);
644
645        let mut session_a = lean_ctx::core::session::SessionState::new();
646        session_a.project_root = Some(root_a.clone());
647        session_a.shell_cwd = Some(root_a.clone());
648        session_a.set_task("repo-a latest task", None);
649        let old_id = session_a.id.clone();
650        session_a.save().unwrap();
651
652        let server = LeanCtxServer::new_with_startup(
653            None,
654            Some(repo_b_src.as_path()),
655            SessionMode::Personal,
656            "default",
657            "default",
658        );
659        lean_ctx::test_env::remove_var("LEAN_CTX_DATA_DIR");
660
661        let session = server.session.read().await;
662        assert_eq!(session.project_root.as_deref(), Some(root_b.as_str()));
663        assert_eq!(
664            session.shell_cwd.as_deref(),
665            Some(repo_b_src_value.as_str())
666        );
667        assert!(session.task.is_none());
668        assert_ne!(session.id, old_id);
669    }
670
671    #[cfg(not(feature = "no-jail"))]
672    #[tokio::test]
673    #[allow(clippy::await_holding_lock)]
674    async fn resolve_path_does_not_auto_update_when_current_root_is_real_project() {
675        // Hermetic config + serialized via test_env_lock so a parallel test that
676        // flips `path_jail` cannot disable this jail-enforcement assertion (#406).
677        let _iso = lean_ctx::core::data_dir::isolated_data_dir();
678        let tmp = tempfile::tempdir().unwrap();
679        let root = tmp.path().join("root");
680        let other = tmp.path().join("other");
681        let root_value = create_git_root(&root);
682        create_git_root(&other);
683        std::fs::write(other.join("b.txt"), "no").unwrap();
684
685        let root_str = root.to_string_lossy().to_string();
686        let server = LeanCtxServer::new_with_project_root(Some(&root_str));
687
688        let err = server
689            .resolve_path(&other.join("b.txt").to_string_lossy())
690            .await
691            .unwrap_err();
692        assert!(err.contains("path escapes project root"));
693
694        let session = server.session.read().await;
695        assert_eq!(session.project_root.as_deref(), Some(root_value.as_str()));
696    }
697
698    // #707: a mid-session worktree switch moves shell_cwd into a nested linked
699    // worktree (own `.git` *file*) while project_root stays at initialize-time.
700    // The path `src/lib.rs` deliberately also exists relative to the test
701    // process CWD (`rust/`): the server's `p.exists()` probe used to
702    // short-circuit on exactly that and serve the stale root copy before the
703    // divergent-checkout precedence could apply.
704    #[tokio::test]
705    #[allow(clippy::await_holding_lock)]
706    async fn resolve_path_prefers_worktree_copy_after_mid_session_switch() {
707        let _iso = lean_ctx::core::data_dir::isolated_data_dir();
708        let tmp = tempfile::tempdir().unwrap();
709        let repo = tmp.path().join("repo");
710        let root_value = create_git_root(&repo);
711        let wt = repo.join(".claude/worktrees/wt");
712        std::fs::create_dir_all(wt.join("src")).unwrap();
713        std::fs::write(wt.join(".git"), "gitdir: ../../../.git/worktrees/wt\n").unwrap();
714        std::fs::create_dir_all(repo.join("src")).unwrap();
715        std::fs::write(repo.join("src/lib.rs"), "stale").unwrap();
716        std::fs::write(wt.join("src/lib.rs"), "fresh").unwrap();
717
718        let server = LeanCtxServer::new_with_project_root(Some(&root_value));
719        {
720            // The worktree switch as ctx_shell's cwd tracking records it.
721            let mut session = server.session.write().await;
722            session.shell_cwd = Some(wt.to_string_lossy().to_string());
723        }
724
725        let out = server.resolve_path("src/lib.rs").await.unwrap();
726        assert_eq!(
727            std::fs::read_to_string(&out).unwrap(),
728            "fresh",
729            "worktree copy must win over the stale project_root copy: {out}"
730        );
731
732        // Switching back restores the established precedence: without
733        // divergence the project_root fallback serves the root copy again.
734        // Probed via a path that does NOT exist relative to the test process
735        // CWD — `src/lib.rs` would hit the (intended) `p.exists()` fast path
736        // and resolve to the real checkout's file, making the assertion
737        // environment-dependent (that is exactly how CI diverged from local).
738        {
739            let mut session = server.session.write().await;
740            session.shell_cwd = Some(root_value.clone());
741        }
742        std::fs::write(repo.join("src/back_707.rs"), "stale").unwrap();
743        std::fs::write(wt.join("src/back_707.rs"), "fresh").unwrap();
744        let out = server.resolve_path("src/back_707.rs").await.unwrap();
745        assert_eq!(std::fs::read_to_string(&out).unwrap(), "stale");
746    }
747}