feat: add Composio tool provider + encrypted secret store + wizard integration

- src/tools/composio.rs: ComposioTool implementing Tool trait
  - list/execute/connect actions via Composio API (1000+ OAuth apps)
  - 60s timeout, proper error handling, JSON schema for LLM
  - 12 tests covering schema, validation, serde, error paths

- src/security/secrets.rs: SecretStore for encrypted credential storage
  - XOR cipher with random 32-byte key stored in ~/.zeroclaw/.secret_key
  - enc: prefix for encrypted values, plaintext passthrough (backward compat)
  - Key file created with 0600 permissions (Unix)
  - 16 tests: roundtrip, unicode, long secrets, corrupt hex, permissions

- src/config/schema.rs: ComposioConfig + SecretsConfig structs
  - Composio: enabled (default: false), api_key, entity_id
  - Secrets: encrypt (default: true)
  - Both with serde(default) for backward compatibility
  - 8 new config tests

- src/onboard/wizard.rs: new Step 5 'Tool Mode & Security'
  - Sovereign (local only) vs Composio (managed OAuth) selection
  - Encrypted secret storage toggle (default: on)
  - 7-step wizard (was 6)

- src/tools/mod.rs: all_tools() now accepts optional composio_key
- src/agent/loop_.rs: wires Composio key from config into tool registry
- README.md: Composio integration + encrypted secrets documentation

1017 tests, 0 clippy warnings, cargo fmt clean.
This commit is contained in:
argenis de la rosa 2026-02-14 02:41:29 -05:00
parent 976c5bbf3c
commit f8befafe4d
9 changed files with 1087 additions and 24 deletions

403
src/tools/composio.rs Normal file
View file

