Skip to main content

acp_agent/runtime/
prepare.rs

1use std::ffi::OsString;
2use std::path::PathBuf;
3
4use anyhow::{Context, Result, bail};
5
6use crate::commands::install::{
7    download_archive, extract_archive, make_executable, resolve_cmd_path,
8};
9use crate::registry::{BinaryTarget, Environment, Platform, RegistryAgent};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12/// A concrete instruction set for invoking an agent binary or package runner.
13///
14/// `program` is the executable path (binary or package manager), `args` are the
15/// command line arguments already resolved by the runtime, `env` holds any
16/// injected environment overrides, and `current_dir` is the working directory
17/// (only set when a binary distribution was unpacked locally).
18pub struct CommandSpec {
19    /// The executable (or package manager) that will be launched.
20    pub program: OsString,
21    /// Arguments prepopulated by the runtime before user args are appended.
22    pub args: Vec<OsString>,
23    /// Environment overrides that should be injected into the child process.
24    pub env: Vec<(OsString, OsString)>,
25    /// Optional working directory used by the child process (set for binaries).
26    pub current_dir: Option<PathBuf>,
27}
28
29#[derive(Debug)]
30/// The prepared payload returned to transports and runners.
31///
32/// Consumers should drop this struct only after the agent process exits; the
33/// optional `temp_dir` keeps the temporary extraction directory alive for the
34/// duration of the child process.
35pub struct PreparedCommand {
36    /// The command specification suitable for `tokio::process::Command`.
37    pub spec: CommandSpec,
38    /// Temp directory that must survive while the agent process runs (if present).
39    pub temp_dir: Option<tempfile::TempDir>,
40}
41
42/// Builds a `PreparedCommand` from a registry entry and extra user arguments.
43///
44/// The runtime first attempts to download and extract a binary distribution for
45/// the current platform, then falls back to `npx` or `uvx` package runners if no
46/// binary target exists. Any resolved temporary directory is returned so callers
47/// can keep it alive while the spawned process runs.
48pub async fn prepare_agent_command(
49    agent: &RegistryAgent,
50    user_args: &[String],
51) -> Result<PreparedCommand> {
52    if let Some(binary) = &agent.distribution.binary {
53        let platform = Platform::current()?;
54        if let Some(target) = binary.for_platform(platform) {
55            let temp_dir = tokio::task::spawn_blocking(tempfile::tempdir)
56                .await
57                .context("failed to create temporary directory task")?
58                .context("failed to create temporary directory")?;
59            let archive_path = download_archive(target, temp_dir.path()).await?;
60            let extracted_dir = temp_dir.path().join("extracted");
61            tokio::fs::create_dir_all(&extracted_dir)
62                .await
63                .with_context(|| format!("failed to create {}", extracted_dir.display()))?;
64            extract_archive(archive_path, extracted_dir.clone()).await?;
65
66            let executable_path = resolve_cmd_path(&extracted_dir, &target.cmd);
67            let metadata = tokio::fs::metadata(&executable_path).await;
68            if metadata
69                .as_ref()
70                .map(|metadata| !metadata.is_file())
71                .unwrap_or(true)
72            {
73                bail!(
74                    "downloaded {}, but could not find \"{}\" at {}",
75                    target.archive,
76                    target.cmd,
77                    executable_path.display()
78                );
79            }
80
81            make_executable(&executable_path).await.with_context(|| {
82                format!("failed to mark {} executable", executable_path.display())
83            })?;
84
85            return Ok(PreparedCommand {
86                spec: binary_command_spec(executable_path, extracted_dir, target, user_args),
87                temp_dir: Some(temp_dir),
88            });
89        }
90    }
91
92    if let Some(npx) = &agent.distribution.npx {
93        return Ok(PreparedCommand {
94            spec: package_command_spec(
95                "npx",
96                &npx.package,
97                npx.args.as_ref(),
98                npx.env.as_ref(),
99                user_args,
100            ),
101            temp_dir: None,
102        });
103    }
104
105    if let Some(uvx) = &agent.distribution.uvx {
106        return Ok(PreparedCommand {
107            spec: package_command_spec(
108                "uvx",
109                &uvx.package,
110                uvx.args.as_ref(),
111                uvx.env.as_ref(),
112                user_args,
113            ),
114            temp_dir: None,
115        });
116    }
117
118    bail!(
119        "agent \"{}\" does not have a runnable distribution",
120        agent.id
121    )
122}
123
124fn package_command_spec(
125    program: &str,
126    package: &str,
127    args: Option<&Vec<String>>,
128    env: Option<&Environment>,
129    user_args: &[String],
130) -> CommandSpec {
131    let mut command_args = vec![OsString::from(package)];
132    if let Some(args) = args {
133        command_args.extend(args.iter().cloned().map(OsString::from));
134    }
135    command_args.extend(user_args.iter().cloned().map(OsString::from));
136
137    CommandSpec {
138        program: OsString::from(program),
139        args: command_args,
140        env: clone_env_pairs(env),
141        current_dir: None,
142    }
143}
144
145fn binary_command_spec(
146    executable_path: PathBuf,
147    extracted_dir: PathBuf,
148    target: &BinaryTarget,
149    user_args: &[String],
150) -> CommandSpec {
151    let mut args: Vec<OsString> = target
152        .args
153        .as_ref()
154        .into_iter()
155        .flatten()
156        .cloned()
157        .map(OsString::from)
158        .collect();
159    args.extend(user_args.iter().cloned().map(OsString::from));
160
161    CommandSpec {
162        program: executable_path.into_os_string(),
163        args,
164        env: clone_env_pairs(target.env.as_ref()),
165        current_dir: Some(extracted_dir),
166    }
167}
168
169fn clone_env_pairs(env: Option<&Environment>) -> Vec<(OsString, OsString)> {
170    env.into_iter()
171        .flat_map(|pairs| pairs.iter())
172        .map(|(key, value)| (OsString::from(key), OsString::from(value)))
173        .collect()
174}