use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::fs;
use crate::secrets::SecretsStore;
use crate::tools::registry::{ToolRegistry, WasmRegistrationError, WasmToolRegistration};
use crate::tools::wasm::capabilities_schema::CapabilitiesFile;
use crate::tools::wasm::{
Capabilities, OAuthRefreshConfig, WasmError, WasmStorageError, WasmToolRuntime, WasmToolStore,
};
#[derive(Debug, thiserror::Error)]
pub enum WasmLoadError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("WASM file not found: {0}")]
WasmNotFound(PathBuf),
#[error("Capabilities file not found: {0}")]
CapabilitiesNotFound(PathBuf),
#[error("Invalid capabilities JSON: {0}")]
InvalidCapabilities(String),
#[error("WASM compilation error: {0}")]
Compilation(#[from] WasmError),
#[error("Storage error: {0}")]
Storage(#[from] WasmStorageError),
#[error("Registration error: {0}")]
Registration(#[from] WasmRegistrationError),
#[error("Invalid tool name: {0}")]
InvalidName(String),
#[error("WIT version mismatch: {0}")]
WitVersionMismatch(String),
}
pub struct WasmToolLoader {
runtime: Arc<WasmToolRuntime>,
registry: Arc<ToolRegistry>,
secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
}
impl WasmToolLoader {
pub fn new(runtime: Arc<WasmToolRuntime>, registry: Arc<ToolRegistry>) -> Self {
Self {
runtime,
registry,
secrets_store: None,
}
}
pub fn with_secrets_store(mut self, store: Arc<dyn SecretsStore + Send + Sync>) -> Self {
self.secrets_store = Some(store);
self
}
pub async fn load_from_files(
&self,
name: &str,
wasm_path: &Path,
capabilities_path: Option<&Path>,
) -> Result<(), WasmLoadError> {
if name.is_empty() || name.contains('/') || name.contains('\\') {
return Err(WasmLoadError::InvalidName(name.to_string()));
}
if !wasm_path.exists() {
return Err(WasmLoadError::WasmNotFound(wasm_path.to_path_buf()));
}
let wasm_bytes = fs::read(wasm_path).await?;
let (capabilities, oauth_refresh, description) = if let Some(cap_path) = capabilities_path {
if cap_path.exists() {
let cap_bytes = fs::read(cap_path).await?;
let cap_file = CapabilitiesFile::from_bytes(&cap_bytes)
.map_err(|e| WasmLoadError::InvalidCapabilities(e.to_string()))?;
cap_file.validate(name);
check_wit_version_compat(
name,
cap_file.wit_version.as_deref(),
crate::tools::wasm::WIT_TOOL_VERSION,
)?;
let caps = cap_file.to_capabilities();
let oauth = resolve_oauth_refresh_config(&cap_file);
let desc = cap_file.description.clone();
if desc.is_none() {
tracing::warn!(
tool = name,
path = %cap_path.display(),
"Capabilities file missing \"description\" field; \
tool will use generic fallback description"
);
}
(caps, oauth, desc)
} else {
tracing::warn!(
tool = name,
path = %cap_path.display(),
"Capabilities file not found, using default (no permissions)"
);
(Capabilities::default(), None, None)
}
} else {
tracing::warn!(
tool = name,
"No capabilities file for WASM tool; \
tool will use generic fallback description"
);
(Capabilities::default(), None, None)
};
self.registry
.register_wasm(WasmToolRegistration {
name,
wasm_bytes: &wasm_bytes,
runtime: &self.runtime,
capabilities,
limits: None,
description: description.as_deref(),
schema: None,
secrets_store: self.secrets_store.clone(),
oauth_refresh,
})
.await?;
tracing::debug!(
name = name,
wasm_path = %wasm_path.display(),
"Loaded WASM tool from file"
);
Ok(())
}
pub async fn load_from_dir(&self, dir: &Path) -> Result<LoadResults, WasmLoadError> {
match fs::metadata(dir).await {
Ok(meta) if meta.is_dir() => {}
Ok(_) => {
return Err(WasmLoadError::Io(std::io::Error::new(
std::io::ErrorKind::NotADirectory,
format!("{} is not a directory", dir.display()),
)));
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(LoadResults::default());
}
Err(e) => return Err(WasmLoadError::Io(e)),
}
let mut entries = match fs::read_dir(dir).await {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(LoadResults::default());
}
Err(e) => return Err(WasmLoadError::Io(e)),
};
let mut results = LoadResults::default();
let mut tool_entries = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("wasm") {
continue;
}
let name = match path.file_stem().and_then(|s| s.to_str()) {
Some(n) => n.to_string(),
None => {
results.errors.push((
path.clone(),
WasmLoadError::InvalidName("invalid filename".to_string()),
));
continue;
}
};
let cap_path = path.with_extension("capabilities.json");
let has_cap = cap_path.exists();
tool_entries.push((name, path, if has_cap { Some(cap_path) } else { None }));
}
let load_futures = tool_entries
.iter()
.map(|(name, path, cap_path)| self.load_from_files(name, path, cap_path.as_deref()));
let load_results = futures::future::join_all(load_futures).await;
for ((name, path, _), result) in tool_entries.into_iter().zip(load_results) {
match result {
Ok(()) => {
results.loaded.push(name);
}
Err(e) => {
tracing::error!(
name = name,
path = %path.display(),
error = %e,
"Failed to load WASM tool"
);
results.errors.push((path, e));
}
}
}
if !results.loaded.is_empty() {
tracing::debug!(
count = results.loaded.len(),
tools = ?results.loaded,
"Loaded WASM tools from directory"
);
}
Ok(results)
}
pub async fn load_from_storage(
&self,
store: &dyn WasmToolStore,
user_id: &str,
tool_name: &str,
) -> Result<(), WasmLoadError> {
self.registry
.register_wasm_from_storage(store, &self.runtime, user_id, tool_name)
.await?;
tracing::info!(
user_id = user_id,
name = tool_name,
"Loaded WASM tool from storage"
);
Ok(())
}
pub async fn load_all_from_storage(
&self,
store: &dyn WasmToolStore,
user_id: &str,
) -> Result<LoadResults, WasmLoadError> {
let tools = store.list(user_id).await?;
let mut results = LoadResults::default();
for tool in tools {
if tool.status != crate::tools::wasm::ToolStatus::Active {
continue;
}
match self.load_from_storage(store, user_id, &tool.name).await {
Ok(()) => {
results.loaded.push(tool.name);
}
Err(e) => {
tracing::error!(
name = tool.name,
user_id = user_id,
error = %e,
"Failed to load WASM tool from storage"
);
results.errors.push((PathBuf::from(&tool.name), e));
}
}
}
Ok(results)
}
}
pub fn check_wit_version_compat(
name: &str,
declared: Option<&str>,
host_version: &str,
) -> Result<(), WasmLoadError> {
let Some(declared_str) = declared else {
return Ok(());
};
let declared = semver::Version::parse(declared_str).map_err(|e| {
WasmLoadError::WitVersionMismatch(format!(
"Extension '{name}' has invalid wit_version '{declared_str}': {e}"
))
})?;
let host = semver::Version::parse(host_version).map_err(|e| {
WasmLoadError::WitVersionMismatch(format!(
"Host WIT version '{host_version}' is invalid: {e}"
))
})?;
if declared.major != host.major {
return Err(WasmLoadError::WitVersionMismatch(format!(
"Extension '{name}' compiled against WIT {declared}, but host supports WIT {host}. \
Major version mismatch — rebuild the extension."
)));
}
if declared.major == 0 && declared.minor != host.minor {
return Err(WasmLoadError::WitVersionMismatch(format!(
"Extension '{name}' compiled against WIT {declared}, but host supports WIT {host}. \
Rebuild the extension against the current WIT."
)));
}
if declared > host {
return Err(WasmLoadError::WitVersionMismatch(format!(
"Extension '{name}' compiled against WIT {declared}, but host only supports WIT {host}. \
Update the host or rebuild with an older WIT."
)));
}
Ok(())
}
fn resolve_oauth_refresh_config(cap_file: &CapabilitiesFile) -> Option<OAuthRefreshConfig> {
let auth = cap_file.auth.as_ref()?;
let oauth = auth.oauth.as_ref()?;
let builtin = crate::cli::oauth_defaults::builtin_credentials(&auth.secret_name);
let exchange_proxy_url = crate::cli::oauth_defaults::exchange_proxy_url();
let client_id = oauth
.client_id
.clone()
.or_else(|| {
oauth
.client_id_env
.as_ref()
.and_then(|env| std::env::var(env).ok())
})
.or_else(|| builtin.as_ref().map(|c| c.client_id.to_string()))?;
let client_secret = oauth
.client_secret
.clone()
.or_else(|| {
oauth
.client_secret_env
.as_ref()
.and_then(|env| std::env::var(env).ok())
})
.or_else(|| builtin.as_ref().map(|c| c.client_secret.to_string()));
let client_secret = crate::cli::oauth_defaults::hosted_proxy_client_secret(
&client_secret,
builtin.as_ref(),
exchange_proxy_url.is_some(),
);
let gateway_token = crate::config::helpers::env_or_override("GATEWAY_AUTH_TOKEN")
.map(|token| token.trim().to_string())
.filter(|token| !token.is_empty());
Some(OAuthRefreshConfig {
token_url: oauth.token_url.clone(),
client_id,
client_secret,
exchange_proxy_url,
gateway_token,
secret_name: auth.secret_name.clone(),
provider: auth.provider.clone(),
})
}
#[derive(Debug, Default)]
pub struct LoadResults {
pub loaded: Vec<String>,
pub errors: Vec<(PathBuf, WasmLoadError)>,
}
impl LoadResults {
pub fn all_succeeded(&self) -> bool {
self.errors.is_empty()
}
pub fn success_count(&self) -> usize {
self.loaded.len()
}
pub fn error_count(&self) -> usize {
self.errors.len()
}
}
const CARGO_MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR");
pub fn resolve_wasm_target_dir(crate_dir: &Path) -> PathBuf {
crate::registry::artifacts::resolve_target_dir(crate_dir)
}
pub fn wasm_artifact_path(crate_dir: &Path, binary_name: &str) -> PathBuf {
resolve_wasm_target_dir(crate_dir)
.join("wasm32-wasip2/release")
.join(format!("{}.wasm", binary_name))
}
fn tools_src_dir() -> PathBuf {
if let Ok(dir) = std::env::var("IRONCLAW_TOOLS_SRC") {
return PathBuf::from(dir);
}
PathBuf::from(CARGO_MANIFEST_DIR).join("tools-src")
}
pub async fn discover_dev_tools() -> Result<HashMap<String, DiscoveredTool>, std::io::Error> {
let src_dir = tools_src_dir();
let mut tools = HashMap::new();
if !src_dir.is_dir() {
return Ok(tools);
}
let mut entries = fs::read_dir(&src_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if !path.is_dir() {
continue;
}
let dir_name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
let crate_name = dir_name.replace('-', "_");
let install_name = format!("{}-tool", dir_name);
let wasm_path = wasm_artifact_path(&path, &format!("{}_tool", crate_name));
if !wasm_path.exists() {
continue;
}
let caps_path = path.join(format!("{}-tool.capabilities.json", dir_name));
tools.insert(
install_name,
DiscoveredTool {
wasm_path,
capabilities_path: if caps_path.exists() {
Some(caps_path)
} else {
None
},
},
);
}
Ok(tools)
}
pub async fn load_dev_tools(
loader: &WasmToolLoader,
install_dir: &Path,
) -> Result<LoadResults, WasmLoadError> {
let dev_tools = discover_dev_tools().await?;
let mut results = LoadResults::default();
if dev_tools.is_empty() {
return Ok(results);
}
for (name, discovered) in &dev_tools {
let installed_path = install_dir.join(format!("{}.wasm", name));
let should_load = if installed_path.exists() {
match (
fs::metadata(&discovered.wasm_path).await,
fs::metadata(&installed_path).await,
) {
(Ok(dev_meta), Ok(inst_meta)) => {
let dev_modified = dev_meta.modified().unwrap_or(std::time::UNIX_EPOCH);
let inst_modified = inst_meta.modified().unwrap_or(std::time::UNIX_EPOCH);
dev_modified > inst_modified
}
_ => true,
}
} else {
true
};
if !should_load {
continue;
}
tracing::info!(
name = name,
wasm_path = %discovered.wasm_path.display(),
"Loading dev tool from build artifacts (newer than installed)"
);
match loader
.load_from_files(
name,
&discovered.wasm_path,
discovered.capabilities_path.as_deref(),
)
.await
{
Ok(()) => {
results.loaded.push(name.clone());
}
Err(e) => {
tracing::error!(
name = name,
error = %e,
"Failed to load dev tool"
);
results.errors.push((discovered.wasm_path.clone(), e));
}
}
}
if !results.loaded.is_empty() {
tracing::info!(
count = results.loaded.len(),
tools = ?results.loaded,
"Loaded dev tools from build artifacts"
);
}
Ok(results)
}
pub async fn discover_tools(dir: &Path) -> Result<HashMap<String, DiscoveredTool>, std::io::Error> {
let mut tools = HashMap::new();
if !dir.is_dir() {
return Ok(tools);
}
let mut entries = fs::read_dir(dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("wasm") {
continue;
}
let name = match path.file_stem().and_then(|s| s.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
let cap_path = path.with_extension("capabilities.json");
tools.insert(
name,
DiscoveredTool {
wasm_path: path,
capabilities_path: if cap_path.exists() {
Some(cap_path)
} else {
None
},
},
);
}
Ok(tools)
}
#[derive(Debug)]
pub struct DiscoveredTool {
pub wasm_path: PathBuf,
pub capabilities_path: Option<PathBuf>,
}
#[cfg(test)]
mod tests {
use std::io::Write;
use tempfile::TempDir;
use crate::config::helpers::lock_env;
use crate::testing::credentials::{TEST_OAUTH_CLIENT_ID, TEST_OAUTH_CLIENT_SECRET};
use crate::tools::wasm::loader::{WasmLoadError, check_wit_version_compat, discover_tools};
struct EnvVarGuard {
key: String,
previous: Option<String>,
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
unsafe {
if let Some(ref value) = self.previous {
std::env::set_var(&self.key, value);
} else {
std::env::remove_var(&self.key);
}
}
}
}
fn set_env_var(key: &str, value: Option<&str>) -> EnvVarGuard {
let previous = std::env::var(key).ok();
unsafe {
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
EnvVarGuard {
key: key.to_string(),
previous,
}
}
#[test]
fn wit_version_compat_none_is_ok() {
assert!(check_wit_version_compat("test", None, "0.2.0").is_ok());
}
#[test]
fn wit_version_compat_exact_match() {
assert!(check_wit_version_compat("test", Some("0.2.0"), "0.2.0").is_ok());
}
#[test]
fn wit_version_compat_patch_older_ok() {
assert!(check_wit_version_compat("test", Some("0.2.0"), "0.2.1").is_ok());
}
#[test]
fn wit_version_compat_minor_mismatch_0x() {
assert!(check_wit_version_compat("test", Some("0.1.0"), "0.2.0").is_err());
assert!(check_wit_version_compat("test", Some("0.3.0"), "0.2.0").is_err());
}
#[test]
fn wit_version_compat_major_mismatch() {
assert!(check_wit_version_compat("test", Some("1.0.0"), "2.0.0").is_err());
}
#[test]
fn wit_version_compat_extension_newer_than_host() {
assert!(check_wit_version_compat("test", Some("0.2.1"), "0.2.0").is_err());
}
#[test]
fn wit_version_compat_invalid_version() {
assert!(check_wit_version_compat("test", Some("not-a-version"), "0.2.0").is_err());
}
#[tokio::test]
async fn test_discover_tools_empty_dir() {
let dir = TempDir::new().unwrap();
let tools = discover_tools(dir.path()).await.unwrap();
assert!(tools.is_empty());
}
#[tokio::test]
async fn test_discover_tools_with_wasm() {
let dir = TempDir::new().unwrap();
let wasm_path = dir.path().join("test_tool.wasm");
std::fs::File::create(&wasm_path).unwrap();
let tools = discover_tools(dir.path()).await.unwrap();
assert_eq!(tools.len(), 1);
assert!(tools.contains_key("test_tool"));
assert!(tools["test_tool"].capabilities_path.is_none());
}
#[tokio::test]
async fn test_discover_tools_with_capabilities() {
let dir = TempDir::new().unwrap();
std::fs::File::create(dir.path().join("slack.wasm")).unwrap();
let mut cap_file =
std::fs::File::create(dir.path().join("slack.capabilities.json")).unwrap();
cap_file.write_all(b"{}").unwrap();
let tools = discover_tools(dir.path()).await.unwrap();
assert_eq!(tools.len(), 1);
assert!(tools["slack"].capabilities_path.is_some());
}
#[tokio::test]
async fn test_discover_tools_ignores_non_wasm() {
let dir = TempDir::new().unwrap();
std::fs::File::create(dir.path().join("readme.md")).unwrap();
std::fs::File::create(dir.path().join("config.json")).unwrap();
std::fs::File::create(dir.path().join("tool.wasm")).unwrap();
let tools = discover_tools(dir.path()).await.unwrap();
assert_eq!(tools.len(), 1);
assert!(tools.contains_key("tool"));
}
#[test]
fn test_load_error_display() {
let err = WasmLoadError::InvalidName("bad/name".to_string());
assert!(err.to_string().contains("bad/name"));
let err = WasmLoadError::WasmNotFound(std::path::PathBuf::from("/foo/bar.wasm"));
assert!(err.to_string().contains("/foo/bar.wasm"));
}
#[test]
fn test_tools_src_dir_default() {
let dir = super::tools_src_dir();
assert!(dir.ends_with("tools-src"));
}
#[tokio::test]
async fn test_discover_dev_tools_finds_build_artifacts() {
let tools = super::discover_dev_tools().await.unwrap();
for (name, discovered) in &tools {
assert!(
name.ends_with("-tool"),
"Dev tool name should end with -tool: {}",
name
);
assert!(
discovered.wasm_path.exists(),
"WASM should exist: {:?}",
discovered.wasm_path
);
}
}
#[test]
fn test_resolve_oauth_refresh_config_with_oauth() {
use crate::tools::wasm::capabilities_schema::{
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema,
};
let caps = CapabilitiesFile {
auth: Some(AuthCapabilitySchema {
secret_name: "google_oauth_token".to_string(),
provider: Some("google".to_string()),
oauth: Some(OAuthConfigSchema {
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
token_url: "https://oauth2.googleapis.com/token".to_string(),
client_id: Some(TEST_OAUTH_CLIENT_ID.to_string()),
client_secret: Some(TEST_OAUTH_CLIENT_SECRET.to_string()),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let config = super::resolve_oauth_refresh_config(&caps);
assert!(config.is_some());
let config = config.unwrap();
assert_eq!(config.token_url, "https://oauth2.googleapis.com/token");
assert_eq!(config.client_id, TEST_OAUTH_CLIENT_ID);
assert_eq!(
config.client_secret,
Some(TEST_OAUTH_CLIENT_SECRET.to_string())
);
assert_eq!(config.exchange_proxy_url, None);
assert_eq!(config.gateway_token, None);
assert_eq!(config.secret_name, "google_oauth_token");
assert_eq!(config.provider, Some("google".to_string()));
}
#[test]
fn test_resolve_oauth_refresh_config_no_auth() {
use crate::tools::wasm::capabilities_schema::CapabilitiesFile;
let caps = CapabilitiesFile::default();
let config = super::resolve_oauth_refresh_config(&caps);
assert!(config.is_none());
}
#[test]
fn test_resolve_oauth_refresh_config_no_oauth() {
use crate::tools::wasm::capabilities_schema::{AuthCapabilitySchema, CapabilitiesFile};
let caps = CapabilitiesFile {
auth: Some(AuthCapabilitySchema {
secret_name: "manual_token".to_string(),
..Default::default()
}),
..Default::default()
};
let config = super::resolve_oauth_refresh_config(&caps);
assert!(config.is_none());
}
#[test]
fn test_resolve_oauth_refresh_config_no_client_id() {
use crate::tools::wasm::capabilities_schema::{
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema,
};
let caps = CapabilitiesFile {
auth: Some(AuthCapabilitySchema {
secret_name: "unknown_provider_token".to_string(),
oauth: Some(OAuthConfigSchema {
authorization_url: "https://example.com/auth".to_string(),
token_url: "https://example.com/token".to_string(),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let config = super::resolve_oauth_refresh_config(&caps);
assert!(config.is_none());
}
#[test]
fn test_resolve_oauth_refresh_config_builtin_google() {
use crate::tools::wasm::capabilities_schema::{
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema,
};
let _guard = lock_env();
let _proxy_guard = set_env_var("IRONCLAW_OAUTH_EXCHANGE_URL", None);
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", None);
let caps = CapabilitiesFile {
auth: Some(AuthCapabilitySchema {
secret_name: "google_oauth_token".to_string(),
provider: Some("google".to_string()),
oauth: Some(OAuthConfigSchema {
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
token_url: "https://oauth2.googleapis.com/token".to_string(),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let config = super::resolve_oauth_refresh_config(&caps);
assert!(config.is_some());
let config = config.unwrap();
assert!(!config.client_id.is_empty());
assert!(config.client_secret.is_some());
assert_eq!(config.exchange_proxy_url, None);
assert_eq!(config.gateway_token, None);
}
#[test]
fn test_resolve_oauth_refresh_config_hosted_proxy_populates_env_and_suppresses_builtin_secret()
{
use crate::tools::wasm::capabilities_schema::{
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema,
};
let _guard = lock_env();
let _proxy_guard = set_env_var(
"IRONCLAW_OAUTH_EXCHANGE_URL",
Some("https://compose-api.example.com"),
);
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
let _client_id_guard =
set_env_var("GOOGLE_OAUTH_CLIENT_ID", Some("hosted-google-client-id"));
let caps = CapabilitiesFile {
auth: Some(AuthCapabilitySchema {
secret_name: "google_oauth_token".to_string(),
provider: Some("google".to_string()),
oauth: Some(OAuthConfigSchema {
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
token_url: "https://oauth2.googleapis.com/token".to_string(),
client_id_env: Some("GOOGLE_OAUTH_CLIENT_ID".to_string()),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let config = super::resolve_oauth_refresh_config(&caps).expect("hosted oauth config");
assert_eq!(config.client_id, "hosted-google-client-id");
assert_eq!(config.client_secret, None);
assert_eq!(
config.exchange_proxy_url.as_deref(),
Some("https://compose-api.example.com")
);
assert_eq!(config.gateway_token.as_deref(), Some("gateway-test-token"));
}
#[test]
fn test_resolve_oauth_refresh_config_hosted_proxy_preserves_explicit_secret() {
use crate::tools::wasm::capabilities_schema::{
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema,
};
let _guard = lock_env();
let _proxy_guard = set_env_var(
"IRONCLAW_OAUTH_EXCHANGE_URL",
Some("https://compose-api.example.com"),
);
let _gateway_token_guard = set_env_var("GATEWAY_AUTH_TOKEN", Some("gateway-test-token"));
let _client_id_guard =
set_env_var("GOOGLE_OAUTH_CLIENT_ID", Some("hosted-google-client-id"));
let _client_secret_guard =
set_env_var("GOOGLE_OAUTH_CLIENT_SECRET", Some("hosted-server-secret"));
let caps = CapabilitiesFile {
auth: Some(AuthCapabilitySchema {
secret_name: "google_oauth_token".to_string(),
provider: Some("google".to_string()),
oauth: Some(OAuthConfigSchema {
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
token_url: "https://oauth2.googleapis.com/token".to_string(),
client_id_env: Some("GOOGLE_OAUTH_CLIENT_ID".to_string()),
client_secret_env: Some("GOOGLE_OAUTH_CLIENT_SECRET".to_string()),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let config = super::resolve_oauth_refresh_config(&caps).expect("hosted oauth config");
assert_eq!(config.client_id, "hosted-google-client-id");
assert_eq!(
config.client_secret.as_deref(),
Some("hosted-server-secret")
);
assert_eq!(
config.exchange_proxy_url.as_deref(),
Some("https://compose-api.example.com")
);
assert_eq!(config.gateway_token.as_deref(), Some("gateway-test-token"));
}
use std::sync::Arc;
use crate::tools::registry::ToolRegistry;
use crate::tools::wasm::{WasmRuntimeConfig, WasmToolRuntime};
fn make_loader() -> super::WasmToolLoader {
let runtime = Arc::new(
WasmToolRuntime::new(WasmRuntimeConfig::for_testing())
.expect("failed to create WASM runtime for test"),
);
let registry = Arc::new(ToolRegistry::new());
super::WasmToolLoader::new(runtime, registry)
}
#[tokio::test]
async fn test_tool_name_rejects_path_separators() {
let dir = TempDir::new().unwrap();
let wasm_path = dir.path().join("dummy.wasm");
std::fs::File::create(&wasm_path).unwrap();
let loader = make_loader();
for bad_name in &["../evil", "foo/bar", "foo\\bar"] {
let result = loader.load_from_files(bad_name, &wasm_path, None).await;
assert!(
result.is_err(),
"Expected error for name {:?}, got Ok",
bad_name
);
let err = result.unwrap_err();
assert!(
matches!(err, WasmLoadError::InvalidName(_)),
"Expected InvalidName for {:?}, got: {}",
bad_name,
err
);
}
}
#[tokio::test]
async fn test_tool_name_rejects_empty() {
let dir = TempDir::new().unwrap();
let wasm_path = dir.path().join("dummy.wasm");
std::fs::File::create(&wasm_path).unwrap();
let loader = make_loader();
let result = loader.load_from_files("", &wasm_path, None).await;
assert!(result.is_err(), "Expected error for empty name, got Ok");
let err = result.unwrap_err();
assert!(
matches!(err, WasmLoadError::InvalidName(_)),
"Expected InvalidName for empty string, got: {}",
err
);
}
#[tokio::test]
async fn test_load_nonexistent_wasm_file() {
let loader = make_loader();
let bogus_path = std::path::PathBuf::from("/tmp/nonexistent_tool_12345.wasm");
let result = loader.load_from_files("bogus", &bogus_path, None).await;
assert!(
result.is_err(),
"Expected error for nonexistent file, got Ok"
);
let err = result.unwrap_err();
assert!(
matches!(err, WasmLoadError::WasmNotFound(_)),
"Expected WasmNotFound, got: {}",
err
);
}
#[tokio::test]
async fn test_load_invalid_wasm_bytes() {
let dir = TempDir::new().unwrap();
let wasm_path = dir.path().join("invalid.wasm");
let mut f = std::fs::File::create(&wasm_path).unwrap();
f.write_all(b"this is not a valid wasm module at all")
.unwrap();
let loader = make_loader();
let result = loader.load_from_files("invalid", &wasm_path, None).await;
assert!(
result.is_err(),
"Expected error for invalid WASM bytes, got Ok"
);
let err = result.unwrap_err();
assert!(
!matches!(err, WasmLoadError::InvalidName(_)),
"Got InvalidName instead of a compilation/registration error: {}",
err
);
}
#[tokio::test]
async fn test_discover_skips_dotfiles() {
let dir = TempDir::new().unwrap();
std::fs::File::create(dir.path().join(".hidden.wasm")).unwrap();
std::fs::File::create(dir.path().join("visible.wasm")).unwrap();
let tools = discover_tools(dir.path()).await.unwrap();
assert!(
tools.contains_key("visible"),
"visible.wasm should be discovered"
);
assert!(
tools.contains_key(".hidden"),
"dotfile .hidden.wasm is currently discovered (no dotfile filter yet)"
);
assert_eq!(tools.len(), 2);
}
#[tokio::test]
async fn test_discover_tools_ignores_subdirectories() {
let dir = TempDir::new().unwrap();
std::fs::File::create(dir.path().join("top_level.wasm")).unwrap();
let sub_dir = dir.path().join("subdir");
std::fs::create_dir(&sub_dir).unwrap();
std::fs::File::create(sub_dir.join("nested.wasm")).unwrap();
let tools = discover_tools(dir.path()).await.unwrap();
assert_eq!(tools.len(), 1, "Only top-level .wasm files should be found");
assert!(
tools.contains_key("top_level"),
"top_level.wasm should be discovered"
);
assert!(
!tools.contains_key("nested"),
"nested.wasm inside subdir should NOT be discovered"
);
}
#[tokio::test]
async fn load_from_dir_returns_empty_when_dir_missing() {
let loader = make_loader();
let dir = TempDir::new().unwrap();
let missing = dir.path().join("nonexistent_tools_dir");
let results = loader.load_from_dir(&missing).await;
let results = results.expect("missing dir should return Ok, not Err");
assert!(results.loaded.is_empty());
assert!(results.errors.is_empty());
}
}