Skip to main content

routa_core/acp/
binary_manager.rs

1//! ACP Binary Manager - Downloads and extracts binary agents.
2//!
3//! Handles:
4//! - Downloading agent archives from URLs
5//! - Extracting ZIP, TAR.GZ, TAR.BZ2 formats
6//! - Setting executable permissions on Unix
7//! - Removing macOS quarantine attributes
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use tokio::sync::Mutex;
13
14use super::paths::AcpPaths;
15use super::registry_types::BinaryInfo;
16
17/// Manages binary agent downloads and extraction.
18pub struct AcpBinaryManager {
19    paths: AcpPaths,
20    /// Locks to prevent concurrent downloads of the same agent
21    download_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
22}
23
24impl AcpBinaryManager {
25    /// Create a new binary manager.
26    pub fn new(paths: AcpPaths) -> Self {
27        Self {
28            paths,
29            download_locks: Arc::new(Mutex::new(HashMap::new())),
30        }
31    }
32
33    /// Download and install a binary agent.
34    /// Returns the path to the executable.
35    pub async fn install_binary(
36        &self,
37        agent_id: &str,
38        version: &str,
39        binary_info: &BinaryInfo,
40    ) -> Result<PathBuf, String> {
41        // Get or create a lock for this agent
42        let lock = {
43            let mut locks = self.download_locks.lock().await;
44            locks
45                .entry(agent_id.to_string())
46                .or_insert_with(|| Arc::new(Mutex::new(())))
47                .clone()
48        };
49
50        // Hold the lock during download/extraction
51        let _guard = lock.lock().await;
52
53        let install_dir = self.paths.agent_version_dir(agent_id, version);
54        let download_dir = self.paths.agent_download_dir(agent_id, version);
55
56        // Check if already installed
57        if install_dir.exists() {
58            if let Some(exe) = self.find_executable(&install_dir, binary_info).await {
59                tracing::info!(
60                    "[AcpBinaryManager] Agent {} already installed at {:?}",
61                    agent_id,
62                    exe
63                );
64                return Ok(exe);
65            }
66        }
67
68        // Create directories
69        tokio::fs::create_dir_all(&download_dir)
70            .await
71            .map_err(|e| format!("Failed to create download dir: {}", e))?;
72        tokio::fs::create_dir_all(&install_dir)
73            .await
74            .map_err(|e| format!("Failed to create install dir: {}", e))?;
75
76        // Download the archive
77        let archive_path = self
78            .download_archive(&binary_info.archive, &download_dir)
79            .await?;
80
81        // Extract the archive
82        self.extract_archive(&archive_path, &install_dir).await?;
83
84        // Find and prepare the executable
85        let exe_path = self
86            .find_executable(&install_dir, binary_info)
87            .await
88            .ok_or_else(|| "Could not find executable in extracted archive".to_string())?;
89
90        // Set executable permissions and remove quarantine
91        self.prepare_executable(&exe_path).await?;
92
93        // Clean up download directory
94        let _ = tokio::fs::remove_dir_all(&download_dir).await;
95
96        tracing::info!(
97            "[AcpBinaryManager] Installed {} v{} at {:?}",
98            agent_id,
99            version,
100            exe_path
101        );
102        Ok(exe_path)
103    }
104
105    /// Download an archive from a URL.
106    async fn download_archive(&self, url: &str, download_dir: &Path) -> Result<PathBuf, String> {
107        tracing::info!("[AcpBinaryManager] Downloading from {}", url);
108
109        let response = reqwest::get(url)
110            .await
111            .map_err(|e| format!("Failed to download: {}", e))?;
112
113        if !response.status().is_success() {
114            return Err(format!(
115                "Download failed with status: {}",
116                response.status()
117            ));
118        }
119
120        // Determine filename from URL or Content-Disposition
121        let filename = url
122            .split('/')
123            .next_back()
124            .unwrap_or("archive")
125            .split('?')
126            .next()
127            .unwrap_or("archive");
128
129        let archive_path = download_dir.join(filename);
130
131        let bytes = response
132            .bytes()
133            .await
134            .map_err(|e| format!("Failed to read response: {}", e))?;
135
136        tokio::fs::write(&archive_path, &bytes)
137            .await
138            .map_err(|e| format!("Failed to write archive: {}", e))?;
139
140        tracing::info!(
141            "[AcpBinaryManager] Downloaded {} bytes to {:?}",
142            bytes.len(),
143            archive_path
144        );
145        Ok(archive_path)
146    }
147
148    /// Extract an archive to a directory.
149    async fn extract_archive(&self, archive_path: &Path, install_dir: &Path) -> Result<(), String> {
150        let archive_str = archive_path.to_string_lossy().to_lowercase();
151        let archive_path = archive_path.to_path_buf();
152        let install_dir = install_dir.to_path_buf();
153
154        // Run extraction in blocking task
155        tokio::task::spawn_blocking(move || {
156            if archive_str.ends_with(".zip") {
157                Self::extract_zip(&archive_path, &install_dir)
158            } else if archive_str.ends_with(".tar.gz") || archive_str.ends_with(".tgz") {
159                Self::extract_tar_gz(&archive_path, &install_dir)
160            } else if archive_str.ends_with(".tar.bz2") || archive_str.ends_with(".tbz2") {
161                Self::extract_tar_bz2(&archive_path, &install_dir)
162            } else if archive_str.ends_with(".tar") {
163                Self::extract_tar(&archive_path, &install_dir)
164            } else {
165                // Assume it's a raw binary
166                let filename = archive_path.file_name().unwrap_or_default();
167                let dest = install_dir.join(filename);
168                std::fs::copy(&archive_path, &dest)
169                    .map_err(|e| format!("Failed to copy binary: {}", e))?;
170                Ok(())
171            }
172        })
173        .await
174        .map_err(|e| format!("Extract task failed: {}", e))?
175    }
176
177    fn extract_zip(archive: &Path, dest: &Path) -> Result<(), String> {
178        let file =
179            std::fs::File::open(archive).map_err(|e| format!("Failed to open zip: {}", e))?;
180        let mut archive =
181            zip::ZipArchive::new(file).map_err(|e| format!("Failed to read zip: {}", e))?;
182
183        for i in 0..archive.len() {
184            let mut file = archive
185                .by_index(i)
186                .map_err(|e| format!("Failed to read zip entry: {}", e))?;
187            let outpath = dest.join(file.mangled_name());
188
189            if file.name().ends_with('/') {
190                std::fs::create_dir_all(&outpath).ok();
191            } else {
192                if let Some(p) = outpath.parent() {
193                    std::fs::create_dir_all(p).ok();
194                }
195                let mut outfile = std::fs::File::create(&outpath)
196                    .map_err(|e| format!("Failed to create file: {}", e))?;
197                std::io::copy(&mut file, &mut outfile)
198                    .map_err(|e| format!("Failed to extract file: {}", e))?;
199            }
200        }
201        Ok(())
202    }
203
204    fn extract_tar_gz(archive: &Path, dest: &Path) -> Result<(), String> {
205        let file =
206            std::fs::File::open(archive).map_err(|e| format!("Failed to open tar.gz: {}", e))?;
207        let gz = flate2::read::GzDecoder::new(file);
208        let mut tar = tar::Archive::new(gz);
209        tar.unpack(dest)
210            .map_err(|e| format!("Failed to extract tar.gz: {}", e))?;
211        Ok(())
212    }
213
214    fn extract_tar_bz2(archive: &Path, dest: &Path) -> Result<(), String> {
215        let file =
216            std::fs::File::open(archive).map_err(|e| format!("Failed to open tar.bz2: {}", e))?;
217        let bz2 = bzip2::read::BzDecoder::new(file);
218        let mut tar = tar::Archive::new(bz2);
219        tar.unpack(dest)
220            .map_err(|e| format!("Failed to extract tar.bz2: {}", e))?;
221        Ok(())
222    }
223
224    fn extract_tar(archive: &Path, dest: &Path) -> Result<(), String> {
225        let file =
226            std::fs::File::open(archive).map_err(|e| format!("Failed to open tar: {}", e))?;
227        let mut tar = tar::Archive::new(file);
228        tar.unpack(dest)
229            .map_err(|e| format!("Failed to extract tar: {}", e))?;
230        Ok(())
231    }
232
233    /// Find the executable in the install directory.
234    async fn find_executable(
235        &self,
236        install_dir: &Path,
237        binary_info: &BinaryInfo,
238    ) -> Option<PathBuf> {
239        // If cmd (executable name) is specified, look for it
240        if let Some(cmd) = &binary_info.cmd {
241            // cmd might be "./codex-acp" or "codex-acp", strip the "./" prefix
242            let exe_name = cmd.strip_prefix("./").unwrap_or(cmd);
243            let direct = install_dir.join(exe_name);
244            if direct.exists() {
245                return Some(direct);
246            }
247            // Search recursively
248            if let Some(found) = self.find_file_recursive(install_dir, exe_name).await {
249                return Some(found);
250            }
251        }
252
253        // Look for common executable patterns
254        let mut entries = tokio::fs::read_dir(install_dir).await.ok()?;
255
256        while let Ok(Some(entry)) = entries.next_entry().await {
257            let path = entry.path();
258            if path.is_file() {
259                // Check if it's executable (on Unix) or has no extension (likely binary)
260                #[cfg(unix)]
261                {
262                    use std::os::unix::fs::PermissionsExt;
263                    if let Ok(meta) = path.metadata() {
264                        if meta.permissions().mode() & 0o111 != 0 {
265                            return Some(path);
266                        }
267                    }
268                }
269                #[cfg(windows)]
270                {
271                    if path.extension().map(|e| e == "exe").unwrap_or(false) {
272                        return Some(path);
273                    }
274                }
275            }
276        }
277        None
278    }
279
280    async fn find_file_recursive(&self, dir: &Path, name: &str) -> Option<PathBuf> {
281        let mut stack = vec![dir.to_path_buf()];
282        while let Some(current) = stack.pop() {
283            if let Ok(mut entries) = tokio::fs::read_dir(&current).await {
284                while let Ok(Some(entry)) = entries.next_entry().await {
285                    let path = entry.path();
286                    if path.is_dir() {
287                        stack.push(path);
288                    } else if path.file_name().map(|n| n == name).unwrap_or(false) {
289                        return Some(path);
290                    }
291                }
292            }
293        }
294        None
295    }
296
297    /// Prepare the executable (set permissions, remove quarantine).
298    async fn prepare_executable(&self, _exe_path: &Path) -> Result<(), String> {
299        #[cfg(unix)]
300        {
301            use std::os::unix::fs::PermissionsExt;
302            let mut perms = tokio::fs::metadata(_exe_path)
303                .await
304                .map_err(|e| format!("Failed to get metadata: {}", e))?
305                .permissions();
306            perms.set_mode(perms.mode() | 0o755);
307            tokio::fs::set_permissions(_exe_path, perms)
308                .await
309                .map_err(|e| format!("Failed to set permissions: {}", e))?;
310        }
311
312        // Remove macOS quarantine attribute
313        #[cfg(target_os = "macos")]
314        {
315            let exe_str = _exe_path.to_string_lossy().to_string();
316            let _ = tokio::process::Command::new("xattr")
317                .args(["-d", "com.apple.quarantine", &exe_str])
318                .output()
319                .await;
320        }
321
322        Ok(())
323    }
324
325    /// Uninstall a binary agent.
326    pub async fn uninstall(&self, agent_id: &str) -> Result<(), String> {
327        let agent_dir = self.paths.agent_dir(agent_id);
328        if agent_dir.exists() {
329            tokio::fs::remove_dir_all(&agent_dir)
330                .await
331                .map_err(|e| format!("Failed to remove agent directory: {}", e))?;
332        }
333        Ok(())
334    }
335}