1pub mod autonomy;
2pub mod ctx_agent;
3pub mod ctx_analyze;
4pub mod ctx_architecture;
5pub mod ctx_artifacts;
6pub mod ctx_benchmark;
7pub mod ctx_callgraph;
8pub mod ctx_compile;
9pub mod ctx_compose;
10pub mod ctx_compress;
11pub mod ctx_compress_memory;
12pub mod ctx_context;
13pub mod ctx_control;
14pub mod ctx_cost;
15pub mod ctx_dedup;
16pub mod ctx_delta;
17pub mod ctx_discover;
18pub mod ctx_edit;
19pub mod ctx_execute;
20pub mod ctx_expand;
21pub mod ctx_explore;
22pub mod ctx_feedback;
23pub mod ctx_fill;
24pub mod ctx_gain;
25pub mod ctx_glob;
26pub mod ctx_graph;
27pub mod ctx_graph_diagram;
28pub mod ctx_graph_diff;
29pub mod ctx_graph_primitives;
30pub mod ctx_handoff;
31pub mod ctx_heatmap;
32pub mod ctx_impact;
33pub mod ctx_index;
34pub mod ctx_intent;
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_plugins;
47pub mod ctx_prefetch;
48pub mod ctx_preload;
49pub mod ctx_proof;
50pub mod ctx_provider;
51pub mod ctx_quality;
52pub mod ctx_read;
53pub mod ctx_refactor;
54pub mod ctx_repomap;
55pub mod ctx_response;
56pub mod ctx_review;
57pub mod ctx_routes;
58pub mod ctx_rules;
59pub mod ctx_search;
60pub mod ctx_semantic_search;
61pub mod ctx_session;
62pub mod ctx_share;
63pub mod ctx_shell;
64pub mod ctx_skillify;
65pub mod ctx_smart_read;
66pub mod ctx_smells;
67pub mod ctx_summary;
68pub mod ctx_symbol;
69pub mod ctx_task;
70pub mod ctx_tools;
71pub mod ctx_transcript_compact;
72pub mod ctx_tree;
73pub mod ctx_verify;
74pub mod ctx_workflow;
75pub(crate) mod edit_io;
76pub(crate) mod edit_recovery;
77pub(crate) mod graph_meta;
78pub(crate) mod knowledge_shared;
79pub(crate) mod output_format;
80pub mod registered;
81pub mod search_hook;
82pub mod search_kernel;
83pub(crate) mod walk_guard;
84
85mod server;
86mod server_lifecycle;
87mod server_metrics;
88mod server_paths;
89pub(crate) mod startup;
90
91pub use server::*;
92pub use startup::create_server;
93
94#[cfg(test)]
95mod resolve_path_tests {
96 use super::startup::canonicalize_path;
97 use super::*;
98
99 fn create_git_root(path: &std::path::Path) -> String {
100 std::fs::create_dir_all(path.join(".git")).unwrap();
101 canonicalize_path(path)
102 }
103
104 #[cfg(not(feature = "no-jail"))]
105 #[tokio::test]
106 #[allow(clippy::await_holding_lock)]
107 async fn resolve_path_can_reroot_to_trusted_startup_root_when_session_root_is_stale() {
108 let _iso = crate::core::data_dir::isolated_data_dir();
115 crate::test_env::set_var("LEAN_CTX_ALLOW_REROOT", "1");
116 let tmp = tempfile::tempdir().unwrap();
117 let stale = tmp.path().join("stale");
118 let real = tmp.path().join("real");
119 std::fs::create_dir_all(&stale).unwrap();
120 let real_root = create_git_root(&real);
121 std::fs::write(real.join("a.txt"), "ok").unwrap();
122
123 let server = LeanCtxServer::new_with_startup(
124 None,
125 Some(real.as_path()),
126 SessionMode::Personal,
127 "default",
128 "default",
129 );
130 {
131 let mut session = server.session.write().await;
132 session.project_root = Some(stale.to_string_lossy().to_string());
133 session.shell_cwd = Some(stale.to_string_lossy().to_string());
134 }
135
136 let out = server
137 .resolve_path(&real.join("a.txt").to_string_lossy())
138 .await
139 .unwrap();
140
141 assert!(out.ends_with("/a.txt"));
142
143 let session = server.session.read().await;
144 assert_eq!(session.project_root.as_deref(), Some(real_root.as_str()));
145 assert_eq!(session.shell_cwd.as_deref(), Some(real_root.as_str()));
146 drop(session);
147 crate::test_env::remove_var("LEAN_CTX_ALLOW_REROOT");
148 }
149
150 #[cfg(not(feature = "no-jail"))]
151 #[tokio::test]
152 #[allow(clippy::await_holding_lock)]
153 async fn resolve_path_rejects_absolute_path_outside_trusted_startup_root() {
154 let _iso = crate::core::data_dir::isolated_data_dir();
157 let tmp = tempfile::tempdir().unwrap();
158 let stale = tmp.path().join("stale");
159 let root = tmp.path().join("root");
160 let other = tmp.path().join("other");
161 std::fs::create_dir_all(&stale).unwrap();
162 create_git_root(&root);
163 let _other_value = create_git_root(&other);
164 std::fs::write(other.join("b.txt"), "no").unwrap();
165
166 let server = LeanCtxServer::new_with_startup(
167 None,
168 Some(root.as_path()),
169 SessionMode::Personal,
170 "default",
171 "default",
172 );
173 {
174 let mut session = server.session.write().await;
175 session.project_root = Some(stale.to_string_lossy().to_string());
176 session.shell_cwd = Some(stale.to_string_lossy().to_string());
177 }
178
179 let err = server
180 .resolve_path(&other.join("b.txt").to_string_lossy())
181 .await
182 .unwrap_err();
183 assert!(err.contains("path escapes project root"));
184
185 let session = server.session.read().await;
186 assert_eq!(
187 session.project_root.as_deref(),
188 Some(stale.to_string_lossy().as_ref())
189 );
190 }
191
192 #[cfg(not(feature = "no-jail"))]
193 #[tokio::test]
194 #[allow(clippy::await_holding_lock)]
195 async fn resolve_path_auto_registers_language_cache_then_retry_succeeds() {
196 let _iso = crate::core::data_dir::isolated_data_dir();
200 let tmp = tempfile::tempdir().unwrap();
201 let root = tmp.path().join("project");
202 create_git_root(&root);
203 let dep = tmp.path().join("go/pkg/mod/example.com/lib@v1");
204 std::fs::create_dir_all(&dep).unwrap();
205 let file = dep.join("lib.go");
206 std::fs::write(&file, "package lib").unwrap();
207
208 let server = LeanCtxServer::new_with_startup(
209 None,
210 Some(root.as_path()),
211 SessionMode::Personal,
212 "default",
213 "default",
214 );
215 {
216 let mut session = server.session.write().await;
217 session.project_root = Some(root.to_string_lossy().to_string());
218 session.shell_cwd = Some(root.to_string_lossy().to_string());
219 }
220
221 let err = server
223 .resolve_path(&file.to_string_lossy())
224 .await
225 .unwrap_err();
226 assert!(
227 err.contains("Auto-detected Go module cache"),
228 "expected auto-detect hint, got: {err}"
229 );
230 assert!(err.contains("Retry"), "hint must ask for a retry: {err}");
231
232 let ok = server
234 .resolve_path(&file.to_string_lossy())
235 .await
236 .unwrap_or_else(|e| panic!("retry must resolve, got: {e}"));
237 assert!(
238 ok.ends_with("/lib.go"),
239 "retry resolves the cache file: {ok}"
240 );
241 }
242
243 #[cfg(not(feature = "no-jail"))]
244 #[tokio::test]
245 #[allow(clippy::await_holding_lock)]
246 async fn resolve_path_auto_reroots_from_agent_config_dir_without_opt_in() {
247 let _iso = crate::core::data_dir::isolated_data_dir();
253 crate::test_env::remove_var("LEAN_CTX_ALLOW_REROOT");
254 let tmp = tempfile::tempdir().unwrap();
255 let agent = tmp.path().join(".copilot");
256 let real = tmp.path().join("repo");
257 std::fs::create_dir_all(&agent).unwrap();
258 let real_root = create_git_root(&real);
259 std::fs::write(real.join("a.txt"), "ok").unwrap();
260
261 let server = LeanCtxServer::new_with_startup(
262 None,
263 None,
264 SessionMode::Personal,
265 "default",
266 "default",
267 );
268 {
269 let mut session = server.session.write().await;
270 session.project_root = Some(agent.to_string_lossy().to_string());
271 session.shell_cwd = Some(agent.to_string_lossy().to_string());
272 }
273
274 let out = server
275 .resolve_path(&real.join("a.txt").to_string_lossy())
276 .await
277 .unwrap();
278 assert!(
279 out.ends_with("/a.txt"),
280 "agent-dir jail must auto-correct: {out}"
281 );
282
283 let session = server.session.read().await;
284 assert_eq!(session.project_root.as_deref(), Some(real_root.as_str()));
285 }
286
287 #[cfg(not(feature = "no-jail"))]
288 #[tokio::test]
289 #[allow(clippy::await_holding_lock)]
290 async fn resolve_path_auto_reroots_from_markerless_client_cwd_without_opt_in() {
291 let _iso = crate::core::data_dir::isolated_data_dir();
295 crate::test_env::remove_var("LEAN_CTX_ALLOW_REROOT");
296 let tmp = tempfile::tempdir().unwrap();
297 let client_cwd = tmp.path().join("Users").join("user");
298 let real = tmp.path().join("workspaces").join("lean-ctx");
299 std::fs::create_dir_all(&client_cwd).unwrap();
300 let real_root = create_git_root(&real);
301 std::fs::write(real.join("rust.rs"), "ok").unwrap();
302
303 let server = LeanCtxServer::new_with_startup(
304 None,
305 None,
306 SessionMode::Personal,
307 "default",
308 "default",
309 );
310 {
311 let mut session = server.session.write().await;
312 session.project_root = Some(client_cwd.to_string_lossy().to_string());
313 session.shell_cwd = Some(client_cwd.to_string_lossy().to_string());
314 }
315
316 let out = server
317 .resolve_path(&real.join("rust.rs").to_string_lossy())
318 .await
319 .unwrap();
320 assert!(
321 out.ends_with("/rust.rs"),
322 "markerless client cwd must auto-correct: {out}"
323 );
324
325 let session = server.session.read().await;
326 assert_eq!(session.project_root.as_deref(), Some(real_root.as_str()));
327 }
328
329 #[cfg(not(feature = "no-jail"))]
330 #[tokio::test]
331 #[allow(clippy::await_holding_lock)]
332 async fn resolve_path_auto_reroots_even_when_extra_root_allows_path() {
333 let _iso = crate::core::data_dir::isolated_data_dir();
334 crate::test_env::remove_var("LEAN_CTX_ALLOW_REROOT");
335 let tmp = tempfile::tempdir().unwrap();
336 let client_cwd = tmp.path().join("Users").join("user");
337 let real = tmp.path().join("workspaces").join("lean-ctx");
338 std::fs::create_dir_all(&client_cwd).unwrap();
339 let real_root = create_git_root(&real);
340 std::fs::write(real.join("rust.rs"), "ok").unwrap();
341
342 let server = LeanCtxServer::new_with_startup(
343 None,
344 None,
345 SessionMode::Personal,
346 "default",
347 "default",
348 );
349 {
350 let mut session = server.session.write().await;
351 session.project_root = Some(client_cwd.to_string_lossy().to_string());
352 session.shell_cwd = Some(client_cwd.to_string_lossy().to_string());
353 session.extra_roots.push(real_root.clone());
354 }
355
356 let out = server
357 .resolve_path(&real.join("rust.rs").to_string_lossy())
358 .await
359 .unwrap();
360 assert!(
361 out.ends_with("/rust.rs"),
362 "extra root must not suppress markerless-cwd reroot: {out}"
363 );
364
365 let session = server.session.read().await;
366 assert_eq!(session.project_root.as_deref(), Some(real_root.as_str()));
367 assert_eq!(session.shell_cwd.as_deref(), Some(real_root.as_str()));
368 }
369
370 #[cfg(not(feature = "no-jail"))]
371 #[tokio::test]
372 #[allow(clippy::await_holding_lock)]
373 async fn resolve_path_markerless_root_still_blocks_markerless_escape() {
374 let _iso = crate::core::data_dir::isolated_data_dir();
378 crate::test_env::remove_var("LEAN_CTX_ALLOW_REROOT");
379 let tmp = tempfile::tempdir().unwrap();
380 let client_cwd = tmp.path().join("Users").join("user");
381 let loose = tmp.path().join("workspaces").join("loose");
382 std::fs::create_dir_all(&client_cwd).unwrap();
383 std::fs::create_dir_all(&loose).unwrap();
384 std::fs::write(loose.join("data.txt"), "no").unwrap();
385
386 let server = LeanCtxServer::new_with_startup(
387 None,
388 None,
389 SessionMode::Personal,
390 "default",
391 "default",
392 );
393 {
394 let mut session = server.session.write().await;
395 session.project_root = Some(client_cwd.to_string_lossy().to_string());
396 session.shell_cwd = Some(client_cwd.to_string_lossy().to_string());
397 }
398
399 let err = server
400 .resolve_path(&loose.join("data.txt").to_string_lossy())
401 .await
402 .unwrap_err();
403 assert!(err.contains("path escapes project root"), "got: {err}");
404
405 let session = server.session.read().await;
406 assert_eq!(
407 session.project_root.as_deref(),
408 Some(client_cwd.to_string_lossy().as_ref())
409 );
410 }
411
412 #[cfg(not(feature = "no-jail"))]
413 #[tokio::test]
414 #[allow(clippy::await_holding_lock)]
415 async fn resolve_path_agent_dir_still_blocks_markerless_escape() {
416 let _iso = crate::core::data_dir::isolated_data_dir();
420 crate::test_env::remove_var("LEAN_CTX_ALLOW_REROOT");
421 let tmp = tempfile::tempdir().unwrap();
422 let agent = tmp.path().join(".copilot");
423 let loose = tmp.path().join("loose");
424 std::fs::create_dir_all(&agent).unwrap();
425 std::fs::create_dir_all(&loose).unwrap();
426 std::fs::write(loose.join("data.txt"), "no").unwrap();
427
428 let server = LeanCtxServer::new_with_startup(
429 None,
430 None,
431 SessionMode::Personal,
432 "default",
433 "default",
434 );
435 {
436 let mut session = server.session.write().await;
437 session.project_root = Some(agent.to_string_lossy().to_string());
438 session.shell_cwd = Some(agent.to_string_lossy().to_string());
439 }
440
441 let err = server
442 .resolve_path(&loose.join("data.txt").to_string_lossy())
443 .await
444 .unwrap_err();
445 assert!(err.contains("path escapes project root"), "got: {err}");
446
447 let session = server.session.read().await;
448 assert_eq!(
449 session.project_root.as_deref(),
450 Some(agent.to_string_lossy().as_ref())
451 );
452 }
453
454 #[tokio::test]
455 #[allow(clippy::await_holding_lock)]
456 async fn startup_prefers_workspace_scoped_session_over_global_latest() {
457 let _lock = crate::core::data_dir::test_env_lock();
458 let _data = tempfile::tempdir().unwrap();
459 let _tmp = tempfile::tempdir().unwrap();
460
461 crate::test_env::set_var("LEAN_CTX_DATA_DIR", _data.path());
462
463 let repo_a = _tmp.path().join("repo-a");
464 let repo_b = _tmp.path().join("repo-b");
465 let root_a = create_git_root(&repo_a);
466 let root_b = create_git_root(&repo_b);
467
468 let mut session_b = crate::core::session::SessionState::new();
469 session_b.project_root = Some(root_b.clone());
470 session_b.shell_cwd = Some(root_b.clone());
471 session_b.set_task("repo-b task", None);
472 session_b.save().unwrap();
473
474 std::thread::sleep(std::time::Duration::from_millis(50));
475
476 let mut session_a = crate::core::session::SessionState::new();
477 session_a.project_root = Some(root_a.clone());
478 session_a.shell_cwd = Some(root_a.clone());
479 session_a.set_task("repo-a latest task", None);
480 session_a.save().unwrap();
481
482 let server = LeanCtxServer::new_with_startup(
483 None,
484 Some(repo_b.as_path()),
485 SessionMode::Personal,
486 "default",
487 "default",
488 );
489 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
490
491 let session = server.session.read().await;
492 assert_eq!(session.project_root.as_deref(), Some(root_b.as_str()));
493 assert_eq!(session.shell_cwd.as_deref(), Some(root_b.as_str()));
494 assert_eq!(
495 session.task.as_ref().map(|t| t.description.as_str()),
496 Some("repo-b task")
497 );
498 }
499
500 #[tokio::test]
501 #[allow(clippy::await_holding_lock)]
502 async fn startup_creates_fresh_session_for_new_workspace_and_preserves_subdir_cwd() {
503 let _lock = crate::core::data_dir::test_env_lock();
504 let _data = tempfile::tempdir().unwrap();
505 let _tmp = tempfile::tempdir().unwrap();
506
507 crate::test_env::set_var("LEAN_CTX_DATA_DIR", _data.path());
508
509 let repo_a = _tmp.path().join("repo-a");
510 let repo_b = _tmp.path().join("repo-b");
511 let repo_b_src = repo_b.join("src");
512 let root_a = create_git_root(&repo_a);
513 let root_b = create_git_root(&repo_b);
514 std::fs::create_dir_all(&repo_b_src).unwrap();
515 let repo_b_src_value = canonicalize_path(&repo_b_src);
516
517 let mut session_a = crate::core::session::SessionState::new();
518 session_a.project_root = Some(root_a.clone());
519 session_a.shell_cwd = Some(root_a.clone());
520 session_a.set_task("repo-a latest task", None);
521 let old_id = session_a.id.clone();
522 session_a.save().unwrap();
523
524 let server = LeanCtxServer::new_with_startup(
525 None,
526 Some(repo_b_src.as_path()),
527 SessionMode::Personal,
528 "default",
529 "default",
530 );
531 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
532
533 let session = server.session.read().await;
534 assert_eq!(session.project_root.as_deref(), Some(root_b.as_str()));
535 assert_eq!(
536 session.shell_cwd.as_deref(),
537 Some(repo_b_src_value.as_str())
538 );
539 assert!(session.task.is_none());
540 assert_ne!(session.id, old_id);
541 }
542
543 #[cfg(not(feature = "no-jail"))]
544 #[tokio::test]
545 #[allow(clippy::await_holding_lock)]
546 async fn resolve_path_does_not_auto_update_when_current_root_is_real_project() {
547 let _iso = crate::core::data_dir::isolated_data_dir();
550 let tmp = tempfile::tempdir().unwrap();
551 let root = tmp.path().join("root");
552 let other = tmp.path().join("other");
553 let root_value = create_git_root(&root);
554 create_git_root(&other);
555 std::fs::write(other.join("b.txt"), "no").unwrap();
556
557 let root_str = root.to_string_lossy().to_string();
558 let server = LeanCtxServer::new_with_project_root(Some(&root_str));
559
560 let err = server
561 .resolve_path(&other.join("b.txt").to_string_lossy())
562 .await
563 .unwrap_err();
564 assert!(err.contains("path escapes project root"));
565
566 let session = server.session.read().await;
567 assert_eq!(session.project_root.as_deref(), Some(root_value.as_str()));
568 }
569
570 #[tokio::test]
577 #[allow(clippy::await_holding_lock)]
578 async fn resolve_path_prefers_worktree_copy_after_mid_session_switch() {
579 let _iso = crate::core::data_dir::isolated_data_dir();
580 let tmp = tempfile::tempdir().unwrap();
581 let repo = tmp.path().join("repo");
582 let root_value = create_git_root(&repo);
583 let wt = repo.join(".claude/worktrees/wt");
584 std::fs::create_dir_all(wt.join("src")).unwrap();
585 std::fs::write(wt.join(".git"), "gitdir: ../../../.git/worktrees/wt\n").unwrap();
586 std::fs::create_dir_all(repo.join("src")).unwrap();
587 std::fs::write(repo.join("src/lib.rs"), "stale").unwrap();
588 std::fs::write(wt.join("src/lib.rs"), "fresh").unwrap();
589
590 let server = LeanCtxServer::new_with_project_root(Some(&root_value));
591 {
592 let mut session = server.session.write().await;
594 session.shell_cwd = Some(wt.to_string_lossy().to_string());
595 }
596
597 let out = server.resolve_path("src/lib.rs").await.unwrap();
598 assert_eq!(
599 std::fs::read_to_string(&out).unwrap(),
600 "fresh",
601 "worktree copy must win over the stale project_root copy: {out}"
602 );
603
604 {
611 let mut session = server.session.write().await;
612 session.shell_cwd = Some(root_value.clone());
613 }
614 std::fs::write(repo.join("src/back_707.rs"), "stale").unwrap();
615 std::fs::write(wt.join("src/back_707.rs"), "fresh").unwrap();
616 let out = server.resolve_path("src/back_707.rs").await.unwrap();
617 assert_eq!(std::fs::read_to_string(&out).unwrap(), "stale");
618 }
619}