From fd5369a33256e070026e77953089a384999c1187 Mon Sep 17 00:00:00 2001 From: "ilblackdragon@gmail.com" Date: Sat, 21 Mar 2026 22:45:42 -0700 Subject: [PATCH] feat: add Composio WASM tool for third-party app integrations Add Composio integration as a WASM tool (tools-src/composio/), providing a single multiplexed tool with 4 actions: list, execute, connect, and connected_accounts. Supports 250+ third-party apps via Composio's REST API with WASM sandbox security (fuel metering, memory limits, network allowlisting, host-injected credentials). Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.toml | 1 + tools-src/composio/Cargo.toml | 23 + .../composio/composio-tool.capabilities.json | 75 ++++ tools-src/composio/src/lib.rs | 425 ++++++++++++++++++ 4 files changed, 524 insertions(+) create mode 100644 tools-src/composio/Cargo.toml create mode 100644 tools-src/composio/composio-tool.capabilities.json create mode 100644 tools-src/composio/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 32645b9a..d379c0ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ exclude = [ "tools-src/google-drive", "tools-src/google-sheets", "tools-src/google-slides", + "tools-src/composio", "tools-src/slack", "tools-src/telegram", "fuzz", diff --git a/tools-src/composio/Cargo.toml b/tools-src/composio/Cargo.toml new file mode 100644 index 00000000..5fc9b8c0 --- /dev/null +++ b/tools-src/composio/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "composio-tool" +version = "0.1.0" +edition = "2021" +description = "Composio integration tool for IronClaw (WASM component)" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +wit-bindgen = "0.41.0" + +[lib] +crate-type = ["cdylib"] + +[profile.release] +opt-level = "s" +lto = true +strip = true +codegen-units = 1 + +[workspace] diff --git a/tools-src/composio/composio-tool.capabilities.json b/tools-src/composio/composio-tool.capabilities.json new file mode 100644 index 00000000..38f2b70c --- /dev/null +++ b/tools-src/composio/composio-tool.capabilities.json @@ -0,0 +1,75 @@ +{ + "version": "0.1.0", + "wit_version": "0.3.0", + "description": "Connect to 250+ apps (Gmail, GitHub, Slack, Notion, etc.) via Composio. Actions: list (browse tools), execute (run a tool), connect (OAuth-link an app), connected_accounts (list linked accounts). Authentication is handled via the 'composio_api_key' secret injected by the host.", + "parameters": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["list", "execute", "connect", "connected_accounts"], + "description": "Action to perform" + }, + "app": { + "type": "string", + "description": "App/toolkit slug (e.g., \"gmail\", \"github\", \"notion\")" + }, + "tool_slug": { + "type": "string", + "description": "Tool action slug for execute (e.g., \"GMAIL_SEND_EMAIL\")" + }, + "params": { + "description": "Parameters for the tool action (JSON object)" + }, + "connected_account_id": { + "type": "string", + "description": "Specific connected account ID (auto-resolved if omitted)" + } + }, + "required": ["action"], + "additionalProperties": false + }, + "capabilities": { + "http": { + "allowlist": [ + { + "host": "backend.composio.dev", + "path_prefix": "/api/v3/", + "methods": ["GET", "POST"] + } + ], + "credentials": { + "composio_api_key": { + "secret_name": "composio_api_key", + "location": { + "type": "header", + "name": "x-api-key" + }, + "host_patterns": ["backend.composio.dev"] + } + }, + "rate_limit": { + "requests_per_minute": 30, + "requests_per_hour": 500 + } + }, + "secrets": { + "allowed_names": ["composio_api_key"] + } + }, + "auth": { + "secret_name": "composio_api_key", + "display_name": "Composio", + "instructions": "Get an API key at app.composio.dev — go to Settings > API Keys to generate one.", + "setup_url": "https://app.composio.dev/", + "env_var": "COMPOSIO_API_KEY" + }, + "setup": { + "required_secrets": [ + { + "name": "composio_api_key", + "prompt": "Composio API key (from app.composio.dev)" + } + ] + } +} diff --git a/tools-src/composio/src/lib.rs b/tools-src/composio/src/lib.rs new file mode 100644 index 00000000..6276f0d8 --- /dev/null +++ b/tools-src/composio/src/lib.rs @@ -0,0 +1,425 @@ +//! Composio WASM Tool for IronClaw. +//! +//! Connects to 250+ third-party apps via Composio's REST API (v3). +//! Provides a single multiplexed tool with actions: list, execute, connect, +//! connected_accounts. +//! +//! # Authentication +//! +//! Store your Composio API key: +//! `ironclaw secret set composio_api_key ` +//! +//! Get a key at: https://app.composio.dev/ + +wit_bindgen::generate!({ + world: "sandboxed-tool", + path: "../../wit/tool.wit", +}); + +use serde::Deserialize; + +const API_BASE: &str = "https://backend.composio.dev/api/v3"; +const MAX_RETRIES: u32 = 3; + +struct ComposioTool; + +impl exports::near::agent::tool::Guest for ComposioTool { + fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response { + match execute_inner(&req.params, req.context.as_deref()) { + Ok(result) => exports::near::agent::tool::Response { + output: Some(result), + error: None, + }, + Err(e) => exports::near::agent::tool::Response { + output: None, + error: Some(e), + }, + } + } + + fn schema() -> String { + SCHEMA.to_string() + } + + fn description() -> String { + "Connect to 250+ apps (Gmail, GitHub, Slack, Notion, etc.) via Composio. \ + Actions: \"list\" (browse tools), \"execute\" (run a tool), \ + \"connect\" (OAuth-link an app), \"connected_accounts\" (list linked accounts). \ + Authentication is handled via the 'composio_api_key' secret injected by the host." + .to_string() + } +} + +#[derive(Debug, Deserialize)] +struct Params { + action: String, + app: Option, + tool_slug: Option, + params: Option, + connected_account_id: Option, +} + +fn execute_inner(params_str: &str, context: Option<&str>) -> Result { + let params: Params = + serde_json::from_str(params_str).map_err(|e| format!("Invalid parameters: {e}"))?; + + if params.action.is_empty() { + return Err("'action' must not be empty".into()); + } + + // Pre-flight: verify API key is available. + if !near::agent::host::secret_exists("composio_api_key") { + return Err( + "Composio API key not found in secret store. Set it with: \ + ironclaw secret set composio_api_key . \ + Get a key at: https://app.composio.dev/" + .into(), + ); + } + + // Extract an entity identifier from context if provided; prefer `entity_id`, + // then `user_id` (from JobContext), then `requester_id`, otherwise "default". + let entity_id = context + .and_then(|ctx| serde_json::from_str::(ctx).ok()) + .and_then(|v| { + v.get("entity_id") + .or_else(|| v.get("user_id")) + .or_else(|| v.get("requester_id")) + .and_then(|e| e.as_str()) + .map(String::from) + }) + .unwrap_or_else(|| "default".to_string()); + + match params.action.as_str() { + "list" => list_tools(params.app.as_deref()), + "execute" => { + let tool_slug = params + .tool_slug + .as_deref() + .ok_or("missing 'tool_slug' for execute action")?; + let action_params = params.params.unwrap_or(serde_json::json!({})); + execute_action( + tool_slug, + &action_params, + &entity_id, + params.connected_account_id.as_deref(), + ) + } + "connect" => { + let app = params + .app + .as_deref() + .ok_or("missing 'app' for connect action")?; + connect_app(app, &entity_id) + } + "connected_accounts" => list_accounts(params.app.as_deref(), &entity_id), + other => Err(format!( + "unknown action \"{other}\", expected: list, execute, connect, connected_accounts" + )), + } +} + +// --------------------------------------------------------------------------- +// API helpers +// --------------------------------------------------------------------------- + +fn api_get(path: &str, query: &[(&str, &str)]) -> Result { + let url = build_url(path, query); + + let headers = serde_json::json!({ + "Accept": "application/json", + "User-Agent": "IronClaw-Composio-Tool/0.1" + }); + + let response = http_with_retry("GET", &url, &headers.to_string(), None)?; + parse_json_response(&response.body, response.status) +} + +fn api_post(path: &str, body: &serde_json::Value) -> Result { + let url = build_url(path, &[]); + + let headers = serde_json::json!({ + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "IronClaw-Composio-Tool/0.1" + }); + + let body_bytes = serde_json::to_vec(body).map_err(|e| format!("JSON serialize error: {e}"))?; + + let response = http_with_retry("POST", &url, &headers.to_string(), Some(&body_bytes))?; + parse_json_response(&response.body, response.status) +} + +fn http_with_retry( + method: &str, + url: &str, + headers: &str, + body: Option<&[u8]>, +) -> Result { + let mut attempt = 0; + loop { + attempt += 1; + + let resp = near::agent::host::http_request(method, url, headers, body, None) + .map_err(|e| format!("HTTP request failed: {e}"))?; + + if resp.status >= 200 && resp.status < 300 { + return Ok(resp); + } + + if attempt < MAX_RETRIES && (resp.status == 429 || resp.status >= 500) { + near::agent::host::log( + near::agent::host::LogLevel::Warn, + &format!( + "Composio API error {} (attempt {}/{}). Retrying...", + resp.status, attempt, MAX_RETRIES + ), + ); + continue; + } + + // Truncate at byte level before UTF-8 conversion to avoid + // panicking on multibyte character boundaries. + let truncated_bytes = if resp.body.len() > 512 { + &resp.body[..512] + } else { + &resp.body + }; + let truncated = String::from_utf8_lossy(truncated_bytes); + return Err(format!("Composio API error (HTTP {}): {truncated}", resp.status)); + } +} + +fn parse_json_response(body: &[u8], status: u16) -> Result { + if !(200..300).contains(&status) { + // Truncate at byte level before UTF-8 conversion to avoid + // panicking on multibyte character boundaries. + let truncated_bytes = if body.len() > 512 { &body[..512] } else { body }; + let truncated = String::from_utf8_lossy(truncated_bytes); + return Err(format!("Composio API {status}: {truncated}")); + } + + let text = String::from_utf8(body.to_vec()) + .map_err(|e| format!("non-UTF8 response: {e}"))?; + serde_json::from_str(&text).map_err(|e| format!("invalid JSON: {e}")) +} + +// --------------------------------------------------------------------------- +// Actions +// --------------------------------------------------------------------------- + +fn list_tools(app: Option<&str>) -> Result { + let query: Vec<(&str, &str)> = match app { + Some(a) => vec![("toolkit_slug", a)], + None => vec![], + }; + let result = api_get("/tools", &query)?; + serde_json::to_string(&result).map_err(|e| format!("Failed to serialize output: {e}")) +} + +fn execute_action( + tool_slug: &str, + params: &serde_json::Value, + entity_id: &str, + connected_account_id: Option<&str>, +) -> Result { + // Auto-resolve connected account if not provided + let account_id = match connected_account_id { + Some(id) => id.to_string(), + None => resolve_account(tool_slug, entity_id)?, + }; + + let body = serde_json::json!({ + "connected_account_id": account_id, + "entity_id": entity_id, + "input": params, + }); + let result = api_post(&format!("/tools/execute/{}", url_encode(tool_slug)), &body)?; + serde_json::to_string(&result).map_err(|e| format!("Failed to serialize output: {e}")) +} + +fn connect_app(app: &str, entity_id: &str) -> Result { + // Resolve auth config for this app + let configs = api_get("/auth_configs", &[("toolkit_slug", app)])?; + let auth_config_id = configs + .as_array() + .and_then(|arr| arr.first()) + .and_then(|c| c.get("id")) + .and_then(|id| id.as_str()) + .ok_or_else(|| { + format!("no auth config found for {app} — configure it at app.composio.dev") + })?; + + let body = serde_json::json!({ + "auth_config_id": auth_config_id, + "user_id": entity_id, + }); + let result = api_post("/connected_accounts/link", &body)?; + serde_json::to_string(&result).map_err(|e| format!("Failed to serialize output: {e}")) +} + +fn list_accounts(app: Option<&str>, entity_id: &str) -> Result { + let mut query = vec![("user_id", entity_id)]; + if let Some(a) = app { + query.push(("toolkit_slug", a)); + } + let result = api_get("/connected_accounts", &query)?; + serde_json::to_string(&result).map_err(|e| format!("Failed to serialize output: {e}")) +} + +/// Look up the toolkit/app slug for a tool via the Composio API. +/// +/// Querying the API is more reliable than parsing the tool slug string, +/// which breaks for multi-word app names (e.g., `GOOGLE_DRIVE_UPLOAD` +/// would incorrectly resolve to `"google"` instead of `"google_drive"`). +fn lookup_app_for_tool(tool_slug: &str) -> Result { + let tools = api_get("/tools", &[("search", tool_slug)])?; + tools + .as_array() + .and_then(|arr| { + arr.iter().find(|t| { + t.get("slug") + .and_then(|s| s.as_str()) + .map(|s| s.eq_ignore_ascii_case(tool_slug)) + .unwrap_or(false) + }) + }) + .and_then(|t| t.get("toolkit_slug").or_else(|| t.get("appName"))) + .and_then(|v| v.as_str()) + .map(|s| s.to_ascii_lowercase()) + .ok_or_else(|| { + format!("could not determine app for tool \"{tool_slug}\" — verify the slug is correct") + }) +} + +/// Auto-resolve connected account for a tool slug. +fn resolve_account(tool_slug: &str, entity_id: &str) -> Result { + let app = lookup_app_for_tool(tool_slug)?; + + let accounts = api_get("/connected_accounts", &[("user_id", entity_id), ("toolkit_slug", &app)])?; + + accounts + .as_array() + .and_then(|arr| { + arr.iter() + .filter(|a| a.get("status").and_then(|s| s.as_str()) == Some("ACTIVE")) + .max_by_key(|a| { + a.get("updatedAt") + .and_then(|u| u.as_str()) + .unwrap_or("") + .to_string() + }) + }) + .and_then(|a| a.get("id")) + .and_then(|id| id.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| { + format!("no connected account for {app} — use composio with action=\"connect\" first") + }) +} + +// --------------------------------------------------------------------------- +// URL helpers +// --------------------------------------------------------------------------- + +fn build_url(path: &str, query: &[(&str, &str)]) -> String { + let mut url = format!("{API_BASE}{path}"); + if !query.is_empty() { + url.push('?'); + for (i, (k, v)) in query.iter().enumerate() { + if i > 0 { + url.push('&'); + } + url.push_str(&url_encode(k)); + url.push('='); + url.push_str(&url_encode(v)); + } + } + url +} + +/// Percent-encode a string for safe use in URL query parameters. +fn url_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len() * 2); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char); + } + b' ' => out.push_str("%20"), + _ => { + out.push('%'); + out.push(char::from(b"0123456789ABCDEF"[(b >> 4) as usize])); + out.push(char::from(b"0123456789ABCDEF"[(b & 0xf) as usize])); + } + } + } + out +} + +const SCHEMA: &str = r#"{ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["list", "execute", "connect", "connected_accounts"], + "description": "Action to perform" + }, + "app": { + "type": "string", + "description": "App/toolkit slug (e.g., \"gmail\", \"github\", \"notion\")" + }, + "tool_slug": { + "type": "string", + "description": "Tool action slug for execute (e.g., \"GMAIL_SEND_EMAIL\")" + }, + "params": { + "description": "Parameters for the tool action (JSON object)" + }, + "connected_account_id": { + "type": "string", + "description": "Specific connected account ID (auto-resolved if omitted)" + } + }, + "required": ["action"], + "additionalProperties": false +}"#; + +export!(ComposioTool); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_url_encode() { + assert_eq!(url_encode("hello world"), "hello%20world"); + assert_eq!(url_encode("foo&bar=baz"), "foo%26bar%3Dbaz"); + assert_eq!(url_encode("simple"), "simple"); + } + + #[test] + fn test_url_encode_multibyte() { + assert_eq!(url_encode("café"), "caf%C3%A9"); + } + + #[test] + fn test_build_url_no_query() { + let url = build_url("/tools", &[]); + assert_eq!(url, format!("{API_BASE}/tools")); + } + + #[test] + fn test_build_url_with_query() { + let url = build_url("/tools", &[("toolkit_slug", "gmail"), ("search", "send")]); + assert!(url.starts_with(&format!("{API_BASE}/tools?"))); + assert!(url.contains("toolkit_slug=gmail")); + assert!(url.contains("search=send")); + } + + #[test] + fn test_build_url_encodes_special_chars() { + let url = build_url("/tools", &[("q", "my app+1")]); + assert!(url.contains("q=my%20app%2B1")); + } +}