From 7cf3c2cc2bcac753c6d75039a51268568ec70acf Mon Sep 17 00:00:00 2001 From: ADD-SP Date: Sun, 15 Feb 2026 08:53:42 +0000 Subject: [PATCH] refactor: replace real filesystem tests with `vfs` crate Use the `vfs` crate's MemoryFS to eliminate real filesystem side effects from unit tests in process.rs and onboard.rs. Tests that previously required sudo/root access (writing to `/run/clawshell/`, `/var/log/clawshell/`) or left temp file artifacts now run entirely in-memory. --- Cargo.lock | 43 +++++- Cargo.toml | 4 + src/onboard.rs | 410 ++++++++++++++++++++++++++----------------------- src/process.rs | 112 +++++++++----- 4 files changed, 340 insertions(+), 229 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 945a0a5..d9fddbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -325,6 +325,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "vfs", "wiremock", ] @@ -565,6 +566,17 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1091,6 +1103,17 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", + "redox_syscall 0.7.1", +] + [[package]] name = "libtest-mimic" version = "0.8.1" @@ -1263,7 +1286,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -1452,6 +1475,15 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_syscall" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.12.3" @@ -2264,6 +2296,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vfs" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e723b9e1c02a3cf9f9d0de6a4ddb8cdc1df859078902fe0ae0589d615711ae6" +dependencies = [ + "filetime", +] + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index eae24d6..0a6db6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ clap = { version = "4.5.58", features = ["derive"] } nix = { version = "0.31.1", features = ["signal", "process", "feature", "user"] } inquire = "0.9.3" console = "0.16.2" +vfs = "0.12" [dev-dependencies] tokio = { version = "1.49", features = ["full", "test-util"] } @@ -40,3 +41,6 @@ predicates = "3" [[test]] name = "config_fixtures" harness = false + +[lints.clippy] +collapsible_if = "allow" diff --git a/src/onboard.rs b/src/onboard.rs index 02111af..8ba6d75 100644 --- a/src/onboard.rs +++ b/src/onboard.rs @@ -3,6 +3,7 @@ use crate::tui; use serde_json::Value; use std::io::{self, BufRead, Write}; use std::path::{Path, PathBuf}; +use vfs::VfsPath; /// API keys detected from an existing OpenClaw installation. #[derive(Debug, Default)] @@ -34,10 +35,29 @@ fn detect_openclaw_api_keys() -> DetectedKeys { /// Inner implementation that accepts an explicit home dir for testability. fn detect_openclaw_api_keys_with_home(home: Option<&str>) -> DetectedKeys { + let root = crate::process::physical_root(); + match home { + Some(h) => match root.join(h.trim_start_matches('/')) { + Ok(home_vfs) => detect_openclaw_api_keys_vfs(&home_vfs), + Err(_) => DetectedKeys { + anthropic: std::env::var("ANTHROPIC_API_KEY").ok(), + openai: std::env::var("OPENAI_API_KEY").ok(), + }, + }, + None => DetectedKeys { + anthropic: std::env::var("ANTHROPIC_API_KEY").ok(), + openai: std::env::var("OPENAI_API_KEY").ok(), + }, + } +} + +/// VFS implementation of API key detection from filesystem sources. +/// Falls back to environment variables for any keys not found on the filesystem. +fn detect_openclaw_api_keys_vfs(home: &VfsPath) -> DetectedKeys { let mut keys = DetectedKeys::default(); // Find the state directory - let state_dir = match home.and_then(find_state_dir) { + let state_dir = match find_state_dir_vfs(home) { Some(d) => d, None => { // Fall back to env vars only @@ -48,11 +68,11 @@ fn detect_openclaw_api_keys_with_home(home: Option<&str>) -> DetectedKeys { }; // Strategy 1: auth-profiles.json - try_auth_profiles(&state_dir, &mut keys); + try_auth_profiles_vfs(&state_dir, &mut keys); // Strategy 2: .env file if keys.anthropic.is_none() || keys.openai.is_none() { - try_dot_env(&state_dir, &mut keys); + try_dot_env_vfs(&state_dir, &mut keys); } // Strategy 3: environment variables @@ -66,28 +86,35 @@ fn detect_openclaw_api_keys_with_home(home: Option<&str>) -> DetectedKeys { keys } -/// Find the first existing OpenClaw state directory. -fn find_state_dir(home: &str) -> Option { +/// Find the first existing OpenClaw state directory (VFS variant). +fn find_state_dir_vfs(home: &VfsPath) -> Option { let candidates = [".openclaw", ".clawdbot", ".moltbot", ".moldbot"]; for name in &candidates { - let path = PathBuf::from(home).join(name); - if path.is_dir() { - return Some(path); + if let Ok(path) = home.join(name) { + if path.exists().unwrap_or(false) { + return Some(path); + } } } None } -/// Scan auth-profiles.json files for API keys. -fn try_auth_profiles(state_dir: &Path, keys: &mut DetectedKeys) { - let agents_dir = state_dir.join("agents"); - let entries = match std::fs::read_dir(&agents_dir) { +/// Scan auth-profiles.json files for API keys (VFS variant). +fn try_auth_profiles_vfs(state_dir: &VfsPath, keys: &mut DetectedKeys) { + let agents_dir = match state_dir.join("agents") { + Ok(d) => d, + Err(_) => return, + }; + let entries = match agents_dir.read_dir() { Ok(e) => e, Err(_) => return, }; - for entry in entries.flatten() { - let profile_path = entry.path().join("agent").join("auth-profiles.json"); - if let Ok(content) = std::fs::read_to_string(&profile_path) + for entry in entries { + let profile_path = match entry.join("agent/auth-profiles.json") { + Ok(p) => p, + Err(_) => continue, + }; + if let Ok(content) = profile_path.read_to_string() && let Ok(json) = serde_json::from_str::(&content) && let Some(profiles) = json.get("profiles").and_then(|p| p.as_object()) { @@ -116,13 +143,21 @@ fn try_auth_profiles(state_dir: &Path, keys: &mut DetectedKeys) { } } -/// Parse a .env file for API keys. -fn try_dot_env(state_dir: &Path, keys: &mut DetectedKeys) { - let env_path = state_dir.join(".env"); - let content = match std::fs::read_to_string(&env_path) { +/// Parse a .env file for API keys (VFS variant). +fn try_dot_env_vfs(state_dir: &VfsPath, keys: &mut DetectedKeys) { + let env_path = match state_dir.join(".env") { + Ok(p) => p, + Err(_) => return, + }; + let content = match env_path.read_to_string() { Ok(c) => c, Err(_) => return, }; + parse_dot_env_content(&content, keys); +} + +/// Shared .env parsing logic. +fn parse_dot_env_content(content: &str, keys: &mut DetectedKeys) { for line in content.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { @@ -313,18 +348,20 @@ pub fn default_openclaw_config_path() -> String { /// Try to load an existing onboarding configuration from the config directory. /// Returns `None` if no previous config exists or it can't be read. fn load_existing_config() -> Option { - load_existing_config_from(&PathBuf::from("/etc/clawshell")) + let root = crate::process::physical_root(); + let config_dir = root.join("etc/clawshell").ok()?; + load_existing_config_from_vfs(&config_dir) } -/// Inner implementation that accepts an explicit config directory for testability. -fn load_existing_config_from(config_dir: &Path) -> Option { - let config_file = config_dir.join("config.json"); - let toml_file = config_dir.join("clawshell.toml"); +/// VFS implementation for loading existing config from a directory. +fn load_existing_config_from_vfs(config_dir: &VfsPath) -> Option { + let config_file = config_dir.join("config.json").ok()?; + let toml_file = config_dir.join("clawshell.toml").ok()?; let mut existing = ExistingConfig::default(); // Read config.json for provider, model, virtual_api_key, openclaw_config_path - if let Ok(content) = std::fs::read_to_string(&config_file) + if let Ok(content) = config_file.read_to_string() && let Ok(json) = serde_json::from_str::(&content) { existing.provider = json @@ -347,7 +384,7 @@ fn load_existing_config_from(config_dir: &Path) -> Option { } // Read clawshell.toml for server host/port - if let Ok(content) = std::fs::read_to_string(&toml_file) + if let Ok(content) = toml_file.read_to_string() && let Ok(toml) = content.parse::() && let Some(server) = toml.get("server").and_then(|s| s.as_table()) { @@ -574,24 +611,27 @@ patterns = [ ) } -/// Backup the OpenClaw configuration file. -/// Returns the backup path on success. -pub fn backup_openclaw_config(openclaw_path: &Path) -> Result> { - if !openclaw_path.exists() { +/// Core backup logic (VFS variant) — copies the file and handles numbered backups. +/// Does NOT apply Unix permissions or chown (MemoryFS doesn't support those). +pub(crate) fn backup_openclaw_config_vfs( + openclaw_path: &VfsPath, +) -> Result> { + if !openclaw_path.exists()? { return Err(format!( "OpenClaw configuration file not found at: {}", - openclaw_path.display() + openclaw_path.as_str() ) .into()); } - let base_backup = openclaw_path.with_file_name("openclaw.json.clawshell.bak"); - let backup_path = if base_backup.exists() { + let parent = openclaw_path.parent(); + let base_backup = parent.join("openclaw.json.clawshell.bak")?; + let backup_path = if base_backup.exists()? { // Find the next available numbered backup let mut n = 1u32; loop { - let numbered = openclaw_path.with_file_name(format!("openclaw.json.clawshell.bak.{n}")); - if !numbered.exists() { + let numbered = parent.join(format!("openclaw.json.clawshell.bak.{n}"))?; + if !numbered.exists()? { break numbered; } n += 1; @@ -599,7 +639,20 @@ pub fn backup_openclaw_config(openclaw_path: &Path) -> Result Result> { + let root = crate::process::physical_root(); + let vfs_path = root.join(openclaw_path.to_string_lossy().trim_start_matches('/'))?; + let backup_vfs = backup_openclaw_config_vfs(&vfs_path)?; + let backup_path = PathBuf::from(backup_vfs.as_str()); // Lock down the backup so no user can read it (contains sensitive config). // Restore requires `sudo chmod 600` first. @@ -740,6 +793,7 @@ fn ensure_nested_object(json: &mut Value, keys: &[&str]) { mod tests { use super::*; use std::io::Cursor; + use vfs::MemoryFS; fn test_config() -> OnboardConfig { OnboardConfig { @@ -753,6 +807,16 @@ mod tests { } } + /// Create a VFS helper that writes content to a path, creating parent dirs. + fn vfs_write(root: &VfsPath, path: &str, content: &str) { + let p = root.join(path).unwrap(); + p.parent().create_dir_all().unwrap(); + p.create_file() + .unwrap() + .write_all(content.as_bytes()) + .unwrap(); + } + #[test] fn test_prompt_reads_input() { let input = b"hello world\n"; @@ -986,68 +1050,70 @@ mod tests { #[test] fn test_backup_openclaw_config() { - use std::os::unix::fs::PermissionsExt; - - let dir = std::env::temp_dir().join("clawshell_test_backup"); - let _ = std::fs::create_dir_all(&dir); - let config_path = dir.join("openclaw.json"); - std::fs::write(&config_path, r#"{"test": true}"#).unwrap(); - - let backup_path = backup_openclaw_config(&config_path).unwrap(); - assert_eq!(backup_path, dir.join("openclaw.json.clawshell.bak")); - assert!(backup_path.exists()); - - // Verify backup is locked down (mode 000) - let perms = std::fs::metadata(&backup_path).unwrap().permissions(); - assert_eq!(perms.mode() & 0o777, 0o000); + let root = VfsPath::new(MemoryFS::new()); + let config_path = root.join("home/user/openclaw.json").unwrap(); + config_path.parent().create_dir_all().unwrap(); + config_path + .create_file() + .unwrap() + .write_all(br#"{"test": true}"#) + .unwrap(); + + let backup_path = backup_openclaw_config_vfs(&config_path).unwrap(); + assert_eq!( + backup_path.as_str(), + "/home/user/openclaw.json.clawshell.bak" + ); + assert!(backup_path.exists().unwrap()); - // Restore read permission to verify content, then clean up - std::fs::set_permissions(&backup_path, std::fs::Permissions::from_mode(0o600)).unwrap(); - let backup_content = std::fs::read_to_string(&backup_path).unwrap(); + let backup_content = backup_path.read_to_string().unwrap(); assert_eq!(backup_content, r#"{"test": true}"#); - - // Cleanup - let _ = std::fs::remove_dir_all(&dir); } #[test] fn test_backup_openclaw_config_numbered() { - use std::os::unix::fs::PermissionsExt; - - let dir = std::env::temp_dir().join("clawshell_test_backup_numbered"); - let _ = std::fs::remove_dir_all(&dir); - let _ = std::fs::create_dir_all(&dir); - let config_path = dir.join("openclaw.json"); + let root = VfsPath::new(MemoryFS::new()); + let config_path = root.join("home/user/openclaw.json").unwrap(); + config_path.parent().create_dir_all().unwrap(); // First backup: creates .bak - std::fs::write(&config_path, r#"{"v": 0}"#).unwrap(); - let bak0 = backup_openclaw_config(&config_path).unwrap(); - assert_eq!(bak0, dir.join("openclaw.json.clawshell.bak")); + config_path + .create_file() + .unwrap() + .write_all(br#"{"v": 0}"#) + .unwrap(); + let bak0 = backup_openclaw_config_vfs(&config_path).unwrap(); + assert_eq!(bak0.as_str(), "/home/user/openclaw.json.clawshell.bak"); // Second backup: .bak exists, creates .bak.1 - std::fs::write(&config_path, r#"{"v": 1}"#).unwrap(); - let bak1 = backup_openclaw_config(&config_path).unwrap(); - assert_eq!(bak1, dir.join("openclaw.json.clawshell.bak.1")); + config_path + .create_file() + .unwrap() + .write_all(br#"{"v": 1}"#) + .unwrap(); + let bak1 = backup_openclaw_config_vfs(&config_path).unwrap(); + assert_eq!(bak1.as_str(), "/home/user/openclaw.json.clawshell.bak.1"); // Third backup: .bak and .bak.1 exist, creates .bak.2 - std::fs::write(&config_path, r#"{"v": 2}"#).unwrap(); - let bak2 = backup_openclaw_config(&config_path).unwrap(); - assert_eq!(bak2, dir.join("openclaw.json.clawshell.bak.2")); + config_path + .create_file() + .unwrap() + .write_all(br#"{"v": 2}"#) + .unwrap(); + let bak2 = backup_openclaw_config_vfs(&config_path).unwrap(); + assert_eq!(bak2.as_str(), "/home/user/openclaw.json.clawshell.bak.2"); // Verify contents - std::fs::set_permissions(&bak0, std::fs::Permissions::from_mode(0o600)).unwrap(); - std::fs::set_permissions(&bak1, std::fs::Permissions::from_mode(0o600)).unwrap(); - std::fs::set_permissions(&bak2, std::fs::Permissions::from_mode(0o600)).unwrap(); - assert_eq!(std::fs::read_to_string(&bak0).unwrap(), r#"{"v": 0}"#); - assert_eq!(std::fs::read_to_string(&bak1).unwrap(), r#"{"v": 1}"#); - assert_eq!(std::fs::read_to_string(&bak2).unwrap(), r#"{"v": 2}"#); - - let _ = std::fs::remove_dir_all(&dir); + assert_eq!(bak0.read_to_string().unwrap(), r#"{"v": 0}"#); + assert_eq!(bak1.read_to_string().unwrap(), r#"{"v": 1}"#); + assert_eq!(bak2.read_to_string().unwrap(), r#"{"v": 2}"#); } #[test] fn test_backup_openclaw_config_missing_file() { - let result = backup_openclaw_config(Path::new("/nonexistent/openclaw.json")); + let root = VfsPath::new(MemoryFS::new()); + let config_path = root.join("nonexistent/openclaw.json").unwrap(); + let result = backup_openclaw_config_vfs(&config_path); assert!(result.is_err()); } @@ -1152,58 +1218,43 @@ mod tests { #[test] fn test_detect_keys_from_auth_profiles() { - let dir = std::env::temp_dir().join("clawshell_test_detect_auth"); - let _ = std::fs::remove_dir_all(&dir); - let state_dir = dir.join(".openclaw"); - let agent_dir = state_dir.join("agents").join("myagent").join("agent"); - std::fs::create_dir_all(&agent_dir).unwrap(); - + let root = VfsPath::new(MemoryFS::new()); let profiles = serde_json::json!({ "profiles": { "anthropic:default": { "key": "sk-ant-detect-123" }, "openai:default": { "key": "sk-oai-detect-456" } } }); - std::fs::write( - agent_dir.join("auth-profiles.json"), - serde_json::to_string(&profiles).unwrap(), - ) - .unwrap(); + vfs_write( + &root, + "home/user/.openclaw/agents/myagent/agent/auth-profiles.json", + &serde_json::to_string(&profiles).unwrap(), + ); - let keys = detect_openclaw_api_keys_with_home(Some(dir.to_str().unwrap())); + let home = root.join("home/user").unwrap(); + let keys = detect_openclaw_api_keys_vfs(&home); assert_eq!(keys.anthropic.as_deref(), Some("sk-ant-detect-123")); assert_eq!(keys.openai.as_deref(), Some("sk-oai-detect-456")); - - let _ = std::fs::remove_dir_all(&dir); } #[test] fn test_detect_keys_from_dot_env() { - let dir = std::env::temp_dir().join("clawshell_test_detect_dotenv"); - let _ = std::fs::remove_dir_all(&dir); - let state_dir = dir.join(".openclaw"); - std::fs::create_dir_all(&state_dir).unwrap(); - - std::fs::write( - state_dir.join(".env"), + let root = VfsPath::new(MemoryFS::new()); + vfs_write( + &root, + "home/user/.openclaw/.env", "ANTHROPIC_API_KEY=sk-ant-env-789\nOPENAI_API_KEY=sk-oai-env-012\n", - ) - .unwrap(); + ); - let keys = detect_openclaw_api_keys_with_home(Some(dir.to_str().unwrap())); + let home = root.join("home/user").unwrap(); + let keys = detect_openclaw_api_keys_vfs(&home); assert_eq!(keys.anthropic.as_deref(), Some("sk-ant-env-789")); assert_eq!(keys.openai.as_deref(), Some("sk-oai-env-012")); - - let _ = std::fs::remove_dir_all(&dir); } #[test] fn test_detect_keys_auth_profiles_takes_priority_over_dot_env() { - let dir = std::env::temp_dir().join("clawshell_test_detect_priority"); - let _ = std::fs::remove_dir_all(&dir); - let state_dir = dir.join(".openclaw"); - let agent_dir = state_dir.join("agents").join("a1").join("agent"); - std::fs::create_dir_all(&agent_dir).unwrap(); + let root = VfsPath::new(MemoryFS::new()); // auth-profiles has only anthropic let profiles = serde_json::json!({ @@ -1211,83 +1262,69 @@ mod tests { "anthropic:default": { "key": "sk-ant-from-profile" } } }); - std::fs::write( - agent_dir.join("auth-profiles.json"), - serde_json::to_string(&profiles).unwrap(), - ) - .unwrap(); + vfs_write( + &root, + "home/user/.openclaw/agents/a1/agent/auth-profiles.json", + &serde_json::to_string(&profiles).unwrap(), + ); // .env has both - std::fs::write( - state_dir.join(".env"), + vfs_write( + &root, + "home/user/.openclaw/.env", "ANTHROPIC_API_KEY=sk-ant-from-env\nOPENAI_API_KEY=sk-oai-from-env\n", - ) - .unwrap(); + ); - let keys = detect_openclaw_api_keys_with_home(Some(dir.to_str().unwrap())); + let home = root.join("home/user").unwrap(); + let keys = detect_openclaw_api_keys_vfs(&home); // anthropic from auth-profiles wins assert_eq!(keys.anthropic.as_deref(), Some("sk-ant-from-profile")); // openai falls through to .env assert_eq!(keys.openai.as_deref(), Some("sk-oai-from-env")); - - let _ = std::fs::remove_dir_all(&dir); } #[test] fn test_detect_keys_no_state_dir() { - let dir = std::env::temp_dir().join("clawshell_test_detect_none"); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - - // No .openclaw etc. directories exist - let keys = detect_openclaw_api_keys_with_home(Some(dir.to_str().unwrap())); - // Without env vars set for this test, should be None - // (env vars may or may not be set in the test environment, so we just - // verify the function doesn't panic) - let _ = keys; + let root = VfsPath::new(MemoryFS::new()); + // Create a home dir with no .openclaw etc. + root.join("home/user").unwrap().create_dir_all().unwrap(); - let _ = std::fs::remove_dir_all(&dir); + let home = root.join("home/user").unwrap(); + // Should not panic — keys come from env vars (or be None) + let keys = detect_openclaw_api_keys_vfs(&home); + let _ = keys; } #[test] fn test_detect_keys_fallback_state_dirs() { - let dir = std::env::temp_dir().join("clawshell_test_detect_fallback"); - let _ = std::fs::remove_dir_all(&dir); + let root = VfsPath::new(MemoryFS::new()); // Only .clawdbot exists (second candidate) - let state_dir = dir.join(".clawdbot"); - std::fs::create_dir_all(&state_dir).unwrap(); - std::fs::write( - state_dir.join(".env"), + vfs_write( + &root, + "home/user/.clawdbot/.env", "ANTHROPIC_API_KEY=sk-ant-clawdbot\n", - ) - .unwrap(); + ); - let keys = detect_openclaw_api_keys_with_home(Some(dir.to_str().unwrap())); + let home = root.join("home/user").unwrap(); + let keys = detect_openclaw_api_keys_vfs(&home); assert_eq!(keys.anthropic.as_deref(), Some("sk-ant-clawdbot")); - - let _ = std::fs::remove_dir_all(&dir); } #[test] fn test_detect_keys_dot_env_skips_empty_and_comments() { - let dir = std::env::temp_dir().join("clawshell_test_detect_env_parse"); - let _ = std::fs::remove_dir_all(&dir); - let state_dir = dir.join(".openclaw"); - std::fs::create_dir_all(&state_dir).unwrap(); - - std::fs::write( - state_dir.join(".env"), + let root = VfsPath::new(MemoryFS::new()); + vfs_write( + &root, + "home/user/.openclaw/.env", "# comment\n\nANTHROPIC_API_KEY=\"sk-quoted\"\nOPENAI_API_KEY=\n", - ) - .unwrap(); + ); - let keys = detect_openclaw_api_keys_with_home(Some(dir.to_str().unwrap())); + let home = root.join("home/user").unwrap(); + let keys = detect_openclaw_api_keys_vfs(&home); assert_eq!(keys.anthropic.as_deref(), Some("sk-quoted")); // Empty value should be skipped assert!(keys.openai.is_none() || keys.openai.as_deref() != Some("")); - - let _ = std::fs::remove_dir_all(&dir); } #[test] @@ -1347,9 +1384,7 @@ mod tests { #[test] fn test_load_existing_config_from_temp_dir() { - let dir = std::env::temp_dir().join("clawshell_test_load_existing"); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); + let root = VfsPath::new(MemoryFS::new()); // Write config.json let config_json = serde_json::json!({ @@ -1359,20 +1394,21 @@ mod tests { "virtual_api_key": "{clawshell-virtual-key-anthropic}", "openclaw_config_path": "/home/user/.openclaw/openclaw.json" }); - std::fs::write( - dir.join("config.json"), - serde_json::to_string_pretty(&config_json).unwrap(), - ) - .unwrap(); + vfs_write( + &root, + "etc/clawshell/config.json", + &serde_json::to_string_pretty(&config_json).unwrap(), + ); // Write clawshell.toml - std::fs::write( - dir.join("clawshell.toml"), + vfs_write( + &root, + "etc/clawshell/clawshell.toml", "[server]\nhost = \"0.0.0.0\"\nport = 9999\n", - ) - .unwrap(); + ); - let existing = load_existing_config_from(&dir).unwrap(); + let config_dir = root.join("etc/clawshell").unwrap(); + let existing = load_existing_config_from_vfs(&config_dir).unwrap(); assert_eq!(existing.provider.as_deref(), Some("anthropic")); assert_eq!( existing.model.as_deref(), @@ -1389,41 +1425,37 @@ mod tests { ); assert_eq!(existing.server_host.as_deref(), Some("0.0.0.0")); assert_eq!(existing.server_port.as_deref(), Some("9999")); - - let _ = std::fs::remove_dir_all(&dir); } #[test] fn test_load_existing_config_from_empty_dir() { - let dir = std::env::temp_dir().join("clawshell_test_load_existing_empty"); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - - let result = load_existing_config_from(&dir); + let root = VfsPath::new(MemoryFS::new()); + root.join("etc/clawshell") + .unwrap() + .create_dir_all() + .unwrap(); + + let config_dir = root.join("etc/clawshell").unwrap(); + let result = load_existing_config_from_vfs(&config_dir); assert!(result.is_none()); - - let _ = std::fs::remove_dir_all(&dir); } #[test] fn test_load_existing_config_from_partial() { - let dir = std::env::temp_dir().join("clawshell_test_load_existing_partial"); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); + let root = VfsPath::new(MemoryFS::new()); // Only clawshell.toml, no config.json - std::fs::write( - dir.join("clawshell.toml"), + vfs_write( + &root, + "etc/clawshell/clawshell.toml", "[server]\nhost = \"127.0.0.1\"\nport = 18790\n", - ) - .unwrap(); + ); - let existing = load_existing_config_from(&dir).unwrap(); + let config_dir = root.join("etc/clawshell").unwrap(); + let existing = load_existing_config_from_vfs(&config_dir).unwrap(); assert!(existing.provider.is_none()); assert_eq!(existing.server_host.as_deref(), Some("127.0.0.1")); assert_eq!(existing.server_port.as_deref(), Some("18790")); - - let _ = std::fs::remove_dir_all(&dir); } #[test] diff --git a/src/process.rs b/src/process.rs index c66a540..a71955a 100644 --- a/src/process.rs +++ b/src/process.rs @@ -2,9 +2,11 @@ use nix::sys::signal::{self, Signal}; use nix::unistd::{Gid, Pid, Uid, User, getuid, setgid, setuid}; use nix::unistd::{SysconfVar, sysconf}; use std::fs; +use std::io::Write as _; use std::path::PathBuf; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tracing::info; +use vfs::VfsPath; /// Default configuration directory. pub const CONFIG_DIR: &str = "/etc/clawshell"; @@ -14,6 +16,25 @@ pub fn default_config_path() -> PathBuf { PathBuf::from(CONFIG_DIR).join("clawshell.toml") } +/// Create a VFS root backed by the real filesystem. +pub(crate) fn physical_root() -> VfsPath { + VfsPath::new(vfs::PhysicalFS::new("/")) +} + +/// PID file path within a VFS root. +fn pid_file_vfs(root: &VfsPath) -> Result> { + if cfg!(target_os = "macos") { + Ok(root.join("var/run/clawshell.pid")?) + } else { + Ok(root.join("run/clawshell/clawshell.pid")?) + } +} + +/// Log file path within a VFS root. +fn log_file_vfs(root: &VfsPath) -> Result> { + Ok(root.join("var/log/clawshell/clawshell.log")?) +} + /// PID file location. /// - Linux: /run/clawshell/clawshell.pid /// - macOS: /var/run/clawshell.pid (flat, no subdirectory since /var/run is a symlink to /private/var/run) @@ -32,30 +53,53 @@ pub fn log_file_path() -> PathBuf { PathBuf::from("/var/log/clawshell/clawshell.log") } +/// Ensure the parent directories for PID and log files exist (VFS variant). +pub(crate) fn ensure_runtime_dirs_vfs(root: &VfsPath) -> Result<(), Box> { + let pid_path = pid_file_vfs(root)?; + pid_path.parent().create_dir_all()?; + let log_path = log_file_vfs(root)?; + log_path.parent().create_dir_all()?; + Ok(()) +} + /// Ensure the parent directories for PID and log files exist. pub fn ensure_runtime_dirs() -> Result<(), Box> { - if let Some(parent) = pid_file_path().parent() { - fs::create_dir_all(parent)?; - } - if let Some(parent) = log_file_path().parent() { - fs::create_dir_all(parent)?; - } + ensure_runtime_dirs_vfs(&physical_root()) +} + +/// Write a PID file (VFS variant). +pub(crate) fn write_pid_file_vfs( + root: &VfsPath, + pid: u32, +) -> Result<(), Box> { + let path = pid_file_vfs(root)?; + path.create_file()?.write_all(pid.to_string().as_bytes())?; Ok(()) } pub fn write_pid_file(pid: u32) -> Result<(), Box> { - fs::write(pid_file_path(), pid.to_string())?; - Ok(()) + write_pid_file_vfs(&physical_root(), pid) +} + +/// Read the PID from the PID file (VFS variant). +pub(crate) fn read_pid_file_vfs(root: &VfsPath) -> Option { + let path = pid_file_vfs(root).ok()?; + path.read_to_string().ok()?.trim().parse().ok() } pub fn read_pid_file() -> Option { - fs::read_to_string(pid_file_path()) - .ok() - .and_then(|s| s.trim().parse().ok()) + read_pid_file_vfs(&physical_root()) +} + +/// Remove the PID file (VFS variant). +pub(crate) fn remove_pid_file_vfs(root: &VfsPath) { + if let Ok(path) = pid_file_vfs(root) { + let _ = path.remove_file(); + } } pub fn remove_pid_file() { - let _ = fs::remove_file(pid_file_path()); + remove_pid_file_vfs(&physical_root()) } fn to_pid(pid: u32) -> Result> { @@ -166,6 +210,7 @@ mod tests { use super::*; use std::path::Path; use std::process; + use vfs::MemoryFS; #[test] fn test_format_duration_seconds() { @@ -221,27 +266,16 @@ mod tests { #[test] fn test_write_and_read_pid_file() { - let pid_path = pid_file_path(); - // Ensure the PID directory exists and is writable - if let Some(parent) = pid_path.parent() { - if fs::create_dir_all(parent).is_err() { - eprintln!("Skipping test_write_and_read_pid_file: cannot create PID dir"); - return; - } - // Check we can actually write in this directory - let probe = parent.join(".clawshell_write_probe"); - if fs::write(&probe, b"").is_err() { - eprintln!("Skipping test_write_and_read_pid_file: PID dir not writable"); - return; - } - let _ = fs::remove_file(&probe); - } - let test_pid = process::id(); - write_pid_file(test_pid).unwrap(); - let read_pid = read_pid_file().unwrap(); + let root = VfsPath::new(MemoryFS::new()); + ensure_runtime_dirs_vfs(&root).unwrap(); + + let test_pid = 12345u32; + write_pid_file_vfs(&root, test_pid).unwrap(); + let read_pid = read_pid_file_vfs(&root).unwrap(); assert_eq!(read_pid, test_pid); - remove_pid_file(); - assert!(read_pid_file().is_none()); + + remove_pid_file_vfs(&root); + assert!(read_pid_file_vfs(&root).is_none()); } #[test] @@ -258,13 +292,13 @@ mod tests { #[test] fn test_ensure_runtime_dirs() { - // This may fail without root, but should not panic - let result = ensure_runtime_dirs(); - // If we have permissions it succeeds; if not, it returns an error - if result.is_ok() { - assert!(pid_file_path().parent().unwrap().exists()); - assert!(log_file_path().parent().unwrap().exists()); - } + let root = VfsPath::new(MemoryFS::new()); + ensure_runtime_dirs_vfs(&root).unwrap(); + + let pid_parent = pid_file_vfs(&root).unwrap().parent(); + assert!(pid_parent.exists().unwrap()); + let log_parent = log_file_vfs(&root).unwrap().parent(); + assert!(log_parent.exists().unwrap()); } #[test]