use log::debug;
use std::ffi::{OsStr, OsString};
use std::path::PathBuf;
use std::process::Stdio;
#[derive(Debug, Clone)]
pub struct AppServerBuilder {
command: PathBuf,
working_directory: Option<PathBuf>,
environment: Vec<(OsString, OsString)>,
config_overrides: Vec<(String, String)>,
extra_args: Vec<String>,
}
impl Default for AppServerBuilder {
fn default() -> Self {
Self::new()
}
}
impl AppServerBuilder {
pub fn new() -> Self {
Self {
command: PathBuf::from("codex"),
working_directory: None,
environment: Vec::new(),
config_overrides: Vec::new(),
extra_args: Vec::new(),
}
}
pub fn command<P: Into<PathBuf>>(mut self, path: P) -> Self {
self.command = path.into();
self
}
pub fn working_directory<P: Into<PathBuf>>(mut self, dir: P) -> Self {
self.working_directory = Some(dir.into());
self
}
pub fn env<K, V>(mut self, key: K, value: V) -> Self
where
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
self.environment
.push((key.as_ref().to_os_string(), value.as_ref().to_os_string()));
self
}
pub fn envs<I, K, V>(mut self, variables: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
self.environment.extend(
variables
.into_iter()
.map(|(key, value)| (key.as_ref().to_os_string(), value.as_ref().to_os_string())),
);
self
}
pub fn config_override<K, V>(mut self, key: K, value: V) -> Self
where
K: Into<String>,
V: Into<String>,
{
self.config_overrides.push((key.into(), value.into()));
self
}
pub fn extra_args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.extra_args.extend(args.into_iter().map(Into::into));
self
}
fn resolve_command(&self) -> crate::error::Result<PathBuf> {
if self.command.is_absolute() {
return Ok(self.command.clone());
}
which::which(&self.command).map_err(|_| crate::error::Error::BinaryNotFound {
name: self.command.display().to_string(),
})
}
fn build_args(&self) -> Vec<String> {
let mut args =
Vec::with_capacity(self.config_overrides.len() * 2 + 3 + self.extra_args.len());
for (k, v) in &self.config_overrides {
args.push("-c".to_string());
args.push(format!("{k}={v}"));
}
args.push("app-server".to_string());
args.push("--listen".to_string());
args.push("stdio://".to_string());
args.extend(self.extra_args.iter().cloned());
args
}
#[cfg(feature = "async-client")]
pub fn build_command(self) -> crate::error::Result<tokio::process::Command> {
self.build_command_sync().map(tokio::process::Command::from)
}
pub fn build_command_sync(self) -> crate::error::Result<std::process::Command> {
let resolved = self.resolve_command()?;
let args = self.build_args();
debug!(
"[CLI] Building app-server command: {} {}",
resolved.display(),
args.join(" ")
);
let mut command = std::process::Command::new(resolved);
command
.args(args)
.envs(self.environment)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(dir) = self.working_directory {
command.current_dir(dir);
}
Ok(command)
}
#[cfg(feature = "async-client")]
pub async fn spawn(self) -> crate::error::Result<tokio::process::Child> {
self.build_command()?
.spawn()
.map_err(crate::error::Error::Io)
}
pub fn spawn_sync(self) -> crate::error::Result<std::process::Child> {
self.build_command_sync()?
.spawn()
.map_err(crate::error::Error::Io)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_args() {
let builder = AppServerBuilder::new();
let args = builder.build_args();
assert_eq!(args, vec!["app-server", "--listen", "stdio://"]);
}
#[test]
fn test_custom_command() {
let builder = AppServerBuilder::new().command("/usr/local/bin/codex");
assert_eq!(builder.command, PathBuf::from("/usr/local/bin/codex"));
}
#[test]
fn test_working_directory() {
let builder = AppServerBuilder::new().working_directory("/tmp/work");
assert_eq!(builder.working_directory, Some(PathBuf::from("/tmp/work")));
}
#[test]
fn test_build_command_sync_applies_process_configuration() {
let executable = std::env::current_exe().unwrap();
let command = AppServerBuilder::new()
.command(&executable)
.working_directory("/tmp/work")
.env("CODEX_HOME", "/tmp/original")
.envs([("RUST_LOG", "debug"), ("CODEX_HOME", "/tmp/codex-home")])
.config_override("approval_policy", "never")
.extra_args(["--strict-config"])
.build_command_sync()
.unwrap();
assert_eq!(command.get_program(), executable);
assert_eq!(
command.get_args().collect::<Vec<_>>(),
[
"-c",
"approval_policy=never",
"app-server",
"--listen",
"stdio://",
"--strict-config",
]
);
assert_eq!(
command.get_current_dir(),
Some(std::path::Path::new("/tmp/work"))
);
assert!(command.get_envs().any(|(key, value)| {
key == "CODEX_HOME" && value == Some(OsStr::new("/tmp/codex-home"))
}));
assert!(command
.get_envs()
.any(|(key, value)| key == "RUST_LOG" && value == Some(OsStr::new("debug"))));
}
#[test]
fn test_config_override_single() {
let args = AppServerBuilder::new()
.config_override("sandbox_mode", "workspace-write")
.build_args();
assert_eq!(
args,
vec![
"-c",
"sandbox_mode=workspace-write",
"app-server",
"--listen",
"stdio://"
]
);
}
#[test]
fn test_config_override_multiple_preserves_order() {
let args = AppServerBuilder::new()
.config_override("sandbox_mode", "workspace-write")
.config_override("approval_policy", "on-request")
.build_args();
assert_eq!(
args,
vec![
"-c",
"sandbox_mode=workspace-write",
"-c",
"approval_policy=on-request",
"app-server",
"--listen",
"stdio://"
]
);
}
#[test]
fn test_extra_args_appended_after_listen() {
let args = AppServerBuilder::new()
.extra_args(["--strict-config"])
.build_args();
assert_eq!(
args,
vec!["app-server", "--listen", "stdio://", "--strict-config"]
);
}
#[test]
fn test_config_override_and_extra_args_combined() {
let args = AppServerBuilder::new()
.config_override("sandbox_mode", "workspace-write")
.extra_args(["--strict-config", "--something-else"])
.build_args();
assert_eq!(
args,
vec![
"-c",
"sandbox_mode=workspace-write",
"app-server",
"--listen",
"stdio://",
"--strict-config",
"--something-else",
]
);
}
#[test]
fn test_config_override_value_with_special_chars_unchanged() {
let args = AppServerBuilder::new()
.config_override("sandbox_permissions", r#"["disk-full-read-access"]"#)
.build_args();
assert_eq!(args[1], r#"sandbox_permissions=["disk-full-read-access"]"#);
}
}