diff --git a/Cargo.lock b/Cargo.lock index 37a8709a..01d3590f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1047,6 +1047,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "filetime" version = "0.2.27" @@ -1943,6 +1949,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "tempfile", "testcontainers-modules", "thiserror 2.0.18", "tokio", @@ -3286,6 +3293,19 @@ version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + [[package]] name = "termcolor" version = "1.4.1" diff --git a/Cargo.toml b/Cargo.toml index 1b1ab514..80b086fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,6 +82,7 @@ rand = "0.8" tokio-test = "0.4" testcontainers-modules = { version = "0.11", features = ["postgres"] } pretty_assertions = "1" +tempfile = "3" [features] default = [] diff --git a/examples/wasm-tools/slack/Cargo.toml b/examples/wasm-tools/slack/Cargo.toml new file mode 100644 index 00000000..92e34820 --- /dev/null +++ b/examples/wasm-tools/slack/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "slack-tool" +version = "0.1.0" +edition = "2021" +description = "Slack integration tool for NEAR Agent (WASM component)" +license = "MIT OR Apache-2.0" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "0.36" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 diff --git a/examples/wasm-tools/slack/README.md b/examples/wasm-tools/slack/README.md new file mode 100644 index 00000000..2836643a --- /dev/null +++ b/examples/wasm-tools/slack/README.md @@ -0,0 +1,238 @@ +# Slack WASM Tool + +A standalone WASM component that provides Slack integration for NEAR Agent. This serves as both a functional tool and a template for building custom WASM tools. + +## Features + +- **send_message**: Send messages to channels or threads +- **list_channels**: List channels the bot has access to +- **get_channel_history**: Retrieve recent messages from a channel +- **post_reaction**: Add emoji reactions to messages +- **get_user_info**: Get information about Slack users + +## Prerequisites + +1. **Rust toolchain** with WASM target: + ```bash + rustup target add wasm32-wasip2 + ``` + +2. **cargo-component** for building WASM components: + ```bash + cargo install cargo-component + ``` + +3. **Slack Bot Token** with the following OAuth scopes: + - `chat:write` - Send messages + - `channels:read` - List public channels + - `channels:history` - Read channel history + - `groups:read` - List private channels + - `groups:history` - Read private channel history + - `reactions:write` - Add reactions + - `users:read` - Get user information + +## Building + +```bash +cd examples/wasm-tools/slack +cargo component build --release +``` + +The compiled WASM component will be at: +``` +target/wasm32-wasip2/release/slack_tool.wasm +``` + +## Installation + +### Option A: File-based (Development) + +Copy the WASM and capabilities files to the agent's tools directory: + +```bash +mkdir -p ~/.near-agent/tools +cp target/wasm32-wasip2/release/slack_tool.wasm ~/.near-agent/tools/slack.wasm +cp slack.capabilities.json ~/.near-agent/tools/ +``` + +### Option B: Database Storage (Production) + +Use the agent CLI or API to store the tool: + +```bash +near-agent tool install \ + --name slack \ + --wasm target/wasm32-wasip2/release/slack_tool.wasm \ + --capabilities slack.capabilities.json +``` + +## Configuration + +Store your Slack bot token as a secret: + +```bash +near-agent secret set slack_bot_token "xoxb-your-token-here" +``` + +Or via SQL: +```sql +INSERT INTO secrets (user_id, name, encrypted_value, key_salt) +VALUES ('your_user_id', 'slack_bot_token', ...); +``` + +## Usage Examples + +### Send a Message + +```json +{ + "action": "send_message", + "channel": "#general", + "text": "Hello from the NEAR Agent!" +} +``` + +### Reply in a Thread + +```json +{ + "action": "send_message", + "channel": "C1234567890", + "text": "This is a thread reply", + "thread_ts": "1234567890.123456" +} +``` + +### List Channels + +```json +{ + "action": "list_channels", + "limit": 50 +} +``` + +### Get Channel History + +```json +{ + "action": "get_channel_history", + "channel": "C1234567890", + "limit": 10 +} +``` + +### Add a Reaction + +```json +{ + "action": "post_reaction", + "channel": "C1234567890", + "timestamp": "1234567890.123456", + "emoji": "thumbsup" +} +``` + +### Get User Info + +```json +{ + "action": "get_user_info", + "user_id": "U1234567890" +} +``` + +## Security Model + +This tool runs in a sandboxed WASM environment with strict capability controls: + +1. **HTTP Allowlist**: Can only access `slack.com/api/*` +2. **Credential Injection**: The bot token is injected by the host runtime; the WASM code never sees it +3. **Rate Limiting**: 50 requests/minute, 1000 requests/hour +4. **No Filesystem Access**: Cannot read/write files except through workspace capability +5. **No Network Access**: Beyond the allowlisted endpoints + +## Capabilities File + +The `slack.capabilities.json` file declares what this tool needs: + +```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"] + } +} +``` + +## Building Your Own Tool + +Use this as a template for creating new WASM tools: + +1. Copy this directory +2. Update `Cargo.toml` with your tool name +3. Modify `src/types.rs` with your action types +4. Implement API calls in `src/api.rs` +5. Update the action dispatch in `src/lib.rs` +6. Create your `*.capabilities.json` file +7. Build with `cargo component build --release` + +### Key Files + +- `Cargo.toml` - Rust package config with WASM target +- `src/lib.rs` - WIT bindings and main dispatch +- `src/types.rs` - Request/response types +- `src/api.rs` - API implementation +- `*.capabilities.json` - Security capabilities declaration + +### WIT Interface + +Tools implement the `sandboxed-tool` world from `wit/tool.wit`: + +```wit +world sandboxed-tool { + import host; // log, http-request, secret-exists, etc. + export tool; // execute, schema, description +} +``` + +## Troubleshooting + +### "Slack bot token not configured" + +Ensure you've stored the secret: +```bash +near-agent secret set slack_bot_token "xoxb-..." +``` + +### "Endpoint not in allowlist" + +Check that `slack.capabilities.json` includes the endpoint you're trying to access. + +### "Rate limit exceeded" + +The tool has a default rate limit of 50 requests/minute. Wait and retry. + +### Build errors + +Ensure you have the WASM target and cargo-component installed: +```bash +rustup target add wasm32-wasip2 +cargo install cargo-component +``` + +## License + +MIT OR Apache-2.0 diff --git a/examples/wasm-tools/slack/slack.capabilities.json b/examples/wasm-tools/slack/slack.capabilities.json new file mode 100644 index 00000000..b034b0fa --- /dev/null +++ b/examples/wasm-tools/slack/slack.capabilities.json @@ -0,0 +1,26 @@ +{ + "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 + }, + "timeout_secs": 30 + }, + "secrets": { + "allowed_names": ["slack_bot_token"] + } +} diff --git a/examples/wasm-tools/slack/src/api.rs b/examples/wasm-tools/slack/src/api.rs new file mode 100644 index 00000000..8f1470fa --- /dev/null +++ b/examples/wasm-tools/slack/src/api.rs @@ -0,0 +1,198 @@ +//! Slack Web API implementation. +//! +//! All API calls go through the host's HTTP capability, which handles +//! credential injection and rate limiting. The WASM tool never sees +//! the actual bot token. + +use crate::bindings::near::agent::host; +use crate::types::*; + +const SLACK_API_BASE: &str = "https://slack.com/api"; + +/// Make a Slack API call. +fn slack_api_call(method: &str, endpoint: &str, body: Option<&str>) -> Result { + let url = format!("{}/{}", SLACK_API_BASE, endpoint); + + // Content-Type header for POST requests + let headers = if body.is_some() { + r#"{"Content-Type": "application/json; charset=utf-8"}"# + } else { + "{}" + }; + + let body_bytes = body.map(|b| b.as_bytes().to_vec()); + + host::log(host::LogLevel::Debug, &format!("Slack API: {} {}", method, endpoint)); + + let response = host::http_request(method, &url, headers, body_bytes.as_deref())?; + + if response.status < 200 || response.status >= 300 { + return Err(format!( + "Slack API returned status {}: {}", + response.status, + String::from_utf8_lossy(&response.body) + )); + } + + String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8 in response: {}", e)) +} + +/// Send a message to a Slack channel. +pub fn send_message( + channel: &str, + text: &str, + thread_ts: Option<&str>, +) -> Result { + let mut payload = serde_json::json!({ + "channel": channel, + "text": text, + }); + + if let Some(ts) = thread_ts { + payload["thread_ts"] = serde_json::Value::String(ts.to_string()); + } + + let body = serde_json::to_string(&payload).map_err(|e| e.to_string())?; + let response = slack_api_call("POST", "chat.postMessage", Some(&body))?; + + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + if !parsed["ok"].as_bool().unwrap_or(false) { + let error = parsed["error"].as_str().unwrap_or("unknown_error"); + return Err(format!("Slack API error: {}", error)); + } + + Ok(SendMessageResult { + ok: true, + channel: parsed["channel"].as_str().unwrap_or(channel).to_string(), + ts: parsed["ts"].as_str().unwrap_or("").to_string(), + message: parsed.get("message").map(|m| MessageInfo { + text: m["text"].as_str().unwrap_or("").to_string(), + user: m["user"].as_str().map(|s| s.to_string()), + ts: m["ts"].as_str().unwrap_or("").to_string(), + }), + }) +} + +/// List channels the bot has access to. +pub fn list_channels(limit: u32) -> Result { + let url = format!( + "conversations.list?types=public_channel,private_channel&limit={}", + limit + ); + + let response = slack_api_call("GET", &url, None)?; + + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + if !parsed["ok"].as_bool().unwrap_or(false) { + let error = parsed["error"].as_str().unwrap_or("unknown_error"); + return Err(format!("Slack API error: {}", error)); + } + + let channels = parsed["channels"] + .as_array() + .map(|arr| { + arr.iter() + .map(|c| Channel { + id: c["id"].as_str().unwrap_or("").to_string(), + name: c["name"].as_str().unwrap_or("").to_string(), + is_private: c["is_private"].as_bool().unwrap_or(false), + is_member: c["is_member"].as_bool().unwrap_or(false), + topic: c["topic"]["value"].as_str().map(|s| s.to_string()), + purpose: c["purpose"]["value"].as_str().map(|s| s.to_string()), + }) + .collect() + }) + .unwrap_or_default(); + + Ok(ListChannelsResult { ok: true, channels }) +} + +/// Get message history from a channel. +pub fn get_channel_history(channel: &str, limit: u32) -> Result { + let url = format!("conversations.history?channel={}&limit={}", channel, limit); + + let response = slack_api_call("GET", &url, None)?; + + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + if !parsed["ok"].as_bool().unwrap_or(false) { + let error = parsed["error"].as_str().unwrap_or("unknown_error"); + return Err(format!("Slack API error: {}", error)); + } + + let messages = parsed["messages"] + .as_array() + .map(|arr| { + arr.iter() + .map(|m| HistoryMessage { + ts: m["ts"].as_str().unwrap_or("").to_string(), + text: m["text"].as_str().unwrap_or("").to_string(), + user: m["user"].as_str().map(|s| s.to_string()), + msg_type: m["type"].as_str().unwrap_or("message").to_string(), + }) + .collect() + }) + .unwrap_or_default(); + + Ok(ChannelHistoryResult { ok: true, messages }) +} + +/// Add a reaction to a message. +pub fn post_reaction(channel: &str, timestamp: &str, emoji: &str) -> Result { + let payload = serde_json::json!({ + "channel": channel, + "timestamp": timestamp, + "name": emoji, + }); + + let body = serde_json::to_string(&payload).map_err(|e| e.to_string())?; + let response = slack_api_call("POST", "reactions.add", Some(&body))?; + + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + if !parsed["ok"].as_bool().unwrap_or(false) { + let error = parsed["error"].as_str().unwrap_or("unknown_error"); + // "already_reacted" is not really an error + if error != "already_reacted" { + return Err(format!("Slack API error: {}", error)); + } + } + + Ok(PostReactionResult { ok: true }) +} + +/// Get information about a user. +pub fn get_user_info(user_id: &str) -> Result { + let url = format!("users.info?user={}", user_id); + + let response = slack_api_call("GET", &url, None)?; + + let parsed: serde_json::Value = + serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; + + if !parsed["ok"].as_bool().unwrap_or(false) { + let error = parsed["error"].as_str().unwrap_or("unknown_error"); + return Err(format!("Slack API error: {}", error)); + } + + let user = &parsed["user"]; + let profile = &user["profile"]; + + Ok(GetUserInfoResult { + ok: true, + user: UserInfo { + id: user["id"].as_str().unwrap_or("").to_string(), + name: user["name"].as_str().unwrap_or("").to_string(), + real_name: profile["real_name"].as_str().map(|s| s.to_string()), + display_name: profile["display_name"].as_str().map(|s| s.to_string()), + email: profile["email"].as_str().map(|s| s.to_string()), + is_bot: user["is_bot"].as_bool().unwrap_or(false), + }, + }) +} diff --git a/examples/wasm-tools/slack/src/lib.rs b/examples/wasm-tools/slack/src/lib.rs new file mode 100644 index 00000000..3d680375 --- /dev/null +++ b/examples/wasm-tools/slack/src/lib.rs @@ -0,0 +1,205 @@ +//! Slack WASM Tool for NEAR Agent. +//! +//! This is a standalone WASM component that provides Slack integration. +//! It demonstrates how to build external tools that can be dynamically +//! loaded by the agent runtime. +//! +//! # Capabilities Required +//! +//! - HTTP: `slack.com/api/*` (GET, POST) +//! - Secrets: `slack_bot_token` (injected automatically) +//! +//! # Supported Actions +//! +//! - `send_message`: Send a message to a channel +//! - `list_channels`: List channels the bot has access to +//! - `get_channel_history`: Get recent messages from a channel +//! - `post_reaction`: Add an emoji reaction to a message +//! - `get_user_info`: Get information about a Slack user +//! +//! # Example Usage +//! +//! ```json +//! {"action": "send_message", "channel": "#general", "text": "Hello from the agent!"} +//! ``` + +mod api; +mod types; + +use types::SlackAction; + +// Generate bindings from the WIT interface. +// This creates the `bindings` module with types and traits. +wit_bindgen::generate!({ + world: "sandboxed-tool", + path: "../../../wit/tool.wit", +}); + +/// Implementation of the tool interface. +struct SlackTool; + +impl exports::near::agent::tool::Guest for SlackTool { + fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response { + match execute_inner(&req.params) { + Ok(result) => exports::near::agent::tool::Response { + result: Some(result), + error: None, + }, + Err(e) => exports::near::agent::tool::Response { + result: None, + error: Some(e), + }, + } + } + + fn schema() -> String { + // JSON Schema for the tool's parameters + r#"{ + "type": "object", + "required": ["action"], + "oneOf": [ + { + "properties": { + "action": { "const": "send_message" }, + "channel": { + "type": "string", + "description": "Channel ID or name (e.g., '#general' or 'C1234567890')" + }, + "text": { + "type": "string", + "description": "Message text (supports Slack mrkdwn formatting)" + }, + "thread_ts": { + "type": "string", + "description": "Optional thread timestamp to reply in a thread" + } + }, + "required": ["action", "channel", "text"] + }, + { + "properties": { + "action": { "const": "list_channels" }, + "limit": { + "type": "integer", + "description": "Maximum number of channels to return (default: 100)", + "default": 100 + } + }, + "required": ["action"] + }, + { + "properties": { + "action": { "const": "get_channel_history" }, + "channel": { + "type": "string", + "description": "Channel ID (e.g., 'C1234567890')" + }, + "limit": { + "type": "integer", + "description": "Maximum number of messages to return (default: 20)", + "default": 20 + } + }, + "required": ["action", "channel"] + }, + { + "properties": { + "action": { "const": "post_reaction" }, + "channel": { + "type": "string", + "description": "Channel ID containing the message" + }, + "timestamp": { + "type": "string", + "description": "Timestamp of the message to react to" + }, + "emoji": { + "type": "string", + "description": "Emoji name without colons (e.g., 'thumbsup')" + } + }, + "required": ["action", "channel", "timestamp", "emoji"] + }, + { + "properties": { + "action": { "const": "get_user_info" }, + "user_id": { + "type": "string", + "description": "User ID (e.g., 'U1234567890')" + } + }, + "required": ["action", "user_id"] + } + ] + }"# + .to_string() + } + + fn description() -> String { + "Slack integration tool for sending messages, listing channels, reading history, \ + adding reactions, and getting user information. Requires a Slack bot token with \ + appropriate scopes (chat:write, channels:read, channels:history, reactions:write, \ + users:read)." + .to_string() + } +} + +/// Inner execution logic with proper error handling. +fn execute_inner(params: &str) -> Result { + // Check if the Slack token is configured + if !bindings::near::agent::host::secret_exists("slack_bot_token") { + return Err( + "Slack bot token not configured. Please add the 'slack_bot_token' secret.".to_string(), + ); + } + + // Parse the action from JSON + let action: SlackAction = + serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {}", e))?; + + bindings::near::agent::host::log( + bindings::near::agent::host::LogLevel::Info, + &format!("Executing Slack action: {:?}", action), + ); + + // Dispatch to the appropriate handler + let result = match action { + SlackAction::SendMessage { + channel, + text, + thread_ts, + } => { + let result = api::send_message(&channel, &text, thread_ts.as_deref())?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + SlackAction::ListChannels { limit } => { + let result = api::list_channels(limit)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + SlackAction::GetChannelHistory { channel, limit } => { + let result = api::get_channel_history(&channel, limit)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + SlackAction::PostReaction { + channel, + timestamp, + emoji, + } => { + let result = api::post_reaction(&channel, ×tamp, &emoji)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + + SlackAction::GetUserInfo { user_id } => { + let result = api::get_user_info(&user_id)?; + serde_json::to_string(&result).map_err(|e| e.to_string())? + } + }; + + Ok(result) +} + +// Export the tool implementation. +export!(SlackTool); diff --git a/examples/wasm-tools/slack/src/types.rs b/examples/wasm-tools/slack/src/types.rs new file mode 100644 index 00000000..e6c54c3d --- /dev/null +++ b/examples/wasm-tools/slack/src/types.rs @@ -0,0 +1,147 @@ +//! Types for Slack API requests and responses. + +use serde::{Deserialize, Serialize}; + +/// Input parameters for the Slack tool. +#[derive(Debug, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum SlackAction { + /// Send a message to a channel. + SendMessage { + /// Channel ID or name (e.g., "#general" or "C1234567890"). + channel: String, + /// Message text (supports Slack mrkdwn formatting). + text: String, + /// Optional thread timestamp to reply in a thread. + #[serde(default)] + thread_ts: Option, + }, + + /// List channels the bot has access to. + ListChannels { + /// Maximum number of channels to return (default: 100). + #[serde(default = "default_limit")] + limit: u32, + }, + + /// Get message history from a channel. + GetChannelHistory { + /// Channel ID (e.g., "C1234567890"). + channel: String, + /// Maximum number of messages to return (default: 20). + #[serde(default = "default_history_limit")] + limit: u32, + }, + + /// Add a reaction (emoji) to a message. + PostReaction { + /// Channel ID containing the message. + channel: String, + /// Timestamp of the message to react to. + timestamp: String, + /// Emoji name without colons (e.g., "thumbsup"). + emoji: String, + }, + + /// Get information about a user. + GetUserInfo { + /// User ID (e.g., "U1234567890"). + user_id: String, + }, +} + +fn default_limit() -> u32 { + 100 +} + +fn default_history_limit() -> u32 { + 20 +} + +/// Result from send_message. +#[derive(Debug, Serialize)] +pub struct SendMessageResult { + pub ok: bool, + pub channel: String, + pub ts: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +/// Basic message info. +#[derive(Debug, Serialize)] +pub struct MessageInfo { + pub text: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, + pub ts: String, +} + +/// A Slack channel. +#[derive(Debug, Serialize)] +pub struct Channel { + pub id: String, + pub name: String, + pub is_private: bool, + pub is_member: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub topic: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub purpose: Option, +} + +/// Result from list_channels. +#[derive(Debug, Serialize)] +pub struct ListChannelsResult { + pub ok: bool, + pub channels: Vec, +} + +/// Result from get_channel_history. +#[derive(Debug, Serialize)] +pub struct ChannelHistoryResult { + pub ok: bool, + pub messages: Vec, +} + +/// A message from channel history. +#[derive(Debug, Serialize)] +pub struct HistoryMessage { + pub ts: String, + pub text: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, + #[serde(rename = "type")] + pub msg_type: String, +} + +/// Result from post_reaction. +#[derive(Debug, Serialize)] +pub struct PostReactionResult { + pub ok: bool, +} + +/// User information. +#[derive(Debug, Serialize)] +pub struct UserInfo { + pub id: String, + pub name: String, + pub real_name: Option, + pub display_name: Option, + pub email: Option, + pub is_bot: bool, +} + +/// Result from get_user_info. +#[derive(Debug, Serialize)] +pub struct GetUserInfoResult { + pub ok: bool, + pub user: UserInfo, +} + +/// Generic Slack API error response. +#[derive(Debug, Deserialize)] +pub struct SlackApiError { + pub ok: bool, + pub error: String, +} diff --git a/src/channels/cli/events.rs b/src/channels/cli/events.rs index aa2194ed..bf5d484e 100644 --- a/src/channels/cli/events.rs +++ b/src/channels/cli/events.rs @@ -20,7 +20,7 @@ pub fn run_event_loop( terminal: &mut Terminal>, app: &mut AppState, msg_tx: mpsc::Sender, - event_rx: &mut mpsc::Receiver, + mut event_rx: mpsc::Receiver, ) -> 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); } diff --git a/src/channels/cli/mod.rs b/src/channels/cli/mod.rs index 98396890..5abd9cf3 100644 --- a/src/channels/cli/mod.rs +++ b/src/channels/cli/mod.rs @@ -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>, + event_tx: Arc>>>, } 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> 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, - mut event_rx: mpsc::Receiver, + event_rx: mpsc::Receiver, ) -> 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()?; diff --git a/src/main.rs b/src/main.rs index 9897b6a7..05d806c0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 diff --git a/src/tools/wasm/capabilities_schema.rs b/src/tools/wasm/capabilities_schema.rs new file mode 100644 index 00000000..ef01da2f --- /dev/null +++ b/src/tools/wasm/capabilities_schema.rs @@ -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, + + /// Secret existence checks. + #[serde(default)] + pub secrets: Option, + + /// Tool invocation via aliases. + #[serde(default)] + pub tool_invoke: Option, + + /// Workspace file read access. + #[serde(default)] + pub workspace: Option, +} + +impl CapabilitiesFile { + /// Parse from JSON string. + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json) + } + + /// Parse from JSON bytes. + pub fn from_bytes(bytes: &[u8]) -> Result { + 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, + + /// Credential mappings (key is an identifier, not the secret name). + #[serde(default)] + pub credentials: HashMap, + + /// Rate limiting configuration. + #[serde(default)] + pub rate_limit: Option, + + /// Maximum request body size in bytes. + #[serde(default)] + pub max_request_bytes: Option, + + /// Maximum response body size in bytes. + #[serde(default)] + pub max_response_bytes: Option, + + /// Request timeout in seconds. + #[serde(default)] + pub timeout_secs: Option, +} + +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, + + /// Allowed HTTP methods (empty = all). + #[serde(default)] + pub methods: Vec, +} + +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, +} + +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, + }, + + /// 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, +} + +/// 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, + + /// Rate limiting for tool calls. + #[serde(default)] + pub rate_limit: Option, +} + +/// 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, +} + +#[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")); + } +} diff --git a/src/tools/wasm/loader.rs b/src/tools/wasm/loader.rs new file mode 100644 index 00000000..7fad73f9 --- /dev/null +++ b/src/tools/wasm/loader.rs @@ -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 `.wasm` and `.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, + registry: Arc, +} + +impl WasmToolLoader { + /// Create a new loader with the given runtime and registry. + pub fn new(runtime: Arc, registry: Arc) -> 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 { + 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 { + 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, + + /// 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, 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, +} + +#[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")); + } +} diff --git a/src/tools/wasm/mod.rs b/src/tools/wasm/mod.rs index 3b96c655..3dcb6fcd 100644 --- a/src/tools/wasm/mod.rs +++ b/src/tools/wasm/mod.rs @@ -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;