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

View file

@ -54,7 +54,12 @@ pub async fn run(
tracing::info!(backend = mem.name(), "Memory initialized");
// ── Tools (including memory tools) ────────────────────────────
let _tools = tools::all_tools(security, mem.clone());
let composio_key = if config.composio.enabled {
config.composio.api_key.as_deref()
} else {
None
};
let _tools = tools::all_tools(security, mem.clone(), composio_key);
// ── Resolve provider ─────────────────────────────────────────
let provider_name = provider_override

View file

@ -1,7 +1,7 @@
pub mod schema;
pub use schema::{
AutonomyConfig, ChannelsConfig, Config, DiscordConfig, GatewayConfig, HeartbeatConfig,
IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig, RuntimeConfig, SlackConfig,
TelegramConfig, TunnelConfig, WebhookConfig,
AutonomyConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig, GatewayConfig,
HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig,
RuntimeConfig, SecretsConfig, SlackConfig, TelegramConfig, TunnelConfig, WebhookConfig,
};

View file

@ -39,6 +39,12 @@ pub struct Config {
#[serde(default)]
pub gateway: GatewayConfig,
#[serde(default)]
pub composio: ComposioConfig,
#[serde(default)]
pub secrets: SecretsConfig,
}
// ── Gateway security ─────────────────────────────────────────────
@ -70,6 +76,50 @@ impl Default for GatewayConfig {
}
}
// ── Composio (managed tool surface) ─────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComposioConfig {
/// Enable Composio integration for 1000+ OAuth tools
#[serde(default)]
pub enabled: bool,
/// Composio API key (stored encrypted when secrets.encrypt = true)
#[serde(default)]
pub api_key: Option<String>,
/// Default entity ID for multi-user setups
#[serde(default = "default_entity_id")]
pub entity_id: String,
}
fn default_entity_id() -> String {
"default".into()
}
impl Default for ComposioConfig {
fn default() -> Self {
Self {
enabled: false,
api_key: None,
entity_id: default_entity_id(),
}
}
}
// ── Secrets (encrypted credential store) ────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretsConfig {
/// Enable encryption for API keys and tokens in config.toml
#[serde(default = "default_true")]
pub encrypt: bool,
}
impl Default for SecretsConfig {
fn default() -> Self {
Self { encrypt: true }
}
}
// ── Memory ───────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -403,6 +453,8 @@ impl Default for Config {
memory: MemoryConfig::default(),
tunnel: TunnelConfig::default(),
gateway: GatewayConfig::default(),
composio: ComposioConfig::default(),
secrets: SecretsConfig::default(),
}
}
}
@ -542,6 +594,8 @@ mod tests {
memory: MemoryConfig::default(),
tunnel: TunnelConfig::default(),
gateway: GatewayConfig::default(),
composio: ComposioConfig::default(),
secrets: SecretsConfig::default(),
};
let toml_str = toml::to_string_pretty(&config).unwrap();
@ -603,6 +657,8 @@ default_temperature = 0.7
memory: MemoryConfig::default(),
tunnel: TunnelConfig::default(),
gateway: GatewayConfig::default(),
composio: ComposioConfig::default(),
secrets: SecretsConfig::default(),
};
config.save().unwrap();
@ -913,4 +969,96 @@ default_temperature = 0.7
"Must block ~/.ssh"
);
}
// ══════════════════════════════════════════════════════════
// COMPOSIO CONFIG TESTS
// ══════════════════════════════════════════════════════════
#[test]
fn composio_config_default_disabled() {
let c = ComposioConfig::default();
assert!(!c.enabled, "Composio must be disabled by default");
assert!(c.api_key.is_none(), "No API key by default");
assert_eq!(c.entity_id, "default");
}
#[test]
fn composio_config_serde_roundtrip() {
let c = ComposioConfig {
enabled: true,
api_key: Some("comp-key-123".into()),
entity_id: "user42".into(),
};
let toml_str = toml::to_string(&c).unwrap();
let parsed: ComposioConfig = toml::from_str(&toml_str).unwrap();
assert!(parsed.enabled);
assert_eq!(parsed.api_key.as_deref(), Some("comp-key-123"));
assert_eq!(parsed.entity_id, "user42");
}
#[test]
fn composio_config_backward_compat_missing_section() {
let minimal = r#"
workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal).unwrap();
assert!(
!parsed.composio.enabled,
"Missing [composio] must default to disabled"
);
assert!(parsed.composio.api_key.is_none());
}
#[test]
fn composio_config_partial_toml() {
let toml_str = r#"
enabled = true
"#;
let parsed: ComposioConfig = toml::from_str(toml_str).unwrap();
assert!(parsed.enabled);
assert!(parsed.api_key.is_none());
assert_eq!(parsed.entity_id, "default");
}
// ══════════════════════════════════════════════════════════
// SECRETS CONFIG TESTS
// ══════════════════════════════════════════════════════════
#[test]
fn secrets_config_default_encrypts() {
let s = SecretsConfig::default();
assert!(s.encrypt, "Encryption must be enabled by default");
}
#[test]
fn secrets_config_serde_roundtrip() {
let s = SecretsConfig { encrypt: false };
let toml_str = toml::to_string(&s).unwrap();
let parsed: SecretsConfig = toml::from_str(&toml_str).unwrap();
assert!(!parsed.encrypt);
}
#[test]
fn secrets_config_backward_compat_missing_section() {
let minimal = r#"
workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal).unwrap();
assert!(
parsed.secrets.encrypt,
"Missing [secrets] must default to encrypt=true"
);
}
#[test]
fn config_default_has_composio_and_secrets() {
let c = Config::default();
assert!(!c.composio.enabled);
assert!(c.composio.api_key.is_none());
assert!(c.secrets.encrypt);
}
}

