Skip to main content

ravenclaws/
ravenfabric.rs

1//! RavenClaws
2//!
3//! Provides a client for communicating with RavenFabric — a secure E2E-encrypted
4//! RavenClaws
5//! to dispatch tasks to remote agents, coordinate across a fleet, and execute
6//! commands on remote hosts.
7//!
8//! # Protocol
9//!
10//! RavenFabric exposes a REST API over HTTPS:
11//! - `POST /api/v1/execute` — Execute a command on a remote host
12//! - `GET /api/v1/agents` — List available remote agents
13//! - `GET /api/v1/health` — Health check
14//!
15//! All requests include the agent ID for identification. Responses are JSON.
16
17use crate::config::RavenFabricConfig;
18use serde::{Deserialize, Serialize};
19use tracing::{info, warn};
20
21/// RavenFabric client for remote execution and mesh coordination
22///
23/// Methods are currently wired for initialization and logging only.
24/// Full remote execution dispatch will be integrated in a follow-up.
25#[derive(Debug, Clone)]
26pub struct RavenFabricClient {
27    /// Configuration
28    config: RavenFabricConfig,
29    /// HTTP client (shared)
30    http_client: reqwest::Client,
31}
32
33/// Request to execute a command on a remote host
34#[derive(Debug, Serialize)]
35struct ExecuteRequest {
36    /// Command to execute
37    command: String,
38    /// Target host (optional — if None, executes on any available agent)
39    #[serde(skip_serializing_if = "Option::is_none")]
40    target_host: Option<String>,
41    /// Timeout in seconds
42    #[serde(default = "default_timeout")]
43    timeout_secs: u64,
44    /// Agent ID for identification
45    agent_id: String,
46}
47
48#[allow(dead_code)]
49fn default_timeout() -> u64 {
50    30
51}
52
53/// Response from a remote execution
54#[derive(Debug, Deserialize)]
55#[allow(dead_code)]
56pub struct ExecuteResponse {
57    /// Whether the execution was successful
58    pub success: bool,
59    /// Standard output
60    pub stdout: String,
61    /// Standard error
62    pub stderr: String,
63    /// Exit code
64    pub exit_code: i32,
65    /// Execution duration in milliseconds
66    pub duration_ms: u64,
67}
68
69/// Information about a remote agent
70#[derive(Debug, Deserialize)]
71#[allow(dead_code)]
72pub struct RemoteAgent {
73    /// Agent ID
74    pub id: String,
75    /// Agent hostname
76    pub hostname: String,
77    /// Agent status (online/offline/busy)
78    pub status: String,
79    /// Last seen timestamp
80    pub last_seen: String,
81    /// Agent capabilities
82    pub capabilities: Vec<String>,
83}
84
85impl RavenFabricClient {
86    /// Create a new RavenFabric client
87    ///
88    /// Returns `None` if RavenFabric is not configured (no endpoint set).
89    pub fn new(config: &RavenFabricConfig) -> Option<Self> {
90        let endpoint = config.endpoint.as_ref()?;
91
92        let http_client = reqwest::Client::builder()
93            .timeout(std::time::Duration::from_secs(30))
94            .user_agent("RavenClaws/0.9.2")
95            .build()
96            .ok()?;
97
98        info!(
99            endpoint = %endpoint,
100            agent_id = ?config.agent_id,
101            "RavenFabric client initialized"
102        );
103
104        Some(Self {
105            config: config.clone(),
106            http_client,
107        })
108    }
109
110    /// Get the configured endpoint
111    pub fn endpoint(&self) -> Option<&str> {
112        self.config.endpoint.as_deref()
113    }
114
115    /// Get the agent ID
116    pub fn agent_id(&self) -> Option<&str> {
117        self.config.agent_id.as_deref()
118    }
119
120    /// Check if RavenFabric is enabled for remote execution
121    pub fn is_enabled(&self) -> bool {
122        self.config.remote_exec
123    }
124
125    /// Build an HTTP URL from the configured endpoint, converting ws:// to http://
126    /// and wss:// to https:// as needed for HTTP-based API calls.
127    fn http_url(&self, path: &str) -> Result<String> {
128        let endpoint = self.config.endpoint.as_deref().ok_or_else(|| {
129            RavenFabricError::NotConfigured("No RavenFabric endpoint configured".to_string())
130        })?;
131        let url = format!("{}{}", endpoint.trim_end_matches('/'), path);
132        // Convert WebSocket schemes to HTTP for REST API calls
133        let url = url
134            .replacen("ws://", "http://", 1)
135            .replacen("wss://", "https://", 1);
136        Ok(url)
137    }
138
139    /// Check RavenFabric health
140    pub async fn health(&self) -> Result<bool> {
141        let url = self.http_url("/api/v1/health")?;
142
143        match self.http_client.get(&url).send().await {
144            Ok(response) => Ok(response.status().is_success()),
145            Err(e) => {
146                warn!(error = %e, url = %url, "RavenFabric health check failed");
147                Err(RavenFabricError::ConnectionFailed(e.to_string()))
148            }
149        }
150    }
151
152    /// List available remote agents
153    pub async fn list_agents(&self) -> Result<Vec<RemoteAgent>> {
154        let url = self.http_url("/api/v1/agents")?;
155
156        let response = self
157            .http_client
158            .get(&url)
159            .send()
160            .await
161            .map_err(|e| RavenFabricError::ConnectionFailed(e.to_string()))?;
162
163        if !response.status().is_success() {
164            return Err(RavenFabricError::RequestFailed(format!(
165                "Failed to list agents: HTTP {}",
166                response.status()
167            )));
168        }
169
170        let agents: Vec<RemoteAgent> = response.json().await.map_err(|e| {
171            RavenFabricError::RequestFailed(format!("Failed to parse agents: {}", e))
172        })?;
173
174        info!(count = agents.len(), "RavenFabric agents listed");
175        Ok(agents)
176    }
177
178    /// Execute a command on a remote host via RavenFabric
179    ///
180    /// If `target_host` is `None`, RavenFabric will execute on any available agent.
181    pub async fn execute(
182        &self,
183        command: &str,
184        target_host: Option<&str>,
185        timeout_secs: u64,
186    ) -> Result<ExecuteResponse> {
187        let url = self.http_url("/api/v1/execute")?;
188
189        let request = ExecuteRequest {
190            command: command.to_string(),
191            target_host: target_host.map(|s| s.to_string()),
192            timeout_secs,
193            agent_id: self
194                .config
195                .agent_id
196                .clone()
197                .unwrap_or_else(|| "ravenclaws-default".to_string()),
198        };
199
200        info!(
201            command = %command,
202            target = ?target_host,
203            timeout = timeout_secs,
204            "RavenFabric execute request"
205        );
206
207        let response = self
208            .http_client
209            .post(&url)
210            .json(&request)
211            .send()
212            .await
213            .map_err(|e| RavenFabricError::ConnectionFailed(e.to_string()))?;
214
215        if !response.status().is_success() {
216            let status = response.status();
217            let body = response.text().await.unwrap_or_default();
218            return Err(RavenFabricError::RequestFailed(format!(
219                "Execute request failed: HTTP {} — {}",
220                status, body
221            )));
222        }
223
224        let result: ExecuteResponse = response.json().await.map_err(|e| {
225            RavenFabricError::RequestFailed(format!("Failed to parse response: {}", e))
226        })?;
227
228        info!(
229            success = result.success,
230            exit_code = result.exit_code,
231            duration_ms = result.duration_ms,
232            "RavenFabric execute completed"
233        );
234
235        Ok(result)
236    }
237
238    /// Execute a command on all available remote agents (broadcast)
239    pub async fn broadcast(
240        &self,
241        command: &str,
242        timeout_secs: u64,
243    ) -> Result<Vec<(String, Result<ExecuteResponse>)>> {
244        let agents = self.list_agents().await?;
245        let mut results = Vec::new();
246
247        for agent in &agents {
248            let result = self
249                .execute(command, Some(&agent.hostname), timeout_secs)
250                .await;
251            results.push((agent.id.clone(), result));
252        }
253
254        info!(
255            command = %command,
256            agent_count = agents.len(),
257            "RavenFabric broadcast completed"
258        );
259
260        Ok(results)
261    }
262}
263
264/// Errors from RavenFabric operations
265#[derive(Debug, thiserror::Error)]
266pub enum RavenFabricError {
267    #[error("RavenFabric not configured: {0}")]
268    NotConfigured(String),
269
270    #[error("RavenFabric connection failed: {0}")]
271    ConnectionFailed(String),
272
273    #[error("RavenFabric request failed: {0}")]
274    RequestFailed(String),
275}
276
277/// Type alias for RavenFabric results
278pub type Result<T> = std::result::Result<T, RavenFabricError>;
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn test_ravenfabric_client_new_no_endpoint() {
286        let config = RavenFabricConfig {
287            endpoint: None,
288            agent_id: None,
289            remote_exec: true,
290            allowed_hosts: vec![],
291        };
292        let client = RavenFabricClient::new(&config);
293        assert!(client.is_none(), "Client should be None when no endpoint");
294    }
295
296    #[test]
297    fn test_ravenfabric_client_new_with_endpoint() {
298        let config = RavenFabricConfig {
299            endpoint: Some("http://localhost:8080".to_string()),
300            agent_id: Some("test-agent".to_string()),
301            remote_exec: true,
302            allowed_hosts: vec![],
303        };
304        let client = RavenFabricClient::new(&config);
305        assert!(
306            client.is_some(),
307            "Client should be Some when endpoint is set"
308        );
309        let client = client.unwrap();
310        assert_eq!(client.endpoint(), Some("http://localhost:8080"));
311        assert_eq!(client.agent_id(), Some("test-agent"));
312        assert!(client.is_enabled());
313    }
314
315    #[test]
316    fn test_ravenfabric_client_disabled() {
317        let config = RavenFabricConfig {
318            endpoint: Some("http://localhost:8080".to_string()),
319            agent_id: None,
320            remote_exec: false,
321            allowed_hosts: vec![],
322        };
323        let client = RavenFabricClient::new(&config);
324        assert!(client.is_some());
325        assert!(!client.unwrap().is_enabled());
326    }
327
328    #[test]
329    fn test_ravenfabric_error_display() {
330        let err = RavenFabricError::NotConfigured("no endpoint".to_string());
331        assert_eq!(
332            format!("{}", err),
333            "RavenFabric not configured: no endpoint"
334        );
335
336        let err = RavenFabricError::ConnectionFailed("timeout".to_string());
337        assert_eq!(format!("{}", err), "RavenFabric connection failed: timeout");
338
339        let err = RavenFabricError::RequestFailed("bad request".to_string());
340        assert_eq!(
341            format!("{}", err),
342            "RavenFabric request failed: bad request"
343        );
344    }
345
346    #[tokio::test]
347    async fn test_ravenfabric_health_no_endpoint() {
348        let config = RavenFabricConfig {
349            endpoint: None,
350            agent_id: None,
351            remote_exec: true,
352            allowed_hosts: vec![],
353        };
354        let client = RavenFabricClient::new(&config);
355        assert!(client.is_none());
356    }
357
358    #[tokio::test]
359    async fn test_ravenfabric_execute_no_endpoint() {
360        let config = RavenFabricConfig {
361            endpoint: None,
362            agent_id: None,
363            remote_exec: true,
364            allowed_hosts: vec![],
365        };
366        // Can't create client without endpoint
367        assert!(RavenFabricClient::new(&config).is_none());
368    }
369
370    #[tokio::test]
371    async fn test_ravenfabric_health_connection_refused() {
372        let config = RavenFabricConfig {
373            endpoint: Some("http://127.0.0.1:1".to_string()), // Port 1 will refuse
374            agent_id: Some("test".to_string()),
375            remote_exec: true,
376            allowed_hosts: vec![],
377        };
378        let client = RavenFabricClient::new(&config).unwrap();
379        let result = client.health().await;
380        assert!(result.is_err());
381        match result.unwrap_err() {
382            RavenFabricError::ConnectionFailed(_) => {} // Expected
383            other => panic!("Expected ConnectionFailed, got: {}", other),
384        }
385    }
386
387    #[tokio::test]
388    async fn test_ravenfabric_list_agents_connection_refused() {
389        let config = RavenFabricConfig {
390            endpoint: Some("http://127.0.0.1:1".to_string()),
391            agent_id: Some("test".to_string()),
392            remote_exec: true,
393            allowed_hosts: vec![],
394        };
395        let client = RavenFabricClient::new(&config).unwrap();
396        let result = client.list_agents().await;
397        assert!(result.is_err());
398    }
399
400    #[tokio::test]
401    async fn test_ravenfabric_execute_connection_refused() {
402        let config = RavenFabricConfig {
403            endpoint: Some("http://127.0.0.1:1".to_string()),
404            agent_id: Some("test".to_string()),
405            remote_exec: true,
406            allowed_hosts: vec![],
407        };
408        let client = RavenFabricClient::new(&config).unwrap();
409        let result = client.execute("echo hello", None, 10).await;
410        assert!(result.is_err());
411    }
412
413    #[test]
414    fn test_execute_request_serialization() {
415        let request = ExecuteRequest {
416            command: "echo hello".to_string(),
417            target_host: Some("agent-1".to_string()),
418            timeout_secs: 30,
419            agent_id: "ravenclaws-test".to_string(),
420        };
421        let json = serde_json::to_string(&request).unwrap();
422        assert!(json.contains("echo hello"));
423        assert!(json.contains("agent-1"));
424        assert!(json.contains("ravenclaws-test"));
425        assert!(json.contains("30"));
426    }
427
428    #[test]
429    fn test_execute_request_no_target() {
430        let request = ExecuteRequest {
431            command: "uptime".to_string(),
432            target_host: None,
433            timeout_secs: 10,
434            agent_id: "test".to_string(),
435        };
436        let json = serde_json::to_string(&request).unwrap();
437        assert!(json.contains("uptime"));
438        assert!(
439            !json.contains("target_host"),
440            "target_host should be skipped when None"
441        );
442    }
443
444    #[test]
445    fn test_remote_agent_deserialization() {
446        let json = r#"{
447            "id": "agent-1",
448            "hostname": "worker-01.example.com",
449            "status": "online",
450            "last_seen": "2026-06-18T12:00:00Z",
451            "capabilities": ["shell", "file", "docker"]
452        }"#;
453        let agent: RemoteAgent = serde_json::from_str(json).unwrap();
454        assert_eq!(agent.id, "agent-1");
455        assert_eq!(agent.hostname, "worker-01.example.com");
456        assert_eq!(agent.status, "online");
457        assert_eq!(agent.capabilities.len(), 3);
458    }
459
460    #[test]
461    fn test_execute_response_deserialization() {
462        let json = r#"{
463            "success": true,
464            "stdout": "hello world\n",
465            "stderr": "",
466            "exit_code": 0,
467            "duration_ms": 42
468        }"#;
469        let response: ExecuteResponse = serde_json::from_str(json).unwrap();
470        assert!(response.success);
471        assert_eq!(response.stdout, "hello world\n");
472        assert_eq!(response.exit_code, 0);
473        assert_eq!(response.duration_ms, 42);
474    }
475
476    #[test]
477    fn test_execute_response_failure() {
478        let json = r#"{
479            "success": false,
480            "stdout": "",
481            "stderr": "command not found",
482            "exit_code": 127,
483            "duration_ms": 5
484        }"#;
485        let response: ExecuteResponse = serde_json::from_str(json).unwrap();
486        assert!(!response.success);
487        assert_eq!(response.stderr, "command not found");
488        assert_eq!(response.exit_code, 127);
489    }
490}