Fix TuiChannel integration and enable in main.rs

- Store event_tx in Arc<Mutex<>> so respond() can send to TUI
- Fix run_event_loop to take owned receiver
- Switch main.rs from SimpleCliChannel to TuiChannel
- Add proper terminal cleanup on TUI error

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-02-02 23:47:41 -08:00
co-authored by Claude Opus 4.5
parent 09032d69cb
commit 4ae59ef52c
14 changed files with 1833 additions and 27 deletions
+3 -3
View File
@@ -20,7 +20,7 @@ pub fn run_event_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut AppState,
msg_tx: mpsc::Sender<IncomingMessage>,
event_rx: &mut mpsc::Receiver<AppEvent>,
mut event_rx: mpsc::Receiver<AppEvent>,
) -> io::Result<()> {
loop {
// Render
@@ -31,7 +31,7 @@ pub fn run_event_loop(
return Ok(());
}
// Poll for events
// Poll for terminal events
if event::poll(TICK_RATE)? {
let evt = event::read()?;
if let Err(e) = handle_event(app, evt, &msg_tx) {
@@ -39,7 +39,7 @@ pub fn run_event_loop(
}
}
// Check for app events (non-blocking)
// Check for app events from agent (non-blocking)
while let Ok(app_event) = event_rx.try_recv() {
handle_app_event(app, app_event);
}
+29 -20
View File
@@ -23,7 +23,7 @@ use crossterm::{
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use tokio::sync::mpsc;
use tokio::sync::{Mutex, mpsc};
use tokio_stream::wrappers::ReceiverStream;
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse};
@@ -36,13 +36,15 @@ pub use overlay::{ApprovalOverlay, ApprovalRequest};
/// TUI channel for interactive terminal input with Ratatui.
pub struct TuiChannel {
/// Channel for sending events to the TUI.
event_tx: Option<mpsc::Sender<AppEvent>>,
event_tx: Arc<Mutex<Option<mpsc::Sender<AppEvent>>>>,
}
impl TuiChannel {
/// Create a new TUI channel.
pub fn new() -> Self {
Self { event_tx: None }
Self {
event_tx: Arc::new(Mutex::new(None)),
}
}
}
@@ -62,21 +64,21 @@ impl Channel for TuiChannel {
let (msg_tx, msg_rx) = mpsc::channel(32);
let (event_tx, event_rx) = mpsc::channel(64);
// Store the event sender so we can send responses
// Note: In the actual implementation, we'd store this properly
// For now, spawn the TUI in a separate task
let event_tx_clone = event_tx.clone();
// Store the event sender for respond()
{
let mut guard = self.event_tx.lock().await;
*guard = Some(event_tx);
}
tokio::task::spawn_blocking(move || {
if let Err(e) = run_tui(msg_tx, event_rx) {
// Try to restore terminal even on error
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
tracing::error!("TUI error: {}", e);
}
});
// Keep the event_tx alive by storing it
// This is a hack; in production we'd use Arc<Mutex<>> or similar
let _ = event_tx_clone;
Ok(Box::pin(ReceiverStream::new(msg_rx)))
}
@@ -85,25 +87,32 @@ impl Channel for TuiChannel {
_msg: &IncomingMessage,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
// Send response event to the TUI
if let Some(ref tx) = self.event_tx {
let _ = tx
.send(AppEvent::Response(response.content))
let guard = self.event_tx.lock().await;
if let Some(ref tx) = *guard {
tx.send(AppEvent::Response(response.content))
.await
.map_err(|e| ChannelError::SendFailed {
name: "tui".to_string(),
reason: e.to_string(),
});
})?;
}
Ok(())
}
async fn health_check(&self) -> Result<(), ChannelError> {
Ok(())
let guard = self.event_tx.lock().await;
if guard.is_some() {
Ok(())
} else {
Err(ChannelError::HealthCheckFailed {
name: "tui".to_string(),
})
}
}
async fn shutdown(&self) -> Result<(), ChannelError> {
if let Some(ref tx) = self.event_tx {
let guard = self.event_tx.lock().await;
if let Some(ref tx) = *guard {
let _ = tx.send(AppEvent::Quit).await;
}
Ok(())
@@ -113,7 +122,7 @@ impl Channel for TuiChannel {
/// Run the TUI event loop (blocking).
fn run_tui(
msg_tx: mpsc::Sender<IncomingMessage>,
mut event_rx: mpsc::Receiver<AppEvent>,
event_rx: mpsc::Receiver<AppEvent>,
) -> io::Result<()> {
// Setup terminal
enable_raw_mode()?;
@@ -126,7 +135,7 @@ fn run_tui(
let mut app = AppState::new();
// Run event loop
let result = events::run_event_loop(&mut terminal, &mut app, msg_tx, &mut event_rx);
let result = events::run_event_loop(&mut terminal, &mut app, msg_tx, event_rx);
// Restore terminal
disable_raw_mode()?;
+4 -4
View File
@@ -7,7 +7,7 @@ use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitEx
use near_agent::{
agent::Agent,
channels::{ChannelManager, CliChannel, HttpChannel},
channels::{ChannelManager, HttpChannel, TuiChannel},
config::Config,
history::Store,
llm::create_llm_provider,
@@ -79,10 +79,10 @@ async fn main() -> anyhow::Result<()> {
// Initialize channel manager
let mut channels = ChannelManager::new();
// Always add CLI channel
// Always add CLI channel (TUI with full-screen interface)
if config.channels.cli.enabled {
channels.add(Box::new(CliChannel::new()));
tracing::info!("CLI channel enabled");
channels.add(Box::new(TuiChannel::new()));
tracing::info!("TUI channel enabled");
}
// Add HTTP channel if configured and not CLI-only mode
+506
View File
@@ -0,0 +1,506 @@
//! JSON schema for WASM tool capabilities files.
//!
//! External WASM tools declare their required capabilities via a sidecar JSON file
//! (e.g., `slack.capabilities.json`). This module defines the schema for those files
//! and provides conversion to runtime [`Capabilities`].
//!
//! # Example Capabilities File
//!
//! ```json
//! {
//! "http": {
//! "allowlist": [
//! { "host": "slack.com", "path_prefix": "/api/", "methods": ["GET", "POST"] }
//! ],
//! "credentials": {
//! "slack_bot_token": {
//! "secret_name": "slack_bot_token",
//! "location": { "type": "bearer" },
//! "host_patterns": ["slack.com"]
//! }
//! },
//! "rate_limit": { "requests_per_minute": 50, "requests_per_hour": 1000 }
//! },
//! "secrets": {
//! "allowed_names": ["slack_bot_token"]
//! }
//! }
//! ```
use std::collections::HashMap;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::secrets::{CredentialLocation, CredentialMapping};
use crate::tools::wasm::{
Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability,
ToolInvokeCapability, WorkspaceCapability,
};
/// Root schema for a capabilities JSON file.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CapabilitiesFile {
/// HTTP request capability.
#[serde(default)]
pub http: Option<HttpCapabilitySchema>,
/// Secret existence checks.
#[serde(default)]
pub secrets: Option<SecretsCapabilitySchema>,
/// Tool invocation via aliases.
#[serde(default)]
pub tool_invoke: Option<ToolInvokeCapabilitySchema>,
/// Workspace file read access.
#[serde(default)]
pub workspace: Option<WorkspaceCapabilitySchema>,
}
impl CapabilitiesFile {
/// Parse from JSON string.
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
/// Parse from JSON bytes.
pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
serde_json::from_slice(bytes)
}
/// Convert to runtime Capabilities.
pub fn to_capabilities(&self) -> Capabilities {
let mut caps = Capabilities::default();
if let Some(http) = &self.http {
caps.http = Some(http.to_http_capability());
}
if let Some(secrets) = &self.secrets {
caps.secrets = Some(SecretsCapability {
allowed_names: secrets.allowed_names.clone(),
});
}
if let Some(tool_invoke) = &self.tool_invoke {
caps.tool_invoke = Some(ToolInvokeCapability {
aliases: tool_invoke.aliases.clone(),
rate_limit: tool_invoke
.rate_limit
.as_ref()
.map(|r| r.to_rate_limit_config())
.unwrap_or_default(),
});
}
if let Some(workspace) = &self.workspace {
caps.workspace_read = Some(WorkspaceCapability {
allowed_prefixes: workspace.allowed_prefixes.clone(),
reader: None, // Injected at runtime
});
}
caps
}
}
/// HTTP capability schema.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HttpCapabilitySchema {
/// Allowed endpoint patterns.
#[serde(default)]
pub allowlist: Vec<EndpointPatternSchema>,
/// Credential mappings (key is an identifier, not the secret name).
#[serde(default)]
pub credentials: HashMap<String, CredentialMappingSchema>,
/// Rate limiting configuration.
#[serde(default)]
pub rate_limit: Option<RateLimitSchema>,
/// Maximum request body size in bytes.
#[serde(default)]
pub max_request_bytes: Option<usize>,
/// Maximum response body size in bytes.
#[serde(default)]
pub max_response_bytes: Option<usize>,
/// Request timeout in seconds.
#[serde(default)]
pub timeout_secs: Option<u64>,
}
impl HttpCapabilitySchema {
fn to_http_capability(&self) -> HttpCapability {
let mut cap = HttpCapability {
allowlist: self
.allowlist
.iter()
.map(|p| p.to_endpoint_pattern())
.collect(),
credentials: self
.credentials
.values()
.map(|m| (m.secret_name.clone(), m.to_credential_mapping()))
.collect(),
rate_limit: self
.rate_limit
.as_ref()
.map(|r| r.to_rate_limit_config())
.unwrap_or_default(),
..Default::default()
};
if let Some(max) = self.max_request_bytes {
cap.max_request_bytes = max;
}
if let Some(max) = self.max_response_bytes {
cap.max_response_bytes = max;
}
if let Some(secs) = self.timeout_secs {
cap.timeout = Duration::from_secs(secs);
}
cap
}
}
/// Endpoint pattern schema.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EndpointPatternSchema {
/// Hostname (e.g., "api.slack.com" or "*.slack.com").
pub host: String,
/// Optional path prefix (e.g., "/api/").
#[serde(default)]
pub path_prefix: Option<String>,
/// Allowed HTTP methods (empty = all).
#[serde(default)]
pub methods: Vec<String>,
}
impl EndpointPatternSchema {
fn to_endpoint_pattern(&self) -> EndpointPattern {
EndpointPattern {
host: self.host.clone(),
path_prefix: self.path_prefix.clone(),
methods: self.methods.clone(),
}
}
}
/// Credential mapping schema.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialMappingSchema {
/// Name of the secret to inject.
pub secret_name: String,
/// Where to inject the credential.
pub location: CredentialLocationSchema,
/// Host patterns this credential applies to.
#[serde(default)]
pub host_patterns: Vec<String>,
}
impl CredentialMappingSchema {
fn to_credential_mapping(&self) -> CredentialMapping {
CredentialMapping {
secret_name: self.secret_name.clone(),
location: self.location.to_credential_location(),
host_patterns: self.host_patterns.clone(),
}
}
}
/// Credential injection location schema.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CredentialLocationSchema {
/// Bearer token in Authorization header.
Bearer,
/// Basic auth (password from secret, username in config).
Basic { username: String },
/// Custom header.
Header {
name: String,
#[serde(default)]
prefix: Option<String>,
},
/// Query parameter.
QueryParam { name: String },
}
impl CredentialLocationSchema {
fn to_credential_location(&self) -> CredentialLocation {
match self {
CredentialLocationSchema::Bearer => CredentialLocation::AuthorizationBearer,
CredentialLocationSchema::Basic { username } => {
CredentialLocation::AuthorizationBasic {
username: username.clone(),
}
}
CredentialLocationSchema::Header { name, prefix } => CredentialLocation::Header {
name: name.clone(),
prefix: prefix.clone(),
},
CredentialLocationSchema::QueryParam { name } => {
CredentialLocation::QueryParam { name: name.clone() }
}
}
}
}
/// Rate limit schema.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimitSchema {
/// Maximum requests per minute.
#[serde(default = "default_requests_per_minute")]
pub requests_per_minute: u32,
/// Maximum requests per hour.
#[serde(default = "default_requests_per_hour")]
pub requests_per_hour: u32,
}
fn default_requests_per_minute() -> u32 {
60
}
fn default_requests_per_hour() -> u32 {
1000
}
impl RateLimitSchema {
fn to_rate_limit_config(&self) -> RateLimitConfig {
RateLimitConfig {
requests_per_minute: self.requests_per_minute,
requests_per_hour: self.requests_per_hour,
}
}
}
/// Secrets capability schema.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecretsCapabilitySchema {
/// Secret names the tool can check existence of (supports glob).
#[serde(default)]
pub allowed_names: Vec<String>,
}
/// Tool invocation capability schema.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolInvokeCapabilitySchema {
/// Mapping from alias to real tool name.
#[serde(default)]
pub aliases: HashMap<String, String>,
/// Rate limiting for tool calls.
#[serde(default)]
pub rate_limit: Option<RateLimitSchema>,
}
/// Workspace read capability schema.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkspaceCapabilitySchema {
/// Allowed path prefixes (e.g., ["context/", "daily/"]).
#[serde(default)]
pub allowed_prefixes: Vec<String>,
}
#[cfg(test)]
mod tests {
use crate::tools::wasm::capabilities_schema::{CapabilitiesFile, CredentialLocationSchema};
#[test]
fn test_parse_minimal() {
let json = "{}";
let caps = CapabilitiesFile::from_json(json).unwrap();
assert!(caps.http.is_none());
assert!(caps.secrets.is_none());
}
#[test]
fn test_parse_http_allowlist() {
let json = r#"{
"http": {
"allowlist": [
{ "host": "api.slack.com", "path_prefix": "/api/", "methods": ["GET", "POST"] }
]
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let http = caps.http.unwrap();
assert_eq!(http.allowlist.len(), 1);
assert_eq!(http.allowlist[0].host, "api.slack.com");
assert_eq!(http.allowlist[0].path_prefix, Some("/api/".to_string()));
assert_eq!(http.allowlist[0].methods, vec!["GET", "POST"]);
}
#[test]
fn test_parse_credentials() {
let json = r#"{
"http": {
"allowlist": [{ "host": "slack.com" }],
"credentials": {
"slack": {
"secret_name": "slack_bot_token",
"location": { "type": "bearer" },
"host_patterns": ["slack.com", "*.slack.com"]
}
}
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let http = caps.http.unwrap();
assert_eq!(http.credentials.len(), 1);
let cred = http.credentials.get("slack").unwrap();
assert_eq!(cred.secret_name, "slack_bot_token");
assert!(matches!(cred.location, CredentialLocationSchema::Bearer));
assert_eq!(cred.host_patterns, vec!["slack.com", "*.slack.com"]);
}
#[test]
fn test_parse_custom_header_credential() {
let json = r#"{
"http": {
"allowlist": [{ "host": "api.example.com" }],
"credentials": {
"api_key": {
"secret_name": "my_api_key",
"location": { "type": "header", "name": "X-API-Key", "prefix": "Key " },
"host_patterns": ["api.example.com"]
}
}
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let http = caps.http.unwrap();
let cred = http.credentials.get("api_key").unwrap();
match &cred.location {
CredentialLocationSchema::Header { name, prefix } => {
assert_eq!(name, "X-API-Key");
assert_eq!(prefix, &Some("Key ".to_string()));
}
_ => panic!("Expected Header location"),
}
}
#[test]
fn test_parse_secrets_capability() {
let json = r#"{
"secrets": {
"allowed_names": ["slack_*", "openai_key"]
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let secrets = caps.secrets.unwrap();
assert_eq!(secrets.allowed_names, vec!["slack_*", "openai_key"]);
}
#[test]
fn test_parse_tool_invoke() {
let json = r#"{
"tool_invoke": {
"aliases": {
"search": "brave_search",
"calc": "calculator"
},
"rate_limit": {
"requests_per_minute": 10,
"requests_per_hour": 100
}
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let tool_invoke = caps.tool_invoke.unwrap();
assert_eq!(
tool_invoke.aliases.get("search"),
Some(&"brave_search".to_string())
);
let rate = tool_invoke.rate_limit.unwrap();
assert_eq!(rate.requests_per_minute, 10);
}
#[test]
fn test_parse_workspace() {
let json = r#"{
"workspace": {
"allowed_prefixes": ["context/", "daily/"]
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let workspace = caps.workspace.unwrap();
assert_eq!(workspace.allowed_prefixes, vec!["context/", "daily/"]);
}
#[test]
fn test_to_capabilities() {
let json = r#"{
"http": {
"allowlist": [{ "host": "api.slack.com", "path_prefix": "/api/" }],
"rate_limit": { "requests_per_minute": 50, "requests_per_hour": 500 }
},
"secrets": {
"allowed_names": ["slack_token"]
}
}"#;
let file = CapabilitiesFile::from_json(json).unwrap();
let caps = file.to_capabilities();
assert!(caps.http.is_some());
let http = caps.http.unwrap();
assert_eq!(http.allowlist.len(), 1);
assert_eq!(http.rate_limit.requests_per_minute, 50);
assert!(caps.secrets.is_some());
let secrets = caps.secrets.unwrap();
assert!(secrets.is_allowed("slack_token"));
}
#[test]
fn test_full_slack_example() {
let json = r#"{
"http": {
"allowlist": [
{ "host": "slack.com", "path_prefix": "/api/", "methods": ["GET", "POST"] }
],
"credentials": {
"slack_bot_token": {
"secret_name": "slack_bot_token",
"location": { "type": "bearer" },
"host_patterns": ["slack.com"]
}
},
"rate_limit": { "requests_per_minute": 50, "requests_per_hour": 1000 }
},
"secrets": {
"allowed_names": ["slack_bot_token"]
}
}"#;
let file = CapabilitiesFile::from_json(json).unwrap();
let caps = file.to_capabilities();
let http = caps.http.unwrap();
assert_eq!(http.allowlist[0].host, "slack.com");
assert!(http.credentials.contains_key("slack_bot_token"));
let secrets = caps.secrets.unwrap();
assert!(secrets.is_allowed("slack_bot_token"));
}
}
+427
View File
@@ -0,0 +1,427 @@
//! Generic WASM tool loader for loading tools from files or directories.
//!
//! This module provides a way to load WASM tools dynamically at runtime from:
//! - A directory containing `<name>.wasm` and `<name>.capabilities.json`
//! - Database storage (via [`WasmToolStore`])
//!
//! # Example: Loading from Directory
//!
//! ```text
//! ~/.near-agent/tools/
//! ├── slack.wasm
//! ├── slack.capabilities.json
//! ├── github.wasm
//! └── github.capabilities.json
//! ```
//!
//! ```ignore
//! let loader = WasmToolLoader::new(runtime, registry);
//! loader.load_from_dir(Path::new("~/.near-agent/tools/")).await?;
//! ```
//!
//! # Security
//!
//! Tools loaded from files are assigned `TrustLevel::User` by default, meaning
//! they run with the most restrictive permissions. Only tools explicitly marked
//! as `verified` or `system` in the database get elevated trust.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::fs;
use crate::tools::registry::{ToolRegistry, WasmRegistrationError, WasmToolRegistration};
use crate::tools::wasm::capabilities_schema::CapabilitiesFile;
use crate::tools::wasm::{
Capabilities, WasmError, WasmStorageError, WasmToolRuntime, WasmToolStore,
};
/// Error during WASM tool loading.
#[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),
}
/// Loads WASM tools from files or storage into the registry.
pub struct WasmToolLoader {
runtime: Arc<WasmToolRuntime>,
registry: Arc<ToolRegistry>,
}
impl WasmToolLoader {
/// Create a new loader with the given runtime and registry.
pub fn new(runtime: Arc<WasmToolRuntime>, registry: Arc<ToolRegistry>) -> Self {
Self { runtime, registry }
}
/// Load a single WASM tool from a file pair.
///
/// Expects:
/// - `wasm_path`: Path to the `.wasm` file
/// - `capabilities_path`: Path to the `.capabilities.json` file (optional)
///
/// If no capabilities file is provided, the tool gets no capabilities (default deny).
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()));
}
// Read WASM bytes
if !wasm_path.exists() {
return Err(WasmLoadError::WasmNotFound(wasm_path.to_path_buf()));
}
let wasm_bytes = fs::read(wasm_path).await?;
// Read capabilities (optional)
let capabilities = 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.to_capabilities()
} else {
tracing::warn!(
path = %cap_path.display(),
"Capabilities file not found, using default (no permissions)"
);
Capabilities::default()
}
} else {
Capabilities::default()
};
// Register the tool
self.registry
.register_wasm(WasmToolRegistration {
name,
wasm_bytes: &wasm_bytes,
runtime: &self.runtime,
capabilities,
limits: None,
description: None,
schema: None,
})
.await?;
tracing::info!(
name = name,
wasm_path = %wasm_path.display(),
"Loaded WASM tool from file"
);
Ok(())
}
/// Load all WASM tools from a directory.
///
/// Scans the directory for `*.wasm` files and loads each one, looking for
/// a matching `*.capabilities.json` sidecar file.
///
/// # Directory Layout
///
/// ```text
/// tools/
/// ├── slack.wasm <- Tool WASM component
/// ├── slack.capabilities.json <- Capabilities (optional)
/// ├── github.wasm
/// └── github.capabilities.json
/// ```
///
/// Tools without a capabilities file get no permissions (default deny).
pub async fn load_from_dir(&self, dir: &Path) -> Result<LoadResults, WasmLoadError> {
if !dir.is_dir() {
return Err(WasmLoadError::Io(std::io::Error::new(
std::io::ErrorKind::NotADirectory,
format!("{} is not a directory", dir.display()),
)));
}
let mut results = LoadResults::default();
let mut entries = fs::read_dir(dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
// Only process .wasm files
if path.extension().and_then(|e| e.to_str()) != Some("wasm") {
continue;
}
// Extract tool name from filename
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;
}
};
// Look for sidecar capabilities file
let cap_path = path.with_extension("capabilities.json");
let cap_path_option = if cap_path.exists() {
Some(cap_path.as_path())
} else {
None
};
match self.load_from_files(&name, &path, cap_path_option).await {
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::info!(
count = results.loaded.len(),
tools = ?results.loaded,
"Loaded WASM tools from directory"
);
}
Ok(results)
}
/// Load a WASM tool from database storage.
///
/// This is a convenience wrapper around [`ToolRegistry::register_wasm_from_storage`].
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(())
}
/// Load all active WASM tools for a user from storage.
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 {
// Skip non-active 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)
}
}
/// Results from loading multiple tools.
#[derive(Debug, Default)]
pub struct LoadResults {
/// Names of successfully loaded tools.
pub loaded: Vec<String>,
/// Errors encountered (path/name, error).
pub errors: Vec<(PathBuf, WasmLoadError)>,
}
impl LoadResults {
/// Check if all tools loaded successfully.
pub fn all_succeeded(&self) -> bool {
self.errors.is_empty()
}
/// Get the count of successfully loaded tools.
pub fn success_count(&self) -> usize {
self.loaded.len()
}
/// Get the count of failed tools.
pub fn error_count(&self) -> usize {
self.errors.len()
}
}
/// Discover WASM tool files in a directory without loading them.
///
/// Returns a map of tool name -> (wasm_path, capabilities_path).
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)
}
/// A discovered WASM tool (not yet loaded).
#[derive(Debug)]
pub struct DiscoveredTool {
/// Path to the WASM file.
pub wasm_path: PathBuf,
/// Path to the capabilities file (if present).
pub capabilities_path: Option<PathBuf>,
}
#[cfg(test)]
mod tests {
use std::io::Write;
use tempfile::TempDir;
use crate::tools::wasm::loader::{WasmLoadError, discover_tools};
#[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();
// Create a fake .wasm file
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();
// Create wasm and capabilities files
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();
// Create non-wasm files
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"));
}
}
+8
View File
@@ -75,10 +75,12 @@
mod allowlist;
mod capabilities;
mod capabilities_schema;
mod credential_injector;
mod error;
mod host;
mod limits;
mod loader;
mod rate_limiter;
mod runtime;
mod storage;
@@ -111,3 +113,9 @@ pub use storage::{
StoredWasmToolWithBinary, ToolStatus, TrustLevel, WasmStorageError, WasmToolStore,
compute_binary_hash, verify_binary_integrity,
};
// Loader
pub use loader::{DiscoveredTool, LoadResults, WasmLoadError, WasmToolLoader, discover_tools};
// Capabilities schema (for parsing *.capabilities.json files)
pub use capabilities_schema::CapabilitiesFile;