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_reroots_from_agent_config_dir_without_opt_in() {
194 let _iso = crate::core::data_dir::isolated_data_dir();
200 crate::test_env::remove_var("LEAN_CTX_ALLOW_REROOT");
201 let tmp = tempfile::tempdir().unwrap();
202 let agent = tmp.path().join(".copilot");
203 let real = tmp.path().join("repo");
204 std::fs::create_dir_all(&agent).unwrap();
205 let real_root = create_git_root(&real);
206 std::fs::write(real.join("a.txt"), "ok").unwrap();
207
208 let server = LeanCtxServer::new_with_startup(
209 None,
210 None,
211 SessionMode::Personal,
212 "default",
213 "default",
214 );
215 {
216 let mut session = server.session.write().await;
217 session.project_root = Some(agent.to_string_lossy().to_string());
218 session.shell_cwd = Some(agent.to_string_lossy().to_string());
219 }
220
221 let out = server
222 .resolve_path(&real.join("a.txt").to_string_lossy())
223 .await
224 .unwrap();
225 assert!(
226 out.ends_with("/a.txt"),
227 "agent-dir jail must auto-correct: {out}"
228 );
229
230 let session = server.session.read().await;
231 assert_eq!(session.project_root.as_deref(), Some(real_root.as_str()));
232 }
233
234 #[cfg(not(feature = "no-jail"))]
235 #[tokio::test]
236 #[allow(clippy::await_holding_lock)]
237 async fn resolve_path_auto_reroots_from_markerless_client_cwd_without_opt_in() {
238 let _iso = crate::core::data_dir::isolated_data_dir();
242 crate::test_env::remove_var("LEAN_CTX_ALLOW_REROOT");
243 let tmp = tempfile::tempdir().unwrap();
244 let client_cwd = tmp.path().join("Users").join("user");
245 let real = tmp.path().join("workspaces").join("lean-ctx");
246 std::fs::create_dir_all(&client_cwd).unwrap();
247 let real_root = create_git_root(&real);
248 std::fs::write(real.join("rust.rs"), "ok").unwrap();
249
250 let server = LeanCtxServer::new_with_startup(
251 None,
252 None,
253 SessionMode::Personal,
254 "default",
255 "default",
256 );
257 {
258 let mut session = server.session.write().await;
259 session.project_root = Some(client_cwd.to_string_lossy().to_string());
260 session.shell_cwd = Some(client_cwd.to_string_lossy().to_string());
261 }
262
263 let out = server
264 .resolve_path(&real.join("rust.rs").to_string_lossy())
265 .await
266 .unwrap();
267 assert!(
268 out.ends_with("/rust.rs"),
269 "markerless client cwd must auto-correct: {out}"
270 );
271
272 let session = server.session.read().await;
273 assert_eq!(session.project_root.as_deref(), Some(real_root.as_str()));
274 }
275
276 #[cfg(not(feature = "no-jail"))]
277 #[tokio::test]
278 #[allow(clippy::await_holding_lock)]
279 async fn resolve_path_markerless_root_still_blocks_markerless_escape() {
280 let _iso = crate::core::data_dir::isolated_data_dir();
284 crate::test_env::remove_var("LEAN_CTX_ALLOW_REROOT");
285 let tmp = tempfile::tempdir().unwrap();
286 let client_cwd = tmp.path().join("Users").join("user");
287 let loose = tmp.path().join("workspaces").join("loose");
288 std::fs::create_dir_all(&client_cwd).unwrap();
289 std::fs::create_dir_all(&loose).unwrap();
290 std::fs::write(loose.join("data.txt"), "no").unwrap();
291
292 let server = LeanCtxServer::new_with_startup(
293 None,
294 None,
295 SessionMode::Personal,
296 "default",
297 "default",
298 );
299 {
300 let mut session = server.session.write().await;
301 session.project_root = Some(client_cwd.to_string_lossy().to_string());
302 session.shell_cwd = Some(client_cwd.to_string_lossy().to_string());
303 }
304
305 let err = server
306 .resolve_path(&loose.join("data.txt").to_string_lossy())
307 .await
308 .unwrap_err();
309 assert!(err.contains("path escapes project root"), "got: {err}");
310
311 let session = server.session.read().await;
312 assert_eq!(
313 session.project_root.as_deref(),
314 Some(client_cwd.to_string_lossy().as_ref())
315 );
316 }
317
318 #[cfg(not(feature = "no-jail"))]
319 #[tokio::test]
320 #[allow(clippy::await_holding_lock)]
321 async fn resolve_path_agent_dir_still_blocks_markerless_escape() {
322 let _iso = crate::core::data_dir::isolated_data_dir();
326 crate::test_env::remove_var("LEAN_CTX_ALLOW_REROOT");
327 let tmp = tempfile::tempdir().unwrap();
328 let agent = tmp.path().join(".copilot");
329 let loose = tmp.path().join("loose");
330 std::fs::create_dir_all(&agent).unwrap();
331 std::fs::create_dir_all(&loose).unwrap();
332 std::fs::write(loose.join("data.txt"), "no").unwrap();
333
334 let server = LeanCtxServer::new_with_startup(
335 None,
336 None,
337 SessionMode::Personal,
338 "default",
339 "default",
340 );
341 {
342 let mut session = server.session.write().await;
343 session.project_root = Some(agent.to_string_lossy().to_string());
344 session.shell_cwd = Some(agent.to_string_lossy().to_string());
345 }
346
347 let err = server
348 .resolve_path(&loose.join("data.txt").to_string_lossy())
349 .await
350 .unwrap_err();
351 assert!(err.contains("path escapes project root"), "got: {err}");
352
353 let session = server.session.read().await;
354 assert_eq!(
355 session.project_root.as_deref(),
356 Some(agent.to_string_lossy().as_ref())
357 );
358 }
359
360 #[tokio::test]
361 #[allow(clippy::await_holding_lock)]
362 async fn startup_prefers_workspace_scoped_session_over_global_latest() {
363 let _lock = crate::core::data_dir::test_env_lock();
364 let _data = tempfile::tempdir().unwrap();
365 let _tmp = tempfile::tempdir().unwrap();
366
367 crate::test_env::set_var("LEAN_CTX_DATA_DIR", _data.path());
368
369 let repo_a = _tmp.path().join("repo-a");
370 let repo_b = _tmp.path().join("repo-b");
371 let root_a = create_git_root(&repo_a);
372 let root_b = create_git_root(&repo_b);
373
374 let mut session_b = crate::core::session::SessionState::new();
375 session_b.project_root = Some(root_b.clone());
376 session_b.shell_cwd = Some(root_b.clone());
377 session_b.set_task("repo-b task", None);
378 session_b.save().unwrap();
379
380 std::thread::sleep(std::time::Duration::from_millis(50));
381
382 let mut session_a = crate::core::session::SessionState::new();
383 session_a.project_root = Some(root_a.clone());
384 session_a.shell_cwd = Some(root_a.clone());
385 session_a.set_task("repo-a latest task", None);
386 session_a.save().unwrap();
387
388 let server = LeanCtxServer::new_with_startup(
389 None,
390 Some(repo_b.as_path()),
391 SessionMode::Personal,
392 "default",
393 "default",
394 );
395 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
396
397 let session = server.session.read().await;
398 assert_eq!(session.project_root.as_deref(), Some(root_b.as_str()));
399 assert_eq!(session.shell_cwd.as_deref(), Some(root_b.as_str()));
400 assert_eq!(
401 session.task.as_ref().map(|t| t.description.as_str()),
402 Some("repo-b task")
403 );
404 }
405
406 #[tokio::test]
407 #[allow(clippy::await_holding_lock)]
408 async fn startup_creates_fresh_session_for_new_workspace_and_preserves_subdir_cwd() {
409 let _lock = crate::core::data_dir::test_env_lock();
410 let _data = tempfile::tempdir().unwrap();
411 let _tmp = tempfile::tempdir().unwrap();
412
413 crate::test_env::set_var("LEAN_CTX_DATA_DIR", _data.path());
414
415 let repo_a = _tmp.path().join("repo-a");
416 let repo_b = _tmp.path().join("repo-b");
417 let repo_b_src = repo_b.join("src");
418 let root_a = create_git_root(&repo_a);
419 let root_b = create_git_root(&repo_b);
420 std::fs::create_dir_all(&repo_b_src).unwrap();
421 let repo_b_src_value = canonicalize_path(&repo_b_src);
422
423 let mut session_a = crate::core::session::SessionState::new();
424 session_a.project_root = Some(root_a.clone());
425 session_a.shell_cwd = Some(root_a.clone());
426 session_a.set_task("repo-a latest task", None);
427 let old_id = session_a.id.clone();
428 session_a.save().unwrap();
429
430 let server = LeanCtxServer::new_with_startup(
431 None,
432 Some(repo_b_src.as_path()),
433 SessionMode::Personal,
434 "default",
435 "default",
436 );
437 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
438
439 let session = server.session.read().await;
440 assert_eq!(session.project_root.as_deref(), Some(root_b.as_str()));
441 assert_eq!(
442 session.shell_cwd.as_deref(),
443 Some(repo_b_src_value.as_str())
444 );
445 assert!(session.task.is_none());
446 assert_ne!(session.id, old_id);
447 }
448
449 #[cfg(not(feature = "no-jail"))]
450 #[tokio::test]
451 #[allow(clippy::await_holding_lock)]
452 async fn resolve_path_does_not_auto_update_when_current_root_is_real_project() {
453 let _iso = crate::core::data_dir::isolated_data_dir();
456 let tmp = tempfile::tempdir().unwrap();
457 let root = tmp.path().join("root");
458 let other = tmp.path().join("other");
459 let root_value = create_git_root(&root);
460 create_git_root(&other);
461 std::fs::write(other.join("b.txt"), "no").unwrap();
462
463 let root_str = root.to_string_lossy().to_string();
464 let server = LeanCtxServer::new_with_project_root(Some(&root_str));
465
466 let err = server
467 .resolve_path(&other.join("b.txt").to_string_lossy())
468 .await
469 .unwrap_err();
470 assert!(err.contains("path escapes project root"));
471
472 let session = server.session.read().await;
473 assert_eq!(session.project_root.as_deref(), Some(root_value.as_str()));
474 }
475
476 #[tokio::test]
483 #[allow(clippy::await_holding_lock)]
484 async fn resolve_path_prefers_worktree_copy_after_mid_session_switch() {
485 let _iso = crate::core::data_dir::isolated_data_dir();
486 let tmp = tempfile::tempdir().unwrap();
487 let repo = tmp.path().join("repo");
488 let root_value = create_git_root(&repo);
489 let wt = repo.join(".claude/worktrees/wt");
490 std::fs::create_dir_all(wt.join("src")).unwrap();
491 std::fs::write(wt.join(".git"), "gitdir: ../../../.git/worktrees/wt\n").unwrap();
492 std::fs::create_dir_all(repo.join("src")).unwrap();
493 std::fs::write(repo.join("src/lib.rs"), "stale").unwrap();
494 std::fs::write(wt.join("src/lib.rs"), "fresh").unwrap();
495
496 let server = LeanCtxServer::new_with_project_root(Some(&root_value));
497 {
498 let mut session = server.session.write().await;
500 session.shell_cwd = Some(wt.to_string_lossy().to_string());
501 }
502
503 let out = server.resolve_path("src/lib.rs").await.unwrap();
504 assert_eq!(
505 std::fs::read_to_string(&out).unwrap(),
506 "fresh",
507 "worktree copy must win over the stale project_root copy: {out}"
508 );
509
510 {
517 let mut session = server.session.write().await;
518 session.shell_cwd = Some(root_value.clone());
519 }
520 std::fs::write(repo.join("src/back_707.rs"), "stale").unwrap();
521 std::fs::write(wt.join("src/back_707.rs"), "fresh").unwrap();
522 let out = server.resolve_path("src/back_707.rs").await.unwrap();
523 assert_eq!(std::fs::read_to_string(&out).unwrap(), "stale");
524 }
525}