From 22b2034b9f09a2478d0a1f8151ca041e578e25cc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 14:43:48 +0300 Subject: [PATCH 01/95] feat(bus): add platform shell selection to the host contract Introduce a SelectShell method and shell selection types in tinybox-bus, bumping the contract version to 1.10, and expose shell discovery and command construction from the local host so callers can pick the right platform shell. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-bus/src/lib.rs | 7 +- crates/tinybox-bus/src/shell_selection.rs | 42 +++++++++ crates/tinybox-host/Cargo.toml | 1 + crates/tinybox-host/src/lib.rs | 3 + crates/tinybox-host/src/local/mod.rs | 2 + crates/tinybox-host/src/local/shell.rs | 92 ++++++++++++++++++++ crates/tinybox-host/src/local/shell_tests.rs | 85 ++++++++++++++++++ 7 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 crates/tinybox-bus/src/shell_selection.rs create mode 100644 crates/tinybox-host/src/local/shell.rs create mode 100644 crates/tinybox-host/src/local/shell_tests.rs diff --git a/crates/tinybox-bus/src/lib.rs b/crates/tinybox-bus/src/lib.rs index 0dadb5c2..77ee30b6 100644 --- a/crates/tinybox-bus/src/lib.rs +++ b/crates/tinybox-bus/src/lib.rs @@ -21,6 +21,9 @@ pub use grants::{LocalGrantConfig, LocalGrants, ProtectedPaths, ResolveLocalGran mod jail; pub use jail::{JailEnforcement, NativeJailConfig}; +mod shell_selection; +pub use shell_selection::{SelectShellRequest, ShellFlavor, ShellInvocation}; + mod shell; pub use shell::{CommandAnalysis, CommandClass, CommandSegment}; mod files; @@ -40,7 +43,8 @@ pub use files::{ /// file transfer for mounted workspaces. 1.6 adds bounded live native output. /// 1.7 adds explicitly approved native directory-jail execution. 1.8 adds a /// bounded, module-owned one-shot Docker operation. 1.9 adds local grant discovery. -pub const CONTRACT_VERSION: (u32, u32) = (1, 9); +/// 1.10 adds platform shell selection. +pub const CONTRACT_VERSION: (u32, u32) = (1, 10); /// Whether this host vocabulary can bind to a module's advertised version. /// The major must match and the module's minor must include every host member. @@ -111,6 +115,7 @@ pub const METHODS: &[&str] = &[ "StartJailedExec", "RunDocker", "ResolveLocalGrants", + "SelectShell", ]; /// Facts about the detected native directory-jail backend. diff --git a/crates/tinybox-bus/src/shell_selection.rs b/crates/tinybox-bus/src/shell_selection.rs new file mode 100644 index 00000000..0400bfa2 --- /dev/null +++ b/crates/tinybox-bus/src/shell_selection.rs @@ -0,0 +1,42 @@ +//! Platform shell invocation vocabulary; selecting a shell grants no execution access. + +use serde::{Deserialize, Serialize}; + +/// Command text and the desired pipeline exit-status convention. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SelectShellRequest { + /// Text interpreted by the platform shell. + pub command: String, + /// Preserve the shell's ordinary last-stage pipeline status rather than + /// enabling Bash pipefail. Windows cmd has no pipefail mode. + #[serde(default)] + pub preserve_pipeline_status: bool, +} + +/// Shell syntax used by the module machine. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ShellFlavor { + /// Windows cmd syntax, including percent-variable expansion. + Cmd, + /// POSIX shell syntax, using Bash when available. + Posix, +} + +/// Selected invocation, returned for discovery and consumed inside TinyBox. +/// Hosts execute commands through the module rather than spawning this plan. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ShellInvocation { + /// Syntax understood by the selected shell. + pub flavor: ShellFlavor, + /// Shell executable on the module machine. + pub program: String, + /// Ordinary process arguments, excluding the executable. + pub arguments: Vec, + /// Windows command payload appended verbatim after ordinary arguments. + /// Rust's ordinary argument escaping is incompatible with cmd quotes. + pub raw_arguments: Option, + /// Parent environment names needed to bootstrap Windows child tools. + /// Values are never returned by shell discovery. + pub bootstrap_environment: Vec, +} diff --git a/crates/tinybox-host/Cargo.toml b/crates/tinybox-host/Cargo.toml index 5f011804..c48bb824 100644 --- a/crates/tinybox-host/Cargo.toml +++ b/crates/tinybox-host/Cargo.toml @@ -14,6 +14,7 @@ publish = false [dependencies] tinybox-core.workspace = true +tinybox-bus.workspace = true tinybox-jail.workspace = true async-trait.workspace = true # `Dir` keeps workspace file handles beneath the approved root without diff --git a/crates/tinybox-host/src/lib.rs b/crates/tinybox-host/src/lib.rs index d7a21858..7fa6699b 100644 --- a/crates/tinybox-host/src/lib.rs +++ b/crates/tinybox-host/src/lib.rs @@ -48,3 +48,6 @@ mod local; pub use limited::{JailLocalHost, LimitedLocalHost, ManagedProcess}; pub use local::{LocalHost, NAME as LOCAL}; + +/// Platform shell discovery and command construction owned by the local host. +pub use local::shell::{build_shell_command, select_shell}; diff --git a/crates/tinybox-host/src/local/mod.rs b/crates/tinybox-host/src/local/mod.rs index cc16c6d8..b8b80231 100644 --- a/crates/tinybox-host/src/local/mod.rs +++ b/crates/tinybox-host/src/local/mod.rs @@ -1,5 +1,7 @@ //! Running commands on the machine tinybox is running on. +pub(crate) mod shell; + use async_trait::async_trait; use std::path::Path; use tinybox_core::{ diff --git a/crates/tinybox-host/src/local/shell.rs b/crates/tinybox-host/src/local/shell.rs new file mode 100644 index 00000000..074139e4 --- /dev/null +++ b/crates/tinybox-host/src/local/shell.rs @@ -0,0 +1,92 @@ +//! Platform shell selection and quote-preserving native command construction. + +use tinybox_bus::{SelectShellRequest, ShellFlavor, ShellInvocation}; + +const WINDOWS_BOOTSTRAP_ENVIRONMENT: &[&str] = &[ + "SystemRoot", + "WINDIR", + "COMSPEC", + "PATHEXT", + "TEMP", + "TMP", + "USERPROFILE", + "APPDATA", + "LOCALAPPDATA", + "ProgramFiles", + "ProgramFiles(x86)", + "ProgramW6432", +]; + +/// Select this machine's shell without starting a process or returning secrets. +/// Unix uses Bash at a standard path with pipefail unless preservation was +/// requested; minimal hosts fall back to sh. Windows uses cmd with raw payload. +#[must_use] +pub fn select_shell(request: &SelectShellRequest) -> ShellInvocation { + let flavor = if cfg!(windows) { + ShellFlavor::Cmd + } else { + ShellFlavor::Posix + }; + selection_for(request, flavor, bash_path()) +} + +/// Build the selected native command without spawning it. The execution +/// provider remains responsible for environment, confinement, pipes and lifecycle. +#[must_use] +pub fn build_shell_command(request: &SelectShellRequest) -> std::process::Command { + let selection = select_shell(request); + let mut command = std::process::Command::new(selection.program); + command.args(selection.arguments); + #[cfg(windows)] + if let Some(raw) = selection.raw_arguments { + use std::os::windows::process::CommandExt; + command.raw_arg(raw); + } + command +} + +fn selection_for( + request: &SelectShellRequest, + flavor: ShellFlavor, + bash: Option<&str>, +) -> ShellInvocation { + match flavor { + ShellFlavor::Cmd => ShellInvocation { + flavor, + program: "cmd".into(), + arguments: vec!["/C".into()], + raw_arguments: Some(request.command.clone()), + bootstrap_environment: WINDOWS_BOOTSTRAP_ENVIRONMENT + .iter() + .map(|name| (*name).into()) + .collect(), + }, + ShellFlavor::Posix => ShellInvocation { + flavor, + program: bash.unwrap_or("sh").into(), + arguments: vec![ + "-lc".into(), + if bash.is_some() && !request.preserve_pipeline_status { + format!("set -o pipefail\n{}", request.command) + } else { + request.command.clone() + }, + ], + raw_arguments: None, + bootstrap_environment: Vec::new(), + }, + } +} + +fn bash_path() -> Option<&'static str> { + static BASH: std::sync::OnceLock> = std::sync::OnceLock::new(); + *BASH.get_or_init(|| { + ["/usr/bin/bash", "/bin/bash"] + .into_iter() + .find(|path| std::path::Path::new(path).is_file()) + }) +} + +#[cfg(test)] +#[path = "shell_tests.rs"] +mod tests; diff --git a/crates/tinybox-host/src/local/shell_tests.rs b/crates/tinybox-host/src/local/shell_tests.rs new file mode 100644 index 00000000..222b4a13 --- /dev/null +++ b/crates/tinybox-host/src/local/shell_tests.rs @@ -0,0 +1,85 @@ +use super::*; + +fn request(preserve_pipeline_status: bool) -> SelectShellRequest { + SelectShellRequest { + command: "false | true".into(), + preserve_pipeline_status, + } +} + +#[test] +fn bash_surfaces_pipe_failures_and_preservation_keeps_the_original_payload() { + let selected = selection_for(&request(false), ShellFlavor::Posix, Some("/bin/bash")); + assert_eq!(selected.arguments, ["-lc", "set -o pipefail\nfalse | true"]); + let preserved = selection_for(&request(true), ShellFlavor::Posix, Some("/bin/bash")); + assert_eq!(preserved.arguments, ["-lc", "false | true"]); +} + +#[test] +fn sh_fallback_never_receives_bash_only_flags() { + let selected = selection_for(&request(false), ShellFlavor::Posix, None); + assert_eq!(selected.program, "sh"); + assert_eq!(selected.arguments, ["-lc", "false | true"]); + assert_eq!(selected.raw_arguments, None); + assert_eq!(selected.bootstrap_environment, Vec::::new()); +} + +#[test] +fn cmd_preserves_quoted_payload_and_lists_bootstrap_names_without_values() { + let payload = r#"echo %USERPROFILE% > "C:\Program Files (x86)\out.txt""#; + let selected = selection_for( + &SelectShellRequest { + command: payload.into(), + preserve_pipeline_status: false, + }, + ShellFlavor::Cmd, + None, + ); + assert_eq!(selected.program, "cmd"); + assert_eq!(selected.arguments, ["/C"]); + assert_eq!(selected.raw_arguments.as_deref(), Some(payload)); + assert_eq!( + selected.bootstrap_environment, + WINDOWS_BOOTSTRAP_ENVIRONMENT + ); +} + +#[test] +fn selected_shell_and_native_builder_agree() { + let requested = request(false); + let selected = select_shell(&requested); + let command = build_shell_command(&requested); + assert_eq!( + command.get_program(), + std::ffi::OsStr::new(&selected.program) + ); +} + +#[test] +#[cfg(unix)] +fn actual_bash_pipeline_failure_is_visible_unless_preservation_is_requested() +-> Result<(), Box> { + // The fallback is tested above; execution only asserts Bash semantics on + // hosts whose selected shell actually supports them. + if bash_path().is_none() { + return Ok(()); + } + assert!(!build_shell_command(&request(false)).status()?.success()); + assert!(build_shell_command(&request(true)).status()?.success()); + Ok(()) +} + +#[test] +#[cfg(windows)] +fn actual_cmd_raw_payload_keeps_quoted_redirection_paths() -> Result<(), Box> +{ + let root = tempfile::tempdir()?; + let output = root.path().join("output with spaces (fixture).txt"); + let requested = SelectShellRequest { + command: format!("echo fixture > \"{}\"", output.display()), + preserve_pipeline_status: false, + }; + assert!(build_shell_command(&requested).status()?.success()); + assert_eq!(std::fs::read_to_string(output)?.trim(), "fixture"); + Ok(()) +} From 611107521b0622b97341c1230011846cfdf883f5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 14:44:17 +0300 Subject: [PATCH 02/95] feat(tinybus): add SelectShell method for platform shell discovery Expose a SelectShell module method that returns the host's shell flavor and invocation plan, selecting Bash with pipefail on Unix or cmd /C on Windows without executing anything. The README documents the new contract and an end-to-end test covers both platform branches. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/README.md | 12 ++++++++++++ .../tinybox-module/src/tinybus_module/mod.rs | 9 ++++++++- .../tests/grants_manifest_e2e.rs | 19 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-module/src/tinybus_module/README.md b/crates/tinybox-module/src/tinybus_module/README.md index ae145967..de766ae8 100644 --- a/crates/tinybox-module/src/tinybus_module/README.md +++ b/crates/tinybox-module/src/tinybus_module/README.md @@ -274,3 +274,15 @@ Shared Cargo Git and registry dependency caches are read-only because their build scripts can execute in later host builds. Commands that fetch dependencies need a separately authorized disposable Cargo home. Hosts must supply their complete floor and apply authorization before using the returned grants in `StartJailedExec`. + +### Platform shell discovery + +Contract 1.10 adds `SelectShell`. The request carries command text and an optional +pipeline-status preservation flag. Unix selects Bash at a standard path with +`set -o pipefail` by default, or sh without that Bash-only flag when Bash is +absent. Windows selects cmd `/C` and preserves the payload as a raw command-line +argument, including quoted redirection paths and percent-variable expansion. +The result includes Windows bootstrap environment names, never their values. +Selection does not execute a command, authorize access or provide confinement; +hosts continue to execute through the module. Native providers can use the +shared quote-preserving TinyBox command builder. diff --git a/crates/tinybox-module/src/tinybus_module/mod.rs b/crates/tinybox-module/src/tinybus_module/mod.rs index 05fe1737..79b057f0 100644 --- a/crates/tinybox-module/src/tinybus_module/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/mod.rs @@ -58,6 +58,13 @@ impl BoxService { ) -> TinyBusResult { std::future::ready(Ok(tinybox_core::shell::analysis::analyze_command(&command))).await } + /// Discover the module machine's shell syntax and invocation plan. + async fn select_shell( + &self, + request: tinybox_bus::SelectShellRequest, + ) -> TinyBusResult { + std::future::ready(Ok(tinybox_host::select_shell(&request))).await + } /// Discover canonical grants using the caller's protected-path floor. async fn resolve_local_grants( &self, @@ -454,7 +461,7 @@ tinybus_module::module_export_optional_static! { setup = setup, worker_threads = 1, provides = ["ai.tinyhumans.tinybox.Box"], - methods = ["Describe", "Create", "Exec", "Inspect", "Close", "Spawn", "IsRunning", "Cancel", "AnalyzeShell", "Reserve", "Capabilities", "Shutdown", "Forward", "CloseForward", "JailStatus", "AnalyzeCommand", "BeginFileRead", "ReadFileChunk", "FinishFileRead", "BeginFileWrite", "WriteFileChunk", "FinishFileWrite", "AbortFileWrite", "StartExec", "ReadOutput", "ReleaseOutput", "StartJailedExec", "RunDocker", "ResolveLocalGrants"], + methods = ["Describe", "Create", "Exec", "Inspect", "Close", "Spawn", "IsRunning", "Cancel", "AnalyzeShell", "Reserve", "Capabilities", "Shutdown", "Forward", "CloseForward", "JailStatus", "AnalyzeCommand", "BeginFileRead", "ReadFileChunk", "FinishFileRead", "BeginFileWrite", "WriteFileChunk", "FinishFileWrite", "AbortFileWrite", "StartExec", "ReadOutput", "ReleaseOutput", "StartJailedExec", "RunDocker", "ResolveLocalGrants", "SelectShell"], signals = [], requires = [], optional = [], diff --git a/crates/tinybox-module/tests/grants_manifest_e2e.rs b/crates/tinybox-module/tests/grants_manifest_e2e.rs index cb7eec9a..8239e442 100644 --- a/crates/tinybox-module/tests/grants_manifest_e2e.rs +++ b/crates/tinybox-module/tests/grants_manifest_e2e.rs @@ -85,6 +85,25 @@ async fn compiled_manifest_admits_and_executes_local_grant_discovery() ] ); assert_eq!(result.read_write, Vec::::new()); + let selection: tinybox_bus::ShellInvocation = proxy + .call( + "SelectShell", + (tinybox_bus::SelectShellRequest { + command: "false | true".into(), + preserve_pipeline_status: false, + },), + ) + .await?; + if cfg!(windows) { + assert_eq!(selection.flavor, tinybox_bus::ShellFlavor::Cmd); + assert_eq!(selection.program, "cmd"); + assert_eq!(selection.arguments, ["/C"]); + assert_eq!(selection.raw_arguments.as_deref(), Some("false | true")); + } else { + assert_eq!(selection.flavor, tinybox_bus::ShellFlavor::Posix); + assert_eq!(selection.arguments[0], "-lc"); + assert_eq!(selection.raw_arguments, None); + } proxy.call::<()>("Shutdown", ()).await?; broker_task.abort(); Ok(()) From aa07097773ebfc90a0bfb6742580159de31d13a2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 14:45:22 +0300 Subject: [PATCH 03/95] test(tinybox-bus): cover shell selection wire defaults and contract bump Add a test asserting that SelectShellRequest defaults preserve_pipeline_status to false and that ShellInvocation round-trips its flavor, raw arguments, and bootstrap environment. Update the existing contract assertions to version 1.10 and the new SelectShell method, and add tinybox-bus as a dev dependency of the crate under test. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.lock | 1 + crates/tinybox-bus/src/lib_tests.rs | 34 +++++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9d72df46..60b03f9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1102,6 +1102,7 @@ dependencies = [ "command-group", "nix 0.30.1", "tempfile", + "tinybox-bus", "tinybox-core", "tinybox-jail", "tokio", diff --git a/crates/tinybox-bus/src/lib_tests.rs b/crates/tinybox-bus/src/lib_tests.rs index 09ab2b7b..c26e7048 100644 --- a/crates/tinybox-bus/src/lib_tests.rs +++ b/crates/tinybox-bus/src/lib_tests.rs @@ -46,8 +46,8 @@ fn docker_run_vocabulary_is_typed_bounded_and_additive() -> Result<(), serde_jso timeout_ms: 120_000, }; assert_eq!(METHODS[27], "RunDocker"); - assert_eq!(METHODS.last(), Some(&"ResolveLocalGrants")); - assert_eq!(CONTRACT_VERSION, (1, 9)); + assert_eq!(METHODS.last(), Some(&"SelectShell")); + assert_eq!(CONTRACT_VERSION, (1, 10)); assert_eq!(MAX_DOCKER_RUN_TIMEOUT_MS, 86_400_000); let serialized = serde_json::to_value(&request)?; assert_eq!(serialized["read_only_rootfs"], false); @@ -123,7 +123,7 @@ fn host_gateway_and_docker_facts_are_additive_and_default_safely() -> Result<(), #[test] fn version_and_capability_wire_snapshot_preserve_contract_compatibility() -> Result<(), serde_json::Error> { - assert_eq!(CONTRACT_VERSION, (1, 9)); + assert_eq!(CONTRACT_VERSION, (1, 10)); assert!(is_compatible(CONTRACT_VERSION)); assert!(!is_compatible((1, 7))); assert!(!is_compatible((1, 6))); @@ -140,7 +140,7 @@ fn version_and_capability_wire_snapshot_preserve_contract_compatibility() exec_backends: Vec::new(), spawn_backends: Vec::new(), }; - let snapshot = serde_json::json!({"contract_version":[1,9],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]}); + let snapshot = serde_json::json!({"contract_version":[1,10],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]}); assert_eq!(serde_json::to_value(&capabilities)?, snapshot); assert_eq!( serde_json::from_value::(snapshot)?, @@ -252,3 +252,29 @@ fn local_grant_configuration_defaults_and_floor_round_trip() -> Result<(), serde ); Ok(()) } + +#[test] +fn shell_selection_wire_defaults_preserve_safe_pipeline_behavior() -> Result<(), serde_json::Error> +{ + let request: SelectShellRequest = + serde_json::from_value(serde_json::json!({"command":"false | true"}))?; + assert!(!request.preserve_pipeline_status); + let invocation = ShellInvocation { + flavor: ShellFlavor::Cmd, + program: "cmd".into(), + arguments: vec!["/C".into()], + raw_arguments: Some(r#"echo "quoted payload""#.into()), + bootstrap_environment: vec!["SystemRoot".into()], + }; + let value = serde_json::to_value(&invocation)?; + assert_eq!(value["flavor"], "cmd"); + assert_eq!(value["raw_arguments"], r#"echo "quoted payload""#); + assert_eq!( + serde_json::from_value::(value)?, + invocation + ); + assert_eq!(METHODS[28], "ResolveLocalGrants"); + assert_eq!(METHODS[29], "SelectShell"); + assert!(!is_compatible((1, 9))); + Ok(()) +} From 4beca81c39f832efd45eb21012bdebd0c42d8380 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 14:48:51 +0300 Subject: [PATCH 04/95] chore: files changed crates/tinybox-bus/src/shell_selection.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-bus/src/shell_selection.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-bus/src/shell_selection.rs b/crates/tinybox-bus/src/shell_selection.rs index 0400bfa2..fb423d6e 100644 --- a/crates/tinybox-bus/src/shell_selection.rs +++ b/crates/tinybox-bus/src/shell_selection.rs @@ -23,7 +23,7 @@ pub enum ShellFlavor { Posix, } -/// Selected invocation, returned for discovery and consumed inside TinyBox. +/// Selected invocation, returned for discovery and consumed inside `TinyBox`. /// Hosts execute commands through the module rather than spawning this plan. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ShellInvocation { From 2dfea5fb834e550a938cc972e68584ed276571b2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 14:52:35 +0300 Subject: [PATCH 05/95] test(tinybox-module): rename grant discovery e2e test Rename the compiled manifest end-to-end test to reflect that it covers both grant and shell discovery, not just local grant discovery. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-module/tests/grants_manifest_e2e.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-module/tests/grants_manifest_e2e.rs b/crates/tinybox-module/tests/grants_manifest_e2e.rs index 8239e442..c3ddbc7c 100644 --- a/crates/tinybox-module/tests/grants_manifest_e2e.rs +++ b/crates/tinybox-module/tests/grants_manifest_e2e.rs @@ -4,7 +4,7 @@ use std::time::Duration; use tinybus::{Connection, broker::Broker, module::ModuleHost, transport::memory::MemoryBus}; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn compiled_manifest_admits_and_executes_local_grant_discovery() +async fn compiled_manifest_admits_and_executes_grant_and_shell_discovery() -> Result<(), Box> { let built_artifact = compiled_artifact()?; // Cargo output inherits the checkout ACL on Windows, which need not meet From 041943b54cc1a6c516ee28577a8c5a84d565b345 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 15:28:05 +0300 Subject: [PATCH 06/95] test(local): cover cmd quoting and shell startup flags Update the shell selection tests to expect `-c` instead of `-lc` and the `/D /S /C` flags with a quoted raw payload for cmd. Add a Windows-only test that runs a quoted executable path through cmd to confirm the raw payload survives quoting. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-host/src/local/shell_tests.rs | 29 ++++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/tinybox-host/src/local/shell_tests.rs b/crates/tinybox-host/src/local/shell_tests.rs index 222b4a13..199b6a42 100644 --- a/crates/tinybox-host/src/local/shell_tests.rs +++ b/crates/tinybox-host/src/local/shell_tests.rs @@ -1,3 +1,4 @@ +//! Shell startup isolation, quoting and pipeline-status regressions. use super::*; fn request(preserve_pipeline_status: bool) -> SelectShellRequest { @@ -10,16 +11,16 @@ fn request(preserve_pipeline_status: bool) -> SelectShellRequest { #[test] fn bash_surfaces_pipe_failures_and_preservation_keeps_the_original_payload() { let selected = selection_for(&request(false), ShellFlavor::Posix, Some("/bin/bash")); - assert_eq!(selected.arguments, ["-lc", "set -o pipefail\nfalse | true"]); + assert_eq!(selected.arguments, ["-c", "set -o pipefail\nfalse | true"]); let preserved = selection_for(&request(true), ShellFlavor::Posix, Some("/bin/bash")); - assert_eq!(preserved.arguments, ["-lc", "false | true"]); + assert_eq!(preserved.arguments, ["-c", "false | true"]); } #[test] fn sh_fallback_never_receives_bash_only_flags() { let selected = selection_for(&request(false), ShellFlavor::Posix, None); assert_eq!(selected.program, "sh"); - assert_eq!(selected.arguments, ["-lc", "false | true"]); + assert_eq!(selected.arguments, ["-c", "false | true"]); assert_eq!(selected.raw_arguments, None); assert_eq!(selected.bootstrap_environment, Vec::::new()); } @@ -36,14 +37,32 @@ fn cmd_preserves_quoted_payload_and_lists_bootstrap_names_without_values() { None, ); assert_eq!(selected.program, "cmd"); - assert_eq!(selected.arguments, ["/C"]); - assert_eq!(selected.raw_arguments.as_deref(), Some(payload)); + assert_eq!(selected.arguments, ["/D", "/S", "/C"]); + assert_eq!(selected.raw_arguments, Some(format!("\"{payload}\""))); assert_eq!( selected.bootstrap_environment, WINDOWS_BOOTSTRAP_ENVIRONMENT ); } +#[test] +#[cfg(windows)] +fn actual_cmd_raw_payload_keeps_a_quoted_executable_and_argument() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let executable = root.path().join("tool with spaces.exe"); + std::fs::copy(std::env::current_exe()?, &executable)?; + let output = root.path().join("quoted executable output.txt"); + let requested = SelectShellRequest { + command: format!("\"{}\" \"--list\" > \"{}\"", executable.display(), output.display()), + preserve_pipeline_status: false, + }; + assert!(build_shell_command(&requested).status()?.success()); + assert!(std::fs::read_to_string(output)? + .contains("actual_cmd_raw_payload_keeps_a_quoted_executable_and_argument")); + Ok(()) +} + #[test] fn selected_shell_and_native_builder_agree() { let requested = request(false); From 15bad6fb2a30b6c24c2d96f65cd24bd3fb198c83 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 15:29:50 +0300 Subject: [PATCH 07/95] fix(host): harden shell selection for cmd and posix Windows cmd invocations now pass /D /S /C and wrap the raw payload in an explicit outer quote, disabling registry AutoRun hooks and stripping exactly one wrapper while preserving quoted executable/argument pairs. Unix drops the login shell flag so login profiles are no longer sourced before the payload, and the contract documentation and tests are updated to match. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-host/Cargo.toml | 1 + crates/tinybox-host/src/local/shell.rs | 8 +++++--- crates/tinybox-host/src/local/shell_tests.rs | 12 +++++++++--- crates/tinybox-module/src/tinybus_module/README.md | 11 ++++++++--- crates/tinybox-module/tests/grants_manifest_e2e.rs | 6 +++--- 5 files changed, 26 insertions(+), 12 deletions(-) diff --git a/crates/tinybox-host/Cargo.toml b/crates/tinybox-host/Cargo.toml index c48bb824..13001872 100644 --- a/crates/tinybox-host/Cargo.toml +++ b/crates/tinybox-host/Cargo.toml @@ -14,6 +14,7 @@ publish = false [dependencies] tinybox-core.workspace = true +# Shell selection shares the module's serialized invocation vocabulary. tinybox-bus.workspace = true tinybox-jail.workspace = true async-trait.workspace = true diff --git a/crates/tinybox-host/src/local/shell.rs b/crates/tinybox-host/src/local/shell.rs index 074139e4..5ef74421 100644 --- a/crates/tinybox-host/src/local/shell.rs +++ b/crates/tinybox-host/src/local/shell.rs @@ -54,8 +54,10 @@ fn selection_for( ShellFlavor::Cmd => ShellInvocation { flavor, program: "cmd".into(), - arguments: vec!["/C".into()], - raw_arguments: Some(request.command.clone()), + // Disable registry AutoRun and make cmd strip exactly our outer + // wrapper, preserving quoted executable/argument pairs inside. + arguments: vec!["/D".into(), "/S".into(), "/C".into()], + raw_arguments: Some(format!("\"{}\"", request.command)), bootstrap_environment: WINDOWS_BOOTSTRAP_ENVIRONMENT .iter() .map(|name| (*name).into()) @@ -65,7 +67,7 @@ fn selection_for( flavor, program: bash.unwrap_or("sh").into(), arguments: vec![ - "-lc".into(), + "-c".into(), if bash.is_some() && !request.preserve_pipeline_status { format!("set -o pipefail\n{}", request.command) } else { diff --git a/crates/tinybox-host/src/local/shell_tests.rs b/crates/tinybox-host/src/local/shell_tests.rs index 199b6a42..f0a81043 100644 --- a/crates/tinybox-host/src/local/shell_tests.rs +++ b/crates/tinybox-host/src/local/shell_tests.rs @@ -54,12 +54,18 @@ fn actual_cmd_raw_payload_keeps_a_quoted_executable_and_argument() std::fs::copy(std::env::current_exe()?, &executable)?; let output = root.path().join("quoted executable output.txt"); let requested = SelectShellRequest { - command: format!("\"{}\" \"--list\" > \"{}\"", executable.display(), output.display()), + command: format!( + "\"{}\" \"--list\" > \"{}\"", + executable.display(), + output.display() + ), preserve_pipeline_status: false, }; assert!(build_shell_command(&requested).status()?.success()); - assert!(std::fs::read_to_string(output)? - .contains("actual_cmd_raw_payload_keeps_a_quoted_executable_and_argument")); + assert!( + std::fs::read_to_string(output)? + .contains("actual_cmd_raw_payload_keeps_a_quoted_executable_and_argument") + ); Ok(()) } diff --git a/crates/tinybox-module/src/tinybus_module/README.md b/crates/tinybox-module/src/tinybus_module/README.md index f45908ab..75bd7195 100644 --- a/crates/tinybox-module/src/tinybus_module/README.md +++ b/crates/tinybox-module/src/tinybus_module/README.md @@ -41,8 +41,10 @@ The interface is `ai.tinyhumans.tinybox.Box` at | WriteFileChunk | WriteFileChunkRequest | FileWriteProgress | | FinishFileWrite | FinishFileWriteRequest | FileWriteProgress | | AbortFileWrite | AbortFileWriteRequest | unit | +| ResolveLocalGrants | ResolveLocalGrantsRequest | LocalGrants | +| SelectShell | SelectShellRequest | ShellInvocation | -Capabilities advertises contract version 1.9; 1.0 denotes the original +Capabilities advertises contract version 1.10; 1.0 denotes the original discovery-only surface. Hosts require equal majors and a module minor at least as new as their vocabulary, using `tinybox_bus::is_compatible`. Version 1.1 added reserved resource/process ownership and terminal shutdown; 1.2 adds host @@ -297,8 +299,11 @@ It does not install a kernel deny rule for filenames created later. Contract 1.10 adds `SelectShell`. The request carries command text and an optional pipeline-status preservation flag. Unix selects Bash at a standard path with `set -o pipefail` by default, or sh without that Bash-only flag when Bash is -absent. Windows selects cmd `/C` and preserves the payload as a raw command-line -argument, including quoted redirection paths and percent-variable expansion. +absent. Unix uses non-login `-c` to avoid loading login profiles before the +payload. Windows selects cmd `/D /S /C`, disabling registry AutoRun hooks and +stripping one explicit outer wrapper around the raw command-line payload. +Quoted executable/argument pairs, redirection paths and percent expansion remain +inside that wrapper. The result includes Windows bootstrap environment names, never their values. Selection does not execute a command, authorize access or provide confinement; hosts continue to execute through the module. Native providers can use the diff --git a/crates/tinybox-module/tests/grants_manifest_e2e.rs b/crates/tinybox-module/tests/grants_manifest_e2e.rs index c3ddbc7c..e2155646 100644 --- a/crates/tinybox-module/tests/grants_manifest_e2e.rs +++ b/crates/tinybox-module/tests/grants_manifest_e2e.rs @@ -97,11 +97,11 @@ async fn compiled_manifest_admits_and_executes_grant_and_shell_discovery() if cfg!(windows) { assert_eq!(selection.flavor, tinybox_bus::ShellFlavor::Cmd); assert_eq!(selection.program, "cmd"); - assert_eq!(selection.arguments, ["/C"]); - assert_eq!(selection.raw_arguments.as_deref(), Some("false | true")); + assert_eq!(selection.arguments, ["/D", "/S", "/C"]); + assert_eq!(selection.raw_arguments.as_deref(), Some("\"false | true\"")); } else { assert_eq!(selection.flavor, tinybox_bus::ShellFlavor::Posix); - assert_eq!(selection.arguments[0], "-lc"); + assert_eq!(selection.arguments[0], "-c"); assert_eq!(selection.raw_arguments, None); } proxy.call::<()>("Shutdown", ()).await?; From 114679e454e9f2b28d3a070a6dd7d8f0222c4765 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 15:37:46 +0300 Subject: [PATCH 08/95] docs: record shell-selection contract and verification sequence Co-authored-by: Medulla --- docs/plans/README.md | 2 ++ docs/plans/shell-selection.md | 38 ++++++++++++++++++++++++++ docs/specs/README.md | 2 ++ docs/specs/shell-selection.md | 50 +++++++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+) create mode 100644 docs/plans/shell-selection.md create mode 100644 docs/specs/shell-selection.md diff --git a/docs/plans/README.md b/docs/plans/README.md index 0a5db163..0d17d2f0 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -20,3 +20,5 @@ code snippets when they remove ambiguity, but do not paste entire future files into the plan. See [`example-retry-policy.md`](example-retry-policy.md) for a test-first sample. + +Shell discovery: [shell-selection.md](shell-selection.md). diff --git a/docs/plans/shell-selection.md b/docs/plans/shell-selection.md new file mode 100644 index 00000000..b3599462 --- /dev/null +++ b/docs/plans/shell-selection.md @@ -0,0 +1,38 @@ +# Shell selection implementation sequence + +[Specification](../specs/shell-selection.md). +This records the implemented discovery slice and its verification order; native +shell execution is tracked separately and remains unfinished. + +1. Add SelectShellRequest, ShellFlavor and ShellInvocation in + crates/tinybox-bus/src/shell_selection.rs. Re-export from src/lib.rs, + append SelectShell to METHODS, advance the additive contract to 1.10, + and cover defaults, serialization and method ordering in lib_tests.rs. +2. Implement platform selection and the native quote-preserving builder in + crates/tinybox-host/src/local/shell.rs; centralize exports in src/lib.rs. + Portable regressions in local/shell_tests.rs pin non-login startup, + Bash pipefail, preservation, sh fallback and cmd /D /S /C raw wrapping. + The startup regressions failed before the review corrections. +3. Add module dispatch and manifest declaration in + crates/tinybox-module/src/tinybus_module/mod.rs. Extend + tests/grants_manifest_e2e.rs to build the actual artifact with --locked + --offline, enforce production admission and invoke SelectShell. +4. Execute pipeline fixtures on Unix and quoted executable, quoted argument + and redirection fixtures on Windows. Keep fallible tests returning Result; + portable selection fixtures also cover unavailable-Bash fallback. +5. Update the module README's method table, contract version and startup + semantics. Run formatting, affected tests, full Clippy/build and the + hosted cross-platform matrix before claiming complete validation. + +Local verification: + +- cargo fmt --all -- --check +- cargo test --locked -p tinybox-bus -p tinybox-host -p tinybox-module --all-features +- cargo clippy --locked --workspace --all-targets --all-features -- -D warnings +- cargo build --locked --workspace --all-targets --all-features + +Discovery implementation and compiled-artifact admission/invocation are complete. +Hosted validation is reported on the pull request. The native consumer remains +open: add a supervised module operation that accepts shell text directly, +preserves raw Windows quoting and uses existing output/cancellation ownership. +Do not mark that consumer objection resolved on discovery-only evidence. diff --git a/docs/specs/README.md b/docs/specs/README.md index a8286ae3..bff57786 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -21,3 +21,5 @@ After the specification is accepted, create a linked implementation plan in the contract; production code still belongs under `src/`. See [`example-retry-policy.md`](example-retry-policy.md) for a complete sample. + +Shell discovery: [shell-selection.md](shell-selection.md). diff --git a/docs/specs/shell-selection.md b/docs/specs/shell-selection.md new file mode 100644 index 00000000..7d84f1c6 --- /dev/null +++ b/docs/specs/shell-selection.md @@ -0,0 +1,50 @@ +# Platform shell selection + +Status: implemented discovery contract, under review. Owner: TinyBox. +Implementation sequence: [shell selection plan](../plans/shell-selection.md). + +## Problem and boundary + +A host must discover the module machine's shell syntax without linking TinyBox +implementation crates or returning environment values. Shell selection does +not execute code, approve access, or claim confinement. Native shell execution +through the module remains a separate required integration slice. + +## Contract + +Contract 1.10 appends SelectShell(SelectShellRequest) -> ShellInvocation. +The request carries command text and preserve_pipeline_status, default false. +The result carries flavor, program, ordinary arguments, optional Windows raw +arguments, and bootstrap environment variable names. + +Unix selects /usr/bin/bash or /bin/bash when present, otherwise sh. Both use +non-login -c. Bash prepends "set -o pipefail" unless preservation is requested; +sh never receives that Bash-only prefix. For "false | true", Bash's default +selection reports the failing stage; preservation retains the last-stage status. + +Windows selects cmd with /D /S /C. /D suppresses registry AutoRun. /S strips +exactly one supplied outer wrapper around the raw payload, leaving inner quoted +executable paths, argument pairs and redirection paths intact. Native builders +must use raw_arg for this payload rather than ordinary argument escaping. + +Bootstrap results list Windows environment names, including SystemRoot, +COMSPEC and USERPROFILE, without values. The execution provider owns its +approved environment, working directory, pipes and lifecycle. + +## Acceptance and constraints + +- Wire defaults and method ordering remain backward compatible. +- Selecting a shell never starts a process or returns environment secrets. +- Portable fixtures cover Bash, sh fallback, pipeline preservation and cmd + argument/wrapper semantics. +- Unix native fixtures exercise pipeline exit statuses. +- Windows native fixtures execute quoted redirection and a quoted executable + with a quoted argument. +- The actual compiled dynamic module passes production manifest admission and + executes SelectShell, including when the surrounding test build is static. +- Hosts execute behavior through the compiled module. Existing argv-only + execution cannot carry cmd raw payloads; its native-shell consumer remains + an open review requirement, not an implemented capability here. + +There are no open decisions for discovery. The pending execution slice must +retain process supervision and prove actual module execution on Windows. From e0c8cee61612e692a99ddbfe643bb7556127acc1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:13:03 +0300 Subject: [PATCH 09/95] test(shell): cover bash startup environment isolation Add a regression test that re-executes the test binary with BASH_ENV pointing at a startup script which exits early, verifying the requested payload still runs and succeeds. The existing cmd argument assertion is updated to match the current flag set. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-host/src/local/shell_tests.rs | 23 +++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-host/src/local/shell_tests.rs b/crates/tinybox-host/src/local/shell_tests.rs index f0a81043..47ffccfd 100644 --- a/crates/tinybox-host/src/local/shell_tests.rs +++ b/crates/tinybox-host/src/local/shell_tests.rs @@ -37,7 +37,7 @@ fn cmd_preserves_quoted_payload_and_lists_bootstrap_names_without_values() { None, ); assert_eq!(selected.program, "cmd"); - assert_eq!(selected.arguments, ["/D", "/S", "/C"]); + assert_eq!(selected.arguments, ["/D", "/E:ON", "/V:OFF", "/S", "/C"]); assert_eq!(selected.raw_arguments, Some(format!("\"{payload}\""))); assert_eq!( selected.bootstrap_environment, @@ -108,3 +108,24 @@ fn actual_cmd_raw_payload_keeps_quoted_redirection_paths() -> Result<(), Box Result<(), Box> { + const CHILD: &str = "TINYBOX_SHELL_ENV_REGRESSION_CHILD"; + if std::env::var_os(CHILD).is_some() { + let request = SelectShellRequest { command: "exit 0".into(), preserve_pipeline_status: false }; + assert!(build_shell_command(&request).status()?.success()); + return Ok(()); + } + let fixture = tempfile::tempdir()?; + let startup = fixture.path().join("startup.sh"); + std::fs::write(&startup, "exit 77\n")?; + let status = std::process::Command::new(std::env::current_exe()?) + .args(["--exact", "local::shell::tests::bash_startup_environment_cannot_replace_the_requested_payload", "--nocapture"]) + .env(CHILD, "1").env("BASH_ENV", &startup) + .status()?; + assert!(status.success(), "startup environment changed the child payload result"); + Ok(()) +} From 1322ae28eca5a0b0c657d763384242bf48e055ae Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:14:54 +0300 Subject: [PATCH 10/95] feat(shell): harden shell startup environment and cmd flags ShellInvocation now carries a remove_environment list that providers apply after approved environment overrides, and Unix builders strip BASH_ENV and ENV so startup hooks cannot replace the requested payload. Windows cmd selection explicitly enables extensions and disables delayed expansion with /E:ON /V:OFF, and older invocations default the new field to empty. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-bus/src/lib_tests.rs | 12 ++++++++++++ crates/tinybox-bus/src/shell_selection.rs | 3 +++ crates/tinybox-host/src/local/shell.rs | 13 ++++++++++++- crates/tinybox-host/src/local/shell_tests.rs | 19 +++++++++++++++---- .../src/tinybus_module/README.md | 6 +++++- .../tests/grants_manifest_e2e.rs | 3 ++- docs/plans/shell-selection.md | 6 +++++- docs/specs/shell-selection.md | 6 +++++- 8 files changed, 59 insertions(+), 9 deletions(-) diff --git a/crates/tinybox-bus/src/lib_tests.rs b/crates/tinybox-bus/src/lib_tests.rs index c26e7048..3c5d53c0 100644 --- a/crates/tinybox-bus/src/lib_tests.rs +++ b/crates/tinybox-bus/src/lib_tests.rs @@ -265,6 +265,7 @@ fn shell_selection_wire_defaults_preserve_safe_pipeline_behavior() -> Result<(), arguments: vec!["/C".into()], raw_arguments: Some(r#"echo "quoted payload""#.into()), bootstrap_environment: vec!["SystemRoot".into()], + remove_environment: Vec::new(), }; let value = serde_json::to_value(&invocation)?; assert_eq!(value["flavor"], "cmd"); @@ -278,3 +279,14 @@ fn shell_selection_wire_defaults_preserve_safe_pipeline_behavior() -> Result<(), assert!(!is_compatible((1, 9))); Ok(()) } + +#[test] +fn older_shell_invocations_default_the_startup_removal_vocabulary() -> Result<(), serde_json::Error> +{ + let invocation: ShellInvocation = serde_json::from_value(serde_json::json!({ + "flavor":"posix", "program":"sh", "arguments":["-c","true"], + "raw_arguments":null, "bootstrap_environment":[] + }))?; + assert_eq!(invocation.remove_environment, Vec::::new()); + Ok(()) +} diff --git a/crates/tinybox-bus/src/shell_selection.rs b/crates/tinybox-bus/src/shell_selection.rs index fb423d6e..27ba1153 100644 --- a/crates/tinybox-bus/src/shell_selection.rs +++ b/crates/tinybox-bus/src/shell_selection.rs @@ -39,4 +39,7 @@ pub struct ShellInvocation { /// Parent environment names needed to bootstrap Windows child tools. /// Values are never returned by shell discovery. pub bootstrap_environment: Vec, + /// Remove these startup-hook variables after applying the approved environment. + #[serde(default)] + pub remove_environment: Vec, } diff --git a/crates/tinybox-host/src/local/shell.rs b/crates/tinybox-host/src/local/shell.rs index 5ef74421..bc352f5e 100644 --- a/crates/tinybox-host/src/local/shell.rs +++ b/crates/tinybox-host/src/local/shell.rs @@ -37,6 +37,9 @@ pub fn build_shell_command(request: &SelectShellRequest) -> std::process::Comman let selection = select_shell(request); let mut command = std::process::Command::new(selection.program); command.args(selection.arguments); + for name in selection.remove_environment { + command.env_remove(name); + } #[cfg(windows)] if let Some(raw) = selection.raw_arguments { use std::os::windows::process::CommandExt; @@ -56,12 +59,19 @@ fn selection_for( program: "cmd".into(), // Disable registry AutoRun and make cmd strip exactly our outer // wrapper, preserving quoted executable/argument pairs inside. - arguments: vec!["/D".into(), "/S".into(), "/C".into()], + arguments: vec![ + "/D".into(), + "/E:ON".into(), + "/V:OFF".into(), + "/S".into(), + "/C".into(), + ], raw_arguments: Some(format!("\"{}\"", request.command)), bootstrap_environment: WINDOWS_BOOTSTRAP_ENVIRONMENT .iter() .map(|name| (*name).into()) .collect(), + remove_environment: Vec::new(), }, ShellFlavor::Posix => ShellInvocation { flavor, @@ -76,6 +86,7 @@ fn selection_for( ], raw_arguments: None, bootstrap_environment: Vec::new(), + remove_environment: vec!["BASH_ENV".into(), "ENV".into()], }, } } diff --git a/crates/tinybox-host/src/local/shell_tests.rs b/crates/tinybox-host/src/local/shell_tests.rs index 47ffccfd..d7e37d35 100644 --- a/crates/tinybox-host/src/local/shell_tests.rs +++ b/crates/tinybox-host/src/local/shell_tests.rs @@ -115,7 +115,10 @@ fn bash_startup_environment_cannot_replace_the_requested_payload() -> Result<(), Box> { const CHILD: &str = "TINYBOX_SHELL_ENV_REGRESSION_CHILD"; if std::env::var_os(CHILD).is_some() { - let request = SelectShellRequest { command: "exit 0".into(), preserve_pipeline_status: false }; + let request = SelectShellRequest { + command: "exit 0".into(), + preserve_pipeline_status: false, + }; assert!(build_shell_command(&request).status()?.success()); return Ok(()); } @@ -123,9 +126,17 @@ fn bash_startup_environment_cannot_replace_the_requested_payload() let startup = fixture.path().join("startup.sh"); std::fs::write(&startup, "exit 77\n")?; let status = std::process::Command::new(std::env::current_exe()?) - .args(["--exact", "local::shell::tests::bash_startup_environment_cannot_replace_the_requested_payload", "--nocapture"]) - .env(CHILD, "1").env("BASH_ENV", &startup) + .args([ + "--exact", + "local::shell::tests::bash_startup_environment_cannot_replace_the_requested_payload", + "--nocapture", + ]) + .env(CHILD, "1") + .env("BASH_ENV", &startup) .status()?; - assert!(status.success(), "startup environment changed the child payload result"); + assert!( + status.success(), + "startup environment changed the child payload result" + ); Ok(()) } diff --git a/crates/tinybox-module/src/tinybus_module/README.md b/crates/tinybox-module/src/tinybus_module/README.md index 75bd7195..29928094 100644 --- a/crates/tinybox-module/src/tinybus_module/README.md +++ b/crates/tinybox-module/src/tinybus_module/README.md @@ -300,7 +300,7 @@ Contract 1.10 adds `SelectShell`. The request carries command text and an option pipeline-status preservation flag. Unix selects Bash at a standard path with `set -o pipefail` by default, or sh without that Bash-only flag when Bash is absent. Unix uses non-login `-c` to avoid loading login profiles before the -payload. Windows selects cmd `/D /S /C`, disabling registry AutoRun hooks and +payload. Windows selects cmd `/D /E:ON /V:OFF /S /C`, disabling registry AutoRun hooks and stripping one explicit outer wrapper around the raw command-line payload. Quoted executable/argument pairs, redirection paths and percent expansion remain inside that wrapper. @@ -308,3 +308,7 @@ The result includes Windows bootstrap environment names, never their values. Selection does not execute a command, authorize access or provide confinement; hosts continue to execute through the module. Native providers can use the shared quote-preserving TinyBox command builder. + +Windows explicitly enables extensions and disables delayed expansion. +Unix builders remove BASH_ENV and ENV; ShellInvocation returns those removal +names. Providers apply them after approved environment overrides. diff --git a/crates/tinybox-module/tests/grants_manifest_e2e.rs b/crates/tinybox-module/tests/grants_manifest_e2e.rs index e2155646..1e8f956d 100644 --- a/crates/tinybox-module/tests/grants_manifest_e2e.rs +++ b/crates/tinybox-module/tests/grants_manifest_e2e.rs @@ -97,12 +97,13 @@ async fn compiled_manifest_admits_and_executes_grant_and_shell_discovery() if cfg!(windows) { assert_eq!(selection.flavor, tinybox_bus::ShellFlavor::Cmd); assert_eq!(selection.program, "cmd"); - assert_eq!(selection.arguments, ["/D", "/S", "/C"]); + assert_eq!(selection.arguments, ["/D", "/E:ON", "/V:OFF", "/S", "/C"]); assert_eq!(selection.raw_arguments.as_deref(), Some("\"false | true\"")); } else { assert_eq!(selection.flavor, tinybox_bus::ShellFlavor::Posix); assert_eq!(selection.arguments[0], "-c"); assert_eq!(selection.raw_arguments, None); + assert_eq!(selection.remove_environment, ["BASH_ENV", "ENV"]); } proxy.call::<()>("Shutdown", ()).await?; broker_task.abort(); diff --git a/docs/plans/shell-selection.md b/docs/plans/shell-selection.md index b3599462..6858b23f 100644 --- a/docs/plans/shell-selection.md +++ b/docs/plans/shell-selection.md @@ -11,7 +11,7 @@ shell execution is tracked separately and remains unfinished. 2. Implement platform selection and the native quote-preserving builder in crates/tinybox-host/src/local/shell.rs; centralize exports in src/lib.rs. Portable regressions in local/shell_tests.rs pin non-login startup, - Bash pipefail, preservation, sh fallback and cmd /D /S /C raw wrapping. + Bash pipefail, preservation, sh fallback and cmd /D /E:ON /V:OFF /S /C raw wrapping. The startup regressions failed before the review corrections. 3. Add module dispatch and manifest declaration in crates/tinybox-module/src/tinybus_module/mod.rs. Extend @@ -36,3 +36,7 @@ Hosted validation is reported on the pull request. The native consumer remains open: add a supervised module operation that accepts shell text directly, preserves raw Windows quoting and uses existing output/cancellation ownership. Do not mark that consumer objection resolved on discovery-only evidence. + +Windows explicitly enables extensions and disables delayed expansion. +Unix builders remove BASH_ENV and ENV; ShellInvocation returns those removal +names. Providers apply them after approved environment overrides. diff --git a/docs/specs/shell-selection.md b/docs/specs/shell-selection.md index 7d84f1c6..5f63344b 100644 --- a/docs/specs/shell-selection.md +++ b/docs/specs/shell-selection.md @@ -22,7 +22,7 @@ non-login -c. Bash prepends "set -o pipefail" unless preservation is requested; sh never receives that Bash-only prefix. For "false | true", Bash's default selection reports the failing stage; preservation retains the last-stage status. -Windows selects cmd with /D /S /C. /D suppresses registry AutoRun. /S strips +Windows selects cmd with /D /E:ON /V:OFF /S /C. /D suppresses registry AutoRun. /S strips exactly one supplied outer wrapper around the raw payload, leaving inner quoted executable paths, argument pairs and redirection paths intact. Native builders must use raw_arg for this payload rather than ordinary argument escaping. @@ -48,3 +48,7 @@ approved environment, working directory, pipes and lifecycle. There are no open decisions for discovery. The pending execution slice must retain process supervision and prove actual module execution on Windows. + +Windows explicitly enables extensions and disables delayed expansion. +Unix builders remove BASH_ENV and ENV; ShellInvocation returns those removal +names. Providers apply them after approved environment overrides. From 855c81c84b487d9039d1475bc8726fa716f5b34e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:19:54 +0300 Subject: [PATCH 11/95] chore: files changed crates/tinybox-module/tests/grants_manifest_e2e.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tests/grants_manifest_e2e.rs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/tinybox-module/tests/grants_manifest_e2e.rs b/crates/tinybox-module/tests/grants_manifest_e2e.rs index 8114fd93..f5792479 100644 --- a/crates/tinybox-module/tests/grants_manifest_e2e.rs +++ b/crates/tinybox-module/tests/grants_manifest_e2e.rs @@ -125,6 +125,38 @@ async fn compiled_manifest_admits_and_executes_grant_and_shell_discovery() "{include}: {result:?}" ); } + let workspace = tempfile::tempdir()?; + let state_dir = tempfile::tempdir()?; + let resource: tinybox_bus::ResourceId = proxy.call("Reserve", (tinybox_bus::ReserveRequest::Resource,)).await?; + let _: tinybox_bus::ResourceInfo = proxy.call("Create", (tinybox_bus::CreateRequest { + resource: resource.clone(), + backend: "passthrough".into(), + workspace: tinybox_bus::Workspace::Directory(workspace.path().to_str().ok_or("non-UTF8 fixture")?.into()), + ..Default::default() + },)).await?; + let process: tinybox_bus::ResourceId = proxy.call("Reserve", (tinybox_bus::ReserveRequest::Process(resource.clone()),)).await?; + let running: tinybox_bus::ProcessRef = proxy.call("StartShellExec", (serde_json::json!({ + "process":process, "resource":resource, + "shell":{"command":"echo native-fixture"}, + "state_dir":state_dir.path().to_str().ok_or("non-UTF8 fixture")?, + "timeout_ms":5000 + }),)).await?; + let batch = tokio::time::timeout(Duration::from_secs(10), async { + loop { + let batch: tinybox_bus::OutputBatch = proxy.call("ReadOutput", (running.clone(), 0_u64)).await?; + if batch.state != tinybox_bus::ExecutionState::Running { + return Ok::<_, tinybus::Error>(batch); + } + tokio::task::yield_now().await; + } + }).await??; + assert_eq!(batch.state, tinybox_bus::ExecutionState::Exited { exit_code: 0 }); + let stdout: Vec = batch.chunks.into_iter() + .filter(|chunk| chunk.stream == tinybox_bus::OutputStream::Stdout) + .flat_map(|chunk| chunk.bytes).collect(); + assert_eq!(String::from_utf8(stdout)?.trim(), "native-fixture"); + proxy.call::<()>("ReleaseOutput", (running,)).await?; + proxy.call::<()>("Close", (resource,)).await?; proxy.call::<()>("Shutdown", ()).await?; broker_task.abort(); Ok(()) From 996c75aa3752445ce58d9a18a3245edba0f7ee51 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:20:50 +0300 Subject: [PATCH 12/95] docs: add native shell execution plan and spec Document the design and rollout plan for running shell commands natively, covering the intended behaviour and the steps needed to implement it. Auto-committed-on: dragonfly Co-authored-by: Medulla --- docs/plans/native-shell-execution.md | 20 ++++++++++++++++ docs/specs/native-shell-execution.md | 35 ++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 docs/plans/native-shell-execution.md create mode 100644 docs/specs/native-shell-execution.md diff --git a/docs/plans/native-shell-execution.md b/docs/plans/native-shell-execution.md new file mode 100644 index 00000000..5535ba9c --- /dev/null +++ b/docs/plans/native-shell-execution.md @@ -0,0 +1,20 @@ +# Native shell execution implementation order + +[Specification](../specs/native-shell-execution.md). + +1. Reproduce the absent module consumer through the unconditional real-artifact + test in crates/tinybox-module/tests/grants_manifest_e2e.rs. +2. Add ShellExecRequest in crates/tinybox-bus/src/shell_execution.rs, append + StartShellExec and contract 1.11, and add TimedOut output failure. +3. Extend the existing collectors in tinybox-host/src/limited/{mod,jailed}.rs + with observed standard-command bridges; retain owned kill/reap behavior. +4. Implement validation, environment and scratch ownership in the module's + native_shell directory. Reuse native_jail preparation for strict confinement. +5. Integrate shell collectors and exact request identity in resources_stream.rs. + Reuse existing reservations, output journals and resource cleanup. +6. Add behavioral tests beside each module and actual artifact execution. + Validate formatting, affected/full tests, Clippy, build, coverage and + cross-platform CI through the workflow runner. + +Implementation remains in progress. No host gitlink or availability claim is +part of this upstream slice. diff --git a/docs/specs/native-shell-execution.md b/docs/specs/native-shell-execution.md new file mode 100644 index 00000000..4edff9a0 --- /dev/null +++ b/docs/specs/native-shell-execution.md @@ -0,0 +1,35 @@ +# Native shell execution + +Status: accepted scope, implementation in progress. Owner: TinyBox. +Sequence: [native shell plan](../plans/native-shell-execution.md). + +Contract 1.11 appends StartShellExec(ShellExecRequest) -> ProcessRef. +The caller supplies process/resource reservations, SelectShellRequest, optional +cwd, approved extra environment, stdin, native jail configuration, timeout +(default 30 seconds, positive, at most 24 hours), and approved existing absolute +state_dir. This is local passthrough execution only; other providers and +unavailable native jails refuse explicitly. + +The module owns shell selection and raw Windows quoting, snapshots only the +standard execution passthrough and Windows bootstrap names, rejects empty +present passthrough values, applies approved overrides, and finally removes +startup-hook variables. It clears the rest of the ambient environment. + +Each execution owns a private scratch directory under +state_dir/artifacts/sandbox-scratch. TMPDIR, TEMP and TMP default to it unless +explicitly overridden. Approved state directory ACLs must be private on Windows; +the host owns approval of state_dir. Native jails add this module-created +scratch as a writable grant after validating caller grants and enforcement. + +Capture uses owned pipes and the existing combined output budget, not shell +redirection. ReadOutput, Cancel, ReleaseOutput, Close and Shutdown retain their +cleanup barriers. Timeouts drop the waiter, wait for supervised kill/reap and +then publish TimedOut. Cleanup failures retain ownership and scratch for retry. +Scratch survives through journal retention and is reclaimed after acknowledged +release/close. Exact original request identity makes retry idempotent even if +the parent environment changed; mixing shell/argv reservations is refused. + +Acceptance proves actual compiled-module execution on Windows and Unix, +quoting, environment isolation, scratch retention/cleanup, timeout/cancellation, +bounded output, identity replay, admission refusal and unavailable confinement. +OpenHuman host adaptation waits for an available released module. From 59787fc151cea5d1f750149a147a5211fd49f355 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:23:13 +0300 Subject: [PATCH 13/95] feat(bus): add native shell execution contract Add a StartShellExec method and ShellExecRequest type to the host vocabulary, bumping the contract to 1.11, and expose a TimedOut execution failure. The limited and jailed hosts gain a run_command_observed entry point so a prepared command can be collected under the same bounded output, timeout and cleanup ownership as argv execution. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-bus/src/lib.rs | 8 +++++- crates/tinybox-bus/src/lib_tests.rs | 24 ++++++++++++++--- crates/tinybox-bus/src/output.rs | 2 ++ crates/tinybox-bus/src/shell_execution.rs | 33 +++++++++++++++++++++++ crates/tinybox-host/src/limited/jailed.rs | 28 ++++++++++++++++++- crates/tinybox-host/src/limited/mod.rs | 30 +++++++++++++++++++-- 6 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 crates/tinybox-bus/src/shell_execution.rs diff --git a/crates/tinybox-bus/src/lib.rs b/crates/tinybox-bus/src/lib.rs index 77ee30b6..3aa94991 100644 --- a/crates/tinybox-bus/src/lib.rs +++ b/crates/tinybox-bus/src/lib.rs @@ -22,6 +22,8 @@ mod jail; pub use jail::{JailEnforcement, NativeJailConfig}; mod shell_selection; +mod shell_execution; +pub use shell_execution::ShellExecRequest; pub use shell_selection::{SelectShellRequest, ShellFlavor, ShellInvocation}; mod shell; @@ -44,7 +46,7 @@ pub use files::{ /// 1.7 adds explicitly approved native directory-jail execution. 1.8 adds a /// bounded, module-owned one-shot Docker operation. 1.9 adds local grant discovery. /// 1.10 adds platform shell selection. -pub const CONTRACT_VERSION: (u32, u32) = (1, 10); +pub const CONTRACT_VERSION: (u32, u32) = (1, 11); /// Whether this host vocabulary can bind to a module's advertised version. /// The major must match and the module's minor must include every host member. @@ -116,6 +118,7 @@ pub const METHODS: &[&str] = &[ "RunDocker", "ResolveLocalGrants", "SelectShell", + "StartShellExec", ]; /// Facts about the detected native directory-jail backend. @@ -510,3 +513,6 @@ mod tests; mod output; pub use output::{ExecutionFailure, ExecutionState, OutputBatch, OutputChunk, OutputStream}; + +/// Invalid native shell execution inputs. +pub const INVALID_SHELL_EXEC: &str = "ai.tinyhumans.tinybox.Error.InvalidShellExec"; diff --git a/crates/tinybox-bus/src/lib_tests.rs b/crates/tinybox-bus/src/lib_tests.rs index 3c5d53c0..e49881e8 100644 --- a/crates/tinybox-bus/src/lib_tests.rs +++ b/crates/tinybox-bus/src/lib_tests.rs @@ -46,8 +46,8 @@ fn docker_run_vocabulary_is_typed_bounded_and_additive() -> Result<(), serde_jso timeout_ms: 120_000, }; assert_eq!(METHODS[27], "RunDocker"); - assert_eq!(METHODS.last(), Some(&"SelectShell")); - assert_eq!(CONTRACT_VERSION, (1, 10)); + assert_eq!(METHODS.last(), Some(&"StartShellExec")); + assert_eq!(CONTRACT_VERSION, (1, 11)); assert_eq!(MAX_DOCKER_RUN_TIMEOUT_MS, 86_400_000); let serialized = serde_json::to_value(&request)?; assert_eq!(serialized["read_only_rootfs"], false); @@ -123,7 +123,7 @@ fn host_gateway_and_docker_facts_are_additive_and_default_safely() -> Result<(), #[test] fn version_and_capability_wire_snapshot_preserve_contract_compatibility() -> Result<(), serde_json::Error> { - assert_eq!(CONTRACT_VERSION, (1, 10)); + assert_eq!(CONTRACT_VERSION, (1, 11)); assert!(is_compatible(CONTRACT_VERSION)); assert!(!is_compatible((1, 7))); assert!(!is_compatible((1, 6))); @@ -290,3 +290,21 @@ fn older_shell_invocations_default_the_startup_removal_vocabulary() -> Result<() assert_eq!(invocation.remove_environment, Vec::::new()); Ok(()) } + + +#[test] +fn native_shell_wire_defaults_close_stdin_and_keep_a_bounded_runtime() +-> Result<(), serde_json::Error> { + let request: ShellExecRequest = serde_json::from_value(serde_json::json!({ + "process":"process", "resource":"resource", "shell":{"command":"true"}, + "state_dir":"/state" + }))?; + assert_eq!(request.timeout_ms, 30_000); + assert_eq!(request.stdin, None); + assert_eq!(request.cwd, None); + assert_eq!(request.jail, None); + assert_eq!(request.extra_env, std::collections::BTreeMap::new()); + assert_eq!(METHODS[30], "StartShellExec"); + assert!(!is_compatible((1, 10))); + Ok(()) +} diff --git a/crates/tinybox-bus/src/output.rs b/crates/tinybox-bus/src/output.rs index 1218cb5b..562fc5e8 100644 --- a/crates/tinybox-bus/src/output.rs +++ b/crates/tinybox-bus/src/output.rs @@ -27,6 +27,8 @@ pub struct OutputChunk { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ExecutionFailure { + /// The runtime budget elapsed; supervised native cleanup is acknowledged. + TimedOut, /// The native operation could not start. StartFailed, /// Combined output exceeded the execution budget. diff --git a/crates/tinybox-bus/src/shell_execution.rs b/crates/tinybox-bus/src/shell_execution.rs new file mode 100644 index 00000000..8b7c8705 --- /dev/null +++ b/crates/tinybox-bus/src/shell_execution.rs @@ -0,0 +1,33 @@ +//! Reserved native shell execution; authorization remains caller-owned. +use crate::{NativeJailConfig, ResourceId, SelectShellRequest}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// Start a local shell under module-owned supervision and output replay. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ShellExecRequest { + /// Caller-known process reservation. + pub process: ResourceId, + /// Existing local passthrough resource. + pub resource: ResourceId, + /// Shell payload and pipeline-status convention. + pub shell: SelectShellRequest, + /// Working directory inside the resource; absent means its root. + #[serde(default)] + pub cwd: Option, + /// Approved overrides on the module's safe parent environment snapshot. + #[serde(default)] + pub extra_env: BTreeMap, + /// Approved existing absolute private state directory for owned scratch. + pub state_dir: String, + /// Positive runtime budget, default thirty seconds, at most twenty-four hours. + #[serde(default = "default_timeout")] + pub timeout_ms: u64, + /// Binary input; absent closes stdin. + #[serde(default)] + pub stdin: Option>, + /// Explicit native confinement; absent is ordinary local execution. + #[serde(default)] + pub jail: Option, +} +const fn default_timeout() -> u64 { 30_000 } diff --git a/crates/tinybox-host/src/limited/jailed.rs b/crates/tinybox-host/src/limited/jailed.rs index 636e5aae..5f64c0c2 100644 --- a/crates/tinybox-host/src/limited/jailed.rs +++ b/crates/tinybox-host/src/limited/jailed.rs @@ -171,6 +171,33 @@ impl JailLocalHost { let mut command = crate::LocalHost::command(request)?.into_std(); // The host explicitly supplies the approved environment for jailed code. command.env_clear().envs(&request.env); + self.collect_command(command, request.stdin.clone(), observer).await + } + + /// Collect a prepared shell inside this host's explicit native jail. + /// The caller clears and supplies the approved environment before invocation. + /// # Errors + /// Reports unavailable spawn, output bounds and retained kill/reap failures. + pub async fn run_command_observed( + &self, + mut command: std::process::Command, + payload: Option>, + observer: Arc, + ) -> Result { + command.stdin(if payload.is_some() { + std::process::Stdio::piped() + } else { + std::process::Stdio::null() + }); + self.collect_command(command, payload, Some(observer)).await + } + + async fn collect_command( + &self, + mut command: std::process::Command, + payload: Option>, + observer: Option>, + ) -> Result { #[cfg(unix)] { use std::os::unix::process::CommandExt; @@ -181,7 +208,6 @@ impl JailLocalHost { .stderr(std::process::Stdio::piped()); let jail = self.jail.clone(); let backend = self.backend.clone(); - let payload = request.stdin.clone(); let state = self.state.clone(); let limit = self.limit; let runtime = tokio::runtime::Handle::try_current() diff --git a/crates/tinybox-host/src/limited/mod.rs b/crates/tinybox-host/src/limited/mod.rs index 6ece7977..dfb00d1f 100644 --- a/crates/tinybox-host/src/limited/mod.rs +++ b/crates/tinybox-host/src/limited/mod.rs @@ -152,7 +152,34 @@ impl LimitedLocalHost { request: &ExecRequest, observer: Option>, ) -> Result { - let mut command = LocalHost::command(request)?; + self.collect_command(LocalHost::command(request)?, request.stdin.clone(), observer).await + } + + /// Collect a prepared native command with the same bounded ownership as argv execution. + /// The provider supplies its approved environment and working directory. + /// # Errors + /// Reports spawn, pipe, output-budget and retained native cleanup failures. + pub async fn run_command_observed( + &self, + command: std::process::Command, + payload: Option>, + observer: Arc, + ) -> Result { + let mut command = tokio::process::Command::from(command); + command.kill_on_drop(true).stdin(if payload.is_some() { + std::process::Stdio::piped() + } else { + std::process::Stdio::null() + }); + self.collect_command(command, payload, Some(observer)).await + } + + async fn collect_command( + &self, + mut command: tokio::process::Command, + payload: Option>, + observer: Option>, + ) -> Result { process::prepare(&mut command)?; command .stdout(std::process::Stdio::piped()) @@ -169,7 +196,6 @@ impl LimitedLocalHost { let stdout = pipe(process::take_stdout(&mut child), "stdout")?; let stderr = pipe(process::take_stderr(&mut child), "stderr")?; let stdin = process::take_stdin(&mut child); - let payload = request.stdin.clone(); let budget = Arc::new(AtomicUsize::new(0)); let limit = self.limit; let state = self.state.clone(); From 3f37d5761413efee43ae9bb94b41a30f0671f583 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:29:29 +0300 Subject: [PATCH 14/95] chore: files changed crates/tinybox-bus/src/lib.rs,crates/tinybox-bus/src/lib_tests.rs,crates/tinybo Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-bus/src/lib.rs | 2 +- crates/tinybox-bus/src/lib_tests.rs | 1 - crates/tinybox-bus/src/shell_execution.rs | 4 +- crates/tinybox-host/src/limited/jailed.rs | 3 +- crates/tinybox-host/src/limited/mod.rs | 7 +- crates/tinybox-module/Cargo.toml | 5 +- .../tinybox-module/src/tinybus_module/mod.rs | 15 +- .../src/tinybus_module/native_shell/mod.rs | 165 ++++++++++++++++++ .../src/tinybus_module/resources_stream.rs | 89 +++++++++- .../tests/grants_manifest_e2e.rs | 65 +++++-- 10 files changed, 320 insertions(+), 36 deletions(-) create mode 100644 crates/tinybox-module/src/tinybus_module/native_shell/mod.rs diff --git a/crates/tinybox-bus/src/lib.rs b/crates/tinybox-bus/src/lib.rs index 3aa94991..fdb7a21e 100644 --- a/crates/tinybox-bus/src/lib.rs +++ b/crates/tinybox-bus/src/lib.rs @@ -21,8 +21,8 @@ pub use grants::{LocalGrantConfig, LocalGrants, ProtectedPaths, ResolveLocalGran mod jail; pub use jail::{JailEnforcement, NativeJailConfig}; -mod shell_selection; mod shell_execution; +mod shell_selection; pub use shell_execution::ShellExecRequest; pub use shell_selection::{SelectShellRequest, ShellFlavor, ShellInvocation}; diff --git a/crates/tinybox-bus/src/lib_tests.rs b/crates/tinybox-bus/src/lib_tests.rs index e49881e8..55f21410 100644 --- a/crates/tinybox-bus/src/lib_tests.rs +++ b/crates/tinybox-bus/src/lib_tests.rs @@ -291,7 +291,6 @@ fn older_shell_invocations_default_the_startup_removal_vocabulary() -> Result<() Ok(()) } - #[test] fn native_shell_wire_defaults_close_stdin_and_keep_a_bounded_runtime() -> Result<(), serde_json::Error> { diff --git a/crates/tinybox-bus/src/shell_execution.rs b/crates/tinybox-bus/src/shell_execution.rs index 8b7c8705..0e1fa6a6 100644 --- a/crates/tinybox-bus/src/shell_execution.rs +++ b/crates/tinybox-bus/src/shell_execution.rs @@ -30,4 +30,6 @@ pub struct ShellExecRequest { #[serde(default)] pub jail: Option, } -const fn default_timeout() -> u64 { 30_000 } +const fn default_timeout() -> u64 { + 30_000 +} diff --git a/crates/tinybox-host/src/limited/jailed.rs b/crates/tinybox-host/src/limited/jailed.rs index 5f64c0c2..27d5d369 100644 --- a/crates/tinybox-host/src/limited/jailed.rs +++ b/crates/tinybox-host/src/limited/jailed.rs @@ -171,7 +171,8 @@ impl JailLocalHost { let mut command = crate::LocalHost::command(request)?.into_std(); // The host explicitly supplies the approved environment for jailed code. command.env_clear().envs(&request.env); - self.collect_command(command, request.stdin.clone(), observer).await + self.collect_command(command, request.stdin.clone(), observer) + .await } /// Collect a prepared shell inside this host's explicit native jail. diff --git a/crates/tinybox-host/src/limited/mod.rs b/crates/tinybox-host/src/limited/mod.rs index dfb00d1f..15d310f1 100644 --- a/crates/tinybox-host/src/limited/mod.rs +++ b/crates/tinybox-host/src/limited/mod.rs @@ -152,7 +152,12 @@ impl LimitedLocalHost { request: &ExecRequest, observer: Option>, ) -> Result { - self.collect_command(LocalHost::command(request)?, request.stdin.clone(), observer).await + self.collect_command( + LocalHost::command(request)?, + request.stdin.clone(), + observer, + ) + .await } /// Collect a prepared native command with the same bounded ownership as argv execution. diff --git a/crates/tinybox-module/Cargo.toml b/crates/tinybox-module/Cargo.toml index 5509873a..0a60adf9 100644 --- a/crates/tinybox-module/Cargo.toml +++ b/crates/tinybox-module/Cargo.toml @@ -41,9 +41,10 @@ tinybox-microvm.workspace = true tinybus.workspace = true tinybus-module.workspace = true -[dev-dependencies] -# Native cancellation verification uses isolated readiness files, not wall-clock sleeps. +# Private shell scratch is owned until supervised process cleanup completes. tempfile = "3" + +[dev-dependencies] # Deterministic delayed-host fixtures implement the existing object-safe host trait. async-trait.workspace = true # Module integration tests exercise the real asynchronous in-memory TinyBus. diff --git a/crates/tinybox-module/src/tinybus_module/mod.rs b/crates/tinybox-module/src/tinybus_module/mod.rs index 4e4c4f67..1ff86fc5 100644 --- a/crates/tinybox-module/src/tinybus_module/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/mod.rs @@ -18,6 +18,7 @@ use tinybox_bus::{ mod docker; mod native_jail; +mod native_shell; mod output; mod resources; @@ -278,6 +279,18 @@ impl BoxService { .await } + /// Start a shell with module-owned environment, scratch and supervision. + async fn start_shell_exec( + &self, + request: tinybox_bus::ShellExecRequest, + ) -> TinyBusResult { + let resources = self.resources.clone(); + finish_operation(tokio::spawn(async move { + resources.start_shell_exec(request).await + })) + .await + } + /// Run one Docker-isolated shell command with bounded output and runtime. async fn run_docker( &self, @@ -467,7 +480,7 @@ tinybus_module::module_export_optional_static! { setup = setup, worker_threads = 1, provides = ["ai.tinyhumans.tinybox.Box"], - methods = ["Describe", "Create", "Exec", "Inspect", "Close", "Spawn", "IsRunning", "Cancel", "AnalyzeShell", "Reserve", "Capabilities", "Shutdown", "Forward", "CloseForward", "JailStatus", "AnalyzeCommand", "BeginFileRead", "ReadFileChunk", "FinishFileRead", "BeginFileWrite", "WriteFileChunk", "FinishFileWrite", "AbortFileWrite", "StartExec", "ReadOutput", "ReleaseOutput", "StartJailedExec", "RunDocker", "ResolveLocalGrants", "SelectShell"], + methods = ["Describe", "Create", "Exec", "Inspect", "Close", "Spawn", "IsRunning", "Cancel", "AnalyzeShell", "Reserve", "Capabilities", "Shutdown", "Forward", "CloseForward", "JailStatus", "AnalyzeCommand", "BeginFileRead", "ReadFileChunk", "FinishFileRead", "BeginFileWrite", "WriteFileChunk", "FinishFileWrite", "AbortFileWrite", "StartExec", "ReadOutput", "ReleaseOutput", "StartJailedExec", "RunDocker", "ResolveLocalGrants", "SelectShell", "StartShellExec"], signals = [], requires = [], optional = [], diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs new file mode 100644 index 00000000..38ff55c6 --- /dev/null +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs @@ -0,0 +1,165 @@ +//! Native shell preparation; the retained context owns private scratch and cleanup. +use std::{ + path::{Component, Path}, + sync::Arc, +}; +use tinybox_bus::ShellExecRequest; +use tinybus::Result; + +pub(super) struct ShellContext { + request: ShellExecRequest, + environment: std::collections::BTreeMap, + _scratch: tempfile::TempDir, + host: ShellHost, +} +enum ShellHost { + Local(tinybox_host::LimitedLocalHost), + Jailed(tinybox_host::JailLocalHost), +} +impl ShellContext { + pub(super) fn prepare( + request: ShellExecRequest, + root: &Path, + resolved: &tinybox_core::ExecRequest, + ) -> Result> { + if request.timeout_ms == 0 + || request.timeout_ms > 86_400_000 + || request.shell.command.contains('\0') + { + return Err(invalid()); + } + let state = Path::new(&request.state_dir); + if request.state_dir.len() > tinybox_bus::MAX_FILE_PATH_BYTES + || request.state_dir.contains('\0') + || !state.is_absolute() + || state + .components() + .any(|c| matches!(c, Component::ParentDir)) + { + return Err(invalid()); + } + let state = state.canonicalize().map_err(|_| invalid())?; + if !state.is_dir() { + return Err(invalid()); + } + let scratch_root = state.join("artifacts/sandbox-scratch"); + std::fs::create_dir_all(&scratch_root).map_err(|_| invalid())?; + let scratch = tempfile::Builder::new() + .prefix("shell-") + .tempdir_in(scratch_root) + .map_err(|_| invalid())?; + let selection = tinybox_host::select_shell(&request.shell); + let mut environment = std::collections::BTreeMap::new(); + for name in [ + "PATH", "HOME", "TERM", "LANG", "LC_ALL", "LC_CTYPE", "USER", "SHELL", "TMPDIR", + ] + .into_iter() + .chain(selection.bootstrap_environment.iter().map(String::as_str)) + { + if let Ok(value) = std::env::var(name) { + if value.is_empty() { + return Err(invalid()); + } + environment.insert(name.to_owned(), value); + } + } + for (name, value) in &request.extra_env { + if name.is_empty() || name.contains(['=', '\0']) || value.contains('\0') { + return Err(invalid()); + } + if cfg!(windows) { + environment.retain(|key, _| !key.eq_ignore_ascii_case(name)); + } + environment.insert(name.clone(), value.clone()); + } + for name in ["TMPDIR", "TEMP", "TMP"] { + if !request.extra_env.keys().any(|key| { + if cfg!(windows) { + key.eq_ignore_ascii_case(name) + } else { + key == name + } + }) { + environment.insert( + name.into(), + scratch.path().to_str().ok_or_else(invalid)?.into(), + ); + } + } + for name in selection.remove_environment { + environment.remove(&name); + } + let host = if let Some(config) = &request.jail { + let backend = tinybox_jail::default_backend(); + let mut jail = super::native_jail::prepare(root, config, resolved, backend.as_ref())?; + jail.read_write.push(scratch.path().to_path_buf()); + if config.enforcement == tinybox_bus::JailEnforcement::Enforced { + backend.require(&jail).map_err(|_| invalid())?; + } else if backend + .plan_check(&jail) + .constraints + .iter() + .any(|(_, support)| *support == tinybox_core::Enforcement::Unsupported) + { + return Err(invalid()); + } + ShellHost::Jailed( + tinybox_host::JailLocalHost::with_backend( + jail, + backend, + tinybox_bus::MAX_OUTPUT_BYTES, + ) + .map_err(|_| invalid())?, + ) + } else { + ShellHost::Local(tinybox_host::LimitedLocalHost::new( + tinybox_bus::MAX_OUTPUT_BYTES, + )) + }; + Ok(Arc::new(Self { + request, + environment, + _scratch: scratch, + host, + })) + } + pub(super) async fn run( + &self, + resolved: &tinybox_core::ExecRequest, + observer: Arc, + ) -> tinybox_core::Result { + let mut command = tinybox_host::build_shell_command(&self.request.shell); + command.env_clear().envs(&self.environment); + if let Some(cwd) = &resolved.cwd { + command.current_dir(cwd); + } + match &self.host { + ShellHost::Local(host) => { + host.run_command_observed(command, self.request.stdin.clone(), observer) + .await + } + ShellHost::Jailed(host) => { + host.run_command_observed(command, self.request.stdin.clone(), observer) + .await + } + } + } + pub(super) fn pending(&self) -> bool { + match &self.host { + ShellHost::Local(host) => host.has_pending_cleanup(), + ShellHost::Jailed(host) => host.has_pending_cleanup(), + } + } + pub(super) async fn drain(&self) -> tinybox_core::Result<()> { + match &self.host { + ShellHost::Local(host) => host.drain_checked().await, + ShellHost::Jailed(host) => host.drain_checked().await, + } + } +} +fn invalid() -> tinybus::Error { + super::resources::failure( + tinybox_bus::INVALID_SHELL_EXEC, + "invalid native shell environment, state directory or runtime budget", + ) +} diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index 0a17023c..aaceb75a 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -10,6 +10,7 @@ const MAX_STREAM_EXECUTIONS: usize = 32; pub(super) struct StreamExecution { request: SpawnRequest, + shell: Option, jail: Option, journal: Arc, task: Mutex>>, @@ -17,6 +18,7 @@ pub(super) struct StreamExecution { } #[derive(Clone)] enum Collector { + Shell(Arc), Host( Arc, Option>, @@ -35,6 +37,7 @@ impl Collector { observer: Arc, ) -> tinybox_core::Result { match self { + Self::Shell(shell) => shell.run(command, observer).await, Self::Host(host, _) => host.run_observed(command, observer).await, Self::Jailed(host) => host.run_observed(command, observer).await, Self::Sandbox(sandbox, id, _) => sandbox.exec_observed(id, command, observer).await, @@ -42,6 +45,7 @@ impl Collector { } fn pending(&self) -> bool { match self { + Self::Shell(shell) => shell.pending(), Self::Host(host, native) => { host.has_pending_observed_cleanup() || native @@ -66,6 +70,7 @@ impl Collector { } async fn drain(&self) -> tinybox_core::Result<()> { match self { + Self::Shell(shell) => shell.drain().await, Self::Host(host, native) => { let remote = host.retry_observed_cleanup().await; if let Some(native) = native { @@ -127,21 +132,40 @@ impl StreamExecution { resolved: tinybox_core::ExecRequest, collector: Collector, jail: Option, + shell: Option, ) -> Self { let journal = Arc::new(OutputJournal::default()); let observer = journal.clone(); let runner = collector.clone(); + let timeout = shell.as_ref().map(|request| request.timeout_ms); let task = tokio::spawn(async move { let _completion = CompletionGuard(observer.clone()); - let result = runner.run(&resolved, observer.clone()).await; + let result = if let Some(timeout) = timeout { + match tokio::time::timeout( + std::time::Duration::from_millis(timeout), + runner.run(&resolved, observer.clone()), + ) + .await + { + Ok(result) => Some(result), + Some(Err(_)) | None => { + let _cleanup = runner.drain().await; + None + } + } + } else { + Some(runner.run(&resolved, observer.clone()).await) + }; if runner.pending() { observer.finish_failed(ExecutionFailure::CleanupFailed); } else if observer.cancel_requested() { observer.finish_exit(0); + } else if result.is_none() { + observer.finish_failed(ExecutionFailure::TimedOut); } else { match result { - Ok(output) => observer.finish_exit(output.exit_code), - Err(tinybox_core::Error::OutputLimitExceeded { .. }) => { + Some(Ok(output)) => observer.finish_exit(output.exit_code), + Some(Err(tinybox_core::Error::OutputLimitExceeded { .. })) => { observer.finish_failed(ExecutionFailure::OutputLimit); } Err(_) => observer.finish_failed(ExecutionFailure::BackendFailed), @@ -150,6 +174,7 @@ impl StreamExecution { }); Self { request, + shell, jail, journal, task: Mutex::new(Some(task)), @@ -200,19 +225,37 @@ impl Resources { &self, request: SpawnRequest, ) -> Result { - self.start_with_jail(request, None).await + self.start_with_jail(request, None, None).await } pub(in crate::tinybus_module) async fn start_jailed_exec( &self, request: SpawnRequest, config: tinybox_bus::NativeJailConfig, ) -> Result { - self.start_with_jail(request, Some(config)).await + self.start_with_jail(request, Some(config), None).await + } + pub(in crate::tinybus_module) async fn start_shell_exec( + &self, + shell: tinybox_bus::ShellExecRequest, + ) -> Result { + let request = SpawnRequest { + process: shell.process.clone(), + command: tinybox_bus::ExecRequest { + resource: shell.resource.clone(), + argv: vec![tinybox_host::select_shell(&shell.shell).program], + cwd: shell.cwd.clone(), + env: shell.extra_env.clone(), + stdin: shell.stdin.clone(), + }, + }; + self.start_with_jail(request, shell.jail.clone(), Some(shell)) + .await } async fn start_with_jail( &self, request: SpawnRequest, jail: Option, + shell: Option, ) -> Result { validate_id(&request.process)?; validate_id(&request.command.resource)?; @@ -221,7 +264,10 @@ impl Resources { process: request.process.clone(), }; if let Some(existing) = self.stream(&process)? { - return if existing.request == request && existing.jail == jail { + return if existing.request == request + && existing.jail == jail + && existing.shell == shell + { Ok(process) } else { Err(failure( @@ -263,7 +309,27 @@ impl Resources { } command }; - let collector = select_collector(entry, jail.as_ref(), &resolved)?; + let collector = if let Some(shell) = &shell { + if !entry.native_host || entry.local_sandbox.is_none() { + return Err(failure( + tinybox_bus::UNSUPPORTED_OPERATION, + "native shell requires local passthrough execution", + )); + } + let root = entry.workspace_root.as_ref().ok_or_else(|| { + failure( + tinybox_bus::UNSUPPORTED_OPERATION, + "native shell requires directory resource", + ) + })?; + Collector::Shell(super::super::native_shell::ShellContext::prepare( + shell.clone(), + root, + &resolved, + )?) + } else { + select_collector(entry, jail.as_ref(), &resolved)? + }; let mut state = self.state.lock().await; let executions = self .executions @@ -275,7 +341,10 @@ impl Resources { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if let Some(existing) = streams.get(&process.process) { - return if existing.request == request && existing.jail == jail { + return if existing.request == request + && existing.jail == jail + && existing.shell == shell + { Ok(process) } else { Err(failure( @@ -309,7 +378,9 @@ impl Resources { )?; streams.insert( process.process.clone(), - Arc::new(StreamExecution::start(request, resolved, collector, jail)), + Arc::new(StreamExecution::start( + request, resolved, collector, jail, shell, + )), ); Ok(process) } diff --git a/crates/tinybox-module/tests/grants_manifest_e2e.rs b/crates/tinybox-module/tests/grants_manifest_e2e.rs index f5792479..b68801fb 100644 --- a/crates/tinybox-module/tests/grants_manifest_e2e.rs +++ b/crates/tinybox-module/tests/grants_manifest_e2e.rs @@ -127,33 +127,60 @@ async fn compiled_manifest_admits_and_executes_grant_and_shell_discovery() } let workspace = tempfile::tempdir()?; let state_dir = tempfile::tempdir()?; - let resource: tinybox_bus::ResourceId = proxy.call("Reserve", (tinybox_bus::ReserveRequest::Resource,)).await?; - let _: tinybox_bus::ResourceInfo = proxy.call("Create", (tinybox_bus::CreateRequest { - resource: resource.clone(), - backend: "passthrough".into(), - workspace: tinybox_bus::Workspace::Directory(workspace.path().to_str().ok_or("non-UTF8 fixture")?.into()), - ..Default::default() - },)).await?; - let process: tinybox_bus::ResourceId = proxy.call("Reserve", (tinybox_bus::ReserveRequest::Process(resource.clone()),)).await?; - let running: tinybox_bus::ProcessRef = proxy.call("StartShellExec", (serde_json::json!({ - "process":process, "resource":resource, - "shell":{"command":"echo native-fixture"}, - "state_dir":state_dir.path().to_str().ok_or("non-UTF8 fixture")?, - "timeout_ms":5000 - }),)).await?; + let resource: tinybox_bus::ResourceId = proxy + .call("Reserve", (tinybox_bus::ReserveRequest::Resource,)) + .await?; + let _: tinybox_bus::ResourceInfo = proxy + .call( + "Create", + (tinybox_bus::CreateRequest { + resource: resource.clone(), + backend: "passthrough".into(), + workspace: tinybox_bus::Workspace::Directory( + workspace.path().to_str().ok_or("non-UTF8 fixture")?.into(), + ), + ..Default::default() + },), + ) + .await?; + let process: tinybox_bus::ResourceId = proxy + .call( + "Reserve", + (tinybox_bus::ReserveRequest::Process(resource.clone()),), + ) + .await?; + let running: tinybox_bus::ProcessRef = proxy + .call( + "StartShellExec", + (serde_json::json!({ + "process":process, "resource":resource, + "shell":{"command":"echo native-fixture"}, + "state_dir":state_dir.path().to_str().ok_or("non-UTF8 fixture")?, + "timeout_ms":5000 + }),), + ) + .await?; let batch = tokio::time::timeout(Duration::from_secs(10), async { loop { - let batch: tinybox_bus::OutputBatch = proxy.call("ReadOutput", (running.clone(), 0_u64)).await?; + let batch: tinybox_bus::OutputBatch = + proxy.call("ReadOutput", (running.clone(), 0_u64)).await?; if batch.state != tinybox_bus::ExecutionState::Running { return Ok::<_, tinybus::Error>(batch); } tokio::task::yield_now().await; } - }).await??; - assert_eq!(batch.state, tinybox_bus::ExecutionState::Exited { exit_code: 0 }); - let stdout: Vec = batch.chunks.into_iter() + }) + .await??; + assert_eq!( + batch.state, + tinybox_bus::ExecutionState::Exited { exit_code: 0 } + ); + let stdout: Vec = batch + .chunks + .into_iter() .filter(|chunk| chunk.stream == tinybox_bus::OutputStream::Stdout) - .flat_map(|chunk| chunk.bytes).collect(); + .flat_map(|chunk| chunk.bytes) + .collect(); assert_eq!(String::from_utf8(stdout)?.trim(), "native-fixture"); proxy.call::<()>("ReleaseOutput", (running,)).await?; proxy.call::<()>("Close", (resource,)).await?; From 651a806eb3113d21aa003dd07a90d9687f64ff8b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:29:58 +0300 Subject: [PATCH 15/95] fix(tinybus-module): correct match arms for stream execution result The result of the awaited stream call is an Option, so the error and empty cases must be matched as Some(Err(_)) | None rather than Err(_). The previous arms were swapped, causing the wrong failure handling to run. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-module/src/tinybus_module/resources_stream.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index aaceb75a..a3f2ece5 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -148,7 +148,7 @@ impl StreamExecution { .await { Ok(result) => Some(result), - Some(Err(_)) | None => { + Err(_) => { let _cleanup = runner.drain().await; None } @@ -168,7 +168,7 @@ impl StreamExecution { Some(Err(tinybox_core::Error::OutputLimitExceeded { .. })) => { observer.finish_failed(ExecutionFailure::OutputLimit); } - Err(_) => observer.finish_failed(ExecutionFailure::BackendFailed), + Some(Err(_)) | None => observer.finish_failed(ExecutionFailure::BackendFailed), } } }); From 00adb9d8a26c36baf3c9bdfaada9344a761e8152 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:32:00 +0300 Subject: [PATCH 16/95] test(shell): cover inherited bash options not aborting payload Add a regression test that runs the shell command builder in a child process with SHELLOPTS and BASHOPTS set, verifying inherited errexit, nounset, and failglob options do not change the semantics of the requested payload. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-host/src/local/shell_tests.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/tinybox-host/src/local/shell_tests.rs b/crates/tinybox-host/src/local/shell_tests.rs index d7e37d35..f98b2dfc 100644 --- a/crates/tinybox-host/src/local/shell_tests.rs +++ b/crates/tinybox-host/src/local/shell_tests.rs @@ -140,3 +140,21 @@ fn bash_startup_environment_cannot_replace_the_requested_payload() ); Ok(()) } + +#[test] +#[cfg(unix)] +fn inherited_bash_options_cannot_abort_the_requested_payload() -> Result<(), Box> { + const CHILD: &str = "TINYBOX_SHELL_OPTIONS_REGRESSION_CHILD"; + if std::env::var_os(CHILD).is_some() { + let request = SelectShellRequest { command: "false; echo reached".into(), preserve_pipeline_status: false }; + let output = build_shell_command(&request).output()?; + assert!(output.status.success()); + assert_eq!(output.stdout, b"reached\n"); + return Ok(()); + } + let status = std::process::Command::new(std::env::current_exe()?) + .args(["--exact", "local::shell::tests::inherited_bash_options_cannot_abort_the_requested_payload", "--nocapture"]) + .env(CHILD, "1").env("SHELLOPTS", "errexit:nounset").env("BASHOPTS", "failglob").status()?; + assert!(status.success(), "inherited Bash options changed payload semantics"); + Ok(()) +} From 6fe8ee2d0c2f6ad9b3a1f8cb4f869a4768d81ba5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:32:03 +0300 Subject: [PATCH 17/95] test(tinybox-module): cover native shell replay, budget and timeout paths Add tests for native shell execution covering idempotent replay of the original request, rejection of a changed request, scratch retention until output release, reservation preservation after an invalid timeout budget, and child cleanup acknowledgement on timeout. Existing stream fixtures gain the new shell field so they keep compiling. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinybus_module/resources_stream_tests.rs | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index 7911179c..44a16994 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -206,6 +206,7 @@ async fn a_panicked_job_is_joined_and_cleanup_failure_stays_owned_until_retry() }, }, jail: None, + shell: None, journal, task: Mutex::new(Some(task)), collector: Collector::Host(Arc::new(tinybox_host::LimitedLocalHost::new(64)), None), @@ -367,6 +368,7 @@ async fn remote_cleanup_failure_keeps_binary_output_and_owner_until_retry_ack() command, Collector::Host(host.clone(), None), None, + None, ); assert!(stream.stop().await.is_err()); assert!(stream.running()); @@ -556,6 +558,7 @@ async fn confirmed_container_destroy_keeps_native_cleanup_retryable_without_dest tinybox_core::ExecRequest::new(["fixture"]), Collector::Sandbox(Arc::new(sandbox), entry.id.clone(), None), None, + None, )); resources .streams @@ -617,3 +620,93 @@ async fn collector_drain_joins_its_owned_active_native_supervisor() -> Result<() ); Ok(()) } + +async fn shell_request( + resources: &Resources, + resource: &ResourceId, + state: &std::path::Path, + payload: &str, +) -> Result { + Ok(tinybox_bus::ShellExecRequest { + process: resources + .reserve(ReserveRequest::Process(resource.clone())) + .await?, + resource: resource.clone(), + shell: tinybox_bus::SelectShellRequest { + command: payload.into(), + preserve_pipeline_status: false, + }, + cwd: None, + extra_env: Default::default(), + state_dir: state.to_string_lossy().into_owned(), + timeout_ms: 5000, + stdin: None, + jail: None, + }) +} +#[tokio::test] +async fn native_shell_replay_uses_original_request_and_retains_scratch_until_release() +-> std::result::Result<(), Box> { + let resources = Resources::default(); + let resource = local_resource(&resources).await?; + let state = tempfile::tempdir()?; + let request = shell_request(&resources, &resource, state.path(), "echo owned").await?; + let process = resources.start_shell_exec(request.clone()).await?; + assert_eq!(resources.start_shell_exec(request.clone()).await?, process); + let mut changed = request; + changed.timeout_ms += 1; + assert!(resources.start_shell_exec(changed).await.is_err()); + completed(&resources, &process).await?; + assert_eq!( + resources.read_output(&process, 0).await?.state, + ExecutionState::Exited(0) + ); + let scratch = state.path().join("artifacts/sandbox-scratch"); + assert_eq!(std::fs::read_dir(&scratch)?.count(), 1); + resources.release_output(&process).await?; + assert_eq!(std::fs::read_dir(&scratch)?.count(), 0); + resources.close(&resource).await?; + Ok(()) +} +#[tokio::test] +async fn native_shell_invalid_budget_does_not_consume_its_reservation() +-> std::result::Result<(), Box> { + let resources = Resources::default(); + let resource = local_resource(&resources).await?; + let state = tempfile::tempdir()?; + let mut request = shell_request(&resources, &resource, state.path(), "echo accepted").await?; + request.timeout_ms = 0; + assert!(resources.start_shell_exec(request.clone()).await.is_err()); + request.timeout_ms = 5000; + let process = resources.start_shell_exec(request).await?; + completed(&resources, &process).await?; + resources.release_output(&process).await?; + resources.close(&resource).await?; + Ok(()) +} +#[tokio::test] +#[cfg(unix)] +async fn native_shell_timeout_acknowledges_child_cleanup_before_output_release() +-> std::result::Result<(), Box> { + let resources = Resources::default(); + let resource = local_resource(&resources).await?; + let state = tempfile::tempdir()?; + let mut request = shell_request( + &resources, + &resource, + state.path(), + "echo partial; sleep 60", + ) + .await?; + request.timeout_ms = 100; + let process = resources.start_shell_exec(request).await?; + completed(&resources, &process).await?; + let output = resources.read_output(&process, 0).await?; + assert_eq!( + output.state, + ExecutionState::Failed(ExecutionFailure::TimedOut) + ); + resources.release_output(&process).await?; + resources.close(&resource).await?; + Ok(()) +} From 24a9eea98a506672a6c5804cc8a17a1d9147c6b4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:32:39 +0300 Subject: [PATCH 18/95] fix(host): strip inherited SHELLOPTS and BASHOPTS from shell environment The shell selection now removes SHELLOPTS and BASHOPTS alongside BASH_ENV and ENV so that inherited Bash options such as errexit or failglob cannot alter the semantics of the requested payload. Tests were updated to cover the new entries and reformatted. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-host/src/local/shell.rs | 7 +++++- crates/tinybox-host/src/local/shell_tests.rs | 24 +++++++++++++++---- .../tests/grants_manifest_e2e.rs | 5 +++- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/crates/tinybox-host/src/local/shell.rs b/crates/tinybox-host/src/local/shell.rs index bc352f5e..d772e857 100644 --- a/crates/tinybox-host/src/local/shell.rs +++ b/crates/tinybox-host/src/local/shell.rs @@ -86,7 +86,12 @@ fn selection_for( ], raw_arguments: None, bootstrap_environment: Vec::new(), - remove_environment: vec!["BASH_ENV".into(), "ENV".into()], + remove_environment: vec![ + "BASH_ENV".into(), + "ENV".into(), + "SHELLOPTS".into(), + "BASHOPTS".into(), + ], }, } } diff --git a/crates/tinybox-host/src/local/shell_tests.rs b/crates/tinybox-host/src/local/shell_tests.rs index f98b2dfc..1c466e85 100644 --- a/crates/tinybox-host/src/local/shell_tests.rs +++ b/crates/tinybox-host/src/local/shell_tests.rs @@ -143,18 +143,32 @@ fn bash_startup_environment_cannot_replace_the_requested_payload() #[test] #[cfg(unix)] -fn inherited_bash_options_cannot_abort_the_requested_payload() -> Result<(), Box> { +fn inherited_bash_options_cannot_abort_the_requested_payload() +-> Result<(), Box> { const CHILD: &str = "TINYBOX_SHELL_OPTIONS_REGRESSION_CHILD"; if std::env::var_os(CHILD).is_some() { - let request = SelectShellRequest { command: "false; echo reached".into(), preserve_pipeline_status: false }; + let request = SelectShellRequest { + command: "false; echo reached".into(), + preserve_pipeline_status: false, + }; let output = build_shell_command(&request).output()?; assert!(output.status.success()); assert_eq!(output.stdout, b"reached\n"); return Ok(()); } let status = std::process::Command::new(std::env::current_exe()?) - .args(["--exact", "local::shell::tests::inherited_bash_options_cannot_abort_the_requested_payload", "--nocapture"]) - .env(CHILD, "1").env("SHELLOPTS", "errexit:nounset").env("BASHOPTS", "failglob").status()?; - assert!(status.success(), "inherited Bash options changed payload semantics"); + .args([ + "--exact", + "local::shell::tests::inherited_bash_options_cannot_abort_the_requested_payload", + "--nocapture", + ]) + .env(CHILD, "1") + .env("SHELLOPTS", "errexit:nounset") + .env("BASHOPTS", "failglob") + .status()?; + assert!( + status.success(), + "inherited Bash options changed payload semantics" + ); Ok(()) } diff --git a/crates/tinybox-module/tests/grants_manifest_e2e.rs b/crates/tinybox-module/tests/grants_manifest_e2e.rs index 8114fd93..188a3227 100644 --- a/crates/tinybox-module/tests/grants_manifest_e2e.rs +++ b/crates/tinybox-module/tests/grants_manifest_e2e.rs @@ -103,7 +103,10 @@ async fn compiled_manifest_admits_and_executes_grant_and_shell_discovery() assert_eq!(selection.flavor, tinybox_bus::ShellFlavor::Posix); assert_eq!(selection.arguments[0], "-c"); assert_eq!(selection.raw_arguments, None); - assert_eq!(selection.remove_environment, ["BASH_ENV", "ENV"]); + assert_eq!( + selection.remove_environment, + ["BASH_ENV", "ENV", "SHELLOPTS", "BASHOPTS"] + ); } for include in ["~other/shared.conf", "%(prefix)/shared.conf"] { std::fs::write( From dde0188fd75fe6bd8674bcdbfc1eb7e00a60e788 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:32:44 +0300 Subject: [PATCH 19/95] test(tinybus): update execution state assertions to struct variants Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/resources_stream_tests.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index 44a16994..e16a6797 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -659,7 +659,7 @@ async fn native_shell_replay_uses_original_request_and_retains_scratch_until_rel completed(&resources, &process).await?; assert_eq!( resources.read_output(&process, 0).await?.state, - ExecutionState::Exited(0) + ExecutionState::Exited { exit_code: 0 } ); let scratch = state.path().join("artifacts/sandbox-scratch"); assert_eq!(std::fs::read_dir(&scratch)?.count(), 1); @@ -704,7 +704,9 @@ async fn native_shell_timeout_acknowledges_child_cleanup_before_output_release() let output = resources.read_output(&process, 0).await?; assert_eq!( output.state, - ExecutionState::Failed(ExecutionFailure::TimedOut) + ExecutionState::Failed { + code: ExecutionFailure::TimedOut + } ); resources.release_output(&process).await?; resources.close(&resource).await?; From f44c028cab5ccc82b4f0cebb0875ce0425166046 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:34:13 +0300 Subject: [PATCH 20/95] refactor(tinybox-module): extract execution collector selection Move the native shell collector selection out of the spawn path into a dedicated helper so the shell and jail branches share one entry point. Add a test covering startup hook clearing, approved env overrides, and binary-safe pipes, and make the grants manifest e2e exercise a quoted Windows executable path. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/resources_stream.rs | 62 +++++++++++-------- .../tinybus_module/resources_stream_tests.rs | 49 ++++++++++++++- .../tests/grants_manifest_e2e.rs | 18 +++++- 3 files changed, 99 insertions(+), 30 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index a3f2ece5..19486b37 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -126,6 +126,34 @@ fn select_collector( } }) } +fn select_execution_collector( + entry: &super::Resource, + jail: Option<&tinybox_bus::NativeJailConfig>, + shell: Option<&tinybox_bus::ShellExecRequest>, + resolved: &tinybox_core::ExecRequest, +) -> Result { + Ok(if let Some(shell) = shell { + if !entry.native_host || entry.local_sandbox.is_none() { + return Err(failure( + tinybox_bus::UNSUPPORTED_OPERATION, + "native shell requires local passthrough execution", + )); + } + let root = entry.workspace_root.as_ref().ok_or_else(|| { + failure( + tinybox_bus::UNSUPPORTED_OPERATION, + "native shell requires directory resource", + ) + })?; + Collector::Shell(super::super::native_shell::ShellContext::prepare( + shell.clone(), + root, + resolved, + )?) + } else { + select_collector(entry, jail, resolved)? + }) +} impl StreamExecution { fn start( request: SpawnRequest, @@ -141,17 +169,16 @@ impl StreamExecution { let task = tokio::spawn(async move { let _completion = CompletionGuard(observer.clone()); let result = if let Some(timeout) = timeout { - match tokio::time::timeout( + if let Ok(result) = tokio::time::timeout( std::time::Duration::from_millis(timeout), runner.run(&resolved, observer.clone()), ) .await { - Ok(result) => Some(result), - Err(_) => { - let _cleanup = runner.drain().await; - None - } + Some(result) + } else { + let _cleanup = runner.drain().await; + None } } else { Some(runner.run(&resolved, observer.clone()).await) @@ -309,27 +336,8 @@ impl Resources { } command }; - let collector = if let Some(shell) = &shell { - if !entry.native_host || entry.local_sandbox.is_none() { - return Err(failure( - tinybox_bus::UNSUPPORTED_OPERATION, - "native shell requires local passthrough execution", - )); - } - let root = entry.workspace_root.as_ref().ok_or_else(|| { - failure( - tinybox_bus::UNSUPPORTED_OPERATION, - "native shell requires directory resource", - ) - })?; - Collector::Shell(super::super::native_shell::ShellContext::prepare( - shell.clone(), - root, - &resolved, - )?) - } else { - select_collector(entry, jail.as_ref(), &resolved)? - }; + let collector = + select_execution_collector(entry, jail.as_ref(), shell.as_ref(), &resolved)?; let mut state = self.state.lock().await; let executions = self .executions diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index e16a6797..0a589c69 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -637,7 +637,7 @@ async fn shell_request( preserve_pipeline_status: false, }, cwd: None, - extra_env: Default::default(), + extra_env: std::collections::BTreeMap::default(), state_dir: state.to_string_lossy().into_owned(), timeout_ms: 5000, stdin: None, @@ -712,3 +712,50 @@ async fn native_shell_timeout_acknowledges_child_cleanup_before_output_release() resources.close(&resource).await?; Ok(()) } + +#[tokio::test] +#[cfg(unix)] +async fn native_shell_clears_startup_hooks_after_approved_overrides_and_captures_binary_pipes() +-> std::result::Result<(), Box> { + let resources = Resources::default(); + let resource = local_resource(&resources).await?; + let state = tempfile::tempdir()?; + let startup = state.path().join("startup.sh"); + std::fs::write(&startup, "exit 77\n")?; + let mut request = shell_request( + &resources, + &resource, + state.path(), + "cat; printf \\\\377 >&2; false; echo $APPROVED", + ) + .await?; + request + .extra_env + .insert("BASH_ENV".into(), startup.to_string_lossy().into_owned()); + request + .extra_env + .insert("SHELLOPTS".into(), "errexit".into()); + request.extra_env.insert("APPROVED".into(), "yes".into()); + request.stdin = Some(vec![0, 255, 1]); + let process = resources.start_shell_exec(request).await?; + completed(&resources, &process).await?; + let output = resources.read_output(&process, 0).await?; + assert_eq!(output.state, ExecutionState::Exited { exit_code: 0 }); + let stdout: Vec = output + .chunks + .iter() + .filter(|c| c.stream == tinybox_bus::OutputStream::Stdout) + .flat_map(|c| c.bytes.clone()) + .collect(); + assert_eq!(stdout, [0, 255, 1, b'y', b'e', b's', 10]); + let stderr: Vec = output + .chunks + .iter() + .filter(|c| c.stream == tinybox_bus::OutputStream::Stderr) + .flat_map(|c| c.bytes.clone()) + .collect(); + assert_eq!(stderr, [255]); + resources.release_output(&process).await?; + resources.close(&resource).await?; + Ok(()) +} diff --git a/crates/tinybox-module/tests/grants_manifest_e2e.rs b/crates/tinybox-module/tests/grants_manifest_e2e.rs index b68801fb..d526e2d1 100644 --- a/crates/tinybox-module/tests/grants_manifest_e2e.rs +++ b/crates/tinybox-module/tests/grants_manifest_e2e.rs @@ -149,12 +149,23 @@ async fn compiled_manifest_admits_and_executes_grant_and_shell_discovery() (tinybox_bus::ReserveRequest::Process(resource.clone()),), ) .await?; + #[cfg(windows)] + let payload = { + let executable = workspace.path().join("quoted fixture tool.exe"); + std::fs::copy(std::env::current_exe()?, &executable)?; + format!( + "\"{}\" \"--list\" & echo native-fixture", + executable.display() + ) + }; + #[cfg(not(windows))] + let payload = "echo native-fixture"; let running: tinybox_bus::ProcessRef = proxy .call( "StartShellExec", (serde_json::json!({ "process":process, "resource":resource, - "shell":{"command":"echo native-fixture"}, + "shell":{"command":payload}, "state_dir":state_dir.path().to_str().ok_or("non-UTF8 fixture")?, "timeout_ms":5000 }),), @@ -181,7 +192,10 @@ async fn compiled_manifest_admits_and_executes_grant_and_shell_discovery() .filter(|chunk| chunk.stream == tinybox_bus::OutputStream::Stdout) .flat_map(|chunk| chunk.bytes) .collect(); - assert_eq!(String::from_utf8(stdout)?.trim(), "native-fixture"); + let stdout = String::from_utf8(stdout)?; + assert!(stdout.trim().ends_with("native-fixture")); + #[cfg(windows)] + assert!(stdout.contains("compiled_manifest_admits_and_executes_grant_and_shell_discovery")); proxy.call::<()>("ReleaseOutput", (running,)).await?; proxy.call::<()>("Close", (resource,)).await?; proxy.call::<()>("Shutdown", ()).await?; From 64911ab8ea48bafe0db967a4ad46636e5d82fe5b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:34:45 +0300 Subject: [PATCH 21/95] refactor(tinybox-module): extract owned collector check into helper The inline check that rejects resources without an owned native collector is now a shared `require_owned_collector` helper so the same validation can be reused by other call sites. Behaviour is unchanged. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/resources_stream.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index 19486b37..49fc22f3 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -126,6 +126,15 @@ fn select_collector( } }) } +fn require_owned_collector(entry: &super::Resource) -> Result<()> { + if entry.collector.is_none() { + return Err(failure( + tinybox_bus::UNSUPPORTED_OPERATION, + "owned native collector unavailable", + )); + } + Ok(()) +} fn select_execution_collector( entry: &super::Resource, jail: Option<&tinybox_bus::NativeJailConfig>, @@ -316,12 +325,7 @@ impl Resources { "live output is unavailable for this provider", )); } - if entry.collector.is_none() { - return Err(failure( - tinybox_bus::UNSUPPORTED_OPERATION, - "owned native collector unavailable", - )); - } + require_owned_collector(entry)?; // Each execution owns its cleanup collector. Cancelling one stream must // not drain an unrelated running supervisor or inherit its failure. // Resolve before consuming admission, and fence native start against Close. From 4eab081d4125f1a5ee23e6dfc826b6de8a92c8c1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:35:39 +0300 Subject: [PATCH 22/95] test(native_shell): add test module for native shell Wire up a cfg(test) module that loads the native shell tests from mod_tests.rs, so the module's behaviour can be covered by unit tests. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/native_shell/README.md | 11 +++ .../src/tinybus_module/native_shell/mod.rs | 4 ++ .../tinybus_module/native_shell/mod_tests.rs | 71 +++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 crates/tinybox-module/src/tinybus_module/native_shell/README.md create mode 100644 crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/README.md b/crates/tinybox-module/src/tinybus_module/native_shell/README.md new file mode 100644 index 00000000..916cf8d6 --- /dev/null +++ b/crates/tinybox-module/src/tinybus_module/native_shell/README.md @@ -0,0 +1,11 @@ +# Native shell execution + +This module prepares caller-approved local shell requests. The retained context +owns the safe environment snapshot, private scratch and a supervised native +collector. It rejects invalid requests before consuming a process reservation. +Resource streams own replay, output budgets, timeout publication and cleanup +barriers. Explicit confinement uses the native jail contract and refuses +unavailable enforcement. + +See [the contract](../../../../../docs/specs/native-shell-execution.md) and +[its sequence](../../../../../docs/plans/native-shell-execution.md). diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs index 38ff55c6..d1c40e3a 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs @@ -163,3 +163,7 @@ fn invalid() -> tinybus::Error { "invalid native shell environment, state directory or runtime budget", ) } + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs new file mode 100644 index 00000000..cb531332 --- /dev/null +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs @@ -0,0 +1,71 @@ +//! Validation and environment behavior before process admission. +use super::*; +fn request(state: &Path) -> ShellExecRequest { + ShellExecRequest { + process: tinybox_bus::ResourceId("p".into()), + resource: tinybox_bus::ResourceId("r".into()), + shell: tinybox_bus::SelectShellRequest { + command: "echo accepted".into(), + preserve_pipeline_status: false, + }, + cwd: None, + extra_env: std::collections::BTreeMap::new(), + state_dir: state.to_string_lossy().into_owned(), + timeout_ms: 1000, + stdin: None, + jail: None, + } +} +#[test] +fn invalid_shell_state_budget_and_environment_are_refused_before_execution() +-> std::result::Result<(), Box> { + let state = tempfile::tempdir()?; + let resolved = tinybox_core::ExecRequest::new(["unused"]); + let original = request(state.path()); + for timeout in [0, 86_400_001] { + let mut invalid = original.clone(); + invalid.timeout_ms = timeout; + assert!(ShellContext::prepare(invalid, state.path(), &resolved).is_err()); + } + for directory in ["relative", "absent", "nul\0"] { + let mut invalid = original.clone(); + invalid.state_dir = directory.into(); + assert!(ShellContext::prepare(invalid, state.path(), &resolved).is_err()); + } + let file = state.path().join("file"); + std::fs::write(&file, "data")?; + let mut invalid = original.clone(); + invalid.state_dir = file.to_string_lossy().into_owned(); + assert!(ShellContext::prepare(invalid, state.path(), &resolved).is_err()); + let mut invalid = original.clone(); + invalid.shell.command = "nul\0".into(); + assert!(ShellContext::prepare(invalid, state.path(), &resolved).is_err()); + for (name, value) in [("", "value"), ("NAME=bad", "value"), ("NAME", "nul\0")] { + let mut invalid = original.clone(); + invalid.extra_env.insert(name.into(), value.into()); + assert!(ShellContext::prepare(invalid, state.path(), &resolved).is_err()); + } + Ok(()) +} +#[test] +fn approved_environment_overrides_preserve_custom_temporary_directory_and_remove_startup_hooks() +-> std::result::Result<(), Box> { + let state = tempfile::tempdir()?; + let resolved = tinybox_core::ExecRequest::new(["unused"]); + let mut request = request(state.path()); + request + .extra_env + .insert("TMPDIR".into(), "approved-temporary-location".into()); + request + .extra_env + .insert("SHELLOPTS".into(), "errexit".into()); + let shell = ShellContext::prepare(request, state.path(), &resolved)?; + assert_eq!( + shell.environment.get("TMPDIR").map(String::as_str), + Some("approved-temporary-location") + ); + #[cfg(unix)] + assert_eq!(shell.environment.get("SHELLOPTS"), None); + assert!(!shell.pending()); + Ok(()) +} From 1ed18e31e66771fae4db1f46d74acc65bb95545f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:36:16 +0300 Subject: [PATCH 23/95] fix(tinybox-module): validate spawn request ids before use Extract the process and resource id checks into a shared helper so the same validation can be reused, and bump the capability contract version snapshot to 1.11 to match. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-bus/src/lib_tests.rs | 2 +- .../tinybox-module/src/tinybus_module/resources_stream.rs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/tinybox-bus/src/lib_tests.rs b/crates/tinybox-bus/src/lib_tests.rs index 55f21410..7367baa1 100644 --- a/crates/tinybox-bus/src/lib_tests.rs +++ b/crates/tinybox-bus/src/lib_tests.rs @@ -140,7 +140,7 @@ fn version_and_capability_wire_snapshot_preserve_contract_compatibility() exec_backends: Vec::new(), spawn_backends: Vec::new(), }; - let snapshot = serde_json::json!({"contract_version":[1,10],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]}); + let snapshot = serde_json::json!({"contract_version":[1,11],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]}); assert_eq!(serde_json::to_value(&capabilities)?, snapshot); assert_eq!( serde_json::from_value::(snapshot)?, diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index 49fc22f3..b4bdc8c5 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -126,6 +126,10 @@ fn select_collector( } }) } +fn validate_execution_ids(request: &SpawnRequest) -> Result<()> { + validate_id(&request.process)?; + validate_id(&request.command.resource) +} fn require_owned_collector(entry: &super::Resource) -> Result<()> { if entry.collector.is_none() { return Err(failure( @@ -293,8 +297,7 @@ impl Resources { jail: Option, shell: Option, ) -> Result { - validate_id(&request.process)?; - validate_id(&request.command.resource)?; + validate_execution_ids(&request)?; let process = ProcessRef { resource: request.command.resource.clone(), process: request.process.clone(), From cd0e19cc6d052b8f52056d89c27858b7691a0767 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:38:16 +0300 Subject: [PATCH 24/95] docs: document native shell execution contract 1.11 Add a README section describing the StartShellExec operation introduced in contract 1.11, covering environment snapshots, scratch and output lifetime, and the refusal behaviour of jail and remote providers. Link the new native-shell-execution spec from the plans and specs indexes, and split the grants manifest e2e test into discovery and native shell helpers. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-module/src/tinybus_module/README.md | 13 ++++++++++++- crates/tinybox-module/tests/grants_manifest_e2e.rs | 14 ++++++++++++-- docs/plans/README.md | 2 ++ docs/specs/README.md | 2 ++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/README.md b/crates/tinybox-module/src/tinybus_module/README.md index dd238c30..b7593516 100644 --- a/crates/tinybox-module/src/tinybus_module/README.md +++ b/crates/tinybox-module/src/tinybus_module/README.md @@ -44,7 +44,7 @@ The interface is `ai.tinyhumans.tinybox.Box` at | ResolveLocalGrants | ResolveLocalGrantsRequest | LocalGrants | | SelectShell | SelectShellRequest | ShellInvocation | -Capabilities advertises contract version 1.10; 1.0 denotes the original +Capabilities advertises contract version 1.11; 1.0 denotes the original discovery-only surface. Hosts require equal majors and a module minor at least as new as their vocabulary, using `tinybox_bus::is_compatible`. Version 1.1 added reserved resource/process ownership and terminal shutdown; 1.2 adds host @@ -316,3 +316,14 @@ shared quote-preserving TinyBox command builder. Windows explicitly enables extensions and disables delayed expansion. Unix builders remove BASH_ENV and ENV; ShellInvocation returns those removal names. Providers apply them after approved environment overrides. + +## Native shell execution + +Contract 1.11 adds `StartShellExec(ShellExecRequest) -> ProcessRef` for local +passthrough resources. It composes platform shell selection with safe parent +environment snapshots, approved overrides, private scratch, bounded pipes and +owned timeout/cancellation cleanup. `ReadOutput` retains scratch until +`ReleaseOutput` or resource close acknowledges cleanup. Explicit native jail +requests refuse unavailable enforcement; remote/Docker providers refuse this +operation. State directory authorization and Windows private parent ACLs belong +to the caller. See [the native shell contract](../../../docs/specs/native-shell-execution.md). diff --git a/crates/tinybox-module/tests/grants_manifest_e2e.rs b/crates/tinybox-module/tests/grants_manifest_e2e.rs index c8134edb..84822cec 100644 --- a/crates/tinybox-module/tests/grants_manifest_e2e.rs +++ b/crates/tinybox-module/tests/grants_manifest_e2e.rs @@ -58,6 +58,14 @@ async fn compiled_manifest_admits_and_executes_grant_and_shell_discovery() tinybox_bus::OBJECT_PATH, tinybox_bus::INTERFACE, )?; + verify_discovery(&proxy).await?; + verify_native_shell(&proxy).await?; + proxy.call::<()>("Shutdown", ()).await?; + broker_task.abort(); + Ok(()) +} + +async fn verify_discovery(proxy: &tinybus::Proxy) -> Result<(), Box> { let home = tempfile::tempdir()?; std::fs::create_dir(home.path().join("data"))?; let result: tinybox_bus::LocalGrants = proxy @@ -128,6 +136,10 @@ async fn compiled_manifest_admits_and_executes_grant_and_shell_discovery() "{include}: {result:?}" ); } + Ok(()) +} + +async fn verify_native_shell(proxy: &tinybus::Proxy) -> Result<(), Box> { let workspace = tempfile::tempdir()?; let state_dir = tempfile::tempdir()?; let resource: tinybox_bus::ResourceId = proxy @@ -201,8 +213,6 @@ async fn compiled_manifest_admits_and_executes_grant_and_shell_discovery() assert!(stdout.contains("compiled_manifest_admits_and_executes_grant_and_shell_discovery")); proxy.call::<()>("ReleaseOutput", (running,)).await?; proxy.call::<()>("Close", (resource,)).await?; - proxy.call::<()>("Shutdown", ()).await?; - broker_task.abort(); Ok(()) } diff --git a/docs/plans/README.md b/docs/plans/README.md index 168861ee..9a93dc14 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -23,3 +23,5 @@ See [`example-retry-policy.md`](example-retry-policy.md) for a test-first sample Shell discovery: [shell-selection.md](shell-selection.md). Local grant discovery: [local-grants.md](local-grants.md). + +- [Native shell execution](native-shell-execution.md) diff --git a/docs/specs/README.md b/docs/specs/README.md index 77f420dd..15872277 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -24,3 +24,5 @@ See [`example-retry-policy.md`](example-retry-policy.md) for a complete sample. Shell discovery: [shell-selection.md](shell-selection.md). Local grant discovery: [local-grants.md](local-grants.md). + +- [Native shell execution](native-shell-execution.md) From c6dac81cce01a125ace0d33a2988ce65ab04e580 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:38:56 +0300 Subject: [PATCH 25/95] refactor(native_shell): inject jail backend into shell context ShellContext::prepare now delegates to a new prepare_with_backend helper that accepts a JailBackend, so the backend is resolved once at the call site instead of inside the jail setup path. This makes the jail preparation testable with a fixture backend, which the new test uses to verify that the owned scratch directory is added and that unsupported constraints are refused. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/native_shell/mod.rs | 9 +- .../tinybus_module/native_shell/mod_tests.rs | 96 +++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs index d1c40e3a..7a86c61d 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs @@ -21,6 +21,14 @@ impl ShellContext { request: ShellExecRequest, root: &Path, resolved: &tinybox_core::ExecRequest, + ) -> Result> { + Self::prepare_with_backend(request, root, resolved, tinybox_jail::default_backend()) + } + fn prepare_with_backend( + request: ShellExecRequest, + root: &Path, + resolved: &tinybox_core::ExecRequest, + backend: Arc, ) -> Result> { if request.timeout_ms == 0 || request.timeout_ms > 86_400_000 @@ -90,7 +98,6 @@ impl ShellContext { environment.remove(&name); } let host = if let Some(config) = &request.jail { - let backend = tinybox_jail::default_backend(); let mut jail = super::native_jail::prepare(root, config, resolved, backend.as_ref())?; jail.read_write.push(scratch.path().to_path_buf()); if config.enforcement == tinybox_bus::JailEnforcement::Enforced { diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs index cb531332..882ce717 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs @@ -69,3 +69,99 @@ fn approved_environment_overrides_preserve_custom_temporary_directory_and_remove assert!(!shell.pending()); Ok(()) } + +#[cfg(unix)] +struct Backend { + full: bool, +} +#[cfg(unix)] +impl tinybox_jail::JailBackend for Backend { + fn name(&self) -> &'static str { + "native-shell-preflight-fixture" + } + fn is_available(&self) -> bool { + true + } + fn isolation(&self) -> tinybox_core::IsolationLevel { + tinybox_core::IsolationLevel::None + } + fn is_suitable_for_untrusted_code(&self) -> bool { + true + } + fn constraint_support(&self) -> tinybox_core::ConstraintSupport { + use tinybox_core::{Constraint, ConstraintSupport, Enforcement}; + if self.full { + ConstraintSupport::NONE + .with(Constraint::Filesystem, Enforcement::Enforced) + .with(Constraint::Network, Enforcement::Enforced) + .with(Constraint::Subprocess, Enforcement::Enforced) + } else { + ConstraintSupport::NONE.with(Constraint::Filesystem, Enforcement::BestEffort) + } + } + fn spawn( + &self, + jail: &tinybox_jail::Jail, + mut command: std::process::Command, + ) -> std::io::Result { + assert!(jail.read_write.iter().any(|path| { + path.components() + .any(|component| component.as_os_str() == "sandbox-scratch") + })); + command.spawn() + } +} +#[cfg(unix)] +#[tokio::test] +async fn native_shell_jail_adds_owned_scratch_and_refuses_unsupported_constraints() +-> std::result::Result<(), Box> { + let state = tempfile::tempdir()?; + let mut resolved = tinybox_core::ExecRequest::new(["unused"]); + resolved.cwd = Some(state.path().to_path_buf()); + let mut request = request(state.path()); + request.jail = Some(tinybox_bus::NativeJailConfig { + read_only: vec![], + read_write: vec![], + allow_net: false, + allow_subprocess: false, + enforcement: tinybox_bus::JailEnforcement::Enforced, + require_process_isolation: false, + }); + let shell = ShellContext::prepare_with_backend( + request.clone(), + state.path(), + &resolved, + Arc::new(Backend { full: true }), + )?; + assert_eq!( + shell + .run( + &resolved, + Arc::new(super::super::output::OutputJournal::default()) + ) + .await? + .exit_code, + 0 + ); + assert!(!shell.pending()); + shell.drain().await?; + let config = request.jail.as_mut().ok_or("missing jail fixture")?; + config.enforcement = tinybox_bus::JailEnforcement::Declared; + let shell = ShellContext::prepare_with_backend( + request.clone(), + state.path(), + &resolved, + Arc::new(Backend { full: true }), + )?; + shell.drain().await?; + assert!( + ShellContext::prepare_with_backend( + request, + state.path(), + &resolved, + Arc::new(Backend { full: false }) + ) + .is_err() + ); + Ok(()) +} From 2464f8932061d41d78ae153280ebb935c8a0a81b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:39:45 +0300 Subject: [PATCH 26/95] refactor(native_shell): move shell context types into a types module Extracted ShellContext and ShellHost into a dedicated types module and re-exported ShellContext so existing call sites keep working. This keeps the module's implementation focused and gives the shared types a clearer home. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/native_shell/mod.rs | 13 +++---------- .../src/tinybus_module/native_shell/types.rs | 12 ++++++++++++ 2 files changed, 15 insertions(+), 10 deletions(-) create mode 100644 crates/tinybox-module/src/tinybus_module/native_shell/types.rs diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs index 7a86c61d..c4b36a3c 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs @@ -6,16 +6,9 @@ use std::{ use tinybox_bus::ShellExecRequest; use tinybus::Result; -pub(super) struct ShellContext { - request: ShellExecRequest, - environment: std::collections::BTreeMap, - _scratch: tempfile::TempDir, - host: ShellHost, -} -enum ShellHost { - Local(tinybox_host::LimitedLocalHost), - Jailed(tinybox_host::JailLocalHost), -} +mod types; +pub(super) use types::ShellContext; +use types::ShellHost; impl ShellContext { pub(super) fn prepare( request: ShellExecRequest, diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/types.rs b/crates/tinybox-module/src/tinybus_module/native_shell/types.rs new file mode 100644 index 00000000..89fe58bc --- /dev/null +++ b/crates/tinybox-module/src/tinybus_module/native_shell/types.rs @@ -0,0 +1,12 @@ +//! Retained native collector and scratch ownership. +use tinybox_bus::ShellExecRequest; +pub(in crate::tinybus_module) struct ShellContext { + pub(super) request: ShellExecRequest, + pub(super) environment: std::collections::BTreeMap, + pub(super) _scratch: tempfile::TempDir, + pub(super) host: ShellHost, +} +pub(super) enum ShellHost { + Local(tinybox_host::LimitedLocalHost), + Jailed(tinybox_host::JailLocalHost), +} From b55f24af22442962aa4e50f7bba7cdba0e93e7ad Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:41:06 +0300 Subject: [PATCH 27/95] docs(tinybus-module): document StartShellExec in the interface table Add the StartShellExec method to the README's method table so the documented surface matches the implemented interface. The test's unix-only gate is dropped, letting the shell timeout cleanup test run on all platforms. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-module/src/tinybus_module/README.md | 1 + .../tinybox-module/src/tinybus_module/resources_stream_tests.rs | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-module/src/tinybus_module/README.md b/crates/tinybox-module/src/tinybus_module/README.md index b7593516..72e06c66 100644 --- a/crates/tinybox-module/src/tinybus_module/README.md +++ b/crates/tinybox-module/src/tinybus_module/README.md @@ -43,6 +43,7 @@ The interface is `ai.tinyhumans.tinybox.Box` at | AbortFileWrite | AbortFileWriteRequest | unit | | ResolveLocalGrants | ResolveLocalGrantsRequest | LocalGrants | | SelectShell | SelectShellRequest | ShellInvocation | +| StartShellExec | ShellExecRequest | ProcessRef | Capabilities advertises contract version 1.11; 1.0 denotes the original discovery-only surface. Hosts require equal majors and a module minor at least diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index 0a589c69..8641138d 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -685,7 +685,6 @@ async fn native_shell_invalid_budget_does_not_consume_its_reservation() Ok(()) } #[tokio::test] -#[cfg(unix)] async fn native_shell_timeout_acknowledges_child_cleanup_before_output_release() -> std::result::Result<(), Box> { let resources = Resources::default(); From 6c5fe06d7bcd1ec7486da303c5c1ee786da8640b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:41:52 +0300 Subject: [PATCH 28/95] test(tinybox-module): cover shell output limit termination and reaping Add a unix-only test asserting that a shell command exceeding the output limit fails with OutputLimit and can still be released and closed cleanly. The existing timeout test now picks a platform-appropriate long-running payload so it works on Windows as well. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinybus_module/resources_stream_tests.rs | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index 8641138d..98c44456 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -690,13 +690,12 @@ async fn native_shell_timeout_acknowledges_child_cleanup_before_output_release() let resources = Resources::default(); let resource = local_resource(&resources).await?; let state = tempfile::tempdir()?; - let mut request = shell_request( - &resources, - &resource, - state.path(), - "echo partial; sleep 60", - ) - .await?; + #[cfg(unix)] + let payload = "echo partial; sleep 60"; + #[cfg(windows)] + let payload = + "echo partial & powershell -NoProfile -NonInteractive -Command \"Start-Sleep -Seconds 60\""; + let mut request = shell_request(&resources, &resource, state.path(), payload).await?; request.timeout_ms = 100; let process = resources.start_shell_exec(request).await?; completed(&resources, &process).await?; @@ -758,3 +757,25 @@ async fn native_shell_clears_startup_hooks_after_approved_overrides_and_captures resources.close(&resource).await?; Ok(()) } + +#[tokio::test] +#[cfg(unix)] +async fn native_shell_output_limit_terminates_and_reaps_before_release() +-> std::result::Result<(), Box> { + let resources = Resources::default(); + let resource = local_resource(&resources).await?; + let state = tempfile::tempdir()?; + let request = shell_request(&resources, &resource, state.path(), "yes output").await?; + let process = resources.start_shell_exec(request).await?; + completed(&resources, &process).await?; + let output = resources.read_output(&process, 0).await?; + assert_eq!( + output.state, + ExecutionState::Failed { + code: ExecutionFailure::OutputLimit + } + ); + resources.release_output(&process).await?; + resources.close(&resource).await?; + Ok(()) +} From 1e7aa0f62478640d0b48a13ededd6c2033922303 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:42:57 +0300 Subject: [PATCH 29/95] docs: update native shell execution status to implemented The plan and spec now record that native shell execution is implemented upstream, with review and cross-platform CI still pending. The previous wording still described implementation as in progress. Auto-committed-on: dragonfly Co-authored-by: Medulla --- docs/plans/native-shell-execution.md | 2 +- docs/specs/native-shell-execution.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/native-shell-execution.md b/docs/plans/native-shell-execution.md index 5535ba9c..baac5363 100644 --- a/docs/plans/native-shell-execution.md +++ b/docs/plans/native-shell-execution.md @@ -16,5 +16,5 @@ Validate formatting, affected/full tests, Clippy, build, coverage and cross-platform CI through the workflow runner. -Implementation remains in progress. No host gitlink or availability claim is +Implementation is complete; upstream review and cross-platform CI remain. No host gitlink or availability claim is part of this upstream slice. diff --git a/docs/specs/native-shell-execution.md b/docs/specs/native-shell-execution.md index 4edff9a0..0a0a46a1 100644 --- a/docs/specs/native-shell-execution.md +++ b/docs/specs/native-shell-execution.md @@ -1,6 +1,6 @@ # Native shell execution -Status: accepted scope, implementation in progress. Owner: TinyBox. +Status: implemented upstream; review and cross-platform CI pending. Owner: TinyBox. Sequence: [native shell plan](../plans/native-shell-execution.md). Contract 1.11 appends StartShellExec(ShellExecRequest) -> ProcessRef. From 118fb6e44349548ae7a9286b95095ae2093a73a0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 16:44:07 +0300 Subject: [PATCH 30/95] test(grants-manifest): extract discovery checks into a helper Move the grant and shell discovery assertions out of the end-to-end test into a verify_discovery helper so the test body stays focused on broker setup and teardown. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-module/tests/grants_manifest_e2e.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-module/tests/grants_manifest_e2e.rs b/crates/tinybox-module/tests/grants_manifest_e2e.rs index 188a3227..25ecb1c1 100644 --- a/crates/tinybox-module/tests/grants_manifest_e2e.rs +++ b/crates/tinybox-module/tests/grants_manifest_e2e.rs @@ -58,6 +58,13 @@ async fn compiled_manifest_admits_and_executes_grant_and_shell_discovery() tinybox_bus::OBJECT_PATH, tinybox_bus::INTERFACE, )?; + verify_discovery(&proxy).await?; + proxy.call::<()>("Shutdown", ()).await?; + broker_task.abort(); + Ok(()) +} + +async fn verify_discovery(proxy: &tinybus::Proxy) -> Result<(), Box> { let home = tempfile::tempdir()?; std::fs::create_dir(home.path().join("data"))?; let result: tinybox_bus::LocalGrants = proxy @@ -128,8 +135,6 @@ async fn compiled_manifest_admits_and_executes_grant_and_shell_discovery() "{include}: {result:?}" ); } - proxy.call::<()>("Shutdown", ()).await?; - broker_task.abort(); Ok(()) } From 477d55da441515483c244ac1b4eee946bc9b7b9d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:25:01 +0300 Subject: [PATCH 31/95] test(tinybus-module): cover retryable close after failed scratch deletion Add a test asserting that when scratch directory removal fails, the output stream is retained and both release_output and close return errors so the caller can retry. Once the obstruction is cleared, close succeeds and the stream is dropped. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinybus_module/resources_stream_tests.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index 98c44456..bc47e29b 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -779,3 +779,29 @@ async fn native_shell_output_limit_terminates_and_reaps_before_release() resources.close(&resource).await?; Ok(()) } + +#[tokio::test] +async fn failed_scratch_deletion_keeps_output_and_close_retryable() +-> std::result::Result<(), Box> { + let resources = Resources::default(); + let resource = local_resource(&resources).await?; + let state = tempfile::tempdir()?; + let request = shell_request(&resources, &resource, state.path(), "echo owned").await?; + let process = resources.start_shell_exec(request).await?; + completed(&resources, &process).await?; + let scratch = std::fs::read_dir(state.path().join("artifacts/sandbox-scratch"))? + .next().ok_or("missing scratch")??.path(); + let retained = scratch.with_extension("retained"); + std::fs::rename(&scratch, &retained)?; + std::fs::write(&scratch, "blocks directory removal")?; + assert!(resources.release_output(&process).await.is_err()); + assert!(resources.stream(&process)?.is_some()); + assert!(resources.close(&resource).await.is_err()); + assert!(resources.stream(&process)?.is_some()); + std::fs::remove_file(&scratch)?; + std::fs::rename(&retained, &scratch)?; + resources.close(&resource).await?; + assert!(!scratch.exists()); + assert!(resources.stream(&process)?.is_none()); + Ok(()) +} From 4f9b118aab118d455233d37db508ffcf8b598441 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:25:41 +0300 Subject: [PATCH 32/95] test(tinybus-module): cover native shell selection and execution over the bus Add an integration test that drives SelectShell and StartShellExec through the bus codec and checks the returned invocation matches the host selection. It also verifies the process exits cleanly with the expected output and that the sandbox scratch directory is left empty after release. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/mod_tests.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index c4aa9e00..98f8cf4b 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -1036,3 +1036,63 @@ async fn unsupported_git_include_expansions_refuse_the_whole_discovery() } Ok(()) } + +#[tokio::test] +async fn native_shell_selection_and_execution_roundtrip_through_the_bus_codec() +-> Result<(), Box> { + let state = tempfile::tempdir()?; + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let service = Connection::connect(bus.connect().await?).await?; + setup(service.clone()).await?; + let client = Connection::connect(bus.connect().await?).await?; + let proxy = client.proxy(INTERFACE, OBJECT_PATH, INTERFACE)?; + let shell = tinybox_bus::SelectShellRequest { + command: "echo native".into(), + preserve_pipeline_status: false, + }; + let selection: tinybox_bus::ShellInvocation = + proxy.call("SelectShell", (shell.clone(),)).await?; + assert_eq!(selection, tinybox_host::select_shell(&shell)); + let resource: tinybox_bus::ResourceId = + proxy.call("Reserve", (tinybox_bus::ReserveRequest::Resource,)).await?; + proxy.call::("Create", (tinybox_bus::CreateRequest { + resource: resource.clone(), + backend: "passthrough".into(), + workspace: tinybox_bus::Workspace::Directory(".".into()), + ..Default::default() + },)).await?; + let process = proxy.call("Reserve", ( + tinybox_bus::ReserveRequest::Process(resource.clone()), + )).await?; + let process: tinybox_bus::ProcessRef = proxy.call("StartShellExec", ( + tinybox_bus::ShellExecRequest { + process, + resource: resource.clone(), + shell, + cwd: None, + extra_env: std::collections::BTreeMap::new(), + state_dir: state.path().to_str().ok_or("non-UTF8 state")?.into(), + timeout_ms: 5000, + stdin: None, + jail: None, + }, + )).await?; + let output = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let batch: tinybox_bus::OutputBatch = + proxy.call("ReadOutput", (process.clone(), 0_u64)).await?; + if batch.state != tinybox_bus::ExecutionState::Running { + break Ok::<_, tinybus::Error>(batch); + } + tokio::task::yield_now().await; + } + }).await??; + assert_eq!(output.state, tinybox_bus::ExecutionState::Exited { exit_code: 0 }); + let bytes: Vec = output.chunks.iter().flat_map(|chunk| chunk.bytes.clone()).collect(); + assert_eq!(String::from_utf8_lossy(&bytes).trim(), "native"); + proxy.call::<()>("ReleaseOutput", (process,)).await?; + assert_eq!(std::fs::read_dir(state.path().join("artifacts/sandbox-scratch"))?.count(), 0); + proxy.call::<()>("Close", (resource,)).await?; + Ok(()) +} From 2618710caeb51c681c25c5ddd23fa9e95a908217 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:26:11 +0300 Subject: [PATCH 33/95] fix(native_shell): retry scratch cleanup after deletion failure Scratch removal now happens during drain and keeps the TempDir alive when deletion fails, so a later close can retry instead of losing the path. Cleanup acknowledgement also waits on the collector, and the README documents the retained-context behaviour. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/mod_tests.rs | 79 ++++++++++++------- .../src/tinybus_module/native_shell/README.md | 4 + .../src/tinybus_module/native_shell/mod.rs | 32 +++++++- .../src/tinybus_module/native_shell/types.rs | 3 +- .../src/tinybus_module/resources_stream.rs | 2 +- .../tinybus_module/resources_stream_tests.rs | 4 +- 6 files changed, 90 insertions(+), 34 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index 98f8cf4b..fc96ee63 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -1054,30 +1054,42 @@ async fn native_shell_selection_and_execution_roundtrip_through_the_bus_codec() let selection: tinybox_bus::ShellInvocation = proxy.call("SelectShell", (shell.clone(),)).await?; assert_eq!(selection, tinybox_host::select_shell(&shell)); - let resource: tinybox_bus::ResourceId = - proxy.call("Reserve", (tinybox_bus::ReserveRequest::Resource,)).await?; - proxy.call::("Create", (tinybox_bus::CreateRequest { - resource: resource.clone(), - backend: "passthrough".into(), - workspace: tinybox_bus::Workspace::Directory(".".into()), - ..Default::default() - },)).await?; - let process = proxy.call("Reserve", ( - tinybox_bus::ReserveRequest::Process(resource.clone()), - )).await?; - let process: tinybox_bus::ProcessRef = proxy.call("StartShellExec", ( - tinybox_bus::ShellExecRequest { - process, - resource: resource.clone(), - shell, - cwd: None, - extra_env: std::collections::BTreeMap::new(), - state_dir: state.path().to_str().ok_or("non-UTF8 state")?.into(), - timeout_ms: 5000, - stdin: None, - jail: None, - }, - )).await?; + let resource: tinybox_bus::ResourceId = proxy + .call("Reserve", (tinybox_bus::ReserveRequest::Resource,)) + .await?; + proxy + .call::( + "Create", + (tinybox_bus::CreateRequest { + resource: resource.clone(), + backend: "passthrough".into(), + workspace: tinybox_bus::Workspace::Directory(".".into()), + ..Default::default() + },), + ) + .await?; + let process = proxy + .call( + "Reserve", + (tinybox_bus::ReserveRequest::Process(resource.clone()),), + ) + .await?; + let process: tinybox_bus::ProcessRef = proxy + .call( + "StartShellExec", + (tinybox_bus::ShellExecRequest { + process, + resource: resource.clone(), + shell, + cwd: None, + extra_env: std::collections::BTreeMap::new(), + state_dir: state.path().to_str().ok_or("non-UTF8 state")?.into(), + timeout_ms: 5000, + stdin: None, + jail: None, + },), + ) + .await?; let output = tokio::time::timeout(std::time::Duration::from_secs(5), async { loop { let batch: tinybox_bus::OutputBatch = @@ -1087,12 +1099,23 @@ async fn native_shell_selection_and_execution_roundtrip_through_the_bus_codec() } tokio::task::yield_now().await; } - }).await??; - assert_eq!(output.state, tinybox_bus::ExecutionState::Exited { exit_code: 0 }); - let bytes: Vec = output.chunks.iter().flat_map(|chunk| chunk.bytes.clone()).collect(); + }) + .await??; + assert_eq!( + output.state, + tinybox_bus::ExecutionState::Exited { exit_code: 0 } + ); + let bytes: Vec = output + .chunks + .iter() + .flat_map(|chunk| chunk.bytes.clone()) + .collect(); assert_eq!(String::from_utf8_lossy(&bytes).trim(), "native"); proxy.call::<()>("ReleaseOutput", (process,)).await?; - assert_eq!(std::fs::read_dir(state.path().join("artifacts/sandbox-scratch"))?.count(), 0); + assert_eq!( + std::fs::read_dir(state.path().join("artifacts/sandbox-scratch"))?.count(), + 0 + ); proxy.call::<()>("Close", (resource,)).await?; Ok(()) } diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/README.md b/crates/tinybox-module/src/tinybus_module/native_shell/README.md index 916cf8d6..0dee62e5 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/README.md +++ b/crates/tinybox-module/src/tinybus_module/native_shell/README.md @@ -9,3 +9,7 @@ unavailable enforcement. See [the contract](../../../../../docs/specs/native-shell-execution.md) and [its sequence](../../../../../docs/plans/native-shell-execution.md). + +Stream cleanup explicitly removes private scratch after draining the native +collector. A deletion failure is returned to the caller and retains the context +and scratch path for a later cleanup retry. diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs index c4b36a3c..646b9199 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs @@ -119,7 +119,8 @@ impl ShellContext { Ok(Arc::new(Self { request, environment, - _scratch: scratch, + scratch: std::sync::Mutex::new(Some(scratch)), + scratch_cleanup_failed: std::sync::atomic::AtomicBool::new(false), host, })) } @@ -145,6 +146,12 @@ impl ShellContext { } } pub(super) fn pending(&self) -> bool { + if self + .scratch_cleanup_failed + .load(std::sync::atomic::Ordering::SeqCst) + { + return true; + } match &self.host { ShellHost::Local(host) => host.has_pending_cleanup(), ShellHost::Jailed(host) => host.has_pending_cleanup(), @@ -152,9 +159,28 @@ impl ShellContext { } pub(super) async fn drain(&self) -> tinybox_core::Result<()> { match &self.host { - ShellHost::Local(host) => host.drain_checked().await, - ShellHost::Jailed(host) => host.drain_checked().await, + ShellHost::Local(host) => host.drain_checked().await?, + ShellHost::Jailed(host) => host.drain_checked().await?, + } + let mut scratch = self + .scratch + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(directory) = scratch.as_ref() { + // Keep the TempDir and its path until deletion succeeds; close() consumes + // ownership even on failure and would make cleanup impossible to retry. + if let Err(error) = std::fs::remove_dir_all(directory.path()) + && error.kind() != std::io::ErrorKind::NotFound + { + self.scratch_cleanup_failed + .store(true, std::sync::atomic::Ordering::SeqCst); + return Err(tinybox_core::Error::io("remove shell scratch", &error)); + } + scratch.take(); } + self.scratch_cleanup_failed + .store(false, std::sync::atomic::Ordering::SeqCst); + Ok(()) } } fn invalid() -> tinybus::Error { diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/types.rs b/crates/tinybox-module/src/tinybus_module/native_shell/types.rs index 89fe58bc..949ec513 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/types.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/types.rs @@ -3,7 +3,8 @@ use tinybox_bus::ShellExecRequest; pub(in crate::tinybus_module) struct ShellContext { pub(super) request: ShellExecRequest, pub(super) environment: std::collections::BTreeMap, - pub(super) _scratch: tempfile::TempDir, + pub(super) scratch: std::sync::Mutex>, + pub(super) scratch_cleanup_failed: std::sync::atomic::AtomicBool, pub(super) host: ShellHost, } pub(super) enum ShellHost { diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index b4bdc8c5..7f929257 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -225,7 +225,7 @@ impl StreamExecution { !self.journal.cleanup_acknowledged() } pub(super) fn cleanup_acknowledged(&self) -> bool { - self.journal.cleanup_acknowledged() + self.journal.cleanup_acknowledged() && !self.collector.pending() } pub(super) async fn stop(&self) -> Result<()> { self.journal.cancel(); diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index bc47e29b..9ea990d3 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -790,7 +790,9 @@ async fn failed_scratch_deletion_keeps_output_and_close_retryable() let process = resources.start_shell_exec(request).await?; completed(&resources, &process).await?; let scratch = std::fs::read_dir(state.path().join("artifacts/sandbox-scratch"))? - .next().ok_or("missing scratch")??.path(); + .next() + .ok_or("missing scratch")?? + .path(); let retained = scratch.with_extension("retained"); std::fs::rename(&scratch, &retained)?; std::fs::write(&scratch, "blocks directory removal")?; From ba7ed282b66d37b7eb13e018665185dda86f59d9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:26:42 +0300 Subject: [PATCH 34/95] fix(tinybus_module): read cleanup acknowledgement from the stream journal The cleanup acknowledgement check now reads through the stream's journal field, matching where the flag is actually stored. The scratch deletion test was extended to cover both the release and close retry paths. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/resources_stream.rs | 2 +- .../tinybus_module/resources_stream_tests.rs | 51 +++++++++++-------- 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index 7f929257..bc8ba47c 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -487,7 +487,7 @@ impl Resources { process: &ProcessRef, ) -> Result<()> { if let Some(stream) = self.stream(process)? { - if !stream.cleanup_acknowledged() { + if !stream.journal.cleanup_acknowledged() { return Err(failure( tinybox_bus::UNSUPPORTED_OPERATION, "execution cleanup is not acknowledged", diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index 9ea990d3..48ace965 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -783,27 +783,34 @@ async fn native_shell_output_limit_terminates_and_reaps_before_release() #[tokio::test] async fn failed_scratch_deletion_keeps_output_and_close_retryable() -> std::result::Result<(), Box> { - let resources = Resources::default(); - let resource = local_resource(&resources).await?; - let state = tempfile::tempdir()?; - let request = shell_request(&resources, &resource, state.path(), "echo owned").await?; - let process = resources.start_shell_exec(request).await?; - completed(&resources, &process).await?; - let scratch = std::fs::read_dir(state.path().join("artifacts/sandbox-scratch"))? - .next() - .ok_or("missing scratch")?? - .path(); - let retained = scratch.with_extension("retained"); - std::fs::rename(&scratch, &retained)?; - std::fs::write(&scratch, "blocks directory removal")?; - assert!(resources.release_output(&process).await.is_err()); - assert!(resources.stream(&process)?.is_some()); - assert!(resources.close(&resource).await.is_err()); - assert!(resources.stream(&process)?.is_some()); - std::fs::remove_file(&scratch)?; - std::fs::rename(&retained, &scratch)?; - resources.close(&resource).await?; - assert!(!scratch.exists()); - assert!(resources.stream(&process)?.is_none()); + for close in [false, true] { + let resources = Resources::default(); + let resource = local_resource(&resources).await?; + let state = tempfile::tempdir()?; + let request = shell_request(&resources, &resource, state.path(), "echo owned").await?; + let process = resources.start_shell_exec(request).await?; + completed(&resources, &process).await?; + let scratch = std::fs::read_dir(state.path().join("artifacts/sandbox-scratch"))? + .next() + .ok_or("missing scratch")?? + .path(); + let retained = scratch.with_extension("retained"); + std::fs::rename(&scratch, &retained)?; + std::fs::write(&scratch, "blocks directory removal")?; + assert!(resources.release_output(&process).await.is_err()); + assert!(resources.stream(&process)?.is_some()); + if close { + assert!(resources.close(&resource).await.is_err()); + assert!(resources.stream(&process)?.is_some()); + } + std::fs::remove_file(&scratch)?; + std::fs::rename(&retained, &scratch)?; + if !close { + resources.release_output(&process).await?; + } + resources.close(&resource).await?; + assert!(!scratch.exists()); + assert!(resources.stream(&process)?.is_none()); + } Ok(()) } From 78249af5ef78eb2b607cd08b988a6e71d1dc96be Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:27:01 +0300 Subject: [PATCH 35/95] test(tinybox-module): include mode and status in descendant cleanup assertion The assertion message in the native cancel and close test now interpolates the mode and process status, so a failure reports which mode was exercised and what the descendant's state actually was instead of a bare message. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-module/src/tinybus_module/resources_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-module/src/tinybus_module/resources_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_tests.rs index 452afdcc..28a513ee 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_tests.rs @@ -2285,7 +2285,7 @@ async fn native_cancel_and_close_terminate_descendants_and_reap_direct_children( .lines() .find(|line| line.starts_with("State:")) .is_some_and(|line| line.contains("Z (zombie)")), - "descendant still executes after native cleanup" + "descendant still executes after native cleanup ({mode}): {status}" ); } } From 6b577f602c844755158b6a7ca5fbedc96c567685 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:27:44 +0300 Subject: [PATCH 36/95] test(tinybus-module): cover shell admission rejection for nonlocal and nondirectory collectors Add a test asserting that start_shell_exec refuses a shell request with UNSUPPORTED_OPERATION when the resource is not a native host or has no workspace root, so the pre-admission checks stay enforced. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinybus_module/resources_stream_tests.rs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index 48ace965..f1034aef 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -814,3 +814,38 @@ async fn failed_scratch_deletion_keeps_output_and_close_retryable() } Ok(()) } + +#[tokio::test] +async fn native_shell_refuses_nonlocal_or_nondirectory_collectors_before_admission() +-> std::result::Result<(), Box> { + let resources = Resources::default(); + let resource = local_resource(&resources).await?; + let state = tempfile::tempdir()?; + let shell = shell_request(&resources, &resource, state.path(), "echo owned").await?; + let slot = resources.slot(&resource).await?; + { + let mut entry = slot.lock().await; + let entry = entry.as_mut().ok_or("missing resource")?; + entry.native_host = false; + } + let error = resources + .start_shell_exec(shell.clone()) + .await + .err() + .ok_or("accepted nonlocal shell")?; + assert_eq!(error.wire_name(), tinybox_bus::UNSUPPORTED_OPERATION); + { + let mut entry = slot.lock().await; + let entry = entry.as_mut().ok_or("missing resource")?; + entry.native_host = true; + entry.workspace_root = None; + } + let error = resources + .start_shell_exec(shell) + .await + .err() + .ok_or("accepted nondirectory shell")?; + assert_eq!(error.wire_name(), tinybox_bus::UNSUPPORTED_OPERATION); + resources.close(&resource).await?; + Ok(()) +} From c9fce8e1073d57a7a2f8b248d5531dda0bb69db8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:30:31 +0300 Subject: [PATCH 37/95] test(shell): cover exported bash functions hiding pipeline failures Add a regression test that re-executes the test binary with an exported BASH_FUNC_false%% override to confirm an inherited shell function cannot mask a failing pipeline stage. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-host/src/local/shell_tests.rs | 25 ++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/tinybox-host/src/local/shell_tests.rs b/crates/tinybox-host/src/local/shell_tests.rs index 1c466e85..a3c8d16d 100644 --- a/crates/tinybox-host/src/local/shell_tests.rs +++ b/crates/tinybox-host/src/local/shell_tests.rs @@ -172,3 +172,28 @@ fn inherited_bash_options_cannot_abort_the_requested_payload() ); Ok(()) } + +#[test] +#[cfg(unix)] +fn exported_bash_functions_cannot_hide_pipeline_failures() +-> Result<(), Box> { + const CHILD: &str = "TINYBOX_SHELL_FUNCTION_REGRESSION_CHILD"; + if bash_path().is_none() { + return Ok(()); + } + if std::env::var_os(CHILD).is_some() { + assert!(!build_shell_command(&request(false)).status()?.success()); + return Ok(()); + } + let status = std::process::Command::new(std::env::current_exe()?) + .args([ + "--exact", + "local::shell::tests::exported_bash_functions_cannot_hide_pipeline_failures", + "--nocapture", + ]) + .env(CHILD, "1") + .env("BASH_FUNC_false%%", "() { return 0; }") + .status()?; + assert!(status.success(), "exported function hid the failing pipeline stage"); + Ok(()) +} From c8c4c963e5764da90dcaf5ce840fda0922a9992c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:31:13 +0300 Subject: [PATCH 38/95] fix(local): run bash with -p to ignore exported functions Bash invocations now pass -p so privileged startup ignores exported functions, which could otherwise shadow builtins and hide failing pipeline stages. Bash discovery was also made testable by extracting a helper that takes the candidate paths, with a test covering rechecks after a candidate appears or disappears. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-host/src/local/shell.rs | 33 ++++++++++++------ crates/tinybox-host/src/local/shell_tests.rs | 35 +++++++++++++++++--- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/crates/tinybox-host/src/local/shell.rs b/crates/tinybox-host/src/local/shell.rs index d772e857..822c68d6 100644 --- a/crates/tinybox-host/src/local/shell.rs +++ b/crates/tinybox-host/src/local/shell.rs @@ -76,14 +76,23 @@ fn selection_for( ShellFlavor::Posix => ShellInvocation { flavor, program: bash.unwrap_or("sh").into(), - arguments: vec![ - "-c".into(), - if bash.is_some() && !request.preserve_pipeline_status { - format!("set -o pipefail\n{}", request.command) - } else { - request.command.clone() - }, - ], + arguments: { + let mut arguments = Vec::new(); + if bash.is_some() { + // Privileged startup ignores exported functions even when + // environment overrides are applied after command construction. + arguments.push("-p".into()); + } + arguments.extend([ + "-c".into(), + if bash.is_some() && !request.preserve_pipeline_status { + format!("set -o pipefail\n{}", request.command) + } else { + request.command.clone() + }, + ]); + arguments + }, raw_arguments: None, bootstrap_environment: Vec::new(), remove_environment: vec![ @@ -97,10 +106,14 @@ fn selection_for( } fn bash_path() -> Option<&'static str> { + bash_path_in(&["/usr/bin/bash", "/bin/bash"]) +} + +fn bash_path_in(paths: &[&'static str]) -> Option<&'static str> { static BASH: std::sync::OnceLock> = std::sync::OnceLock::new(); *BASH.get_or_init(|| { - ["/usr/bin/bash", "/bin/bash"] - .into_iter() + paths + .iter().copied() .find(|path| std::path::Path::new(path).is_file()) }) } diff --git a/crates/tinybox-host/src/local/shell_tests.rs b/crates/tinybox-host/src/local/shell_tests.rs index a3c8d16d..0bf72b00 100644 --- a/crates/tinybox-host/src/local/shell_tests.rs +++ b/crates/tinybox-host/src/local/shell_tests.rs @@ -11,9 +11,12 @@ fn request(preserve_pipeline_status: bool) -> SelectShellRequest { #[test] fn bash_surfaces_pipe_failures_and_preservation_keeps_the_original_payload() { let selected = selection_for(&request(false), ShellFlavor::Posix, Some("/bin/bash")); - assert_eq!(selected.arguments, ["-c", "set -o pipefail\nfalse | true"]); + assert_eq!( + selected.arguments, + ["-p", "-c", "set -o pipefail\nfalse | true"] + ); let preserved = selection_for(&request(true), ShellFlavor::Posix, Some("/bin/bash")); - assert_eq!(preserved.arguments, ["-c", "false | true"]); + assert_eq!(preserved.arguments, ["-p", "-c", "false | true"]); } #[test] @@ -175,8 +178,8 @@ fn inherited_bash_options_cannot_abort_the_requested_payload() #[test] #[cfg(unix)] -fn exported_bash_functions_cannot_hide_pipeline_failures() --> Result<(), Box> { +fn exported_bash_functions_cannot_hide_pipeline_failures() -> Result<(), Box> +{ const CHILD: &str = "TINYBOX_SHELL_FUNCTION_REGRESSION_CHILD"; if bash_path().is_none() { return Ok(()); @@ -194,6 +197,28 @@ fn exported_bash_functions_cannot_hide_pipeline_failures() .env(CHILD, "1") .env("BASH_FUNC_false%%", "() { return 0; }") .status()?; - assert!(status.success(), "exported function hid the failing pipeline stage"); + assert!( + status.success(), + "exported function hid the failing pipeline stage" + ); + Ok(()) +} + + +#[test] +fn bash_discovery_rechecks_availability() -> Result<(), Box> { + // A changing candidate list represents installation/removal without altering + // global system paths or relying on the machine's installed shell. + let fixture = tempfile::NamedTempFile::new()?; + // The discovery API returns standard static paths in production; leak only + // this small test path so the same candidate can be checked repeatedly. + let candidate: &'static str = Box::leak(fixture.path().to_string_lossy().into_owned().into_boxed_str()); + assert_eq!(bash_path_in(&[candidate]), Some(candidate)); + fixture.close()?; + assert_eq!(bash_path_in(&[candidate]), None); + std::fs::write(candidate, "fixture")?; + let discovered = bash_path_in(&[candidate]); + std::fs::remove_file(candidate)?; + assert_eq!(discovered, Some(candidate)); Ok(()) } From 88fdcd0386d5ce7be5b62ac5a29f553cb8a89add Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:31:46 +0300 Subject: [PATCH 39/95] refactor(local): drop caching from bash path discovery bash_path_in no longer memoizes its result, so each call re-probes the candidate paths and reflects shell installation or removal immediately. The test no longer needs to leak its fixture path, and the shell selection docs now record the additional removed variables and the -p flag. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-host/src/local/shell.rs | 12 +++++------- crates/tinybox-host/src/local/shell_tests.rs | 6 ++---- docs/plans/shell-selection.md | 6 ++++-- docs/specs/shell-selection.md | 6 ++++-- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/crates/tinybox-host/src/local/shell.rs b/crates/tinybox-host/src/local/shell.rs index 822c68d6..a6c9b4c8 100644 --- a/crates/tinybox-host/src/local/shell.rs +++ b/crates/tinybox-host/src/local/shell.rs @@ -109,13 +109,11 @@ fn bash_path() -> Option<&'static str> { bash_path_in(&["/usr/bin/bash", "/bin/bash"]) } -fn bash_path_in(paths: &[&'static str]) -> Option<&'static str> { - static BASH: std::sync::OnceLock> = std::sync::OnceLock::new(); - *BASH.get_or_init(|| { - paths - .iter().copied() - .find(|path| std::path::Path::new(path).is_file()) - }) +fn bash_path_in<'a>(paths: &[&'a str]) -> Option<&'a str> { + paths + .iter() + .copied() + .find(|path| std::path::Path::new(path).is_file()) } #[cfg(test)] diff --git a/crates/tinybox-host/src/local/shell_tests.rs b/crates/tinybox-host/src/local/shell_tests.rs index 0bf72b00..43af8bf1 100644 --- a/crates/tinybox-host/src/local/shell_tests.rs +++ b/crates/tinybox-host/src/local/shell_tests.rs @@ -204,15 +204,13 @@ fn exported_bash_functions_cannot_hide_pipeline_failures() -> Result<(), Box Result<(), Box> { // A changing candidate list represents installation/removal without altering // global system paths or relying on the machine's installed shell. let fixture = tempfile::NamedTempFile::new()?; - // The discovery API returns standard static paths in production; leak only - // this small test path so the same candidate can be checked repeatedly. - let candidate: &'static str = Box::leak(fixture.path().to_string_lossy().into_owned().into_boxed_str()); + let candidate_string = fixture.path().to_string_lossy().into_owned(); + let candidate = candidate_string.as_str(); assert_eq!(bash_path_in(&[candidate]), Some(candidate)); fixture.close()?; assert_eq!(bash_path_in(&[candidate]), None); diff --git a/docs/plans/shell-selection.md b/docs/plans/shell-selection.md index 6858b23f..fd1658ba 100644 --- a/docs/plans/shell-selection.md +++ b/docs/plans/shell-selection.md @@ -38,5 +38,7 @@ preserves raw Windows quoting and uses existing output/cancellation ownership. Do not mark that consumer objection resolved on discovery-only evidence. Windows explicitly enables extensions and disables delayed expansion. -Unix builders remove BASH_ENV and ENV; ShellInvocation returns those removal -names. Providers apply them after approved environment overrides. +Unix builders remove BASH_ENV, ENV, SHELLOPTS and BASHOPTS; ShellInvocation +returns those removal names. Bash uses -p to ignore imported environment +functions. Each discovery probes the standard paths again so shell installation +or removal is reflected immediately. Providers apply them after approved environment overrides. diff --git a/docs/specs/shell-selection.md b/docs/specs/shell-selection.md index 5f63344b..db609326 100644 --- a/docs/specs/shell-selection.md +++ b/docs/specs/shell-selection.md @@ -50,5 +50,7 @@ There are no open decisions for discovery. The pending execution slice must retain process supervision and prove actual module execution on Windows. Windows explicitly enables extensions and disables delayed expansion. -Unix builders remove BASH_ENV and ENV; ShellInvocation returns those removal -names. Providers apply them after approved environment overrides. +Unix builders remove BASH_ENV, ENV, SHELLOPTS and BASHOPTS; ShellInvocation +returns those removal names. Bash uses -p to ignore imported environment +functions. Each discovery probes the standard paths again so shell installation +or removal is reflected immediately. Providers apply them after approved environment overrides. From 32509825ba3074471a4cc18e1fb451b5637255a7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:31:56 +0300 Subject: [PATCH 40/95] test(grants-manifest): relax shell argument assertions The end-to-end test now checks that the argument list contains -c rather than requiring it in the first position, and additionally asserts that bash invocations start with -p. This accommodates the shell selection behaviour where bash is launched with a privileged-mode flag ahead of the command flag. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-module/tests/grants_manifest_e2e.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-module/tests/grants_manifest_e2e.rs b/crates/tinybox-module/tests/grants_manifest_e2e.rs index 25ecb1c1..f20aad01 100644 --- a/crates/tinybox-module/tests/grants_manifest_e2e.rs +++ b/crates/tinybox-module/tests/grants_manifest_e2e.rs @@ -108,7 +108,10 @@ async fn verify_discovery(proxy: &tinybus::Proxy) -> Result<(), Box Date: Sun, 11 Oct 2026 22:32:46 +0300 Subject: [PATCH 41/95] test(grants): cover shell execution through the compiled manifest Extend the grants manifest end-to-end test to run a shell command through the compiled module and assert on its output, exercising quoting and redirection paths that discovery alone did not reach. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tests/grants_manifest_e2e.rs | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/crates/tinybox-module/tests/grants_manifest_e2e.rs b/crates/tinybox-module/tests/grants_manifest_e2e.rs index f20aad01..e8845fcb 100644 --- a/crates/tinybox-module/tests/grants_manifest_e2e.rs +++ b/crates/tinybox-module/tests/grants_manifest_e2e.rs @@ -59,6 +59,7 @@ async fn compiled_manifest_admits_and_executes_grant_and_shell_discovery() tinybox_bus::INTERFACE, )?; verify_discovery(&proxy).await?; + verify_shell_execution(&proxy).await?; proxy.call::<()>("Shutdown", ()).await?; broker_task.abort(); Ok(()) @@ -141,6 +142,56 @@ async fn verify_discovery(proxy: &tinybus::Proxy) -> Result<(), Box Result<(), Box> { + let workspace = tempfile::tempdir()?; + let resource: tinybox_bus::ResourceId = proxy.call("Reserve", (tinybox_bus::ReserveRequest::Resource,)).await?; + let _: tinybox_bus::ResourceInfo = proxy.call("Create", (tinybox_bus::CreateRequest { + resource: resource.clone(), + backend: "passthrough".into(), + workspace: tinybox_bus::Workspace::Directory(workspace.path().to_str().ok_or("workspace is not UTF-8")?.into()), + ..Default::default() + },)).await?; + let process = proxy.call("Reserve", (tinybox_bus::ReserveRequest::Process(resource.clone()),)).await?; + // Windows runs a quoted executable with a quoted argument and redirects to + // a path containing spaces and metacharacters through the actual module. + #[cfg(windows)] + let payload = { + let tool = workspace.path().join("tool with spaces.exe"); + std::fs::copy(std::env::current_exe()?, &tool)?; + format!("\"{}\" \"--list\" > \"{}\"", tool.display(), workspace.path().join("output (fixture).txt").display()) + }; + #[cfg(not(windows))] + let payload = "printf fixture > 'output (fixture).txt'".to_owned(); + let process: tinybox_bus::ProcessRef = proxy.call("StartShellExec", ( + tinybox_bus::SpawnRequest { + process, + command: tinybox_bus::ExecRequest { + resource: resource.clone(), argv: Vec::new(), cwd: None, + env: Default::default(), stdin: None, + }, + }, + tinybox_bus::SelectShellRequest { command: payload, preserve_pipeline_status: false }, + )).await?; + let batch = tokio::time::timeout(Duration::from_secs(10), async { + loop { + let batch: tinybox_bus::OutputBatch = proxy.call("ReadOutput", (process.clone(), 0_u64)).await?; + if batch.state != tinybox_bus::ExecutionState::Running { + return Ok::<_, tinybus::Error>(batch); + } + tokio::task::yield_now().await; + } + }).await??; + assert_eq!(batch.state, tinybox_bus::ExecutionState::Exited { exit_code: 0 }); + let output = std::fs::read_to_string(workspace.path().join("output (fixture).txt"))?; + #[cfg(windows)] + assert!(output.contains("compiled_manifest_admits_and_executes_grant_and_shell_discovery")); + #[cfg(not(windows))] + assert_eq!(output, "fixture"); + proxy.call::<()>("ReleaseOutput", (process,)).await?; + proxy.call::<()>("Close", (resource,)).await?; + Ok(()) +} + fn compiled_artifact() -> Result> { let executable = std::env::current_exe()?; let dependencies = executable From e285a0b362e17c8140c29536f0377b983e814620 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:34:08 +0300 Subject: [PATCH 42/95] feat(bus): add supervised native shell execution Add a StartShellExec method and bump the contract to 1.11 so modules can run platform shell text on local passthrough resources. The host gains a run_shell_observed entry point that reuses the existing output and cleanup supervisor, and the module rejects shell requests with non-empty argv or without a local passthrough backend. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-bus/src/lib.rs | 4 +- crates/tinybox-bus/src/lib_tests.rs | 6 +- crates/tinybox-host/src/limited/mod.rs | 41 +++++++++- .../tinybox-module/src/tinybus_module/mod.rs | 15 +++- .../src/tinybus_module/resources_stream.rs | 70 ++++++++++++++-- .../tinybus_module/resources_stream_tests.rs | 28 +++++++ .../tests/grants_manifest_e2e.rs | 82 ++++++++++++++----- 7 files changed, 210 insertions(+), 36 deletions(-) diff --git a/crates/tinybox-bus/src/lib.rs b/crates/tinybox-bus/src/lib.rs index 77ee30b6..285c7170 100644 --- a/crates/tinybox-bus/src/lib.rs +++ b/crates/tinybox-bus/src/lib.rs @@ -44,7 +44,8 @@ pub use files::{ /// 1.7 adds explicitly approved native directory-jail execution. 1.8 adds a /// bounded, module-owned one-shot Docker operation. 1.9 adds local grant discovery. /// 1.10 adds platform shell selection. -pub const CONTRACT_VERSION: (u32, u32) = (1, 10); +/// 1.11 adds supervised native shell execution on local passthrough resources. +pub const CONTRACT_VERSION: (u32, u32) = (1, 11); /// Whether this host vocabulary can bind to a module's advertised version. /// The major must match and the module's minor must include every host member. @@ -116,6 +117,7 @@ pub const METHODS: &[&str] = &[ "RunDocker", "ResolveLocalGrants", "SelectShell", + "StartShellExec", ]; /// Facts about the detected native directory-jail backend. diff --git a/crates/tinybox-bus/src/lib_tests.rs b/crates/tinybox-bus/src/lib_tests.rs index 3c5d53c0..6a890f02 100644 --- a/crates/tinybox-bus/src/lib_tests.rs +++ b/crates/tinybox-bus/src/lib_tests.rs @@ -46,8 +46,8 @@ fn docker_run_vocabulary_is_typed_bounded_and_additive() -> Result<(), serde_jso timeout_ms: 120_000, }; assert_eq!(METHODS[27], "RunDocker"); - assert_eq!(METHODS.last(), Some(&"SelectShell")); - assert_eq!(CONTRACT_VERSION, (1, 10)); + assert_eq!(METHODS.last(), Some(&"StartShellExec")); + assert_eq!(CONTRACT_VERSION, (1, 11)); assert_eq!(MAX_DOCKER_RUN_TIMEOUT_MS, 86_400_000); let serialized = serde_json::to_value(&request)?; assert_eq!(serialized["read_only_rootfs"], false); @@ -123,7 +123,7 @@ fn host_gateway_and_docker_facts_are_additive_and_default_safely() -> Result<(), #[test] fn version_and_capability_wire_snapshot_preserve_contract_compatibility() -> Result<(), serde_json::Error> { - assert_eq!(CONTRACT_VERSION, (1, 10)); + assert_eq!(CONTRACT_VERSION, (1, 11)); assert!(is_compatible(CONTRACT_VERSION)); assert!(!is_compatible((1, 7))); assert!(!is_compatible((1, 6))); diff --git a/crates/tinybox-host/src/limited/mod.rs b/crates/tinybox-host/src/limited/mod.rs index 6ece7977..5e870b44 100644 --- a/crates/tinybox-host/src/limited/mod.rs +++ b/crates/tinybox-host/src/limited/mod.rs @@ -147,12 +147,51 @@ impl LimitedLocalHost { } impl LimitedLocalHost { + /// Execute platform shell text with the ordinary output and cleanup supervisor. + /// The request supplies cwd, environment and stdin; its argv is unused. + /// + /// # Errors + /// Returns native startup, output-limit and cleanup errors just like observed argv execution. + pub async fn run_shell_observed( + &self, + request: &ExecRequest, + shell: &tinybox_bus::SelectShellRequest, + observer: Arc, + ) -> Result { + let mut command = crate::build_shell_command(shell); + command.envs(&request.env); + if let Some(cwd) = &request.cwd { + command.current_dir(cwd); + } + command.stdin(if request.stdin.is_some() { + std::process::Stdio::piped() + } else { + std::process::Stdio::null() + }); + // Apply startup removals after provider/caller environment overrides. + for name in crate::select_shell(shell).remove_environment { + command.env_remove(name); + } + let mut command = tokio::process::Command::from(command); + command.kill_on_drop(true); + self.collect_command(request, Some(observer), command).await + } + async fn collect( &self, request: &ExecRequest, observer: Option>, ) -> Result { - let mut command = LocalHost::command(request)?; + self.collect_command(request, observer, LocalHost::command(request)?) + .await + } + + async fn collect_command( + &self, + request: &ExecRequest, + observer: Option>, + mut command: tokio::process::Command, + ) -> Result { process::prepare(&mut command)?; command .stdout(std::process::Stdio::piped()) diff --git a/crates/tinybox-module/src/tinybus_module/mod.rs b/crates/tinybox-module/src/tinybus_module/mod.rs index 4e4c4f67..81f5eb86 100644 --- a/crates/tinybox-module/src/tinybus_module/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/mod.rs @@ -265,6 +265,19 @@ impl BoxService { )) .await } + /// Execute native shell text on a local passthrough resource with supervised output. + async fn start_shell_exec( + &self, + request: SpawnRequest, + shell: tinybox_bus::SelectShellRequest, + ) -> TinyBusResult { + let resources = self.resources.clone(); + finish_operation(tokio::spawn(async move { + resources.start_shell_exec(request, shell).await + })) + .await + } + /// Start an execution in the resource's native directory jail. async fn start_jailed_exec( &self, @@ -467,7 +480,7 @@ tinybus_module::module_export_optional_static! { setup = setup, worker_threads = 1, provides = ["ai.tinyhumans.tinybox.Box"], - methods = ["Describe", "Create", "Exec", "Inspect", "Close", "Spawn", "IsRunning", "Cancel", "AnalyzeShell", "Reserve", "Capabilities", "Shutdown", "Forward", "CloseForward", "JailStatus", "AnalyzeCommand", "BeginFileRead", "ReadFileChunk", "FinishFileRead", "BeginFileWrite", "WriteFileChunk", "FinishFileWrite", "AbortFileWrite", "StartExec", "ReadOutput", "ReleaseOutput", "StartJailedExec", "RunDocker", "ResolveLocalGrants", "SelectShell"], + methods = ["Describe", "Create", "Exec", "Inspect", "Close", "Spawn", "IsRunning", "Cancel", "AnalyzeShell", "Reserve", "Capabilities", "Shutdown", "Forward", "CloseForward", "JailStatus", "AnalyzeCommand", "BeginFileRead", "ReadFileChunk", "FinishFileRead", "BeginFileWrite", "WriteFileChunk", "FinishFileWrite", "AbortFileWrite", "StartExec", "ReadOutput", "ReleaseOutput", "StartJailedExec", "RunDocker", "ResolveLocalGrants", "SelectShell", "StartShellExec"], signals = [], requires = [], optional = [], diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index 0a17023c..d2ab4508 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -11,12 +11,17 @@ const MAX_STREAM_EXECUTIONS: usize = 32; pub(super) struct StreamExecution { request: SpawnRequest, jail: Option, + shell: Option, journal: Arc, task: Mutex>>, collector: Collector, } #[derive(Clone)] enum Collector { + Shell( + Arc, + tinybox_bus::SelectShellRequest, + ), Host( Arc, Option>, @@ -35,6 +40,7 @@ impl Collector { observer: Arc, ) -> tinybox_core::Result { match self { + Self::Shell(host, shell) => host.run_shell_observed(command, shell, observer).await, Self::Host(host, _) => host.run_observed(command, observer).await, Self::Jailed(host) => host.run_observed(command, observer).await, Self::Sandbox(sandbox, id, _) => sandbox.exec_observed(id, command, observer).await, @@ -42,6 +48,7 @@ impl Collector { } fn pending(&self) -> bool { match self { + Self::Shell(host, _) => host.has_pending_cleanup(), Self::Host(host, native) => { host.has_pending_observed_cleanup() || native @@ -66,6 +73,7 @@ impl Collector { } async fn drain(&self) -> tinybox_core::Result<()> { match self { + Self::Shell(host, _) => host.drain_checked().await, Self::Host(host, native) => { let remote = host.retry_observed_cleanup().await; if let Some(native) = native { @@ -127,6 +135,7 @@ impl StreamExecution { resolved: tinybox_core::ExecRequest, collector: Collector, jail: Option, + shell: Option, ) -> Self { let journal = Arc::new(OutputJournal::default()); let observer = journal.clone(); @@ -151,6 +160,7 @@ impl StreamExecution { Self { request, jail, + shell, journal, task: Mutex::new(Some(task)), collector, @@ -200,19 +210,34 @@ impl Resources { &self, request: SpawnRequest, ) -> Result { - self.start_with_jail(request, None).await + self.start_with_jail(request, None, None).await } pub(in crate::tinybus_module) async fn start_jailed_exec( &self, request: SpawnRequest, config: tinybox_bus::NativeJailConfig, ) -> Result { - self.start_with_jail(request, Some(config)).await + self.start_with_jail(request, Some(config), None).await + } + pub(in crate::tinybus_module) async fn start_shell_exec( + &self, + request: SpawnRequest, + shell: tinybox_bus::SelectShellRequest, + ) -> Result { + if !request.command.argv.is_empty() { + return Err(failure( + tinybox_bus::INVALID_ID, + "shell execution requires empty argv", + )); + } + self.start_with_jail(request, None, Some(shell)).await } + async fn start_with_jail( &self, request: SpawnRequest, jail: Option, + shell: Option, ) -> Result { validate_id(&request.process)?; validate_id(&request.command.resource)?; @@ -221,7 +246,10 @@ impl Resources { process: request.process.clone(), }; if let Some(existing) = self.stream(&process)? { - return if existing.request == request && existing.jail == jail { + return if existing.request == request + && existing.jail == jail + && existing.shell == shell + { Ok(process) } else { Err(failure( @@ -249,21 +277,42 @@ impl Resources { "owned native collector unavailable", )); } + if shell.is_some() && (!entry.native_host || entry.local_sandbox.is_none()) { + return Err(failure( + tinybox_bus::UNSUPPORTED_OPERATION, + "platform shell execution requires local passthrough", + )); + } // Each execution owns its cleanup collector. Cancelling one stream must // not drain an unrelated running supervisor or inherit its failure. // Resolve before consuming admission, and fence native start against Close. + let mut native_request = command(request.command.clone()); + if shell.is_some() { + // Passthrough resolution still supplies resource cwd and environment. + // The placeholder is never executed; the native builder owns argv. + native_request.argv = vec!["shell".into()]; + } let resolved = if let Some(sandbox) = &entry.local_sandbox { sandbox - .resolve_command(&entry.id, &command(request.command.clone())) + .resolve_command(&entry.id, &native_request) .map_err(|error| backend_error(&error))? } else { - let command = command(request.command.clone()); + let command = native_request; if command.argv.is_empty() { return Err(failure(tinybox_bus::INVALID_ID, "empty execution command")); } command }; - let collector = select_collector(entry, jail.as_ref(), &resolved)?; + let collector = if let Some(shell) = &shell { + Collector::Shell( + Arc::new(tinybox_host::LimitedLocalHost::new( + tinybox_bus::MAX_OUTPUT_BYTES, + )), + shell.clone(), + ) + } else { + select_collector(entry, jail.as_ref(), &resolved)? + }; let mut state = self.state.lock().await; let executions = self .executions @@ -275,7 +324,10 @@ impl Resources { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if let Some(existing) = streams.get(&process.process) { - return if existing.request == request && existing.jail == jail { + return if existing.request == request + && existing.jail == jail + && existing.shell == shell + { Ok(process) } else { Err(failure( @@ -309,7 +361,9 @@ impl Resources { )?; streams.insert( process.process.clone(), - Arc::new(StreamExecution::start(request, resolved, collector, jail)), + Arc::new(StreamExecution::start( + request, resolved, collector, jail, shell, + )), ); Ok(process) } diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index 7911179c..6aa2e0a3 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -617,3 +617,31 @@ async fn collector_drain_joins_its_owned_active_native_supervisor() -> Result<() ); Ok(()) } + +#[tokio::test] +async fn native_shell_execution_preserves_admission_and_request_identity() -> Result<()> { + let resources = Resources::default(); + let resource = local_resource(&resources).await?; + let request = request(&resources, &resource, Vec::new()).await?; + let shell = tinybox_bus::SelectShellRequest { + command: "echo fixture".into(), + preserve_pipeline_status: false, + }; + let process = resources.start_shell_exec(request.clone(), shell.clone()).await?; + assert_eq!(process, resources.start_shell_exec(request.clone(), shell.clone()).await?); + let mut different = shell.clone(); + different.command = "echo different".into(); + assert!(resources.start_shell_exec(request.clone(), different).await.is_err()); + assert!(resources.start_exec(request.clone()).await.is_err()); + completed(&resources, &process).await?; + let batch = resources.read_output(&process, 0).await?; + assert_eq!(batch.state, ExecutionState::Exited { exit_code: 0 }); + let bytes: Vec<_> = batch.chunks.iter().flat_map(|chunk| chunk.bytes.iter().copied()).collect(); + assert_eq!(String::from_utf8_lossy(&bytes).trim(), "fixture"); + resources.release_output(&process).await?; + let mut nonempty = request; + nonempty.command.argv = true_command(); + assert!(resources.start_shell_exec(nonempty, shell).await.is_err()); + resources.close(&resource).await?; + Ok(()) +} diff --git a/crates/tinybox-module/tests/grants_manifest_e2e.rs b/crates/tinybox-module/tests/grants_manifest_e2e.rs index e8845fcb..1502b742 100644 --- a/crates/tinybox-module/tests/grants_manifest_e2e.rs +++ b/crates/tinybox-module/tests/grants_manifest_e2e.rs @@ -144,44 +144,82 @@ async fn verify_discovery(proxy: &tinybus::Proxy) -> Result<(), Box Result<(), Box> { let workspace = tempfile::tempdir()?; - let resource: tinybox_bus::ResourceId = proxy.call("Reserve", (tinybox_bus::ReserveRequest::Resource,)).await?; - let _: tinybox_bus::ResourceInfo = proxy.call("Create", (tinybox_bus::CreateRequest { - resource: resource.clone(), - backend: "passthrough".into(), - workspace: tinybox_bus::Workspace::Directory(workspace.path().to_str().ok_or("workspace is not UTF-8")?.into()), - ..Default::default() - },)).await?; - let process = proxy.call("Reserve", (tinybox_bus::ReserveRequest::Process(resource.clone()),)).await?; + let resource: tinybox_bus::ResourceId = proxy + .call("Reserve", (tinybox_bus::ReserveRequest::Resource,)) + .await?; + let _: tinybox_bus::ResourceInfo = proxy + .call( + "Create", + (tinybox_bus::CreateRequest { + resource: resource.clone(), + backend: "passthrough".into(), + workspace: tinybox_bus::Workspace::Directory( + workspace + .path() + .to_str() + .ok_or("workspace is not UTF-8")? + .into(), + ), + ..Default::default() + },), + ) + .await?; + let process = proxy + .call( + "Reserve", + (tinybox_bus::ReserveRequest::Process(resource.clone()),), + ) + .await?; // Windows runs a quoted executable with a quoted argument and redirects to // a path containing spaces and metacharacters through the actual module. #[cfg(windows)] let payload = { let tool = workspace.path().join("tool with spaces.exe"); std::fs::copy(std::env::current_exe()?, &tool)?; - format!("\"{}\" \"--list\" > \"{}\"", tool.display(), workspace.path().join("output (fixture).txt").display()) + format!( + "\"{}\" \"--list\" > \"{}\"", + tool.display(), + workspace.path().join("output (fixture).txt").display() + ) }; #[cfg(not(windows))] let payload = "printf fixture > 'output (fixture).txt'".to_owned(); - let process: tinybox_bus::ProcessRef = proxy.call("StartShellExec", ( - tinybox_bus::SpawnRequest { - process, - command: tinybox_bus::ExecRequest { - resource: resource.clone(), argv: Vec::new(), cwd: None, - env: Default::default(), stdin: None, - }, - }, - tinybox_bus::SelectShellRequest { command: payload, preserve_pipeline_status: false }, - )).await?; + let process: tinybox_bus::ProcessRef = proxy + .call( + "StartShellExec", + ( + tinybox_bus::SpawnRequest { + process, + command: tinybox_bus::ExecRequest { + resource: resource.clone(), + argv: Vec::new(), + cwd: None, + env: Default::default(), + stdin: None, + }, + }, + tinybox_bus::SelectShellRequest { + command: payload, + preserve_pipeline_status: false, + }, + ), + ) + .await?; let batch = tokio::time::timeout(Duration::from_secs(10), async { loop { - let batch: tinybox_bus::OutputBatch = proxy.call("ReadOutput", (process.clone(), 0_u64)).await?; + let batch: tinybox_bus::OutputBatch = + proxy.call("ReadOutput", (process.clone(), 0_u64)).await?; if batch.state != tinybox_bus::ExecutionState::Running { return Ok::<_, tinybus::Error>(batch); } tokio::task::yield_now().await; } - }).await??; - assert_eq!(batch.state, tinybox_bus::ExecutionState::Exited { exit_code: 0 }); + }) + .await??; + assert_eq!( + batch.state, + tinybox_bus::ExecutionState::Exited { exit_code: 0 } + ); let output = std::fs::read_to_string(workspace.path().join("output (fixture).txt"))?; #[cfg(windows)] assert!(output.contains("compiled_manifest_admits_and_executes_grant_and_shell_discovery")); From 86ef52a3c4a64ac5aaf0d07f8cc2e6b8a2a937bb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:34:54 +0300 Subject: [PATCH 43/95] feat(tinybus): add StartShellExec for supervised native shell execution Contract 1.11 appends StartShellExec(SpawnRequest, SelectShellRequest) so hosts can run shell text through the module's existing output, cancellation and cleanup supervisor, with empty argv distinguishing it from ordinary StartExec. It supports local passthrough resources only, since selection describes the module machine's shell, and the native builder preserves cmd raw arguments. Docs and stream tests are updated to cover the new operation and its identity Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/README.md | 16 ++++++++++--- .../tinybus_module/resources_stream_tests.rs | 24 +++++++++++++++---- docs/plans/shell-selection.md | 17 +++++++------ docs/specs/shell-selection.md | 23 +++++++++++------- 4 files changed, 57 insertions(+), 23 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/README.md b/crates/tinybox-module/src/tinybus_module/README.md index dd238c30..d3765376 100644 --- a/crates/tinybox-module/src/tinybus_module/README.md +++ b/crates/tinybox-module/src/tinybus_module/README.md @@ -43,8 +43,9 @@ The interface is `ai.tinyhumans.tinybox.Box` at | AbortFileWrite | AbortFileWriteRequest | unit | | ResolveLocalGrants | ResolveLocalGrantsRequest | LocalGrants | | SelectShell | SelectShellRequest | ShellInvocation | +| StartShellExec | SpawnRequest, SelectShellRequest | ProcessRef | -Capabilities advertises contract version 1.10; 1.0 denotes the original +Capabilities advertises contract version 1.11; 1.0 denotes the original discovery-only surface. Hosts require equal majors and a module minor at least as new as their vocabulary, using `tinybox_bus::is_compatible`. Version 1.1 added reserved resource/process ownership and terminal shutdown; 1.2 adds host @@ -314,5 +315,14 @@ hosts continue to execute through the module. Native providers can use the shared quote-preserving TinyBox command builder. Windows explicitly enables extensions and disables delayed expansion. -Unix builders remove BASH_ENV and ENV; ShellInvocation returns those removal -names. Providers apply them after approved environment overrides. +Unix builders remove BASH_ENV, ENV, SHELLOPTS and BASHOPTS; ShellInvocation +returns those removal names. Bash uses -p to ignore exported functions. +Discovery checks standard Bash paths on every call. Providers apply them after approved environment overrides. + +Contract 1.11 adds `StartShellExec(SpawnRequest, SelectShellRequest)`. Supply an +empty argv in SpawnRequest; its resource, cwd, environment and stdin retain +their usual meaning. This operation supports local passthrough resources only, +because selection describes the module machine's shell. It uses the native +builder, including Windows raw arguments, and the existing supervised bounded +output, cancellation and cleanup lifecycle. Read and release its journal with +ReadOutput and ReleaseOutput. Retries must preserve both requests exactly. diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index 6aa2e0a3..109e5067 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -627,16 +627,32 @@ async fn native_shell_execution_preserves_admission_and_request_identity() -> Re command: "echo fixture".into(), preserve_pipeline_status: false, }; - let process = resources.start_shell_exec(request.clone(), shell.clone()).await?; - assert_eq!(process, resources.start_shell_exec(request.clone(), shell.clone()).await?); + let process = resources + .start_shell_exec(request.clone(), shell.clone()) + .await?; + assert_eq!( + process, + resources + .start_shell_exec(request.clone(), shell.clone()) + .await? + ); let mut different = shell.clone(); different.command = "echo different".into(); - assert!(resources.start_shell_exec(request.clone(), different).await.is_err()); + assert!( + resources + .start_shell_exec(request.clone(), different) + .await + .is_err() + ); assert!(resources.start_exec(request.clone()).await.is_err()); completed(&resources, &process).await?; let batch = resources.read_output(&process, 0).await?; assert_eq!(batch.state, ExecutionState::Exited { exit_code: 0 }); - let bytes: Vec<_> = batch.chunks.iter().flat_map(|chunk| chunk.bytes.iter().copied()).collect(); + let bytes: Vec<_> = batch + .chunks + .iter() + .flat_map(|chunk| chunk.bytes.iter().copied()) + .collect(); assert_eq!(String::from_utf8_lossy(&bytes).trim(), "fixture"); resources.release_output(&process).await?; let mut nonempty = request; diff --git a/docs/plans/shell-selection.md b/docs/plans/shell-selection.md index fd1658ba..fcb41950 100644 --- a/docs/plans/shell-selection.md +++ b/docs/plans/shell-selection.md @@ -1,8 +1,7 @@ # Shell selection implementation sequence [Specification](../specs/shell-selection.md). -This records the implemented discovery slice and its verification order; native -shell execution is tracked separately and remains unfinished. +This records the discovery and native execution slices and their verification order. 1. Add SelectShellRequest, ShellFlavor and ShellInvocation in crates/tinybox-bus/src/shell_selection.rs. Re-export from src/lib.rs, @@ -31,11 +30,15 @@ Local verification: - cargo clippy --locked --workspace --all-targets --all-features -- -D warnings - cargo build --locked --workspace --all-targets --all-features -Discovery implementation and compiled-artifact admission/invocation are complete. -Hosted validation is reported on the pull request. The native consumer remains -open: add a supervised module operation that accepts shell text directly, -preserves raw Windows quoting and uses existing output/cancellation ownership. -Do not mark that consumer objection resolved on discovery-only evidence. +6. Contract 1.11 appends StartShellExec(SpawnRequest, SelectShellRequest). + Reuse the live supervisor with a native-shell collector, keep shell text in + the idempotency identity, resolve resource cwd/environment, and reject + non-local or non-passthrough resources. Empty argv distinguishes shell + execution from ordinary StartExec. Cover identity/admission in stream tests + and execute through the compiled artifact on Unix and Windows. + +Discovery and supervised native execution are implemented. Hosted validation +is reported on the pull request; Windows behavior requires its native matrix. Windows explicitly enables extensions and disables delayed expansion. Unix builders remove BASH_ENV, ENV, SHELLOPTS and BASHOPTS; ShellInvocation diff --git a/docs/specs/shell-selection.md b/docs/specs/shell-selection.md index db609326..732a3b54 100644 --- a/docs/specs/shell-selection.md +++ b/docs/specs/shell-selection.md @@ -1,6 +1,6 @@ # Platform shell selection -Status: implemented discovery contract, under review. Owner: TinyBox. +Status: implemented discovery and supervised native execution, under review. Owner: TinyBox. Implementation sequence: [shell selection plan](../plans/shell-selection.md). ## Problem and boundary @@ -8,7 +8,7 @@ Implementation sequence: [shell selection plan](../plans/shell-selection.md). A host must discover the module machine's shell syntax without linking TinyBox implementation crates or returning environment values. Shell selection does not execute code, approve access, or claim confinement. Native shell execution -through the module remains a separate required integration slice. +uses the module-owned output and cleanup supervisor. ## Contract @@ -18,7 +18,7 @@ The result carries flavor, program, ordinary arguments, optional Windows raw arguments, and bootstrap environment variable names. Unix selects /usr/bin/bash or /bin/bash when present, otherwise sh. Both use -non-login -c. Bash prepends "set -o pipefail" unless preservation is requested; +non-login -c; Bash also uses -p to ignore imported environment functions. Bash prepends "set -o pipefail" unless preservation is requested; sh never receives that Bash-only prefix. For "false | true", Bash's default selection reports the failing stage; preservation retains the last-stage status. @@ -42,12 +42,17 @@ approved environment, working directory, pipes and lifecycle. with a quoted argument. - The actual compiled dynamic module passes production manifest admission and executes SelectShell, including when the surrounding test build is static. -- Hosts execute behavior through the compiled module. Existing argv-only - execution cannot carry cmd raw payloads; its native-shell consumer remains - an open review requirement, not an implemented capability here. - -There are no open decisions for discovery. The pending execution slice must -retain process supervision and prove actual module execution on Windows. +- Hosts execute behavior through the compiled module. Contract 1.11 appends + StartShellExec(SpawnRequest, SelectShellRequest) -> ProcessRef. The spawn + command supplies the resource, cwd, environment and stdin with empty argv. +- StartShellExec supports local passthrough resources only and rejects other + providers before consuming the process reservation. Selection describes the + module machine, so its plan cannot be applied to remote hosts or containers. +- It shares StartExec's bounded output journals, request-identity checks, + cancellation and cleanup barriers. The native builder preserves cmd raw + arguments through to the supervised child. +- Compiled-module fixtures execute shell text on Unix and quoted executable, + argument and redirection paths on Windows. Windows explicitly enables extensions and disables delayed expansion. Unix builders remove BASH_ENV, ENV, SHELLOPTS and BASHOPTS; ShellInvocation From 5e06baf408288a96b2405d49ce43b0f17371f477 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:35:05 +0300 Subject: [PATCH 44/95] test(tinybox-module): cover scratch retention for cancelled native shell Add a test asserting that a cancelled native shell keeps its sandbox scratch directory until output is released, and that release clears it. Also extend the timeout test with the same scratch-count check before release. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinybus_module/resources_stream_tests.rs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index f1034aef..9388088d 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -706,6 +706,10 @@ async fn native_shell_timeout_acknowledges_child_cleanup_before_output_release() code: ExecutionFailure::TimedOut } ); + assert_eq!( + std::fs::read_dir(state.path().join("artifacts/sandbox-scratch"))?.count(), + 1 + ); resources.release_output(&process).await?; resources.close(&resource).await?; Ok(()) @@ -849,3 +853,36 @@ async fn native_shell_refuses_nonlocal_or_nondirectory_collectors_before_admissi resources.close(&resource).await?; Ok(()) } + +#[tokio::test] +async fn cancelled_native_shell_retains_scratch_until_output_release() +-> std::result::Result<(), Box> { + let resources = Resources::default(); + let resource = local_resource(&resources).await?; + let state = tempfile::tempdir()?; + #[cfg(unix)] + let payload = "echo ready; sleep 60"; + #[cfg(windows)] + let payload = + "echo ready & powershell -NoProfile -NonInteractive -Command \"Start-Sleep -Seconds 60\""; + let request = shell_request(&resources, &resource, state.path(), payload).await?; + let process = resources.start_shell_exec(request).await?; + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while resources.read_output(&process, 0).await?.chunks.is_empty() { + tokio::task::yield_now().await; + } + Ok::<_, tinybus::Error>(()) + }) + .await??; + resources.cancel(&process).await?; + assert_eq!( + resources.read_output(&process, 0).await?.state, + ExecutionState::Cancelled + ); + let scratch = state.path().join("artifacts/sandbox-scratch"); + assert_eq!(std::fs::read_dir(&scratch)?.count(), 1); + resources.release_output(&process).await?; + assert_eq!(std::fs::read_dir(&scratch)?.count(), 0); + resources.close(&resource).await?; + Ok(()) +} From e38bd4ccc7abf1ecb151e69cd7e53d1e96939e87 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:35:15 +0300 Subject: [PATCH 45/95] test: update wire snapshot and stream fixtures for new fields Bump the capability wire snapshot to contract version 1.11 and add the newly required shell and stream arguments to the resources stream test fixtures so they compile against the updated signatures. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-bus/src/lib_tests.rs | 2 +- .../src/tinybus_module/resources_stream_tests.rs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-bus/src/lib_tests.rs b/crates/tinybox-bus/src/lib_tests.rs index 6a890f02..359979d2 100644 --- a/crates/tinybox-bus/src/lib_tests.rs +++ b/crates/tinybox-bus/src/lib_tests.rs @@ -140,7 +140,7 @@ fn version_and_capability_wire_snapshot_preserve_contract_compatibility() exec_backends: Vec::new(), spawn_backends: Vec::new(), }; - let snapshot = serde_json::json!({"contract_version":[1,10],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]}); + let snapshot = serde_json::json!({"contract_version":[1,11],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]}); assert_eq!(serde_json::to_value(&capabilities)?, snapshot); assert_eq!( serde_json::from_value::(snapshot)?, diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index 109e5067..347718f8 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -206,6 +206,7 @@ async fn a_panicked_job_is_joined_and_cleanup_failure_stays_owned_until_retry() }, }, jail: None, + shell: None, journal, task: Mutex::new(Some(task)), collector: Collector::Host(Arc::new(tinybox_host::LimitedLocalHost::new(64)), None), @@ -367,6 +368,7 @@ async fn remote_cleanup_failure_keeps_binary_output_and_owner_until_retry_ack() command, Collector::Host(host.clone(), None), None, + None, ); assert!(stream.stop().await.is_err()); assert!(stream.running()); @@ -556,6 +558,7 @@ async fn confirmed_container_destroy_keeps_native_cleanup_retryable_without_dest tinybox_core::ExecRequest::new(["fixture"]), Collector::Sandbox(Arc::new(sandbox), entry.id.clone(), None), None, + None, )); resources .streams From 4ee9721cb72e9be848d2189b80f6e711432c17aa Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:35:40 +0300 Subject: [PATCH 46/95] fix(native_shell): run scratch cleanup off the async worker Scratch directory deletion now happens on a blocking thread instead of the async worker, and stream teardown disposes the shell scratch so cleanup failures surface as backend errors. The scratch state is shared via Arc so the blocking closure can report whether removal succeeded. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/native_shell/mod.rs | 16 +++++++++-- .../tinybus_module/native_shell/mod_tests.rs | 28 +++++++++++++++++++ .../src/tinybus_module/native_shell/types.rs | 4 +-- .../src/tinybus_module/resources_stream.rs | 16 +++++++++-- 4 files changed, 57 insertions(+), 7 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs index 646b9199..c82faeca 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs @@ -119,8 +119,8 @@ impl ShellContext { Ok(Arc::new(Self { request, environment, - scratch: std::sync::Mutex::new(Some(scratch)), - scratch_cleanup_failed: std::sync::atomic::AtomicBool::new(false), + scratch: Arc::new(std::sync::Mutex::new(Some(scratch))), + scratch_cleanup_failed: Arc::new(std::sync::atomic::AtomicBool::new(false)), host, })) } @@ -162,6 +162,16 @@ impl ShellContext { ShellHost::Local(host) => host.drain_checked().await?, ShellHost::Jailed(host) => host.drain_checked().await?, } + Ok(()) + } + pub(super) async fn dispose_scratch(&self) -> tinybox_core::Result<()> { + self.dispose_scratch_with(|path| std::fs::remove_dir_all(path)) + .await + } + async fn dispose_scratch_with( + &self, + remove: impl FnOnce(&Path) -> std::io::Result<()> + Send + 'static, + ) -> tinybox_core::Result<()> { let mut scratch = self .scratch .lock() @@ -169,7 +179,7 @@ impl ShellContext { if let Some(directory) = scratch.as_ref() { // Keep the TempDir and its path until deletion succeeds; close() consumes // ownership even on failure and would make cleanup impossible to retry. - if let Err(error) = std::fs::remove_dir_all(directory.path()) + if let Err(error) = remove(directory.path()) && error.kind() != std::io::ErrorKind::NotFound { self.scratch_cleanup_failed diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs index 882ce717..e8df58b1 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs @@ -165,3 +165,31 @@ async fn native_shell_jail_adds_owned_scratch_and_refuses_unsupported_constraint ); Ok(()) } + +#[tokio::test(flavor = "current_thread")] +async fn scratch_deletion_runs_outside_the_async_worker() +-> std::result::Result<(), Box> { + let state = tempfile::tempdir()?; + let resolved = tinybox_core::ExecRequest::new(["unused"]); + let shell = ShellContext::prepare(request(state.path()), state.path(), &resolved)?; + let observed = Arc::new(std::sync::Mutex::new(None)); + let recorded = observed.clone(); + let worker = std::thread::current().id(); + shell + .dispose_scratch_with(move |path| { + *recorded + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some(std::thread::current().id()); + std::fs::remove_dir_all(path) + }) + .await?; + assert_ne!( + *observed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + Some(worker), + "recursive filesystem deletion must not run on the sole async worker" + ); + Ok(()) +} diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/types.rs b/crates/tinybox-module/src/tinybus_module/native_shell/types.rs index 949ec513..6c6fad08 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/types.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/types.rs @@ -3,8 +3,8 @@ use tinybox_bus::ShellExecRequest; pub(in crate::tinybus_module) struct ShellContext { pub(super) request: ShellExecRequest, pub(super) environment: std::collections::BTreeMap, - pub(super) scratch: std::sync::Mutex>, - pub(super) scratch_cleanup_failed: std::sync::atomic::AtomicBool, + pub(super) scratch: std::sync::Arc>>, + pub(super) scratch_cleanup_failed: std::sync::Arc, pub(super) host: ShellHost, } pub(super) enum ShellHost { diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index bc8ba47c..2b5227f9 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -247,6 +247,18 @@ impl StreamExecution { } Ok(()) } + async fn dispose(&self) -> Result<()> { + self.stop().await?; + if let Collector::Shell(shell) = &self.collector { + shell.dispose_scratch().await.map_err(|_| { + failure( + tinybox_bus::BACKEND_ERROR, + "shell scratch cleanup remains pending", + ) + })?; + } + Ok(()) + } } impl Drop for StreamExecution { fn drop(&mut self) { @@ -440,7 +452,7 @@ impl Resources { pub(super) async fn close_streams(&self, resource: &ResourceId) -> Result<()> { let mut failure = None; for stream in self.streams_for(resource) { - if let Err(error) = stream.stop().await { + if let Err(error) = stream.dispose().await { failure.get_or_insert(error); } } @@ -493,7 +505,7 @@ impl Resources { "execution cleanup is not acknowledged", )); } - stream.stop().await?; + stream.dispose().await?; self.streams .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) From 1badd56e70580c00adaa697aa0f3b31c289ee13c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:36:00 +0300 Subject: [PATCH 47/95] fix(native_shell): run scratch cleanup on a blocking worker Scratch removal now happens inside spawn_blocking so the async runtime is not blocked by filesystem deletion, and the scratch handle and failure flag are cloned into the task instead of borrowed. The README was updated to describe that output release and resource close remove scratch on a blocking worker, while timeout and cancellation drain the process but retain scratch as long as its journal is retained. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/native_shell/README.md | 5 ++- .../src/tinybus_module/native_shell/mod.rs | 42 +++++++++++-------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/README.md b/crates/tinybox-module/src/tinybus_module/native_shell/README.md index 0dee62e5..08b93a40 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/README.md +++ b/crates/tinybox-module/src/tinybus_module/native_shell/README.md @@ -10,6 +10,7 @@ unavailable enforcement. See [the contract](../../../../../docs/specs/native-shell-execution.md) and [its sequence](../../../../../docs/plans/native-shell-execution.md). -Stream cleanup explicitly removes private scratch after draining the native -collector. A deletion failure is returned to the caller and retains the context +Output release and resource close explicitly remove private scratch on a +blocking worker after draining the native collector. Timeout and cancellation +drain the process but retain scratch for as long as its journal is retained. A deletion failure is returned to the caller and retains the context and scratch path for a later cleanup retry. diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs index c82faeca..22573a82 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs @@ -172,25 +172,31 @@ impl ShellContext { &self, remove: impl FnOnce(&Path) -> std::io::Result<()> + Send + 'static, ) -> tinybox_core::Result<()> { - let mut scratch = self - .scratch - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(directory) = scratch.as_ref() { - // Keep the TempDir and its path until deletion succeeds; close() consumes - // ownership even on failure and would make cleanup impossible to retry. - if let Err(error) = remove(directory.path()) - && error.kind() != std::io::ErrorKind::NotFound - { - self.scratch_cleanup_failed - .store(true, std::sync::atomic::Ordering::SeqCst); - return Err(tinybox_core::Error::io("remove shell scratch", &error)); + let scratch = self.scratch.clone(); + let failed = self.scratch_cleanup_failed.clone(); + tokio::task::spawn_blocking(move || { + let mut scratch = self + .scratch + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(directory) = scratch.as_ref() { + // Keep the TempDir and its path until deletion succeeds; close() consumes + // ownership even on failure and would make cleanup impossible to retry. + if let Err(error) = remove(directory.path()) + && error.kind() != std::io::ErrorKind::NotFound + { + failed.store(true, std::sync::atomic::Ordering::SeqCst); + return Err(tinybox_core::Error::io("remove shell scratch", &error)); + } + scratch.take(); } - scratch.take(); - } - self.scratch_cleanup_failed - .store(false, std::sync::atomic::Ordering::SeqCst); - Ok(()) + failed.store(false, std::sync::atomic::Ordering::SeqCst); + Ok(()) + }) + .await + .map_err(|error| { + tinybox_core::Error::io("join shell scratch cleanup", &std::io::Error::other(error)) + })? } } fn invalid() -> tinybus::Error { From 801f7272947091a25039e6f89a74ece003317634 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:36:07 +0300 Subject: [PATCH 48/95] refactor(tinybus-module): extract shell execution preparation Move the shell and native command resolution into a shared helper so the unsupported-platform check runs before admission is consumed. Add tests covering cwd, environment, stdin and output limits for observed shell execution, and confirming remote selection is rejected without consuming a slot. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/limited/observed_tests.rs | 32 ++++++++ .../src/tinybus_module/resources_stream.rs | 81 ++++++++++--------- .../tinybus_module/resources_stream_tests.rs | 36 +++++++++ 3 files changed, 113 insertions(+), 36 deletions(-) diff --git a/crates/tinybox-host/src/limited/observed_tests.rs b/crates/tinybox-host/src/limited/observed_tests.rs index 82bc3ffb..077a08fd 100644 --- a/crates/tinybox-host/src/limited/observed_tests.rs +++ b/crates/tinybox-host/src/limited/observed_tests.rs @@ -272,3 +272,35 @@ async fn cleanup_retry_does_not_wait_for_or_cancel_an_active_local_execution() - ); Ok(()) } + +#[tokio::test] +async fn observed_shell_keeps_cwd_environment_stdin_and_output_limits() -> Result<()> { + let root = tempfile::tempdir().map_err(|error| Error::io("create fixture", &error))?; + let host = LimitedLocalHost::new(64); + let request = ExecRequest::new(Vec::::new()) + .with_cwd(root.path()) + .with_env("TINYBOX_SHELL_VALUE", "fixture") + .with_env("SHELLOPTS", "errexit") + .with_stdin("input"); + let shell = tinybox_bus::SelectShellRequest { + command: "false; printf '%s:' \"$TINYBOX_SHELL_VALUE\"; cat; printf file > result.txt" + .into(), + preserve_pipeline_status: false, + }; + let output = host + .run_shell_observed(&request, &shell, Arc::new(Observer::default())) + .await?; + assert!(output.succeeded()); + assert_eq!(output.stdout, b"fixture:input"); + assert!(root.path().join("result.txt").is_file()); + let error = LimitedLocalHost::new(1) + .run_shell_observed(&request, &shell, Arc::new(Observer::default())) + .await + .err(); + assert!(matches!( + error, + Some(Error::OutputLimitExceeded { limit: 1 }) + )); + host.drain_checked().await?; + Ok(()) +} diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index d2ab4508..e62ab5e8 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -129,6 +129,48 @@ fn select_collector( } }) } +fn prepare_execution( + entry: &super::Resource, + request: &SpawnRequest, + jail: Option<&tinybox_bus::NativeJailConfig>, + shell: Option<&tinybox_bus::SelectShellRequest>, +) -> Result<(tinybox_core::ExecRequest, Collector)> { + if shell.is_some() && (!entry.native_host || entry.local_sandbox.is_none()) { + return Err(failure( + tinybox_bus::UNSUPPORTED_OPERATION, + "platform shell execution requires local passthrough", + )); + } + let mut native_request = command(request.command.clone()); + if shell.is_some() { + // Passthrough resolution still supplies resource cwd and environment. + // The placeholder is never executed; the native builder owns argv. + native_request.argv = vec!["shell".into()]; + } + let resolved = if let Some(sandbox) = &entry.local_sandbox { + sandbox + .resolve_command(&entry.id, &native_request) + .map_err(|error| backend_error(&error))? + } else { + let command = native_request; + if command.argv.is_empty() { + return Err(failure(tinybox_bus::INVALID_ID, "empty execution command")); + } + command + }; + let collector = if let Some(shell) = shell { + Collector::Shell( + Arc::new(tinybox_host::LimitedLocalHost::new( + tinybox_bus::MAX_OUTPUT_BYTES, + )), + shell.clone(), + ) + } else { + select_collector(entry, jail, &resolved)? + }; + Ok((resolved, collector)) +} + impl StreamExecution { fn start( request: SpawnRequest, @@ -277,42 +319,9 @@ impl Resources { "owned native collector unavailable", )); } - if shell.is_some() && (!entry.native_host || entry.local_sandbox.is_none()) { - return Err(failure( - tinybox_bus::UNSUPPORTED_OPERATION, - "platform shell execution requires local passthrough", - )); - } - // Each execution owns its cleanup collector. Cancelling one stream must - // not drain an unrelated running supervisor or inherit its failure. - // Resolve before consuming admission, and fence native start against Close. - let mut native_request = command(request.command.clone()); - if shell.is_some() { - // Passthrough resolution still supplies resource cwd and environment. - // The placeholder is never executed; the native builder owns argv. - native_request.argv = vec!["shell".into()]; - } - let resolved = if let Some(sandbox) = &entry.local_sandbox { - sandbox - .resolve_command(&entry.id, &native_request) - .map_err(|error| backend_error(&error))? - } else { - let command = native_request; - if command.argv.is_empty() { - return Err(failure(tinybox_bus::INVALID_ID, "empty execution command")); - } - command - }; - let collector = if let Some(shell) = &shell { - Collector::Shell( - Arc::new(tinybox_host::LimitedLocalHost::new( - tinybox_bus::MAX_OUTPUT_BYTES, - )), - shell.clone(), - ) - } else { - select_collector(entry, jail.as_ref(), &resolved)? - }; + // Each execution owns its collector; resolve before consuming admission. + let (resolved, collector) = + prepare_execution(entry, &request, jail.as_ref(), shell.as_ref())?; let mut state = self.state.lock().await; let executions = self .executions diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index 347718f8..0b9ae5f7 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -664,3 +664,39 @@ async fn native_shell_execution_preserves_admission_and_request_identity() -> Re resources.close(&resource).await?; Ok(()) } + +#[tokio::test] +async fn shell_execution_rejects_remote_selection_without_consuming_admission() -> Result<()> { + let resources = Resources::default(); + let resource = local_resource(&resources).await?; + let request = request(&resources, &resource, Vec::new()).await?; + let shell = tinybox_bus::SelectShellRequest { + command: "echo fixture".into(), + preserve_pipeline_status: false, + }; + let slot = resources.slot(&resource).await?; + { + let mut guard = slot.lock().await; + let entry = guard + .as_mut() + .ok_or_else(|| Error::failed("fixture resource missing"))?; + entry.native_host = false; + } + assert!( + matches!(resources.start_shell_exec(request.clone(), shell.clone()).await, + Err(tinybus::Error::MethodFailed { ref name, .. }) if name.as_str() == tinybox_bus::UNSUPPORTED_OPERATION) + ); + { + let mut guard = slot.lock().await; + let entry = guard + .as_mut() + .ok_or_else(|| Error::failed("fixture resource missing"))?; + entry.native_host = true; + } + let process = resources.start_shell_exec(request, shell).await?; + completed(&resources, &process).await?; + resources.cancel(&process).await?; + resources.release_output(&process).await?; + resources.close(&resource).await?; + Ok(()) +} From 1af93d74090b62fefe4d6f4aa303b12506f5e4ca Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:36:15 +0300 Subject: [PATCH 49/95] test(grants): use explicit BTreeMap default for env Replace the inferred Default::default() for the env field with an explicit std::collections::BTreeMap::default() so the expected value's type is unambiguous in the end-to-end grants manifest test. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-module/tests/grants_manifest_e2e.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-module/tests/grants_manifest_e2e.rs b/crates/tinybox-module/tests/grants_manifest_e2e.rs index 1502b742..e6aa6db7 100644 --- a/crates/tinybox-module/tests/grants_manifest_e2e.rs +++ b/crates/tinybox-module/tests/grants_manifest_e2e.rs @@ -194,7 +194,7 @@ async fn verify_shell_execution(proxy: &tinybus::Proxy) -> Result<(), Box Date: Sun, 11 Oct 2026 22:36:24 +0300 Subject: [PATCH 50/95] refactor(native_shell): use captured scratch handle in cleanup task The blocking cleanup closure now locks the scratch handle captured before the task was spawned instead of reaching back through self, which is not available inside the move closure. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-module/src/tinybus_module/native_shell/mod.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs index 22573a82..54cfef23 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs @@ -175,8 +175,7 @@ impl ShellContext { let scratch = self.scratch.clone(); let failed = self.scratch_cleanup_failed.clone(); tokio::task::spawn_blocking(move || { - let mut scratch = self - .scratch + let mut scratch = scratch .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if let Some(directory) = scratch.as_ref() { From 6755908471ed5199cee9cb6a041541ef85a43a4c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:39:33 +0300 Subject: [PATCH 51/95] chore: files changed crates/tinybox-module/src/tinybus_module/mod_tests.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/mod_tests.rs | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index c4aa9e00..4f2fa191 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -1036,3 +1036,59 @@ async fn unsupported_git_include_expansions_refuse_the_whole_discovery() } Ok(()) } + +#[tokio::test] +async fn shell_dispatch_uses_supervised_output_and_cleanup() -> tinybus::Result<()> { + let service = BoxService::default(); + let resource = service + .reserve(tinybox_bus::ReserveRequest::Resource) + .await?; + service + .create(tinybox_bus::CreateRequest { + resource: resource.clone(), + backend: "passthrough".into(), + workspace: tinybox_bus::Workspace::Directory(".".into()), + ..Default::default() + }) + .await?; + let process = service + .reserve(tinybox_bus::ReserveRequest::Process(resource.clone())) + .await?; + let process = service + .start_shell_exec( + tinybox_bus::SpawnRequest { + process, + command: tinybox_bus::ExecRequest { + resource: resource.clone(), + argv: Vec::new(), + cwd: None, + env: std::collections::BTreeMap::new(), + stdin: None, + }, + }, + tinybox_bus::SelectShellRequest { + command: "echo fixture".into(), + preserve_pipeline_status: false, + }, + ) + .await?; + let batch = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let batch = service.read_output(process.clone(), 0).await?; + if batch.state != tinybox_bus::ExecutionState::Running { + return Ok::<_, tinybus::Error>(batch); + } + tokio::task::yield_now().await; + } + }) + .await + .map_err(|error| tinybus::Error::failed(error.to_string()))??; + assert_eq!( + batch.state, + tinybox_bus::ExecutionState::Exited { exit_code: 0 } + ); + service.release_output(process).await?; + service.close(resource).await?; + service.shutdown().await?; + Ok(()) +} From e2bf0156b3176f236d3e0927064fd147ae851712 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:41:01 +0300 Subject: [PATCH 52/95] test(grants-manifest): build dynamic module under checkout target The end-to-end test now derives its dynamic module build directory from CARGO_MANIFEST_DIR instead of the current executable's path, so the artifact lands in this checkout's target tree. This keeps concurrent PRs from replacing the module between its build and admission. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinybox-module/tests/grants_manifest_e2e.rs | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/crates/tinybox-module/tests/grants_manifest_e2e.rs b/crates/tinybox-module/tests/grants_manifest_e2e.rs index e6aa6db7..519fa59a 100644 --- a/crates/tinybox-module/tests/grants_manifest_e2e.rs +++ b/crates/tinybox-module/tests/grants_manifest_e2e.rs @@ -231,23 +231,16 @@ async fn verify_shell_execution(proxy: &tinybus::Proxy) -> Result<(), Box Result> { - let executable = std::env::current_exe()?; - let dependencies = executable - .parent() - .ok_or("integration test has no parent directory")?; let filename = format!( "{}tinybox{}", std::env::consts::DLL_PREFIX, std::env::consts::DLL_SUFFIX ); - // All-feature/static-link builds omit the dynamic ABI. Always build the - // dynamic variant separately: another validation command may replace the - // ordinary dependency artifact after Cargo releases its compilation lock. - let target = dependencies - .parent() - .and_then(std::path::Path::parent) - .ok_or("integration test has no target directory")? - .join("module-dynamic-proof"); + // All-feature/static-link builds omit the dynamic ABI. Build a dynamic + // variant under this checkout: other PRs share ordinary Cargo artifacts + // and must not replace the module between its build and admission. + let target = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/module-dynamic-proof"); let status = std::process::Command::new(env!("CARGO")) .args([ "build", From 4272dc54bdbe56519f90908e77d02062109ba2c3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:41:42 +0300 Subject: [PATCH 53/95] chore: files changed crates/tinybox-host/src/local/shell.rs,crates/tinybox-host/src/local/shell_test Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-host/src/local/shell.rs | 5 ++++- crates/tinybox-host/src/local/shell_tests.rs | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-host/src/local/shell.rs b/crates/tinybox-host/src/local/shell.rs index a6c9b4c8..4e003566 100644 --- a/crates/tinybox-host/src/local/shell.rs +++ b/crates/tinybox-host/src/local/shell.rs @@ -34,7 +34,10 @@ pub fn select_shell(request: &SelectShellRequest) -> ShellInvocation { /// provider remains responsible for environment, confinement, pipes and lifecycle. #[must_use] pub fn build_shell_command(request: &SelectShellRequest) -> std::process::Command { - let selection = select_shell(request); + command_for(select_shell(request)) +} + +fn command_for(selection: ShellInvocation) -> std::process::Command { let mut command = std::process::Command::new(selection.program); command.args(selection.arguments); for name in selection.remove_environment { diff --git a/crates/tinybox-host/src/local/shell_tests.rs b/crates/tinybox-host/src/local/shell_tests.rs index 43af8bf1..53c17de4 100644 --- a/crates/tinybox-host/src/local/shell_tests.rs +++ b/crates/tinybox-host/src/local/shell_tests.rs @@ -220,3 +220,14 @@ fn bash_discovery_rechecks_availability() -> Result<(), Box Result<(), Box> { + let selected = selection_for(&SelectShellRequest { + command: "exit 0".into(), preserve_pipeline_status: false, + }, ShellFlavor::Posix, None); + let status = command_for(selected).env("PATH", "").status()?; + assert!(status.success()); + Ok(()) +} From d9cfe26e962463f76a20a83394b17288a016a4a6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:42:04 +0300 Subject: [PATCH 54/95] fix(shell): use absolute /bin/sh for the posix fallback The POSIX fallback now resolves to /bin/sh instead of a bare sh, so an overridden PATH can no longer prevent the fallback shell from starting. Tests and the shell-selection docs were updated to match the absolute path. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-host/src/local/shell.rs | 2 +- crates/tinybox-host/src/local/shell_tests.rs | 13 +++++++++---- crates/tinybox-module/src/tinybus_module/README.md | 2 +- docs/specs/shell-selection.md | 3 ++- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/tinybox-host/src/local/shell.rs b/crates/tinybox-host/src/local/shell.rs index 4e003566..053a9742 100644 --- a/crates/tinybox-host/src/local/shell.rs +++ b/crates/tinybox-host/src/local/shell.rs @@ -78,7 +78,7 @@ fn selection_for( }, ShellFlavor::Posix => ShellInvocation { flavor, - program: bash.unwrap_or("sh").into(), + program: bash.unwrap_or("/bin/sh").into(), arguments: { let mut arguments = Vec::new(); if bash.is_some() { diff --git a/crates/tinybox-host/src/local/shell_tests.rs b/crates/tinybox-host/src/local/shell_tests.rs index 53c17de4..bd85d69f 100644 --- a/crates/tinybox-host/src/local/shell_tests.rs +++ b/crates/tinybox-host/src/local/shell_tests.rs @@ -22,7 +22,7 @@ fn bash_surfaces_pipe_failures_and_preservation_keeps_the_original_payload() { #[test] fn sh_fallback_never_receives_bash_only_flags() { let selected = selection_for(&request(false), ShellFlavor::Posix, None); - assert_eq!(selected.program, "sh"); + assert_eq!(selected.program, "/bin/sh"); assert_eq!(selected.arguments, ["-c", "false | true"]); assert_eq!(selected.raw_arguments, None); assert_eq!(selected.bootstrap_environment, Vec::::new()); @@ -224,9 +224,14 @@ fn bash_discovery_rechecks_availability() -> Result<(), Box Result<(), Box> { - let selected = selection_for(&SelectShellRequest { - command: "exit 0".into(), preserve_pipeline_status: false, - }, ShellFlavor::Posix, None); + let selected = selection_for( + &SelectShellRequest { + command: "exit 0".into(), + preserve_pipeline_status: false, + }, + ShellFlavor::Posix, + None, + ); let status = command_for(selected).env("PATH", "").status()?; assert!(status.success()); Ok(()) diff --git a/crates/tinybox-module/src/tinybus_module/README.md b/crates/tinybox-module/src/tinybus_module/README.md index d3765376..558207c5 100644 --- a/crates/tinybox-module/src/tinybus_module/README.md +++ b/crates/tinybox-module/src/tinybus_module/README.md @@ -303,7 +303,7 @@ unset; unsupported ~user/ and %(prefix)/ expansion rejects complete discovery. Contract 1.10 adds `SelectShell`. The request carries command text and an optional pipeline-status preservation flag. Unix selects Bash at a standard path with -`set -o pipefail` by default, or sh without that Bash-only flag when Bash is +`set -o pipefail` by default, or /bin/sh without that Bash-only flag when Bash is absent. Unix uses non-login `-c` to avoid loading login profiles before the payload. Windows selects cmd `/D /E:ON /V:OFF /S /C`, disabling registry AutoRun hooks and stripping one explicit outer wrapper around the raw command-line payload. diff --git a/docs/specs/shell-selection.md b/docs/specs/shell-selection.md index 732a3b54..86594db5 100644 --- a/docs/specs/shell-selection.md +++ b/docs/specs/shell-selection.md @@ -17,7 +17,8 @@ The request carries command text and preserve_pipeline_status, default false. The result carries flavor, program, ordinary arguments, optional Windows raw arguments, and bootstrap environment variable names. -Unix selects /usr/bin/bash or /bin/bash when present, otherwise sh. Both use +Unix selects /usr/bin/bash or /bin/bash when present, otherwise /bin/sh. The fallback path is absolute so approved +PATH overrides affect payload tools without preventing shell startup. Both use non-login -c; Bash also uses -p to ignore imported environment functions. Bash prepends "set -o pipefail" unless preservation is requested; sh never receives that Bash-only prefix. For "false | true", Bash's default selection reports the failing stage; preservation retains the last-stage status. From 8360865a313002edc910ee1a7136c84a70d768e7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:48:34 +0300 Subject: [PATCH 55/95] test(shell): cover cmd selection and payload path isolation Add tests asserting that cmd selection resolves through the parent system directory and that cmd startup ignores an overridden payload path. The contract version references in the module README and shell selection docs are also corrected to match the current 1.12 and 1.13 numbers. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-host/src/local/shell_tests.rs | 33 +++++++++++++++++++ .../src/tinybus_module/README.md | 6 ++-- docs/plans/shell-selection.md | 4 +-- docs/specs/shell-selection.md | 4 +-- 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/crates/tinybox-host/src/local/shell_tests.rs b/crates/tinybox-host/src/local/shell_tests.rs index bd85d69f..00f06ce9 100644 --- a/crates/tinybox-host/src/local/shell_tests.rs +++ b/crates/tinybox-host/src/local/shell_tests.rs @@ -236,3 +236,36 @@ fn fallback_shell_survives_an_overridden_payload_path() -> Result<(), Box Result<(), Box> { + let fixture = tempfile::tempdir()?; + // A test runner is a valid executable but cannot interpret cmd's switches. + std::fs::copy(std::env::current_exe()?, fixture.path().join("cmd.exe"))?; + let requested = SelectShellRequest { + command: "echo %PATH%".into(), + preserve_pipeline_status: false, + }; + let output = build_shell_command(&requested) + .env("PATH", fixture.path()) + .output()?; + assert!(output.status.success()); + assert_eq!( + String::from_utf8(output.stdout)?.trim(), + fixture.path().to_string_lossy() + ); + Ok(()) +} diff --git a/crates/tinybox-module/src/tinybus_module/README.md b/crates/tinybox-module/src/tinybus_module/README.md index ebc28133..cccbecdf 100644 --- a/crates/tinybox-module/src/tinybus_module/README.md +++ b/crates/tinybox-module/src/tinybus_module/README.md @@ -48,7 +48,7 @@ The interface is `ai.tinyhumans.tinybox.Box` at | SelectShell | SelectShellRequest | ShellInvocation | | StartShellExec | SpawnRequest, SelectShellRequest | ProcessRef | -Capabilities advertises contract version 1.11; 1.0 denotes the original +Capabilities advertises contract version 1.13; 1.0 denotes the original discovery-only surface. Hosts require equal majors and a module minor at least as new as their vocabulary, using `tinybox_bus::is_compatible`. Version 1.1 added reserved resource/process ownership and terminal shutdown; 1.2 adds host @@ -325,7 +325,7 @@ unset; unsupported ~user/ and %(prefix)/ expansion rejects complete discovery. ### Platform shell discovery -Contract 1.10 adds `SelectShell`. The request carries command text and an optional +Contract 1.12 adds `SelectShell`. The request carries command text and an optional pipeline-status preservation flag. Unix selects Bash at a standard path with `set -o pipefail` by default, or /bin/sh without that Bash-only flag when Bash is absent. Unix uses non-login `-c` to avoid loading login profiles before the @@ -343,7 +343,7 @@ Unix builders remove BASH_ENV, ENV, SHELLOPTS and BASHOPTS; ShellInvocation returns those removal names. Bash uses -p to ignore exported functions. Discovery checks standard Bash paths on every call. Providers apply them after approved environment overrides. -Contract 1.11 adds `StartShellExec(SpawnRequest, SelectShellRequest)`. Supply an +Contract 1.13 adds `StartShellExec(SpawnRequest, SelectShellRequest)`. Supply an empty argv in SpawnRequest; its resource, cwd, environment and stdin retain their usual meaning. This operation supports local passthrough resources only, because selection describes the module machine's shell. It uses the native diff --git a/docs/plans/shell-selection.md b/docs/plans/shell-selection.md index fcb41950..80e930be 100644 --- a/docs/plans/shell-selection.md +++ b/docs/plans/shell-selection.md @@ -5,7 +5,7 @@ This records the discovery and native execution slices and their verification or 1. Add SelectShellRequest, ShellFlavor and ShellInvocation in crates/tinybox-bus/src/shell_selection.rs. Re-export from src/lib.rs, - append SelectShell to METHODS, advance the additive contract to 1.10, + append SelectShell to METHODS, advance the additive contract to 1.12, and cover defaults, serialization and method ordering in lib_tests.rs. 2. Implement platform selection and the native quote-preserving builder in crates/tinybox-host/src/local/shell.rs; centralize exports in src/lib.rs. @@ -30,7 +30,7 @@ Local verification: - cargo clippy --locked --workspace --all-targets --all-features -- -D warnings - cargo build --locked --workspace --all-targets --all-features -6. Contract 1.11 appends StartShellExec(SpawnRequest, SelectShellRequest). +6. Contract 1.13 appends StartShellExec(SpawnRequest, SelectShellRequest). Reuse the live supervisor with a native-shell collector, keep shell text in the idempotency identity, resolve resource cwd/environment, and reject non-local or non-passthrough resources. Empty argv distinguishes shell diff --git a/docs/specs/shell-selection.md b/docs/specs/shell-selection.md index 86594db5..f5e9ab79 100644 --- a/docs/specs/shell-selection.md +++ b/docs/specs/shell-selection.md @@ -12,7 +12,7 @@ uses the module-owned output and cleanup supervisor. ## Contract -Contract 1.10 appends SelectShell(SelectShellRequest) -> ShellInvocation. +Contract 1.12 appends SelectShell(SelectShellRequest) -> ShellInvocation. The request carries command text and preserve_pipeline_status, default false. The result carries flavor, program, ordinary arguments, optional Windows raw arguments, and bootstrap environment variable names. @@ -43,7 +43,7 @@ approved environment, working directory, pipes and lifecycle. with a quoted argument. - The actual compiled dynamic module passes production manifest admission and executes SelectShell, including when the surrounding test build is static. -- Hosts execute behavior through the compiled module. Contract 1.11 appends +- Hosts execute behavior through the compiled module. Contract 1.13 appends StartShellExec(SpawnRequest, SelectShellRequest) -> ProcessRef. The spawn command supplies the resource, cwd, environment and stdin with empty argv. - StartShellExec supports local passthrough resources only and rejects other From 85f1c4dc37a90eef78efd43be02bb39169b09869 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:48:55 +0300 Subject: [PATCH 56/95] fix(local): resolve cmd.exe from SystemRoot instead of PATH Windows shell selection now launches cmd.exe from SystemRoot\System32 rather than relying on a bare "cmd" lookup, so payload PATH or COMSPEC overrides cannot substitute a different command processor. The spec and test were updated to match the resolved path. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-host/src/local/shell.rs | 14 +++++++++++++- crates/tinybox-host/src/local/shell_tests.rs | 2 +- docs/specs/shell-selection.md | 3 ++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/tinybox-host/src/local/shell.rs b/crates/tinybox-host/src/local/shell.rs index 053a9742..87ef3b02 100644 --- a/crates/tinybox-host/src/local/shell.rs +++ b/crates/tinybox-host/src/local/shell.rs @@ -59,7 +59,7 @@ fn selection_for( match flavor { ShellFlavor::Cmd => ShellInvocation { flavor, - program: "cmd".into(), + program: cmd_path(), // Disable registry AutoRun and make cmd strip exactly our outer // wrapper, preserving quoted executable/argument pairs inside. arguments: vec![ @@ -108,6 +108,18 @@ fn selection_for( } } +// Resolve before any payload environment overrides are applied. COMSPEC and +// the child's PATH must not choose a different command processor. +fn cmd_path() -> String { + let root = std::env::var_os("SystemRoot") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(r"C:\Windows")); + root.join("System32") + .join("cmd.exe") + .to_string_lossy() + .into_owned() +} + fn bash_path() -> Option<&'static str> { bash_path_in(&["/usr/bin/bash", "/bin/bash"]) } diff --git a/crates/tinybox-host/src/local/shell_tests.rs b/crates/tinybox-host/src/local/shell_tests.rs index 00f06ce9..615e04ac 100644 --- a/crates/tinybox-host/src/local/shell_tests.rs +++ b/crates/tinybox-host/src/local/shell_tests.rs @@ -39,7 +39,7 @@ fn cmd_preserves_quoted_payload_and_lists_bootstrap_names_without_values() { ShellFlavor::Cmd, None, ); - assert_eq!(selected.program, "cmd"); + assert_eq!(selected.program, cmd_path()); assert_eq!(selected.arguments, ["/D", "/E:ON", "/V:OFF", "/S", "/C"]); assert_eq!(selected.raw_arguments, Some(format!("\"{payload}\""))); assert_eq!( diff --git a/docs/specs/shell-selection.md b/docs/specs/shell-selection.md index f5e9ab79..f97b5e34 100644 --- a/docs/specs/shell-selection.md +++ b/docs/specs/shell-selection.md @@ -23,7 +23,8 @@ non-login -c; Bash also uses -p to ignore imported environment functions. Bash p sh never receives that Bash-only prefix. For "false | true", Bash's default selection reports the failing stage; preservation retains the last-stage status. -Windows selects cmd with /D /E:ON /V:OFF /S /C. /D suppresses registry AutoRun. /S strips +Windows selects SystemRoot/System32/cmd.exe from the parent environment, +independently of payload PATH or COMSPEC overrides, with /D /E:ON /V:OFF /S /C. /D suppresses registry AutoRun. /S strips exactly one supplied outer wrapper around the raw payload, leaving inner quoted executable paths, argument pairs and redirection paths intact. Native builders must use raw_arg for this payload rather than ordinary argument escaping. From b07c28157b2a8df933b382c9a83b486464202b18 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:49:43 +0300 Subject: [PATCH 57/95] refactor: use map_or_else for fallible path defaults Replace map/unwrap_or_else chains with map_or_else in the Windows command processor lookup and the git config home resolution, so the fallback is computed lazily and the intent reads more directly. No behaviour change. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-host/src/local/shell.rs | 7 ++++--- crates/tinybox-host/src/local/shell_tests.rs | 7 ++++--- crates/tinybox-jail/src/grants/git_config.rs | 4 +--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/tinybox-host/src/local/shell.rs b/crates/tinybox-host/src/local/shell.rs index 87ef3b02..97a81ad9 100644 --- a/crates/tinybox-host/src/local/shell.rs +++ b/crates/tinybox-host/src/local/shell.rs @@ -111,9 +111,10 @@ fn selection_for( // Resolve before any payload environment overrides are applied. COMSPEC and // the child's PATH must not choose a different command processor. fn cmd_path() -> String { - let root = std::env::var_os("SystemRoot") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::PathBuf::from(r"C:\Windows")); + let root = std::env::var_os("SystemRoot").map_or_else( + || std::path::PathBuf::from(r"C:\Windows"), + std::path::PathBuf::from, + ); root.join("System32") .join("cmd.exe") .to_string_lossy() diff --git a/crates/tinybox-host/src/local/shell_tests.rs b/crates/tinybox-host/src/local/shell_tests.rs index 615e04ac..fe3b9529 100644 --- a/crates/tinybox-host/src/local/shell_tests.rs +++ b/crates/tinybox-host/src/local/shell_tests.rs @@ -240,9 +240,10 @@ fn fallback_shell_survives_an_overridden_payload_path() -> Result<(), Box { home: &Path, xdg: Option<&std::ffi::OsStr>, ) -> tinybox_core::Result<()> { - let config_home = xdg_root(xdg) - .map(Path::to_path_buf) - .unwrap_or_else(|| home.join(".config")); + let config_home = xdg_root(xdg).map_or_else(|| home.join(".config"), Path::to_path_buf); let roots = [home.join(GITCONFIG_FILE), config_home.join("git/config")]; self.fixed_read_only(&config_home.join("git/ignore"), "gitignore"); let mut visited = HashSet::new(); From 9779e163ea43cdcda8c5e7b1ae794b2d8208e368 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:50:56 +0300 Subject: [PATCH 58/95] docs(tinybus-module): document windows cmd.exe resolution The SelectShell contract now resolves cmd.exe from SystemRoot/System32 independently of payload PATH and COMSPEC overrides, so the README describes that lookup alongside the existing switch behaviour. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-module/src/tinybus_module/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/README.md b/crates/tinybox-module/src/tinybus_module/README.md index cccbecdf..ab315b6f 100644 --- a/crates/tinybox-module/src/tinybus_module/README.md +++ b/crates/tinybox-module/src/tinybus_module/README.md @@ -329,8 +329,10 @@ Contract 1.12 adds `SelectShell`. The request carries command text and an option pipeline-status preservation flag. Unix selects Bash at a standard path with `set -o pipefail` by default, or /bin/sh without that Bash-only flag when Bash is absent. Unix uses non-login `-c` to avoid loading login profiles before the -payload. Windows selects cmd `/D /E:ON /V:OFF /S /C`, disabling registry AutoRun hooks and -stripping one explicit outer wrapper around the raw command-line payload. +payload. Windows selects `SystemRoot/System32/cmd.exe` from the parent +environment independently of payload PATH and COMSPEC overrides. Its +`/D /E:ON /V:OFF /S /C` switches disable registry AutoRun hooks and strip one +explicit outer wrapper around the raw command-line payload. Quoted executable/argument pairs, redirection paths and percent expansion remain inside that wrapper. The result includes Windows bootstrap environment names, never their values. From 4d79180cdcaf053ef6c3813ec6d861a043235e49 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:54:11 +0300 Subject: [PATCH 59/95] test(tinybox-jail): canonicalize paths in git config grant assertions The test compared grant paths against non-canonicalized temp dir paths, which could fail on systems where the temp directory is a symlink. Canonicalizing the expected paths makes the assertions match how the grants are actually stored. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants/git_config_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-jail/src/grants/git_config_tests.rs b/crates/tinybox-jail/src/grants/git_config_tests.rs index 74ad90ac..467c12a1 100644 --- a/crates/tinybox-jail/src/grants/git_config_tests.rs +++ b/crates/tinybox-jail/src/grants/git_config_tests.rs @@ -90,13 +90,13 @@ fn absolute_xdg_config_home_replaces_home_config_root() -> Result<(), Box Date: Sun, 11 Oct 2026 22:55:06 +0300 Subject: [PATCH 60/95] test(grants): isolate git config fixtures from xdg override Git config fixtures that exercise ~/.config now re-exec themselves in a child process with XDG_CONFIG_HOME removed, so they no longer depend on or mutate the runner's environment when it sets an override. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants/mod_tests.rs | 27 +++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/tinybox-jail/src/grants/mod_tests.rs b/crates/tinybox-jail/src/grants/mod_tests.rs index 8f1769c5..21c47d49 100644 --- a/crates/tinybox-jail/src/grants/mod_tests.rs +++ b/crates/tinybox-jail/src/grants/mod_tests.rs @@ -3,6 +3,20 @@ use super::*; use std::fs; use std::path::{Path, PathBuf}; +// These fixtures deliberately exercise ~/.config, so run in a child without +// the runner's XDG override rather than mutating the parallel test environment. +fn run_without_xdg_override(test: &str) -> Result> { + if std::env::var_os("XDG_CONFIG_HOME").is_none() { + return Ok(false); + } + let status = std::process::Command::new(std::env::current_exe()?) + .args(["--exact", &format!("grants::tests::{test}"), "--nocapture"]) + .env_remove("XDG_CONFIG_HOME") + .status()?; + assert!(status.success(), "isolated Git fixture failed"); + Ok(true) +} + fn fake_home() -> std::io::Result { let home = tempfile::tempdir()?; for d in [ @@ -668,6 +682,9 @@ fn explicit_writable_grants_cannot_promote_shared_cargo_sources() #[test] fn git_ignore_patterns_cannot_add_config_include_grants() -> Result<(), Box> { + if run_without_xdg_override("git_ignore_patterns_cannot_add_config_include_grants")? { + return Ok(()); + } let home = tempfile::tempdir()?; fs::create_dir_all(home.path().join(".config/git"))?; fs::write( @@ -1004,6 +1021,11 @@ fn git_include_with_a_trailing_comment_grants_the_actual_file() #[cfg(unix)] fn discovered_git_config_links_cannot_be_replaced_through_writable_parents() -> Result<(), Box> { + if run_without_xdg_override( + "discovered_git_config_links_cannot_be_replaced_through_writable_parents", + )? { + return Ok(()); + } let home = tempfile::tempdir()?; fs::create_dir_all(home.path().join(".config/git"))?; fs::create_dir(home.path().join("dotfiles"))?; @@ -1023,6 +1045,11 @@ fn discovered_git_config_links_cannot_be_replaced_through_writable_parents() #[cfg(unix)] fn duplicate_git_config_targets_keep_each_discovered_alias_read_only() -> Result<(), Box> { + if run_without_xdg_override( + "duplicate_git_config_targets_keep_each_discovered_alias_read_only", + )? { + return Ok(()); + } let home = tempfile::tempdir()?; fs::create_dir_all(home.path().join(".config/git"))?; fs::create_dir(home.path().join("dotfiles"))?; From dff10919e537149564d0078b6ca7bb3dd4d9fd70 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 22:55:50 +0300 Subject: [PATCH 61/95] chore: files changed crates/tinybox-module/tests/grants_manifest_e2e.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-module/tests/grants_manifest_e2e.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-module/tests/grants_manifest_e2e.rs b/crates/tinybox-module/tests/grants_manifest_e2e.rs index 519fa59a..0671f0cf 100644 --- a/crates/tinybox-module/tests/grants_manifest_e2e.rs +++ b/crates/tinybox-module/tests/grants_manifest_e2e.rs @@ -104,7 +104,14 @@ async fn verify_discovery(proxy: &tinybus::Proxy) -> Result<(), Box Date: Sun, 11 Oct 2026 22:58:24 +0300 Subject: [PATCH 62/95] test(git_config): canonicalize paths in xdg config home assertions The test compared grant paths against non-canonicalized temp dir paths, which could fail on systems where the temp directory is a symlink. Both assertions now canonicalize the expected paths before checking membership. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants/git_config_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-jail/src/grants/git_config_tests.rs b/crates/tinybox-jail/src/grants/git_config_tests.rs index 74ad90ac..467c12a1 100644 --- a/crates/tinybox-jail/src/grants/git_config_tests.rs +++ b/crates/tinybox-jail/src/grants/git_config_tests.rs @@ -90,13 +90,13 @@ fn absolute_xdg_config_home_replaces_home_config_root() -> Result<(), Box Date: Sun, 11 Oct 2026 23:01:14 +0300 Subject: [PATCH 63/95] test(tinybox-module): cover shell discovery and docker deadline validation Add tests asserting that shell discovery preserves the requested pipeline status on both Windows and POSIX, and that docker control dispatch rejects zero and over-limit timeouts with the invalid control error. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/mod_tests.rs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index b90c5c83..6e3d195f 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -1112,3 +1112,47 @@ async fn shell_dispatch_uses_supervised_output_and_cleanup() -> tinybus::Result< service.shutdown().await?; Ok(()) } + +#[tokio::test] +async fn shell_discovery_preserves_the_requested_pipeline_status() -> tinybus::Result<()> { + let service = BoxService::default(); + let selected = service + .select_shell(tinybox_bus::SelectShellRequest { + command: "false | true".into(), + preserve_pipeline_status: true, + }) + .await?; + if cfg!(windows) { + assert_eq!(selected.flavor, tinybox_bus::ShellFlavor::Cmd); + assert_eq!(selected.raw_arguments.as_deref(), Some("\"false | true\"")); + } else { + assert_eq!(selected.flavor, tinybox_bus::ShellFlavor::Posix); + assert_eq!( + selected.arguments.last().map(String::as_str), + Some("false | true") + ); + } + Ok(()) +} + +#[tokio::test] +async fn docker_control_dispatch_rejects_unbounded_deadlines() { + let service = BoxService::default(); + let status = service + .docker_status(tinybox_bus::DockerStatusRequest { timeout_ms: 0 }) + .await; + assert!( + matches!(status, Err(tinybus::Error::MethodFailed { name, .. }) + if name == tinybox_bus::INVALID_DOCKER_CONTROL) + ); + let cleanup = service + .cleanup_docker(tinybox_bus::CleanupDockerRequest { + label: "openhuman.sandbox=true".into(), + timeout_ms: tinybox_bus::MAX_DOCKER_CONTROL_TIMEOUT_MS + 1, + }) + .await; + assert!( + matches!(cleanup, Err(tinybus::Error::MethodFailed { name, .. }) + if name == tinybox_bus::INVALID_DOCKER_CONTROL) + ); +} From 29880f02ad122858e13bd2387e29d47c204bab03 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:07:17 +0300 Subject: [PATCH 64/95] test(tinybox-module): cover timeout status surviving failed cleanup Add a regression test asserting that a timed-out execution keeps its TimedOut status once cleanup is retried and acknowledged, rather than being masked by the earlier CleanupFailed state. The remote fixture hosts gain a wait_for_timeout flag so the test can hold cleanup pending until the timeout fires. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinybus_module/resources_stream_tests.rs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index 9226c86c..2ebb4359 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -303,6 +303,7 @@ struct RemoteObservedFixture { pending: std::sync::atomic::AtomicBool, reject_retry: std::sync::atomic::AtomicBool, collected_calls: std::sync::atomic::AtomicUsize, + wait_for_timeout: bool, } #[async_trait::async_trait] impl tinybox_core::Host for RemoteObservedFixture { @@ -327,6 +328,9 @@ impl tinybox_core::Host for RemoteObservedFixture { observer.output(tinybox_core::OutputStream::Stdout, &[255, 0])?; self.pending .store(true, std::sync::atomic::Ordering::SeqCst); + if self.wait_for_timeout { + std::future::pending::<()>().await; + } Err(tinybox_core::Error::ObservedCleanupPending { provider: "fixture".into(), }) @@ -352,6 +356,7 @@ async fn remote_cleanup_failure_keeps_binary_output_and_owner_until_retry_ack() pending: std::sync::atomic::AtomicBool::new(false), reject_retry: std::sync::atomic::AtomicBool::new(true), collected_calls: std::sync::atomic::AtomicUsize::new(0), + wait_for_timeout: false, }); let resource = ResourceId("fixture-resource".into()); let command = tinybox_core::ExecRequest::new(["fixture"]); @@ -477,6 +482,9 @@ impl tinybox_core::Host for DockerFaultHost { observer.output(tinybox_core::OutputStream::Stdout, b"owned")?; self.pending .store(true, std::sync::atomic::Ordering::SeqCst); + if self.wait_for_timeout { + std::future::pending::<()>().await; + } Err(tinybox_core::Error::ObservedCleanupPending { provider: "fixture".into(), }) @@ -974,3 +982,56 @@ async fn shell_execution_rejects_remote_selection_without_consuming_admission() resources.close(&resource).await?; Ok(()) } + +#[tokio::test] +async fn timeout_status_survives_failed_cleanup_until_retry_acknowledges_it() +-> std::result::Result<(), Box> { + let resources = Resources::default(); + let resource = local_resource(&resources).await?; + let state = tempfile::tempdir()?; + let mut shell = shell_request(&resources, &resource, state.path(), "unused").await?; + shell.timeout_ms = 1; + let host = Arc::new(RemoteObservedFixture { + pending: std::sync::atomic::AtomicBool::new(false), + reject_retry: std::sync::atomic::AtomicBool::new(true), + collected_calls: std::sync::atomic::AtomicUsize::new(0), + wait_for_timeout: true, + }); + let stream = StreamExecution::start( + request(&resources, &resource, true_command()).await?, + tinybox_core::ExecRequest::new(["unused"]), + Collector::Host(host.clone(), None), + None, + Some(shell), + None, + ); + { + let mut task = stream.task.lock().await; + tokio::time::timeout( + std::time::Duration::from_secs(5), + task.as_mut().ok_or("missing execution task")?, + ) + .await??; + task.take(); + } + assert!(stream.stop().await.is_err()); + assert_eq!( + stream.journal.read(0)?.state, + ExecutionState::Failed { + code: ExecutionFailure::CleanupFailed, + } + ); + assert!(!stream.cleanup_acknowledged()); + host.reject_retry + .store(false, std::sync::atomic::Ordering::SeqCst); + stream.stop().await?; + assert_eq!( + stream.journal.read(0)?.state, + ExecutionState::Failed { + code: ExecutionFailure::TimedOut, + } + ); + assert!(stream.cleanup_acknowledged()); + resources.close(&resource).await?; + Ok(()) +} From c3a6ea9005cbfdf8f81bb4d27bdd2bcc42478dd1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:07:42 +0300 Subject: [PATCH 65/95] test(tinybox-module): remove unused timeout wait from fault host The DockerFaultHost fixture no longer blocks on a pending future when wait_for_timeout is set, since the flag was never exercised by the stream tests. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/resources_stream_tests.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index 2ebb4359..bba2e7f2 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -482,9 +482,6 @@ impl tinybox_core::Host for DockerFaultHost { observer.output(tinybox_core::OutputStream::Stdout, b"owned")?; self.pending .store(true, std::sync::atomic::Ordering::SeqCst); - if self.wait_for_timeout { - std::future::pending::<()>().await; - } Err(tinybox_core::Error::ObservedCleanupPending { provider: "fixture".into(), }) From 53507c5a1e174289200d1bb9e0ff56043778402a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:08:08 +0300 Subject: [PATCH 66/95] fix(tinybus): report timed out executions as timed out Stream executions that hit their timeout were reported as backend failures because the timeout branch never recorded why the run ended. A flag is now set when the timeout elapses and consulted when the journal is finished, so callers see ExecutionFailure::TimedOut instead of a generic backend error. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/resources_stream.rs | 12 +++++++++++- .../src/tinybus_module/resources_stream_tests.rs | 1 + 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index 5e792c60..63d1ede1 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -16,6 +16,7 @@ pub(super) struct StreamExecution { journal: Arc, task: Mutex>>, collector: Collector, + timed_out: Arc, } #[derive(Clone)] enum Collector { @@ -235,6 +236,8 @@ impl StreamExecution { let observer = journal.clone(); let runner = collector.clone(); let timeout = shell.as_ref().map(|request| request.timeout_ms); + let timed_out = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let timeout_outcome = timed_out.clone(); let task = tokio::spawn(async move { let _completion = CompletionGuard(observer.clone()); let result = if let Some(timeout) = timeout { @@ -246,6 +249,7 @@ impl StreamExecution { { Some(result) } else { + timeout_outcome.store(true, std::sync::atomic::Ordering::SeqCst); let _cleanup = runner.drain().await; None } @@ -276,6 +280,7 @@ impl StreamExecution { journal, task: Mutex::new(Some(task)), collector, + timed_out, } } pub(super) fn running(&self) -> bool { @@ -300,7 +305,12 @@ impl StreamExecution { ) })?; if !self.journal.cleanup_acknowledged() { - self.journal.finish_failed(ExecutionFailure::BackendFailed); + let failure = if self.timed_out.load(std::sync::atomic::Ordering::SeqCst) { + ExecutionFailure::TimedOut + } else { + ExecutionFailure::BackendFailed + }; + self.journal.finish_failed(failure); } Ok(()) } diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index bba2e7f2..f6902162 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -208,6 +208,7 @@ async fn a_panicked_job_is_joined_and_cleanup_failure_stays_owned_until_retry() jail: None, shell: None, platform_shell: None, + timed_out: Arc::new(std::sync::atomic::AtomicBool::new(false)), journal, task: Mutex::new(Some(task)), collector: Collector::Host(Arc::new(tinybox_host::LimitedLocalHost::new(64)), None), From b73cd9102c1c8c8531427c8f9ebbf635b899babe Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:08:16 +0300 Subject: [PATCH 67/95] docs(specs): clarify timeout outcome retention on cleanup retry Document that a pending timeout outcome survives until a later cleanup retry succeeds, making the retry semantics of the native shell execution spec explicit. Auto-committed-on: dragonfly Co-authored-by: Medulla --- docs/specs/native-shell-execution.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/specs/native-shell-execution.md b/docs/specs/native-shell-execution.md index 90da0343..21d7892f 100644 --- a/docs/specs/native-shell-execution.md +++ b/docs/specs/native-shell-execution.md @@ -24,7 +24,8 @@ scratch as a writable grant after validating caller grants and enforcement. Capture uses owned pipes and the existing combined output budget, not shell redirection. ReadOutput, Cancel, ReleaseOutput, Close and Shutdown retain their cleanup barriers. Timeouts drop the waiter, wait for supervised kill/reap and -then publish TimedOut. Cleanup failures retain ownership and scratch for retry. +then publish TimedOut. Cleanup failures retain ownership and scratch for retry; +the pending timeout outcome survives until a later cleanup retry succeeds. Scratch survives through journal retention and is reclaimed after acknowledged release/close. Exact original request identity makes retry idempotent even if the parent environment changed; mixing shell/argv reservations is refused. From f728ea4b83e0940c8e3cc0a77bcb92d417e2d31a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:09:16 +0300 Subject: [PATCH 68/95] fix(native_shell): run scratch preparation off the async worker Shell scratch preparation now runs on a blocking thread instead of the sole async worker, so filesystem work no longer stalls the runtime. Added a test asserting preparation happens on a different thread. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/native_shell/mod.rs | 12 ++++++++ .../tinybus_module/native_shell/mod_tests.rs | 28 +++++++++++++++++++ .../src/tinybus_module/resources_stream.rs | 22 +++++++++------ 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs index 54cfef23..e78e6d9e 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs @@ -10,6 +10,18 @@ mod types; pub(super) use types::ShellContext; use types::ShellHost; impl ShellContext { + pub(super) async fn prepare_owned( + request: ShellExecRequest, + root: std::path::PathBuf, + resolved: tinybox_core::ExecRequest, + ) -> Result> { + Self::prepare_owned_with(move || Self::prepare(request, &root, &resolved)).await + } + async fn prepare_owned_with( + prepare: impl FnOnce() -> Result> + Send + 'static, + ) -> Result> { + prepare() + } pub(super) fn prepare( request: ShellExecRequest, root: &Path, diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs index e8df58b1..b2e4db1d 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs @@ -193,3 +193,31 @@ async fn scratch_deletion_runs_outside_the_async_worker() ); Ok(()) } + +#[tokio::test(flavor = "current_thread")] +async fn scratch_preparation_runs_outside_the_async_worker() +-> std::result::Result<(), Box> { + let state = tempfile::tempdir()?; + let root = state.path().to_path_buf(); + let request = request(&root); + let resolved = tinybox_core::ExecRequest::new(["unused"]); + let observed = Arc::new(std::sync::Mutex::new(None)); + let recorded = observed.clone(); + let worker = std::thread::current().id(); + let shell = ShellContext::prepare_owned_with(move || { + *recorded + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(std::thread::current().id()); + ShellContext::prepare(request, &root, &resolved) + }) + .await?; + assert_ne!( + *observed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + Some(worker), + "filesystem preparation must not run on the sole async worker" + ); + shell.dispose_scratch().await?; + Ok(()) +} diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index 63d1ede1..9541c0ee 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -150,7 +150,7 @@ fn require_owned_collector(entry: &super::Resource) -> Result<()> { } Ok(()) } -fn select_execution_collector( +async fn select_execution_collector( entry: &super::Resource, jail: Option<&tinybox_bus::NativeJailConfig>, shell: Option<&tinybox_bus::ShellExecRequest>, @@ -169,16 +169,19 @@ fn select_execution_collector( "native shell requires directory resource", ) })?; - Collector::Shell(super::super::native_shell::ShellContext::prepare( - shell.clone(), - root, - resolved, - )?) + Collector::Shell( + super::super::native_shell::ShellContext::prepare_owned( + shell.clone(), + root.clone(), + resolved.clone(), + ) + .await?, + ) } else { select_collector(entry, jail, resolved)? }) } -fn prepare_execution( +async fn prepare_execution( entry: &super::Resource, request: &SpawnRequest, jail: Option<&tinybox_bus::NativeJailConfig>, @@ -219,7 +222,7 @@ fn prepare_execution( shell.clone(), ) } else { - select_execution_collector(entry, jail, shell, &resolved)? + select_execution_collector(entry, jail, shell, &resolved).await? }; Ok((resolved, collector)) } @@ -429,7 +432,8 @@ impl Resources { jail.as_ref(), shell.as_ref(), platform_shell.as_ref(), - )?; + ) + .await?; let mut state = self.state.lock().await; let executions = self .executions From 049954eb98cf6b25f0a2fe45e9ff35007dafead9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:09:44 +0300 Subject: [PATCH 69/95] fix(native-shell): run preparation on blocking workers Filesystem preparation now runs via spawn_blocking so slow storage cannot stall the module runtime, with worker failures mapped to a backend error. The spec was updated to document that preparation and recursive cleanup happen on blocking workers. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinybox-module/src/tinybus_module/native_shell/mod.rs | 7 ++++++- docs/specs/native-shell-execution.md | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs index e78e6d9e..ac0f5081 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs @@ -20,7 +20,12 @@ impl ShellContext { async fn prepare_owned_with( prepare: impl FnOnce() -> Result> + Send + 'static, ) -> Result> { - prepare() + tokio::task::spawn_blocking(prepare).await.map_err(|_| { + super::resources::failure( + tinybox_bus::BACKEND_ERROR, + "native shell preparation worker failed", + ) + })? } pub(super) fn prepare( request: ShellExecRequest, diff --git a/docs/specs/native-shell-execution.md b/docs/specs/native-shell-execution.md index 21d7892f..1f45941c 100644 --- a/docs/specs/native-shell-execution.md +++ b/docs/specs/native-shell-execution.md @@ -20,6 +20,8 @@ state_dir/artifacts/sandbox-scratch. TMPDIR, TEMP and TMP default to it unless explicitly overridden. Approved state directory ACLs must be private on Windows; the host owns approval of state_dir. Native jails add this module-created scratch as a writable grant after validating caller grants and enforcement. +Filesystem preparation and recursive cleanup run on blocking workers so slow +storage does not stall the module runtime. Capture uses owned pipes and the existing combined output budget, not shell redirection. ReadOutput, Cancel, ReleaseOutput, Close and Shutdown retain their From fa3f11deb3d7b0b3b4e0a84f70e089806c42d57b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:10:13 +0300 Subject: [PATCH 70/95] fix(tinybus-module): reject unsupported providers before preparing execution The execution support check now runs inside prepare_execution instead of after the resource lookup, so unsupported providers are rejected before any collector or execution state is set up. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/resources_stream.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index 9541c0ee..6601d85f 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -188,6 +188,14 @@ async fn prepare_execution( shell: Option<&tinybox_bus::ShellExecRequest>, platform_shell: Option<&tinybox_bus::SelectShellRequest>, ) -> Result<(tinybox_core::ExecRequest, Collector)> { + if !entry.execution_supported + || (entry.local_sandbox.is_none() && entry.docker_sandbox.is_none()) + { + return Err(failure( + tinybox_bus::UNSUPPORTED_OPERATION, + "live output is unavailable for this provider", + )); + } require_owned_collector(entry)?; // Each execution owns its cleanup collector. Cancelling one stream must // not drain an unrelated running supervisor or inherit its failure. @@ -418,14 +426,6 @@ impl Resources { let entry = slot .as_ref() .ok_or_else(|| failure(tinybox_bus::UNKNOWN_RESOURCE, "closed resource"))?; - if !entry.execution_supported - || (entry.local_sandbox.is_none() && entry.docker_sandbox.is_none()) - { - return Err(failure( - tinybox_bus::UNSUPPORTED_OPERATION, - "live output is unavailable for this provider", - )); - } let (resolved, collector) = prepare_execution( entry, &request, From a46978e567ff5804f105e360fc9ab4f6f4b98c15 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:14:03 +0300 Subject: [PATCH 71/95] test(host): cover bash discovery and posix mode edge cases Add tests asserting that bash discovery skips non-executable candidates and that an inherited POSIXLY_CORRECT value does not change command substitution behaviour. Existing discovery tests now set executable permissions on their fixtures so they exercise the same path on unix. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-host/src/local/shell_tests.rs | 44 ++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/crates/tinybox-host/src/local/shell_tests.rs b/crates/tinybox-host/src/local/shell_tests.rs index fe3b9529..56628938 100644 --- a/crates/tinybox-host/src/local/shell_tests.rs +++ b/crates/tinybox-host/src/local/shell_tests.rs @@ -209,12 +209,22 @@ fn bash_discovery_rechecks_availability() -> Result<(), Box Result<(), Box Result<(), Box> { + let Some(bash) = bash_path() else { return Ok(()); }; + let selected = selection_for(&SelectShellRequest { + command: "set -e; x=$(false; echo reached); echo \"$x\"".into(), + preserve_pipeline_status: true, + }, ShellFlavor::Posix, Some(bash)); + // Supply the ambient value before applying the invocation's removal list. + let mut command = std::process::Command::new(&selected.program); + command.args(&selected.arguments).env("POSIXLY_CORRECT", "1"); + for name in selected.remove_environment { command.env_remove(name); } + let output = command.output()?; + assert!(output.status.success()); + assert_eq!(output.stdout, b"reached\n"); + Ok(()) +} + +#[test] +#[cfg(unix)] +fn bash_discovery_skips_non_executable_files() +-> Result<(), Box> { + use std::os::unix::fs::PermissionsExt; + let first = tempfile::NamedTempFile::new()?; + let second = tempfile::NamedTempFile::new()?; + std::fs::set_permissions(second.path(), std::fs::Permissions::from_mode(0o700))?; + let first = first.path().to_str().ok_or("non utf8 fixture")?; + let second = second.path().to_str().ok_or("non utf8 fixture")?; + assert_eq!(bash_path_in(&[first, second]), Some(second)); + assert_eq!(bash_path_in(&[first]), None); + Ok(()) +} From 3f3c4d064d872b2a4566e35a4b0c3549f6e8c205 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:14:42 +0300 Subject: [PATCH 72/95] chore: files changed crates/tinybox-host/Cargo.toml,crates/tinybox-host/src/local/shell.rs,crates/ti Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-host/Cargo.toml | 4 ++-- crates/tinybox-host/src/local/shell.rs | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/tinybox-host/Cargo.toml b/crates/tinybox-host/Cargo.toml index 13001872..9c022f2a 100644 --- a/crates/tinybox-host/Cargo.toml +++ b/crates/tinybox-host/Cargo.toml @@ -27,8 +27,8 @@ cap-std = "4" tokio = { workspace = true, features = ["process", "io-util", "sync"] } [target.'cfg(unix)'.dependencies] -# Safe process-group signalling keeps shell descendants within supervised cleanup. -nix = { version = "0.30", default-features = false, features = ["signal", "process"] } +# Safe process-group signalling supervises descendants; access checks select executable shells. +nix = { version = "0.30", default-features = false, features = ["signal", "process", "fs"] } [target.'cfg(windows)'.dependencies] # Job Objects keep supervised Windows descendants inside one killable process tree. diff --git a/crates/tinybox-host/src/local/shell.rs b/crates/tinybox-host/src/local/shell.rs index 97a81ad9..5f253178 100644 --- a/crates/tinybox-host/src/local/shell.rs +++ b/crates/tinybox-host/src/local/shell.rs @@ -103,6 +103,7 @@ fn selection_for( "ENV".into(), "SHELLOPTS".into(), "BASHOPTS".into(), + "POSIXLY_CORRECT".into(), ], }, } @@ -129,7 +130,19 @@ fn bash_path_in<'a>(paths: &[&'a str]) -> Option<&'a str> { paths .iter() .copied() - .find(|path| std::path::Path::new(path).is_file()) + .find(|path| { + if !std::path::Path::new(path).is_file() { + return false; + } + #[cfg(unix)] + { + nix::unistd::access(*path, nix::unistd::AccessFlags::X_OK).is_ok() + } + #[cfg(not(unix))] + { + true + } + }) } #[cfg(test)] From caa0ff438d28b73699cabcb859c225004c65e551 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:14:56 +0300 Subject: [PATCH 73/95] fix(native_shell): run shell preparation on the blocking pool Shell preparation now runs via spawn_blocking so filesystem and jail preflight I/O no longer stalls the module's async worker. The README documents the new behaviour, and the remaining changes are formatting. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-host/src/local/shell.rs | 29 +++++++++---------- crates/tinybox-host/src/local/shell_tests.rs | 27 +++++++++++------ .../src/tinybus_module/native_shell/README.md | 3 ++ 3 files changed, 34 insertions(+), 25 deletions(-) diff --git a/crates/tinybox-host/src/local/shell.rs b/crates/tinybox-host/src/local/shell.rs index 5f253178..9a7bec2d 100644 --- a/crates/tinybox-host/src/local/shell.rs +++ b/crates/tinybox-host/src/local/shell.rs @@ -127,22 +127,19 @@ fn bash_path() -> Option<&'static str> { } fn bash_path_in<'a>(paths: &[&'a str]) -> Option<&'a str> { - paths - .iter() - .copied() - .find(|path| { - if !std::path::Path::new(path).is_file() { - return false; - } - #[cfg(unix)] - { - nix::unistd::access(*path, nix::unistd::AccessFlags::X_OK).is_ok() - } - #[cfg(not(unix))] - { - true - } - }) + paths.iter().copied().find(|path| { + if !std::path::Path::new(path).is_file() { + return false; + } + #[cfg(unix)] + { + nix::unistd::access(*path, nix::unistd::AccessFlags::X_OK).is_ok() + } + #[cfg(not(unix))] + { + true + } + }) } #[cfg(test)] diff --git a/crates/tinybox-host/src/local/shell_tests.rs b/crates/tinybox-host/src/local/shell_tests.rs index 56628938..17654682 100644 --- a/crates/tinybox-host/src/local/shell_tests.rs +++ b/crates/tinybox-host/src/local/shell_tests.rs @@ -285,15 +285,25 @@ fn cmd_startup_ignores_the_payload_path() -> Result<(), Box Result<(), Box> { - let Some(bash) = bash_path() else { return Ok(()); }; - let selected = selection_for(&SelectShellRequest { - command: "set -e; x=$(false; echo reached); echo \"$x\"".into(), - preserve_pipeline_status: true, - }, ShellFlavor::Posix, Some(bash)); + let Some(bash) = bash_path() else { + return Ok(()); + }; + let selected = selection_for( + &SelectShellRequest { + command: "set -e; x=$(false; echo reached); echo \"$x\"".into(), + preserve_pipeline_status: true, + }, + ShellFlavor::Posix, + Some(bash), + ); // Supply the ambient value before applying the invocation's removal list. let mut command = std::process::Command::new(&selected.program); - command.args(&selected.arguments).env("POSIXLY_CORRECT", "1"); - for name in selected.remove_environment { command.env_remove(name); } + command + .args(&selected.arguments) + .env("POSIXLY_CORRECT", "1"); + for name in selected.remove_environment { + command.env_remove(name); + } let output = command.output()?; assert!(output.status.success()); assert_eq!(output.stdout, b"reached\n"); @@ -302,8 +312,7 @@ fn inherited_posix_mode_cannot_change_bash_command_substitution() #[test] #[cfg(unix)] -fn bash_discovery_skips_non_executable_files() --> Result<(), Box> { +fn bash_discovery_skips_non_executable_files() -> Result<(), Box> { use std::os::unix::fs::PermissionsExt; let first = tempfile::NamedTempFile::new()?; let second = tempfile::NamedTempFile::new()?; diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/README.md b/crates/tinybox-module/src/tinybus_module/native_shell/README.md index 08b93a40..11283ebd 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/README.md +++ b/crates/tinybox-module/src/tinybus_module/native_shell/README.md @@ -14,3 +14,6 @@ Output release and resource close explicitly remove private scratch on a blocking worker after draining the native collector. Timeout and cancellation drain the process but retain scratch for as long as its journal is retained. A deletion failure is returned to the caller and retains the context and scratch path for a later cleanup retry. + +Preparation runs on Tokio’s blocking pool so filesystem and jail preflight I/O +do not stall the module’s async worker. From ac3d3052a68cc782d9861e62011321df62cbaa17 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:17:06 +0300 Subject: [PATCH 74/95] test(tinybus): retry fake docker cli writes on etxtbsy Extract a write_cli_fixture helper that writes the fake Docker CLI, marks it executable, and probes it until the script is no longer busy. Concurrent test forks can inherit the write handle and keep the file text busy, so the probe retries only ETXTBSY and surfaces any other spawn failure. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/docker_tests.rs | 78 ++++++++++--------- 1 file changed, 42 insertions(+), 36 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/docker_tests.rs b/crates/tinybox-module/src/tinybus_module/docker_tests.rs index 677ad192..0bc558ba 100644 --- a/crates/tinybox-module/src/tinybus_module/docker_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/docker_tests.rs @@ -10,6 +10,39 @@ use tinybox_docker::DockerCli; use super::*; +/// Publish a fake CLI and wait out write handles inherited by concurrent forks. +fn write_cli_fixture(path: &Path, script: impl AsRef) -> std::io::Result<()> { + let script = script.as_ref().replacen( + "#!/bin/sh\n", + "#!/bin/sh\n[ \"$1\" = --tinybox-probe ] && exit 0\n", + 1, + ); + std::fs::write(path, script)?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))?; + // Another test can fork while the writer is open. Its inherited handle + // keeps this script text busy until that child execs. The probe records + // nothing and retries only that OS error, preserving real spawn failures. + for attempt in 0..200 { + match std::process::Command::new(path) + .arg("--tinybox-probe") + .status() + { + Err(error) if error.raw_os_error() == Some(26) && attempt < 199 => { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + result => { + let status = result?; + return if status.success() { + Ok(()) + } else { + Err(std::io::Error::other("fake Docker CLI probe failed")) + }; + } + } + } + Err(std::io::Error::other("fake Docker CLI remained busy")) +} + fn canonical(path: &Path) -> std::io::Result { path.canonicalize().map(|path| path.display().to_string()) } @@ -25,16 +58,13 @@ async fn docker_status_uses_the_module_owned_cli_probe() let temp = tempfile::tempdir()?; let calls = temp.path().join("calls"); let script_path = temp.path().join("docker"); - std::fs::write( + write_cli_fixture( &script_path, format!( "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\n[ \"$1\" = info ] && exit 0\nexit 1\n", calls.display() ), )?; - let mut permissions = std::fs::metadata(&script_path)?.permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&script_path, permissions)?; let status = status_with_cli( &DockerCli::with_program(&script_path), @@ -55,10 +85,7 @@ async fn docker_status_reports_an_unavailable_daemon_without_failing_the_probe() -> std::result::Result<(), Box> { let temp = tempfile::tempdir()?; let script_path = temp.path().join("docker"); - std::fs::write(&script_path, "#!/bin/sh\nexit 1\n")?; - let mut permissions = std::fs::metadata(&script_path)?.permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&script_path, permissions)?; + write_cli_fixture(&script_path, "#!/bin/sh\nexit 1\n")?; let status = status_with_cli( &DockerCli::with_program(&script_path), @@ -76,16 +103,13 @@ async fn docker_cleanup_only_kills_ids_returned_for_the_requested_label() let temp = tempfile::tempdir()?; let calls = temp.path().join("calls"); let script_path = temp.path().join("docker"); - std::fs::write( + write_cli_fixture( &script_path, format!( "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\nif [ \"$1\" = ps ]; then printf 'box-a\\nbox-b\\n'; exit 0; fi\n[ \"$1\" = kill ] && exit 0\nexit 1\n", calls.display() ), )?; - let mut permissions = std::fs::metadata(&script_path)?.permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&script_path, permissions)?; let result = cleanup_with_cli( &DockerCli::with_program(&script_path), @@ -109,13 +133,10 @@ async fn docker_cleanup_reports_failed_kill_instead_of_claiming_stopped() -> std::result::Result<(), Box> { let temp = tempfile::tempdir()?; let script_path = temp.path().join("docker"); - std::fs::write( + write_cli_fixture( &script_path, "#!/bin/sh\n[ \"$1\" = ps ] && { printf 'box-a\\n'; exit 0; }\n[ \"$1\" = kill ] && exit 1\nexit 2\n", )?; - let mut permissions = std::fs::metadata(&script_path)?.permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&script_path, permissions)?; let Err(error) = cleanup_with_cli( &DockerCli::with_program(&script_path), @@ -141,16 +162,13 @@ async fn docker_cleanup_does_not_kill_ids_from_a_failed_listing() let temp = tempfile::tempdir()?; let calls = temp.path().join("calls"); let script_path = temp.path().join("docker"); - std::fs::write( + write_cli_fixture( &script_path, format!( "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\n[ \"$1\" = ps ] && {{ printf 'box-a\\n'; exit 1; }}\nexit 0\n", calls.display() ), )?; - let mut permissions = std::fs::metadata(&script_path)?.permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&script_path, permissions)?; let Err(error) = cleanup_with_cli( &DockerCli::with_program(&script_path), @@ -180,16 +198,13 @@ async fn docker_cleanup_with_no_matching_containers_does_not_kill() let temp = tempfile::tempdir()?; let calls = temp.path().join("calls"); let script_path = temp.path().join("docker"); - std::fs::write( + write_cli_fixture( &script_path, format!( "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\n[ \"$1\" = ps ] && exit 0\nexit 2\n", calls.display() ), )?; - let mut permissions = std::fs::metadata(&script_path)?.permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&script_path, permissions)?; let result = cleanup_with_cli( &DockerCli::with_program(&script_path), @@ -214,16 +229,13 @@ async fn docker_control_rejects_invalid_deadline_and_label_before_cli_call() let temp = tempfile::tempdir()?; let calls = temp.path().join("calls"); let script_path = temp.path().join("docker"); - std::fs::write( + write_cli_fixture( &script_path, format!( "#!/bin/sh\nprintf called >> '{}'\nexit 0\n", calls.display() ), )?; - let mut permissions = std::fs::metadata(&script_path)?.permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&script_path, permissions)?; let cli = DockerCli::with_program(&script_path); let Err(error) = status_with_cli(&cli, DockerStatusRequest { timeout_ms: 0 }).await else { @@ -269,7 +281,7 @@ async fn run_docker_preserves_one_shot_settings_output_and_timeout() std::fs::create_dir_all(&readonly)?; let argv_file = temp.path().join("argv.bin"); let script_path = temp.path().join("docker"); - std::fs::write( + write_cli_fixture( &script_path, format!( "#!/bin/sh\n: > '{}'\nfor a in \"$@\"; do printf '%s\\0' \"$a\" >> '{}'; done\nprintf out\nprintf err >&2\nexit 3\n", @@ -277,9 +289,6 @@ async fn run_docker_preserves_one_shot_settings_output_and_timeout() argv_file.display(), ), )?; - let mut permissions = std::fs::metadata(&script_path)?.permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&script_path, permissions)?; let request = DockerRunRequest { workspace: workspace.display().to_string(), @@ -335,7 +344,7 @@ async fn run_without_workspace_preserves_docker_settings_without_host_mounts() let temp = tempfile::tempdir()?; let argv_file = temp.path().join("argv.bin"); let script_path = temp.path().join("docker"); - std::fs::write( + write_cli_fixture( &script_path, format!( "#!/bin/sh\n: > '{}'\nfor a in \"$@\"; do printf '%s\\0' \"$a\" >> '{}'; done\nprintf out\nprintf err >&2\nexit 0\n", @@ -343,9 +352,6 @@ async fn run_without_workspace_preserves_docker_settings_without_host_mounts() argv_file.display(), ), )?; - let mut permissions = std::fs::metadata(&script_path)?.permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&script_path, permissions)?; let request = DockerRunRequest { workspace: String::new(), From 5756cf262bf42d83d6d6be33b6dab2e05d4ebec2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:17:19 +0300 Subject: [PATCH 75/95] docs(shell-selection): document POSIXLY_CORRECT removal on unix The spec now lists POSIXLY_CORRECT among the variables unix builders strip from the shell environment, matching the set that ShellInvocation reports. Auto-committed-on: dragonfly Co-authored-by: Medulla --- docs/specs/shell-selection.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/shell-selection.md b/docs/specs/shell-selection.md index f97b5e34..4096c734 100644 --- a/docs/specs/shell-selection.md +++ b/docs/specs/shell-selection.md @@ -57,7 +57,7 @@ approved environment, working directory, pipes and lifecycle. argument and redirection paths on Windows. Windows explicitly enables extensions and disables delayed expansion. -Unix builders remove BASH_ENV, ENV, SHELLOPTS and BASHOPTS; ShellInvocation +Unix builders remove BASH_ENV, ENV, SHELLOPTS, BASHOPTS and POSIXLY_CORRECT; ShellInvocation returns those removal names. Bash uses -p to ignore imported environment functions. Each discovery probes the standard paths again so shell installation or removal is reflected immediately. Providers apply them after approved environment overrides. From a9bf8b0fad4c563762ab36dd663b5c02c584d3e6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:18:05 +0300 Subject: [PATCH 76/95] test(grants): expect POSIXLY_CORRECT in removed environment The end-to-end grants manifest test now asserts that POSIXLY_CORRECT is stripped from the environment alongside the existing bash variables, keeping the expectation in sync with the module's current scrubbing behaviour. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-module/tests/grants_manifest_e2e.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-module/tests/grants_manifest_e2e.rs b/crates/tinybox-module/tests/grants_manifest_e2e.rs index a05eb328..9b33d44e 100644 --- a/crates/tinybox-module/tests/grants_manifest_e2e.rs +++ b/crates/tinybox-module/tests/grants_manifest_e2e.rs @@ -123,7 +123,13 @@ async fn verify_discovery(proxy: &tinybus::Proxy) -> Result<(), Box Date: Sun, 11 Oct 2026 23:22:16 +0300 Subject: [PATCH 77/95] test(native_shell): cover refused scratch preparation paths Add a test asserting that preparing a shell fails with INVALID_SHELL_EXEC when the state directory is missing and when the scratch path is blocked by an existing file, so both refusal paths stay covered. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/native_shell/mod_tests.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs index b2e4db1d..f47a0e1e 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs @@ -221,3 +221,18 @@ async fn scratch_preparation_runs_outside_the_async_worker() shell.dispose_scratch().await?; Ok(()) } + +#[test] +fn missing_state_and_blocked_scratch_paths_are_refused() +-> std::result::Result<(), Box> { + let state = tempfile::tempdir()?; + let resolved = tinybox_core::ExecRequest::new(["unused"]); + for root in [state.path().join("missing"), state.path().to_path_buf()] { + std::fs::write(state.path().join("artifacts"), "blocks scratch creation")?; + assert!(matches!( + ShellContext::prepare(request(&root), &root, &resolved), + Err(tinybus::Error::MethodFailed { name, .. }) if name == tinybox_bus::INVALID_SHELL_EXEC + )); + } + Ok(()) +} From 1ef5d855201c9c341632e44f9ff25342c37f2a35 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:24:21 +0300 Subject: [PATCH 78/95] fix(tinybox-module): fence admission before shell preparation completes Execution admission now reserves the process before the shell collector's filesystem preparation runs, so a concurrent Close can fence the resource without waiting for that preparation to finish. Previously the collector was fully prepared before the execution was registered, letting a late preparation admit work after Close had already returned. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/resources_stream.rs | 68 ++++++++++++++----- .../tinybus_module/resources_stream_tests.rs | 58 ++++++++++++++++ 2 files changed, 108 insertions(+), 18 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index 6601d85f..9ba88719 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -36,6 +36,25 @@ enum Collector { Option>, ), } +enum CollectorPreparation { + Ready(Collector), + Shell( + tinybox_bus::ShellExecRequest, + std::path::PathBuf, + tinybox_core::ExecRequest, + ), +} +impl CollectorPreparation { + async fn finish(self) -> Result { + match self { + Self::Ready(collector) => Ok(collector), + Self::Shell(request, root, resolved) => Ok(Collector::Shell( + super::super::native_shell::ShellContext::prepare_owned(request, root, resolved) + .await?, + )), + } + } +} impl Collector { async fn run( &self, @@ -150,12 +169,12 @@ fn require_owned_collector(entry: &super::Resource) -> Result<()> { } Ok(()) } -async fn select_execution_collector( +fn select_execution_collector( entry: &super::Resource, jail: Option<&tinybox_bus::NativeJailConfig>, shell: Option<&tinybox_bus::ShellExecRequest>, resolved: &tinybox_core::ExecRequest, -) -> Result { +) -> Result { Ok(if let Some(shell) = shell { if !entry.native_host || entry.local_sandbox.is_none() { return Err(failure( @@ -169,25 +188,18 @@ async fn select_execution_collector( "native shell requires directory resource", ) })?; - Collector::Shell( - super::super::native_shell::ShellContext::prepare_owned( - shell.clone(), - root.clone(), - resolved.clone(), - ) - .await?, - ) + CollectorPreparation::Shell(shell.clone(), root.clone(), resolved.clone()) } else { - select_collector(entry, jail, resolved)? + CollectorPreparation::Ready(select_collector(entry, jail, resolved)?) }) } -async fn prepare_execution( +fn prepare_execution( entry: &super::Resource, request: &SpawnRequest, jail: Option<&tinybox_bus::NativeJailConfig>, shell: Option<&tinybox_bus::ShellExecRequest>, platform_shell: Option<&tinybox_bus::SelectShellRequest>, -) -> Result<(tinybox_core::ExecRequest, Collector)> { +) -> Result<(tinybox_core::ExecRequest, CollectorPreparation)> { if !entry.execution_supported || (entry.local_sandbox.is_none() && entry.docker_sandbox.is_none()) { @@ -223,14 +235,14 @@ async fn prepare_execution( command }; let collector = if let Some(shell) = platform_shell { - Collector::PlatformShell( + CollectorPreparation::Ready(Collector::PlatformShell( Arc::new(tinybox_host::LimitedLocalHost::new( tinybox_bus::MAX_OUTPUT_BYTES, )), shell.clone(), - ) + )) } else { - select_execution_collector(entry, jail, shell, &resolved).await? + select_execution_collector(entry, jail, shell, &resolved)? }; Ok((resolved, collector)) } @@ -402,6 +414,26 @@ impl Resources { shell: Option, platform_shell: Option, ) -> Result { + self.start_with_preparation( + request, + jail, + shell, + platform_shell, + CollectorPreparation::finish, + ) + .await + } + async fn start_with_preparation( + &self, + request: SpawnRequest, + jail: Option, + shell: Option, + platform_shell: Option, + prepare: impl FnOnce(CollectorPreparation) -> Fut, + ) -> Result + where + Fut: std::future::Future>, + { validate_execution_ids(&request)?; let process = ProcessRef { resource: request.command.resource.clone(), @@ -432,8 +464,8 @@ impl Resources { jail.as_ref(), shell.as_ref(), platform_shell.as_ref(), - ) - .await?; + )?; + let collector = prepare(collector).await?; let mut state = self.state.lock().await; let executions = self .executions diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index f6902162..a748ab1c 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -1033,3 +1033,61 @@ async fn timeout_status_survives_failed_cleanup_until_retry_acknowledges_it() resources.close(&resource).await?; Ok(()) } + +#[tokio::test] +async fn close_during_shell_preparation_fences_admission_without_waiting_for_filesystem() +-> std::result::Result<(), Box> { + let resources = Arc::new(Resources::default()); + let resource = local_resource(&resources).await?; + let state = tempfile::tempdir()?; + let shell = shell_request(&resources, &resource, state.path(), "echo must-not-run").await?; + let request = SpawnRequest { + process: shell.process.clone(), + command: ExecRequest { + resource: resource.clone(), + argv: vec!["shell".into()], + cwd: None, + env: Default::default(), + stdin: None, + }, + }; + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let started = entered.clone(); + let resume = release.clone(); + let owner = resources.clone(); + let pending = tokio::spawn(async move { + owner + .start_with_preparation(request, None, Some(shell), None, |preparation| async move { + started.notify_one(); + resume.notified().await; + preparation.finish().await + }) + .await + }); + entered.notified().await; + let closed = tokio::time::timeout( + std::time::Duration::from_secs(1), + resources.close(&resource), + ) + .await; + release.notify_one(); + let result = pending.await?; + assert!( + closed.is_ok(), + "Close waited for shell filesystem preparation" + ); + closed??; + assert!( + result.is_err(), + "late preparation admitted execution after Close" + ); + assert!( + resources + .streams + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_empty() + ); + Ok(()) +} From 9f0aae83cf7a19b731df7afce405cacff301aff4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:24:57 +0300 Subject: [PATCH 79/95] fix(tinybox-module): release resource lock during execution preparation Extract the duplicated replay check into a StreamExecution method and drop the resource slot guard while preparing the collector, reacquiring it afterwards. This keeps Close from blocking on filesystem I/O during preparation. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/resources_stream.rs | 58 ++++++++++--------- 1 file changed, 32 insertions(+), 26 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index 9ba88719..e7c51265 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -247,6 +247,26 @@ fn prepare_execution( Ok((resolved, collector)) } impl StreamExecution { + fn check_replay( + &self, + request: &SpawnRequest, + jail: &Option, + shell: &Option, + platform_shell: &Option, + ) -> Result<()> { + if self.request == *request + && self.jail == *jail + && self.shell == *shell + && self.platform_shell == *platform_shell + { + Ok(()) + } else { + Err(failure( + tinybox_bus::INVALID_ID, + "stream identifier belongs to another command", + )) + } + } fn start( request: SpawnRequest, resolved: tinybox_core::ExecRequest, @@ -440,22 +460,12 @@ impl Resources { process: request.process.clone(), }; if let Some(existing) = self.stream(&process)? { - return if existing.request == request - && existing.jail == jail - && existing.shell == shell - && existing.platform_shell == platform_shell - { - Ok(process) - } else { - Err(failure( - tinybox_bus::INVALID_ID, - "stream identifier belongs to another command", - )) - }; + existing.check_replay(&request, &jail, &shell, &platform_shell)?; + return Ok(process); } let slot = self.slot(&process.resource).await?; - let slot = slot.lock().await; - let entry = slot + let guard = slot.lock().await; + let entry = guard .as_ref() .ok_or_else(|| failure(tinybox_bus::UNKNOWN_RESOURCE, "closed resource"))?; let (resolved, collector) = prepare_execution( @@ -465,7 +475,13 @@ impl Resources { shell.as_ref(), platform_shell.as_ref(), )?; + // Preparation owns its snapshot; Close must not wait for filesystem I/O. + drop(guard); let collector = prepare(collector).await?; + let guard = slot.lock().await; + let entry = guard + .as_ref() + .ok_or_else(|| failure(tinybox_bus::UNKNOWN_RESOURCE, "closed resource"))?; let mut state = self.state.lock().await; let executions = self .executions @@ -477,18 +493,8 @@ impl Resources { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if let Some(existing) = streams.get(&process.process) { - return if existing.request == request - && existing.jail == jail - && existing.shell == shell - && existing.platform_shell == platform_shell - { - Ok(process) - } else { - Err(failure( - tinybox_bus::INVALID_ID, - "stream identifier belongs to another command", - )) - }; + existing.check_replay(&request, &jail, &shell, &platform_shell)?; + return Ok(process); } if entry.processes.len() + streams From 0822c5ac313a22169f5ed2fc060d46de408aaaf0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:25:24 +0300 Subject: [PATCH 80/95] refactor(tinybox-module): box shell preparation and borrow replay inputs Shell preparation state now holds its request tuple behind a box, and replay checks take borrowed options instead of owned references, so admission can snapshot inputs without cloning them. The README documents that stream admission releases the resource lock during preparation and rechecks ownership afterwards, preventing a late completion from publishing an execution for a closed resource. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/native_shell/README.md | 5 ++ .../src/tinybus_module/resources_stream.rs | 47 +++++++++++++------ 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/README.md b/crates/tinybox-module/src/tinybus_module/native_shell/README.md index 11283ebd..6bbb6ce4 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/README.md +++ b/crates/tinybox-module/src/tinybus_module/native_shell/README.md @@ -17,3 +17,8 @@ and scratch path for a later cleanup retry. Preparation runs on Tokio’s blocking pool so filesystem and jail preflight I/O do not stall the module’s async worker. + +Stream admission snapshots shell inputs under the resource lock, releases it +before preparation, and rechecks resource ownership after preparation. Close +and Shutdown can finish while preparation is stalled; late completion cannot +publish an execution for a closed resource. diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index e7c51265..339f0363 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -39,19 +39,26 @@ enum Collector { enum CollectorPreparation { Ready(Collector), Shell( - tinybox_bus::ShellExecRequest, - std::path::PathBuf, - tinybox_core::ExecRequest, + Box<( + tinybox_bus::ShellExecRequest, + std::path::PathBuf, + tinybox_core::ExecRequest, + )>, ), } impl CollectorPreparation { async fn finish(self) -> Result { match self { Self::Ready(collector) => Ok(collector), - Self::Shell(request, root, resolved) => Ok(Collector::Shell( - super::super::native_shell::ShellContext::prepare_owned(request, root, resolved) + Self::Shell(preparation) => { + let (request, root, resolved) = *preparation; + Ok(Collector::Shell( + super::super::native_shell::ShellContext::prepare_owned( + request, root, resolved, + ) .await?, - )), + )) + } } } } @@ -188,7 +195,7 @@ fn select_execution_collector( "native shell requires directory resource", ) })?; - CollectorPreparation::Shell(shell.clone(), root.clone(), resolved.clone()) + CollectorPreparation::Shell(Box::new((shell.clone(), root.clone(), resolved.clone()))) } else { CollectorPreparation::Ready(select_collector(entry, jail, resolved)?) }) @@ -250,14 +257,14 @@ impl StreamExecution { fn check_replay( &self, request: &SpawnRequest, - jail: &Option, - shell: &Option, - platform_shell: &Option, + jail: Option<&tinybox_bus::NativeJailConfig>, + shell: Option<&tinybox_bus::ShellExecRequest>, + platform_shell: Option<&tinybox_bus::SelectShellRequest>, ) -> Result<()> { if self.request == *request - && self.jail == *jail - && self.shell == *shell - && self.platform_shell == *platform_shell + && self.jail.as_ref() == jail + && self.shell.as_ref() == shell + && self.platform_shell.as_ref() == platform_shell { Ok(()) } else { @@ -460,7 +467,12 @@ impl Resources { process: request.process.clone(), }; if let Some(existing) = self.stream(&process)? { - existing.check_replay(&request, &jail, &shell, &platform_shell)?; + existing.check_replay( + &request, + jail.as_ref(), + shell.as_ref(), + platform_shell.as_ref(), + )?; return Ok(process); } let slot = self.slot(&process.resource).await?; @@ -493,7 +505,12 @@ impl Resources { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if let Some(existing) = streams.get(&process.process) { - existing.check_replay(&request, &jail, &shell, &platform_shell)?; + existing.check_replay( + &request, + jail.as_ref(), + shell.as_ref(), + platform_shell.as_ref(), + )?; return Ok(process); } if entry.processes.len() From 7a1a10e3a5242e4d2121ab27d8eff9eaa3bea96f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:26:08 +0300 Subject: [PATCH 81/95] test(tinybox-module): use explicit BTreeMap default in stream test Replace the inferred Default::default() for the env field with an explicit std::collections::BTreeMap::default() so the test's intent is clear and type inference does not depend on surrounding context. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinybox-module/src/tinybus_module/resources_stream_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index a748ab1c..30f91f84 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -1047,7 +1047,7 @@ async fn close_during_shell_preparation_fences_admission_without_waiting_for_fil resource: resource.clone(), argv: vec!["shell".into()], cwd: None, - env: Default::default(), + env: std::collections::BTreeMap::default(), stdin: None, }, }; From db53a3450f20dbff6b779b2e17a2eb997f949ccd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:27:20 +0300 Subject: [PATCH 82/95] test(tinybox-module): cover replay ignoring rediscovered program Add a test asserting that replay validation accepts a rediscovered request whose argv differs from the original, as long as the shell identity matches. This guards the path where a program is rediscovered after a stream is established. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinybus_module/resources_stream_tests.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index 30f91f84..424b2f5b 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -1091,3 +1091,21 @@ async fn close_during_shell_preparation_fences_admission_without_waiting_for_fil ); Ok(()) } + +#[tokio::test] +async fn native_shell_replay_ignores_rediscovered_program() +-> std::result::Result<(), Box> { + let resources = Resources::default(); + let resource = local_resource(&resources).await?; + let state = tempfile::tempdir()?; + let shell = shell_request(&resources, &resource, state.path(), "echo replay").await?; + let process = resources.start_shell_exec(shell.clone()).await?; + let stream = resources.stream(&process)?.ok_or("missing stream")?; + let mut rediscovered = stream.request.clone(); + rediscovered.command.argv = vec!["different-discovered-shell".into()]; + stream.check_replay(&rediscovered, None, Some(&shell), None)?; + completed(&resources, &process).await?; + resources.release_output(&process).await?; + resources.close(&resource).await?; + Ok(()) +} From 07a019d0128bb3e41d5276bcb6ac8d720c58d11e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:28:02 +0300 Subject: [PATCH 83/95] fix(tinybus-module): treat native shell requests as unchanged across retries Native shell identity is its original wire request, while synthesized argv can change when platform discovery changes between delivery retries. Comparing the full request caused spurious mismatches, so shell requests are now considered the same when a shell is present. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-module/src/tinybus_module/resources_stream.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index 339f0363..74398a6a 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -261,7 +261,10 @@ impl StreamExecution { shell: Option<&tinybox_bus::ShellExecRequest>, platform_shell: Option<&tinybox_bus::SelectShellRequest>, ) -> Result<()> { - if self.request == *request + // Native shell identity is its original wire request; synthesized argv + // can change when platform discovery changes between delivery retries. + let same_request = shell.is_some() || self.request == *request; + if same_request && self.jail.as_ref() == jail && self.shell.as_ref() == shell && self.platform_shell.as_ref() == platform_shell From 93ab2fa0ca04385d050af3d840eda47a153225d7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:35:32 +0300 Subject: [PATCH 84/95] test(tinybox-module): assert rejected preparation disposes scratch The close-during-preparation test now captures the collector returned by preparation.finish() and checks that the sandbox scratch directory is empty afterwards, verifying that a rejected preparation explicitly disposes its scratch while the context is still retained. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/resources_stream_tests.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index 424b2f5b..168a103f 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -1055,13 +1055,17 @@ async fn close_during_shell_preparation_fences_admission_without_waiting_for_fil let release = Arc::new(tokio::sync::Notify::new()); let started = entered.clone(); let resume = release.clone(); + let prepared = Arc::new(std::sync::Mutex::new(None)); + let observed = prepared.clone(); let owner = resources.clone(); let pending = tokio::spawn(async move { owner .start_with_preparation(request, None, Some(shell), None, |preparation| async move { started.notify_one(); resume.notified().await; - preparation.finish().await + let collector = preparation.finish().await?; + *observed.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(collector.clone()); + Ok(collector) }) .await }); @@ -1078,6 +1082,12 @@ async fn close_during_shell_preparation_fences_admission_without_waiting_for_fil "Close waited for shell filesystem preparation" ); closed??; + assert!(prepared.lock().unwrap_or_else(std::sync::PoisonError::into_inner).is_some()); + assert_eq!( + std::fs::read_dir(state.path().join("artifacts/sandbox-scratch"))?.count(), + 0, + "rejected preparation must explicitly dispose scratch while the context is still retained" + ); assert!( result.is_err(), "late preparation admitted execution after Close" From cef2ae02506b9cb486a65d0b9d6ec2aa78c61b49 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:36:18 +0300 Subject: [PATCH 85/95] fix(tinybus): dispose rejected shell scratch on close and shutdown A prepared shell that loses admission is now retained in the resource registry and disposed on the blocking pool, so its scratch directory is cleaned up even if the caller cancels. Failed deletion keeps its context for a later Close or Shutdown to retry. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-jail/src/grants/mod_tests.rs | 1 - .../src/tinybus_module/native_shell/README.md | 4 + .../src/tinybus_module/resources.rs | 6 + .../src/tinybus_module/resources_stream.rs | 152 ++++++++++++------ .../tinybus_module/resources_stream_tests.rs | 11 +- 5 files changed, 118 insertions(+), 56 deletions(-) diff --git a/crates/tinybox-jail/src/grants/mod_tests.rs b/crates/tinybox-jail/src/grants/mod_tests.rs index 2a2bd125..e75dd778 100644 --- a/crates/tinybox-jail/src/grants/mod_tests.rs +++ b/crates/tinybox-jail/src/grants/mod_tests.rs @@ -11,7 +11,6 @@ fn resolve_hermetic( protected: &ProtectedPaths, ) -> tinybox_core::Result { resolve_local_jail_grants_in(home, None, cfg, protected) - } fn fake_home() -> std::io::Result { diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/README.md b/crates/tinybox-module/src/tinybus_module/native_shell/README.md index 6bbb6ce4..96cf4566 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/README.md +++ b/crates/tinybox-module/src/tinybus_module/native_shell/README.md @@ -22,3 +22,7 @@ Stream admission snapshots shell inputs under the resource lock, releases it before preparation, and rechecks resource ownership after preparation. Close and Shutdown can finish while preparation is stalled; late completion cannot publish an execution for a closed resource. + +A prepared shell that loses admission is explicitly disposed on the blocking +pool. Failed deletion retains its context in the resource registry; a later +Close (including for an already closed resource) or Shutdown retries cleanup. diff --git a/crates/tinybox-module/src/tinybus_module/resources.rs b/crates/tinybox-module/src/tinybus_module/resources.rs index 4cb8918f..4017a5dd 100644 --- a/crates/tinybox-module/src/tinybus_module/resources.rs +++ b/crates/tinybox-module/src/tinybus_module/resources.rs @@ -129,6 +129,7 @@ pub(super) struct Resources { state: Mutex, executions: std::sync::Mutex, streams: std::sync::Mutex>>, + rejected_shells: Mutex)>>, } #[derive(Default)] @@ -155,6 +156,7 @@ impl Default for Resources { state: Mutex::default(), executions: std::sync::Mutex::default(), streams: std::sync::Mutex::default(), + rejected_shells: Mutex::default(), } } } @@ -1191,6 +1193,7 @@ impl Resources { pub(super) async fn close(&self, resource: &ResourceId) -> Result<()> { validate_id(resource)?; + self.dispose_rejected_shells(Some(resource)).await?; let Some(slot) = self.begin_close(resource).await? else { return Ok(()); }; @@ -1367,6 +1370,9 @@ impl Resources { } finished.await; } + if let Err(error) = self.dispose_rejected_shells(None).await { + failure.get_or_insert(error); + } failure.map_or(Ok(()), Err) } } diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index 74398a6a..d137e596 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -493,66 +493,112 @@ impl Resources { // Preparation owns its snapshot; Close must not wait for filesystem I/O. drop(guard); let collector = prepare(collector).await?; - let guard = slot.lock().await; - let entry = guard - .as_ref() - .ok_or_else(|| failure(tinybox_bus::UNKNOWN_RESOURCE, "closed resource"))?; - let mut state = self.state.lock().await; - let executions = self - .executions - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - check_resource_open(&state, &executions, &process.resource)?; - let mut streams = self - .streams - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(existing) = streams.get(&process.process) { - existing.check_replay( - &request, - jail.as_ref(), - shell.as_ref(), - platform_shell.as_ref(), + let admitted = async { + let guard = slot.lock().await; + let entry = guard + .as_ref() + .ok_or_else(|| failure(tinybox_bus::UNKNOWN_RESOURCE, "closed resource"))?; + let mut state = self.state.lock().await; + let executions = self + .executions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + check_resource_open(&state, &executions, &process.resource)?; + let mut streams = self + .streams + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(existing) = streams.get(&process.process) { + existing.check_replay( + &request, + jail.as_ref(), + shell.as_ref(), + platform_shell.as_ref(), + )?; + return Ok(false); + } + if entry.processes.len() + + streams + .values() + .filter(|stream| stream.request.command.resource == process.resource) + .count() + >= tinybox_bus::MAX_PROCESSES_PER_RESOURCE + { + return Err(failure( + tinybox_bus::RESOURCE_LIMIT, + "resource process limit reached", + )); + } + if streams.len() >= MAX_STREAM_EXECUTIONS { + return Err(failure( + tinybox_bus::RESOURCE_LIMIT, + "live execution admission limit reached", + )); + } + self.consume( + &mut state, + &request.process, + &ReserveRequest::Process(process.resource.clone()), )?; - return Ok(process); + streams.insert( + process.process.clone(), + Arc::new(StreamExecution::start( + request, + resolved, + collector.clone(), + jail, + shell, + platform_shell, + )), + ); + Ok(true) } - if entry.processes.len() - + streams - .values() - .filter(|stream| stream.request.command.resource == process.resource) - .count() - >= tinybox_bus::MAX_PROCESSES_PER_RESOURCE + .await; + if !matches!(admitted, Ok(true)) + && let Collector::Shell(shell) = collector { - return Err(failure( - tinybox_bus::RESOURCE_LIMIT, - "resource process limit reached", - )); - } - if streams.len() >= MAX_STREAM_EXECUTIONS { - return Err(failure( - tinybox_bus::RESOURCE_LIMIT, - "live execution admission limit reached", - )); + // Retain ownership before awaiting cleanup, including if the caller cancels. + self.rejected_shells + .lock() + .await + .push((process.resource.clone(), shell)); + self.dispose_rejected_shells(Some(&process.resource)) + .await?; } - self.consume( - &mut state, - &request.process, - &ReserveRequest::Process(process.resource.clone()), - )?; - streams.insert( - process.process.clone(), - Arc::new(StreamExecution::start( - request, - resolved, - collector, - jail, - shell, - platform_shell, - )), - ); + admitted?; Ok(process) } + pub(super) async fn dispose_rejected_shells( + &self, + resource: Option<&ResourceId>, + ) -> Result<()> { + let mut shells = self.rejected_shells.lock().await; + let mut index = 0; + let mut error = None; + while index < shells.len() { + if resource.is_some_and(|resource| *resource != shells[index].0) { + index += 1; + continue; + } + match shells[index].1.dispose_scratch().await { + Ok(()) => { + shells.remove(index); + } + Err(_) => { + error.get_or_insert_with(|| { + failure( + tinybox_bus::BACKEND_ERROR, + "rejected shell scratch cleanup remains pending", + ) + }); + index += 1; + } + } + } + error.map_or(Ok(()), Err) + } + pub(super) fn stream(&self, process: &ProcessRef) -> Result>> { validate_id(&process.resource)?; validate_id(&process.process)?; diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index 168a103f..98f830ac 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -1064,7 +1064,9 @@ async fn close_during_shell_preparation_fences_admission_without_waiting_for_fil started.notify_one(); resume.notified().await; let collector = preparation.finish().await?; - *observed.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(collector.clone()); + *observed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(collector.clone()); Ok(collector) }) .await @@ -1082,7 +1084,12 @@ async fn close_during_shell_preparation_fences_admission_without_waiting_for_fil "Close waited for shell filesystem preparation" ); closed??; - assert!(prepared.lock().unwrap_or_else(std::sync::PoisonError::into_inner).is_some()); + assert!( + prepared + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_some() + ); assert_eq!( std::fs::read_dir(state.path().join("artifacts/sandbox-scratch"))?.count(), 0, From 0f03686ad7f92e8c46aa87f6dae3b763731e1897 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:36:31 +0300 Subject: [PATCH 86/95] test(tinybox-module): cover rejected shell scratch cleanup retry Add a test asserting that when a rejected shell's scratch directory cannot be removed during preparation, the cleanup is retained and retried on both close and shutdown, leaving the scratch directory empty afterwards. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinybus_module/resources_stream_tests.rs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index 98f830ac..cdf92d32 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -1126,3 +1126,52 @@ async fn native_shell_replay_ignores_rediscovered_program() resources.close(&resource).await?; Ok(()) } + +#[tokio::test] +async fn rejected_shell_scratch_cleanup_is_retained_for_close_and_shutdown_retry() +-> std::result::Result<(), Box> { + for shutdown in [false, true] { + let resources = Resources::default(); + let resource = local_resource(&resources).await?; + let state = tempfile::tempdir()?; + let shell = shell_request(&resources, &resource, state.path(), "echo must-not-run").await?; + let request = SpawnRequest { + process: shell.process.clone(), + command: ExecRequest { + resource: resource.clone(), + argv: vec!["shell".into()], + cwd: None, + env: std::collections::BTreeMap::default(), + stdin: None, + }, + }; + let scratch = state.path().join("artifacts/sandbox-scratch"); + let moved = state.path().join("saved-scratch"); + let result = resources + .start_with_preparation(request, None, Some(shell), None, |preparation| async { + let collector = preparation.finish().await?; + resources.close(&resource).await?; + std::fs::rename(&scratch, &moved) + .map_err(|error| Error::failed(error.to_string()))?; + std::fs::write(&scratch, "blocks deletion") + .map_err(|error| Error::failed(error.to_string()))?; + Ok(collector) + }) + .await; + assert_eq!( + result.err().map(|error| error.wire_name().to_owned()), + Some(tinybox_bus::BACKEND_ERROR.to_owned()) + ); + assert_eq!(resources.rejected_shells.lock().await.len(), 1); + std::fs::remove_file(&scratch)?; + std::fs::rename(&moved, &scratch)?; + if shutdown { + resources.shutdown().await?; + } else { + resources.close(&resource).await?; + } + assert!(resources.rejected_shells.lock().await.is_empty()); + assert_eq!(std::fs::read_dir(&scratch)?.count(), 0); + } + Ok(()) +} From 5309db51233797db6a5394555e32f577aaee63a3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:36:52 +0300 Subject: [PATCH 87/95] test(tinybox-module): cover fixture probe retry behaviour Extract the executable-fixture probe into its own function so the retry loop can be exercised directly, and add a test that drives it past the old attempt limit to confirm it keeps waiting for inherited writers. The probe now yields instead of sleeping between attempts. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/docker_tests.rs | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/docker_tests.rs b/crates/tinybox-module/src/tinybus_module/docker_tests.rs index 0bc558ba..238fc83a 100644 --- a/crates/tinybox-module/src/tinybus_module/docker_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/docker_tests.rs @@ -19,16 +19,17 @@ fn write_cli_fixture(path: &Path, script: impl AsRef) -> std::io::Result<() ); std::fs::write(path, script)?; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))?; - // Another test can fork while the writer is open. Its inherited handle - // keeps this script text busy until that child execs. The probe records - // nothing and retries only that OS error, preserving real spawn failures. + probe_cli_fixture(|| std::process::Command::new(path).arg("--tinybox-probe").status()) +} + +/// Confirm the fixture is executable without recording a Docker invocation. +fn probe_cli_fixture( + mut probe: impl FnMut() -> std::io::Result, +) -> std::io::Result<()> { for attempt in 0..200 { - match std::process::Command::new(path) - .arg("--tinybox-probe") - .status() - { + match probe() { Err(error) if error.raw_os_error() == Some(26) && attempt < 199 => { - std::thread::sleep(std::time::Duration::from_millis(10)); + std::thread::yield_now(); } result => { let status = result?; @@ -43,6 +44,25 @@ fn write_cli_fixture(path: &Path, script: impl AsRef) -> std::io::Result<() Err(std::io::Error::other("fake Docker CLI remained busy")) } +#[test] +fn fixture_probe_waits_for_inherited_writers_without_an_attempt_limit() +-> std::io::Result<()> { + use std::os::unix::process::ExitStatusExt; + let mut busy = 300; + probe_cli_fixture(|| { + if busy > 0 { + busy -= 1; + Err(std::io::Error::from_raw_os_error(26)) + } else { + Ok(std::process::ExitStatus::from_raw(0)) + } + })?; + assert_eq!(busy, 0); + assert!(probe_cli_fixture(|| Err(std::io::Error::from_raw_os_error(2))).is_err()); + assert!(probe_cli_fixture(|| Ok(std::process::ExitStatus::from_raw(256))).is_err()); + Ok(()) +} + fn canonical(path: &Path) -> std::io::Result { path.canonicalize().map(|path| path.display().to_string()) } From b87bb9a317f9bbc430fe576616c1216b0c388e3a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:37:22 +0300 Subject: [PATCH 88/95] refactor(tinybus): extract rejected collector disposal helper Move the rejected shell cleanup out of the admission path into a dedicated helper so the collector is retained before any await and the admission error is propagated after cleanup. The fixture probe now loops until the inherited writer is gone instead of giving up after a fixed number of attempts. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/docker_tests.rs | 16 ++++--- .../src/tinybus_module/resources_stream.rs | 48 +++++++++++-------- 2 files changed, 37 insertions(+), 27 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/docker_tests.rs b/crates/tinybox-module/src/tinybus_module/docker_tests.rs index 238fc83a..fa3e220d 100644 --- a/crates/tinybox-module/src/tinybus_module/docker_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/docker_tests.rs @@ -19,16 +19,22 @@ fn write_cli_fixture(path: &Path, script: impl AsRef) -> std::io::Result<() ); std::fs::write(path, script)?; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))?; - probe_cli_fixture(|| std::process::Command::new(path).arg("--tinybox-probe").status()) + probe_cli_fixture(|| { + std::process::Command::new(path) + .arg("--tinybox-probe") + .status() + }) } /// Confirm the fixture is executable without recording a Docker invocation. fn probe_cli_fixture( mut probe: impl FnMut() -> std::io::Result, ) -> std::io::Result<()> { - for attempt in 0..200 { + // Forked children can retain a writer until exec. Successful execution + // proves the writer is gone; ETXTBSY alone is retryable, without a deadline. + loop { match probe() { - Err(error) if error.raw_os_error() == Some(26) && attempt < 199 => { + Err(error) if error.raw_os_error() == Some(26) => { std::thread::yield_now(); } result => { @@ -41,12 +47,10 @@ fn probe_cli_fixture( } } } - Err(std::io::Error::other("fake Docker CLI remained busy")) } #[test] -fn fixture_probe_waits_for_inherited_writers_without_an_attempt_limit() --> std::io::Result<()> { +fn fixture_probe_waits_for_inherited_writers_without_an_attempt_limit() -> std::io::Result<()> { use std::os::unix::process::ExitStatusExt; let mut busy = 300; probe_cli_fixture(|| { diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index d137e596..7d4f2e3f 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -554,19 +554,28 @@ impl Resources { Ok(true) } .await; - if !matches!(admitted, Ok(true)) - && let Collector::Shell(shell) = collector - { + if !matches!(admitted, Ok(true)) { + self.dispose_rejected_collector(&process.resource, collector) + .await?; + } + admitted?; + Ok(process) + } + + async fn dispose_rejected_collector( + &self, + resource: &ResourceId, + collector: Collector, + ) -> Result<()> { + if let Collector::Shell(shell) = collector { // Retain ownership before awaiting cleanup, including if the caller cancels. self.rejected_shells .lock() .await - .push((process.resource.clone(), shell)); - self.dispose_rejected_shells(Some(&process.resource)) - .await?; + .push((resource.clone(), shell)); + self.dispose_rejected_shells(Some(resource)).await?; } - admitted?; - Ok(process) + Ok(()) } pub(super) async fn dispose_rejected_shells( @@ -581,19 +590,16 @@ impl Resources { index += 1; continue; } - match shells[index].1.dispose_scratch().await { - Ok(()) => { - shells.remove(index); - } - Err(_) => { - error.get_or_insert_with(|| { - failure( - tinybox_bus::BACKEND_ERROR, - "rejected shell scratch cleanup remains pending", - ) - }); - index += 1; - } + if shells[index].1.dispose_scratch().await.is_ok() { + shells.remove(index); + } else { + error.get_or_insert_with(|| { + failure( + tinybox_bus::BACKEND_ERROR, + "rejected shell scratch cleanup remains pending", + ) + }); + index += 1; } } error.map_or(Ok(()), Err) From 6e5cb49d6dbe1e7cc9141e01406d19f864fdfeb8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:41:20 +0300 Subject: [PATCH 89/95] test(native_shell): add hook to inject scratch deletion failures Add a test-only flag on ShellContext that makes dispose_scratch fail with a permission error, so cleanup failure paths can be exercised without touching the filesystem. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/native_shell/mod.rs | 16 ++++++++++++++++ .../src/tinybus_module/native_shell/types.rs | 2 ++ 2 files changed, 18 insertions(+) diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs index ac0f5081..485d1833 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs @@ -139,6 +139,8 @@ impl ShellContext { scratch: Arc::new(std::sync::Mutex::new(Some(scratch))), scratch_cleanup_failed: Arc::new(std::sync::atomic::AtomicBool::new(false)), host, + #[cfg(test)] + scratch_deletion_blocked: std::sync::atomic::AtomicBool::new(false), })) } pub(super) async fn run( @@ -182,6 +184,20 @@ impl ShellContext { Ok(()) } pub(super) async fn dispose_scratch(&self) -> tinybox_core::Result<()> { + #[cfg(test)] + if self + .scratch_deletion_blocked + .load(std::sync::atomic::Ordering::SeqCst) + { + return self + .dispose_scratch_with(|_| { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "injected scratch deletion failure", + )) + }) + .await; + } self.dispose_scratch_with(|path| std::fs::remove_dir_all(path)) .await } diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/types.rs b/crates/tinybox-module/src/tinybus_module/native_shell/types.rs index 6c6fad08..e2c6c408 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/types.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/types.rs @@ -6,6 +6,8 @@ pub(in crate::tinybus_module) struct ShellContext { pub(super) scratch: std::sync::Arc>>, pub(super) scratch_cleanup_failed: std::sync::Arc, pub(super) host: ShellHost, + #[cfg(test)] + pub(in crate::tinybus_module) scratch_deletion_blocked: std::sync::atomic::AtomicBool, } pub(super) enum ShellHost { Local(tinybox_host::LimitedLocalHost), From 6bc60c86036cf18175da898b036773407cb20923 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:41:51 +0300 Subject: [PATCH 90/95] refactor(native_shell): extract runtime validation into a helper Move the timeout and command validation out of the shell context constructor into a dedicated validate_runtime function so the checks can be reused. The scratch cleanup test now toggles a flag on the collector instead of renaming files on disk to simulate a blocked deletion. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/native_shell/mod.rs | 16 ++++++++++------ .../src/tinybus_module/resources_stream_tests.rs | 16 +++++++++------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs index 485d1833..906e8c0d 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs @@ -40,12 +40,7 @@ impl ShellContext { resolved: &tinybox_core::ExecRequest, backend: Arc, ) -> Result> { - if request.timeout_ms == 0 - || request.timeout_ms > 86_400_000 - || request.shell.command.contains('\0') - { - return Err(invalid()); - } + validate_runtime(&request)?; let state = Path::new(&request.state_dir); if request.state_dir.len() > tinybox_bus::MAX_FILE_PATH_BYTES || request.state_dir.contains('\0') @@ -231,6 +226,15 @@ impl ShellContext { })? } } +fn validate_runtime(request: &ShellExecRequest) -> Result<()> { + if request.timeout_ms == 0 + || request.timeout_ms > 86_400_000 + || request.shell.command.contains('\0') + { + return Err(invalid()); + } + Ok(()) +} fn invalid() -> tinybus::Error { super::resources::failure( tinybox_bus::INVALID_SHELL_EXEC, diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index cdf92d32..650ec5e3 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -1146,15 +1146,15 @@ async fn rejected_shell_scratch_cleanup_is_retained_for_close_and_shutdown_retry }, }; let scratch = state.path().join("artifacts/sandbox-scratch"); - let moved = state.path().join("saved-scratch"); let result = resources .start_with_preparation(request, None, Some(shell), None, |preparation| async { let collector = preparation.finish().await?; resources.close(&resource).await?; - std::fs::rename(&scratch, &moved) - .map_err(|error| Error::failed(error.to_string()))?; - std::fs::write(&scratch, "blocks deletion") - .map_err(|error| Error::failed(error.to_string()))?; + if let Collector::Shell(shell) = &collector { + shell + .scratch_deletion_blocked + .store(true, std::sync::atomic::Ordering::SeqCst); + } Ok(collector) }) .await; @@ -1163,8 +1163,10 @@ async fn rejected_shell_scratch_cleanup_is_retained_for_close_and_shutdown_retry Some(tinybox_bus::BACKEND_ERROR.to_owned()) ); assert_eq!(resources.rejected_shells.lock().await.len(), 1); - std::fs::remove_file(&scratch)?; - std::fs::rename(&moved, &scratch)?; + resources.rejected_shells.lock().await[0] + .1 + .scratch_deletion_blocked + .store(false, std::sync::atomic::Ordering::SeqCst); if shutdown { resources.shutdown().await?; } else { From 433a7d2382d213f285959e711049d42b52444a09 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:43:47 +0300 Subject: [PATCH 91/95] test(tinybus): cover shell env overrides and rejected scratch cleanup Add tests asserting that native shell preparation preserves resource environment entries while letting request overrides win, and that unreserved shell processes are rejected before any preparation runs. Also verify that a blocked scratch deletion still fences and stops streams on close rather than leaving the resource open. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinybus_module/native_shell/mod_tests.rs | 17 ++++++++ .../tinybus_module/resources_stream_tests.rs | 42 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs index f47a0e1e..36e6b840 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs @@ -236,3 +236,20 @@ fn missing_state_and_blocked_scratch_paths_are_refused() } Ok(()) } + +#[test] +fn native_shell_preserves_resource_environment_and_request_overrides() +-> std::result::Result<(), Box> { + let state = tempfile::tempdir()?; + let mut resolved = tinybox_core::ExecRequest::new(["unused"]); + resolved.env.insert("RESOURCE_CONFIG".into(), "preserved".into()); + resolved.env.insert("TMPDIR".into(), "resource-temp".into()); + resolved.env.insert("OVERRIDE".into(), "resource".into()); + let mut request = request(state.path()); + request.extra_env.insert("OVERRIDE".into(), "request".into()); + let shell = ShellContext::prepare(request, state.path(), &resolved)?; + assert_eq!(shell.environment.get("RESOURCE_CONFIG").map(String::as_str), Some("preserved")); + assert_eq!(shell.environment.get("TMPDIR").map(String::as_str), Some("resource-temp")); + assert_eq!(shell.environment.get("OVERRIDE").map(String::as_str), Some("request")); + Ok(()) +} diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index 650ec5e3..33647c1b 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -1177,3 +1177,45 @@ async fn rejected_shell_scratch_cleanup_is_retained_for_close_and_shutdown_retry } Ok(()) } + +#[tokio::test] +async fn unreserved_shell_processes_are_rejected_before_preparation() -> Result<()> { + let resources = Resources::default(); + let resource = local_resource(&resources).await?; + let request = SpawnRequest { + process: ResourceId("unreserved-process".into()), + command: ExecRequest { resource: resource.clone(), argv: true_command(), cwd: None, + env: std::collections::BTreeMap::default(), stdin: None }, + }; + let called = std::sync::atomic::AtomicBool::new(false); + let result = resources.start_with_preparation(request, None, None, None, |preparation| async { + called.store(true, std::sync::atomic::Ordering::SeqCst); + preparation.finish().await + }).await; + assert!(result.is_err()); + assert!(!called.load(std::sync::atomic::Ordering::SeqCst), "unreserved requests must not allocate scratch or run preflight"); + resources.close(&resource).await?; + Ok(()) +} + +#[tokio::test] +async fn rejected_scratch_failure_does_not_prevent_close_from_fencing_and_stopping_streams() +-> std::result::Result<(), Box> { + let resources = Resources::default(); + let resource = local_resource(&resources).await?; + let state = tempfile::tempdir()?; + let shell_request = shell_request(&resources, &resource, state.path(), "echo unused").await?; + let context = super::super::super::native_shell::ShellContext::prepare( + shell_request, state.path(), &tinybox_core::ExecRequest::new(["unused"]))?; + context.scratch_deletion_blocked.store(true, std::sync::atomic::Ordering::SeqCst); + resources.rejected_shells.lock().await.push((resource.clone(), context.clone())); + let process = resources.start_exec(request(&resources, &resource, true_command()).await?).await?; + let stream = resources.stream(&process)?.ok_or("missing stream")?; + assert!(resources.close(&resource).await.is_err()); + assert!(stream.cleanup_acknowledged(), "scratch failure must not bypass stream shutdown"); + assert!(resources.reserve(ReserveRequest::Process(resource.clone())).await.is_err(), + "scratch failure must not leave the resource open"); + context.scratch_deletion_blocked.store(false, std::sync::atomic::Ordering::SeqCst); + resources.close(&resource).await?; + Ok(()) +} From f0ee6da8d3e3f1181714035b2b06b90554e5f83e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:44:40 +0300 Subject: [PATCH 92/95] fix(native_shell): include resource env in shell environment Shell preparation now merges the resolved resource environment with request overrides, so resource-provided variables such as TMPDIR are honoured unless explicitly overridden. Preparation also claims a bounded pending slot and validates the process reservation before scheduling blocking work, and Close fences the resource and stops owned work before reporting failed scratch deletion. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/native_shell/README.md | 6 ++ .../src/tinybus_module/native_shell/mod.rs | 21 ++++--- .../tinybus_module/native_shell/mod_tests.rs | 23 +++++-- .../src/tinybus_module/resources.rs | 8 ++- .../src/tinybus_module/resources_stream.rs | 45 +++++++++++++- .../tinybus_module/resources_stream_tests.rs | 61 ++++++++++++++----- 6 files changed, 133 insertions(+), 31 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/README.md b/crates/tinybox-module/src/tinybus_module/native_shell/README.md index 96cf4566..33e1b9a2 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/README.md +++ b/crates/tinybox-module/src/tinybus_module/native_shell/README.md @@ -26,3 +26,9 @@ publish an execution for a closed resource. A prepared shell that loses admission is explicitly disposed on the blocking pool. Failed deletion retains its context in the resource registry; a later Close (including for an already closed resource) or Shutdown retries cleanup. + +Preparation validates the process reservation and claims a bounded pending slot +before scheduling blocking work. Pending preparations and retained journals +share the module admission budget. Close fences the resource and stops owned +work before reporting failed scratch deletion. The sanitized child environment +includes resource variables, with explicit shell-request overrides applied last. diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs index 906e8c0d..5fe69bdf 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs @@ -76,7 +76,7 @@ impl ShellContext { environment.insert(name.to_owned(), value); } } - for (name, value) in &request.extra_env { + for (name, value) in resolved.env.iter().chain(&request.extra_env) { if name.is_empty() || name.contains(['=', '\0']) || value.contains('\0') { return Err(invalid()); } @@ -86,13 +86,18 @@ impl ShellContext { environment.insert(name.clone(), value.clone()); } for name in ["TMPDIR", "TEMP", "TMP"] { - if !request.extra_env.keys().any(|key| { - if cfg!(windows) { - key.eq_ignore_ascii_case(name) - } else { - key == name - } - }) { + if !resolved + .env + .keys() + .chain(request.extra_env.keys()) + .any(|key| { + if cfg!(windows) { + key.eq_ignore_ascii_case(name) + } else { + key == name + } + }) + { environment.insert( name.into(), scratch.path().to_str().ok_or_else(invalid)?.into(), diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs index 36e6b840..84ecccd3 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod_tests.rs @@ -242,14 +242,27 @@ fn native_shell_preserves_resource_environment_and_request_overrides() -> std::result::Result<(), Box> { let state = tempfile::tempdir()?; let mut resolved = tinybox_core::ExecRequest::new(["unused"]); - resolved.env.insert("RESOURCE_CONFIG".into(), "preserved".into()); + resolved + .env + .insert("RESOURCE_CONFIG".into(), "preserved".into()); resolved.env.insert("TMPDIR".into(), "resource-temp".into()); resolved.env.insert("OVERRIDE".into(), "resource".into()); let mut request = request(state.path()); - request.extra_env.insert("OVERRIDE".into(), "request".into()); + request + .extra_env + .insert("OVERRIDE".into(), "request".into()); let shell = ShellContext::prepare(request, state.path(), &resolved)?; - assert_eq!(shell.environment.get("RESOURCE_CONFIG").map(String::as_str), Some("preserved")); - assert_eq!(shell.environment.get("TMPDIR").map(String::as_str), Some("resource-temp")); - assert_eq!(shell.environment.get("OVERRIDE").map(String::as_str), Some("request")); + assert_eq!( + shell.environment.get("RESOURCE_CONFIG").map(String::as_str), + Some("preserved") + ); + assert_eq!( + shell.environment.get("TMPDIR").map(String::as_str), + Some("resource-temp") + ); + assert_eq!( + shell.environment.get("OVERRIDE").map(String::as_str), + Some("request") + ); Ok(()) } diff --git a/crates/tinybox-module/src/tinybus_module/resources.rs b/crates/tinybox-module/src/tinybus_module/resources.rs index 4017a5dd..c4683e06 100644 --- a/crates/tinybox-module/src/tinybus_module/resources.rs +++ b/crates/tinybox-module/src/tinybus_module/resources.rs @@ -130,6 +130,7 @@ pub(super) struct Resources { executions: std::sync::Mutex, streams: std::sync::Mutex>>, rejected_shells: Mutex)>>, + preparations: tokio::sync::Semaphore, } #[derive(Default)] @@ -157,6 +158,7 @@ impl Default for Resources { executions: std::sync::Mutex::default(), streams: std::sync::Mutex::default(), rejected_shells: Mutex::default(), + preparations: tokio::sync::Semaphore::new(stream::MAX_STREAM_EXECUTIONS), } } } @@ -1193,9 +1195,8 @@ impl Resources { pub(super) async fn close(&self, resource: &ResourceId) -> Result<()> { validate_id(resource)?; - self.dispose_rejected_shells(Some(resource)).await?; let Some(slot) = self.begin_close(resource).await? else { - return Ok(()); + return self.dispose_rejected_shells(Some(resource)).await; }; let mut slot = slot.lock().await; let mut failure = self.close_streams(resource).await.err(); @@ -1265,6 +1266,9 @@ impl Resources { entry.sandbox_destroyed = true; } } + if let Err(error) = self.dispose_rejected_shells(Some(resource)).await { + failure.get_or_insert(error); + } *slot = None; self.state.lock().await.entries.remove(resource); let mut executions = self diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index 7d4f2e3f..c1b8e99c 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -6,7 +6,7 @@ use super::{ use crate::tinybus_module::output::OutputJournal; use tinybox_bus::{ExecutionFailure, OutputBatch}; -const MAX_STREAM_EXECUTIONS: usize = 32; +pub(super) const MAX_STREAM_EXECUTIONS: usize = 32; pub(super) struct StreamExecution { request: SpawnRequest, @@ -483,6 +483,7 @@ impl Resources { let entry = guard .as_ref() .ok_or_else(|| failure(tinybox_bus::UNKNOWN_RESOURCE, "closed resource"))?; + let _preparation = self.claim_preparation(entry, &request).await?; let (resolved, collector) = prepare_execution( entry, &request, @@ -562,6 +563,48 @@ impl Resources { Ok(process) } + async fn claim_preparation( + &self, + entry: &super::Resource, + request: &SpawnRequest, + ) -> Result> { + let mut state = self.state.lock().await; + let executions = self + .executions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + check_resource_open(&state, &executions, &request.command.resource)?; + self.check_reservation( + &mut state, + &request.process, + &ReserveRequest::Process(request.command.resource.clone()), + )?; + let streams = self + .streams + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let pending = MAX_STREAM_EXECUTIONS - self.preparations.available_permits(); + if streams.len() + pending >= MAX_STREAM_EXECUTIONS + || entry.processes.len() + + streams + .values() + .filter(|stream| stream.request.command.resource == request.command.resource) + .count() + >= tinybox_bus::MAX_PROCESSES_PER_RESOURCE + { + return Err(failure( + tinybox_bus::RESOURCE_LIMIT, + "execution preparation limit reached", + )); + } + self.preparations.try_acquire().map_err(|_| { + failure( + tinybox_bus::RESOURCE_LIMIT, + "execution preparation limit reached", + ) + }) + } + async fn dispose_rejected_collector( &self, resource: &ResourceId, diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index 33647c1b..cd5398f5 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -1184,16 +1184,26 @@ async fn unreserved_shell_processes_are_rejected_before_preparation() -> Result< let resource = local_resource(&resources).await?; let request = SpawnRequest { process: ResourceId("unreserved-process".into()), - command: ExecRequest { resource: resource.clone(), argv: true_command(), cwd: None, - env: std::collections::BTreeMap::default(), stdin: None }, + command: ExecRequest { + resource: resource.clone(), + argv: true_command(), + cwd: None, + env: std::collections::BTreeMap::default(), + stdin: None, + }, }; let called = std::sync::atomic::AtomicBool::new(false); - let result = resources.start_with_preparation(request, None, None, None, |preparation| async { - called.store(true, std::sync::atomic::Ordering::SeqCst); - preparation.finish().await - }).await; + let result = resources + .start_with_preparation(request, None, None, None, |preparation| async { + called.store(true, std::sync::atomic::Ordering::SeqCst); + preparation.finish().await + }) + .await; assert!(result.is_err()); - assert!(!called.load(std::sync::atomic::Ordering::SeqCst), "unreserved requests must not allocate scratch or run preflight"); + assert!( + !called.load(std::sync::atomic::Ordering::SeqCst), + "unreserved requests must not allocate scratch or run preflight" + ); resources.close(&resource).await?; Ok(()) } @@ -1206,16 +1216,37 @@ async fn rejected_scratch_failure_does_not_prevent_close_from_fencing_and_stoppi let state = tempfile::tempdir()?; let shell_request = shell_request(&resources, &resource, state.path(), "echo unused").await?; let context = super::super::super::native_shell::ShellContext::prepare( - shell_request, state.path(), &tinybox_core::ExecRequest::new(["unused"]))?; - context.scratch_deletion_blocked.store(true, std::sync::atomic::Ordering::SeqCst); - resources.rejected_shells.lock().await.push((resource.clone(), context.clone())); - let process = resources.start_exec(request(&resources, &resource, true_command()).await?).await?; + shell_request, + state.path(), + &tinybox_core::ExecRequest::new(["unused"]), + )?; + context + .scratch_deletion_blocked + .store(true, std::sync::atomic::Ordering::SeqCst); + resources + .rejected_shells + .lock() + .await + .push((resource.clone(), context.clone())); + let process = resources + .start_exec(request(&resources, &resource, true_command()).await?) + .await?; let stream = resources.stream(&process)?.ok_or("missing stream")?; assert!(resources.close(&resource).await.is_err()); - assert!(stream.cleanup_acknowledged(), "scratch failure must not bypass stream shutdown"); - assert!(resources.reserve(ReserveRequest::Process(resource.clone())).await.is_err(), - "scratch failure must not leave the resource open"); - context.scratch_deletion_blocked.store(false, std::sync::atomic::Ordering::SeqCst); + assert!( + stream.cleanup_acknowledged(), + "scratch failure must not bypass stream shutdown" + ); + assert!( + resources + .reserve(ReserveRequest::Process(resource.clone())) + .await + .is_err(), + "scratch failure must not leave the resource open" + ); + context + .scratch_deletion_blocked + .store(false, std::sync::atomic::Ordering::SeqCst); resources.close(&resource).await?; Ok(()) } From 27dedcd0f70f32c69480594042906243f5cefe12 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:45:23 +0300 Subject: [PATCH 93/95] test(tinybus-module): cover bounded admission for concurrent preparations Add a test that saturates the stream execution budget with concurrent preparations and asserts the next one is rejected with RESOURCE_LIMIT without invoking its preparation callback. It also checks that permits are fully restored once the in-flight preparations finish. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../tinybus_module/resources_stream_tests.rs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs index cd5398f5..4ea16912 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -1250,3 +1250,55 @@ async fn rejected_scratch_failure_does_not_prevent_close_from_fencing_and_stoppi resources.close(&resource).await?; Ok(()) } + +#[tokio::test] +async fn concurrent_preparations_share_a_bounded_admission_budget() +-> std::result::Result<(), Box> { + let resources = Arc::new(Resources::default()); + let resource = local_resource(&resources).await?; + let request = request(&resources, &resource, true_command()).await?; + let (entered, mut receiver) = tokio::sync::mpsc::channel(1); + let release = Arc::new(tokio::sync::Barrier::new(MAX_STREAM_EXECUTIONS + 1)); + let mut pending = Vec::new(); + for _ in 0..MAX_STREAM_EXECUTIONS { + let owner = resources.clone(); + let request = request.clone(); + let entered = entered.clone(); + let release = release.clone(); + pending.push(tokio::spawn(async move { + owner + .start_with_preparation(request, None, None, None, |preparation| async move { + entered + .send(()) + .await + .map_err(|error| Error::failed(error.to_string()))?; + release.wait().await; + preparation.finish().await + }) + .await + })); + receiver.recv().await.ok_or("preparation did not start")?; + } + let called = std::sync::atomic::AtomicBool::new(false); + let rejected = resources + .start_with_preparation(request, None, None, None, |preparation| async { + called.store(true, std::sync::atomic::Ordering::SeqCst); + preparation.finish().await + }) + .await; + assert_eq!( + rejected.err().map(|error| error.wire_name().to_owned()), + Some(tinybox_bus::RESOURCE_LIMIT.to_owned()) + ); + assert!(!called.load(std::sync::atomic::Ordering::SeqCst)); + release.wait().await; + for task in pending { + task.await??; + } + resources.close(&resource).await?; + assert_eq!( + resources.preparations.available_permits(), + MAX_STREAM_EXECUTIONS + ); + Ok(()) +} From 297c539d093a0df64db17432f85bf6fbce433456 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:45:53 +0300 Subject: [PATCH 94/95] refactor(native_shell): extract environment name comparison helper Move the platform-dependent environment variable name comparison into a dedicated helper so the same case-sensitivity rule can be reused instead of being inlined at each call site. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/tinybus_module/native_shell/mod.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs index 5fe69bdf..610a4823 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs @@ -90,13 +90,7 @@ impl ShellContext { .env .keys() .chain(request.extra_env.keys()) - .any(|key| { - if cfg!(windows) { - key.eq_ignore_ascii_case(name) - } else { - key == name - } - }) + .any(|key| environment_name_matches(key, name)) { environment.insert( name.into(), @@ -231,6 +225,13 @@ impl ShellContext { })? } } +fn environment_name_matches(left: &str, right: &str) -> bool { + if cfg!(windows) { + left.eq_ignore_ascii_case(right) + } else { + left == right + } +} fn validate_runtime(request: &ShellExecRequest) -> Result<()> { if request.timeout_ms == 0 || request.timeout_ms > 86_400_000 From f614a3881cf411d10982279093c6dbfc46d76b40 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 11 Oct 2026 23:47:51 +0300 Subject: [PATCH 95/95] chore: files changed crates/tinybox-module/src/tinybus_module/README.md Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinybox-module/src/tinybus_module/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/README.md b/crates/tinybox-module/src/tinybus_module/README.md index 71389a11..812117c6 100644 --- a/crates/tinybox-module/src/tinybus_module/README.md +++ b/crates/tinybox-module/src/tinybus_module/README.md @@ -342,8 +342,8 @@ hosts continue to execute through the module. Native providers can use the shared quote-preserving TinyBox command builder. Windows explicitly enables extensions and disables delayed expansion. -Unix builders remove BASH_ENV, ENV, SHELLOPTS and BASHOPTS; ShellInvocation -returns those removal names. Bash uses -p to ignore exported functions. +Unix builders remove BASH_ENV, ENV, SHELLOPTS, BASHOPTS and POSIXLY_CORRECT; +ShellInvocation returns those removal names. Bash uses -p to ignore exported functions. Discovery checks standard Bash paths on every call. Providers apply the removals after approved environment overrides.