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}