Skip to content

Commit d55bf47

Browse files
committed
feat: add command wrapper hook and upgrade to Conductor
- Upgrade agent-client-protocol from 0.11 to 0.13 - Add agent-client-protocol-conductor dependency - Replace manual stdin/stdout forwarding with ConductorImpl, which handles acp-over-mcp and future protocol extensions - Add CommandWrapper trait and ResolvedCommand struct for sandboxing (e.g., bwrap) — callers can intercept the resolved program/args/envs before the process is spawned - Make Acpr generic over the wrapper type (Acpr<W: CommandWrapper>) with NullWrapper as the zero-cost default
1 parent 42a183e commit d55bf47

3 files changed

Lines changed: 221 additions & 137 deletions

File tree

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ clap = { version = "4", features = ["derive"] }
4444
dirs = "6.0.0"
4545
flate2 = "1.1.9"
4646
reqwest = { version = "0.13.2", features = ["json"] }
47-
agent-client-protocol = "0.11"
47+
agent-client-protocol = "0.13"
48+
agent-client-protocol-conductor = "0.13"
4849
serde = { version = "1.0.228", features = ["derive"] }
4950
serde_json = "1.0.149"
5051
tar = "0.4.45"

src/lib.rs

Lines changed: 151 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,60 @@ pub mod registry;
44
pub use cli::*;
55
pub use registry::*;
66

7-
use agent_client_protocol::{Agent as AcpAgent, ByteStreams, Client, ConnectTo};
7+
use agent_client_protocol::{
8+
AcpAgent, Agent as AcpAgentRole, Client, ConnectTo, Stdio,
9+
schema::{EnvVariable, McpServer, McpServerStdio},
10+
};
11+
use agent_client_protocol_conductor::{AgentOnly, ConductorImpl};
12+
use std::ffi::OsString;
813
use std::path::PathBuf;
9-
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
10-
use tokio::process::Command;
11-
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
14+
use tokio::io::{AsyncRead, AsyncWrite};
1215
use tracing::{debug, info};
1316

17+
/// A resolved command ready to be spawned, before any wrapping is applied.
18+
#[derive(Debug, Clone)]
19+
pub struct ResolvedCommand {
20+
pub program: OsString,
21+
pub args: Vec<OsString>,
22+
pub envs: Vec<(OsString, OsString)>,
23+
}
24+
25+
/// Trait for transforming a resolved command before it is spawned.
26+
///
27+
/// Implement this to wrap agent processes in a sandbox or modify their
28+
/// execution environment. A blanket impl is provided for all
29+
/// `Fn(ResolvedCommand) -> ResolvedCommand`.
30+
pub trait CommandWrapper {
31+
fn wrap(&self, cmd: ResolvedCommand) -> ResolvedCommand;
32+
}
33+
34+
/// The default (no-op) command wrapper.
35+
pub struct NullWrapper;
36+
37+
impl CommandWrapper for NullWrapper {
38+
fn wrap(&self, cmd: ResolvedCommand) -> ResolvedCommand {
39+
cmd
40+
}
41+
}
42+
43+
impl<F: Fn(ResolvedCommand) -> ResolvedCommand> CommandWrapper for F {
44+
fn wrap(&self, cmd: ResolvedCommand) -> ResolvedCommand {
45+
self(cmd)
46+
}
47+
}
48+
1449
/// Simple function to run an agent by name
1550
pub async fn run(agent_name: &str) -> Result<(), Box<dyn std::error::Error>> {
1651
Acpr::new(agent_name).run().await
1752
}
1853

1954
/// Main library interface for acpr
20-
pub struct Acpr {
55+
pub struct Acpr<W: CommandWrapper = NullWrapper> {
2156
pub agent_name: String,
2257
cache_dir: Option<PathBuf>,
2358
registry_file: Option<PathBuf>,
2459
force: Option<ForceOption>,
60+
command_wrapper: W,
2561
}
2662

2763
impl Acpr {
@@ -32,9 +68,12 @@ impl Acpr {
3268
cache_dir: None,
3369
registry_file: None,
3470
force: None,
71+
command_wrapper: NullWrapper,
3572
}
3673
}
74+
}
3775

