//! Tool config resolution pipeline. //! //! Four-step resolution: //! 1. `effective_tool_config = config.tool_config.unwrap_or_else(|| parent.effective_tool_config.clone())` //! 2. `merged = merge_mcp_tools(effective_tool_config, shared.mcp_servers.snapshot())` //! 3. `filtered = config.capability_mode.filter(merged)` //! 4. `toolset = build_finalized_toolset(filtered, &session.cwd, &session.session_env, ...)` use crate::capability::{CapabilityMode, kind_allowed}; use crate::config::SessionContextFactory; use crate::error::{WorkspaceError, WorkspaceResult}; use kigi_tools::registry::types::{ FinalizedToolset, ToolConfig, ToolRegistryBuilder, ToolServerConfig, }; use kigi_tools::types::tool::ToolKind; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; /// Create-shaped entry of the resolution pipeline: run /// [`resolve_session_toolset_rebuild`] around a FRESH factory-built /// session-lifetime terminal backend, and return that backend so the caller /// can store it on the session it is creating. pub(crate) fn resolve_session_toolset( effective_tool_config: ToolServerConfig, capability_mode: CapabilityMode, mcp_snapshot: &[ToolConfig], cwd: PathBuf, session_env: Arc>, session_id: &str, factory: &dyn SessionContextFactory, local_registry: Option, lsp: Option>, viewer_ctx: Option, notification_handle: Option, ) -> WorkspaceResult<( ToolServerConfig, Arc, crate::config::SessionTerminalBackend, )> { let terminal_backend = factory.build_terminal_backend(); let (effective, toolset) = resolve_session_toolset_rebuild( effective_tool_config, capability_mode, mcp_snapshot, cwd, session_env, session_id, factory, local_registry, lsp, viewer_ctx, notification_handle, terminal_backend.backend().clone(), )?; Ok((effective, toolset, terminal_backend)) } /// Rebuild-shaped entry: run steps 2-5 of the resolution pipeline around an /// EXISTING session-owned terminal backend. The parameter is non-optional on /// purpose: every toolset-swap call site must state which backend it rebuilds /// around, so background tasks and shell state can never be orphaned by a /// resolve that silently built a fresh backend. /// /// Returns the *unmodified* `effective_tool_config` (step-1 baseline) so /// the caller can store it on the session. The FinalizedToolset reflects /// MCP merging and capability filtering on top of that baseline. /// /// **MCP-origin `kind: None` tools are dropped under every non-`All` /// mode.** Baseline `kind: None` tools are always kept — but before /// filtering, kind-less baseline entries whose id the binary's registry /// knows get their [`ToolKind`] backfilled (see [`backfill_tool_kinds`]), /// so the capability filter applies to pinned toolsets whose wire entries /// cannot carry a kind. pub(crate) fn resolve_session_toolset_rebuild( effective_tool_config: ToolServerConfig, capability_mode: CapabilityMode, mcp_snapshot: &[ToolConfig], cwd: PathBuf, session_env: Arc>, session_id: &str, factory: &dyn SessionContextFactory, local_registry: Option, lsp: Option>, viewer_ctx: Option, notification_handle: Option, terminal_backend: Arc, ) -> WorkspaceResult<(ToolServerConfig, Arc)> { let mut builder = factory.registry_builder(); if let Some(lr) = local_registry { builder = builder.with_local_registry(lr); } let baseline = backfill_tool_kinds(&effective_tool_config, &builder.known_tool_kinds()); let finalize_config = merge_and_filter(&baseline, mcp_snapshot, capability_mode, session_id); let mut ctx = factory.build_session_context(session_id, cwd, session_env, terminal_backend); if let Some(lsp_handle) = lsp { ctx.lsp = Some(lsp_handle); } if let Some(handle) = notification_handle { ctx.notification_handle = handle; } let toolset = builder .finalize_with_trunc_config( finalize_config, ctx, kigi_tools::types::context::TruncationConfig::default(), viewer_ctx, ) .map_err(|errs| { let summary: Vec = errs.iter().map(|e| e.summary()).collect(); WorkspaceError::Finalize(summary.join("; ")) })?; Ok((effective_tool_config, Arc::new(toolset))) } /// Backfill `kind: None` baseline entries from the binary's own registry /// (fully-qualified id -> declared [`ToolKind`]). /// /// Ids unknown to the registry stay `None` and keep the always-kept /// baseline behavior (ad-hoc `ToolConfig::simple` tools). Entries that /// already carry a kind are left untouched. fn backfill_tool_kinds( config: &ToolServerConfig, kinds: &HashMap, ) -> ToolServerConfig { ToolServerConfig { tools: config .tools .iter() .map(|tool| { let mut tool = tool.clone(); if tool.kind.is_none() { tool.kind = kinds.get(&tool.id).copied(); } tool }) .collect(), behavior_preset: config.behavior_preset.clone(), } } /// Steps 2-3 of the resolution pipeline, without the `finalize` step: /// /// - **Step 2** -- MCP merge: append MCP-origin tools, skipping ID/name collisions with baseline. /// - **Step 3** -- Capability filter: drop tools whose `kind` is not allowed by the mode. /// External (MCP) `kind: None` tools are only kept under `CapabilityMode::All`. /// /// Priority on ID/name collision: baseline wins over MCP. pub(crate) fn merge_and_filter( baseline: &ToolServerConfig, mcp_snapshot: &[ToolConfig], mode: CapabilityMode, session_id: &str, ) -> ToolServerConfig { if mcp_snapshot.is_empty() { return mode.filter(baseline); } let baseline_ids: std::collections::HashSet<&str> = baseline.tools.iter().map(|t| t.id.as_str()).collect(); let mut taken_names: std::collections::HashSet = baseline .tools .iter() .map(|t| { let unqualified = t.id.rsplit_once(':').map_or(t.id.as_str(), |(_, n)| n); t.resolve_client_name(unqualified) }) .collect(); let mut tagged: Vec<(ToolConfig, bool)> = baseline.tools.iter().cloned().map(|t| (t, false)).collect(); for mcp_tool in mcp_snapshot { if baseline_ids.contains(mcp_tool.id.as_str()) { tracing::warn!( mcp_id = % mcp_tool.id, session = % session_id, "skipping MCP tool: id collides with baseline" ); continue; } let client_name = mcp_tool.resolve_client_name(&mcp_tool.id); if !taken_names.insert(client_name.clone()) { tracing::warn!( mcp_id = % mcp_tool.id, client_name = % client_name, session = % session_id, "skipping MCP tool: resolved client name collides with another tool" ); continue; } tagged.push((mcp_tool.clone(), true)); } let kept: Vec = tagged .into_iter() .filter(|(tool, is_external)| match tool.kind { Some(k) => kind_allowed(mode, k), None if !*is_external => true, None => matches!(mode, CapabilityMode::All), }) .map(|(t, _)| t) .collect(); ToolServerConfig { tools: kept, behavior_preset: baseline.behavior_preset.clone(), } } /// Alias for backward compatibility. pub type NoopSessionContextFactory = WorkspaceSessionContextFactory; /// Sanitize a `session_id` into a single safe filesystem path segment: chars /// outside `[A-Za-z0-9_-]` become `_`, empty becomes `anon`. When any /// replacement happened, an 8-hex digest of the ORIGINAL id is appended so the /// mapping stays injective — plain substitution would collide distinct ids /// (`sess/1` and `sess_1`) into one directory, cross-contaminating /// persistence and rehydration. Already-safe ids (the common UUID case) /// map to themselves. fn sanitize_session_id(session_id: &str) -> String { let mut safe = String::with_capacity(session_id.len()); let mut modified = false; for c in session_id.chars() { if c.is_ascii_alphanumeric() || c == '_' || c == '-' { safe.push(c); } else { safe.push('_'); modified = true; } } if safe.is_empty() { safe.push_str("anon"); modified = true; } if modified { let digest = kigi_file_utils::sha256_hex(session_id.as_bytes()); safe.push('-'); safe.push_str(&digest[..8]); } safe } /// `/sessions/`, creating the directory when possible. fn ensure_session_dir(root: &std::path::Path, session_id: &str) -> (PathBuf, std::io::Result<()>) { let dir = root.join("sessions").join(sanitize_session_id(session_id)); let created = std::fs::create_dir_all(&dir); (dir, created) } /// Serializes tests (across modules) that mutate the process-global /// `KIGI_WORKSPACE_TOOL_STATE_ENABLED`. Aliased to the crate-wide /// [`crate::ENV_TEST_LOCK`] so ALL env-mutating tests share ONE lock (the /// hazard is the global `environ` array, not the variable's value). #[cfg(test)] pub(crate) use crate::ENV_TEST_LOCK as TOOL_STATE_ENV_LOCK; /// [`SessionContextFactory`] for workspace sessions. /// /// [`SessionContext::session_folder`] is `/tmp/sessions//` /// (terminal logs and other tool artifacts — not the project `cwd`). /// /// Terminal backends are persistent-shell [`LocalTerminalBackend`]s, built /// once per session by [`build_terminal_backend`] and passed into every /// [`build_session_context`] call. /// /// [`build_terminal_backend`]: crate::config::SessionContextFactory::build_terminal_backend /// [`build_session_context`]: crate::config::SessionContextFactory::build_session_context /// [`LocalTerminalBackend`]: kigi_tools::computer::local::LocalTerminalBackend #[derive(Default)] pub struct WorkspaceSessionContextFactory; impl WorkspaceSessionContextFactory { pub fn new() -> Self { Self } /// `/tmp/sessions//` for terminal logs and other tool artifacts. fn resolve_session_folder(session_id: &str) -> PathBuf { let (dir, created) = ensure_session_dir(std::path::Path::new("/tmp"), session_id); if let Err(e) = created { tracing::warn!( session = % session_id, dir = % dir.display(), error = % e, "session_folder: failed to create dir; tools may create it on write" ); } dir } } impl SessionContextFactory for WorkspaceSessionContextFactory { fn build_session_context( &self, session_id: &str, cwd: PathBuf, session_env: Arc>, backend: Arc, ) -> kigi_tools::registry::types::SessionContext { let fs = Arc::new(kigi_tools::computer::local::LocalFs) as Arc; let notification_handle = kigi_tools::notification::ToolNotificationHandle::noop(); kigi_tools::registry::types::SessionContext { backend, fs, cwd, session_folder: Self::resolve_session_folder(session_id), session_env, notification_handle, owner_session_id: None, parent_scheduler_handle: None, skills: vec![], state_path: PathBuf::new(), memory_backend: None, web_search_config: kigi_tools::implementations::web_search::WebSearchConfig::default(), web_fetch_config: build_web_fetch_config(), lsp: None, app_builder_deployer_config: kigi_tools::implementations::kigi::deploy_app::AppBuilderDeployerConfig::default(), api_key_provider: None, attribution_callback: None, system_reminder_tag: kigi_tools::reminders::DEFAULT_REMINDER_TAG, } } fn build_terminal_backend(&self) -> crate::config::SessionTerminalBackend { crate::config::SessionTerminalBackend::local( kigi_tools::computer::local::LocalTerminalBackend::with_persistent_shell(), ) } fn registry_builder(&self) -> ToolRegistryBuilder { ToolRegistryBuilder::new() } fn known_tool_ids(&self) -> Arc> { static IDS: std::sync::LazyLock>> = std::sync::LazyLock::new(|| Arc::new(ToolRegistryBuilder::new().known_tool_ids())); IDS.clone() } } /// Enabled with default params unless `KIGI_DISABLE_WEB_FETCH=1` is set. fn build_web_fetch_config() -> kigi_tools::implementations::kigi::web_fetch::WebFetchConfig { use kigi_tools::implementations::kigi::web_fetch::{WebFetchConfig, WebFetchParams}; if std::env::var("KIGI_DISABLE_WEB_FETCH").is_ok_and(|v| v == "1" || v == "true") { return WebFetchConfig::Disabled; } let mut params = WebFetchParams::default(); if let Ok(proxy) = std::env::var("KIGI_WEB_FETCH_PROXY") { params.proxy_endpoint = Some(proxy); } if kigi_config::env_bool("KIGI_WEB_FETCH_ALLOW_LOCAL") == Some(true) { params.allow_local = Some(true); } WebFetchConfig::Enabled { params } } #[cfg(any(test, feature = "test-support"))] pub mod test_support { use crate::config::SessionContextFactory; use kigi_tools::computer::local::{LocalFs, LocalTerminalBackend}; use kigi_tools::notification::ToolNotificationHandle; use kigi_tools::registry::types::{ SessionContext, ToolConfig, ToolRegistryBuilder, ToolServerConfig, }; use kigi_tools::types::tool::ToolKind; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use tempfile::TempDir; /// Test factory: builds a `SessionContext` rooted at a per-test temp dir. pub struct TestSessionContextFactory { pub temp: TempDir, } impl Default for TestSessionContextFactory { fn default() -> Self { Self::new() } } impl TestSessionContextFactory { pub fn new() -> Self { Self { temp: TempDir::new().expect("create temp dir"), } } } impl SessionContextFactory for TestSessionContextFactory { fn build_session_context( &self, session_id: &str, cwd: PathBuf, session_env: Arc>, backend: Arc, ) -> SessionContext { let session_root = self .temp .path() .join(super::sanitize_session_id(session_id)); std::fs::create_dir_all(&session_root).expect("create session root"); SessionContext { backend, fs: Arc::new(LocalFs), cwd, session_folder: session_root.clone(), session_env, notification_handle: ToolNotificationHandle::noop(), owner_session_id: None, parent_scheduler_handle: None, skills: vec![], state_path: session_root.join("tool_state.json"), memory_backend: None, web_search_config: Default::default(), web_fetch_config: Default::default(), lsp: None, app_builder_deployer_config: Default::default(), api_key_provider: None, attribution_callback: None, system_reminder_tag: kigi_tools::reminders::DEFAULT_REMINDER_TAG, } } fn build_terminal_backend(&self) -> crate::config::SessionTerminalBackend { crate::config::SessionTerminalBackend::local(LocalTerminalBackend::new()) } fn registry_builder(&self) -> ToolRegistryBuilder { ToolRegistryBuilder::new() } } /// `ToolConfig` builder helper. pub fn tc(id: &str, kind: Option) -> ToolConfig { ToolConfig { id: id.to_owned(), params: None, name_override: None, params_name_overrides: None, description_override: None, behavior_version: None, kind, } } /// Minimal valid `ToolServerConfig` for finalize-time tests. pub fn baseline_config() -> ToolServerConfig { ToolServerConfig { tools: vec![ tc("Kigi:read_file", Some(ToolKind::Read)), tc("Kigi:search_replace", Some(ToolKind::Edit)), tc("Kigi:grep", Some(ToolKind::Search)), tc("Kigi:list_dir", Some(ToolKind::ListDir)), ], behavior_preset: None, } } } #[cfg(test)] mod tests { use super::*; use crate::config::SessionContextFactory; use kigi_tools::types::tool::ToolKind; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; fn factory_for_test() -> Arc { Arc::new(test_support::TestSessionContextFactory::new()) } fn empty_env() -> Arc> { Arc::new(HashMap::new()) } #[tokio::test] async fn resolve_session_toolset_empty_mcp_snapshot_is_noop_for_baseline() { let factory = factory_for_test(); let cwd = PathBuf::from("/tmp"); let baseline = test_support::baseline_config(); let baseline_ids: Vec = baseline.tools.iter().map(|t| t.id.clone()).collect(); let (eff, ts, _backend) = resolve_session_toolset( baseline, CapabilityMode::ReadWrite, &[], cwd, empty_env(), "main", factory.as_ref(), None, None, None, None, ) .expect("resolve"); assert_eq!( eff.tools .iter() .map(|t| t.id.clone()) .collect::>(), baseline_ids ); assert!(!ts.tool_definitions().is_empty()); } #[tokio::test] async fn resolve_session_toolset_mcp_merge_dedup_by_id_baseline_wins() { let factory = factory_for_test(); let baseline = ToolServerConfig { tools: vec![test_support::tc("Kigi:read_file", Some(ToolKind::Read))], behavior_preset: None, }; let mut mcp_dup = test_support::tc("Kigi:read_file", Some(ToolKind::Read)); mcp_dup.name_override = Some("mcp_read".into()); let snapshot = vec![mcp_dup]; let (_eff, ts, _backend) = resolve_session_toolset( baseline, CapabilityMode::ReadWrite, &snapshot, PathBuf::from("/tmp"), empty_env(), "main", factory.as_ref(), None, None, None, None, ) .expect("resolve"); let defs = ts.tool_definitions(); let names: Vec<&str> = defs.iter().map(|d| d.function.name.as_str()).collect(); assert!( names.contains(&"read_file"), "baseline read_file must survive: {names:?}" ); assert!( !names.contains(&"mcp_read"), "MCP duplicate must be skipped: {names:?}" ); } #[test] fn backfill_tool_kinds_fills_known_kindless_ids_only() { let kinds = HashMap::from([ ("Kigi:search_replace".to_owned(), ToolKind::Edit), ("Kigi:read_file".to_owned(), ToolKind::Read), ]); let config = ToolServerConfig { tools: vec![ test_support::tc("Kigi:search_replace", None), test_support::tc("adhoc.opaque", None), test_support::tc("Kigi:read_file", Some(ToolKind::Search)), ], behavior_preset: Some("current".to_owned()), }; let backfilled = backfill_tool_kinds(&config, &kinds); let kind_of = |id: &str| { backfilled .tools .iter() .find(|t| t.id == id) .expect("tool present") .kind }; assert_eq!(kind_of("Kigi:search_replace"), Some(ToolKind::Edit)); assert_eq!( kind_of("adhoc.opaque"), None, "ids unknown to the registry stay kind-less" ); assert_eq!( kind_of("Kigi:read_file"), Some(ToolKind::Search), "an explicit kind wins over the registry's" ); assert_eq!(backfilled.behavior_preset.as_deref(), Some("current")); } /// Regression: pinned server-bind toolsets arrive kind-less (the gRPC /// `ToolConfigEntry` has no kind field), which used to make every /// baseline entry bypass the capability filter — a `read_only` /// sub-agent kept edit + execute tools. The registry backfill must /// restore the filter. #[tokio::test] async fn resolve_session_toolset_readonly_filters_kindless_pinned_tools() { let factory = factory_for_test(); let baseline = ToolServerConfig { tools: vec![ test_support::tc("Kigi:read_file", None), test_support::tc("Kigi:grep", None), test_support::tc("Kigi:list_dir", None), test_support::tc("Kigi:search_replace", None), test_support::tc("Kigi:run_terminal_cmd", None), ], behavior_preset: None, }; let (eff, ts, _backend) = resolve_session_toolset( baseline, CapabilityMode::ReadOnly, &[], PathBuf::from("/tmp"), empty_env(), "main", factory.as_ref(), None, None, None, None, ) .expect("resolve"); assert!(eff.tools.iter().all(|t| t.kind.is_none())); let names: Vec = ts .tool_definitions() .into_iter() .map(|d| d.function.name) .collect(); for kept in ["read_file", "grep", "list_dir"] { assert!( names.iter().any(|n| n == kept), "{kept} must survive ReadOnly: {names:?}" ); } for dropped in ["search_replace", "run_terminal_cmd"] { assert!( !names.iter().any(|n| n == dropped), "{dropped} must be dropped under ReadOnly: {names:?}" ); } } #[test] fn resolve_session_toolset_mcp_edit_dropped_under_readonly() { let baseline = ToolServerConfig { tools: vec![test_support::tc("Kigi:read_file", Some(ToolKind::Read))], behavior_preset: None, }; let mcp_edit = test_support::tc("mcp.editor", Some(ToolKind::Edit)); let filtered = merge_and_filter(&baseline, &[mcp_edit], CapabilityMode::ReadOnly, "test"); assert!(!filtered.tools.iter().any(|t| t.id == "mcp.editor")); } #[tokio::test] async fn resolve_session_toolset_mcp_kind_none_dropped_under_readonly() { let factory = factory_for_test(); let baseline = ToolServerConfig { tools: vec![ test_support::tc("Kigi:read_file", Some(ToolKind::Read)), test_support::tc("baseline.opaque", None), ], behavior_preset: None, }; let mcp = vec![test_support::tc("mcp.opaque", None)]; let filtered = merge_and_filter(&baseline, &mcp, CapabilityMode::ReadOnly, "test_session"); let kept_ids: Vec<&str> = filtered.tools.iter().map(|t| t.id.as_str()).collect(); assert!( kept_ids.contains(&"baseline.opaque"), "baseline kind: None must survive ReadOnly: {kept_ids:?}" ); assert!( !kept_ids.contains(&"mcp.opaque"), "MCP kind: None MUST be dropped under ReadOnly: {kept_ids:?}" ); assert!( kept_ids.contains(&"Kigi:read_file"), "baseline Read kind must survive ReadOnly: {kept_ids:?}" ); let _ = factory; } #[tokio::test] async fn resolve_session_toolset_mcp_kind_none_kept_under_all() { let baseline = ToolServerConfig { tools: vec![], behavior_preset: None, }; let mcp = vec![test_support::tc("mcp.opaque", None)]; let filtered = merge_and_filter(&baseline, &mcp, CapabilityMode::All, "test_session"); let kept_ids: Vec<&str> = filtered.tools.iter().map(|t| t.id.as_str()).collect(); assert!( kept_ids.contains(&"mcp.opaque"), "All mode keeps MCP kind: None: {kept_ids:?}" ); } #[tokio::test] async fn resolve_session_toolset_mcp_name_override_collision_skipped() { let baseline = ToolServerConfig { tools: vec![], behavior_preset: None, }; let mut mcp_a = test_support::tc("mcp.tool_a", Some(ToolKind::Read)); mcp_a.name_override = Some("shared_name".into()); let mut mcp_b = test_support::tc("mcp.tool_b", Some(ToolKind::Read)); mcp_b.name_override = Some("shared_name".into()); let mcp = vec![mcp_a, mcp_b]; let filtered = merge_and_filter(&baseline, &mcp, CapabilityMode::ReadOnly, "test_session"); let ids: Vec<&str> = filtered.tools.iter().map(|t| t.id.as_str()).collect(); assert!(ids.contains(&"mcp.tool_a"), "first wins: {ids:?}"); assert!( !ids.contains(&"mcp.tool_b"), "duplicate name dropped: {ids:?}" ); } #[test] fn factory_session_folder_is_tmp_sessions_not_project_cwd() { let cwd = PathBuf::from("/workspace"); let folder = WorkspaceSessionContextFactory::resolve_session_folder("sess-1"); let expected = PathBuf::from("/tmp/sessions/sess-1"); assert_eq!(folder, expected); assert!(folder.is_dir()); assert!(!folder.starts_with(&cwd)); assert_eq!( folder.join("terminal").join("call-42.log"), PathBuf::from("/tmp/sessions/sess-1/terminal/call-42.log") ); } #[test] fn factory_session_folder_sanitizes_and_isolates_ids() { let sessions = PathBuf::from("/tmp/sessions"); let hostile = WorkspaceSessionContextFactory::resolve_session_folder("../../etc"); assert!(hostile.starts_with(&sessions)); assert_eq!(hostile.parent(), Some(sessions.as_path())); assert_ne!(hostile, sessions.join("etc")); let a = WorkspaceSessionContextFactory::resolve_session_folder("sess/1"); let b = WorkspaceSessionContextFactory::resolve_session_folder("sess_1"); assert_ne!(a, b); let empty = WorkspaceSessionContextFactory::resolve_session_folder(""); assert_eq!(empty.parent(), Some(sessions.as_path())); assert!( empty .file_name() .and_then(|n| n.to_str()) .is_some_and(|s| s.starts_with("anon-")) ); let uuid = "019f3c0a-e2c2-79a3-908d-e8a8f088fe52"; assert_eq!( WorkspaceSessionContextFactory::resolve_session_folder(uuid), sessions.join(uuid) ); } #[test] fn ensure_session_dir_shared_by_state_and_session_folder() { let home = tempfile::TempDir::new().unwrap(); let (under_home, ok) = ensure_session_dir(home.path(), "shared-id"); assert!(ok.is_ok()); assert_eq!(under_home, home.path().join("sessions").join("shared-id")); assert!(under_home.is_dir()); let (under_tmp, ok) = ensure_session_dir(std::path::Path::new("/tmp"), "shared-id"); assert!(ok.is_ok()); assert_eq!(under_tmp, PathBuf::from("/tmp/sessions/shared-id")); assert!(under_tmp.is_dir()); } /// A hostile `session_id` (`../../etc`) is sanitized to a single safe /// segment and cannot traverse outside `/sessions/`. #[test] fn ensure_session_dir_sanitizes_malicious_session_id_no_traversal() { let home = tempfile::TempDir::new().unwrap(); let sessions = home.path().join("sessions"); let (session_dir, created) = ensure_session_dir(home.path(), "../../etc"); assert!(created.is_ok()); assert!( session_dir.starts_with(&sessions), "session dir escaped sessions/: {}", session_dir.display() ); assert_eq!( session_dir.parent(), Some(sessions.as_path()), "session dir must be a direct child of sessions/, got {}", session_dir.display() ); let seg = session_dir .file_name() .and_then(|n| n.to_str()) .expect("segment is valid utf-8"); assert!( seg.chars() .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'), "sanitized segment must contain no path separators or dots: {seg:?}" ); assert!( session_dir.is_dir(), "the confined session dir must be the only thing created" ); } /// Sanitization is injective: distinct ids that substitute to the same /// base string still map to distinct directories (hash disambiguator), /// while already-safe ids map to themselves. #[test] fn sanitize_session_id_is_injective() { assert_eq!(super::sanitize_session_id("sess-1_a"), "sess-1_a"); assert_ne!( super::sanitize_session_id("sess/1"), super::sanitize_session_id("sess_1"), "substitution collisions must be disambiguated" ); assert_ne!( super::sanitize_session_id("sess/1"), super::sanitize_session_id("sess.1"), ); assert!(super::sanitize_session_id("").starts_with("anon-")); } /// A toolset rebuilt for the SAME session rehydrates persisted state from /// disk; a DIFFERENT session_id cold-starts with no cross-contamination. #[tokio::test] async fn tool_state_rehydrates_same_session_and_cold_starts_other() { use kigi_tools::types::resources::{State, WebCitationCounter}; let factory = test_support::TestSessionContextFactory::new(); let cwd = PathBuf::from("/tmp"); let (_eff, ts_a, _backend_a) = resolve_session_toolset( test_support::baseline_config(), CapabilityMode::ReadWrite, &[], cwd.clone(), empty_env(), "sess-A", &factory, None, None, None, None, ) .expect("build toolset A"); { let mut res = ts_a.resources.lock().await; let counter = res.get_or_default::>(); counter.counter = 123; } ts_a.save_and_flush_persistence().await; drop(ts_a); let (_eff, ts_b, _backend_b) = resolve_session_toolset( test_support::baseline_config(), CapabilityMode::ReadWrite, &[], cwd.clone(), empty_env(), "sess-A", &factory, None, None, None, None, ) .expect("build toolset B"); { let res = ts_b.resources.lock().await; let counter = res .get::>() .expect("WebCitationCounter must be present after rehydration"); assert_eq!( counter.counter, 123, "tool state must survive a rebuild for the same session (rehydration)" ); } let (_eff, ts_c, _backend_c) = resolve_session_toolset( test_support::baseline_config(), CapabilityMode::ReadWrite, &[], cwd, empty_env(), "sess-B", &factory, None, None, None, None, ) .expect("build toolset C"); { let res = ts_c.resources.lock().await; let contaminated = res .get::>() .is_some_and(|c| c.counter == 123); assert!( !contaminated, "a different session_id must cold-start, never inherit sess-A state" ); } } }