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 crate::process::configure_no_window(&mut command);
227 Ok(command)
228 }
229
230 #[cfg(feature = "async-client")]
232 pub async fn spawn(self) -> crate::error::Result<tokio::process::Child> {
233 self.build_command()?
234 .spawn()
235 .map_err(crate::error::Error::Io)
236 }
237
238 pub fn spawn_sync(self) -> crate::error::Result<std::process::Child> {
240 self.build_command_sync()?
241 .spawn()
242 .map_err(crate::error::Error::Io)
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn test_default_args() {
252 let builder = AppServerBuilder::new();
253 let args = builder.build_args();
254
255 assert_eq!(args, vec!["app-server", "--listen", "stdio://"]);
256 }
257
258 #[test]
259 fn test_custom_command() {
260 let builder = AppServerBuilder::new().command("/usr/local/bin/codex");
261 assert_eq!(builder.command, PathBuf::from("/usr/local/bin/codex"));
262 }
263
264 #[test]
265 fn test_working_directory() {
266 let builder = AppServerBuilder::new().working_directory("/tmp/work");
267 assert_eq!(builder.working_directory, Some(PathBuf::from("/tmp/work")));
268 }
269
270 #[test]
271 fn test_build_command_sync_applies_process_configuration() {
272 let executable = std::env::current_exe().unwrap();
273 let command = AppServerBuilder::new()
274 .command(&executable)
275 .working_directory("/tmp/work")
276 .env("CODEX_HOME", "/tmp/original")
277 .envs([("RUST_LOG", "debug"), ("CODEX_HOME", "/tmp/codex-home")])
278 .config_override("approval_policy", "never")
279 .extra_args(["--strict-config"])
280 .build_command_sync()
281 .unwrap();
282
283 assert_eq!(command.get_program(), executable);
284 assert_eq!(
285 command.get_args().collect::<Vec<_>>(),
286 [
287 "-c",
288 "approval_policy=never",
289 "app-server",
290 "--listen",
291 "stdio://",
292 "--strict-config",
293 ]
294 );
295 assert_eq!(
296 command.get_current_dir(),
297 Some(std::path::Path::new("/tmp/work"))
298 );
299 assert!(command.get_envs().any(|(key, value)| {
300 key == "CODEX_HOME" && value == Some(OsStr::new("/tmp/codex-home"))
301 }));
302 assert!(command
303 .get_envs()
304 .any(|(key, value)| key == "RUST_LOG" && value == Some(OsStr::new("debug"))));
305 }
306
307 #[test]
308 fn test_config_override_single() {
309 let args = AppServerBuilder::new()
310 .config_override("sandbox_mode", "workspace-write")
311 .build_args();
312 assert_eq!(
313 args,
314 vec![
315 "-c",
316 "sandbox_mode=workspace-write",
317 "app-server",
318 "--listen",
319 "stdio://"
320 ]
321 );
322 }
323
324 #[test]
325 fn test_config_override_multiple_preserves_order() {
326 let args = AppServerBuilder::new()
327 .config_override("sandbox_mode", "workspace-write")
328 .config_override("approval_policy", "on-request")
329 .build_args();
330 assert_eq!(
333 args,
334 vec![
335 "-c",
336 "sandbox_mode=workspace-write",
337 "-c",
338 "approval_policy=on-request",
339 "app-server",
340 "--listen",
341 "stdio://"
342 ]
343 );
344 }
345
346 #[test]
347 fn test_extra_args_appended_after_listen() {
348 let args = AppServerBuilder::new()
349 .extra_args(["--strict-config"])
350 .build_args();
351 assert_eq!(
352 args,
353 vec!["app-server", "--listen", "stdio://", "--strict-config"]
354 );
355 }
356
357 #[test]
358 fn test_config_override_and_extra_args_combined() {
359 let args = AppServerBuilder::new()
360 .config_override("sandbox_mode", "workspace-write")
361 .extra_args(["--strict-config", "--something-else"])
362 .build_args();
363 assert_eq!(
364 args,
365 vec![
366 "-c",
367 "sandbox_mode=workspace-write",
368 "app-server",
369 "--listen",
370 "stdio://",
371 "--strict-config",
372 "--something-else",
373 ]
374 );
375 }
376
377 #[test]
378 fn test_config_override_value_with_special_chars_unchanged() {
379 let args = AppServerBuilder::new()
382 .config_override("sandbox_permissions", r#"["disk-full-read-access"]"#)
383 .build_args();
384 assert_eq!(args[1], r#"sandbox_permissions=["disk-full-read-access"]"#);
385 }
386}