Skip to main content

agent_tools_interface/core/
cli_executor.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::process::Stdio;
4
5use thiserror::Error;
6
7use crate::core::auth_generator::{self, AuthCache, GenContext};
8use crate::core::keyring::Keyring;
9use crate::core::manifest::Provider;
10
11// ---------------------------------------------------------------------------
12// Errors
13// ---------------------------------------------------------------------------
14
15#[derive(Error, Debug)]
16pub enum CliError {
17    #[error("CLI config error: {0}")]
18    Config(String),
19    #[error("Missing keyring key: {0}")]
20    MissingKey(String),
21    #[error("Failed to spawn CLI process: {0}")]
22    Spawn(String),
23    #[error("CLI timed out after {0}s")]
24    Timeout(u64),
25    #[error("CLI exited with code {code}: {stderr}")]
26    NonZeroExit { code: i32, stderr: String },
27    #[error("IO error: {0}")]
28    Io(#[from] std::io::Error),
29    #[error("Credential file error: {0}")]
30    CredentialFile(String),
31}
32
33// ---------------------------------------------------------------------------
34// CredentialFile — wipe-on-drop temporary credential files
35// ---------------------------------------------------------------------------
36
37pub struct CredentialFile {
38    pub path: PathBuf,
39    wipe_on_drop: bool,
40}
41
42impl Drop for CredentialFile {
43    fn drop(&mut self) {
44        if self.wipe_on_drop {
45            // Best-effort overwrite with zeros then delete
46            if let Ok(meta) = std::fs::metadata(&self.path) {
47                let len = meta.len() as usize;
48                if len > 0 {
49                    if let Ok(file) = std::fs::OpenOptions::new().write(true).open(&self.path) {
50                        use std::io::Write;
51                        let zeros = vec![0u8; len];
52                        let _ = (&file).write_all(&zeros);
53                        let _ = file.sync_all();
54                    }
55                }
56            }
57            let _ = std::fs::remove_file(&self.path);
58        }
59    }
60}
61
62// ---------------------------------------------------------------------------
63// Credential file materialization
64// ---------------------------------------------------------------------------
65
66/// Materialize a keyring secret as a file on disk with 0600 permissions.
67///
68/// In dev mode (`wipe_on_drop = false`), uses a stable path so repeated runs
69/// reuse the same file. In prod mode (`wipe_on_drop = true`), appends a random
70/// suffix so concurrent invocations don't collide.
71pub fn materialize_credential_file(
72    key_name: &str,
73    content: &str,
74    wipe_on_drop: bool,
75    ati_dir: &Path,
76) -> Result<CredentialFile, CliError> {
77    use std::os::unix::fs::OpenOptionsExt;
78
79    let creds_dir = ati_dir.join(".creds");
80    std::fs::create_dir_all(&creds_dir).map_err(|e| {
81        CliError::CredentialFile(format!("failed to create {}: {e}", creds_dir.display()))
82    })?;
83
84    let path = if wipe_on_drop {
85        let suffix: u32 = rand::random();
86        creds_dir.join(format!("{key_name}_{suffix}"))
87    } else {
88        creds_dir.join(key_name)
89    };
90
91    let mut file = std::fs::OpenOptions::new()
92        .write(true)
93        .create(true)
94        .truncate(true)
95        .mode(0o600)
96        .open(&path)
97        .map_err(|e| {
98            CliError::CredentialFile(format!("failed to write {}: {e}", path.display()))
99        })?;
100
101    {
102        use std::io::Write;
103        file.write_all(content.as_bytes()).map_err(|e| {
104            CliError::CredentialFile(format!("failed to write {}: {e}", path.display()))
105        })?;
106        file.sync_all().map_err(|e| {
107            CliError::CredentialFile(format!("failed to sync {}: {e}", path.display()))
108        })?;
109    }
110
111    Ok(CredentialFile { path, wipe_on_drop })
112}
113
114// ---------------------------------------------------------------------------
115// Env resolution
116// ---------------------------------------------------------------------------
117
118/// Resolve `${key_ref}` placeholders in a string from the keyring.
119/// Same logic as `resolve_env_value` in `mcp_client.rs`.
120fn resolve_env_value(value: &str, keyring: &Keyring) -> Result<String, CliError> {
121    let mut result = value.to_string();
122    while let Some(start) = result.find("${") {
123        let rest = &result[start + 2..];
124        if let Some(end) = rest.find('}') {
125            let key_name = &rest[..end];
126            let replacement = keyring
127                .get(key_name)
128                .ok_or_else(|| CliError::MissingKey(key_name.to_string()))?;
129            result = format!("{}{}{}", &result[..start], replacement, &rest[end + 1..]);
130        } else {
131            break; // No closing brace
132        }
133    }
134    Ok(result)
135}
136
137/// Resolve a provider's `cli_env` map against the keyring.
138///
139/// Three value forms:
140/// - `@{key_ref}`: materialize the keyring value as a credential file; env value = file path
141/// - `${key_ref}` (possibly inline): substitute from keyring
142/// - plain string: pass through unchanged
143///
144/// Returns the resolved env map and a vec of `CredentialFile`s whose lifetimes
145/// must span the subprocess execution (they are wiped on drop).
146pub fn resolve_cli_env(
147    env_map: &HashMap<String, String>,
148    keyring: &Keyring,
149    wipe_on_drop: bool,
150    ati_dir: &Path,
151) -> Result<(HashMap<String, String>, Vec<CredentialFile>), CliError> {
152    let mut resolved = HashMap::with_capacity(env_map.len());
153    let mut cred_files: Vec<CredentialFile> = Vec::new();
154
155    for (key, value) in env_map {
156        if let Some(key_ref) = value.strip_prefix("@{").and_then(|s| s.strip_suffix('}')) {
157            // File-materialized credential
158            let content = keyring
159                .get(key_ref)
160                .ok_or_else(|| CliError::MissingKey(key_ref.to_string()))?;
161            let cf = materialize_credential_file(key_ref, content, wipe_on_drop, ati_dir)?;
162            resolved.insert(key.clone(), cf.path.to_string_lossy().into_owned());
163            cred_files.push(cf);
164        } else if value.contains("${") {
165            // Inline keyring substitution
166            let val = resolve_env_value(value, keyring)?;
167            resolved.insert(key.clone(), val);
168        } else {
169            // Plain passthrough
170            resolved.insert(key.clone(), value.clone());
171        }
172    }
173
174    Ok((resolved, cred_files))
175}
176
177// ---------------------------------------------------------------------------
178// Execute CLI tool
179// ---------------------------------------------------------------------------
180
181/// Execute a CLI provider tool as a subprocess.
182///
183/// Builds a curated environment (only safe vars from the host + resolved
184/// provider env), spawns the CLI command with the provider's default args
185/// plus the caller's raw args, enforces a timeout, and returns stdout
186/// parsed as JSON (or as a plain string fallback).
187pub async fn execute(
188    provider: &Provider,
189    raw_args: &[String],
190    keyring: &Keyring,
191) -> Result<serde_json::Value, CliError> {
192    execute_with_gen(provider, raw_args, keyring, None, None).await
193}
194
195/// Execute a CLI provider tool, optionally using a dynamic auth generator.
196pub async fn execute_with_gen(
197    provider: &Provider,
198    raw_args: &[String],
199    keyring: &Keyring,
200    gen_ctx: Option<&GenContext>,
201    auth_cache: Option<&AuthCache>,
202) -> Result<serde_json::Value, CliError> {
203    let cli_command = provider
204        .cli_command
205        .as_deref()
206        .ok_or_else(|| CliError::Config("provider missing cli_command".into()))?;
207
208    let timeout_secs = provider.cli_timeout_secs.unwrap_or(120);
209
210    let ati_dir = std::env::var("ATI_DIR")
211        .map(PathBuf::from)
212        .unwrap_or_else(|_| {
213            std::env::var("HOME")
214                .map(PathBuf::from)
215                .unwrap_or_else(|_| PathBuf::from("/tmp"))
216                .join(".ati")
217        });
218
219    let wipe_on_drop = keyring.ephemeral;
220
221    // Resolve provider CLI env vars against keyring.
222    // cred_files must live until after the subprocess exits (Drop does cleanup).
223    let (resolved_env, cred_files) =
224        resolve_cli_env(&provider.cli_env, keyring, wipe_on_drop, &ati_dir)?;
225
226    // Build curated base env from host
227    let mut final_env: HashMap<String, String> = HashMap::new();
228    for var in &["PATH", "HOME", "TMPDIR", "LANG", "USER", "TERM"] {
229        if let Ok(val) = std::env::var(var) {
230            final_env.insert(var.to_string(), val);
231        }
232    }
233    // Layer provider-resolved env on top
234    final_env.extend(resolved_env);
235
236    // If auth_generator is configured, run it and inject into env
237    if let Some(gen) = &provider.auth_generator {
238        let default_ctx = GenContext::default();
239        let ctx = gen_ctx.unwrap_or(&default_ctx);
240        let default_cache = AuthCache::new();
241        let cache = auth_cache.unwrap_or(&default_cache);
242        match auth_generator::generate(provider, gen, ctx, keyring, cache).await {
243            Ok(cred) => {
244                final_env.insert("ATI_AUTH_TOKEN".to_string(), cred.value);
245                for (k, v) in &cred.extra_env {
246                    final_env.insert(k.clone(), v.clone());
247                }
248            }
249            Err(e) => {
250                return Err(CliError::Config(format!("auth_generator failed: {e}")));
251            }
252        }
253    }
254
255    // Clone values for the blocking closure
256    let command = cli_command.to_string();
257    let default_args = provider.cli_default_args.clone();
258    let extra_args = raw_args.to_vec();
259    let env_snapshot = final_env;
260    let timeout_dur = std::time::Duration::from_secs(timeout_secs);
261
262    // Spawn the subprocess via tokio::process so we get an async-aware child
263    // that we can kill on timeout (unlike spawn_blocking + std::process which
264    // would leave the subprocess running when the timeout fires).
265    let child = tokio::process::Command::new(&command)
266        .args(&default_args)
267        .args(&extra_args)
268        .env_clear()
269        .envs(&env_snapshot)
270        .stdout(Stdio::piped())
271        .stderr(Stdio::piped())
272        .kill_on_drop(true)
273        .spawn()
274        .map_err(|e| CliError::Spawn(format!("{command}: {e}")))?;
275
276    // Apply timeout — kill_on_drop ensures the child is killed if we bail early
277    let output = tokio::time::timeout(timeout_dur, child.wait_with_output())
278        .await
279        .map_err(|_| CliError::Timeout(timeout_secs))?
280        .map_err(CliError::Io)?;
281
282    // cred_files still alive here — drop explicitly after subprocess exits
283    drop(cred_files);
284
285    if !output.status.success() {
286        let code = output.status.code().unwrap_or(-1);
287        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
288        return Err(CliError::NonZeroExit { code, stderr });
289    }
290
291    let stdout = String::from_utf8_lossy(&output.stdout);
292    let value = match serde_json::from_str::<serde_json::Value>(stdout.trim()) {
293        Ok(v) => v,
294        Err(_) => serde_json::Value::String(stdout.trim().to_string()),
295    };
296
297    Ok(value)
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use std::fs;
304
305    #[test]
306    fn test_materialize_credential_file_dev_mode() {
307        let tmp = tempfile::tempdir().unwrap();
308        let cf = materialize_credential_file("test_key", "secret123", false, tmp.path()).unwrap();
309        assert_eq!(cf.path, tmp.path().join(".creds/test_key"));
310        let content = fs::read_to_string(&cf.path).unwrap();
311        assert_eq!(content, "secret123");
312
313        // Check permissions (unix)
314        #[cfg(unix)]
315        {
316            use std::os::unix::fs::PermissionsExt;
317            let mode = fs::metadata(&cf.path).unwrap().permissions().mode() & 0o777;
318            assert_eq!(mode, 0o600);
319        }
320    }
321
322    #[test]
323    fn test_materialize_credential_file_prod_mode_unique() {
324        let tmp = tempfile::tempdir().unwrap();
325        let cf1 = materialize_credential_file("key", "val1", true, tmp.path()).unwrap();
326        let cf2 = materialize_credential_file("key", "val2", true, tmp.path()).unwrap();
327        // Prod mode paths should differ (random suffix)
328        assert_ne!(cf1.path, cf2.path);
329    }
330
331    #[test]
332    fn test_credential_file_wipe_on_drop() {
333        let tmp = tempfile::tempdir().unwrap();
334        let path;
335        {
336            let cf = materialize_credential_file("wipe_me", "sensitive", true, tmp.path()).unwrap();
337            path = cf.path.clone();
338            assert!(path.exists());
339        }
340        // After drop, file should be deleted
341        assert!(!path.exists());
342    }
343}