Skip to main content

objectiveai_cli/command/plugins/
run.rs

1//! `plugins run` — bare-naked port of legacy `dispatch_external`.
2//!
3//! Resolves the installed plugin's exec command (the tools model:
4//! the manifest's per-OS argv plus the caller's args, run with CWD =
5//! the plugin's `cli/` folder), spawns it with piped
6//! stdin/stdout/stderr, and yields each parsed line from the plugin's
7//! stdout as a [`ResponseItem`] as it arrives. The bidirectional
8//! protocol — plugin emits a `Command` request, the host runs it and
9//! streams the result back into the plugin's stdin wrapped in a
10//! `PluginCommandResponse` envelope, terminated by a
11//! `CommandComplete` marker — stays internal to the leaf. Consumers
12//! observe Command requests as stream items but the actual execution
13//! and stdin write-back happens here.
14
15use std::pin::Pin;
16use std::process::Stdio;
17use std::sync::Arc;
18
19use futures::{Stream, StreamExt};
20use objectiveai_sdk::cli::command::plugins::run::{Request, ResponseItem};
21use objectiveai_sdk::cli::plugins::Output as PluginOutput;
22use objectiveai_sdk::cli::{Error as CliError, ErrorType as CliErrorType};
23use serde::Serialize;
24use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
25use tokio::process::{ChildStdin, Command};
26use tokio::sync::Mutex;
27use tokio::task::JoinHandle;
28
29use crate::child_io::{PipeEvent, spawn_stdout_reader};
30use crate::context::Context;
31use crate::error::Error;
32
33type ItemStream = Pin<Box<dyn Stream<Item = Result<ResponseItem, Error>> + Send>>;
34
35pub async fn execute(ctx: &Context, request: Request) -> Result<ItemStream, Error> {
36    let coord = format!("{}/{}/{}", request.owner, request.name, request.version);
37    let (exec, cli_dir) = ctx
38        .filesystem
39        .resolve_plugin(&request.owner, &request.name, &request.version)
40        .await
41        .ok_or_else(|| Error::PluginNotFound(coord.clone()))?;
42
43    // The command is the plugin's exec vector merged with the
44    // caller's args, verbatim. The first element is the program; the
45    // rest are its arguments. CWD is the plugin's `cli/` folder —
46    // always — with the same relative-path program resolution tools
47    // use.
48    let mut argv = exec;
49    argv.extend(request.args.iter().cloned());
50    let mut argv = argv.into_iter();
51    let program = argv
52        .next()
53        .ok_or_else(|| Error::PluginNotFound(format!("{coord} (empty exec)")))?;
54    let program = crate::spawn::resolve_program(program, &cli_dir);
55
56    // Per-plugin scratch space inside the (transient) state tree —
57    // plugins that persist files write here, never into their own
58    // (possibly committed) install folder.
59    let state_dir = ctx
60        .filesystem
61        .state_dir()
62        .join("plugins")
63        .join(&request.owner)
64        .join(&request.name)
65        .join(&request.version);
66    tokio::fs::create_dir_all(&state_dir)
67        .await
68        .map_err(Error::PluginSpawn)?;
69
70    // Per-plugin database compartment: an owned schema plus readonly
71    // access to the base objectiveai tables, handed to the child as
72    // a role-scoped connection URL. Provisioning is idempotent; a
73    // failure fails the run loudly rather than spawning a child with
74    // a silently missing database.
75    let postgres_url = crate::db::compartment::ensure(
76        ctx.db_handle().await?,
77        crate::db::compartment::Kind::Plugin,
78        &request.owner,
79        &request.name,
80        &request.version,
81    )
82    .await?;
83
84    // Context for nested (plugin-originated) commands: this caller's
85    // ctx, stamped with the plugin coordinate. `ctx.plugin` is set so
86    // a nested command knows which plugin it runs on behalf of, and
87    // `config.plugin_*` is set so any subprocess that nested command
88    // itself spawns inherits the coordinate via `apply_config_env`.
89    // No `reset_api_client()` here: plugin coords aren't on the
90    // `HttpClient`, so the memoized API client deliberately stays
91    // shared with the caller's ctx.
92    let mut nested_ctx = ctx.clone();
93    nested_ctx.config.plugin_owner = Some(request.owner.clone());
94    nested_ctx.config.plugin_repository = Some(request.name.clone());
95    nested_ctx.config.plugin_version = Some(request.version.clone());
96    nested_ctx.plugin = Some(crate::plugin_path::PluginPath {
97        owner: request.owner.clone(),
98        repository: request.name.clone(),
99        version: request.version.clone(),
100    });
101
102    let mut cmd = Command::new(&program);
103    cmd.args(argv)
104        .current_dir(&cli_dir)
105        .env("OBJECTIVEAI_STATE_DIR", &state_dir)
106        .env("OBJECTIVEAI_BIN_DIR", &cli_dir)
107        .env("OBJECTIVEAI_POSTGRES_URL", postgres_url)
108        .stdin(Stdio::piped())
109        .stdout(Stdio::piped())
110        .stderr(Stdio::piped());
111    crate::spawn::apply_config_env(&mut cmd, &nested_ctx.config);
112
113    // Leash the plugin to the cli process: a plugin `mcp begin` is a
114    // long-lived MCP server, and the conduit holds its Child inside a
115    // detached drain task — so without an OS-level leash it outlives the
116    // cli. `subprocess_reaper::spawn` sets `kill_on_drop` AND the OS leash
117    // (job object / PR_SET_PDEATHSIG / kqueue guardian) so it dies with
118    // the cli even on a force-kill.
119    let mut child =
120        objectiveai_sdk::subprocess_reaper::spawn(&mut cmd).map_err(Error::PluginSpawn)?;
121    let stdout = child.stdout.take().expect("stdout was piped");
122    let stderr = child.stderr.take().expect("stderr was piped");
123    let stdin = child.stdin.take().expect("stdin was piped");
124    let plugin_stdin: Arc<Mutex<ChildStdin>> = Arc::new(Mutex::new(stdin));
125
126    // Capture the plugin's stderr (stdout is the protocol channel) into
127    // `objectiveai.plugin_messages` on a dedicated task that OWNS the
128    // pipe and reads it line-by-line — the OS pipe is the buffer, so a
129    // slow DB just backpressures the plugin's stderr writes. Joined at
130    // the end of the stream so every line is flushed before `plugins
131    // run` completes.
132    let stderr_writer = spawn_stderr_log_writer(
133        ctx.db_client().await?.clone(),
134        request.owner.clone(),
135        request.name.clone(),
136        request.version.clone(),
137        ctx.config.agent_instance_hierarchy.clone(),
138        ctx.config.response_id.clone(),
139        stderr,
140    );
141
142    let mut events = spawn_stdout_reader(stdout);
143
144    let stream = async_stream::stream! {
145        let mut command_tasks: Vec<(Option<String>, JoinHandle<i32>)> = Vec::new();
146        while let Some(event) = events.recv().await {
147            match event {
148                PipeEvent::Stderr(_) => {
149                    // stderr is owned by the DB log-writer task, not this
150                    // reader (stdout-only), so this never fires — kept for
151                    // match exhaustiveness over `PipeEvent`.
152                }
153                PipeEvent::Stdout(trimmed) => {
154                    match serde_json::from_str::<PluginOutput>(&trimmed) {
155                        Ok(PluginOutput::Error(e)) => {
156                            yield Ok(ResponseItem::Error(e));
157                        }
158                        Ok(PluginOutput::Mcp(mcp)) => {
159                            yield Ok(ResponseItem::Mcp(mcp));
160                        }
161                        Ok(PluginOutput::Command(c)) => {
162                            // Command requests are host-internal —
163                            // the CLI intercepts them to drive the
164                            // bidirectional protocol back into the
165                            // plugin's stdin and does NOT surface
166                            // them on the user-visible `ResponseItem`
167                            // stream.
168                            let task_id = Some(c.id);
169                            let task = run_nested_command(
170                                nested_ctx.clone(),
171                                c.command,
172                                plugin_stdin.clone(),
173                                task_id.clone(),
174                            );
175                            command_tasks.push((task_id, task));
176                        }
177                        Ok(PluginOutput::Notification(value)) => {
178                            yield Ok(ResponseItem::Notification(value));
179                        }
180                        Err(_) => {
181                            // Legacy fallback: surface the raw line
182                            // as a notification so unparseable plugin
183                            // output is at least observable rather
184                            // than silently dropped.
185                            yield Ok(ResponseItem::Notification(
186                                serde_json::Value::String(trimmed),
187                            ));
188                        }
189                    }
190                }
191                PipeEvent::StdoutEof | PipeEvent::StderrEof => {}
192                PipeEvent::StdoutErr(e) | PipeEvent::StderrErr(e) => {
193                    yield Err(Error::PluginRead(e));
194                    return;
195                }
196            }
197        }
198
199        // Await any in-flight Command tasks the plugin queued before its
200        // stdout EOF. Each task writes its OWN terminal `CommandComplete`
201        // when its stream ends (see `run_nested_command`), so here we just
202        // let them finish before dropping plugin stdin.
203        for (_id, task) in command_tasks {
204            let _ = task.await;
205        }
206
207        // Drop our reference to plugin stdin so the kernel pipe closes
208        // and a polite plugin sees EOF on its stdin read.
209        drop(plugin_stdin);
210
211        match child.wait().await {
212            Ok(status) if status.success() => {}
213            Ok(status) => {
214                yield Err(Error::PluginExit(status.code().unwrap_or(1)));
215            }
216            Err(e) => {
217                yield Err(Error::PluginRead(e));
218            }
219        }
220
221        // The child has exited, so its stderr pipe is closed; join the
222        // log-writer task so every captured line is committed before the
223        // run stream ends. Surface the first DB/read error if any.
224        match stderr_writer.await {
225            Ok(Ok(())) => {}
226            Ok(Err(e)) => {
227                yield Err(e);
228            }
229            Err(_join) => {}
230        }
231    };
232
233    Ok(Box::pin(stream))
234}
235
236/// Spawn the task that owns the plugin's stderr pipe and appends every
237/// line to `objectiveai.plugin_messages`. Reads one line at a time, so
238/// the OS pipe backpressures a slow DB rather than buffering unbounded.
239/// The returned handle is awaited before the run stream completes; the
240/// first insert/read error is surfaced through it.
241fn spawn_stderr_log_writer(
242    pool: crate::db::Pool,
243    owner: String,
244    name: String,
245    version: String,
246    agent_instance_hierarchy: String,
247    response_id: Option<String>,
248    stderr: tokio::process::ChildStderr,
249) -> JoinHandle<Result<(), Error>> {
250    tokio::spawn(async move {
251        let mut lines = BufReader::new(stderr).lines();
252        loop {
253            match lines.next_line().await {
254                Ok(Some(line)) => {
255                    let created_at = std::time::SystemTime::now()
256                        .duration_since(std::time::UNIX_EPOCH)
257                        .map(|d| d.as_secs() as i64)
258                        .unwrap_or(0);
259                    crate::db::logs::insert_plugin_message(
260                        &pool,
261                        &owner,
262                        &name,
263                        &version,
264                        &agent_instance_hierarchy,
265                        response_id.as_deref(),
266                        created_at,
267                        &line,
268                    )
269                    .await
270                    .map_err(Error::from)?;
271                }
272                Ok(None) => return Ok(()),
273                Err(e) => return Err(Error::PluginRead(e)),
274            }
275        }
276    })
277}
278
279/// Dispatch a plugin-originated command IN-PROCESS — no subprocess, no
280/// Postgres re-bootstrap. `command` arrives as an already-tokenized
281/// argv vector (the plugin executor carries it structured), so it runs
282/// through the very same `crate::run` entry the cli binary uses without
283/// any re-tokenization — an argument value containing whitespace stays a
284/// single token. Dispatched against `ctx` (which already carries this
285/// caller's identity plus the plugin coordinate). The body is a mirror
286/// of `main.rs::run_command`: every line that binary would write to
287/// stdout is instead forwarded into `plugin_stdin` wrapped in a
288/// [`PluginCommandResponse`]. Returns an exit code for the terminal
289/// `CommandComplete` (the tool's code on a `ToolExit`, else 0/1).
290fn run_nested_command(
291    ctx: Context,
292    command: Vec<String>,
293    plugin_stdin: Arc<Mutex<ChildStdin>>,
294    id: Option<String>,
295) -> JoinHandle<i32> {
296    tokio::spawn(async move {
297        let id = id.as_deref();
298        let exit_code = run_nested_inner(ctx, command, plugin_stdin.clone(), id).await;
299        // Per-command stream terminator: tell the plugin THIS command's
300        // response stream is exhausted (carries the id) so its executor
301        // ends the right stream. Written promptly when the stream ends —
302        // not deferred to plugin-stdout EOF — and swallowed by the executor.
303        let _ = write_envelope(
304            &plugin_stdin,
305            &PluginCommandResponse {
306                id,
307                value: CommandComplete {
308                    kind: "command_complete",
309                    exit_code,
310                },
311            },
312        )
313        .await;
314        exit_code
315    })
316}
317
318/// Run one plugin-originated command in-process, forwarding each output
319/// line into `plugin_stdin` as a [`PluginCommandResponse`]. Returns the
320/// exit code (the tool's code on a `ToolExit`, else 0/1); the caller
321/// ([`run_nested_command`]) writes the terminal [`CommandComplete`] once
322/// this returns.
323async fn run_nested_inner(
324    ctx: Context,
325    command: Vec<String>,
326    plugin_stdin: Arc<Mutex<ChildStdin>>,
327    id: Option<&str>,
328) -> i32 {
329    // Argv is already tokenized by the plugin executor — one
330    // element per argument. No `split_whitespace`, so a value like
331    // `--simple "a b c"` is not re-split into separate tokens.
332    let tokens: Vec<String> = command;
333
334    // A plugin may not invoke `plugins` or `tools` commands — no
335    // running another plugin, no running a tool. Forward the same
336    // error line the cli would emit for the forbidden cases.
337    let forbidden = match tokens.first().map(String::as_str) {
338        Some("plugins") => Some("plugins"),
339        Some("tools") => Some("tools"),
340        _ => None,
341    };
342    if let Some(kind) = forbidden {
343        let _ = forward_error(
344            &plugin_stdin,
345            id,
346            &Error::PluginCommandForbidden(kind),
347            Some(true),
348        )
349        .await;
350        return 1;
351    }
352
353    // `args[0]` is the program name, which `crate::run` strips
354    // unconditionally; the plugin's command is just the subcommand
355    // tokens, so prepend a placeholder.
356    let mut args: Vec<String> = vec!["objectiveai-cli".to_string()];
357    args.extend(tokens);
358
359    // A mirror of `main.rs::run_command`: drive the same `crate::run`
360    // stream, but forward each line into the plugin's stdin (wrapped
361    // in a `PluginCommandResponse`) instead of writing it to stdout.
362    let run_stream = match crate::run(args, Some(ctx)).await {
363        Ok(s) => s,
364        Err(e) => {
365            if let Error::ClapParse(ref clap_err) = e {
366                if crate::is_informational(clap_err) {
367                    let _ = forward_help(&plugin_stdin, id, &clap_err.to_string()).await;
368                    return 0;
369                }
370            }
371            let _ = forward_error(&plugin_stdin, id, &e, Some(true)).await;
372            return match e {
373                Error::ToolExit(code) => code,
374                _ => 1,
375            };
376        }
377    };
378    // Both arms forward each item to the plugin's stdin as a line;
379    // `drain` is generic over the item type (typed root items vs
380    // post-transform JSON).
381    let last_tool_exit = match run_stream {
382        crate::RunStream::Execute(stream) => drain(&plugin_stdin, id, stream).await,
383        crate::RunStream::ExecuteTransform(stream) => drain(&plugin_stdin, id, stream).await,
384    };
385    last_tool_exit.unwrap_or(0)
386}
387
388/// Drain a run stream into the plugin's stdin — one
389/// [`PluginCommandResponse`] line per item. Returns the last `ToolExit`
390/// code seen (surfaced as an `Err` item). Generic over the item type so
391/// it serves both `RunStream` arms. Stops early if the plugin's stdin
392/// closes.
393async fn drain<S, T>(
394    plugin_stdin: &Arc<Mutex<ChildStdin>>,
395    id: Option<&str>,
396    mut stream: S,
397) -> Option<i32>
398where
399    S: Stream<Item = Result<T, Error>> + Unpin,
400    T: Serialize,
401{
402    let mut last_tool_exit: Option<i32> = None;
403    while let Some(item) = stream.next().await {
404        let written = match item {
405            Ok(value) => forward_line(plugin_stdin, id, &value).await,
406            Err(e) => {
407                if let Error::ToolExit(code) = &e {
408                    last_tool_exit = Some(*code);
409                }
410                forward_error(plugin_stdin, id, &e, None).await
411            }
412        };
413        if written.is_err() {
414            // Plugin's stdin is gone; abandon the run.
415            break;
416        }
417    }
418    last_tool_exit
419}
420
421/// Mirror of `main.rs::write_line`: serialize `value` and forward it to
422/// the plugin's stdin in a [`PluginCommandResponse`] envelope.
423async fn forward_line<T: Serialize>(
424    plugin_stdin: &Arc<Mutex<ChildStdin>>,
425    id: Option<&str>,
426    value: &T,
427) -> std::io::Result<()> {
428    write_envelope(plugin_stdin, &PluginCommandResponse { id, value }).await
429}
430
431/// Mirror of `main.rs::write_error_line`.
432async fn forward_error(
433    plugin_stdin: &Arc<Mutex<ChildStdin>>,
434    id: Option<&str>,
435    e: &Error,
436    fatal: Option<bool>,
437) -> std::io::Result<()> {
438    let payload = CliError {
439        r#type: CliErrorType::Error,
440        level: Some(objectiveai_sdk::cli::Level::Error),
441        fatal,
442        message: e.output_message(),
443    };
444    forward_line(plugin_stdin, id, &payload).await
445}
446
447/// Mirror of `main.rs::write_help_line`.
448async fn forward_help(
449    plugin_stdin: &Arc<Mutex<ChildStdin>>,
450    id: Option<&str>,
451    help: &str,
452) -> std::io::Result<()> {
453    let payload = serde_json::json!({ "type": "help", "help": help });
454    forward_line(plugin_stdin, id, &payload).await
455}
456
457async fn write_envelope<T: Serialize>(
458    stdin: &Arc<Mutex<ChildStdin>>,
459    envelope: &T,
460) -> std::io::Result<()> {
461    let line = serde_json::to_string(envelope).expect("envelope serializes");
462    let mut guard = stdin.lock().await;
463    guard.write_all(line.as_bytes()).await?;
464    guard.write_all(b"\n").await?;
465    guard.flush().await?;
466    Ok(())
467}
468
469/// Wire envelope for nested-command output streamed back to plugin
470/// stdin. Matches `cli.plugins.PluginCommandResponse.json`. Defined
471/// locally rather than in the SDK because the SDK's `cli/output`
472/// module that hosts the canonical type is currently torn-up.
473#[derive(Serialize)]
474struct PluginCommandResponse<'a, T> {
475    #[serde(skip_serializing_if = "Option::is_none")]
476    id: Option<&'a str>,
477    value: T,
478}
479
480/// Terminal marker written to plugin stdin after each nested command
481/// finishes. Matches `cli.output.notification.CommandComplete.json`.
482#[derive(Serialize)]
483struct CommandComplete {
484    #[serde(rename = "type")]
485    kind: &'static str,
486    exit_code: i32,
487}
488
489pub mod request_schema {
490    use objectiveai_sdk::cli::command::plugins::run as sdk;
491    use objectiveai_sdk::cli::command::plugins::run::request_schema::{Request, Response};
492
493    use crate::context::Context;
494    use crate::error::Error;
495
496    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
497        Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::Request)))
498    }
499}
500
501pub mod response_schema {
502    use objectiveai_sdk::cli::command::plugins::run as sdk;
503    use objectiveai_sdk::cli::command::plugins::run::response_schema::{Request, Response};
504
505    use crate::context::Context;
506    use crate::error::Error;
507
508    pub async fn execute(_ctx: &Context, _request: Request) -> Result<Response, Error> {
509        Ok(objectiveai_sdk::cli::command::ResponseSchema(schemars::schema_for!(sdk::ResponseItem)))
510    }
511}