Skip to content

Commit 1110b52

Browse files
committed
feat: add IRONCLAW_BASE_DIR env var with LazyLock caching
This allows users to place their ironclaw data directory anywhere by setting the IRONCLAW_BASE_DIR environment variable instead of the hardcoded ~/.ironclaw path. Usage: IRONCLAW_BASE_DIR=/custom/path ironclaw Features: - Value computed once at startup and cached via LazyLock for thread safety - Empty string or null bytes in env var treated as unset (falls back to default) - Warns user if home directory cannot be determined (falls back to ./.ironclaw) - Warns user if IRONCLAW_BASE_DIR contains null bytes This is useful for development, testing, or running ironclaw in environments where modifying HOME is not desirable.
1 parent bf35b59 commit 1110b52

26 files changed

Lines changed: 225 additions & 121 deletions

File tree

src/bootstrap.rs

Lines changed: 162 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,70 @@
77
//! File: `~/.ironclaw/.env` (standard dotenvy format)
88
99
use std::path::PathBuf;
10+
use std::sync::LazyLock;
11+
12+
const IRONCLAW_BASE_DIR_ENV: &str = "IRONCLAW_BASE_DIR";
13+
14+
/// Lazily computed IronClaw base directory, cached for the lifetime of the process.
15+
static IRONCLAW_BASE_DIR: LazyLock<PathBuf> = LazyLock::new(compute_ironclaw_base_dir);
16+
17+
/// Compute the IronClaw base directory from environment.
18+
///
19+
/// This is the underlying implementation used by both the public
20+
/// `ironclaw_base_dir()` function (which caches the result) and tests
21+
/// (which need to verify different configurations).
22+
fn compute_ironclaw_base_dir() -> PathBuf {
23+
std::env::var(IRONCLAW_BASE_DIR_ENV)
24+
.map(PathBuf::from)
25+
.map(|path| {
26+
if path.as_os_str().is_empty() {
27+
default_base_dir()
28+
} else if path.to_string_lossy().contains('\0') {
29+
eprintln!("Warning: IRONCLAW_BASE_DIR contains null bytes, using default");
30+
default_base_dir()
31+
} else {
32+
path
33+
}
34+
})
35+
.unwrap_or_else(|_| default_base_dir())
36+
}
37+
38+
/// Get the default IronClaw base directory (~/.ironclaw).
39+
///
40+
/// Logs a warning if the home directory cannot be determined and falls back to
41+
/// the current directory.
42+
fn default_base_dir() -> PathBuf {
43+
if let Some(home) = dirs::home_dir() {
44+
home.join(".ironclaw")
45+
} else {
46+
eprintln!("Warning: Could not determine home directory, using current directory");
47+
PathBuf::from(".").join(".ironclaw")
48+
}
49+
}
50+
51+
/// Get the IronClaw base directory.
52+
///
53+
/// Override with `IRONCLAW_BASE_DIR` environment variable.
54+
/// Defaults to `~/.ironclaw` (or `./.ironclaw` if home directory cannot be determined).
55+
///
56+
/// Thread-safe: the value is computed once and cached in a `LazyLock`.
57+
///
58+
/// # Environment Variable Behavior
59+
/// - If `IRONCLAW_BASE_DIR` is set to a non-empty path, that path is used.
60+
/// - If `IRONCLAW_BASE_DIR` is set to an empty string, it is treated as unset.
61+
/// - If `IRONCLAW_BASE_DIR` contains null bytes, a warning is printed and the default is used.
62+
/// - If the home directory cannot be determined, a warning is printed and the current directory is used.
63+
///
64+
/// # Returns
65+
/// A `PathBuf` pointing to the base directory. The path is not validated
66+
/// for existence.
67+
pub fn ironclaw_base_dir() -> PathBuf {
68+
IRONCLAW_BASE_DIR.clone()
69+
}
1070

1171
/// Path to the IronClaw-specific `.env` file: `~/.ironclaw/.env`.
1272
pub fn ironclaw_env_path() -> PathBuf {
13-
dirs::home_dir()
14-
.unwrap_or_else(|| PathBuf::from("."))
15-
.join(".ironclaw")
16-
.join(".env")
73+
ironclaw_base_dir().join(".env")
1774
}
1875

