Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Script spawner seam (`host-caps`)

`caps::host::script` now launches every `code`/`shell` script through a
`ScriptSpawner`. `install_script_spawner` lets a host route scripts through its
jail; `require_script_spawner(true)` makes a run with no spawner fail closed.
The default `DirectSpawner` keeps the previous unjailed behaviour.

### Storage moves entirely onto the `tinystoragedrivers` ports

tinyflows no longer ships any storage backend of its own. Every durable piece is
Expand Down
6 changes: 4 additions & 2 deletions crates/tinyflows/src/caps/host/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,10 @@ pub use self::http::{
};
pub use self::mocks::{SchemaAwareMockAgentRunner, SchemaAwareMockLlm, sample_for_schema};
pub use self::script::{
DEFAULT_SHELL, INPUT_ENV, Interpreter, ScriptCompletion, ScriptLanguage, ScriptOutput,
ScriptRequest, ScriptSource, USER_SHELL, run_script, run_script_capture,
DEFAULT_SHELL, DirectSpawner, INPUT_ENV, Interpreter, ScriptCompletion, ScriptLanguage,
ScriptOutput, ScriptRequest, ScriptSource, ScriptSpawner, SpawnOutput, SpawnSpec, USER_SHELL,
clear_script_spawner, install_script_spawner, require_script_spawner, run_script,
run_script_capture,
};
pub use self::script_policy::{ScriptPolicy, is_valid_env_name, read_env};
pub use self::shell::ProcessShellRunner;
8 changes: 7 additions & 1 deletion crates/tinyflows/src/caps/host/script.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
//!
//! # What this is not
//!
//! Not a sandbox. The child inherits this process's environment and privileges,
//! Not a sandbox by itself. A host installs a [`ScriptSpawner`] to run scripts
//! inside its own jail; see [`install_script_spawner`]. By default the child inherits this process's environment and privileges,
//! and the only boundary is a temporary working directory, which is not one.
//! What *is* checked, at the boundary above this one, is where a script may come
//! from and where it may run: see [`super::script_policy`]. A host decides
Expand Down Expand Up @@ -344,7 +345,12 @@ pub struct ScriptCompletion {
}

mod runner;
mod spawner;
pub use runner::{run_script, run_script_capture};
pub use spawner::{
DirectSpawner, ScriptSpawner, SpawnOutput, SpawnSpec, clear_script_spawner,
install_script_spawner, require_script_spawner,
};

