Skip to main content

codex_codes/
cli.rs

1//! Builder for launching the Codex app-server process.
2//!
3//! The [`AppServerBuilder`] configures and spawns `codex app-server --listen stdio://`,
4//! a long-lived process that speaks JSON-RPC over newline-delimited stdio.
5
6use log::debug;
7use std::path::PathBuf;
8use std::process::Stdio;
9
10/// Builder for launching a Codex app-server process.
11///
12/// Produces commands of the form: `codex [-c k=v]... app-server --listen stdio:// [extra]...`
13///
14/// All model, sandbox, and approval configuration that isn't expressible as a
15/// CLI flag is done via JSON-RPC requests after connecting. For everything
16/// that *is* a CLI flag, see [`config_override`](Self::config_override) and
17/// [`extra_args`](Self::extra_args).
18#[derive(Debug, Clone)]
19pub struct AppServerBuilder {
20    command: PathBuf,
21    working_directory: Option<PathBuf>,
22    /// `-c key=value` overrides, in insertion order. Emitted *before* the
23    /// `app-server` subcommand because `-c` is a global `codex` flag.
24    config_overrides: Vec<(String, String)>,
25    /// Raw additional args appended *after* the `--listen stdio://` so they
26    /// land as subcommand args to `app-server`.
27    extra_args: Vec<String>,
28}
29
30impl Default for AppServerBuilder {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36impl AppServerBuilder {
37    /// Create a new builder with default settings.
38    pub fn new() -> Self {
39        Self {
40            command: PathBuf::from("codex"),
41            working_directory: None,
42            config_overrides: Vec::new(),
43            extra_args: Vec::new(),
44        }
45    }
46
47    /// Set custom path to the codex binary.
48    pub fn command<P: Into<PathBuf>>(mut self, path: P) -> Self {
49        self.command = path.into();
50        self
51    }
52
53    /// Set the working directory for the app-server process.
54    pub fn working_directory<P: Into<PathBuf>>(mut self, dir: P) -> Self {
55        self.working_directory = Some(dir.into());
56        self
57    }
58
59    /// Append a `-c key=value` global config override.
60    ///
61    /// Repeatable. Each call appends one override; order is preserved on the
62    /// command line. The `value` is passed to codex unparsed — codex tries
63    /// TOML, then falls back to the raw string. The caller is responsible for
64    /// any quoting / escaping the value itself needs (e.g. arrays:
65    /// `("sandbox_permissions", r#"["disk-full-read-access"]"#)`).
66    ///
67    /// `-c` flags are placed *before* the `app-server` subcommand because
68    /// they're parsed as global `codex` options, not subcommand args.
69    ///
70    /// # Example
71    ///
72    /// ```
73    /// use codex_codes::AppServerBuilder;
74    ///
75    /// let builder = AppServerBuilder::new()
76    ///     .config_override("sandbox_mode", "workspace-write")
77    ///     .config_override("approval_policy", "on-request");
78    /// ```
79    pub fn config_override<K, V>(mut self, key: K, value: V) -> Self
80    where
81        K: Into<String>,
82        V: Into<String>,
83    {
84        self.config_overrides.push((key.into(), value.into()));
85        self
86    }
87
88    /// Append raw arguments to the `app-server` subcommand invocation.
89    ///
90    /// Inserted *after* the hardcoded `--listen stdio://`, so they land as
91    /// subcommand args. Use this for flags the SDK doesn't model yet — e.g.
92    /// `--strict-config`, or `--session-source app-server` once that becomes
93    /// available on the multitool subcommand.
94    ///
95    /// For `-c key=value` global overrides use [`config_override`](Self::config_override)
96    /// instead; those need to be placed before the subcommand.
97    ///
98    /// # Example
99    ///
100    /// ```
101    /// use codex_codes::AppServerBuilder;
102    ///
103    /// let builder = AppServerBuilder::new()
104    ///     .extra_args(["--strict-config"]);
105    /// ```
106    pub fn extra_args<I, S>(mut self, args: I) -> Self
107    where
108        I: IntoIterator<Item = S>,
109        S: Into<String>,
110    {
111        self.extra_args.extend(args.into_iter().map(Into::into));
112        self
113    }
114
115    /// Resolve the command path, using `which` for non-absolute paths.
116    fn resolve_command(&self) -> crate::error::Result<PathBuf> {
117        if self.command.is_absolute() {
118            return Ok(self.command.clone());
119        }
120        which::which(&self.command).map_err(|_| crate::error::Error::BinaryNotFound {
121            name: self.command.display().to_string(),
122        })
123    }
124
125    /// Build the command arguments.
126    ///
127    /// Layout: `[-c k=v]... app-server --listen stdio:// [extra_args]...`
128    fn build_args(&self) -> Vec<String> {
129        let mut args =
130            Vec::with_capacity(self.config_overrides.len() * 2 + 3 + self.extra_args.len());
131        for (k, v) in &self.config_overrides {
132            args.push("-c".to_string());
133            args.push(format!("{k}={v}"));
134        }
135        args.push("app-server".to_string());
136        args.push("--listen".to_string());
137        args.push("stdio://".to_string());
138        args.extend(self.extra_args.iter().cloned());
139        args
140    }
141
142    /// Spawn the app-server process asynchronously.
143    #[cfg(feature = "async-client")]
144    pub async fn spawn(self) -> crate::error::Result<tokio::process::Child> {
145        let resolved = self.resolve_command()?;
146        let args = self.build_args();
147
148        debug!(
149            "[CLI] Spawning async app-server: {} {}",
150            resolved.display(),
151            args.join(" ")
152        );
153
154        let mut cmd = tokio::process::Command::new(&resolved);
155        cmd.args(&args)
156            .stdin(Stdio::piped())
157            .stdout(Stdio::piped())
158            .stderr(Stdio::piped());
159
160        if let Some(ref dir) = self.working_directory {
161            cmd.current_dir(dir);
162        }
163
164        cmd.spawn().map_err(crate::error::Error::Io)
165    }
166
167    /// Spawn the app-server process synchronously.
168    pub fn spawn_sync(self) -> crate::error::Result<std::process::Child> {
169        let resolved = self.resolve_command()?;
170        let args = self.build_args();
171
172        debug!(
173            "[CLI] Spawning sync app-server: {} {}",
174            resolved.display(),
175            args.join(" ")
176        );
177
178        let mut cmd = std::process::Command::new(&resolved);
179        cmd.args(&args)
180            .stdin(Stdio::piped())
181            .stdout(Stdio::piped())
182            .stderr(Stdio::piped());
183
184        if let Some(ref dir) = self.working_directory {
185            cmd.current_dir(dir);
186        }
187
188        cmd.spawn().map_err(crate::error::Error::Io)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn test_default_args() {
198        let builder = AppServerBuilder::new();
199        let args = builder.build_args();
200
201        assert_eq!(args, vec!["app-server", "--listen", "stdio://"]);
202    }
203
204    #[test]
205    fn test_custom_command() {
206        let builder = AppServerBuilder::new().command("/usr/local/bin/codex");
207        assert_eq!(builder.command, PathBuf::from("/usr/local/bin/codex"));
208    }
209
210    #[test]
211    fn test_working_directory() {
212        let builder = AppServerBuilder::new().working_directory("/tmp/work");
213        assert_eq!(builder.working_directory, Some(PathBuf::from("/tmp/work")));
214    }
215
216    #[test]
217    fn test_config_override_single() {
218        let args = AppServerBuilder::new()
219            .config_override("sandbox_mode", "workspace-write")
220            .build_args();
221        assert_eq!(
222            args,
223            vec![
224                "-c",
225                "sandbox_mode=workspace-write",
226                "app-server",
227                "--listen",
228                "stdio://"
229            ]
230        );
231    }
232
233    #[test]
234    fn test_config_override_multiple_preserves_order() {
235        let args = AppServerBuilder::new()
236            .config_override("sandbox_mode", "workspace-write")
237            .config_override("approval_policy", "on-request")
238            .build_args();
239        // Both `-c` pairs come BEFORE `app-server` since `-c` is a global
240        // codex flag.
241        assert_eq!(
242            args,
243            vec![
244                "-c",
245                "sandbox_mode=workspace-write",
246                "-c",
247                "approval_policy=on-request",
248                "app-server",
249                "--listen",
250                "stdio://"
251            ]
252        );
253    }
254
255    #[test]
256    fn test_extra_args_appended_after_listen() {
257        let args = AppServerBuilder::new()
258            .extra_args(["--strict-config"])
259            .build_args();
260        assert_eq!(
261            args,
262            vec!["app-server", "--listen", "stdio://", "--strict-config"]
263        );
264    }
265
266    #[test]
267    fn test_config_override_and_extra_args_combined() {
268        let args = AppServerBuilder::new()
269            .config_override("sandbox_mode", "workspace-write")
270            .extra_args(["--strict-config", "--something-else"])
271            .build_args();
272        assert_eq!(
273            args,
274            vec![
275                "-c",
276                "sandbox_mode=workspace-write",
277                "app-server",
278                "--listen",
279                "stdio://",
280                "--strict-config",
281                "--something-else",
282            ]
283        );
284    }
285
286    #[test]
287    fn test_config_override_value_with_special_chars_unchanged() {
288        // Caller is responsible for quoting the value half; we pass it
289        // through unchanged so codex's TOML parser sees it verbatim.
290        let args = AppServerBuilder::new()
291            .config_override("sandbox_permissions", r#"["disk-full-read-access"]"#)
292            .build_args();
293        assert_eq!(args[1], r#"sandbox_permissions=["disk-full-read-access"]"#);
294    }
295}