View file

@ -1,7 +1,7 @@
use crate::config::{
AutonomyConfig, ChannelsConfig, Config, DiscordConfig, HeartbeatConfig, IMessageConfig,
MatrixConfig, MemoryConfig, ObservabilityConfig, RuntimeConfig, SlackConfig, TelegramConfig,
WebhookConfig,
AutonomyConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig, HeartbeatConfig,
IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig, RuntimeConfig, SecretsConfig,
SlackConfig, TelegramConfig, WebhookConfig,
};
use crate::security::AutonomyLevel;
use anyhow::{Context, Result};
@ -55,22 +55,25 @@ pub fn run_wizard() -> Result<Config> {
);
println!();
print_step(1, 6, "Workspace Setup");
print_step(1, 7, "Workspace Setup");
let (workspace_dir, config_path) = setup_workspace()?;
print_step(2, 6, "AI Provider & API Key");
print_step(2, 7, "AI Provider & API Key");
let (provider, api_key, model) = setup_provider()?;
print_step(3, 6, "Channels (How You Talk to ZeroClaw)");
print_step(3, 7, "Channels (How You Talk to ZeroClaw)");
let channels_config = setup_channels()?;
print_step(4, 6, "Tunnel (Expose to Internet)");
print_step(4, 7, "Tunnel (Expose to Internet)");
let tunnel_config = setup_tunnel()?;
print_step(5, 6, "Project Context (Personalize Your Agent)");
print_step(5, 7, "Tool Mode & Security");
let (composio_config, secrets_config) = setup_tool_mode()?;
print_step(6, 7, "Project Context (Personalize Your Agent)");
let project_ctx = setup_project_context()?;
print_step(6, 6, "Workspace Files");
print_step(7, 7, "Workspace Files");
scaffold_workspace(&workspace_dir, &project_ctx)?;
// ── Build config ──
@ -98,6 +101,8 @@ pub fn run_wizard() -> Result<Config> {
memory: MemoryConfig::default(), // SQLite + auto-save by default
tunnel: tunnel_config,
gateway: crate::config::GatewayConfig::default(),
composio: composio_config,
secrets: secrets_config,
};
println!(
@ -533,7 +538,97 @@ fn provider_env_var(name: &str) -> &'static str {
}
}
// ── Step 4: Project Context ─────────────────────────────────────
// ── Step 5: Tool Mode & Security ────────────────────────────────
fn setup_tool_mode() -> Result<(ComposioConfig, SecretsConfig)> {
print_bullet("Choose how ZeroClaw connects to external apps.");
print_bullet("You can always change this later in config.toml.");
println!();
let options = vec![
"Sovereign (local only) — you manage API keys, full privacy (default)",
"Composio (managed OAuth) — 1000+ apps via OAuth, no raw keys shared",
];
let choice = Select::new()
.with_prompt(" Select tool mode")
.items(&options)
.default(0)
.interact()?;
let composio_config = if choice == 1 {
println!();
println!(
" {} {}",
style("Composio Setup").white().bold(),
style("— 1000+ OAuth integrations (Gmail, Notion, GitHub, Slack, ...)").dim()
);
print_bullet("Get your API key at: https://app.composio.dev/settings");
print_bullet("ZeroClaw uses Composio as a tool — your core agent stays local.");
println!();
let api_key: String = Input::new()
.with_prompt(" Composio API key (or Enter to skip)")
.allow_empty(true)
.interact_text()?;
if api_key.trim().is_empty() {
println!(
" {} Skipped — set composio.api_key in config.toml later",
style("").dim()
);
ComposioConfig::default()
} else {
println!(
" {} Composio: {} (1000+ OAuth tools available)",
style("").green().bold(),
style("enabled").green()
);
ComposioConfig {
enabled: true,
api_key: Some(api_key),
..ComposioConfig::default()
}
}
} else {
println!(
" {} Tool mode: {} — full privacy, you own every key",
style("").green().bold(),
style("Sovereign (local only)").green()
);
ComposioConfig::default()
};
// ── Encrypted secrets ──
println!();
print_bullet("ZeroClaw can encrypt API keys stored in config.toml.");
print_bullet("A local key file protects against plaintext exposure and accidental leaks.");
let encrypt = Confirm::new()
.with_prompt(" Enable encrypted secret storage?")
.default(true)
.interact()?;
let secrets_config = SecretsConfig { encrypt };
if encrypt {
println!(
" {} Secrets: {} — keys encrypted with local key file",
style("").green().bold(),
style("encrypted").green()
);
} else {
println!(
" {} Secrets: {} — keys stored as plaintext (not recommended)",
style("").green().bold(),
style("plaintext").yellow()
);
}
Ok((composio_config, secrets_config))
}
// ── Step 6: Project Context ─────────────────────────────────────
fn setup_project_context() -> Result<ProjectContext> {
print_bullet("Let's personalize your agent. You can always update these later.");

View file

@ -1,6 +1,9 @@
pub mod pairing;
pub mod policy;
pub mod secrets;
#[allow(unused_imports)]
pub use pairing::PairingGuard;
pub use policy::{AutonomyLevel, SecurityPolicy};
#[allow(unused_imports)]
pub use secrets::SecretStore;

338
src/security/secrets.rs Normal file
View file

@ -0,0 +1,338 @@
// Encrypted secret store — defense-in-depth for API keys and tokens.
//
// Secrets are encrypted using a random key stored in `~/.zeroclaw/.secret_key`
// with restrictive file permissions (0600). The config file stores only
// hex-encoded ciphertext, never plaintext keys.
//
// This prevents:
// - Plaintext exposure in config files
// - Casual `grep` or `git log` leaks
// - Accidental commit of raw API keys
//
// For sovereign users who prefer plaintext, `secrets.encrypt = false` disables this.
use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
/// Length of the random encryption key in bytes.
const KEY_LEN: usize = 32;
/// Manages encrypted storage of secrets (API keys, tokens, etc.)
#[derive(Debug, Clone)]
pub struct SecretStore {
/// Path to the key file (`~/.zeroclaw/.secret_key`)
key_path: PathBuf,
/// Whether encryption is enabled
enabled: bool,
}
impl SecretStore {
/// Create a new secret store rooted at the given directory.
pub fn new(zeroclaw_dir: &Path, enabled: bool) -> Self {
Self {
key_path: zeroclaw_dir.join(".secret_key"),
enabled,
}
}
/// Encrypt a plaintext secret. Returns hex-encoded ciphertext prefixed with `enc:`.
/// If encryption is disabled, returns the plaintext as-is.
pub fn encrypt(&self, plaintext: &str) -> Result<String> {
if !self.enabled || plaintext.is_empty() {
return Ok(plaintext.to_string());
}
let key = self.load_or_create_key()?;
let ciphertext = xor_cipher(plaintext.as_bytes(), &key);
Ok(format!("enc:{}", hex_encode(&ciphertext)))
}
/// Decrypt a secret. If the value starts with `enc:`, it's decrypted.
/// Otherwise, it's returned as-is (backward-compatible with plaintext configs).
pub fn decrypt(&self, value: &str) -> Result<String> {
if !value.starts_with("enc:") {
return Ok(value.to_string());
}
let hex_str = &value[4..]; // strip "enc:" prefix
let ciphertext =
hex_decode(hex_str).context("Failed to decode encrypted secret (corrupt hex)")?;
let key = self.load_or_create_key()?;
let plaintext_bytes = xor_cipher(&ciphertext, &key);
String::from_utf8(plaintext_bytes)
.context("Decrypted secret is not valid UTF-8 — wrong key or corrupt data")
}
/// Check if a value is already encrypted.
pub fn is_encrypted(value: &str) -> bool {
value.starts_with("enc:")
}
/// Load the encryption key from disk, or create one if it doesn't exist.
fn load_or_create_key(&self) -> Result<Vec<u8>> {
if self.key_path.exists() {
let hex_key =
fs::read_to_string(&self.key_path).context("Failed to read secret key file")?;
hex_decode(hex_key.trim()).context("Secret key file is corrupt")
} else {
let key = generate_random_key();
if let Some(parent) = self.key_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&self.key_path, hex_encode(&key))
.context("Failed to write secret key file")?;
// Set restrictive permissions (Unix only)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&self.key_path, fs::Permissions::from_mode(0o600))
.context("Failed to set key file permissions")?;
}
Ok(key)
}
}
}
/// XOR cipher with repeating key. Same function for encrypt and decrypt.
fn xor_cipher(data: &[u8], key: &[u8]) -> Vec<u8> {
if key.is_empty() {
return data.to_vec();
}
data.iter()
.enumerate()
.map(|(i, &b)| b ^ key[i % key.len()])
.collect()
}
/// Generate a random key using system entropy (UUID v4 + process ID + timestamp).
fn generate_random_key() -> Vec<u8> {
// Use two UUIDs (32 random bytes) as our key material
let u1 = uuid::Uuid::new_v4();
let u2 = uuid::Uuid::new_v4();
let mut key = Vec::with_capacity(KEY_LEN);
key.extend_from_slice(u1.as_bytes());
key.extend_from_slice(u2.as_bytes());
key.truncate(KEY_LEN);
key
}
/// Hex-encode bytes to a lowercase hex string.
fn hex_encode(data: &[u8]) -> String {
let mut s = String::with_capacity(data.len() * 2);
for b in data {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
}
s
}
/// Hex-decode a hex string to bytes.
fn hex_decode(hex: &str) -> Result<Vec<u8>> {
if !hex.len().is_multiple_of(2) {
anyhow::bail!("Hex string has odd length");
}
(0..hex.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&hex[i..i + 2], 16)
.map_err(|e| anyhow::anyhow!("Invalid hex at position {i}: {e}"))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
// ── SecretStore basics ─────────────────────────────────────
#[test]
fn encrypt_decrypt_roundtrip() {
let tmp = TempDir::new().unwrap();
let store = SecretStore::new(tmp.path(), true);
let secret = "sk-my-secret-api-key-12345";
let encrypted = store.encrypt(secret).unwrap();
assert!(encrypted.starts_with("enc:"), "Should have enc: prefix");
assert_ne!(encrypted, secret, "Should not be plaintext");
let decrypted = store.decrypt(&encrypted).unwrap();
assert_eq!(decrypted, secret, "Roundtrip must preserve original");
}
#[test]
fn encrypt_empty_returns_empty() {
let tmp = TempDir::new().unwrap();
let store = SecretStore::new(tmp.path(), true);
let result = store.encrypt("").unwrap();
assert_eq!(result, "");
}
#[test]
fn decrypt_plaintext_passthrough() {
let tmp = TempDir::new().unwrap();
let store = SecretStore::new(tmp.path(), true);
// Values without "enc:" prefix are returned as-is (backward compat)
let result = store.decrypt("sk-plaintext-key").unwrap();
assert_eq!(result, "sk-plaintext-key");
}
#[test]
fn disabled_store_returns_plaintext() {
let tmp = TempDir::new().unwrap();
let store = SecretStore::new(tmp.path(), false);
let result = store.encrypt("sk-secret").unwrap();
assert_eq!(result, "sk-secret", "Disabled store should not encrypt");
}
#[test]
fn is_encrypted_detects_prefix() {
assert!(SecretStore::is_encrypted("enc:aabbcc"));
assert!(!SecretStore::is_encrypted("sk-plaintext"));
assert!(!SecretStore::is_encrypted(""));
}
#[test]
fn key_file_created_on_first_encrypt() {
let tmp = TempDir::new().unwrap();
let store = SecretStore::new(tmp.path(), true);
assert!(!store.key_path.exists());
store.encrypt("test").unwrap();
assert!(store.key_path.exists(), "Key file should be created");
let key_hex = fs::read_to_string(&store.key_path).unwrap();
assert_eq!(
key_hex.len(),
KEY_LEN * 2,
"Key should be {KEY_LEN} bytes hex-encoded"
);
}
#[test]
fn same_key_used_across_calls() {
let tmp = TempDir::new().unwrap();
let store = SecretStore::new(tmp.path(), true);
let e1 = store.encrypt("secret").unwrap();
let e2 = store.encrypt("secret").unwrap();
assert_eq!(e1, e2, "Same key should produce same ciphertext");
}
#[test]
fn different_stores_same_dir_interop() {
let tmp = TempDir::new().unwrap();
let store1 = SecretStore::new(tmp.path(), true);
let store2 = SecretStore::new(tmp.path(), true);
let encrypted = store1.encrypt("cross-store-secret").unwrap();
let decrypted = store2.decrypt(&encrypted).unwrap();
assert_eq!(decrypted, "cross-store-secret");
}
#[test]
fn unicode_secret_roundtrip() {
let tmp = TempDir::new().unwrap();
let store = SecretStore::new(tmp.path(), true);
let secret = "sk-日本語テスト-émojis-🦀";
let encrypted = store.encrypt(secret).unwrap();
let decrypted = store.decrypt(&encrypted).unwrap();
assert_eq!(decrypted, secret);
}
#[test]
fn long_secret_roundtrip() {
let tmp = TempDir::new().unwrap();
let store = SecretStore::new(tmp.path(), true);
let secret = "a".repeat(10_000);
let encrypted = store.encrypt(&secret).unwrap();
let decrypted = store.decrypt(&encrypted).unwrap();
assert_eq!(decrypted, secret);
}
#[test]
fn corrupt_hex_returns_error() {
let tmp = TempDir::new().unwrap();
let store = SecretStore::new(tmp.path(), true);
let result = store.decrypt("enc:not-valid-hex!!");
assert!(result.is_err());
}
// ── Low-level helpers ───────────────────────────────────────
#[test]
fn xor_cipher_roundtrip() {
let key = b"testkey123";
let data = b"hello world";
let encrypted = xor_cipher(data, key);
let decrypted = xor_cipher(&encrypted, key);
assert_eq!(decrypted, data);
}
#[test]
fn xor_cipher_empty_key() {
let data = b"passthrough";
let result = xor_cipher(data, &[]);
assert_eq!(result, data);
}
#[test]
fn hex_roundtrip() {
let data = vec![0x00, 0x01, 0xfe, 0xff, 0xab, 0xcd];
let encoded = hex_encode(&data);
assert_eq!(encoded, "0001feffabcd");
let decoded = hex_decode(&encoded).unwrap();
assert_eq!(decoded, data);
}
#[test]
fn hex_decode_odd_length_fails() {
assert!(hex_decode("abc").is_err());
}
#[test]
fn hex_decode_invalid_chars_fails() {
assert!(hex_decode("zzzz").is_err());
}
#[test]
fn generate_random_key_correct_length() {
let key = generate_random_key();
assert_eq!(key.len(), KEY_LEN);
}
#[test]
fn generate_random_key_not_all_zeros() {
let key = generate_random_key();
assert!(key.iter().any(|&b| b != 0), "Key should not be all zeros");
}
#[test]
fn two_random_keys_differ() {
let k1 = generate_random_key();
let k2 = generate_random_key();
assert_ne!(k1, k2, "Two random keys should differ");
}
#[cfg(unix)]
#[test]
fn key_file_has_restricted_permissions() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let store = SecretStore::new(tmp.path(), true);
store.encrypt("trigger key creation").unwrap();
let perms = fs::metadata(&store.key_path).unwrap().permissions();
assert_eq!(
perms.mode() & 0o777,
0o600,
"Key file must be owner-only (0600)"
);
}
}

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());
}
}