#[cfg(test)]
#[path = "script_tests.rs"]
Expand Down
84 changes: 26 additions & 58 deletions crates/tinyflows/src/caps/host/script/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,6 @@ pub async fn run_script(request: ScriptRequest<'_>) -> Result<ScriptOutput> {
/// on disk, or the script outlives `request.timeout` — that is, when there is no
/// exit status to report at all.
pub async fn run_script_capture(request: ScriptRequest<'_>) -> Result<ScriptCompletion> {
use tokio::io::AsyncWriteExt;

let ScriptRequest {
language,
interpreter,
Expand Down Expand Up @@ -115,67 +113,37 @@ pub async fn run_script_capture(request: ScriptRequest<'_>) -> Result<ScriptComp
std::fs::write(&input_path, &body)
.map_err(|err| EngineError::Capability(format!("script: {err}")))?;

let mut command = tokio::process::Command::new(program);
command
.args(leading_args)
.arg(&script)
.arg(&input_path)
.env(INPUT_ENV, &input_path)
// Layered after `INPUT_ENV` so a workflow's own declaration wins over
// the inherited value of the same name — that is what declaring one is
// for. A host that wants the path under a second name of its own (an
// older, product-specific spelling its authored workflows already use)
// puts it here.
.envs(env)
.current_dir(cwd.unwrap_or_else(|| dir.path()))
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
// Tokio does not kill a child when its future is dropped, so a timeout
// would otherwise leave an infinite script running forever with nothing
// holding a handle to it.
.kill_on_drop(true);

let mut child = command.spawn().map_err(|err| {
EngineError::Capability(format!(
"script: cannot run `{program}` ({err}). Is it installed and on PATH?"
))
})?;

// Writing stdin and draining stdout/stderr happen concurrently, not one
// after the other: a script that prints before it finishes reading stdin
// fills its stdout pipe while this side is still blocked in `write_all` on
// stdin, and neither side would ever unblock the other — a real deadlock,
// not just a slow path, for any input near the OS pipe buffer size. The
// writer runs on its own task so `wait_with_output` starts reading
// immediately; if the child exits without reading all of stdin, the pipe
// simply closes underneath the writer, which surfaces as a write error we
// ignore (the exit status and stderr are the story in that case, not this).
let mut stdin = child.stdin.take();
let writer = tokio::spawn(async move {
if let Some(mut stdin) = stdin.take() {
let _ = stdin.write_all(&body).await;
let _ = stdin.shutdown().await;
}
});
let output = tokio::time::timeout(timeout, async {
let output = child.wait_with_output().await;
// Joined so a slow writer is still bounded by `timeout` above, not left
// running past the point this function returns.
let _ = writer.await;
output
})
.await
.map_err(|_| {
EngineError::Capability(format!("script: timed out after {}s", timeout.as_secs()))
})?
.map_err(|err| EngineError::Capability(format!("script: {program}: {err}")))?;
// Resolved before anything runs so a fail-closed host refuses here.
let spawner = spawner::current_spawner()?;
let mut args: Vec<String> = leading_args.to_vec();
args.push(script.to_string_lossy().into_owned());
args.push(input_path.to_string_lossy().into_owned());
Comment on lines +118 to +120

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve non-UTF-8 paths passed to the spawner

When ScriptSource::File points to a valid Unix path containing non-UTF-8 bytes, to_string_lossy() substitutes replacement characters before either a custom spawner or DirectSpawner sees it, turning a script that the previous .arg(&script) implementation could execute into a nonexistent path. Carry argument paths as OsString or PathBuf and only render them lossily for diagnostics.

Useful? React with 👍 / 👎.

// Layered after `INPUT_ENV` so a workflow's own declaration wins over the
// inherited value of the same name.
let mut spawn_env = BTreeMap::new();
spawn_env.insert(
INPUT_ENV.to_string(),
input_path.to_string_lossy().into_owned(),
);
spawn_env.extend(env.iter().map(|(k, v)| (k.clone(), v.clone())));
tracing::debug!(program, "script: spawning");
let output = spawner
.spawn(spawner::SpawnSpec {
program: program.to_string(),
args,
env: spawn_env,
cwd: cwd.map_or_else(|| dir.path().to_path_buf(), Path::to_path_buf),
staging_dir: dir.path().to_path_buf(),
stdin: body,
timeout,
})
.await?;

Ok(ScriptCompletion {
// `-1` for a signal: `ExitStatus::code()` is `None` when a process was
// terminated rather than exiting, and a caller comparing against zero
// must not read that as success.
exit_code: output.status.code().unwrap_or(-1),
exit_code: output.exit_code,
stdout: String::from_utf8_lossy(&output.stdout).trim().to_string(),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
})
Expand Down
169 changes: 169 additions & 0 deletions crates/tinyflows/src/caps/host/script/spawner.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
//! The seam a host uses to put script processes inside its own jail.
//!
//! The runner stages the script and its input on disk, then hands one
//! [`SpawnSpec`] to a [`ScriptSpawner`]. The default [`DirectSpawner`] runs the
//! interpreter as a plain child of this process (the historical behaviour). A
//! host with a sandbox installs its own spawner with [`install_script_spawner`]
//! so every script, `code` and `shell` alike, runs through the jail.
//!
//! A host that must never run an unjailed script also calls
//! [`require_script_spawner`]: from then on a run with no installed spawner
//! fails closed instead of falling back to [`DirectSpawner`].

use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::time::Duration;

use async_trait::async_trait;

use super::*;

/// One fully staged process launch.
#[derive(Debug, Clone)]
pub struct SpawnSpec {
/// The program to run: a bare name or an absolute path.
pub program: String,
/// Every argument, in order: the interpreter's leading arguments, then the
/// script path, then the input path.
pub args: Vec<String>,
/// Environment layered over the inherited one.
pub env: BTreeMap<String, String>,
/// The working directory.
pub cwd: PathBuf,
/// The directory holding the staged script and `input.json`. A jail must
/// make it readable inside the box.
pub staging_dir: PathBuf,
/// Bytes written to the process's stdin.
pub stdin: Vec<u8>,
/// How long the process may run.
pub timeout: Duration,
}

/// What a spawned process produced.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpawnOutput {
/// Exit status, or `-1` when a signal terminated it.
pub exit_code: i32,
/// Raw stdout.
pub stdout: Vec<u8>,
/// Raw stderr.
pub stderr: Vec<u8>,
}

/// Launches a staged script process, possibly inside a jail.
#[async_trait]
pub trait ScriptSpawner: Send + Sync {
/// Run `spec` to completion.
///
/// # Errors
///
/// Fails when the process cannot be launched (or the jail refuses it) or it
/// outlives `spec.timeout`.
async fn spawn(&self, spec: SpawnSpec) -> Result<SpawnOutput>;
}

/// Runs the interpreter as an unjailed child of this process.
#[derive(Debug, Default, Clone, Copy)]
pub struct DirectSpawner;

#[async_trait]
impl ScriptSpawner for DirectSpawner {
async fn spawn(&self, spec: SpawnSpec) -> Result<SpawnOutput> {
use tokio::io::AsyncWriteExt;

let SpawnSpec {
program,
args,
env,
cwd,
stdin: body,
timeout,
..
} = spec;
let mut command = tokio::process::Command::new(&program);
command
.args(&args)
.envs(&env)
.current_dir(&cwd)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
// Tokio does not kill a child when its future is dropped, so a
// timeout would otherwise leave an infinite script running.
.kill_on_drop(true);
let mut child = command.spawn().map_err(|err| {
EngineError::Capability(format!(
"script: cannot run `{program}` ({err}). Is it installed and on PATH?"
))
})?;
// Stdin is written on its own task while stdout/stderr drain, so a
// script that prints before reading all of stdin cannot deadlock.
let mut stdin = child.stdin.take();
let writer = tokio::spawn(async move {
if let Some(mut stdin) = stdin.take() {
let _ = stdin.write_all(&body).await;
let _ = stdin.shutdown().await;
}
});
let output = tokio::time::timeout(timeout, async {
let output = child.wait_with_output().await;
let _ = writer.await;
output
})
.await
.map_err(|_| {
EngineError::Capability(format!("script: timed out after {}s", timeout.as_secs()))
})?
.map_err(|err| EngineError::Capability(format!("script: {program}: {err}")))?;
Ok(SpawnOutput {
exit_code: output.status.code().unwrap_or(-1),
stdout: output.stdout,
stderr: output.stderr,
})
}
}

struct SpawnerState {
installed: Option<Arc<dyn ScriptSpawner>>,
required: bool,
}

static STATE: RwLock<SpawnerState> = RwLock::new(SpawnerState {
installed: None,
required: false,
});

/// Route every script this process runs through `spawner`.
pub fn install_script_spawner(spawner: Arc<dyn ScriptSpawner>) {
tracing::debug!("script spawner installed");
STATE.write().unwrap_or_else(|e| e.into_inner()).installed = Some(spawner);
}

/// Remove the installed spawner, returning to [`DirectSpawner`].
pub fn clear_script_spawner() {
STATE.write().unwrap_or_else(|e| e.into_inner()).installed = None;
}

/// Fail closed: while `required`, a script run with no installed spawner is
/// refused rather than run unjailed.
pub fn require_script_spawner(required: bool) {
STATE.write().unwrap_or_else(|e| e.into_inner()).required = required;
}

/// The spawner a run should use.
///
/// # Errors
///
/// Fails when a spawner is required and none is installed.
pub(super) fn current_spawner() -> Result<Arc<dyn ScriptSpawner>> {
let state = STATE.read().unwrap_or_else(|e| e.into_inner());
match (&state.installed, state.required) {
(Some(spawner), _) => Ok(Arc::clone(spawner)),
(None, true) => Err(EngineError::Capability(
"script: refused, a script spawner is required but none is installed (fail closed)"
.to_string(),
)),
(None, false) => Ok(Arc::new(DirectSpawner)),
}
}
Loading
Loading