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}