1976
/// Load env vars from `~/.ironclaw/.env` (in addition to the standard `.env`).
@@ -185,9 +242,7 @@ pub async fn migrate_disk_to_db(
185242
store: &dyn crate::db::Database,
186243
user_id: &str,
187244
) -> Result<(), MigrationError> {
188-
let ironclaw_dir = dirs::home_dir()
189-
.unwrap_or_else(|| PathBuf::from("."))
190-
.join(".ironclaw");
245+
let ironclaw_dir = ironclaw_base_dir();
191246
let legacy_settings_path = ironclaw_dir.join("settings.json");
192247

193248
if !legacy_settings_path.exists() {
@@ -321,8 +376,11 @@ pub enum MigrationError {
321376
#[cfg(test)]
322377
mod tests {
323378
use super::*;
379+
use std::sync::Mutex;
324380
use tempfile::tempdir;
325381

382+
static ENV_MUTEX: Mutex<()> = Mutex::new(());
383+
326384
#[test]
327385
fn test_save_and_load_database_url() {
328386
let dir = tempdir().unwrap();
@@ -580,4 +638,101 @@ INJECTED="pwned"#;
580638
assert!(onboard.is_some(), "ONBOARD_COMPLETED must be present");
581639
assert_eq!(onboard.unwrap().1, "true");
582640
}
641+
642+
#[test]
643+
fn test_ironclaw_base_dir_default() {
644+
// This test must run first (or in isolation) before the LazyLock is initialized.
645+
// It verifies that when IRONCLAW_BASE_DIR is not set, the default path is used.
646+
let _guard = ENV_MUTEX.lock().unwrap();
647+
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
648+
unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") };
649+
650+
// Force re-evaluation by calling the computation function directly
651+
let path = compute_ironclaw_base_dir();
652+
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
653+
assert_eq!(path, home.join(".ironclaw"));
654+
655+
if let Some(val) = old_val {
656+
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) };
657+
}
658+
}
659+
660+
#[test]
661+
fn test_ironclaw_base_dir_env_override() {
662+
// This test verifies that when IRONCLAW_BASE_DIR is set,
663+
// the custom path is used. Must run before LazyLock is initialized.
664+
let _guard = ENV_MUTEX.lock().unwrap();
665+
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
666+
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/custom/ironclaw/path") };
667+
668+
// Force re-evaluation by calling the computation function directly
669+
let path = compute_ironclaw_base_dir();
670+
assert_eq!(path, std::path::PathBuf::from("/custom/ironclaw/path"));
671+
672+
if let Some(val) = old_val {
673+
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) };
674+
} else {
675+
unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") };
676+
}
677+
}
678+
679+
#[test]
680+
fn test_ironclaw_env_path_uses_base_dir() {
681+
// Verifies that ironclaw_env_path correctly joins .env to the base dir.
682+
// Uses compute_ironclaw_base_dir directly to avoid LazyLock caching.
683+
let _guard = ENV_MUTEX.lock().unwrap();
684+
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
685+
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/my/custom/dir") };
686+
687+
// Test the path construction logic directly
688+
let base_path = compute_ironclaw_base_dir();
689+
let env_path = base_path.join(".env");
690+
assert_eq!(env_path, std::path::PathBuf::from("/my/custom/dir/.env"));
691+
692+
if let Some(val) = old_val {
693+
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) };
694+
} else {
695+
unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") };
696+
}
697+
}
698+
699+
#[test]
700+
fn test_ironclaw_base_dir_empty_env() {
701+
// Verifies that empty IRONCLAW_BASE_DIR falls back to default.
702+
let _guard = ENV_MUTEX.lock().unwrap();
703+
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
704+
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "") };
705+
706+
// Force re-evaluation by calling the computation function directly
707+
let path = compute_ironclaw_base_dir();
708+
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
709+
assert_eq!(path, home.join(".ironclaw"));
710+
711+
if let Some(val) = old_val {
712+
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) };
713+
} else {
714+
unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") };
715+
}
716+
}
717+
718+
#[test]
719+
fn test_ironclaw_base_dir_special_chars() {
720+
// Verifies that paths with special characters are handled correctly.
721+
let _guard = ENV_MUTEX.lock().unwrap();
722+
let old_val = std::env::var("IRONCLAW_BASE_DIR").ok();
723+
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", "/tmp/test_with-special.chars") };
724+
725+
// Force re-evaluation by calling the computation function directly
726+
let path = compute_ironclaw_base_dir();
727+
assert_eq!(
728+
path,
729+
std::path::PathBuf::from("/tmp/test_with-special.chars")
730+
);
731+
732+
if let Some(val) = old_val {
733+
unsafe { std::env::set_var("IRONCLAW_BASE_DIR", val) };
734+
} else {
735+
unsafe { std::env::remove_var("IRONCLAW_BASE_DIR") };
736+
}
737+
}
583738
}

