From c592a8f2def54f0191a9a07553fcde313fd6f8b7 Mon Sep 17 00:00:00 2001 From: ibhagwan <59988195+ibhagwan@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:43:43 -0500 Subject: [PATCH] feat: add IRONCLAW_BASE_DIR env var with LazyLock caching (#397) --- src/bootstrap.rs | 188 +++++++++++++++++++++- src/channels/repl.rs | 6 +- src/channels/signal.rs | 9 +- src/channels/wasm/loader.rs | 6 +- src/channels/web/handlers/static_files.rs | 7 +- src/channels/web/server.rs | 7 +- src/cli/doctor.rs | 6 +- src/cli/status.rs | 11 +- src/cli/tool.rs | 5 +- src/config/channels.rs | 6 +- src/config/database.rs | 6 +- src/config/hygiene.rs | 5 +- src/config/llm.rs | 6 +- src/config/skills.rs | 11 +- src/config/wasm.rs | 6 +- src/llm/session.rs | 6 +- src/orchestrator/job_manager.rs | 16 +- src/pairing/store.rs | 6 +- src/registry/installer.rs | 7 +- src/service.rs | 11 +- src/settings.rs | 12 +- src/setup/wizard.rs | 15 +- src/tools/builtin/job.rs | 6 +- src/tools/builtin/message.rs | 5 +- src/tools/mcp/config.rs | 6 +- src/workspace/hygiene.rs | 7 +- 26 files changed, 255 insertions(+), 127 deletions(-) diff --git a/src/bootstrap.rs b/src/bootstrap.rs index f2366e3e..10a8d660 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -7,13 +7,75 @@ //! File: `~/.ironclaw/.env` (standard dotenvy format) use std::path::PathBuf; +use std::sync::LazyLock; + +const IRONCLAW_BASE_DIR_ENV: &str = "IRONCLAW_BASE_DIR"; + +/// Lazily computed IronClaw base directory, cached for the lifetime of the process. +static IRONCLAW_BASE_DIR: LazyLock = LazyLock::new(compute_ironclaw_base_dir); + +/// Compute the IronClaw base directory from environment. +/// +/// This is the underlying implementation used by both the public +/// `ironclaw_base_dir()` function (which caches the result) and tests +/// (which need to verify different configurations). +pub fn compute_ironclaw_base_dir() -> PathBuf { + std::env::var(IRONCLAW_BASE_DIR_ENV) + .map(PathBuf::from) + .map(|path| { + if path.as_os_str().is_empty() { + default_base_dir() + } else if !path.is_absolute() { + eprintln!( + "Warning: IRONCLAW_BASE_DIR is a relative path '{}', resolved against current directory", + path.display() + ); + path + } else { + path + } + }) + .unwrap_or_else(|_| default_base_dir()) +} + +/// Get the default IronClaw base directory (~/.ironclaw). +/// +/// Logs a warning if the home directory cannot be determined and falls back to +/// the current directory. +fn default_base_dir() -> PathBuf { + if let Some(home) = dirs::home_dir() { + home.join(".ironclaw") + } else { + eprintln!("Warning: Could not determine home directory, using current directory"); + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from("/tmp")) + .join(".ironclaw") + } +} + +/// Get the IronClaw base directory. +/// +/// Override with `IRONCLAW_BASE_DIR` environment variable. +/// Defaults to `~/.ironclaw` (or `./.ironclaw` if home directory cannot be determined). +/// +/// Thread-safe: the value is computed once and cached in a `LazyLock`. +/// +/// # Environment Variable Behavior +/// - If `IRONCLAW_BASE_DIR` is set to a non-empty path, that path is used. +/// - If `IRONCLAW_BASE_DIR` is set to an empty string, it is treated as unset. +/// - If `IRONCLAW_BASE_DIR` contains null bytes, a warning is printed and the default is used. +/// - If the home directory cannot be determined, a warning is printed and the current directory is used. +/// +/// # Returns +/// A `PathBuf` pointing to the base directory. The path is not validated +/// for existence. +pub fn ironclaw_base_dir() -> PathBuf { + IRONCLAW_BASE_DIR.clone() +} /// Path to the IronClaw-specific `.env` file: `~/.ironclaw/.env`. pub fn ironclaw_env_path() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".ironclaw") - .join(".env") + ironclaw_base_dir().join(".env") } /// Load env vars from `~/.ironclaw/.env` (in addition to the standard `.env`). @@ -200,9 +262,7 @@ pub async fn migrate_disk_to_db( store: &dyn crate::db::Database, user_id: &str, ) -> Result<(), MigrationError> { - let ironclaw_dir = dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".ironclaw"); + let ironclaw_dir = ironclaw_base_dir(); let legacy_settings_path = ironclaw_dir.join("settings.json"); if !legacy_settings_path.exists() { @@ -336,8 +396,11 @@ pub enum MigrationError { #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex; use tempfile::tempdir; + static ENV_MUTEX: Mutex<()> = Mutex::new(()); + #[test] fn test_save_and_load_database_url() { let dir = tempdir().unwrap(); @@ -727,4 +790,115 @@ INJECTED="pwned"#; assert_eq!(&found.unwrap().1, value, "{key} value mismatch"); } } + + #[test] + fn test_ironclaw_base_dir_default() { + // This test must run first (or in isolation) before the LazyLock is initialized. + // It verifies that when IRONCLAW_BASE_DIR is not set, the default path is used. + let _guard = ENV_MUTEX.lock().unwrap(); + let old_val = std::env::var("IRONCLAW_BASE_DIR").ok(); + // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests + unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") }; + + // Force re-evaluation by calling the computation function directly + let path = compute_ironclaw_base_dir(); + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + assert_eq!(path, home.join(".ironclaw")); + + if let Some(val) = old_val { + // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests + unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) }; + } + } + + #[test] + fn test_ironclaw_base_dir_env_override() { + // This test verifies that when IRONCLAW_BASE_DIR is set, + // the custom path is used. Must run before LazyLock is initialized. + let _guard = ENV_MUTEX.lock().unwrap(); + let old_val = std::env::var("IRONCLAW_BASE_DIR").ok(); + // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests + unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/custom/ironclaw/path") }; + + // Force re-evaluation by calling the computation function directly + let path = compute_ironclaw_base_dir(); + assert_eq!(path, std::path::PathBuf::from("/custom/ironclaw/path")); + + if let Some(val) = old_val { + // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests + unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) }; + } else { + // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests + unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") }; + } + } + + #[test] + fn test_compute_base_dir_env_path_join() { + // Verifies that ironclaw_env_path correctly joins .env to the base dir. + // Uses compute_ironclaw_base_dir directly to avoid LazyLock caching. + let _guard = ENV_MUTEX.lock().unwrap(); + let old_val = std::env::var("IRONCLAW_BASE_DIR").ok(); + // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests + unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/my/custom/dir") }; + + // Test the path construction logic directly + let base_path = compute_ironclaw_base_dir(); + let env_path = base_path.join(".env"); + assert_eq!(env_path, std::path::PathBuf::from("/my/custom/dir/.env")); + + if let Some(val) = old_val { + // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests + unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) }; + } else { + // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests + unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") }; + } + } + + #[test] + fn test_ironclaw_base_dir_empty_env() { + // Verifies that empty IRONCLAW_BASE_DIR falls back to default. + let _guard = ENV_MUTEX.lock().unwrap(); + let old_val = std::env::var("IRONCLAW_BASE_DIR").ok(); + // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests + unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "") }; + + // Force re-evaluation by calling the computation function directly + let path = compute_ironclaw_base_dir(); + let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + assert_eq!(path, home.join(".ironclaw")); + + if let Some(val) = old_val { + // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests + unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) }; + } else { + // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests + unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") }; + } + } + + #[test] + fn test_ironclaw_base_dir_special_chars() { + // Verifies that paths with special characters are handled correctly. + let _guard = ENV_MUTEX.lock().unwrap(); + let old_val = std::env::var("IRONCLAW_BASE_DIR").ok(); + // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests + unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/tmp/test_with-special.chars") }; + + // Force re-evaluation by calling the computation function directly + let path = compute_ironclaw_base_dir(); + assert_eq!( + path, + std::path::PathBuf::from("/tmp/test_with-special.chars") + ); + + if let Some(val) = old_val { + // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests + unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) }; + } else { + // SAFETY: ENV_MUTEX ensures single-threaded access to env vars in tests + unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") }; + } + } } diff --git a/src/channels/repl.rs b/src/channels/repl.rs index 15500a40..99f327b8 100644 --- a/src/channels/repl.rs +++ b/src/channels/repl.rs @@ -38,6 +38,7 @@ use tokio::sync::mpsc; use tokio_stream::wrappers::ReceiverStream; use crate::agent::truncate_for_preview; +use crate::bootstrap::ironclaw_base_dir; use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; use crate::error::ChannelError; @@ -279,10 +280,7 @@ fn print_help() { /// Get the history file path (~/.ironclaw/history). fn history_path() -> std::path::PathBuf { - dirs::home_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join(".ironclaw") - .join("history") + ironclaw_base_dir().join("history") } #[async_trait] diff --git a/src/channels/signal.rs b/src/channels/signal.rs index c578ff9a..09f19d23 100644 --- a/src/channels/signal.rs +++ b/src/channels/signal.rs @@ -17,6 +17,7 @@ use serde::Deserialize; use tokio::sync::RwLock; use uuid::Uuid; +use crate::bootstrap::ironclaw_base_dir; use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; use crate::config::SignalConfig; use crate::error::ChannelError; @@ -557,9 +558,7 @@ impl SignalChannel { /// - All paths are within ~/.ironclaw/ sandbox fn validate_attachment_paths(paths: &[String]) -> Result<(), ChannelError> { // Get the sandbox base directory (same as MessageTool uses) - let base_dir = dirs::home_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join(".ironclaw"); + let base_dir = ironclaw_base_dir(); for path in paths { crate::tools::builtin::path_utils::validate_path(path, Some(&base_dir)).map_err( @@ -2671,9 +2670,7 @@ mod tests { use std::fs; // Create test files in sandbox - let base_dir = dirs::home_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join(".ironclaw"); + let base_dir = crate::bootstrap::ironclaw_base_dir(); // Create sandbox directory if it doesn't exist (needed for CI) let _ = fs::create_dir_all(&base_dir); diff --git a/src/channels/wasm/loader.rs b/src/channels/wasm/loader.rs index 710c108d..e597fc32 100644 --- a/src/channels/wasm/loader.rs +++ b/src/channels/wasm/loader.rs @@ -11,6 +11,7 @@ use std::sync::Arc; use tokio::fs; +use crate::bootstrap::ironclaw_base_dir; use crate::channels::wasm::capabilities::ChannelCapabilities; use crate::channels::wasm::error::WasmChannelError; use crate::channels::wasm::runtime::WasmChannelRuntime; @@ -356,10 +357,7 @@ pub struct DiscoveredChannel { /// Returns ~/.ironclaw/channels/ #[allow(dead_code)] pub fn default_channels_dir() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".ironclaw") - .join("channels") + ironclaw_base_dir().join("channels") } #[cfg(test)] diff --git a/src/channels/web/handlers/static_files.rs b/src/channels/web/handlers/static_files.rs index cd0eeece..c198d95e 100644 --- a/src/channels/web/handlers/static_files.rs +++ b/src/channels/web/handlers/static_files.rs @@ -6,6 +6,7 @@ use axum::{ response::{Html, IntoResponse}, }; +use crate::bootstrap::ironclaw_base_dir; use crate::channels::web::types::*; // --- Static file handlers --- @@ -71,11 +72,7 @@ async fn serve_project_file(project_id: &str, path: &str) -> axum::response::Res return (StatusCode::BAD_REQUEST, "Invalid project ID").into_response(); } - let base = dirs::home_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join(".ironclaw") - .join("projects") - .join(project_id); + let base = ironclaw_base_dir().join("projects").join(project_id); let file_path = base.join(path); diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 40c5ca4b..e8db24a2 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -26,6 +26,7 @@ use tower_http::set_header::SetResponseHeaderLayer; use uuid::Uuid; use crate::agent::SessionManager; +use crate::bootstrap::ironclaw_base_dir; use crate::channels::IncomingMessage; use crate::channels::web::auth::{AuthState, auth_middleware}; use crate::channels::web::handlers::skills::{ @@ -1921,11 +1922,7 @@ async fn serve_project_file(project_id: &str, path: &str) -> axum::response::Res return (StatusCode::BAD_REQUEST, "Invalid project ID").into_response(); } - let base = dirs::home_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join(".ironclaw") - .join("projects") - .join(project_id); + let base = ironclaw_base_dir().join("projects").join(project_id); let file_path = base.join(path); diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs index 6746a8e1..a1b32b6b 100644 --- a/src/cli/doctor.rs +++ b/src/cli/doctor.rs @@ -6,6 +6,8 @@ use std::path::PathBuf; +use crate::bootstrap::ironclaw_base_dir; + /// Run all diagnostic checks and print results. pub async fn run_doctor_command() -> anyhow::Result<()> { println!("IronClaw Doctor"); @@ -195,9 +197,7 @@ async fn try_pg_connect() -> Result<(), String> { } fn check_workspace_dir() -> CheckResult { - let dir = dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".ironclaw"); + let dir = ironclaw_base_dir(); if dir.exists() { if dir.is_dir() { diff --git a/src/cli/status.rs b/src/cli/status.rs index 2f9bf28d..99e7b27e 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -5,6 +5,7 @@ use std::path::PathBuf; +use crate::bootstrap::ironclaw_base_dir; use crate::settings::Settings; /// Run the status command, printing system health info. @@ -206,15 +207,9 @@ fn count_wasm_files(dir: &std::path::Path) -> usize { } fn default_tools_dir() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".ironclaw") - .join("tools") + ironclaw_base_dir().join("tools") } fn default_channels_dir() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".ironclaw") - .join("channels") + ironclaw_base_dir().join("channels") } diff --git a/src/cli/tool.rs b/src/cli/tool.rs index eb4aef04..3835b1bd 100644 --- a/src/cli/tool.rs +++ b/src/cli/tool.rs @@ -9,6 +9,7 @@ use std::sync::Arc; use clap::Subcommand; use tokio::fs; +use crate::bootstrap::ironclaw_base_dir; use crate::config::Config; #[allow(unused_imports)] use crate::db::Database; @@ -19,9 +20,7 @@ use crate::tools::wasm::{CapabilitiesFile, compute_binary_hash}; /// Default tools directory. fn default_tools_dir() -> PathBuf { - dirs::home_dir() - .map(|h| h.join(".ironclaw").join("tools")) - .unwrap_or_else(|| PathBuf::from(".ironclaw/tools")) + ironclaw_base_dir().join("tools") } #[derive(Subcommand, Debug, Clone)] diff --git a/src/config/channels.rs b/src/config/channels.rs index 31e4e42f..5cc35da1 100644 --- a/src/config/channels.rs +++ b/src/config/channels.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use secrecy::SecretString; +use crate::bootstrap::ironclaw_base_dir; use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env}; use crate::error::ConfigError; use crate::settings::Settings; @@ -193,8 +194,5 @@ impl ChannelsConfig { /// Get the default channels directory (~/.ironclaw/channels/). fn default_channels_dir() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".ironclaw") - .join("channels") + ironclaw_base_dir().join("channels") } diff --git a/src/config/database.rs b/src/config/database.rs index 12b176c0..0a580f91 100644 --- a/src/config/database.rs +++ b/src/config/database.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use secrecy::{ExposeSecret, SecretString}; +use crate::bootstrap::ironclaw_base_dir; use crate::config::helpers::{optional_env, parse_optional_env}; use crate::error::ConfigError; @@ -123,8 +124,5 @@ impl DatabaseConfig { /// Default libSQL database path (~/.ironclaw/ironclaw.db). pub fn default_libsql_path() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".ironclaw") - .join("ironclaw.db") + ironclaw_base_dir().join("ironclaw.db") } diff --git a/src/config/hygiene.rs b/src/config/hygiene.rs index 174ab7c9..426bf7ec 100644 --- a/src/config/hygiene.rs +++ b/src/config/hygiene.rs @@ -1,3 +1,4 @@ +use crate::bootstrap::ironclaw_base_dir; use crate::config::helpers::{parse_bool_env, parse_optional_env}; use crate::error::ConfigError; @@ -41,9 +42,7 @@ impl HygieneConfig { enabled: self.enabled, retention_days: self.retention_days, cadence_hours: self.cadence_hours, - state_dir: dirs::home_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join(".ironclaw"), + state_dir: ironclaw_base_dir(), } } } diff --git a/src/config/llm.rs b/src/config/llm.rs index 60ff9d7f..2db37f98 100644 --- a/src/config/llm.rs +++ b/src/config/llm.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use secrecy::SecretString; +use crate::bootstrap::ironclaw_base_dir; use crate::config::helpers::{optional_env, parse_optional_env}; use crate::error::ConfigError; use crate::settings::Settings; @@ -373,10 +374,7 @@ fn parse_extra_headers(val: &str) -> Result, ConfigError> /// Get the default session file path (~/.ironclaw/session.json). fn default_session_path() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".ironclaw") - .join("session.json") + ironclaw_base_dir().join("session.json") } #[cfg(test)] diff --git a/src/config/skills.rs b/src/config/skills.rs index f6f742b0..97970784 100644 --- a/src/config/skills.rs +++ b/src/config/skills.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use crate::bootstrap::ironclaw_base_dir; use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env}; use crate::error::ConfigError; @@ -34,18 +35,12 @@ impl Default for SkillsConfig { /// Get the default user skills directory (~/.ironclaw/skills/). fn default_skills_dir() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".ironclaw") - .join("skills") + ironclaw_base_dir().join("skills") } /// Get the default installed skills directory (~/.ironclaw/installed_skills/). fn default_installed_skills_dir() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".ironclaw") - .join("installed_skills") + ironclaw_base_dir().join("installed_skills") } impl SkillsConfig { diff --git a/src/config/wasm.rs b/src/config/wasm.rs index 9d069c8b..224f2e95 100644 --- a/src/config/wasm.rs +++ b/src/config/wasm.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use std::time::Duration; +use crate::bootstrap::ironclaw_base_dir; use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env}; use crate::error::ConfigError; @@ -39,10 +40,7 @@ impl Default for WasmConfig { /// Get the default tools directory (~/.ironclaw/tools/). fn default_tools_dir() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".ironclaw") - .join("tools") + ironclaw_base_dir().join("tools") } impl WasmConfig { diff --git a/src/llm/session.rs b/src/llm/session.rs index ac4539b3..2dedfe56 100644 --- a/src/llm/session.rs +++ b/src/llm/session.rs @@ -7,6 +7,7 @@ use std::path::PathBuf; use std::sync::Arc; +use crate::bootstrap::ironclaw_base_dir; use crate::cli::oauth_defaults::OAUTH_CALLBACK_PORT; use chrono::{DateTime, Utc}; @@ -46,10 +47,7 @@ impl Default for SessionConfig { /// Get the default session file path (~/.ironclaw/session.json). pub fn default_session_path() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".ironclaw") - .join("session.json") + ironclaw_base_dir().join("session.json") } /// Manages NEAR AI session tokens with persistence and automatic renewal. diff --git a/src/orchestrator/job_manager.rs b/src/orchestrator/job_manager.rs index 9bcc969e..f55db75e 100644 --- a/src/orchestrator/job_manager.rs +++ b/src/orchestrator/job_manager.rs @@ -11,6 +11,7 @@ use chrono::{DateTime, Utc}; use tokio::sync::RwLock; use uuid::Uuid; +use crate::bootstrap::ironclaw_base_dir; use crate::error::OrchestratorError; use crate::orchestrator::auth::{CredentialGrant, TokenStore}; use crate::sandbox::connect_docker; @@ -158,11 +159,14 @@ fn validate_bind_mount_path( ), })?; - let home = dirs::home_dir().ok_or_else(|| OrchestratorError::ContainerCreationFailed { - job_id, - reason: "could not determine home directory for path validation".to_string(), - })?; - let projects_base = home.join(".ironclaw").join("projects"); + let projects_base = ironclaw_base_dir().join("projects"); + + if !projects_base.is_absolute() { + return Err(OrchestratorError::ContainerCreationFailed { + job_id, + reason: "base directory is not absolute; cannot safely validate bind mounts".into(), + }); + } // Ensure the base exists so canonicalize always succeeds. std::fs::create_dir_all(&projects_base).map_err(|e| { @@ -617,7 +621,7 @@ mod tests { #[test] fn test_validate_bind_mount_valid_path() { - let base = dirs::home_dir().unwrap().join(".ironclaw").join("projects"); + let base = crate::bootstrap::compute_ironclaw_base_dir().join("projects"); std::fs::create_dir_all(&base).unwrap(); let test_dir = base.join("test_validate_bind"); diff --git a/src/pairing/store.rs b/src/pairing/store.rs index dba62720..c0175688 100644 --- a/src/pairing/store.rs +++ b/src/pairing/store.rs @@ -12,6 +12,8 @@ use fs4::FileExt; use rand::Rng; use serde::{Deserialize, Serialize}; +use crate::bootstrap::ironclaw_base_dir; + const PAIRING_CODE_LENGTH: usize = 8; const PAIRING_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; /// TTL for pending pairing requests (minutes, not hours — reduces brute-force window). @@ -70,9 +72,7 @@ struct AllowFromStoreFile { } fn default_pairing_dir() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".ironclaw") + ironclaw_base_dir() } fn safe_channel_key(channel: &str) -> Result { diff --git a/src/registry/installer.rs b/src/registry/installer.rs index 8ee0d563..7f46a5dc 100644 --- a/src/registry/installer.rs +++ b/src/registry/installer.rs @@ -5,6 +5,7 @@ use std::path::{Component, Path, PathBuf}; use tokio::fs; +use crate::bootstrap::ironclaw_base_dir; use crate::registry::catalog::RegistryError; use crate::registry::manifest::{BundleDefinition, ExtensionManifest, ManifestKind}; @@ -179,11 +180,11 @@ impl RegistryInstaller { /// Default installer using standard paths. pub fn with_defaults(repo_root: PathBuf) -> Self { - let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + let base_dir = ironclaw_base_dir(); Self { repo_root, - tools_dir: home.join(".ironclaw").join("tools"), - channels_dir: home.join(".ironclaw").join("channels"), + tools_dir: base_dir.join("tools"), + channels_dir: base_dir.join("channels"), } } diff --git a/src/service.rs b/src/service.rs index d86d36ae..9bc6088f 100644 --- a/src/service.rs +++ b/src/service.rs @@ -12,6 +12,8 @@ use std::process::Command; use anyhow::{Context, Result, bail}; +use crate::bootstrap::ironclaw_base_dir; + const SERVICE_LABEL: &str = "com.ironclaw.daemon"; const SYSTEMD_UNIT: &str = "ironclaw.service"; @@ -57,7 +59,7 @@ fn install_macos() -> Result<()> { } let exe = std::env::current_exe().context("failed to resolve current executable")?; - let logs_dir = ironclaw_logs_dir()?; + let logs_dir = ironclaw_logs_dir(); std::fs::create_dir_all(&logs_dir)?; let stdout = logs_dir.join("daemon.stdout.log"); @@ -250,9 +252,8 @@ fn linux_unit_path() -> Result { .join(SYSTEMD_UNIT)) } -fn ironclaw_logs_dir() -> Result { - let home = dirs::home_dir().context("could not find home directory")?; - Ok(home.join(".ironclaw").join("logs")) +fn ironclaw_logs_dir() -> PathBuf { + ironclaw_base_dir().join("logs") } // ── Shell helpers ─────────────────────────────────────────────── @@ -350,7 +351,7 @@ mod tests { #[test] fn logs_dir_under_ironclaw() { - let path = ironclaw_logs_dir().unwrap(); + let path = ironclaw_logs_dir(); let s = path.to_string_lossy(); assert!(s.ends_with(".ironclaw/logs"), "unexpected path: {s}"); } diff --git a/src/settings.rs b/src/settings.rs index 1921c592..0e4b1fd9 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -7,6 +7,8 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; +use crate::bootstrap::ironclaw_base_dir; + /// User settings persisted to disk. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Settings { @@ -656,10 +658,7 @@ impl Settings { /// Get the default settings file path (~/.ironclaw/settings.json). pub fn default_path() -> std::path::PathBuf { - dirs::home_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join(".ironclaw") - .join("settings.json") + ironclaw_base_dir().join("settings.json") } /// Load settings from disk, returning default if not found. @@ -677,10 +676,7 @@ impl Settings { /// Default TOML config file path (~/.ironclaw/config.toml). pub fn default_toml_path() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".ironclaw") - .join("config.toml") + ironclaw_base_dir().join("config.toml") } /// Load settings from a TOML file. diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index 3662a653..7bc85d00 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -20,6 +20,7 @@ use secrecy::{ExposeSecret, SecretString}; #[cfg(feature = "postgres")] use tokio_postgres::NoTls; +use crate::bootstrap::ironclaw_base_dir; use crate::channels::wasm::{ ChannelCapabilitiesFile, available_channel_names, install_bundled_channel, }; @@ -1465,9 +1466,7 @@ impl SetupWizard { println!(); // Discover available WASM channels - let channels_dir = dirs::home_dir() - .ok_or_else(|| SetupError::Config("Could not determine home directory".into()))? - .join(".ironclaw/channels"); + let channels_dir = ironclaw_base_dir().join("channels"); let mut discovered_channels = discover_wasm_channels(&channels_dir).await; let installed_names: HashSet = discovered_channels @@ -1697,9 +1696,7 @@ impl SetupWizard { println!(); // Check which tools are already installed - let tools_dir = dirs::home_dir() - .ok_or_else(|| SetupError::Config("Could not determine home directory".into()))? - .join(".ironclaw/tools"); + let tools_dir = ironclaw_base_dir().join("tools"); let installed_tools = discover_installed_tools(&tools_dir).await; @@ -1739,9 +1736,7 @@ impl SetupWizard { let installer = crate::registry::installer::RegistryInstaller::new( repo_root.to_path_buf(), tools_dir.clone(), - dirs::home_dir() - .unwrap_or_default() - .join(".ironclaw/channels"), + ironclaw_base_dir().join("channels"), ); let mut installed_count = 0; @@ -2795,7 +2790,7 @@ async fn install_selected_registry_channels( let installer = crate::registry::installer::RegistryInstaller::new( repo_root.clone(), - dirs::home_dir().unwrap_or_default().join(".ironclaw/tools"), + ironclaw_base_dir().join("tools"), channels_dir.to_path_buf(), ); diff --git a/src/tools/builtin/job.rs b/src/tools/builtin/job.rs index 6d1befda..7da26577 100644 --- a/src/tools/builtin/job.rs +++ b/src/tools/builtin/job.rs @@ -14,6 +14,7 @@ use async_trait::async_trait; use chrono::Utc; use uuid::Uuid; +use crate::bootstrap::ironclaw_base_dir; use crate::channels::IncomingMessage; use crate::channels::web::types::SseEvent; use crate::context::{ContextManager, JobContext, JobState}; @@ -554,10 +555,7 @@ fn validate_env_var_name(name: &str) -> Result<(), ToolError> { } fn projects_base() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".ironclaw") - .join("projects") + ironclaw_base_dir().join("projects") } /// Resolve the project directory, creating it if it doesn't exist. diff --git a/src/tools/builtin/message.rs b/src/tools/builtin/message.rs index bdf0b9aa..e2690b02 100644 --- a/src/tools/builtin/message.rs +++ b/src/tools/builtin/message.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use async_trait::async_trait; use tokio::sync::RwLock; +use crate::bootstrap::ironclaw_base_dir; use crate::channels::{ChannelManager, OutgoingResponse}; use crate::context::JobContext; use crate::tools::tool::{ @@ -27,9 +28,7 @@ pub struct MessageTool { impl MessageTool { pub fn new(channel_manager: Arc) -> Self { - let base_dir = dirs::home_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join(".ironclaw"); + let base_dir = ironclaw_base_dir(); Self { channel_manager, diff --git a/src/tools/mcp/config.rs b/src/tools/mcp/config.rs index 1d802e6c..784f0aa2 100644 --- a/src/tools/mcp/config.rs +++ b/src/tools/mcp/config.rs @@ -9,6 +9,7 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use tokio::fs; +use crate::bootstrap::ironclaw_base_dir; use crate::tools::tool::ToolError; /// Configuration for connecting to a remote MCP server. @@ -251,10 +252,7 @@ impl From for ToolError { /// Get the default MCP servers configuration path. pub fn default_config_path() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".ironclaw") - .join("mcp-servers.json") + ironclaw_base_dir().join("mcp-servers.json") } /// Load MCP server configurations from the default location. diff --git a/src/workspace/hygiene.rs b/src/workspace/hygiene.rs index d269232b..8e5935fe 100644 --- a/src/workspace/hygiene.rs +++ b/src/workspace/hygiene.rs @@ -20,6 +20,7 @@ use std::path::PathBuf; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use crate::bootstrap::ironclaw_base_dir; use crate::workspace::Workspace; /// Configuration for workspace hygiene. @@ -37,15 +38,11 @@ pub struct HygieneConfig { impl Default for HygieneConfig { fn default() -> Self { - let state_dir = dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".ironclaw"); - Self { enabled: true, retention_days: 30, cadence_hours: 12, - state_dir, + state_dir: ironclaw_base_dir(), } } }