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
79 changes: 24 additions & 55 deletions src/openhuman/agent/host_runtime.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Native and Docker shell runtime adapters (`RuntimeAdapter` implementations).

use crate::openhuman::agent::platform_shell;
use crate::openhuman::config::RuntimeConfig;
use std::path::{Path, PathBuf};

Expand Down Expand Up @@ -77,30 +78,10 @@ impl RuntimeAdapter for NativeRuntime {
command: &str,
workspace_dir: &Path,
) -> anyhow::Result<tokio::process::Command> {
// On Windows hosts there is no POSIX `sh`; drive PowerShell instead.
// `-NoProfile` keeps startup fast and avoids user profile side effects.
let mut cmd = if cfg!(windows) {
let mut c = tokio::process::Command::new("powershell");
c.arg("-NoProfile").arg("-Command").arg(command);
c
} else if let Some(bash) = bash_path() {
// Prefer bash with `pipefail` so a failed stage in a pipeline (e.g.
// `pip install … | tail`) surfaces as a non-zero exit instead of
// being masked by the last stage's success. Without it the harness
// records the call as successful and the repeated-failure circuit
// breaker (`RepeatedToolFailureMiddleware`, tinyagents/middleware.rs)
// never trips, so the agent loops on a
// command that is silently failing. `/bin/sh` is dash on
// Debian/Ubuntu and rejects `set -o pipefail`, so this is gated on
// bash actually being present; otherwise we fall back to plain sh.
let mut c = tokio::process::Command::new(bash);
c.arg("-lc").arg(format!("set -o pipefail\n{command}"));
c
} else {
let mut c = tokio::process::Command::new("sh");
c.arg("-lc").arg(command);
c
};
// Shell selection is shared with the sandboxed execution paths in
// `sandbox::ops` so all three sites (native, unsandboxed, jailed)
// pick the same platform-appropriate shell (#4705).
let mut cmd = platform_shell::build_tokio_command(command);
// Validate the CWD up front so a missing/bad action_dir produces an
// actionable message naming the path, instead of an opaque OS error 267
// (ERROR_DIRECTORY) from CreateProcessW on Windows / a raw ENOENT on
Expand Down Expand Up @@ -138,21 +119,6 @@ fn maybe_hide_window(cmd: &mut tokio::process::Command, hide: bool) {
);
}

/// Locate a `bash` binary once (cached — this is hit on every shell call) for
/// the `pipefail` wrapper in [`NativeRuntime::build_shell_command`]. Returns
/// `None` on hosts without bash (e.g. minimal containers), where we fall back
/// to plain `sh` without pipefail.
fn bash_path() -> Option<&'static str> {
static BASH: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
BASH.get_or_init(|| {
["/usr/bin/bash", "/bin/bash"]
.into_iter()
.find(|p| Path::new(p).exists())
.map(str::to_string)
})
.as_deref()
}

