mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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:
co-authored by
Claude Opus 4.5
parent
09032d69cb
commit
4ae59ef52c
Generated
+20
@@ -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"
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> {
|
||||
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<SendMessageResult, String> {
|
||||
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<ListChannelsResult, String> {
|
||||
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<ChannelHistoryResult, String> {
|
||||
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<PostReactionResult, String> {
|
||||
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<GetUserInfoResult, String> {
|
||||
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),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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<String, String> {
|
||||
// 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);
|
||||
@@ -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<String>,
|
||||
},
|
||||
|
||||
/// 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<MessageInfo>,
|
||||
}
|
||||
|
||||
/// Basic message info.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MessageInfo {
|
||||
pub text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<String>,
|
||||
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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub purpose: Option<String>,
|
||||
}
|
||||
|
||||
/// Result from list_channels.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ListChannelsResult {
|
||||
pub ok: bool,
|
||||
pub channels: Vec<Channel>,
|
||||
}
|
||||
|
||||
/// Result from get_channel_history.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ChannelHistoryResult {
|
||||
pub ok: bool,
|
||||
pub messages: Vec<HistoryMessage>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
#[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<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub email: Option<String>,
|
||||
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,
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user