Skip to main content

objectiveai_cli/
run.rs

1use std::pin::Pin;
2
3use envconfig::Envconfig;
4use futures::Stream;
5use objectiveai_sdk::cli::command::{CommandRequest, ResponseItem, parse_request};
6
7use crate::context::Context;
8use crate::error::Error;
9
10/// Windows-only: clear `HANDLE_FLAG_INHERIT` on this process's
11/// stdin/stdout/stderr handles. Called at the top of the instance
12/// subprocess fast-path so plugin spawns (and any other later
13/// `Stdio::piped()` spawn that triggers `bInheritHandles=TRUE`)
14/// don't leak this process's stdio write ends to those children.
15///
16/// Without this, on Windows a plugin spawned by the instance
17/// subprocess inherits the instance's stdout/stderr write ends.
18/// The plugin keeps those handles open for its whole lifetime
19/// (e.g. an `axum::serve` that runs forever). When the instance
20/// exits, the cli outer's reads of the instance's stdout/stderr
21/// don't see EOF — the plugin is still holding the write ends —
22/// and the cli outer hangs forever waiting for an EOF that never
23/// arrives.
24#[cfg(windows)]
25pub fn clear_stdio_inheritance() {
26    use windows_sys::Win32::Foundation::{HANDLE_FLAG_INHERIT, SetHandleInformation};
27    use windows_sys::Win32::System::Console::{
28        GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE,
29    };
30    // SAFETY: GetStdHandle is sound to call from any thread; the
31    // returned HANDLE is process-global and survives. SetHandleInformation
32    // mutates only the flags on the HANDLE, never the underlying
33    // file/pipe. Failure is best-effort (e.g. handle was already
34    // INVALID_HANDLE_VALUE) and silently ignored — clearing the
35    // inheritance flag is an optimization for child spawns, not a
36    // correctness requirement for our own stdio reads/writes.
37    unsafe {
38        for std_id in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] {
39            let h = GetStdHandle(std_id);
40            // GetStdHandle returns INVALID_HANDLE_VALUE on error and
41            // 0 / NULL when the stream isn't attached; skip both.
42            if !h.is_null() && h as isize != -1 {
43                let _ = SetHandleInformation(h, HANDLE_FLAG_INHERIT, 0);
44            }
45        }
46    }
47}
48
49#[derive(Envconfig)]
50struct EnvConfigBuilder {
51    #[envconfig(from = "CONFIG_SET_FORBIDDEN")]
52    config_set_forbidden: Option<String>,
53    #[envconfig(from = "OBJECTIVEAI_DIR")]
54    objectiveai_dir: Option<String>,
55    #[envconfig(from = "OBJECTIVEAI_STATE")]
56    objectiveai_state: Option<String>,
57    #[envconfig(from = "COMMIT_AUTHOR_NAME")]
58    commit_author_name: Option<String>,
59    #[envconfig(from = "COMMIT_AUTHOR_EMAIL")]
60    commit_author_email: Option<String>,
61    #[envconfig(from = "OBJECTIVEAI_AGENT_INSTANCE_HIERARCHY")]
62    agent_instance_hierarchy: Option<String>,
63    #[envconfig(from = "OBJECTIVEAI_AGENT_ID")]
64    agent_id: Option<String>,
65    #[envconfig(from = "OBJECTIVEAI_AGENT_FULL_ID")]
66    agent_full_id: Option<String>,
67    #[envconfig(from = "OBJECTIVEAI_AGENT_REMOTE")]
68    agent_remote: Option<String>,
69    #[envconfig(from = "OBJECTIVEAI_RESPONSE_ID")]
70    response_id: Option<String>,
71    #[envconfig(from = "OBJECTIVEAI_RESPONSE_IDS")]
72    response_ids: Option<String>,
73    #[envconfig(from = "MCP_SESSION_ID")]
74    mcp_session_id: Option<String>,
75    #[envconfig(from = "OBJECTIVEAI_PLUGIN_OWNER")]
76    plugin_owner: Option<String>,
77    #[envconfig(from = "OBJECTIVEAI_PLUGIN_REPOSITORY")]
78    plugin_repository: Option<String>,
79    #[envconfig(from = "OBJECTIVEAI_PLUGIN_VERSION")]
80    plugin_version: Option<String>,
81    #[envconfig(from = "DAEMON_ADDRESS")]
82    daemon_address: Option<String>,
83    #[envconfig(from = "DAEMON_PORT")]
84    daemon_port: Option<u16>,
85    #[envconfig(from = "DAEMON_SECRET")]
86    daemon_secret: Option<String>,
87}
88
89impl EnvConfigBuilder {
90    pub fn build(self) -> ConfigBuilder {
91        fn parse_bool(s: &str) -> bool {
92            let v = s.trim();
93            !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false")
94        }
95        ConfigBuilder {
96            config_set_forbidden: self.config_set_forbidden.map(|s| parse_bool(&s)),
97            objectiveai_dir: self.objectiveai_dir,
98            objectiveai_state: self.objectiveai_state,
99            commit_author_name: self.commit_author_name,
100            commit_author_email: self.commit_author_email,
101            agent_instance_hierarchy: self.agent_instance_hierarchy,
102            agent_id: self.agent_id,
103            agent_full_id: self.agent_full_id,
104            agent_remote: self.agent_remote,
105            response_id: self.response_id,
106            response_ids: self.response_ids,
107            mcp_session_id: self.mcp_session_id,
108            plugin_owner: self.plugin_owner,
109            plugin_repository: self.plugin_repository,
110            plugin_version: self.plugin_version,
111            daemon_address: self.daemon_address,
112            daemon_port: self.daemon_port,
113            daemon_secret: self.daemon_secret,
114        }
115    }
116}
117
118#[derive(Default)]
119pub struct ConfigBuilder {
120    pub config_set_forbidden: Option<bool>,
121    pub objectiveai_dir: Option<String>,
122    pub objectiveai_state: Option<String>,
123    pub commit_author_name: Option<String>,
124    pub commit_author_email: Option<String>,
125    pub agent_instance_hierarchy: Option<String>,
126    pub agent_id: Option<String>,
127    pub agent_full_id: Option<String>,
128    pub agent_remote: Option<String>,
129    pub response_id: Option<String>,
130    pub response_ids: Option<String>,
131    pub mcp_session_id: Option<String>,
132    pub plugin_owner: Option<String>,
133    pub plugin_repository: Option<String>,
134    pub plugin_version: Option<String>,
135    pub daemon_address: Option<String>,
136    pub daemon_port: Option<u16>,
137    pub daemon_secret: Option<String>,
138}
139
140impl Envconfig for ConfigBuilder {
141    #[allow(deprecated)]
142    fn init() -> Result<Self, envconfig::Error> {
143        EnvConfigBuilder::init().map(|e| e.build())
144    }
145
146    fn init_from_env() -> Result<Self, envconfig::Error> {
147        EnvConfigBuilder::init_from_env().map(|e| e.build())
148    }
149
150    fn init_from_hashmap(
151        hashmap: &std::collections::HashMap<String, String>,
152    ) -> Result<Self, envconfig::Error> {
153        EnvConfigBuilder::init_from_hashmap(hashmap).map(|e| e.build())
154    }
155}
156
157impl ConfigBuilder {
158    pub fn build(self) -> Config {
159        Config {
160            config_set_forbidden: self.config_set_forbidden.unwrap_or(false),
161            objectiveai_dir: self.objectiveai_dir,
162            objectiveai_state: self.objectiveai_state,
163            commit_author_name: self.commit_author_name,
164            commit_author_email: self.commit_author_email,
165            agent_instance_hierarchy: self
166                .agent_instance_hierarchy
167                .unwrap_or_else(|| "cli".to_string()),
168            agent_id: self.agent_id,
169            agent_full_id: self.agent_full_id,
170            agent_remote: self.agent_remote,
171            response_id: self.response_id,
172            response_ids: self.response_ids,
173            mcp_session_id: self.mcp_session_id,
174            plugin_owner: self.plugin_owner,
175            plugin_repository: self.plugin_repository,
176            plugin_version: self.plugin_version,
177            daemon_address: self
178                .daemon_address
179                .unwrap_or_else(|| "127.0.0.1".to_string()),
180            daemon_port: self.daemon_port.unwrap_or(0),
181            daemon_secret: self.daemon_secret,
182        }
183    }
184}
185
186#[derive(Debug, Clone)]
187pub struct Config {
188    pub config_set_forbidden: bool,
189    /// Layout root override (`OBJECTIVEAI_DIR`); None = ~/.objectiveai.
190    pub objectiveai_dir: Option<String>,
191    /// State name (`OBJECTIVEAI_STATE`); None = "default".
192    pub objectiveai_state: Option<String>,
193    pub commit_author_name: Option<String>,
194    pub commit_author_email: Option<String>,
195    pub agent_instance_hierarchy: String,
196    pub agent_id: Option<String>,
197    /// WF-level agent identity from `OBJECTIVEAI_AGENT_FULL_ID` / the
198    /// `X-OBJECTIVEAI-AGENT-FULL-ID` reverse-attach header. Propagated
199    /// onto spawned plugin subprocesses by the conduit so plugin-side
200    /// code can stamp it on outbound calls.
201    pub agent_full_id: Option<String>,
202    /// JSON-encoded `RemotePath` from `OBJECTIVEAI_AGENT_REMOTE` /
203    /// the `X-OBJECTIVEAI-AGENT-REMOTE` reverse-attach header. Empty
204    /// when the WF was inline. Propagated onto spawned plugins.
205    pub agent_remote: Option<String>,
206    /// Current response id from `OBJECTIVEAI_RESPONSE_ID` / the
207    /// `X-OBJECTIVEAI-RESPONSE-ID` reverse-attach header. Propagated
208    /// onto spawned plugins so plugin-side code can stamp it on
209    /// outbound calls.
210    pub response_id: Option<String>,
211    /// Comma-separated response id chain from
212    /// `OBJECTIVEAI_RESPONSE_IDS` / `X-OBJECTIVEAI-RESPONSE-IDS`.
213    /// Propagated onto spawned plugins.
214    pub response_ids: Option<String>,
215    pub mcp_session_id: Option<String>,
216    /// Plugin coordinate (`OBJECTIVEAI_PLUGIN_OWNER` / `_REPOSITORY` /
217    /// `_VERSION`) of the plugin a command is running on behalf of.
218    /// Set by `plugins run` on the config used to launch nested
219    /// (plugin-originated) commands; assembled into
220    /// [`crate::context::Context::plugin`] at startup.
221    pub plugin_owner: Option<String>,
222    pub plugin_repository: Option<String>,
223    pub plugin_version: Option<String>,
224    /// Bind address for the resident daemon's broadcast WebSocket
225    /// server (`DAEMON_ADDRESS`); default `127.0.0.1`.
226    pub daemon_address: String,
227    /// Bind port for the resident daemon's broadcast WebSocket server
228    /// (`DAEMON_PORT`); default `0` (OS-assigned).
229    pub daemon_port: u16,
230    /// Optional shared secret for the daemon's WebSocket server
231    /// (`DAEMON_SECRET`). When set, every connection's first-message
232    /// auth preamble must carry a valid `sha256=<hex(SHA256(secret))>`
233    /// signature; when `None`, the server is open.
234    pub daemon_secret: Option<String>,
235}
236
237/// What [`run`] yields, mirroring the two SDK root dispatch entry
238/// points. The arm is chosen by whether the parsed request carries an
239/// output transform (`RequestBase::transform`):
240///
241/// - [`RunStream::Execute`]: no transform — the typed root
242///   [`ResponseItem`] stream (identical to what `run` used to return).
243/// - [`RunStream::ExecuteTransform`]: a transform is set — each item is
244///   the transformed JSON output (`serde_json::Value`).
245pub enum RunStream {
246    Execute(Pin<Box<dyn Stream<Item = Result<ResponseItem, Error>> + Send>>),
247    ExecuteTransform(Pin<Box<dyn Stream<Item = Result<serde_json::Value, Error>> + Send>>),
248}
249
250/// Build the top-level CLI config from the process environment.
251pub fn load_config() -> Config {
252    ConfigBuilder::init_from_env().unwrap_or_default().build()
253}
254
255/// Did clap exit with one of the "successful informational output"
256/// variants? `--help`, `--version`, or a missing-subcommand bail.
257pub fn is_informational(e: &clap::Error) -> bool {
258    use clap::error::ErrorKind;
259    matches!(
260        e.kind(),
261        ErrorKind::DisplayHelp
262            | ErrorKind::DisplayVersion
263            | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
264    )
265}
266
267/// Run the CLI command tree.
268///
269/// Clap-parse argv against the SDK's top-level command surface;
270/// `TryFrom` it into [`Request`]; resolve [`Context`] (caller-
271/// supplied or built from env); dispatch through the in-process
272/// [`crate::executor::CliCommandExecutor`] via the SDK root
273/// `execute` / `execute_transform` (the latter when the request
274/// carries an output transform). Returns a [`RunStream`] whose
275/// variant reflects that choice.
276///
277/// The `instance` subprocess subcommand is **not** handled here
278/// — it has its own entry point at [`crate::instance::run`]
279/// because its wire shape (`InstanceEmission`) differs from
280/// [`ResponseItem`]. `main.rs` routes argv[1] == "instance" to
281/// that entry directly and writes its own ndjson; ordinary
282/// command flows go through this function.
283///
284/// Pre-dispatch failures (clap parse error, arg-conversion error,
285/// context build) and child-function errors propagate as the outer
286/// `Err`. On success the caller consumes the stream.
287// NOTE: explicit boxed future (not `async fn`). `run` calls the SDK
288// root dispatch through `CliCommandExecutor`, and `tasks run` /
289// `plugins run` re-enter `run` — so `run`, the executor, and the local
290// command dispatch are mutually recursive. An `async fn`'s opaque
291// return type can't be computed through that cycle (E0391); an explicit
292// `Pin<Box<dyn Future + Send>>` return gives the recursion a known type
293// to terminate on.
294pub fn run(
295    args: Vec<String>,
296    ctx: Option<Context>,
297) -> Pin<Box<dyn std::future::Future<Output = Result<RunStream, Error>> + Send>> {
298    Box::pin(async move {
299    // Windows: clear the inheritance flag on this process's
300    // stdin/stdout/stderr handles. They were marked inheritable by
301    // whoever spawned us via `Stdio::piped()` — necessary so we
302    // inherit them at all — but if we leave the flag set, every
303    // grandchild process we (or any of our descendants) spawn with
304    // `Stdio::piped()` (which sets `bInheritHandles=TRUE`) inherits
305    // OUR stdio handles in addition to its own new pipes. That
306    // grandchild (e.g. a plugin RMCP server living for the whole
307    // agent completion) then holds our stdio write ends open even
308    // after we exit, leaving our parent's reads of our stdout/stderr
309    // hanging forever instead of EOF'ing.
310    //
311    // Applies to BOTH branches:
312    //   - The `instance` branch (called from a parent cli) so plugin
313    //     grandchildren don't inherit the instance's stdio.
314    //   - The non-instance branch (the outer cli, called from a test
315    //     harness or another shell) so the instance subprocess
316    //     doesn't inherit the outer cli's stdio — otherwise an
317    //     orphan plugin grandchild keeps the outer cli's stdout
318    //     pipe alive after the instance dies, and the harness
319    //     hangs waiting for stdout EOF.
320    //
321    // Clearing the flag is a no-op for our own use of std{in,out,err}
322    // — we keep using them normally. It only affects what gets
323    // propagated on subsequent `CreateProcessW` calls.
324    #[cfg(windows)]
325    clear_stdio_inheritance();
326
327    // `Context::new` is synchronous and IO-free; the API client,
328    // viewer client, and db pool connect lazily on first use via
329    // `ctx.{api,viewer,db}_client()`. No explicit setup call needed
330    // here.
331    let ctx = match ctx {
332        Some(c) => c,
333        None => Context::new(load_config()),
334    };
335
336    // `args[0]` is the program name however the binary was invoked —
337    // bare name from PATH, full path from a test harness or a
338    // `current_exe()` self-respawn — never part of the command.
339    // Strip it unconditionally; `parse_request` prepends its own
340    // canonical bin name. (Matching on the literal `"objectiveai"`
341    // inside `parse_request` is NOT enough: a full-path argv[0] like
342    // `C:\...\objectiveai-cli.exe` would be parsed as a subcommand.)
343    // A top-level `--request <json>` (handled inside `parse_request` via
344    // `TryFrom<Command>`) executes a JSON `CliCommandRequest` directly,
345    // mutually exclusive with any command path (clap enforces it). Either
346    // front door converges here on the same typed `Request`.
347    let request = parse_request(args.get(1..).unwrap_or_default()).map_err(|e| match e {
348        objectiveai_sdk::cli::command::ParseError::Clap(e) => Error::ClapParse(e),
349        objectiveai_sdk::cli::command::ParseError::FromArgs(e) => Error::FromArgs(e),
350    })?;
351
352    // Producer tee: before executing, ensure the resident daemon is up
353    // (idempotent — no respawn if already running) and open a feed of
354    // this run's request + stream items into its broadcast socket. Wholly
355    // best-effort: any failure yields `None` and the command runs
356    // unaffected. The daemon foreground itself is skipped (see
357    // `should_tee`).
358    let feed = start_tee(&ctx, &request).await;
359
360    // Decouple broadcast writes from stream yielding: the executor
361    // sends each PRE-transform item into an UNBOUNDED channel (a send
362    // never blocks, never drops), and a spawned writer task drains it
363    // onto the producer socket. `run` owns the writer's lifecycle: the
364    // stream it returns awaits the writer's completion after the
365    // command finishes, so a program exit can never truncate in-flight
366    // socket writes.
367    let (tee_tx, tee_writer) = match feed {
368        Some(mut feed) => {
369            let (tx, mut rx) =
370                tokio::sync::mpsc::unbounded_channel::<serde_json::Value>();
371            let writer = tokio::spawn(async move {
372                while let Some(value) = rx.recv().await {
373                    if feed.write(&value).await.is_err() {
374                        // Daemon gone: stop consuming; remaining sends
375                        // fail and the executor's tee goes quiet.
376                        break;
377                    }
378                }
379                // `feed` drops here → socket closes → the daemon reads
380                // EOF and broadcasts the run's terminator.
381            });
382            (Some(tx), Some(writer))
383        }
384        None => (None, None),
385    };
386
387    // Drive the request through the in-process executor, picking the
388    // SDK root dispatch entry by whether the request carries an output
389    // transform. The executor tees the PRE-transform items to the
390    // broadcast and applies the transform / token / timeout adapters;
391    // `execute_transform` additionally sets the transform on the leaf
392    // request and yields the post-transform JSON, whereas `execute`
393    // yields the typed root items.
394    let transform = request.request_base().transform();
395    let executor = crate::executor::CliCommandExecutor::new(ctx, tee_tx);
396    match transform {
397        Some(transform) => {
398            let stream =
399                objectiveai_sdk::cli::command::execute_transform(&executor, request, transform, None)
400                    .await;
401            drop(executor);
402            match stream {
403                Ok(stream) => Ok(RunStream::ExecuteTransform(await_tee_completion(
404                    stream, tee_writer,
405                ))),
406                Err(e) => {
407                    settle_tee(tee_writer).await;
408                    Err(e)
409                }
410            }
411        }
412        None => {
413            let stream =
414                objectiveai_sdk::cli::command::execute(&executor, request, None).await;
415            drop(executor);
416            match stream {
417                Ok(stream) => {
418                    Ok(RunStream::Execute(await_tee_completion(stream, tee_writer)))
419                }
420                Err(e) => {
421                    settle_tee(tee_writer).await;
422                    Err(e)
423                }
424            }
425        }
426    }
427    })
428}
429
430/// Whether this run should be teed into the daemon broadcast socket.
431/// Everything is teed EXCEPT the resident daemon's own foreground process
432/// (`daemon spawn --foreground`): its socket isn't bound and its lock
433/// isn't published yet when the tee runs, so teeing it would
434/// deadlock / fork-bomb daemon startup. Every real command — including the
435/// `daemon spawn` launcher, `daemon kill`, and `kill_all` — is teed.
436fn should_tee(request: &objectiveai_sdk::cli::command::Request) -> bool {
437    use objectiveai_sdk::cli::command::{Request, daemon};
438    !matches!(
439        request,
440        Request::Daemon(daemon::Request::Spawn(r))
441            if r.dangerous_advanced.as_ref().and_then(|a| a.foreground) == Some(true)
442    )
443}
444
445/// The producer's agent/plugin context object — exactly the fields the
446/// `ListenerRequest<T>` wrapper carries. `None`s are omitted.
447fn tee_context(config: &Config) -> serde_json::Value {
448    let mut map = serde_json::Map::new();
449    map.insert(
450        "agent_instance_hierarchy".to_string(),
451        serde_json::Value::String(config.agent_instance_hierarchy.clone()),
452    );
453    for (key, value) in [
454        ("agent_id", &config.agent_id),
455        ("agent_full_id", &config.agent_full_id),
456        ("agent_remote", &config.agent_remote),
457        ("response_id", &config.response_id),
458        ("response_ids", &config.response_ids),
459        ("plugin_owner", &config.plugin_owner),
460        ("plugin_repository", &config.plugin_repository),
461        ("plugin_version", &config.plugin_version),
462    ] {
463        if let Some(val) = value {
464            map.insert(key.to_string(), serde_json::Value::String(val.clone()));
465        }
466    }
467    serde_json::Value::Object(map)
468}
469
470/// Ensure the daemon is up and open a feed, writing the producer context
471/// then the request. Best-effort: returns `None` (no teeing) on any
472/// failure or for a non-teed request.
473async fn start_tee(
474    ctx: &Context,
475    request: &objectiveai_sdk::cli::command::Request,
476) -> Option<crate::websockets::daemon_stream::FeedWriter> {
477    if !should_tee(request) {
478        return None;
479    }
480    // Idempotent: `spawn` returns immediately if the daemon already holds
481    // its lock; otherwise it spawns it once and waits for readiness. The
482    // returned lock content is the daemon's published `ws://` URL —
483    // record it on the ctx so later handlers (notably `viewer spawn`)
484    // can hand it to daemon WebSocket consumers.
485    if let Ok(url) = crate::command::daemon::spawn::spawn(ctx).await {
486        ctx.set_daemon_address(url);
487    }
488    let mut feed =
489        crate::websockets::daemon_stream::connect_feed(&ctx.filesystem.state_dir())
490            .await
491            .ok()?;
492    feed.write(&tee_context(&ctx.config)).await.ok()?;
493    let request_json = serde_json::to_value(request).ok()?;
494    feed.write(&request_json).await.ok()?;
495    Some(feed)
496}
497
498/// Wrap the command stream so that, after it completes, the run
499/// AWAITS the broadcast writer task before ending — the consumer
500/// (main.rs stdout drain, the daemon's `/execute` drain) cannot
501/// finish until every queued socket write has landed, so process
502/// exit never truncates the broadcast. The inner stream (which holds
503/// the executor's tee sender) is dropped first, closing the channel
504/// so the writer drains out and exits.
505fn await_tee_completion<T>(
506    stream: Pin<Box<dyn Stream<Item = Result<T, Error>> + Send>>,
507    writer: Option<tokio::task::JoinHandle<()>>,
508) -> Pin<Box<dyn Stream<Item = Result<T, Error>> + Send>>
509where
510    T: Send + 'static,
511{
512    let Some(writer) = writer else {
513        return stream;
514    };
515    Box::pin(async_stream::stream! {
516        use futures::StreamExt;
517        let mut inner = stream;
518        while let Some(item) = inner.next().await {
519            yield item;
520        }
521        // Drop the inner stream first: it owns the tee sender, and the
522        // writer only finishes once every sender is gone.
523        drop(inner);
524        let _ = writer.await;
525    })
526}
527
528/// Early-error path: the run failed before producing a stream. Drop
529/// nothing extra (the executor and its sender are already gone at the
530/// call sites) — just wait for the writer to flush and exit.
531async fn settle_tee(writer: Option<tokio::task::JoinHandle<()>>) {
532    if let Some(writer) = writer {
533        let _ = writer.await;
534    }
535}