src/channels/repl.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ use tokio::sync::mpsc;
3838
use tokio_stream::wrappers::ReceiverStream;
3939

4040
use crate::agent::truncate_for_preview;
41+
use crate::bootstrap::ironclaw_base_dir;
4142
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
4243
use crate::error::ChannelError;
4344

@@ -279,10 +280,7 @@ fn print_help() {
279280

280281
/// Get the history file path (~/.ironclaw/history).
281282
fn history_path() -> std::path::PathBuf {
282-
dirs::home_dir()
283-
.unwrap_or_else(|| std::path::PathBuf::from("."))
284-
.join(".ironclaw")
285-
.join("history")
283+
ironclaw_base_dir().join("history")
286284
}
287285

288286
#[async_trait]

src/channels/signal.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use serde::Deserialize;
1717
use tokio::sync::RwLock;
1818
use uuid::Uuid;
1919

20+
use crate::bootstrap::ironclaw_base_dir;
2021
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
2122
use crate::config::SignalConfig;
2223
use crate::error::ChannelError;
@@ -557,9 +558,7 @@ impl SignalChannel {
557558
/// - All paths are within ~/.ironclaw/ sandbox
558559
fn validate_attachment_paths(paths: &[String]) -> Result<(), ChannelError> {
559560
// Get the sandbox base directory (same as MessageTool uses)
560-
let base_dir = dirs::home_dir()
561-
.unwrap_or_else(|| std::path::PathBuf::from("."))
562-
.join(".ironclaw");
561+
let base_dir = ironclaw_base_dir();
563562

564563
for path in paths {
565564
crate::tools::builtin::path_utils::validate_path(path, Some(&base_dir)).map_err(

src/channels/wasm/loader.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use std::sync::Arc;
1111

1212
use tokio::fs;
1313

14+
use crate::bootstrap::ironclaw_base_dir;
1415
use crate::channels::wasm::capabilities::ChannelCapabilities;
1516
use crate::channels::wasm::error::WasmChannelError;
1617
use crate::channels::wasm::runtime::WasmChannelRuntime;
@@ -349,10 +350,7 @@ pub struct DiscoveredChannel {
349350
/// Returns ~/.ironclaw/channels/
350351
#[allow(dead_code)]
351352
pub fn default_channels_dir() -> PathBuf {
352-
dirs::home_dir()
353-
.unwrap_or_else(|| PathBuf::from("."))
354-
.join(".ironclaw")
355-
.join("channels")
353+
ironclaw_base_dir().join("channels")
356354
}
357355

358356
#[cfg(test)]

src/channels/web/handlers/static_files.rs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use axum::{
66
response::{Html, IntoResponse},
77
};
88

9+
use crate::bootstrap::ironclaw_base_dir;
910
use crate::channels::web::types::*;
1011

1112
// --- Static file handlers ---
@@ -71,11 +72,7 @@ async fn serve_project_file(project_id: &str, path: &str) -> axum::response::Res
7172
return (StatusCode::BAD_REQUEST, "Invalid project ID").into_response();
7273
}
7374

74-
let base = dirs::home_dir()
75-
.unwrap_or_else(|| std::path::PathBuf::from("."))
76-
.join(".ironclaw")
77-
.join("projects")
78-
.join(project_id);
75+
let base = ironclaw_base_dir().join("projects").join(project_id);
7976

8077
let file_path = base.join(path);
8178

src/channels/web/server.rs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ use tower_http::set_header::SetResponseHeaderLayer;
2626
use uuid::Uuid;
2727

2828
use crate::agent::SessionManager;
29+
use crate::bootstrap::ironclaw_base_dir;
2930
use crate::channels::IncomingMessage;
3031
use crate::channels::web::auth::{AuthState, auth_middleware};
3132
use crate::channels::web::handlers::skills::{
@@ -1921,11 +1922,7 @@ async fn serve_project_file(project_id: &str, path: &str) -> axum::response::Res
19211922
return (StatusCode::BAD_REQUEST, "Invalid project ID").into_response();
19221923
}
19231924

1924-
let base = dirs::home_dir()
1925-
.unwrap_or_else(|| std::path::PathBuf::from("."))
1926-
.join(".ironclaw")
1927-
.join("projects")
1928-
.join(project_id);
1925+
let base = ironclaw_base_dir().join("projects").join(project_id);
19291926

19301927
let file_path = base.join(path);
19311928

src/cli/doctor.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
77
use std::path::PathBuf;
88

9+
use crate::bootstrap::ironclaw_base_dir;
10+
911
/// Run all diagnostic checks and print results.
1012
pub async fn run_doctor_command() -> anyhow::Result<()> {
1113
println!("IronClaw Doctor");
@@ -195,9 +197,7 @@ async fn try_pg_connect() -> Result<(), String> {
195197
}
196198

197199
fn check_workspace_dir() -> CheckResult {
198-
let dir = dirs::home_dir()
199-
.unwrap_or_else(|| PathBuf::from("."))
200-
.join(".ironclaw");
200+
let dir = ironclaw_base_dir();
201201

202202
if dir.exists() {
203203
if dir.is_dir() {

src/cli/status.rs

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
66
use std::path::PathBuf;
77

8+
use crate::bootstrap::ironclaw_base_dir;
89
use crate::settings::Settings;
910

1011
/// Run the status command, printing system health info.
@@ -206,15 +207,9 @@ fn count_wasm_files(dir: &std::path::Path) -> usize {
206207
}
207208

208209
fn default_tools_dir() -> PathBuf {
209-
dirs::home_dir()
210-
.unwrap_or_else(|| PathBuf::from("."))
211-
.join(".ironclaw")
212-
.join("tools")
210+
ironclaw_base_dir().join("tools")
213211
}
214212

215213
fn default_channels_dir() -> PathBuf {
216-
dirs::home_dir()
217-
.unwrap_or_else(|| PathBuf::from("."))
218-
.join(".ironclaw")
219-
.join("channels")
214+
ironclaw_base_dir().join("channels")
220215
}

src/cli/tool.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use std::sync::Arc;
99
use clap::Subcommand;
1010
use tokio::fs;
1111

12+
use crate::bootstrap::ironclaw_base_dir;
1213
use crate::config::Config;
1314
#[allow(unused_imports)]
1415
use crate::db::Database;
@@ -19,9 +20,7 @@ use crate::tools::wasm::{CapabilitiesFile, compute_binary_hash};
1920

2021
/// Default tools directory.
2122
fn default_tools_dir() -> PathBuf {
22-
dirs::home_dir()
23-
.map(|h| h.join(".ironclaw").join("tools"))
24-
.unwrap_or_else(|| PathBuf::from(".ironclaw/tools"))
23+
ironclaw_base_dir().join("tools")
2524
}
2625

2726
#[derive(Subcommand, Debug, Clone)]

src/config/channels.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use std::path::PathBuf;
22

33
use secrecy::SecretString;
44

5+
use crate::bootstrap::ironclaw_base_dir;
56
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
67
use crate::error::ConfigError;
78
use crate::settings::Settings;
@@ -193,8 +194,5 @@ impl ChannelsConfig {
193194

194195
/// Get the default channels directory (~/.ironclaw/channels/).
195196
fn default_channels_dir() -> PathBuf {
196-
dirs::home_dir()
197-
.unwrap_or_else(|| PathBuf::from("."))
198-
.join(".ironclaw")
199-
.join("channels")
197+
ironclaw_base_dir().join("channels")
200198
}

0 commit comments

Comments
 (0)