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