diff --git a/CHANGELOG.md b/CHANGELOG.md index 4759c2e..fa94c0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -_No unreleased changes yet._ +### Added + +- A `shell` node kind that runs a shell script — inline via `config.source` or + from a file via `config.script_path` — with an optional `interpreter` + (`sh`/`bash`), `cwd`, and `env`. A non-zero exit fails the step; a successful + run emits `{ exit_code, stdout, stderr, stdout_json }`. +- A `ShellRunner` capability trait (`caps::shell`) and the optional + `Capabilities::shell` slot behind it. The engine never resolves a script path, + chooses an environment, or spawns a process: it hands the host a validated + `ShellRequest` and the host decides what is reachable. `None` refuses `shell` + nodes with a capability error. + +### Changed + +- **Breaking:** `Capabilities` gained a `shell` field. Hosts constructing the + struct literally add `shell: None` (or their own runner). ## [0.3.0] - YYYY-MM-DD diff --git a/README.md b/README.md index 054d1e9..d3beaaf 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,8 @@ Rust 2024 · MSRV 1.85 · `#![forbid(unsafe_code)]` · GPL-3.0-or-later. - Full node catalog implemented and tested — control-flow (`condition`, `switch`, `merge`, `split_out`, `transform`) and capability-backed (`agent`, - `tool_call`, `http_request`, `code`, `output_parser`, `sub_workflow`), plus the + `tool_call`, `http_request`, `code`, `shell`, `output_parser`, `sub_workflow`), + plus the `trigger` entry node. **Reliability** @@ -54,7 +55,7 @@ Rust 2024 · MSRV 1.85 · `#![forbid(unsafe_code)]` · GPL-3.0-or-later. **Extensibility** - Host-injected capability traits: `LlmProvider`, `ToolInvoker`, `HttpClient`, - `CodeRunner`, and `StateStore`. Deterministic in-memory mocks ship behind the + `CodeRunner`, `ShellRunner`, and `StateStore`. Deterministic in-memory mocks ship behind the `mock` cargo feature (`caps::mock::mock_capabilities()`). - Opaque `connection_ref` credential references — the host resolves them to real secrets; the crate never sees them. @@ -187,6 +188,7 @@ done | `tool_call` | Invokes one specific integration action deterministically (no LLM). | | `http_request` | Performs an outbound HTTP request. | | `code` | Runs sandboxed user code (JavaScript or Python). | +| `shell` | Runs a shell script, inline or from a script file, with a working directory and environment. | | `output_parser` | Parses / validates an upstream agent's output into a structured shape. | | `sub_workflow` | Runs another workflow as a nested sub-graph and returns its output. | | `condition` | Two-way IF; emits on the `true` or `false` port. | diff --git a/src/caps/mock.rs b/src/caps/mock.rs index 34abec7..f5b488e 100644 --- a/src/caps/mock.rs +++ b/src/caps/mock.rs @@ -10,8 +10,8 @@ use async_trait::async_trait; use serde_json::{Value, json}; use crate::caps::{ - AgentRunner, Capabilities, CodeLanguage, CodeRunner, HttpClient, LlmProvider, StateStore, - ToolInvoker, WorkflowResolver, + AgentRunner, Capabilities, CodeLanguage, CodeRunner, HttpClient, LlmProvider, ShellOutcome, + ShellRequest, ShellRunner, ShellScript, StateStore, ToolInvoker, WorkflowResolver, }; use crate::error::{EngineError, Result}; use crate::model::WorkflowGraph; @@ -77,6 +77,36 @@ impl CodeRunner for MockCode { } } +/// A [`ShellRunner`] that never spawns anything and echoes the request instead. +/// +/// Always succeeds. Standard output is the script text (inline source, or the +/// path as written), so a test can assert which script a `shell` node asked +/// for; standard error carries the interpreter, working directory, and +/// environment. A test that needs a failing script supplies its own runner — +/// baking a magic "fail" script into the mock would make an author's real +/// script mean something different during a dry run. +#[derive(Debug, Default, Clone)] +pub struct MockShell; + +#[async_trait] +impl ShellRunner for MockShell { + async fn run(&self, request: ShellRequest) -> Result { + Ok(ShellOutcome { + exit_code: 0, + stdout: match &request.script { + ShellScript::Inline(source) => source.clone(), + ShellScript::Path(path) => path.clone(), + }, + stderr: format!( + "{} cwd={} env={}", + request.interpreter.as_str(), + request.cwd.unwrap_or_default(), + json!(request.env), + ), + }) + } +} + /// A [`StateStore`] backed by an in-memory map guarded by a mutex. #[derive(Debug, Default)] pub struct MockStateStore { @@ -144,6 +174,7 @@ pub fn mock_capabilities_with_resolver(resolver: impl WorkflowResolver + 'static tools: Arc::new(MockTools), http: Arc::new(MockHttp), code: Arc::new(MockCode), + shell: Some(Arc::new(MockShell)), state: Arc::new(MockStateStore::default()), resolver: Arc::new(resolver), // No agent registry by default: `agent` nodes use `MockLlm`. Use diff --git a/src/caps/mod.rs b/src/caps/mod.rs index 0a0d7b0..9148825 100644 --- a/src/caps/mod.rs +++ b/src/caps/mod.rs @@ -7,6 +7,7 @@ #[cfg(any(test, feature = "mock"))] pub mod mock; +pub mod shell; use std::sync::Arc; @@ -15,6 +16,8 @@ use serde_json::Value; use crate::error::Result; +pub use self::shell::{ShellInterpreter, ShellOutcome, ShellRequest, ShellRunner, ShellScript}; + /// A chat / LLM provider used by `agent` and `output_parser` nodes. #[async_trait] pub trait LlmProvider: Send + Sync { @@ -156,6 +159,10 @@ pub struct Capabilities { /// [`LlmProvider`] completion. `None` on hosts without an agent registry, in /// which case `agent` nodes always use [`LlmProvider`]. pub agent: Option>, + /// Optional runner for `shell` nodes. `None` on hosts that do not permit + /// workflows to execute shell scripts, in which case a `shell` node fails + /// with a capability error rather than silently doing nothing. + pub shell: Option>, } #[cfg(test)] diff --git a/src/caps/shell.rs b/src/caps/shell.rs new file mode 100644 index 0000000..3d2fb17 --- /dev/null +++ b/src/caps/shell.rs @@ -0,0 +1,136 @@ +//! The `shell` node's host capability: running a shell script out of process. +//! +//! Shell execution is deliberately *not* folded into [`CodeRunner`] — a shell +//! step needs a working directory, an environment, and the process's exit +//! status and streams, none of which the code capability's +//! `(language, source, input) -> Value` shape can carry. +//! +//! The engine stays host-agnostic here in the strongest sense: it never touches +//! the filesystem or spawns anything. It parses a node's config into a +//! [`ShellRequest`] and hands it to the host, which decides which paths are +//! reachable, which environment a script inherits, and whether shell steps are +//! permitted at all. Path and working-directory strings in a request are +//! **untrusted authoring input**; a host must validate them before use. +//! +//! [`CodeRunner`]: crate::caps::CodeRunner + +use std::collections::BTreeMap; + +use async_trait::async_trait; +use serde_json::Value; + +use crate::error::Result; + +/// The shell runtime that executes a `shell` node's script. +/// +/// Closed on purpose: an author cannot name an arbitrary interpreter binary, +/// because that would turn `config.interpreter` into a second way to choose +/// what program the host runs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ShellInterpreter { + /// POSIX `sh` — the default, and the portable choice. + #[default] + Sh, + /// GNU Bash, for scripts that need `pipefail`, arrays, or `[[ … ]]`. + Bash, +} + +impl ShellInterpreter { + /// The wire name an author writes in `config.interpreter`. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Sh => "sh", + Self::Bash => "bash", + } + } + + /// Parses a wire name, returning `None` for anything not advertised. + #[must_use] + pub fn parse(name: &str) -> Option { + match name { + "sh" => Some(Self::Sh), + "bash" => Some(Self::Bash), + _ => None, + } + } +} + +/// Where the script to run comes from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ShellScript { + /// A script written inline in the workflow document, so a reviewer reading + /// the workflow sees exactly what will execute. + Inline(String), + /// A path to an external script file, as written by the workflow author. + /// + /// This is untrusted configuration: the engine passes the string through + /// verbatim and the host is responsible for resolving it against whatever + /// root it permits, and for rejecting traversal outside that root. + Path(String), +} + +/// One `shell` node's execution request. +#[derive(Debug, Clone)] +pub struct ShellRequest { + /// The shell runtime to execute with. + pub interpreter: ShellInterpreter, + /// The script itself, inline or by path. + pub script: ShellScript, + /// The working directory the author asked for, if any. + /// + /// Untrusted configuration, exactly like [`ShellScript::Path`]. `None` + /// leaves the choice to the host. + pub cwd: Option, + /// Environment variables the author declared, sorted by name so a request + /// is reproducible. + /// + /// Whether these are added to an inherited environment or are the entire + /// environment is the host's decision, not the engine's. + pub env: BTreeMap, + /// The node's input items, serialized as JSON for the script to read. + pub input: Value, +} + +/// What running a script produced. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShellOutcome { + /// The process exit status. Hosts report a negative value when a process + /// was terminated by a signal rather than exiting normally. + pub exit_code: i32, + /// Everything the script wrote to standard output, lossily decoded as + /// UTF-8. + pub stdout: String, + /// Everything the script wrote to standard error, lossily decoded as + /// UTF-8. + pub stderr: String, +} + +impl ShellOutcome { + /// Whether the process exited successfully. + #[must_use] + pub fn is_success(&self) -> bool { + self.exit_code == 0 + } +} + +/// Runs a shell script for a `shell` node. +/// +/// A host that does not want workflows executing shell scripts leaves +/// [`Capabilities::shell`](crate::caps::Capabilities::shell) as `None`, and the +/// `shell` node fails with a capability error naming the missing capability. +#[async_trait] +pub trait ShellRunner: Send + Sync { + /// Executes `request`, returning the process outcome. + /// + /// A non-zero exit is *not* an error here — it is reported through + /// [`ShellOutcome::exit_code`] so the host's own failures (an unreachable + /// path, a rejected environment name, a timeout) stay distinguishable from + /// a script that ran and failed. The `shell` node is what turns a non-zero + /// exit into a failed step. + /// + /// # Errors + /// Returns an [`EngineError::Capability`](crate::error::EngineError::Capability) + /// when the host refuses the request or cannot run it at all. + async fn run(&self, request: ShellRequest) -> Result; +} diff --git a/src/catalog.rs b/src/catalog.rs index 39eb459..ae4667c 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -22,12 +22,13 @@ use serde_json::{Value, json}; /// The node kinds, in the canonical order used wherever the DSL is enumerated /// (matches [`NodeKind`](crate::model::NodeKind)'s serde discriminators). -pub const NODE_KINDS: [&str; 12] = [ +pub const NODE_KINDS: [&str; 13] = [ "trigger", "agent", "tool_call", "http_request", "code", + "shell", "condition", "switch", "merge", @@ -162,7 +163,7 @@ pub fn all_contracts() -> Vec { .collect() } -/// The contract for one node kind, or `None` if `kind` is not one of the 12. +/// The contract for one node kind, or `None` if `kind` is not catalogued. pub fn contract_for(kind: &str) -> Option { let c = match kind { "trigger" => NodeKindContract { @@ -328,6 +329,61 @@ pub fn contract_for(kind: &str) -> Option { }), notes: vec![], }, + "shell" => NodeKindContract { + kind: "shell".to_string(), + summary: "Run a shell script — inline, or an external script file.".to_string(), + description: "Runs config.source (an inline script) or config.script_path (a script \ + file) through the host's ShellRunner capability, with an optional working \ + directory and environment. The script reads the node's input items as JSON from \ + the file named by its first argument. A non-zero exit fails the step; a \ + successful run emits one item of { exit_code, stdout, stderr, stdout_json }, \ + where stdout_json is the parsed stdout when it was JSON and null otherwise. \ + Whether shell steps run at all, which paths config.script_path and config.cwd may \ + reach, and what environment a script inherits are the host's decisions." + .to_string(), + config_fields: vec![ + ConfigField::optional( + "source", + "string", + "An inline script. Required unless script_path is set; the two are mutually exclusive.", + ), + ConfigField::optional( + "script_path", + "string", + "A path to an external script file, resolved and access-checked by the host. Required unless source is set.", + ), + ConfigField::optional("interpreter", "enum", "The shell runtime; defaults to sh.") + .with_enum(&["sh", "bash"]), + ConfigField::optional( + "cwd", + "string", + "The working directory to run in, subject to the host's own path policy.", + ), + ConfigField::optional( + "env", + "object", + "Environment variables as a flat name/value map of strings.", + ), + ], + ports: PortSpec::linear(), + example: json!({ + "id": "build", "kind": "shell", "name": "Build", + "config": { + "interpreter": "bash", + "cwd": "/srv/project", + "env": { "PROFILE": "release" }, + "source": "set -euo pipefail\ncargo build --release\nprintf '{\"built\":true}'" + } + }), + notes: vec![ + "The first argument to the script is a JSON file holding the node's input items." + .to_string(), + "A non-zero exit status fails the step; stderr is quoted in the error.".to_string(), + "Prefer config.source: an inline script stays reviewable with the workflow, where \ + a script_path is only as trustworthy as the file it names." + .to_string(), + ], + }, "condition" => NodeKindContract { kind: "condition".to_string(), summary: "A boolean gate that routes to the `true` or `false` port.".to_string(), @@ -499,7 +555,7 @@ mod tests { } } } - assert_eq!(all_contracts().len(), 12); + assert_eq!(all_contracts().len(), 13); } #[test] diff --git a/src/model/node_kind.rs b/src/model/node_kind.rs index aca26ce..0b1d186 100644 --- a/src/model/node_kind.rs +++ b/src/model/node_kind.rs @@ -30,6 +30,9 @@ pub enum NodeKind { HttpRequest, /// Executes sandboxed user code (JavaScript or Python). Code, + /// Executes an inline POSIX-compatible shell script through the host's + /// explicitly configured code capability. + Shell, /// Two-way conditional branch, emitting on the `true` or `false` port. Condition, /// Multi-way branch keyed by an expression result. @@ -92,6 +95,7 @@ mod tests { assert_wire(&NodeKind::ToolCall, "tool_call"); assert_wire(&NodeKind::HttpRequest, "http_request"); assert_wire(&NodeKind::Code, "code"); + assert_wire(&NodeKind::Shell, "shell"); assert_wire(&NodeKind::Condition, "condition"); assert_wire(&NodeKind::Switch, "switch"); assert_wire(&NodeKind::Merge, "merge"); diff --git a/src/nodes/integration/mod.rs b/src/nodes/integration/mod.rs index 38ce963..08084ba 100644 --- a/src/nodes/integration/mod.rs +++ b/src/nodes/integration/mod.rs @@ -1,5 +1,5 @@ //! Capability-backed node executors: `agent`, `tool_call`, `http_request`, -//! `code`, `output_parser`, and `sub_workflow`. These reach the outside world +//! `code`, `shell`, `output_parser`, and `sub_workflow`. These reach the outside world //! through the host capabilities in [`crate::caps`]. //! //! One module per node kind so parallel work can edit them without conflicts. @@ -10,6 +10,7 @@ pub(crate) mod envelope; pub mod http_request; pub mod output_parser; pub(crate) mod schema; +pub mod shell; pub mod sub_workflow; pub mod tool_call; @@ -17,5 +18,10 @@ pub use agent::AgentNode; pub use code::CodeNode; pub use http_request::HttpRequestNode; pub use output_parser::OutputParserNode; +pub use shell::ShellNode; pub use sub_workflow::SubWorkflowNode; pub use tool_call::ToolCallNode; + +#[cfg(test)] +#[path = "shell_tests.rs"] +mod shell_tests; diff --git a/src/nodes/integration/shell.rs b/src/nodes/integration/shell.rs new file mode 100644 index 0000000..a83562b --- /dev/null +++ b/src/nodes/integration/shell.rs @@ -0,0 +1,188 @@ +//! The `shell` node: shell scripts delegated to the host's shell capability. +//! +//! This module only *parses and validates* a node's config into a +//! [`ShellRequest`]. Everything with an effect — resolving a script path, +//! choosing an environment, spawning a process — belongs to the host behind +//! [`ShellRunner`](crate::caps::ShellRunner). + +use std::collections::BTreeMap; + +use async_trait::async_trait; +use serde_json::Value; + +use crate::caps::{ShellInterpreter, ShellRequest, ShellScript}; +use crate::data::Item; +use crate::error::{EngineError, Result}; +use crate::nodes::{NodeContext, NodeExecutor, NodeOutput}; + +/// Executes a shell script via [`crate::caps::ShellRunner`]. +#[derive(Debug, Default, Clone)] +pub struct ShellNode; + +/// Reads `config.interpreter`, defaulting to `sh`. +fn interpreter_of(config: &Value) -> Result { + let Some(named) = config.get("interpreter") else { + return Ok(ShellInterpreter::default()); + }; + let named = named.as_str().ok_or_else(|| { + EngineError::Capability("shell: config.interpreter must be a string".to_string()) + })?; + ShellInterpreter::parse(named).ok_or_else(|| { + EngineError::Capability(format!( + "shell: unsupported interpreter '{named}'; expected 'sh' or 'bash'" + )) + }) +} + +/// Reads exactly one of `config.source` (inline) or `config.script_path`. +/// +/// Requiring exactly one keeps the node unambiguous: a config carrying both +/// would otherwise silently run whichever the implementation happened to check +/// first, which is precisely the kind of thing a reviewer misses. +fn script_of(config: &Value) -> Result { + let source = config.get("source"); + let path = config.get("script_path"); + + match (source, path) { + (Some(_), Some(_)) => Err(EngineError::Capability( + "shell: set config.source or config.script_path, not both".to_string(), + )), + (Some(source), None) => { + let source = source.as_str().ok_or_else(|| { + EngineError::Capability("shell: config.source must be a string".to_string()) + })?; + if source.trim().is_empty() { + return Err(EngineError::Capability( + "shell: config.source must be a non-empty script".to_string(), + )); + } + Ok(ShellScript::Inline(source.to_string())) + } + (None, Some(path)) => { + let path = path.as_str().ok_or_else(|| { + EngineError::Capability("shell: config.script_path must be a string".to_string()) + })?; + if path.trim().is_empty() { + return Err(EngineError::Capability( + "shell: config.script_path must be a non-empty path".to_string(), + )); + } + Ok(ShellScript::Path(path.to_string())) + } + (None, None) => Err(EngineError::Capability( + "shell: config.source (inline script) or config.script_path (script file) is required" + .to_string(), + )), + } +} + +/// Reads `config.cwd`, rejecting a non-string or blank value. +fn cwd_of(config: &Value) -> Result> { + let Some(cwd) = config.get("cwd") else { + return Ok(None); + }; + let cwd = cwd + .as_str() + .ok_or_else(|| EngineError::Capability("shell: config.cwd must be a string".to_string()))?; + if cwd.trim().is_empty() { + return Err(EngineError::Capability( + "shell: config.cwd must be a non-empty path when present".to_string(), + )); + } + Ok(Some(cwd.to_string())) +} + +/// Reads `config.env` as a flat string map. +/// +/// Only strings are accepted: coercing a number or boolean would make the value +/// a script actually sees depend on JSON formatting rather than on what the +/// author wrote. +fn env_of(config: &Value) -> Result> { + let Some(env) = config.get("env") else { + return Ok(BTreeMap::new()); + }; + let env = env.as_object().ok_or_else(|| { + EngineError::Capability("shell: config.env must be an object of strings".to_string()) + })?; + env.iter() + .map(|(name, value)| { + let value = value.as_str().ok_or_else(|| { + EngineError::Capability(format!( + "shell: config.env.{name} must be a string, not {}", + kind_of(value) + )) + })?; + Ok((name.clone(), value.to_string())) + }) + .collect() +} + +/// A short name for a JSON value's type, for error messages. +fn kind_of(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "a boolean", + Value::Number(_) => "a number", + Value::String(_) => "a string", + Value::Array(_) => "an array", + Value::Object(_) => "an object", + } +} + +/// The last `limit` characters of `text`, marked when anything was dropped. +fn tail(text: &str, limit: usize) -> String { + let text = text.trim(); + let length = text.chars().count(); + if length <= limit { + return text.to_string(); + } + let kept: String = text.chars().skip(length - limit).collect(); + format!("…{kept}") +} + +#[async_trait] +impl NodeExecutor for ShellNode { + async fn execute(&self, ctx: NodeContext<'_>) -> Result { + let config = &ctx.node.config; + let runner = ctx.caps.shell.as_ref().ok_or_else(|| { + EngineError::Capability( + "shell: this host has no shell capability, so `shell` nodes cannot run".to_string(), + ) + })?; + + let request = ShellRequest { + interpreter: interpreter_of(config)?, + script: script_of(config)?, + cwd: cwd_of(config)?, + env: env_of(config)?, + input: serde_json::to_value(ctx.input) + .map_err(|err| EngineError::Capability(err.to_string()))?, + }; + + let outcome = runner.run(request).await?; + if !outcome.is_success() { + // A failed step emits no items, so the streams go into the message: + // a tail of stderr is what makes the failure diagnosable from a run + // record alone. + return Err(EngineError::Capability(format!( + "shell: script exited with status {}: {}", + outcome.exit_code, + tail(&outcome.stderr, STDERR_TAIL_LIMIT) + ))); + } + + // Structured output when the script printed JSON, alongside the raw + // streams — a step that pipes text and a step that emits JSON are both + // ordinary uses, so neither is made to look like the exception. + let stdout_json: Value = serde_json::from_str(outcome.stdout.trim()).unwrap_or(Value::Null); + Ok(NodeOutput::main(vec![Item::new(serde_json::json!({ + "exit_code": outcome.exit_code, + "stdout": outcome.stdout, + "stderr": outcome.stderr, + "stdout_json": stdout_json, + }))])) + } +} + +/// How much of a failed script's standard error is quoted in the step error. +const STDERR_TAIL_LIMIT: usize = 2000; diff --git a/src/nodes/integration/shell_tests.rs b/src/nodes/integration/shell_tests.rs new file mode 100644 index 0000000..5bb719a --- /dev/null +++ b/src/nodes/integration/shell_tests.rs @@ -0,0 +1,233 @@ +//! Tests for `shell`-node config validation and capability delegation. + +use serde_json::{Value, json}; + +use super::ShellNode; +use crate::caps::mock::mock_capabilities; +use crate::caps::{Capabilities, ShellOutcome, ShellRequest, ShellRunner, ShellScript}; +use crate::data::Item; +use crate::error::Result; +use crate::model::{Node, NodeKind}; +use crate::nodes::{NodeContext, NodeExecutor, NodeOutput}; + +fn shell_node(config: Value) -> Node { + Node { + id: "shell".into(), + kind: NodeKind::Shell, + type_version: 1, + name: "Shell".into(), + config, + ports: vec![], + position: None, + } +} + +async fn execute_with(caps: Capabilities, config: Value) -> Result { + let node = shell_node(config); + ShellNode + .execute(NodeContext { + node: &node, + input: &[Item::new(json!({ "seed": 1 }))], + run: &Value::Null, + nodes: &Value::Null, + caps: &caps, + }) + .await +} + +async fn execute(config: Value) -> Result { + execute_with(mock_capabilities(), config).await +} + +#[tokio::test] +async fn inline_source_runs_and_surfaces_both_streams() { + let output = execute(json!({ "source": "printf ok" })) + .await + .expect("an inline script is runnable"); + let item = &output.items[0].json; + assert_eq!(item["exit_code"], 0); + assert_eq!(item["stdout"], "printf ok"); + assert!(item["stderr"].as_str().expect("stderr").contains("sh cwd=")); + assert!(item["stdout_json"].is_null()); +} + +#[tokio::test] +async fn json_on_stdout_is_parsed_alongside_the_raw_text() { + let output = execute(json!({ "source": "{\"built\":true}" })) + .await + .expect("a JSON-emitting script is runnable"); + let item = &output.items[0].json; + assert_eq!(item["stdout_json"], json!({ "built": true })); + assert_eq!(item["stdout"], "{\"built\":true}"); +} + +/// A runner that reports the exit code and stderr it was constructed with. +struct FailingShell { + exit_code: i32, + stderr: String, +} + +#[async_trait::async_trait] +impl ShellRunner for FailingShell { + async fn run(&self, request: ShellRequest) -> Result { + assert!(matches!(request.script, ShellScript::Inline(_))); + Ok(ShellOutcome { + exit_code: self.exit_code, + stdout: String::new(), + stderr: self.stderr.clone(), + }) + } +} + +/// Capability bundle whose shell runner is `runner`. +fn caps_with(runner: impl ShellRunner + 'static) -> Capabilities { + Capabilities { + shell: Some(std::sync::Arc::new(runner)), + ..mock_capabilities() + } +} + +#[tokio::test] +async fn a_non_zero_exit_fails_the_step() { + let caps = caps_with(FailingShell { + exit_code: 3, + stderr: "no such target".to_string(), + }); + let error = execute_with(caps, json!({ "source": "make all" })) + .await + .expect_err("a failing script must fail its step"); + let message = error.to_string(); + assert!(message.contains("exited with status 3"), "{message}"); + assert!(message.contains("no such target"), "{message}"); +} + +#[tokio::test] +async fn a_script_path_reaches_the_host_verbatim_for_validation() { + let output = execute(json!({ "script_path": "scripts/build.sh" })) + .await + .expect("a path script is runnable"); + // The engine never resolves the path itself; the host sees what was written. + assert_eq!(output.items[0].json["stdout"], "scripts/build.sh"); +} + +#[tokio::test] +async fn interpreter_cwd_and_env_are_forwarded() { + let output = execute(json!({ + "source": "printf ok", + "interpreter": "bash", + "cwd": "/srv/build", + "env": { "PROFILE": "release" }, + })) + .await + .expect("a fully configured script is runnable"); + let stderr = output.items[0].json["stderr"] + .as_str() + .expect("stderr") + .to_string(); + assert!(stderr.contains("bash"), "interpreter missing: {stderr}"); + assert!(stderr.contains("cwd=/srv/build"), "cwd missing: {stderr}"); + assert!(stderr.contains("\"PROFILE\":\"release\""), "env: {stderr}"); +} + +#[tokio::test] +async fn a_missing_script_is_rejected() { + let error = execute(json!({ "interpreter": "sh" })) + .await + .expect_err("a node with no script must not run"); + assert!(error.to_string().contains("is required")); +} + +#[tokio::test] +async fn declaring_both_a_script_and_a_path_is_rejected() { + let error = execute(json!({ "source": "printf ok", "script_path": "a.sh" })) + .await + .expect_err("an ambiguous node must not run"); + assert!(error.to_string().contains("not both")); +} + +#[tokio::test] +async fn an_empty_script_is_rejected() { + let error = execute(json!({ "source": " " })) + .await + .expect_err("an empty script must not run"); + assert!(error.to_string().contains("non-empty script")); + + let error = execute(json!({ "script_path": " " })) + .await + .expect_err("an empty path must not run"); + assert!(error.to_string().contains("non-empty path")); +} + +#[tokio::test] +async fn non_string_config_values_are_rejected() { + for (config, needle) in [ + (json!({ "source": 1 }), "config.source must be a string"), + ( + json!({ "script_path": 1 }), + "config.script_path must be a string", + ), + ( + json!({ "source": "x", "interpreter": 1 }), + "config.interpreter must be a string", + ), + ( + json!({ "source": "x", "cwd": 1 }), + "config.cwd must be a string", + ), + ( + json!({ "source": "x", "cwd": " " }), + "config.cwd must be a non-empty path", + ), + ( + json!({ "source": "x", "env": [] }), + "config.env must be an object", + ), + ( + json!({ "source": "x", "env": { "N": 1 } }), + "config.env.N must be a string, not a number", + ), + ] { + let error = execute(config.clone()) + .await + .expect_err("malformed config must not run"); + assert!( + error.to_string().contains(needle), + "{config} produced {error}" + ); + } +} + +#[tokio::test] +async fn an_unknown_interpreter_is_rejected() { + let error = execute(json!({ "source": "printf ok", "interpreter": "zsh" })) + .await + .expect_err("an unadvertised interpreter must not run"); + assert!(error.to_string().contains("expected 'sh' or 'bash'")); +} + +#[tokio::test] +async fn a_host_without_the_capability_says_so() { + let caps = Capabilities { + shell: None, + ..mock_capabilities() + }; + let error = execute_with(caps, json!({ "source": "printf ok" })) + .await + .expect_err("a host without the capability must refuse"); + assert!(error.to_string().contains("no shell capability")); +} + +#[tokio::test] +async fn a_long_stderr_is_truncated_to_its_tail() { + let caps = caps_with(FailingShell { + exit_code: 1, + stderr: format!("{}tail-marker", "x".repeat(4000)), + }); + let error = execute_with(caps, json!({ "source": "noisy" })) + .await + .expect_err("a failing script must fail its step"); + let message = error.to_string(); + assert!(message.contains('…'), "not truncated: {message}"); + // The tail is what survives, because the last lines explain the failure. + assert!(message.ends_with("tail-marker"), "wrong end: {message}"); +} diff --git a/src/nodes/mod.rs b/src/nodes/mod.rs index e086d25..63ef28a 100644 --- a/src/nodes/mod.rs +++ b/src/nodes/mod.rs @@ -267,6 +267,7 @@ pub(crate) fn executor_for(kind: &NodeKind) -> Box { NodeKind::ToolCall => Box::new(integration::ToolCallNode), NodeKind::HttpRequest => Box::new(integration::HttpRequestNode), NodeKind::Code => Box::new(integration::CodeNode), + NodeKind::Shell => Box::new(integration::ShellNode), NodeKind::OutputParser => Box::new(integration::OutputParserNode), NodeKind::SubWorkflow => Box::new(integration::SubWorkflowNode), NodeKind::Condition => Box::new(control_flow::ConditionNode), @@ -288,7 +289,7 @@ mod tests { /// Every [`NodeKind`] variant, so the coverage below stays exhaustive. fn all_kinds() -> Vec { use NodeKind::{ - Agent, Code, Condition, HttpRequest, Merge, OutputParser, SplitOut, SubWorkflow, + Agent, Code, Condition, HttpRequest, Merge, OutputParser, Shell, SplitOut, SubWorkflow, Switch, ToolCall, Transform, Trigger, }; vec![ @@ -297,6 +298,7 @@ mod tests { ToolCall, HttpRequest, Code, + Shell, Condition, Switch, Merge, @@ -311,6 +313,7 @@ mod tests { fn config_for(kind: &NodeKind) -> Value { match kind { NodeKind::ToolCall => json!({ "slug": "demo" }), + NodeKind::Shell => json!({ "source": "printf ok" }), NodeKind::SubWorkflow => json!({ "workflow": { "nodes": [{ "id": "ct", "kind": "trigger", "name": "ct" }], "edges": [] } }), diff --git a/wiki/Capability-Traits.md b/wiki/Capability-Traits.md index 4299e2d..e284fd6 100644 --- a/wiki/Capability-Traits.md +++ b/wiki/Capability-Traits.md @@ -19,6 +19,7 @@ examples without any real backend. | `ToolInvoker` | `tool_call` | Invokes a named integration action (`slug` + `args`), returning its output. | | `HttpClient` | `http_request` | Issues an outbound HTTP request described by JSON, returning the response. | | `CodeRunner` | `code` | Executes sandboxed user code (`CodeLanguage::JavaScript` / `Python`) with a JSON input. | +| `ShellRunner` | `shell` | Runs a shell script (inline or by path) with a working directory and environment, returning its exit code, stdout, and stderr. Optional: `None` refuses `shell` nodes. | | `StateStore` | resumable / stateful workflows | Durable key/value state (`load` / `store`) for a run. | ## Connection references diff --git a/wiki/Home.md b/wiki/Home.md index 37f1b70..ce8a92a 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -19,7 +19,7 @@ Rust 2024 · MSRV 1.85 · `#![forbid(unsafe_code)]` · GPL-3.0-or-later. behind the `mock` feature for tests and examples. - **11 node kinds + a trigger.** Native control flow (`condition`, `switch`, `merge`, `split_out`, `transform`) and capability-backed effects (`agent`, - `tool_call`, `http_request`, `code`, `output_parser`, `sub_workflow`). + `tool_call`, `http_request`, `code`, `shell`, `output_parser`, `sub_workflow`). - **Real routing.** Linear paths, conditional branching, parallel fan-out, and a fan-in merge barrier. - **Item-based data flow.** State is a `serde_json::Value` laid out as diff --git a/wiki/Node-Catalog.md b/wiki/Node-Catalog.md index cbe8a36..1fe6622 100644 --- a/wiki/Node-Catalog.md +++ b/wiki/Node-Catalog.md @@ -38,6 +38,7 @@ traits](Capability-Traits). | `tool_call` | Invokes one specific integration action | Config `slug`, `args` — via `ToolInvoker` | | `http_request` | Outbound HTTP request | Config `method`, `url`, `headers`, `query`, `body` — via `HttpClient` | | `code` | Runs sandboxed user code | Config `language` (`javascript`/`python`), `source` — via `CodeRunner` | +| `shell` | Runs a shell script, inline or from a file | Config `source` **or** `script_path`, plus `interpreter` (`sh`/`bash`), `cwd`, `env` — via `ShellRunner` | | `output_parser` | Parses/validates an agent's output into a structured shape | May use `LlmProvider` for auto-fixing; can nest as a sub-agent | | `sub_workflow` | Runs another workflow as a nested sub-graph | Config `workflow_id`, `input` mapping | @@ -47,7 +48,7 @@ scope before use, so their parameters can data-bind directly from upstream outpu (e.g. `args: { "channel": "=item.channel" }`). Non-`=` values pass through as literals. -All 11 node kinds plus the trigger are implemented and dispatched by the engine. +All 12 node kinds plus the trigger are implemented and dispatched by the engine. Per-node error handling (`on_error` stop/continue/route, `retry`, an `error` port) and approval gating (`requires_approval`) are configured through the same free-form `config`.