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(crate) mod walk_guard;
82
83mod server;
84mod server_lifecycle;
85mod server_metrics;
86mod server_paths;
87pub(crate) mod startup;
88
89pub use server::*;
90pub use startup::create_server;
91
92#[cfg(test)]
93mod resolve_path_tests {
94 use super::startup::canonicalize_path;
95 use super::*;
96
97 fn create_git_root(path: &std::path::Path) -> String {
98 std::fs::create_dir_all(path.join(".git")).unwrap();
99 canonicalize_path(path)
100 }
101
102 #[cfg(not(feature = "no-jail"))]
103 #[tokio::test]
104 #[allow(clippy::await_holding_lock)]
105 async fn resolve_path_can_reroot_to_trusted_startup_root_when_session_root_is_stale() {
106 let _iso = crate::core::data_dir::isolated_data_dir();
113 crate::test_env::set_var("LEAN_CTX_ALLOW_REROOT", "1");
114 let tmp = tempfile::tempdir().unwrap();
115 let stale = tmp.path().join("stale");
116 let real = tmp.path().join("real");
117 std::fs::create_dir_all(&stale).unwrap();
118 let real_root = create_git_root(&real);
119 std::fs::write(real.join("a.txt"), "ok").unwrap();
120
121 let server = LeanCtxServer::new_with_startup(
122 None,
123 Some(real.as_path()),
124 SessionMode::Personal,
125 "default",
126 "default",
127 );
128 {
129 let mut session = server.session.write().await;
130 session.project_root = Some(stale.to_string_lossy().to_string());
131 session.shell_cwd = Some(stale.to_string_lossy().to_string());
132 }
133
134 let out = server
135 .resolve_path(&real.join("a.txt").to_string_lossy())
136 .await
137 .unwrap();
138
139 assert!(out.ends_with("/a.txt"));
140
141 let session = server.session.read().await;
142 assert_eq!(session.project_root.as_deref(), Some(real_root.as_str()));
143 assert_eq!(session.shell_cwd.as_deref(), Some(real_root.as_str()));
144 drop(session);
145 crate::test_env::remove_var("LEAN_CTX_ALLOW_REROOT");
146 }
147
148 #[cfg(not(feature = "no-jail"))]
149 #[tokio::test]
150 #[allow(clippy::await_holding_lock)]
151 async fn resolve_path_rejects_absolute_path_outside_trusted_startup_root() {
152 let _iso = crate::core::data_dir::isolated_data_dir();
155 let tmp = tempfile::tempdir().unwrap();
156 let stale = tmp.path().join("stale");
157 let root = tmp.path().join("root");
158 let other = tmp.path().join("other");
159 std::fs::create_dir_all(&stale).unwrap();
160 create_git_root(&root);
161 let _other_value = create_git_root(&other);
162 std::fs::write(other.join("b.txt"), "no").unwrap();
163
164 let server = LeanCtxServer::new_with_startup(
165 None,
166 Some(root.as_path()),
167 SessionMode::Personal,
168 "default",
169 "default",
170 );
171 {
172 let mut session = server.session.write().await;
173 session.project_root = Some(stale.to_string_lossy().to_string());
174 session.shell_cwd = Some(stale.to_string_lossy().to_string());
175 }
176
177 let err = server
178 .resolve_path(&other.join("b.txt").to_string_lossy())
179 .await
180 .unwrap_err();
181 assert!(err.contains("path escapes project root"));
182
183 let session = server.session.read().await;
184 assert_eq!(
185 session.project_root.as_deref(),
186 Some(stale.to_string_lossy().as_ref())
187 );
188 }
189
190 #[cfg(not(feature = "no-jail"))]
191 #[tokio::test]
192 #[allow(clippy::await_holding_lock)]
193 async fn resolve_path_auto_registers_language_cache_then_retry_succeeds() {
194 let _iso = crate::core::data_dir::isolated_data_dir();
198 let tmp = tempfile::tempdir().unwrap();
199 let root = tmp.path().join("project");
200 create_git_root(&root);
201 let dep = tmp.path().join("go/pkg/mod/example.com/lib@v1");
202 std::fs::create_dir_all(&dep).unwrap();
203 let file = dep.join("lib.go");
204 std::fs::write(&file, "package lib").unwrap();
205
206 let server = LeanCtxServer::new_with_startup(
207 None,
208 Some(root.as_path()),
209 SessionMode::Personal,
210 "default",
211 "default",
212 );
213 {
214 let mut session = server.session.write().await;
215 session.project_root = Some(root.to_string_lossy().to_string());
216 session.shell_cwd = Some(root.to_string_lossy().to_string());
217 }
218
219 let err = server
221 .resolve_path(&file.to_string_lossy())
222 .await
223 .unwrap_err();
224 assert!(
225 err.contains("Auto-detected Go module cache"),
226 "expected auto-detect hint, got: {err}"
227 );
228 assert!(err.contains("Retry"), "hint must ask for a retry: {err}");
229
230 let ok = server
232 .resolve_path(&file.to_string_lossy())
233 .await
234 .unwrap_or_else(|e| panic!("retry must resolve, got: {e}"));
235 assert!(
236 ok.ends_with("/lib.go"),
237 "retry resolves the cache file: {ok}"
238 );
239 }
240
241 #[cfg(not(feature = "no-jail"))]
242 #[tokio::test]
243 #[allow(clippy::await_holding_lock)]
244 async fn resolve_path_auto_reroots_from_agent_config_dir_without_opt_in() {
245 let _iso = crate::core::data_dir::isolated_data_dir();
251 crate::test_env::remove_var("LEAN_CTX_ALLOW_REROOT");
252 let tmp = tempfile::tempdir().unwrap();
253 let agent = tmp.path().join(".copilot");
254 let real = tmp.path().join("repo");
255 std::fs::create_dir_all(&agent).unwrap();
256 let real_root = create_git_root(&real);
257 std::fs::write(real.join("a.txt"), "ok").unwrap();
258
259 let server = LeanCtxServer::new_with_startup(
260 None,
261 None,
262 SessionMode::Personal,
263 "default",
264 "default",
265 );
266 {
267 let mut session = server.session.write().await;
268 session.project_root = Some(agent.to_string_lossy().to_string());
269 session.shell_cwd = Some(agent.to_string_lossy().to_string());
270 }
271
272 let out = server
273 .resolve_path(&real.join("a.txt").to_string_lossy())
274 .await
275 .unwrap();
276 assert!(
277 out.ends_with("/a.txt"),
278 "agent-dir jail must auto-correct: {out}"
279 );
280
281 let session = server.session.read().await;
282 assert_eq!(session.project_root.as_deref(), Some(real_root.as_str()));
283 }
284
285 #[cfg(not(feature = "no-jail"))]
286 #[tokio::test]
287 #[allow(clippy::await_holding_lock)]
288 async fn resolve_path_auto_reroots_from_markerless_client_cwd_without_opt_in() {
289 let _iso = crate::core::data_dir::isolated_data_dir();
293 crate::test_env::remove_var("LEAN_CTX_ALLOW_REROOT");
294 let tmp = tempfile::tempdir().unwrap();
295 let client_cwd = tmp.path().join("Users").join("user");
296 let real = tmp.path().join("workspaces").join("lean-ctx");
297 std::fs::create_dir_all(&client_cwd).unwrap();
298 let real_root = create_git_root(&real);
299 std::fs::write(real.join("rust.rs"), "ok").unwrap();
300
301 let server = LeanCtxServer::new_with_startup(
302 None,
303 None,
304 SessionMode::Personal,
305 "default",
306 "default",
307 );
308 {
309 let mut session = server.session.write().await;
310 session.project_root = Some(client_cwd.to_string_lossy().to_string());
311 session.shell_cwd = Some(client_cwd.to_string_lossy().to_string());
312 }
313
314 let out = server
315 .resolve_path(&real.join("rust.rs").to_string_lossy())
316 .await
317 .unwrap();
318 assert!(
319 out.ends_with("/rust.rs"),
320 "markerless client cwd must auto-correct: {out}"
321 );
322
323 let session = server.session.read().await;
324 assert_eq!(session.project_root.as_deref(), Some(real_root.as_str()));
325 }
326
327 #[cfg(not(feature = "no-jail"))]
328 #[tokio::test]
329 #[allow(clippy::await_holding_lock)]
330 async fn resolve_path_markerless_root_still_blocks_markerless_escape() {
331 let _iso = crate::core::data_dir::isolated_data_dir();
335 crate::test_env::remove_var("LEAN_CTX_ALLOW_REROOT");
336 let tmp = tempfile::tempdir().unwrap();
337 let client_cwd = tmp.path().join("Users").join("user");
338 let loose = tmp.path().join("workspaces").join("loose");
339 std::fs::create_dir_all(&client_cwd).unwrap();
340 std::fs::create_dir_all(&loose).unwrap();
341 std::fs::write(loose.join("data.txt"), "no").unwrap();
342
343 let server = LeanCtxServer::new_with_startup(
344 None,
345 None,
346 SessionMode::Personal,
347 "default",
348 "default",
349 );
350 {
351 let mut session = server.session.write().await;
352 session.project_root = Some(client_cwd.to_string_lossy().to_string());
353 session.shell_cwd = Some(client_cwd.to_string_lossy().to_string());
354 }
355
356 let err = server
357 .resolve_path(&loose.join("data.txt").to_string_lossy())
358 .await
359 .unwrap_err();
360 assert!(err.contains("path escapes project root"), "got: {err}");
361
362 let session = server.session.read().await;
363 assert_eq!(
364 session.project_root.as_deref(),
365 Some(client_cwd.to_string_lossy().as_ref())
366 );
367 }
368
369 #[cfg(not(feature = "no-jail"))]
370 #[tokio::test]
371 #[allow(clippy::await_holding_lock)]
372 async fn resolve_path_agent_dir_still_blocks_markerless_escape() {
373 let _iso = crate::core::data_dir::isolated_data_dir();
377 crate::test_env::remove_var("LEAN_CTX_ALLOW_REROOT");
378 let tmp = tempfile::tempdir().unwrap();
379 let agent = tmp.path().join(".copilot");
380 let loose = tmp.path().join("loose");
381 std::fs::create_dir_all(&agent).unwrap();
382 std::fs::create_dir_all(&loose).unwrap();
383 std::fs::write(loose.join("data.txt"), "no").unwrap();
384
385 let server = LeanCtxServer::new_with_startup(
386 None,
387 None,
388 SessionMode::Personal,
389 "default",
390 "default",
391 );
392 {
393 let mut session = server.session.write().await;
394 session.project_root = Some(agent.to_string_lossy().to_string());
395 session.shell_cwd = Some(agent.to_string_lossy().to_string());
396 }
397
398 let err = server
399 .resolve_path(&loose.join("data.txt").to_string_lossy())
400 .await
401 .unwrap_err();
402 assert!(err.contains("path escapes project root"), "got: {err}");
403
404 let session = server.session.read().await;
405 assert_eq!(
406 session.project_root.as_deref(),
407 Some(agent.to_string_lossy().as_ref())
408 );
409 }
410
411 #[tokio::test]
412 #[allow(clippy::await_holding_lock)]
413 async fn startup_prefers_workspace_scoped_session_over_global_latest() {
414 let _lock = crate::core::data_dir::test_env_lock();
415 let _data = tempfile::tempdir().unwrap();
416 let _tmp = tempfile::tempdir().unwrap();
417
418 crate::test_env::set_var("LEAN_CTX_DATA_DIR", _data.path());
419
420 let repo_a = _tmp.path().join("repo-a");
421 let repo_b = _tmp.path().join("repo-b");
422 let root_a = create_git_root(&repo_a);
423 let root_b = create_git_root(&repo_b);
424
425 let mut session_b = crate::core::session::SessionState::new();
426 session_b.project_root = Some(root_b.clone());
427 session_b.shell_cwd = Some(root_b.clone());
428 session_b.set_task("repo-b task", None);
429 session_b.save().unwrap();
430
431 std::thread::sleep(std::time::Duration::from_millis(50));
432
433 let mut session_a = crate::core::session::SessionState::new();
434 session_a.project_root = Some(root_a.clone());
435 session_a.shell_cwd = Some(root_a.clone());
436 session_a.set_task("repo-a latest task", None);
437 session_a.save().unwrap();
438
439 let server = LeanCtxServer::new_with_startup(
440 None,
441 Some(repo_b.as_path()),
442 SessionMode::Personal,
443 "default",
444 "default",
445 );
446 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
447
448 let session = server.session.read().await;
449 assert_eq!(session.project_root.as_deref(), Some(root_b.as_str()));
450 assert_eq!(session.shell_cwd.as_deref(), Some(root_b.as_str()));
451 assert_eq!(
452 session.task.as_ref().map(|t| t.description.as_str()),
453 Some("repo-b task")
454 );
455 }
456
457 #[tokio::test]
458 #[allow(clippy::await_holding_lock)]
459 async fn startup_creates_fresh_session_for_new_workspace_and_preserves_subdir_cwd() {
460 let _lock = crate::core::data_dir::test_env_lock();
461 let _data = tempfile::tempdir().unwrap();
462 let _tmp = tempfile::tempdir().unwrap();
463
464 crate::test_env::set_var("LEAN_CTX_DATA_DIR", _data.path());
465
466 let repo_a = _tmp.path().join("repo-a");
467 let repo_b = _tmp.path().join("repo-b");
468 let repo_b_src = repo_b.join("src");
469 let root_a = create_git_root(&repo_a);
470 let root_b = create_git_root(&repo_b);
471 std::fs::create_dir_all(&repo_b_src).unwrap();
472 let repo_b_src_value = canonicalize_path(&repo_b_src);
473
474 let mut session_a = crate::core::session::SessionState::new();
475 session_a.project_root = Some(root_a.clone());
476 session_a.shell_cwd = Some(root_a.clone());
477 session_a.set_task("repo-a latest task", None);
478 let old_id = session_a.id.clone();
479 session_a.save().unwrap();
480
481 let server = LeanCtxServer::new_with_startup(
482 None,
483 Some(repo_b_src.as_path()),
484 SessionMode::Personal,
485 "default",
486 "default",
487 );
488 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
489
490 let session = server.session.read().await;
491 assert_eq!(session.project_root.as_deref(), Some(root_b.as_str()));
492 assert_eq!(
493 session.shell_cwd.as_deref(),
494 Some(repo_b_src_value.as_str())
495 );
496 assert!(session.task.is_none());
497 assert_ne!(session.id, old_id);
498 }
499
500 #[cfg(not(feature = "no-jail"))]
501 #[tokio::test]
502 #[allow(clippy::await_holding_lock)]
503 async fn resolve_path_does_not_auto_update_when_current_root_is_real_project() {
504 let _iso = crate::core::data_dir::isolated_data_dir();
507 let tmp = tempfile::tempdir().unwrap();
508 let root = tmp.path().join("root");
509 let other = tmp.path().join("other");
510 let root_value = create_git_root(&root);
511 create_git_root(&other);
512 std::fs::write(other.join("b.txt"), "no").unwrap();
513
514 let root_str = root.to_string_lossy().to_string();
515 let server = LeanCtxServer::new_with_project_root(Some(&root_str));
516
517 let err = server
518 .resolve_path(&other.join("b.txt").to_string_lossy())
519 .await
520 .unwrap_err();
521 assert!(err.contains("path escapes project root"));
522
523 let session = server.session.read().await;
524 assert_eq!(session.project_root.as_deref(), Some(root_value.as_str()));
525 }
526
527 #[tokio::test]
534 #[allow(clippy::await_holding_lock)]
535 async fn resolve_path_prefers_worktree_copy_after_mid_session_switch() {
536 let _iso = crate::core::data_dir::isolated_data_dir();
537 let tmp = tempfile::tempdir().unwrap();
538 let repo = tmp.path().join("repo");
539 let root_value = create_git_root(&repo);
540 let wt = repo.join(".claude/worktrees/wt");
541 std::fs::create_dir_all(wt.join("src")).unwrap();
542 std::fs::write(wt.join(".git"), "gitdir: ../../../.git/worktrees/wt\n").unwrap();
543 std::fs::create_dir_all(repo.join("src")).unwrap();
544 std::fs::write(repo.join("src/lib.rs"), "stale").unwrap();
545 std::fs::write(wt.join("src/lib.rs"), "fresh").unwrap();
546
547 let server = LeanCtxServer::new_with_project_root(Some(&root_value));
548 {
549 let mut session = server.session.write().await;
551 session.shell_cwd = Some(wt.to_string_lossy().to_string());
552 }
553
554 let out = server.resolve_path("src/lib.rs").await.unwrap();
555 assert_eq!(
556 std::fs::read_to_string(&out).unwrap(),
557 "fresh",
558 "worktree copy must win over the stale project_root copy: {out}"
559 );
560
561 {
568 let mut session = server.session.write().await;
569 session.shell_cwd = Some(root_value.clone());
570 }
571 std::fs::write(repo.join("src/back_707.rs"), "stale").unwrap();
572 std::fs::write(wt.join("src/back_707.rs"), "fresh").unwrap();
573 let out = server.resolve_path("src/back_707.rs").await.unwrap();
574 assert_eq!(std::fs::read_to_string(&out).unwrap(), "stale");
575 }
576}