@ -0,0 +1,403 @@
// Composio Tool Provider — optional managed tool surface with 1000+ OAuth integrations.
//
// When enabled, ZeroClaw can execute actions on Gmail, Notion, GitHub, Slack, etc.
// through Composio's API without storing raw OAuth tokens locally.
//
// This is opt-in. Users who prefer sovereign/local-only mode skip this entirely.
// The Composio API key is stored in the encrypted secret store.
use super::traits::{Tool, ToolResult};
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::json;
const COMPOSIO_API_BASE: &str = "https://backend.composio.dev/api/v2";
/// A tool that proxies actions to the Composio managed tool platform.
pub struct ComposioTool {
api_key: String,
client: Client,
}
impl ComposioTool {
pub fn new(api_key: &str) -> Self {
Self {
api_key: api_key.to_string(),
client: Client::builder()
.timeout(std::time::Duration::from_secs(60))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| Client::new()),
}
}
/// List available Composio apps/actions for the authenticated user.
pub async fn list_actions(
&self,
app_name: Option<&str>,
) -> anyhow::Result<Vec<ComposioAction>> {
let mut url = format!("{COMPOSIO_API_BASE}/actions");
if let Some(app) = app_name {
url = format!("{url}?appNames={app}");
}
let resp = self
.client
.get(&url)
.header("x-api-key", &self.api_key)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await.unwrap_or_default();
anyhow::bail!("Composio API error: {err}");
}
let body: ComposioActionsResponse = resp.json().await?;
Ok(body.items)
}
/// Execute a Composio action by name with given parameters.
pub async fn execute_action(
&self,
action_name: &str,
params: serde_json::Value,
entity_id: Option<&str>,
) -> anyhow::Result<serde_json::Value> {
let url = format!("{COMPOSIO_API_BASE}/actions/{action_name}/execute");
let mut body = json!({
"input": params,
});
if let Some(entity) = entity_id {
body["entityId"] = json!(entity);
}
let resp = self
.client
.post(&url)
.header("x-api-key", &self.api_key)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await.unwrap_or_default();
anyhow::bail!("Composio action execution failed: {err}");
}
let result: serde_json::Value = resp.json().await?;
Ok(result)
}
/// Get the OAuth connection URL for a specific app.
pub async fn get_connection_url(
&self,
app_name: &str,
entity_id: &str,
) -> anyhow::Result<String> {
let url = format!("{COMPOSIO_API_BASE}/connectedAccounts");
let body = json!({
"integrationId": app_name,
"entityId": entity_id,
});
let resp = self
.client
.post(&url)
.header("x-api-key", &self.api_key)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let err = resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to get connection URL: {err}");
}
let result: serde_json::Value = resp.json().await?;
result
.get("redirectUrl")
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| anyhow::anyhow!("No redirect URL in response"))
}
}
#[async_trait]
impl Tool for ComposioTool {
fn name(&self) -> &str {
"composio"
}
fn description(&self) -> &str {
"Execute actions on 1000+ apps via Composio (Gmail, Notion, GitHub, Slack, etc.). \
Use action='list' to see available actions, or action='execute' with action_name and params."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"description": "The operation: 'list' (list available actions), 'execute' (run an action), or 'connect' (get OAuth URL)",
"enum": ["list", "execute", "connect"]
},
"app": {
"type": "string",
"description": "App name filter for 'list', or app name for 'connect' (e.g. 'gmail', 'notion', 'github')"
},
"action_name": {
"type": "string",
"description": "The Composio action name to execute (e.g. 'GMAIL_FETCH_EMAILS')"
},
"params": {
"type": "object",
"description": "Parameters to pass to the action"
},
"entity_id": {
"type": "string",
"description": "Entity ID for multi-user setups (defaults to 'default')"
}
},
"required": ["action"]
})
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let action = args
.get("action")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'action' parameter"))?;
let entity_id = args
.get("entity_id")
.and_then(|v| v.as_str())
.unwrap_or("default");
match action {
"list" => {
let app = args.get("app").and_then(|v| v.as_str());
match self.list_actions(app).await {
Ok(actions) => {
let summary: Vec<String> = actions
.iter()
.take(20)
.map(|a| {
format!(
"- {} ({}): {}",
a.name,
a.app_name.as_deref().unwrap_or("?"),
a.description.as_deref().unwrap_or("")
)
})
.collect();
let total = actions.len();
let output = format!(
"Found {total} available actions:\n{}{}",
summary.join("\n"),
if total > 20 {
format!("\n... and {} more", total - 20)
} else {
String::new()
}
);
Ok(ToolResult {
success: true,
output,
error: None,
})
}
Err(e) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Failed to list actions: {e}")),
}),
}
}
"execute" => {
let action_name = args
.get("action_name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'action_name' for execute"))?;
let params = args.get("params").cloned().unwrap_or(json!({}));
match self
.execute_action(action_name, params, Some(entity_id))
.await
{
Ok(result) => {
let output = serde_json::to_string_pretty(&result)
.unwrap_or_else(|_| format!("{result:?}"));
Ok(ToolResult {
success: true,
output,
error: None,
})
}
Err(e) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Action execution failed: {e}")),
}),
}
}
"connect" => {
let app = args
.get("app")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'app' for connect"))?;
match self.get_connection_url(app, entity_id).await {
Ok(url) => Ok(ToolResult {
success: true,
output: format!("Open this URL to connect {app}:\n{url}"),
error: None,
}),
Err(e) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!("Failed to get connection URL: {e}")),
}),
}
}
_ => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(
"Unknown action '{action}'. Use 'list', 'execute', or 'connect'."
)),
}),
}
}
}
// ── API response types ──────────────────────────────────────────
#[derive(Debug, Deserialize)]
struct ComposioActionsResponse {
#[serde(default)]
items: Vec<ComposioAction>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComposioAction {
pub name: String,
#[serde(rename = "appName")]
pub app_name: Option<String>,
pub description: Option<String>,
#[serde(default)]
pub enabled: bool,
}
#[cfg(test)]
mod tests {
use super::*;
// ── Constructor ───────────────────────────────────────────
#[test]
fn composio_tool_has_correct_name() {
let tool = ComposioTool::new("test-key");
assert_eq!(tool.name(), "composio");
}
#[test]
fn composio_tool_has_description() {
let tool = ComposioTool::new("test-key");
assert!(!tool.description().is_empty());
assert!(tool.description().contains("1000+"));
}
#[test]
fn composio_tool_schema_has_required_fields() {
let tool = ComposioTool::new("test-key");
let schema = tool.parameters_schema();
assert!(schema["properties"]["action"].is_object());
assert!(schema["properties"]["action_name"].is_object());
assert!(schema["properties"]["params"].is_object());
assert!(schema["properties"]["app"].is_object());
let required = schema["required"].as_array().unwrap();
assert!(required.contains(&json!("action")));
}
#[test]
fn composio_tool_spec_roundtrip() {
let tool = ComposioTool::new("test-key");
let spec = tool.spec();
assert_eq!(spec.name, "composio");
assert!(spec.parameters.is_object());
}
// ── Execute validation ────────────────────────────────────
#[tokio::test]
async fn execute_missing_action_returns_error() {
let tool = ComposioTool::new("test-key");
let result = tool.execute(json!({})).await;
assert!(result.is_err());
}
#[tokio::test]
async fn execute_unknown_action_returns_error() {
let tool = ComposioTool::new("test-key");
let result = tool.execute(json!({"action": "unknown"})).await.unwrap();
assert!(!result.success);
assert!(result.error.as_ref().unwrap().contains("Unknown action"));
}
#[tokio::test]
async fn execute_without_action_name_returns_error() {
let tool = ComposioTool::new("test-key");
let result = tool.execute(json!({"action": "execute"})).await;
assert!(result.is_err());
}
#[tokio::test]
async fn connect_without_app_returns_error() {
let tool = ComposioTool::new("test-key");
let result = tool.execute(json!({"action": "connect"})).await;
assert!(result.is_err());
}
// ── API response parsing ──────────────────────────────────
#[test]
fn composio_action_deserializes() {
let json_str = r#"{"name": "GMAIL_FETCH_EMAILS", "appName": "gmail", "description": "Fetch emails", "enabled": true}"#;
let action: ComposioAction = serde_json::from_str(json_str).unwrap();
assert_eq!(action.name, "GMAIL_FETCH_EMAILS");
assert_eq!(action.app_name.as_deref(), Some("gmail"));
assert!(action.enabled);
}
#[test]
fn composio_actions_response_deserializes() {
let json_str = r#"{"items": [{"name": "TEST_ACTION", "appName": "test", "description": "A test", "enabled": true}]}"#;
let resp: ComposioActionsResponse = serde_json::from_str(json_str).unwrap();
assert_eq!(resp.items.len(), 1);
assert_eq!(resp.items[0].name, "TEST_ACTION");
}
#[test]
fn composio_actions_response_empty() {
let json_str = r#"{"items": []}"#;
let resp: ComposioActionsResponse = serde_json::from_str(json_str).unwrap();
assert!(resp.items.is_empty());
}
#[test]
fn composio_actions_response_missing_items_defaults() {
let json_str = r#"{}"#;
let resp: ComposioActionsResponse = serde_json::from_str(json_str).unwrap();
assert!(resp.items.is_empty());
}
}