pub struct DockerRuntime {
config: crate::openhuman::config::DockerRuntimeConfig,
}
Expand Down Expand Up @@ -257,8 +223,11 @@ mod tests {
assert_eq!(runtime.memory_budget(), 0);
assert!(runtime.storage_path().ends_with("openhuman/runtime"));

// Use a tempdir so `ensure_usable_cwd` accepts the path on every
// OS (`/tmp` does not exist on Windows).
let tempdir = tempfile::tempdir().unwrap();
let command = runtime
.build_shell_command("echo hi", Path::new("/tmp"))
.build_shell_command("echo hi", tempdir.path())
.unwrap();
let prog = command
.as_std()
Expand All @@ -270,19 +239,17 @@ mod tests {
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
// NativeRuntime prefers bash with `set -o pipefail` when bash is present
// (so masked pipe failures surface), and falls back to plain `sh`.
if let Some(bash) = bash_path() {
assert_eq!(prog, bash);
assert_eq!(
args,
vec!["-lc".to_string(), "set -o pipefail\necho hi".to_string()]
);
// Shell selection is delegated to `platform_shell::build_tokio_command`
// — this test just asserts NativeRuntime is wired into it. The full
// per-platform matrix is covered by `platform_shell::tests`.
if cfg!(windows) {
assert_eq!(prog, "cmd");
assert_eq!(args, vec!["/C".to_string(), "echo hi".to_string()]);
} else {
assert_eq!(prog, "sh");
assert_eq!(args, vec!["-lc".to_string(), "echo hi".to_string()]);
assert!(prog.ends_with("bash") || prog == "sh");
assert_eq!(args.first().map(String::as_str), Some("-lc"));
}
assert_eq!(command.as_std().get_current_dir(), Some(Path::new("/tmp")));
assert_eq!(command.as_std().get_current_dir(), Some(tempdir.path()));
}

#[test]
Expand Down Expand Up @@ -359,16 +326,18 @@ mod tests {
let native = create_runtime(&RuntimeConfig::default(), true).unwrap();
assert_eq!(native.name(), "native");

// Tempdir so `ensure_usable_cwd` accepts it on Windows CI too.
let tempdir = tempfile::tempdir().unwrap();
let runtime = NativeRuntime::with_hide_window(true);
let command = runtime
.build_shell_command("echo hi", Path::new("/tmp"))
.build_shell_command("echo hi", tempdir.path())
.expect("hide_window should not break command construction");
assert_eq!(command.as_std().get_current_dir(), Some(Path::new("/tmp")));
assert_eq!(command.as_std().get_current_dir(), Some(tempdir.path()));

// The program/args are identical with and without the flag — hiding the
// window must not alter what is executed.
let plain = NativeRuntime::with_hide_window(false)
.build_shell_command("echo hi", Path::new("/tmp"))
.build_shell_command("echo hi", tempdir.path())
.unwrap();
assert_eq!(command.as_std().get_program(), plain.as_std().get_program());
}
Expand All @@ -394,7 +363,7 @@ mod tests {
#[cfg(unix)]
#[tokio::test]
async fn native_shell_pipefail_surfaces_failed_pipe_stage() {
if bash_path().is_none() {
if platform_shell::bash_path().is_none() {
return; // no bash → plain sh, pipefail unavailable
}
let rt = NativeRuntime::new();
Expand Down
5 changes: 5 additions & 0 deletions src/openhuman/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ pub mod host_runtime;
pub mod library;
pub mod multimodal;
pub mod pformat;
/// Cross-platform shell selection shared by [`host_runtime::NativeRuntime`]
/// and [`crate::openhuman::sandbox::ops`] so all three shell-spawning sites
/// agree on `cmd.exe` (Windows) vs `bash`/`sh` (Unix). Fixes #4705 where
/// the sandbox paths hardcoded `sh` and failed at spawn on Windows.
pub mod platform_shell;
pub mod progress;
/// Structured tracing export off the [`progress`] channel: turns the
/// real-time [`progress::AgentProgress`] stream into OpenTelemetry/
Expand Down
196 changes: 196 additions & 0 deletions src/openhuman/agent/platform_shell.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
//! Cross-platform shell selection for spawning agent shell commands.
//!
//! Consolidates the "which shell binary do we spawn?" decision so
//! [`NativeRuntime::build_shell_command`](super::host_runtime::NativeRuntime)
//! and the sandbox execution paths in
//! [`crate::openhuman::sandbox::ops`] can share one Windows-aware
//! implementation. Prior to this module the sandbox paths hardcoded
//! `Command::new("sh")`, which fails at `CreateProcessW` on Windows in
//! ~30ms because `sh` is not in `PATH` (#4705).
//!
//! **Shell choice per platform:**
//!
//! - **Windows** → `cmd.exe /C <command>`. Chosen over PowerShell because
//! Windows users expect `%VAR%` expansion (`echo %USERPROFILE%`) and
//! byte-transparent `>` / `2>` redirection for the sandboxed output-
//! capture path in
//! [`crate::openhuman::sandbox::ops::execute_local_jail`]. PowerShell
//! 5.1's `>` writes UTF-16LE and does not expand `%VAR%`.
//! - **Unix** → `bash -lc "set -o pipefail\n<command>"` when bash is
//! available at `/usr/bin/bash` or `/bin/bash`, otherwise `sh -lc
//! <command>`. `set -o pipefail` surfaces a failed stage in a pipeline
//! (e.g. `pip install … | tail`) as a non-zero exit instead of being
//! masked by the last stage — without it the harness records the call
//! as successful and the repeated-failure circuit breaker
//! (`RepeatedToolFailureMiddleware`) never trips, so the agent loops
//! on a silently-failing command. `/bin/sh` is dash on Debian/Ubuntu
//! and rejects `set -o pipefail`, so this is gated on bash actually
//! being present; otherwise we fall back to plain sh.

use std::path::Path;

/// Build a [`tokio::process::Command`] that runs `command` under the
/// platform's default shell. Callers are responsible for setting
/// `current_dir`, environment, and stdio.
pub fn build_tokio_command(command: &str) -> tokio::process::Command {
let (program, args) = select_shell_program_and_args(command);
let mut cmd = tokio::process::Command::new(program);
cmd.args(args);
cmd
}

/// [`std::process::Command`] variant for callers that hand the command
/// to [`crate::openhuman::cwd_jail::spawn`], which is built around
/// `std::process::Command` (not the tokio variant).
pub fn build_std_command(command: &str) -> std::process::Command {
let (program, args) = select_shell_program_and_args(command);
let mut cmd = std::process::Command::new(program);
cmd.args(args);
cmd
}

/// Single source of truth for the shell-selection policy shared by
/// [`build_tokio_command`] and [`build_std_command`] — future changes
/// to the platform matrix (adding pwsh, changing pipefail semantics)
/// belong here, so both `Command` flavours stay in lockstep.
fn select_shell_program_and_args(command: &str) -> (&'static str, [String; 2]) {
if cfg!(windows) {
("cmd", ["/C".to_string(), command.to_string()])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⚠️ Major (correctness, Windows) — cmd /C receives the command via Command::arg(), whose MSVCRT-style quoting mangles embedded double-quotes that cmd.exe does not understand.

Rust's std/tokio Command::arg on Windows quotes any arg containing whitespace and escapes interior " as \" (the CommandLineToArgvW convention). cmd.exe does not parse \" — it only toggles quote state on bare ". So a command handed to cmd /C as a single arg is re-parsed by cmd with the wrong quote boundaries.

Two consequences:

  1. Redirect wrap always breaks. wrap_with_output_redirection (below) always produces ... > "out" 2> "err". After arg() escaping the line becomes cmd /C "echo hi > \"out\" 2> \"err\"". cmd sees the > inside a quoted span, treats it as a literal, and performs no redirection — the .sandbox_stdout/.sandbox_stderr files stay empty and execute_local_jail returns empty output. This is exactly the SandboxMode::Sandboxed → Local path the tinyflows code node uses on Windows, so the PR's headline Windows fix silently returns no output there.
  2. Any command containing " breaks even on the non-redirect path (echo "hello world" → cmd runs echo \hello world\).

Fix: pass the payload to cmd verbatim via raw_arg on Windows:

// build_tokio_command / build_std_command, Windows arm
use std::os::windows::process::CommandExt;
let mut cmd = Command::new("cmd");
cmd.raw_arg("/C").raw_arg(command); // no MSVCRT re-quoting

Since select_shell_program_and_args returns (program, [String;2]) and raw_arg is Windows-only, this likely means branching the two build_* fns on cfg!(windows) rather than sharing the tuple helper. Please verify on real Windows — the current unit tests only assert the arg vector, never spawn cmd with a quoted/redirected command.

} else if let Some(bash) = bash_path() {
(
bash,
["-lc".to_string(), format!("set -o pipefail\n{command}")],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💡 Question / minor — behavior change on the sandbox path: sh -c → login shell bash -lc.

Previously execute_unsandboxed and execute_local_jail used sh -c <cmd> (non-login). Routing through platform_shell now runs bash -lc "set -o pipefail\n<cmd>" on Unix. Two side effects worth confirming are intended:

  • -l sources login profile scripts (/etc/profile, and since HOME is in SANDBOX_ENV_PASSTHROUGH, ~/.bash_profile/~/.profile). Any echo/banner in those files lands in the captured .sandbox_stdout for the jail path, corrupting the tool's reported output.
  • Login-profile sourcing adds per-call latency and can rewrite PATH out from under the curated SANDBOX_ENV_PASSTHROUGH set.

If parity with the old non-login sandbox behavior is desired, consider a non-login variant (-c with an explicit set -o pipefail prefix) for the sandbox callers, or document that the sandbox intentionally adopts NativeRuntime's login-shell semantics.

)
} else {
("sh", ["-lc".to_string(), command.to_string()])
}
}

/// Wrap `command` so that stdout and stderr redirect to the given file
/// paths, using shell syntax compatible with the platform's default
/// shell as selected by [`build_tokio_command`] / [`build_std_command`].
///
/// Used by [`crate::openhuman::sandbox::ops::execute_local_jail`] to
/// capture output on backends (macOS Seatbelt) that rebuild the command
/// internally and don't forward piped stdio settings.
pub fn wrap_with_output_redirection(
command: &str,
stdout_path: &Path,
stderr_path: &Path,
) -> String {
if cfg!(windows) {
// cmd.exe has no `{ … }` command grouping, but `>`/`2>` bind to
// the whole /C payload when placed at the end, so a plain
// trailing redirect captures the full output for both single
// commands and pipelines. Double-quote paths so backslashes,
// spaces, and `(` / `)` inside typical Windows workspace paths
// (e.g. `C:\Program Files (x86)\…`) don't break parsing.
format!(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⚠️ Major (follow-on to the raw_arg comment above). This wrap is the concrete place the quoting bug bites: the produced string always contains double-quoted paths (> "…" 2> "…"), and it is spawned via build_std_commandcmd /C <arg> whose Command::arg() escaping puts the >/2> inside a cmd quote-span, so no redirection happens and the stdout/stderr temp files are never written. On Windows this path is reached for SandboxMode::Sandboxed local agents (and the tinyflows code node). Blocking factor is the same raw_arg fix on the Windows arm; additionally, the Windows branch here formats the redirect paths with .display(), which on real Windows yields backslash paths (C:\\Users\\…) — those need to survive verbatim too, reinforcing the raw-arg approach. Worth an execution test (execute_local_jail on Windows CI actually reading back captured output), not just the current string-equality assertion.

"{command} > \"{}\" 2> \"{}\"",
stdout_path.display(),
stderr_path.display()
)
} else {
// sh/bash need `{ … ; }` grouping so a semicolon- or pipe-
// separated multi-stage `command` routes *all* stages' output
// to the temp files. Without the group `a; b > out` would only
// redirect `b`. Single-quote paths so shell metacharacters in
// the workspace path stay literal.
format!(
"{{ {command} ; }} > '{}' 2> '{}'",
stdout_path.display(),
stderr_path.display()
)
}
}

/// Locate a `bash` binary once (cached — hit on every shell call) for
/// the `pipefail` wrapper. Returns `None` on hosts without bash at a
/// standard path (Windows, minimal containers), where we fall back to
/// plain `sh` without pipefail. Exposed `pub(crate)` so regression
/// tests in [`super::host_runtime`] can skip the pipefail assertions
/// on bash-less hosts.
pub(crate) fn bash_path() -> Option<&'static str> {
static BASH: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
BASH.get_or_init(|| {
["/usr/bin/bash", "/bin/bash"]
.into_iter()
.find(|p| Path::new(p).exists())
.map(str::to_string)
})
.as_deref()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;

#[test]
fn tokio_command_selects_platform_shell() {
let cmd = build_tokio_command("echo hi");
let prog = cmd.as_std().get_program().to_string_lossy().into_owned();
let args: Vec<String> = cmd
.as_std()
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();

if cfg!(windows) {
assert_eq!(prog, "cmd");
assert_eq!(args, vec!["/C".to_string(), "echo hi".to_string()]);
} else if let Some(bash) = bash_path() {
assert_eq!(prog, bash);
assert_eq!(
args,
vec!["-lc".to_string(), "set -o pipefail\necho hi".to_string()]
);
} else {
assert_eq!(prog, "sh");
assert_eq!(args, vec!["-lc".to_string(), "echo hi".to_string()]);
}
}

#[test]
fn std_command_selects_platform_shell() {
let cmd = build_std_command("echo hi");
let prog = cmd.get_program().to_string_lossy().into_owned();
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();

if cfg!(windows) {
assert_eq!(prog, "cmd");
assert_eq!(args, vec!["/C".to_string(), "echo hi".to_string()]);
} else if let Some(bash) = bash_path() {
assert_eq!(prog, bash);
assert_eq!(
args,
vec!["-lc".to_string(), "set -o pipefail\necho hi".to_string()]
);
} else {
assert_eq!(prog, "sh");
assert_eq!(args, vec!["-lc".to_string(), "echo hi".to_string()]);
}
}

#[test]
fn output_redirection_wraps_per_platform() {
let stdout = PathBuf::from("/tmp/openhuman/out.log");
let stderr = PathBuf::from("/tmp/openhuman/err.log");
let wrapped = wrap_with_output_redirection("echo hi", &stdout, &stderr);

if cfg!(windows) {
assert_eq!(
wrapped,
r#"echo hi > "/tmp/openhuman/out.log" 2> "/tmp/openhuman/err.log""#
);
} else {
assert_eq!(
wrapped,
"{ echo hi ; } > '/tmp/openhuman/out.log' 2> '/tmp/openhuman/err.log'"
);
}
}
}
36 changes: 31 additions & 5 deletions src/openhuman/cwd_jail/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,19 @@ impl JailBackend for AppContainerBackend {
}

fn is_available(&self) -> bool {
// AppContainer is available on Windows 8+ and Server 2012+.
// We could probe `CreateAppContainerProfile`'s availability via
// GetProcAddress, but treating the target_os = "windows" cfg as
// good enough — supported Windows versions are all 10+.
true
// AppContainer is *reachable* on every supported Windows (8+ / Server
// 2012+), but the spawn path in `spawn_in_container` can't yet return
// a `std::process::Child` — after `CreateProcessW` succeeds it must
// reply `Err(io::ErrorKind::Unsupported)` (see TODO at

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nitpick. The comment points at spawn_in_container, ~line 279 but the Err(Unsupported) return / TODO is at line ~287–295 in the current file. Minor drift; consider referencing the function name only (line numbers rot) — e.g. "see the Err(Unsupported) return at the end of spawn_in_container".

// `spawn_in_container`, ~line 279). Reporting the backend as
// available anyway strands the successfully-spawned process on the
// caller side: `execute_local_jail` bubbles the `Err` up as a spawn
// failure and never `wait`s on the running `cmd.exe`, so it
// orphan-runs against the redirected stdout/stderr files. Report
// unavailable until `Child` bridging lands so `pick_backend()` /
// `execute_local_jail` route through `NoopBackend`, which returns a
// real waitable `Child`. (PR #4723 review — #4705.)
false
}

fn spawn(&self, jail: &Jail, cmd: Command) -> io::Result<Child> {
Expand Down Expand Up @@ -584,4 +592,22 @@ mod tests {
assert!(s.len() <= 70);
assert!(s.starts_with("openhuman."));
}

/// PR #4723 review — `AppContainerBackend::is_available()` must
/// stay `false` until the spawn path can return a
/// `std::process::Child`. Reporting available strands a
/// successfully-spawned `cmd.exe` process because `spawn_in_container`
/// currently answers `Err(Unsupported)` after `CreateProcessW`, and
/// callers (e.g. `execute_local_jail`) drop the child on the floor.
/// Flip back to `true` in the same commit that lands the
/// `OwnedHandle -> Child` bridge.
#[test]
fn appcontainer_backend_reports_unavailable_until_child_bridge_lands() {
assert!(
!AppContainerBackend::new().is_available(),
"AppContainer must report unavailable while its spawn path \
cannot yield a waitable std::process::Child — see #4705 / \
PR #4723 for the orphan-spawn hazard"
);
}
}
Loading
Loading