Skip to main content

objectiveai_sdk/cli/command/command_executor/
binary.rs

1use std::path::PathBuf;
2use std::pin::Pin;
3
4use futures::{Stream, StreamExt};
5use tokio::io::{AsyncBufReadExt, BufReader};
6use tokio::process::Command;
7
8use crate::cli::command::{AgentArguments, CommandExecutor, CommandRequest, CommandResponse};
9
10/// Spawn the `objectiveai` cli binary on disk, feed it the argv from
11/// `request.into_command()`, and stream each stdout JSONL line back as
12/// either a typed `T` or a structured [`crate::cli::Error`].
13///
14/// The binary is resolved at `execute` time (not at construction).
15/// Resolution order:
16///
17/// - [`BinaryExecutor::from_path`] — returns the explicit path verbatim
18///   (used by tests pointing at an out-of-tree build).
19/// - [`BinaryExecutor::new`] with `Some(objectiveai_dir)` —
20///   `<objectiveai_dir>/bin/objectiveai{.exe}`.
21/// - [`BinaryExecutor::new`] with `None` —
22///   `<home>/.objectiveai/bin/objectiveai{.exe}`.
23pub struct BinaryExecutor {
24    /// The layout root (`OBJECTIVEAI_DIR`); the binary inside is
25    /// always `bin/objectiveai{.exe}`. `None` falls back to the
26    /// home-dir default (`~/.objectiveai`).
27    objectiveai_dir: Option<PathBuf>,
28    /// When set, used verbatim and the `objectiveai_dir` lookup is
29    /// skipped entirely. Lets callers (notably tests) point the
30    /// executor at any binary by absolute path without enforcing the
31    /// `<dir>/objectiveai` naming convention.
32    explicit_path: Option<PathBuf>,
33    /// Extra environment variables to set on the spawned child. Stacks
34    /// on top of the parent's environment (the child inherits the rest
35    /// — this map only overrides). Set via [`Self::env`].
36    extra_env: Vec<(String, String)>,
37    /// When `true`, the spawned child is sent a kill signal whenever
38    /// the [`tokio::process::Child`] held in the stream state is
39    /// dropped. Defaults to `false` (the SDK's long-standing
40    /// behaviour: drop-without-kill lets the child finish its work
41    /// orphaned). Set via [`Self::kill_on_drop`]. Test wrappers that
42    /// want to abort hung children (e.g. cli-test
43    /// `HangPreventingBinaryCommandExecutor`) flip this on.
44    kill_on_drop: bool,
45    /// When `true`, spawn the child detached from the parent's console
46    /// / process group so it survives the parent process exiting.
47    /// Unix already does this automatically when the parent dies
48    /// (kernel re-parents to init). Windows needs explicit
49    /// `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` creation flags;
50    /// otherwise the child's inherited console closes with the parent
51    /// and the child gets a `CTRL_CLOSE_EVENT`. Defaults to `false`.
52    detach: bool,
53}
54
55impl BinaryExecutor {
56    pub fn new(objectiveai_dir: Option<impl Into<PathBuf>>) -> Self {
57        Self {
58            objectiveai_dir: objectiveai_dir.map(Into::into),
59            explicit_path: None,
60            extra_env: Vec::new(),
61            kill_on_drop: false,
62            detach: false,
63        }
64    }
65
66    /// Construct an executor that spawns the binary at `binary`
67    /// directly, regardless of file name. Skips the `objectiveai`
68    /// name lookup so tests can target a `target/debug/objectiveai-cli`
69    /// build without renaming or symlinking.
70    pub fn from_path(binary: impl Into<PathBuf>) -> Self {
71        Self {
72            objectiveai_dir: None,
73            explicit_path: Some(binary.into()),
74            extra_env: Vec::new(),
75            kill_on_drop: false,
76            detach: false,
77        }
78    }
79
80
81    /// Set an environment variable on every child the executor spawns.
82    /// Stacks on top of the parent's env; intended for tests that need
83    /// to pin a per-instance `OBJECTIVEAI_DIR`/`OBJECTIVEAI_STATE` or `OBJECTIVEAI_ADDRESS`
84    /// without racing other parallel tests via `std::env::set_var`.
85    pub fn env(
86        mut self,
87        key: impl Into<String>,
88        value: impl Into<String>,
89    ) -> Self {
90        self.extra_env.push((key.into(), value.into()));
91        self
92    }
93
94    /// When `true`, the spawned [`tokio::process::Child`] held in the
95    /// stream state is sent a kill signal when the stream is dropped.
96    /// Defaults to `false`. Wrappers that detect hangs (e.g. the
97    /// cli-test `HangPreventingBinaryCommandExecutor`) toggle this on
98    /// so dropping the inner stream tears the cli child down.
99    pub fn kill_on_drop(mut self, on: bool) -> Self {
100        self.kill_on_drop = on;
101        self
102    }
103
104    /// When `true`, the child is spawned detached from the parent's
105    /// console / process group so it survives parent exit. Pairs
106    /// naturally with `kill_on_drop = false` (the default): drop the
107    /// stream + exit the parent, the child keeps running orphaned.
108    ///
109    /// - **Windows**: sets `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP`
110    ///   on the `CreateProcessW` call. `DETACHED_PROCESS` is what
111    ///   actually makes orphan survival work — without it the child
112    ///   inherits the parent's console and receives `CTRL_CLOSE_EVENT`
113    ///   when the parent's console window closes. The process-group
114    ///   flag is belt-and-suspenders signal isolation.
115    /// - **Unix**: no-op. The kernel automatically re-parents the
116    ///   child to init when the parent exits.
117    ///
118    /// Defaults to `false`.
119    pub fn detach(mut self, on: bool) -> Self {
120        self.detach = on;
121        self
122    }
123
124    fn binary_path(&self) -> Result<PathBuf, Error> {
125        if let Some(p) = &self.explicit_path {
126            return Ok(p.clone());
127        }
128        let dir = match &self.objectiveai_dir {
129            Some(d) => d.clone(),
130            None => dirs::home_dir()
131                .ok_or(Error::NoHomeDir)?
132                .join(".objectiveai"),
133        };
134        let name = if cfg!(windows) { "objectiveai.exe" } else { "objectiveai" };
135        Ok(dir.join("bin").join(name))
136    }
137}
138
139#[derive(Debug, thiserror::Error)]
140pub enum Error {
141    /// `dirs::home_dir()` returned `None` and no `objectiveai_dir` was set.
142    #[error("no home directory and no objectiveai_dir set")]
143    NoHomeDir,
144    /// Failed to spawn the binary at the resolved path.
145    #[error("failed to spawn cli binary: {0}")]
146    Spawn(std::io::Error),
147    /// Child stdout was unexpectedly absent after spawn.
148    #[error("cli binary child has no stdout handle")]
149    NoStdout,
150    /// Reading stdout line failed.
151    #[error("read cli binary stdout: {0}")]
152    Io(std::io::Error),
153    /// Stdout produced a line that didn't deserialize as either the
154    /// structured `cli::Error` or `T`.
155    #[error("decode cli binary stdout line: {0}")]
156    Json(serde_json::Error),
157    /// Structured error emitted by the cli binary on stdout.
158    #[error("{0}")]
159    Cli(crate::cli::Error),
160    /// `execute_one` was called but the stream produced no items.
161    #[error("cli binary stream produced no items")]
162    Empty,
163}
164
165/// Per-line untagged decode. `Err` is listed first so serde tries it
166/// before `Ok` — `cli::Error`'s `type:"error"` constant short-circuits
167/// every non-error wire shape, then `Ok(T)` is the fallthrough.
168#[derive(serde::Deserialize)]
169#[serde(untagged)]
170enum Line<T> {
171    Err(crate::cli::Error),
172    Ok(T),
173}
174
175impl<T> From<Line<T>> for Result<T, Error> {
176    fn from(line: Line<T>) -> Self {
177        match line {
178            Line::Err(e) => Err(Error::Cli(e)),
179            Line::Ok(t) => Ok(t),
180        }
181    }
182}
183
184impl CommandExecutor for BinaryExecutor {
185    type Error = Error;
186    type Stream<T>
187        = Pin<Box<dyn Stream<Item = Result<T, Error>> + Send>>
188    where
189        T: Send + 'static;
190
191    async fn execute<R, T>(
192        &self,
193        request: R,
194        agent_arguments: Option<&AgentArguments>,
195    ) -> Result<Self::Stream<T>, Error>
196    where
197        R: CommandRequest + Send + serde::Serialize,
198        T: CommandResponse + serde::Serialize + serde::de::DeserializeOwned + Send + 'static,
199    {
200        // Dispatch the typed request as JSON via the cli's top-level
201        // `--request` flag — request -> argv lowering no longer exists.
202        let argv = vec![
203            "--request".to_string(),
204            serde_json::to_string(&request).map_err(Error::Json)?,
205        ];
206        let binary = self.binary_path()?;
207
208        let mut command = Command::new(&binary);
209        crate::process::no_window(&mut command);
210        command
211            .args(&argv)
212            .stdin(std::process::Stdio::null())
213            .stdout(std::process::Stdio::piped())
214            .stderr(std::process::Stdio::inherit())
215            .kill_on_drop(self.kill_on_drop);
216        for (k, v) in &self.extra_env {
217            command.env(k, v);
218        }
219        // Windows detach. Required for orphan-survival when the
220        // parent will exit before the child finishes — without
221        // `DETACHED_PROCESS` the child inherits the parent's
222        // console and dies on `CTRL_CLOSE_EVENT` when the
223        // parent's console closes. We've hit this bug twice in
224        // the past; preserve the flag. Unix gets re-parent-to-init
225        // for free.
226        #[cfg(windows)]
227        if self.detach {
228            const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
229            const DETACHED_PROCESS: u32 = 0x0000_0008;
230            command.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS);
231        }
232        // Per-call agent identity override. When `Some`, every field
233        // gets applied atomically: `Some(v)` → set, `None` →
234        // env_remove so the parent's value can't leak through. When
235        // the bag itself is `None`, parent env is inherited
236        // untouched.
237        if let Some(args) = agent_arguments {
238            args.apply_to_command(&mut command);
239        }
240        // Keep OUR OWN std handles out of the child (and thus out of
241        // any resident server the child goes on to spawn): a leaked
242        // copy of our stdout's write end in a long-lived grandchild
243        // means whoever pipes us never sees EOF after we exit. See
244        // `win_handles::disinherit_std_handles`. Our explicit pipes to
245        // this child and its inherited stderr are unaffected.
246        #[cfg(windows)]
247        crate::win_handles::disinherit_std_handles();
248        let mut child = command.spawn().map_err(Error::Spawn)?;
249
250        let stdout = child.stdout.take().ok_or(Error::NoStdout)?;
251        let lines = BufReader::new(stdout).lines();
252
253        // Carry `child` in the unfold state so its handle isn't dropped
254        // mid-stream. tokio's Child defaults to kill_on_drop = false,
255        // so on stream drop the child remains running until it finishes.
256        let stream = futures::stream::unfold(
257            (child, lines),
258            |(child, mut lines)| async move {
259                match lines.next_line().await {
260                    Ok(Some(line)) => {
261                        let item = match serde_json::from_str::<Line<T>>(&line) {
262                            Ok(line) => line.into(),
263                            Err(e) => Err(Error::Json(e)),
264                        };
265                        Some((item, (child, lines)))
266                    }
267                    Ok(None) => None,
268                    Err(e) => Some((Err(Error::Io(e)), (child, lines))),
269                }
270            },
271        );
272
273        Ok(Box::pin(stream))
274    }
275
276    async fn execute_one<R, T>(
277        &self,
278        request: R,
279        agent_arguments: Option<&AgentArguments>,
280    ) -> Result<T, Error>
281    where
282        R: CommandRequest + Send + serde::Serialize,
283        T: CommandResponse + serde::Serialize + serde::de::DeserializeOwned + Send + 'static,
284    {
285        let mut stream = self.execute::<R, T>(request, agent_arguments).await?;
286        stream.next().await.ok_or(Error::Empty)?
287    }
288}