1use log::debug;
7use std::ffi::{OsStr, OsString};
8use std::path::PathBuf;
9use std::process::Stdio;
10
11#[derive(Debug, Clone)]
20pub struct AppServerBuilder {
21 command: PathBuf,
22 working_directory: Option<PathBuf>,
23 environment: Vec<(OsString, OsString)>,
25 config_overrides: Vec<(String, String)>,
28 extra_args: Vec<String>,
31}
32
33impl Default for AppServerBuilder {
34 fn default() -> Self {
35 Self::new()
36 }
37}
38
39impl AppServerBuilder {
40 pub fn new() -> Self {
42 Self {
43 command: PathBuf::from("codex"),
44 working_directory: None,
45 environment: Vec::new(),
46 config_overrides: Vec::new(),
47 extra_args: Vec::new(),
48 }
49 }
50
51 pub fn command<P: Into<PathBuf>>(mut self, path: P) -> Self {
53 self.command = path.into();
54 self
55 }
56
57 pub fn working_directory<P: Into<PathBuf>>(mut self, dir: P) -> Self {
59 self.working_directory = Some(dir.into());
60 self
61 }
62
63 pub fn env<K, V>(mut self, key: K, value: V) -> Self
76 where
77 K: AsRef<OsStr>,
78 V: AsRef<OsStr>,
79 {
80 self.environment
81 .push((key.as_ref().to_os_string(), value.as_ref().to_os_string()));
82 self
83 }
84
85 pub fn envs<I, K, V>(mut self, variables: I) -> Self
90 where
91 I: IntoIterator<Item = (K, V)>,
92 K: AsRef<OsStr>,
93 V: AsRef<OsStr>,
94 {
95 self.environment.extend(
96 variables
97 .into_iter()
98 .map(|(key, value)| (key.as_ref().to_os_string(), value.as_ref().to_os_string())),
99 );
100 self
101 }
102
103 pub fn config_override<K, V>(mut self, key: K, value: V) -> Self
124 where
125 K: Into<String>,
126 V: Into<String>,
127 {
128 self.config_overrides.push((key.into(), value.into()));
129 self
130 }
131
132 pub fn extra_args<I, S>(mut self, args: I) -> Self
151 where
152 I: IntoIterator<Item = S>,
153 S: Into<String>,
154 {
155 self.extra_args.extend(args.into_iter().map(Into::into));
156 self
157 }
158
159 fn resolve_command(&self) -> crate::error::Result<PathBuf> {
161 if self.command.is_absolute() {
162 return Ok(self.command.clone());
163 }
164 which::which(&self.command).map_err(|_| crate::error::Error::BinaryNotFound {
165 name: self.command.display().to_string(),
166 })
167 }
168
169 fn build_args(&self) -> Vec<String> {
173 let mut args =
174 Vec::with_capacity(self.config_overrides.len() * 2 + 3 + self.extra_args.len());
175 for (k, v) in &self.config_overrides {
176 args.push("-c".to_string());
177 args.push(format!("{k}={v}"));
178 }
179 args.push("app-server".to_string());
180 args.push("--listen".to_string());
181 args.push("stdio://".to_string());
182 args.extend(self.extra_args.iter().cloned());
183 args
184 }
185
186 #[cfg(feature = "async-client")]
195 pub fn build_command(self) -> crate::error::Result<tokio::process::Command> {
196 self.build_command_sync().map(tokio::process::Command::from)
197 }
198
199 pub fn build_command_sync(self) -> crate::error::Result<std::process::Command> {
205 let resolved = self.resolve_command()?;
206 let args = self.build_args();
207
208 debug!(
209 "[CLI] Building app-server command: {} {}",
210 resolved.display(),
211 args.join(" ")
212 );
213
214 let mut command = std::process::Command::new(resolved);
215 command
216 .args(args)
217 .envs(self.environment)
218 .stdin(Stdio::piped())
219 .stdout(Stdio::piped())
220 .stderr(Stdio::piped());
221
222 if let Some(dir) = self.working_directory {
223 command.current_dir(dir);
224 }
225
226 Ok(command)
227 }
228
229 #[cfg(feature = "async-client")]
231 pub async fn spawn(self) -> crate::error::Result<tokio::process::Child> {
232 self.build_command()?
233 .spawn()
234 .map_err(crate::error::Error::Io)
235 }
236
237 pub fn spawn_sync(self) -> crate::error::Result<std::process::Child> {
239 self.build_command_sync()?
240 .spawn()
241 .map_err(crate::error::Error::Io)
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn test_default_args() {
251 let builder = AppServerBuilder::new();
252 let args = builder.build_args();
253
254 assert_eq!(args, vec!["app-server", "--listen", "stdio://"]);
255 }
256
257 #[test]
258 fn test_custom_command() {
259 let builder = AppServerBuilder::new().command("/usr/local/bin/codex");
260 assert_eq!(builder.command, PathBuf::from("/usr/local/bin/codex"));
261 }
262
263 #[test]
264 fn test_working_directory() {
265 let builder = AppServerBuilder::new().working_directory("/tmp/work");
266 assert_eq!(builder.working_directory, Some(PathBuf::from("/tmp/work")));
267 }
268
269 #[test]
270 fn test_build_command_sync_applies_process_configuration() {
271 let executable = std::env::current_exe().unwrap();
272 let command = AppServerBuilder::new()
273 .command(&executable)
274 .working_directory("/tmp/work")
275 .env("CODEX_HOME", "/tmp/original")
276 .envs([("RUST_LOG", "debug"), ("CODEX_HOME", "/tmp/codex-home")])
277 .config_override("approval_policy", "never")
278 .extra_args(["--strict-config"])
279 .build_command_sync()
280 .unwrap();
281
282 assert_eq!(command.get_program(), executable);
283 assert_eq!(
284 command.get_args().collect::<Vec<_>>(),
285 [
286 "-c",
287 "approval_policy=never",
288 "app-server",
289 "--listen",
290 "stdio://",
291 "--strict-config",
292 ]
293 );
294 assert_eq!(
295 command.get_current_dir(),
296 Some(std::path::Path::new("/tmp/work"))
297 );
298 assert!(command.get_envs().any(|(key, value)| {
299 key == "CODEX_HOME" && value == Some(OsStr::new("/tmp/codex-home"))
300 }));
301 assert!(command
302 .get_envs()
303 .any(|(key, value)| key == "RUST_LOG" && value == Some(OsStr::new("debug"))));
304 }
305
306 #[test]
307 fn test_config_override_single() {
308 let args = AppServerBuilder::new()
309 .config_override("sandbox_mode", "workspace-write")
310 .build_args();
311 assert_eq!(
312 args,
313 vec![
314 "-c",
315 "sandbox_mode=workspace-write",
316 "app-server",
317 "--listen",
318 "stdio://"
319 ]
320 );
321 }
322
323 #[test]
324 fn test_config_override_multiple_preserves_order() {
325 let args = AppServerBuilder::new()
326 .config_override("sandbox_mode", "workspace-write")
327 .config_override("approval_policy", "on-request")
328 .build_args();
329 assert_eq!(
332 args,
333 vec![
334 "-c",
335 "sandbox_mode=workspace-write",
336 "-c",
337 "approval_policy=on-request",
338 "app-server",
339 "--listen",
340 "stdio://"
341 ]
342 );
343 }
344
345 #[test]
346 fn test_extra_args_appended_after_listen() {
347 let args = AppServerBuilder::new()
348 .extra_args(["--strict-config"])
349 .build_args();
350 assert_eq!(
351 args,
352 vec!["app-server", "--listen", "stdio://", "--strict-config"]
353 );
354 }
355
356 #[test]
357 fn test_config_override_and_extra_args_combined() {
358 let args = AppServerBuilder::new()
359 .config_override("sandbox_mode", "workspace-write")
360 .extra_args(["--strict-config", "--something-else"])
361 .build_args();
362 assert_eq!(
363 args,
364 vec![
365 "-c",
366 "sandbox_mode=workspace-write",
367 "app-server",
368 "--listen",
369 "stdio://",
370 "--strict-config",
371 "--something-else",
372 ]
373 );
374 }
375
376 #[test]
377 fn test_config_override_value_with_special_chars_unchanged() {
378 let args = AppServerBuilder::new()
381 .config_override("sandbox_permissions", r#"["disk-full-read-access"]"#)
382 .build_args();
383 assert_eq!(args[1], r#"sandbox_permissions=["disk-full-read-access"]"#);
384 }
385}