Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand All @@ -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.
Expand Down Expand Up @@ -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. |
Expand Down
35 changes: 33 additions & 2 deletions src/caps/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<ShellOutcome> {
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 {
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/caps/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

#[cfg(any(test, feature = "mock"))]
pub mod mock;
pub mod shell;

use std::sync::Arc;

Expand All @@ -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 {
Expand Down Expand Up @@ -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<Arc<dyn AgentRunner>>,
/// 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<Arc<dyn ShellRunner>>,
}

#[cfg(test)]
Expand Down
136 changes: 136 additions & 0 deletions src/caps/shell.rs
Original file line number Diff line number Diff line change
@@ -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<Self> {
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<String>,
/// 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<String, String>,
/// 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<ShellOutcome>;
}
62 changes: 59 additions & 3 deletions src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -162,7 +163,7 @@ pub fn all_contracts() -> Vec<NodeKindContract> {
.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<NodeKindContract> {
let c = match kind {
"trigger" => NodeKindContract {
Expand Down Expand Up @@ -328,6 +329,61 @@ pub fn contract_for(kind: &str) -> Option<NodeKindContract> {
}),
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(),
Expand Down Expand Up @@ -499,7 +555,7 @@ mod tests {
}
}
}
assert_eq!(all_contracts().len(), 12);
assert_eq!(all_contracts().len(), 13);
}

#[test]
Expand Down
4 changes: 4 additions & 0 deletions src/model/node_kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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");
Expand Down
Loading