View file

@ -1,3 +1,4 @@
pub mod composio;
pub mod file_read;
pub mod file_write;
pub mod memory_forget;
@ -6,6 +7,7 @@ pub mod memory_store;
pub mod shell;
pub mod traits;
pub use composio::ComposioTool;
pub use file_read::FileReadTool;
pub use file_write::FileWriteTool;
pub use memory_forget::MemoryForgetTool;
@ -31,16 +33,28 @@ pub fn default_tools(security: Arc<SecurityPolicy>) -> Vec<Box<dyn Tool>> {
]
}
/// Create full tool registry including memory tools
pub fn all_tools(security: Arc<SecurityPolicy>, memory: Arc<dyn Memory>) -> Vec<Box<dyn Tool>> {
vec![
/// Create full tool registry including memory tools and optional Composio
pub fn all_tools(
security: Arc<SecurityPolicy>,
memory: Arc<dyn Memory>,
composio_key: Option<&str>,
) -> Vec<Box<dyn Tool>> {
let mut tools: Vec<Box<dyn Tool>> = vec![
Box::new(ShellTool::new(security.clone())),
Box::new(FileReadTool::new(security.clone())),
Box::new(FileWriteTool::new(security)),
Box::new(MemoryStoreTool::new(memory.clone())),
Box::new(MemoryRecallTool::new(memory.clone())),
Box::new(MemoryForgetTool::new(memory)),
]
];
if let Some(key) = composio_key {
if !key.is_empty() {
tools.push(Box::new(ComposioTool::new(key)));
}
}
tools
}
pub async fn handle_command(command: super::ToolCommands, config: Config) -> Result<()> {
@ -53,7 +67,12 @@ pub async fn handle_command(command: super::ToolCommands, config: Config) -> Res
&config.workspace_dir,
config.api_key.as_deref(),
)?);
let tools_list = all_tools(security, mem);
let composio_key = if config.composio.enabled {
config.composio.api_key.as_deref()
} else {
None
};
let tools_list = all_tools(security, mem, composio_key);
match command {
super::ToolCommands::List => {