From 95d194ab1aa3d9b7af5adc4f82970386cd0f16c5 Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Thu, 20 Aug 2026 17:32:51 +0200 Subject: [PATCH 1/5] fix(daemon): reject projectless watch roots --- src/daemon/git_watch.rs | 6 ++- src/daemon/git_watch/tests.rs | 74 +++++++++++++++++++++++++++++++++++ src/serve.rs | 41 ++++++++++++++----- 3 files changed, 110 insertions(+), 11 deletions(-) diff --git a/src/daemon/git_watch.rs b/src/daemon/git_watch.rs index 77870c7e6f..87cdf07700 100644 --- a/src/daemon/git_watch.rs +++ b/src/daemon/git_watch.rs @@ -312,10 +312,14 @@ impl GitWatcher { None => crate::global_db::GlobalDb::open().await, }; if let Some(db) = db { + let open_options = daemon_open_options(&self.inner); let projects = db.code_projects_seen_within(window, cap).await; for record in projects { let root = PathBuf::from(&record.canonical_root); - if root.is_dir() { + if root.is_dir() + && root.join(".git").exists() + && TraceDecay::has_initialized_store_with_options(&root, &open_options).await + { self.ensure_watching(&root).await; } } diff --git a/src/daemon/git_watch/tests.rs b/src/daemon/git_watch/tests.rs index 217180e810..3283d554a8 100644 --- a/src/daemon/git_watch/tests.rs +++ b/src/daemon/git_watch/tests.rs @@ -280,6 +280,80 @@ async fn disabled_watcher_never_registers() { assert!(watcher.health_report().await.is_empty()); } +#[tokio::test] +async fn spawn_skips_recent_registry_rows_without_an_initialized_store() { + let _profile = crate::config::PinnedUserDataDir::new(); + let profile_root = crate::storage::default_profile_root().unwrap(); + let global_db_path = profile_root.join("global.db"); + let global_db = crate::global_db::GlobalDb::open_at(&global_db_path) + .await + .expect("open isolated global registry"); + + let valid = temp_repo(); + crate::storage::write_enrollment_marker( + valid.path(), + &crate::storage::EnrollmentMarker { + project_id: "proj_valid_watch".to_string(), + storage_mode: crate::storage::StorageMode::ProfileSharded, + }, + ) + .expect("write valid enrollment marker"); + let layout = crate::storage::resolve_layout_for_current_profile(valid.path()) + .expect("resolve valid project layout"); + std::fs::create_dir_all(layout.graph_db_path.parent().unwrap()) + .expect("create valid graph directory"); + std::fs::write(&layout.graph_db_path, b"").expect("create valid graph marker"); + global_db + .upsert_code_project("proj_valid_watch", valid.path(), None, None, Some("main")) + .await + .expect("register valid project"); + + let invalid = tempfile::tempdir().unwrap(); + crate::storage::write_enrollment_marker( + invalid.path(), + &crate::storage::EnrollmentMarker { + project_id: "proj_invalid_watch".to_string(), + storage_mode: crate::storage::StorageMode::ProfileSharded, + }, + ) + .expect("write stale enrollment marker"); + let invalid_layout = crate::storage::resolve_layout_for_current_profile(invalid.path()) + .expect("resolve stale project layout"); + std::fs::create_dir_all(invalid_layout.graph_db_path.parent().unwrap()) + .expect("create stale graph directory"); + std::fs::write(&invalid_layout.graph_db_path, b"").expect("create stale graph marker"); + global_db + .upsert_code_project( + "proj_invalid_watch", + invalid.path(), + None, + None, + Some("main"), + ) + .await + .expect("register stale directory-only project"); + + let watcher = GitWatcher::new(fast_watch_config()); + watcher.spawn(Some(global_db_path)).await; + + let watched = watcher + .health_report() + .await + .into_iter() + .map(|(path, _)| path) + .collect::>(); + assert!( + watched.contains(&valid.path().canonicalize().unwrap()), + "an initialized registered project must still be watched" + ); + assert!( + !watched.contains(&invalid.path().canonicalize().unwrap()), + "a stale registry row for an existing non-project directory must not start a watcher" + ); + + watcher.shutdown().await; +} + #[tokio::test] async fn shutdown_cancels_and_joins_watcher_tasks() { let repo = temp_repo(); diff --git a/src/serve.rs b/src/serve.rs index 6f68746087..c6dcb69e7a 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -184,23 +184,31 @@ fn proxy_serve_handshake( let path = sanitize_serve_path_arg(path_arg); let explicit_path = path.is_some(); let mut project_path = if explicit_path { - crate::config::resolve_path(path) + Some(crate::config::resolve_path(path)) } else { - crate::config::resolve_path_with_discovery(None) + original_cwd.and_then(crate::config::discover_project_root) }; - let initialized = TraceDecay::is_initialized(&project_path); - let auto_init_root = (!initialized && crate::config::load_sync_config(&project_path).auto_init) - .then(|| crate::worktree::git_worktree_root(&project_path)) - .flatten(); + let initialized = project_path + .as_deref() + .is_some_and(TraceDecay::is_initialized); + let auto_init_candidate = project_path.as_deref().or(original_cwd); + let auto_init_root = auto_init_candidate + .filter(|candidate| !initialized && crate::config::load_sync_config(candidate).auto_init) + .and_then(crate::worktree::git_worktree_root); if let Some(root) = auto_init_root.as_ref() { - project_path.clone_from(root); + project_path = Some(root.clone()); } - let scope_prefix = serve_scope_prefix(original_cwd, &project_path); - let telemetry_timings = timings || crate::config::load_telemetry_config(&project_path).timings; + let scope_prefix = project_path + .as_deref() + .and_then(|project_path| serve_scope_prefix(original_cwd, project_path)); + let telemetry_timings = timings + || project_path + .as_deref() + .is_some_and(|path| crate::config::load_telemetry_config(path).timings); let mut handshake = crate::daemon::DaemonHandshake::for_current_client( - Some(project_path), + project_path, scope_prefix, telemetry_timings, auto_init_root.is_some(), @@ -234,6 +242,19 @@ pub const DEGRADED_SERVE_STDERR_MARKER: &str = mod tests { use super::*; + #[test] + fn projectless_cwd_does_not_become_a_daemon_project() { + let _profile = crate::config::PinnedUserDataDir::new(); + let cwd = tempfile::tempdir().unwrap(); + + let handshake = proxy_serve_handshake(None, Some(cwd.path()), false) + .expect("build projectless proxy handshake"); + + assert_eq!(handshake.project_path, None); + assert!(!handshake.allow_init); + assert!(handshake.allow_initialize_root_routing); + } + #[tokio::test] async fn direct_project_open_fails_closed() { let path = Path::new("/tmp/tracedecay-direct-open-must-not-run"); From a416a23c7bd412e6d016aa856edc2c2cc41d0ebb Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Sat, 22 Aug 2026 13:58:56 +0200 Subject: [PATCH 2/5] fix(daemon): reject protected auto roots --- src/config.rs | 14 +++++++++++ src/daemon.rs | 19 ++++++++++++--- src/daemon/git_watch.rs | 1 + src/daemon/git_watch/tests.rs | 24 ++++++++++++++++++ src/daemon/tests.rs | 46 +++++++++++++++++++++++++++++++++++ src/serve.rs | 22 ++++++++++++++++- 6 files changed, 121 insertions(+), 5 deletions(-) diff --git a/src/config.rs b/src/config.rs index 6cc9b25476..e1af1b914e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -39,6 +39,20 @@ pub async fn discover_project_root_with_identity( .then_some(candidate) } +/// Returns true for broad filesystem locations that must never be admitted by +/// automatic project discovery. Explicit user commands may still inspect or +/// manage these paths, but background MCP routing and Git watchers must not +/// turn them into code projects. +pub(crate) fn is_protected_auto_project_root(path: &std::path::Path) -> bool { + let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + if path.parent().is_none() { + return true; + } + dirs::home_dir() + .map(|home| home.canonicalize().unwrap_or(home)) + .is_some_and(|home| path == home) +} + #[cfg(test)] pub struct PinnedUserDataDir { _lock: std::sync::MutexGuard<'static, ()>, diff --git a/src/daemon.rs b/src/daemon.rs index ac7f2b74f9..d8cf4a53f5 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1189,6 +1189,9 @@ async fn resolve_daemon_initialize_route( for root in &roots { let mut candidate = root.canonicalize().unwrap_or_else(|_| root.clone()); loop { + if crate::config::is_protected_auto_project_root(&candidate) { + break; + } if registry .project_registry_context_by_alias(&candidate) .await @@ -1204,6 +1207,9 @@ async fn resolve_daemon_initialize_route( } } if let Some(identity) = crate::worktree::git_repo_identity(root) { + if crate::config::is_protected_auto_project_root(&identity.worktree_root) { + continue; + } if registry .project_registry_context_by_identity( &identity.worktree_root, @@ -1231,12 +1237,17 @@ async fn resolve_daemon_initialize_route( for root in roots { if let Some(project_path) = crate::config::discover_project_root(&root) { - return Some(InitializeRouteMetadata { - project_path, - allow_init: false, - }); + if !crate::config::is_protected_auto_project_root(&project_path) { + return Some(InitializeRouteMetadata { + project_path, + allow_init: false, + }); + } } if let Some(identity) = crate::worktree::git_repo_identity(&root) { + if crate::config::is_protected_auto_project_root(&identity.worktree_root) { + continue; + } let allow_init = crate::config::load_sync_config(&identity.worktree_root).auto_init; return Some(InitializeRouteMetadata { project_path: identity.worktree_root, diff --git a/src/daemon/git_watch.rs b/src/daemon/git_watch.rs index 87cdf07700..954d223ca1 100644 --- a/src/daemon/git_watch.rs +++ b/src/daemon/git_watch.rs @@ -317,6 +317,7 @@ impl GitWatcher { for record in projects { let root = PathBuf::from(&record.canonical_root); if root.is_dir() + && !crate::config::is_protected_auto_project_root(&root) && root.join(".git").exists() && TraceDecay::has_initialized_store_with_options(&root, &open_options).await { diff --git a/src/daemon/git_watch/tests.rs b/src/daemon/git_watch/tests.rs index 3283d554a8..163a4cc23b 100644 --- a/src/daemon/git_watch/tests.rs +++ b/src/daemon/git_watch/tests.rs @@ -333,6 +333,26 @@ async fn spawn_skips_recent_registry_rows_without_an_initialized_store() { .await .expect("register stale directory-only project"); + let home = dirs::home_dir().expect("test home"); + git(&home, &["init", "-q"]); + crate::storage::write_enrollment_marker( + &home, + &crate::storage::EnrollmentMarker { + project_id: "proj_protected_home_watch".to_string(), + storage_mode: crate::storage::StorageMode::ProfileSharded, + }, + ) + .expect("write protected-home enrollment marker"); + let home_layout = crate::storage::resolve_layout_for_current_profile(&home) + .expect("resolve protected-home project layout"); + std::fs::create_dir_all(home_layout.graph_db_path.parent().unwrap()) + .expect("create protected-home graph directory"); + std::fs::write(&home_layout.graph_db_path, b"").expect("create protected-home graph marker"); + global_db + .upsert_code_project("proj_protected_home_watch", &home, None, None, Some("main")) + .await + .expect("register protected-home project"); + let watcher = GitWatcher::new(fast_watch_config()); watcher.spawn(Some(global_db_path)).await; @@ -350,6 +370,10 @@ async fn spawn_skips_recent_registry_rows_without_an_initialized_store() { !watched.contains(&invalid.path().canonicalize().unwrap()), "a stale registry row for an existing non-project directory must not start a watcher" ); + assert!( + !watched.contains(&home.canonicalize().unwrap()), + "the user home must never start a watcher even when stale metadata looks initialized" + ); watcher.shutdown().await; } diff --git a/src/daemon/tests.rs b/src/daemon/tests.rs index d2223e1cf6..cd38f9be9f 100644 --- a/src/daemon/tests.rs +++ b/src/daemon/tests.rs @@ -1940,6 +1940,52 @@ async fn initialize_root_routing_delegates_config_gated_git_auto_init() { assert!(!routed_handshake.allow_init); } +#[cfg(unix)] +#[tokio::test] +async fn initialize_root_routing_rejects_user_home_git_auto_init() { + let _profile = crate::config::PinnedUserDataDir::new(); + let home = dirs::home_dir() + .expect("test home") + .canonicalize() + .expect("canonical test home"); + let git_status = std::process::Command::new(crate::git::git_program()) + .args(["init", "-q"]) + .current_dir(&home) + .status() + .expect("git init test home"); + assert!(git_status.success(), "git init should succeed"); + + let fallback = TempDir::new().expect("fallback temp dir"); + let mut handshake = test_handshake_defaults(); + handshake.project_path = Some(fallback.path().to_path_buf()); + handshake.allow_initialize_root_routing = true; + handshake.client_identity = test_client_identity_for(home.clone()); + let store_administration = super::StoreAdministration::default(); + let line = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "roots": [{ + "uri": format!("file://{}", home.display()), + "name": "user-home" + }] + } + }) + .to_string(); + + let route = super::apply_daemon_initialize_route(&mut handshake, &line, &store_administration) + .await + .expect("daemon initialize routing should remain healthy"); + + assert!( + route.is_none(), + "user home must not become an auto-init route" + ); + assert_eq!(handshake.project_path.as_deref(), Some(fallback.path())); + assert!(!handshake.allow_init); +} + #[cfg(unix)] #[tokio::test] async fn serve_proxies_when_socket_already_exists() { diff --git a/src/serve.rs b/src/serve.rs index c6dcb69e7a..7564f58fc8 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -195,7 +195,8 @@ fn proxy_serve_handshake( let auto_init_candidate = project_path.as_deref().or(original_cwd); let auto_init_root = auto_init_candidate .filter(|candidate| !initialized && crate::config::load_sync_config(candidate).auto_init) - .and_then(crate::worktree::git_worktree_root); + .and_then(crate::worktree::git_worktree_root) + .filter(|root| !crate::config::is_protected_auto_project_root(root)); if let Some(root) = auto_init_root.as_ref() { project_path = Some(root.clone()); } @@ -255,6 +256,25 @@ mod tests { assert!(handshake.allow_initialize_root_routing); } + #[test] + fn user_home_git_cwd_does_not_become_a_daemon_project() { + let _profile = crate::config::PinnedUserDataDir::new(); + let home = dirs::home_dir().expect("test home"); + let status = std::process::Command::new(crate::git::git_program()) + .args(["init", "-q"]) + .current_dir(&home) + .status() + .expect("git init test home"); + assert!(status.success()); + + let handshake = proxy_serve_handshake(None, Some(&home), false) + .expect("build protected-home proxy handshake"); + + assert_eq!(handshake.project_path, None); + assert!(!handshake.allow_init); + assert!(handshake.allow_initialize_root_routing); + } + #[tokio::test] async fn direct_project_open_fails_closed() { let path = Path::new("/tmp/tracedecay-direct-open-must-not-run"); From 113ab9daa57cdd1ae66ecf13d95748866d08a844 Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Sat, 22 Aug 2026 13:59:13 +0200 Subject: [PATCH 3/5] perf(sessions): bound history queries Drive payload reference discovery from FTS candidates and treat null optional time bounds as absent. --- .../tracedecay-sessions/src/runtime/lcm/gc.rs | 58 +++++++++++++++---- src/mcp/tools/handlers/session.rs | 3 + src/mcp/tools/handlers/session/tests.rs | 12 ++++ 3 files changed, 63 insertions(+), 10 deletions(-) diff --git a/crates/tracedecay-sessions/src/runtime/lcm/gc.rs b/crates/tracedecay-sessions/src/runtime/lcm/gc.rs index 8d5ac5403b..2a42129417 100644 --- a/crates/tracedecay-sessions/src/runtime/lcm/gc.rs +++ b/crates/tracedecay-sessions/src/runtime/lcm/gc.rs @@ -3,7 +3,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::time::Instant; -use libsql::{Connection, params}; +use libsql::{Connection, Value as SqlValue, params}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -143,15 +143,8 @@ pub async fn referenced_payload_refs( session_id: Option<&str>, ) -> Result, LcmError> { let mut refs = BTreeSet::new(); - let mut rows = conn - .query( - "SELECT storage_kind, payload_ref, content, snippet_text, index_text, metadata_json - FROM lcm_raw_messages - WHERE (?1 = 'all' OR provider = ?1) - AND (?2 IS NULL OR session_id = ?2)", - params![provider, util::opt_text(session_id)], - ) - .await?; + let (sql, values) = referenced_payload_refs_query(provider, session_id); + let mut rows = conn.query(&sql, values).await?; while let Some(row) = rows.next().await? { let storage_kind: String = row.get(0)?; let payload_ref: Option = row.get(1).unwrap_or(None); @@ -170,6 +163,39 @@ pub async fn referenced_payload_refs( Ok(refs) } +fn referenced_payload_refs_query( + provider: &str, + session_id: Option<&str>, +) -> (String, Vec) { + // Every live externalized placeholder is indexed in index_text. Drive the + // scan from FTS so status/doctor inspect payload-bearing messages instead + // of materializing every text-heavy raw message in a large session store. + let mut values = vec![SqlValue::Text("externalized".to_string())]; + let mut filters = Vec::new(); + if provider != "all" { + filters.push(format!("r.provider = ?{}", values.len() + 1)); + values.push(SqlValue::Text(provider.to_string())); + } + if let Some(session_id) = session_id { + filters.push(format!("r.session_id = ?{}", values.len() + 1)); + values.push(SqlValue::Text(session_id.to_string())); + } + let filter_sql = if filters.is_empty() { + String::new() + } else { + format!(" AND {}", filters.join(" AND ")) + }; + ( + format!( + "SELECT r.storage_kind, r.payload_ref, r.content, r.snippet_text, r.index_text, r.metadata_json + FROM lcm_raw_messages_fts + JOIN lcm_raw_messages r ON r.store_id = lcm_raw_messages_fts.rowid + WHERE lcm_raw_messages_fts MATCH ?{filter_sql}" + ), + values, + ) +} + fn extract_live_payload_refs_from_text(text: &str) -> Vec { let mut refs = Vec::new(); let mut offset = 0usize; @@ -1131,6 +1157,18 @@ mod tests { ); } + #[test] + fn referenced_payload_refs_query_uses_fts_candidates_and_scoped_filters() { + let (sql, values) = referenced_payload_refs_query("codex", Some("session-a")); + + assert!(sql.contains("FROM lcm_raw_messages_fts")); + assert!(sql.contains("lcm_raw_messages_fts MATCH ?")); + assert!(sql.contains("r.provider = ?")); + assert!(sql.contains("r.session_id = ?")); + assert!(!sql.contains("? = 'all' OR")); + assert_eq!(values.len(), 3); + } + #[tokio::test] async fn referenced_payload_refs_ignores_tombstoned_placeholders() -> Result<(), String> { let store = test_store().await?; diff --git a/src/mcp/tools/handlers/session.rs b/src/mcp/tools/handlers/session.rs index cbb0eb8511..dee6a13be1 100644 --- a/src/mcp/tools/handlers/session.rs +++ b/src/mcp/tools/handlers/session.rs @@ -1470,6 +1470,9 @@ fn non_negative_timestamp_arg( let Some(value) = args.get(name) else { return Ok(None); }; + if value.is_null() { + return Ok(None); + } let timestamp = match value { Value::Number(number) => number .as_i64() diff --git a/src/mcp/tools/handlers/session/tests.rs b/src/mcp/tools/handlers/session/tests.rs index 446b4b991e..1affa7a6a6 100644 --- a/src/mcp/tools/handlers/session/tests.rs +++ b/src/mcp/tools/handlers/session/tests.rs @@ -2,6 +2,18 @@ use super::*; use crate::mcp::response_handles::lock_response_handle_store; use crate::sessions::{SessionMessageRecord, SessionRecord}; +#[test] +fn message_search_time_range_ignores_null_optional_bounds() { + let range = message_search_time_range(&serde_json::json!({ + "since": "2026-08-20", + "until": null, + })) + .expect("null optional bounds should be treated as absent"); + + assert!(range.start_time.is_some()); + assert_eq!(range.end_time, None); +} + #[tokio::test] async fn identical_message_catch_ups_share_one_leader() { let key = format!("test-singleflight-{}", std::process::id()); From 0693e79124317f032af53d828e37179dc7890abb Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Sun, 23 Aug 2026 12:55:57 +0200 Subject: [PATCH 4/5] fix(daemon): retain initialize project routes --- src/daemon.rs | 50 +++++++++++++++++++++++++++++++++++++++--- src/daemon/tests.rs | 22 +++++++++++++++++++ src/mcp/server.rs | 53 ++++++++++++++++++++++++++++++++++++++------- 3 files changed, 114 insertions(+), 11 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index d8cf4a53f5..2f939ffcbd 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -41,6 +41,7 @@ pub const HOOK_EVENT_METHOD: &str = "tracedecay/hookEvent"; const TOOL_LIST_CHANGED_METHOD: &str = "notifications/tools/list_changed"; #[cfg(unix)] const MAX_CATALOG_REFRESH_CLIENTS_PER_GENERATION: usize = 1_024; +const MAX_INITIALIZE_ROUTE_CLIENTS: usize = 1_024; const HOOK_EVENT_NOTIFY_TIMEOUT: Duration = Duration::from_millis(750); const DAEMON_TOOL_LIVENESS_POLL_INTERVAL: Duration = Duration::from_secs(5); const DAEMON_TOOL_HEALTH_CONNECT_TIMEOUT: Duration = Duration::from_secs(1); @@ -1039,6 +1040,7 @@ pub async fn run_foreground(_socket_path: PathBuf) -> Result<()> { let lifecycle = DaemonLifecycle::default(); let store_administration = StoreAdministration::default(); let project_open_gates = Arc::new(tokio::sync::Mutex::new(ProjectOpenGates::default())); + let initialize_routes = Arc::new(tokio::sync::Mutex::new(HashMap::new())); let mut clients: JoinSet> = JoinSet::new(); loop { let stream = tokio::select! { @@ -1055,6 +1057,7 @@ pub async fn run_foreground(_socket_path: PathBuf) -> Result<()> { let client_lifecycle = lifecycle.clone(); let store_administration = store_administration.clone(); let project_open_gates = Arc::clone(&project_open_gates); + let initialize_routes = Arc::clone(&initialize_routes); clients.spawn(async move { serve_windows_broker_client( stream, @@ -1062,6 +1065,7 @@ pub async fn run_foreground(_socket_path: PathBuf) -> Result<()> { &client_lifecycle, store_administration, project_open_gates, + initialize_routes, #[cfg(test)] None, ) @@ -1872,6 +1876,7 @@ struct DaemonEngine { catalog_refresh_notified_clients: Arc>>, /// Prevents capacity exhaustion from flooding the daemon log. catalog_refresh_saturation_logged: Arc, + initialize_routes: InitializeRouteCache, /// Git-metadata watcher (design D3/D5). Default-constructed inert; the real /// config-driven watcher is installed by `run_foreground_unix` via /// [`DaemonEngine::with_git_watcher`] before the accept loop starts. @@ -2085,14 +2090,15 @@ fn portable_database_owner_reconciler( }) } -#[cfg(unix)] #[derive(Clone, Debug, PartialEq, Eq, Hash)] struct CatalogRefreshClientKey { client_identity: DaemonClientIdentity, client_instance_id: String, } -#[cfg(unix)] +type InitializeRouteCache = + Arc>>; + impl CatalogRefreshClientKey { fn from_handshake(handshake: &DaemonHandshake) -> Self { Self { @@ -2102,7 +2108,6 @@ impl CatalogRefreshClientKey { } } -#[cfg(unix)] fn valid_client_instance_id(client_instance_id: &str) -> bool { let bytes = client_instance_id.as_bytes(); (bytes.len() == 32 @@ -2664,6 +2669,32 @@ async fn apply_daemon_initialize_route( Ok(Some(route)) } +async fn apply_cached_initialize_route( + handshake: &mut DaemonHandshake, + initialize_route: Option<&InitializeRouteMetadata>, + routes: &InitializeRouteCache, +) { + if !valid_client_instance_id(&handshake.client_instance_id) { + return; + } + let key = CatalogRefreshClientKey::from_handshake(handshake); + let mut routes = routes.lock().await; + if let Some(route) = initialize_route { + if routes.len() < MAX_INITIALIZE_ROUTE_CLIENTS || routes.contains_key(&key) { + routes.insert(key, route.clone()); + } + return; + } + if handshake.project_path.is_some() { + return; + } + let Some(route) = routes.get(&key) else { + return; + }; + handshake.project_path = Some(route.project_path.clone()); + handshake.allow_init = route.allow_init; +} + fn attach_initialize_route_metadata( response: &mut JsonRpcResponse, route: &InitializeRouteMetadata, @@ -2902,6 +2933,12 @@ async fn serve_broker_socket_client( &engine.store_administration, ) .await?; + apply_cached_initialize_route( + &mut handshake, + initialize_route.as_ref(), + &engine.initialize_routes, + ) + .await; if let Some(request) = parse_branch_admin_request(&first_request_line) { let result = match request.action.clone() { Ok(action) => engine.execute_branch_admin(&handshake, action).await, @@ -3059,6 +3096,7 @@ async fn serve_windows_broker_client( lifecycle: &DaemonLifecycle, store_administration: StoreAdministration, project_open_gates: Arc>, + initialize_routes: InitializeRouteCache, #[cfg(test)] project_open_attempts: Option>, ) -> Result<()> { let mut transport = BrokerStreamTransport::new(stream); @@ -3087,6 +3125,12 @@ async fn serve_windows_broker_client( let initialize_route = apply_daemon_initialize_route(&mut handshake, &first_request_line, &store_administration) .await?; + apply_cached_initialize_route( + &mut handshake, + initialize_route.as_ref(), + &initialize_routes, + ) + .await; if let Some(request) = parse_branch_admin_request(&first_request_line) { let result = match request.action.clone() { Ok(action) => { diff --git a/src/daemon/tests.rs b/src/daemon/tests.rs index cd38f9be9f..69be2359c3 100644 --- a/src/daemon/tests.rs +++ b/src/daemon/tests.rs @@ -116,6 +116,7 @@ async fn portable_broker_requests_reuse_one_authenticated_project_owner() { &lifecycle, store_administration, gates, + Default::default(), Some(attempts), )) .await @@ -274,6 +275,7 @@ async fn portable_broker_bootstrap_bypasses_project_writer_gate() { &lifecycle, administration, gates, + Default::default(), Some(attempts), )) .await @@ -1743,6 +1745,25 @@ async fn connect_with_restart_grace_gives_up_with_restart_hint() { } #[cfg(unix)] +#[tokio::test] +async fn cached_initialize_route_rebinds_later_projectless_handshake() { + let routes = Default::default(); + let mut initialized = test_handshake_defaults(); + initialized.client_instance_id = "mcp-1234567890".to_string(); + let route = super::InitializeRouteMetadata { + project_path: PathBuf::from("/registered/project"), + allow_init: true, + }; + super::apply_cached_initialize_route(&mut initialized, Some(&route), &routes).await; + + let mut later = test_handshake_defaults(); + later.client_instance_id = initialized.client_instance_id; + super::apply_cached_initialize_route(&mut later, None, &routes).await; + + assert_eq!(later.project_path, Some(route.project_path)); + assert!(later.allow_init); +} + #[tokio::test] async fn initialize_root_routing_replaces_cached_project_and_scope() { let profile = TempDir::new().expect("profile temp dir"); @@ -2610,6 +2631,7 @@ async fn portable_broker_rejects_missing_auth_before_routing() { &DaemonLifecycle::default(), server_administration, gates, + Default::default(), Some(server_attempts), )) .await diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 4ea35075be..c8580d860b 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -204,16 +204,53 @@ pub(crate) async fn resolve_initialize_roots_project_path( } pub(crate) fn initialize_root_paths(params: Option<&Value>) -> Vec { - params - .and_then(|p| p.get("roots")) - .and_then(Value::as_array) + let Some(params) = params else { + return Vec::new(); + }; + let listed_uris = ["roots", "workspaceFolders"] .into_iter() - .flatten() - .filter_map(|root| { - let uri = root.get("uri").and_then(Value::as_str)?; - crate::serve::local_path_from_mcp_root_uri(uri) + .flat_map(|key| { + params + .get(key) + .and_then(Value::as_array) + .into_iter() + .flatten() }) - .collect() + .filter_map(|entry| entry.get("uri").and_then(Value::as_str)); + let root_uri = params.get("rootUri").and_then(Value::as_str).into_iter(); + let mut paths = Vec::new(); + for uri in listed_uris.chain(root_uri) { + if let Some(path) = crate::serve::local_path_from_mcp_root_uri(uri) + && !paths.contains(&path) + { + paths.push(path); + } + } + paths +} + +#[cfg(test)] +mod initialize_root_path_tests { + use super::initialize_root_paths; + + #[test] + fn initialize_roots_include_workspace_folders_and_root_uri_without_duplicates() { + let temp = tempfile::tempdir().expect("temp dir"); + let first = temp.path().join("first"); + let second = temp.path().join("second"); + let first_uri = url::Url::from_file_path(&first).expect("first file URI"); + let second_uri = url::Url::from_file_path(&second).expect("second file URI"); + let params = serde_json::json!({ + "roots": [{"uri": first_uri.as_str()}], + "workspaceFolders": [ + {"uri": first_uri.as_str(), "name": "first"}, + {"uri": second_uri.as_str(), "name": "second"} + ], + "rootUri": second_uri.as_str() + }); + + assert_eq!(initialize_root_paths(Some(¶ms)), vec![first, second]); + } } fn match_initialize_root_to_registered_project( From cfd46214578564902aa4ff1c7b0246bde452ab7e Mon Sep 17 00:00:00 2001 From: Hashem Khalifa Date: Sun, 23 Aug 2026 22:53:28 +0200 Subject: [PATCH 5/5] fix(daemon): allow registry tools without project --- src/daemon.rs | 22 +++++++++++++++ src/daemon/tests.rs | 49 ++++++++++++++++++++++++++++++++++ src/mcp/tools/handlers/info.rs | 40 ++++++++++++++------------- src/mcp/tools/handlers/mod.rs | 27 ++++++++++++++++--- src/mcp/tools/mod.rs | 4 ++- 5 files changed, 120 insertions(+), 22 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 2f939ffcbd..43200bebed 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -3566,6 +3566,28 @@ async fn projectless_tools_call_response( Err(error) => JsonRpcResponse::error(id, ErrorCode::InternalError, error.to_string()), }; } + if matches!( + tool_name, + "tracedecay_project_list" | "tracedecay_project_search" | "tracedecay_project_context" + ) { + let global_db = match store_administration + .global_database(&client_identity.global_db_path) + .await + { + Ok(global_db) => global_db, + Err(error) => { + return JsonRpcResponse::error(id, ErrorCode::InternalError, error.to_string()); + } + }; + return match crate::mcp::tools::handle_projectless_registry_tool( + tool_name, arguments, &global_db, + ) + .await + { + Ok(result) => JsonRpcResponse::success(id, result.value), + Err(error) => JsonRpcResponse::error(id, ErrorCode::InternalError, error.to_string()), + }; + } JsonRpcResponse::error( id, ErrorCode::InternalError, diff --git a/src/daemon/tests.rs b/src/daemon/tests.rs index 69be2359c3..9500bcf3c7 100644 --- a/src/daemon/tests.rs +++ b/src/daemon/tests.rs @@ -3592,6 +3592,55 @@ async fn socket_client_rejects_tool_calls_without_project() { .expect("projectless client shutdown should be clean"); } +#[tokio::test] +async fn projectless_registry_tools_use_the_client_project_registry() { + let temp = TempDir::new().expect("temp profile"); + let project = temp.path().join("alpha-project"); + std::fs::create_dir_all(&project).expect("project root"); + let client_identity = test_client_identity_for(temp.path().to_path_buf()); + let global_db = crate::global_db::GlobalDb::open_at(&client_identity.global_db_path) + .await + .expect("open client registry"); + global_db + .upsert_code_project("proj_alpha", &project, None, None, Some("main")) + .await + .expect("register project"); + drop(global_db); + let store_administration = StoreAdministration::default(); + let calls = [ + ("tracedecay_project_list", json!({"format": "json"})), + ( + "tracedecay_project_search", + json!({"query": "alpha", "format": "json"}), + ), + ( + "tracedecay_project_context", + json!({"project_id": "proj_alpha", "format": "json"}), + ), + ]; + let mut failures = Vec::new(); + for (id, (tool_name, arguments)) in calls.into_iter().enumerate() { + let params = json!({"name": tool_name, "arguments": arguments}); + let response = super::projectless_tools_call_response( + json!(id), + Some(¶ms), + &client_identity, + &store_administration, + ) + .await; + let response = serde_json::to_value(response).expect("serialize response"); + if let Some(error) = response.get("error") { + failures.push(format!("{tool_name}: {error}")); + continue; + } + assert!( + response["result"].to_string().contains("proj_alpha"), + "{tool_name} omitted the registered project: {response}" + ); + } + assert!(failures.is_empty(), "{}", failures.join("; ")); +} + #[cfg(unix)] #[tokio::test] async fn daemon_linked_worktree_route_repairs_primary_identity_and_keeps_alias() { diff --git a/src/mcp/tools/handlers/info.rs b/src/mcp/tools/handlers/info.rs index 8d55c452b6..bd0a3e18e5 100644 --- a/src/mcp/tools/handlers/info.rs +++ b/src/mcp/tools/handlers/info.rs @@ -450,8 +450,8 @@ async fn open_project_registry_read_only( Ok(Some((path, ProjectRegistryDb::Owned(Box::new(db))))) } -fn project_registry_result(cg: &TraceDecay, args: &Value, payload: &Value) -> ToolResult { - render_registry_result(Some(cg.project_root()), args, payload) +fn project_registry_result(cg: Option<&TraceDecay>, args: &Value, payload: &Value) -> ToolResult { + render_registry_result(cg.map(TraceDecay::project_root), args, payload) } fn registry_result(args: &Value, payload: &Value) -> ToolResult { @@ -510,7 +510,8 @@ fn empty_registry_view_payload(title: &str) -> (Value, Value, Value) { /// Resolves the active project's registry id by looking up `cg`'s project /// root in the registry, the same identity lookup the `tracedecay projects` /// CLI performs for its own `active_project_id` (see `src/project_cmd.rs`). -async fn active_project_id(cg: &TraceDecay, db: &ProjectRegistryDb<'_>) -> Option { +async fn active_project_id(cg: Option<&TraceDecay>, db: &ProjectRegistryDb<'_>) -> Option { + let cg = cg?; let project_root = cg.project_root(); let git_common_dir = crate::worktree::git_common_dir(project_root); db.db() @@ -521,7 +522,7 @@ async fn active_project_id(cg: &TraceDecay, db: &ProjectRegistryDb<'_>) -> Optio /// Handles `tracedecay_project_list` tool calls. pub(super) async fn handle_project_list( - cg: &TraceDecay, + cg: Option<&TraceDecay>, args: Value, global_db: Option<&GlobalDb>, allow_default_registry_fallback: bool, @@ -569,7 +570,7 @@ pub(super) async fn handle_project_list( /// Handles `tracedecay_project_search` tool calls. pub(super) async fn handle_project_search( - cg: &TraceDecay, + cg: Option<&TraceDecay>, args: Value, global_db: Option<&GlobalDb>, allow_default_registry_fallback: bool, @@ -624,23 +625,23 @@ pub(super) async fn handle_project_search( )) } -fn project_context_alias_path<'a>(cg: &'a TraceDecay, args: &'a Value) -> (PathBuf, bool) { +fn project_context_alias_path(cg: Option<&TraceDecay>, args: &Value) -> Option<(PathBuf, bool)> { let Some(path) = args.get("path").and_then(Value::as_str) else { - return (cg.project_root().to_path_buf(), true); + return cg.map(|cg| (cg.project_root().to_path_buf(), true)); }; let path = Path::new(path); let allow_git_identity = GlobalDb::is_explicit_project_path_selector(path.to_string_lossy().as_ref()); if path.is_absolute() { - (path.to_path_buf(), allow_git_identity) + Some((path.to_path_buf(), allow_git_identity)) } else { - (cg.project_root().join(path), allow_git_identity) + cg.map(|cg| (cg.project_root().join(path), allow_git_identity)) } } /// Handles `tracedecay_project_context` tool calls. pub(super) async fn handle_project_context( - cg: &TraceDecay, + cg: Option<&TraceDecay>, args: Value, global_db: Option<&GlobalDb>, allow_default_registry_fallback: bool, @@ -657,14 +658,17 @@ pub(super) async fn handle_project_context( let context = if let Some(project_id) = args.get("project_id").and_then(Value::as_str) { db.db().project_registry_context_by_id(project_id).await } else { - let (alias_path, allow_git_identity) = project_context_alias_path(cg, &args); - if let Some(context) = db.db().project_registry_context_by_alias(&alias_path).await { - Some(context) - } else if allow_git_identity { - let git_common_dir = crate::worktree::git_common_dir(&alias_path); - db.db() - .project_registry_context_by_identity(&alias_path, git_common_dir.as_deref()) - .await + if let Some((alias_path, allow_git_identity)) = project_context_alias_path(cg, &args) { + if let Some(context) = db.db().project_registry_context_by_alias(&alias_path).await { + Some(context) + } else if allow_git_identity { + let git_common_dir = crate::worktree::git_common_dir(&alias_path); + db.db() + .project_registry_context_by_identity(&alias_path, git_common_dir.as_deref()) + .await + } else { + None + } } else { None } diff --git a/src/mcp/tools/handlers/mod.rs b/src/mcp/tools/handlers/mod.rs index 8a22625854..fc3de3c56f 100644 --- a/src/mcp/tools/handlers/mod.rs +++ b/src/mcp/tools/handlers/mod.rs @@ -88,6 +88,27 @@ pub async fn handle_user_lcm_tool( } } +pub(crate) async fn handle_projectless_registry_tool( + tool_name: &str, + args: Value, + global_db: &GlobalDb, +) -> Result { + match tool_name { + "tracedecay_project_list" => { + info::handle_project_list(None, args, Some(global_db), false).await + } + "tracedecay_project_search" => { + info::handle_project_search(None, args, Some(global_db), false).await + } + "tracedecay_project_context" => { + info::handle_project_context(None, args, Some(global_db), false).await + } + _ => Err(TraceDecayError::Config { + message: format!("unsupported projectless registry tool: {tool_name}"), + }), + } +} + use super::ToolResult; use super::dispatch_policy::{ tool_accepts_registered_project_selector, tool_dispatches_registered_project_reader, @@ -449,7 +470,7 @@ pub async fn handle_tool_call_with_registry_and_implicit_project( "tracedecay_storage_status" => info::handle_storage_status(cg, args, scope_prefix).await, "tracedecay_project_list" => { info::handle_project_list( - cg, + Some(cg), args, options.global_db, options.allow_default_registry_fallback, @@ -458,7 +479,7 @@ pub async fn handle_tool_call_with_registry_and_implicit_project( } "tracedecay_project_search" => { info::handle_project_search( - cg, + Some(cg), args, options.global_db, options.allow_default_registry_fallback, @@ -467,7 +488,7 @@ pub async fn handle_tool_call_with_registry_and_implicit_project( } "tracedecay_project_context" => { info::handle_project_context( - cg, + Some(cg), args, options.global_db, options.allow_default_registry_fallback, diff --git a/src/mcp/tools/mod.rs b/src/mcp/tools/mod.rs index c91801700f..a7ffffe176 100644 --- a/src/mcp/tools/mod.rs +++ b/src/mcp/tools/mod.rs @@ -26,7 +26,9 @@ pub use handlers::{ ToolCallRegistryOptions, handle_tool_call, handle_tool_call_with_registry, handle_tool_call_with_registry_and_implicit_project, handle_user_lcm_tool, }; -pub(crate) use handlers::{handle_projectless_admin_cli, handle_projectless_hook_runtime}; +pub(crate) use handlers::{ + handle_projectless_admin_cli, handle_projectless_hook_runtime, handle_projectless_registry_tool, +}; /// Maximum character length for a tool response before truncation. const MAX_RESPONSE_CHARS: usize = 15_000;