@@ -4,24 +4,60 @@ pub mod registry;
44pub use cli:: * ;
55pub 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 ;
813use 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 } ;
1215use 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
1550pub 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
2763impl 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