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 = std::fs::File::open(archive).map_err(|e| format!("Failed to open zip: {e}"))?;
179        let mut archive =
180            zip::ZipArchive::new(file).map_err(|e| format!("Failed to read zip: {e}"))?;
181
182        for i in 0..archive.len() {
183            let mut file = archive
184                .by_index(i)
185                .map_err(|e| format!("Failed to read zip entry: {e}"))?;
186            let outpath = dest.join(file.mangled_name());
187
188            if file.name().ends_with('/') {
189                std::fs::create_dir_all(&outpath).ok();
190            } else {
191                if let Some(p) = outpath.parent() {
192                    std::fs::create_dir_all(p).ok();
193                }
194                let mut outfile = std::fs::File::create(&outpath)
195                    .map_err(|e| format!("Failed to create file: {e}"))?;
196                std::io::copy(&mut file, &mut outfile)
197                    .map_err(|e| format!("Failed to extract file: {e}"))?;
198            }
199        }
200        Ok(())
201    }
202
203    fn extract_tar_gz(archive: &Path, dest: &Path) -> Result<(), String> {
204        let file =
205            std::fs::File::open(archive).map_err(|e| format!("Failed to open tar.gz: {e}"))?;
206        let gz = flate2::read::GzDecoder::new(file);
207        let mut tar = tar::Archive::new(gz);
208        tar.unpack(dest)
209            .map_err(|e| format!("Failed to extract tar.gz: {e}"))?;
210        Ok(())
211    }
212
213    fn extract_tar_bz2(archive: &Path, dest: &Path) -> Result<(), String> {
214        let file =
215            std::fs::File::open(archive).map_err(|e| format!("Failed to open tar.bz2: {e}"))?;
216        let bz2 = bzip2::read::BzDecoder::new(file);
217        let mut tar = tar::Archive::new(bz2);
218        tar.unpack(dest)
219            .map_err(|e| format!("Failed to extract tar.bz2: {e}"))?;
220        Ok(())
221    }
222
223    fn extract_tar(archive: &Path, dest: &Path) -> Result<(), String> {
224        let file = std::fs::File::open(archive).map_err(|e| format!("Failed to open tar: {e}"))?;
225        let mut tar = tar::Archive::new(file);
226        tar.unpack(dest)
227            .map_err(|e| format!("Failed to extract tar: {e}"))?;
228        Ok(())
229    }
230
231    /// Find the executable in the install directory.
232    async fn find_executable(
233        &self,
234        install_dir: &Path,
235        binary_info: &BinaryInfo,
236    ) -> Option<PathBuf> {
237        // If cmd (executable name) is specified, look for it
238        if let Some(cmd) = &binary_info.cmd {
239            // cmd might be "./codex-acp" or "codex-acp", strip the "./" prefix
240            let exe_name = cmd.strip_prefix("./").unwrap_or(cmd);
241            let direct = install_dir.join(exe_name);
242            if direct.exists() {
243                return Some(direct);
244            }
245            // Search recursively
246            if let Some(found) = self.find_file_recursive(install_dir, exe_name).await {
247                return Some(found);
248            }
249        }
250
251        // Look for common executable patterns
252        let mut entries = tokio::fs::read_dir(install_dir).await.ok()?;
253
254        while let Ok(Some(entry)) = entries.next_entry().await {
255            let path = entry.path();
256            if path.is_file() {
257                // Check if it's executable (on Unix) or has no extension (likely binary)
258                #[cfg(unix)]
259                {
260                    use std::os::unix::fs::PermissionsExt;
261                    if let Ok(meta) = path.metadata() {
262                        if meta.permissions().mode() & 0o111 != 0 {
263                            return Some(path);
264                        }
265                    }
266                }
267                #[cfg(windows)]
268                {
269                    if path.extension().map(|e| e == "exe").unwrap_or(false) {
270                        return Some(path);
271                    }
272                }
273            }
274        }
275        None
276    }
277
278    async fn find_file_recursive(&self, dir: &Path, name: &str) -> Option<PathBuf> {
279        let mut stack = vec![dir.to_path_buf()];
280        while let Some(current) = stack.pop() {
281            if let Ok(mut entries) = tokio::fs::read_dir(&current).await {
282                while let Ok(Some(entry)) = entries.next_entry().await {
283                    let path = entry.path();
284                    if path.is_dir() {
285                        stack.push(path);
286                    } else if path.file_name().map(|n| n == name).unwrap_or(false) {
287                        return Some(path);
288                    }
289                }
290            }
291        }
292        None
293    }
294
295    /// Prepare the executable (set permissions, remove quarantine).
296    async fn prepare_executable(&self, _exe_path: &Path) -> Result<(), String> {
297        #[cfg(unix)]
298        {
299            use std::os::unix::fs::PermissionsExt;
300            let mut perms = tokio::fs::metadata(_exe_path)
301                .await
302                .map_err(|e| format!("Failed to get metadata: {e}"))?
303                .permissions();
304            perms.set_mode(perms.mode() | 0o755);
305            tokio::fs::set_permissions(_exe_path, perms)
306                .await
307                .map_err(|e| format!("Failed to set permissions: {e}"))?;
308        }
309
310        // Remove macOS quarantine attribute
311        #[cfg(target_os = "macos")]
312        {
313            let exe_str = _exe_path.to_string_lossy().to_string();
314            let _ = tokio::process::Command::new("xattr")
315                .args(["-d", "com.apple.quarantine", &exe_str])
316                .output()
317                .await;
318        }
319
320        Ok(())
321    }
322
323    /// Uninstall a binary agent.
324    pub async fn uninstall(&self, agent_id: &str) -> Result<(), String> {
325        let agent_dir = self.paths.agent_dir(agent_id);
326        if agent_dir.exists() {
327            tokio::fs::remove_dir_all(&agent_dir)
328                .await
329                .map_err(|e| format!("Failed to remove agent directory: {e}"))?;
330        }
331        Ok(())
332    }
333}