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