76+
impl<W: CommandWrapper> Acpr<W> {
3877
/// Set a custom cache directory
3978
pub fn with_cache_dir(mut self, cache_dir: PathBuf) -> Self {
4079
self.cache_dir = Some(cache_dir);
@@ -53,22 +92,24 @@ impl Acpr {
5392
self
5493
}
5594

56-
/// Run the agent with default stdio
57-
pub async fn run(&self) -> Result<(), Box<dyn std::error::Error>> {
58-
self.run_with_streams(tokio::io::stdin(), tokio::io::stdout())
59-
.await
95+
/// Set a command wrapper that transforms the resolved command before it is spawned.
96+
///
97+
/// The wrapper receives the fully-resolved command (after registry lookup, binary download,
98+
/// and args applied) and returns a modified command. This is useful for wrapping the agent
99+
/// process in a sandbox (e.g., bubblewrap/bwrap).
100+
pub fn with_command_wrapper<F: CommandWrapper>(self, wrapper: F) -> Acpr<F> {
101+
Acpr {
102+
agent_name: self.agent_name,
103+
cache_dir: self.cache_dir,
104+
registry_file: self.registry_file,
105+
force: self.force,
106+
command_wrapper: wrapper,
107+
}
60108
}
61109

62-
/// Run the agent with custom stdio streams
63-
pub async fn run_with_streams<R, W>(
64-
&self,
65-
stdin: R,
66-
stdout: W,
67-
) -> Result<(), Box<dyn std::error::Error>>
68-
where
69-
R: AsyncRead + Unpin + Send + 'static,
70-
W: AsyncWrite + Unpin + Send + 'static,
71-
{
110+
/// Resolve the registry and build the command for this agent,
111+
/// applying the command wrapper.
112+
async fn resolve_agent(&self) -> Result<AcpAgent, Box<dyn std::error::Error>> {
72113
let cache_dir = self.cache_dir.clone().unwrap_or_else(|| {
73114
dirs::cache_dir()
74115
.expect("No cache directory found")
@@ -84,120 +125,112 @@ impl Acpr {
84125
.find(|a| a.id == self.agent_name)
85126
.ok_or("Agent not found")?;
86127

87-
debug!("Running agent: {}", agent.id);
88-
89-
let mut cmd = self.build_command(agent, &cache_dir).await?;
90-
cmd.stdin(std::process::Stdio::piped())
91-
.stdout(std::process::Stdio::piped())
92-
.stderr(std::process::Stdio::inherit());
93-
debug!("Running cmd: {cmd:?}");
94-
95-
let mut child = cmd.spawn()?;
96-
let child_stdin = child.stdin.take().unwrap();
97-
let child_stdout = child.stdout.take().unwrap();
98-
99-
let stdin_future = async {
100-
let mut stdin = stdin;
101-
let mut child_stdin = child_stdin;
102-
let mut buf = [0u8; 8192];
103-
loop {
104-
match stdin.read(&mut buf).await {
105-
Ok(0) => {
106-
debug!("stdin: EOF received");
107-
break;
108-
}
109-
Ok(n) => {
110-
debug!("stdin: received {} bytes", n);
111-
if let Err(e) = child_stdin.write_all(&buf[..n]).await {
112-
tracing::debug!("stdin write error: {}", e);
113-
break;
114-
}
115-
if let Err(e) = child_stdin.flush().await {
116-
tracing::debug!("stdin flush error: {}", e);
117-
break;
118-
}
119-
debug!("stdin: forwarded {} bytes to child", n);
120-
}
121-
Err(e) => {
122-
tracing::debug!("stdin read error: {}", e);
123-
break;
124-
}
125-
}
126-
}
127-
Ok::<(), std::io::Error>(())
128-
};
129-
130-
let stdout_future = async {
131-
let mut child_stdout = child_stdout;
132-
let mut stdout = stdout;
133-
let mut buf = [0u8; 8192];
134-
loop {
135-
match child_stdout.read(&mut buf).await {
136-
Ok(0) => {
137-
debug!("stdout: EOF from child");
138-
break;
139-
}
140-
Ok(n) => {
141-
debug!("stdout: received {} bytes from child", n);
142-
if let Err(e) = stdout.write_all(&buf[..n]).await {
143-
tracing::debug!("stdout write error: {}", e);
144-
break;
145-
}
146-
if let Err(e) = stdout.flush().await {
147-
tracing::debug!("stdout flush error: {}", e);
148-
break;
149-
}
150-
debug!("stdout: forwarded {} bytes", n);
151-
}
152-
Err(e) => {
153-
tracing::debug!("stdout read error: {}", e);
154-
break;
155-
}
156-
}
157-
}
158-
Ok::<(), std::io::Error>(())
159-
};
128+
debug!("Resolving agent: {}", agent.id);
160129

161-
tokio::try_join!(
162-
async { child.wait().await.map_err(|e| e.into()) },
163-
stdin_future,
164-
stdout_future
165-
)?;
130+
let resolved = self.resolve_command(agent, &cache_dir).await?;
131+
let resolved = self.command_wrapper.wrap(resolved);
132+
133+
let args: Vec<String> = resolved
134+
.args
135+
.iter()
136+
.map(|a| a.to_string_lossy().into_owned())
137+
.collect();
138+
let envs: Vec<EnvVariable> = resolved
139+
.envs
140+
.iter()
141+
.map(|(k, v)| {
142+
EnvVariable::new(
143+
k.to_string_lossy().into_owned(),
144+
v.to_string_lossy().into_owned(),
145+
)
146+
})
147+
.collect();
148+
149+
let command = resolved.program.to_string_lossy().into_owned();
150+
let mcp_server = McpServerStdio::new(&self.agent_name, &command)
151+
.args(args)
152+
.env(envs);
153+
let acp_agent = AcpAgent::new(McpServer::Stdio(mcp_server));
154+
155+
Ok(acp_agent)
156+
}
157+
158+
/// Run the agent with default stdio, using the Conductor for protocol handling.
159+
pub async fn run(&self) -> Result<(), Box<dyn std::error::Error>> {
160+
let acp_agent = self.resolve_agent().await?;
161+
let conductor = ConductorImpl::new_agent(&self.agent_name, AgentOnly(acp_agent));
162+
conductor
163+
.run(Stdio::new())
164+
.await
165+
.map_err(|e| e.to_string())?;
166+
Ok(())
167+
}
166168

169+
/// Run the agent with custom stdio streams, using the Conductor for protocol handling.
170+
pub async fn run_with_streams<R, Wr>(
171+
&self,
172+
stdin: R,
173+
stdout: Wr,
174+
) -> Result<(), Box<dyn std::error::Error>>
175+
where
176+
R: AsyncRead + Unpin + Send + 'static,
177+
Wr: AsyncWrite + Unpin + Send + 'static,
178+
{
179+
let acp_agent = self.resolve_agent().await?;
180+
let conductor = ConductorImpl::new_agent(&self.agent_name, AgentOnly(acp_agent));
181+
182+
let byte_streams = agent_client_protocol::ByteStreams::new(
183+
tokio_util::compat::TokioAsyncWriteCompatExt::compat_write(stdout),
184+
tokio_util::compat::TokioAsyncReadCompatExt::compat(stdin),
185+
);
186+
conductor
187+
.run(byte_streams)
188+
.await
189+
.map_err(|e| e.to_string())?;
167190
Ok(())
168191
}
169192

170-
async fn build_command(
193+
async fn resolve_command(
171194
&self,
172195
agent: &Agent,
173196
cache_dir: &PathBuf,
174-
) -> Result<Command, Box<dyn std::error::Error>> {
197+
) -> Result<ResolvedCommand, Box<dyn std::error::Error>> {
175198
if let Some(npx) = &agent.distribution.npx {
176199
info!("Executing npx package: {}", npx.package);
177-
let mut cmd = Command::new("npx");
178-
cmd.arg("-y");
179200
let package_arg = if npx.package.contains('@') && npx.package.matches('@').count() > 1 {
180201
npx.package.clone()
181202
} else {
182203
format!("{}@latest", npx.package)
183204
};
184-
cmd.arg(package_arg).args(&npx.args);
185-
Ok(cmd)
205+
let mut args: Vec<OsString> = vec!["-y".into(), package_arg.into()];
206+
args.extend(npx.args.iter().map(OsString::from));
207+
Ok(ResolvedCommand {
208+
program: "npx".into(),
209+
args,
210+
envs: vec![],
211+
})
186212
} else if let Some(uvx) = &agent.distribution.uvx {
187213
info!("Executing uvx package: {}", uvx.package);
188-
let mut cmd = Command::new("uvx");
189-
cmd.arg(&uvx.package).args(&uvx.args);
190-
Ok(cmd)
214+
let mut args: Vec<OsString> = vec![uvx.package.clone().into()];
215+
args.extend(uvx.args.iter().map(OsString::from));
216+
Ok(ResolvedCommand {
217+
program: "uvx".into(),
218+
args,
219+
envs: vec![],
220+
})
191221
} else if !agent.distribution.binary.is_empty() {
192222
let platform = get_platform();
193223
debug!("Platform detected: {}", platform);
194224
if let Some(binary_dist) = agent.distribution.binary.get(&platform) {
195225
let binary_path =
196226
download_binary(agent, binary_dist, cache_dir, self.force.as_ref()).await?;
197227
info!("Executing binary: {:?}", binary_path);
198-
let mut cmd = Command::new(&binary_path);
199-
cmd.args(&binary_dist.args);
200-
Ok(cmd)
228+
let args: Vec<OsString> = binary_dist.args.iter().map(OsString::from).collect();
229+
Ok(ResolvedCommand {
230+
program: binary_path.into_os_string(),
231+
args,
232+
envs: vec![],
233+
})
201234
} else {
202235
Err(format!("No binary available for platform: {}", platform).into())
203236
}
@@ -207,35 +240,19 @@ impl Acpr {
207240
}
208241
}
209242

210-
/// Implement ConnectTo<Client> so Acpr can act as an ACP agent
211-
impl ConnectTo<Client> for Acpr {
243+
/// Implement ConnectTo<Client> so Acpr can act as an ACP agent via Conductor
244+
impl<W: CommandWrapper + Send + Sync + 'static> ConnectTo<Client> for Acpr<W> {
212245
async fn connect_to(
213246
self,
214-
client: impl ConnectTo<AcpAgent>,
247+
client: impl ConnectTo<AcpAgentRole>,
215248
) -> Result<(), agent_client_protocol::Error> {
216-
debug!("ConnectTo: creating duplex streams");
217-
let (client_stdin, agent_stdin) = tokio::io::duplex(8192);
218-
let (agent_stdout, client_stdout) = tokio::io::duplex(8192);
219-
220-
debug!("ConnectTo: creating ByteStreams for sacp");
221-
let byte_streams = ByteStreams::new(client_stdin.compat_write(), client_stdout.compat());
222-
223-
debug!("ConnectTo: starting agent and client tasks");
224-
tokio::try_join!(
225-
async {
226-
debug!("ConnectTo: starting agent process");
227-
self.run_with_streams(agent_stdin, agent_stdout)
228-
.await
229-
.map_err(|e| agent_client_protocol::Error::internal_error().data(e.to_string()))
230-
},
231-
async {
232-
debug!("ConnectTo: starting sacp client connection");
233-
ConnectTo::<Client>::connect_to(byte_streams, client).await
234-
}
235-
)?;
249+
let acp_agent = self
250+
.resolve_agent()
251+
.await
252+
.map_err(|e| agent_client_protocol::Error::internal_error().data(e.to_string()))?;
236253

237-
debug!("ConnectTo: both tasks completed successfully");
238-
Ok(())
254+
let conductor = ConductorImpl::new_agent(&self.agent_name, AgentOnly(acp_agent));
255+
conductor.run(client).await
239256
}
240257
}
241258

0 commit comments

Comments
 (0)