Skip to main content

supercode_harness/
plugins.rs

1//! P5-12 (COMPOSABLE-HARNESS-DESIGN.md §2 module 18 `plugins`, D7 "in-process
2//! extension API, packaging/marketplaces, custom tools from files, provider
3//! injection, extension UI, plugin/package installation"; §2.1 D-10:
4//! "config-borne code execution without a trust gate is an injection hole").
5//!
6//! # The ABI decision: out-of-process, trust-gated, manifest-declared
7//!
8//! A plugin is **not** an in-process dynamically-linked library or FFI —
9//! that would be memory-unsafe in Rust, a versioning nightmare across
10//! plugin/host builds, and would bypass the trust/sandbox boundary this
11//! module exists to enforce. Instead, a plugin is a **directory** containing
12//! a **manifest** (`plugin.toml`, `RawManifest`) that DECLARES what it
13//! contributes — the manifest is DATA; the plugin's own code runs ONLY as a
14//! subprocess this crate spawns, never linked into supercode's address
15//! space. This keeps a plugin memory-safe to load (a malformed/hostile
16//! manifest can't corrupt this process, only fail to parse), language-
17//! agnostic (a plugin can be any executable), sandboxable via
18//! [`crate::sandbox`]/`crate::tools::build_sandboxed_sh` exactly like
19//! `bash`, and cleanly trust-gated (below) for D-10.
20//!
21//! ## The manifest schema (the ABI contract)
22//! ```toml
23//! name = "my-plugin"      # optional — the plugin's directory name is the
24//! version = "0.1.0"       # fallback/authoritative namespace either way
25//!
26//! [[tools]]
27//! name = "greet"                 # required, non-empty
28//! command = "python3"            # required, non-empty — the executable
29//! args = ["greet.py"]            # optional, fixed argv (config-borne, trusted)
30//! description = "Say hello"      # optional
31//! params = { type = "object", properties = { name = { type = "string" } } }
32//! # ^ optional JSON Schema for the tool's input; defaults to
33//! #   {"type": "object"} (an MCP-style server would be the natural growth
34//! #   path for a richer tool surface — see "Honest gaps" below).
35//!
36//! [[hooks]]
37//! event = "post_tool"            # a lifecycle event name (cli::hooks::HookEvent)
38//! command = "notify.sh"          # required, non-empty
39//! ```
40//! A plugin registers its `[[tools]]` entries into the model-visible
41//! [`crate::tools::ToolRegistry`] (namespaced `plugin__<plugin>__<tool>`,
42//! mirroring [`crate::mcp::McpServerHandle`]'s `mcp__<server>__<tool>`
43//! convention) via [`register_into`]. `[[hooks]]` entries are PARSED,
44//! VALIDATED, and carried on [`LoadedPlugins::hooks`] — see "Honest gaps"
45//! below for why their lifecycle EMISSION is not yet wired in this build,
46//! the exact same "registerable now, emission deferred" shape
47//! `crates/cli/src/hooks.rs`'s own `subagent_start`/`pre_compact` events
48//! already use (that module's doc comment, P5-7).
49//!
50//! ## Discovery
51//! [`discover_manifests`] scans a list of directories, each expected to
52//! contain `<plugin-name>/plugin.toml` subdirectories — the ALWAYS-scanned
53//! `$SUPERCODE_HOME/plugins` (mirroring `crate::agent::global_instructions_dir`,
54//! the same "trusted user/global tier" location every other user-level
55//! resource in this crate lives under) plus any extra
56//! `[capabilities.plugins] dirs = [...]` entries. Since `[capabilities.plugins]`
57//! (`dirs` included) is wholesale project-forbidden (see "Trust model"
58//! below), `dirs` can only ever be user/global-layer or preset data — never
59//! attacker-controlled project config.
60//!
61//! ## Subprocess execution model
62//! A registered [`PluginTool::execute`] spawns the manifest's fixed
63//! `command`/`args` (never the model's own arguments — see below) through
64//! `crate::tools::build_sandboxed_sh`, the SAME sandboxed-spawn builder
65//! `crate::tools::BashTool`/`crate::agent::Agent::background_exec` use — so
66//! a plugin tool's subprocess gets the identical P5-10 OS sandbox
67//! (Landlock/seatbelt)/env-policy/network-policy posture a `bash` call
68//! would, not a second, weaker path. Like `background_exec`
69//! (`crate::agent`'s own P5-6 precedent), the child is placed in its own
70//! process group (`Command::process_group(0)`, unix) and unconditionally
71//! group-killed after the call completes (success, error, OR timeout) —
72//! see `kill_group` — so a plugin that spawns a persistent worker
73//! grandchild (the exact P5-11 LSP-review class this mirrors) never
74//! orphans one.
75//!
76//! **The model's own tool-call arguments are never shell-spliced.** They
77//! are serialized to JSON and written to the child's STDIN — never appended
78//! to the (fixed, manifest-sourced) command string `build_sandboxed_sh`
79//! wraps in `sh -c`. Since the model-controlled content never touches that
80//! string at all, there is nothing for it to break out of.
81//!
82//! Output (stdout/stderr, captured separately) is bounded at
83//! [`PLUGIN_TOOL_MAX_OUTPUT_BYTES`] each — reading never stops at the cap
84//! (so a flooding child can't wedge on a full OS pipe), only what's
85//! RETAINED is bounded, the same "reading never stops, retention does"
86//! contract `crate::background::CapturedOutput` documents for itself. The
87//! whole call is bounded by [`DEFAULT_PLUGIN_TOOL_TIMEOUT_SECS`]; on
88//! timeout the process group is killed and a clear timeout error is
89//! returned — never a hang.
90//!
91//! ## Trust model (D-10 — the cardinal requirement)
92//! A plugin is arbitrary code execution, so nothing here ever loads OR RUNS
93//! one without an affirmative trust decision:
94//! - [`crate::Config::plugins_enabled`] (`[capabilities.plugins] enabled`)
95//!   is the feature's own master gate — `false` (the default) means
96//!   [`discover_and_load`] returns [`PluginLoadOutcome::Disabled`] without
97//!   ever touching the filesystem (no directory read, no manifest parse, no
98//!   subprocess) — byte-identical to before this module existed.
99//! - [`is_trusted`] is the SEPARATE workspace-trust gate
100//!   (`[capabilities.trust]`): even with `plugins_enabled = true`, a
101//!   workspace whose [`TrustDecision`] isn't [`TrustDecision::Always`] gets
102//!   [`PluginLoadOutcome::BlockedPendingTrust`] — loud (a caller-visible,
103//!   non-silent outcome; see [`register_into`]'s one-line stderr notice),
104//!   never a silent partial load. **Honest gap:** this build has no
105//!   interactive "trust this workspace?" prompt UI wired up anywhere (no
106//!   consumer of `TrustDecision::Ask` exists yet, matching
107//!   `crate::mcp::HeadlessElicitationHandler`'s own "deny-default, pending a
108//!   real interactive handler" precedent) — so `ask` (pi's own own default)
109//!   and `never` both cleanly refuse to load in this build; only an
110//!   operator explicitly setting `default = "always"` in their trusted
111//!   user/global config unlocks plugin loading. This narrows what pi's own
112//!   `defaultProjectTrust = "ask"` would otherwise interactively allow, in
113//!   the safe direction (quarantine-by-default), never the unsafe one.
114//! - The RESOLVER's own hard dependency (`configfile::validate_modules`'s
115//!   pre-existing D-10 check, `plugins → trust`) refuses to resolve a config
116//!   with `plugins` on and `trust` off at all — this module's own
117//!   [`is_trusted`] check is a SECOND, finer-grained gate on top (trust
118//!   *enabled* is necessary but not sufficient; it must also have decided
119//!   `always`).
120//! - `[capabilities.plugins]` (the whole table: `enabled`, `dirs`, any
121//!   future contribution key) is wholesale PROJECT-FORBIDDEN — stripped by
122//!   both `crate::configfile::sanitize_for_project` and
123//!   `crates/cli/src/userconfig.rs`'s own copy, exactly like `hooks`/
124//!   `mcp.servers`/`server` (config-borne code execution). A hostile
125//!   `.supercode.toml` cannot enable plugins, add a plugin directory, or
126//!   loosen the trust decision at all — only the user/global layer (or a
127//!   preset extended from it) can.
128//!
129//! ## Honest, deliberate gaps (build brief: "no declared-but-dead key")
130//! - **No pi-TS-extension compatibility.** pi's in-process TypeScript
131//!   `ExtensionAPI` (jiti-loaded modules, ~40 events, `registerProvider`/
132//!   `setEditorComponent`/overlay UI) cannot and does not run under this
133//!   ABI — supercode's `plugins` module has its OWN ABI by design
134//!   (COMPOSABLE-HARNESS-DESIGN.md line 1080-1081, an already-accepted
135//!   recorded deviation), not an emulation of pi's. An existing pi
136//!   extension simply does not run here.
137//! - **No marketplace / package installation / `npm install`.** Plugins are
138//!   discovered from a local, trusted directory only — there is no
139//!   `plugin install <name>` command, no registry client, no network fetch
140//!   anywhere in this module. Fetching/installing a plugin (from a
141//!   marketplace, npm, or otherwise) is the OPERATOR'S job today (place a
142//!   directory under `$SUPERCODE_HOME/plugins`), same posture `lsp`/
143//!   `formatters` already take for THEIR external tools (§2 module 28's own
144//!   "no auto-spawn/auto-download fleet" gap).
145//! - **No extension UI / provider injection.** `registerProvider`,
146//!   `setEditorComponent`, overlay UI, and any other in-process
147//!   extension-surface hook are impossible by construction under an
148//!   out-of-process ABI (a subprocess cannot reach into this process's
149//!   UI/provider registry) — not a partially-wired knob, simply not offered.
150//! - **Hook FIRING is deferred; hook REGISTRATION is not.** A manifest's
151//!   `[[hooks]]` entries are parsed, validated, trust-gated exactly like
152//!   `[[tools]]`, and carried on [`LoadedPlugins::hooks`] — but no lifecycle
153//!   site in `crates/cli` consults them yet (the same "registerable now,
154//!   emission deferred" shape `crates/cli/src/hooks.rs` already ships and
155//!   documents for `subagent_start`/`subagent_stop`/`pre_compact`/
156//!   `post_compact`, P5-7). [`register_into`] prints a one-time-per-call
157//!   warning when a loaded, trusted plugin declares a hook, so this is a
158//!   visible, honest gap — never a silent no-op.
159//! - **No hash-trust / manifest-change re-prompt (cx§7 "quarantine +
160//!   hash-trust").** The weakest form re-evaluates [`is_trusted`] (a
161//!   workspace-level decision) on every load, but does not fingerprint an
162//!   individual manifest's content to force a re-decision when it changes —
163//!   tracked, not hidden: a workspace already at `TrustDecision::Always`
164//!   trusts every manifest under its scanned directories, including one
165//!   edited after the fact. The `enabled`/`default`/`dirs` knobs this
166//!   module DOES expose are all real and wired; this is a scope gap on top
167//!   of them, not a dead key.
168
169use std::path::{Path, PathBuf};
170use std::time::Duration;
171
172use async_trait::async_trait;
173use serde_json::Value;
174
175use crate::error::{Error, Result};
176use crate::tools::{Tool, ToolContext};
177
178/// Default wall-clock bound on a single plugin tool invocation — generous
179/// for a real script while bounding how long a hanging/misbehaving plugin
180/// can stall the agent loop (mirrors `crate::mcp::DEFAULT_MCP_TIMEOUT`'s
181/// rationale for the same "config-borne subprocess" trust class).
182pub const DEFAULT_PLUGIN_TOOL_TIMEOUT_SECS: u64 = 30;
183
184/// Hardening cap (mirrors `crate::mcp::MCP_MAX_RESPONSE_BYTES`'s rationale,
185/// scaled down: a plugin tool result is model-context-bound, not a raw
186/// resource fetch): the maximum bytes of stdout (and, separately, stderr)
187/// a plugin tool invocation retains — reading never stops at this cap (see
188/// the module doc comment), only retention does, so a flooding child can't
189/// wedge on a full OS pipe either.
190pub const PLUGIN_TOOL_MAX_OUTPUT_BYTES: usize = 1024 * 1024;
191
192/// §2 module 14 `trust`'s `[capabilities.trust] default = "ask" | "always" |
193/// "never"` decision (§3.1 schema; every preset that turns trust on sets
194/// `default = "ask"`, pi's own `defaultProjectTrust` default). See the
195/// module doc comment's "Trust model" section for why, absent an
196/// interactive upgrade path in this build, only [`TrustDecision::Always`]
197/// actually unlocks plugin loading — `Ask`/`Never` both cleanly refuse
198/// rather than silently granting or hanging on a prompt nothing answers.
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
200pub enum TrustDecision {
201    /// Prompt before trusting — pi's own default. No interactive handler is
202    /// wired in this build (honest gap, see the module doc comment), so
203    /// this behaves like [`TrustDecision::Never`] for [`is_trusted`].
204    #[default]
205    Ask,
206    /// Always trusted — the only value [`is_trusted`] accepts today.
207    Always,
208    /// Never trusted, regardless of anything else.
209    Never,
210}
211
212impl TrustDecision {
213    /// Parse the `"ask"` / `"always"` / `"never"` config strings (§3.1).
214    /// Unrecognized text is never silently trusted — the resolver treats an
215    /// unparseable value the same as "not `always`" (see [`is_trusted`]),
216    /// so an operator typo fails closed, not open.
217    pub fn parse(s: &str) -> Option<TrustDecision> {
218        match s {
219            "ask" => Some(TrustDecision::Ask),
220            "always" => Some(TrustDecision::Always),
221            "never" => Some(TrustDecision::Never),
222            _ => None,
223        }
224    }
225}
226
227/// §2 module 14 `trust` + D-10: is this workspace trusted to load/run
228/// config-declared plugin code? See the module doc comment's "Trust model"
229/// section. `false` whenever [`crate::Config::trust_enabled`] is `false`
230/// (the master gate — matches every OTHER module's "disabled means the
231/// setting underneath is never consulted" contract) OR
232/// [`crate::Config::trust_default`] isn't exactly [`TrustDecision::Always`].
233pub fn is_trusted(config: &crate::Config) -> bool {
234    config.trust_enabled && config.trust_default == TrustDecision::Always
235}
236
237/// One `[[tools]]` entry from a `plugin.toml` manifest — see the module doc
238/// comment's "Manifest schema" section.
239#[derive(Debug, Clone, PartialEq)]
240pub struct PluginToolSpec {
241    /// The executable to spawn (searched on `PATH`, like any `Command::new`)
242    /// — fixed, manifest-sourced data; never the model's own input.
243    pub command: String,
244    /// Fixed extra arguments to `command` — same trust class as `command`.
245    pub args: Vec<String>,
246    /// Human description surfaced to the model as the tool's description.
247    pub description: String,
248    /// JSON Schema for the tool's input object; `{"type": "object"}` when
249    /// the manifest doesn't declare one (same default
250    /// [`crate::mcp::McpToolDef::input_schema`] uses).
251    pub params: Value,
252}
253
254/// One `[[hooks]]` entry from a `plugin.toml` manifest — parsed and
255/// trust-gated, but not yet wired to firing (see the module doc comment's
256/// "Honest, deliberate gaps" section).
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub struct PluginHookSpec {
259    /// The lifecycle event name (e.g. `"post_tool"`,
260    /// `"session_start"` — `crates/cli/src/hooks.rs::HookEvent::as_str`'s
261    /// string form).
262    pub event: String,
263    /// The command to run — same trust class as a tool's `command`
264    /// (config-borne, from an already trust-gated manifest).
265    pub command: String,
266}
267
268/// A parsed, trust-gated-pending `plugin.toml` manifest — see the module
269/// doc comment's "Manifest schema" section for the ABI contract this
270/// mirrors.
271#[derive(Debug, Clone, PartialEq)]
272pub struct PluginManifest {
273    /// The plugin's own declared name, or its directory name when the
274    /// manifest omits `name` (see [`parse_manifest_str`]).
275    pub name: String,
276    /// Free-form version string (`"0.0.0"` when omitted) — descriptive
277    /// only; this module does not interpret or compare versions.
278    pub version: String,
279    /// `(tool short name, spec)` pairs from `[[tools]]`, in manifest order.
280    /// An entry with an empty `name` or `command` is skipped (malformed,
281    /// not a crash — same "skip the bad entry" precedent
282    /// `crate::configfile::lsp_servers_from_settings` documents for
283    /// itself).
284    pub tools: Vec<(String, PluginToolSpec)>,
285    /// `[[hooks]]` entries, in manifest order. An entry with an empty
286    /// `event` or `command` is skipped, same precedent as `tools`.
287    pub hooks: Vec<PluginHookSpec>,
288}
289
290#[derive(Debug, Default, serde::Deserialize)]
291struct RawManifest {
292    name: Option<String>,
293    #[serde(default)]
294    version: String,
295    #[serde(default)]
296    tools: Vec<RawTool>,
297    #[serde(default)]
298    hooks: Vec<RawHook>,
299}
300
301#[derive(Debug, Default, serde::Deserialize)]
302struct RawTool {
303    #[serde(default)]
304    name: String,
305    #[serde(default)]
306    command: String,
307    #[serde(default)]
308    args: Vec<String>,
309    #[serde(default)]
310    description: String,
311    params: Option<Value>,
312}
313
314#[derive(Debug, Default, serde::Deserialize)]
315struct RawHook {
316    #[serde(default)]
317    event: String,
318    #[serde(default)]
319    command: String,
320}
321
322/// Parse `text` (a `plugin.toml`'s contents) into a [`PluginManifest`],
323/// using `fallback_name` (the plugin's directory name) when the manifest
324/// itself doesn't declare `name`. A malformed TOML document is a clean
325/// `Err`, never a panic; a malformed INDIVIDUAL `[[tools]]`/`[[hooks]]`
326/// entry (empty `name`/`command`/`event`) is silently skipped rather than
327/// failing the whole manifest (see [`PluginManifest::tools`]'s doc
328/// comment).
329pub fn parse_manifest_str(text: &str, fallback_name: &str) -> Result<PluginManifest> {
330    let raw: RawManifest = toml::from_str(text)
331        .map_err(|e| Error::tool("plugins", format!("parsing manifest: {e}")))?;
332    let name = raw
333        .name
334        .filter(|n| !n.trim().is_empty())
335        .unwrap_or_else(|| fallback_name.to_string());
336    let version = if raw.version.trim().is_empty() {
337        "0.0.0".to_string()
338    } else {
339        raw.version
340    };
341    let tools = raw
342        .tools
343        .into_iter()
344        .filter(|t| !t.name.trim().is_empty() && !t.command.trim().is_empty())
345        .map(|t| {
346            (
347                t.name,
348                PluginToolSpec {
349                    command: t.command,
350                    args: t.args,
351                    description: t.description,
352                    params: t
353                        .params
354                        .unwrap_or_else(|| serde_json::json!({"type": "object"})),
355                },
356            )
357        })
358        .collect();
359    let hooks = raw
360        .hooks
361        .into_iter()
362        .filter(|h| !h.event.trim().is_empty() && !h.command.trim().is_empty())
363        .map(|h| PluginHookSpec {
364            event: h.event,
365            command: h.command,
366        })
367        .collect();
368    Ok(PluginManifest {
369        name,
370        version,
371        tools,
372        hooks,
373    })
374}
375
376/// Read and parse `path` (a `plugin.toml` file) — see [`parse_manifest_str`].
377/// `fallback_name` is the containing directory's name.
378pub fn parse_manifest(path: &Path, fallback_name: &str) -> Result<PluginManifest> {
379    let text = std::fs::read_to_string(path)
380        .map_err(|e| Error::tool("plugins", format!("reading {}: {e}", path.display())))?;
381    parse_manifest_str(&text, fallback_name)
382}
383
384/// The always-scanned trusted plugins location:
385/// `$SUPERCODE_HOME/plugins` (mirrors `crate::agent::global_instructions_dir`
386/// — the same user/global tier every other ambient resource in this crate
387/// lives under).
388pub fn default_plugins_dir() -> PathBuf {
389    crate::agent::global_instructions_dir().join("plugins")
390}
391
392/// Scan `dirs` for `<plugin-name>/plugin.toml` manifests — each entry of
393/// `dirs` is expected to be a directory whose immediate subdirectories are
394/// plugin roots (the same shape `default_plugins_dir()` itself has). Returns
395/// `(plugin name, manifest path)` pairs, sorted by name; a name that
396/// appears under more than one scanned directory keeps the LAST directory's
397/// entry (later/more-specific wins — same precedent
398/// `crates/cli/src/main.rs::attach_mcp`'s "same-named entries here WIN"
399/// documents for `capabilities.mcp.servers` over `mcp.json`). A `dirs`
400/// entry that doesn't exist or isn't readable is silently skipped (not
401/// every configured location need exist).
402pub fn discover_manifests(dirs: &[PathBuf]) -> Vec<(String, PathBuf)> {
403    let mut found: std::collections::BTreeMap<String, PathBuf> = std::collections::BTreeMap::new();
404    for dir in dirs {
405        let Ok(entries) = std::fs::read_dir(dir) else {
406            continue;
407        };
408        for entry in entries.flatten() {
409            let path = entry.path();
410            if !path.is_dir() {
411                continue;
412            }
413            let manifest = path.join("plugin.toml");
414            if !manifest.is_file() {
415                continue;
416            }
417            let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
418                continue;
419            };
420            found.insert(name.to_string(), manifest);
421        }
422    }
423    found.into_iter().collect()
424}
425
426/// SIGKILL an entire process group — reused verbatim from
427/// `crate::lsp::kill_process_group` (P5-11's grandchild-orphan fix), the
428/// exact same primitive for the exact same reason: a plugin's declared
429/// command commonly spawns its OWN worker subprocess, and plain
430/// `Child::start_kill` only ever signals the one directly-tracked pid.
431#[cfg(unix)]
432fn kill_group(pid: u32) {
433    crate::lsp::kill_process_group(pid);
434}
435
436#[cfg(not(unix))]
437fn kill_group(_pid: u32) {}
438
439/// POSIX single-quote a string for safe inclusion in a `sh -c` command —
440/// used ONLY for the manifest's OWN fixed `command`/`args` (trusted,
441/// config-borne data), never for the model's tool-call arguments, which
442/// travel over stdin instead (see the module doc comment's "Subprocess
443/// execution model" section). Wrapping in single quotes and escaping any
444/// embedded single quote (`'` -> `'\''`) is safe regardless of what
445/// characters the string contains.
446fn shell_quote(s: &str) -> String {
447    format!("'{}'", s.replace('\'', "'\\''"))
448}
449
450/// Bounded-read one child pipe to completion — reading never stops at
451/// `cap` (so the child can't wedge on a full OS pipe by writing past it),
452/// only what's RETAINED does; returns `(text, truncated)`.
453async fn drain_capped<R>(mut reader: R, cap: usize) -> (String, bool)
454where
455    R: tokio::io::AsyncRead + Unpin,
456{
457    use tokio::io::AsyncReadExt;
458    let mut buf: Vec<u8> = Vec::new();
459    let mut truncated = false;
460    let mut chunk = [0u8; 8192];
461    loop {
462        match reader.read(&mut chunk).await {
463            Ok(0) => break,
464            Ok(n) => {
465                if buf.len() < cap {
466                    let room = cap - buf.len();
467                    let take = room.min(n);
468                    buf.extend_from_slice(&chunk[..take]);
469                    if take < n {
470                        truncated = true;
471                    }
472                } else {
473                    truncated = true;
474                }
475            }
476            Err(_) => break,
477        }
478    }
479    (String::from_utf8_lossy(&buf).into_owned(), truncated)
480}
481
482/// A model-callable tool backed by one plugin's declared `[[tools]]` entry
483/// — see the module doc comment's "Subprocess execution model" section for
484/// the full spawn/sandbox/bound/no-orphan contract [`Tool::execute`] below
485/// implements.
486#[derive(Debug, Clone)]
487pub struct PluginTool {
488    name: String,
489    description: String,
490    params: Value,
491    command: String,
492    args: Vec<String>,
493    timeout: Duration,
494}
495
496impl PluginTool {
497    /// Build the namespaced (`plugin__<plugin>__<tool>`) tool for one
498    /// manifest `[[tools]]` entry — mirrors
499    /// `crate::mcp::McpServerHandle::tools`'s `mcp__<server>__<tool>`
500    /// convention. Uses [`DEFAULT_PLUGIN_TOOL_TIMEOUT_SECS`]; see
501    /// `Self::with_timeout` to override (test-only — this module exposes
502    /// no config knob for it, matching weakest-form scope).
503    pub fn new(plugin_name: &str, tool_name: &str, spec: &PluginToolSpec) -> Self {
504        PluginTool {
505            name: format!("plugin__{plugin_name}__{tool_name}"),
506            description: spec.description.clone(),
507            params: spec.params.clone(),
508            command: spec.command.clone(),
509            args: spec.args.clone(),
510            timeout: Duration::from_secs(DEFAULT_PLUGIN_TOOL_TIMEOUT_SECS),
511        }
512    }
513
514    /// Test-only: override the per-call timeout so timeout/bounded-ness
515    /// tests don't need to wait out the real
516    /// [`DEFAULT_PLUGIN_TOOL_TIMEOUT_SECS`].
517    #[cfg(test)]
518    fn with_timeout(mut self, timeout: Duration) -> Self {
519        self.timeout = timeout;
520        self
521    }
522}
523
524#[async_trait]
525impl Tool for PluginTool {
526    fn name(&self) -> &str {
527        &self.name
528    }
529
530    fn description(&self) -> &str {
531        &self.description
532    }
533
534    fn parameters(&self) -> Value {
535        self.params.clone()
536    }
537
538    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
539        let quoted = format!(
540            "{} {}",
541            shell_quote(&self.command),
542            self.args
543                .iter()
544                .map(|a| shell_quote(a))
545                .collect::<Vec<_>>()
546                .join(" ")
547        );
548        let mut cmd = crate::tools::build_sandboxed_sh(&quoted, ctx)?;
549        cmd.current_dir(&ctx.cwd)
550            .stdin(std::process::Stdio::piped())
551            .stdout(std::process::Stdio::piped())
552            .stderr(std::process::Stdio::piped())
553            .kill_on_drop(true);
554        #[cfg(unix)]
555        cmd.process_group(0);
556
557        let mut child = cmd
558            .spawn()
559            .map_err(|e| Error::tool("plugins", format!("spawn `{}`: {e}", self.command)))?;
560        let pid = child.id();
561
562        let args_json = serde_json::to_vec(&args)
563            .map_err(|e| Error::tool("plugins", format!("encoding tool args: {e}")))?;
564        let mut stdin = child
565            .stdin
566            .take()
567            .ok_or_else(|| Error::tool("plugins", "no stdin"))?;
568        let stdout = child
569            .stdout
570            .take()
571            .ok_or_else(|| Error::tool("plugins", "no stdout"))?;
572        let stderr = child
573            .stderr
574            .take()
575            .ok_or_else(|| Error::tool("plugins", "no stderr"))?;
576
577        let run = async {
578            use tokio::io::AsyncWriteExt;
579            // Write the model's tool-call arguments as JSON over stdin —
580            // NEVER appended to the command string above (see the module
581            // doc comment). Best-effort: a plugin that doesn't read stdin
582            // at all must not hang this write forever, so this is inside
583            // the same outer timeout as everything else in `run`.
584            let _ = stdin.write_all(&args_json).await;
585            let _ = stdin.flush().await;
586            drop(stdin); // EOF, so a plugin blocked on read(stdin) unblocks
587
588            let stdout_task = tokio::spawn(drain_capped(stdout, PLUGIN_TOOL_MAX_OUTPUT_BYTES));
589            let stderr_task = tokio::spawn(drain_capped(stderr, PLUGIN_TOOL_MAX_OUTPUT_BYTES));
590            let status = child.wait().await;
591            let (out, out_truncated) = stdout_task.await.unwrap_or_default();
592            let (err, err_truncated) = stderr_task.await.unwrap_or_default();
593            (status, out, out_truncated, err, err_truncated)
594        };
595
596        let outcome = tokio::time::timeout(self.timeout, run).await;
597
598        // Unconditional group-kill, success OR timeout OR error — belt-
599        // and-suspenders against a surviving worker grandchild even on the
600        // clean-exit path (see the module doc comment's no-orphan
601        // paragraph; `killpg` on an already-exited leader's group still
602        // reaches any surviving member, and is a documented no-op — ESRCH
603        // — if the whole group is already gone).
604        if let Some(pid) = pid {
605            kill_group(pid);
606        }
607
608        let (status, out, out_truncated, err, err_truncated) = match outcome {
609            Ok(result) => result,
610            Err(_) => {
611                return Err(Error::tool(
612                    "plugins",
613                    format!(
614                        "plugin tool `{}` timed out after {:?}",
615                        self.name, self.timeout
616                    ),
617                ));
618            }
619        };
620
621        let mut result = out;
622        if out_truncated {
623            result.push_str(&format!(
624                "\n[plugin output truncated at {PLUGIN_TOOL_MAX_OUTPUT_BYTES} bytes]"
625            ));
626        }
627        if !err.trim().is_empty() {
628            result.push_str("\n[stderr]\n");
629            result.push_str(&err);
630            if err_truncated {
631                result.push_str(&format!(
632                    "\n[plugin stderr truncated at {PLUGIN_TOOL_MAX_OUTPUT_BYTES} bytes]"
633                ));
634            }
635        }
636        match status {
637            Ok(s) if !s.success() => {
638                result.push_str(&format!(
639                    "\n[plugin tool `{}` exited {}]",
640                    self.name,
641                    s.code().map(|c| c.to_string()).unwrap_or_default()
642                ));
643            }
644            Err(e) => {
645                return Err(Error::tool(
646                    "plugins",
647                    format!("plugin tool `{}` wait failed: {e}", self.name),
648                ));
649            }
650            _ => {}
651        }
652        Ok(result)
653    }
654}
655
656/// Every trusted, loaded plugin's contributions — [`discover_and_load`]'s
657/// success case.
658#[derive(Debug, Default)]
659pub struct LoadedPlugins {
660    /// Ready-to-register tools, in discovery order.
661    pub tools: Vec<PluginTool>,
662    /// `(plugin name, hook spec)` pairs — see the module doc comment's
663    /// "Honest, deliberate gaps" section for why these are carried but not
664    /// yet fired.
665    pub hooks: Vec<(String, PluginHookSpec)>,
666    /// Names of every plugin whose manifest parsed successfully.
667    pub loaded_plugin_names: Vec<String>,
668    /// Human-readable warnings for manifests that failed to parse — never
669    /// fatal to the OTHER plugins' load (one bad manifest doesn't sink the
670    /// rest), but never silently swallowed either.
671    pub warnings: Vec<String>,
672}
673
674/// The result of one [`discover_and_load`] call — see the module doc
675/// comment's "Trust model" section for what drives each variant.
676#[derive(Debug)]
677pub enum PluginLoadOutcome {
678    /// `[capabilities.plugins] enabled` is `false` (the default) — nothing
679    /// was touched: no directory read, no manifest parsed, no subprocess
680    /// spawned.
681    Disabled,
682    /// `enabled = true`, but [`is_trusted`] said no — nothing was loaded.
683    /// Distinct from [`PluginLoadOutcome::Disabled`] so a caller can report
684    /// this honestly (quarantined pending trust) rather than looking
685    /// identical to the feature being off.
686    BlockedPendingTrust,
687    /// Trusted and enabled — every discovered manifest was at least
688    /// attempted; see [`LoadedPlugins::warnings`] for any that failed.
689    Loaded(LoadedPlugins),
690}
691
692/// The single entry point: resolve `config`'s plugin gates
693/// ([`crate::Config::plugins_enabled`], [`is_trusted`]) and, only if both
694/// pass, discover + parse every manifest under the scanned directories.
695/// See the module doc comment's "Trust model" section for the full D-10
696/// contract this enforces.
697pub fn discover_and_load(config: &crate::Config) -> PluginLoadOutcome {
698    if !config.plugins_enabled {
699        return PluginLoadOutcome::Disabled;
700    }
701    if !is_trusted(config) {
702        return PluginLoadOutcome::BlockedPendingTrust;
703    }
704    let mut dirs = vec![default_plugins_dir()];
705    dirs.extend(config.plugins_dirs.iter().cloned());
706    let manifests = discover_manifests(&dirs);
707
708    let mut loaded = LoadedPlugins::default();
709    for (name, path) in manifests {
710        match parse_manifest(&path, &name) {
711            Ok(manifest) => {
712                for (tool_name, spec) in &manifest.tools {
713                    loaded
714                        .tools
715                        .push(PluginTool::new(&manifest.name, tool_name, spec));
716                }
717                for hook in &manifest.hooks {
718                    loaded.hooks.push((manifest.name.clone(), hook.clone()));
719                }
720                loaded.loaded_plugin_names.push(manifest.name);
721            }
722            Err(e) => {
723                loaded
724                    .warnings
725                    .push(format!("plugin `{name}` ({}): {e}", path.display()));
726            }
727        }
728    }
729    PluginLoadOutcome::Loaded(loaded)
730}
731
732/// Register every trusted, loaded plugin tool into `registry` — the single
733/// production choke point `crate::agent::Agent::with_parts` calls (so every
734/// `Agent` construction path gets plugin tools "for free", the same way
735/// `crate::lsp`/`crate::formatters`/`crate::checkpoint`'s observers are
736/// wired unconditionally from `crate::agent::build_tool_context`). A no-op,
737/// with nothing printed, when [`crate::Config::plugins_enabled`] is `false`
738/// (default-off byte-identity). When enabled but not yet trusted, prints
739/// ONE line to stderr (never silent — see [`PluginLoadOutcome::BlockedPendingTrust`]'s
740/// doc comment) and registers nothing. When loaded, registers every tool
741/// and prints a one-time-per-call warning for any hook a trusted plugin
742/// declared (see the module doc comment's "Honest, deliberate gaps"
743/// section) plus any manifest parse warning.
744pub fn register_into(config: &crate::Config, registry: &mut crate::tools::ToolRegistry) {
745    match discover_and_load(config) {
746        PluginLoadOutcome::Disabled => {}
747        PluginLoadOutcome::BlockedPendingTrust => {
748            eprintln!(
749                "warning: [capabilities.plugins] is enabled but this workspace is not trusted \
750                 ([capabilities.trust] default must be \"always\") — no plugin was loaded"
751            );
752        }
753        PluginLoadOutcome::Loaded(loaded) => {
754            for warning in &loaded.warnings {
755                eprintln!("warning: {warning}");
756            }
757            for (plugin, hook) in &loaded.hooks {
758                eprintln!(
759                    "warning: plugin `{plugin}`'s `{}` hook is registered but is not yet \
760                     emitted in this build (no-op) — see crate::plugins's module doc comment",
761                    hook.event
762                );
763            }
764            for tool in loaded.tools {
765                registry.register(tool);
766            }
767        }
768    }
769}
770
771#[cfg(test)]
772mod tests {
773    use super::*;
774
775    fn tmp(tag: &str) -> PathBuf {
776        let dir = std::env::temp_dir().join(format!(
777            "supercode-plugins-test-{tag}-{}-{}",
778            std::process::id(),
779            std::time::SystemTime::now()
780                .duration_since(std::time::UNIX_EPOCH)
781                .map(|d| d.as_nanos())
782                .unwrap_or(0)
783        ));
784        std::fs::create_dir_all(&dir).unwrap();
785        dir
786    }
787
788    fn write_manifest(dir: &Path, name: &str, toml: &str) -> PathBuf {
789        let plugin_dir = dir.join(name);
790        std::fs::create_dir_all(&plugin_dir).unwrap();
791        let manifest = plugin_dir.join("plugin.toml");
792        std::fs::write(&manifest, toml).unwrap();
793        manifest
794    }
795
796    // ---- TrustDecision / is_trusted ----------------------------------
797
798    #[test]
799    fn trust_decision_parse_round_trips_known_values() {
800        assert_eq!(TrustDecision::parse("ask"), Some(TrustDecision::Ask));
801        assert_eq!(TrustDecision::parse("always"), Some(TrustDecision::Always));
802        assert_eq!(TrustDecision::parse("never"), Some(TrustDecision::Never));
803        assert_eq!(TrustDecision::parse("bogus"), None);
804    }
805
806    #[test]
807    fn is_trusted_requires_both_trust_enabled_and_default_always() {
808        let base = crate::Config::builder().model("m").build();
809        assert!(!is_trusted(&base), "trust disabled by default");
810
811        let enabled_ask = crate::Config::builder()
812            .model("m")
813            .trust_enabled(true)
814            .trust_default(TrustDecision::Ask)
815            .build();
816        assert!(
817            !is_trusted(&enabled_ask),
818            "trust enabled but default=ask must NOT be trusted (no interactive upgrade wired)"
819        );
820
821        let enabled_never = crate::Config::builder()
822            .model("m")
823            .trust_enabled(true)
824            .trust_default(TrustDecision::Never)
825            .build();
826        assert!(!is_trusted(&enabled_never));
827
828        let enabled_always = crate::Config::builder()
829            .model("m")
830            .trust_enabled(true)
831            .trust_default(TrustDecision::Always)
832            .build();
833        assert!(is_trusted(&enabled_always));
834
835        let disabled_always = crate::Config::builder()
836            .model("m")
837            .trust_enabled(false)
838            .trust_default(TrustDecision::Always)
839            .build();
840        assert!(
841            !is_trusted(&disabled_always),
842            "trust_enabled=false must gate regardless of trust_default"
843        );
844    }
845
846    // ---- manifest parsing ----------------------------------------------
847
848    #[test]
849    fn parse_manifest_str_parses_tools_and_hooks() {
850        let toml = r#"
851name = "demo"
852version = "1.2.3"
853
854[[tools]]
855name = "greet"
856command = "echo"
857args = ["hi"]
858description = "says hi"
859params = { type = "object" }
860
861[[hooks]]
862event = "post_tool"
863command = "notify.sh"
864"#;
865        let m = parse_manifest_str(toml, "fallback").unwrap();
866        assert_eq!(m.name, "demo");
867        assert_eq!(m.version, "1.2.3");
868        assert_eq!(m.tools.len(), 1);
869        assert_eq!(m.tools[0].0, "greet");
870        assert_eq!(m.tools[0].1.command, "echo");
871        assert_eq!(m.tools[0].1.args, vec!["hi".to_string()]);
872        assert_eq!(m.hooks.len(), 1);
873        assert_eq!(m.hooks[0].event, "post_tool");
874        assert_eq!(m.hooks[0].command, "notify.sh");
875    }
876
877    #[test]
878    fn parse_manifest_str_falls_back_to_directory_name_when_name_absent() {
879        let m = parse_manifest_str("version = \"0.1.0\"", "my-dir-name").unwrap();
880        assert_eq!(m.name, "my-dir-name");
881    }
882
883    #[test]
884    fn parse_manifest_str_defaults_version_and_params_when_absent() {
885        let toml = r#"
886[[tools]]
887name = "t"
888command = "echo"
889"#;
890        let m = parse_manifest_str(toml, "p").unwrap();
891        assert_eq!(m.version, "0.0.0");
892        assert_eq!(m.tools[0].1.params, serde_json::json!({"type": "object"}));
893    }
894
895    #[test]
896    fn parse_manifest_str_skips_malformed_entries_without_failing_the_manifest() {
897        let toml = r#"
898[[tools]]
899name = ""
900command = "echo"
901
902[[tools]]
903name = "ok"
904command = ""
905
906[[tools]]
907name = "good"
908command = "echo"
909
910[[hooks]]
911event = ""
912command = "x"
913"#;
914        let m = parse_manifest_str(toml, "p").unwrap();
915        assert_eq!(m.tools.len(), 1, "only the fully-valid tool survives");
916        assert_eq!(m.tools[0].0, "good");
917        assert!(m.hooks.is_empty());
918    }
919
920    #[test]
921    fn parse_manifest_str_rejects_malformed_toml() {
922        assert!(parse_manifest_str("not valid toml [[[", "p").is_err());
923    }
924
925    // ---- discovery -------------------------------------------------------
926
927    #[test]
928    fn discover_manifests_finds_plugin_toml_under_immediate_subdirs() {
929        let dir = tmp("discover");
930        write_manifest(&dir, "alpha", "name = \"alpha\"\n");
931        write_manifest(&dir, "beta", "name = \"beta\"\n");
932        // A subdirectory with no plugin.toml must be ignored.
933        std::fs::create_dir_all(dir.join("not-a-plugin")).unwrap();
934
935        let found = discover_manifests(std::slice::from_ref(&dir));
936        let names: Vec<&str> = found.iter().map(|(n, _)| n.as_str()).collect();
937        assert_eq!(names, vec!["alpha", "beta"], "sorted by name");
938        std::fs::remove_dir_all(&dir).ok();
939    }
940
941    #[test]
942    fn discover_manifests_missing_dir_is_silently_skipped() {
943        let missing = tmp("missing-parent").join("does-not-exist");
944        let found = discover_manifests(&[missing]);
945        assert!(found.is_empty());
946    }
947
948    #[test]
949    fn discover_manifests_later_dir_wins_on_name_collision() {
950        let dir_a = tmp("collide-a");
951        let dir_b = tmp("collide-b");
952        write_manifest(&dir_a, "dup", "version = \"1.0.0\"\n");
953        write_manifest(&dir_b, "dup", "version = \"2.0.0\"\n");
954        let found = discover_manifests(&[dir_a.clone(), dir_b.clone()]);
955        assert_eq!(found.len(), 1);
956        let (_, path) = &found[0];
957        assert!(path.starts_with(&dir_b), "later dir must win");
958        std::fs::remove_dir_all(&dir_a).ok();
959        std::fs::remove_dir_all(&dir_b).ok();
960    }
961
962    // ---- discover_and_load / register_into gating -----------------------
963
964    #[test]
965    fn discover_and_load_is_disabled_when_plugins_off_default_off_byte_identity() {
966        let config = crate::Config::builder().model("m").build();
967        assert!(!config.plugins_enabled);
968        assert!(matches!(
969            discover_and_load(&config),
970            PluginLoadOutcome::Disabled
971        ));
972    }
973
974    #[test]
975    fn discover_and_load_is_blocked_pending_trust_when_untrusted() {
976        let config = crate::Config::builder()
977            .model("m")
978            .plugins_enabled(true)
979            .trust_enabled(true)
980            .trust_default(TrustDecision::Ask)
981            .build();
982        assert!(matches!(
983            discover_and_load(&config),
984            PluginLoadOutcome::BlockedPendingTrust
985        ));
986    }
987
988    #[test]
989    fn discover_and_load_is_blocked_when_trust_module_itself_is_off() {
990        let config = crate::Config::builder()
991            .model("m")
992            .plugins_enabled(true)
993            .trust_enabled(false)
994            .build();
995        assert!(matches!(
996            discover_and_load(&config),
997            PluginLoadOutcome::BlockedPendingTrust
998        ));
999    }
1000
1001    #[test]
1002    fn discover_and_load_loads_tools_from_a_trusted_configured_dir() {
1003        let dir = tmp("load-trusted");
1004        write_manifest(
1005            &dir,
1006            "demo",
1007            "name = \"demo\"\n\n[[tools]]\nname = \"echo_it\"\ncommand = \"echo\"\n",
1008        );
1009        let config = crate::Config::builder()
1010            .model("m")
1011            .plugins_enabled(true)
1012            .plugins_dirs(vec![dir.clone()])
1013            .trust_enabled(true)
1014            .trust_default(TrustDecision::Always)
1015            .build();
1016        match discover_and_load(&config) {
1017            PluginLoadOutcome::Loaded(loaded) => {
1018                assert_eq!(loaded.loaded_plugin_names, vec!["demo".to_string()]);
1019                assert_eq!(loaded.tools.len(), 1);
1020                assert_eq!(loaded.tools[0].name(), "plugin__demo__echo_it");
1021            }
1022            other => panic!("expected Loaded, got {other:?}"),
1023        }
1024        std::fs::remove_dir_all(&dir).ok();
1025    }
1026
1027    #[test]
1028    fn discover_and_load_records_a_warning_for_an_unparseable_manifest_without_failing_others() {
1029        let dir = tmp("load-warn");
1030        write_manifest(&dir, "bad", "not valid toml [[[");
1031        write_manifest(
1032            &dir,
1033            "good",
1034            "name = \"good\"\n\n[[tools]]\nname = \"t\"\ncommand = \"echo\"\n",
1035        );
1036        let config = crate::Config::builder()
1037            .model("m")
1038            .plugins_enabled(true)
1039            .plugins_dirs(vec![dir.clone()])
1040            .trust_enabled(true)
1041            .trust_default(TrustDecision::Always)
1042            .build();
1043        match discover_and_load(&config) {
1044            PluginLoadOutcome::Loaded(loaded) => {
1045                assert_eq!(loaded.tools.len(), 1, "the good plugin still loads");
1046                assert_eq!(loaded.warnings.len(), 1);
1047                assert!(loaded.warnings[0].contains("bad"));
1048            }
1049            other => panic!("expected Loaded, got {other:?}"),
1050        }
1051        std::fs::remove_dir_all(&dir).ok();
1052    }
1053
1054    #[test]
1055    fn register_into_is_a_true_noop_when_plugins_disabled() {
1056        let config = crate::Config::builder().model("m").build();
1057        let mut registry = crate::tools::ToolRegistry::new();
1058        register_into(&config, &mut registry);
1059        assert_eq!(registry.len(), 0);
1060    }
1061
1062    #[test]
1063    fn register_into_registers_nothing_when_untrusted() {
1064        let dir = tmp("register-untrusted");
1065        write_manifest(
1066            &dir,
1067            "demo",
1068            "name = \"demo\"\n\n[[tools]]\nname = \"t\"\ncommand = \"echo\"\n",
1069        );
1070        let config = crate::Config::builder()
1071            .model("m")
1072            .plugins_enabled(true)
1073            .plugins_dirs(vec![dir.clone()])
1074            .trust_enabled(true)
1075            .trust_default(TrustDecision::Never)
1076            .build();
1077        let mut registry = crate::tools::ToolRegistry::new();
1078        register_into(&config, &mut registry);
1079        assert_eq!(registry.len(), 0, "untrusted plugin must never register");
1080        std::fs::remove_dir_all(&dir).ok();
1081    }
1082
1083    #[test]
1084    fn register_into_registers_trusted_tools() {
1085        let dir = tmp("register-trusted");
1086        write_manifest(
1087            &dir,
1088            "demo",
1089            "name = \"demo\"\n\n[[tools]]\nname = \"t\"\ncommand = \"echo\"\n",
1090        );
1091        let config = crate::Config::builder()
1092            .model("m")
1093            .plugins_enabled(true)
1094            .plugins_dirs(vec![dir.clone()])
1095            .trust_enabled(true)
1096            .trust_default(TrustDecision::Always)
1097            .build();
1098        let mut registry = crate::tools::ToolRegistry::new();
1099        register_into(&config, &mut registry);
1100        assert_eq!(registry.len(), 1);
1101        assert!(registry.get("plugin__demo__t").is_some());
1102        std::fs::remove_dir_all(&dir).ok();
1103    }
1104
1105    // ---- PluginTool::execute: subprocess model ---------------------------
1106
1107    fn ctx(cwd: PathBuf) -> ToolContext {
1108        ToolContext::new(cwd)
1109    }
1110
1111    #[tokio::test]
1112    async fn plugin_tool_executes_and_returns_stdout() {
1113        let dir = tmp("exec-basic");
1114        let spec = PluginToolSpec {
1115            command: "echo".to_string(),
1116            args: vec!["hello-plugin".to_string()],
1117            description: String::new(),
1118            params: serde_json::json!({"type": "object"}),
1119        };
1120        let tool = PluginTool::new("demo", "say", &spec);
1121        let out = tool
1122            .execute(serde_json::json!({}), &ctx(dir.clone()))
1123            .await
1124            .unwrap();
1125        assert!(out.contains("hello-plugin"), "{out}");
1126        std::fs::remove_dir_all(&dir).ok();
1127    }
1128
1129    #[tokio::test]
1130    async fn plugin_tool_args_are_never_shell_spliced() {
1131        // A tool-call argument containing shell metacharacters and a
1132        // `touch` payload must be INERT — it travels over stdin, never
1133        // concatenated into the sh -c command string.
1134        let dir = tmp("exec-no-splice");
1135        let marker = dir.join("PWNED");
1136        let spec = PluginToolSpec {
1137            command: "cat".to_string(),
1138            args: vec![],
1139            description: String::new(),
1140            params: serde_json::json!({"type": "object"}),
1141        };
1142        let tool = PluginTool::new("demo", "cat_args", &spec);
1143        let evil = format!("$(touch {})", marker.display());
1144        let out = tool
1145            .execute(serde_json::json!({"payload": evil}), &ctx(dir.clone()))
1146            .await
1147            .unwrap();
1148        assert!(
1149            out.contains("touch"),
1150            "cat should echo the literal, unevaluated JSON back: {out}"
1151        );
1152        assert!(
1153            !marker.exists(),
1154            "shell metacharacters in tool-call args must never be evaluated"
1155        );
1156        std::fs::remove_dir_all(&dir).ok();
1157    }
1158
1159    #[tokio::test]
1160    async fn plugin_tool_bounds_output_and_marks_it_truncated() {
1161        let dir = tmp("exec-bounded");
1162        // `yes` floods stdout forever — this proves the read completes
1163        // (never hangs/OOMs) and is retained only up to the cap.
1164        let spec = PluginToolSpec {
1165            command: "sh".to_string(),
1166            args: vec![
1167                "-c".to_string(),
1168                format!(
1169                    "head -c {} /dev/zero | tr '\\0' 'a'",
1170                    PLUGIN_TOOL_MAX_OUTPUT_BYTES * 2
1171                ),
1172            ],
1173            description: String::new(),
1174            params: serde_json::json!({"type": "object"}),
1175        };
1176        let tool = PluginTool::new("demo", "flood", &spec);
1177        let out = tool
1178            .execute(serde_json::json!({}), &ctx(dir.clone()))
1179            .await
1180            .unwrap();
1181        assert!(out.contains("truncated"), "{}", &out[..out.len().min(200)]);
1182        assert!(
1183            out.len() < PLUGIN_TOOL_MAX_OUTPUT_BYTES * 2,
1184            "retained output must be bounded well below what the child wrote"
1185        );
1186        std::fs::remove_dir_all(&dir).ok();
1187    }
1188
1189    #[tokio::test]
1190    async fn plugin_tool_timeout_is_bounded_and_reported() {
1191        let dir = tmp("exec-timeout");
1192        let spec = PluginToolSpec {
1193            command: "sleep".to_string(),
1194            args: vec!["3600".to_string()],
1195            description: String::new(),
1196            params: serde_json::json!({"type": "object"}),
1197        };
1198        let tool = PluginTool::new("demo", "hang", &spec).with_timeout(Duration::from_millis(300));
1199        let started = std::time::Instant::now();
1200        let result = tokio::time::timeout(
1201            Duration::from_secs(10),
1202            tool.execute(serde_json::json!({}), &ctx(dir.clone())),
1203        )
1204        .await
1205        .expect("must not hang past the plugin tool's own timeout");
1206        assert!(result.is_err(), "a hanging plugin tool must error out");
1207        assert!(
1208            result.unwrap_err().to_string().contains("timed out"),
1209            "error should say it timed out"
1210        );
1211        assert!(
1212            started.elapsed() < Duration::from_secs(5),
1213            "took {:?}, expected to bail out near the configured timeout",
1214            started.elapsed()
1215        );
1216        std::fs::remove_dir_all(&dir).ok();
1217    }
1218
1219    /// No-orphan proof (mirrors `crate::lsp`'s P5-11 grandchild test): a
1220    /// plugin tool that spawns its own persistent worker grandchild must
1221    /// not leave it running after the tool call completes.
1222    #[cfg(unix)]
1223    #[tokio::test]
1224    async fn plugin_tool_reaps_grandchild_worker_processes() {
1225        let dir = tmp("exec-grandchild");
1226        let pidfile = dir.join("worker.pid");
1227        let script = dir.join("spawn_worker.sh");
1228        std::fs::write(
1229            &script,
1230            format!(
1231                "#!/bin/sh\nsleep 3600 &\necho $! > {}\nwait\n",
1232                pidfile.display()
1233            ),
1234        )
1235        .unwrap();
1236        let spec = PluginToolSpec {
1237            command: "sh".to_string(),
1238            args: vec![script.to_string_lossy().into_owned()],
1239            description: String::new(),
1240            params: serde_json::json!({"type": "object"}),
1241        };
1242        let tool = PluginTool::new("demo", "spawns_worker", &spec)
1243            .with_timeout(Duration::from_millis(300));
1244
1245        // Race the tool call against a short timeout via a background task
1246        // so we can inspect the grandchild pid while the parent is still
1247        // "running" (the script's own `wait` blocks until the plugin
1248        // subprocess's whole group is killed).
1249        let handle = tokio::spawn({
1250            let dir = dir.clone();
1251            async move { tool.execute(serde_json::json!({}), &ctx(dir)).await }
1252        });
1253
1254        let mut grandchild_pid: Option<i32> = None;
1255        for _ in 0..150 {
1256            if let Ok(s) = std::fs::read_to_string(&pidfile) {
1257                if let Ok(pid) = s.trim().parse::<i32>() {
1258                    grandchild_pid = Some(pid);
1259                    break;
1260                }
1261            }
1262            tokio::time::sleep(Duration::from_millis(20)).await;
1263        }
1264        let grandchild_pid = grandchild_pid.expect("worker must have recorded its pid");
1265        assert!(
1266            unsafe { libc::kill(grandchild_pid, 0) == 0 },
1267            "grandchild worker must be alive before the plugin tool call completes"
1268        );
1269
1270        // The script's own `sh` never exits on its own (it `wait`s on the
1271        // backgrounded sleep) — the ONLY thing that ends this call is our
1272        // own timeout's group-kill, which is exactly the no-orphan path
1273        // under test.
1274        let _ = tokio::time::timeout(Duration::from_secs(10), handle).await;
1275
1276        let mut still_alive = true;
1277        for _ in 0..150 {
1278            let alive = unsafe { libc::kill(grandchild_pid, 0) == 0 };
1279            if !alive {
1280                still_alive = false;
1281                break;
1282            }
1283            tokio::time::sleep(Duration::from_millis(20)).await;
1284        }
1285        assert!(
1286            !still_alive,
1287            "grandchild worker pid {grandchild_pid} must be dead — it must not orphan"
1288        );
1289        std::fs::remove_dir_all(&dir).ok();
1290    }
1291}