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:
parent
976c5bbf3c
commit
f8befafe4d
9 changed files with 1087 additions and 24 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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
338
src/security/secrets.rs
Normal 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
403
src/tools/composio.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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 => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue