diff --git a/src/lib.rs b/src/lib.rs index f21342856..42992a1ac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -69,6 +69,7 @@ pub mod runtime; pub(crate) mod security; pub(crate) mod service; pub(crate) mod skills; +pub mod test_capabilities; pub mod tools; pub(crate) mod tunnel; pub mod update; diff --git a/src/providers/anthropic.rs b/src/providers/anthropic.rs index ed3d60d85..092ce50b9 100644 --- a/src/providers/anthropic.rs +++ b/src/providers/anthropic.rs @@ -1287,7 +1287,15 @@ mod tests { }), ); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + if let Err(err) = std::net::TcpListener::bind("127.0.0.1:0") { + let reason = format!("loopback bind unavailable: {err}"); + eprintln!("Skipping loopback-dependent Anthropic test: {reason}"); + return; + } + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("loopback bind should be available after capability check"); let addr = listener.local_addr().unwrap(); let server_handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); diff --git a/src/test_capabilities.rs b/src/test_capabilities.rs new file mode 100644 index 000000000..3771f060a --- /dev/null +++ b/src/test_capabilities.rs @@ -0,0 +1,56 @@ +//! Lightweight capability probes used by tests in constrained environments. +//! +//! These helpers let tests skip gracefully when sandbox restrictions prevent +//! operations like loopback binds or writable-home access. + +use std::env; +use std::fs; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Return the configured home directory from environment variables. +pub fn home_dir_from_env() -> Option { + env::var_os("HOME") + .or_else(|| env::var_os("USERPROFILE")) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +/// Check that a directory is writable by creating and deleting a tiny probe file. +pub fn check_writable_dir(path: &Path) -> Result<(), String> { + fs::create_dir_all(path).map_err(|err| { + format!( + "failed to create directory {} for capability probe: {err}", + path.display() + ) + })?; + + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + let probe_name = format!(".zeroclaw-capability-probe-{}-{nanos}", std::process::id()); + let probe_path = path.join(probe_name); + + fs::write(&probe_path, b"probe") + .map_err(|err| format!("failed to write probe file {}: {err}", probe_path.display()))?; + + if let Err(err) = fs::remove_file(&probe_path) { + return Err(format!( + "failed to clean up probe file {}: {err}", + probe_path.display() + )); + } + + Ok(()) +} + +/// Verify loopback bind capability for local mock servers used in tests. +pub fn check_loopback_bind() -> Result<(), String> { + TcpListener::bind("127.0.0.1:0") + .map(|listener| { + drop(listener); + }) + .map_err(|err| format!("loopback bind unavailable: {err}")) +} diff --git a/tests/gemini_fallback_oauth_refresh.rs b/tests/gemini_fallback_oauth_refresh.rs index fde98dbea..e6576d01d 100644 --- a/tests/gemini_fallback_oauth_refresh.rs +++ b/tests/gemini_fallback_oauth_refresh.rs @@ -35,7 +35,16 @@ use std::path::PathBuf; #[ignore = "requires live Gemini OAuth credentials with refresh_token"] async fn gemini_warmup_refreshes_expired_oauth_token() -> Result<()> { // Find ~/.zeroclaw/auth-profiles.json - let home = env::var("HOME").expect("HOME env var not set"); + let Some(home) = zeroclaw::test_capabilities::home_dir_from_env() else { + eprintln!("⚠️ Skipping test: neither HOME nor USERPROFILE is set"); + return Ok(()); + }; + + if let Err(reason) = zeroclaw::test_capabilities::check_writable_dir(&home) { + eprintln!("⚠️ Skipping test: home directory is not writable ({reason})"); + return Ok(()); + } + let zeroclaw_dir = PathBuf::from(home).join(".zeroclaw"); let auth_profiles_path = zeroclaw_dir.join("auth-profiles.json");