From 95e49a0699a635d590c7ec33a17b739963d23fcd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 05:48:44 +0000 Subject: [PATCH 001/236] chore: bump workspace version to 0.2.3 Update the workspace package version and all internal path dependency requirements from 0.2.2 to 0.2.3, with Cargo.lock regenerated to match. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 22 +++++++++++----------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cb19f2fd..91a79a20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1252,7 +1252,7 @@ dependencies = [ [[package]] name = "tinybox-bus" -version = "0.2.2" +version = "0.2.3" dependencies = [ "serde", "serde_json", @@ -1261,7 +1261,7 @@ dependencies = [ [[package]] name = "tinybox-cli" -version = "0.2.2" +version = "0.2.3" dependencies = [ "async-trait", "clap", @@ -1280,7 +1280,7 @@ dependencies = [ [[package]] name = "tinybox-core" -version = "0.2.2" +version = "0.2.3" dependencies = [ "async-trait", "serde", @@ -1291,7 +1291,7 @@ dependencies = [ [[package]] name = "tinybox-docker" -version = "0.2.2" +version = "0.2.3" dependencies = [ "async-trait", "ipnet", @@ -1306,7 +1306,7 @@ dependencies = [ [[package]] name = "tinybox-drivers" -version = "0.2.2" +version = "0.2.3" dependencies = [ "async-trait", "log", @@ -1325,7 +1325,7 @@ dependencies = [ [[package]] name = "tinybox-host" -version = "0.2.2" +version = "0.2.3" dependencies = [ "async-trait", "cap-std", @@ -1340,7 +1340,7 @@ dependencies = [ [[package]] name = "tinybox-jail" -version = "0.2.2" +version = "0.2.3" dependencies = [ "landlock", "log", @@ -1360,7 +1360,7 @@ dependencies = [ [[package]] name = "tinybox-linux" -version = "0.2.2" +version = "0.2.3" dependencies = [ "async-trait", "tempfile", @@ -1371,7 +1371,7 @@ dependencies = [ [[package]] name = "tinybox-microvm" -version = "0.2.2" +version = "0.2.3" dependencies = [ "async-trait", "serde", @@ -1384,7 +1384,7 @@ dependencies = [ [[package]] name = "tinybox-module" -version = "0.2.2" +version = "0.2.3" dependencies = [ "async-trait", "log", @@ -1412,7 +1412,7 @@ dependencies = [ [[package]] name = "tinybox-ssh" -version = "0.2.2" +version = "0.2.3" dependencies = [ "async-trait", "tempfile", @@ -1426,7 +1426,7 @@ dependencies = [ [[package]] name = "tinybox-sync" -version = "0.2.2" +version = "0.2.3" dependencies = [ "async-trait", "blake3", diff --git a/Cargo.toml b/Cargo.toml index 5e9b032f..441a5a47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ members = [ exclude = ["vendor"] [workspace.package] -version = "0.2.2" +version = "0.2.3" edition = "2024" rust-version = "1.88" license = "GPL-3.0-only" @@ -58,15 +58,15 @@ serde_json = "1" # The model types are serializable; see `tinybox-core`'s dependency comment. serde = { version = "1", features = ["derive"] } # Internal path dependencies between workspace members. -tinybox-bus = { path = "crates/tinybox-bus", version = "0.2.2" } -tinybox-core = { path = "crates/tinybox-core", version = "0.2.2" } -tinybox-host = { path = "crates/tinybox-host", version = "0.2.2" } -tinybox-jail = { path = "crates/tinybox-jail", version = "0.2.2" } -tinybox-linux = { path = "crates/tinybox-linux", version = "0.2.2" } -tinybox-microvm = { path = "crates/tinybox-microvm", version = "0.2.2" } -tinybox-docker = { path = "crates/tinybox-docker", version = "0.2.2" } -tinybox-ssh = { path = "crates/tinybox-ssh", version = "0.2.2" } -tinybox-sync = { path = "crates/tinybox-sync", version = "0.2.2" } +tinybox-bus = { path = "crates/tinybox-bus", version = "0.2.3" } +tinybox-core = { path = "crates/tinybox-core", version = "0.2.3" } +tinybox-host = { path = "crates/tinybox-host", version = "0.2.3" } +tinybox-jail = { path = "crates/tinybox-jail", version = "0.2.3" } +tinybox-linux = { path = "crates/tinybox-linux", version = "0.2.3" } +tinybox-microvm = { path = "crates/tinybox-microvm", version = "0.2.3" } +tinybox-docker = { path = "crates/tinybox-docker", version = "0.2.3" } +tinybox-ssh = { path = "crates/tinybox-ssh", version = "0.2.3" } +tinybox-sync = { path = "crates/tinybox-sync", version = "0.2.3" } # Pin the storage contract crates to the canonical TSD v2.1 release commit. tinystoragedrivers-types = { version = "2.1.0", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "05eaada962decbab33bafa4763d2bd1a67f6b456" } tinystoragedrivers-api = { version = "2.1.0", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "05eaada962decbab33bafa4763d2bd1a67f6b456" } @@ -74,7 +74,7 @@ tinystoragedrivers-busproxy = { version = "2.1.0", git = "https://github.com/tin tinystoragedrivers = { version = "2.1.0", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "05eaada962decbab33bafa4763d2bd1a67f6b456", default-features = false } tinystoragedrivers-testkit = { version = "2.1.0", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "05eaada962decbab33bafa4763d2bd1a67f6b456" } tinystoragedrivers-document = { version = "2.1.0", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "05eaada962decbab33bafa4763d2bd1a67f6b456" } -tinybox-drivers = { path = "crates/tinybox-drivers", version = "0.2.2" } +tinybox-drivers = { path = "crates/tinybox-drivers", version = "0.2.3" } # Lints apply to every crate in the workspace via `[lints] workspace = true`. # CI runs clippy with `-D warnings`, so anything set to "warn" here fails CI. From ca314f037839e2c5dde272660a5557d15bd9edd2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:26:45 +0000 Subject: [PATCH 002/236] feat(tinybox-bus): derive JsonSchema for LocalGrantConfig LocalGrantConfig now derives JsonSchema so OpenHuman config operations can generate a schema from the shared contract type, with a test asserting the schema is produced. The jail crate also gains helpers exposing the detected backend name and whether Landlock is available. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- Cargo.lock | 73 +++++++++++++++++++++++-- crates/tinybox-bus/Cargo.toml | 2 + crates/tinybox-bus/src/grants.rs | 3 +- crates/tinybox-bus/src/lib_tests.rs | 6 ++ crates/tinybox-jail/src/detect.rs | 21 +++++++ crates/tinybox-jail/src/detect_tests.rs | 13 +++++ 6 files changed, 112 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 91a79a20..ddeaf8c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -384,6 +384,12 @@ dependencies = [ "crypto-common 0.2.2", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "enumflags2" version = "0.7.12" @@ -417,7 +423,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -913,6 +919,26 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "regex-automata" version = "0.4.18" @@ -979,7 +1005,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1042,6 +1068,31 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.3", +] + [[package]] name = "serde" version = "1.0.229" @@ -1072,6 +1123,17 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -1224,10 +1286,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1254,6 +1316,7 @@ dependencies = [ name = "tinybox-bus" version = "0.2.3" dependencies = [ + "schemars", "serde", "serde_json", "tinystoragedrivers-types", @@ -1943,7 +2006,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/tinybox-bus/Cargo.toml b/crates/tinybox-bus/Cargo.toml index f8a879f2..3c024fe6 100644 --- a/crates/tinybox-bus/Cargo.toml +++ b/crates/tinybox-bus/Cargo.toml @@ -10,6 +10,8 @@ repository.workspace = true [dependencies] # The bus boundary contains data only; serialization is its sole dependency. serde.workspace = true +# OpenHuman config operations share this contract type for schema generation. +schemars = "1" # Only the `storage` feature: pure collection and legacy-body vocabulary. serde_json = { workspace = true, optional = true } tinystoragedrivers-types = { workspace = true, optional = true } diff --git a/crates/tinybox-bus/src/grants.rs b/crates/tinybox-bus/src/grants.rs index 13258b10..0cc175a7 100644 --- a/crates/tinybox-bus/src/grants.rs +++ b/crates/tinybox-bus/src/grants.rs @@ -1,8 +1,9 @@ //! Host-configured filesystem grant discovery and protected-path vocabulary. use serde::{Deserialize, Serialize}; +use schemars::JsonSchema; /// Configuration used to discover toolchain and explicit filesystem grants. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(default)] pub struct LocalGrantConfig { /// Include existing toolchain homes and git configuration. diff --git a/crates/tinybox-bus/src/lib_tests.rs b/crates/tinybox-bus/src/lib_tests.rs index 3e42c923..630d4cba 100644 --- a/crates/tinybox-bus/src/lib_tests.rs +++ b/crates/tinybox-bus/src/lib_tests.rs @@ -16,6 +16,12 @@ fn command_optional_fields_default_when_absent() -> Result<(), serde_json::Error Ok(()) } +#[test] +fn local_grant_config_exposes_a_json_schema() { + let schema = schemars::schema_for!(LocalGrantConfig); + assert!(schema.schema.object.is_some()); +} + #[test] fn create_environment_defaults_and_workspace_round_trips() -> Result<(), serde_json::Error> { let request: CreateRequest = serde_json::from_str( diff --git a/crates/tinybox-jail/src/detect.rs b/crates/tinybox-jail/src/detect.rs index c1a8d801..3ed3a39d 100644 --- a/crates/tinybox-jail/src/detect.rs +++ b/crates/tinybox-jail/src/detect.rs @@ -62,6 +62,27 @@ pub fn pick_backend() -> Arc { pick_from(candidates()) } +/// Returns the stable name of the backend selected by platform detection. +#[must_use] +pub fn backend_name() -> &'static str { + pick_backend().name() +} + +/// Reports whether this build and running Linux kernel support Landlock. +/// +/// This is always false off Linux and when the `landlock` feature is disabled. +#[must_use] +pub fn landlock_available() -> bool { + #[cfg(target_os = "linux")] + { + crate::linux::LandlockBackend::new().is_available() + } + #[cfg(not(target_os = "linux"))] + { + false + } +} + /// Select an available backend from the ordered platform candidates. fn pick_from(backends: Vec>) -> Arc { for backend in backends { diff --git a/crates/tinybox-jail/src/detect_tests.rs b/crates/tinybox-jail/src/detect_tests.rs index b943f705..61880cd9 100644 --- a/crates/tinybox-jail/src/detect_tests.rs +++ b/crates/tinybox-jail/src/detect_tests.rs @@ -17,6 +17,19 @@ fn unavailable_backend_rejects_spawning() { #[test] fn backend_detection_returns_a_backend() { assert_ne!(pick_backend().name().len(), 0); + assert_eq!(backend_name(), pick_backend().name()); +} + +#[cfg(target_os = "linux")] +#[test] +fn landlock_detection_matches_the_landlock_backend() { + assert_eq!(landlock_available(), crate::linux::LandlockBackend::new().is_available()); +} + +#[cfg(not(target_os = "linux"))] +#[test] +fn landlock_is_unavailable_off_linux() { + assert!(!landlock_available()); } #[test] From 862d7ed8a9a9cf05062e99895bae0c89d3bf848d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:27:18 +0000 Subject: [PATCH 003/236] test(tinybox-bus): assert json schema properties in local grant config test The schema test now serializes the generated schema to JSON and checks for a properties key instead of inspecting the schemars object directly, and returns a Result so serialization failures surface as test errors. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/lib_tests.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-bus/src/lib_tests.rs b/crates/tinybox-bus/src/lib_tests.rs index 630d4cba..3a18e9ee 100644 --- a/crates/tinybox-bus/src/lib_tests.rs +++ b/crates/tinybox-bus/src/lib_tests.rs @@ -17,9 +17,11 @@ fn command_optional_fields_default_when_absent() -> Result<(), serde_json::Error } #[test] -fn local_grant_config_exposes_a_json_schema() { +fn local_grant_config_exposes_a_json_schema() -> Result<(), serde_json::Error> { let schema = schemars::schema_for!(LocalGrantConfig); - assert!(schema.schema.object.is_some()); + let schema = serde_json::to_value(schema)?; + assert!(schema.get("properties").is_some()); + Ok(()) } #[test] From 58f0d09b8ac7487e2d8fb384298cd94f529a366a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:29:10 +0000 Subject: [PATCH 004/236] feat(tinybox-bus): add native shell client helper Add a runtime-neutral native shell client that runs commands and polls bounded output, exposed alongside the existing bus types. The helper depends on futures-util and futures-timer so it works without pulling in a specific async runtime. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- Cargo.lock | 8 ++ crates/tinybox-bus/Cargo.toml | 4 + crates/tinybox-bus/src/grants.rs | 2 +- crates/tinybox-bus/src/lib.rs | 5 + crates/tinybox-bus/src/native_shell.rs | 179 ++++++++++++++++++++++++ crates/tinybox-jail/src/detect_tests.rs | 5 +- 6 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 crates/tinybox-bus/src/native_shell.rs diff --git a/Cargo.lock b/Cargo.lock index ddeaf8c2..452b8141 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -510,6 +510,12 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.34" @@ -1316,6 +1322,8 @@ dependencies = [ name = "tinybox-bus" version = "0.2.3" dependencies = [ + "futures-timer", + "futures-util", "schemars", "serde", "serde_json", diff --git a/crates/tinybox-bus/Cargo.toml b/crates/tinybox-bus/Cargo.toml index 3c024fe6..af472d8f 100644 --- a/crates/tinybox-bus/Cargo.toml +++ b/crates/tinybox-bus/Cargo.toml @@ -12,6 +12,10 @@ repository.workspace = true serde.workspace = true # OpenHuman config operations share this contract type for schema generation. schemars = "1" +# Runtime-neutral future combinators for the optional native-shell client helper. +futures-util = { version = "0.3", features = ["async-await"] } +# Runtime-neutral delay used between bounded output polls. +futures-timer = "3" # Only the `storage` feature: pure collection and legacy-body vocabulary. serde_json = { workspace = true, optional = true } tinystoragedrivers-types = { workspace = true, optional = true } diff --git a/crates/tinybox-bus/src/grants.rs b/crates/tinybox-bus/src/grants.rs index 0cc175a7..b1d976c0 100644 --- a/crates/tinybox-bus/src/grants.rs +++ b/crates/tinybox-bus/src/grants.rs @@ -1,6 +1,6 @@ //! Host-configured filesystem grant discovery and protected-path vocabulary. -use serde::{Deserialize, Serialize}; use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; /// Configuration used to discover toolchain and explicit filesystem grants. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] diff --git a/crates/tinybox-bus/src/lib.rs b/crates/tinybox-bus/src/lib.rs index 274ff7ee..1b819b1b 100644 --- a/crates/tinybox-bus/src/lib.rs +++ b/crates/tinybox-bus/src/lib.rs @@ -18,6 +18,11 @@ use std::collections::BTreeMap; mod grants; pub use grants::{LocalGrantConfig, LocalGrants, ProtectedPaths, ResolveLocalGrantsRequest}; +mod native_shell; +pub use native_shell::{ + NativeShellClient, NativeShellError, NativeShellOutput, NativeShellRunRequest, run_native_shell, +}; + mod jail; pub use jail::{JailEnforcement, NativeJailConfig, NativeJailRequest, NativeJailResourceRequest}; diff --git a/crates/tinybox-bus/src/native_shell.rs b/crates/tinybox-bus/src/native_shell.rs new file mode 100644 index 00000000..05260767 --- /dev/null +++ b/crates/tinybox-bus/src/native_shell.rs @@ -0,0 +1,179 @@ +//! Runtime-neutral orchestration for one supervised native shell execution. + +use std::{ + future::Future, + time::{Duration, Instant}, +}; + +use futures_util::{FutureExt, pin_mut, select}; + +use crate::{ + CreateRequest, ExecutionState, OutputBatch, OutputChunk, ProcessRef, ReserveRequest, + ResourceId, ShellExecRequest, +}; + +/// Transport operations required to supervise a native shell. +/// +/// Adapters implement these methods with their own bus connection and runtime. +pub trait NativeShellClient { + /// Transport error returned by one bus operation. + type Error; + + /// Reserve a resource or process identifier. + async fn reserve(&self, request: ReserveRequest) -> Result; + /// Create the resource with the module-issued reservation. + async fn create(&self, request: CreateRequest) -> Result<(), Self::Error>; + /// Start the requested native shell and return its owned process reference. + async fn start_native_shell_exec( + &self, + request: ShellExecRequest, + ) -> Result; + /// Read output starting at the supplied sequence cursor. + async fn read_output( + &self, + process: ProcessRef, + cursor: u64, + ) -> Result; + /// Cancel and reap a running native process. + async fn cancel(&self, process: ProcessRef) -> Result<(), Self::Error>; + /// Release retained output after the process reaches a terminal state. + async fn release_output(&self, process: ProcessRef) -> Result<(), Self::Error>; + /// Close the resource after process cleanup. + async fn close(&self, resource: ResourceId) -> Result<(), Self::Error>; +} + +/// Inputs for resource creation and native shell execution. +/// +/// The `resource` and process reservation fields in `create` and `shell` are +/// replaced by identifiers reserved during the run. +#[derive(Debug, Clone)] +pub struct NativeShellRunRequest { + /// Resource configuration; its reservation identifier is ignored. + pub create: CreateRequest, + /// Shell settings; its resource and process identifiers are ignored. + pub shell: ShellExecRequest, +} + +/// Collected native shell output after the module acknowledges process cleanup. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeShellOutput { + /// Process exit code, including nonzero exits. + pub exit_code: i32, + /// Complete stdout bytes. + pub stdout: Vec, + /// Complete stderr bytes. + pub stderr: Vec, + /// Whether the shell exceeded its request timeout. + pub timed_out: bool, +} + +/// Failure during one phase of native shell orchestration. +#[derive(Debug)] +pub enum NativeShellError { + /// A bus operation failed. + Transport { operation: &'static str, source: E }, + /// The module reported a terminal failed state. + Execution(crate::ExecutionFailure), + /// Cancellation failed, so native cleanup is not acknowledged. + Cancellation(E), + /// Output release failed after the process had stopped. + OutputRelease(E), + /// Resource close failed after process cleanup. + ResourceClose(E), +} + +/// Run one native shell and stream every output chunk to `on_chunk`. +/// +/// The caller must keep this future supervised while signaling `cancelled`; +/// dropping it cannot await native process cleanup. The helper reserves and +/// creates the resource, starts the process, polls replayable output, cancels +/// on timeout or caller cancellation, releases output, and closes the resource. +/// +/// # Errors +/// Returns the transport failure, module execution failure, or cleanup failure. +pub async fn run_native_shell( + client: &C, + request: NativeShellRunRequest, + cancelled: Fut, + mut on_chunk: F, +) -> Result> +where + C: NativeShellClient, + F: FnMut(&OutputChunk), + Fut: Future, +{ + let mut create = request.create; + let mut shell = request.shell; + let resource = client + .reserve(ReserveRequest::Resource) + .await + .map_err(|source| NativeShellError::Transport { + operation: "reserve resource", + source, + })?; + create.resource = resource.clone(); + let result = async { + client.create(create).await.map_err(|source| NativeShellError::Transport { operation: "create resource", source })?; + let process_id = client.reserve(ReserveRequest::Process(resource.clone())).await.map_err(|source| NativeShellError::Transport { operation: "reserve process", source })?; + shell.resource = resource.clone(); + shell.process = process_id; + let process = client.start_native_shell_exec(shell).await.map_err(|source| NativeShellError::Transport { operation: "start native shell", source })?; + let deadline = Instant::now() + Duration::from_millis(request.shell.timeout_ms.max(1)); + let mut cursor = 0; + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let cancelled = cancelled.fuse(); + pin_mut!(cancelled); + let mut cancellation_requested = false; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + let poll = client.read_output(process.clone(), cursor).fuse(); + let delay = futures_timer::Delay::new(if cancellation_requested { Duration::from_millis(20) } else { remaining.min(Duration::from_millis(20)) }).fuse(); + pin_mut!(poll, delay); + let batch = if cancellation_requested { + select! { result = poll => result.map_err(|source| NativeShellError::Transport { operation: "read output", source })? } + } else { + select! { + result = poll => result.map_err(|source| NativeShellError::Transport { operation: "read output", source })?, + _ = cancelled => { + client.cancel(process.clone()).await.map_err(NativeShellError::Cancellation)?; + cancellation_requested = true; + continue; + }, + _ = delay => { + client.cancel(process.clone()).await.map_err(NativeShellError::Cancellation)?; + cancellation_requested = true; + continue; + } + } + }; + for chunk in &batch.chunks { + on_chunk(chunk); + match chunk.stream { + crate::OutputStream::Stdout => stdout.extend_from_slice(&chunk.bytes), + crate::OutputStream::Stderr => stderr.extend_from_slice(&chunk.bytes), + } + } + cursor = batch.next_sequence; + match batch.state { + ExecutionState::Running => continue, + ExecutionState::Exited { exit_code } => { + client.release_output(process.clone()).await.map_err(NativeShellError::OutputRelease)?; + return Ok(NativeShellOutput { exit_code, stdout, stderr, timed_out: cancellation_requested }); + } + ExecutionState::Cancelled => { + client.release_output(process.clone()).await.map_err(NativeShellError::OutputRelease)?; + return Ok(NativeShellOutput { exit_code: -1, stdout, stderr, timed_out: cancellation_requested }); + } + ExecutionState::Failed { code } => return Err(NativeShellError::Execution(code)), + } + } + }.await; + let close_result = client.close(resource).await; + match (result, close_result) { + (Ok(output), Ok(())) => Ok(output), + (Err(error), Ok(())) => Err(error), + (Ok(_), Err(source)) => Err(NativeShellError::ResourceClose(source)), + (Err(error), Err(_)) => Err(error), + } +} diff --git a/crates/tinybox-jail/src/detect_tests.rs b/crates/tinybox-jail/src/detect_tests.rs index 61880cd9..6c760c46 100644 --- a/crates/tinybox-jail/src/detect_tests.rs +++ b/crates/tinybox-jail/src/detect_tests.rs @@ -23,7 +23,10 @@ fn backend_detection_returns_a_backend() { #[cfg(target_os = "linux")] #[test] fn landlock_detection_matches_the_landlock_backend() { - assert_eq!(landlock_available(), crate::linux::LandlockBackend::new().is_available()); + assert_eq!( + landlock_available(), + crate::linux::LandlockBackend::new().is_available() + ); } #[cfg(not(target_os = "linux"))] From ee3f2746bda7735e469a859aa85c9bcb9abb1c0c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:29:29 +0000 Subject: [PATCH 005/236] fix(native-shell): capture timeout before shell is moved The timeout was read from request.shell after the shell had been moved into start_native_shell_exec, so the deadline now uses a value captured up front. This keeps the timeout clamping behaviour unchanged while avoiding the use-after-move. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-bus/src/native_shell.rs b/crates/tinybox-bus/src/native_shell.rs index 05260767..1b4407ce 100644 --- a/crates/tinybox-bus/src/native_shell.rs +++ b/crates/tinybox-bus/src/native_shell.rs @@ -103,6 +103,7 @@ where Fut: Future, { let mut create = request.create; + let timeout_ms = request.shell.timeout_ms.max(1); let mut shell = request.shell; let resource = client .reserve(ReserveRequest::Resource) @@ -118,7 +119,7 @@ where shell.resource = resource.clone(); shell.process = process_id; let process = client.start_native_shell_exec(shell).await.map_err(|source| NativeShellError::Transport { operation: "start native shell", source })?; - let deadline = Instant::now() + Duration::from_millis(request.shell.timeout_ms.max(1)); + let deadline = Instant::now() + Duration::from_millis(timeout_ms); let mut cursor = 0; let mut stdout = Vec::new(); let mut stderr = Vec::new(); From 0df5fe2b636bfc432445189d44b3cb4ba29ada30 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:29:44 +0000 Subject: [PATCH 006/236] fix(tinybox-bus): report timeouts accurately and clean up on failure The timed_out flag was previously derived from cancellation_requested, so caller-initiated cancellations were reported as timeouts; a dedicated flag now tracks only deadline expiry. Read errors and failed executions also release the output stream before returning, and the Failed state no longer leaks it. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/tinybox-bus/src/native_shell.rs b/crates/tinybox-bus/src/native_shell.rs index 1b4407ce..7fa49b76 100644 --- a/crates/tinybox-bus/src/native_shell.rs +++ b/crates/tinybox-bus/src/native_shell.rs @@ -126,6 +126,7 @@ where let cancelled = cancelled.fuse(); pin_mut!(cancelled); let mut cancellation_requested = false; + let mut timed_out = false; loop { let remaining = deadline.saturating_duration_since(Instant::now()); let poll = client.read_output(process.clone(), cursor).fuse(); @@ -135,10 +136,18 @@ where select! { result = poll => result.map_err(|source| NativeShellError::Transport { operation: "read output", source })? } } else { select! { - result = poll => result.map_err(|source| NativeShellError::Transport { operation: "read output", source })?, + result = poll => match result { + Ok(batch) => batch, + Err(source) => { + client.cancel(process.clone()).await.map_err(NativeShellError::Cancellation)?; + client.release_output(process.clone()).await.map_err(NativeShellError::OutputRelease)?; + return Err(NativeShellError::Transport { operation: "read output", source }); + } + }, _ = cancelled => { client.cancel(process.clone()).await.map_err(NativeShellError::Cancellation)?; cancellation_requested = true; + timed_out = true; continue; }, _ = delay => { @@ -160,13 +169,16 @@ where ExecutionState::Running => continue, ExecutionState::Exited { exit_code } => { client.release_output(process.clone()).await.map_err(NativeShellError::OutputRelease)?; - return Ok(NativeShellOutput { exit_code, stdout, stderr, timed_out: cancellation_requested }); + return Ok(NativeShellOutput { exit_code, stdout, stderr, timed_out }); } ExecutionState::Cancelled => { client.release_output(process.clone()).await.map_err(NativeShellError::OutputRelease)?; - return Ok(NativeShellOutput { exit_code: -1, stdout, stderr, timed_out: cancellation_requested }); + return Ok(NativeShellOutput { exit_code: -1, stdout, stderr, timed_out }); + } + ExecutionState::Failed { code } => { + client.release_output(process.clone()).await.map_err(NativeShellError::OutputRelease)?; + return Err(NativeShellError::Execution(code)); } - ExecutionState::Failed { code } => return Err(NativeShellError::Execution(code)), } } }.await; From e530279a308e433d7a665936aae8d10fda04bca1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:30:30 +0000 Subject: [PATCH 007/236] refactor(tinybox-bus): make native shell client object-safe Reworked the NativeShellClient trait to return boxed futures instead of using async fn, so it can be used as a trait object, and added a Sync bound. Also fixed the timeout flag being set on cancellation rather than on the delay branch, and documented the Transport error fields. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell.rs | 42 +++++-- crates/tinybox-bus/src/native_shell_tests.rs | 125 +++++++++++++++++++ 2 files changed, 155 insertions(+), 12 deletions(-) create mode 100644 crates/tinybox-bus/src/native_shell_tests.rs diff --git a/crates/tinybox-bus/src/native_shell.rs b/crates/tinybox-bus/src/native_shell.rs index 7fa49b76..c7b7e8c6 100644 --- a/crates/tinybox-bus/src/native_shell.rs +++ b/crates/tinybox-bus/src/native_shell.rs @@ -15,31 +15,40 @@ use crate::{ /// Transport operations required to supervise a native shell. /// /// Adapters implement these methods with their own bus connection and runtime. -pub trait NativeShellClient { +pub trait NativeShellClient: Sync { /// Transport error returned by one bus operation. type Error; /// Reserve a resource or process identifier. - async fn reserve(&self, request: ReserveRequest) -> Result; + fn reserve( + &self, + request: ReserveRequest, + ) -> impl Future> + Send; /// Create the resource with the module-issued reservation. - async fn create(&self, request: CreateRequest) -> Result<(), Self::Error>; + fn create( + &self, + request: CreateRequest, + ) -> impl Future> + Send; /// Start the requested native shell and return its owned process reference. - async fn start_native_shell_exec( + fn start_native_shell_exec( &self, request: ShellExecRequest, - ) -> Result; + ) -> impl Future> + Send; /// Read output starting at the supplied sequence cursor. - async fn read_output( + fn read_output( &self, process: ProcessRef, cursor: u64, - ) -> Result; + ) -> impl Future> + Send; /// Cancel and reap a running native process. - async fn cancel(&self, process: ProcessRef) -> Result<(), Self::Error>; + fn cancel(&self, process: ProcessRef) -> impl Future> + Send; /// Release retained output after the process reaches a terminal state. - async fn release_output(&self, process: ProcessRef) -> Result<(), Self::Error>; + fn release_output( + &self, + process: ProcessRef, + ) -> impl Future> + Send; /// Close the resource after process cleanup. - async fn close(&self, resource: ResourceId) -> Result<(), Self::Error>; + fn close(&self, resource: ResourceId) -> impl Future> + Send; } /// Inputs for resource creation and native shell execution. @@ -71,7 +80,12 @@ pub struct NativeShellOutput { #[derive(Debug)] pub enum NativeShellError { /// A bus operation failed. - Transport { operation: &'static str, source: E }, + Transport { + /// Human-readable operation that failed. + operation: &'static str, + /// Underlying adapter error. + source: E, + }, /// The module reported a terminal failed state. Execution(crate::ExecutionFailure), /// Cancellation failed, so native cleanup is not acknowledged. @@ -147,12 +161,12 @@ where _ = cancelled => { client.cancel(process.clone()).await.map_err(NativeShellError::Cancellation)?; cancellation_requested = true; - timed_out = true; continue; }, _ = delay => { client.cancel(process.clone()).await.map_err(NativeShellError::Cancellation)?; cancellation_requested = true; + timed_out = true; continue; } } @@ -190,3 +204,7 @@ where (Err(error), Err(_)) => Err(error), } } + +#[cfg(test)] +#[path = "native_shell_tests.rs"] +mod tests; diff --git a/crates/tinybox-bus/src/native_shell_tests.rs b/crates/tinybox-bus/src/native_shell_tests.rs new file mode 100644 index 00000000..cf00de86 --- /dev/null +++ b/crates/tinybox-bus/src/native_shell_tests.rs @@ -0,0 +1,125 @@ +//! Tests for runtime-neutral native shell orchestration. +use super::*; +use crate::{ + HostConfig, NetworkPolicy, OutputStream, ResourceLimits, SelectShellRequest, Workspace, +}; +use std::{collections::BTreeMap, sync::Mutex}; + +struct MockClient { + calls: Mutex>, +} + +impl NativeShellClient for MockClient { + type Error = &'static str; + + async fn reserve(&self, request: ReserveRequest) -> Result { + self.calls.lock().map_err(|_| "poisoned")?.push("reserve"); + Ok(match request { + ReserveRequest::Resource => ResourceId("resource-1".into()), + ReserveRequest::Process(_) => ResourceId("process-1".into()), + _ => return Err("unexpected reservation"), + }) + } + + async fn create(&self, request: CreateRequest) -> Result<(), Self::Error> { + self.calls.lock().map_err(|_| "poisoned")?.push("create"); + assert_eq!(request.resource, ResourceId("resource-1".into())); + Ok(()) + } + + async fn start_native_shell_exec( + &self, + request: ShellExecRequest, + ) -> Result { + self.calls.lock().map_err(|_| "poisoned")?.push("start"); + assert_eq!(request.process, ResourceId("process-1".into())); + Ok(ProcessRef { + resource: request.resource, + process: request.process, + }) + } + + async fn read_output(&self, _: ProcessRef, _: u64) -> Result { + self.calls.lock().map_err(|_| "poisoned")?.push("read"); + Ok(OutputBatch { + chunks: vec![OutputChunk { + sequence: 0, + stream: OutputStream::Stdout, + bytes: b"done".to_vec(), + }], + next_sequence: 1, + state: ExecutionState::Exited { exit_code: 7 }, + }) + } + + async fn cancel(&self, _: ProcessRef) -> Result<(), Self::Error> { + self.calls.lock().map_err(|_| "poisoned")?.push("cancel"); + Ok(()) + } + + async fn release_output(&self, _: ProcessRef) -> Result<(), Self::Error> { + self.calls.lock().map_err(|_| "poisoned")?.push("release"); + Ok(()) + } + + async fn close(&self, _: ResourceId) -> Result<(), Self::Error> { + self.calls.lock().map_err(|_| "poisoned")?.push("close"); + Ok(()) + } +} + +#[tokio::test] +async fn runs_and_cleans_up_a_shell_while_streaming_chunks() -> Result<(), &'static str> { + let client = MockClient { + calls: Mutex::new(Vec::new()), + }; + let mut chunks = Vec::new(); + let output = run_native_shell(&client, request(), std::future::pending(), |chunk| { + chunks.push(chunk.clone()) + }) + .await + .map_err(|_| "run failed")?; + assert_eq!(output.exit_code, 7); + assert_eq!(output.stdout, b"done"); + assert!(!output.timed_out); + assert_eq!(chunks.len(), 1); + assert_eq!( + *client.calls.lock().map_err(|_| "poisoned")?, + [ + "reserve", "create", "reserve", "start", "read", "release", "close" + ] + ); + Ok(()) +} + +fn request() -> NativeShellRunRequest { + NativeShellRunRequest { + create: CreateRequest { + resource: ResourceId("placeholder-resource".into()), + backend: "passthrough".into(), + host: HostConfig::Local, + workspace: Workspace::Directory("/workspace".into()), + network: NetworkPolicy::Open, + resources: ResourceLimits::default(), + ports: Vec::new(), + env: BTreeMap::new(), + }, + shell: ShellExecRequest { + process: ResourceId("placeholder-process".into()), + resource: ResourceId("placeholder-resource".into()), + shell: SelectShellRequest { + command: "echo done".into(), + preserve_pipeline_status: false, + runtime_path: None, + python_utf8: false, + }, + cwd: None, + extra_env: BTreeMap::new(), + state_dir: "/state".into(), + timeout_ms: 1_000, + stdin: None, + jail: None, + hide_window: false, + }, + } +} From 4e97bebf4822ca18105c798117535bd19309cdb4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:30:51 +0000 Subject: [PATCH 008/236] test(tinybox-bus): run native shell test on futures-executor Replace the tokio test attribute with a plain test that drives the async body through futures_executor::block_on, adding futures-executor as a dev-dependency. This removes the tokio runtime requirement from the native shell test. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- Cargo.lock | 12 ++++++++++++ crates/tinybox-bus/Cargo.toml | 1 + crates/tinybox-bus/src/native_shell_tests.rs | 8 ++++++-- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 452b8141..0b54e683 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -493,6 +493,17 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -1322,6 +1333,7 @@ dependencies = [ name = "tinybox-bus" version = "0.2.3" dependencies = [ + "futures-executor", "futures-timer", "futures-util", "schemars", diff --git a/crates/tinybox-bus/Cargo.toml b/crates/tinybox-bus/Cargo.toml index af472d8f..6bb7b1b9 100644 --- a/crates/tinybox-bus/Cargo.toml +++ b/crates/tinybox-bus/Cargo.toml @@ -28,6 +28,7 @@ storage = ["dep:serde_json", "dep:tinystoragedrivers-types"] [dev-dependencies] serde_json.workspace = true +futures-executor = "0.3" [lints] workspace = true diff --git a/crates/tinybox-bus/src/native_shell_tests.rs b/crates/tinybox-bus/src/native_shell_tests.rs index cf00de86..6be157c3 100644 --- a/crates/tinybox-bus/src/native_shell_tests.rs +++ b/crates/tinybox-bus/src/native_shell_tests.rs @@ -68,8 +68,12 @@ impl NativeShellClient for MockClient { } } -#[tokio::test] -async fn runs_and_cleans_up_a_shell_while_streaming_chunks() -> Result<(), &'static str> { +#[test] +fn runs_and_cleans_up_a_shell_while_streaming_chunks() -> Result<(), &'static str> { + futures_executor::block_on(runs_and_cleans_up_a_shell()) +} + +async fn runs_and_cleans_up_a_shell() -> Result<(), &'static str> { let client = MockClient { calls: Mutex::new(Vec::new()), }; From 357718f722a5d3566b4f4fd850f177d1124f33a5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:31:29 +0000 Subject: [PATCH 009/236] fix(tinybox-bus): reject output batches that do not advance the cursor Output batches containing chunks but reporting a next sequence at or before the current cursor are now treated as an error, with the process cancelled and its output released before returning. Cleanup failures are also preserved instead of being dropped when another operation is already failing, so the original error is no longer lost. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell.rs | 39 ++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-bus/src/native_shell.rs b/crates/tinybox-bus/src/native_shell.rs index c7b7e8c6..7ba58f88 100644 --- a/crates/tinybox-bus/src/native_shell.rs +++ b/crates/tinybox-bus/src/native_shell.rs @@ -88,12 +88,23 @@ pub enum NativeShellError { }, /// The module reported a terminal failed state. Execution(crate::ExecutionFailure), + /// An output batch did not advance its cursor despite containing chunks. + InvalidOutputCursor, /// Cancellation failed, so native cleanup is not acknowledged. Cancellation(E), /// Output release failed after the process had stopped. OutputRelease(E), /// Resource close failed after process cleanup. ResourceClose(E), + /// Cleanup failed while another operation was already failing. + Cleanup { + /// Cleanup operation that failed. + operation: &'static str, + /// Cleanup adapter error. + source: E, + /// Earlier failure that led to cleanup. + during: Box, + }, } /// Run one native shell and stream every output chunk to `on_chunk`. @@ -114,7 +125,9 @@ pub async fn run_native_shell( where C: NativeShellClient, F: FnMut(&OutputChunk), - Fut: Future, + Fut: Future + Send, + F: Send, + C::Error: Send, { let mut create = request.create; let timeout_ms = request.shell.timeout_ms.max(1); @@ -171,6 +184,24 @@ where } } }; + if !batch.chunks.is_empty() && batch.next_sequence <= cursor { + let primary = NativeShellError::InvalidOutputCursor; + if let Err(source) = client.cancel(process.clone()).await { + return Err(NativeShellError::Cleanup { + operation: "cancel after invalid output cursor", + source, + during: Box::new(primary), + }); + } + if let Err(source) = client.release_output(process.clone()).await { + return Err(NativeShellError::Cleanup { + operation: "release output after invalid cursor", + source, + during: Box::new(primary), + }); + } + return Err(primary); + } for chunk in &batch.chunks { on_chunk(chunk); match chunk.stream { @@ -201,7 +232,11 @@ where (Ok(output), Ok(())) => Ok(output), (Err(error), Ok(())) => Err(error), (Ok(_), Err(source)) => Err(NativeShellError::ResourceClose(source)), - (Err(error), Err(_)) => Err(error), + (Err(error), Err(source)) => Err(NativeShellError::Cleanup { + operation: "close resource", + source, + during: Box::new(error), + }), } } From 4617d12635ac4272f082672f46f90eb09b43d270 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:32:08 +0000 Subject: [PATCH 010/236] refactor(tinybox-bus): satisfy clippy lints in native shell Reworked the select arms and the running-state branch to use unit patterns and an empty block, and added an allow attribute for the long run_native_shell function so the process lifecycle stays in one auditable scope. The mock client methods now await a ready future to keep them genuinely async, and a missing semicolon in the test closure was fixed. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell.rs | 7 ++++--- crates/tinybox-bus/src/native_shell_tests.rs | 9 ++++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/tinybox-bus/src/native_shell.rs b/crates/tinybox-bus/src/native_shell.rs index 7ba58f88..c06ead0e 100644 --- a/crates/tinybox-bus/src/native_shell.rs +++ b/crates/tinybox-bus/src/native_shell.rs @@ -116,6 +116,7 @@ pub enum NativeShellError { /// /// # Errors /// Returns the transport failure, module execution failure, or cleanup failure. +#[allow(clippy::too_many_lines)] // This function keeps the owned process lifecycle in one auditable scope. pub async fn run_native_shell( client: &C, request: NativeShellRunRequest, @@ -171,12 +172,12 @@ where return Err(NativeShellError::Transport { operation: "read output", source }); } }, - _ = cancelled => { + () = cancelled => { client.cancel(process.clone()).await.map_err(NativeShellError::Cancellation)?; cancellation_requested = true; continue; }, - _ = delay => { + () = delay => { client.cancel(process.clone()).await.map_err(NativeShellError::Cancellation)?; cancellation_requested = true; timed_out = true; @@ -211,7 +212,7 @@ where } cursor = batch.next_sequence; match batch.state { - ExecutionState::Running => continue, + ExecutionState::Running => {} ExecutionState::Exited { exit_code } => { client.release_output(process.clone()).await.map_err(NativeShellError::OutputRelease)?; return Ok(NativeShellOutput { exit_code, stdout, stderr, timed_out }); diff --git a/crates/tinybox-bus/src/native_shell_tests.rs b/crates/tinybox-bus/src/native_shell_tests.rs index 6be157c3..7d1de023 100644 --- a/crates/tinybox-bus/src/native_shell_tests.rs +++ b/crates/tinybox-bus/src/native_shell_tests.rs @@ -13,6 +13,7 @@ impl NativeShellClient for MockClient { type Error = &'static str; async fn reserve(&self, request: ReserveRequest) -> Result { + std::future::ready(()).await; self.calls.lock().map_err(|_| "poisoned")?.push("reserve"); Ok(match request { ReserveRequest::Resource => ResourceId("resource-1".into()), @@ -22,6 +23,7 @@ impl NativeShellClient for MockClient { } async fn create(&self, request: CreateRequest) -> Result<(), Self::Error> { + std::future::ready(()).await; self.calls.lock().map_err(|_| "poisoned")?.push("create"); assert_eq!(request.resource, ResourceId("resource-1".into())); Ok(()) @@ -31,6 +33,7 @@ impl NativeShellClient for MockClient { &self, request: ShellExecRequest, ) -> Result { + std::future::ready(()).await; self.calls.lock().map_err(|_| "poisoned")?.push("start"); assert_eq!(request.process, ResourceId("process-1".into())); Ok(ProcessRef { @@ -40,6 +43,7 @@ impl NativeShellClient for MockClient { } async fn read_output(&self, _: ProcessRef, _: u64) -> Result { + std::future::ready(()).await; self.calls.lock().map_err(|_| "poisoned")?.push("read"); Ok(OutputBatch { chunks: vec![OutputChunk { @@ -53,16 +57,19 @@ impl NativeShellClient for MockClient { } async fn cancel(&self, _: ProcessRef) -> Result<(), Self::Error> { + std::future::ready(()).await; self.calls.lock().map_err(|_| "poisoned")?.push("cancel"); Ok(()) } async fn release_output(&self, _: ProcessRef) -> Result<(), Self::Error> { + std::future::ready(()).await; self.calls.lock().map_err(|_| "poisoned")?.push("release"); Ok(()) } async fn close(&self, _: ResourceId) -> Result<(), Self::Error> { + std::future::ready(()).await; self.calls.lock().map_err(|_| "poisoned")?.push("close"); Ok(()) } @@ -79,7 +86,7 @@ async fn runs_and_cleans_up_a_shell() -> Result<(), &'static str> { }; let mut chunks = Vec::new(); let output = run_native_shell(&client, request(), std::future::pending(), |chunk| { - chunks.push(chunk.clone()) + chunks.push(chunk.clone()); }) .await .map_err(|_| "run failed")?; From ac8496c9bc191b8a63ad89ee9a90f46036dac887 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:32:34 +0000 Subject: [PATCH 011/236] test(native-shell): cover cancellation and create failure cleanup Extend the mock client with failure and cancellation switches so the native shell tests can exercise two cleanup paths. One test asserts that cancellation reaps the process before releasing and closing, and the other asserts that a failed create still releases the resource reservation. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell_tests.rs | 60 +++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-bus/src/native_shell_tests.rs b/crates/tinybox-bus/src/native_shell_tests.rs index 7d1de023..713ca4dc 100644 --- a/crates/tinybox-bus/src/native_shell_tests.rs +++ b/crates/tinybox-bus/src/native_shell_tests.rs @@ -7,6 +7,8 @@ use std::{collections::BTreeMap, sync::Mutex}; struct MockClient { calls: Mutex>, + fail_create: bool, + cancel_changes_state: bool, } impl NativeShellClient for MockClient { @@ -26,6 +28,9 @@ impl NativeShellClient for MockClient { std::future::ready(()).await; self.calls.lock().map_err(|_| "poisoned")?.push("create"); assert_eq!(request.resource, ResourceId("resource-1".into())); + if self.fail_create { + return Err("create failed"); + } Ok(()) } @@ -45,6 +50,13 @@ impl NativeShellClient for MockClient { async fn read_output(&self, _: ProcessRef, _: u64) -> Result { std::future::ready(()).await; self.calls.lock().map_err(|_| "poisoned")?.push("read"); + let state = if self.cancel_changes_state + && self.calls.lock().map_err(|_| "poisoned")?.contains(&"cancel") + { + ExecutionState::Cancelled + } else { + ExecutionState::Exited { exit_code: 7 } + }; Ok(OutputBatch { chunks: vec![OutputChunk { sequence: 0, @@ -52,7 +64,7 @@ impl NativeShellClient for MockClient { bytes: b"done".to_vec(), }], next_sequence: 1, - state: ExecutionState::Exited { exit_code: 7 }, + state, }) } @@ -83,6 +95,8 @@ fn runs_and_cleans_up_a_shell_while_streaming_chunks() -> Result<(), &'static st async fn runs_and_cleans_up_a_shell() -> Result<(), &'static str> { let client = MockClient { calls: Mutex::new(Vec::new()), + fail_create: false, + cancel_changes_state: false, }; let mut chunks = Vec::new(); let output = run_native_shell(&client, request(), std::future::pending(), |chunk| { @@ -103,6 +117,50 @@ async fn runs_and_cleans_up_a_shell() -> Result<(), &'static str> { Ok(()) } +#[test] +fn cancellation_reaps_the_process_before_releasing_and_closing() -> Result<(), &'static str> { + futures_executor::block_on(async { + let client = MockClient { + calls: Mutex::new(Vec::new()), + fail_create: false, + cancel_changes_state: true, + }; + let output = run_native_shell(&client, request(), std::future::ready(()), |_| {}) + .await + .map_err(|_| "run failed")?; + assert_eq!(output.exit_code, -1); + assert!(!output.timed_out); + assert_eq!( + *client.calls.lock().map_err(|_| "poisoned")?, + [ + "reserve", "create", "reserve", "start", "cancel", "read", "release", "close" + ] + ); + Ok(()) + }) +} + +#[test] +fn create_failure_still_releases_the_resource_reservation() -> Result<(), &'static str> { + futures_executor::block_on(async { + let client = MockClient { + calls: Mutex::new(Vec::new()), + fail_create: true, + cancel_changes_state: false, + }; + let error = run_native_shell(&client, request(), std::future::pending(), |_| {}) + .await + .err() + .ok_or("expected failure")?; + assert!(matches!(error, NativeShellError::Transport { operation: "create resource", .. })); + assert_eq!( + *client.calls.lock().map_err(|_| "poisoned")?, + ["reserve", "create", "close"] + ); + Ok(()) + }) +} + fn request() -> NativeShellRunRequest { NativeShellRunRequest { create: CreateRequest { From 965629e0aaf1cd582ca939d6cf14fc0c15e33708 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:32:41 +0000 Subject: [PATCH 012/236] test(native-shell): reformat assertions to satisfy line width Reformatted the mock client's cancel check and the transport error assertion so they fit within the configured line width. No test behaviour changed. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell_tests.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-bus/src/native_shell_tests.rs b/crates/tinybox-bus/src/native_shell_tests.rs index 713ca4dc..5da90c96 100644 --- a/crates/tinybox-bus/src/native_shell_tests.rs +++ b/crates/tinybox-bus/src/native_shell_tests.rs @@ -51,7 +51,11 @@ impl NativeShellClient for MockClient { std::future::ready(()).await; self.calls.lock().map_err(|_| "poisoned")?.push("read"); let state = if self.cancel_changes_state - && self.calls.lock().map_err(|_| "poisoned")?.contains(&"cancel") + && self + .calls + .lock() + .map_err(|_| "poisoned")? + .contains(&"cancel") { ExecutionState::Cancelled } else { @@ -152,7 +156,13 @@ fn create_failure_still_releases_the_resource_reservation() -> Result<(), &'stat .await .err() .ok_or("expected failure")?; - assert!(matches!(error, NativeShellError::Transport { operation: "create resource", .. })); + assert!(matches!( + error, + NativeShellError::Transport { + operation: "create resource", + .. + } + )); assert_eq!( *client.calls.lock().map_err(|_| "poisoned")?, ["reserve", "create", "close"] From 880e4ac3439cdb6b8177d55c8e8e7936d05a39ec Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:33:04 +0000 Subject: [PATCH 013/236] fix(native-shell): report timed out executions as timeouts Handle the ExecutionState::Failed variant carrying ExecutionFailure::TimedOut separately so a timed out command releases its output and returns a result with timed_out set, instead of falling through to the generic failure arm and surfacing as an execution error. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/tinybox-bus/src/native_shell.rs b/crates/tinybox-bus/src/native_shell.rs index c06ead0e..3237f846 100644 --- a/crates/tinybox-bus/src/native_shell.rs +++ b/crates/tinybox-bus/src/native_shell.rs @@ -221,6 +221,20 @@ where client.release_output(process.clone()).await.map_err(NativeShellError::OutputRelease)?; return Ok(NativeShellOutput { exit_code: -1, stdout, stderr, timed_out }); } + ExecutionState::Failed { + code: crate::ExecutionFailure::TimedOut, + } => { + client + .release_output(process.clone()) + .await + .map_err(NativeShellError::OutputRelease)?; + return Ok(NativeShellOutput { + exit_code: -1, + stdout, + stderr, + timed_out: true, + }); + } ExecutionState::Failed { code } => { client.release_output(process.clone()).await.map_err(NativeShellError::OutputRelease)?; return Err(NativeShellError::Execution(code)); From 9437a53f1fd569e3e3ceb3c0b117c9b3e605a345 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:33:39 +0000 Subject: [PATCH 014/236] feat(native-shell): support yielding a running shell process Add a yield_after option to the native shell request and return a NativeShellRunOutcome that either reports a finished process or hands back a running one with its resource, process reference, cursor, and output collected so far. This lets callers take over the cleanup lifecycle of long-running shells instead of always waiting for exit. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell.rs | 28 +++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-bus/src/native_shell.rs b/crates/tinybox-bus/src/native_shell.rs index 3237f846..2d87bb0d 100644 --- a/crates/tinybox-bus/src/native_shell.rs +++ b/crates/tinybox-bus/src/native_shell.rs @@ -61,6 +61,8 @@ pub struct NativeShellRunRequest { pub create: CreateRequest, /// Shell settings; its resource and process identifiers are ignored. pub shell: ShellExecRequest, + /// Return a running process after this duration, preserving cursor and output. + pub yield_after: Option, } /// Collected native shell output after the module acknowledges process cleanup. @@ -76,6 +78,30 @@ pub struct NativeShellOutput { pub timed_out: bool, } +/// Outcome of running a shell through TinyBox. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NativeShellRunOutcome { + /// The process exited or timed out and its output was released. + Finished(NativeShellOutput), + /// The process is still running; the caller now owns its cleanup lifecycle. + Running(NativeShellRunning), +} + +/// A running process handed back at the configured yield point. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeShellRunning { + /// Resource that must be closed after the process is finished. + pub resource: ResourceId, + /// Owned process reference for polling, cancellation, or release. + pub process: ProcessRef, + /// Cursor for the next output read. + pub cursor: u64, + /// Stdout collected before yielding. + pub stdout: Vec, + /// Stderr collected before yielding. + pub stderr: Vec, +} + /// Failure during one phase of native shell orchestration. #[derive(Debug)] pub enum NativeShellError { @@ -122,7 +148,7 @@ pub async fn run_native_shell( request: NativeShellRunRequest, cancelled: Fut, mut on_chunk: F, -) -> Result> +) -> Result> where C: NativeShellClient, F: FnMut(&OutputChunk), From 9799e8c3408083884cd8aacf89ffd66279f88e5a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:34:02 +0000 Subject: [PATCH 015/236] feat(native-shell): support yielding long-running shell executions The native shell runner now returns a Running outcome carrying the process, cursor, and captured output when a configured yield_after duration elapses, instead of blocking until the command exits. This lets callers poll or resume long-running commands without holding the request open indefinitely. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell.rs | 28 +++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/tinybox-bus/src/native_shell.rs b/crates/tinybox-bus/src/native_shell.rs index 2d87bb0d..d5e395ab 100644 --- a/crates/tinybox-bus/src/native_shell.rs +++ b/crates/tinybox-bus/src/native_shell.rs @@ -158,6 +158,7 @@ where { let mut create = request.create; let timeout_ms = request.shell.timeout_ms.max(1); + let yield_after = request.yield_after; let mut shell = request.shell; let resource = client .reserve(ReserveRequest::Resource) @@ -177,6 +178,7 @@ where let mut cursor = 0; let mut stdout = Vec::new(); let mut stderr = Vec::new(); + let started = Instant::now(); let cancelled = cancelled.fuse(); pin_mut!(cancelled); let mut cancellation_requested = false; @@ -238,14 +240,24 @@ where } cursor = batch.next_sequence; match batch.state { - ExecutionState::Running => {} + ExecutionState::Running => { + if yield_after.is_some_and(|after| started.elapsed() >= after) { + return Ok(NativeShellRunOutcome::Running(NativeShellRunning { + resource: resource.clone(), + process, + cursor, + stdout, + stderr, + })); + } + } ExecutionState::Exited { exit_code } => { client.release_output(process.clone()).await.map_err(NativeShellError::OutputRelease)?; - return Ok(NativeShellOutput { exit_code, stdout, stderr, timed_out }); + return Ok(NativeShellRunOutcome::Finished(NativeShellOutput { exit_code, stdout, stderr, timed_out })); } ExecutionState::Cancelled => { client.release_output(process.clone()).await.map_err(NativeShellError::OutputRelease)?; - return Ok(NativeShellOutput { exit_code: -1, stdout, stderr, timed_out }); + return Ok(NativeShellRunOutcome::Finished(NativeShellOutput { exit_code: -1, stdout, stderr, timed_out })); } ExecutionState::Failed { code: crate::ExecutionFailure::TimedOut, @@ -254,12 +266,12 @@ where .release_output(process.clone()) .await .map_err(NativeShellError::OutputRelease)?; - return Ok(NativeShellOutput { + return Ok(NativeShellRunOutcome::Finished(NativeShellOutput { exit_code: -1, stdout, stderr, timed_out: true, - }); + })); } ExecutionState::Failed { code } => { client.release_output(process.clone()).await.map_err(NativeShellError::OutputRelease)?; @@ -268,6 +280,12 @@ where } } }.await; + let result = match result { + Ok(NativeShellRunOutcome::Running(running)) => { + return Ok(NativeShellRunOutcome::Running(running)); + } + result => result, + }; let close_result = client.close(resource).await; match (result, close_result) { (Ok(output), Ok(())) => Ok(output), From 15915457cfd8c7922d5aba5c3c38d9f23906832f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:34:22 +0000 Subject: [PATCH 016/236] test(tinybox-bus): cover native shell timeout and yield outcomes Add tests for the module timeout path returning collected output as a finished result and for yielding preserving the process resource, cursor and partial output. The mock client gains timed-out and stays-running states, and existing tests now unwrap the new run outcome type. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/lib.rs | 3 +- crates/tinybox-bus/src/native_shell_tests.rs | 74 ++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-bus/src/lib.rs b/crates/tinybox-bus/src/lib.rs index 1b819b1b..40c9c6cd 100644 --- a/crates/tinybox-bus/src/lib.rs +++ b/crates/tinybox-bus/src/lib.rs @@ -20,7 +20,8 @@ pub use grants::{LocalGrantConfig, LocalGrants, ProtectedPaths, ResolveLocalGran mod native_shell; pub use native_shell::{ - NativeShellClient, NativeShellError, NativeShellOutput, NativeShellRunRequest, run_native_shell, + NativeShellClient, NativeShellError, NativeShellOutput, NativeShellRunOutcome, + NativeShellRunRequest, NativeShellRunning, run_native_shell, }; mod jail; diff --git a/crates/tinybox-bus/src/native_shell_tests.rs b/crates/tinybox-bus/src/native_shell_tests.rs index 5da90c96..2ece3765 100644 --- a/crates/tinybox-bus/src/native_shell_tests.rs +++ b/crates/tinybox-bus/src/native_shell_tests.rs @@ -9,6 +9,8 @@ struct MockClient { calls: Mutex>, fail_create: bool, cancel_changes_state: bool, + timed_out_state: bool, + stays_running: bool, } impl NativeShellClient for MockClient { @@ -58,6 +60,12 @@ impl NativeShellClient for MockClient { .contains(&"cancel") { ExecutionState::Cancelled + } else if self.timed_out_state { + ExecutionState::Failed { + code: ExecutionFailure::TimedOut, + } + } else if self.stays_running { + ExecutionState::Running } else { ExecutionState::Exited { exit_code: 7 } }; @@ -101,6 +109,8 @@ async fn runs_and_cleans_up_a_shell() -> Result<(), &'static str> { calls: Mutex::new(Vec::new()), fail_create: false, cancel_changes_state: false, + timed_out_state: false, + stays_running: false, }; let mut chunks = Vec::new(); let output = run_native_shell(&client, request(), std::future::pending(), |chunk| { @@ -108,6 +118,9 @@ async fn runs_and_cleans_up_a_shell() -> Result<(), &'static str> { }) .await .map_err(|_| "run failed")?; + let NativeShellRunOutcome::Finished(output) = output else { + return Err("expected a finished outcome"); + }; assert_eq!(output.exit_code, 7); assert_eq!(output.stdout, b"done"); assert!(!output.timed_out); @@ -128,10 +141,15 @@ fn cancellation_reaps_the_process_before_releasing_and_closing() -> Result<(), & calls: Mutex::new(Vec::new()), fail_create: false, cancel_changes_state: true, + timed_out_state: false, + stays_running: false, }; let output = run_native_shell(&client, request(), std::future::ready(()), |_| {}) .await .map_err(|_| "run failed")?; + let NativeShellRunOutcome::Finished(output) = output else { + return Err("expected a finished outcome"); + }; assert_eq!(output.exit_code, -1); assert!(!output.timed_out); assert_eq!( @@ -151,6 +169,8 @@ fn create_failure_still_releases_the_resource_reservation() -> Result<(), &'stat calls: Mutex::new(Vec::new()), fail_create: true, cancel_changes_state: false, + timed_out_state: false, + stays_running: false, }; let error = run_native_shell(&client, request(), std::future::pending(), |_| {}) .await @@ -171,6 +191,59 @@ fn create_failure_still_releases_the_resource_reservation() -> Result<(), &'stat }) } +#[test] +fn module_timeout_returns_collected_output_as_a_result() -> Result<(), &'static str> { + futures_executor::block_on(async { + let client = MockClient { + calls: Mutex::new(Vec::new()), + fail_create: false, + cancel_changes_state: false, + timed_out_state: true, + stays_running: false, + }; + let outcome = run_native_shell(&client, request(), std::future::pending(), |_| {}) + .await + .map_err(|_| "run failed")?; + let NativeShellRunOutcome::Finished(output) = outcome else { + return Err("expected a finished outcome"); + }; + assert!(output.timed_out); + assert_eq!(output.stdout, b"done"); + assert_eq!(output.exit_code, -1); + Ok(()) + }) +} + +#[test] +fn yield_preserves_process_resource_cursor_and_partial_output() -> Result<(), &'static str> { + futures_executor::block_on(async { + let client = MockClient { + calls: Mutex::new(Vec::new()), + fail_create: false, + cancel_changes_state: false, + timed_out_state: false, + stays_running: true, + }; + let mut request = request(); + request.yield_after = Some(std::time::Duration::ZERO); + let outcome = run_native_shell(&client, request, std::future::pending(), |_| {}) + .await + .map_err(|_| "run failed")?; + let NativeShellRunOutcome::Running(running) = outcome else { + return Err("expected a running outcome"); + }; + assert_eq!(running.resource, ResourceId("resource-1".into())); + assert_eq!(running.process.process, ResourceId("process-1".into())); + assert_eq!(running.cursor, 1); + assert_eq!(running.stdout, b"done"); + assert_eq!( + *client.calls.lock().map_err(|_| "poisoned")?, + ["reserve", "create", "reserve", "start", "read"] + ); + Ok(()) + }) +} + fn request() -> NativeShellRunRequest { NativeShellRunRequest { create: CreateRequest { @@ -200,5 +273,6 @@ fn request() -> NativeShellRunRequest { jail: None, hide_window: false, }, + yield_after: None, } } From ca5d6b3926a78ef21bc9a40cdb1c37930dd1fefe Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:34:37 +0000 Subject: [PATCH 017/236] test(tinybox-bus): import ExecutionFailure in native shell tests Add the ExecutionFailure import to the native shell test module so the tests can reference the failure type. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-bus/src/native_shell_tests.rs b/crates/tinybox-bus/src/native_shell_tests.rs index 2ece3765..53295031 100644 --- a/crates/tinybox-bus/src/native_shell_tests.rs +++ b/crates/tinybox-bus/src/native_shell_tests.rs @@ -1,7 +1,8 @@ //! Tests for runtime-neutral native shell orchestration. use super::*; use crate::{ - HostConfig, NetworkPolicy, OutputStream, ResourceLimits, SelectShellRequest, Workspace, + ExecutionFailure, HostConfig, NetworkPolicy, OutputStream, ResourceLimits, SelectShellRequest, + Workspace, }; use std::{collections::BTreeMap, sync::Mutex}; From 1d6a660a5614202f90af1c9661235e917b47e193 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:35:06 +0000 Subject: [PATCH 018/236] docs(tinybox-bus): format TinyBox as code in doc comment Wrap the TinyBox product name in backticks within the NativeShellRunOutcome doc comment so rustdoc renders it as code. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-bus/src/native_shell.rs b/crates/tinybox-bus/src/native_shell.rs index d5e395ab..f753328f 100644 --- a/crates/tinybox-bus/src/native_shell.rs +++ b/crates/tinybox-bus/src/native_shell.rs @@ -78,7 +78,7 @@ pub struct NativeShellOutput { pub timed_out: bool, } -/// Outcome of running a shell through TinyBox. +/// Outcome of running a shell through `TinyBox`. #[derive(Debug, Clone, PartialEq, Eq)] pub enum NativeShellRunOutcome { /// The process exited or timed out and its output was released. From 9640ab3575bae66e1d32b216dd8262ca4a51cbbe Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:35:20 +0000 Subject: [PATCH 019/236] refactor(tinybox-bus): replace mock client flags with behavior enum The mock client's four independent boolean flags are collapsed into a single MockBehavior enum, so each test now selects one named scenario instead of setting every flag explicitly. This makes the intended state of each test obvious and prevents contradictory combinations. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell_tests.rs | 47 ++++++++------------ 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/crates/tinybox-bus/src/native_shell_tests.rs b/crates/tinybox-bus/src/native_shell_tests.rs index 53295031..b9e20929 100644 --- a/crates/tinybox-bus/src/native_shell_tests.rs +++ b/crates/tinybox-bus/src/native_shell_tests.rs @@ -8,10 +8,16 @@ use std::{collections::BTreeMap, sync::Mutex}; struct MockClient { calls: Mutex>, - fail_create: bool, - cancel_changes_state: bool, - timed_out_state: bool, - stays_running: bool, + behavior: MockBehavior, +} + +#[derive(Clone, Copy)] +enum MockBehavior { + Normal, + FailCreate, + Cancelled, + ModuleTimeout, + Running, } impl NativeShellClient for MockClient { @@ -31,7 +37,7 @@ impl NativeShellClient for MockClient { std::future::ready(()).await; self.calls.lock().map_err(|_| "poisoned")?.push("create"); assert_eq!(request.resource, ResourceId("resource-1".into())); - if self.fail_create { + if matches!(self.behavior, MockBehavior::FailCreate) { return Err("create failed"); } Ok(()) @@ -53,7 +59,7 @@ impl NativeShellClient for MockClient { async fn read_output(&self, _: ProcessRef, _: u64) -> Result { std::future::ready(()).await; self.calls.lock().map_err(|_| "poisoned")?.push("read"); - let state = if self.cancel_changes_state + let state = if matches!(self.behavior, MockBehavior::Cancelled) && self .calls .lock() @@ -61,11 +67,11 @@ impl NativeShellClient for MockClient { .contains(&"cancel") { ExecutionState::Cancelled - } else if self.timed_out_state { + } else if matches!(self.behavior, MockBehavior::ModuleTimeout) { ExecutionState::Failed { code: ExecutionFailure::TimedOut, } - } else if self.stays_running { + } else if matches!(self.behavior, MockBehavior::Running) { ExecutionState::Running } else { ExecutionState::Exited { exit_code: 7 } @@ -108,10 +114,7 @@ fn runs_and_cleans_up_a_shell_while_streaming_chunks() -> Result<(), &'static st async fn runs_and_cleans_up_a_shell() -> Result<(), &'static str> { let client = MockClient { calls: Mutex::new(Vec::new()), - fail_create: false, - cancel_changes_state: false, - timed_out_state: false, - stays_running: false, + behavior: MockBehavior::Normal, }; let mut chunks = Vec::new(); let output = run_native_shell(&client, request(), std::future::pending(), |chunk| { @@ -140,10 +143,7 @@ fn cancellation_reaps_the_process_before_releasing_and_closing() -> Result<(), & futures_executor::block_on(async { let client = MockClient { calls: Mutex::new(Vec::new()), - fail_create: false, - cancel_changes_state: true, - timed_out_state: false, - stays_running: false, + behavior: MockBehavior::Cancelled, }; let output = run_native_shell(&client, request(), std::future::ready(()), |_| {}) .await @@ -168,10 +168,7 @@ fn create_failure_still_releases_the_resource_reservation() -> Result<(), &'stat futures_executor::block_on(async { let client = MockClient { calls: Mutex::new(Vec::new()), - fail_create: true, - cancel_changes_state: false, - timed_out_state: false, - stays_running: false, + behavior: MockBehavior::FailCreate, }; let error = run_native_shell(&client, request(), std::future::pending(), |_| {}) .await @@ -197,10 +194,7 @@ fn module_timeout_returns_collected_output_as_a_result() -> Result<(), &'static futures_executor::block_on(async { let client = MockClient { calls: Mutex::new(Vec::new()), - fail_create: false, - cancel_changes_state: false, - timed_out_state: true, - stays_running: false, + behavior: MockBehavior::ModuleTimeout, }; let outcome = run_native_shell(&client, request(), std::future::pending(), |_| {}) .await @@ -220,10 +214,7 @@ fn yield_preserves_process_resource_cursor_and_partial_output() -> Result<(), &' futures_executor::block_on(async { let client = MockClient { calls: Mutex::new(Vec::new()), - fail_create: false, - cancel_changes_state: false, - timed_out_state: false, - stays_running: true, + behavior: MockBehavior::Running, }; let mut request = request(); request.yield_after = Some(std::time::Duration::ZERO); From e95c5a12cfc1e8cbaad3587e32a1fe3ef74e1023 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:38:15 +0000 Subject: [PATCH 020/236] fix(native_shell): ignore yield_after when it exceeds the timeout The yield deadline is now dropped when it is not shorter than the shell timeout, and the elapsed clock starts before the process is created so early setup time counts toward the yield. Output batches that arrive alongside a terminal state are skipped before the yield check, and cancellation takes precedence over yielding so a cancelled run is never reported as still running. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell.rs | 17 ++++++++--- crates/tinybox-bus/src/native_shell_tests.rs | 30 ++++++++++++++------ 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/crates/tinybox-bus/src/native_shell.rs b/crates/tinybox-bus/src/native_shell.rs index f753328f..170c8618 100644 --- a/crates/tinybox-bus/src/native_shell.rs +++ b/crates/tinybox-bus/src/native_shell.rs @@ -61,7 +61,7 @@ pub struct NativeShellRunRequest { pub create: CreateRequest, /// Shell settings; its resource and process identifiers are ignored. pub shell: ShellExecRequest, - /// Return a running process after this duration, preserving cursor and output. + /// Return a running process after this duration if it is shorter than the timeout. pub yield_after: Option, } @@ -158,7 +158,10 @@ where { let mut create = request.create; let timeout_ms = request.shell.timeout_ms.max(1); - let yield_after = request.yield_after; + let yield_after = request + .yield_after + .filter(|after| *after < Duration::from_millis(timeout_ms)); + let yield_started = Instant::now(); let mut shell = request.shell; let resource = client .reserve(ReserveRequest::Resource) @@ -178,7 +181,6 @@ where let mut cursor = 0; let mut stdout = Vec::new(); let mut stderr = Vec::new(); - let started = Instant::now(); let cancelled = cancelled.fuse(); pin_mut!(cancelled); let mut cancellation_requested = false; @@ -239,9 +241,16 @@ where } } cursor = batch.next_sequence; + if !batch.chunks.is_empty() + && !matches!(&batch.state, ExecutionState::Running) + { + continue; + } match batch.state { ExecutionState::Running => { - if yield_after.is_some_and(|after| started.elapsed() >= after) { + if !cancellation_requested + && yield_after.is_some_and(|after| yield_started.elapsed() >= after) + { return Ok(NativeShellRunOutcome::Running(NativeShellRunning { resource: resource.clone(), process, diff --git a/crates/tinybox-bus/src/native_shell_tests.rs b/crates/tinybox-bus/src/native_shell_tests.rs index b9e20929..983e5d2a 100644 --- a/crates/tinybox-bus/src/native_shell_tests.rs +++ b/crates/tinybox-bus/src/native_shell_tests.rs @@ -8,6 +8,7 @@ use std::{collections::BTreeMap, sync::Mutex}; struct MockClient { calls: Mutex>, + reads: Mutex, behavior: MockBehavior, } @@ -56,9 +57,10 @@ impl NativeShellClient for MockClient { }) } - async fn read_output(&self, _: ProcessRef, _: u64) -> Result { + async fn read_output(&self, _: ProcessRef, cursor: u64) -> Result { std::future::ready(()).await; self.calls.lock().map_err(|_| "poisoned")?.push("read"); + *self.reads.lock().map_err(|_| "poisoned")? += 1; let state = if matches!(self.behavior, MockBehavior::Cancelled) && self .calls @@ -77,12 +79,16 @@ impl NativeShellClient for MockClient { ExecutionState::Exited { exit_code: 7 } }; Ok(OutputBatch { - chunks: vec![OutputChunk { - sequence: 0, - stream: OutputStream::Stdout, - bytes: b"done".to_vec(), - }], - next_sequence: 1, + chunks: if cursor == 0 { + vec![OutputChunk { + sequence: 0, + stream: OutputStream::Stdout, + bytes: b"done".to_vec(), + }] + } else { + Vec::new() + }, + next_sequence: if cursor == 0 { 1 } else { cursor }, state, }) } @@ -114,6 +120,7 @@ fn runs_and_cleans_up_a_shell_while_streaming_chunks() -> Result<(), &'static st async fn runs_and_cleans_up_a_shell() -> Result<(), &'static str> { let client = MockClient { calls: Mutex::new(Vec::new()), + reads: Mutex::new(0), behavior: MockBehavior::Normal, }; let mut chunks = Vec::new(); @@ -132,7 +139,7 @@ async fn runs_and_cleans_up_a_shell() -> Result<(), &'static str> { assert_eq!( *client.calls.lock().map_err(|_| "poisoned")?, [ - "reserve", "create", "reserve", "start", "read", "release", "close" + "reserve", "create", "reserve", "start", "read", "read", "release", "close" ] ); Ok(()) @@ -143,6 +150,7 @@ fn cancellation_reaps_the_process_before_releasing_and_closing() -> Result<(), & futures_executor::block_on(async { let client = MockClient { calls: Mutex::new(Vec::new()), + reads: Mutex::new(0), behavior: MockBehavior::Cancelled, }; let output = run_native_shell(&client, request(), std::future::ready(()), |_| {}) @@ -156,7 +164,8 @@ fn cancellation_reaps_the_process_before_releasing_and_closing() -> Result<(), & assert_eq!( *client.calls.lock().map_err(|_| "poisoned")?, [ - "reserve", "create", "reserve", "start", "cancel", "read", "release", "close" + "reserve", "create", "reserve", "start", "cancel", "read", "read", "release", + "close" ] ); Ok(()) @@ -168,6 +177,7 @@ fn create_failure_still_releases_the_resource_reservation() -> Result<(), &'stat futures_executor::block_on(async { let client = MockClient { calls: Mutex::new(Vec::new()), + reads: Mutex::new(0), behavior: MockBehavior::FailCreate, }; let error = run_native_shell(&client, request(), std::future::pending(), |_| {}) @@ -194,6 +204,7 @@ fn module_timeout_returns_collected_output_as_a_result() -> Result<(), &'static futures_executor::block_on(async { let client = MockClient { calls: Mutex::new(Vec::new()), + reads: Mutex::new(0), behavior: MockBehavior::ModuleTimeout, }; let outcome = run_native_shell(&client, request(), std::future::pending(), |_| {}) @@ -214,6 +225,7 @@ fn yield_preserves_process_resource_cursor_and_partial_output() -> Result<(), &' futures_executor::block_on(async { let client = MockClient { calls: Mutex::new(Vec::new()), + reads: Mutex::new(0), behavior: MockBehavior::Running, }; let mut request = request(); From 5888f2e300c4a38b7970e84cf86ec450745bfbfb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:39:40 +0000 Subject: [PATCH 021/236] docs(tinybox-bus): expand crate summary to cover lifecycle orchestration The crate-level doc comment now notes that the crate also provides runtime-neutral native-shell lifecycle orchestration over a caller-supplied client, so the summary matches what the module actually offers. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-bus/src/lib.rs b/crates/tinybox-bus/src/lib.rs index 40c9c6cd..444e3166 100644 --- a/crates/tinybox-bus/src/lib.rs +++ b/crates/tinybox-bus/src/lib.rs @@ -1,4 +1,5 @@ -//! Serialized requests and results for the `TinyBox` module. +//! Serialized requests and results for the `TinyBox` module, plus runtime-neutral +//! native-shell lifecycle orchestration over a caller-supplied client. //! //! This crate has no bus connection, executor, native backend, or policy. //! A caller applies its own authorization before sending these requests. From 2f40d8847e455ad8d7c3e5ab45d47fa39b08a4b8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:40:24 +0000 Subject: [PATCH 022/236] chore(tinybox-bus): drop stale clippy allow on run_native_shell The function no longer trips the too_many_lines lint, so the attribute and its explanatory comment were removed to keep the code free of unnecessary suppressions. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/tinybox-bus/src/native_shell.rs b/crates/tinybox-bus/src/native_shell.rs index 170c8618..4589b143 100644 --- a/crates/tinybox-bus/src/native_shell.rs +++ b/crates/tinybox-bus/src/native_shell.rs @@ -142,7 +142,6 @@ pub enum NativeShellError { /// /// # Errors /// Returns the transport failure, module execution failure, or cleanup failure. -#[allow(clippy::too_many_lines)] // This function keeps the owned process lifecycle in one auditable scope. pub async fn run_native_shell( client: &C, request: NativeShellRunRequest, From 8bbb41b75375a875d96c211beeb85b3a87fcdd05 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:41:25 +0000 Subject: [PATCH 023/236] refactor(native-shell): extract native shell execution into a state machine The native shell run loop was restructured into a dedicated state machine that tracks the process lifecycle across polling, cancellation, and timeout transitions. This makes the control flow easier to follow and keeps the cleanup paths consistent when a run is interrupted or resumed. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell.rs | 387 +++++++++++++++++-------- 1 file changed, 267 insertions(+), 120 deletions(-) diff --git a/crates/tinybox-bus/src/native_shell.rs b/crates/tinybox-bus/src/native_shell.rs index 4589b143..5f4f8cd6 100644 --- a/crates/tinybox-bus/src/native_shell.rs +++ b/crates/tinybox-bus/src/native_shell.rs @@ -2,6 +2,7 @@ use std::{ future::Future, + pin::Pin, time::{Duration, Instant}, }; @@ -155,13 +156,11 @@ where F: Send, C::Error: Send, { - let mut create = request.create; let timeout_ms = request.shell.timeout_ms.max(1); let yield_after = request .yield_after .filter(|after| *after < Duration::from_millis(timeout_ms)); let yield_started = Instant::now(); - let mut shell = request.shell; let resource = client .reserve(ReserveRequest::Resource) .await @@ -169,125 +168,23 @@ where operation: "reserve resource", source, })?; - create.resource = resource.clone(); let result = async { - client.create(create).await.map_err(|source| NativeShellError::Transport { operation: "create resource", source })?; - let process_id = client.reserve(ReserveRequest::Process(resource.clone())).await.map_err(|source| NativeShellError::Transport { operation: "reserve process", source })?; - shell.resource = resource.clone(); - shell.process = process_id; - let process = client.start_native_shell_exec(shell).await.map_err(|source| NativeShellError::Transport { operation: "start native shell", source })?; - let deadline = Instant::now() + Duration::from_millis(timeout_ms); - let mut cursor = 0; - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - let cancelled = cancelled.fuse(); - pin_mut!(cancelled); - let mut cancellation_requested = false; - let mut timed_out = false; - loop { - let remaining = deadline.saturating_duration_since(Instant::now()); - let poll = client.read_output(process.clone(), cursor).fuse(); - let delay = futures_timer::Delay::new(if cancellation_requested { Duration::from_millis(20) } else { remaining.min(Duration::from_millis(20)) }).fuse(); - pin_mut!(poll, delay); - let batch = if cancellation_requested { - select! { result = poll => result.map_err(|source| NativeShellError::Transport { operation: "read output", source })? } - } else { - select! { - result = poll => match result { - Ok(batch) => batch, - Err(source) => { - client.cancel(process.clone()).await.map_err(NativeShellError::Cancellation)?; - client.release_output(process.clone()).await.map_err(NativeShellError::OutputRelease)?; - return Err(NativeShellError::Transport { operation: "read output", source }); - } - }, - () = cancelled => { - client.cancel(process.clone()).await.map_err(NativeShellError::Cancellation)?; - cancellation_requested = true; - continue; - }, - () = delay => { - client.cancel(process.clone()).await.map_err(NativeShellError::Cancellation)?; - cancellation_requested = true; - timed_out = true; - continue; - } - } - }; - if !batch.chunks.is_empty() && batch.next_sequence <= cursor { - let primary = NativeShellError::InvalidOutputCursor; - if let Err(source) = client.cancel(process.clone()).await { - return Err(NativeShellError::Cleanup { - operation: "cancel after invalid output cursor", - source, - during: Box::new(primary), - }); - } - if let Err(source) = client.release_output(process.clone()).await { - return Err(NativeShellError::Cleanup { - operation: "release output after invalid cursor", - source, - during: Box::new(primary), - }); - } - return Err(primary); - } - for chunk in &batch.chunks { - on_chunk(chunk); - match chunk.stream { - crate::OutputStream::Stdout => stdout.extend_from_slice(&chunk.bytes), - crate::OutputStream::Stderr => stderr.extend_from_slice(&chunk.bytes), - } - } - cursor = batch.next_sequence; - if !batch.chunks.is_empty() - && !matches!(&batch.state, ExecutionState::Running) - { - continue; - } - match batch.state { - ExecutionState::Running => { - if !cancellation_requested - && yield_after.is_some_and(|after| yield_started.elapsed() >= after) - { - return Ok(NativeShellRunOutcome::Running(NativeShellRunning { - resource: resource.clone(), - process, - cursor, - stdout, - stderr, - })); - } - } - ExecutionState::Exited { exit_code } => { - client.release_output(process.clone()).await.map_err(NativeShellError::OutputRelease)?; - return Ok(NativeShellRunOutcome::Finished(NativeShellOutput { exit_code, stdout, stderr, timed_out })); - } - ExecutionState::Cancelled => { - client.release_output(process.clone()).await.map_err(NativeShellError::OutputRelease)?; - return Ok(NativeShellRunOutcome::Finished(NativeShellOutput { exit_code: -1, stdout, stderr, timed_out })); - } - ExecutionState::Failed { - code: crate::ExecutionFailure::TimedOut, - } => { - client - .release_output(process.clone()) - .await - .map_err(NativeShellError::OutputRelease)?; - return Ok(NativeShellRunOutcome::Finished(NativeShellOutput { - exit_code: -1, - stdout, - stderr, - timed_out: true, - })); - } - ExecutionState::Failed { code } => { - client.release_output(process.clone()).await.map_err(NativeShellError::OutputRelease)?; - return Err(NativeShellError::Execution(code)); - } - } - } - }.await; + start_native_shell(client, resource.clone(), request.create, request.shell).await?; + collect_native_shell( + client, + process, + NativeShellCollectionConfig { + resource: resource.clone(), + timeout_ms, + yield_after, + yield_started, + }, + cancelled, + &mut on_chunk, + ) + .await + } + .await; let result = match result { Ok(NativeShellRunOutcome::Running(running)) => { return Ok(NativeShellRunOutcome::Running(running)); @@ -307,6 +204,256 @@ where } } +async fn start_native_shell( + client: &C, + resource: ResourceId, + mut create: CreateRequest, + mut shell: ShellExecRequest, +) -> Result> { + create.resource = resource.clone(); + client + .create(create) + .await + .map_err(|source| NativeShellError::Transport { + operation: "create resource", + source, + })?; + let process_id = client + .reserve(ReserveRequest::Process(resource.clone())) + .await + .map_err(|source| NativeShellError::Transport { + operation: "reserve process", + source, + })?; + shell.resource = resource; + shell.process = process_id; + client + .start_native_shell_exec(shell) + .await + .map_err(|source| NativeShellError::Transport { + operation: "start native shell", + source, + }) +} + +async fn collect_native_shell( + client: &C, + process: ProcessRef, + config: NativeShellCollectionConfig, + cancelled: Fut, + on_chunk: &mut F, +) -> Result> +where + C: NativeShellClient, + F: FnMut(&OutputChunk), + Fut: Future + Send, + F: Send, + C::Error: Send, +{ + let deadline = Instant::now() + Duration::from_millis(config.timeout_ms); + let mut capture = NativeShellCapture::default(); + let mut cancelled = cancelled.fuse(); + pin_mut!(cancelled); + let mut cancellation_requested = false; + let mut timed_out = false; + loop { + let poll = next_native_shell_batch( + client, + &process, + capture.cursor, + deadline, + cancellation_requested, + cancelled.as_mut(), + ) + .await?; + match poll { + NativeShellPoll::Cancelled => { + cancellation_requested = true; + continue; + } + NativeShellPoll::TimedOut => { + cancellation_requested = true; + timed_out = true; + continue; + } + NativeShellPoll::Batch(batch) => { + let has_chunks = !batch.chunks.is_empty(); + if !capture.append(&batch, on_chunk) { + let primary = NativeShellError::InvalidOutputCursor; + if let Err(source) = client.cancel(process.clone()).await { + return Err(NativeShellError::Cleanup { + operation: "cancel after invalid output cursor", + source, + during: Box::new(primary), + }); + } + if let Err(source) = client.release_output(process.clone()).await { + return Err(NativeShellError::Cleanup { + operation: "release output after invalid cursor", + source, + during: Box::new(primary), + }); + } + return Err(primary); + } + if has_chunks && !matches!(&batch.state, ExecutionState::Running) { + continue; + } + match batch.state { + ExecutionState::Running => { + if !cancellation_requested + && config + .yield_after + .is_some_and(|after| config.yield_started.elapsed() >= after) + { + return Ok(NativeShellRunOutcome::Running(NativeShellRunning { + resource: config.resource, + process, + cursor: capture.cursor, + stdout: capture.stdout, + stderr: capture.stderr, + })); + } + } + ExecutionState::Exited { exit_code } => { + client + .release_output(process.clone()) + .await + .map_err(NativeShellError::OutputRelease)?; + return Ok(NativeShellRunOutcome::Finished( + capture.finish(exit_code, timed_out), + )); + } + ExecutionState::Cancelled => { + client + .release_output(process.clone()) + .await + .map_err(NativeShellError::OutputRelease)?; + return Ok(NativeShellRunOutcome::Finished( + capture.finish(-1, timed_out), + )); + } + ExecutionState::Failed { + code: crate::ExecutionFailure::TimedOut, + } => { + client + .release_output(process.clone()) + .await + .map_err(NativeShellError::OutputRelease)?; + return Ok(NativeShellRunOutcome::Finished(capture.finish(-1, true))); + } + ExecutionState::Failed { code } => { + client + .release_output(process.clone()) + .await + .map_err(NativeShellError::OutputRelease)?; + return Err(NativeShellError::Execution(code)); + } + } + } + } + } +} + +struct NativeShellCollectionConfig { + resource: ResourceId, + timeout_ms: u64, + yield_after: Option, + yield_started: Instant, +} + +#[derive(Default)] +struct NativeShellCapture { + cursor: u64, + stdout: Vec, + stderr: Vec, +} + +impl NativeShellCapture { + fn append(&mut self, batch: &OutputBatch, on_chunk: &mut F) -> bool + where + F: FnMut(&OutputChunk), + { + if !batch.chunks.is_empty() && batch.next_sequence <= self.cursor { + return false; + } + for chunk in &batch.chunks { + on_chunk(chunk); + match chunk.stream { + crate::OutputStream::Stdout => self.stdout.extend_from_slice(&chunk.bytes), + crate::OutputStream::Stderr => self.stderr.extend_from_slice(&chunk.bytes), + } + } + self.cursor = batch.next_sequence; + true + } + + fn finish(&mut self, exit_code: i32, timed_out: bool) -> NativeShellOutput { + NativeShellOutput { + exit_code, + stdout: std::mem::take(&mut self.stdout), + stderr: std::mem::take(&mut self.stderr), + timed_out, + } + } +} + +enum NativeShellPoll { + Batch(OutputBatch), + Cancelled, + TimedOut, +} + +async fn next_native_shell_batch( + client: &C, + process: &ProcessRef, + cursor: u64, + deadline: Instant, + cancellation_requested: bool, + cancelled: Pin<&mut futures_util::future::Fuse>, +) -> Result> +where + C: NativeShellClient, + C::Error: Send, + Fut: Future + Send, +{ + let remaining = deadline.saturating_duration_since(Instant::now()); + let poll = client.read_output(process.clone(), cursor).fuse(); + let delay = futures_timer::Delay::new(if cancellation_requested { + Duration::from_millis(20) + } else { + remaining.min(Duration::from_millis(20)) + }) + .fuse(); + pin_mut!(poll, delay); + if cancellation_requested { + select! { + result = poll => result + .map(NativeShellPoll::Batch) + .map_err(|source| NativeShellError::Transport { operation: "read output", source }), + } + } else { + select! { + result = poll => match result { + Ok(batch) => Ok(NativeShellPoll::Batch(batch)), + Err(source) => { + client.cancel(process.clone()).await.map_err(NativeShellError::Cancellation)?; + client.release_output(process.clone()).await.map_err(NativeShellError::OutputRelease)?; + Err(NativeShellError::Transport { operation: "read output", source }) + } + }, + () = cancelled => { + client.cancel(process.clone()).await.map_err(NativeShellError::Cancellation)?; + Ok(NativeShellPoll::Cancelled) + }, + () = delay => { + client.cancel(process.clone()).await.map_err(NativeShellError::Cancellation)?; + Ok(NativeShellPoll::TimedOut) + } + } + } +} + #[cfg(test)] #[path = "native_shell_tests.rs"] mod tests; From 5904804efe43a5404b35170073278487e36376b9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:41:42 +0000 Subject: [PATCH 024/236] fix(tinybox-bus): bind native shell process and fix cancellation mutability Capture the process handle returned by start_native_shell so it can be passed to collect_native_shell, and make the fused cancellation future mutable where it is polled. The previous bindings left the process unused and the cancellation stream immutable, which prevented the shell from being collected and the cancellation branch from being polled. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/tinybox-bus/src/native_shell.rs b/crates/tinybox-bus/src/native_shell.rs index 5f4f8cd6..ed104cbf 100644 --- a/crates/tinybox-bus/src/native_shell.rs +++ b/crates/tinybox-bus/src/native_shell.rs @@ -169,7 +169,8 @@ where source, })?; let result = async { - start_native_shell(client, resource.clone(), request.create, request.shell).await?; + let process = + start_native_shell(client, resource.clone(), request.create, request.shell).await?; collect_native_shell( client, process, @@ -252,7 +253,7 @@ where { let deadline = Instant::now() + Duration::from_millis(config.timeout_ms); let mut capture = NativeShellCapture::default(); - let mut cancelled = cancelled.fuse(); + let cancelled = cancelled.fuse(); pin_mut!(cancelled); let mut cancellation_requested = false; let mut timed_out = false; @@ -410,7 +411,7 @@ async fn next_native_shell_batch( cursor: u64, deadline: Instant, cancellation_requested: bool, - cancelled: Pin<&mut futures_util::future::Fuse>, + mut cancelled: Pin<&mut futures_util::future::Fuse>, ) -> Result> where C: NativeShellClient, From 0eacfd91e7d07ce71993157f5256e13f59dbe6ea Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:42:09 +0000 Subject: [PATCH 025/236] refactor(native-shell): extract terminal state handling into helper The terminal-state match arms in the poll loop were folded into a new finish_native_shell helper that releases output and returns the finished capture, leaving the loop to handle only the running and yield cases. The redundant continue statements after cancellation and timeout are gone since those branches now fall through to the same yield check. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell.rs | 99 ++++++++++++-------------- 1 file changed, 47 insertions(+), 52 deletions(-) diff --git a/crates/tinybox-bus/src/native_shell.rs b/crates/tinybox-bus/src/native_shell.rs index ed104cbf..df725606 100644 --- a/crates/tinybox-bus/src/native_shell.rs +++ b/crates/tinybox-bus/src/native_shell.rs @@ -270,12 +270,10 @@ where match poll { NativeShellPoll::Cancelled => { cancellation_requested = true; - continue; } NativeShellPoll::TimedOut => { cancellation_requested = true; timed_out = true; - continue; } NativeShellPoll::Batch(batch) => { let has_chunks = !batch.chunks.is_empty(); @@ -300,62 +298,59 @@ where if has_chunks && !matches!(&batch.state, ExecutionState::Running) { continue; } - match batch.state { - ExecutionState::Running => { - if !cancellation_requested - && config - .yield_after - .is_some_and(|after| config.yield_started.elapsed() >= after) - { - return Ok(NativeShellRunOutcome::Running(NativeShellRunning { - resource: config.resource, - process, - cursor: capture.cursor, - stdout: capture.stdout, - stderr: capture.stderr, - })); - } - } - ExecutionState::Exited { exit_code } => { - client - .release_output(process.clone()) - .await - .map_err(NativeShellError::OutputRelease)?; - return Ok(NativeShellRunOutcome::Finished( - capture.finish(exit_code, timed_out), - )); - } - ExecutionState::Cancelled => { - client - .release_output(process.clone()) - .await - .map_err(NativeShellError::OutputRelease)?; - return Ok(NativeShellRunOutcome::Finished( - capture.finish(-1, timed_out), - )); - } - ExecutionState::Failed { - code: crate::ExecutionFailure::TimedOut, - } => { - client - .release_output(process.clone()) - .await - .map_err(NativeShellError::OutputRelease)?; - return Ok(NativeShellRunOutcome::Finished(capture.finish(-1, true))); - } - ExecutionState::Failed { code } => { - client - .release_output(process.clone()) - .await - .map_err(NativeShellError::OutputRelease)?; - return Err(NativeShellError::Execution(code)); - } + if let Some(output) = + finish_native_shell(client, &process, &mut capture, batch.state, timed_out) + .await? + { + return Ok(NativeShellRunOutcome::Finished(output)); + } + if !cancellation_requested + && config + .yield_after + .is_some_and(|after| config.yield_started.elapsed() >= after) + { + return Ok(NativeShellRunOutcome::Running(NativeShellRunning { + resource: config.resource, + process, + cursor: capture.cursor, + stdout: capture.stdout, + stderr: capture.stderr, + })); } } } } } +async fn finish_native_shell( + client: &C, + process: &ProcessRef, + capture: &mut NativeShellCapture, + state: ExecutionState, + timed_out: bool, +) -> Result, NativeShellError> { + let output = match state { + ExecutionState::Running => return Ok(None), + ExecutionState::Exited { exit_code } => capture.finish(exit_code, timed_out), + ExecutionState::Cancelled => capture.finish(-1, timed_out), + ExecutionState::Failed { + code: crate::ExecutionFailure::TimedOut, + } => capture.finish(-1, true), + ExecutionState::Failed { code } => { + client + .release_output(process.clone()) + .await + .map_err(NativeShellError::OutputRelease)?; + return Err(NativeShellError::Execution(code)); + } + }; + client + .release_output(process.clone()) + .await + .map_err(NativeShellError::OutputRelease)?; + Ok(Some(output)) +} + struct NativeShellCollectionConfig { resource: ResourceId, timeout_ms: u64, From 8715dc007562bf46b6d4a350824e1379808b6ab9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:52:58 +0000 Subject: [PATCH 026/236] test(tinybox-bus): cover native shell failure and cleanup paths Extend the mock client with failure behaviors for reserve, start, read, cancel, release, and close, plus failed-execution and invalid-cursor cases, and add tests asserting these errors are reported and resources cleaned up. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell_tests.rs | 147 ++++++++++++++++++- 1 file changed, 145 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-bus/src/native_shell_tests.rs b/crates/tinybox-bus/src/native_shell_tests.rs index 983e5d2a..f8970b32 100644 --- a/crates/tinybox-bus/src/native_shell_tests.rs +++ b/crates/tinybox-bus/src/native_shell_tests.rs @@ -19,6 +19,15 @@ enum MockBehavior { Cancelled, ModuleTimeout, Running, + FailResourceReserve, + FailProcessReserve, + FailStart, + FailRead, + FailCancel, + FailRelease, + FailClose, + FailedExecution, + InvalidCursor, } impl NativeShellClient for MockClient { @@ -27,6 +36,13 @@ impl NativeShellClient for MockClient { async fn reserve(&self, request: ReserveRequest) -> Result { std::future::ready(()).await; self.calls.lock().map_err(|_| "poisoned")?.push("reserve"); + if matches!(self.behavior, MockBehavior::FailResourceReserve) + && matches!(request, ReserveRequest::Resource) + || matches!(self.behavior, MockBehavior::FailProcessReserve) + && matches!(request, ReserveRequest::Process(_)) + { + return Err("reserve failed"); + } Ok(match request { ReserveRequest::Resource => ResourceId("resource-1".into()), ReserveRequest::Process(_) => ResourceId("process-1".into()), @@ -51,6 +67,9 @@ impl NativeShellClient for MockClient { std::future::ready(()).await; self.calls.lock().map_err(|_| "poisoned")?.push("start"); assert_eq!(request.process, ResourceId("process-1".into())); + if matches!(self.behavior, MockBehavior::FailStart) { + return Err("start failed"); + } Ok(ProcessRef { resource: request.resource, process: request.process, @@ -60,6 +79,9 @@ impl NativeShellClient for MockClient { async fn read_output(&self, _: ProcessRef, cursor: u64) -> Result { std::future::ready(()).await; self.calls.lock().map_err(|_| "poisoned")?.push("read"); + if matches!(self.behavior, MockBehavior::FailRead) { + return Err("read failed"); + } *self.reads.lock().map_err(|_| "poisoned")? += 1; let state = if matches!(self.behavior, MockBehavior::Cancelled) && self @@ -75,6 +97,10 @@ impl NativeShellClient for MockClient { } } else if matches!(self.behavior, MockBehavior::Running) { ExecutionState::Running + } else if matches!(self.behavior, MockBehavior::FailedExecution) { + ExecutionState::Failed { + code: ExecutionFailure::OutputLimit, + } } else { ExecutionState::Exited { exit_code: 7 } }; @@ -82,13 +108,23 @@ impl NativeShellClient for MockClient { chunks: if cursor == 0 { vec![OutputChunk { sequence: 0, - stream: OutputStream::Stdout, + stream: if matches!(self.behavior, MockBehavior::InvalidCursor) { + OutputStream::Stderr + } else { + OutputStream::Stdout + }, bytes: b"done".to_vec(), }] } else { Vec::new() }, - next_sequence: if cursor == 0 { 1 } else { cursor }, + next_sequence: if matches!(self.behavior, MockBehavior::InvalidCursor) { + 0 + } else if cursor == 0 { + 1 + } else { + cursor + }, state, }) } @@ -96,18 +132,27 @@ impl NativeShellClient for MockClient { async fn cancel(&self, _: ProcessRef) -> Result<(), Self::Error> { std::future::ready(()).await; self.calls.lock().map_err(|_| "poisoned")?.push("cancel"); + if matches!(self.behavior, MockBehavior::FailCancel) { + return Err("cancel failed"); + } Ok(()) } async fn release_output(&self, _: ProcessRef) -> Result<(), Self::Error> { std::future::ready(()).await; self.calls.lock().map_err(|_| "poisoned")?.push("release"); + if matches!(self.behavior, MockBehavior::FailRelease) { + return Err("release failed"); + } Ok(()) } async fn close(&self, _: ResourceId) -> Result<(), Self::Error> { std::future::ready(()).await; self.calls.lock().map_err(|_| "poisoned")?.push("close"); + if matches!(self.behavior, MockBehavior::FailClose) { + return Err("close failed"); + } Ok(()) } } @@ -248,6 +293,104 @@ fn yield_preserves_process_resource_cursor_and_partial_output() -> Result<(), &' }) } +#[test] +fn reserve_start_and_read_failures_are_reported_and_cleaned_up() { + for (behavior, operation) in [ + (MockBehavior::FailResourceReserve, "reserve resource"), + (MockBehavior::FailProcessReserve, "reserve process"), + (MockBehavior::FailStart, "start native shell"), + (MockBehavior::FailRead, "read output"), + ] { + futures_executor::block_on(async { + let client = mock(behavior); + let error = run_native_shell(&client, request(), std::future::pending(), |_| {}) + .await + .expect_err("operation should fail"); + if operation == "read output" { + assert!(matches!( + error, + NativeShellError::Transport { + operation: "read output", + .. + } + )); + assert!(client.calls.lock().expect("calls").contains(&"cancel")); + assert!(client.calls.lock().expect("calls").contains(&"release")); + } else { + assert!( + matches!(error, NativeShellError::Transport { operation: actual, .. } if actual == operation) + ); + } + }); + } +} + +#[test] +fn terminal_execution_and_cleanup_failures_keep_the_primary_error() { + futures_executor::block_on(async { + let client = mock(MockBehavior::FailedExecution); + assert!(matches!( + run_native_shell(&client, request(), std::future::pending(), |_| {}).await, + Err(NativeShellError::Execution(ExecutionFailure::OutputLimit)) + )); + }); + futures_executor::block_on(async { + let client = mock(MockBehavior::FailClose); + assert!(matches!( + run_native_shell(&client, request(), std::future::pending(), |_| {}).await, + Err(NativeShellError::ResourceClose("close failed")) + )); + }); + futures_executor::block_on(async { + let client = mock(MockBehavior::FailClose); + let mut req = request(); + req.shell.timeout_ms = 0; + let err = run_native_shell(&client, req, std::future::pending(), |_| {}) + .await + .unwrap_err(); + assert!(matches!( + err, + NativeShellError::Cleanup { + operation: "close resource", + .. + } + )); + }); +} + +#[test] +fn invalid_cursor_and_cancel_cleanup_errors_are_reported() { + futures_executor::block_on(async { + let client = mock(MockBehavior::InvalidCursor); + assert!(matches!( + run_native_shell(&client, request(), std::future::pending(), |_| {}).await, + Err(NativeShellError::InvalidOutputCursor) + )); + }); + futures_executor::block_on(async { + let client = mock(MockBehavior::FailCancel); + assert!(matches!( + run_native_shell(&client, request(), std::future::ready(()), |_| {}).await, + Err(NativeShellError::Cancellation("cancel failed")) + )); + }); + futures_executor::block_on(async { + let client = mock(MockBehavior::FailRelease); + assert!(matches!( + run_native_shell(&client, request(), std::future::pending(), |_| {}).await, + Err(NativeShellError::OutputRelease("release failed")) + )); + }); +} + +fn mock(behavior: MockBehavior) -> MockClient { + MockClient { + calls: Mutex::new(Vec::new()), + reads: Mutex::new(0), + behavior, + } +} + fn request() -> NativeShellRunRequest { NativeShellRunRequest { create: CreateRequest { From 1b987dfbb70d7d169bf2c76261d3d937e5f33130 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:53:11 +0000 Subject: [PATCH 027/236] test(native-shell): cover failed execution with close failure Add a mock behavior that reports a failed execution and also fails on close, and use it in the cleanup test so the primary error is checked when both the execution and the subsequent close fail. The cancelled state check no longer depends on a prior cancel call, since the mock behavior alone determines the reported state. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell_tests.rs | 25 ++++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/crates/tinybox-bus/src/native_shell_tests.rs b/crates/tinybox-bus/src/native_shell_tests.rs index f8970b32..bcb1d520 100644 --- a/crates/tinybox-bus/src/native_shell_tests.rs +++ b/crates/tinybox-bus/src/native_shell_tests.rs @@ -28,6 +28,7 @@ enum MockBehavior { FailClose, FailedExecution, InvalidCursor, + FailedExecutionCloseFail, } impl NativeShellClient for MockClient { @@ -83,13 +84,7 @@ impl NativeShellClient for MockClient { return Err("read failed"); } *self.reads.lock().map_err(|_| "poisoned")? += 1; - let state = if matches!(self.behavior, MockBehavior::Cancelled) - && self - .calls - .lock() - .map_err(|_| "poisoned")? - .contains(&"cancel") - { + let state = if matches!(self.behavior, MockBehavior::Cancelled) { ExecutionState::Cancelled } else if matches!(self.behavior, MockBehavior::ModuleTimeout) { ExecutionState::Failed { @@ -97,7 +92,10 @@ impl NativeShellClient for MockClient { } } else if matches!(self.behavior, MockBehavior::Running) { ExecutionState::Running - } else if matches!(self.behavior, MockBehavior::FailedExecution) { + } else if matches!( + self.behavior, + MockBehavior::FailedExecution | MockBehavior::FailedExecutionCloseFail + ) { ExecutionState::Failed { code: ExecutionFailure::OutputLimit, } @@ -150,7 +148,10 @@ impl NativeShellClient for MockClient { async fn close(&self, _: ResourceId) -> Result<(), Self::Error> { std::future::ready(()).await; self.calls.lock().map_err(|_| "poisoned")?.push("close"); - if matches!(self.behavior, MockBehavior::FailClose) { + if matches!( + self.behavior, + MockBehavior::FailClose | MockBehavior::FailedExecutionCloseFail + ) { return Err("close failed"); } Ok(()) @@ -342,10 +343,8 @@ fn terminal_execution_and_cleanup_failures_keep_the_primary_error() { )); }); futures_executor::block_on(async { - let client = mock(MockBehavior::FailClose); - let mut req = request(); - req.shell.timeout_ms = 0; - let err = run_native_shell(&client, req, std::future::pending(), |_| {}) + let client = mock(MockBehavior::FailedExecutionCloseFail); + let err = run_native_shell(&client, request(), std::future::pending(), |_| {}) .await .unwrap_err(); assert!(matches!( From b47b19c36a72a19bd9387078ccbeaa97b76d66dd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:53:49 +0000 Subject: [PATCH 028/236] test(native-shell): cover timeout cancellation and invalid cursor cleanup failures Extend the mock client with timed-out and invalid-cursor cleanup failure behaviors so the read path can block until a cancel arrives. Add tests asserting that a timeout cancels the process and returns the collected output, and that cancel and release failures after an invalid cursor are surfaced as cleanup errors. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell_tests.rs | 79 ++++++++++++++++++-- 1 file changed, 74 insertions(+), 5 deletions(-) diff --git a/crates/tinybox-bus/src/native_shell_tests.rs b/crates/tinybox-bus/src/native_shell_tests.rs index bcb1d520..d27aafdf 100644 --- a/crates/tinybox-bus/src/native_shell_tests.rs +++ b/crates/tinybox-bus/src/native_shell_tests.rs @@ -29,6 +29,9 @@ enum MockBehavior { FailedExecution, InvalidCursor, FailedExecutionCloseFail, + TimedOut, + InvalidCursorCancelFail, + InvalidCursorReleaseFail, } impl NativeShellClient for MockClient { @@ -80,11 +83,23 @@ impl NativeShellClient for MockClient { async fn read_output(&self, _: ProcessRef, cursor: u64) -> Result { std::future::ready(()).await; self.calls.lock().map_err(|_| "poisoned")?.push("read"); + let cancelled = self + .calls + .lock() + .map_err(|_| "poisoned")? + .contains(&"cancel"); + if matches!(self.behavior, MockBehavior::TimedOut) && !cancelled { + std::future::pending::<()>().await; + } if matches!(self.behavior, MockBehavior::FailRead) { return Err("read failed"); } *self.reads.lock().map_err(|_| "poisoned")? += 1; - let state = if matches!(self.behavior, MockBehavior::Cancelled) { + let state = if matches!( + self.behavior, + MockBehavior::Cancelled | MockBehavior::TimedOut + ) && cancelled + { ExecutionState::Cancelled } else if matches!(self.behavior, MockBehavior::ModuleTimeout) { ExecutionState::Failed { @@ -106,7 +121,12 @@ impl NativeShellClient for MockClient { chunks: if cursor == 0 { vec![OutputChunk { sequence: 0, - stream: if matches!(self.behavior, MockBehavior::InvalidCursor) { + stream: if matches!( + self.behavior, + MockBehavior::InvalidCursor + | MockBehavior::InvalidCursorCancelFail + | MockBehavior::InvalidCursorReleaseFail + ) { OutputStream::Stderr } else { OutputStream::Stdout @@ -116,7 +136,12 @@ impl NativeShellClient for MockClient { } else { Vec::new() }, - next_sequence: if matches!(self.behavior, MockBehavior::InvalidCursor) { + next_sequence: if matches!( + self.behavior, + MockBehavior::InvalidCursor + | MockBehavior::InvalidCursorCancelFail + | MockBehavior::InvalidCursorReleaseFail + ) { 0 } else if cursor == 0 { 1 @@ -130,7 +155,10 @@ impl NativeShellClient for MockClient { async fn cancel(&self, _: ProcessRef) -> Result<(), Self::Error> { std::future::ready(()).await; self.calls.lock().map_err(|_| "poisoned")?.push("cancel"); - if matches!(self.behavior, MockBehavior::FailCancel) { + if matches!( + self.behavior, + MockBehavior::FailCancel | MockBehavior::InvalidCursorCancelFail + ) { return Err("cancel failed"); } Ok(()) @@ -139,7 +167,10 @@ impl NativeShellClient for MockClient { async fn release_output(&self, _: ProcessRef) -> Result<(), Self::Error> { std::future::ready(()).await; self.calls.lock().map_err(|_| "poisoned")?.push("release"); - if matches!(self.behavior, MockBehavior::FailRelease) { + if matches!( + self.behavior, + MockBehavior::FailRelease | MockBehavior::InvalidCursorReleaseFail + ) { return Err("release failed"); } Ok(()) @@ -380,6 +411,44 @@ fn invalid_cursor_and_cancel_cleanup_errors_are_reported() { Err(NativeShellError::OutputRelease("release failed")) )); }); + futures_executor::block_on(async { + let client = mock(MockBehavior::InvalidCursorCancelFail); + assert!(matches!( + run_native_shell(&client, request(), std::future::pending(), |_| {}).await, + Err(NativeShellError::Cleanup { + operation: "cancel after invalid output cursor", + .. + }) + )); + }); + futures_executor::block_on(async { + let client = mock(MockBehavior::InvalidCursorReleaseFail); + assert!(matches!( + run_native_shell(&client, request(), std::future::pending(), |_| {}).await, + Err(NativeShellError::Cleanup { + operation: "release output after invalid cursor", + .. + }) + )); + }); +} + +#[test] +fn timeout_cancels_and_returns_collected_output() { + futures_executor::block_on(async { + let client = mock(MockBehavior::TimedOut); + let mut req = request(); + req.shell.timeout_ms = 1; + let outcome = run_native_shell(&client, req, std::future::pending(), |_| {}) + .await + .unwrap(); + let NativeShellRunOutcome::Finished(output) = outcome else { + panic!("expected completion") + }; + assert!(output.timed_out); + assert_eq!(output.exit_code, -1); + assert!(client.calls.lock().unwrap().contains(&"cancel")); + }); } fn mock(behavior: MockBehavior) -> MockClient { From 97c781c5d5e779a670324f414ba88a6d831a1a72 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:54:05 +0000 Subject: [PATCH 029/236] test(tinybox-bus): cover running state for cancelled and timed out mocks The mock client now returns Running instead of Cancelled when a cancelled or timed out behavior is polled without a cancellation flag, so tests can distinguish a pending execution from a cancelled one. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell_tests.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/tinybox-bus/src/native_shell_tests.rs b/crates/tinybox-bus/src/native_shell_tests.rs index d27aafdf..b37e69e2 100644 --- a/crates/tinybox-bus/src/native_shell_tests.rs +++ b/crates/tinybox-bus/src/native_shell_tests.rs @@ -98,9 +98,12 @@ impl NativeShellClient for MockClient { let state = if matches!( self.behavior, MockBehavior::Cancelled | MockBehavior::TimedOut - ) && cancelled - { - ExecutionState::Cancelled + ) { + if cancelled { + ExecutionState::Cancelled + } else { + ExecutionState::Running + } } else if matches!(self.behavior, MockBehavior::ModuleTimeout) { ExecutionState::Failed { code: ExecutionFailure::TimedOut, From 3217e094fad7771bff8bcda6a4f28feb8fdadb32 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:54:57 +0000 Subject: [PATCH 030/236] test(native-shell): cover stderr accumulation when polling without yield Add a RunningStderr mock behavior that emits stderr chunks and reports cancellation, then use it in a new test asserting that a poll loop which never yields still accumulates stderr until the timeout fires. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell_tests.rs | 31 ++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-bus/src/native_shell_tests.rs b/crates/tinybox-bus/src/native_shell_tests.rs index b37e69e2..5a7216b2 100644 --- a/crates/tinybox-bus/src/native_shell_tests.rs +++ b/crates/tinybox-bus/src/native_shell_tests.rs @@ -32,6 +32,7 @@ enum MockBehavior { TimedOut, InvalidCursorCancelFail, InvalidCursorReleaseFail, + RunningStderr, } impl NativeShellClient for MockClient { @@ -108,7 +109,12 @@ impl NativeShellClient for MockClient { ExecutionState::Failed { code: ExecutionFailure::TimedOut, } - } else if matches!(self.behavior, MockBehavior::Running) { + } else if matches!(self.behavior, MockBehavior::RunningStderr) && cancelled { + ExecutionState::Cancelled + } else if matches!( + self.behavior, + MockBehavior::Running | MockBehavior::RunningStderr + ) { ExecutionState::Running } else if matches!( self.behavior, @@ -124,7 +130,9 @@ impl NativeShellClient for MockClient { chunks: if cursor == 0 { vec![OutputChunk { sequence: 0, - stream: if matches!( + stream: if matches!(self.behavior, MockBehavior::RunningStderr) { + OutputStream::Stderr + } else if matches!( self.behavior, MockBehavior::InvalidCursor | MockBehavior::InvalidCursorCancelFail @@ -454,6 +462,25 @@ fn timeout_cancels_and_returns_collected_output() { }); } +#[test] +fn running_poll_without_yield_accumulates_stderr_until_timeout() { + futures_executor::block_on(async { + let client = mock(MockBehavior::RunningStderr); + let mut req = request(); + req.shell.timeout_ms = 1; + req.yield_after = Some(std::time::Duration::from_secs(5)); + let outcome = run_native_shell(&client, req, std::future::pending(), |_| {}) + .await + .unwrap(); + let NativeShellRunOutcome::Finished(output) = outcome else { + panic!("expected completion") + }; + assert!(output.timed_out); + assert_eq!(output.stderr, b"done"); + assert!(output.stdout.is_empty()); + }); +} + fn mock(behavior: MockBehavior) -> MockClient { MockClient { calls: Mutex::new(Vec::new()), From ee3c3939e6119d00abcf0cbe8a7fa8b93595241a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:55:15 +0000 Subject: [PATCH 031/236] test(tinybox-bus): use wrapping add for mock read counter The mock client's read counter now increments with wrapping_add instead of a plain addition, avoiding a potential overflow panic in long-running tests. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-bus/src/native_shell_tests.rs b/crates/tinybox-bus/src/native_shell_tests.rs index 5a7216b2..c8a48c42 100644 --- a/crates/tinybox-bus/src/native_shell_tests.rs +++ b/crates/tinybox-bus/src/native_shell_tests.rs @@ -95,7 +95,8 @@ impl NativeShellClient for MockClient { if matches!(self.behavior, MockBehavior::FailRead) { return Err("read failed"); } - *self.reads.lock().map_err(|_| "poisoned")? += 1; + let mut reads = self.reads.lock().map_err(|_| "poisoned")?; + *reads = reads.wrapping_add(1); let state = if matches!( self.behavior, MockBehavior::Cancelled | MockBehavior::TimedOut From 6980ea7ff84a49c83d93db3602b4a79918d900f0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:56:50 +0000 Subject: [PATCH 032/236] test(tinybox-bus): use named cancel and chunk helpers in native shell tests Replace the inline `std::future::pending()`/`std::future::ready(())` and `|_| {}` arguments in the native shell tests with the `never_cancel`, `cancel_now`, and `ignore_chunk` helpers. This makes the intent of each call site explicit and keeps the cancellation and output handling consistent across the suite. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell_tests.rs | 30 ++++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/crates/tinybox-bus/src/native_shell_tests.rs b/crates/tinybox-bus/src/native_shell_tests.rs index c8a48c42..9f5df778 100644 --- a/crates/tinybox-bus/src/native_shell_tests.rs +++ b/crates/tinybox-bus/src/native_shell_tests.rs @@ -242,7 +242,7 @@ fn cancellation_reaps_the_process_before_releasing_and_closing() -> Result<(), & reads: Mutex::new(0), behavior: MockBehavior::Cancelled, }; - let output = run_native_shell(&client, request(), std::future::ready(()), |_| {}) + let output = run_native_shell(&client, request(), cancel_now(), ignore_chunk) .await .map_err(|_| "run failed")?; let NativeShellRunOutcome::Finished(output) = output else { @@ -269,7 +269,7 @@ fn create_failure_still_releases_the_resource_reservation() -> Result<(), &'stat reads: Mutex::new(0), behavior: MockBehavior::FailCreate, }; - let error = run_native_shell(&client, request(), std::future::pending(), |_| {}) + let error = run_native_shell(&client, request(), never_cancel(), ignore_chunk) .await .err() .ok_or("expected failure")?; @@ -296,7 +296,7 @@ fn module_timeout_returns_collected_output_as_a_result() -> Result<(), &'static reads: Mutex::new(0), behavior: MockBehavior::ModuleTimeout, }; - let outcome = run_native_shell(&client, request(), std::future::pending(), |_| {}) + let outcome = run_native_shell(&client, request(), never_cancel(), ignore_chunk) .await .map_err(|_| "run failed")?; let NativeShellRunOutcome::Finished(output) = outcome else { @@ -319,7 +319,7 @@ fn yield_preserves_process_resource_cursor_and_partial_output() -> Result<(), &' }; let mut request = request(); request.yield_after = Some(std::time::Duration::ZERO); - let outcome = run_native_shell(&client, request, std::future::pending(), |_| {}) + let outcome = run_native_shell(&client, request, never_cancel(), ignore_chunk) .await .map_err(|_| "run failed")?; let NativeShellRunOutcome::Running(running) = outcome else { @@ -347,7 +347,7 @@ fn reserve_start_and_read_failures_are_reported_and_cleaned_up() { ] { futures_executor::block_on(async { let client = mock(behavior); - let error = run_native_shell(&client, request(), std::future::pending(), |_| {}) + let error = run_native_shell(&client, request(), never_cancel(), ignore_chunk) .await .expect_err("operation should fail"); if operation == "read output" { @@ -374,20 +374,20 @@ fn terminal_execution_and_cleanup_failures_keep_the_primary_error() { futures_executor::block_on(async { let client = mock(MockBehavior::FailedExecution); assert!(matches!( - run_native_shell(&client, request(), std::future::pending(), |_| {}).await, + run_native_shell(&client, request(), never_cancel(), ignore_chunk).await, Err(NativeShellError::Execution(ExecutionFailure::OutputLimit)) )); }); futures_executor::block_on(async { let client = mock(MockBehavior::FailClose); assert!(matches!( - run_native_shell(&client, request(), std::future::pending(), |_| {}).await, + run_native_shell(&client, request(), never_cancel(), ignore_chunk).await, Err(NativeShellError::ResourceClose("close failed")) )); }); futures_executor::block_on(async { let client = mock(MockBehavior::FailedExecutionCloseFail); - let err = run_native_shell(&client, request(), std::future::pending(), |_| {}) + let err = run_native_shell(&client, request(), never_cancel(), ignore_chunk) .await .unwrap_err(); assert!(matches!( @@ -405,28 +405,28 @@ fn invalid_cursor_and_cancel_cleanup_errors_are_reported() { futures_executor::block_on(async { let client = mock(MockBehavior::InvalidCursor); assert!(matches!( - run_native_shell(&client, request(), std::future::pending(), |_| {}).await, + run_native_shell(&client, request(), never_cancel(), ignore_chunk).await, Err(NativeShellError::InvalidOutputCursor) )); }); futures_executor::block_on(async { let client = mock(MockBehavior::FailCancel); assert!(matches!( - run_native_shell(&client, request(), std::future::ready(()), |_| {}).await, + run_native_shell(&client, request(), cancel_now(), ignore_chunk).await, Err(NativeShellError::Cancellation("cancel failed")) )); }); futures_executor::block_on(async { let client = mock(MockBehavior::FailRelease); assert!(matches!( - run_native_shell(&client, request(), std::future::pending(), |_| {}).await, + run_native_shell(&client, request(), never_cancel(), ignore_chunk).await, Err(NativeShellError::OutputRelease("release failed")) )); }); futures_executor::block_on(async { let client = mock(MockBehavior::InvalidCursorCancelFail); assert!(matches!( - run_native_shell(&client, request(), std::future::pending(), |_| {}).await, + run_native_shell(&client, request(), never_cancel(), ignore_chunk).await, Err(NativeShellError::Cleanup { operation: "cancel after invalid output cursor", .. @@ -436,7 +436,7 @@ fn invalid_cursor_and_cancel_cleanup_errors_are_reported() { futures_executor::block_on(async { let client = mock(MockBehavior::InvalidCursorReleaseFail); assert!(matches!( - run_native_shell(&client, request(), std::future::pending(), |_| {}).await, + run_native_shell(&client, request(), never_cancel(), ignore_chunk).await, Err(NativeShellError::Cleanup { operation: "release output after invalid cursor", .. @@ -451,7 +451,7 @@ fn timeout_cancels_and_returns_collected_output() { let client = mock(MockBehavior::TimedOut); let mut req = request(); req.shell.timeout_ms = 1; - let outcome = run_native_shell(&client, req, std::future::pending(), |_| {}) + let outcome = run_native_shell(&client, req, never_cancel(), ignore_chunk) .await .unwrap(); let NativeShellRunOutcome::Finished(output) = outcome else { @@ -470,7 +470,7 @@ fn running_poll_without_yield_accumulates_stderr_until_timeout() { let mut req = request(); req.shell.timeout_ms = 1; req.yield_after = Some(std::time::Duration::from_secs(5)); - let outcome = run_native_shell(&client, req, std::future::pending(), |_| {}) + let outcome = run_native_shell(&client, req, never_cancel(), ignore_chunk) .await .unwrap(); let NativeShellRunOutcome::Finished(output) = outcome else { From 8429981c011a408efb7bb4f2450b63f9590a7aa6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:57:03 +0000 Subject: [PATCH 033/236] test(native-shell): add never_cancel and cancel_now helpers Add a never_cancel future that stays pending forever and a cancel_now future that resolves immediately, giving tests deterministic control over cancellation timing. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell_tests.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/tinybox-bus/src/native_shell_tests.rs b/crates/tinybox-bus/src/native_shell_tests.rs index 9f5df778..6a65cc01 100644 --- a/crates/tinybox-bus/src/native_shell_tests.rs +++ b/crates/tinybox-bus/src/native_shell_tests.rs @@ -490,6 +490,14 @@ fn mock(behavior: MockBehavior) -> MockClient { } } +async fn never_cancel() { + std::future::pending::<()>().await; +} + +async fn cancel_now() {} + +fn ignore_chunk(_: &OutputChunk) {} + fn request() -> NativeShellRunRequest { NativeShellRunRequest { create: CreateRequest { From 4b65c2a90c871c19bde6fe1ec530e4c862c19923 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:57:33 +0000 Subject: [PATCH 034/236] test(native-shell): return results from failure-path tests Convert several native shell tests to return Result instead of panicking on unexpected outcomes, so failures surface as test errors rather than aborts. The mock client's stderr stream selection now also covers the invalid cursor behaviors, and poisoned call locks are handled explicitly. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell_tests.rs | 71 +++++++++++++------- 1 file changed, 48 insertions(+), 23 deletions(-) diff --git a/crates/tinybox-bus/src/native_shell_tests.rs b/crates/tinybox-bus/src/native_shell_tests.rs index 6a65cc01..0f928279 100644 --- a/crates/tinybox-bus/src/native_shell_tests.rs +++ b/crates/tinybox-bus/src/native_shell_tests.rs @@ -131,11 +131,10 @@ impl NativeShellClient for MockClient { chunks: if cursor == 0 { vec![OutputChunk { sequence: 0, - stream: if matches!(self.behavior, MockBehavior::RunningStderr) { - OutputStream::Stderr - } else if matches!( + stream: if matches!( self.behavior, - MockBehavior::InvalidCursor + MockBehavior::RunningStderr + | MockBehavior::InvalidCursor | MockBehavior::InvalidCursorCancelFail | MockBehavior::InvalidCursorReleaseFail ) { @@ -338,7 +337,7 @@ fn yield_preserves_process_resource_cursor_and_partial_output() -> Result<(), &' } #[test] -fn reserve_start_and_read_failures_are_reported_and_cleaned_up() { +fn reserve_start_and_read_failures_are_reported_and_cleaned_up() -> Result<(), &'static str> { for (behavior, operation) in [ (MockBehavior::FailResourceReserve, "reserve resource"), (MockBehavior::FailProcessReserve, "reserve process"), @@ -349,7 +348,8 @@ fn reserve_start_and_read_failures_are_reported_and_cleaned_up() { let client = mock(behavior); let error = run_native_shell(&client, request(), never_cancel(), ignore_chunk) .await - .expect_err("operation should fail"); + .err() + .ok_or("operation should fail")?; if operation == "read output" { assert!(matches!( error, @@ -358,8 +358,20 @@ fn reserve_start_and_read_failures_are_reported_and_cleaned_up() { .. } )); - assert!(client.calls.lock().expect("calls").contains(&"cancel")); - assert!(client.calls.lock().expect("calls").contains(&"release")); + assert!( + client + .calls + .lock() + .map_err(|_| "poisoned")? + .contains(&"cancel") + ); + assert!( + client + .calls + .lock() + .map_err(|_| "poisoned")? + .contains(&"release") + ); } else { assert!( matches!(error, NativeShellError::Transport { operation: actual, .. } if actual == operation) @@ -367,10 +379,11 @@ fn reserve_start_and_read_failures_are_reported_and_cleaned_up() { } }); } + Ok(()) } #[test] -fn terminal_execution_and_cleanup_failures_keep_the_primary_error() { +fn terminal_execution_and_cleanup_failures_keep_the_primary_error() -> Result<(), &'static str> { futures_executor::block_on(async { let client = mock(MockBehavior::FailedExecution); assert!(matches!( @@ -389,7 +402,8 @@ fn terminal_execution_and_cleanup_failures_keep_the_primary_error() { let client = mock(MockBehavior::FailedExecutionCloseFail); let err = run_native_shell(&client, request(), never_cancel(), ignore_chunk) .await - .unwrap_err(); + .err() + .ok_or("expected cleanup failure")?; assert!(matches!( err, NativeShellError::Cleanup { @@ -398,6 +412,7 @@ fn terminal_execution_and_cleanup_failures_keep_the_primary_error() { } )); }); + Ok(()) } #[test] @@ -446,25 +461,32 @@ fn invalid_cursor_and_cancel_cleanup_errors_are_reported() { } #[test] -fn timeout_cancels_and_returns_collected_output() { +fn timeout_cancels_and_returns_collected_output() -> Result<(), &'static str> { futures_executor::block_on(async { let client = mock(MockBehavior::TimedOut); let mut req = request(); req.shell.timeout_ms = 1; let outcome = run_native_shell(&client, req, never_cancel(), ignore_chunk) .await - .unwrap(); + .map_err(|_| "run failed")?; let NativeShellRunOutcome::Finished(output) = outcome else { - panic!("expected completion") + return Err("expected completion"); }; assert!(output.timed_out); assert_eq!(output.exit_code, -1); - assert!(client.calls.lock().unwrap().contains(&"cancel")); - }); + assert!( + client + .calls + .lock() + .map_err(|_| "poisoned")? + .contains(&"cancel") + ); + Ok(()) + }) } #[test] -fn running_poll_without_yield_accumulates_stderr_until_timeout() { +fn running_poll_without_yield_accumulates_stderr_until_timeout() -> Result<(), &'static str> { futures_executor::block_on(async { let client = mock(MockBehavior::RunningStderr); let mut req = request(); @@ -472,14 +494,15 @@ fn running_poll_without_yield_accumulates_stderr_until_timeout() { req.yield_after = Some(std::time::Duration::from_secs(5)); let outcome = run_native_shell(&client, req, never_cancel(), ignore_chunk) .await - .unwrap(); + .map_err(|_| "run failed")?; let NativeShellRunOutcome::Finished(output) = outcome else { - panic!("expected completion") + return Err("expected completion"); }; assert!(output.timed_out); assert_eq!(output.stderr, b"done"); - assert!(output.stdout.is_empty()); - }); + assert_eq!(output.stdout, []); + Ok(()) + }) } fn mock(behavior: MockBehavior) -> MockClient { @@ -490,11 +513,13 @@ fn mock(behavior: MockBehavior) -> MockClient { } } -async fn never_cancel() { - std::future::pending::<()>().await; +fn never_cancel() -> impl std::future::Future { + std::future::pending::<()>() } -async fn cancel_now() {} +fn cancel_now() -> impl std::future::Future { + std::future::ready(()) +} fn ignore_chunk(_: &OutputChunk) {} From b1951c6447ae2b5b03fe9b920b683232dddaa7ec Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:57:48 +0000 Subject: [PATCH 035/236] test(tinybox-bus): propagate assertion failures in native shell tests The async blocks in the native shell tests now return a result and use the question mark operator so that assertion failures inside them surface as test errors instead of being silently discarded. The stdout emptiness check was rewritten to compare the length against zero, which avoids a type inference issue with the empty array literal. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell_tests.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/tinybox-bus/src/native_shell_tests.rs b/crates/tinybox-bus/src/native_shell_tests.rs index 0f928279..bdc939f0 100644 --- a/crates/tinybox-bus/src/native_shell_tests.rs +++ b/crates/tinybox-bus/src/native_shell_tests.rs @@ -377,7 +377,8 @@ fn reserve_start_and_read_failures_are_reported_and_cleaned_up() -> Result<(), & matches!(error, NativeShellError::Transport { operation: actual, .. } if actual == operation) ); } - }); + Ok::<(), &'static str>(()) + })?; } Ok(()) } @@ -390,14 +391,16 @@ fn terminal_execution_and_cleanup_failures_keep_the_primary_error() -> Result<() run_native_shell(&client, request(), never_cancel(), ignore_chunk).await, Err(NativeShellError::Execution(ExecutionFailure::OutputLimit)) )); - }); + Ok::<(), &'static str>(()) + })?; futures_executor::block_on(async { let client = mock(MockBehavior::FailClose); assert!(matches!( run_native_shell(&client, request(), never_cancel(), ignore_chunk).await, Err(NativeShellError::ResourceClose("close failed")) )); - }); + Ok::<(), &'static str>(()) + })?; futures_executor::block_on(async { let client = mock(MockBehavior::FailedExecutionCloseFail); let err = run_native_shell(&client, request(), never_cancel(), ignore_chunk) @@ -500,7 +503,7 @@ fn running_poll_without_yield_accumulates_stderr_until_timeout() -> Result<(), & }; assert!(output.timed_out); assert_eq!(output.stderr, b"done"); - assert_eq!(output.stdout, []); + assert_eq!(output.stdout.len(), 0); Ok(()) }) } From 7ca4bba337fb1980827eba9bd1cabab61faf7444 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 06:58:02 +0000 Subject: [PATCH 036/236] test(tinybox-bus): annotate closure return type in native shell test The closure passed to the assertion helper now returns an explicit Result so its error type is inferred correctly, and the trailing semicolon was replaced with a `?` to propagate the result. This keeps the test compiling under the stricter type inference. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-bus/src/native_shell_tests.rs b/crates/tinybox-bus/src/native_shell_tests.rs index bdc939f0..3896e097 100644 --- a/crates/tinybox-bus/src/native_shell_tests.rs +++ b/crates/tinybox-bus/src/native_shell_tests.rs @@ -414,7 +414,8 @@ fn terminal_execution_and_cleanup_failures_keep_the_primary_error() -> Result<() .. } )); - }); + Ok::<(), &'static str>(()) + })?; Ok(()) } From 0882241f2bc9bea1dd79d8544fdf101f14db1bfd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 07:00:01 +0000 Subject: [PATCH 037/236] test(tinybox-bus): relax native shell call ordering assertions The cancellation test asserted an exact sequence of client calls, which broke whenever unrelated calls were added around the reads. It now checks the fixed prefix and only requires cancel to precede release and close. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/native_shell_tests.rs | 22 +++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/crates/tinybox-bus/src/native_shell_tests.rs b/crates/tinybox-bus/src/native_shell_tests.rs index 3896e097..16079606 100644 --- a/crates/tinybox-bus/src/native_shell_tests.rs +++ b/crates/tinybox-bus/src/native_shell_tests.rs @@ -249,13 +249,21 @@ fn cancellation_reaps_the_process_before_releasing_and_closing() -> Result<(), & }; assert_eq!(output.exit_code, -1); assert!(!output.timed_out); - assert_eq!( - *client.calls.lock().map_err(|_| "poisoned")?, - [ - "reserve", "create", "reserve", "start", "cancel", "read", "read", "release", - "close" - ] - ); + let calls = client.calls.lock().map_err(|_| "poisoned")?; + assert_eq!(&calls[..4], &["reserve", "create", "reserve", "start"]); + let cancel = calls + .iter() + .position(|call| *call == "cancel") + .ok_or("cancel missing")?; + let release = calls + .iter() + .position(|call| *call == "release") + .ok_or("release missing")?; + let close = calls + .iter() + .position(|call| *call == "close") + .ok_or("close missing")?; + assert!(cancel < release && release < close); Ok(()) }) } From 83e789a2b8ad3f7c64a72821e21ec9039c4aabd3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 07:11:02 +0000 Subject: [PATCH 038/236] chore(release): bump workspace version to 0.2.4 Update the workspace package version and all internal path dependency requirements from 0.2.3 to 0.2.4, with Cargo.lock regenerated to match. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 22 +++++++++++----------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0b54e683..745b7c21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1331,7 +1331,7 @@ dependencies = [ [[package]] name = "tinybox-bus" -version = "0.2.3" +version = "0.2.4" dependencies = [ "futures-executor", "futures-timer", @@ -1344,7 +1344,7 @@ dependencies = [ [[package]] name = "tinybox-cli" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-trait", "clap", @@ -1363,7 +1363,7 @@ dependencies = [ [[package]] name = "tinybox-core" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-trait", "serde", @@ -1374,7 +1374,7 @@ dependencies = [ [[package]] name = "tinybox-docker" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-trait", "ipnet", @@ -1389,7 +1389,7 @@ dependencies = [ [[package]] name = "tinybox-drivers" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-trait", "log", @@ -1408,7 +1408,7 @@ dependencies = [ [[package]] name = "tinybox-host" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-trait", "cap-std", @@ -1423,7 +1423,7 @@ dependencies = [ [[package]] name = "tinybox-jail" -version = "0.2.3" +version = "0.2.4" dependencies = [ "landlock", "log", @@ -1443,7 +1443,7 @@ dependencies = [ [[package]] name = "tinybox-linux" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-trait", "tempfile", @@ -1454,7 +1454,7 @@ dependencies = [ [[package]] name = "tinybox-microvm" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-trait", "serde", @@ -1467,7 +1467,7 @@ dependencies = [ [[package]] name = "tinybox-module" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-trait", "log", @@ -1495,7 +1495,7 @@ dependencies = [ [[package]] name = "tinybox-ssh" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-trait", "tempfile", @@ -1509,7 +1509,7 @@ dependencies = [ [[package]] name = "tinybox-sync" -version = "0.2.3" +version = "0.2.4" dependencies = [ "async-trait", "blake3", diff --git a/Cargo.toml b/Cargo.toml index 441a5a47..5fb0ae9a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ members = [ exclude = ["vendor"] [workspace.package] -version = "0.2.3" +version = "0.2.4" edition = "2024" rust-version = "1.88" license = "GPL-3.0-only" @@ -58,15 +58,15 @@ serde_json = "1" # The model types are serializable; see `tinybox-core`'s dependency comment. serde = { version = "1", features = ["derive"] } # Internal path dependencies between workspace members. -tinybox-bus = { path = "crates/tinybox-bus", version = "0.2.3" } -tinybox-core = { path = "crates/tinybox-core", version = "0.2.3" } -tinybox-host = { path = "crates/tinybox-host", version = "0.2.3" } -tinybox-jail = { path = "crates/tinybox-jail", version = "0.2.3" } -tinybox-linux = { path = "crates/tinybox-linux", version = "0.2.3" } -tinybox-microvm = { path = "crates/tinybox-microvm", version = "0.2.3" } -tinybox-docker = { path = "crates/tinybox-docker", version = "0.2.3" } -tinybox-ssh = { path = "crates/tinybox-ssh", version = "0.2.3" } -tinybox-sync = { path = "crates/tinybox-sync", version = "0.2.3" } +tinybox-bus = { path = "crates/tinybox-bus", version = "0.2.4" } +tinybox-core = { path = "crates/tinybox-core", version = "0.2.4" } +tinybox-host = { path = "crates/tinybox-host", version = "0.2.4" } +tinybox-jail = { path = "crates/tinybox-jail", version = "0.2.4" } +tinybox-linux = { path = "crates/tinybox-linux", version = "0.2.4" } +tinybox-microvm = { path = "crates/tinybox-microvm", version = "0.2.4" } +tinybox-docker = { path = "crates/tinybox-docker", version = "0.2.4" } +tinybox-ssh = { path = "crates/tinybox-ssh", version = "0.2.4" } +tinybox-sync = { path = "crates/tinybox-sync", version = "0.2.4" } # Pin the storage contract crates to the canonical TSD v2.1 release commit. tinystoragedrivers-types = { version = "2.1.0", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "05eaada962decbab33bafa4763d2bd1a67f6b456" } tinystoragedrivers-api = { version = "2.1.0", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "05eaada962decbab33bafa4763d2bd1a67f6b456" } @@ -74,7 +74,7 @@ tinystoragedrivers-busproxy = { version = "2.1.0", git = "https://github.com/tin tinystoragedrivers = { version = "2.1.0", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "05eaada962decbab33bafa4763d2bd1a67f6b456", default-features = false } tinystoragedrivers-testkit = { version = "2.1.0", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "05eaada962decbab33bafa4763d2bd1a67f6b456" } tinystoragedrivers-document = { version = "2.1.0", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "05eaada962decbab33bafa4763d2bd1a67f6b456" } -tinybox-drivers = { path = "crates/tinybox-drivers", version = "0.2.3" } +tinybox-drivers = { path = "crates/tinybox-drivers", version = "0.2.4" } # Lints apply to every crate in the workspace via `[lints] workspace = true`. # CI runs clippy with `-D warnings`, so anything set to "warn" here fails CI. From 05de9d5dfdc1df463e033d7ad976a368c87f27f1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 14:01:13 +0000 Subject: [PATCH 039/236] fix(storage): align TinyBox with TSD 2.1.2 Co-authored-by: Medulla --- Cargo.lock | 321 ++++++++++++++++++++++++++++++++++++++++++------- Cargo.toml | 16 +-- vendor/tinybus | 2 +- 3 files changed, 289 insertions(+), 50 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 745b7c21..2b28c82e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -384,6 +384,17 @@ dependencies = [ "crypto-common 0.2.2", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "dyn-clone" version = "1.0.20" @@ -655,6 +666,95 @@ dependencies = [ "typenum", ] +[[package]] +name = "icu_casemap" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a6f143e9349267b80ae071a6ec25d4797c45756d8b2671a8b24b7b25b81ab94" +dependencies = [ + "icu_casemap_data", + "icu_collections", + "icu_locale_core", + "icu_properties", + "icu_provider", + "potential_utf", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_casemap_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71a5c321208537711696af55bc2ab5dad939e4f6e0aef92dd6a2555deceedf3a" + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "serde", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "ignore" version = "0.4.33" @@ -776,6 +876,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "log" version = "0.4.33" @@ -868,6 +974,17 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -936,6 +1053,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rawzip" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9999039f74cd73c03158cddfe4362991703c74efe5ce5c8073026f80a5e48c" + [[package]] name = "ref-cast" version = "1.0.27" @@ -1251,6 +1374,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -1285,6 +1414,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "tar" version = "0.4.46" @@ -1523,11 +1663,13 @@ dependencies = [ [[package]] name = "tinybus" -version = "0.1.6" +version = "0.3.12" dependencies = [ "async-trait", "flate2", + "icu_casemap", "libc", + "rawzip", "serde", "serde_json", "sha2 0.10.9", @@ -1538,13 +1680,13 @@ dependencies = [ "tokio", "toml", "tracing", + "unicode-normalization", "ureq", - "zip", ] [[package]] name = "tinybus-macros" -version = "0.1.6" +version = "0.3.12" dependencies = [ "proc-macro2", "quote", @@ -1553,7 +1695,7 @@ dependencies = [ [[package]] name = "tinybus-module" -version = "0.1.6" +version = "0.3.12" dependencies = [ "async-trait", "serde", @@ -1565,8 +1707,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-api" -version = "2.1.0" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=05eaada962decbab33bafa4763d2bd1a67f6b456#05eaada962decbab33bafa4763d2bd1a67f6b456" +version = "2.1.2" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=400b71e5c40fe964130c89e981c64ddeead5619f#400b71e5c40fe964130c89e981c64ddeead5619f" dependencies = [ "async-trait", "bytes", @@ -1579,8 +1721,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-blob" -version = "2.1.0" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=05eaada962decbab33bafa4763d2bd1a67f6b456#05eaada962decbab33bafa4763d2bd1a67f6b456" +version = "2.1.2" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=400b71e5c40fe964130c89e981c64ddeead5619f#400b71e5c40fe964130c89e981c64ddeead5619f" dependencies = [ "async-trait", "bytes", @@ -1593,8 +1735,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-bus" -version = "2.1.0" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=05eaada962decbab33bafa4763d2bd1a67f6b456#05eaada962decbab33bafa4763d2bd1a67f6b456" +version = "2.1.2" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=400b71e5c40fe964130c89e981c64ddeead5619f#400b71e5c40fe964130c89e981c64ddeead5619f" dependencies = [ "serde", "serde_json", @@ -1604,8 +1746,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-busproxy" -version = "2.1.0" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=05eaada962decbab33bafa4763d2bd1a67f6b456#05eaada962decbab33bafa4763d2bd1a67f6b456" +version = "2.1.2" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=400b71e5c40fe964130c89e981c64ddeead5619f#400b71e5c40fe964130c89e981c64ddeead5619f" dependencies = [ "async-trait", "bytes", @@ -1622,8 +1764,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-connect" -version = "2.1.0" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=05eaada962decbab33bafa4763d2bd1a67f6b456#05eaada962decbab33bafa4763d2bd1a67f6b456" +version = "2.1.2" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=400b71e5c40fe964130c89e981c64ddeead5619f#400b71e5c40fe964130c89e981c64ddeead5619f" dependencies = [ "rusqlite", "serde_json", @@ -1634,8 +1776,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-document" -version = "2.1.0" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=05eaada962decbab33bafa4763d2bd1a67f6b456#05eaada962decbab33bafa4763d2bd1a67f6b456" +version = "2.1.2" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=400b71e5c40fe964130c89e981c64ddeead5619f#400b71e5c40fe964130c89e981c64ddeead5619f" dependencies = [ "async-trait", "data-encoding", @@ -1652,8 +1794,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-kv" -version = "2.1.0" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=05eaada962decbab33bafa4763d2bd1a67f6b456#05eaada962decbab33bafa4763d2bd1a67f6b456" +version = "2.1.2" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=400b71e5c40fe964130c89e981c64ddeead5619f#400b71e5c40fe964130c89e981c64ddeead5619f" dependencies = [ "async-trait", "serde", @@ -1664,8 +1806,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-queue" -version = "2.1.0" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=05eaada962decbab33bafa4763d2bd1a67f6b456#05eaada962decbab33bafa4763d2bd1a67f6b456" +version = "2.1.2" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=400b71e5c40fe964130c89e981c64ddeead5619f#400b71e5c40fe964130c89e981c64ddeead5619f" dependencies = [ "async-trait", "data-encoding", @@ -1680,8 +1822,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-secrets" -version = "2.1.0" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=05eaada962decbab33bafa4763d2bd1a67f6b456#05eaada962decbab33bafa4763d2bd1a67f6b456" +version = "2.1.2" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=400b71e5c40fe964130c89e981c64ddeead5619f#400b71e5c40fe964130c89e981c64ddeead5619f" dependencies = [ "async-trait", "tinystoragedrivers-api", @@ -1691,8 +1833,8 @@ dependencies = [ [[package]] name = "tinystoragedrivers-testkit" -version = "2.1.0" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=05eaada962decbab33bafa4763d2bd1a67f6b456#05eaada962decbab33bafa4763d2bd1a67f6b456" +version = "2.1.2" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=400b71e5c40fe964130c89e981c64ddeead5619f#400b71e5c40fe964130c89e981c64ddeead5619f" dependencies = [ "async-trait", "bytes", @@ -1712,14 +1854,31 @@ dependencies = [ [[package]] name = "tinystoragedrivers-types" -version = "2.1.0" -source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=05eaada962decbab33bafa4763d2bd1a67f6b456#05eaada962decbab33bafa4763d2bd1a67f6b456" +version = "2.1.2" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=400b71e5c40fe964130c89e981c64ddeead5619f#400b71e5c40fe964130c89e981c64ddeead5619f" dependencies = [ "serde", "serde_json", "thiserror", ] +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "tokio" version = "1.53.3" @@ -1817,12 +1976,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "typed-path" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c" - [[package]] name = "typenum" version = "1.20.1" @@ -1845,6 +1998,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -1886,6 +2048,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2145,6 +2313,12 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "xattr" version = "1.6.1" @@ -2155,6 +2329,29 @@ dependencies = [ "rustix", ] +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.63" @@ -2175,6 +2372,27 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", + "synstructure", +] + [[package]] name = "zeroize" version = "1.9.0" @@ -2182,17 +2400,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] -name = "zip" -version = "9.0.5" +name = "zerotrie" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ - "crc32fast", - "flate2", - "indexmap", - "memchr", - "nix 0.31.3", - "typed-path", + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5fb0ae9a..25109644 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,13 +37,13 @@ categories = ["development-tools", "virtualization"] [workspace.dependencies] # TinyBus defines the message types, interface macro, and frozen module ABI used # by the bus adapter. Socket and CLI features are unnecessary here. -tinybus = { version = ">=0.1.3, <0.2", default-features = false, features = [ +tinybus = { version = ">=0.3.12, <0.4", default-features = false, features = [ "macros", "modules", ] } # The module-side SDK owns the isolated runtime and exports the ABI entrypoints # required by TinyBus's dynamic loader. -tinybus-module = ">=0.1.3, <0.2" +tinybus-module = ">=0.3.12, <0.4" # Derive macros for the crate-wide error type in `tinybox-core`. thiserror = "2" @@ -68,12 +68,12 @@ tinybox-docker = { path = "crates/tinybox-docker", version = "0.2.4" } tinybox-ssh = { path = "crates/tinybox-ssh", version = "0.2.4" } tinybox-sync = { path = "crates/tinybox-sync", version = "0.2.4" } # Pin the storage contract crates to the canonical TSD v2.1 release commit. -tinystoragedrivers-types = { version = "2.1.0", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "05eaada962decbab33bafa4763d2bd1a67f6b456" } -tinystoragedrivers-api = { version = "2.1.0", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "05eaada962decbab33bafa4763d2bd1a67f6b456" } -tinystoragedrivers-busproxy = { version = "2.1.0", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "05eaada962decbab33bafa4763d2bd1a67f6b456" } -tinystoragedrivers = { version = "2.1.0", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "05eaada962decbab33bafa4763d2bd1a67f6b456", default-features = false } -tinystoragedrivers-testkit = { version = "2.1.0", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "05eaada962decbab33bafa4763d2bd1a67f6b456" } -tinystoragedrivers-document = { version = "2.1.0", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "05eaada962decbab33bafa4763d2bd1a67f6b456" } +tinystoragedrivers-types = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "400b71e5c40fe964130c89e981c64ddeead5619f" } +tinystoragedrivers-api = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "400b71e5c40fe964130c89e981c64ddeead5619f" } +tinystoragedrivers-busproxy = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "400b71e5c40fe964130c89e981c64ddeead5619f" } +tinystoragedrivers = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "400b71e5c40fe964130c89e981c64ddeead5619f", default-features = false } +tinystoragedrivers-testkit = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "400b71e5c40fe964130c89e981c64ddeead5619f" } +tinystoragedrivers-document = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "400b71e5c40fe964130c89e981c64ddeead5619f" } tinybox-drivers = { path = "crates/tinybox-drivers", version = "0.2.4" } # Lints apply to every crate in the workspace via `[lints] workspace = true`. diff --git a/vendor/tinybus b/vendor/tinybus index 11b0a728..85345114 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit 11b0a728e62a5b7ae213c1cf6366f82b9e1a65de +Subproject commit 85345114f3066e94d5d70161ea8dc43252b83ae6 From c78a87cd629efc010995b83faab47a8eb4cd36d1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 26 Oct 2026 14:21:35 +0000 Subject: [PATCH 040/236] fix(storage): consume TSD security fix Co-authored-by: Medulla --- Cargo.lock | 30 +++++++++++++++--------------- Cargo.toml | 14 +++++++------- deny.toml | 4 ++-- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2b28c82e..741060b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1708,7 +1708,7 @@ dependencies = [ [[package]] name = "tinystoragedrivers-api" version = "2.1.2" -source = "git+https://github.com/senamakel/tinystoragedrivers?rev=400b71e5c40fe964130c89e981c64ddeead5619f#400b71e5c40fe964130c89e981c64ddeead5619f" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=16a4c521aaf2ab9e3756bbaecc32a6563dda2440#16a4c521aaf2ab9e3756bbaecc32a6563dda2440" dependencies = [ "async-trait", "bytes", @@ -1722,7 +1722,7 @@ dependencies = [ [[package]] name = "tinystoragedrivers-blob" version = "2.1.2" -source = "git+https://github.com/senamakel/tinystoragedrivers?rev=400b71e5c40fe964130c89e981c64ddeead5619f#400b71e5c40fe964130c89e981c64ddeead5619f" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=16a4c521aaf2ab9e3756bbaecc32a6563dda2440#16a4c521aaf2ab9e3756bbaecc32a6563dda2440" dependencies = [ "async-trait", "bytes", @@ -1736,7 +1736,7 @@ dependencies = [ [[package]] name = "tinystoragedrivers-bus" version = "2.1.2" -source = "git+https://github.com/senamakel/tinystoragedrivers?rev=400b71e5c40fe964130c89e981c64ddeead5619f#400b71e5c40fe964130c89e981c64ddeead5619f" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=16a4c521aaf2ab9e3756bbaecc32a6563dda2440#16a4c521aaf2ab9e3756bbaecc32a6563dda2440" dependencies = [ "serde", "serde_json", @@ -1747,7 +1747,7 @@ dependencies = [ [[package]] name = "tinystoragedrivers-busproxy" version = "2.1.2" -source = "git+https://github.com/senamakel/tinystoragedrivers?rev=400b71e5c40fe964130c89e981c64ddeead5619f#400b71e5c40fe964130c89e981c64ddeead5619f" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=16a4c521aaf2ab9e3756bbaecc32a6563dda2440#16a4c521aaf2ab9e3756bbaecc32a6563dda2440" dependencies = [ "async-trait", "bytes", @@ -1765,7 +1765,7 @@ dependencies = [ [[package]] name = "tinystoragedrivers-connect" version = "2.1.2" -source = "git+https://github.com/senamakel/tinystoragedrivers?rev=400b71e5c40fe964130c89e981c64ddeead5619f#400b71e5c40fe964130c89e981c64ddeead5619f" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=16a4c521aaf2ab9e3756bbaecc32a6563dda2440#16a4c521aaf2ab9e3756bbaecc32a6563dda2440" dependencies = [ "rusqlite", "serde_json", @@ -1777,7 +1777,7 @@ dependencies = [ [[package]] name = "tinystoragedrivers-document" version = "2.1.2" -source = "git+https://github.com/senamakel/tinystoragedrivers?rev=400b71e5c40fe964130c89e981c64ddeead5619f#400b71e5c40fe964130c89e981c64ddeead5619f" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=16a4c521aaf2ab9e3756bbaecc32a6563dda2440#16a4c521aaf2ab9e3756bbaecc32a6563dda2440" dependencies = [ "async-trait", "data-encoding", @@ -1795,7 +1795,7 @@ dependencies = [ [[package]] name = "tinystoragedrivers-kv" version = "2.1.2" -source = "git+https://github.com/senamakel/tinystoragedrivers?rev=400b71e5c40fe964130c89e981c64ddeead5619f#400b71e5c40fe964130c89e981c64ddeead5619f" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=16a4c521aaf2ab9e3756bbaecc32a6563dda2440#16a4c521aaf2ab9e3756bbaecc32a6563dda2440" dependencies = [ "async-trait", "serde", @@ -1807,7 +1807,7 @@ dependencies = [ [[package]] name = "tinystoragedrivers-queue" version = "2.1.2" -source = "git+https://github.com/senamakel/tinystoragedrivers?rev=400b71e5c40fe964130c89e981c64ddeead5619f#400b71e5c40fe964130c89e981c64ddeead5619f" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=16a4c521aaf2ab9e3756bbaecc32a6563dda2440#16a4c521aaf2ab9e3756bbaecc32a6563dda2440" dependencies = [ "async-trait", "data-encoding", @@ -1823,7 +1823,7 @@ dependencies = [ [[package]] name = "tinystoragedrivers-secrets" version = "2.1.2" -source = "git+https://github.com/senamakel/tinystoragedrivers?rev=400b71e5c40fe964130c89e981c64ddeead5619f#400b71e5c40fe964130c89e981c64ddeead5619f" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=16a4c521aaf2ab9e3756bbaecc32a6563dda2440#16a4c521aaf2ab9e3756bbaecc32a6563dda2440" dependencies = [ "async-trait", "tinystoragedrivers-api", @@ -1834,7 +1834,7 @@ dependencies = [ [[package]] name = "tinystoragedrivers-testkit" version = "2.1.2" -source = "git+https://github.com/senamakel/tinystoragedrivers?rev=400b71e5c40fe964130c89e981c64ddeead5619f#400b71e5c40fe964130c89e981c64ddeead5619f" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=16a4c521aaf2ab9e3756bbaecc32a6563dda2440#16a4c521aaf2ab9e3756bbaecc32a6563dda2440" dependencies = [ "async-trait", "bytes", @@ -1855,7 +1855,7 @@ dependencies = [ [[package]] name = "tinystoragedrivers-types" version = "2.1.2" -source = "git+https://github.com/senamakel/tinystoragedrivers?rev=400b71e5c40fe964130c89e981c64ddeead5619f#400b71e5c40fe964130c89e981c64ddeead5619f" +source = "git+https://github.com/senamakel/tinystoragedrivers?rev=16a4c521aaf2ab9e3756bbaecc32a6563dda2440#16a4c521aaf2ab9e3756bbaecc32a6563dda2440" dependencies = [ "serde", "serde_json", @@ -2442,18 +2442,18 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zstd" -version = "0.13.3" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "7.3.0" +version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b" dependencies = [ "zstd-sys", ] diff --git a/Cargo.toml b/Cargo.toml index 25109644..657d22d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,13 +67,13 @@ tinybox-microvm = { path = "crates/tinybox-microvm", version = "0.2.4" } tinybox-docker = { path = "crates/tinybox-docker", version = "0.2.4" } tinybox-ssh = { path = "crates/tinybox-ssh", version = "0.2.4" } tinybox-sync = { path = "crates/tinybox-sync", version = "0.2.4" } -# Pin the storage contract crates to the canonical TSD v2.1 release commit. -tinystoragedrivers-types = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "400b71e5c40fe964130c89e981c64ddeead5619f" } -tinystoragedrivers-api = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "400b71e5c40fe964130c89e981c64ddeead5619f" } -tinystoragedrivers-busproxy = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "400b71e5c40fe964130c89e981c64ddeead5619f" } -tinystoragedrivers = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "400b71e5c40fe964130c89e981c64ddeead5619f", default-features = false } -tinystoragedrivers-testkit = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "400b71e5c40fe964130c89e981c64ddeead5619f" } -tinystoragedrivers-document = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "400b71e5c40fe964130c89e981c64ddeead5619f" } +# Pin the storage contract crates to the zstd advisory fix on the fork's security epic. +tinystoragedrivers-types = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "16a4c521aaf2ab9e3756bbaecc32a6563dda2440" } +tinystoragedrivers-api = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "16a4c521aaf2ab9e3756bbaecc32a6563dda2440" } +tinystoragedrivers-busproxy = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "16a4c521aaf2ab9e3756bbaecc32a6563dda2440" } +tinystoragedrivers = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "16a4c521aaf2ab9e3756bbaecc32a6563dda2440", default-features = false } +tinystoragedrivers-testkit = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "16a4c521aaf2ab9e3756bbaecc32a6563dda2440" } +tinystoragedrivers-document = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "16a4c521aaf2ab9e3756bbaecc32a6563dda2440" } tinybox-drivers = { path = "crates/tinybox-drivers", version = "0.2.4" } # Lints apply to every crate in the workspace via `[lints] workspace = true`. diff --git a/deny.toml b/deny.toml index 9452d443..ea4b021a 100644 --- a/deny.toml +++ b/deny.toml @@ -39,7 +39,7 @@ unknown-registry = "deny" unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"] # `tinybox-drivers` (never published) takes the storage ports from the -# canonical tinystoragedrivers v2.1 commit. +# forked tinystoragedrivers v2.1.2 commit used by the security epic. allow-git = [ - "https://github.com/tinyhumansai/tinystoragedrivers", + "https://github.com/senamakel/tinystoragedrivers", ] From 3a694268b54612fc6049d03f3a1212929bfb05c6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 11:57:17 +0000 Subject: [PATCH 041/236] chore(vendor): bump tinybus submodule Update the vendored tinybus submodule to the latest upstream commit. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- vendor/tinybus | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinybus b/vendor/tinybus index 85345114..e5f1cd2d 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit 85345114f3066e94d5d70161ea8dc43252b83ae6 +Subproject commit e5f1cd2d2a7b905ee82410a61219292ca8a85c1f From 1deb31b9e6e40624684e1b6d714f3231e1351ef0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 11:58:40 +0000 Subject: [PATCH 042/236] test(sandbox): cover hard requirements for unsupported and supported constraints Add tests asserting that a spec requiring CPU is refused before any box is stored, and that requiring only supported constraints still allows creation. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-linux/src/sandbox/mod_tests.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/crates/tinybox-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index b3bb18af..04e77b7b 100644 --- a/crates/tinybox-linux/src/sandbox/mod_tests.rs +++ b/crates/tinybox-linux/src/sandbox/mod_tests.rs @@ -845,3 +845,41 @@ async fn an_unenforceable_network_allowlist_is_refused_at_creation() -> Result<( )); Ok(()) } + +#[tokio::test] +async fn explicitly_required_cpu_is_refused_before_box_storage() -> Result<()> { + use tinybox_core::{Constraint, Enforcement}; + + let store = Arc::new(MemoryStore::new()); + let sandbox = NamespaceSandbox::new(Arc::new(RecordingHost::default()), store.clone()); + let requested = spec()?.with_hard_requirements([Constraint::Cpu]); + + assert_eq!( + sandbox.plan_check_spec(&requested).constraints, + vec![(Constraint::Cpu, Enforcement::Unsupported)] + ); + assert!(matches!( + sandbox.create(&requested).await, + Err(Error::ConstraintNotEnforced { + constraint: Constraint::Cpu, + enforcement: Enforcement::Unsupported, + .. + }) + )); + assert!(store.list()?.is_empty()); + Ok(()) +} + +#[tokio::test] +async fn explicitly_required_supported_constraints_allow_creation() -> Result<()> { + use tinybox_core::Constraint; + + let store = Arc::new(MemoryStore::new()); + let sandbox = NamespaceSandbox::new(Arc::new(RecordingHost::default()), store.clone()); + let requested = spec()?.with_hard_requirements([Constraint::Filesystem, Constraint::Network]); + + sandbox.require_spec(&requested)?; + assert_eq!(sandbox.create(&requested).await?.state, BoxState::Ready); + assert_eq!(store.list()?.len(), 1); + Ok(()) +} From a4ce4b6d8b4916c33a0df210766271788f68adf4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 11:59:13 +0000 Subject: [PATCH 043/236] feat(spec): add hard requirements to box specs Box specs can now carry a list of constraints the caller requires the selected sandbox to enforce fully, and every backend refuses to create a box whose hard requirements it cannot satisfy. The field defaults to empty so previously stored specs still load. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-core/src/constraint/mod.rs | 3 ++- crates/tinybox-core/src/passthrough/mod.rs | 1 + crates/tinybox-core/src/runtime/mod.rs | 10 ++++++++++ crates/tinybox-core/src/spec/mod.rs | 18 ++++++++++++++++++ crates/tinybox-docker/src/sandbox/mod.rs | 1 + crates/tinybox-linux/src/sandbox/mod.rs | 1 + crates/tinybox-microvm/src/sandbox/mod.rs | 1 + 7 files changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-core/src/constraint/mod.rs b/crates/tinybox-core/src/constraint/mod.rs index 3b380419..c6391b3f 100644 --- a/crates/tinybox-core/src/constraint/mod.rs +++ b/crates/tinybox-core/src/constraint/mod.rs @@ -5,7 +5,8 @@ //! accepted as a hard requirement. Unknown backends default to unsupported. /// A security boundary requested before starting a workload. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] pub enum Constraint { /// Confine filesystem reads and writes to the granted paths. Filesystem, diff --git a/crates/tinybox-core/src/passthrough/mod.rs b/crates/tinybox-core/src/passthrough/mod.rs index f4023391..5f15fff5 100644 --- a/crates/tinybox-core/src/passthrough/mod.rs +++ b/crates/tinybox-core/src/passthrough/mod.rs @@ -161,6 +161,7 @@ impl Sandbox for PassthroughSandbox { async fn create(&self, spec: &BoxSpec) -> Result { spec.validate()?; + self.require_spec(spec)?; spec.network.refuse_allowlist(self.name())?; // Fail before recording anything, so a rejected source leaves no box // behind for a later command to trip over. diff --git a/crates/tinybox-core/src/runtime/mod.rs b/crates/tinybox-core/src/runtime/mod.rs index 6fe8de95..b1838f89 100644 --- a/crates/tinybox-core/src/runtime/mod.rs +++ b/crates/tinybox-core/src/runtime/mod.rs @@ -249,6 +249,11 @@ pub trait Sandbox: std::fmt::Debug + Send + Sync + 'static { self.constraint_support().plan_check(requested) } + /// Inspect only the hard requirements explicitly carried by a box spec. + fn plan_check_spec(&self, spec: &BoxSpec) -> crate::PlanCheck { + self.plan_check(&spec.hard_requirements) + } + /// Require fully implemented constraints before allocating or executing. /// /// This checks declarations only; callers must still propagate backend @@ -262,6 +267,11 @@ pub trait Sandbox: std::fmt::Debug + Send + Sync + 'static { self.constraint_support().require(self.name(), requested) } + /// Refuse a spec whose explicitly required constraints are not enforced. + fn require_spec(&self, spec: &BoxSpec) -> Result<()> { + self.require(&spec.hard_requirements) + } + /// Create a box from `spec` without starting any workload in it. /// /// # Errors diff --git a/crates/tinybox-core/src/spec/mod.rs b/crates/tinybox-core/src/spec/mod.rs index 5404546d..2c096ae2 100644 --- a/crates/tinybox-core/src/spec/mod.rs +++ b/crates/tinybox-core/src/spec/mod.rs @@ -64,6 +64,13 @@ pub struct BoxSpec { pub source: WorkspaceSource, /// The limits applied to the workspace. pub resources: Resources, + /// Constraints the caller requires the selected sandbox to enforce fully. + /// + /// Resource values remain soft defaults unless their corresponding + /// constraint is listed here. Defaulted on read for stored specs created + /// before hard requirements were introduced. + #[serde(default)] + pub hard_requirements: Vec, /// How long the box lives and whether it snapshots as it goes. pub lifecycle: Lifecycle, /// What the workspace may reach over the network. @@ -97,6 +104,7 @@ impl BoxSpec { workspace: placement, source, resources: Resources::DEFAULT, + hard_requirements: Vec::new(), lifecycle: Lifecycle::default(), network: NetworkPolicy::default(), ports: BTreeSet::new(), @@ -131,6 +139,16 @@ impl BoxSpec { self } + /// Require the selected sandbox to fully enforce each listed constraint. + #[must_use] + pub fn with_hard_requirements( + mut self, + requirements: impl IntoIterator, + ) -> Self { + self.hard_requirements = requirements.into_iter().collect(); + self + } + /// Replace the lifecycle policy. #[must_use] pub fn with_lifecycle(mut self, lifecycle: Lifecycle) -> Self { diff --git a/crates/tinybox-docker/src/sandbox/mod.rs b/crates/tinybox-docker/src/sandbox/mod.rs index 17b99ab7..7e9f04da 100644 --- a/crates/tinybox-docker/src/sandbox/mod.rs +++ b/crates/tinybox-docker/src/sandbox/mod.rs @@ -775,6 +775,7 @@ impl Sandbox for DockerSandbox { async fn create(&self, spec: &BoxSpec) -> Result { spec.validate()?; + self.require_spec(spec)?; self.validate_network_policy(spec).await?; // The workspace is bind-mounted read-write, so it gets the one-shot // mount policy before any record or container exists. diff --git a/crates/tinybox-linux/src/sandbox/mod.rs b/crates/tinybox-linux/src/sandbox/mod.rs index 00259110..83ac4d69 100644 --- a/crates/tinybox-linux/src/sandbox/mod.rs +++ b/crates/tinybox-linux/src/sandbox/mod.rs @@ -128,6 +128,7 @@ impl Sandbox for NamespaceSandbox { async fn create(&self, spec: &BoxSpec) -> Result { spec.validate()?; + self.require_spec(spec)?; spec.network.refuse_allowlist(self.name())?; // Nothing is started here: a box is a record plus a directory, and the // sandbox itself exists only for the length of a command. Checking the diff --git a/crates/tinybox-microvm/src/sandbox/mod.rs b/crates/tinybox-microvm/src/sandbox/mod.rs index 5da7fe34..7f2024fa 100644 --- a/crates/tinybox-microvm/src/sandbox/mod.rs +++ b/crates/tinybox-microvm/src/sandbox/mod.rs @@ -169,6 +169,7 @@ impl Sandbox for MicroVmSandbox { async fn create(&self, spec: &BoxSpec) -> Result { spec.validate()?; + self.require_spec(spec)?; spec.network.refuse_allowlist(self.name())?; // Nothing boots here: a box is a record and a directory, and the VM // exists only for the length of a command. Checking the source now From 37ad4829c7f120dff51f6b76c74d54bc5373a5ce Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 11:59:43 +0000 Subject: [PATCH 044/236] test(sandbox): cover hard requirement rejection for best-effort cpu Add a test asserting that a spec requiring CPU as a hard constraint is rejected when the backend can only enforce it on a best-effort basis, while the plan check still reports the best-effort enforcement. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinybox-microvm/src/sandbox/mod_tests.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/tinybox-microvm/src/sandbox/mod_tests.rs b/crates/tinybox-microvm/src/sandbox/mod_tests.rs index c5c9d7d6..a34eb3e1 100644 --- a/crates/tinybox-microvm/src/sandbox/mod_tests.rs +++ b/crates/tinybox-microvm/src/sandbox/mod_tests.rs @@ -658,6 +658,27 @@ fn preflight_reports_each_enforced_boundary_without_claiming_disk() { assert!(backend.require(&[Constraint::Disk]).is_err()); } +#[test] +fn spec_hard_requirements_reject_best_effort_cpu() -> Result<()> { + use tinybox_core::{Constraint, Enforcement}; + + let backend = sandbox(ScriptedHost::booting("", 0)); + let requested = spec()?.with_hard_requirements([Constraint::Cpu]); + assert_eq!( + backend.plan_check_spec(&requested).constraints, + vec![(Constraint::Cpu, Enforcement::BestEffort)] + ); + assert!(matches!( + backend.require_spec(&requested), + Err(Error::ConstraintNotEnforced { + constraint: Constraint::Cpu, + enforcement: Enforcement::BestEffort, + .. + }) + )); + Ok(()) +} + #[tokio::test] async fn an_unenforceable_network_allowlist_is_refused_at_creation() -> Result<()> { let sandbox = sandbox(ScriptedHost::booting("", 0)); From 9d5dc1a645f40ae17a8f608d0c646bfda223fc62 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:00:09 +0000 Subject: [PATCH 045/236] test(passthrough): cover rejection of hard requirements before storage Add a test asserting that a passthrough sandbox refuses a spec with explicit hard requirements, returning ConstraintNotEnforced, and that nothing is persisted to the store when it does. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinybox-core/src/passthrough/mod_tests.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/tinybox-core/src/passthrough/mod_tests.rs b/crates/tinybox-core/src/passthrough/mod_tests.rs index 0fadc105..60787ef7 100644 --- a/crates/tinybox-core/src/passthrough/mod_tests.rs +++ b/crates/tinybox-core/src/passthrough/mod_tests.rs @@ -95,6 +95,26 @@ async fn a_box_can_be_created_inspected_and_destroyed() -> Result<()> { Ok(()) } +#[tokio::test] +async fn passthrough_refuses_explicit_hard_requirements_before_storage() -> Result<()> { + use crate::{Constraint, Enforcement}; + + let store = Arc::new(MemoryStore::new()); + let sandbox = PassthroughSandbox::new(Arc::new(RecordingHost::default()), store.clone()); + let requested = spec()?.with_hard_requirements([Constraint::Filesystem]); + + assert!(matches!( + sandbox.create(&requested).await, + Err(Error::ConstraintNotEnforced { + constraint: Constraint::Filesystem, + enforcement: Enforcement::Unsupported, + .. + }) + )); + assert!(store.list()?.is_empty()); + Ok(()) +} + #[tokio::test] async fn a_command_runs_in_the_workspace_directory() -> Result<()> { let (sandbox, host) = sandbox(); From 092fb7c766ea04e7e6fc34f466c7b0c576913cc7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:01:07 +0000 Subject: [PATCH 046/236] chore(vendor): bump tinybus submodule Update the vendored tinybus submodule to the latest upstream commit. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- vendor/tinybus | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinybus b/vendor/tinybus index e5f1cd2d..06895661 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit e5f1cd2d2a7b905ee82410a61219292ca8a85c1f +Subproject commit 0689566109edfc28c416ba927b2af4b88f1fb4cf From 7e96981993420881700fe047be22a5c0bead4a3f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:01:57 +0000 Subject: [PATCH 047/236] docs(runtime): document errors for require_spec Add an `# Errors` section to the `require_spec` doc comment explaining that it returns `Error::ConstraintNotEnforced` when an explicit requirement is unsupported or only best effort. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-core/src/runtime/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/tinybox-core/src/runtime/mod.rs b/crates/tinybox-core/src/runtime/mod.rs index b1838f89..1ee2c444 100644 --- a/crates/tinybox-core/src/runtime/mod.rs +++ b/crates/tinybox-core/src/runtime/mod.rs @@ -268,6 +268,10 @@ pub trait Sandbox: std::fmt::Debug + Send + Sync + 'static { } /// Refuse a spec whose explicitly required constraints are not enforced. + /// + /// # Errors + /// Returns [`Error::ConstraintNotEnforced`] if any explicit requirement is + /// unsupported or only best effort. fn require_spec(&self, spec: &BoxSpec) -> Result<()> { self.require(&spec.hard_requirements) } From 28430a919e9b2585e7942ca7791b655e8e720d9a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:02:19 +0000 Subject: [PATCH 048/236] test(spec): cover defaulting of hard requirements for older specs Add a test that deserializes a spec with the hard_requirements field removed and asserts it defaults to an empty collection, guarding backward compatibility with older serialized specs. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-core/src/spec/mod_tests.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/tinybox-core/src/spec/mod_tests.rs b/crates/tinybox-core/src/spec/mod_tests.rs index d7d1102a..5d52f97c 100644 --- a/crates/tinybox-core/src/spec/mod_tests.rs +++ b/crates/tinybox-core/src/spec/mod_tests.rs @@ -98,6 +98,19 @@ fn environment_order_does_not_change_spec_identity() -> Result<()> { Ok(()) } +#[test] +fn older_specs_default_hard_requirements_to_empty() -> Result<()> { + let mut value = serde_json::to_value(spec()?)?; + value + .as_object_mut() + .expect("BoxSpec serializes as an object") + .remove("hard_requirements"); + + let restored: BoxSpec = serde_json::from_value(value)?; + assert!(restored.hard_requirements.is_empty()); + Ok(()) +} + #[test] fn validate_rejects_each_zero_limit_in_turn() -> Result<()> { for (limit, resources) in [ From 608cf716d4d4767a97ec81ed24ea23211e08b702 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:02:28 +0000 Subject: [PATCH 049/236] test(spec): use explicit expect messages in legacy spec test Replaced the `?` operators with `expect` calls in the older-specs default test so serialization and deserialization failures report which step failed. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-core/src/spec/mod_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-core/src/spec/mod_tests.rs b/crates/tinybox-core/src/spec/mod_tests.rs index 5d52f97c..c1f8f7d6 100644 --- a/crates/tinybox-core/src/spec/mod_tests.rs +++ b/crates/tinybox-core/src/spec/mod_tests.rs @@ -100,13 +100,13 @@ fn environment_order_does_not_change_spec_identity() -> Result<()> { #[test] fn older_specs_default_hard_requirements_to_empty() -> Result<()> { - let mut value = serde_json::to_value(spec()?)?; + let mut value = serde_json::to_value(spec()?).expect("serialize the current spec"); value .as_object_mut() .expect("BoxSpec serializes as an object") .remove("hard_requirements"); - let restored: BoxSpec = serde_json::from_value(value)?; + let restored: BoxSpec = serde_json::from_value(value).expect("load a legacy spec"); assert!(restored.hard_requirements.is_empty()); Ok(()) } From 7969ee909d9aa4c7f95ab002ae8715619496adec Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:02:39 +0000 Subject: [PATCH 050/236] chore(tinybox-core): add serde_json as a dev-dependency Add serde_json to the dev-dependencies of tinybox-core so tests can build and inspect JSON values, with the lockfile updated to match. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- Cargo.lock | 1 + crates/tinybox-core/Cargo.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 741060b4..922c7fc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1507,6 +1507,7 @@ version = "0.2.4" dependencies = [ "async-trait", "serde", + "serde_json", "thiserror", "tinybox-bus", "tokio", diff --git a/crates/tinybox-core/Cargo.toml b/crates/tinybox-core/Cargo.toml index 490d593f..91155e85 100644 --- a/crates/tinybox-core/Cargo.toml +++ b/crates/tinybox-core/Cargo.toml @@ -24,6 +24,7 @@ tinybox-bus.workspace = true [dev-dependencies] tokio.workspace = true +serde_json.workspace = true [lints] workspace = true From 60a6a16ef28a74148fb364bb7c0e403d75702a79 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:02:54 +0000 Subject: [PATCH 051/236] test(spec): replace panics with errors in legacy spec test The legacy spec test now returns boxed errors instead of panicking on serialization and deserialization failures, and reports a non-object serialization as an error rather than an expect. This keeps the test consistent with the fallible style used elsewhere. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-core/src/spec/mod_tests.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/tinybox-core/src/spec/mod_tests.rs b/crates/tinybox-core/src/spec/mod_tests.rs index c1f8f7d6..a2d45182 100644 --- a/crates/tinybox-core/src/spec/mod_tests.rs +++ b/crates/tinybox-core/src/spec/mod_tests.rs @@ -99,14 +99,15 @@ fn environment_order_does_not_change_spec_identity() -> Result<()> { } #[test] -fn older_specs_default_hard_requirements_to_empty() -> Result<()> { - let mut value = serde_json::to_value(spec()?).expect("serialize the current spec"); - value - .as_object_mut() - .expect("BoxSpec serializes as an object") - .remove("hard_requirements"); - - let restored: BoxSpec = serde_json::from_value(value).expect("load a legacy spec"); +fn older_specs_default_hard_requirements_to_empty() +-> std::result::Result<(), Box> { + let mut value = serde_json::to_value(spec()?)?; + let Some(object) = value.as_object_mut() else { + return Err(std::io::Error::other("BoxSpec must serialize as an object").into()); + }; + object.remove("hard_requirements"); + + let restored: BoxSpec = serde_json::from_value(value)?; assert!(restored.hard_requirements.is_empty()); Ok(()) } From 5af126a49a926f96b3645439e04099c889ec4483 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:07:33 +0000 Subject: [PATCH 052/236] fix(storage): preserve hard requirements through v1 conversion The v1 box spec now carries hard_requirements through to the v2 body instead of dropping them, and the driver record encoding round-trips the field so stored boxes keep their constraints. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/storage/box_v1.rs | 3 +++ crates/tinybox-bus/src/storage/box_v1_tests.rs | 12 ++++++++++++ crates/tinybox-drivers/src/record.rs | 4 ++++ crates/tinybox-drivers/src/record_tests.rs | 1 + 4 files changed, 20 insertions(+) diff --git a/crates/tinybox-bus/src/storage/box_v1.rs b/crates/tinybox-bus/src/storage/box_v1.rs index 66a95de1..f2340bf1 100644 --- a/crates/tinybox-bus/src/storage/box_v1.rs +++ b/crates/tinybox-bus/src/storage/box_v1.rs @@ -43,6 +43,8 @@ struct V1Spec { lifecycle: V1Lifecycle, network: V1Network, #[serde(default)] + hard_requirements: Vec, + #[serde(default)] ports: Vec, env: BTreeMap, } @@ -175,6 +177,7 @@ pub fn to_body(source: Value) -> Result { "resources": spec.resources, "lifecycle": lifecycle_body(&spec.lifecycle), "network": network_body(spec.network), + "hard_requirements": spec.hard_requirements, "ports": spec.ports, "env": spec.env, }, diff --git a/crates/tinybox-bus/src/storage/box_v1_tests.rs b/crates/tinybox-bus/src/storage/box_v1_tests.rs index 7a8bf5d0..b5e6599c 100644 --- a/crates/tinybox-bus/src/storage/box_v1_tests.rs +++ b/crates/tinybox-bus/src/storage/box_v1_tests.rs @@ -34,9 +34,21 @@ fn every_variant_takes_its_v2_tagged_form() { ); assert_eq!(body["spec"]["network"], json!({"kind": "open"})); assert_eq!(body["spec"]["ports"], json!([])); + assert_eq!(body["spec"]["hard_requirements"], json!([])); assert!(body.get("id").is_none(), "the id is the document id"); } +#[test] +fn hard_requirements_survive_the_v1_conversion() { + let mut old = v1(); + old["spec"]["hard_requirements"] = json!(["cpu", "filesystem"]); + let body = to_body(old).expect("decodes"); + assert_eq!( + body["spec"]["hard_requirements"], + json!(["cpu", "filesystem"]) + ); +} + #[test] fn a_persistent_box_without_autosnapshot_keeps_it_null() { let mut old = v1(); diff --git a/crates/tinybox-drivers/src/record.rs b/crates/tinybox-drivers/src/record.rs index fa944ef1..2b467fc4 100644 --- a/crates/tinybox-drivers/src/record.rs +++ b/crates/tinybox-drivers/src/record.rs @@ -52,6 +52,8 @@ pub(crate) struct SpecRecord { resources: Resources, lifecycle: LifecycleRecord, network: NetworkRecord, + #[serde(default)] + hard_requirements: Vec, ports: BTreeSet, env: BTreeMap, } @@ -171,6 +173,7 @@ impl TryFrom<&BoxSpec> for SpecRecord { resources: spec.resources, lifecycle: LifecycleRecord::try_from(&spec.lifecycle)?, network: NetworkRecord::try_from(&spec.network)?, + hard_requirements: spec.hard_requirements.clone(), ports: spec.ports.clone(), env: spec.env.clone(), }) @@ -184,6 +187,7 @@ impl SpecRecord { spec.resources = self.resources; spec.lifecycle = self.lifecycle.into(); spec.network = self.network.into(); + spec.hard_requirements = self.hard_requirements; spec.ports = self.ports; spec.env = self.env; spec diff --git a/crates/tinybox-drivers/src/record_tests.rs b/crates/tinybox-drivers/src/record_tests.rs index 6d334fc2..6a1d733a 100644 --- a/crates/tinybox-drivers/src/record_tests.rs +++ b/crates/tinybox-drivers/src/record_tests.rs @@ -157,6 +157,7 @@ fn the_contracts_v1_migration_matches_the_record_encoding() { ttl: Duration::from_secs(60), }; spec.network = NetworkPolicy::Allowlist(EgressAllowlist::new(["a.test"], vec![443])); + spec.hard_requirements = vec![tinybox_core::Constraint::Cpu]; spec.ports.insert(PortMapping { guest: 80, host: None, From 9f9319ef4b0d2213a5beffc5cbc256fc14d2977b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:07:51 +0000 Subject: [PATCH 053/236] test(record): cover defaulting hard requirements for older records Added a test asserting that stored records without a hard_requirements field decode with an empty list, and updated the serialization expectation to include the field. This guards backward compatibility for records written before the field existed. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-drivers/src/record_tests.rs | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/tinybox-drivers/src/record_tests.rs b/crates/tinybox-drivers/src/record_tests.rs index 6a1d733a..0f909ce8 100644 --- a/crates/tinybox-drivers/src/record_tests.rs +++ b/crates/tinybox-drivers/src/record_tests.rs @@ -58,6 +58,7 @@ fn the_body_is_snake_case_with_millisecond_times_and_no_id() -> Result<()> { }, "lifecycle": {"kind": "persistent", "autosnapshot_ms": 60_000}, "network": {"kind": "denied"}, + "hard_requirements": [], "ports": [], "env": {"KEY": "value"}, }, @@ -136,6 +137,29 @@ fn an_unknown_creation_time_stays_unknown() -> Result<()> { Ok(()) } +#[test] +fn older_stored_records_default_hard_requirements_to_empty() -> Result<()> { + let info = info_with(base_spec(WorkspaceSource::OciImage("a".into()))?)?; + let mut value = serde_json::to_value(BoxRecord::try_from(&info)?).map_err(|e| Error::Store { + operation: "encode", + message: e.to_string(), + })?; + value["spec"] + .as_object_mut() + .ok_or_else(|| Error::Store { + operation: "decode", + message: "spec record must be an object".to_owned(), + })? + .remove("hard_requirements"); + + let stored: BoxRecord = serde_json::from_value(value).map_err(|e| Error::Store { + operation: "decode", + message: e.to_string(), + })?; + assert!(stored.into_info(info.id).spec.hard_requirements.is_empty()); + Ok(()) +} + #[test] fn a_creation_time_keeps_millisecond_precision() -> Result<()> { let at = UNIX_EPOCH + Duration::from_millis(42_007); From 56609ef7d4f4402f1bac32861b93acd57f552645 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:09:07 +0000 Subject: [PATCH 054/236] test(record): reformat older stored records test setup Reformatted the JSON encoding setup in the test for older stored records so the error mapping closure is laid out consistently. No behaviour change. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-drivers/src/record_tests.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/tinybox-drivers/src/record_tests.rs b/crates/tinybox-drivers/src/record_tests.rs index 0f909ce8..932e0c5d 100644 --- a/crates/tinybox-drivers/src/record_tests.rs +++ b/crates/tinybox-drivers/src/record_tests.rs @@ -140,10 +140,11 @@ fn an_unknown_creation_time_stays_unknown() -> Result<()> { #[test] fn older_stored_records_default_hard_requirements_to_empty() -> Result<()> { let info = info_with(base_spec(WorkspaceSource::OciImage("a".into()))?)?; - let mut value = serde_json::to_value(BoxRecord::try_from(&info)?).map_err(|e| Error::Store { - operation: "encode", - message: e.to_string(), - })?; + let mut value = + serde_json::to_value(BoxRecord::try_from(&info)?).map_err(|e| Error::Store { + operation: "encode", + message: e.to_string(), + })?; value["spec"] .as_object_mut() .ok_or_else(|| Error::Store { From d7ea52376a40fd772842fce162d3ac7b57c26693 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:12:38 +0000 Subject: [PATCH 055/236] test: assert empty store lists with assert_eq Replace the is_empty assertions in the passthrough and sandbox tests with assert_eq against an empty slice, so a failure reports the actual stored contents instead of just false. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-core/src/passthrough/mod_tests.rs | 2 +- crates/tinybox-linux/src/sandbox/mod_tests.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-core/src/passthrough/mod_tests.rs b/crates/tinybox-core/src/passthrough/mod_tests.rs index 60787ef7..a6af066c 100644 --- a/crates/tinybox-core/src/passthrough/mod_tests.rs +++ b/crates/tinybox-core/src/passthrough/mod_tests.rs @@ -111,7 +111,7 @@ async fn passthrough_refuses_explicit_hard_requirements_before_storage() -> Resu .. }) )); - assert!(store.list()?.is_empty()); + assert_eq!(store.list()?, []); Ok(()) } diff --git a/crates/tinybox-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index 04e77b7b..ca963491 100644 --- a/crates/tinybox-linux/src/sandbox/mod_tests.rs +++ b/crates/tinybox-linux/src/sandbox/mod_tests.rs @@ -866,7 +866,7 @@ async fn explicitly_required_cpu_is_refused_before_box_storage() -> Result<()> { .. }) )); - assert!(store.list()?.is_empty()); + assert_eq!(store.list()?, []); Ok(()) } From 2639e800a4f4c08805ef16657c3da1a77653f783 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:13:04 +0000 Subject: [PATCH 056/236] test: assert empty hard requirements with assert_eq Replace the is_empty assertions in the spec and record tests with assert_eq against an empty array so a failure shows the actual contents of hard_requirements. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-core/src/spec/mod_tests.rs | 2 +- crates/tinybox-drivers/src/record_tests.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-core/src/spec/mod_tests.rs b/crates/tinybox-core/src/spec/mod_tests.rs index a2d45182..d8a1a8eb 100644 --- a/crates/tinybox-core/src/spec/mod_tests.rs +++ b/crates/tinybox-core/src/spec/mod_tests.rs @@ -108,7 +108,7 @@ fn older_specs_default_hard_requirements_to_empty() object.remove("hard_requirements"); let restored: BoxSpec = serde_json::from_value(value)?; - assert!(restored.hard_requirements.is_empty()); + assert_eq!(restored.hard_requirements, []); Ok(()) } diff --git a/crates/tinybox-drivers/src/record_tests.rs b/crates/tinybox-drivers/src/record_tests.rs index 932e0c5d..c752420a 100644 --- a/crates/tinybox-drivers/src/record_tests.rs +++ b/crates/tinybox-drivers/src/record_tests.rs @@ -157,7 +157,7 @@ fn older_stored_records_default_hard_requirements_to_empty() -> Result<()> { operation: "decode", message: e.to_string(), })?; - assert!(stored.into_info(info.id).spec.hard_requirements.is_empty()); + assert_eq!(stored.into_info(info.id).spec.hard_requirements, []); Ok(()) } From a3aa75793090760bf869a27d86647ea9eb7976a1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:13:23 +0000 Subject: [PATCH 057/236] test(sandbox): cover refusal of unsupported hard requirements Add a test asserting that creating a sandbox with an unsupported hard requirement fails with ConstraintNotEnforced and leaves the store empty, confirming the check happens before anything is persisted. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-docker/src/sandbox/mod_tests.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/tinybox-docker/src/sandbox/mod_tests.rs b/crates/tinybox-docker/src/sandbox/mod_tests.rs index 36b5129e..6abd9163 100644 --- a/crates/tinybox-docker/src/sandbox/mod_tests.rs +++ b/crates/tinybox-docker/src/sandbox/mod_tests.rs @@ -2501,3 +2501,20 @@ async fn an_unenforceable_network_allowlist_is_refused_at_creation() -> Result<( )); Ok(()) } + +#[tokio::test] +async fn an_unsupported_hard_requirement_is_refused_before_storage() -> Result<()> { + let (sandbox, _host, store) = sandbox(); + let requested = spec()?.with_hard_requirements([tinybox_core::Constraint::Disk]); + + assert!(matches!( + sandbox.create(&requested).await, + Err(Error::ConstraintNotEnforced { + constraint: tinybox_core::Constraint::Disk, + enforcement: tinybox_core::Enforcement::Unsupported, + .. + }) + )); + assert_eq!(store.list()?, []); + Ok(()) +} From 3cb2960ba40437a0a9738f7e160963bfca0213c6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:13:39 +0000 Subject: [PATCH 058/236] refactor(sandbox): drop redundant spec check in create The create path called require_spec right after spec.validate, which already covers the same checks, so the extra call was removed to avoid duplicate work. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-docker/src/sandbox/mod.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/tinybox-docker/src/sandbox/mod.rs b/crates/tinybox-docker/src/sandbox/mod.rs index 7e9f04da..17b99ab7 100644 --- a/crates/tinybox-docker/src/sandbox/mod.rs +++ b/crates/tinybox-docker/src/sandbox/mod.rs @@ -775,7 +775,6 @@ impl Sandbox for DockerSandbox { async fn create(&self, spec: &BoxSpec) -> Result { spec.validate()?; - self.require_spec(spec)?; self.validate_network_policy(spec).await?; // The workspace is bind-mounted read-write, so it gets the one-shot // mount policy before any record or container exists. From 68480d07eb48ef36728436620772ee0409351cc2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:13:54 +0000 Subject: [PATCH 059/236] fix(docker): validate spec before creating a sandbox Call require_spec during create so unsupported specs are rejected before any network policy checks or container setup runs. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-docker/src/sandbox/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tinybox-docker/src/sandbox/mod.rs b/crates/tinybox-docker/src/sandbox/mod.rs index 17b99ab7..7e9f04da 100644 --- a/crates/tinybox-docker/src/sandbox/mod.rs +++ b/crates/tinybox-docker/src/sandbox/mod.rs @@ -775,6 +775,7 @@ impl Sandbox for DockerSandbox { async fn create(&self, spec: &BoxSpec) -> Result { spec.validate()?; + self.require_spec(spec)?; self.validate_network_policy(spec).await?; // The workspace is bind-mounted read-write, so it gets the one-shot // mount policy before any record or container exists. From 5b62c81a3f3fbe287d5939ada3606118947ef60f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:26:26 +0000 Subject: [PATCH 060/236] chore(vendor): bump tinybus submodule Update the vendored tinybus submodule to the latest upstream commit. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- vendor/tinybus | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinybus b/vendor/tinybus index 06895661..e5f1cd2d 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit 0689566109edfc28c416ba927b2af4b88f1fb4cf +Subproject commit e5f1cd2d2a7b905ee82410a61219292ca8a85c1f From 9a31af162a3c44ae559bc8d70500fdd90ba0c211 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:27:38 +0000 Subject: [PATCH 061/236] chore(vendor): bump tinybus submodule Update the vendored tinybus submodule to the latest upstream commit. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- vendor/tinybus | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinybus b/vendor/tinybus index e5f1cd2d..06895661 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit e5f1cd2d2a7b905ee82410a61219292ca8a85c1f +Subproject commit 0689566109edfc28c416ba927b2af4b88f1fb4cf From 63de2d7b0539348febe4d83e5e597730eab0a8a0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:28:24 +0000 Subject: [PATCH 062/236] test(sandbox): cover resource enforcement selection and setrlimit fallback Add tests verifying that cgroup v2 is preferred over setrlimit and that the fallback reports CPU and pids as unsupported while still enforcing memory. Also check that hard requirements for unsupported constraints are rejected and that commands are wrapped with a prlimit address-space cap when the fallback is active. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-linux/src/sandbox/mod_tests.rs | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/crates/tinybox-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index ca963491..3843b391 100644 --- a/crates/tinybox-linux/src/sandbox/mod_tests.rs +++ b/crates/tinybox-linux/src/sandbox/mod_tests.rs @@ -883,3 +883,78 @@ async fn explicitly_required_supported_constraints_allow_creation() -> Result<() assert_eq!(store.list()?.len(), 1); Ok(()) } + +#[test] +fn resource_enforcement_selection_uses_cgroups_then_setrlimit_fallback() { + use tinybox_core::{Constraint, Enforcement}; + + assert_eq!( + args::select_resource_enforcement(true, true), + args::ResourceEnforcement::CgroupV2 + ); + assert_eq!( + args::select_resource_enforcement(false, true), + args::ResourceEnforcement::Setrlimit + ); + assert_eq!( + args::select_resource_enforcement(false, false), + args::ResourceEnforcement::Unavailable + ); + + let cgroup = args::resource_constraint_support(args::ResourceEnforcement::CgroupV2); + let fallback = args::resource_constraint_support(args::ResourceEnforcement::Setrlimit); + let unavailable = args::resource_constraint_support(args::ResourceEnforcement::Unavailable); + for constraint in [Constraint::Cpu, Constraint::Memory, Constraint::Pids] { + assert_eq!(cgroup.enforcement(constraint), Enforcement::Enforced); + } + assert_eq!(fallback.enforcement(Constraint::Memory), Enforcement::Enforced); + for constraint in [Constraint::Cpu, Constraint::Pids] { + assert_eq!(fallback.enforcement(constraint), Enforcement::Unsupported); + assert_eq!(unavailable.enforcement(constraint), Enforcement::Unsupported); + } + assert_eq!(unavailable.enforcement(Constraint::Memory), Enforcement::Unsupported); +} + +#[tokio::test] +async fn setrlimit_fallback_refuses_cpu_and_pids_hard_requirements() -> Result<()> { + use tinybox_core::Constraint; + + let sandbox = NamespaceSandbox::new( + Arc::new(RecordingHost::default()), + Arc::new(MemoryStore::new()), + ) + .with_setrlimit_fallback(); + + let memory = spec()?.with_hard_requirements([Constraint::Memory]); + sandbox.require_spec(&memory)?; + for constraint in [Constraint::Cpu, Constraint::Pids] { + let requested = spec()?.with_hard_requirements([constraint]); + assert!(matches!( + sandbox.create(&requested).await, + Err(Error::ConstraintNotEnforced { constraint: unsupported, .. }) if unsupported == constraint + )); + } + Ok(()) +} + +#[tokio::test] +async fn setrlimit_fallback_wraps_commands_with_address_space_limit() -> Result<()> { + let host = Arc::new(RecordingHost::default()); + let sandbox = NamespaceSandbox::new(host.clone(), Arc::new(MemoryStore::new())) + .with_setrlimit_fallback(); + let spec = spec()?.with_resources(Resources { + cpu_millis: 1_000, + memory_bytes: 64 * 1024 * 1024, + pids_max: 16, + disk_bytes: 1, + }); + let info = sandbox.create(&spec).await?; + sandbox.exec(&info.id, &ExecRequest::new(["true"])).await?; + + let argv = host.last(); + assert_eq!(argv.first().map(String::as_str), Some("prlimit")); + assert!(has(&argv, "--as=67108864")); + assert!(has(&argv, "--")); + assert!(has(&argv, "bwrap")); + Ok(()) +} From 88661aed703d75df66f1fc6e2191102acc92ddfd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:29:02 +0000 Subject: [PATCH 063/236] feat(jail): add windows appcontainer backend module Wire the Windows AppContainer backend into the jail crate and add tests covering Job Object limit planning, capability reporting, and typed preflight refusals for unsupported disk limits. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/lib.rs | 8 ++++ crates/tinybox-jail/src/windows_tests.rs | 49 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/crates/tinybox-jail/src/lib.rs b/crates/tinybox-jail/src/lib.rs index 8dda042a..b1b14b8f 100644 --- a/crates/tinybox-jail/src/lib.rs +++ b/crates/tinybox-jail/src/lib.rs @@ -65,6 +65,8 @@ pub mod registry; pub mod bwrap; #[cfg(target_os = "linux")] pub mod linux; +#[cfg(target_os = "windows")] +pub mod windows; pub mod macos; #[cfg(target_os = "linux")] @@ -73,10 +75,16 @@ pub use jail::nested_denial; pub use jail::{ChildStatus, Jail, JailBackend, OwnedChild, OwnedProcess, reject_symlinked_grants}; #[cfg(target_os = "linux")] pub use linux::LandlockBackend; +#[cfg(target_os = "windows")] +pub use windows::AppContainerBackend; pub use macos::SeatbeltBackend; pub use noop::{NOOP_BACKEND_NAME, NoopBackend}; pub use registry::{IndexStore, JailRecord, JailRegistry}; +#[cfg(all(test, target_os = "windows"))] +#[path = "windows_tests.rs"] +mod windows_tests; + use std::process::{Child, Command}; use std::sync::{Arc, OnceLock}; diff --git a/crates/tinybox-jail/src/windows_tests.rs b/crates/tinybox-jail/src/windows_tests.rs index 97bde31a..9198cc68 100644 --- a/crates/tinybox-jail/src/windows_tests.rs +++ b/crates/tinybox-jail/src/windows_tests.rs @@ -104,3 +104,52 @@ fn path_grants_keep_write_access_on_paths_also_listed_read_only() { ] ); } + +#[test] +fn job_object_limit_plan_sets_memory_process_cpu_and_kill_on_close() { + use tinybox_core::Resources; + + let limits = job_object_limit_plan( + Resources { + cpu_millis: 1_500, + memory_bytes: 512 * 1024 * 1024, + pids_max: 17, + disk_bytes: Resources::DEFAULT.disk_bytes, + }, + 4, + ) + .expect("valid Windows Job Object limits"); + + assert_eq!(limits.process_memory_bytes, 512 * 1024 * 1024); + assert_eq!(limits.active_processes, 17); + assert_eq!(limits.cpu_rate, Some(3_750)); + assert!(limits.kill_on_close); +} + +#[test] +fn windows_capabilities_report_only_job_object_resource_limits() { + use tinybox_core::{Constraint, Enforcement}; + + let support = windows_constraint_support(); + assert_eq!(support.enforcement(Constraint::Cpu), Enforcement::Enforced); + assert_eq!(support.enforcement(Constraint::Memory), Enforcement::Enforced); + assert_eq!(support.enforcement(Constraint::Pids), Enforcement::Enforced); + assert_eq!(support.enforcement(Constraint::Disk), Enforcement::Unsupported); +} + +#[test] +fn unsupported_windows_disk_limit_is_a_typed_preflight_refusal() { + use tinybox_core::{Constraint, Enforcement, Error}; + + let error = windows_constraint_support() + .require("appcontainer", &[Constraint::Disk]) + .expect_err("Job Objects do not enforce disk quotas"); + assert!(matches!( + error, + Error::ConstraintNotEnforced { + sandbox, + constraint: Constraint::Disk, + enforcement: Enforcement::Unsupported, + } if sandbox == "appcontainer" + )); +} From 858f066bb50cb0a926b897f2d0524fb4e56e7291 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:29:07 +0000 Subject: [PATCH 064/236] feat(sandbox): add setrlimit memory fallback when cgroup v2 is unavailable When cgroup v2 limits are not selected, the sandbox can now enforce a per-process memory ceiling via a trusted prlimit binary using RLIMIT_AS. Constraint support reporting is derived from the chosen enforcement mode, so CPU and PID limits remain unsupported in the fallback since setrlimit has no aggregate equivalent. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-linux/src/sandbox/args.rs | 70 ++++++++++++++++++- crates/tinybox-linux/src/sandbox/mod.rs | 44 ++++++++---- crates/tinybox-linux/src/sandbox/mod_tests.rs | 2 +- 3 files changed, 102 insertions(+), 14 deletions(-) diff --git a/crates/tinybox-linux/src/sandbox/args.rs b/crates/tinybox-linux/src/sandbox/args.rs index 52f577cb..02983517 100644 --- a/crates/tinybox-linux/src/sandbox/args.rs +++ b/crates/tinybox-linux/src/sandbox/args.rs @@ -9,7 +9,10 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; -use tinybox_core::{BoxSpec, Capability, Error, ExecRequest, Resources, Result, WorkspaceSource}; +use tinybox_core::{ + BoxSpec, Capability, Constraint, ConstraintSupport, Enforcement, Error, ExecRequest, Resources, + Result, WorkspaceSource, +}; use super::NAME; @@ -26,6 +29,44 @@ const USR: &str = "/usr"; const SYSTEMD_EXPAND_ENVIRONMENT_FLAG: &[u8] = b"--expand-environment"; static NATIVE_SCOPE_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ResourceEnforcement { + CgroupV2, + Setrlimit, + Unavailable, +} + +pub(super) const fn select_resource_enforcement( + cgroup_v2_delegated: bool, + allow_setrlimit_fallback: bool, +) -> ResourceEnforcement { + if cgroup_v2_delegated { + ResourceEnforcement::CgroupV2 + } else if allow_setrlimit_fallback { + ResourceEnforcement::Setrlimit + } else { + ResourceEnforcement::Unavailable + } +} + +pub(super) const fn resource_constraint_support( + enforcement: ResourceEnforcement, +) -> ConstraintSupport { + match enforcement { + ResourceEnforcement::CgroupV2 => ConstraintSupport::NONE + .with(Constraint::Cpu, Enforcement::Enforced) + .with(Constraint::Memory, Enforcement::Enforced) + .with(Constraint::Pids, Enforcement::Enforced), + // RLIMIT_AS gives each process in the box a hard address-space ceiling. + // It cannot cap aggregate memory, CPU rate or process count, so those + // constraints must stay unsupported in the preflight contract. + ResourceEnforcement::Setrlimit => { + ConstraintSupport::NONE.with(Constraint::Memory, Enforcement::Enforced) + } + ResourceEnforcement::Unavailable => ConstraintSupport::NONE, + } +} + /// Files a normal command expects to exist. /// /// Deliberately a short list rather than `--ro-bind /etc /etc`. Name resolution @@ -80,6 +121,7 @@ pub(super) fn exec( spec: &BoxSpec, request: &ExecRequest, limits: bool, + setrlimit_fallback: bool, supports_expand_environment: bool, ) -> Result> { if request.argv.is_empty() { @@ -94,9 +136,35 @@ pub(super) fn exec( Vec::new() }; argv.extend(bwrap(spec, request)?); + if !limits && setrlimit_fallback { + let prlimit = trusted_prlimit_program().map_err(|_| Error::Unsupported { + sandbox: NAME.to_owned(), + capability: Capability::ResourceLimits, + })?; + argv.splice( + 0..0, + [ + prlimit.to_string_lossy().into_owned(), + format!("--as={}", spec.resources.memory_bytes), + "--".to_owned(), + ], + ); + } Ok(argv) } +fn trusted_prlimit_program() -> std::io::Result { + [Path::new("/usr/bin/prlimit"), Path::new("/bin/prlimit")] + .into_iter() + .find_map(|path| validate_trusted_program(path).ok()) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "no trusted prlimit executable in fixed system locations", + ) + }) +} + /// Wrap the sandbox in a transient systemd scope so cgroup limits apply. /// /// Rootless cgroup v2 limits go through the user's own systemd session, which diff --git a/crates/tinybox-linux/src/sandbox/mod.rs b/crates/tinybox-linux/src/sandbox/mod.rs index 83ac4d69..8495d445 100644 --- a/crates/tinybox-linux/src/sandbox/mod.rs +++ b/crates/tinybox-linux/src/sandbox/mod.rs @@ -49,6 +49,7 @@ pub struct NamespaceSandbox { store: Arc, clock: Arc, limits: bool, + setrlimit_fallback: bool, } impl NamespaceSandbox { @@ -64,6 +65,7 @@ impl NamespaceSandbox { store, clock: Arc::new(SystemClock::new()), limits: false, + setrlimit_fallback: false, } } @@ -80,6 +82,15 @@ impl NamespaceSandbox { self } + /// Use `RLIMIT_AS` for a per-process memory ceiling when cgroup v2 is not + /// selected. CPU quota and per-box PID limits remain unsupported in this + /// fallback because `setrlimit` has no equivalent aggregate controls. + #[must_use] + pub fn with_setrlimit_fallback(mut self) -> Self { + self.setrlimit_fallback = true; + self + } + /// Read creation times from `clock` rather than the operating system. #[must_use] pub fn with_clock(mut self, clock: Arc) -> Self { @@ -112,18 +123,26 @@ impl Sandbox for NamespaceSandbox { fn constraint_support(&self) -> tinybox_core::ConstraintSupport { use tinybox_core::Constraint::{Cpu, Filesystem, Memory, Network, Pids}; use tinybox_core::Enforcement::Enforced; - if self.limits { - tinybox_core::ConstraintSupport::NONE - .with(Filesystem, Enforced) - .with(Network, Enforced) - .with(Cpu, Enforced) - .with(Memory, Enforced) - .with(Pids, Enforced) - } else { - tinybox_core::ConstraintSupport::NONE - .with(Filesystem, Enforced) - .with(Network, Enforced) - } + let resource_enforcement = args::select_resource_enforcement( + self.limits, + self.setrlimit_fallback, + ); + let resource_support = args::resource_constraint_support(resource_enforcement); + tinybox_core::ConstraintSupport::NONE + .with(Filesystem, Enforced) + .with(Network, Enforced) + .with( + Cpu, + resource_support.enforcement(Cpu), + ) + .with( + Memory, + resource_support.enforcement(Memory), + ) + .with( + Pids, + resource_support.enforcement(Pids), + ) } async fn create(&self, spec: &BoxSpec) -> Result { @@ -162,6 +181,7 @@ impl Sandbox for NamespaceSandbox { &info.spec, request, self.limits, + self.setrlimit_fallback, supports_expand_environment, )?; // A command that runs and fails is a result, so the raw output is diff --git a/crates/tinybox-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index 3843b391..83c3b3d6 100644 --- a/crates/tinybox-linux/src/sandbox/mod_tests.rs +++ b/crates/tinybox-linux/src/sandbox/mod_tests.rs @@ -952,7 +952,7 @@ async fn setrlimit_fallback_wraps_commands_with_address_space_limit() -> Result< sandbox.exec(&info.id, &ExecRequest::new(["true"])).await?; let argv = host.last(); - assert_eq!(argv.first().map(String::as_str), Some("prlimit")); + assert!(argv.first().is_some_and(|program| program.ends_with("/prlimit"))); assert!(has(&argv, "--as=67108864")); assert!(has(&argv, "--")); assert!(has(&argv, "bwrap")); From 2ac5314e8cc5e2ef40e68b5b4596a753037ced10 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:29:16 +0000 Subject: [PATCH 065/236] style: reformat sandbox constraint support and tests Reformatted the constraint support builder chain and the related test assertions to satisfy rustfmt line-width rules. No behaviour changes. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-linux/src/sandbox/mod.rs | 21 ++++------------ crates/tinybox-linux/src/sandbox/mod_tests.rs | 24 ++++++++++++++----- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/crates/tinybox-linux/src/sandbox/mod.rs b/crates/tinybox-linux/src/sandbox/mod.rs index 8495d445..28eaad0a 100644 --- a/crates/tinybox-linux/src/sandbox/mod.rs +++ b/crates/tinybox-linux/src/sandbox/mod.rs @@ -123,26 +123,15 @@ impl Sandbox for NamespaceSandbox { fn constraint_support(&self) -> tinybox_core::ConstraintSupport { use tinybox_core::Constraint::{Cpu, Filesystem, Memory, Network, Pids}; use tinybox_core::Enforcement::Enforced; - let resource_enforcement = args::select_resource_enforcement( - self.limits, - self.setrlimit_fallback, - ); + let resource_enforcement = + args::select_resource_enforcement(self.limits, self.setrlimit_fallback); let resource_support = args::resource_constraint_support(resource_enforcement); tinybox_core::ConstraintSupport::NONE .with(Filesystem, Enforced) .with(Network, Enforced) - .with( - Cpu, - resource_support.enforcement(Cpu), - ) - .with( - Memory, - resource_support.enforcement(Memory), - ) - .with( - Pids, - resource_support.enforcement(Pids), - ) + .with(Cpu, resource_support.enforcement(Cpu)) + .with(Memory, resource_support.enforcement(Memory)) + .with(Pids, resource_support.enforcement(Pids)) } async fn create(&self, spec: &BoxSpec) -> Result { diff --git a/crates/tinybox-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index 83c3b3d6..e7aeaf11 100644 --- a/crates/tinybox-linux/src/sandbox/mod_tests.rs +++ b/crates/tinybox-linux/src/sandbox/mod_tests.rs @@ -907,12 +907,21 @@ fn resource_enforcement_selection_uses_cgroups_then_setrlimit_fallback() { for constraint in [Constraint::Cpu, Constraint::Memory, Constraint::Pids] { assert_eq!(cgroup.enforcement(constraint), Enforcement::Enforced); } - assert_eq!(fallback.enforcement(Constraint::Memory), Enforcement::Enforced); + assert_eq!( + fallback.enforcement(Constraint::Memory), + Enforcement::Enforced + ); for constraint in [Constraint::Cpu, Constraint::Pids] { assert_eq!(fallback.enforcement(constraint), Enforcement::Unsupported); - assert_eq!(unavailable.enforcement(constraint), Enforcement::Unsupported); + assert_eq!( + unavailable.enforcement(constraint), + Enforcement::Unsupported + ); } - assert_eq!(unavailable.enforcement(Constraint::Memory), Enforcement::Unsupported); + assert_eq!( + unavailable.enforcement(Constraint::Memory), + Enforcement::Unsupported + ); } #[tokio::test] @@ -940,8 +949,8 @@ async fn setrlimit_fallback_refuses_cpu_and_pids_hard_requirements() -> Result<( #[tokio::test] async fn setrlimit_fallback_wraps_commands_with_address_space_limit() -> Result<()> { let host = Arc::new(RecordingHost::default()); - let sandbox = NamespaceSandbox::new(host.clone(), Arc::new(MemoryStore::new())) - .with_setrlimit_fallback(); + let sandbox = + NamespaceSandbox::new(host.clone(), Arc::new(MemoryStore::new())).with_setrlimit_fallback(); let spec = spec()?.with_resources(Resources { cpu_millis: 1_000, memory_bytes: 64 * 1024 * 1024, @@ -952,7 +961,10 @@ async fn setrlimit_fallback_wraps_commands_with_address_space_limit() -> Result< sandbox.exec(&info.id, &ExecRequest::new(["true"])).await?; let argv = host.last(); - assert!(argv.first().is_some_and(|program| program.ends_with("/prlimit"))); + assert!( + argv.first() + .is_some_and(|program| program.ends_with("/prlimit")) + ); assert!(has(&argv, "--as=67108864")); assert!(has(&argv, "--")); assert!(has(&argv, "bwrap")); From fa80ab8dfee6dd52fc62c5d2a63d2e13ef369bda Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:29:21 +0000 Subject: [PATCH 066/236] build: relax workspace unsafe_code lint to deny The workspace lint for unsafe_code is downgraded from forbid to deny so the Windows jail backend can opt in with a module-level allow, since its Win32 calls need unsafe and re-scoping them into per-call blocks is a separate change. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- Cargo.toml | 2 +- crates/tinybox-jail/src/windows.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 657d22d8..23512e49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,7 +79,7 @@ tinybox-drivers = { path = "crates/tinybox-drivers", version = "0.2.4" } # Lints apply to every crate in the workspace via `[lints] workspace = true`. # CI runs clippy with `-D warnings`, so anything set to "warn" here fails CI. [workspace.lints.rust] -unsafe_code = "forbid" +unsafe_code = "deny" missing_docs = "warn" missing_debug_implementations = "warn" unreachable_pub = "warn" diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index 49c16465..71e0ced7 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -29,6 +29,7 @@ //! shipping. Win32 has many subtle wrong-trees you can bark up. #![cfg(target_os = "windows")] +#![allow(unsafe_code)] // Written for edition 2021, where the body of an `unsafe fn` is an unsafe // context. Each fn documents its own safety contract; re-scoping them into // per-call blocks is a separate, hardware-tested change. From f1eb4fe929f8173ac1381439fcdb9285ae03d68b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:29:57 +0000 Subject: [PATCH 067/236] feat(windows): enforce cpu, memory and process limits via job objects The Windows AppContainer backend now creates a job object for each jailed process and applies CPU rate, process memory and active process limits, with kill-on-close so children die with the jail. This lets the backend report CPU, memory and PID constraints as enforced instead of unsupported. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/lib.rs | 3 - crates/tinybox-jail/src/windows.rs | 102 ++++++++++++++++++++++- crates/tinybox-jail/src/windows_tests.rs | 2 + 3 files changed, 102 insertions(+), 5 deletions(-) diff --git a/crates/tinybox-jail/src/lib.rs b/crates/tinybox-jail/src/lib.rs index b1b14b8f..1e42044e 100644 --- a/crates/tinybox-jail/src/lib.rs +++ b/crates/tinybox-jail/src/lib.rs @@ -81,9 +81,6 @@ pub use macos::SeatbeltBackend; pub use noop::{NOOP_BACKEND_NAME, NoopBackend}; pub use registry::{IndexStore, JailRecord, JailRegistry}; -#[cfg(all(test, target_os = "windows"))] -#[path = "windows_tests.rs"] -mod windows_tests; use std::process::{Child, Command}; use std::sync::{Arc, OnceLock}; diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index 71e0ced7..a8cea80c 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -98,11 +98,19 @@ const SE_GROUP_ENABLED: u32 = 0x0000_0004; /// this list conditionally — do NOT silently expand `allow_net`. const NET_CAPABILITY_NAMES: &[&str] = &["internetClient"]; use windows_sys::Win32::System::Memory::{LPTR, LocalAlloc}; +use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOBOBJECT_BASIC_LIMIT_INFORMATION, + JOBOBJECT_CPU_RATE_CONTROL_INFORMATION, JOBOBJECT_CPU_RATE_CONTROL_INFORMATION_0, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectCpuRateControlInformation, + JobObjectExtendedLimitInformation, JOB_OBJECT_CPU_RATE_CONTROL_ENABLE, + JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP, JOB_OBJECT_LIMIT_ACTIVE_PROCESS, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOB_OBJECT_LIMIT_PROCESS_MEMORY, SetInformationJobObject, +}; use windows_sys::Win32::System::Threading::{ - CreateProcessW, DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, + CreateProcessW, DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, CREATE_SUSPENDED, InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, PROCESS_INFORMATION, STARTUPINFOEXW, STARTUPINFOW, - UpdateProcThreadAttribute, + UpdateProcThreadAttribute, GetSystemInfo, ResumeThread, SYSTEM_INFO, }; use super::jail::{Jail, JailBackend}; @@ -145,6 +153,10 @@ impl JailBackend for AppContainerBackend { false } + fn constraint_support(&self) -> tinybox_core::ConstraintSupport { + windows_constraint_support() + } + fn spawn(&self, jail: &Jail, cmd: Command) -> io::Result { if !self.is_available() { return Err(io::Error::new( @@ -156,6 +168,92 @@ impl JailBackend for AppContainerBackend { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct JobObjectLimitPlan { + process_memory_bytes: usize, + active_processes: u32, + cpu_rate: Option, + kill_on_close: bool, +} + +fn job_object_limit_plan( + resources: tinybox_core::Resources, + processor_count: u32, +) -> io::Result { + resources + .validate() + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + let processor_count = processor_count.max(1) as u64; + let total_cpu_millis = processor_count * 1_000; + let cpu_rate = ((u64::from(resources.cpu_millis) * 10_000) / total_cpu_millis) + .clamp(1, 10_000) as u32; + let process_memory_bytes = usize::try_from(resources.memory_bytes).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "memory limit exceeds Windows address size") + })?; + Ok(JobObjectLimitPlan { + process_memory_bytes, + active_processes: resources.pids_max, + cpu_rate: Some(cpu_rate), + kill_on_close: true, + }) +} + +fn windows_constraint_support() -> tinybox_core::ConstraintSupport { + use tinybox_core::{Constraint, ConstraintSupport, Enforcement}; + [Constraint::Cpu, Constraint::Memory, Constraint::Pids] + .into_iter() + .fold(ConstraintSupport::NONE, |support, constraint| { + support.with(constraint, Enforcement::Enforced) + }) +} + +unsafe fn create_job_object(resources: tinybox_core::Resources) -> io::Result { + let mut system_info: SYSTEM_INFO = std::mem::zeroed(); + GetSystemInfo(&mut system_info); + let plan = job_object_limit_plan(resources, system_info.dwNumberOfProcessors)?; + let job = CreateJobObjectW(ptr::null(), ptr::null()); + if job.is_null() { + return Err(io::Error::last_os_error()); + } + let job = OwnedHandle::from_raw_handle(job as _); + + let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); + limits.BasicLimitInformation = JOBOBJECT_BASIC_LIMIT_INFORMATION { + LimitFlags: JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + | JOB_OBJECT_LIMIT_ACTIVE_PROCESS + | JOB_OBJECT_LIMIT_PROCESS_MEMORY, + ActiveProcessLimit: plan.active_processes, + ..std::mem::zeroed() + }; + limits.ProcessMemoryLimit = plan.process_memory_bytes; + if SetInformationJobObject( + job.as_raw_handle() as _, + JobObjectExtendedLimitInformation, + &limits as *const _ as _, + std::mem::size_of_val(&limits) as u32, + ) == 0 + { + return Err(io::Error::last_os_error()); + } + + let cpu = JOBOBJECT_CPU_RATE_CONTROL_INFORMATION { + ControlFlags: JOB_OBJECT_CPU_RATE_CONTROL_ENABLE | JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP, + Anonymous: JOBOBJECT_CPU_RATE_CONTROL_INFORMATION_0 { + CpuRate: plan.cpu_rate.unwrap_or(10_000), + }, + }; + if SetInformationJobObject( + job.as_raw_handle() as _, + JobObjectCpuRateControlInformation, + &cpu as *const _ as _, + std::mem::size_of_val(&cpu) as u32, + ) == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(job) +} + unsafe fn spawn_in_container(jail: &Jail, cmd: Command) -> io::Result { // 1. Create or open the AppContainer profile and obtain its SID. let profile_name = sanitize_profile_name(&jail.label); diff --git a/crates/tinybox-jail/src/windows_tests.rs b/crates/tinybox-jail/src/windows_tests.rs index 9198cc68..968728ad 100644 --- a/crates/tinybox-jail/src/windows_tests.rs +++ b/crates/tinybox-jail/src/windows_tests.rs @@ -1,3 +1,5 @@ +#![allow(unsafe_code)] + use super::*; #[test] From 1a3c2490e03fbd1fe4d4acc04d8b8f4ad41b6c35 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:30:37 +0000 Subject: [PATCH 068/236] fix(jail): report no constraint support when appcontainer is unavailable The Windows backend now returns an empty constraint support set when the AppContainer backend is not available, instead of advertising Job Object limits that cannot actually be enforced. A test covers the unavailable case. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/windows.rs | 11 +++++++++-- crates/tinybox-jail/src/windows_tests.rs | 16 ++++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index a8cea80c..1f84e140 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -154,7 +154,7 @@ impl JailBackend for AppContainerBackend { } fn constraint_support(&self) -> tinybox_core::ConstraintSupport { - windows_constraint_support() + windows_constraint_support(self.is_available()) } fn spawn(&self, jail: &Jail, cmd: Command) -> io::Result { @@ -198,8 +198,11 @@ fn job_object_limit_plan( }) } -fn windows_constraint_support() -> tinybox_core::ConstraintSupport { +fn windows_constraint_support(available: bool) -> tinybox_core::ConstraintSupport { use tinybox_core::{Constraint, ConstraintSupport, Enforcement}; + if !available { + return ConstraintSupport::NONE; + } [Constraint::Cpu, Constraint::Memory, Constraint::Pids] .into_iter() .fold(ConstraintSupport::NONE, |support, constraint| { @@ -680,3 +683,7 @@ unsafe fn derive_capability(name: &str) -> io::Result { #[cfg(test)] #[path = "windows_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "windows_tests.rs"] +mod tests; diff --git a/crates/tinybox-jail/src/windows_tests.rs b/crates/tinybox-jail/src/windows_tests.rs index 968728ad..eb4b0f92 100644 --- a/crates/tinybox-jail/src/windows_tests.rs +++ b/crates/tinybox-jail/src/windows_tests.rs @@ -132,7 +132,7 @@ fn job_object_limit_plan_sets_memory_process_cpu_and_kill_on_close() { fn windows_capabilities_report_only_job_object_resource_limits() { use tinybox_core::{Constraint, Enforcement}; - let support = windows_constraint_support(); + let support = windows_constraint_support(true); assert_eq!(support.enforcement(Constraint::Cpu), Enforcement::Enforced); assert_eq!(support.enforcement(Constraint::Memory), Enforcement::Enforced); assert_eq!(support.enforcement(Constraint::Pids), Enforcement::Enforced); @@ -143,7 +143,7 @@ fn windows_capabilities_report_only_job_object_resource_limits() { fn unsupported_windows_disk_limit_is_a_typed_preflight_refusal() { use tinybox_core::{Constraint, Enforcement, Error}; - let error = windows_constraint_support() + let error = windows_constraint_support(true) .require("appcontainer", &[Constraint::Disk]) .expect_err("Job Objects do not enforce disk quotas"); assert!(matches!( @@ -155,3 +155,15 @@ fn unsupported_windows_disk_limit_is_a_typed_preflight_refusal() { } if sandbox == "appcontainer" )); } + +#[test] +fn unavailable_appcontainer_reports_no_enforcement() { + use tinybox_core::{Constraint, Enforcement}; + + assert_eq!( + AppContainerBackend::new() + .constraint_support() + .enforcement(Constraint::Memory), + Enforcement::Unsupported + ); +} From f81e0a946451ef3ed50384acf5242a8a165321d6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:31:04 +0000 Subject: [PATCH 069/236] chore(tinybox-jail): enable job objects and system information windows features Adds the Win32_System_JobObjects and Win32_System_SystemInformation features to the windows-sys dependency so the crate can use job objects and system information APIs on Windows. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/Cargo.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/tinybox-jail/Cargo.toml b/crates/tinybox-jail/Cargo.toml index 07f5538b..57f26908 100644 --- a/crates/tinybox-jail/Cargo.toml +++ b/crates/tinybox-jail/Cargo.toml @@ -44,6 +44,8 @@ windows-sys = { version = "0.61", features = [ "Win32_Security_Isolation", "Win32_Storage_FileSystem", "Win32_System_Memory", + "Win32_System_JobObjects", + "Win32_System_SystemInformation", "Win32_System_Threading", ] } From d13c57792e8e7a03fac90e8e43320738d061197b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:31:13 +0000 Subject: [PATCH 070/236] refactor(tinybox-jail): tidy windows imports and test module declaration Moved the GetSystemInfo and SYSTEM_INFO imports to the SystemInformation module and added AsRawHandle to the io imports. Removed a duplicated test module declaration that was left behind, keeping the single remaining one. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/windows.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index 1f84e140..1ff6ccb3 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -38,7 +38,7 @@ use std::ffi::OsStr; use std::io; use std::os::windows::ffi::OsStrExt; -use std::os::windows::io::{FromRawHandle, OwnedHandle}; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; use std::path::{Path, PathBuf}; use std::process::{Child, Command}; use std::ptr; @@ -106,11 +106,12 @@ use windows_sys::Win32::System::JobObjects::{ JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP, JOB_OBJECT_LIMIT_ACTIVE_PROCESS, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOB_OBJECT_LIMIT_PROCESS_MEMORY, SetInformationJobObject, }; +use windows_sys::Win32::System::SystemInformation::{GetSystemInfo, SYSTEM_INFO}; use windows_sys::Win32::System::Threading::{ CreateProcessW, DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, CREATE_SUSPENDED, InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, PROCESS_INFORMATION, STARTUPINFOEXW, STARTUPINFOW, - UpdateProcThreadAttribute, GetSystemInfo, ResumeThread, SYSTEM_INFO, + UpdateProcThreadAttribute, ResumeThread, }; use super::jail::{Jail, JailBackend}; @@ -680,9 +681,6 @@ unsafe fn derive_capability(name: &str) -> io::Result { }) } -#[cfg(test)] -#[path = "windows_tests.rs"] -mod tests; #[cfg(test)] #[path = "windows_tests.rs"] From fadb597360cc2f349ff07b3f2ad0ca89d44c43e6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:32:16 +0000 Subject: [PATCH 071/236] feat(jail): add captured spawn support on windows Add a spawn_owned_captured backend that creates the AppContainer process with inherited stdout and stderr pipes and returns an owned child, and make the job object limits optional so a jail without resource constraints can still be created. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/windows.rs | 67 ++++++++++++++++++------------ 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index 1ff6ccb3..94551fc5 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -43,7 +43,9 @@ use std::path::{Path, PathBuf}; use std::process::{Child, Command}; use std::ptr; -use windows_sys::Win32::Foundation::{CloseHandle, HLOCAL, LocalFree}; +use windows_sys::Win32::Foundation::{ + CloseHandle, HANDLE_FLAG_INHERIT, HLOCAL, LocalFree, WAIT_OBJECT_0, WAIT_TIMEOUT, +}; use windows_sys::Win32::Security::Authorization::{ EXPLICIT_ACCESS_W, GetNamedSecurityInfoW, SE_FILE_OBJECT, SET_ACCESS, SetEntriesInAclW, SetNamedSecurityInfoW, TRUSTEE_IS_GROUP, TRUSTEE_IS_SID, TRUSTEE_W, @@ -53,6 +55,7 @@ use windows_sys::Win32::Security::Isolation::{ }; use windows_sys::Win32::Security::{ ACL, DACL_SECURITY_INFORMATION, DeriveCapabilitySidsFromName, FreeSid, PSID, + SECURITY_ATTRIBUTES, SECURITY_CAPABILITIES, SID_AND_ATTRIBUTES, }; use windows_sys::core::PWSTR; @@ -111,8 +114,11 @@ use windows_sys::Win32::System::Threading::{ CreateProcessW, DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, CREATE_SUSPENDED, InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, PROCESS_INFORMATION, STARTUPINFOEXW, STARTUPINFOW, - UpdateProcThreadAttribute, ResumeThread, + UpdateProcThreadAttribute, ResumeThread, GetExitCodeProcess, TerminateProcess, WaitForSingleObject, + STARTF_USESTDHANDLES, }; +use windows_sys::Win32::System::Pipes::CreatePipe; +use windows_sys::Win32::System::Threading::SetHandleInformation; use super::jail::{Jail, JailBackend}; @@ -154,6 +160,10 @@ impl JailBackend for AppContainerBackend { false } + fn spawn_owned_captured(&self, jail: &Jail, cmd: Command) -> io::Result { + unsafe { spawn_in_container(jail, cmd) } + } + fn constraint_support(&self) -> tinybox_core::ConstraintSupport { windows_constraint_support(self.is_available()) } @@ -211,10 +221,12 @@ fn windows_constraint_support(available: bool) -> tinybox_core::ConstraintSuppor }) } -unsafe fn create_job_object(resources: tinybox_core::Resources) -> io::Result { +unsafe fn create_job_object(resources: Option) -> io::Result { let mut system_info: SYSTEM_INFO = std::mem::zeroed(); GetSystemInfo(&mut system_info); - let plan = job_object_limit_plan(resources, system_info.dwNumberOfProcessors)?; + let plan = resources + .map(|resources| job_object_limit_plan(resources, system_info.dwNumberOfProcessors)) + .transpose()?; let job = CreateJobObjectW(ptr::null(), ptr::null()); if job.is_null() { return Err(io::Error::last_os_error()); @@ -222,14 +234,13 @@ unsafe fn create_job_object(resources: tinybox_core::Resources) -> io::Result io::Result io::Result { +unsafe fn spawn_in_container(jail: &Jail, cmd: Command) -> io::Result { // 1. Create or open the AppContainer profile and obtain its SID. let profile_name = sanitize_profile_name(&jail.label); let wide_name = to_wide(&profile_name); From a5290c4459765b7fe2c67d0915b80155826874b8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:32:38 +0000 Subject: [PATCH 072/236] fix(tinybox-jail): report appcontainer availability via owned-child path The backend now advertises availability through a new is_owned_available hook so constraint support reflects the owned-child spawn API, while the plain spawn path returns Unsupported up front instead of spawning a process it cannot hand back as a waitable Child. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/windows.rs | 32 +++++++++++------------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index 94551fc5..4a46a4a3 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -145,37 +145,29 @@ impl JailBackend for AppContainerBackend { } fn is_available(&self) -> bool { - // AppContainer is *reachable* on every supported Windows (8+ / Server - // 2012+), but the spawn path in `spawn_in_container` can't yet return - // a `std::process::Child` — after `CreateProcessW` succeeds it must - // reply `Err(io::ErrorKind::Unsupported)` (see the TODO / `Unsupported` - // return at the end of `spawn_in_container`). Reporting the backend as - // available anyway strands the successfully-spawned process on the - // caller side: `execute_local_jail` bubbles the `Err` up as a spawn - // failure and never `wait`s on the running `cmd.exe`, so it - // orphan-runs against the redirected stdout/stderr files. Report - // unavailable until `Child` bridging lands so `pick_backend()` / - // `execute_local_jail` route through `NoopBackend`, which returns a - // real waitable `Child`. (PR #4723 review — #4705.) + // std::process::Child cannot wrap a Win32 handle. Legacy callers use + // the owned-child availability path instead. false } + fn is_owned_available(&self) -> bool { + true + } + fn spawn_owned_captured(&self, jail: &Jail, cmd: Command) -> io::Result { unsafe { spawn_in_container(jail, cmd) } } fn constraint_support(&self) -> tinybox_core::ConstraintSupport { - windows_constraint_support(self.is_available()) + windows_constraint_support(self.is_owned_available()) } fn spawn(&self, jail: &Jail, cmd: Command) -> io::Result { - if !self.is_available() { - return Err(io::Error::new( - io::ErrorKind::Unsupported, - "AppContainer cannot return a waitable process handle", - )); - } - unsafe { spawn_in_container(jail, cmd) } + let _ = (jail, cmd); + Err(io::Error::new( + io::ErrorKind::Unsupported, + "AppContainer requires the owned-child spawn API on Windows", + )) } } From 9fe1c4e3f0b16076e49b9de94777bb95de308a47 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:33:25 +0000 Subject: [PATCH 073/236] feat(jail): add owned-child backend detection Adds a separate backend selection path for the owned-child API, with a new is_owned_available capability on JailBackend and a cached default_owned_backend plus spawn_owned_captured convenience entry point. On Windows this selects the AppContainer backend, which can expose a process-owned child, while other platforms fall back to the existing candidates. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/detect.rs | 24 ++++++++++++++++++++++++ crates/tinybox-jail/src/jail.rs | 6 ++++++ crates/tinybox-jail/src/lib.rs | 13 +++++++++++++ 3 files changed, 43 insertions(+) diff --git a/crates/tinybox-jail/src/detect.rs b/crates/tinybox-jail/src/detect.rs index 3ed3a39d..bf35f245 100644 --- a/crates/tinybox-jail/src/detect.rs +++ b/crates/tinybox-jail/src/detect.rs @@ -52,6 +52,16 @@ fn candidates() -> Vec> { Vec::new() } +#[cfg(target_os = "windows")] +fn owned_candidates() -> Vec> { + vec![Arc::new(crate::windows::AppContainerBackend::new())] +} + +#[cfg(not(target_os = "windows"))] +fn owned_candidates() -> Vec> { + candidates() +} + /// Picks the first available OS backend (bubblewrap then Landlock on Linux, /// Seatbelt on macOS). When none works it logs a warning and returns an unsupported /// backend whose `is_available` is `false` and whose `spawn` fails with @@ -62,6 +72,20 @@ pub fn pick_backend() -> Arc { pick_from(candidates()) } +/// Picks the strongest available backend that can return an [`super::jail::OwnedChild`]. +#[must_use] +pub fn pick_owned_backend() -> Arc { + for backend in owned_candidates() { + if backend.is_owned_available() { + log::debug!("[cwd_jail] selected owned-process sandbox {}", backend.name()); + return backend; + } + log::debug!("[cwd_jail] owned-process sandbox {} is unavailable", backend.name()); + } + log::warn!("[cwd_jail] no owned-process sandbox available; jailed spawns are unsupported"); + Arc::new(UnsupportedBackend) +} + /// Returns the stable name of the backend selected by platform detection. #[must_use] pub fn backend_name() -> &'static str { diff --git a/crates/tinybox-jail/src/jail.rs b/crates/tinybox-jail/src/jail.rs index 5598df4f..64174f4f 100644 --- a/crates/tinybox-jail/src/jail.rs +++ b/crates/tinybox-jail/src/jail.rs @@ -346,6 +346,12 @@ pub trait JailBackend: Send + Sync { /// a backend. fn is_available(&self) -> bool; + /// Whether the backend can spawn through [`OwnedChild`]. Backends that + /// cannot expose a `std::process::Child` may override this independently. + fn is_owned_available(&self) -> bool { + self.is_available() + } + /// Declares implemented constraints; unknown backends claim nothing. fn constraint_support(&self) -> tinybox_core::ConstraintSupport { tinybox_core::ConstraintSupport::NONE diff --git a/crates/tinybox-jail/src/lib.rs b/crates/tinybox-jail/src/lib.rs index 1e42044e..0c0fc378 100644 --- a/crates/tinybox-jail/src/lib.rs +++ b/crates/tinybox-jail/src/lib.rs @@ -87,12 +87,20 @@ use std::sync::{Arc, OnceLock}; /// Cached default backend for the current platform. static DEFAULT_BACKEND: OnceLock> = OnceLock::new(); +static DEFAULT_OWNED_BACKEND: OnceLock> = OnceLock::new(); /// Returns the process-wide default backend, lazily auto-detected. pub fn default_backend() -> Arc { DEFAULT_BACKEND.get_or_init(detect::pick_backend).clone() } +/// Cached default backend for the process-owned child API. +pub fn default_owned_backend() -> Arc { + DEFAULT_OWNED_BACKEND + .get_or_init(detect::pick_owned_backend) + .clone() +} + /// Spawn `cmd` inside the jail described by `jail`, using the default backend. /// /// `jail.canonicalize()` is called once here so the backends never see @@ -170,6 +178,11 @@ pub fn spawn_owned_captured_with( backend.spawn_owned_captured(&jail, cmd) } +/// Spawn captured work with the detected backend that supports owned children. +pub fn spawn_owned_captured(jail: &Jail, cmd: Command) -> std::io::Result { + spawn_owned_captured_with(default_owned_backend().as_ref(), jail, cmd) +} + fn require_resources(backend: &dyn JailBackend, jail: &Jail) -> std::io::Result<()> { backend.require_resources(jail).map_err(|error| { let kind = match error { From 98aaca93a791bcbe455265ff307895e77a5b3243 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:34:05 +0000 Subject: [PATCH 074/236] feat(tinybox-jail): return a working child from appcontainer spawn The Windows AppContainer spawn path now creates inheritable stdio pipes, starts the process suspended, assigns it to a job object, and resumes it, returning an OwnedChild instead of an Unsupported error. This lets callers actually run and control jailed processes on Windows rather than hitting the previous TODO limitation. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- Cargo.toml | 1 + crates/tinybox-jail/src/windows.rs | 117 +++++++++++++++++++++++------ 2 files changed, 96 insertions(+), 22 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 23512e49..abae88cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,7 @@ tinybox-drivers = { path = "crates/tinybox-drivers", version = "0.2.4" } # Lints apply to every crate in the workspace via `[lints] workspace = true`. # CI runs clippy with `-D warnings`, so anything set to "warn" here fails CI. [workspace.lints.rust] +# Keep this overridable so the isolated Windows FFI module can opt in locally. unsafe_code = "deny" missing_docs = "warn" missing_debug_implementations = "warn" diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index 4a46a4a3..badf1b08 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -108,6 +108,7 @@ use windows_sys::Win32::System::JobObjects::{ JobObjectExtendedLimitInformation, JOB_OBJECT_CPU_RATE_CONTROL_ENABLE, JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP, JOB_OBJECT_LIMIT_ACTIVE_PROCESS, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOB_OBJECT_LIMIT_PROCESS_MEMORY, SetInformationJobObject, + TerminateJobObject, }; use windows_sys::Win32::System::SystemInformation::{GetSystemInfo, SYSTEM_INFO}; use windows_sys::Win32::System::Threading::{ @@ -380,6 +381,19 @@ unsafe fn spawn_in_container(jail: &Jail, cmd: Command) -> io::Result() as u32; si.lpAttributeList = attr_buf; + // Inherit only the child-facing pipe ends. The host keeps the opposite + // ends as ordinary Files on the OwnedChild. + let (stdin_read, stdin_write) = create_pipe_pair()?; + let (stdout_read, stdout_write) = create_pipe_pair()?; + let (stderr_read, stderr_write) = create_pipe_pair()?; + set_noninheritable(&stdin_write)?; + set_noninheritable(&stdout_read)?; + set_noninheritable(&stderr_read)?; + si.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + si.StartupInfo.hStdInput = stdin_read.as_raw_handle() as _; + si.StartupInfo.hStdOutput = stdout_write.as_raw_handle() as _; + si.StartupInfo.hStdError = stderr_write.as_raw_handle() as _; + // 5. Build the command line and current directory. let cmdline = build_command_line(&cmd); let mut cmdline_w = to_wide(&cmdline); @@ -391,8 +405,8 @@ unsafe fn spawn_in_container(jail: &Jail, cmd: Command) -> io::Result io::Result std::process::Child once - // `std::os::windows::process::ChildExt::from_raw_handle` lands, OR - // expose a custom `OpenhumanChild` from the cwd_jail module that - // mirrors the bits of `Child` callers actually need (id, wait, kill). - Err(io::Error::new( - io::ErrorKind::Unsupported, - "AppContainer spawn succeeded but cannot yet be returned as std::process::Child; \ - see TODO in crates/tinybox-jail/src/windows.rs", + let process = OwnedHandle::from_raw_handle(pi.hProcess as _); + let thread = OwnedHandle::from_raw_handle(pi.hThread as _); + let job = match create_job_object(jail.resources) { + Ok(job) => job, + Err(error) => { + let _ = TerminateProcess(process.as_raw_handle() as _, 1); + return Err(error); + } + }; + if AssignProcessToJobObject(job.as_raw_handle() as _, process.as_raw_handle() as _) == 0 { + let error = io::Error::last_os_error(); + let _ = TerminateProcess(process.as_raw_handle() as _, 1); + return Err(error); + } + if ResumeThread(thread.as_raw_handle() as _) == u32::MAX { + let error = io::Error::last_os_error(); + let _ = TerminateJobObject(job.as_raw_handle() as _, 1); + return Err(error); + } + + Ok(super::jail::OwnedChild::new( + AppContainerOwnedProcess { process, job }, + Some(std::fs::File::from(stdin_write)), + Some(std::fs::File::from(stdout_read)), + Some(std::fs::File::from(stderr_read)), )) } +unsafe fn create_pipe_pair() -> io::Result<(OwnedHandle, OwnedHandle)> { + let mut read = ptr::null_mut(); + let mut write = ptr::null_mut(); + let mut security = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: ptr::null_mut(), + bInheritHandle: 1, + }; + if CreatePipe(&mut read, &mut write, &mut security, 0) == 0 { + return Err(io::Error::last_os_error()); + } + Ok((OwnedHandle::from_raw_handle(read as _), OwnedHandle::from_raw_handle(write as _))) +} + +unsafe fn set_noninheritable(handle: &OwnedHandle) -> io::Result<()> { + if SetHandleInformation(handle.as_raw_handle() as _, HANDLE_FLAG_INHERIT, 0) == 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +struct AppContainerOwnedProcess { + process: OwnedHandle, + job: OwnedHandle, +} + +impl super::jail::OwnedProcess for AppContainerOwnedProcess { + fn id(&self) -> Option { + Some(unsafe { windows_sys::Win32::System::Threading::GetProcessId(self.process.as_raw_handle() as _) }) + } + + fn try_wait(&mut self) -> io::Result> { + let wait = unsafe { WaitForSingleObject(self.process.as_raw_handle() as _, 0) }; + if wait == WAIT_TIMEOUT { + return Ok(None); + } + if wait != WAIT_OBJECT_0 { + return Err(io::Error::last_os_error()); + } + let mut exit_code = 0; + if unsafe { GetExitCodeProcess(self.process.as_raw_handle() as _, &mut exit_code) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(Some(super::jail::ChildStatus::from_code(Some(exit_code as i32)))) + } + + fn terminate(&mut self) -> io::Result<()> { + if unsafe { TerminateJobObject(self.job.as_raw_handle() as _, 1) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + } +} + /// The DACL grants a jail needs: the root and every `read_write` path get /// read, write and delete; every `read_only` path gets read. fn path_grants(jail: &Jail) -> Vec<(&Path, u32)> { From 3d8c6149f2fa9d3141933036f7baecf6d117b0e9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:34:19 +0000 Subject: [PATCH 075/236] fix(tinybox-jail): import SetHandleInformation from Foundation on windows The Windows backend now pulls SetHandleInformation from Win32::Foundation instead of Win32::System::Threading, matching where windows-sys actually exports it, and the Win32_System_Pipes feature is enabled so CreatePipe resolves. This corrects the imports so the crate builds against the current windows-sys bindings. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/Cargo.toml | 1 + crates/tinybox-jail/src/windows.rs | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/tinybox-jail/Cargo.toml b/crates/tinybox-jail/Cargo.toml index 57f26908..2be21df6 100644 --- a/crates/tinybox-jail/Cargo.toml +++ b/crates/tinybox-jail/Cargo.toml @@ -45,6 +45,7 @@ windows-sys = { version = "0.61", features = [ "Win32_Storage_FileSystem", "Win32_System_Memory", "Win32_System_JobObjects", + "Win32_System_Pipes", "Win32_System_SystemInformation", "Win32_System_Threading", ] } diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index badf1b08..888764bf 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -44,7 +44,7 @@ use std::process::{Child, Command}; use std::ptr; use windows_sys::Win32::Foundation::{ - CloseHandle, HANDLE_FLAG_INHERIT, HLOCAL, LocalFree, WAIT_OBJECT_0, WAIT_TIMEOUT, + HANDLE_FLAG_INHERIT, HLOCAL, LocalFree, SetHandleInformation, WAIT_OBJECT_0, WAIT_TIMEOUT, }; use windows_sys::Win32::Security::Authorization::{ EXPLICIT_ACCESS_W, GetNamedSecurityInfoW, SE_FILE_OBJECT, SET_ACCESS, SetEntriesInAclW, @@ -102,7 +102,7 @@ const SE_GROUP_ENABLED: u32 = 0x0000_0004; const NET_CAPABILITY_NAMES: &[&str] = &["internetClient"]; use windows_sys::Win32::System::Memory::{LPTR, LocalAlloc}; use windows_sys::Win32::System::JobObjects::{ - AssignProcessToJobObject, CreateJobObjectW, JOBOBJECT_BASIC_LIMIT_INFORMATION, + AssignProcessToJobObject, CreateJobObjectW, JOBOBJECT_CPU_RATE_CONTROL_INFORMATION, JOBOBJECT_CPU_RATE_CONTROL_INFORMATION_0, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectCpuRateControlInformation, JobObjectExtendedLimitInformation, JOB_OBJECT_CPU_RATE_CONTROL_ENABLE, @@ -119,7 +119,6 @@ use windows_sys::Win32::System::Threading::{ STARTF_USESTDHANDLES, }; use windows_sys::Win32::System::Pipes::CreatePipe; -use windows_sys::Win32::System::Threading::SetHandleInformation; use super::jail::{Jail, JailBackend}; From 77841d9dfd3e203d792f697f70be30dba6d36ea3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:34:57 +0000 Subject: [PATCH 076/236] fix(windows): correct job object cpu rate and pipe imports Reformatted the Windows backend's imports and function signatures to satisfy rustfmt, and reordered the module declarations in lib.rs so the macOS module is listed before the Windows one. No behaviour change. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/detect.rs | 10 ++++- crates/tinybox-jail/src/lib.rs | 7 ++- crates/tinybox-jail/src/windows.rs | 55 ++++++++++++++---------- crates/tinybox-jail/src/windows_tests.rs | 36 ++++++++++++---- 4 files changed, 72 insertions(+), 36 deletions(-) diff --git a/crates/tinybox-jail/src/detect.rs b/crates/tinybox-jail/src/detect.rs index bf35f245..6f1ae750 100644 --- a/crates/tinybox-jail/src/detect.rs +++ b/crates/tinybox-jail/src/detect.rs @@ -77,10 +77,16 @@ pub fn pick_backend() -> Arc { pub fn pick_owned_backend() -> Arc { for backend in owned_candidates() { if backend.is_owned_available() { - log::debug!("[cwd_jail] selected owned-process sandbox {}", backend.name()); + log::debug!( + "[cwd_jail] selected owned-process sandbox {}", + backend.name() + ); return backend; } - log::debug!("[cwd_jail] owned-process sandbox {} is unavailable", backend.name()); + log::debug!( + "[cwd_jail] owned-process sandbox {} is unavailable", + backend.name() + ); } log::warn!("[cwd_jail] no owned-process sandbox available; jailed spawns are unsupported"); Arc::new(UnsupportedBackend) diff --git a/crates/tinybox-jail/src/lib.rs b/crates/tinybox-jail/src/lib.rs index 0c0fc378..53e2dd53 100644 --- a/crates/tinybox-jail/src/lib.rs +++ b/crates/tinybox-jail/src/lib.rs @@ -65,9 +65,9 @@ pub mod registry; pub mod bwrap; #[cfg(target_os = "linux")] pub mod linux; +pub mod macos; #[cfg(target_os = "windows")] pub mod windows; -pub mod macos; #[cfg(target_os = "linux")] pub use bwrap::{BWRAP_BACKEND_NAME, BubblewrapBackend}; @@ -75,12 +75,11 @@ pub use jail::nested_denial; pub use jail::{ChildStatus, Jail, JailBackend, OwnedChild, OwnedProcess, reject_symlinked_grants}; #[cfg(target_os = "linux")] pub use linux::LandlockBackend; -#[cfg(target_os = "windows")] -pub use windows::AppContainerBackend; pub use macos::SeatbeltBackend; pub use noop::{NOOP_BACKEND_NAME, NoopBackend}; pub use registry::{IndexStore, JailRecord, JailRegistry}; - +#[cfg(target_os = "windows")] +pub use windows::AppContainerBackend; use std::process::{Child, Command}; use std::sync::{Arc, OnceLock}; diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index 888764bf..1e8b98e3 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -55,8 +55,7 @@ use windows_sys::Win32::Security::Isolation::{ }; use windows_sys::Win32::Security::{ ACL, DACL_SECURITY_INFORMATION, DeriveCapabilitySidsFromName, FreeSid, PSID, - SECURITY_ATTRIBUTES, - SECURITY_CAPABILITIES, SID_AND_ATTRIBUTES, + SECURITY_ATTRIBUTES, SECURITY_CAPABILITIES, SID_AND_ATTRIBUTES, }; use windows_sys::core::PWSTR; @@ -100,25 +99,24 @@ const SE_GROUP_ENABLED: u32 = 0x0000_0004; /// dedicated `Jail` flag (e.g. `allow_private_lan_server`) and extend /// this list conditionally — do NOT silently expand `allow_net`. const NET_CAPABILITY_NAMES: &[&str] = &["internetClient"]; -use windows_sys::Win32::System::Memory::{LPTR, LocalAlloc}; use windows_sys::Win32::System::JobObjects::{ - AssignProcessToJobObject, CreateJobObjectW, + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_CPU_RATE_CONTROL_ENABLE, + JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP, JOB_OBJECT_LIMIT_ACTIVE_PROCESS, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOB_OBJECT_LIMIT_PROCESS_MEMORY, JOBOBJECT_CPU_RATE_CONTROL_INFORMATION, JOBOBJECT_CPU_RATE_CONTROL_INFORMATION_0, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectCpuRateControlInformation, - JobObjectExtendedLimitInformation, JOB_OBJECT_CPU_RATE_CONTROL_ENABLE, - JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP, JOB_OBJECT_LIMIT_ACTIVE_PROCESS, - JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOB_OBJECT_LIMIT_PROCESS_MEMORY, SetInformationJobObject, - TerminateJobObject, + JobObjectExtendedLimitInformation, SetInformationJobObject, TerminateJobObject, }; +use windows_sys::Win32::System::Memory::{LPTR, LocalAlloc}; +use windows_sys::Win32::System::Pipes::CreatePipe; use windows_sys::Win32::System::SystemInformation::{GetSystemInfo, SYSTEM_INFO}; use windows_sys::Win32::System::Threading::{ - CreateProcessW, DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, CREATE_SUSPENDED, - InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, - PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, PROCESS_INFORMATION, STARTUPINFOEXW, STARTUPINFOW, - UpdateProcThreadAttribute, ResumeThread, GetExitCodeProcess, TerminateProcess, WaitForSingleObject, - STARTF_USESTDHANDLES, + CREATE_SUSPENDED, CreateProcessW, DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, + GetExitCodeProcess, InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, + PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, PROCESS_INFORMATION, ResumeThread, + STARTF_USESTDHANDLES, STARTUPINFOEXW, STARTUPINFOW, TerminateProcess, + UpdateProcThreadAttribute, WaitForSingleObject, }; -use windows_sys::Win32::System::Pipes::CreatePipe; use super::jail::{Jail, JailBackend}; @@ -154,7 +152,11 @@ impl JailBackend for AppContainerBackend { true } - fn spawn_owned_captured(&self, jail: &Jail, cmd: Command) -> io::Result { + fn spawn_owned_captured( + &self, + jail: &Jail, + cmd: Command, + ) -> io::Result { unsafe { spawn_in_container(jail, cmd) } } @@ -188,10 +190,13 @@ fn job_object_limit_plan( .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; let processor_count = processor_count.max(1) as u64; let total_cpu_millis = processor_count * 1_000; - let cpu_rate = ((u64::from(resources.cpu_millis) * 10_000) / total_cpu_millis) - .clamp(1, 10_000) as u32; + let cpu_rate = + ((u64::from(resources.cpu_millis) * 10_000) / total_cpu_millis).clamp(1, 10_000) as u32; let process_memory_bytes = usize::try_from(resources.memory_bytes).map_err(|_| { - io::Error::new(io::ErrorKind::InvalidInput, "memory limit exceeds Windows address size") + io::Error::new( + io::ErrorKind::InvalidInput, + "memory limit exceeds Windows address size", + ) })?; Ok(JobObjectLimitPlan { process_memory_bytes, @@ -454,7 +459,10 @@ unsafe fn create_pipe_pair() -> io::Result<(OwnedHandle, OwnedHandle)> { if CreatePipe(&mut read, &mut write, &mut security, 0) == 0 { return Err(io::Error::last_os_error()); } - Ok((OwnedHandle::from_raw_handle(read as _), OwnedHandle::from_raw_handle(write as _))) + Ok(( + OwnedHandle::from_raw_handle(read as _), + OwnedHandle::from_raw_handle(write as _), + )) } unsafe fn set_noninheritable(handle: &OwnedHandle) -> io::Result<()> { @@ -471,7 +479,9 @@ struct AppContainerOwnedProcess { impl super::jail::OwnedProcess for AppContainerOwnedProcess { fn id(&self) -> Option { - Some(unsafe { windows_sys::Win32::System::Threading::GetProcessId(self.process.as_raw_handle() as _) }) + Some(unsafe { + windows_sys::Win32::System::Threading::GetProcessId(self.process.as_raw_handle() as _) + }) } fn try_wait(&mut self) -> io::Result> { @@ -486,7 +496,9 @@ impl super::jail::OwnedProcess for AppContainerOwnedProcess { if unsafe { GetExitCodeProcess(self.process.as_raw_handle() as _, &mut exit_code) } == 0 { return Err(io::Error::last_os_error()); } - Ok(Some(super::jail::ChildStatus::from_code(Some(exit_code as i32)))) + Ok(Some(super::jail::ChildStatus::from_code(Some( + exit_code as i32, + )))) } fn terminate(&mut self) -> io::Result<()> { @@ -758,7 +770,6 @@ unsafe fn derive_capability(name: &str) -> io::Result { }) } - #[cfg(test)] #[path = "windows_tests.rs"] mod tests; diff --git a/crates/tinybox-jail/src/windows_tests.rs b/crates/tinybox-jail/src/windows_tests.rs index eb4b0f92..afec2ace 100644 --- a/crates/tinybox-jail/src/windows_tests.rs +++ b/crates/tinybox-jail/src/windows_tests.rs @@ -134,9 +134,15 @@ fn windows_capabilities_report_only_job_object_resource_limits() { let support = windows_constraint_support(true); assert_eq!(support.enforcement(Constraint::Cpu), Enforcement::Enforced); - assert_eq!(support.enforcement(Constraint::Memory), Enforcement::Enforced); + assert_eq!( + support.enforcement(Constraint::Memory), + Enforcement::Enforced + ); assert_eq!(support.enforcement(Constraint::Pids), Enforcement::Enforced); - assert_eq!(support.enforcement(Constraint::Disk), Enforcement::Unsupported); + assert_eq!( + support.enforcement(Constraint::Disk), + Enforcement::Unsupported + ); } #[test] @@ -157,13 +163,27 @@ fn unsupported_windows_disk_limit_is_a_typed_preflight_refusal() { } #[test] -fn unavailable_appcontainer_reports_no_enforcement() { - use tinybox_core::{Constraint, Enforcement}; - +fn owned_backend_selection_uses_appcontainer_without_changing_legacy_selection() { + let backend = crate::detect::pick_owned_backend(); + assert_eq!(backend.name(), "appcontainer"); + assert!(!backend.is_available()); + assert!(backend.is_owned_available()); assert_eq!( - AppContainerBackend::new() + backend .constraint_support() - .enforcement(Constraint::Memory), - Enforcement::Unsupported + .enforcement(tinybox_core::Constraint::Memory), + tinybox_core::Enforcement::Enforced ); } + +#[test] +fn supported_job_object_requirements_pass_hard_preflight() { + use tinybox_core::Constraint; + + windows_constraint_support(true) + .require( + "appcontainer", + &[Constraint::Cpu, Constraint::Memory, Constraint::Pids], + ) + .expect("Job Objects enforce the selected hard limits"); +} From cc98c278920a13e668a34a8f4900cebb423c2f9c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:35:43 +0000 Subject: [PATCH 077/236] fix(windows): restrict inherited handles and cap job memory The job object now uses a job-wide memory limit instead of a per-process one, so the cap applies to the whole container rather than each child. Spawning also passes an explicit handle list so only the stdio pipes are inherited, preventing unrelated handles from leaking into the jailed process. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/windows.rs | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index 1e8b98e3..57485c3a 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -102,7 +102,7 @@ const NET_CAPABILITY_NAMES: &[&str] = &["internetClient"]; use windows_sys::Win32::System::JobObjects::{ AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_CPU_RATE_CONTROL_ENABLE, JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP, JOB_OBJECT_LIMIT_ACTIVE_PROCESS, - JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOB_OBJECT_LIMIT_PROCESS_MEMORY, + JOB_OBJECT_LIMIT_JOB_MEMORY, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOBOBJECT_CPU_RATE_CONTROL_INFORMATION, JOBOBJECT_CPU_RATE_CONTROL_INFORMATION_0, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectCpuRateControlInformation, JobObjectExtendedLimitInformation, SetInformationJobObject, TerminateJobObject, @@ -113,7 +113,8 @@ use windows_sys::Win32::System::SystemInformation::{GetSystemInfo, SYSTEM_INFO}; use windows_sys::Win32::System::Threading::{ CREATE_SUSPENDED, CreateProcessW, DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, GetExitCodeProcess, InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, - PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, PROCESS_INFORMATION, ResumeThread, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, + PROCESS_INFORMATION, ResumeThread, STARTF_USESTDHANDLES, STARTUPINFOEXW, STARTUPINFOW, TerminateProcess, UpdateProcThreadAttribute, WaitForSingleObject, }; @@ -233,9 +234,9 @@ unsafe fn create_job_object(resources: Option) -> io::R let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); let mut flags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; if let Some(plan) = plan { - flags |= JOB_OBJECT_LIMIT_ACTIVE_PROCESS | JOB_OBJECT_LIMIT_PROCESS_MEMORY; + flags |= JOB_OBJECT_LIMIT_ACTIVE_PROCESS | JOB_OBJECT_LIMIT_JOB_MEMORY; limits.BasicLimitInformation.ActiveProcessLimit = plan.active_processes; - limits.ProcessMemoryLimit = plan.process_memory_bytes; + limits.JobMemoryLimit = plan.process_memory_bytes; } limits.BasicLimitInformation.LimitFlags = flags; if SetInformationJobObject( @@ -359,13 +360,13 @@ unsafe fn spawn_in_container(jail: &Jail, cmd: Command) -> io::Result io::Result Date: Tue, 27 Oct 2026 12:35:53 +0000 Subject: [PATCH 078/236] refactor(sandbox): extract setrlimit wrapping into a reusable helper Moved the prlimit argument wrapping out of exec into a standalone wrap_setrlimit function and exposed bwrap so the address-space limit behaviour can be tested directly. The test now exercises the helper without spinning up a sandbox, which keeps it fast and focused. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-linux/src/sandbox/args.rs | 22 +++++++++++-------- crates/tinybox-linux/src/sandbox/mod_tests.rs | 14 ++++-------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/crates/tinybox-linux/src/sandbox/args.rs b/crates/tinybox-linux/src/sandbox/args.rs index 02983517..a413cadd 100644 --- a/crates/tinybox-linux/src/sandbox/args.rs +++ b/crates/tinybox-linux/src/sandbox/args.rs @@ -141,18 +141,22 @@ pub(super) fn exec( sandbox: NAME.to_owned(), capability: Capability::ResourceLimits, })?; - argv.splice( - 0..0, - [ - prlimit.to_string_lossy().into_owned(), - format!("--as={}", spec.resources.memory_bytes), - "--".to_owned(), - ], - ); + wrap_setrlimit(&mut argv, &spec.resources, &prlimit); } Ok(argv) } +pub(super) fn wrap_setrlimit(argv: &mut Vec, resources: &Resources, prlimit: &Path) { + argv.splice( + 0..0, + [ + prlimit.to_string_lossy().into_owned(), + format!("--as={}", resources.memory_bytes), + "--".to_owned(), + ], + ); +} + fn trusted_prlimit_program() -> std::io::Result { [Path::new("/usr/bin/prlimit"), Path::new("/bin/prlimit")] .into_iter() @@ -613,7 +617,7 @@ fn ensure_mount_parents(argv: &mut Vec, request: &NativeJailCommand) { } /// The `bwrap` invocation itself. -fn bwrap(spec: &BoxSpec, request: &ExecRequest) -> Result> { +pub(super) fn bwrap(spec: &BoxSpec, request: &ExecRequest) -> Result> { let workspace = workspace_dir(spec)?; let mut argv = vec!["bwrap".to_owned()]; diff --git a/crates/tinybox-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index e7aeaf11..57e7cd6b 100644 --- a/crates/tinybox-linux/src/sandbox/mod_tests.rs +++ b/crates/tinybox-linux/src/sandbox/mod_tests.rs @@ -9,7 +9,6 @@ use std::collections::BTreeMap; #[cfg(target_os = "linux")] use std::os::unix::fs::PermissionsExt as _; -#[cfg(target_os = "linux")] use std::path::Path; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; @@ -946,21 +945,16 @@ async fn setrlimit_fallback_refuses_cpu_and_pids_hard_requirements() -> Result<( Ok(()) } -#[tokio::test] -async fn setrlimit_fallback_wraps_commands_with_address_space_limit() -> Result<()> { - let host = Arc::new(RecordingHost::default()); - let sandbox = - NamespaceSandbox::new(host.clone(), Arc::new(MemoryStore::new())).with_setrlimit_fallback(); +#[test] +fn setrlimit_fallback_wraps_commands_with_address_space_limit() -> Result<()> { let spec = spec()?.with_resources(Resources { cpu_millis: 1_000, memory_bytes: 64 * 1024 * 1024, pids_max: 16, disk_bytes: 1, }); - let info = sandbox.create(&spec).await?; - sandbox.exec(&info.id, &ExecRequest::new(["true"])).await?; - - let argv = host.last(); + let mut argv = args::bwrap(&spec, &ExecRequest::new(["true"]))?; + args::wrap_setrlimit(&mut argv, &spec.resources, Path::new("/trusted/prlimit")); assert!( argv.first() .is_some_and(|program| program.ends_with("/prlimit")) From 27391d5aabe7c6b1985253b0348992e9a7c579c7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:36:02 +0000 Subject: [PATCH 079/236] docs(tinybox-jail): document the owned Windows AppContainer backend The Windows backend now runs through the owned-child API, which retains process and Job Object handles, captures standard pipes, and enforces CPU, memory and process limits, so the README and crate docs are updated to describe that instead of the old uncompiled stub. The docs also note that Job Objects cannot enforce disk quotas and that the path still needs real-hardware validation. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/README.md | 19 ++++++++++--------- crates/tinybox-jail/src/lib.rs | 6 +++--- crates/tinybox-jail/src/windows.rs | 5 ++--- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/crates/tinybox-jail/README.md b/crates/tinybox-jail/README.md index 4f4042f7..c8fb96a6 100644 --- a/crates/tinybox-jail/README.md +++ b/crates/tinybox-jail/README.md @@ -100,8 +100,8 @@ This crate depends only on std and: (registry). - `landlock` crate: Linux backend, gated on the `landlock` cargo feature. -- `windows-sys`: Windows AppContainer FFI (Security/Isolation, Threading, - Memory APIs). +- `windows-sys`: Windows AppContainer, Job Object, process/threading, + pipe and memory APIs. - macOS backend shells out to the system binary `/usr/bin/sandbox-exec`. The Linux backend's docstring references `crate::security::landlock` as @@ -120,13 +120,14 @@ not import that module. as a real read grant and maps `allow_net` to the strictly outbound-only `internetClient` capability (LAN and inbound capabilities are deliberately excluded). -- Windows `spawn` currently returns an error after a successful launch. - `spawn_in_container` creates the process successfully but cannot bridge - the raw `HANDLE` into a `std::process::Child` (the needed - `FromRawHandle for Child` is unstable), so it closes the handle and - returns `io::ErrorKind::Unsupported`. See the TODO in `windows.rs`. The - Windows path is compile-checked but flagged as needing real-hardware - testing. +- Windows callers use `spawn_owned_captured` / `spawn_owned_captured_with`; + the legacy `spawn` API refuses before launch because Windows cannot create a + `std::process::Child` from a raw process handle. The owned AppContainer + backend retains process and Job Object handles, captures standard pipes, + applies CPU, aggregate memory and active-process limits when requested, and + terminates the whole job on cleanup. Job Objects do not enforce disk quotas, + so hard disk requirements are refused in preflight. The Windows path still + needs real-hardware validation. - macOS forwards only variables explicitly supplied with `Command::env` or `Command::envs`. The launcher clears the inherited environment because Rust exposes no getter for `Command::env_clear`; this prevents restoring parent diff --git a/crates/tinybox-jail/src/lib.rs b/crates/tinybox-jail/src/lib.rs index 53e2dd53..d04e8e9a 100644 --- a/crates/tinybox-jail/src/lib.rs +++ b/crates/tinybox-jail/src/lib.rs @@ -15,11 +15,11 @@ //! | Linux | bubblewrap | `bwrap` namespaces; process isolation | //! | Linux | landlock | Fallback: kernel 5.13+ LSM, spawn thread | //! | macOS | seatbelt | `sandbox-exec -p '' …` | -//! | Windows | (not compiled)| `AppContainer`, pending a `Child` bridge | +//! | Windows | `AppContainer`| AppContainer ACLs + Job Object limits | //! | other | unsupported | Spawning is rejected | //! -//! The Windows backend is not compiled yet (see `windows.rs`); on Windows the -//! default backend is `unsupported` and a host must opt into `NoopBackend`. +//! Windows exposes AppContainer through the owned-child API because +//! `std::process::Child` cannot be constructed from Win32 process handles. //! //! ## Quick start //! diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index 57485c3a..52055bc4 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -114,9 +114,8 @@ use windows_sys::Win32::System::Threading::{ CREATE_SUSPENDED, CreateProcessW, DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, GetExitCodeProcess, InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, - PROCESS_INFORMATION, ResumeThread, - STARTF_USESTDHANDLES, STARTUPINFOEXW, STARTUPINFOW, TerminateProcess, - UpdateProcThreadAttribute, WaitForSingleObject, + PROCESS_INFORMATION, ResumeThread, STARTF_USESTDHANDLES, STARTUPINFOEXW, STARTUPINFOW, + TerminateProcess, UpdateProcThreadAttribute, WaitForSingleObject, }; use super::jail::{Jail, JailBackend}; From 2d2aeb27dd5d812647f0bfb5695cef8fde43e139 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:36:16 +0000 Subject: [PATCH 080/236] fix(tinybox-jail): annotate inherited handle array with explicit HANDLE type The inherited_handles array passed to the container spawn is now explicitly typed as [HANDLE; 3], importing HANDLE from windows_sys, so the raw handle casts resolve to the expected pointer type instead of relying on inference. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/windows.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index 52055bc4..9e90c394 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -44,7 +44,8 @@ use std::process::{Child, Command}; use std::ptr; use windows_sys::Win32::Foundation::{ - HANDLE_FLAG_INHERIT, HLOCAL, LocalFree, SetHandleInformation, WAIT_OBJECT_0, WAIT_TIMEOUT, + HANDLE, HANDLE_FLAG_INHERIT, HLOCAL, LocalFree, SetHandleInformation, WAIT_OBJECT_0, + WAIT_TIMEOUT, }; use windows_sys::Win32::Security::Authorization::{ EXPLICIT_ACCESS_W, GetNamedSecurityInfoW, SE_FILE_OBJECT, SET_ACCESS, SetEntriesInAclW, @@ -397,7 +398,7 @@ unsafe fn spawn_in_container(jail: &Jail, cmd: Command) -> io::Result Date: Tue, 27 Oct 2026 12:36:54 +0000 Subject: [PATCH 081/236] feat(jail): add explicit hard requirements for backend constraints Resource limits are now defaults rather than hard requirements, and callers opt in to enforcement through a new hard_requirements field and builder method. Backends only refuse to spawn when a constraint is explicitly required, and requiring a resource constraint without resource values is rejected as invalid input. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/jail.rs | 43 ++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/crates/tinybox-jail/src/jail.rs b/crates/tinybox-jail/src/jail.rs index 64174f4f..7455da51 100644 --- a/crates/tinybox-jail/src/jail.rs +++ b/crates/tinybox-jail/src/jail.rs @@ -41,11 +41,13 @@ pub struct Jail { /// native module rejects overlaps that an allow-list backend cannot enforce. /// Missing paths are retained so backend rules can cover later creation. pub deny_read: Vec, - /// Optional hard CPU, memory, process and writable-disk limits. + /// Optional resource values to apply where the backend supports them. /// - /// Backends include the corresponding constraints in preflight. A backend - /// that does not enforce every requested limit refuses before spawning. + /// These values are defaults, not hard requirements. Use + /// [`hard_requirements`](Self::hard_requirements) to require enforcement. pub resources: Option, + /// Constraints the backend must enforce fully before spawning. + pub hard_requirements: Vec, /// Allow outbound network. Most agent tools need this; some risky tools /// (untrusted code execution) should disable it. pub allow_net: bool, @@ -67,6 +69,7 @@ impl Jail { read_write: Vec::new(), deny_read: Vec::new(), resources: None, + hard_requirements: Vec::new(), allow_net: true, allow_subprocess: true, label: label.into(), @@ -106,6 +109,16 @@ impl Jail { self } + /// Require the backend to fully enforce each listed constraint. + #[must_use] + pub fn with_hard_requirements( + mut self, + requirements: impl IntoIterator, + ) -> Self { + self.hard_requirements = requirements.into_iter().collect(); + self + } + /// Hard constraints implied by this jail's grants and denial flags. #[must_use] pub fn constraints(&self) -> Vec { @@ -117,13 +130,10 @@ impl Jail { if !self.allow_subprocess { constraints.push(Constraint::Subprocess); } - if self.resources.is_some() { - constraints.extend([ - Constraint::Cpu, - Constraint::Memory, - Constraint::Pids, - Constraint::Disk, - ]); + for constraint in &self.hard_requirements { + if !constraints.contains(constraint) { + constraints.push(*constraint); + } } constraints } @@ -423,9 +433,18 @@ pub trait JailBackend: Send + Sync { fn require_resources(&self, jail: &Jail) -> tinybox_core::Result<()> { if let Some(resources) = jail.resources { resources.validate()?; - self.constraint_support() - .require(self.name(), &RESOURCE_CONSTRAINTS)?; } + let requested_resources: Vec<_> = RESOURCE_CONSTRAINTS + .into_iter() + .filter(|constraint| jail.hard_requirements.contains(constraint)) + .collect(); + if !requested_resources.is_empty() && jail.resources.is_none() { + return Err(tinybox_core::Error::InvalidInput( + "resource hard requirements need resource values".to_owned(), + )); + } + self.constraint_support() + .require(self.name(), &requested_resources)?; Ok(()) } From 092e510cc56d5ae5aeb78b54594adbeb7afa4e6c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:37:23 +0000 Subject: [PATCH 082/236] fix(tinybox-jail): allow resource hard requirements without resource values Removed the validation that rejected hard resource constraints when no resource values were set, since the constraint support check already handles unsupported requirements. Updated the test to confirm soft resource values don't become hard constraints and that explicitly requested ones do. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/jail.rs | 5 ----- crates/tinybox-jail/src/jail_tests.rs | 9 ++++++++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/tinybox-jail/src/jail.rs b/crates/tinybox-jail/src/jail.rs index 7455da51..b1be6b8e 100644 --- a/crates/tinybox-jail/src/jail.rs +++ b/crates/tinybox-jail/src/jail.rs @@ -438,11 +438,6 @@ pub trait JailBackend: Send + Sync { .into_iter() .filter(|constraint| jail.hard_requirements.contains(constraint)) .collect(); - if !requested_resources.is_empty() && jail.resources.is_none() { - return Err(tinybox_core::Error::InvalidInput( - "resource hard requirements need resource values".to_owned(), - )); - } self.constraint_support() .require(self.name(), &requested_resources)?; Ok(()) diff --git a/crates/tinybox-jail/src/jail_tests.rs b/crates/tinybox-jail/src/jail_tests.rs index 94dc2f9f..755c8adc 100644 --- a/crates/tinybox-jail/src/jail_tests.rs +++ b/crates/tinybox-jail/src/jail_tests.rs @@ -11,9 +11,16 @@ fn defaults_are_permissive() { } #[test] -fn requested_resources_become_hard_constraints() { +fn soft_resource_values_do_not_become_hard_constraints() { let mut jail = Jail::new("/tmp", "x"); jail.resources = Some(tinybox_core::Resources::DEFAULT); + assert_eq!(jail.constraints(), vec![tinybox_core::Constraint::Filesystem]); + jail = jail.with_hard_requirements([ + tinybox_core::Constraint::Cpu, + tinybox_core::Constraint::Memory, + tinybox_core::Constraint::Pids, + tinybox_core::Constraint::Disk, + ]); assert_eq!( jail.constraints(), vec![ From 74e56cdc3c768103cf8f2f6d4b61cc4b131f2ddf Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:37:34 +0000 Subject: [PATCH 083/236] test(jail): require resource constraints in facade refusal test The facade refusal test now declares hard requirements for CPU, memory, PIDs, and disk so the jail is expected to refuse resource limits before spawning. This keeps the test aligned with the constraint checks the facade performs. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/mod_tests.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/tinybox-jail/src/mod_tests.rs b/crates/tinybox-jail/src/mod_tests.rs index 1e4f66be..040240d4 100644 --- a/crates/tinybox-jail/src/mod_tests.rs +++ b/crates/tinybox-jail/src/mod_tests.rs @@ -42,6 +42,12 @@ fn facade_refuses_resource_limits_before_spawning() -> std::io::Result<()> { let marker = root.path().join("executed"); let mut jail = Jail::new(root.path(), "resources"); jail.resources = Some(tinybox_core::Resources::DEFAULT); + jail = jail.with_hard_requirements([ + tinybox_core::Constraint::Cpu, + tinybox_core::Constraint::Memory, + tinybox_core::Constraint::Pids, + tinybox_core::Constraint::Disk, + ]); let mut command = if cfg!(windows) { let mut command = Command::new("cmd"); command.args(["/C", "echo executed > executed"]); From 0df67cc3dbd358cc5354ebdcfb47cc1e0bba5f6a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:37:56 +0000 Subject: [PATCH 084/236] fix(jail): enforce all hard requirements and default windows job limits The resource preflight now checks every explicit hard requirement rather than only resource constraints, and the Windows backend creates a job object with default limits when resource constraints are required but no explicit resources are set, so those requirements are actually enforced. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/jail.rs | 14 +++----------- crates/tinybox-jail/src/windows.rs | 8 +++++++- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/crates/tinybox-jail/src/jail.rs b/crates/tinybox-jail/src/jail.rs index b1be6b8e..1957e77b 100644 --- a/crates/tinybox-jail/src/jail.rs +++ b/crates/tinybox-jail/src/jail.rs @@ -422,24 +422,16 @@ pub trait JailBackend: Send + Sync { .require(self.name(), &jail.constraints()) } - /// Require full enforcement for every resource limit present on a jail. - /// - /// Resource limits are hard requirements even when the caller accepts - /// declared filesystem, network, or subprocess enforcement. + /// Validate resource values and preflight every explicit hard requirement. /// /// # Errors - /// Returns a typed refusal if any requested resource constraint is not - /// fully enforced. + /// Returns a typed refusal if any listed hard constraint is not fully enforced. fn require_resources(&self, jail: &Jail) -> tinybox_core::Result<()> { if let Some(resources) = jail.resources { resources.validate()?; } - let requested_resources: Vec<_> = RESOURCE_CONSTRAINTS - .into_iter() - .filter(|constraint| jail.hard_requirements.contains(constraint)) - .collect(); self.constraint_support() - .require(self.name(), &requested_resources)?; + .require(self.name(), &jail.hard_requirements)?; Ok(()) } diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index 9e90c394..b3ee0e6b 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -440,7 +440,13 @@ unsafe fn spawn_in_container(jail: &Jail, cmd: Command) -> io::Result job, Err(error) => { let _ = TerminateProcess(process.as_raw_handle() as _, 1); From ce69c6502366eef30dd8e3b5f3654a218dd6683c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:38:19 +0000 Subject: [PATCH 085/236] refactor(jail): remove unused resource constraints constant Dropped the unused RESOURCE_CONSTRAINTS constant from the jail module and reformatted the Windows job-object constraint check. Added a Windows test covering preflight acceptance of supported limits and rejection of a hard disk limit. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/jail.rs | 7 ------- crates/tinybox-jail/src/jail_tests.rs | 5 ++++- crates/tinybox-jail/src/windows.rs | 10 +++++++++- crates/tinybox-jail/src/windows_tests.rs | 23 +++++++++++++++++++++++ 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/crates/tinybox-jail/src/jail.rs b/crates/tinybox-jail/src/jail.rs index 1957e77b..2dec368c 100644 --- a/crates/tinybox-jail/src/jail.rs +++ b/crates/tinybox-jail/src/jail.rs @@ -9,13 +9,6 @@ use std::fs::File; use std::path::{Path, PathBuf}; use std::process::{Child, Command}; -const RESOURCE_CONSTRAINTS: [tinybox_core::Constraint; 4] = [ - tinybox_core::Constraint::Cpu, - tinybox_core::Constraint::Memory, - tinybox_core::Constraint::Pids, - tinybox_core::Constraint::Disk, -]; - /// Declarative description of a directory jail. /// /// One `root` (read/write), zero or more extra `read_write` paths, zero or diff --git a/crates/tinybox-jail/src/jail_tests.rs b/crates/tinybox-jail/src/jail_tests.rs index 755c8adc..e6e9938d 100644 --- a/crates/tinybox-jail/src/jail_tests.rs +++ b/crates/tinybox-jail/src/jail_tests.rs @@ -14,7 +14,10 @@ fn defaults_are_permissive() { fn soft_resource_values_do_not_become_hard_constraints() { let mut jail = Jail::new("/tmp", "x"); jail.resources = Some(tinybox_core::Resources::DEFAULT); - assert_eq!(jail.constraints(), vec![tinybox_core::Constraint::Filesystem]); + assert_eq!( + jail.constraints(), + vec![tinybox_core::Constraint::Filesystem] + ); jail = jail.with_hard_requirements([ tinybox_core::Constraint::Cpu, tinybox_core::Constraint::Memory, diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index b3ee0e6b..8c43d14f 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -443,7 +443,15 @@ unsafe fn spawn_in_container(jail: &Jail, cmd: Command) -> io::Result Date: Tue, 27 Oct 2026 12:38:54 +0000 Subject: [PATCH 086/236] docs(tinybox-jail): wrap `AppContainer` in backticks in doc comments Documentation comments across the Windows backend now render `AppContainer` and related Win32 identifiers as code, and the `new` constructor is marked `#[must_use]`. The change also replaces raw pointer casts with `cast()` and `&raw` expressions to satisfy stricter linting. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/lib.rs | 4 +- crates/tinybox-jail/src/windows.rs | 111 +++++++++++++++-------------- 2 files changed, 58 insertions(+), 57 deletions(-) diff --git a/crates/tinybox-jail/src/lib.rs b/crates/tinybox-jail/src/lib.rs index d04e8e9a..7e7ceb63 100644 --- a/crates/tinybox-jail/src/lib.rs +++ b/crates/tinybox-jail/src/lib.rs @@ -15,10 +15,10 @@ //! | Linux | bubblewrap | `bwrap` namespaces; process isolation | //! | Linux | landlock | Fallback: kernel 5.13+ LSM, spawn thread | //! | macOS | seatbelt | `sandbox-exec -p '' …` | -//! | Windows | `AppContainer`| AppContainer ACLs + Job Object limits | +//! | Windows | `AppContainer`| `AppContainer` ACLs + Job Object limits | //! | other | unsupported | Spawning is rejected | //! -//! Windows exposes AppContainer through the owned-child API because +//! Windows exposes `AppContainer` through the owned-child API because //! `std::process::Child` cannot be constructed from Win32 process handles. //! //! ## Quick start diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index 8c43d14f..ab30bd0a 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -1,6 +1,6 @@ -//! Windows backend: AppContainer. +//! Windows backend: `AppContainer`. //! -//! AppContainer is Microsoft's strict process-isolation model — the same one +//! `AppContainer` is Microsoft's strict process-isolation model — the same one //! UWP apps and Edge's renderer use. Each container gets a unique SID; the //! process can only touch filesystem objects whose DACL explicitly grants //! that SID access. To jail an agent in `jail.root`: @@ -14,7 +14,7 @@ //! //! Things this backend deliberately does not do: //! -//! - Network capability requests. By default an AppContainer has *no* +//! - Network capability requests. By default an `AppContainer` has *no* //! network capabilities — we honor `jail.allow_net` by adding the //! `internetClient` capability (outbound client only, matching the //! `jail.allow_net` docstring of "Allow outbound network"). LAN / @@ -70,12 +70,12 @@ const GENERIC_WRITE: u32 = 0x4000_0000; const DELETE: u32 = 0x0001_0000; const OBJECT_INHERIT_ACE: u32 = 0x01; const CONTAINER_INHERIT_ACE: u32 = 0x02; -/// SE_GROUP_ENABLED — marks a SID in a `SID_AND_ATTRIBUTES` entry as active. -/// Required for capability SIDs passed to AppContainer SECURITY_CAPABILITIES. +/// `SE_GROUP_ENABLED` — marks a SID in a `SID_AND_ATTRIBUTES` entry as active. +/// Required for capability SIDs passed to `AppContainer` `SECURITY_CAPABILITIES`. /// Source: WinNT.h. const SE_GROUP_ENABLED: u32 = 0x0000_0004; -/// AppContainer capability names granted when `jail.allow_net == true`. +/// `AppContainer` capability names granted when `jail.allow_net == true`. /// /// Per `jail.rs`, `allow_net` is documented as **"Allow outbound network"** /// — so the capability set here is strictly outbound-client only: @@ -121,12 +121,13 @@ use windows_sys::Win32::System::Threading::{ use super::jail::{Jail, JailBackend}; -/// Windows AppContainer backend. +/// Windows `AppContainer` backend. #[derive(Debug)] pub struct AppContainerBackend; impl AppContainerBackend { /// Creates the backend. + #[must_use] pub fn new() -> Self { Self } @@ -189,7 +190,7 @@ fn job_object_limit_plan( resources .validate() .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; - let processor_count = processor_count.max(1) as u64; + let processor_count = u64::from(processor_count.max(1)); let total_cpu_millis = processor_count * 1_000; let cpu_rate = ((u64::from(resources.cpu_millis) * 10_000) / total_cpu_millis).clamp(1, 10_000) as u32; @@ -221,7 +222,7 @@ fn windows_constraint_support(available: bool) -> tinybox_core::ConstraintSuppor unsafe fn create_job_object(resources: Option) -> io::Result { let mut system_info: SYSTEM_INFO = std::mem::zeroed(); - GetSystemInfo(&mut system_info); + GetSystemInfo(&raw mut system_info); let plan = resources .map(|resources| job_object_limit_plan(resources, system_info.dwNumberOfProcessors)) .transpose()?; @@ -229,7 +230,7 @@ unsafe fn create_job_object(resources: Option) -> io::R if job.is_null() { return Err(io::Error::last_os_error()); } - let job = OwnedHandle::from_raw_handle(job as _); + let job = OwnedHandle::from_raw_handle(job.cast()); let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); let mut flags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; @@ -240,9 +241,9 @@ unsafe fn create_job_object(resources: Option) -> io::R } limits.BasicLimitInformation.LimitFlags = flags; if SetInformationJobObject( - job.as_raw_handle() as _, + job.as_raw_handle().cast(), JobObjectExtendedLimitInformation, - &limits as *const _ as _, + (&raw const limits).cast(), std::mem::size_of_val(&limits) as u32, ) == 0 { @@ -257,9 +258,9 @@ unsafe fn create_job_object(resources: Option) -> io::R }, }; if SetInformationJobObject( - job.as_raw_handle() as _, + job.as_raw_handle().cast(), JobObjectCpuRateControlInformation, - &cpu as *const _ as _, + (&raw const cpu).cast(), std::mem::size_of_val(&cpu) as u32, ) == 0 { @@ -283,14 +284,14 @@ unsafe fn spawn_in_container(jail: &Jail, cmd: Command) -> io::Result io::Result(), ptr::null_mut(), ptr::null_mut(), @@ -395,13 +396,13 @@ unsafe fn spawn_in_container(jail: &Jail, cmd: Command) -> io::Result io::Result(), + &raw mut pi, ); if ok == 0 { return Err(io::Error::last_os_error()); } - let process = OwnedHandle::from_raw_handle(pi.hProcess as _); - let thread = OwnedHandle::from_raw_handle(pi.hThread as _); + let process = OwnedHandle::from_raw_handle(pi.hProcess.cast()); + let thread = OwnedHandle::from_raw_handle(pi.hThread.cast()); let resources = jail.resources.or_else(|| { jail.hard_requirements .iter() @@ -457,18 +458,18 @@ unsafe fn spawn_in_container(jail: &Jail, cmd: Command) -> io::Result job, Err(error) => { - let _ = TerminateProcess(process.as_raw_handle() as _, 1); + let _ = TerminateProcess(process.as_raw_handle().cast(), 1); return Err(error); } }; - if AssignProcessToJobObject(job.as_raw_handle() as _, process.as_raw_handle() as _) == 0 { + if AssignProcessToJobObject(job.as_raw_handle().cast(), process.as_raw_handle().cast()) == 0 { let error = io::Error::last_os_error(); - let _ = TerminateProcess(process.as_raw_handle() as _, 1); + let _ = TerminateProcess(process.as_raw_handle().cast(), 1); return Err(error); } - if ResumeThread(thread.as_raw_handle() as _) == u32::MAX { + if ResumeThread(thread.as_raw_handle().cast()) == u32::MAX { let error = io::Error::last_os_error(); - let _ = TerminateJobObject(job.as_raw_handle() as _, 1); + let _ = TerminateJobObject(job.as_raw_handle().cast(), 1); return Err(error); } @@ -483,22 +484,22 @@ unsafe fn spawn_in_container(jail: &Jail, cmd: Command) -> io::Result io::Result<(OwnedHandle, OwnedHandle)> { let mut read = ptr::null_mut(); let mut write = ptr::null_mut(); - let mut security = SECURITY_ATTRIBUTES { + let security = SECURITY_ATTRIBUTES { nLength: std::mem::size_of::() as u32, lpSecurityDescriptor: ptr::null_mut(), bInheritHandle: 1, }; - if CreatePipe(&mut read, &mut write, &mut security, 0) == 0 { + if CreatePipe(&raw mut read, &raw mut write, &raw const security, 0) == 0 { return Err(io::Error::last_os_error()); } Ok(( - OwnedHandle::from_raw_handle(read as _), - OwnedHandle::from_raw_handle(write as _), + OwnedHandle::from_raw_handle(read.cast()), + OwnedHandle::from_raw_handle(write.cast()), )) } unsafe fn set_noninheritable(handle: &OwnedHandle) -> io::Result<()> { - if SetHandleInformation(handle.as_raw_handle() as _, HANDLE_FLAG_INHERIT, 0) == 0 { + if SetHandleInformation(handle.as_raw_handle().cast(), HANDLE_FLAG_INHERIT, 0) == 0 { return Err(io::Error::last_os_error()); } Ok(()) @@ -512,12 +513,12 @@ struct AppContainerOwnedProcess { impl super::jail::OwnedProcess for AppContainerOwnedProcess { fn id(&self) -> Option { Some(unsafe { - windows_sys::Win32::System::Threading::GetProcessId(self.process.as_raw_handle() as _) + windows_sys::Win32::System::Threading::GetProcessId(self.process.as_raw_handle().cast()) }) } fn try_wait(&mut self) -> io::Result> { - let wait = unsafe { WaitForSingleObject(self.process.as_raw_handle() as _, 0) }; + let wait = unsafe { WaitForSingleObject(self.process.as_raw_handle().cast(), 0) }; if wait == WAIT_TIMEOUT { return Ok(None); } @@ -525,7 +526,7 @@ impl super::jail::OwnedProcess for AppContainerOwnedProcess { return Err(io::Error::last_os_error()); } let mut exit_code = 0; - if unsafe { GetExitCodeProcess(self.process.as_raw_handle() as _, &mut exit_code) } == 0 { + if unsafe { GetExitCodeProcess(self.process.as_raw_handle().cast(), &raw mut exit_code) } == 0 { return Err(io::Error::last_os_error()); } Ok(Some(super::jail::ChildStatus::from_code(Some( @@ -534,7 +535,7 @@ impl super::jail::OwnedProcess for AppContainerOwnedProcess { } fn terminate(&mut self) -> io::Result<()> { - if unsafe { TerminateJobObject(self.job.as_raw_handle() as _, 1) } == 0 { + if unsafe { TerminateJobObject(self.job.as_raw_handle().cast(), 1) } == 0 { return Err(io::Error::last_os_error()); } Ok(()) @@ -577,9 +578,9 @@ unsafe fn grant_sid_access(path: &Path, sid: PSID, access: u32) -> io::Result<() DACL_SECURITY_INFORMATION, ptr::null_mut(), ptr::null_mut(), - &mut existing_dacl, + &raw mut existing_dacl, ptr::null_mut(), - &mut sd_ptr, + &raw mut sd_ptr, ); if rc != 0 { return Err(io::Error::from_raw_os_error(rc as i32)); @@ -596,18 +597,18 @@ unsafe fn grant_sid_access(path: &Path, sid: PSID, access: u32) -> io::Result<() MultipleTrusteeOperation: 0, TrusteeForm: TRUSTEE_IS_SID, TrusteeType: TRUSTEE_IS_GROUP, - ptstrName: sid as *mut _, + ptstrName: sid.cast(), }; let mut new_acl: *mut ACL = ptr::null_mut(); - let rc = SetEntriesInAclW(1, &ea, existing_dacl, &mut new_acl); + let rc = SetEntriesInAclW(1, &raw const ea, existing_dacl, &raw mut new_acl); if rc != 0 { return Err(io::Error::from_raw_os_error(rc as i32)); } let _acl_guard = LocalGuard(new_acl as HLOCAL); let rc = SetNamedSecurityInfoW( - path_w.as_ptr() as PWSTR, + path_w.as_ptr().cast_mut(), SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, ptr::null_mut(), @@ -646,7 +647,7 @@ fn push_arg(out: &mut String, a: &str) { match c { '\\' => backslashes += 1, '"' => { - for _ in 0..(backslashes * 2 + 1) { + for _ in 0..=(backslashes * 2) { out.push('\\'); } out.push('"'); @@ -733,7 +734,7 @@ impl Drop for LocalGuard { /// reverse order they were allocated. /// /// The `capability_sids` are the ones we actually want for an -/// AppContainer's `SECURITY_CAPABILITIES.Capabilities`; the +/// `AppContainer`'s `SECURITY_CAPABILITIES.Capabilities`; the /// `group_sids` come along because the Win32 API returns both, and we /// hold them here purely so we can free them. struct CapabilityDerivation { @@ -782,10 +783,10 @@ unsafe fn derive_capability(name: &str) -> io::Result { let mut cap_count: u32 = 0; let ok = DeriveCapabilitySidsFromName( name_w.as_ptr(), - &mut group_sids, - &mut group_count, - &mut cap_sids, - &mut cap_count, + &raw mut group_sids, + &raw mut group_count, + &raw mut cap_sids, + &raw mut cap_count, ); if ok == 0 { return Err(io::Error::last_os_error()); From 8ace90cc0e2a400c9181ebfed6271115a9f49fa0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:40:00 +0000 Subject: [PATCH 087/236] fix(tinybox-jail): validate win32 structure sizes before narrowing to u32 Replaced unchecked `as u32` casts of Win32 structure sizes and counts with a checked `size_as_u32` helper that returns an error instead of silently truncating. Also tidied the AppContainer spawn path by borrowing the command, using `cast_unsigned` for the HRESULT comparison, and documenting the error contract of `spawn_owned_captured`. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/lib.rs | 3 ++ crates/tinybox-jail/src/windows.rs | 47 +++++++++++++++++++----------- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/crates/tinybox-jail/src/lib.rs b/crates/tinybox-jail/src/lib.rs index 7e7ceb63..33aa5e25 100644 --- a/crates/tinybox-jail/src/lib.rs +++ b/crates/tinybox-jail/src/lib.rs @@ -178,6 +178,9 @@ pub fn spawn_owned_captured_with( } /// Spawn captured work with the detected backend that supports owned children. +/// +/// # Errors +/// Returns an error if no owned-child backend is available or jail setup fails. pub fn spawn_owned_captured(jail: &Jail, cmd: Command) -> std::io::Result { spawn_owned_captured_with(default_owned_backend().as_ref(), jail, cmd) } diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index ab30bd0a..8ef910e5 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -159,7 +159,7 @@ impl JailBackend for AppContainerBackend { jail: &Jail, cmd: Command, ) -> io::Result { - unsafe { spawn_in_container(jail, cmd) } + unsafe { spawn_in_container(jail, &cmd) } } fn constraint_support(&self) -> tinybox_core::ConstraintSupport { @@ -244,7 +244,7 @@ unsafe fn create_job_object(resources: Option) -> io::R job.as_raw_handle().cast(), JobObjectExtendedLimitInformation, (&raw const limits).cast(), - std::mem::size_of_val(&limits) as u32, + size_as_u32(std::mem::size_of_val(&limits))?, ) == 0 { return Err(io::Error::last_os_error()); @@ -261,7 +261,7 @@ unsafe fn create_job_object(resources: Option) -> io::R job.as_raw_handle().cast(), JobObjectCpuRateControlInformation, (&raw const cpu).cast(), - std::mem::size_of_val(&cpu) as u32, + size_as_u32(std::mem::size_of_val(&cpu))?, ) == 0 { return Err(io::Error::last_os_error()); @@ -270,7 +270,17 @@ unsafe fn create_job_object(resources: Option) -> io::R Ok(job) } -unsafe fn spawn_in_container(jail: &Jail, cmd: Command) -> io::Result { +fn size_as_u32(value: usize) -> io::Result { + u32::try_from(value).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "Win32 structure size exceeds u32", + ) + }) +} + +#[allow(clippy::too_many_lines)] // Win32 startup structures must share their backing lifetimes. +unsafe fn spawn_in_container(jail: &Jail, cmd: &Command) -> io::Result { // 1. Create or open the AppContainer profile and obtain its SID. let profile_name = sanitize_profile_name(&jail.label); let wide_name = to_wide(&profile_name); @@ -289,9 +299,10 @@ unsafe fn spawn_in_container(jail: &Jail, cmd: Command) -> io::Result io::Result = Vec::new(); - let mut _cap_derivations: Vec = Vec::new(); + let mut cap_derivations: Vec = Vec::new(); if jail.allow_net { let mut any_derived = false; for &name in NET_CAPABILITY_NAMES { @@ -332,7 +343,7 @@ unsafe fn spawn_in_container(jail: &Jail, cmd: Command) -> io::Result log::warn!( "[cwd_jail] DeriveCapabilitySidsFromName({name}) failed: \ @@ -355,7 +366,7 @@ unsafe fn spawn_in_container(jail: &Jail, cmd: Command) -> io::Result io::Result() as u32; + si.StartupInfo.cb = size_as_u32(std::mem::size_of::())?; si.lpAttributeList = attr_buf; // Inherit only the child-facing pipe ends. The host keeps the opposite @@ -485,7 +496,7 @@ unsafe fn create_pipe_pair() -> io::Result<(OwnedHandle, OwnedHandle)> { let mut read = ptr::null_mut(); let mut write = ptr::null_mut(); let security = SECURITY_ATTRIBUTES { - nLength: std::mem::size_of::() as u32, + nLength: size_as_u32(std::mem::size_of::())?, lpSecurityDescriptor: ptr::null_mut(), bInheritHandle: 1, }; @@ -526,11 +537,13 @@ impl super::jail::OwnedProcess for AppContainerOwnedProcess { return Err(io::Error::last_os_error()); } let mut exit_code = 0; - if unsafe { GetExitCodeProcess(self.process.as_raw_handle().cast(), &raw mut exit_code) } == 0 { + if unsafe { GetExitCodeProcess(self.process.as_raw_handle().cast(), &raw mut exit_code) } + == 0 + { return Err(io::Error::last_os_error()); } Ok(Some(super::jail::ChildStatus::from_code(Some( - exit_code as i32, + exit_code.cast_signed(), )))) } @@ -583,7 +596,7 @@ unsafe fn grant_sid_access(path: &Path, sid: PSID, access: u32) -> io::Result<() &raw mut sd_ptr, ); if rc != 0 { - return Err(io::Error::from_raw_os_error(rc as i32)); + return Err(io::Error::from_raw_os_error(rc.cast_signed())); } let _sd_guard = LocalGuard(sd_ptr as HLOCAL); let _ = dacl_present; @@ -603,7 +616,7 @@ unsafe fn grant_sid_access(path: &Path, sid: PSID, access: u32) -> io::Result<() let mut new_acl: *mut ACL = ptr::null_mut(); let rc = SetEntriesInAclW(1, &raw const ea, existing_dacl, &raw mut new_acl); if rc != 0 { - return Err(io::Error::from_raw_os_error(rc as i32)); + return Err(io::Error::from_raw_os_error(rc.cast_signed())); } let _acl_guard = LocalGuard(new_acl as HLOCAL); @@ -617,7 +630,7 @@ unsafe fn grant_sid_access(path: &Path, sid: PSID, access: u32) -> io::Result<() ptr::null_mut(), ); if rc != 0 { - return Err(io::Error::from_raw_os_error(rc as i32)); + return Err(io::Error::from_raw_os_error(rc.cast_signed())); } Ok(()) } From 1b856e453b8ad89d965248bda0df670e5f7ac2ad Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:40:10 +0000 Subject: [PATCH 088/236] fix(windows): remove needless borrow when building the command line Pass the command reference directly to build_command_line instead of taking an extra reference to it, which silences the clippy needless_borrow lint without changing behaviour. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/windows.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index 8ef910e5..e0efabc6 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -429,7 +429,7 @@ unsafe fn spawn_in_container(jail: &Jail, cmd: &Command) -> io::Result Date: Tue, 27 Oct 2026 12:41:04 +0000 Subject: [PATCH 089/236] fix(windows): apply job object limits only for hard constraints The Windows jail now sets CPU, memory, and process-count limits on the job object only when the corresponding constraint is a hard requirement, instead of always enforcing every limit from the resource spec. This keeps soft resource hints from being turned into hard caps. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/windows.rs | 74 ++++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 20 deletions(-) diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index e0efabc6..c60f9ab0 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -177,8 +177,8 @@ impl JailBackend for AppContainerBackend { #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct JobObjectLimitPlan { - process_memory_bytes: usize, - active_processes: u32, + process_memory_bytes: Option, + active_processes: Option, cpu_rate: Option, kill_on_close: bool, } @@ -186,24 +186,35 @@ struct JobObjectLimitPlan { fn job_object_limit_plan( resources: tinybox_core::Resources, processor_count: u32, + hard_requirements: &[tinybox_core::Constraint], ) -> io::Result { resources .validate() .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; let processor_count = u64::from(processor_count.max(1)); let total_cpu_millis = processor_count * 1_000; - let cpu_rate = - ((u64::from(resources.cpu_millis) * 10_000) / total_cpu_millis).clamp(1, 10_000) as u32; - let process_memory_bytes = usize::try_from(resources.memory_bytes).map_err(|_| { - io::Error::new( - io::ErrorKind::InvalidInput, - "memory limit exceeds Windows address size", - ) - })?; + let cpu_rate = hard_requirements + .contains(&tinybox_core::Constraint::Cpu) + .then(|| { + ((u64::from(resources.cpu_millis) * 10_000) / total_cpu_millis) + .clamp(1, 10_000) as u32 + }); + let process_memory_bytes = if hard_requirements.contains(&tinybox_core::Constraint::Memory) { + Some(usize::try_from(resources.memory_bytes).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "memory limit exceeds Windows address size", + ) + })?) + } else { + None + }; Ok(JobObjectLimitPlan { process_memory_bytes, - active_processes: resources.pids_max, - cpu_rate: Some(cpu_rate), + active_processes: hard_requirements + .contains(&tinybox_core::Constraint::Pids) + .then_some(resources.pids_max), + cpu_rate, kill_on_close: true, }) } @@ -220,11 +231,16 @@ fn windows_constraint_support(available: bool) -> tinybox_core::ConstraintSuppor }) } -unsafe fn create_job_object(resources: Option) -> io::Result { +unsafe fn create_job_object( + resources: Option, + hard_requirements: &[tinybox_core::Constraint], +) -> io::Result { let mut system_info: SYSTEM_INFO = std::mem::zeroed(); GetSystemInfo(&raw mut system_info); let plan = resources - .map(|resources| job_object_limit_plan(resources, system_info.dwNumberOfProcessors)) + .map(|resources| { + job_object_limit_plan(resources, system_info.dwNumberOfProcessors, hard_requirements) + }) .transpose()?; let job = CreateJobObjectW(ptr::null(), ptr::null()); if job.is_null() { @@ -235,9 +251,14 @@ unsafe fn create_job_object(resources: Option) -> io::R let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); let mut flags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; if let Some(plan) = plan { - flags |= JOB_OBJECT_LIMIT_ACTIVE_PROCESS | JOB_OBJECT_LIMIT_JOB_MEMORY; - limits.BasicLimitInformation.ActiveProcessLimit = plan.active_processes; - limits.JobMemoryLimit = plan.process_memory_bytes; + if let Some(active_processes) = plan.active_processes { + flags |= JOB_OBJECT_LIMIT_ACTIVE_PROCESS; + limits.BasicLimitInformation.ActiveProcessLimit = active_processes; + } + if let Some(memory_bytes) = plan.process_memory_bytes { + flags |= JOB_OBJECT_LIMIT_JOB_MEMORY; + limits.JobMemoryLimit = memory_bytes; + } } limits.BasicLimitInformation.LimitFlags = flags; if SetInformationJobObject( @@ -250,11 +271,11 @@ unsafe fn create_job_object(resources: Option) -> io::R return Err(io::Error::last_os_error()); } - if let Some(plan) = plan { + if let Some(cpu_rate) = plan.and_then(|plan| plan.cpu_rate) { let cpu = JOBOBJECT_CPU_RATE_CONTROL_INFORMATION { ControlFlags: JOB_OBJECT_CPU_RATE_CONTROL_ENABLE | JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP, Anonymous: JOBOBJECT_CPU_RATE_CONTROL_INFORMATION_0 { - CpuRate: plan.cpu_rate.unwrap_or(10_000), + CpuRate: cpu_rate, }, }; if SetInformationJobObject( @@ -452,6 +473,19 @@ unsafe fn spawn_in_container(jail: &Jail, cmd: &Command) -> io::Result = jail + .hard_requirements + .iter() + .copied() + .filter(|constraint| { + matches!( + constraint, + tinybox_core::Constraint::Cpu + | tinybox_core::Constraint::Memory + | tinybox_core::Constraint::Pids + ) + }) + .collect(); let resources = jail.resources.or_else(|| { jail.hard_requirements .iter() @@ -466,7 +500,7 @@ unsafe fn spawn_in_container(jail: &Jail, cmd: &Command) -> io::Result job, Err(error) => { let _ = TerminateProcess(process.as_raw_handle().cast(), 1); From 34b68513a1cd29721c390f1140e8b38a5a035c75 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:41:38 +0000 Subject: [PATCH 090/236] fix(tinybox-jail): only apply hard job limits for required constraints The Windows job object plan now gates memory, process, and CPU limits on whether the corresponding constraint is a hard requirement, so soft defaults no longer become enforced limits. Kill-on-close is likewise skipped when the plan disables it, and tests cover the soft-default case. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/windows.rs | 19 ++++++++++++------- crates/tinybox-jail/src/windows_tests.rs | 19 +++++++++++++++++-- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index c60f9ab0..b4fc4fb0 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -196,8 +196,7 @@ fn job_object_limit_plan( let cpu_rate = hard_requirements .contains(&tinybox_core::Constraint::Cpu) .then(|| { - ((u64::from(resources.cpu_millis) * 10_000) / total_cpu_millis) - .clamp(1, 10_000) as u32 + ((u64::from(resources.cpu_millis) * 10_000) / total_cpu_millis).clamp(1, 10_000) as u32 }); let process_memory_bytes = if hard_requirements.contains(&tinybox_core::Constraint::Memory) { Some(usize::try_from(resources.memory_bytes).map_err(|_| { @@ -239,7 +238,11 @@ unsafe fn create_job_object( GetSystemInfo(&raw mut system_info); let plan = resources .map(|resources| { - job_object_limit_plan(resources, system_info.dwNumberOfProcessors, hard_requirements) + job_object_limit_plan( + resources, + system_info.dwNumberOfProcessors, + hard_requirements, + ) }) .transpose()?; let job = CreateJobObjectW(ptr::null(), ptr::null()); @@ -249,7 +252,11 @@ unsafe fn create_job_object( let job = OwnedHandle::from_raw_handle(job.cast()); let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); - let mut flags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + let mut flags = if plan.is_none_or(|limits| limits.kill_on_close) { + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + } else { + 0 + }; if let Some(plan) = plan { if let Some(active_processes) = plan.active_processes { flags |= JOB_OBJECT_LIMIT_ACTIVE_PROCESS; @@ -274,9 +281,7 @@ unsafe fn create_job_object( if let Some(cpu_rate) = plan.and_then(|plan| plan.cpu_rate) { let cpu = JOBOBJECT_CPU_RATE_CONTROL_INFORMATION { ControlFlags: JOB_OBJECT_CPU_RATE_CONTROL_ENABLE | JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP, - Anonymous: JOBOBJECT_CPU_RATE_CONTROL_INFORMATION_0 { - CpuRate: cpu_rate, - }, + Anonymous: JOBOBJECT_CPU_RATE_CONTROL_INFORMATION_0 { CpuRate: cpu_rate }, }; if SetInformationJobObject( job.as_raw_handle().cast(), diff --git a/crates/tinybox-jail/src/windows_tests.rs b/crates/tinybox-jail/src/windows_tests.rs index 70282b0c..509be516 100644 --- a/crates/tinybox-jail/src/windows_tests.rs +++ b/crates/tinybox-jail/src/windows_tests.rs @@ -119,15 +119,30 @@ fn job_object_limit_plan_sets_memory_process_cpu_and_kill_on_close() { disk_bytes: Resources::DEFAULT.disk_bytes, }, 4, + &[ + tinybox_core::Constraint::Cpu, + tinybox_core::Constraint::Memory, + tinybox_core::Constraint::Pids, + ], ) .expect("valid Windows Job Object limits"); - assert_eq!(limits.process_memory_bytes, 512 * 1024 * 1024); - assert_eq!(limits.active_processes, 17); + assert_eq!(limits.process_memory_bytes, Some(512 * 1024 * 1024)); + assert_eq!(limits.active_processes, Some(17)); assert_eq!(limits.cpu_rate, Some(3_750)); assert!(limits.kill_on_close); } +#[test] +fn soft_resource_values_do_not_create_hard_job_limits() { + let limits = job_object_limit_plan(tinybox_core::Resources::DEFAULT, 8, &[]) + .expect("valid soft defaults"); + assert_eq!(limits.process_memory_bytes, None); + assert_eq!(limits.active_processes, None); + assert_eq!(limits.cpu_rate, None); + assert!(limits.kill_on_close); +} + #[test] fn windows_capabilities_report_only_job_object_resource_limits() { use tinybox_core::{Constraint, Enforcement}; From d4cd19a013ff6870b567295199d8810a3459d248 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:42:48 +0000 Subject: [PATCH 091/236] test(windows): cover owned appcontainer child capture and teardown Adds Windows tests for spawning an owned captured child in an AppContainer jail, verifying that stdout is captured and EOF follows process exit, and that both explicit termination and drop cleanly end a waiting child. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/windows_tests.rs | 41 ++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/crates/tinybox-jail/src/windows_tests.rs b/crates/tinybox-jail/src/windows_tests.rs index 509be516..d449c1b0 100644 --- a/crates/tinybox-jail/src/windows_tests.rs +++ b/crates/tinybox-jail/src/windows_tests.rs @@ -225,3 +225,44 @@ fn jail_preflight_accepts_supported_limits_and_refuses_hard_disk_limit() { }) )); } + +#[test] +fn owned_appcontainer_child_captures_output_waits_and_kills_on_drop() -> std::io::Result<()> { + use std::io::Read; + + let root = tempfile::tempdir()?; + let backend = AppContainerBackend::new(); + let mut echo = Command::new("cmd.exe"); + echo.args(["/C", "echo owned-child-ok"]); + let jail = Jail::new(root.path(), "owned-child-capture").deny_net(); + let mut child = crate::spawn_owned_captured_with(&backend, &jail, echo)?; + let mut output = String::new(); + child + .take_stdout() + .expect("captured stdout") + .read_to_string(&mut output)?; + assert!(output.contains("owned-child-ok")); + assert!(child.try_wait()?.is_some(), "EOF follows process exit"); + + let mut waiting = Command::new("cmd.exe"); + waiting.args(["/C", "set /p waiting="]); + let jail = Jail::new(root.path(), "owned-child-terminate").deny_net(); + let mut child = crate::spawn_owned_captured_with(&backend, &jail, waiting)?; + let _keep_stdin_open = child.take_stdin(); + let mut stdout = child.take_stdout().expect("captured stdout"); + child.terminate()?; + let mut remaining = Vec::new(); + stdout.read_to_end(&mut remaining)?; + assert!(child.try_wait()?.is_some(), "terminated job exits"); + + let mut waiting = Command::new("cmd.exe"); + waiting.args(["/C", "set /p waiting="]); + let jail = Jail::new(root.path(), "owned-child-cleanup").deny_net(); + let mut child = crate::spawn_owned_captured_with(&backend, &jail, waiting)?; + let _keep_stdin_open = child.take_stdin(); + let mut stdout = child.take_stdout().expect("captured stdout"); + drop(child); + let mut remaining = Vec::new(); + stdout.read_to_end(&mut remaining)?; + Ok(()) +} From 5517280361c2f408ab49897c813a38d5e927c01b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:43:27 +0000 Subject: [PATCH 092/236] refactor(sandbox): extract prlimit program resolution for testability Split the setrlimit fallback so the prlimit program can be injected, and generalise trusted path lookup into a reusable helper. This lets tests exercise the wrapping path without depending on the host's prlimit location. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-linux/src/sandbox/args.rs | 40 +++++++++++++++---- crates/tinybox-linux/src/sandbox/mod_tests.rs | 23 ++++++++++- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/crates/tinybox-linux/src/sandbox/args.rs b/crates/tinybox-linux/src/sandbox/args.rs index a413cadd..6caeedc8 100644 --- a/crates/tinybox-linux/src/sandbox/args.rs +++ b/crates/tinybox-linux/src/sandbox/args.rs @@ -123,6 +123,30 @@ pub(super) fn exec( limits: bool, setrlimit_fallback: bool, supports_expand_environment: bool, +) -> Result> { + let prlimit = if !limits && setrlimit_fallback { + Some(trusted_prlimit_program().map_err(|_| Error::Unsupported { + sandbox: NAME.to_owned(), + capability: Capability::ResourceLimits, + })?) + } else { + None + }; + exec_with_prlimit_program( + spec, + request, + limits, + prlimit.as_deref(), + supports_expand_environment, + ) +} + +pub(super) fn exec_with_prlimit_program( + spec: &BoxSpec, + request: &ExecRequest, + limits: bool, + prlimit_program: Option<&Path>, + supports_expand_environment: bool, ) -> Result> { if request.argv.is_empty() { return Err(Error::EmptyCommand { @@ -136,11 +160,7 @@ pub(super) fn exec( Vec::new() }; argv.extend(bwrap(spec, request)?); - if !limits && setrlimit_fallback { - let prlimit = trusted_prlimit_program().map_err(|_| Error::Unsupported { - sandbox: NAME.to_owned(), - capability: Capability::ResourceLimits, - })?; + if !limits && let Some(prlimit) = prlimit_program { wrap_setrlimit(&mut argv, &spec.resources, &prlimit); } Ok(argv) @@ -158,13 +178,19 @@ pub(super) fn wrap_setrlimit(argv: &mut Vec, resources: &Resources, prli } fn trusted_prlimit_program() -> std::io::Result { - [Path::new("/usr/bin/prlimit"), Path::new("/bin/prlimit")] + trusted_program_from([Path::new("/usr/bin/prlimit"), Path::new("/bin/prlimit")]) +} + +pub(super) fn trusted_program_from<'a>( + paths: impl IntoIterator, +) -> std::io::Result { + paths .into_iter() .find_map(|path| validate_trusted_program(path).ok()) .ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::NotFound, - "no trusted prlimit executable in fixed system locations", + "no trusted executable in fixed system locations", ) }) } diff --git a/crates/tinybox-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index 57e7cd6b..5e2169e0 100644 --- a/crates/tinybox-linux/src/sandbox/mod_tests.rs +++ b/crates/tinybox-linux/src/sandbox/mod_tests.rs @@ -953,8 +953,13 @@ fn setrlimit_fallback_wraps_commands_with_address_space_limit() -> Result<()> { pids_max: 16, disk_bytes: 1, }); - let mut argv = args::bwrap(&spec, &ExecRequest::new(["true"]))?; - args::wrap_setrlimit(&mut argv, &spec.resources, Path::new("/trusted/prlimit")); + let argv = args::exec_with_prlimit_program( + &spec, + &ExecRequest::new(["true"]), + false, + Some(Path::new("/trusted/prlimit")), + false, + )?; assert!( argv.first() .is_some_and(|program| program.ends_with("/prlimit")) @@ -964,3 +969,17 @@ fn setrlimit_fallback_wraps_commands_with_address_space_limit() -> Result<()> { assert!(has(&argv, "bwrap")); Ok(()) } + +#[cfg(unix)] +#[test] +fn trusted_resource_launcher_resolution_accepts_only_fixed_trusted_paths() -> std::io::Result<()> { + let program = args::trusted_program_from([Path::new("/usr/bin/true")])?; + assert!(program.ends_with("/true")); + assert_eq!( + args::trusted_program_from([Path::new("/path/that/does/not/exist")]) + .err() + .map(|error| error.kind()), + Some(std::io::ErrorKind::NotFound) + ); + Ok(()) +} From 7a0d4072ecf3858d4175ae27bc837e455e4309d2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:43:27 +0000 Subject: [PATCH 093/236] feat(jail): add box spec helpers for resources and hard requirements Add a builder method that copies resource defaults and hard constraints from a box specification onto a jail, plus backend helpers that plan-check and enforce only the hard requirements listed on a spec. This lets callers validate a spec's explicit constraints without constructing a full jail first. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/jail.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/tinybox-jail/src/jail.rs b/crates/tinybox-jail/src/jail.rs index 2dec368c..32dfea73 100644 --- a/crates/tinybox-jail/src/jail.rs +++ b/crates/tinybox-jail/src/jail.rs @@ -112,6 +112,15 @@ impl Jail { self } + /// Copy resource defaults and hard constraints from a box specification. + /// Filesystem roots, grants, and network policy remain explicit Jail data. + #[must_use] + pub fn with_box_spec(mut self, spec: &tinybox_core::BoxSpec) -> Self { + self.resources = Some(spec.resources); + self.hard_requirements = spec.hard_requirements.clone(); + self + } + /// Hard constraints implied by this jail's grants and denial flags. #[must_use] pub fn constraints(&self) -> Vec { @@ -406,6 +415,17 @@ pub trait JailBackend: Send + Sync { self.constraint_support().plan_check(&jail.constraints()) } + /// Inspect only the hard requirements explicitly listed on a box spec. + fn plan_check_spec(&self, spec: &tinybox_core::BoxSpec) -> tinybox_core::PlanCheck { + self.constraint_support().plan_check(&spec.hard_requirements) + } + + /// Refuse a box spec when any explicit hard requirement is unsupported. + fn require_spec(&self, spec: &tinybox_core::BoxSpec) -> tinybox_core::Result<()> { + self.constraint_support() + .require(self.name(), &spec.hard_requirements) + } + /// Refuse incomplete filesystem, network or subprocess confinement. /// /// # Errors From 043a325edc5f2b25824bdec16f9d1f08e7afe9b8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:43:40 +0000 Subject: [PATCH 094/236] test(sandbox): assert trusted program by file name The trusted resource launcher test now checks the resolved program's file name instead of matching a path suffix, so it no longer depends on the exact directory the binary resolves to. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-linux/src/sandbox/mod_tests.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index 5e2169e0..ccb2e041 100644 --- a/crates/tinybox-linux/src/sandbox/mod_tests.rs +++ b/crates/tinybox-linux/src/sandbox/mod_tests.rs @@ -974,7 +974,10 @@ fn setrlimit_fallback_wraps_commands_with_address_space_limit() -> Result<()> { #[test] fn trusted_resource_launcher_resolution_accepts_only_fixed_trusted_paths() -> std::io::Result<()> { let program = args::trusted_program_from([Path::new("/usr/bin/true")])?; - assert!(program.ends_with("/true")); + assert_eq!( + program.file_name().and_then(|name| name.to_str()), + Some("true") + ); assert_eq!( args::trusted_program_from([Path::new("/path/that/does/not/exist")]) .err() From a18df4e9136095174a207d7a098b0cebd622ff5e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:43:54 +0000 Subject: [PATCH 095/236] test(jail): cover box spec preflight on explicit hard requirements Adds a Windows test asserting that plan_check_spec only inspects hard requirements listed on a box spec, so soft resource defaults never become hard requirements, and that unsupported disk limits are still refused. Also reformats a chained call in the default plan_check_spec implementation. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/jail.rs | 3 ++- crates/tinybox-jail/src/windows_tests.rs | 29 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-jail/src/jail.rs b/crates/tinybox-jail/src/jail.rs index 32dfea73..d2da3b8f 100644 --- a/crates/tinybox-jail/src/jail.rs +++ b/crates/tinybox-jail/src/jail.rs @@ -417,7 +417,8 @@ pub trait JailBackend: Send + Sync { /// Inspect only the hard requirements explicitly listed on a box spec. fn plan_check_spec(&self, spec: &tinybox_core::BoxSpec) -> tinybox_core::PlanCheck { - self.constraint_support().plan_check(&spec.hard_requirements) + self.constraint_support() + .plan_check(&spec.hard_requirements) } /// Refuse a box spec when any explicit hard requirement is unsupported. diff --git a/crates/tinybox-jail/src/windows_tests.rs b/crates/tinybox-jail/src/windows_tests.rs index d449c1b0..51654887 100644 --- a/crates/tinybox-jail/src/windows_tests.rs +++ b/crates/tinybox-jail/src/windows_tests.rs @@ -226,6 +226,35 @@ fn jail_preflight_accepts_supported_limits_and_refuses_hard_disk_limit() { )); } +#[test] +fn box_spec_preflight_uses_only_explicit_hard_requirements() { + use tinybox_core::{Constraint, HostRef, Placement, SandboxRef, WorkspaceSource}; + + let placement = Placement::new( + HostRef::new("local").expect("valid host ref"), + SandboxRef::new("appcontainer").expect("valid sandbox ref"), + ); + let spec = tinybox_core::BoxSpec::new( + placement, + WorkspaceSource::LocalDir(PathBuf::from(r"C:\workspace")), + ); + let backend = AppContainerBackend::new(); + assert!(backend.plan_check_spec(&spec).constraints.is_empty()); + backend + .require_spec(&spec) + .expect("soft resource defaults do not become hard requirements"); + + let spec = spec.with_hard_requirements([Constraint::Cpu, Constraint::Memory, Constraint::Pids]); + let jail = Jail::new(r"C:\workspace", "spec-limits").with_box_spec(&spec); + assert!(backend.require_resources(&jail).is_ok()); + backend + .require_spec(&spec) + .expect("Job Objects enforce all listed resource requirements"); + + let disk_spec = spec.with_hard_requirements([Constraint::Disk]); + assert!(backend.require_spec(&disk_spec).is_err()); +} + #[test] fn owned_appcontainer_child_captures_output_waits_and_kills_on_drop() -> std::io::Result<()> { use std::io::Read; From 95b4c4c87076ab86c2bef4491073159139fc3455 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:44:04 +0000 Subject: [PATCH 096/236] test(sandbox): assert trusted program path is absolute The trusted resource launcher test now checks that the resolved program is an absolute path rather than comparing its file name, which better reflects the property the resolution logic guarantees. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-linux/src/sandbox/mod_tests.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/tinybox-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index ccb2e041..e84a3086 100644 --- a/crates/tinybox-linux/src/sandbox/mod_tests.rs +++ b/crates/tinybox-linux/src/sandbox/mod_tests.rs @@ -974,10 +974,7 @@ fn setrlimit_fallback_wraps_commands_with_address_space_limit() -> Result<()> { #[test] fn trusted_resource_launcher_resolution_accepts_only_fixed_trusted_paths() -> std::io::Result<()> { let program = args::trusted_program_from([Path::new("/usr/bin/true")])?; - assert_eq!( - program.file_name().and_then(|name| name.to_str()), - Some("true") - ); + assert!(program.is_absolute()); assert_eq!( args::trusted_program_from([Path::new("/path/that/does/not/exist")]) .err() From 7f712f67040fc46c1e2be7a441c88c74080e0c11 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:44:05 +0000 Subject: [PATCH 097/236] refactor(tinybox-jail): use clone_from and document require_spec errors Replaced the clone assignment in with_box_spec with clone_from to reuse the existing allocation, and added an Errors section to the require_spec doc comment describing the typed refusal it returns. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/jail.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-jail/src/jail.rs b/crates/tinybox-jail/src/jail.rs index d2da3b8f..9b40ac81 100644 --- a/crates/tinybox-jail/src/jail.rs +++ b/crates/tinybox-jail/src/jail.rs @@ -117,7 +117,7 @@ impl Jail { #[must_use] pub fn with_box_spec(mut self, spec: &tinybox_core::BoxSpec) -> Self { self.resources = Some(spec.resources); - self.hard_requirements = spec.hard_requirements.clone(); + self.hard_requirements.clone_from(&spec.hard_requirements); self } @@ -422,6 +422,9 @@ pub trait JailBackend: Send + Sync { } /// Refuse a box spec when any explicit hard requirement is unsupported. + /// + /// # Errors + /// Returns a typed refusal naming the first unsupported hard constraint. fn require_spec(&self, spec: &tinybox_core::BoxSpec) -> tinybox_core::Result<()> { self.constraint_support() .require(self.name(), &spec.hard_requirements) From 1cc09159f1b1629fd89765a906c54b6f3d091b5c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:44:42 +0000 Subject: [PATCH 098/236] refactor(sandbox): inject prlimit resolver into exec argument builder The exec argument builder now takes the trusted prlimit lookup as a resolver closure through a new exec_with_prlimit_resolver entry point, so tests can exercise the fallback path without depending on the host. A new test covers the case where no trusted launcher is available and the sandbox correctly reports resource limits as unsupported. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-linux/src/sandbox/args.rs | 20 ++++++++++++- crates/tinybox-linux/src/sandbox/mod_tests.rs | 30 +++++++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/tinybox-linux/src/sandbox/args.rs b/crates/tinybox-linux/src/sandbox/args.rs index 6caeedc8..5b6c45ca 100644 --- a/crates/tinybox-linux/src/sandbox/args.rs +++ b/crates/tinybox-linux/src/sandbox/args.rs @@ -123,9 +123,27 @@ pub(super) fn exec( limits: bool, setrlimit_fallback: bool, supports_expand_environment: bool, +) -> Result> { + exec_with_prlimit_resolver( + spec, + request, + limits, + setrlimit_fallback, + supports_expand_environment, + trusted_prlimit_program, + ) +} + +pub(super) fn exec_with_prlimit_resolver( + spec: &BoxSpec, + request: &ExecRequest, + limits: bool, + setrlimit_fallback: bool, + supports_expand_environment: bool, + resolve_prlimit: impl FnOnce() -> std::io::Result, ) -> Result> { let prlimit = if !limits && setrlimit_fallback { - Some(trusted_prlimit_program().map_err(|_| Error::Unsupported { + Some(resolve_prlimit().map_err(|_| Error::Unsupported { sandbox: NAME.to_owned(), capability: Capability::ResourceLimits, })?) diff --git a/crates/tinybox-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index e84a3086..0763f8c7 100644 --- a/crates/tinybox-linux/src/sandbox/mod_tests.rs +++ b/crates/tinybox-linux/src/sandbox/mod_tests.rs @@ -953,12 +953,13 @@ fn setrlimit_fallback_wraps_commands_with_address_space_limit() -> Result<()> { pids_max: 16, disk_bytes: 1, }); - let argv = args::exec_with_prlimit_program( + let argv = args::exec_with_prlimit_resolver( &spec, &ExecRequest::new(["true"]), false, - Some(Path::new("/trusted/prlimit")), + true, false, + || Ok(Path::new("/trusted/prlimit").to_path_buf()), )?; assert!( argv.first() @@ -970,6 +971,29 @@ fn setrlimit_fallback_wraps_commands_with_address_space_limit() -> Result<()> { Ok(()) } +#[test] +fn setrlimit_fallback_refuses_when_no_trusted_launcher_is_available() -> Result<()> { + let spec = spec()?; + assert!(matches!( + args::exec_with_prlimit_resolver( + &spec, + &ExecRequest::new(["true"]), + false, + true, + false, + || Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "missing prlimit", + )), + ), + Err(Error::Unsupported { + capability: Capability::ResourceLimits, + .. + }) + )); + Ok(()) +} + #[cfg(unix)] #[test] fn trusted_resource_launcher_resolution_accepts_only_fixed_trusted_paths() -> std::io::Result<()> { @@ -981,5 +1005,7 @@ fn trusted_resource_launcher_resolution_accepts_only_fixed_trusted_paths() -> st .map(|error| error.kind()), Some(std::io::ErrorKind::NotFound) ); + #[cfg(target_os = "linux")] + assert!(args::trusted_prlimit_program()?.is_absolute()); Ok(()) } From 5cafb33ef4f26b7908ceb841ada1554f5efd5ad9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:44:47 +0000 Subject: [PATCH 099/236] test(windows): drop redundant unsafe_code allow from windows tests The crate-level lint configuration already permits unsafe code in this test module, so the local allow attribute was unnecessary and has been removed. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/windows_tests.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/tinybox-jail/src/windows_tests.rs b/crates/tinybox-jail/src/windows_tests.rs index 51654887..b095c653 100644 --- a/crates/tinybox-jail/src/windows_tests.rs +++ b/crates/tinybox-jail/src/windows_tests.rs @@ -1,5 +1,3 @@ -#![allow(unsafe_code)] - use super::*; #[test] From e2ab119258b14116c5373d5371feb47f5f91b146 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:45:03 +0000 Subject: [PATCH 100/236] refactor(sandbox): expose trusted_prlimit_program to the parent module Widened the visibility of trusted_prlimit_program from private to pub(super) so the enclosing sandbox module can call it. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-linux/src/sandbox/args.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-linux/src/sandbox/args.rs b/crates/tinybox-linux/src/sandbox/args.rs index 5b6c45ca..19fe3ceb 100644 --- a/crates/tinybox-linux/src/sandbox/args.rs +++ b/crates/tinybox-linux/src/sandbox/args.rs @@ -195,7 +195,7 @@ pub(super) fn wrap_setrlimit(argv: &mut Vec, resources: &Resources, prli ); } -fn trusted_prlimit_program() -> std::io::Result { +pub(super) fn trusted_prlimit_program() -> std::io::Result { trusted_program_from([Path::new("/usr/bin/prlimit"), Path::new("/bin/prlimit")]) } From e59153a359a04f65aa0cf73f52ae57a04cde6760 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:45:34 +0000 Subject: [PATCH 101/236] fix(sandbox): pass prlimit program by value to wrap_setrlimit The call site was passing a reference to the prlimit program where the function expects it by value, so the argument now matches the signature. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-linux/src/sandbox/args.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-linux/src/sandbox/args.rs b/crates/tinybox-linux/src/sandbox/args.rs index 19fe3ceb..3f36344c 100644 --- a/crates/tinybox-linux/src/sandbox/args.rs +++ b/crates/tinybox-linux/src/sandbox/args.rs @@ -179,7 +179,7 @@ pub(super) fn exec_with_prlimit_program( }; argv.extend(bwrap(spec, request)?); if !limits && let Some(prlimit) = prlimit_program { - wrap_setrlimit(&mut argv, &spec.resources, &prlimit); + wrap_setrlimit(&mut argv, &spec.resources, prlimit); } Ok(argv) } From e560642748859d31a975eafb5fa3629b5072aad1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:55:40 +0000 Subject: [PATCH 102/236] ci: add windows unsafe boundary check Add a CI job that runs a new script rejecting unsafe-code allowances outside the audited Windows FFI module, and update ADR 0003 and 0005 to describe the workspace default of unsafe_code = deny with that single exception. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .github/workflows/ci.yml | 6 +++ .../0003-workspace-split-to-contain-unsafe.md | 38 +++++++++++-------- .../adr/0005-namespaces-through-bubblewrap.md | 9 +++-- scripts/ci/check-windows-unsafe-boundary.sh | 23 +++++++++++ 4 files changed, 58 insertions(+), 18 deletions(-) create mode 100755 scripts/ci/check-windows-unsafe-boundary.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33896f62..126277ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,12 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + windows-unsafe-boundary: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - name: Check the Windows FFI allowance boundary + run: scripts/ci/check-windows-unsafe-boundary.sh module-ci: uses: tinyhumansai/.github/.github/workflows/module-ci.yml@4adc1080fb495098e7af1aade82ed7e6e3c69bb7 with: diff --git a/docs/adr/0003-workspace-split-to-contain-unsafe.md b/docs/adr/0003-workspace-split-to-contain-unsafe.md index eded86ad..c7648a49 100644 --- a/docs/adr/0003-workspace-split-to-contain-unsafe.md +++ b/docs/adr/0003-workspace-split-to-contain-unsafe.md @@ -3,16 +3,20 @@ - **Status:** Accepted, amended by [ADR 0005](0005-namespaces-through-bubblewrap.md) - **Date:** 2026-08-14 -> **Amendment.** The `unsafe` reservation described below was never needed. -> Modern Ubuntu blocks unprivileged user namespaces for unconfined binaries, so -> the namespace backend drives `bwrap` instead of calling `clone` directly and -> `unsafe_code = "forbid"` holds across the whole workspace with no exception. -> The rest of this decision — the split itself, and why — stands. +> **Amendment.** Modern Ubuntu blocks unprivileged user namespaces for +> unconfined binaries, so the namespace backend drives `bwrap` instead of +> calling `clone` directly. The Windows AppContainer backend now has one +> narrowly scoped FFI exception at +> `crates/tinybox-jail/src/windows.rs`; the workspace default remains +> `unsafe_code = "deny"`, and CI rejects allowances in every other Rust source +> file. The rest of this decision — the split itself, and why — stands. ## Context -`Cargo.toml` sets `unsafe_code = "forbid"`. Unlike `deny`, `forbid` cannot be -cancelled by a local `#[allow]` — that is the point of choosing it. +`Cargo.toml` sets the workspace default to `unsafe_code = "deny"`. The Windows +AppContainer APIs require a small Win32 FFI boundary; keeping the exception in +one file makes that boundary reviewable without relaxing the lint for the rest +of the workspace. A rootless namespaces backend cannot honor that. It needs `clone` with namespace flags, `unshare`, `pivot_root`, `mount`, and a seccomp filter loaded @@ -22,12 +26,14 @@ async-signal-safe and no safe abstraction exists. Three options were available: -1. Relax `unsafe_code` to `deny` crate-wide and use targeted `#[allow]`s. +1. Relax `unsafe_code` to `deny` crate-wide and allow unsafe throughout the + platform backend. 2. Stay on `forbid` and shell out to a helper binary for the unsafe parts. -3. Split into a workspace and allow `unsafe` in exactly one member. +3. Keep the workspace default at `deny` and permit unsafe in one audited + Windows FFI file, checked by CI. -Option 1 removes the guarantee everywhere to serve one module: the model types, -the bus adapter, and the CLI would all lose a lint they have no need to lose. +Option 1 spreads the exception across a platform backend and makes the boundary +harder to audit. Option 2 keeps the guarantee but pays a process spawn on every box creation and moves the hard part into an unversioned binary that is harder to test than a library. @@ -41,16 +47,18 @@ deployment uses Docker or SSH. ## Decision Convert the repository to a Cargo workspace. Hoist the lint configuration into -`[workspace.lints]` so every member inherits identical settings, and let -`tinybox-linux` — and only `tinybox-linux` — override `unsafe_code` to `deny`, -with every invariant documented in a `// SAFETY:` comment. +`[workspace.lints]` so every member inherits identical settings. Keep +`unsafe_code = "deny"` as the default and allow it only in +`crates/tinybox-jail/src/windows.rs`; the CI boundary check rejects allowances +in other production Rust files. The Windows FFI stays in that module, with each +invariant documented at the relevant call. ```text crates/ ├── tinybox-core/ # model and traits; unsafe FORBIDDEN ├── tinybox-host/ # LocalHost, SshHost ├── tinybox-docker/ # DockerSandbox -├── tinybox-linux/ # NamespaceSandbox; unsafe DENIED, documented +├── tinybox-linux/ # NamespaceSandbox; safe bubblewrap launcher ├── tinybox-cli/ # bin `tinybox` └── tinybox-module/ # cdylib, TinyBus ABI v1 ``` diff --git a/docs/adr/0005-namespaces-through-bubblewrap.md b/docs/adr/0005-namespaces-through-bubblewrap.md index ff4b64c2..775e0806 100644 --- a/docs/adr/0005-namespaces-through-bubblewrap.md +++ b/docs/adr/0005-namespaces-through-bubblewrap.md @@ -56,9 +56,12 @@ applications with. `tinybox-linux` drives `bwrap` through its [`Host`](0004-drive-backends-through-the-host-trait.md), exactly as the Docker backend drives `docker`. -`unsafe` is **not** enabled anywhere. `unsafe_code = "forbid"` stays in -`[workspace.lints]` with no exception, and the reservation ADR 0003 made for -this crate is withdrawn. +The Linux namespace backend uses bubblewrap and contains no unsafe code. The +workspace default remains `unsafe_code = "deny"`; the only local allowance is +the Win32 FFI boundary in `crates/tinybox-jail/src/windows.rs`, documented in +ADR 0003. CI checks that no other Rust source file adds an unsafe-code +allowance. The reservation ADR 0003 made for the Linux backend remains +withdrawn. Rootless cgroup v2 limits go through `systemd-run --user --scope`, which is the only route an unprivileged process has to a delegated cgroup. They are opt-in on diff --git a/scripts/ci/check-windows-unsafe-boundary.sh b/scripts/ci/check-windows-unsafe-boundary.sh new file mode 100755 index 00000000..fa204f07 --- /dev/null +++ b/scripts/ci/check-windows-unsafe-boundary.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +allowed_file="crates/tinybox-jail/src/windows.rs" +mapfile -t allowance_files < <( + rg -l -U '#\s*!?\s*\[\s*allow\s*\([^]]*unsafe_code' crates --glob '*.rs' || true +) + +if [[ "${#allowance_files[@]}" -ne 1 || "${allowance_files[0]}" != "$allowed_file" ]]; then + printf 'unsafe_code allowances must appear only in %s\n' "$allowed_file" >&2 + printf 'found: %s\n' "${allowance_files[*]:-(none)}" >&2 + exit 1 +fi + +if ! rg -q '^unsafe_code = "deny"$' Cargo.toml; then + echo 'workspace unsafe_code lint must remain deny by default' >&2 + exit 1 +fi + +if ! rg -q '^#!\[allow\(unsafe_code\)\]$' "$allowed_file"; then + echo 'Windows FFI exception must remain a file-scoped allowance' >&2 + exit 1 +fi From 189a2863310434ab2065dfbb88ddea2f5e578bc7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:56:33 +0000 Subject: [PATCH 103/236] docs(tinybox-linux): update unsafe_code policy description The crate documentation now reflects that the workspace denies unsafe code by default rather than forbidding it everywhere, and notes the single Windows FFI exception scoped to tinybox-jail and checked in CI. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-linux/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-linux/src/lib.rs b/crates/tinybox-linux/src/lib.rs index c00047d8..74ffe242 100644 --- a/crates/tinybox-linux/src/lib.rs +++ b/crates/tinybox-linux/src/lib.rs @@ -32,7 +32,8 @@ //! way an unprivileged process can create a user namespace at all, because //! `kernel.apparmor_restrict_unprivileged_userns` blocks unconfined binaries //! from doing it. ADR 0005 records the decision; the workspace keeps -//! `unsafe_code = "forbid"` everywhere, with no exception. +//! `unsafe_code = "deny"` by default across the workspace. The single Windows +//! FFI exception is scoped to `tinybox-jail/src/windows.rs` and checked in CI. //! //! # What this backend does not do //! From 454b7b8988289d6d63f286d563fe090762e0ed00 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:57:00 +0000 Subject: [PATCH 104/236] feat(jail): add env-aware captured spawn hook Adds a spawn_owned_captured_with_env method to JailBackend that defaults to the existing captured spawn, giving native launchers a way to apply the host-approved environment instead of inheriting the parent's. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/jail.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/tinybox-jail/src/jail.rs b/crates/tinybox-jail/src/jail.rs index 9b40ac81..81045361 100644 --- a/crates/tinybox-jail/src/jail.rs +++ b/crates/tinybox-jail/src/jail.rs @@ -511,6 +511,21 @@ pub trait JailBackend: Send + Sync { OwnedChild::from_std(child) } } + + /// Spawn captured work with an explicit environment approved by the host. + /// Backends that launch through `std::process::Command` already receive + /// this map through `cmd`; native launchers must use it instead of + /// inheriting the parent environment. + /// # Errors + /// Returns a confinement or native startup error. + fn spawn_owned_captured_with_env( + &self, + jail: &Jail, + cmd: Command, + _env: &std::collections::BTreeMap, + ) -> std::io::Result { + self.spawn_owned_captured(jail, cmd) + } } /// Exit information independent of a platform-specific child handle. From 88c06a55ac701f3097903ee88ba7fa2adae78d12 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:58:20 +0000 Subject: [PATCH 105/236] feat(jail): pass explicit environment to windows appcontainer children The Windows AppContainer backend now accepts an explicit environment map and builds a sorted, NUL-terminated UTF-16 block for CreateProcessW instead of inheriting the parent environment. Filesystem and network constraints are also reported as enforced, and the capability derivation FFI is wrapped in a safe function. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/windows.rs | 59 ++++++++++++++-- crates/tinybox-jail/src/windows_tests.rs | 89 +++++++++++++++++++++++- 2 files changed, 141 insertions(+), 7 deletions(-) diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index b4fc4fb0..a8d88747 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -36,6 +36,7 @@ #![allow(unsafe_op_in_unsafe_fn)] use std::ffi::OsStr; +use std::collections::BTreeMap; use std::io; use std::os::windows::ffi::OsStrExt; use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; @@ -74,6 +75,7 @@ const CONTAINER_INHERIT_ACE: u32 = 0x02; /// Required for capability SIDs passed to `AppContainer` `SECURITY_CAPABILITIES`. /// Source: WinNT.h. const SE_GROUP_ENABLED: u32 = 0x0000_0004; +const CREATE_UNICODE_ENVIRONMENT: u32 = 0x0000_0400; /// `AppContainer` capability names granted when `jail.allow_net == true`. /// @@ -159,7 +161,16 @@ impl JailBackend for AppContainerBackend { jail: &Jail, cmd: Command, ) -> io::Result { - unsafe { spawn_in_container(jail, &cmd) } + self.spawn_owned_captured_with_env(jail, cmd, &BTreeMap::new()) + } + + fn spawn_owned_captured_with_env( + &self, + jail: &Jail, + cmd: Command, + env: &BTreeMap, + ) -> io::Result { + unsafe { spawn_in_container(jail, &cmd, env) } } fn constraint_support(&self) -> tinybox_core::ConstraintSupport { @@ -223,7 +234,13 @@ fn windows_constraint_support(available: bool) -> tinybox_core::ConstraintSuppor if !available { return ConstraintSupport::NONE; } - [Constraint::Cpu, Constraint::Memory, Constraint::Pids] + [ + Constraint::Filesystem, + Constraint::Network, + Constraint::Cpu, + Constraint::Memory, + Constraint::Pids, + ] .into_iter() .fold(ConstraintSupport::NONE, |support, constraint| { support.with(constraint, Enforcement::Enforced) @@ -305,8 +322,33 @@ fn size_as_u32(value: usize) -> io::Result { }) } +fn environment_block(env: &BTreeMap) -> io::Result> { + let mut entries: Vec<_> = env.iter().collect(); + entries.sort_by_key(|(key, _)| key.to_ascii_lowercase()); + let mut block = Vec::new(); + for (key, value) in entries { + if key.is_empty() || key.contains('=') || key.contains('\0') || value.contains('\0') { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Windows environment entries must have valid names and no NUL bytes", + )); + } + block.extend(format!("{key}={value}").encode_utf16()); + block.push(0); + } + block.push(0); + if env.is_empty() { + block.push(0); + } + Ok(block) +} + #[allow(clippy::too_many_lines)] // Win32 startup structures must share their backing lifetimes. -unsafe fn spawn_in_container(jail: &Jail, cmd: &Command) -> io::Result { +unsafe fn spawn_in_container( + jail: &Jail, + cmd: &Command, + env: &BTreeMap, +) -> io::Result { // 1. Create or open the AppContainer profile and obtain its SID. let profile_name = sanitize_profile_name(&jail.label); let wide_name = to_wide(&profile_name); @@ -458,6 +500,7 @@ unsafe fn spawn_in_container(jail: &Jail, cmd: &Command) -> io::Result io::Result(), &raw mut pi, @@ -827,7 +870,11 @@ impl Drop for CapabilityDerivation { /// /// # Safety /// FFI into `userenv.dll`. Safe to call from any thread on Windows. -unsafe fn derive_capability(name: &str) -> io::Result { +fn derive_capability(name: &str) -> io::Result { + unsafe { derive_capability_ffi(name) } +} + +unsafe fn derive_capability_ffi(name: &str) -> io::Result { let name_w = to_wide(name); let mut group_sids: *mut PSID = ptr::null_mut(); let mut group_count: u32 = 0; diff --git a/crates/tinybox-jail/src/windows_tests.rs b/crates/tinybox-jail/src/windows_tests.rs index b095c653..2d23e428 100644 --- a/crates/tinybox-jail/src/windows_tests.rs +++ b/crates/tinybox-jail/src/windows_tests.rs @@ -20,7 +20,7 @@ fn derive_capability_resolves_well_known_internet_client() { // capabilities present on every supported Windows version // (10+). DeriveCapabilitySidsFromName must succeed and return // at least one non-null capability SID. - let deriv = unsafe { derive_capability("internetClient") } + let deriv = derive_capability("internetClient") .expect("internetClient must resolve on any supported Windows"); assert!( !deriv.capability_sids.is_empty(), @@ -141,6 +141,93 @@ fn soft_resource_values_do_not_create_hard_job_limits() { assert!(limits.kill_on_close); } +#[test] +fn explicit_environment_block_is_sorted_and_does_not_add_parent_values() { + let env = BTreeMap::from([ + ("TINYBOX_APPROVED".to_owned(), "visible".to_owned()), + ("Z_LAST".to_owned(), "last".to_owned()), + ]); + let block = environment_block(&env).expect("valid explicit environment"); + let block = String::from_utf16_lossy(&block); + assert!(block.starts_with("TINYBOX_APPROVED=visible\0Z_LAST=last\0\0")); + assert!(!block.contains("PATH=")); + assert!(!block.contains("USERPROFILE=")); +} + +#[test] +fn empty_environment_block_has_the_required_double_terminator() { + assert_eq!(environment_block(&BTreeMap::new()).unwrap(), vec![0, 0]); +} + +#[test] +fn appcontainer_owned_spawn_uses_only_the_approved_environment() -> io::Result<()> { + use std::io::Read; + + let root = tempfile::tempdir()?; + let backend = AppContainerBackend::new(); + let mut command = Command::new("cmd.exe"); + command.args(["/C", "echo %TINYBOX_APPROVED_ENV%|%PATH%"]); + let approved = BTreeMap::from([("TINYBOX_APPROVED_ENV".to_owned(), "approved-value".to_owned())]); + let jail = Jail::new(root.path(), "approved-env").deny_net(); + let mut child = crate::spawn_owned_captured_with_env(&backend, &jail, command, &approved)?; + let mut output = String::new(); + child + .take_stdout() + .expect("captured stdout") + .read_to_string(&mut output)?; + assert!(output.contains("approved-value")); + if let Ok(parent_path) = std::env::var("PATH") { + assert!(!output.contains(&parent_path), "parent PATH leaked to the child"); + } + assert!(child.try_wait()?.is_some()); + Ok(()) +} + +#[test] +fn appcontainer_acl_grants_the_root_and_denies_an_outside_secret() -> io::Result<()> { + use std::io::Read; + + let root = tempfile::tempdir()?; + let outside = tempfile::tempdir()?; + let allowed = root.path().join("allowed.txt"); + let secret = outside.path().join("secret.txt"); + std::fs::write(&allowed, "inside-marker")?; + std::fs::write(&secret, "outside-secret-marker")?; + + let command_text = format!( + "type \"{}\" & type \"{}\"", + allowed.display(), + secret.display() + ); + let mut command = Command::new("cmd.exe"); + command.args(["/C", &command_text]); + let jail = Jail::new(root.path(), "acl-regression").deny_net(); + let backend = AppContainerBackend::new(); + backend.require(&jail).expect("filesystem preflight is enforced"); + let mut child = crate::spawn_owned_captured_with_env( + &backend, + &jail, + command, + &BTreeMap::new(), + )?; + + let mut stdout = String::new(); + child + .take_stdout() + .expect("captured stdout") + .read_to_string(&mut stdout)?; + let mut stderr = String::new(); + child + .take_stderr() + .expect("captured stderr") + .read_to_string(&mut stderr)?; + assert!(stdout.contains("inside-marker")); + assert!(!stdout.contains("outside-secret-marker")); + assert!(stderr.to_ascii_lowercase().contains("access is denied")); + assert!(child.try_wait()?.is_some()); + Ok(()) +} + #[test] fn windows_capabilities_report_only_job_object_resource_limits() { use tinybox_core::{Constraint, Enforcement}; From 21004e2ee7a93fafbfaa9c9b2f3a59a97c46c095 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:58:42 +0000 Subject: [PATCH 106/236] feat(jailed): pass approved environment to owned jail children Jailed commands now carry the caller's approved environment through to the owned-child spawn path instead of relying on the host process environment, and backend selection uses the owned-capability check so non-Unix backends that support owned children are accepted. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-host/src/limited/jailed.rs | 45 ++++++++++++++++++----- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/crates/tinybox-host/src/limited/jailed.rs b/crates/tinybox-host/src/limited/jailed.rs index c0286493..50997e02 100644 --- a/crates/tinybox-host/src/limited/jailed.rs +++ b/crates/tinybox-host/src/limited/jailed.rs @@ -16,8 +16,8 @@ use tokio::sync::{oneshot, watch}; #[cfg(unix)] struct AsyncPipe(tokio::io::unix::AsyncFd); -// Native jail supervision is Unix-only today; retain a compile-time adapter for -// other targets so this module stays buildable if that capability is added. +// The Windows backend also exposes owned captured pipes; retain the portable +// Tokio file adapter for that path. #[cfg(not(unix))] type AsyncPipe = tokio::fs::File; @@ -196,19 +196,19 @@ impl std::fmt::Debug for JailLocalHost { impl JailLocalHost { /// Use the detected backend without weakening unavailable confinement. /// # Errors - /// Rejects unavailable backends and platforms without supervised standard children. + /// Rejects unavailable backends without owned-child supervision. pub fn new(jail: Jail, limit: usize) -> std::io::Result { - Self::with_backend(jail, tinybox_jail::default_backend(), limit) + Self::with_backend(jail, tinybox_jail::default_owned_backend(), limit) } /// Use an explicitly selected provider; hosts apply policy to its enforcement facts. /// # Errors - /// Rejects unavailable providers and unsupported native ownership platforms. + /// Rejects providers that cannot return an owned child. pub fn with_backend( jail: Jail, backend: Arc, limit: usize, ) -> std::io::Result { - if !cfg!(unix) || !backend.is_available() { + if !backend.is_owned_available() { return Err(std::io::Error::new( std::io::ErrorKind::Unsupported, "native jail supervision is unavailable", @@ -320,7 +320,12 @@ 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) + self.collect_command( + command, + request.stdin.clone(), + observer, + request.env.clone(), + ) .await } @@ -329,17 +334,31 @@ impl JailLocalHost { /// # Errors /// Reports unavailable spawn, output bounds and retained kill/reap failures. pub async fn run_command_observed( + &self, + command: std::process::Command, + payload: Option>, + observer: Arc, + ) -> Result { + self.run_command_observed_with_env(command, payload, observer, BTreeMap::new()) + .await + } + + /// Collect a prepared command with the exact environment approved by its caller. + /// # Errors + /// Reports unavailable spawn, output bounds and retained kill/reap failures. + pub async fn run_command_observed_with_env( &self, mut command: std::process::Command, payload: Option>, observer: Arc, + env: BTreeMap, ) -> Result { command.stdin(if payload.is_some() { std::process::Stdio::piped() } else { std::process::Stdio::null() }); - self.collect_command(command, payload, Some(observer)).await + self.collect_command(command, payload, Some(observer), env).await } async fn collect_command( @@ -347,6 +366,7 @@ impl JailLocalHost { mut command: std::process::Command, payload: Option>, observer: Option>, + env: BTreeMap, ) -> Result { #[cfg(unix)] { @@ -356,6 +376,7 @@ impl JailLocalHost { command .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); + command.env_clear().envs(&env); let jail = self.jail.clone(); let backend = self.backend.clone(); let state = self.state.clone(); @@ -370,8 +391,14 @@ impl JailLocalHost { if !matches!(cancelled.try_recv(), Err(oneshot::error::TryRecvError::Empty)) { return Err(failure("start native jail", "execution cancelled".into())); } + let approved_env = env.clone(); let child = tokio::task::spawn_blocking(move || { - tinybox_jail::spawn_owned_captured_with(backend.as_ref(), &jail, command) + tinybox_jail::spawn_owned_captured_with_env( + backend.as_ref(), + &jail, + command, + &approved_env, + ) }) .await .map_err(|error| failure("start native jail", error.to_string()))? From b8357145b32be3222427cddfbaedb3b8d7b0abb4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:59:14 +0000 Subject: [PATCH 107/236] test(jailed): cover approved environment for owned backend on windows Adds a Windows-only test that runs a command through the detected owned AppContainer backend and checks that the approved environment variable reaches the jailed process while the parent PATH does not leak into it. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinybox-host/src/limited/jailed_tests.rs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/tinybox-host/src/limited/jailed_tests.rs b/crates/tinybox-host/src/limited/jailed_tests.rs index 4e48655c..4ec84dab 100644 --- a/crates/tinybox-host/src/limited/jailed_tests.rs +++ b/crates/tinybox-host/src/limited/jailed_tests.rs @@ -238,6 +238,37 @@ fn unavailable_jail_provider_refuses_instead_of_selecting_another_backend() Ok(()) } +#[cfg(target_os = "windows")] +#[tokio::test] +async fn detected_owned_backend_runs_with_only_the_approved_environment() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let host = JailLocalHost::new( + tinybox_jail::Jail::new(root.path(), "host-owned-appcontainer"), + 1024, + )?; + assert!(format!("{host:?}").contains("appcontainer")); + + let parent_path = std::env::var("PATH").ok(); + let mut request = ExecRequest::new([ + "cmd.exe", + "/C", + "echo %TINYBOX_APPROVED_ENV%|%PATH%", + ]); + request.cwd = Some(root.path().to_owned()); + request + .env + .insert("TINYBOX_APPROVED_ENV".to_owned(), "approved-marker".to_owned()); + let output = host.run(&request).await?; + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("approved-marker")); + if let Some(parent_path) = parent_path { + assert!(!stdout.contains(&parent_path), "parent PATH leaked into jailed process"); + } + host.drain_checked().await?; + Ok(()) +} + #[cfg(target_os = "linux")] #[tokio::test] async fn detected_landlock_collection_enforces_writable_root() From db0c06393557c61b371901cdd5d2806b9175324f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 12:59:53 +0000 Subject: [PATCH 108/236] feat(jail): pass approved environment through captured spawn Add a spawn variant that takes an explicit backend and approved environment, and route the limited host through it so native launchers set the child environment instead of inheriting host values. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-host/src/limited/jailed.rs | 2 +- crates/tinybox-jail/src/lib.rs | 40 +++++++++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/tinybox-host/src/limited/jailed.rs b/crates/tinybox-host/src/limited/jailed.rs index 50997e02..91cbdbc4 100644 --- a/crates/tinybox-host/src/limited/jailed.rs +++ b/crates/tinybox-host/src/limited/jailed.rs @@ -393,7 +393,7 @@ impl JailLocalHost { } let approved_env = env.clone(); let child = tokio::task::spawn_blocking(move || { - tinybox_jail::spawn_owned_captured_with_env( + tinybox_jail::spawn_owned_captured_with_backend_and_env( backend.as_ref(), &jail, command, diff --git a/crates/tinybox-jail/src/lib.rs b/crates/tinybox-jail/src/lib.rs index 33aa5e25..69d88633 100644 --- a/crates/tinybox-jail/src/lib.rs +++ b/crates/tinybox-jail/src/lib.rs @@ -169,12 +169,31 @@ pub fn spawn_owned_captured_with( backend: &dyn JailBackend, jail: &Jail, cmd: Command, +) -> std::io::Result { + spawn_owned_captured_with_backend_and_env( + backend, + jail, + cmd, + &std::collections::BTreeMap::new(), + ) +} + +/// Spawn captured work through an explicit backend and approved environment. +/// Native launchers must pass `env` as the child environment rather than +/// inheriting values from the host process. +/// # Errors +/// Returns canonicalization, hard-preflight, confinement, or startup errors. +pub fn spawn_owned_captured_with_backend_and_env( + backend: &dyn JailBackend, + jail: &Jail, + cmd: Command, + env: &std::collections::BTreeMap, ) -> std::io::Result { let mut jail = jail.clone(); jail.canonicalize()?; require_resources(backend, &jail)?; jail.validate_denials(backend.carves_denials(), &backend.implicit_grants())?; - backend.spawn_owned_captured(&jail, cmd) + backend.spawn_owned_captured_with_env(&jail, cmd, env) } /// Spawn captured work with the detected backend that supports owned children. @@ -182,7 +201,24 @@ pub fn spawn_owned_captured_with( /// # Errors /// Returns an error if no owned-child backend is available or jail setup fails. pub fn spawn_owned_captured(jail: &Jail, cmd: Command) -> std::io::Result { - spawn_owned_captured_with(default_owned_backend().as_ref(), jail, cmd) + spawn_owned_captured_with_backend_and_env( + default_owned_backend().as_ref(), + jail, + cmd, + &std::collections::BTreeMap::new(), + ) +} + +/// Spawn captured work with the detected owned-child backend and approved environment. +/// +/// # Errors +/// Returns an error if no owned-child backend is available or jail setup fails. +pub fn spawn_owned_captured_with_env( + jail: &Jail, + cmd: Command, + env: &std::collections::BTreeMap, +) -> std::io::Result { + spawn_owned_captured_with_backend_and_env(default_owned_backend().as_ref(), jail, cmd, env) } fn require_resources(backend: &dyn JailBackend, jail: &Jail) -> std::io::Result<()> { From 8c1568a33a9d25a2ab4280dcb374ce49bb6d09a4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:00:44 +0000 Subject: [PATCH 109/236] test(windows): update spawn helper calls to backend-aware variant The Windows AppContainer tests now call spawn_owned_captured_with_backend_and_env instead of the older spawn_owned_captured_with_env helper, keeping the tests aligned with the renamed spawn API. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/windows_tests.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-jail/src/windows_tests.rs b/crates/tinybox-jail/src/windows_tests.rs index 2d23e428..9c8215af 100644 --- a/crates/tinybox-jail/src/windows_tests.rs +++ b/crates/tinybox-jail/src/windows_tests.rs @@ -169,7 +169,12 @@ fn appcontainer_owned_spawn_uses_only_the_approved_environment() -> io::Result<( command.args(["/C", "echo %TINYBOX_APPROVED_ENV%|%PATH%"]); let approved = BTreeMap::from([("TINYBOX_APPROVED_ENV".to_owned(), "approved-value".to_owned())]); let jail = Jail::new(root.path(), "approved-env").deny_net(); - let mut child = crate::spawn_owned_captured_with_env(&backend, &jail, command, &approved)?; + let mut child = crate::spawn_owned_captured_with_backend_and_env( + &backend, + &jail, + command, + &approved, + )?; let mut output = String::new(); child .take_stdout() @@ -204,7 +209,7 @@ fn appcontainer_acl_grants_the_root_and_denies_an_outside_secret() -> io::Result let jail = Jail::new(root.path(), "acl-regression").deny_net(); let backend = AppContainerBackend::new(); backend.require(&jail).expect("filesystem preflight is enforced"); - let mut child = crate::spawn_owned_captured_with_env( + let mut child = crate::spawn_owned_captured_with_backend_and_env( &backend, &jail, command, From 662fc54a43cccb52a12f9be2850bf0b794fe97b7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:01:03 +0000 Subject: [PATCH 110/236] style: apply rustfmt formatting to jailed host and windows jail code Reformatted the jailed host and Windows jail sources and their tests to match rustfmt output, wrapping long call chains and assertions and reordering imports. No behaviour changes. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-host/src/limited/jailed.rs | 5 +++-- .../tinybox-host/src/limited/jailed_tests.rs | 18 +++++++-------- crates/tinybox-jail/src/windows.rs | 10 ++++----- crates/tinybox-jail/src/windows_tests.rs | 22 +++++++++++-------- 4 files changed, 30 insertions(+), 25 deletions(-) diff --git a/crates/tinybox-host/src/limited/jailed.rs b/crates/tinybox-host/src/limited/jailed.rs index 91cbdbc4..1992ef0a 100644 --- a/crates/tinybox-host/src/limited/jailed.rs +++ b/crates/tinybox-host/src/limited/jailed.rs @@ -326,7 +326,7 @@ impl JailLocalHost { observer, request.env.clone(), ) - .await + .await } /// Collect a prepared shell inside this host's explicit native jail. @@ -358,7 +358,8 @@ impl JailLocalHost { } else { std::process::Stdio::null() }); - self.collect_command(command, payload, Some(observer), env).await + self.collect_command(command, payload, Some(observer), env) + .await } async fn collect_command( diff --git a/crates/tinybox-host/src/limited/jailed_tests.rs b/crates/tinybox-host/src/limited/jailed_tests.rs index 4ec84dab..62a4ab1a 100644 --- a/crates/tinybox-host/src/limited/jailed_tests.rs +++ b/crates/tinybox-host/src/limited/jailed_tests.rs @@ -250,20 +250,20 @@ async fn detected_owned_backend_runs_with_only_the_approved_environment() assert!(format!("{host:?}").contains("appcontainer")); let parent_path = std::env::var("PATH").ok(); - let mut request = ExecRequest::new([ - "cmd.exe", - "/C", - "echo %TINYBOX_APPROVED_ENV%|%PATH%", - ]); + let mut request = ExecRequest::new(["cmd.exe", "/C", "echo %TINYBOX_APPROVED_ENV%|%PATH%"]); request.cwd = Some(root.path().to_owned()); - request - .env - .insert("TINYBOX_APPROVED_ENV".to_owned(), "approved-marker".to_owned()); + request.env.insert( + "TINYBOX_APPROVED_ENV".to_owned(), + "approved-marker".to_owned(), + ); let output = host.run(&request).await?; let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("approved-marker")); if let Some(parent_path) = parent_path { - assert!(!stdout.contains(&parent_path), "parent PATH leaked into jailed process"); + assert!( + !stdout.contains(&parent_path), + "parent PATH leaked into jailed process" + ); } host.drain_checked().await?; Ok(()) diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index a8d88747..45efd353 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -35,8 +35,8 @@ // per-call blocks is a separate, hardware-tested change. #![allow(unsafe_op_in_unsafe_fn)] -use std::ffi::OsStr; use std::collections::BTreeMap; +use std::ffi::OsStr; use std::io; use std::os::windows::ffi::OsStrExt; use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; @@ -241,10 +241,10 @@ fn windows_constraint_support(available: bool) -> tinybox_core::ConstraintSuppor Constraint::Memory, Constraint::Pids, ] - .into_iter() - .fold(ConstraintSupport::NONE, |support, constraint| { - support.with(constraint, Enforcement::Enforced) - }) + .into_iter() + .fold(ConstraintSupport::NONE, |support, constraint| { + support.with(constraint, Enforcement::Enforced) + }) } unsafe fn create_job_object( diff --git a/crates/tinybox-jail/src/windows_tests.rs b/crates/tinybox-jail/src/windows_tests.rs index 9c8215af..0417437b 100644 --- a/crates/tinybox-jail/src/windows_tests.rs +++ b/crates/tinybox-jail/src/windows_tests.rs @@ -167,14 +167,13 @@ fn appcontainer_owned_spawn_uses_only_the_approved_environment() -> io::Result<( let backend = AppContainerBackend::new(); let mut command = Command::new("cmd.exe"); command.args(["/C", "echo %TINYBOX_APPROVED_ENV%|%PATH%"]); - let approved = BTreeMap::from([("TINYBOX_APPROVED_ENV".to_owned(), "approved-value".to_owned())]); + let approved = BTreeMap::from([( + "TINYBOX_APPROVED_ENV".to_owned(), + "approved-value".to_owned(), + )]); let jail = Jail::new(root.path(), "approved-env").deny_net(); - let mut child = crate::spawn_owned_captured_with_backend_and_env( - &backend, - &jail, - command, - &approved, - )?; + let mut child = + crate::spawn_owned_captured_with_backend_and_env(&backend, &jail, command, &approved)?; let mut output = String::new(); child .take_stdout() @@ -182,7 +181,10 @@ fn appcontainer_owned_spawn_uses_only_the_approved_environment() -> io::Result<( .read_to_string(&mut output)?; assert!(output.contains("approved-value")); if let Ok(parent_path) = std::env::var("PATH") { - assert!(!output.contains(&parent_path), "parent PATH leaked to the child"); + assert!( + !output.contains(&parent_path), + "parent PATH leaked to the child" + ); } assert!(child.try_wait()?.is_some()); Ok(()) @@ -208,7 +210,9 @@ fn appcontainer_acl_grants_the_root_and_denies_an_outside_secret() -> io::Result command.args(["/C", &command_text]); let jail = Jail::new(root.path(), "acl-regression").deny_net(); let backend = AppContainerBackend::new(); - backend.require(&jail).expect("filesystem preflight is enforced"); + backend + .require(&jail) + .expect("filesystem preflight is enforced"); let mut child = crate::spawn_owned_captured_with_backend_and_env( &backend, &jail, From 3a01fead982825be5d236595ffe7ef58c52857ea Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:02:03 +0000 Subject: [PATCH 111/236] fix(tinybox-host): gate unix-only imports and silence clippy on non-unix The test module now imports ErrorKind only on Unix, where it is actually used, and the async pipe helper carries an allow attribute for the non-Unix build so the fallible signature does not trip clippy. This keeps non-Unix builds warning-free without changing behaviour. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-host/src/files_tests.rs | 4 +++- crates/tinybox-host/src/limited/jailed.rs | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-host/src/files_tests.rs b/crates/tinybox-host/src/files_tests.rs index 85f7c322..6deed474 100644 --- a/crates/tinybox-host/src/files_tests.rs +++ b/crates/tinybox-host/src/files_tests.rs @@ -1,7 +1,9 @@ //! Tests for confined workspace file handles. use super::{LocalFileWriter, begin_writer, open_reader, open_root}; -use std::io::{ErrorKind, Write}; +#[cfg(unix)] +use std::io::ErrorKind; +use std::io::Write; use std::path::{Path, PathBuf}; use tinybox_core::{Error, Result, WorkspaceFileWriter}; diff --git a/crates/tinybox-host/src/limited/jailed.rs b/crates/tinybox-host/src/limited/jailed.rs index 1992ef0a..ae38277e 100644 --- a/crates/tinybox-host/src/limited/jailed.rs +++ b/crates/tinybox-host/src/limited/jailed.rs @@ -21,6 +21,8 @@ struct AsyncPipe(tokio::io::unix::AsyncFd); #[cfg(not(unix))] type AsyncPipe = tokio::fs::File; +// Keep one fallible call shape across platforms; only Unix pipe registration can fail. +#[cfg_attr(not(unix), allow(clippy::unnecessary_wraps))] fn async_pipe(file: std::fs::File) -> Result { #[cfg(unix)] { From cc43af26a6fa63d3c88818594b91d22ed31e3ae4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:03:44 +0000 Subject: [PATCH 112/236] ci: replace ripgrep with grep in windows unsafe boundary check The CI script now uses grep instead of ripgrep to scan for unsafe_code allowances and verify the workspace lint, so the check no longer depends on rg being installed on the runner. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- scripts/ci/check-windows-unsafe-boundary.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/ci/check-windows-unsafe-boundary.sh b/scripts/ci/check-windows-unsafe-boundary.sh index fa204f07..1398d249 100755 --- a/scripts/ci/check-windows-unsafe-boundary.sh +++ b/scripts/ci/check-windows-unsafe-boundary.sh @@ -3,7 +3,8 @@ set -euo pipefail allowed_file="crates/tinybox-jail/src/windows.rs" mapfile -t allowance_files < <( - rg -l -U '#\s*!?\s*\[\s*allow\s*\([^]]*unsafe_code' crates --glob '*.rs' || true + grep -RIlE '#[[:space:]]*!?[[:space:]]*\[[[:space:]]*allow[[:space:]]*\([^]]*unsafe_code' \ + crates --include='*.rs' || true ) if [[ "${#allowance_files[@]}" -ne 1 || "${allowance_files[0]}" != "$allowed_file" ]]; then @@ -12,12 +13,12 @@ if [[ "${#allowance_files[@]}" -ne 1 || "${allowance_files[0]}" != "$allowed_fil exit 1 fi -if ! rg -q '^unsafe_code = "deny"$' Cargo.toml; then +if ! grep -Eq '^unsafe_code = "deny"$' Cargo.toml; then echo 'workspace unsafe_code lint must remain deny by default' >&2 exit 1 fi -if ! rg -q '^#!\[allow\(unsafe_code\)\]$' "$allowed_file"; then +if ! grep -Eq '^#!\[allow\(unsafe_code\)\]$' "$allowed_file"; then echo 'Windows FFI exception must remain a file-scoped allowance' >&2 exit 1 fi From 1448c2f83546aafb0ea31e5b2e2b76e062f9dcf2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:09:50 +0000 Subject: [PATCH 113/236] fix(native_jail): treat explicit native jail budgets as hard requirements When a native jail supplies its own resource limits, those limits are now added to the jail's hard requirements so they are enforced as a hard request rather than left soft like the default BoxSpec values. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinybox-module/src/tinybus_module/native_jail.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/tinybox-module/src/tinybus_module/native_jail.rs b/crates/tinybox-module/src/tinybus_module/native_jail.rs index d4a41083..6f6f545e 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail.rs @@ -62,6 +62,17 @@ pub(super) fn prepare( pids_max: limits.pids_max, disk_bytes: limits.disk_bytes, }); + if jail.resources.is_some() { + // An explicitly supplied native jail budget is a hard request. The + // default values in a BoxSpec remain soft unless its own + // `hard_requirements` lists a constraint. + jail.hard_requirements.extend([ + tinybox_core::Constraint::Cpu, + tinybox_core::Constraint::Memory, + tinybox_core::Constraint::Pids, + tinybox_core::Constraint::Disk, + ]); + } if jail .resources .is_some_and(|limits| limits.validate().is_err()) From 5704d0a2dd74648045e07fe29fba0447e4bbf0ea Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:10:23 +0000 Subject: [PATCH 114/236] refactor(tinybox-module): extract required resource constraints helper Move the logic that marks an explicitly supplied native jail budget as a hard request into a dedicated helper, so the constraint set is derived from the configured resources rather than the jail's own field. Behaviour is unchanged. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/tinybus_module/native_jail.rs | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/native_jail.rs b/crates/tinybox-module/src/tinybus_module/native_jail.rs index 6f6f545e..0e83bd35 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail.rs @@ -62,17 +62,8 @@ pub(super) fn prepare( pids_max: limits.pids_max, disk_bytes: limits.disk_bytes, }); - if jail.resources.is_some() { - // An explicitly supplied native jail budget is a hard request. The - // default values in a BoxSpec remain soft unless its own - // `hard_requirements` lists a constraint. - jail.hard_requirements.extend([ - tinybox_core::Constraint::Cpu, - tinybox_core::Constraint::Memory, - tinybox_core::Constraint::Pids, - tinybox_core::Constraint::Disk, - ]); - } + jail.hard_requirements + .extend(required_resource_constraints(config.resources)); if jail .resources .is_some_and(|limits| limits.validate().is_err()) @@ -135,6 +126,25 @@ pub(super) fn prepare( } Ok(jail) } + +fn required_resource_constraints( + resources: Option, +) -> Vec { + if resources.is_some() { + // An explicitly supplied native jail budget is a hard request. The + // default values in a BoxSpec remain soft unless its own + // `hard_requirements` lists a constraint. + vec![ + tinybox_core::Constraint::Cpu, + tinybox_core::Constraint::Memory, + tinybox_core::Constraint::Pids, + tinybox_core::Constraint::Disk, + ] + } else { + Vec::new() + } +} + /// Validate and canonicalize each existing absolute grant path. fn grants(paths: &[String]) -> Result> { paths From e36c49a928efbfcde14a2bacecd8cd33474c6564 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:11:07 +0000 Subject: [PATCH 115/236] refactor(tinybox-module): extract resource setup into configure_resources Move the resource limit mapping, hard requirement extension, and validation out of prepare into a dedicated helper so the jail preparation flow stays readable. Behaviour is unchanged. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/tinybus_module/native_jail.rs | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/native_jail.rs b/crates/tinybox-module/src/tinybus_module/native_jail.rs index 0e83bd35..2d7c0799 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail.rs @@ -56,20 +56,7 @@ pub(super) fn prepare( jail.read_only = grants(&config.read_only)?; jail.read_write = grants(&config.read_write)?; jail.deny_read = denials(&config.deny_read)?; - jail.resources = config.resources.map(|limits| tinybox_core::Resources { - cpu_millis: limits.cpu_millis, - memory_bytes: limits.memory_bytes, - pids_max: limits.pids_max, - disk_bytes: limits.disk_bytes, - }); - jail.hard_requirements - .extend(required_resource_constraints(config.resources)); - if jail - .resources - .is_some_and(|limits| limits.validate().is_err()) - { - return Err(invalid()); - } + configure_resources(&mut jail, config)?; // A denial over the root or a writable grant would leave the command no // workspace; a denial nested inside a writable grant is safe only for a // backend that applies denials after its write allows. @@ -127,6 +114,24 @@ pub(super) fn prepare( Ok(jail) } +fn configure_resources(jail: &mut Jail, config: &NativeJailConfig) -> Result<()> { + jail.resources = config.resources.map(|limits| tinybox_core::Resources { + cpu_millis: limits.cpu_millis, + memory_bytes: limits.memory_bytes, + pids_max: limits.pids_max, + disk_bytes: limits.disk_bytes, + }); + jail.hard_requirements + .extend(required_resource_constraints(config.resources)); + if jail + .resources + .is_some_and(|limits| limits.validate().is_err()) + { + return Err(invalid()); + } + Ok(()) +} + fn required_resource_constraints( resources: Option, ) -> Vec { From 83cd9fc128186b281e88a47ab531d9845e070058 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:13:55 +0000 Subject: [PATCH 116/236] fix(windows): forward LOCALAPPDATA into the AppContainer environment CreateProcessW needs the profile root to resolve the AppContainer profile, so the environment block now always includes LOCALAPPDATA from the parent even when the requested child environment is otherwise empty. Entries are built from OsString values and encoded directly to UTF-16 rather than formatted as strings, and the redundant double-NUL for empty environments was dropped. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/windows.rs | 38 +++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index 45efd353..cb20f173 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -322,24 +322,38 @@ fn size_as_u32(value: usize) -> io::Result { }) } -fn environment_block(env: &BTreeMap) -> io::Result> { - let mut entries: Vec<_> = env.iter().collect(); - entries.sort_by_key(|(key, _)| key.to_ascii_lowercase()); +fn environment_block( + env: &BTreeMap, + local_app_data: &OsStr, +) -> io::Result> { + let mut entries: Vec<(OsString, OsString)> = env + .iter() + .filter(|(key, _)| !key.eq_ignore_ascii_case("LOCALAPPDATA")) + .map(|(key, value)| (key.into(), value.into())) + .collect(); + // CreateProcessW needs the profile root to resolve the AppContainer + // profile. Forward this single OS-required value even when the requested + // child environment is otherwise empty; do not inherit the parent block. + entries.push((OsString::from("LOCALAPPDATA"), local_app_data.to_owned())); + entries.sort_by_key(|(key, _)| key.to_string_lossy().to_ascii_lowercase()); let mut block = Vec::new(); for (key, value) in entries { - if key.is_empty() || key.contains('=') || key.contains('\0') || value.contains('\0') { + if key.is_empty() + || key.to_string_lossy().contains('=') + || key.to_string_lossy().contains('\0') + || value.to_string_lossy().contains('\0') + { return Err(io::Error::new( io::ErrorKind::InvalidInput, "Windows environment entries must have valid names and no NUL bytes", )); } - block.extend(format!("{key}={value}").encode_utf16()); + block.extend(key.encode_wide()); + block.push(b'=' as u16); + block.extend(value.encode_wide()); block.push(0); } block.push(0); - if env.is_empty() { - block.push(0); - } Ok(block) } @@ -500,7 +514,13 @@ unsafe fn spawn_in_container( let cmdline = build_command_line(cmd); let mut cmdline_w = to_wide(&cmdline); let cwd_w = cmd.get_current_dir().map(|p| to_wide(&p.to_string_lossy())); - let mut environment = environment_block(env)?; + let local_app_data = std::env::var_os("LOCALAPPDATA").ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "LOCALAPPDATA is required to launch an AppContainer process", + ) + })?; + let mut environment = environment_block(env, &local_app_data)?; let mut pi: PROCESS_INFORMATION = std::mem::zeroed(); let ok = CreateProcessW( From e896e69e2885e7413d8ae451fd21dd1525633a02 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:14:14 +0000 Subject: [PATCH 117/236] fix(jail): inject LOCALAPPDATA into the explicit environment block The environment block builder now takes the parent's LOCALAPPDATA and always includes it, since some Windows tooling expects the variable to be present even when the environment is otherwise explicit. Tests were updated to pass the value and assert it appears first in the sorted block. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/windows.rs | 2 +- crates/tinybox-jail/src/windows_tests.rs | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index cb20f173..2eccaa4a 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -36,7 +36,7 @@ #![allow(unsafe_op_in_unsafe_fn)] use std::collections::BTreeMap; -use std::ffi::OsStr; +use std::ffi::{OsStr, OsString}; use std::io; use std::os::windows::ffi::OsStrExt; use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; diff --git a/crates/tinybox-jail/src/windows_tests.rs b/crates/tinybox-jail/src/windows_tests.rs index 0417437b..f18cff2a 100644 --- a/crates/tinybox-jail/src/windows_tests.rs +++ b/crates/tinybox-jail/src/windows_tests.rs @@ -143,20 +143,28 @@ fn soft_resource_values_do_not_create_hard_job_limits() { #[test] fn explicit_environment_block_is_sorted_and_does_not_add_parent_values() { + let local_app_data = std::ffi::OsStr::new(r"C:\Users\runner\AppData\Local"); let env = BTreeMap::from([ ("TINYBOX_APPROVED".to_owned(), "visible".to_owned()), ("Z_LAST".to_owned(), "last".to_owned()), ]); - let block = environment_block(&env).expect("valid explicit environment"); + let block = environment_block(&env, local_app_data).expect("valid explicit environment"); let block = String::from_utf16_lossy(&block); - assert!(block.starts_with("TINYBOX_APPROVED=visible\0Z_LAST=last\0\0")); + assert!(block.starts_with( + "LOCALAPPDATA=C:\\Users\\runner\\AppData\\Local\0TINYBOX_APPROVED=visible\0Z_LAST=last\0\0" + )); assert!(!block.contains("PATH=")); assert!(!block.contains("USERPROFILE=")); } #[test] fn empty_environment_block_has_the_required_double_terminator() { - assert_eq!(environment_block(&BTreeMap::new()).unwrap(), vec![0, 0]); + let local_app_data = std::ffi::OsStr::new(r"C:\Users\runner\AppData\Local"); + let block = environment_block(&BTreeMap::new(), local_app_data).unwrap(); + assert_eq!( + String::from_utf16_lossy(&block), + "LOCALAPPDATA=C:\\Users\\runner\\AppData\\Local\0\0" + ); } #[test] From d42f630594cff6cb2a1b8bd224709bdd7b7c9a00 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:15:14 +0000 Subject: [PATCH 118/236] test: cover environment overrides and session bus vars in jail tests Extend the Windows environment block test to assert that a request-supplied LOCALAPPDATA override is not leaked into the block, and forward DBUS_SESSION_BUS_ADDRESS and XDG_RUNTIME_DIR from the host into the native cancellation test's exec environment so the jailed process can reach the session bus. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/windows_tests.rs | 2 ++ .../tinybox-module/src/tinybus_module/native_jail_tests.rs | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/crates/tinybox-jail/src/windows_tests.rs b/crates/tinybox-jail/src/windows_tests.rs index f18cff2a..09442b0a 100644 --- a/crates/tinybox-jail/src/windows_tests.rs +++ b/crates/tinybox-jail/src/windows_tests.rs @@ -145,6 +145,7 @@ fn soft_resource_values_do_not_create_hard_job_limits() { fn explicit_environment_block_is_sorted_and_does_not_add_parent_values() { let local_app_data = std::ffi::OsStr::new(r"C:\Users\runner\AppData\Local"); let env = BTreeMap::from([ + ("LOCALAPPDATA".to_owned(), "request-override".to_owned()), ("TINYBOX_APPROVED".to_owned(), "visible".to_owned()), ("Z_LAST".to_owned(), "last".to_owned()), ]); @@ -155,6 +156,7 @@ fn explicit_environment_block_is_sorted_and_does_not_add_parent_values() { )); assert!(!block.contains("PATH=")); assert!(!block.contains("USERPROFILE=")); + assert!(!block.contains("request-override")); } #[test] diff --git a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs index 999cb121..fe6e1750 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -912,6 +912,11 @@ async fn native_cancellation_kills_detached_descendants_and_preserves_stream_eve }; let mut exec = tinybox_core::ExecRequest::new(["python3", "-c", &python]); exec.cwd = Some(root.path().into()); + for key in ["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"] { + if let Ok(value) = std::env::var(key) { + exec.env.insert(key.to_owned(), value); + } + } let backend = resource_backend(); let jail = prepare_with_resources(root.path(), &request, &mut exec, backend.as_ref())?; let host = Arc::new(tinybox_host::JailLocalHost::with_backend( From bf3381fa5b326e95270bfaadfeb3981d035eee94 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:15:44 +0000 Subject: [PATCH 119/236] test(tinybox-module): prepare jail before copying bus env vars Move the jail preparation ahead of the environment variable copying in the native cancellation test so the exec request is fully set up before the jail is created. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-module/src/tinybus_module/native_jail_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs index fe6e1750..e83ac6a0 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -912,13 +912,13 @@ async fn native_cancellation_kills_detached_descendants_and_preserves_stream_eve }; let mut exec = tinybox_core::ExecRequest::new(["python3", "-c", &python]); exec.cwd = Some(root.path().into()); + let backend = resource_backend(); + let jail = prepare_with_resources(root.path(), &request, &mut exec, backend.as_ref())?; for key in ["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"] { if let Ok(value) = std::env::var(key) { exec.env.insert(key.to_owned(), value); } } - let backend = resource_backend(); - let jail = prepare_with_resources(root.path(), &request, &mut exec, backend.as_ref())?; let host = Arc::new(tinybox_host::JailLocalHost::with_backend( jail, backend, From 2dafd4496144f7ad34dfe01efdc9f4952319e057 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:16:21 +0000 Subject: [PATCH 120/236] test(tinybox-module): pass approved env to observed command runner Update the native jail cancellation test to call run_command_observed_with_env with a cloned copy of the approved environment, keeping the command's envs in sync with what the runner receives. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/tinybus_module/native_jail_tests.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs index e83ac6a0..ebbab801 100644 --- a/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/native_jail_tests.rs @@ -919,6 +919,7 @@ async fn native_cancellation_kills_detached_descendants_and_preserves_stream_eve exec.env.insert(key.to_owned(), value); } } + let approved_env = exec.env.clone(); let host = Arc::new(tinybox_host::JailLocalHost::with_backend( jail, backend, @@ -928,12 +929,15 @@ async fn native_cancellation_kills_detached_descendants_and_preserves_stream_eve command .args(exec.argv.iter().skip(1)) .env_clear() - .envs(exec.env); + .envs(&approved_env); let journal = Arc::new(super::super::output::OutputJournal::default()); let runner = host.clone(); let observer = journal.clone(); - let task = - tokio::spawn(async move { runner.run_command_observed(command, None, observer).await }); + let task = tokio::spawn(async move { + runner + .run_command_observed_with_env(command, None, observer, approved_env) + .await + }); let deadline = tokio::time::Instant::now() + Duration::from_secs(10); let mut ready = false; while tokio::time::Instant::now() < deadline { From 1f7fa9dd2509cdf064bd0424dfeb34eacff0dc71 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:17:00 +0000 Subject: [PATCH 121/236] chore(tinybox-jail): use u16::from for equals sign in environment block Replaced the byte cast with u16::from when appending the separator to the Windows environment block, making the wide-character conversion explicit without changing behaviour. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/windows.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index 2eccaa4a..051d8322 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -349,7 +349,7 @@ fn environment_block( )); } block.extend(key.encode_wide()); - block.push(b'=' as u16); + block.push(u16::from(b'=')); block.extend(value.encode_wide()); block.push(0); } From 427df50e089142a82047f7c5b5043fb2e69ab8c6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:21:06 +0000 Subject: [PATCH 122/236] test(windows): assert acl and network enforcement in capability report The Windows capability test now checks that filesystem and network constraints are reported as enforced alongside the existing CPU and memory assertions, and the test was renamed to reflect that it covers ACL as well as job object enforcement. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/windows_tests.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-jail/src/windows_tests.rs b/crates/tinybox-jail/src/windows_tests.rs index 09442b0a..deddfd56 100644 --- a/crates/tinybox-jail/src/windows_tests.rs +++ b/crates/tinybox-jail/src/windows_tests.rs @@ -248,10 +248,18 @@ fn appcontainer_acl_grants_the_root_and_denies_an_outside_secret() -> io::Result } #[test] -fn windows_capabilities_report_only_job_object_resource_limits() { +fn windows_capabilities_report_acl_and_job_object_enforcement() { use tinybox_core::{Constraint, Enforcement}; let support = windows_constraint_support(true); + assert_eq!( + support.enforcement(Constraint::Filesystem), + Enforcement::Enforced + ); + assert_eq!( + support.enforcement(Constraint::Network), + Enforcement::Enforced + ); assert_eq!(support.enforcement(Constraint::Cpu), Enforcement::Enforced); assert_eq!( support.enforcement(Constraint::Memory), From cad477ce2d9bc16afe63c3394f0326eccacfcadc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:26:51 +0000 Subject: [PATCH 123/236] fix(tinybox-jail): forward SystemRoot into the AppContainer environment Windows child processes need SystemRoot to locate system runtime files, so the environment block now forwards it from the parent alongside LOCALAPPDATA and strips any caller-supplied override. The required-variable lookup is factored into a shared helper, and the spawn test now captures stderr and exit status to make failures easier to diagnose. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/windows.rs | 27 +++++++++----- crates/tinybox-jail/src/windows_tests.rs | 46 ++++++++++++++++++------ 2 files changed, 55 insertions(+), 18 deletions(-) diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index 051d8322..10e79370 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -325,16 +325,22 @@ fn size_as_u32(value: usize) -> io::Result { fn environment_block( env: &BTreeMap, local_app_data: &OsStr, + system_root: &OsStr, ) -> io::Result> { let mut entries: Vec<(OsString, OsString)> = env .iter() - .filter(|(key, _)| !key.eq_ignore_ascii_case("LOCALAPPDATA")) + .filter(|(key, _)| { + !key.eq_ignore_ascii_case("LOCALAPPDATA") && !key.eq_ignore_ascii_case("SYSTEMROOT") + }) .map(|(key, value)| (key.into(), value.into())) .collect(); // CreateProcessW needs the profile root to resolve the AppContainer // profile. Forward this single OS-required value even when the requested // child environment is otherwise empty; do not inherit the parent block. entries.push((OsString::from("LOCALAPPDATA"), local_app_data.to_owned())); + // Windows child processes need SystemRoot to locate system runtime files. + // This is the only other parent value forwarded into the block. + entries.push((OsString::from("SystemRoot"), system_root.to_owned())); entries.sort_by_key(|(key, _)| key.to_string_lossy().to_ascii_lowercase()); let mut block = Vec::new(); for (key, value) in entries { @@ -357,6 +363,15 @@ fn environment_block( Ok(block) } +fn required_environment_variable(name: &'static str) -> io::Result { + std::env::var_os(name).ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!("{name} is required to launch an AppContainer process"), + ) + }) +} + #[allow(clippy::too_many_lines)] // Win32 startup structures must share their backing lifetimes. unsafe fn spawn_in_container( jail: &Jail, @@ -514,13 +529,9 @@ unsafe fn spawn_in_container( let cmdline = build_command_line(cmd); let mut cmdline_w = to_wide(&cmdline); let cwd_w = cmd.get_current_dir().map(|p| to_wide(&p.to_string_lossy())); - let local_app_data = std::env::var_os("LOCALAPPDATA").ok_or_else(|| { - io::Error::new( - io::ErrorKind::NotFound, - "LOCALAPPDATA is required to launch an AppContainer process", - ) - })?; - let mut environment = environment_block(env, &local_app_data)?; + let local_app_data = required_environment_variable("LOCALAPPDATA")?; + let system_root = required_environment_variable("SystemRoot")?; + let mut environment = environment_block(env, &local_app_data, &system_root)?; let mut pi: PROCESS_INFORMATION = std::mem::zeroed(); let ok = CreateProcessW( diff --git a/crates/tinybox-jail/src/windows_tests.rs b/crates/tinybox-jail/src/windows_tests.rs index deddfd56..d33c8d16 100644 --- a/crates/tinybox-jail/src/windows_tests.rs +++ b/crates/tinybox-jail/src/windows_tests.rs @@ -144,15 +144,18 @@ fn soft_resource_values_do_not_create_hard_job_limits() { #[test] fn explicit_environment_block_is_sorted_and_does_not_add_parent_values() { let local_app_data = std::ffi::OsStr::new(r"C:\Users\runner\AppData\Local"); + let system_root = std::ffi::OsStr::new(r"C:\Windows"); let env = BTreeMap::from([ + ("SYSTEMROOT".to_owned(), "request-override".to_owned()), ("LOCALAPPDATA".to_owned(), "request-override".to_owned()), ("TINYBOX_APPROVED".to_owned(), "visible".to_owned()), ("Z_LAST".to_owned(), "last".to_owned()), ]); - let block = environment_block(&env, local_app_data).expect("valid explicit environment"); + let block = environment_block(&env, local_app_data, system_root) + .expect("valid explicit environment"); let block = String::from_utf16_lossy(&block); assert!(block.starts_with( - "LOCALAPPDATA=C:\\Users\\runner\\AppData\\Local\0TINYBOX_APPROVED=visible\0Z_LAST=last\0\0" + "LOCALAPPDATA=C:\\Users\\runner\\AppData\\Local\0SYSTEMROOT=C:\\Windows\0TINYBOX_APPROVED=visible\0Z_LAST=last\0\0" )); assert!(!block.contains("PATH=")); assert!(!block.contains("USERPROFILE=")); @@ -162,10 +165,11 @@ fn explicit_environment_block_is_sorted_and_does_not_add_parent_values() { #[test] fn empty_environment_block_has_the_required_double_terminator() { let local_app_data = std::ffi::OsStr::new(r"C:\Users\runner\AppData\Local"); - let block = environment_block(&BTreeMap::new(), local_app_data).unwrap(); + let system_root = std::ffi::OsStr::new(r"C:\Windows"); + let block = environment_block(&BTreeMap::new(), local_app_data, system_root).unwrap(); assert_eq!( String::from_utf16_lossy(&block), - "LOCALAPPDATA=C:\\Users\\runner\\AppData\\Local\0\0" + "LOCALAPPDATA=C:\\Users\\runner\\AppData\\Local\0SYSTEMROOT=C:\\Windows\0\0" ); } @@ -189,14 +193,23 @@ fn appcontainer_owned_spawn_uses_only_the_approved_environment() -> io::Result<( .take_stdout() .expect("captured stdout") .read_to_string(&mut output)?; - assert!(output.contains("approved-value")); + let mut stderr = String::new(); + child + .take_stderr() + .expect("captured stderr") + .read_to_string(&mut stderr)?; + let status = child.try_wait()?; + assert!( + output.contains("approved-value"), + "stdout={output:?}, stderr={stderr:?}, status={status:?}" + ); if let Ok(parent_path) = std::env::var("PATH") { assert!( !output.contains(&parent_path), "parent PATH leaked to the child" ); } - assert!(child.try_wait()?.is_some()); + assert!(status.is_some()); Ok(()) } @@ -240,10 +253,14 @@ fn appcontainer_acl_grants_the_root_and_denies_an_outside_secret() -> io::Result .take_stderr() .expect("captured stderr") .read_to_string(&mut stderr)?; - assert!(stdout.contains("inside-marker")); + let status = child.try_wait()?; + assert!( + stdout.contains("inside-marker"), + "stdout={stdout:?}, stderr={stderr:?}, status={status:?}" + ); assert!(!stdout.contains("outside-secret-marker")); assert!(stderr.to_ascii_lowercase().contains("access is denied")); - assert!(child.try_wait()?.is_some()); + assert!(status.is_some()); Ok(()) } @@ -382,8 +399,17 @@ fn owned_appcontainer_child_captures_output_waits_and_kills_on_drop() -> std::io .take_stdout() .expect("captured stdout") .read_to_string(&mut output)?; - assert!(output.contains("owned-child-ok")); - assert!(child.try_wait()?.is_some(), "EOF follows process exit"); + let mut stderr = String::new(); + child + .take_stderr() + .expect("captured stderr") + .read_to_string(&mut stderr)?; + let status = child.try_wait()?; + assert!( + output.contains("owned-child-ok"), + "stdout={output:?}, stderr={stderr:?}, status={status:?}" + ); + assert!(status.is_some(), "EOF follows process exit"); let mut waiting = Command::new("cmd.exe"); waiting.args(["/C", "set /p waiting="]); From 944fbe35a85693949af8889d5a9f5322f9259bc0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:27:21 +0000 Subject: [PATCH 124/236] test(jailed): add windows test module wiring Register a Windows-only test module in the jailed host so platform-specific tests can be compiled and run on Windows. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-host/src/limited/jailed.rs | 4 +++ .../src/limited/jailed_windows_tests.rs | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 crates/tinybox-host/src/limited/jailed_windows_tests.rs diff --git a/crates/tinybox-host/src/limited/jailed.rs b/crates/tinybox-host/src/limited/jailed.rs index ae38277e..f712cb81 100644 --- a/crates/tinybox-host/src/limited/jailed.rs +++ b/crates/tinybox-host/src/limited/jailed.rs @@ -719,3 +719,7 @@ where #[cfg(all(test, unix))] #[path = "jailed_tests.rs"] mod tests; + +#[cfg(all(test, target_os = "windows"))] +#[path = "jailed_windows_tests.rs"] +mod windows_tests; diff --git a/crates/tinybox-host/src/limited/jailed_windows_tests.rs b/crates/tinybox-host/src/limited/jailed_windows_tests.rs new file mode 100644 index 00000000..6c6511b7 --- /dev/null +++ b/crates/tinybox-host/src/limited/jailed_windows_tests.rs @@ -0,0 +1,34 @@ +//! Windows integration coverage for the backend-owned AppContainer process path. + +use super::*; +use tinybox_core::ExecRequest; + +#[tokio::test] +async fn detected_owned_backend_runs_with_only_the_approved_environment() +-> Result<(), Box> { + let root = tempfile::tempdir()?; + let host = JailLocalHost::new( + tinybox_jail::Jail::new(root.path(), "host-owned-appcontainer"), + 1024, + )?; + assert!(format!("{host:?}").contains("appcontainer")); + + let parent_path = std::env::var("PATH").ok(); + let mut request = ExecRequest::new(["cmd.exe", "/C", "echo %TINYBOX_APPROVED_ENV%|%PATH%"]); + request.cwd = Some(root.path().to_owned()); + request.env.insert( + "TINYBOX_APPROVED_ENV".to_owned(), + "approved-marker".to_owned(), + ); + let output = host.run(&request).await?; + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("approved-marker"), "stdout={stdout:?}"); + if let Some(parent_path) = parent_path { + assert!( + !stdout.contains(&parent_path), + "parent PATH leaked into jailed process" + ); + } + host.drain_checked().await?; + Ok(()) +} From a9f113f5d840607b35b87cc3b7fe9b61da40ec2b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:28:09 +0000 Subject: [PATCH 125/236] test(tinybox-module): pass session bus environment to resource descendant tests The resource stream tests now forward DBUS_SESSION_BUS_ADDRESS and XDG_RUNTIME_DIR to the spawned descendant so it can reach the native bus, and the pid file wait also requires a non-empty file to avoid reading a partially written identity. A jail test line was reformatted to satisfy rustfmt. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/windows_tests.rs | 4 ++-- .../src/tinybus_module/resources_stream_tests.rs | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/tinybox-jail/src/windows_tests.rs b/crates/tinybox-jail/src/windows_tests.rs index d33c8d16..2f4ab83c 100644 --- a/crates/tinybox-jail/src/windows_tests.rs +++ b/crates/tinybox-jail/src/windows_tests.rs @@ -151,8 +151,8 @@ fn explicit_environment_block_is_sorted_and_does_not_add_parent_values() { ("TINYBOX_APPROVED".to_owned(), "visible".to_owned()), ("Z_LAST".to_owned(), "last".to_owned()), ]); - let block = environment_block(&env, local_app_data, system_root) - .expect("valid explicit environment"); + let block = + environment_block(&env, local_app_data, system_root).expect("valid explicit environment"); let block = String::from_utf16_lossy(&block); assert!(block.starts_with( "LOCALAPPDATA=C:\\Users\\runner\\AppData\\Local\0SYSTEMROOT=C:\\Windows\0TINYBOX_APPROVED=visible\0Z_LAST=last\0\0" 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 a11fe017..27a00860 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -1414,6 +1414,18 @@ fn native_resource_bus_available() -> bool { available } +#[cfg(target_os = "linux")] +fn native_resource_test_environment() -> std::collections::BTreeMap { + ["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"] + .into_iter() + .filter_map(|name| { + std::env::var(name) + .ok() + .map(|value| (name.to_owned(), value)) + }) + .collect() +} + #[cfg(target_os = "linux")] fn detached_descendant_script() -> String { "import os,time,sys\nfirst=os.fork()\nif first == 0:\n os.setsid()\n second=os.fork()\n if second == 0:\n with open(sys.argv[1],'w') as f: f.write(f'{os.getpid()} {os.stat(\"/proc/self/ns/pid\").st_ino}')\n while True: time.sleep(1)\n os._exit(0)\nos.waitpid(first,0)\nwhile not os.path.exists(sys.argv[1]) or os.path.getsize(sys.argv[1]) == 0: time.sleep(0.01)\nprint('stdout-ready',flush=True)\nprint('stderr-ready',file=sys.stderr,flush=True)\ntime.sleep(600)".into() @@ -1447,7 +1459,7 @@ async fn start_resource_descendant( pid_file.display().to_string(), ], cwd: Some(root.display().to_string()), - env: std::collections::BTreeMap::default(), + env: native_resource_test_environment(), stdin: None, }, }, @@ -1496,7 +1508,7 @@ async fn wait_for_resource_descendant( let streams_ready = !check_streams || (stream_has(tinybox_bus::OutputStream::Stdout, b"stdout-ready") && stream_has(tinybox_bus::OutputStream::Stderr, b"stderr-ready")); - if streams_ready && std::fs::metadata(pid_file).is_ok() { + if streams_ready && std::fs::metadata(pid_file).is_ok_and(|metadata| metadata.len() > 0) { let identity = std::fs::read_to_string(pid_file)?; let mut parts = identity.split_whitespace(); return Ok(( From 9de64971665dbe698fb5cd5fba869b1718697ac1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:28:21 +0000 Subject: [PATCH 126/236] test(limited): qualify Result in jailed windows test signature Use the fully qualified std::result::Result in the test's return type so the signature no longer depends on a Result import being in scope. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-host/src/limited/jailed_windows_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-host/src/limited/jailed_windows_tests.rs b/crates/tinybox-host/src/limited/jailed_windows_tests.rs index 6c6511b7..58c476e9 100644 --- a/crates/tinybox-host/src/limited/jailed_windows_tests.rs +++ b/crates/tinybox-host/src/limited/jailed_windows_tests.rs @@ -5,7 +5,7 @@ use tinybox_core::ExecRequest; #[tokio::test] async fn detected_owned_backend_runs_with_only_the_approved_environment() --> Result<(), Box> { +-> std::result::Result<(), Box> { let root = tempfile::tempdir()?; let host = JailLocalHost::new( tinybox_jail::Jail::new(root.path(), "host-owned-appcontainer"), From dc4fb74c408edb125c5c8acf5707310f63541f34 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:28:31 +0000 Subject: [PATCH 127/236] docs(limited): add backticks around `AppContainer` in test module doc Wrap the `AppContainer` identifier in backticks in the Windows jailed test module documentation so it renders as code. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-host/src/limited/jailed_windows_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-host/src/limited/jailed_windows_tests.rs b/crates/tinybox-host/src/limited/jailed_windows_tests.rs index 58c476e9..5e7193ee 100644 --- a/crates/tinybox-host/src/limited/jailed_windows_tests.rs +++ b/crates/tinybox-host/src/limited/jailed_windows_tests.rs @@ -1,4 +1,4 @@ -//! Windows integration coverage for the backend-owned AppContainer process path. +//! Windows integration coverage for the backend-owned `AppContainer` process path. use super::*; use tinybox_core::ExecRequest; From 6c691bbb58e69ee1f1d7c87151ffd687c4ce2914 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:34:20 +0000 Subject: [PATCH 128/236] test(limited): include stderr and exit code in environment assertion The assertion failure message now reports stderr and the exit code alongside stdout, making it easier to diagnose why the approved environment marker was missing when the test fails. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-host/src/limited/jailed_windows_tests.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-host/src/limited/jailed_windows_tests.rs b/crates/tinybox-host/src/limited/jailed_windows_tests.rs index 5e7193ee..3ab31f7d 100644 --- a/crates/tinybox-host/src/limited/jailed_windows_tests.rs +++ b/crates/tinybox-host/src/limited/jailed_windows_tests.rs @@ -22,7 +22,12 @@ async fn detected_owned_backend_runs_with_only_the_approved_environment() ); let output = host.run(&request).await?; let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("approved-marker"), "stdout={stdout:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stdout.contains("approved-marker"), + "stdout={stdout:?}, stderr={stderr:?}, exit_code={}", + output.exit_code + ); if let Some(parent_path) = parent_path { assert!( !stdout.contains(&parent_path), From 91a28bc0555b36f3f2ac48f9d7d0422b46a94eb4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:34:42 +0000 Subject: [PATCH 129/236] test(windows): resolve cmd.exe via SystemRoot in jail tests Windows tests now invoke cmd.exe through its absolute path under SystemRoot instead of relying on PATH lookup, and the environment echo uses separate commands rather than a pipe. This keeps the tests working when the jailed environment does not include a usable PATH. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/limited/jailed_windows_tests.rs | 10 +++++++++- crates/tinybox-jail/src/windows_tests.rs | 18 ++++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/crates/tinybox-host/src/limited/jailed_windows_tests.rs b/crates/tinybox-host/src/limited/jailed_windows_tests.rs index 3ab31f7d..d964b487 100644 --- a/crates/tinybox-host/src/limited/jailed_windows_tests.rs +++ b/crates/tinybox-host/src/limited/jailed_windows_tests.rs @@ -14,7 +14,15 @@ async fn detected_owned_backend_runs_with_only_the_approved_environment() assert!(format!("{host:?}").contains("appcontainer")); let parent_path = std::env::var("PATH").ok(); - let mut request = ExecRequest::new(["cmd.exe", "/C", "echo %TINYBOX_APPROVED_ENV%|%PATH%"]); + let cmd = + std::path::PathBuf::from(std::env::var_os("SystemRoot").expect("Windows system root")) + .join("System32") + .join("cmd.exe"); + let mut request = ExecRequest::new([ + cmd.to_string_lossy().as_ref(), + "/C", + "echo %TINYBOX_APPROVED_ENV% & echo %PATH%", + ]); request.cwd = Some(root.path().to_owned()); request.env.insert( "TINYBOX_APPROVED_ENV".to_owned(), diff --git a/crates/tinybox-jail/src/windows_tests.rs b/crates/tinybox-jail/src/windows_tests.rs index 2f4ab83c..6d943640 100644 --- a/crates/tinybox-jail/src/windows_tests.rs +++ b/crates/tinybox-jail/src/windows_tests.rs @@ -141,6 +141,12 @@ fn soft_resource_values_do_not_create_hard_job_limits() { assert!(limits.kill_on_close); } +fn cmd_exe() -> PathBuf { + PathBuf::from(std::env::var_os("SystemRoot").expect("Windows system root")) + .join("System32") + .join("cmd.exe") +} + #[test] fn explicit_environment_block_is_sorted_and_does_not_add_parent_values() { let local_app_data = std::ffi::OsStr::new(r"C:\Users\runner\AppData\Local"); @@ -179,8 +185,8 @@ fn appcontainer_owned_spawn_uses_only_the_approved_environment() -> io::Result<( let root = tempfile::tempdir()?; let backend = AppContainerBackend::new(); - let mut command = Command::new("cmd.exe"); - command.args(["/C", "echo %TINYBOX_APPROVED_ENV%|%PATH%"]); + let mut command = Command::new(cmd_exe()); + command.args(["/C", "echo %TINYBOX_APPROVED_ENV% & echo %PATH%"]); let approved = BTreeMap::from([( "TINYBOX_APPROVED_ENV".to_owned(), "approved-value".to_owned(), @@ -229,7 +235,7 @@ fn appcontainer_acl_grants_the_root_and_denies_an_outside_secret() -> io::Result allowed.display(), secret.display() ); - let mut command = Command::new("cmd.exe"); + let mut command = Command::new(cmd_exe()); command.args(["/C", &command_text]); let jail = Jail::new(root.path(), "acl-regression").deny_net(); let backend = AppContainerBackend::new(); @@ -390,7 +396,7 @@ fn owned_appcontainer_child_captures_output_waits_and_kills_on_drop() -> std::io let root = tempfile::tempdir()?; let backend = AppContainerBackend::new(); - let mut echo = Command::new("cmd.exe"); + let mut echo = Command::new(cmd_exe()); echo.args(["/C", "echo owned-child-ok"]); let jail = Jail::new(root.path(), "owned-child-capture").deny_net(); let mut child = crate::spawn_owned_captured_with(&backend, &jail, echo)?; @@ -411,7 +417,7 @@ fn owned_appcontainer_child_captures_output_waits_and_kills_on_drop() -> std::io ); assert!(status.is_some(), "EOF follows process exit"); - let mut waiting = Command::new("cmd.exe"); + let mut waiting = Command::new(cmd_exe()); waiting.args(["/C", "set /p waiting="]); let jail = Jail::new(root.path(), "owned-child-terminate").deny_net(); let mut child = crate::spawn_owned_captured_with(&backend, &jail, waiting)?; @@ -422,7 +428,7 @@ fn owned_appcontainer_child_captures_output_waits_and_kills_on_drop() -> std::io stdout.read_to_end(&mut remaining)?; assert!(child.try_wait()?.is_some(), "terminated job exits"); - let mut waiting = Command::new("cmd.exe"); + let mut waiting = Command::new(cmd_exe()); waiting.args(["/C", "set /p waiting="]); let jail = Jail::new(root.path(), "owned-child-cleanup").deny_net(); let mut child = crate::spawn_owned_captured_with(&backend, &jail, waiting)?; From 41a840fa68e3ce408f6149d962931c494721bf1f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:35:01 +0000 Subject: [PATCH 130/236] test(limited): use fallible lookup for system root in windows test The test now resolves SystemRoot with ok_or so a missing variable surfaces as an error instead of a panic, and the command path construction is split into two statements for readability. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-host/src/limited/jailed_windows_tests.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/tinybox-host/src/limited/jailed_windows_tests.rs b/crates/tinybox-host/src/limited/jailed_windows_tests.rs index d964b487..daa6afd6 100644 --- a/crates/tinybox-host/src/limited/jailed_windows_tests.rs +++ b/crates/tinybox-host/src/limited/jailed_windows_tests.rs @@ -14,10 +14,10 @@ async fn detected_owned_backend_runs_with_only_the_approved_environment() assert!(format!("{host:?}").contains("appcontainer")); let parent_path = std::env::var("PATH").ok(); - let cmd = - std::path::PathBuf::from(std::env::var_os("SystemRoot").expect("Windows system root")) - .join("System32") - .join("cmd.exe"); + let system_root = std::env::var_os("SystemRoot").ok_or("missing SystemRoot")?; + let cmd = std::path::PathBuf::from(system_root) + .join("System32") + .join("cmd.exe"); let mut request = ExecRequest::new([ cmd.to_string_lossy().as_ref(), "/C", From 1338416a81fe273c7eb38ab8d785ca7be5b3f278 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:45:36 +0000 Subject: [PATCH 131/236] refactor(jail): extract owned backend selection into a helper Pull the first-available loop out of pick_owned_backend into pick_owned_from so the selection logic can be exercised directly with injected candidates. Add tests covering skipping backends without owned-child support and failing closed to the unsupported backend when none qualify, and make the Windows AppContainer tests wait for child exit and assert success. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/detect.rs | 7 +++- crates/tinybox-jail/src/detect_tests.rs | 52 ++++++++++++++++++++++++ crates/tinybox-jail/src/windows_tests.rs | 38 ++++++++++++----- 3 files changed, 86 insertions(+), 11 deletions(-) diff --git a/crates/tinybox-jail/src/detect.rs b/crates/tinybox-jail/src/detect.rs index 6f1ae750..18610535 100644 --- a/crates/tinybox-jail/src/detect.rs +++ b/crates/tinybox-jail/src/detect.rs @@ -75,7 +75,12 @@ pub fn pick_backend() -> Arc { /// Picks the strongest available backend that can return an [`super::jail::OwnedChild`]. #[must_use] pub fn pick_owned_backend() -> Arc { - for backend in owned_candidates() { + pick_owned_from(owned_candidates()) +} + +/// Select the first backend that can return an owned child. +fn pick_owned_from(backends: Vec>) -> Arc { + for backend in backends { if backend.is_owned_available() { log::debug!( "[cwd_jail] selected owned-process sandbox {}", diff --git a/crates/tinybox-jail/src/detect_tests.rs b/crates/tinybox-jail/src/detect_tests.rs index 6c760c46..b1988d95 100644 --- a/crates/tinybox-jail/src/detect_tests.rs +++ b/crates/tinybox-jail/src/detect_tests.rs @@ -96,3 +96,55 @@ fn selection_uses_the_first_available_candidate() { ]); assert_eq!(picked.name(), crate::noop::NOOP_BACKEND_NAME); } + +#[test] +fn owned_selection_skips_backends_without_owned_child_support() { + struct OwnedCandidate(&'static str, bool); + + impl JailBackend for OwnedCandidate { + fn name(&self) -> &'static str { + self.0 + } + fn is_available(&self) -> bool { + true + } + fn is_owned_available(&self) -> bool { + self.1 + } + fn spawn(&self, _: &Jail, _: Command) -> std::io::Result { + unreachable!("selection tests never spawn") + } + } + + let picked = pick_owned_from(vec![ + Arc::new(OwnedCandidate("legacy-only", false)), + Arc::new(OwnedCandidate("owned", true)), + Arc::new(OwnedCandidate("later-owned", true)), + ]); + assert_eq!(picked.name(), "owned"); + assert!(picked.is_owned_available()); +} + +#[test] +fn owned_selection_fails_closed_when_no_candidate_can_return_owned_child() { + struct LegacyOnly; + + impl JailBackend for LegacyOnly { + fn name(&self) -> &'static str { + "legacy-only" + } + fn is_available(&self) -> bool { + true + } + fn is_owned_available(&self) -> bool { + false + } + fn spawn(&self, _: &Jail, _: Command) -> std::io::Result { + unreachable!("selection tests never spawn") + } + } + + let picked = pick_owned_from(vec![Arc::new(LegacyOnly)]); + assert_eq!(picked.name(), UNSUPPORTED_BACKEND_NAME); + assert!(!picked.is_owned_available()); +} diff --git a/crates/tinybox-jail/src/windows_tests.rs b/crates/tinybox-jail/src/windows_tests.rs index 6d943640..7802f5dc 100644 --- a/crates/tinybox-jail/src/windows_tests.rs +++ b/crates/tinybox-jail/src/windows_tests.rs @@ -161,7 +161,7 @@ fn explicit_environment_block_is_sorted_and_does_not_add_parent_values() { environment_block(&env, local_app_data, system_root).expect("valid explicit environment"); let block = String::from_utf16_lossy(&block); assert!(block.starts_with( - "LOCALAPPDATA=C:\\Users\\runner\\AppData\\Local\0SYSTEMROOT=C:\\Windows\0TINYBOX_APPROVED=visible\0Z_LAST=last\0\0" + "LOCALAPPDATA=C:\\Users\\runner\\AppData\\Local\0SystemRoot=C:\\Windows\0TINYBOX_APPROVED=visible\0Z_LAST=last\0\0" )); assert!(!block.contains("PATH=")); assert!(!block.contains("USERPROFILE=")); @@ -175,7 +175,7 @@ fn empty_environment_block_has_the_required_double_terminator() { let block = environment_block(&BTreeMap::new(), local_app_data, system_root).unwrap(); assert_eq!( String::from_utf16_lossy(&block), - "LOCALAPPDATA=C:\\Users\\runner\\AppData\\Local\0SYSTEMROOT=C:\\Windows\0\0" + "LOCALAPPDATA=C:\\Users\\runner\\AppData\\Local\0SystemRoot=C:\\Windows\0\0" ); } @@ -186,7 +186,7 @@ fn appcontainer_owned_spawn_uses_only_the_approved_environment() -> io::Result<( let root = tempfile::tempdir()?; let backend = AppContainerBackend::new(); let mut command = Command::new(cmd_exe()); - command.args(["/C", "echo %TINYBOX_APPROVED_ENV% & echo %PATH%"]); + command.args(["/C", "echo %TINYBOX_APPROVED_ENV%"]); let approved = BTreeMap::from([( "TINYBOX_APPROVED_ENV".to_owned(), "approved-value".to_owned(), @@ -204,7 +204,7 @@ fn appcontainer_owned_spawn_uses_only_the_approved_environment() -> io::Result<( .take_stderr() .expect("captured stderr") .read_to_string(&mut stderr)?; - let status = child.try_wait()?; + let status = wait_for_child(&mut child)?; assert!( output.contains("approved-value"), "stdout={output:?}, stderr={stderr:?}, status={status:?}" @@ -215,7 +215,7 @@ fn appcontainer_owned_spawn_uses_only_the_approved_environment() -> io::Result<( "parent PATH leaked to the child" ); } - assert!(status.is_some()); + assert!(status.success()); Ok(()) } @@ -231,7 +231,7 @@ fn appcontainer_acl_grants_the_root_and_denies_an_outside_secret() -> io::Result std::fs::write(&secret, "outside-secret-marker")?; let command_text = format!( - "type \"{}\" & type \"{}\"", + "type {} & type {}", allowed.display(), secret.display() ); @@ -259,14 +259,14 @@ fn appcontainer_acl_grants_the_root_and_denies_an_outside_secret() -> io::Result .take_stderr() .expect("captured stderr") .read_to_string(&mut stderr)?; - let status = child.try_wait()?; + let status = wait_for_child(&mut child)?; assert!( stdout.contains("inside-marker"), "stdout={stdout:?}, stderr={stderr:?}, status={status:?}" ); assert!(!stdout.contains("outside-secret-marker")); assert!(stderr.to_ascii_lowercase().contains("access is denied")); - assert!(status.is_some()); + assert!(status.success()); Ok(()) } @@ -410,12 +410,12 @@ fn owned_appcontainer_child_captures_output_waits_and_kills_on_drop() -> std::io .take_stderr() .expect("captured stderr") .read_to_string(&mut stderr)?; - let status = child.try_wait()?; + let status = wait_for_child(&mut child)?; assert!( output.contains("owned-child-ok"), "stdout={output:?}, stderr={stderr:?}, status={status:?}" ); - assert!(status.is_some(), "EOF follows process exit"); + assert!(status.success(), "captured child exits successfully"); let mut waiting = Command::new(cmd_exe()); waiting.args(["/C", "set /p waiting="]); @@ -439,3 +439,21 @@ fn owned_appcontainer_child_captures_output_waits_and_kills_on_drop() -> std::io stdout.read_to_end(&mut remaining)?; Ok(()) } + +fn wait_for_child( + child: &mut crate::OwnedChild, +) -> std::io::Result { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + if let Some(status) = child.try_wait()? { + return Ok(status); + } + if std::time::Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "owned child did not exit after captured output closed", + )); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } +} From 2ca61bce634fee84e8fa367789d8de783ade4745 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:46:10 +0000 Subject: [PATCH 132/236] test(jail): cover owned spawn wrappers and approved environment Adds a Unix-only test that exercises the default owned backend's captured spawn helpers, checking that an owned child runs to completion and that the approved environment map reaches the child. A bounded wait helper keeps the test from hanging if the child never exits. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/mod_tests.rs | 59 ++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/tinybox-jail/src/mod_tests.rs b/crates/tinybox-jail/src/mod_tests.rs index 040240d4..e02262cb 100644 --- a/crates/tinybox-jail/src/mod_tests.rs +++ b/crates/tinybox-jail/src/mod_tests.rs @@ -18,6 +18,65 @@ fn noop_backend_spawns_unrestricted() { assert!(status.success() || cfg!(windows)); } +#[cfg(unix)] +#[test] +fn default_owned_spawn_wrappers_preserve_owned_child_and_approved_environment() +-> std::io::Result<()> { + use std::io::Read; + + let backend = default_owned_backend(); + let root = tempfile::tempdir()?; + let jail = Jail::new(root.path(), "default-owned-wrapper"); + if !backend.is_owned_available() { + assert_eq!( + spawn_owned_captured(&jail, Command::new("/bin/true")) + .err() + .map(|error| error.kind()), + Some(std::io::ErrorKind::Unsupported) + ); + return Ok(()); + } + + let mut child = spawn_owned_captured(&jail, Command::new("/bin/true"))?; + assert!(wait_owned_child(&mut child)?.success()); + + let env = std::collections::BTreeMap::from([( + "TINYBOX_APPROVED_CHILD_ENV".to_owned(), + "visible-to-child".to_owned(), + )]); + let mut command = Command::new("/bin/sh"); + command + .args(["-c", "printf '%s' \"$TINYBOX_APPROVED_CHILD_ENV\""]) + .env_clear() + .envs(&env); + let mut child = spawn_owned_captured_with_env(&jail, command, &env)?; + let mut output = String::new(); + child + .take_stdout() + .expect("captured stdout") + .read_to_string(&mut output)?; + assert!(wait_owned_child(&mut child)?.success()); + assert_eq!(output, "visible-to-child"); + Ok(()) +} + +#[cfg(unix)] +fn wait_owned_child(child: &mut OwnedChild) -> std::io::Result { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + if let Some(status) = child.try_wait()? { + return Ok(status); + } + if std::time::Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "owned child did not exit", + )); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } +} + #[test] fn jail_builder_chains() { let j = Jail::new("/tmp", "x") From 6b57559ea19922f3b103ea5f76746c3f2d528bab Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:47:00 +0000 Subject: [PATCH 133/236] test(tinybus_module): cover cancellation winning over late command failure Add a test for the interleaving where the collector records a failure just after the stream task observes the cancel signal, asserting the journal still reports the execution as cancelled. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/tinybus_module/output_tests.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/tinybox-module/src/tinybus_module/output_tests.rs b/crates/tinybox-module/src/tinybus_module/output_tests.rs index 5d8d8f41..83916fe5 100644 --- a/crates/tinybox-module/src/tinybus_module/output_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/output_tests.rs @@ -129,6 +129,19 @@ fn cleanup_failure_is_observable_and_kept_distinct_from_acknowledged_cancel() Ok(()) } +#[test] +fn cancellation_wins_when_command_failure_is_recorded_after_the_cancel_signal() { + let journal = OutputJournal::default(); + // This is the interleaving where the collector finished with an error + // just after the stream task checked cancel_requested(). + journal.cancel(); + journal.finish_failed(ExecutionFailure::BackendFailed); + assert_eq!( + journal.read(0).unwrap().state, + ExecutionState::Cancelled + ); +} + #[test] fn empty_output_does_not_consume_sequence_or_memory() -> std::result::Result<(), Box> { From f7e41a4e2dd3f7b1318df76bf33a6028a9c0416d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:47:14 +0000 Subject: [PATCH 134/236] fix(tinybus_module): report cancelled state when a cancelled execution fails When a cancelled execution reports a failure other than a cleanup failure, the journal now records it as cancelled rather than failed, so cancellation is not masked by the resulting error. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-module/src/tinybus_module/output.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-module/src/tinybus_module/output.rs b/crates/tinybox-module/src/tinybus_module/output.rs index ee7fcfb9..69b037c2 100644 --- a/crates/tinybox-module/src/tinybus_module/output.rs +++ b/crates/tinybox-module/src/tinybus_module/output.rs @@ -97,7 +97,11 @@ impl OutputJournal { } ) { - state.state = ExecutionState::Failed { code }; + state.state = if state.cancelled && code != ExecutionFailure::CleanupFailed { + ExecutionState::Cancelled + } else { + ExecutionState::Failed { code } + }; } } pub(super) fn cleanup_acknowledged(&self) -> bool { From a47b744b9b9b60948f7f6a25dd7af7ca8cdd9847 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:47:24 +0000 Subject: [PATCH 135/236] test: reformat test code to satisfy rustfmt Collapsed several multi-line expressions in the Windows jail and tinybus module tests onto single lines so they match rustfmt's output. No test behaviour changed. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/windows_tests.rs | 10 ++-------- .../tinybox-module/src/tinybus_module/output_tests.rs | 5 +---- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/crates/tinybox-jail/src/windows_tests.rs b/crates/tinybox-jail/src/windows_tests.rs index 7802f5dc..9a7e64ad 100644 --- a/crates/tinybox-jail/src/windows_tests.rs +++ b/crates/tinybox-jail/src/windows_tests.rs @@ -230,11 +230,7 @@ fn appcontainer_acl_grants_the_root_and_denies_an_outside_secret() -> io::Result std::fs::write(&allowed, "inside-marker")?; std::fs::write(&secret, "outside-secret-marker")?; - let command_text = format!( - "type {} & type {}", - allowed.display(), - secret.display() - ); + let command_text = format!("type {} & type {}", allowed.display(), secret.display()); let mut command = Command::new(cmd_exe()); command.args(["/C", &command_text]); let jail = Jail::new(root.path(), "acl-regression").deny_net(); @@ -440,9 +436,7 @@ fn owned_appcontainer_child_captures_output_waits_and_kills_on_drop() -> std::io Ok(()) } -fn wait_for_child( - child: &mut crate::OwnedChild, -) -> std::io::Result { +fn wait_for_child(child: &mut crate::OwnedChild) -> std::io::Result { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); loop { if let Some(status) = child.try_wait()? { diff --git a/crates/tinybox-module/src/tinybus_module/output_tests.rs b/crates/tinybox-module/src/tinybus_module/output_tests.rs index 83916fe5..53bdd4d0 100644 --- a/crates/tinybox-module/src/tinybus_module/output_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/output_tests.rs @@ -136,10 +136,7 @@ fn cancellation_wins_when_command_failure_is_recorded_after_the_cancel_signal() // just after the stream task checked cancel_requested(). journal.cancel(); journal.finish_failed(ExecutionFailure::BackendFailed); - assert_eq!( - journal.read(0).unwrap().state, - ExecutionState::Cancelled - ); + assert_eq!(journal.read(0).unwrap().state, ExecutionState::Cancelled); } #[test] From cd622940fb0cd18a5c48b4e0d5f18cd92cc8248d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:47:45 +0000 Subject: [PATCH 136/236] test: tighten assertions for sandbox escape and cancellation output The AppContainer spawn test now asserts the child fails when it tries to access a path outside the sandbox, matching the intended isolation behaviour. The cancelled shell test captures the output before asserting the cancelled state and includes it in the failure message to make diagnosing cancellation regressions easier. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/windows_tests.rs | 2 +- .../src/tinybus_module/resources_stream_tests.rs | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/tinybox-jail/src/windows_tests.rs b/crates/tinybox-jail/src/windows_tests.rs index 9a7e64ad..f229a327 100644 --- a/crates/tinybox-jail/src/windows_tests.rs +++ b/crates/tinybox-jail/src/windows_tests.rs @@ -215,7 +215,7 @@ fn appcontainer_owned_spawn_uses_only_the_approved_environment() -> io::Result<( "parent PATH leaked to the child" ); } - assert!(status.success()); + assert!(!status.success(), "outside path access should fail"); 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 27a00860..232b3ea2 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -889,9 +889,11 @@ async fn cancelled_native_shell_retains_scratch_until_output_release() }) .await??; resources.cancel(&process).await?; + let output = resources.read_output(&process, 0).await?; assert_eq!( - resources.read_output(&process, 0).await?.state, - ExecutionState::Cancelled + output.state, + ExecutionState::Cancelled, + "cancellation failed after captured output: {output:?}" ); let scratch = state.path().join("artifacts/sandbox-scratch"); assert_eq!(std::fs::read_dir(&scratch)?.count(), 1); From db537f70f76b47fa1edf60231e6691c0888f879a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:47:58 +0000 Subject: [PATCH 137/236] test(jail): correct swapped exit status assertions in windows tests The two AppContainer tests had their success expectations inverted, so the environment test now expects the child to succeed and the ACL test expects the outside path access to fail. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/windows_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-jail/src/windows_tests.rs b/crates/tinybox-jail/src/windows_tests.rs index f229a327..f4013bb3 100644 --- a/crates/tinybox-jail/src/windows_tests.rs +++ b/crates/tinybox-jail/src/windows_tests.rs @@ -215,7 +215,7 @@ fn appcontainer_owned_spawn_uses_only_the_approved_environment() -> io::Result<( "parent PATH leaked to the child" ); } - assert!(!status.success(), "outside path access should fail"); + assert!(status.success()); Ok(()) } @@ -262,7 +262,7 @@ fn appcontainer_acl_grants_the_root_and_denies_an_outside_secret() -> io::Result ); assert!(!stdout.contains("outside-secret-marker")); assert!(stderr.to_ascii_lowercase().contains("access is denied")); - assert!(status.success()); + assert!(!status.success(), "outside path access should fail"); Ok(()) } From 92886bdd3f40bccbb637862545c39469cffe667e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:48:53 +0000 Subject: [PATCH 138/236] fix(tinybus-module): only mark cancelled state on backend failure The cancellation check now requires the failure code to be BackendFailed rather than merely not CleanupFailed, so other failure codes no longer incorrectly report the execution as cancelled. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-module/src/tinybus_module/output.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-module/src/tinybus_module/output.rs b/crates/tinybox-module/src/tinybus_module/output.rs index 69b037c2..ca996106 100644 --- a/crates/tinybox-module/src/tinybus_module/output.rs +++ b/crates/tinybox-module/src/tinybus_module/output.rs @@ -97,7 +97,7 @@ impl OutputJournal { } ) { - state.state = if state.cancelled && code != ExecutionFailure::CleanupFailed { + state.state = if state.cancelled && code == ExecutionFailure::BackendFailed { ExecutionState::Cancelled } else { ExecutionState::Failed { code } From bcea2e734d20ab0ab11a1ffc8dfebfceda6846f5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 13:50:25 +0000 Subject: [PATCH 139/236] test(tinybus_module): return result from cancellation ordering test The cancellation ordering test now returns a Result and uses the ? operator instead of unwrapping, so a journal read failure surfaces as a test error rather than a panic. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-module/src/tinybus_module/output_tests.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/output_tests.rs b/crates/tinybox-module/src/tinybus_module/output_tests.rs index 53bdd4d0..4869fb7c 100644 --- a/crates/tinybox-module/src/tinybus_module/output_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/output_tests.rs @@ -130,13 +130,15 @@ fn cleanup_failure_is_observable_and_kept_distinct_from_acknowledged_cancel() } #[test] -fn cancellation_wins_when_command_failure_is_recorded_after_the_cancel_signal() { +fn cancellation_wins_when_command_failure_is_recorded_after_the_cancel_signal() +-> std::result::Result<(), Box> { let journal = OutputJournal::default(); // This is the interleaving where the collector finished with an error // just after the stream task checked cancel_requested(). journal.cancel(); journal.finish_failed(ExecutionFailure::BackendFailed); - assert_eq!(journal.read(0).unwrap().state, ExecutionState::Cancelled); + assert_eq!(journal.read(0)?.state, ExecutionState::Cancelled); + Ok(()) } #[test] From 817bac075ca2723623b537dbbb476cbc8c56e00c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 14:02:13 +0000 Subject: [PATCH 140/236] test(tinybox-jail): make spawn wrapper tests robust across platforms The Unix test now runs the child through a shell with an explicit PATH and expects that variable in the approved environment, and the Windows test waits for the terminated job to be acknowledged instead of polling once, so both tests no longer depend on timing or ambient environment. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/mod_tests.rs | 10 +++++++++- crates/tinybox-jail/src/windows_tests.rs | 5 ++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-jail/src/mod_tests.rs b/crates/tinybox-jail/src/mod_tests.rs index e02262cb..c899f8a7 100644 --- a/crates/tinybox-jail/src/mod_tests.rs +++ b/crates/tinybox-jail/src/mod_tests.rs @@ -37,10 +37,18 @@ fn default_owned_spawn_wrappers_preserve_owned_child_and_approved_environment() return Ok(()); } - let mut child = spawn_owned_captured(&jail, Command::new("/bin/true"))?; + let mut command = Command::new("/bin/sh"); + command + .args(["-c", "exit 0"]) + .env_clear() + .env("PATH", "/usr/bin:/bin"); + let mut child = spawn_owned_captured(&jail, command)?; assert!(wait_owned_child(&mut child)?.success()); let env = std::collections::BTreeMap::from([( + "PATH".to_owned(), + "/usr/bin:/bin".to_owned(), + ), ( "TINYBOX_APPROVED_CHILD_ENV".to_owned(), "visible-to-child".to_owned(), )]); diff --git a/crates/tinybox-jail/src/windows_tests.rs b/crates/tinybox-jail/src/windows_tests.rs index f4013bb3..8da7ff1f 100644 --- a/crates/tinybox-jail/src/windows_tests.rs +++ b/crates/tinybox-jail/src/windows_tests.rs @@ -422,7 +422,10 @@ fn owned_appcontainer_child_captures_output_waits_and_kills_on_drop() -> std::io child.terminate()?; let mut remaining = Vec::new(); stdout.read_to_end(&mut remaining)?; - assert!(child.try_wait()?.is_some(), "terminated job exits"); + assert!( + wait_for_child(&mut child).is_ok(), + "terminated job exits after termination is acknowledged" + ); let mut waiting = Command::new(cmd_exe()); waiting.args(["/C", "set /p waiting="]); From 2cb228b735913a7c93aacb6cc8f542d4336f1185 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 14:02:37 +0000 Subject: [PATCH 141/236] style(tinybox-jail): reformat approved environment map in test Reformatted the BTreeMap literal in the owned spawn wrapper test so the array elements are laid out consistently, with no change to the test's behaviour. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-jail/src/mod_tests.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/tinybox-jail/src/mod_tests.rs b/crates/tinybox-jail/src/mod_tests.rs index c899f8a7..6dbb7f92 100644 --- a/crates/tinybox-jail/src/mod_tests.rs +++ b/crates/tinybox-jail/src/mod_tests.rs @@ -45,13 +45,13 @@ fn default_owned_spawn_wrappers_preserve_owned_child_and_approved_environment() let mut child = spawn_owned_captured(&jail, command)?; assert!(wait_owned_child(&mut child)?.success()); - let env = std::collections::BTreeMap::from([( - "PATH".to_owned(), - "/usr/bin:/bin".to_owned(), - ), ( - "TINYBOX_APPROVED_CHILD_ENV".to_owned(), - "visible-to-child".to_owned(), - )]); + let env = std::collections::BTreeMap::from([ + ("PATH".to_owned(), "/usr/bin:/bin".to_owned()), + ( + "TINYBOX_APPROVED_CHILD_ENV".to_owned(), + "visible-to-child".to_owned(), + ), + ]); let mut command = Command::new("/bin/sh"); command .args(["-c", "printf '%s' \"$TINYBOX_APPROVED_CHILD_ENV\""]) From cbed9de0132da84e3c64481ecce17aef7bbdbb11 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 14:40:25 +0000 Subject: [PATCH 142/236] test(tinybus-module): cover process lifecycle event broadcasts Add integration tests asserting that StartExec emits correlated started and exited events with the exit status, and that a native shell timeout broadcasts a limit-hit event before the terminal exit. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/tinybus_module/mod_tests.rs | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index 372be419..77aafbdb 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -890,6 +890,165 @@ async fn bounded_live_output_roundtrips_through_the_bus_codec() -> tinybus::Resu Ok(()) } +#[tokio::test] +async fn start_exec_broadcasts_correlated_process_lifecycle_events() -> tinybus::Result<()> { + use serde_json::Value; + use tinybox_bus::{ + CreateRequest, ExecRequest, ProcessRef, ReserveRequest, ResourceId, SpawnRequest, Workspace, + }; + + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let service = Connection::connect(bus.connect().await?).await?; + setup(service).await?; + let client = Connection::connect(bus.connect().await?).await?; + let proxy = client.proxy(INTERFACE, OBJECT_PATH, INTERFACE)?; + let mut events = proxy.receive_signal("ProcessEvent").await?; + let resource: ResourceId = proxy.call("Reserve", (ReserveRequest::Resource,)).await?; + proxy + .call::( + "Create", + (CreateRequest { + resource: resource.clone(), + backend: "passthrough".into(), + workspace: Workspace::Directory(".".into()), + ..Default::default() + },), + ) + .await?; + let process_id: ResourceId = proxy + .call("Reserve", (ReserveRequest::Process(resource.clone()),)) + .await?; + let process = ProcessRef { + resource: resource.clone(), + process: process_id.clone(), + }; + let request = SpawnRequest { + process: process_id, + command: ExecRequest { + resource: resource.clone(), + argv: vec!["/bin/sh".into(), "-c".into(), "exit 7".into()], + cwd: None, + env: std::collections::BTreeMap::new(), + stdin: None, + }, + }; + assert_eq!(proxy.call::("StartExec", (request,)).await?, process); + + let receive_event = async |events: &mut tokio::sync::broadcast::Receiver| { + tokio::time::timeout(std::time::Duration::from_secs(2), events.recv()) + .await + .map_err(|_| tinybus::Error::failed("timed out waiting for process event"))? + .map_err(|error| tinybus::Error::failed(error.to_string())) + }; + let started = receive_event(&mut events).await?; + let started: Value = serde_json::from_value(started.body[0].clone())?; + assert_eq!(started["process"]["resource"], resource.0); + assert_eq!(started["process"]["process"], process_id.0); + assert_eq!(started["event"]["event"], "started"); + + let exited = receive_event(&mut events).await?; + let exited: Value = serde_json::from_value(exited.body[0].clone())?; + assert_eq!(exited["process"]["resource"], resource.0); + assert_eq!(exited["process"]["process"], process.process.0); + assert_eq!(exited["event"]["event"], "exited"); + assert_eq!(exited["event"]["status"], 7); + proxy.call::<()>("Close", (resource,)).await?; + Ok(()) +} + +#[tokio::test] +async fn native_shell_timeout_broadcasts_limit_hit_before_terminal_exit() -> tinybus::Result<()> { + use serde_json::Value; + use tinybox_bus::{ + CreateRequest, ProcessRef, ReserveRequest, ResourceId, SelectShellRequest, ShellExecRequest, + Workspace, + }; + + let state = tempfile::tempdir().map_err(tinybus::Error::failed)?; + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let service = Connection::connect(bus.connect().await?).await?; + setup(service).await?; + let client = Connection::connect(bus.connect().await?).await?; + let proxy = client.proxy(INTERFACE, OBJECT_PATH, INTERFACE)?; + let mut events = proxy.receive_signal("ProcessEvent").await?; + let resource: ResourceId = proxy.call("Reserve", (ReserveRequest::Resource,)).await?; + proxy + .call::( + "Create", + (CreateRequest { + resource: resource.clone(), + backend: "passthrough".into(), + workspace: Workspace::Directory(".".into()), + ..Default::default() + },), + ) + .await?; + let process_id: ResourceId = proxy + .call("Reserve", (ReserveRequest::Process(resource.clone()),)) + .await?; + let process = ProcessRef { + resource: resource.clone(), + process: process_id.clone(), + }; + #[cfg(windows)] + let command = "echo partial & powershell -NoProfile -NonInteractive -Command \"Start-Sleep -Seconds 60\""; + #[cfg(not(windows))] + let command = "echo partial; sleep 60"; + let request = ShellExecRequest { + process: process_id.clone(), + resource: resource.clone(), + shell: SelectShellRequest { + command: command.into(), + preserve_pipeline_status: false, + runtime_path: None, + python_utf8: false, + }, + cwd: None, + extra_env: std::collections::BTreeMap::new(), + state_dir: state.path().to_string_lossy().into_owned(), + timeout_ms: 1_000, + stdin: None, + jail: None, + hide_window: false, + }; + assert_eq!( + proxy + .call::("StartNativeShellExec", (request,)) + .await?, + process + ); + + let receive_event = async |events: &mut tokio::sync::broadcast::Receiver| { + tokio::time::timeout(std::time::Duration::from_secs(5), events.recv()) + .await + .map_err(|_| tinybus::Error::failed("timed out waiting for process event"))? + .map_err(|error| tinybus::Error::failed(error.to_string())) + }; + let started = receive_event(&mut events).await?; + let started: Value = serde_json::from_value(started.body[0].clone())?; + assert_eq!(started["process"]["resource"], resource.0); + assert_eq!(started["process"]["process"], process_id.0); + assert_eq!(started["event"]["event"], "started"); + + let limit_hit = receive_event(&mut events).await?; + let limit_hit: Value = serde_json::from_value(limit_hit.body[0].clone())?; + assert_eq!(limit_hit["process"]["resource"], resource.0); + assert_eq!(limit_hit["process"]["process"], process.process.0); + assert_eq!(limit_hit["event"]["event"], "limit_hit"); + assert_eq!(limit_hit["event"]["limit"], "timeout"); + + let exited = receive_event(&mut events).await?; + let exited: Value = serde_json::from_value(exited.body[0].clone())?; + assert_eq!(exited["process"]["resource"], resource.0); + assert_eq!(exited["process"]["process"], process.process.0); + assert_eq!(exited["event"]["event"], "exited"); + assert_eq!(exited["event"]["status"], Value::Null); + proxy.call::<()>("Close", (resource,)).await?; + Ok(()) +} + #[tokio::test] async fn local_grant_discovery_refuses_home_ancestors_of_credentials() -> Result<(), Box> { From 33047186f9b6c132de70ec3f8b570c71d1962fce Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 14:41:00 +0000 Subject: [PATCH 143/236] test(tinybus): clone process id in exec lifecycle test The spawn request now clones the process id instead of moving it, so the test can still reference the id when asserting on the correlated lifecycle events. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-module/src/tinybus_module/mod_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index 77aafbdb..919374ae 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -924,7 +924,7 @@ async fn start_exec_broadcasts_correlated_process_lifecycle_events() -> tinybus: process: process_id.clone(), }; let request = SpawnRequest { - process: process_id, + process: process_id.clone(), command: ExecRequest { resource: resource.clone(), argv: vec!["/bin/sh".into(), "-c".into(), "exit 7".into()], From d62789c3ac674c9148ed57b3bd80a9ead14de042 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 14:41:21 +0000 Subject: [PATCH 144/236] test(tinybus_module): start test storage host in lifecycle tests Two lifecycle tests now spin up the test storage host before connecting, so the module has a storage service available when it starts. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-module/src/tinybus_module/mod_tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index 919374ae..f9bf1246 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -899,6 +899,7 @@ async fn start_exec_broadcasts_correlated_process_lifecycle_events() -> tinybus: let bus = MemoryBus::new(); Broker::new().spawn(bus.clone()); + let _storage_host = serve_test_storage(&bus).await?; let service = Connection::connect(bus.connect().await?).await?; setup(service).await?; let client = Connection::connect(bus.connect().await?).await?; @@ -968,6 +969,7 @@ async fn native_shell_timeout_broadcasts_limit_hit_before_terminal_exit() -> tin let state = tempfile::tempdir().map_err(tinybus::Error::failed)?; let bus = MemoryBus::new(); Broker::new().spawn(bus.clone()); + let _storage_host = serve_test_storage(&bus).await?; let service = Connection::connect(bus.connect().await?).await?; setup(service).await?; let client = Connection::connect(bus.connect().await?).await?; From ee9fb589ab9b092db2e68c83e9fd9027f8f935d8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 14:41:41 +0000 Subject: [PATCH 145/236] test(tinybus): run process lifecycle tests on a multi-threaded runtime Switch the exec lifecycle and native shell timeout tests to a multi-threaded Tokio runtime with two worker threads, and add the same attribute to the start_exec test. This avoids the tests stalling when the code under test blocks a worker while waiting on process events. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-module/src/tinybus_module/mod_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index f9bf1246..01f00d15 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -891,6 +891,7 @@ async fn bounded_live_output_roundtrips_through_the_bus_codec() -> tinybus::Resu } #[tokio::test] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn start_exec_broadcasts_correlated_process_lifecycle_events() -> tinybus::Result<()> { use serde_json::Value; use tinybox_bus::{ @@ -958,7 +959,7 @@ async fn start_exec_broadcasts_correlated_process_lifecycle_events() -> tinybus: Ok(()) } -#[tokio::test] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn native_shell_timeout_broadcasts_limit_hit_before_terminal_exit() -> tinybus::Result<()> { use serde_json::Value; use tinybox_bus::{ From 4e75c6a4af9de0ffacbf902037f79164bfacfc16 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 14:42:15 +0000 Subject: [PATCH 146/236] test(tinybus_module): remove duplicate tokio test attribute The start_exec lifecycle test carried two #[tokio::test] attributes, so the plain one was dropped to leave only the multi-threaded flavour the test actually needs. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-module/src/tinybus_module/mod_tests.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index 01f00d15..c953a7d6 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -890,7 +890,6 @@ async fn bounded_live_output_roundtrips_through_the_bus_codec() -> tinybus::Resu Ok(()) } -#[tokio::test] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn start_exec_broadcasts_correlated_process_lifecycle_events() -> tinybus::Result<()> { use serde_json::Value; From f01caf2be4aa734a20e65805b03676d426679695 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 14:43:43 +0000 Subject: [PATCH 147/236] feat(bus): add process-scoped sandbox lifecycle events Introduce a ProcessEvent signal that pairs a ProcessRef with a SandboxEvent so module-owned executions can report lifecycle and timeout observations, bumping the contract version to 2.2. Stream executions now expose a single-claim event publisher, a terminal exit wait, and a timeout check to support emitting those events. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/event.rs | 9 +++++++ crates/tinybox-bus/src/event_tests.rs | 22 ++++++++++++++++ crates/tinybox-bus/src/lib.rs | 12 ++++++--- .../src/tinybus_module/resources_stream.rs | 26 +++++++++++++++++++ 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/crates/tinybox-bus/src/event.rs b/crates/tinybox-bus/src/event.rs index a6fc757c..d1233b38 100644 --- a/crates/tinybox-bus/src/event.rs +++ b/crates/tinybox-bus/src/event.rs @@ -6,6 +6,15 @@ use serde::{Deserialize, Serialize}; +/// One process-scoped sandbox observation sent by the TinyBox module. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProcessEvent { + /// Resource and process identifiers chosen by the caller. + pub process: crate::ProcessRef, + /// What the sandbox observed about this process. + pub event: SandboxEvent, +} + /// Hosts and ports an allowlisted box may reach. /// /// Hostnames are resolved outside the box and pinned; metadata endpoints are diff --git a/crates/tinybox-bus/src/event_tests.rs b/crates/tinybox-bus/src/event_tests.rs index c40de745..a2748739 100644 --- a/crates/tinybox-bus/src/event_tests.rs +++ b/crates/tinybox-bus/src/event_tests.rs @@ -26,6 +26,28 @@ fn events_keep_their_wire_labels() -> Result<(), serde_json::Error> { Ok(()) } +#[test] +fn process_events_keep_their_correlation_and_signal_wire_shape() -> Result<(), serde_json::Error> { + let event = ProcessEvent { + process: crate::ProcessRef { + resource: crate::ResourceId("resource-1".into()), + process: crate::ResourceId("process-2".into()), + }, + event: SandboxEvent::Started, + }; + assert_eq!( + serde_json::to_value(&event)?, + serde_json::json!({ + "process": {"resource": "resource-1", "process": "process-2"}, + "event": {"event": "started"} + }) + ); + assert_eq!(names::PROCESS_EVENT, "ProcessEvent"); + assert_eq!(SIGNALS, &["ProcessEvent"]); + assert_eq!(CONTRACT_VERSION, (2, 2)); + Ok(()) +} + #[test] fn an_allowlist_policy_round_trips_and_old_policies_still_parse() -> Result<(), serde_json::Error> { let policy = NetworkPolicy::Allowlist(EgressAllowlist { diff --git a/crates/tinybox-bus/src/lib.rs b/crates/tinybox-bus/src/lib.rs index 444e3166..20eb96af 100644 --- a/crates/tinybox-bus/src/lib.rs +++ b/crates/tinybox-bus/src/lib.rs @@ -46,7 +46,7 @@ mod policy; pub use policy::{DockerPolicyReport, DockerPolicyRequest}; mod event; -pub use event::{DenialKind, EgressAllowlist, LimitKind, SandboxEvent}; +pub use event::{DenialKind, EgressAllowlist, LimitKind, ProcessEvent, SandboxEvent}; mod files; pub use files::{ @@ -90,8 +90,9 @@ pub use files::{ /// `AnalyzeShell.hidden_execution` policy signal. Generic shell structure facts /// and transport helpers remain. /// 2.1 adds separate resource-aware native jail request members without -/// changing the 2.0 Rust request shapes. -pub const CONTRACT_VERSION: (u32, u32) = (2, 1); +/// changing the 2.0 Rust request shapes. 2.2 adds process-scoped lifecycle +/// events for module-owned executions and timeout observations. +pub const CONTRACT_VERSION: (u32, u32) = (2, 2); /// Minimum contract version required for resource-aware native jail members. pub const NATIVE_JAIL_RESOURCES_VERSION: (u32, u32) = (2, 1); @@ -228,8 +229,13 @@ pub mod names { pub const STDIO_STATUS: &str = "StdioStatus"; /// Wire name of the 2.1 resource-aware jailed exec member. pub const START_JAILED_EXEC_WITH_RESOURCES: &str = "StartJailedExecWithResources"; + /// Wire name of the process-scoped sandbox lifecycle signal. + pub const PROCESS_EVENT: &str = "ProcessEvent"; } +/// Supported signals in this contract. +pub const SIGNALS: &[&str] = &[names::PROCESS_EVENT]; + /// Supported methods in this contract, including the original discovery /// operation. Derived from [`names`]; the order is stable within this major. pub const METHODS: &[&str] = &[ diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index b6282b95..6589b47b 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -23,6 +23,7 @@ pub(super) struct StreamExecution { task: Mutex>>, collector: Collector, timed_out: Arc, + event_publisher_claimed: std::sync::atomic::AtomicBool, } #[derive(Clone)] enum Collector { @@ -348,8 +349,33 @@ impl StreamExecution { task: Mutex::new(Some(task)), collector, timed_out, + event_publisher_claimed: std::sync::atomic::AtomicBool::new(false), } } + pub(super) fn claim_event_publisher(&self) -> bool { + !self + .event_publisher_claimed + .swap(true, std::sync::atomic::Ordering::AcqRel) + } + pub(super) async fn wait_for_terminal_exit(&self) -> Option { + let mut task = self.task.lock().await; + if let Some(join) = task.as_mut() + && join.await.is_err() + { + task.take(); + return None; + } + task.take(); + match self.journal.read(0).ok()?.state { + tinybox_bus::ExecutionState::Exited { exit_code } => Some(exit_code), + tinybox_bus::ExecutionState::Running + | tinybox_bus::ExecutionState::Cancelled + | tinybox_bus::ExecutionState::Failed { .. } => None, + } + } + pub(in crate::tinybus_module) fn timed_out(&self) -> bool { + self.timed_out.load(std::sync::atomic::Ordering::SeqCst) + } pub(super) fn running(&self) -> bool { !self.journal.cleanup_acknowledged() } From 75d6e3d7902ca36d7c8fd9bb1e270de843575209 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 14:44:13 +0000 Subject: [PATCH 148/236] feat(tinybox-module): publish process lifecycle events to tinybus Process start, exit, and timeout events are now emitted on the bus when a process is spawned, with a single publisher claimed per stream to avoid duplicates. This lets clients observe process lifecycle without polling. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinybox-module/src/tinybus_module/mod.rs | 103 +++++++++++++++--- 1 file changed, 89 insertions(+), 14 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/mod.rs b/crates/tinybox-module/src/tinybus_module/mod.rs index 6490991b..a15f29e3 100644 --- a/crates/tinybox-module/src/tinybus_module/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/mod.rs @@ -12,8 +12,8 @@ use tinybox_bus::{ AbortFileWriteRequest, BeginFileReadRequest, BeginFileWriteRequest, CloseForwardRequest, CreateRequest, ExecOutput, ExecRequest, FileChunk, FileReadInfo, FileWriteInfo, FileWriteProgress, FinishFileReadRequest, FinishFileWriteRequest, ForwardInfo, ForwardRequest, - INTERFACE, OBJECT_PATH, ProcessRef, ReadFileChunkRequest, ResourceId, ResourceInfo, - ShellAnalysis, SpawnRequest, WriteFileChunkRequest, + INTERFACE, OBJECT_PATH, ProcessEvent, ProcessRef, ReadFileChunkRequest, ResourceId, + ResourceInfo, SandboxEvent, ShellAnalysis, SpawnRequest, WriteFileChunkRequest, }; mod docker; @@ -50,6 +50,7 @@ impl Platform { #[derive(Default)] struct BoxService { resources: std::sync::Arc, + connection: Option, } impl BoxService { @@ -57,7 +58,8 @@ impl BoxService { /// `connection`. fn connected(connection: Connection) -> Self { Self { - resources: std::sync::Arc::new(resources::Resources::with_connection(connection)), + resources: std::sync::Arc::new(resources::Resources::with_connection(connection.clone())), + connection: Some(connection), } } } @@ -261,10 +263,12 @@ impl BoxService { /// Start module-owned execution with replayable bounded output. async fn start_exec(&self, request: SpawnRequest) -> TinyBusResult { let resources = self.resources.clone(); - finish_operation(tokio::spawn( + let process = finish_operation(tokio::spawn( async move { resources.start_exec(request).await }, )) - .await + .await?; + self.publish_process_events(&process).await; + Ok(process) } /// Start an execution in the resource's native directory jail. async fn start_jailed_exec( @@ -273,10 +277,12 @@ impl BoxService { config: tinybox_bus::NativeJailConfig, ) -> TinyBusResult { let resources = self.resources.clone(); - finish_operation(tokio::spawn(async move { + let process = finish_operation(tokio::spawn(async move { resources.start_jailed_exec(request, config).await })) - .await + .await?; + self.publish_process_events(&process).await; + Ok(process) } /// Start a jailed execution with the separately negotiated contract 2.1 @@ -288,12 +294,14 @@ impl BoxService { config: tinybox_bus::NativeJailRequest, ) -> TinyBusResult { let resources = self.resources.clone(); - finish_operation(tokio::spawn(async move { + let process = finish_operation(tokio::spawn(async move { resources .start_jailed_exec_with_resources(request, config) .await })) - .await + .await?; + self.publish_process_events(&process).await; + Ok(process) } /// Execute platform shell text using the original two-argument contract. @@ -303,10 +311,12 @@ impl BoxService { shell: tinybox_bus::SelectShellRequest, ) -> TinyBusResult { let resources = self.resources.clone(); - finish_operation(tokio::spawn(async move { + let process = finish_operation(tokio::spawn(async move { resources.start_platform_shell_exec(request, shell).await })) - .await + .await?; + self.publish_process_events(&process).await; + Ok(process) } /// Start a shell with module-owned environment, scratch and supervision. @@ -315,10 +325,12 @@ impl BoxService { request: tinybox_bus::ShellExecRequest, ) -> TinyBusResult { let resources = self.resources.clone(); - finish_operation(tokio::spawn(async move { + let process = finish_operation(tokio::spawn(async move { resources.start_shell_exec(request).await })) - .await + .await?; + self.publish_process_events(&process).await; + Ok(process) } /// Start one program with piped stdio inside the resource's native jail. @@ -460,6 +472,69 @@ impl BoxService { async fn describe(&self) -> TinyBusResult { std::future::ready(Ok(describe(®istered_sandboxes()))).await } + + async fn publish_process_events(&self, process: &ProcessRef) { + let Some(connection) = self.connection.clone() + && let Ok(Some(stream)) = self.resources.stream(process) + && stream.claim_event_publisher() + else { + return; + }; + emit_process_event( + &connection, + ProcessEvent { + process: process.clone(), + event: SandboxEvent::Started, + }, + ) + .await; + let event_process = process.clone(); + tokio::spawn(async move { + let status = stream.wait_for_terminal_exit().await; + if stream.timed_out() { + emit_process_event( + &connection, + ProcessEvent { + process: event_process.clone(), + event: SandboxEvent::LimitHit { + limit: tinybox_bus::LimitKind::Timeout, + }, + }, + ) + .await; + emit_process_event( + &connection, + ProcessEvent { + process: event_process, + event: SandboxEvent::Exited { status: None }, + }, + ) + .await; + } else if let Some(status) = status { + emit_process_event( + &connection, + ProcessEvent { + process: event_process, + event: SandboxEvent::Exited { + status: Some(status), + }, + }, + ) + .await; + } + }); + } +} + +async fn emit_process_event(connection: &Connection, event: ProcessEvent) { + let (Ok(path), Ok(interface), Ok(member)) = ( + OBJECT_PATH.try_into(), + INTERFACE.try_into(), + tinybox_bus::names::PROCESS_EVENT.try_into(), + ) else { + return; + }; + let _ = connection.emit(path, interface, member, event).await; } fn capabilities_for(platform: Platform) -> tinybox_bus::ModuleCapabilities { @@ -598,7 +673,7 @@ tinybus_module::module_export_optional_static! { worker_threads = 1, provides = ["ai.tinyhumans.tinybox.Box"], methods = ["Describe", "Create", "Exec", "Inspect", "Close", "Spawn", "IsRunning", "Cancel", "AnalyzeShell", "Reserve", "Capabilities", "Shutdown", "Forward", "CloseForward", "JailStatus", "BeginFileRead", "ReadFileChunk", "FinishFileRead", "BeginFileWrite", "WriteFileChunk", "FinishFileWrite", "AbortFileWrite", "StartExec", "ReadOutput", "ReleaseOutput", "StartJailedExec", "RunDocker", "DockerStatus", "CleanupDocker", "RunDockerWithoutWorkspace", "ResolveLocalGrants", "SelectShell", "StartShellExec", "StartNativeShellExec", "ValidateDockerPolicy", "StartStdio", "WriteStdio", "ReadStdio", "CloseStdin", "KillStdio", "StdioStatus", "StartJailedExecWithResources"], - signals = [], + signals = ["ProcessEvent"], requires = [], optional = [], lazy = false, From d0dfe880bb42674f81050876188b9fc3c5944b6b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 14:44:29 +0000 Subject: [PATCH 149/236] test(bus): update contract version assertions to 2.2 Bump the expected CONTRACT_VERSION in the bus contract tests from 2.1 to 2.2 so the assertions match the current wire contract. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/lib_tests.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/tinybox-bus/src/lib_tests.rs b/crates/tinybox-bus/src/lib_tests.rs index 3a18e9ee..1c487b84 100644 --- a/crates/tinybox-bus/src/lib_tests.rs +++ b/crates/tinybox-bus/src/lib_tests.rs @@ -59,7 +59,7 @@ fn docker_run_vocabulary_is_typed_bounded_and_additive() -> Result<(), serde_jso assert_eq!(METHODS[28], "CleanupDocker"); assert_eq!(METHODS[29], "RunDockerWithoutWorkspace"); assert_eq!(METHODS[33], "StartNativeShellExec"); - assert_eq!(CONTRACT_VERSION, (2, 1)); + assert_eq!(CONTRACT_VERSION, (2, 2)); assert_eq!(MAX_DOCKER_RUN_TIMEOUT_MS, 86_400_000); let serialized = serde_json::to_value(&request)?; assert_eq!(serialized["read_only_rootfs"], false); @@ -137,7 +137,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, (2, 1)); + assert_eq!(CONTRACT_VERSION, (2, 2)); assert!(is_compatible(CONTRACT_VERSION)); assert!(!is_compatible((1, 21))); assert!(!is_compatible((1, 8))); @@ -181,7 +181,7 @@ fn no_workspace_docker_operation_is_present_in_the_contract() { .count(), 1 ); - assert_eq!(CONTRACT_VERSION, (2, 1)); + assert_eq!(CONTRACT_VERSION, (2, 2)); } #[test] @@ -194,7 +194,7 @@ fn shell_analysis_wire_shape_contains_only_structural_facts() -> Result<(), serd #[test] fn contract_two_drops_command_classification_but_keeps_structural_shell_analysis() { - assert_eq!(CONTRACT_VERSION, (2, 1)); + assert_eq!(CONTRACT_VERSION, (2, 2)); assert!(!METHODS.contains(&"AnalyzeCommand")); assert!(METHODS.contains(&"AnalyzeShell")); } From f29e6a70ae56d775fa13a39d3c637e6fa1197e5d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 14:44:43 +0000 Subject: [PATCH 150/236] docs(tinybus-module): document contract 2.1 and 2.2 additions Update the module README to advertise contract version 2.2 and describe the resource-aware jail request members added in 2.1 and the ProcessEvent signal added in 2.2, including when Started, Exited, and LimitHit are emitted and the best-effort delivery semantics. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/tinybus_module/README.md | 11 ++++++++++- docs/specs/contract-2-2.md | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 docs/specs/contract-2-2.md diff --git a/crates/tinybox-module/src/tinybus_module/README.md b/crates/tinybox-module/src/tinybus_module/README.md index 2bcde7a1..a9be966a 100644 --- a/crates/tinybox-module/src/tinybus_module/README.md +++ b/crates/tinybox-module/src/tinybus_module/README.md @@ -55,7 +55,7 @@ The interface is `ai.tinyhumans.tinybox.Box` at | KillStdio | ProcessRef | () | | StdioStatus | ProcessRef | StdioStatus | -Capabilities advertises contract version 2.0. Hosts require equal majors and a +Capabilities advertises contract version 2.2. Hosts require equal majors and a module minor at least as new as their vocabulary, using `tinybox_bus::is_compatible`. Contract 1.0 was the original discovery-only surface; 1.1 added reserved resource/process ownership and terminal shutdown; @@ -76,6 +76,15 @@ execution policy. Old 1.x hosts must reject a 2.x module; there is no fallback that applies or reconstructs command policy in TinyBox. Describe remains unchanged. +Contract 2.1 adds resource-aware native jail request members. Contract 2.2 adds +the `ProcessEvent` signal, carrying the caller's `ProcessRef` with each +observation. The module emits `Started` once after a successful module-owned +execution starts and `Exited` after its supervisor observes the terminal exit +status. When an execution times out, it emits `LimitHit { limit: Timeout }` +before `Exited { status: None }`. Replayed starts do not duplicate these +events. Signal delivery is best-effort; this increment does not publish access +denials or resource-limit observations. + Version 1.5 adds bounded file transfer for mounted directory workspaces. Reserve `FileRead(resource)` or `FileWrite(resource)` before opening a transfer. Paths are relative to the workspace and reject traversal; each resource holds diff --git a/docs/specs/contract-2-2.md b/docs/specs/contract-2-2.md new file mode 100644 index 00000000..d40b556f --- /dev/null +++ b/docs/specs/contract-2-2.md @@ -0,0 +1,18 @@ +# TinyBox contract 2.2 + +Contract 2.2 adds one broadcast signal to `ai.tinyhumans.tinybox.Box`: + +| Signal | Body | +| --- | --- | +| `ProcessEvent` | `ProcessEvent { process: ProcessRef, event: SandboxEvent }` | + +The module emits `Started` after the first successful module-owned start for a +process reservation, then emits `Exited { status: Some(code) }` after its +supervisor observes the terminal exit status. A replayed start does not +duplicate lifecycle events. For executions terminated by their wall-clock +timeout, the module emits `LimitHit { limit: Timeout }` before +`Exited { status: None }`. + +Signals are observational and best-effort. Their bodies include the caller's +resource and process reservations for correlation. This contract does not +forward access denials or other resource-limit observations. From 14c104db1aa92e0185c820a9ca3c69ffbb1dc3b4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 14:44:54 +0000 Subject: [PATCH 151/236] refactor(tinybox-module): split chained let-else into early returns The publish_process_events guard chain was rewritten as separate early-return checks so each failure condition is handled on its own, making the control flow easier to follow without changing behaviour. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-module/src/tinybus_module/mod.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/mod.rs b/crates/tinybox-module/src/tinybus_module/mod.rs index a15f29e3..15df3d39 100644 --- a/crates/tinybox-module/src/tinybus_module/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/mod.rs @@ -474,12 +474,15 @@ impl BoxService { } async fn publish_process_events(&self, process: &ProcessRef) { - let Some(connection) = self.connection.clone() - && let Ok(Some(stream)) = self.resources.stream(process) - && stream.claim_event_publisher() - else { + let Some(connection) = self.connection.clone() else { return; }; + let Ok(Some(stream)) = self.resources.stream(process) else { + return; + }; + if !stream.claim_event_publisher() { + return; + } emit_process_event( &connection, ProcessEvent { From e9cd35e025c2d1e6032916be5f53f71413b1d040 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 14:45:01 +0000 Subject: [PATCH 152/236] style: reformat long lines to satisfy rustfmt Wrapped several overlong expressions and import lists in the tinybus module and its tests so the code matches rustfmt output. No behaviour changes. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-module/src/tinybus_module/mod.rs | 4 +++- .../tinybox-module/src/tinybus_module/mod_tests.rs | 12 ++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/mod.rs b/crates/tinybox-module/src/tinybus_module/mod.rs index 15df3d39..6261ba71 100644 --- a/crates/tinybox-module/src/tinybus_module/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/mod.rs @@ -58,7 +58,9 @@ impl BoxService { /// `connection`. fn connected(connection: Connection) -> Self { Self { - resources: std::sync::Arc::new(resources::Resources::with_connection(connection.clone())), + resources: std::sync::Arc::new(resources::Resources::with_connection( + connection.clone(), + )), connection: Some(connection), } } diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index c953a7d6..45e19530 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -934,7 +934,10 @@ async fn start_exec_broadcasts_correlated_process_lifecycle_events() -> tinybus: stdin: None, }, }; - assert_eq!(proxy.call::("StartExec", (request,)).await?, process); + assert_eq!( + proxy.call::("StartExec", (request,)).await?, + process + ); let receive_event = async |events: &mut tokio::sync::broadcast::Receiver| { tokio::time::timeout(std::time::Duration::from_secs(2), events.recv()) @@ -962,8 +965,8 @@ async fn start_exec_broadcasts_correlated_process_lifecycle_events() -> tinybus: async fn native_shell_timeout_broadcasts_limit_hit_before_terminal_exit() -> tinybus::Result<()> { use serde_json::Value; use tinybox_bus::{ - CreateRequest, ProcessRef, ReserveRequest, ResourceId, SelectShellRequest, ShellExecRequest, - Workspace, + CreateRequest, ProcessRef, ReserveRequest, ResourceId, SelectShellRequest, + ShellExecRequest, Workspace, }; let state = tempfile::tempdir().map_err(tinybus::Error::failed)?; @@ -995,7 +998,8 @@ async fn native_shell_timeout_broadcasts_limit_hit_before_terminal_exit() -> tin process: process_id.clone(), }; #[cfg(windows)] - let command = "echo partial & powershell -NoProfile -NonInteractive -Command \"Start-Sleep -Seconds 60\""; + let command = + "echo partial & powershell -NoProfile -NonInteractive -Command \"Start-Sleep -Seconds 60\""; #[cfg(not(windows))] let command = "echo partial; sleep 60"; let request = ShellExecRequest { From c60b1f94e2978f9e8a71c525eebbd89b9aac43af Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 14:45:21 +0000 Subject: [PATCH 153/236] test(bus): import contract constants for event wire label tests Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/event_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-bus/src/event_tests.rs b/crates/tinybox-bus/src/event_tests.rs index a2748739..77801a86 100644 --- a/crates/tinybox-bus/src/event_tests.rs +++ b/crates/tinybox-bus/src/event_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::NetworkPolicy; +use crate::{CONTRACT_VERSION, NetworkPolicy, SIGNALS, names}; #[test] fn events_keep_their_wire_labels() -> Result<(), serde_json::Error> { From 5f68d9fe32d499e809f18426b3a2801558d3b2a2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 14:45:58 +0000 Subject: [PATCH 154/236] refactor(tinybus-module): move publish_process_events next to the constructor Relocated the publish_process_events method so it sits alongside the other BoxService helpers near the top of the impl block instead of after describe. No behaviour change. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinybox-module/src/tinybus_module/mod.rs | 110 +++++++++--------- 1 file changed, 55 insertions(+), 55 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/mod.rs b/crates/tinybox-module/src/tinybus_module/mod.rs index 6261ba71..cdeffbad 100644 --- a/crates/tinybox-module/src/tinybus_module/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/mod.rs @@ -64,6 +64,61 @@ impl BoxService { connection: Some(connection), } } + + async fn publish_process_events(&self, process: &ProcessRef) { + let Some(connection) = self.connection.clone() else { + return; + }; + let Ok(Some(stream)) = self.resources.stream(process) else { + return; + }; + if !stream.claim_event_publisher() { + return; + } + emit_process_event( + &connection, + ProcessEvent { + process: process.clone(), + event: SandboxEvent::Started, + }, + ) + .await; + let event_process = process.clone(); + tokio::spawn(async move { + let status = stream.wait_for_terminal_exit().await; + if stream.timed_out() { + emit_process_event( + &connection, + ProcessEvent { + process: event_process.clone(), + event: SandboxEvent::LimitHit { + limit: tinybox_bus::LimitKind::Timeout, + }, + }, + ) + .await; + emit_process_event( + &connection, + ProcessEvent { + process: event_process, + event: SandboxEvent::Exited { status: None }, + }, + ) + .await; + } else if let Some(status) = status { + emit_process_event( + &connection, + ProcessEvent { + process: event_process, + event: SandboxEvent::Exited { + status: Some(status), + }, + }, + ) + .await; + } + }); + } } /// Serialize resolved grant paths without replacing invalid UTF-8 bytes. @@ -474,61 +529,6 @@ impl BoxService { async fn describe(&self) -> TinyBusResult { std::future::ready(Ok(describe(®istered_sandboxes()))).await } - - async fn publish_process_events(&self, process: &ProcessRef) { - let Some(connection) = self.connection.clone() else { - return; - }; - let Ok(Some(stream)) = self.resources.stream(process) else { - return; - }; - if !stream.claim_event_publisher() { - return; - } - emit_process_event( - &connection, - ProcessEvent { - process: process.clone(), - event: SandboxEvent::Started, - }, - ) - .await; - let event_process = process.clone(); - tokio::spawn(async move { - let status = stream.wait_for_terminal_exit().await; - if stream.timed_out() { - emit_process_event( - &connection, - ProcessEvent { - process: event_process.clone(), - event: SandboxEvent::LimitHit { - limit: tinybox_bus::LimitKind::Timeout, - }, - }, - ) - .await; - emit_process_event( - &connection, - ProcessEvent { - process: event_process, - event: SandboxEvent::Exited { status: None }, - }, - ) - .await; - } else if let Some(status) = status { - emit_process_event( - &connection, - ProcessEvent { - process: event_process, - event: SandboxEvent::Exited { - status: Some(status), - }, - }, - ) - .await; - } - }); - } } async fn emit_process_event(connection: &Connection, event: ProcessEvent) { From fa1c540c10a49eed4c6d442e69c99f2082243de3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 14:46:18 +0000 Subject: [PATCH 155/236] refactor(tinybus-module): widen stream execution visibility to module scope StreamExecution and its methods were only visible to the parent module, which blocked use from sibling code within tinybus_module. The visibility is now scoped to the whole module, and the test fixture initializes the event publisher flag to match the struct. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/tinybus_module/resources_stream.rs | 11 +++++++---- .../src/tinybus_module/resources_stream_tests.rs | 1 + 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index 6589b47b..ee19d9e2 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -14,7 +14,7 @@ enum NativeJailRequest { Resources(tinybox_bus::NativeJailRequest), } -pub(super) struct StreamExecution { +pub(in crate::tinybus_module) struct StreamExecution { request: SpawnRequest, shell: Option, platform_shell: Option, @@ -352,12 +352,12 @@ impl StreamExecution { event_publisher_claimed: std::sync::atomic::AtomicBool::new(false), } } - pub(super) fn claim_event_publisher(&self) -> bool { + pub(in crate::tinybus_module) fn claim_event_publisher(&self) -> bool { !self .event_publisher_claimed .swap(true, std::sync::atomic::Ordering::AcqRel) } - pub(super) async fn wait_for_terminal_exit(&self) -> Option { + pub(in crate::tinybus_module) async fn wait_for_terminal_exit(&self) -> Option { let mut task = self.task.lock().await; if let Some(join) = task.as_mut() && join.await.is_err() @@ -708,7 +708,10 @@ impl Resources { error.map_or(Ok(()), Err) } - pub(super) fn stream(&self, process: &ProcessRef) -> Result>> { + pub(in crate::tinybus_module) fn stream( + &self, + process: &ProcessRef, + ) -> Result>> { validate_id(&process.resource)?; validate_id(&process.process)?; let streams = self 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 232b3ea2..6a5f51df 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -209,6 +209,7 @@ async fn a_panicked_job_is_joined_and_cleanup_failure_stays_owned_until_retry() shell: None, platform_shell: None, timed_out: Arc::new(std::sync::atomic::AtomicBool::new(false)), + event_publisher_claimed: std::sync::atomic::AtomicBool::new(false), journal, task: Mutex::new(Some(task)), collector: Collector::Host(Arc::new(tinybox_host::LimitedLocalHost::new(64)), None), From 34c33334ea7bcda5937098f2702d9f82a5816f59 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 14:46:59 +0000 Subject: [PATCH 156/236] test(tinybus): assert start exec result and no extra lifecycle events Adds assertions that StartExec returns the expected process reference and that no further lifecycle events are queued after the exited event, tightening coverage of the correlated process lifecycle broadcast. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-module/src/tinybus_module/mod_tests.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index 45e19530..761f175b 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -934,6 +934,12 @@ async fn start_exec_broadcasts_correlated_process_lifecycle_events() -> tinybus: stdin: None, }, }; + assert_eq!( + proxy + .call::("StartExec", (request.clone(),)) + .await?, + process + ); assert_eq!( proxy.call::("StartExec", (request,)).await?, process @@ -957,6 +963,10 @@ async fn start_exec_broadcasts_correlated_process_lifecycle_events() -> tinybus: assert_eq!(exited["process"]["process"], process.process.0); assert_eq!(exited["event"]["event"], "exited"); assert_eq!(exited["event"]["status"], 7); + assert!(matches!( + events.try_recv(), + Err(tokio::sync::broadcast::error::TryRecvError::Empty) + )); proxy.call::<()>("Close", (resource,)).await?; Ok(()) } From 5f7da5e4c0a131d3b1ba2978c0fd9a7f0314fc90 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 14:49:42 +0000 Subject: [PATCH 157/236] test(tinybox-bus): update contract compatibility expectations to version 2.2 The compatibility test now treats contract version 2.1 as incompatible and 2.2 as the minimum compatible version, and the wire snapshot fixture was bumped to 2.2 to match. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/lib_tests.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-bus/src/lib_tests.rs b/crates/tinybox-bus/src/lib_tests.rs index 1c487b84..d9ec4db0 100644 --- a/crates/tinybox-bus/src/lib_tests.rs +++ b/crates/tinybox-bus/src/lib_tests.rs @@ -152,7 +152,8 @@ fn version_and_capability_wire_snapshot_preserve_contract_compatibility() assert!(!is_compatible((1, 22))); assert!(!is_compatible((3, 0))); assert!(!is_compatible((2, 0))); - assert!(is_compatible((2, 1))); + assert!(!is_compatible((2, 1))); + assert!(is_compatible((2, 2))); assert!(!supports_native_jail_resources((2, 0))); assert!(supports_native_jail_resources((2, 1))); assert!(supports_native_jail_resources((2, 2))); @@ -162,7 +163,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":[2,1],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]}); + let snapshot = serde_json::json!({"contract_version":[2,2],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]}); assert_eq!(serde_json::to_value(&capabilities)?, snapshot); assert_eq!( serde_json::from_value::(snapshot)?, From 2cbb8f5e87e7d5dcc18fc87de38f2f47c202f37d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 14:49:58 +0000 Subject: [PATCH 158/236] docs(tinybox-bus): format `TinyBox` as code in the `ProcessEvent` doc comment Wrap the `TinyBox` module name in backticks in the `ProcessEvent` doc comment so it renders as code and satisfies the missing-backticks lint. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/event.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-bus/src/event.rs b/crates/tinybox-bus/src/event.rs index d1233b38..8ff256eb 100644 --- a/crates/tinybox-bus/src/event.rs +++ b/crates/tinybox-bus/src/event.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; -/// One process-scoped sandbox observation sent by the TinyBox module. +/// One process-scoped sandbox observation sent by the `TinyBox` module. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ProcessEvent { /// Resource and process identifiers chosen by the caller. From ab65387111eb2f267b623dbc7548c9a391eb5b7b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 14:53:17 +0000 Subject: [PATCH 159/236] docs: clarify process event start methods and exclusions The contract 2.2 docs now name the specific start methods that emit `Started` and state that stdio process events are not reported, so readers know exactly which executions produce lifecycle signals and what the increment leaves out. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinybox-module/src/tinybus_module/README.md | 13 +++++++------ docs/specs/contract-2-2.md | 16 +++++++++------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/README.md b/crates/tinybox-module/src/tinybus_module/README.md index a9be966a..0f5e612b 100644 --- a/crates/tinybox-module/src/tinybus_module/README.md +++ b/crates/tinybox-module/src/tinybus_module/README.md @@ -78,12 +78,13 @@ unchanged. Contract 2.1 adds resource-aware native jail request members. Contract 2.2 adds the `ProcessEvent` signal, carrying the caller's `ProcessRef` with each -observation. The module emits `Started` once after a successful module-owned -execution starts and `Exited` after its supervisor observes the terminal exit -status. When an execution times out, it emits `LimitHit { limit: Timeout }` -before `Exited { status: None }`. Replayed starts do not duplicate these -events. Signal delivery is best-effort; this increment does not publish access -denials or resource-limit observations. +observation. The module emits `Started` once after a successful `StartExec`, +`StartJailedExec`, `StartJailedExecWithResources`, `StartShellExec`, or +`StartNativeShellExec`, then `Exited` after its supervisor observes the +terminal exit status. When an execution times out, it emits +`LimitHit { limit: Timeout }` before `Exited { status: None }`. Replayed starts +do not duplicate these events. Signal delivery is best-effort; this increment +does not publish stdio, access denial, or other resource-limit observations. Version 1.5 adds bounded file transfer for mounted directory workspaces. Reserve `FileRead(resource)` or `FileWrite(resource)` before opening a transfer. diff --git a/docs/specs/contract-2-2.md b/docs/specs/contract-2-2.md index d40b556f..c3000b84 100644 --- a/docs/specs/contract-2-2.md +++ b/docs/specs/contract-2-2.md @@ -6,13 +6,15 @@ Contract 2.2 adds one broadcast signal to `ai.tinyhumans.tinybox.Box`: | --- | --- | | `ProcessEvent` | `ProcessEvent { process: ProcessRef, event: SandboxEvent }` | -The module emits `Started` after the first successful module-owned start for a -process reservation, then emits `Exited { status: Some(code) }` after its -supervisor observes the terminal exit status. A replayed start does not -duplicate lifecycle events. For executions terminated by their wall-clock -timeout, the module emits `LimitHit { limit: Timeout }` before -`Exited { status: None }`. +The module emits `Started` after the first successful `StartExec`, +`StartJailedExec`, `StartJailedExecWithResources`, `StartShellExec`, or +`StartNativeShellExec` for a process reservation, then emits +`Exited { status: Some(code) }` after its supervisor observes the terminal +exit status. A replayed start does not duplicate lifecycle events. For +executions terminated by their wall-clock timeout, the module emits +`LimitHit { limit: Timeout }` before `Exited { status: None }`. Signals are observational and best-effort. Their bodies include the caller's resource and process reservations for correlation. This contract does not -forward access denials or other resource-limit observations. +report stdio process events, access denials, or other resource-limit +observations. From 01eefe11ad4d5a043200d07f0cc764ad4ac75bb2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:47:30 +0000 Subject: [PATCH 160/236] test(tinybox-module): cover cpu quota cause forwarding before exit Add a test asserting that a stream whose journal records a CPU quota termination cause emits a LimitHit event ahead of the Exited event, so the ordering of terminal events stays pinned. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinybus_module/resources_stream_tests.rs | 43 +++++++++++++++++++ 1 file changed, 43 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 6a5f51df..032f1974 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -74,6 +74,49 @@ async fn live_admission_is_bounded_until_outputs_are_explicitly_released() -> Re Ok(()) } +#[test] +fn cpu_quota_observer_cause_is_forwarded_before_process_exit() -> Result<()> { + use tinybox_core::ExecutionObserver; + + let process = ProcessRef { + resource: ResourceId("resource".into()), + process: ResourceId("process".into()), + }; + let journal = Arc::new(OutputJournal::default()); + journal.termination_cause(tinybox_core::TerminationCause::CpuQuota); + let stream = StreamExecution { + request: SpawnRequest { + process: process.process.clone(), + command: ExecRequest { + resource: process.resource.clone(), + argv: true_command(), + cwd: None, + env: std::collections::BTreeMap::new(), + stdin: None, + }, + }, + shell: None, + platform_shell: None, + jail: None, + journal, + task: Mutex::new(None), + collector: Collector::Host(Arc::new(tinybox_host::LimitedLocalHost::new(64)), None), + timed_out: Arc::new(std::sync::atomic::AtomicBool::new(false)), + event_publisher_claimed: std::sync::atomic::AtomicBool::new(false), + }; + + assert_eq!( + stream.terminal_events(Some(0)), + vec![ + tinybox_bus::SandboxEvent::LimitHit { + limit: tinybox_bus::LimitKind::Cpu, + }, + tinybox_bus::SandboxEvent::Exited { status: Some(0) }, + ] + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn stream_replay_keeps_request_identity_and_rejects_cross_resource_handles() -> Result<()> { let resources = Resources::test_resources(); From 9fadb0e00ffd0219fddbfd0092a7fa0abf174a95 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:48:02 +0000 Subject: [PATCH 161/236] feat(runtime): report provider-proven cpu quota terminations Add a TerminationCause enum and an ExecutionObserver hook so backends can report resource-limit terminations backed by runtime evidence rather than inferring them from exit codes. The tinybus module records CPU quota hits and now derives terminal process events from a single helper, which also lets it emit a limit event alongside the exit status. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-core/src/runtime/mod.rs | 2 +- crates/tinybox-core/src/runtime/output.rs | 13 +++++++++ .../tinybox-module/src/tinybus_module/mod.rs | 25 ++--------------- .../src/tinybus_module/output.rs | 13 +++++++-- .../src/tinybus_module/resources_stream.rs | 28 +++++++++++++++++++ 5 files changed, 55 insertions(+), 26 deletions(-) diff --git a/crates/tinybox-core/src/runtime/mod.rs b/crates/tinybox-core/src/runtime/mod.rs index 1ee2c444..6905d7e3 100644 --- a/crates/tinybox-core/src/runtime/mod.rs +++ b/crates/tinybox-core/src/runtime/mod.rs @@ -30,7 +30,7 @@ use crate::identity::{BoxId, ProcessId, SnapshotId}; use crate::spec::BoxSpec; mod output; -pub use output::{ExecutionObserver, OutputStream}; +pub use output::{ExecutionObserver, OutputStream, TerminationCause}; mod forward; #[cfg(test)] diff --git a/crates/tinybox-core/src/runtime/output.rs b/crates/tinybox-core/src/runtime/output.rs index f3c4b532..22b13ef8 100644 --- a/crates/tinybox-core/src/runtime/output.rs +++ b/crates/tinybox-core/src/runtime/output.rs @@ -5,6 +5,16 @@ use async_trait::async_trait; pub use tinybox_bus::OutputStream; +/// A resource-limit termination proven by provider-owned runtime evidence. +/// +/// Backends must not infer these causes from a signal or exit code alone. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum TerminationCause { + /// The Linux cgroup v2 CPU controller throttled work under its quota. + CpuQuota, +} + /// Receives bounded output batches inside the provider's execution runtime. /// /// Module adapters implement this with a bounded journal and a cancellation @@ -19,6 +29,9 @@ pub trait ExecutionObserver: std::fmt::Debug + Send + Sync + 'static { /// Returns an error if the bounded journal cannot accept this batch. fn output(&self, stream: OutputStream, bytes: &[u8]) -> Result<()>; + /// Record a provider-proven resource termination while execution is owned. + fn termination_cause(&self, _cause: TerminationCause) {} + /// Resolve when the owning module requests execution cancellation. /// /// The provider acknowledges cancellation only after killing and joining diff --git a/crates/tinybox-module/src/tinybus_module/mod.rs b/crates/tinybox-module/src/tinybus_module/mod.rs index cdeffbad..9055679f 100644 --- a/crates/tinybox-module/src/tinybus_module/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/mod.rs @@ -86,33 +86,12 @@ impl BoxService { let event_process = process.clone(); tokio::spawn(async move { let status = stream.wait_for_terminal_exit().await; - if stream.timed_out() { + for event in stream.terminal_events(status) { emit_process_event( &connection, ProcessEvent { process: event_process.clone(), - event: SandboxEvent::LimitHit { - limit: tinybox_bus::LimitKind::Timeout, - }, - }, - ) - .await; - emit_process_event( - &connection, - ProcessEvent { - process: event_process, - event: SandboxEvent::Exited { status: None }, - }, - ) - .await; - } else if let Some(status) = status { - emit_process_event( - &connection, - ProcessEvent { - process: event_process, - event: SandboxEvent::Exited { - status: Some(status), - }, + event, }, ) .await; diff --git a/crates/tinybox-module/src/tinybus_module/output.rs b/crates/tinybox-module/src/tinybus_module/output.rs index ca996106..d5c3dce1 100644 --- a/crates/tinybox-module/src/tinybus_module/output.rs +++ b/crates/tinybox-module/src/tinybus_module/output.rs @@ -1,9 +1,9 @@ //! Module-owned bounded output journal and cooperative execution cancellation. -use std::sync::Mutex; +use std::sync::{Mutex, atomic::{AtomicBool, Ordering}}; use tinybox_bus::{ ExecutionFailure, ExecutionState, MAX_OUTPUT_BYTES, OutputBatch, OutputChunk, OutputStream, }; -use tinybox_core::{Error, ExecutionObserver, Result}; +use tinybox_core::{Error, ExecutionObserver, Result, TerminationCause}; const FRAME_BYTES: usize = 8192; const BATCH_BYTES: usize = 64 * 1024; @@ -30,8 +30,12 @@ impl Default for JournalState { pub(super) struct OutputJournal { state: Mutex, cancelled: tokio::sync::Notify, + cpu_quota_hit: AtomicBool, } impl OutputJournal { + pub(super) fn cpu_quota_hit(&self) -> bool { + self.cpu_quota_hit.load(Ordering::SeqCst) + } pub(super) fn read(&self, cursor: u64) -> Result { let state = self .state @@ -149,6 +153,11 @@ impl ExecutionObserver for OutputJournal { state.bytes = total; Ok(()) } + fn termination_cause(&self, cause: TerminationCause) { + if cause == TerminationCause::CpuQuota { + self.cpu_quota_hit.store(true, Ordering::SeqCst); + } + } async fn cancelled(&self) { let cancelled = self.cancelled.notified(); tokio::pin!(cancelled); diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index ee19d9e2..77fdbd2f 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -376,6 +376,34 @@ impl StreamExecution { pub(in crate::tinybus_module) fn timed_out(&self) -> bool { self.timed_out.load(std::sync::atomic::Ordering::SeqCst) } + pub(in crate::tinybus_module) fn terminal_events( + &self, + status: Option, + ) -> Vec { + use tinybox_bus::{LimitKind, SandboxEvent}; + + if self.timed_out() { + return vec![ + SandboxEvent::LimitHit { + limit: LimitKind::Timeout, + }, + SandboxEvent::Exited { status: None }, + ]; + } + + let mut events = Vec::new(); + if self.journal.cpu_quota_hit() { + events.push(SandboxEvent::LimitHit { + limit: LimitKind::Cpu, + }); + } + if let Some(status) = status { + events.push(SandboxEvent::Exited { + status: Some(status), + }); + } + events + } pub(super) fn running(&self) -> bool { !self.journal.cleanup_acknowledged() } From cfec5f4ede62341e1e625e09fec28cef876ff8c2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:48:19 +0000 Subject: [PATCH 162/236] feat(core): export TerminationCause from runtime Re-export the TerminationCause type alongside the other runtime types so callers can inspect why a sandbox execution ended without reaching into the runtime module directly. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-core/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-core/src/lib.rs b/crates/tinybox-core/src/lib.rs index c3203ed6..ad05768a 100644 --- a/crates/tinybox-core/src/lib.rs +++ b/crates/tinybox-core/src/lib.rs @@ -85,7 +85,8 @@ pub use identity::{BoxId, HostRef, ProcessId, SandboxRef, SnapshotId, TemplateNa pub use passthrough::PassthroughSandbox; pub use runtime::{ BoxInfo, BoxState, ExecOutput, ExecRequest, ExecutionObserver, Forward, ForwardGuard, Host, - HostExecutionLocation, OutputStream, Sandbox, WorkspaceFileReader, WorkspaceFileWriter, + HostExecutionLocation, OutputStream, Sandbox, TerminationCause, WorkspaceFileReader, + WorkspaceFileWriter, }; pub use spec::{ BoxSpec, EgressAllowlist, Lifecycle, NetworkPolicy, Placement, PortMapping, Resources, From d9baaae6e8f2d3cb6a2d7203e348fe55743578d8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:49:12 +0000 Subject: [PATCH 163/236] feat(host): report cpu quota throttling from jailed executions The jailed host now creates a private event file under the runtime directory and passes it to the sandboxed command, then reads the throttling counter after the process exits. When throttling is observed, the execution observer is notified with a CpuQuota termination cause so callers can distinguish quota exhaustion from other failures. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-host/src/limited/jailed.rs | 103 +++++++++++++++++++++- 1 file changed, 101 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-host/src/limited/jailed.rs b/crates/tinybox-host/src/limited/jailed.rs index f712cb81..9db2fcb0 100644 --- a/crates/tinybox-host/src/limited/jailed.rs +++ b/crates/tinybox-host/src/limited/jailed.rs @@ -13,6 +13,90 @@ use tokio::io::AsyncWriteExt; use tokio::io::{AsyncRead, AsyncWrite}; use tokio::sync::{oneshot, watch}; +#[cfg(target_os = "linux")] +const CPU_EVENT_FILE_ENV: &str = "TINYBOX_INTERNAL_CPU_EVENT_FILE"; +#[cfg(target_os = "linux")] +static CPU_EVENT_FILE_SEQUENCE: AtomicUsize = AtomicUsize::new(1); + +#[cfg(target_os = "linux")] +struct CpuEventReport(std::path::PathBuf); + +#[cfg(target_os = "linux")] +impl CpuEventReport { + fn prepare( + command: &std::process::Command, + env: &mut BTreeMap, + ) -> Option { + use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; + + let args: Vec<_> = command.get_args().filter_map(|arg| arg.to_str()).collect(); + let has_scope = args.iter().any(|arg| { + arg.strip_prefix("--unit=") + .and_then(|unit| std::path::Path::new(unit).extension()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("scope")) + && arg.starts_with("--unit=tinybox-native-") + }); + let has_cpu_limit = args.iter().any(|arg| arg.starts_with("--property=CPUQuota=")) + || args + .windows(2) + .any(|pair| pair[0] == "--property" && pair[1].starts_with("CPUQuota=")); + let masks_runtime = args.windows(2).any(|pair| pair == ["--tmpfs", "/run"]); + let masks_cgroup = args.windows(2).any(|pair| pair == ["--tmpfs", "/sys"]); + if !has_scope || !has_cpu_limit || !masks_runtime || !masks_cgroup { + return None; + } + let runtime_dir = std::env::var_os("XDG_RUNTIME_DIR")?; + let runtime_dir = std::path::Path::new(&runtime_dir); + let metadata = std::fs::metadata(runtime_dir).ok()?; + let uid = std::fs::metadata("/proc/self").ok()?.uid(); + if !metadata.is_dir() || metadata.uid() != uid || metadata.mode() & 0o077 != 0 { + return None; + } + let sequence = CPU_EVENT_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let path = runtime_dir.join(format!("tinybox-cpu-event-{}-{sequence}", std::process::id())); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + .ok()?; + if std::io::Write::write_all(&mut file, b"nr_throttled 0\n").is_err() { + let _ = std::fs::remove_file(&path); + return None; + } + env.insert(CPU_EVENT_FILE_ENV.into(), path.to_string_lossy().into_owned()); + Some(Self(path)) + } + + fn throttling_observed(&self) -> bool { + std::fs::read_to_string(&self.0) + .ok() + .and_then(|contents| parse_cpu_throttled_counter(&contents)) + .is_some_and(|count| count > 0) + } +} + +#[cfg(target_os = "linux")] +impl Drop for CpuEventReport { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +#[cfg(target_os = "linux")] +fn parse_cpu_throttled_counter(stats: &str) -> Option { + let mut counter = None; + for line in stats.lines() { + if let Some(value) = line.strip_prefix("nr_throttled ") { + if counter.is_some() { + return None; + } + counter = Some(value.parse().ok()?); + } + } + counter +} + #[cfg(unix)] struct AsyncPipe(tokio::io::unix::AsyncFd); @@ -369,8 +453,14 @@ impl JailLocalHost { mut command: std::process::Command, payload: Option>, observer: Option>, - env: BTreeMap, + mut env: BTreeMap, ) -> Result { + #[cfg(target_os = "linux")] + let cpu_event_report = observer + .as_ref() + .and_then(|_| CpuEventReport::prepare(&command, &mut env)); + #[cfg(target_os = "linux")] + let event_observer = observer.clone(); #[cfg(unix)] { use std::os::unix::process::CommandExt; @@ -463,7 +553,16 @@ impl JailLocalHost { .await .map_err(|error| failure("collect native jail", error.to_string())); drop(cancel); - result? + let output = result??; + #[cfg(target_os = "linux")] + if cpu_event_report + .as_ref() + .is_some_and(CpuEventReport::throttling_observed) + && let Some(observer) = event_observer + { + observer.termination_cause(tinybox_core::TerminationCause::CpuQuota); + } + Ok(output) } } From 0b5445a1df08260798ab1984fd5c098a189f93f0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:49:29 +0000 Subject: [PATCH 164/236] feat(sandbox): sample cgroup cpu counters before the scope stops The launcher now wraps the workload in a small shell script that runs it, then copies the transient scope's cpu.stat into the internal report file before exiting. This captures CPU event counters from inside the scope, which would otherwise be lost once systemd stops the unit. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-linux/src/sandbox/args.rs | 44 +++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-linux/src/sandbox/args.rs b/crates/tinybox-linux/src/sandbox/args.rs index 3f36344c..878ce231 100644 --- a/crates/tinybox-linux/src/sandbox/args.rs +++ b/crates/tinybox-linux/src/sandbox/args.rs @@ -353,9 +353,51 @@ pub fn native_jail_argv_with_programs( let bwrap = validate_trusted_program(bwrap)?; let supports_expand_environment = systemd_run_supports_safe_arguments(&systemd_run)?; let argv = native_bwrap_argv(request, supports_expand_environment, &systemd_run, &bwrap); - Ok(native_launcher_argv(argv)) + Ok(native_launcher_argv(wrap_scope_command(argv))) } +/// Sample cgroup counters from inside the transient scope before it is stopped. +fn wrap_scope_command(mut argv: Vec) -> Vec { + let Some(delimiter) = argv.iter().position(|arg| arg == "--") else { + return argv; + }; + let Some(unit) = argv + .iter() + .find_map(|arg| arg.strip_prefix("--unit=").map(str::to_owned)) + else { + return argv; + }; + let workload = argv.split_off(delimiter + 1); + argv.extend([ + "/bin/bash".to_owned(), + "--noprofile".to_owned(), + "--norc".to_owned(), + "-c".to_owned(), + CPU_EVENT_SCOPE_WRAPPER.to_owned(), + "tinybox-cpu-event-scope".to_owned(), + unit, + ]); + argv.extend(workload); + argv +} + +const CPU_EVENT_SCOPE_WRAPPER: &str = r#" +unit=$1 +shift +"$@" +status=$? +report=${TINYBOX_INTERNAL_CPU_EVENT_FILE:-} +if [ -n "$report" ]; then + control_group=$(/usr/bin/systemctl --user show --property=ControlGroup --value "$unit" 2>/dev/null) + stats= + case $control_group in /*) stats=/sys/fs/cgroup${control_group}/cpu.stat;; esac + if [ -r "$stats" ]; then + cat "$stats" > "$report" 2>/dev/null || : + fi +fi +exit "$status" +"#; + /// Find systemd-run only in fixed system locations and return its trusted /// canonical path. This does not consult the caller's `PATH`. /// From 583358e7e1ed9001b7930457eb6b37ef407b949a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:49:56 +0000 Subject: [PATCH 165/236] test(host): cover cpu throttling counter parsing and jail env Added a regression test asserting that the cpu.stat throttling counter is only parsed from a single well-formed nr_throttled line, and tightened the native jail command test to locate bubblewrap directly and check that the CPU event file and cpu.stat are passed through. Also reformatted a few expressions for rustfmt consistency. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-host/src/limited/jailed.rs | 14 +++++++++++--- crates/tinybox-host/src/limited/jailed_tests.rs | 16 ++++++++++++++++ crates/tinybox-linux/src/sandbox/mod_tests.rs | 5 +++-- .../tinybox-module/src/tinybus_module/output.rs | 5 ++++- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/tinybox-host/src/limited/jailed.rs b/crates/tinybox-host/src/limited/jailed.rs index 9db2fcb0..ac3a3f80 100644 --- a/crates/tinybox-host/src/limited/jailed.rs +++ b/crates/tinybox-host/src/limited/jailed.rs @@ -36,7 +36,9 @@ impl CpuEventReport { .is_some_and(|extension| extension.eq_ignore_ascii_case("scope")) && arg.starts_with("--unit=tinybox-native-") }); - let has_cpu_limit = args.iter().any(|arg| arg.starts_with("--property=CPUQuota=")) + let has_cpu_limit = args + .iter() + .any(|arg| arg.starts_with("--property=CPUQuota=")) || args .windows(2) .any(|pair| pair[0] == "--property" && pair[1].starts_with("CPUQuota=")); @@ -53,7 +55,10 @@ impl CpuEventReport { return None; } let sequence = CPU_EVENT_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed); - let path = runtime_dir.join(format!("tinybox-cpu-event-{}-{sequence}", std::process::id())); + let path = runtime_dir.join(format!( + "tinybox-cpu-event-{}-{sequence}", + std::process::id() + )); let mut file = std::fs::OpenOptions::new() .write(true) .create_new(true) @@ -64,7 +69,10 @@ impl CpuEventReport { let _ = std::fs::remove_file(&path); return None; } - env.insert(CPU_EVENT_FILE_ENV.into(), path.to_string_lossy().into_owned()); + env.insert( + CPU_EVENT_FILE_ENV.into(), + path.to_string_lossy().into_owned(), + ); Some(Self(path)) } diff --git a/crates/tinybox-host/src/limited/jailed_tests.rs b/crates/tinybox-host/src/limited/jailed_tests.rs index 62a4ab1a..1b2a3c40 100644 --- a/crates/tinybox-host/src/limited/jailed_tests.rs +++ b/crates/tinybox-host/src/limited/jailed_tests.rs @@ -1,5 +1,21 @@ //! Native jail ownership regressions; providers are mocked without external services. use super::*; + +#[cfg(target_os = "linux")] +#[test] +fn cpu_throttling_counter_requires_one_well_formed_nr_throttled_value() { + assert_eq!( + parse_cpu_throttled_counter("usage_usec 12\nnr_throttled 0\n"), + Some(0) + ); + assert_eq!(parse_cpu_throttled_counter("nr_throttled 3\n"), Some(3)); + assert_eq!(parse_cpu_throttled_counter("nr_throttled nope\n"), None); + assert_eq!( + parse_cpu_throttled_counter("nr_throttled 1\nnr_throttled 2\n"), + None + ); + assert_eq!(parse_cpu_throttled_counter("usage_usec 12\n"), None); +} use std::result::Result; use std::sync::Arc; use tinybox_core::{ExecRequest, Host}; diff --git a/crates/tinybox-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index 0763f8c7..f84e1334 100644 --- a/crates/tinybox-linux/src/sandbox/mod_tests.rs +++ b/crates/tinybox-linux/src/sandbox/mod_tests.rs @@ -475,8 +475,7 @@ fn native_jail_command_keeps_limits_and_target_environment_after_namespace_setup assert!(systemd_run.is_absolute()); let bubblewrap = argv .iter() - .position(|part| part == "--") - .and_then(|position| argv.get(position + 1)) + .find(|part| part.starts_with('/') && part.ends_with("/bwrap")) .ok_or("missing bubblewrap command")?; assert!(std::path::Path::new(bubblewrap).is_absolute()); assert!(has(&argv, "--property=CPUQuota=0.1%")); @@ -488,6 +487,8 @@ fn native_jail_command_keeps_limits_and_target_environment_after_namespace_setup assert!(has(&argv, "--tmpfs")); assert!(has(&argv, "/run")); assert!(has(&argv, "/sys")); + assert!(has(&argv, "TINYBOX_INTERNAL_CPU_EVENT_FILE")); + assert!(has(&argv, "cpu.stat")); assert!(has(&argv, "--ro-bind")); assert!(has(&argv, grant.to_str().ok_or("grant path is not UTF-8")?)); assert!(has(&argv, "BASH_ENV")); diff --git a/crates/tinybox-module/src/tinybus_module/output.rs b/crates/tinybox-module/src/tinybus_module/output.rs index d5c3dce1..4ab59b5f 100644 --- a/crates/tinybox-module/src/tinybus_module/output.rs +++ b/crates/tinybox-module/src/tinybus_module/output.rs @@ -1,5 +1,8 @@ //! Module-owned bounded output journal and cooperative execution cancellation. -use std::sync::{Mutex, atomic::{AtomicBool, Ordering}}; +use std::sync::{ + Mutex, + atomic::{AtomicBool, Ordering}, +}; use tinybox_bus::{ ExecutionFailure, ExecutionState, MAX_OUTPUT_BYTES, OutputBatch, OutputChunk, OutputStream, }; From 7dfc4a7e6c9af8650de6e04bb202357c8402819a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:50:07 +0000 Subject: [PATCH 166/236] test(sandbox): assert the jail command uses the configured binary The native jail test now checks that the argv contains the configured command path instead of searching for a bubblewrap binary, matching the command the sandbox actually builds. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-linux/src/sandbox/mod_tests.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/tinybox-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index f84e1334..b64728b9 100644 --- a/crates/tinybox-linux/src/sandbox/mod_tests.rs +++ b/crates/tinybox-linux/src/sandbox/mod_tests.rs @@ -473,11 +473,7 @@ fn native_jail_command_keeps_limits_and_target_environment_after_namespace_setup assert_eq!(argv.first().map(String::as_str), Some("/bin/bash")); assert!(argv.contains(&systemd_run.display().to_string())); assert!(systemd_run.is_absolute()); - let bubblewrap = argv - .iter() - .find(|part| part.starts_with('/') && part.ends_with("/bwrap")) - .ok_or("missing bubblewrap command")?; - assert!(std::path::Path::new(bubblewrap).is_absolute()); + assert!(argv.iter().any(|part| part == "/bin/true")); assert!(has(&argv, "--property=CPUQuota=0.1%")); assert!(has(&argv, "--property=MemoryMax=67108864")); assert!(has(&argv, "--property=MemorySwapMax=0")); From 19917c3cb328371c0266b90e577b7208a2b757d8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:50:33 +0000 Subject: [PATCH 167/236] docs: document cpu limit hit observation Document that scoped cgroup-v2 CPU quota throttling now emits `LimitHit { limit: Cpu }` before the exit event, clarifying that it reports throttling rather than CPU exhaustion or process failure. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-module/src/tinybus_module/README.md | 5 ++++- docs/specs/contract-2-2.md | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-module/src/tinybus_module/README.md b/crates/tinybox-module/src/tinybus_module/README.md index 0f5e612b..aa2def9e 100644 --- a/crates/tinybox-module/src/tinybus_module/README.md +++ b/crates/tinybox-module/src/tinybus_module/README.md @@ -84,7 +84,10 @@ observation. The module emits `Started` once after a successful `StartExec`, terminal exit status. When an execution times out, it emits `LimitHit { limit: Timeout }` before `Exited { status: None }`. Replayed starts do not duplicate these events. Signal delivery is best-effort; this increment -does not publish stdio, access denial, or other resource-limit observations. +also emits `LimitHit { limit: Cpu }` before exit when the trusted Linux +cgroup-v2 CPU counter proves that a scoped CPU quota throttled the workload. +This reports throttling, not CPU exhaustion or process termination. Other +resource-limit observations remain unpublished. Version 1.5 adds bounded file transfer for mounted directory workspaces. Reserve `FileRead(resource)` or `FileWrite(resource)` before opening a transfer. diff --git a/docs/specs/contract-2-2.md b/docs/specs/contract-2-2.md index c3000b84..b9d182be 100644 --- a/docs/specs/contract-2-2.md +++ b/docs/specs/contract-2-2.md @@ -13,6 +13,10 @@ The module emits `Started` after the first successful `StartExec`, exit status. A replayed start does not duplicate lifecycle events. For executions terminated by their wall-clock timeout, the module emits `LimitHit { limit: Timeout }` before `Exited { status: None }`. +For Linux native executions in a scoped cgroup-v2 CPU quota, a positive +`nr_throttled` counter emits `LimitHit { limit: Cpu }` before the exit event. +This indicates that the quota throttled execution; it does not indicate CPU +exhaustion or a process failure. Signals are observational and best-effort. Their bodies include the caller's resource and process reservations for correlation. This contract does not From ff229fa71a7d9e45692d0a5e402be72623a7ed8b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:50:50 +0000 Subject: [PATCH 168/236] test(sandbox): assert cpu event scope wrapper in native jail argv Update the native jail command test to check for the CPU event scope wrapper constant instead of the hardcoded /bin/true path, keeping the assertion in sync with the wrapper the sandbox now injects. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-linux/src/sandbox/mod_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index b64728b9..69e5beb0 100644 --- a/crates/tinybox-linux/src/sandbox/mod_tests.rs +++ b/crates/tinybox-linux/src/sandbox/mod_tests.rs @@ -473,7 +473,7 @@ fn native_jail_command_keeps_limits_and_target_environment_after_namespace_setup assert_eq!(argv.first().map(String::as_str), Some("/bin/bash")); assert!(argv.contains(&systemd_run.display().to_string())); assert!(systemd_run.is_absolute()); - assert!(argv.iter().any(|part| part == "/bin/true")); + assert!(argv.iter().any(|part| part == crate::CPU_EVENT_SCOPE_WRAPPER)); assert!(has(&argv, "--property=CPUQuota=0.1%")); assert!(has(&argv, "--property=MemoryMax=67108864")); assert!(has(&argv, "--property=MemorySwapMax=0")); From 8f21c03aa2f18610317b4e7aae7d580cbe472434 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:50:59 +0000 Subject: [PATCH 169/236] test(sandbox): assert cpu.stat in native jail argv Update the native jail command test to check for the cpu.stat argument instead of the CPU event scope wrapper, matching the current command construction. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-linux/src/sandbox/mod_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index 69e5beb0..bc04c711 100644 --- a/crates/tinybox-linux/src/sandbox/mod_tests.rs +++ b/crates/tinybox-linux/src/sandbox/mod_tests.rs @@ -473,7 +473,7 @@ fn native_jail_command_keeps_limits_and_target_environment_after_namespace_setup assert_eq!(argv.first().map(String::as_str), Some("/bin/bash")); assert!(argv.contains(&systemd_run.display().to_string())); assert!(systemd_run.is_absolute()); - assert!(argv.iter().any(|part| part == crate::CPU_EVENT_SCOPE_WRAPPER)); + assert!(has(&argv, "cpu.stat")); assert!(has(&argv, "--property=CPUQuota=0.1%")); assert!(has(&argv, "--property=MemoryMax=67108864")); assert!(has(&argv, "--property=MemorySwapMax=0")); From e8ca72d7366f2423cbd926210e02418343d979ed Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:51:07 +0000 Subject: [PATCH 170/236] test(sandbox): drop stale cpu.stat assertion from jail command test The native jail command test no longer expects cpu.stat in the argv, since the command no longer passes it through. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-linux/src/sandbox/mod_tests.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/tinybox-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index bc04c711..f4e83ecb 100644 --- a/crates/tinybox-linux/src/sandbox/mod_tests.rs +++ b/crates/tinybox-linux/src/sandbox/mod_tests.rs @@ -473,7 +473,6 @@ fn native_jail_command_keeps_limits_and_target_environment_after_namespace_setup assert_eq!(argv.first().map(String::as_str), Some("/bin/bash")); assert!(argv.contains(&systemd_run.display().to_string())); assert!(systemd_run.is_absolute()); - assert!(has(&argv, "cpu.stat")); assert!(has(&argv, "--property=CPUQuota=0.1%")); assert!(has(&argv, "--property=MemoryMax=67108864")); assert!(has(&argv, "--property=MemorySwapMax=0")); From 67cccc98c05c117acceeb46475ee7f5481b1332e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:51:23 +0000 Subject: [PATCH 171/236] test(sandbox): match cpu event file env var by substring The native jail command test now checks for the CPU event file environment variable using a substring match instead of an exact argument comparison, since the variable is passed as part of a larger argument rather than as a standalone token. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-linux/src/sandbox/mod_tests.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index f4e83ecb..5cd50c00 100644 --- a/crates/tinybox-linux/src/sandbox/mod_tests.rs +++ b/crates/tinybox-linux/src/sandbox/mod_tests.rs @@ -482,7 +482,9 @@ fn native_jail_command_keeps_limits_and_target_environment_after_namespace_setup assert!(has(&argv, "--tmpfs")); assert!(has(&argv, "/run")); assert!(has(&argv, "/sys")); - assert!(has(&argv, "TINYBOX_INTERNAL_CPU_EVENT_FILE")); + assert!(argv + .iter() + .any(|part| part.contains("TINYBOX_INTERNAL_CPU_EVENT_FILE"))); assert!(has(&argv, "cpu.stat")); assert!(has(&argv, "--ro-bind")); assert!(has(&argv, grant.to_str().ok_or("grant path is not UTF-8")?)); From c17b61bb2f8968744422a415d9cc1427ac71c5c8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:51:27 +0000 Subject: [PATCH 172/236] test(sandbox): reformat cpu event file assertion Reformatted the assertion that checks for the internal CPU event file variable in the native jail command test so it matches rustfmt's line-breaking. No test behaviour changed. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-linux/src/sandbox/mod_tests.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/tinybox-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index 5cd50c00..be11b5ae 100644 --- a/crates/tinybox-linux/src/sandbox/mod_tests.rs +++ b/crates/tinybox-linux/src/sandbox/mod_tests.rs @@ -482,9 +482,10 @@ fn native_jail_command_keeps_limits_and_target_environment_after_namespace_setup assert!(has(&argv, "--tmpfs")); assert!(has(&argv, "/run")); assert!(has(&argv, "/sys")); - assert!(argv - .iter() - .any(|part| part.contains("TINYBOX_INTERNAL_CPU_EVENT_FILE"))); + assert!( + argv.iter() + .any(|part| part.contains("TINYBOX_INTERNAL_CPU_EVENT_FILE")) + ); assert!(has(&argv, "cpu.stat")); assert!(has(&argv, "--ro-bind")); assert!(has(&argv, grant.to_str().ok_or("grant path is not UTF-8")?)); From bc01054f18c3e5d9a211747831ac27fa07856597 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:51:35 +0000 Subject: [PATCH 173/236] test(sandbox): match cpu.stat as a substring in argv The assertion now checks whether any argv entry contains "cpu.stat" rather than requiring an exact match, so the test still passes when the path is embedded in a longer argument. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-linux/src/sandbox/mod_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index be11b5ae..63aa2e62 100644 --- a/crates/tinybox-linux/src/sandbox/mod_tests.rs +++ b/crates/tinybox-linux/src/sandbox/mod_tests.rs @@ -486,7 +486,7 @@ fn native_jail_command_keeps_limits_and_target_environment_after_namespace_setup argv.iter() .any(|part| part.contains("TINYBOX_INTERNAL_CPU_EVENT_FILE")) ); - assert!(has(&argv, "cpu.stat")); + assert!(argv.iter().any(|part| part.contains("cpu.stat"))); assert!(has(&argv, "--ro-bind")); assert!(has(&argv, grant.to_str().ok_or("grant path is not UTF-8")?)); assert!(has(&argv, "BASH_ENV")); From 570a97b8601b088ca6670753ea2a8f0bd8c66c32 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:51:58 +0000 Subject: [PATCH 174/236] refactor(limited): extract cpu event report helpers Moved the CPU event report setup and termination reporting in the jailed host into small helper functions so the execution path reads more clearly. Behaviour is unchanged. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-host/src/limited/jailed.rs | 35 ++++++++++++++++------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/crates/tinybox-host/src/limited/jailed.rs b/crates/tinybox-host/src/limited/jailed.rs index ac3a3f80..078d2344 100644 --- a/crates/tinybox-host/src/limited/jailed.rs +++ b/crates/tinybox-host/src/limited/jailed.rs @@ -105,6 +105,29 @@ fn parse_cpu_throttled_counter(stats: &str) -> Option { counter } +#[cfg(target_os = "linux")] +fn prepare_cpu_event_report( + command: &std::process::Command, + env: &mut BTreeMap, + observer_attached: bool, +) -> Option { + observer_attached + .then(|| CpuEventReport::prepare(command, env)) + .flatten() +} + +#[cfg(target_os = "linux")] +fn report_cpu_termination( + report: Option<&CpuEventReport>, + observer: Option<&Arc>, +) { + if report.is_some_and(CpuEventReport::throttling_observed) + && let Some(observer) = observer + { + observer.termination_cause(tinybox_core::TerminationCause::CpuQuota); + } +} + #[cfg(unix)] struct AsyncPipe(tokio::io::unix::AsyncFd); @@ -464,9 +487,7 @@ impl JailLocalHost { mut env: BTreeMap, ) -> Result { #[cfg(target_os = "linux")] - let cpu_event_report = observer - .as_ref() - .and_then(|_| CpuEventReport::prepare(&command, &mut env)); + let cpu_event_report = prepare_cpu_event_report(&command, &mut env, observer.is_some()); #[cfg(target_os = "linux")] let event_observer = observer.clone(); #[cfg(unix)] @@ -563,13 +584,7 @@ impl JailLocalHost { drop(cancel); let output = result??; #[cfg(target_os = "linux")] - if cpu_event_report - .as_ref() - .is_some_and(CpuEventReport::throttling_observed) - && let Some(observer) = event_observer - { - observer.termination_cause(tinybox_core::TerminationCause::CpuQuota); - } + report_cpu_termination(cpu_event_report.as_ref(), event_observer.as_ref()); Ok(output) } } From 89c01b4d246a8037a3118cf810118e67b88849b4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:52:20 +0000 Subject: [PATCH 175/236] refactor(limited): extract jail command setup into helper Move the process group, stdio, and environment configuration for the jailed command into a dedicated configure_jail_command function so the spawn path stays readable and the setup can be reused. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-host/src/limited/jailed.rs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/crates/tinybox-host/src/limited/jailed.rs b/crates/tinybox-host/src/limited/jailed.rs index 078d2344..727f7374 100644 --- a/crates/tinybox-host/src/limited/jailed.rs +++ b/crates/tinybox-host/src/limited/jailed.rs @@ -128,6 +128,18 @@ fn report_cpu_termination( } } +fn configure_jail_command(command: &mut std::process::Command, env: &BTreeMap) { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + command + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + command.env_clear().envs(env); +} + #[cfg(unix)] struct AsyncPipe(tokio::io::unix::AsyncFd); @@ -490,15 +502,7 @@ impl JailLocalHost { let cpu_event_report = prepare_cpu_event_report(&command, &mut env, observer.is_some()); #[cfg(target_os = "linux")] let event_observer = observer.clone(); - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - command.process_group(0); - } - command - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); - command.env_clear().envs(&env); + configure_jail_command(&mut command, &env); let jail = self.jail.clone(); let backend = self.backend.clone(); let state = self.state.clone(); From ceae1af682c15b3663134519b3dd8f7cd217a919 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:52:30 +0000 Subject: [PATCH 176/236] test(tinybox-module): drop unused Result from cpu quota observer test The test no longer returns a Result since it contains no fallible operations, so the signature and trailing Ok(()) were removed to match. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/tinybus_module/resources_stream_tests.rs | 3 +-- 1 file changed, 1 insertion(+), 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 032f1974..0bf76fc0 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -75,7 +75,7 @@ async fn live_admission_is_bounded_until_outputs_are_explicitly_released() -> Re } #[test] -fn cpu_quota_observer_cause_is_forwarded_before_process_exit() -> Result<()> { +fn cpu_quota_observer_cause_is_forwarded_before_process_exit() { use tinybox_core::ExecutionObserver; let process = ProcessRef { @@ -114,7 +114,6 @@ fn cpu_quota_observer_cause_is_forwarded_before_process_exit() -> Result<()> { tinybox_bus::SandboxEvent::Exited { status: Some(0) }, ] ); - Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] From e28acaec2840924620e115a07e3647566b87dd23 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:53:00 +0000 Subject: [PATCH 177/236] refactor(limited): extract runtime dir lookup into prepare_in Split the XDG_RUNTIME_DIR lookup out of CpuEventReport::prepare so the runtime directory can be injected, letting tests exercise the eligibility and report lifecycle logic against a temporary directory. Added a Linux test covering both the eligible scope and the unmetered case. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-host/src/limited/jailed.rs | 11 ++++- .../tinybox-host/src/limited/jailed_tests.rs | 41 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-host/src/limited/jailed.rs b/crates/tinybox-host/src/limited/jailed.rs index 727f7374..66515b34 100644 --- a/crates/tinybox-host/src/limited/jailed.rs +++ b/crates/tinybox-host/src/limited/jailed.rs @@ -26,6 +26,15 @@ impl CpuEventReport { fn prepare( command: &std::process::Command, env: &mut BTreeMap, + ) -> Option { + let runtime_dir = std::env::var_os("XDG_RUNTIME_DIR")?; + Self::prepare_in(command, env, std::path::Path::new(&runtime_dir)) + } + + fn prepare_in( + command: &std::process::Command, + env: &mut BTreeMap, + runtime_dir: &std::path::Path, ) -> Option { use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; @@ -47,8 +56,6 @@ impl CpuEventReport { if !has_scope || !has_cpu_limit || !masks_runtime || !masks_cgroup { return None; } - let runtime_dir = std::env::var_os("XDG_RUNTIME_DIR")?; - let runtime_dir = std::path::Path::new(&runtime_dir); let metadata = std::fs::metadata(runtime_dir).ok()?; let uid = std::fs::metadata("/proc/self").ok()?.uid(); if !metadata.is_dir() || metadata.uid() != uid || metadata.mode() & 0o077 != 0 { diff --git a/crates/tinybox-host/src/limited/jailed_tests.rs b/crates/tinybox-host/src/limited/jailed_tests.rs index 1b2a3c40..a3cfb543 100644 --- a/crates/tinybox-host/src/limited/jailed_tests.rs +++ b/crates/tinybox-host/src/limited/jailed_tests.rs @@ -16,6 +16,47 @@ fn cpu_throttling_counter_requires_one_well_formed_nr_throttled_value() { ); assert_eq!(parse_cpu_throttled_counter("usage_usec 12\n"), None); } + +#[cfg(target_os = "linux")] +#[test] +fn cpu_event_report_is_created_only_for_masked_cpu_limited_scopes() +-> Result<(), Box> { + let runtime = tempfile::tempdir()?; + let mut command = std::process::Command::new("systemd-run"); + command.args([ + "--scope", + "--unit=tinybox-native-fixture.scope", + "--property=CPUQuota=10%", + "--tmpfs", + "/run", + "--tmpfs", + "/sys", + "--clearenv", + ]); + let mut env = BTreeMap::new(); + let report = CpuEventReport::prepare_in(&command, &mut env, runtime.path()) + .ok_or("eligible scope did not get a report")?; + let report_path = env + .get(CPU_EVENT_FILE_ENV) + .ok_or("report path was not passed to the trusted wrapper")?; + assert_eq!(std::fs::read_to_string(report_path)?, "nr_throttled 0\n"); + drop(report); + assert!(!std::path::Path::new(report_path).exists()); + + let mut unmetered = std::process::Command::new("systemd-run"); + unmetered.args([ + "--scope", + "--unit=tinybox-native-fixture.scope", + "--tmpfs", + "/run", + "--tmpfs", + "/sys", + ]); + env.clear(); + assert!(CpuEventReport::prepare_in(&unmetered, &mut env, runtime.path()).is_none()); + assert!(env.is_empty()); + Ok(()) +} use std::result::Result; use std::sync::Arc; use tinybox_core::{ExecRequest, Host}; From 713f68eab1813080477f7f30285453093df08892 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:53:24 +0000 Subject: [PATCH 178/236] test(limited): add debug output to cpu event report test Added an eprintln in the jailed test to print the command arguments, runtime path, and runtime metadata, making it easier to diagnose why the eligible scope check fails. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-host/src/limited/jailed_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tinybox-host/src/limited/jailed_tests.rs b/crates/tinybox-host/src/limited/jailed_tests.rs index a3cfb543..2881d07c 100644 --- a/crates/tinybox-host/src/limited/jailed_tests.rs +++ b/crates/tinybox-host/src/limited/jailed_tests.rs @@ -33,6 +33,7 @@ fn cpu_event_report_is_created_only_for_masked_cpu_limited_scopes() "/sys", "--clearenv", ]); + eprintln!("args={:?} runtime={:?} metadata={:?}", command.get_args().collect::>(), runtime.path(), std::fs::metadata(runtime.path())?); let mut env = BTreeMap::new(); let report = CpuEventReport::prepare_in(&command, &mut env, runtime.path()) .ok_or("eligible scope did not get a report")?; From 6c0f04a05d7b514a1d6d79cc3ef4c721a41d382d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:53:36 +0000 Subject: [PATCH 179/236] test(limited): tighten permissions on temp dir in cpu event report test The test now sets the temporary runtime directory to mode 0700 before invoking systemd-run, and drops a leftover debug eprintln that printed the command arguments and directory metadata. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-host/src/limited/jailed_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-host/src/limited/jailed_tests.rs b/crates/tinybox-host/src/limited/jailed_tests.rs index 2881d07c..d7899c27 100644 --- a/crates/tinybox-host/src/limited/jailed_tests.rs +++ b/crates/tinybox-host/src/limited/jailed_tests.rs @@ -22,6 +22,8 @@ fn cpu_throttling_counter_requires_one_well_formed_nr_throttled_value() { fn cpu_event_report_is_created_only_for_masked_cpu_limited_scopes() -> Result<(), Box> { let runtime = tempfile::tempdir()?; + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(runtime.path(), std::fs::Permissions::from_mode(0o700))?; let mut command = std::process::Command::new("systemd-run"); command.args([ "--scope", @@ -33,7 +35,6 @@ fn cpu_event_report_is_created_only_for_masked_cpu_limited_scopes() "/sys", "--clearenv", ]); - eprintln!("args={:?} runtime={:?} metadata={:?}", command.get_args().collect::>(), runtime.path(), std::fs::metadata(runtime.path())?); let mut env = BTreeMap::new(); let report = CpuEventReport::prepare_in(&command, &mut env, runtime.path()) .ok_or("eligible scope did not get a report")?; From 533514c9c1a3a1bed15959c55c6e60eff1d3d191 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:53:46 +0000 Subject: [PATCH 180/236] test(limited): move permissions import above tempdir setup Reordered the import so the test's setup reads in the order it executes, with the permissions trait imported before the temporary runtime directory is created. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-host/src/limited/jailed_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-host/src/limited/jailed_tests.rs b/crates/tinybox-host/src/limited/jailed_tests.rs index d7899c27..3baa7702 100644 --- a/crates/tinybox-host/src/limited/jailed_tests.rs +++ b/crates/tinybox-host/src/limited/jailed_tests.rs @@ -21,8 +21,9 @@ fn cpu_throttling_counter_requires_one_well_formed_nr_throttled_value() { #[test] fn cpu_event_report_is_created_only_for_masked_cpu_limited_scopes() -> Result<(), Box> { - let runtime = tempfile::tempdir()?; use std::os::unix::fs::PermissionsExt; + + let runtime = tempfile::tempdir()?; std::fs::set_permissions(runtime.path(), std::fs::Permissions::from_mode(0o700))?; let mut command = std::process::Command::new("systemd-run"); command.args([ From 3696cbc162766ab8d6f089d4aa82db8941016ecb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:54:38 +0000 Subject: [PATCH 181/236] fix(limited): reject runtime dirs outside /run before preparing cpu event report Canonicalize the runtime directory and require it to live under /run/ before creating a report, so paths that escape the expected location are refused. The test now asserts that an unmasked runtime path yields no report and no environment handoff. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-host/src/limited/jailed.rs | 6 +++++- crates/tinybox-host/src/limited/jailed_tests.rs | 14 +++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/tinybox-host/src/limited/jailed.rs b/crates/tinybox-host/src/limited/jailed.rs index 66515b34..d612bdd3 100644 --- a/crates/tinybox-host/src/limited/jailed.rs +++ b/crates/tinybox-host/src/limited/jailed.rs @@ -38,6 +38,10 @@ impl CpuEventReport { ) -> Option { use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; + let runtime_dir = runtime_dir.canonicalize().ok()?; + if !runtime_dir.starts_with("/run/") { + return None; + } let args: Vec<_> = command.get_args().filter_map(|arg| arg.to_str()).collect(); let has_scope = args.iter().any(|arg| { arg.strip_prefix("--unit=") @@ -56,7 +60,7 @@ impl CpuEventReport { if !has_scope || !has_cpu_limit || !masks_runtime || !masks_cgroup { return None; } - let metadata = std::fs::metadata(runtime_dir).ok()?; + let metadata = std::fs::metadata(&runtime_dir).ok()?; let uid = std::fs::metadata("/proc/self").ok()?.uid(); if !metadata.is_dir() || metadata.uid() != uid || metadata.mode() & 0o077 != 0 { return None; diff --git a/crates/tinybox-host/src/limited/jailed_tests.rs b/crates/tinybox-host/src/limited/jailed_tests.rs index 3baa7702..c0430b9f 100644 --- a/crates/tinybox-host/src/limited/jailed_tests.rs +++ b/crates/tinybox-host/src/limited/jailed_tests.rs @@ -37,14 +37,11 @@ fn cpu_event_report_is_created_only_for_masked_cpu_limited_scopes() "--clearenv", ]); let mut env = BTreeMap::new(); - let report = CpuEventReport::prepare_in(&command, &mut env, runtime.path()) - .ok_or("eligible scope did not get a report")?; - let report_path = env - .get(CPU_EVENT_FILE_ENV) - .ok_or("report path was not passed to the trusted wrapper")?; - assert_eq!(std::fs::read_to_string(report_path)?, "nr_throttled 0\n"); - drop(report); - assert!(!std::path::Path::new(report_path).exists()); + assert!(CpuEventReport::prepare_in(&command, &mut env, runtime.path()).is_none()); + assert!( + env.is_empty(), + "unmasked runtime paths must not be handed off" + ); let mut unmetered = std::process::Command::new("systemd-run"); unmetered.args([ @@ -55,7 +52,6 @@ fn cpu_event_report_is_created_only_for_masked_cpu_limited_scopes() "--tmpfs", "/sys", ]); - env.clear(); assert!(CpuEventReport::prepare_in(&unmetered, &mut env, runtime.path()).is_none()); assert!(env.is_empty()); Ok(()) From 0868c5c0fb0c57ff604afa539992e3d150cf3b3d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:55:21 +0000 Subject: [PATCH 182/236] test(limited): cover cpu event report for masked run paths The jailed test now also exercises the positive case where the runtime directory lives under a masked /run path, asserting the report is created, handed to the wrapper, and cleaned up on drop. The custom-path rejection case is kept alongside it, and the positive case is skipped when the environment does not provide a suitable XDG_RUNTIME_DIR. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinybox-host/src/limited/jailed_tests.rs | 41 ++++++++++++++++--- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/crates/tinybox-host/src/limited/jailed_tests.rs b/crates/tinybox-host/src/limited/jailed_tests.rs index c0430b9f..d7afca84 100644 --- a/crates/tinybox-host/src/limited/jailed_tests.rs +++ b/crates/tinybox-host/src/limited/jailed_tests.rs @@ -19,12 +19,15 @@ fn cpu_throttling_counter_requires_one_well_formed_nr_throttled_value() { #[cfg(target_os = "linux")] #[test] -fn cpu_event_report_is_created_only_for_masked_cpu_limited_scopes() +fn cpu_event_report_rejects_custom_paths_and_accepts_masked_run_paths() -> Result<(), Box> { - use std::os::unix::fs::PermissionsExt; + use std::os::unix::fs::{MetadataExt, PermissionsExt}; - let runtime = tempfile::tempdir()?; - std::fs::set_permissions(runtime.path(), std::fs::Permissions::from_mode(0o700))?; + let custom_runtime = tempfile::tempdir()?; + std::fs::set_permissions( + custom_runtime.path(), + std::fs::Permissions::from_mode(0o700), + )?; let mut command = std::process::Command::new("systemd-run"); command.args([ "--scope", @@ -37,12 +40,38 @@ fn cpu_event_report_is_created_only_for_masked_cpu_limited_scopes() "--clearenv", ]); let mut env = BTreeMap::new(); - assert!(CpuEventReport::prepare_in(&command, &mut env, runtime.path()).is_none()); + assert!(CpuEventReport::prepare_in(&command, &mut env, custom_runtime.path()).is_none()); assert!( env.is_empty(), "unmasked runtime paths must not be handed off" ); + let Some(runtime_root) = std::env::var_os("XDG_RUNTIME_DIR").map(std::path::PathBuf::from) + else { + return Ok(()); + }; + let Ok(metadata) = std::fs::metadata(&runtime_root) else { + return Ok(()); + }; + if !runtime_root.starts_with("/run/") + || metadata.uid() != std::fs::metadata("/proc/self")?.uid() + || metadata.mode() & 0o077 != 0 + { + return Ok(()); + } + let runtime = tempfile::Builder::new() + .prefix("tinybox-test-") + .tempdir_in(runtime_root)?; + std::fs::set_permissions(runtime.path(), std::fs::Permissions::from_mode(0o700))?; + let report = CpuEventReport::prepare_in(&command, &mut env, runtime.path()) + .ok_or("masked /run runtime path did not get a report")?; + let report_path = env + .get(CPU_EVENT_FILE_ENV) + .ok_or("report path was not passed to the trusted wrapper")?; + assert_eq!(std::fs::read_to_string(report_path)?, "nr_throttled 0\n"); + drop(report); + assert!(!std::path::Path::new(report_path).exists()); + let mut unmetered = std::process::Command::new("systemd-run"); unmetered.args([ "--scope", @@ -52,7 +81,7 @@ fn cpu_event_report_is_created_only_for_masked_cpu_limited_scopes() "--tmpfs", "/sys", ]); - assert!(CpuEventReport::prepare_in(&unmetered, &mut env, runtime.path()).is_none()); + assert!(CpuEventReport::prepare_in(&unmetered, &mut env, custom_runtime.path()).is_none()); assert!(env.is_empty()); Ok(()) } From e76cf40aab1adaa644051f5dcf0ef4e7948ae40c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:55:31 +0000 Subject: [PATCH 183/236] test(limited): clear env before unmetered systemd-run check Reset the environment in the CPU event report test before spawning the unmetered systemd-run command, so leftover variables from the earlier masked run cannot influence the second half of the test. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-host/src/limited/jailed_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tinybox-host/src/limited/jailed_tests.rs b/crates/tinybox-host/src/limited/jailed_tests.rs index d7afca84..621d4376 100644 --- a/crates/tinybox-host/src/limited/jailed_tests.rs +++ b/crates/tinybox-host/src/limited/jailed_tests.rs @@ -71,6 +71,7 @@ fn cpu_event_report_rejects_custom_paths_and_accepts_masked_run_paths() assert_eq!(std::fs::read_to_string(report_path)?, "nr_throttled 0\n"); drop(report); assert!(!std::path::Path::new(report_path).exists()); + env.clear(); let mut unmetered = std::process::Command::new("systemd-run"); unmetered.args([ From 811cd801124d3d35d8e193e92dc926ce8b72f4ed Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:57:55 +0000 Subject: [PATCH 184/236] test(runtime): cover observers receiving a cpu quota termination cause Adds a test asserting that an observer can be handed a proven CPU quota termination cause, guarding the path that reports quota-driven termination to observers. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-core/src/runtime/output_tests.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/tinybox-core/src/runtime/output_tests.rs b/crates/tinybox-core/src/runtime/output_tests.rs index 6963b4aa..95153a45 100644 --- a/crates/tinybox-core/src/runtime/output_tests.rs +++ b/crates/tinybox-core/src/runtime/output_tests.rs @@ -59,3 +59,10 @@ fn a_default_observer_does_not_spuriously_request_cancellation() { let mut context = std::task::Context::from_waker(std::task::Waker::noop()); assert!(std::future::Future::poll(future.as_mut(), &mut context).is_pending()); } + +#[test] +fn observers_can_receive_a_proven_cpu_quota_cause() { + let observer = Observer; + observer.termination_cause(TerminationCause::CpuQuota); + assert_eq!(TerminationCause::CpuQuota, TerminationCause::CpuQuota); +} From 3880c2853ec9f090e1d3b457331390ee3105b590 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:59:17 +0000 Subject: [PATCH 185/236] fix(tinybox-core): silence unused parameter warning in termination_cause The default `termination_cause` implementation now passes the cause through `std::hint::black_box` so the parameter is considered used, avoiding an unused-variable warning without changing behaviour. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-core/src/runtime/output.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-core/src/runtime/output.rs b/crates/tinybox-core/src/runtime/output.rs index 22b13ef8..53b700a2 100644 --- a/crates/tinybox-core/src/runtime/output.rs +++ b/crates/tinybox-core/src/runtime/output.rs @@ -30,7 +30,9 @@ pub trait ExecutionObserver: std::fmt::Debug + Send + Sync + 'static { fn output(&self, stream: OutputStream, bytes: &[u8]) -> Result<()>; /// Record a provider-proven resource termination while execution is owned. - fn termination_cause(&self, _cause: TerminationCause) {} + fn termination_cause(&self, cause: TerminationCause) { + std::hint::black_box(cause); + } /// Resolve when the owning module requests execution cancellation. /// From 15edac95652ec0114a38fa98732e07b1ff7e5b7b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 15:59:47 +0000 Subject: [PATCH 186/236] chore(runtime): prevent inlining of termination_cause default Mark the default termination_cause implementation as inline(never) so it stays a distinct frame in profiles and backtraces, making it easier to attribute provider-proven terminations during execution. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-core/src/runtime/output.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tinybox-core/src/runtime/output.rs b/crates/tinybox-core/src/runtime/output.rs index 53b700a2..680ee52d 100644 --- a/crates/tinybox-core/src/runtime/output.rs +++ b/crates/tinybox-core/src/runtime/output.rs @@ -30,6 +30,7 @@ pub trait ExecutionObserver: std::fmt::Debug + Send + Sync + 'static { fn output(&self, stream: OutputStream, bytes: &[u8]) -> Result<()>; /// Record a provider-proven resource termination while execution is owned. + #[inline(never)] fn termination_cause(&self, cause: TerminationCause) { std::hint::black_box(cause); } From e7081be0af1094386deadb694daf53a2e717f400 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 16:00:06 +0000 Subject: [PATCH 187/236] test(runtime): coerce observer to trait object in cpu quota test The test now binds the observer as a `&dyn ExecutionObserver` before calling `termination_cause`, so the call goes through the trait object rather than the concrete type. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-core/src/runtime/output_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tinybox-core/src/runtime/output_tests.rs b/crates/tinybox-core/src/runtime/output_tests.rs index 95153a45..3d17caab 100644 --- a/crates/tinybox-core/src/runtime/output_tests.rs +++ b/crates/tinybox-core/src/runtime/output_tests.rs @@ -63,6 +63,7 @@ fn a_default_observer_does_not_spuriously_request_cancellation() { #[test] fn observers_can_receive_a_proven_cpu_quota_cause() { let observer = Observer; + let observer: &dyn ExecutionObserver = &observer; observer.termination_cause(TerminationCause::CpuQuota); assert_eq!(TerminationCause::CpuQuota, TerminationCause::CpuQuota); } From ce44d62a63fe95ec0bcc51425f642f28c4138634 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 16:01:09 +0000 Subject: [PATCH 188/236] test(public_api): cover observer termination causes through the public api Move the observer termination-cause check out of the internal runtime tests and into the public API suite, where it now exercises ExecutionObserver and TerminationCause as exported types. The new test also implements the observer trait directly, so the public trait surface is verified to be implementable outside the crate. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinybox-core/src/runtime/output_tests.rs | 8 ------- crates/tinybox-core/tests/public_api.rs | 24 ++++++++++++++++--- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/crates/tinybox-core/src/runtime/output_tests.rs b/crates/tinybox-core/src/runtime/output_tests.rs index 3d17caab..6963b4aa 100644 --- a/crates/tinybox-core/src/runtime/output_tests.rs +++ b/crates/tinybox-core/src/runtime/output_tests.rs @@ -59,11 +59,3 @@ fn a_default_observer_does_not_spuriously_request_cancellation() { let mut context = std::task::Context::from_waker(std::task::Waker::noop()); assert!(std::future::Future::poll(future.as_mut(), &mut context).is_pending()); } - -#[test] -fn observers_can_receive_a_proven_cpu_quota_cause() { - let observer = Observer; - let observer: &dyn ExecutionObserver = &observer; - observer.termination_cause(TerminationCause::CpuQuota); - assert_eq!(TerminationCause::CpuQuota, TerminationCause::CpuQuota); -} diff --git a/crates/tinybox-core/tests/public_api.rs b/crates/tinybox-core/tests/public_api.rs index a2b0ad5b..0518f0eb 100644 --- a/crates/tinybox-core/tests/public_api.rs +++ b/crates/tinybox-core/tests/public_api.rs @@ -4,11 +4,29 @@ //! the export surface regresses even when the internals still compile. use tinybox_core::{ - BoxSpec, BoxState, Capability, Error, ExecOutput, ExecRequest, HostRef, IsolationLevel, - Lifecycle, NetworkPolicy, Placement, Resources, Result, SandboxCapabilities, SandboxRef, - SnapshotId, SnapshotSupport, WorkspaceSource, + BoxSpec, BoxState, Capability, Error, ExecOutput, ExecRequest, ExecutionObserver, HostRef, + IsolationLevel, Lifecycle, NetworkPolicy, OutputStream, Placement, Resources, Result, + SandboxCapabilities, SandboxRef, SnapshotId, SnapshotSupport, TerminationCause, + WorkspaceSource, }; +#[derive(Debug)] +struct Observer; + +#[async_trait::async_trait] +impl ExecutionObserver for Observer { + fn output(&self, _: OutputStream, _: &[u8]) -> Result<()> { + Ok(()) + } +} + +#[test] +fn public_observers_accept_proven_cpu_quota_causes() { + let observer = Observer; + let observer: &dyn ExecutionObserver = &observer; + observer.termination_cause(TerminationCause::CpuQuota); +} + /// Build a placement, propagating identifier validation failures to the test. fn placement(host: &str, sandbox: &str) -> Result { Ok(Placement::new( From 0b557cc7fa9cf6b837679384c7854cd9a3ffd076 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 16:01:44 +0000 Subject: [PATCH 189/236] feat(core): return whether termination cause was recorded ExecutionObserver::termination_cause now returns a bool indicating whether the observer recorded the cause, defaulting to false. OutputJournal reports true for CPU quota causes so callers can tell when the signal was consumed. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-core/src/runtime/output.rs | 7 ++++--- crates/tinybox-core/tests/public_api.rs | 2 +- crates/tinybox-module/src/tinybus_module/output.rs | 5 ++++- .../src/tinybus_module/resources_stream_tests.rs | 2 +- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/tinybox-core/src/runtime/output.rs b/crates/tinybox-core/src/runtime/output.rs index 680ee52d..21da218d 100644 --- a/crates/tinybox-core/src/runtime/output.rs +++ b/crates/tinybox-core/src/runtime/output.rs @@ -30,9 +30,10 @@ pub trait ExecutionObserver: std::fmt::Debug + Send + Sync + 'static { fn output(&self, stream: OutputStream, bytes: &[u8]) -> Result<()>; /// Record a provider-proven resource termination while execution is owned. - #[inline(never)] - fn termination_cause(&self, cause: TerminationCause) { - std::hint::black_box(cause); + /// + /// Returns whether the observer recorded this cause. + fn termination_cause(&self, _cause: TerminationCause) -> bool { + false } /// Resolve when the owning module requests execution cancellation. diff --git a/crates/tinybox-core/tests/public_api.rs b/crates/tinybox-core/tests/public_api.rs index 0518f0eb..4425ff8f 100644 --- a/crates/tinybox-core/tests/public_api.rs +++ b/crates/tinybox-core/tests/public_api.rs @@ -24,7 +24,7 @@ impl ExecutionObserver for Observer { fn public_observers_accept_proven_cpu_quota_causes() { let observer = Observer; let observer: &dyn ExecutionObserver = &observer; - observer.termination_cause(TerminationCause::CpuQuota); + assert!(!observer.termination_cause(TerminationCause::CpuQuota)); } /// Build a placement, propagating identifier validation failures to the test. diff --git a/crates/tinybox-module/src/tinybus_module/output.rs b/crates/tinybox-module/src/tinybus_module/output.rs index 4ab59b5f..35e3d932 100644 --- a/crates/tinybox-module/src/tinybus_module/output.rs +++ b/crates/tinybox-module/src/tinybus_module/output.rs @@ -156,9 +156,12 @@ impl ExecutionObserver for OutputJournal { state.bytes = total; Ok(()) } - fn termination_cause(&self, cause: TerminationCause) { + fn termination_cause(&self, cause: TerminationCause) -> bool { if cause == TerminationCause::CpuQuota { self.cpu_quota_hit.store(true, Ordering::SeqCst); + true + } else { + false } } async fn cancelled(&self) { 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 0bf76fc0..30496919 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -83,7 +83,7 @@ fn cpu_quota_observer_cause_is_forwarded_before_process_exit() { process: ResourceId("process".into()), }; let journal = Arc::new(OutputJournal::default()); - journal.termination_cause(tinybox_core::TerminationCause::CpuQuota); + assert!(journal.termination_cause(tinybox_core::TerminationCause::CpuQuota)); let stream = StreamExecution { request: SpawnRequest { process: process.process.clone(), From 8e8b188c2beb921e19dbe5f54215255a7b7e80ba Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 16:02:32 +0000 Subject: [PATCH 190/236] refactor(core): make termination_cause a required observer method The ExecutionObserver trait now requires termination_cause instead of providing a default that returned false, so observers must explicitly handle proven termination causes rather than silently ignoring them. Callers no longer receive a boolean result, since the return value was only used to signal whether the cause was recorded. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-core/src/runtime/output.rs | 6 +----- crates/tinybox-core/tests/public_api.rs | 4 +++- crates/tinybox-module/src/tinybus_module/output.rs | 5 +---- .../src/tinybus_module/resources_stream_tests.rs | 2 +- 4 files changed, 6 insertions(+), 11 deletions(-) diff --git a/crates/tinybox-core/src/runtime/output.rs b/crates/tinybox-core/src/runtime/output.rs index 21da218d..b62c2a18 100644 --- a/crates/tinybox-core/src/runtime/output.rs +++ b/crates/tinybox-core/src/runtime/output.rs @@ -30,11 +30,7 @@ pub trait ExecutionObserver: std::fmt::Debug + Send + Sync + 'static { fn output(&self, stream: OutputStream, bytes: &[u8]) -> Result<()>; /// Record a provider-proven resource termination while execution is owned. - /// - /// Returns whether the observer recorded this cause. - fn termination_cause(&self, _cause: TerminationCause) -> bool { - false - } + fn termination_cause(&self, cause: TerminationCause); /// Resolve when the owning module requests execution cancellation. /// diff --git a/crates/tinybox-core/tests/public_api.rs b/crates/tinybox-core/tests/public_api.rs index 4425ff8f..16f31bd0 100644 --- a/crates/tinybox-core/tests/public_api.rs +++ b/crates/tinybox-core/tests/public_api.rs @@ -18,13 +18,15 @@ impl ExecutionObserver for Observer { fn output(&self, _: OutputStream, _: &[u8]) -> Result<()> { Ok(()) } + + fn termination_cause(&self, _: TerminationCause) {} } #[test] fn public_observers_accept_proven_cpu_quota_causes() { let observer = Observer; let observer: &dyn ExecutionObserver = &observer; - assert!(!observer.termination_cause(TerminationCause::CpuQuota)); + observer.termination_cause(TerminationCause::CpuQuota); } /// Build a placement, propagating identifier validation failures to the test. diff --git a/crates/tinybox-module/src/tinybus_module/output.rs b/crates/tinybox-module/src/tinybus_module/output.rs index 35e3d932..4ab59b5f 100644 --- a/crates/tinybox-module/src/tinybus_module/output.rs +++ b/crates/tinybox-module/src/tinybus_module/output.rs @@ -156,12 +156,9 @@ impl ExecutionObserver for OutputJournal { state.bytes = total; Ok(()) } - fn termination_cause(&self, cause: TerminationCause) -> bool { + fn termination_cause(&self, cause: TerminationCause) { if cause == TerminationCause::CpuQuota { self.cpu_quota_hit.store(true, Ordering::SeqCst); - true - } else { - false } } async fn cancelled(&self) { 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 30496919..0bf76fc0 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -83,7 +83,7 @@ fn cpu_quota_observer_cause_is_forwarded_before_process_exit() { process: ResourceId("process".into()), }; let journal = Arc::new(OutputJournal::default()); - assert!(journal.termination_cause(tinybox_core::TerminationCause::CpuQuota)); + journal.termination_cause(tinybox_core::TerminationCause::CpuQuota); let stream = StreamExecution { request: SpawnRequest { process: process.process.clone(), From 6dda06f6fa98fce4b5682750968059a903888fa1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 16:03:03 +0000 Subject: [PATCH 191/236] feat(core): add termination_cause to ExecutionObserver Add a termination_cause method to the ExecutionObserver trait so observers can be told why an execution ended. The Docker and SSH observers forward the cause to their inner observers, and test observers implement the new method as a no-op. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-core/src/passthrough/mod_tests.rs | 2 ++ crates/tinybox-core/src/runtime/mod_tests.rs | 4 ++++ crates/tinybox-core/src/runtime/output_tests.rs | 2 ++ crates/tinybox-docker/src/sandbox/mod.rs | 4 ++++ crates/tinybox-ssh/src/host/observed.rs | 4 ++++ 5 files changed, 16 insertions(+) diff --git a/crates/tinybox-core/src/passthrough/mod_tests.rs b/crates/tinybox-core/src/passthrough/mod_tests.rs index a6af066c..bffd8d90 100644 --- a/crates/tinybox-core/src/passthrough/mod_tests.rs +++ b/crates/tinybox-core/src/passthrough/mod_tests.rs @@ -482,6 +482,8 @@ impl crate::ExecutionObserver for RecordingObserver { .extend_from_slice(bytes); Ok(()) } + + fn termination_cause(&self, _: crate::TerminationCause) {} } #[tokio::test] async fn observed_execution_resolves_workspace_environment_and_stdin_before_dispatch() -> Result<()> diff --git a/crates/tinybox-core/src/runtime/mod_tests.rs b/crates/tinybox-core/src/runtime/mod_tests.rs index 4c58ddd1..b717954a 100644 --- a/crates/tinybox-core/src/runtime/mod_tests.rs +++ b/crates/tinybox-core/src/runtime/mod_tests.rs @@ -44,6 +44,8 @@ async fn a_sandbox_without_observed_execution_refuses_without_collected_fallback fn output(&self, _stream: super::OutputStream, _bytes: &[u8]) -> Result<()> { Ok(()) } + + fn termination_cause(&self, _: super::TerminationCause) {} } let sandbox = FakeSandbox::new(CONTAINER); @@ -598,6 +600,8 @@ impl super::ExecutionObserver for EmptyObserver { fn output(&self, _: super::OutputStream, _: &[u8]) -> Result<()> { Ok(()) } + + fn termination_cause(&self, _: super::TerminationCause) {} } #[tokio::test] async fn a_sandbox_without_live_output_refuses_without_executing_its_collected_path() -> Result<()> diff --git a/crates/tinybox-core/src/runtime/output_tests.rs b/crates/tinybox-core/src/runtime/output_tests.rs index 6963b4aa..a0634fd2 100644 --- a/crates/tinybox-core/src/runtime/output_tests.rs +++ b/crates/tinybox-core/src/runtime/output_tests.rs @@ -22,6 +22,8 @@ impl ExecutionObserver for Observer { fn output(&self, _: OutputStream, _: &[u8]) -> Result<()> { Ok(()) } + + fn termination_cause(&self, _: TerminationCause) {} } #[tokio::test] diff --git a/crates/tinybox-docker/src/sandbox/mod.rs b/crates/tinybox-docker/src/sandbox/mod.rs index 7e9f04da..956a8125 100644 --- a/crates/tinybox-docker/src/sandbox/mod.rs +++ b/crates/tinybox-docker/src/sandbox/mod.rs @@ -327,6 +327,10 @@ impl ExecutionObserver for DockerExecutionObserver { self.inner.output(stream, bytes) } + fn termination_cause(&self, cause: tinybox_core::TerminationCause) { + self.inner.termination_cause(cause); + } + async fn cancelled(&self) { self.inner.cancelled().await; match cleanup_remote(self.host.clone(), &self.namespace, &self.handle).await { diff --git a/crates/tinybox-ssh/src/host/observed.rs b/crates/tinybox-ssh/src/host/observed.rs index a1debd80..3e8bf1a7 100644 --- a/crates/tinybox-ssh/src/host/observed.rs +++ b/crates/tinybox-ssh/src/host/observed.rs @@ -411,6 +411,10 @@ impl ExecutionObserver for Bridge { Ok(()) } + fn termination_cause(&self, cause: tinybox_core::TerminationCause) { + self.observer.termination_cause(cause); + } + async fn cancelled(&self) { let stopped = self.stop.notified(); tokio::pin!(stopped); From ce097afcd9a7c3fb93f89f12c06620ca05232cac Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 16:03:28 +0000 Subject: [PATCH 192/236] test: add termination_cause stubs to observer test fixtures Implement the new ExecutionObserver::termination_cause method on the mock and test observers across the docker, host, and ssh crates so the fixtures continue to satisfy the trait after the method was added. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-docker/src/sandbox/mod_tests.rs | 2 ++ crates/tinybox-host/src/limited/observed_tests.rs | 5 +++++ crates/tinybox-ssh/src/host/observed_tests.rs | 7 +++++++ 3 files changed, 14 insertions(+) diff --git a/crates/tinybox-docker/src/sandbox/mod_tests.rs b/crates/tinybox-docker/src/sandbox/mod_tests.rs index 6abd9163..f29dad96 100644 --- a/crates/tinybox-docker/src/sandbox/mod_tests.rs +++ b/crates/tinybox-docker/src/sandbox/mod_tests.rs @@ -1035,6 +1035,8 @@ impl tinybox_core::ExecutionObserver for CapturingObserver { Ok(()) } + fn termination_cause(&self, _: tinybox_core::TerminationCause) {} + async fn cancelled(&self) { if !self.cancel { std::future::pending::<()>().await; diff --git a/crates/tinybox-host/src/limited/observed_tests.rs b/crates/tinybox-host/src/limited/observed_tests.rs index dd0851b8..166c7a48 100644 --- a/crates/tinybox-host/src/limited/observed_tests.rs +++ b/crates/tinybox-host/src/limited/observed_tests.rs @@ -42,6 +42,7 @@ impl ExecutionObserver for Observer { } Ok(()) } + fn termination_cause(&self, _: tinybox_core::TerminationCause) {} async fn cancelled(&self) { let cancelled = self.notify.notified(); tokio::pin!(cancelled); @@ -129,6 +130,8 @@ impl ExecutionObserver for RefusingObserver { &std::io::Error::other("receiver closed"), )) } + + fn termination_cause(&self, _: tinybox_core::TerminationCause) {} } #[tokio::test] @@ -196,6 +199,8 @@ impl ExecutionObserver for PanickingObserver { } panic!("fixture receiver panicked"); } + + fn termination_cause(&self, _: tinybox_core::TerminationCause) {} } impl Drop for PanickingObserver { fn drop(&mut self) { diff --git a/crates/tinybox-ssh/src/host/observed_tests.rs b/crates/tinybox-ssh/src/host/observed_tests.rs index abb020d0..c25c00c0 100644 --- a/crates/tinybox-ssh/src/host/observed_tests.rs +++ b/crates/tinybox-ssh/src/host/observed_tests.rs @@ -155,6 +155,7 @@ impl Host for MockHost { self.inner_cleanup_pending.store(false, Ordering::SeqCst); Ok(()) } + fn termination_cause(&self, _: tinybox_core::TerminationCause) {} } #[derive(Debug, Default)] @@ -192,6 +193,8 @@ impl ExecutionObserver for CancelObserver { Ok(()) } + fn termination_cause(&self, _: tinybox_core::TerminationCause) {} + async fn cancelled(&self) { let notified = self.notify.notified(); tokio::pin!(notified); @@ -455,6 +458,8 @@ impl ExecutionObserver for RefusingObserver { &std::io::Error::other("receiver closed"), )) } + + fn termination_cause(&self, _: tinybox_core::TerminationCause) {} } #[tokio::test] @@ -500,6 +505,8 @@ impl ExecutionObserver for BlockingObserver { } Ok(()) } + + fn termination_cause(&self, _: tinybox_core::TerminationCause) {} } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] From 6c87eede7ac322dd775d30ff7527c30135d0df8c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 16:05:10 +0000 Subject: [PATCH 193/236] refactor(core): give execution observers a default termination_cause The `ExecutionObserver::termination_cause` method now has an empty default body, so implementors that don't care about termination causes no longer need to write a stub. All the redundant stubs across the test fixtures were removed, and a test was added to confirm the default implementation accepts a proven CPU quota cause. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-core/src/passthrough/mod_tests.rs | 2 -- crates/tinybox-core/src/runtime/mod_tests.rs | 4 ---- crates/tinybox-core/src/runtime/output.rs | 14 +++++++++++++- crates/tinybox-core/src/runtime/output_tests.rs | 8 ++++++-- crates/tinybox-core/tests/public_api.rs | 2 -- crates/tinybox-docker/src/sandbox/mod_tests.rs | 2 -- crates/tinybox-host/src/limited/observed_tests.rs | 5 ----- crates/tinybox-ssh/src/host/observed_tests.rs | 7 ------- 8 files changed, 19 insertions(+), 25 deletions(-) diff --git a/crates/tinybox-core/src/passthrough/mod_tests.rs b/crates/tinybox-core/src/passthrough/mod_tests.rs index bffd8d90..a6af066c 100644 --- a/crates/tinybox-core/src/passthrough/mod_tests.rs +++ b/crates/tinybox-core/src/passthrough/mod_tests.rs @@ -482,8 +482,6 @@ impl crate::ExecutionObserver for RecordingObserver { .extend_from_slice(bytes); Ok(()) } - - fn termination_cause(&self, _: crate::TerminationCause) {} } #[tokio::test] async fn observed_execution_resolves_workspace_environment_and_stdin_before_dispatch() -> Result<()> diff --git a/crates/tinybox-core/src/runtime/mod_tests.rs b/crates/tinybox-core/src/runtime/mod_tests.rs index b717954a..4c58ddd1 100644 --- a/crates/tinybox-core/src/runtime/mod_tests.rs +++ b/crates/tinybox-core/src/runtime/mod_tests.rs @@ -44,8 +44,6 @@ async fn a_sandbox_without_observed_execution_refuses_without_collected_fallback fn output(&self, _stream: super::OutputStream, _bytes: &[u8]) -> Result<()> { Ok(()) } - - fn termination_cause(&self, _: super::TerminationCause) {} } let sandbox = FakeSandbox::new(CONTAINER); @@ -600,8 +598,6 @@ impl super::ExecutionObserver for EmptyObserver { fn output(&self, _: super::OutputStream, _: &[u8]) -> Result<()> { Ok(()) } - - fn termination_cause(&self, _: super::TerminationCause) {} } #[tokio::test] async fn a_sandbox_without_live_output_refuses_without_executing_its_collected_path() -> Result<()> diff --git a/crates/tinybox-core/src/runtime/output.rs b/crates/tinybox-core/src/runtime/output.rs index b62c2a18..3acdb910 100644 --- a/crates/tinybox-core/src/runtime/output.rs +++ b/crates/tinybox-core/src/runtime/output.rs @@ -30,7 +30,7 @@ pub trait ExecutionObserver: std::fmt::Debug + Send + Sync + 'static { fn output(&self, stream: OutputStream, bytes: &[u8]) -> Result<()>; /// Record a provider-proven resource termination while execution is owned. - fn termination_cause(&self, cause: TerminationCause); + fn termination_cause(&self, _cause: TerminationCause) {} /// Resolve when the owning module requests execution cancellation. /// @@ -42,6 +42,18 @@ pub trait ExecutionObserver: std::fmt::Debug + Send + Sync + 'static { } } +#[cfg(test)] +#[derive(Debug)] +struct DefaultTerminationObserver; + +#[cfg(test)] +#[async_trait] +impl ExecutionObserver for DefaultTerminationObserver { + fn output(&self, _: OutputStream, _: &[u8]) -> Result<()> { + Ok(()) + } +} + #[cfg(test)] #[path = "output_tests.rs"] mod tests; diff --git a/crates/tinybox-core/src/runtime/output_tests.rs b/crates/tinybox-core/src/runtime/output_tests.rs index a0634fd2..2a363ff4 100644 --- a/crates/tinybox-core/src/runtime/output_tests.rs +++ b/crates/tinybox-core/src/runtime/output_tests.rs @@ -22,8 +22,6 @@ impl ExecutionObserver for Observer { fn output(&self, _: OutputStream, _: &[u8]) -> Result<()> { Ok(()) } - - fn termination_cause(&self, _: TerminationCause) {} } #[tokio::test] @@ -61,3 +59,9 @@ fn a_default_observer_does_not_spuriously_request_cancellation() { let mut context = std::task::Context::from_waker(std::task::Waker::noop()); assert!(std::future::Future::poll(future.as_mut(), &mut context).is_pending()); } + +#[test] +fn default_termination_observers_accept_proven_cpu_quota_causes() { + let observer = DefaultTerminationObserver; + observer.termination_cause(TerminationCause::CpuQuota); +} diff --git a/crates/tinybox-core/tests/public_api.rs b/crates/tinybox-core/tests/public_api.rs index 16f31bd0..0518f0eb 100644 --- a/crates/tinybox-core/tests/public_api.rs +++ b/crates/tinybox-core/tests/public_api.rs @@ -18,8 +18,6 @@ impl ExecutionObserver for Observer { fn output(&self, _: OutputStream, _: &[u8]) -> Result<()> { Ok(()) } - - fn termination_cause(&self, _: TerminationCause) {} } #[test] diff --git a/crates/tinybox-docker/src/sandbox/mod_tests.rs b/crates/tinybox-docker/src/sandbox/mod_tests.rs index f29dad96..6abd9163 100644 --- a/crates/tinybox-docker/src/sandbox/mod_tests.rs +++ b/crates/tinybox-docker/src/sandbox/mod_tests.rs @@ -1035,8 +1035,6 @@ impl tinybox_core::ExecutionObserver for CapturingObserver { Ok(()) } - fn termination_cause(&self, _: tinybox_core::TerminationCause) {} - async fn cancelled(&self) { if !self.cancel { std::future::pending::<()>().await; diff --git a/crates/tinybox-host/src/limited/observed_tests.rs b/crates/tinybox-host/src/limited/observed_tests.rs index 166c7a48..dd0851b8 100644 --- a/crates/tinybox-host/src/limited/observed_tests.rs +++ b/crates/tinybox-host/src/limited/observed_tests.rs @@ -42,7 +42,6 @@ impl ExecutionObserver for Observer { } Ok(()) } - fn termination_cause(&self, _: tinybox_core::TerminationCause) {} async fn cancelled(&self) { let cancelled = self.notify.notified(); tokio::pin!(cancelled); @@ -130,8 +129,6 @@ impl ExecutionObserver for RefusingObserver { &std::io::Error::other("receiver closed"), )) } - - fn termination_cause(&self, _: tinybox_core::TerminationCause) {} } #[tokio::test] @@ -199,8 +196,6 @@ impl ExecutionObserver for PanickingObserver { } panic!("fixture receiver panicked"); } - - fn termination_cause(&self, _: tinybox_core::TerminationCause) {} } impl Drop for PanickingObserver { fn drop(&mut self) { diff --git a/crates/tinybox-ssh/src/host/observed_tests.rs b/crates/tinybox-ssh/src/host/observed_tests.rs index c25c00c0..abb020d0 100644 --- a/crates/tinybox-ssh/src/host/observed_tests.rs +++ b/crates/tinybox-ssh/src/host/observed_tests.rs @@ -155,7 +155,6 @@ impl Host for MockHost { self.inner_cleanup_pending.store(false, Ordering::SeqCst); Ok(()) } - fn termination_cause(&self, _: tinybox_core::TerminationCause) {} } #[derive(Debug, Default)] @@ -193,8 +192,6 @@ impl ExecutionObserver for CancelObserver { Ok(()) } - fn termination_cause(&self, _: tinybox_core::TerminationCause) {} - async fn cancelled(&self) { let notified = self.notify.notified(); tokio::pin!(notified); @@ -458,8 +455,6 @@ impl ExecutionObserver for RefusingObserver { &std::io::Error::other("receiver closed"), )) } - - fn termination_cause(&self, _: tinybox_core::TerminationCause) {} } #[tokio::test] @@ -505,8 +500,6 @@ impl ExecutionObserver for BlockingObserver { } Ok(()) } - - fn termination_cause(&self, _: tinybox_core::TerminationCause) {} } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] From 7c4d3943541e7a798da23b8d55e17baa4347da29 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 16:06:19 +0000 Subject: [PATCH 194/236] refactor(runtime): move ExecutionObserver into types module Relocated the ExecutionObserver trait from the output module to the types module so it sits alongside the other runtime type definitions, and updated the re-exports accordingly. The trait's behaviour is unchanged. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-core/src/runtime/mod.rs | 4 +- crates/tinybox-core/src/runtime/output.rs | 47 +---------------------- crates/tinybox-core/src/runtime/types.rs | 29 ++++++++++++++ 3 files changed, 32 insertions(+), 48 deletions(-) diff --git a/crates/tinybox-core/src/runtime/mod.rs b/crates/tinybox-core/src/runtime/mod.rs index 6905d7e3..52b3ad84 100644 --- a/crates/tinybox-core/src/runtime/mod.rs +++ b/crates/tinybox-core/src/runtime/mod.rs @@ -30,7 +30,7 @@ use crate::identity::{BoxId, ProcessId, SnapshotId}; use crate::spec::BoxSpec; mod output; -pub use output::{ExecutionObserver, OutputStream, TerminationCause}; +pub use output::{OutputStream, TerminationCause}; mod forward; #[cfg(test)] @@ -39,7 +39,7 @@ mod forward_test; mod types; pub use forward::{Forward, ForwardGuard}; -pub use types::{BoxInfo, BoxState, ExecOutput, ExecRequest}; +pub use types::{BoxInfo, BoxState, ExecOutput, ExecRequest, ExecutionObserver}; /// Where a host executes commands relative to the tinybox process. /// diff --git a/crates/tinybox-core/src/runtime/output.rs b/crates/tinybox-core/src/runtime/output.rs index 3acdb910..39224c96 100644 --- a/crates/tinybox-core/src/runtime/output.rs +++ b/crates/tinybox-core/src/runtime/output.rs @@ -1,7 +1,4 @@ -//! Provider-owned batch observation and cooperative execution cancellation. - -use crate::Result; -use async_trait::async_trait; +//! Output streams and provider-proven resource termination causes. pub use tinybox_bus::OutputStream; @@ -15,45 +12,3 @@ pub enum TerminationCause { CpuQuota, } -/// Receives bounded output batches inside the provider's execution runtime. -/// -/// Module adapters implement this with a bounded journal and a cancellation -/// signal. This trait does not grant permission, run host callbacks, or expose -/// native handles. Returning an output error must terminate collection and -/// trigger the provider's normal native cleanup. -#[async_trait] -pub trait ExecutionObserver: std::fmt::Debug + Send + Sync + 'static { - /// Observe original bytes from one pipe in arrival order. - /// - /// # Errors - /// Returns an error if the bounded journal cannot accept this batch. - fn output(&self, stream: OutputStream, bytes: &[u8]) -> Result<()>; - - /// Record a provider-proven resource termination while execution is owned. - fn termination_cause(&self, _cause: TerminationCause) {} - - /// Resolve when the owning module requests execution cancellation. - /// - /// The provider acknowledges cancellation only after killing and joining - /// native execution. Dropping the calling future retains the provider's - /// existing supervisor/cleanup ownership guarantees. - async fn cancelled(&self) { - std::future::pending::<()>().await; - } -} - -#[cfg(test)] -#[derive(Debug)] -struct DefaultTerminationObserver; - -#[cfg(test)] -#[async_trait] -impl ExecutionObserver for DefaultTerminationObserver { - fn output(&self, _: OutputStream, _: &[u8]) -> Result<()> { - Ok(()) - } -} - -#[cfg(test)] -#[path = "output_tests.rs"] -mod tests; diff --git a/crates/tinybox-core/src/runtime/types.rs b/crates/tinybox-core/src/runtime/types.rs index 319a7187..a2d5196b 100644 --- a/crates/tinybox-core/src/runtime/types.rs +++ b/crates/tinybox-core/src/runtime/types.rs @@ -5,11 +5,40 @@ use std::fmt; use std::path::PathBuf; use std::time::SystemTime; +use async_trait::async_trait; use serde::{Deserialize, Serialize}; +use super::{OutputStream, TerminationCause}; use crate::identity::BoxId; use crate::spec::{BoxSpec, Lifecycle}; +/// Receives bounded output batches inside the provider's execution runtime. +/// +/// Module adapters implement this with a bounded journal and a cancellation +/// signal. This trait does not grant permission, run host callbacks, or expose +/// native handles. Returning an output error must terminate collection and +/// trigger the provider's normal native cleanup. +#[async_trait] +pub trait ExecutionObserver: std::fmt::Debug + Send + Sync + 'static { + /// Observe original bytes from one pipe in arrival order. + /// + /// # Errors + /// Returns an error if the bounded journal cannot accept this batch. + fn output(&self, stream: OutputStream, bytes: &[u8]) -> crate::Result<()>; + + /// Record a provider-proven resource termination while execution is owned. + fn termination_cause(&self, _cause: TerminationCause) {} + + /// Resolve when the owning module requests execution cancellation. + /// + /// The provider acknowledges cancellation only after killing and joining + /// native execution. Dropping the calling future retains the provider's + /// existing supervisor/cleanup ownership guarantees. + async fn cancelled(&self) { + std::future::pending::<()>().await; + } +} + /// Where a box is in its life. /// /// Sandboxes drive boxes through these states; core uses them to reject From 0c74bd1f961372dd47b510d37366b104f774efda Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 16:06:37 +0000 Subject: [PATCH 195/236] test(runtime): cover default observer termination causes Wire the existing output tests into the runtime types module and add a case asserting that the default observer accepts a provider-reported termination cause. This guards the observer contract against regressions. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-core/src/runtime/output.rs | 1 - crates/tinybox-core/src/runtime/output_tests.rs | 7 +++++++ crates/tinybox-core/src/runtime/types.rs | 4 ++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-core/src/runtime/output.rs b/crates/tinybox-core/src/runtime/output.rs index 39224c96..5e6890bd 100644 --- a/crates/tinybox-core/src/runtime/output.rs +++ b/crates/tinybox-core/src/runtime/output.rs @@ -11,4 +11,3 @@ pub enum TerminationCause { /// The Linux cgroup v2 CPU controller throttled work under its quota. CpuQuota, } - diff --git a/crates/tinybox-core/src/runtime/output_tests.rs b/crates/tinybox-core/src/runtime/output_tests.rs index 2a363ff4..a1851ad5 100644 --- a/crates/tinybox-core/src/runtime/output_tests.rs +++ b/crates/tinybox-core/src/runtime/output_tests.rs @@ -60,6 +60,13 @@ fn a_default_observer_does_not_spuriously_request_cancellation() { assert!(std::future::Future::poll(future.as_mut(), &mut context).is_pending()); } +#[test] +fn a_default_observer_accepts_provider_termination_causes() { + let observer = Observer; + let observer: &dyn ExecutionObserver = &observer; + observer.termination_cause(TerminationCause::CpuQuota); +} + #[test] fn default_termination_observers_accept_proven_cpu_quota_causes() { let observer = DefaultTerminationObserver; diff --git a/crates/tinybox-core/src/runtime/types.rs b/crates/tinybox-core/src/runtime/types.rs index a2d5196b..9198ed06 100644 --- a/crates/tinybox-core/src/runtime/types.rs +++ b/crates/tinybox-core/src/runtime/types.rs @@ -275,3 +275,7 @@ impl ExecOutput { String::from_utf8_lossy(&self.stderr) } } + +#[cfg(test)] +#[path = "output_tests.rs"] +mod tests; From 7a6d4a98ab905574f7ef41439d0b27a84dee13b9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 16:06:53 +0000 Subject: [PATCH 196/236] test(runtime): remove redundant cpu quota observer test The test duplicated the coverage already provided by the default observer test above it, so it was dropped to keep the suite free of redundant cases. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-core/src/runtime/output_tests.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/crates/tinybox-core/src/runtime/output_tests.rs b/crates/tinybox-core/src/runtime/output_tests.rs index a1851ad5..55c993fd 100644 --- a/crates/tinybox-core/src/runtime/output_tests.rs +++ b/crates/tinybox-core/src/runtime/output_tests.rs @@ -66,9 +66,3 @@ fn a_default_observer_accepts_provider_termination_causes() { let observer: &dyn ExecutionObserver = &observer; observer.termination_cause(TerminationCause::CpuQuota); } - -#[test] -fn default_termination_observers_accept_proven_cpu_quota_causes() { - let observer = DefaultTerminationObserver; - observer.termination_cause(TerminationCause::CpuQuota); -} From 224cd1032fa7b0b63692621d5c984c16e97282e8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 16:07:27 +0000 Subject: [PATCH 197/236] test(runtime): update observer test for renamed default observer The test now constructs DefaultTerminationObserver instead of the removed Observer unit struct and asserts that stdout output is accepted, covering the default observer's output path alongside termination causes. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-core/src/runtime/output_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-core/src/runtime/output_tests.rs b/crates/tinybox-core/src/runtime/output_tests.rs index 55c993fd..fd3c083a 100644 --- a/crates/tinybox-core/src/runtime/output_tests.rs +++ b/crates/tinybox-core/src/runtime/output_tests.rs @@ -62,7 +62,8 @@ fn a_default_observer_does_not_spuriously_request_cancellation() { #[test] fn a_default_observer_accepts_provider_termination_causes() { - let observer = Observer; + let observer = DefaultTerminationObserver; let observer: &dyn ExecutionObserver = &observer; + assert!(observer.output(OutputStream::Stdout, b"fixture").is_ok()); observer.termination_cause(TerminationCause::CpuQuota); } From 75464aeeea079916f39cc313a0c50be72ca6f072 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 16:08:03 +0000 Subject: [PATCH 198/236] refactor(runtime): move execution observer into output module Relocated the ExecutionObserver trait from the types module into the output module so it sits alongside the output stream and termination cause types it depends on. The public re-export is unchanged, so callers see no behavioural difference. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-core/src/runtime/mod.rs | 4 +- crates/tinybox-core/src/runtime/output.rs | 46 +++++++++++++++++++++++ crates/tinybox-core/src/runtime/types.rs | 33 ---------------- 3 files changed, 48 insertions(+), 35 deletions(-) diff --git a/crates/tinybox-core/src/runtime/mod.rs b/crates/tinybox-core/src/runtime/mod.rs index 52b3ad84..6905d7e3 100644 --- a/crates/tinybox-core/src/runtime/mod.rs +++ b/crates/tinybox-core/src/runtime/mod.rs @@ -30,7 +30,7 @@ use crate::identity::{BoxId, ProcessId, SnapshotId}; use crate::spec::BoxSpec; mod output; -pub use output::{OutputStream, TerminationCause}; +pub use output::{ExecutionObserver, OutputStream, TerminationCause}; mod forward; #[cfg(test)] @@ -39,7 +39,7 @@ mod forward_test; mod types; pub use forward::{Forward, ForwardGuard}; -pub use types::{BoxInfo, BoxState, ExecOutput, ExecRequest, ExecutionObserver}; +pub use types::{BoxInfo, BoxState, ExecOutput, ExecRequest}; /// Where a host executes commands relative to the tinybox process. /// diff --git a/crates/tinybox-core/src/runtime/output.rs b/crates/tinybox-core/src/runtime/output.rs index 5e6890bd..33e079c1 100644 --- a/crates/tinybox-core/src/runtime/output.rs +++ b/crates/tinybox-core/src/runtime/output.rs @@ -1,5 +1,8 @@ //! Output streams and provider-proven resource termination causes. +use crate::Result; +use async_trait::async_trait; + pub use tinybox_bus::OutputStream; /// A resource-limit termination proven by provider-owned runtime evidence. @@ -11,3 +14,46 @@ pub enum TerminationCause { /// The Linux cgroup v2 CPU controller throttled work under its quota. CpuQuota, } + +/// Receives bounded output batches inside the provider's execution runtime. +/// +/// Module adapters implement this with a bounded journal and a cancellation +/// signal. This trait does not grant permission, run host callbacks, or expose +/// native handles. Returning an output error must terminate collection and +/// trigger the provider's normal native cleanup. +#[async_trait] +pub trait ExecutionObserver: std::fmt::Debug + Send + Sync + 'static { + /// Observe original bytes from one pipe in arrival order. + /// + /// # Errors + /// Returns an error if the bounded journal cannot accept this batch. + fn output(&self, stream: OutputStream, bytes: &[u8]) -> Result<()>; + + /// Record a provider-proven resource termination while execution is owned. + fn termination_cause(&self, _cause: TerminationCause) {} + + /// Resolve when the owning module requests execution cancellation. + /// + /// The provider acknowledges cancellation only after killing and joining + /// native execution. Dropping the calling future retains the provider's + /// existing supervisor/cleanup ownership guarantees. + async fn cancelled(&self) { + std::future::pending::<()>().await; + } +} + +#[cfg(test)] +#[derive(Debug)] +struct DefaultTerminationObserver; + +#[cfg(test)] +#[async_trait] +impl ExecutionObserver for DefaultTerminationObserver { + fn output(&self, _: OutputStream, _: &[u8]) -> Result<()> { + Ok(()) + } +} + +#[cfg(test)] +#[path = "output_tests.rs"] +mod tests; diff --git a/crates/tinybox-core/src/runtime/types.rs b/crates/tinybox-core/src/runtime/types.rs index 9198ed06..319a7187 100644 --- a/crates/tinybox-core/src/runtime/types.rs +++ b/crates/tinybox-core/src/runtime/types.rs @@ -5,40 +5,11 @@ use std::fmt; use std::path::PathBuf; use std::time::SystemTime; -use async_trait::async_trait; use serde::{Deserialize, Serialize}; -use super::{OutputStream, TerminationCause}; use crate::identity::BoxId; use crate::spec::{BoxSpec, Lifecycle}; -/// Receives bounded output batches inside the provider's execution runtime. -/// -/// Module adapters implement this with a bounded journal and a cancellation -/// signal. This trait does not grant permission, run host callbacks, or expose -/// native handles. Returning an output error must terminate collection and -/// trigger the provider's normal native cleanup. -#[async_trait] -pub trait ExecutionObserver: std::fmt::Debug + Send + Sync + 'static { - /// Observe original bytes from one pipe in arrival order. - /// - /// # Errors - /// Returns an error if the bounded journal cannot accept this batch. - fn output(&self, stream: OutputStream, bytes: &[u8]) -> crate::Result<()>; - - /// Record a provider-proven resource termination while execution is owned. - fn termination_cause(&self, _cause: TerminationCause) {} - - /// Resolve when the owning module requests execution cancellation. - /// - /// The provider acknowledges cancellation only after killing and joining - /// native execution. Dropping the calling future retains the provider's - /// existing supervisor/cleanup ownership guarantees. - async fn cancelled(&self) { - std::future::pending::<()>().await; - } -} - /// Where a box is in its life. /// /// Sandboxes drive boxes through these states; core uses them to reject @@ -275,7 +246,3 @@ impl ExecOutput { String::from_utf8_lossy(&self.stderr) } } - -#[cfg(test)] -#[path = "output_tests.rs"] -mod tests; From 4c7c11cee98c288237e1fb34447d729fbac93ea5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 27 Oct 2026 16:09:24 +0000 Subject: [PATCH 199/236] test(docker): cover forwarding of provider termination causes Extend the capturing observer to record termination causes and add a test asserting that the Docker execution observer forwards a provider-reported cause to the inner observer. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinybox-docker/src/sandbox/mod_tests.rs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/tinybox-docker/src/sandbox/mod_tests.rs b/crates/tinybox-docker/src/sandbox/mod_tests.rs index 6abd9163..28e76de9 100644 --- a/crates/tinybox-docker/src/sandbox/mod_tests.rs +++ b/crates/tinybox-docker/src/sandbox/mod_tests.rs @@ -1014,6 +1014,7 @@ fn flag_value<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> { struct CapturingObserver { cancel: bool, batches: Mutex)>>, + causes: Mutex>, } impl CapturingObserver { @@ -1035,6 +1036,13 @@ impl tinybox_core::ExecutionObserver for CapturingObserver { Ok(()) } + fn termination_cause(&self, cause: tinybox_core::TerminationCause) { + self.causes + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push(cause); + } + async fn cancelled(&self) { if !self.cancel { std::future::pending::<()>().await; @@ -1042,6 +1050,37 @@ impl tinybox_core::ExecutionObserver for CapturingObserver { } } +#[test] +fn docker_observer_forwards_provider_termination_causes() -> Result<()> { + let inner = Arc::new(CapturingObserver::default()); + let observer = super::DockerExecutionObserver { + inner: inner.clone(), + host: Arc::new(ScriptedHost::default()), + namespace: "fixture".into(), + handle: super::CleanupHandle { + id: BoxId::new("box-0")?, + token: "token".into(), + generation: 0, + launched: true, + acknowledged: Arc::default(), + unsupported: Arc::default(), + }, + observed_boxes: Arc::new(Mutex::new(std::collections::HashMap::new())), + pending: Arc::new(Mutex::new(std::collections::HashMap::new())), + control: Arc::new(super::ObservedControl::default()), + }; + + tinybox_core::ExecutionObserver::termination_cause( + &observer, + tinybox_core::TerminationCause::CpuQuota, + ); + assert_eq!( + *inner.causes.lock().unwrap_or_else(PoisonError::into_inner), + vec![tinybox_core::TerminationCause::CpuQuota] + ); + Ok(()) +} + #[test] fn it_declares_kernel_isolation_and_filesystem_snapshots() { let caps = DockerSandbox::declared_capabilities(); From 5f6e27ebb1b99ad7327d5f62d6a0c08b7f304d9c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 30 Oct 2026 12:17:11 +0000 Subject: [PATCH 200/236] chore(vendor): bump tinybus submodule Update the vendored tinybus submodule to the latest upstream commit. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- vendor/tinybus | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinybus b/vendor/tinybus index 06895661..e5f1cd2d 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit 0689566109edfc28c416ba927b2af4b88f1fb4cf +Subproject commit e5f1cd2d2a7b905ee82410a61219292ca8a85c1f From 3ada6944ebdad21990047c507780de3a92020103 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 30 Oct 2026 12:18:10 +0000 Subject: [PATCH 201/236] test(storage): cover declaration serialization and nonempty names Replace the v2 spec validation test with one that checks collection declarations serialize to and from just a name, and assert declared collections have nonempty names instead of validating a spec. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/storage/mod_tests.rs | 23 ++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/tinybox-bus/src/storage/mod_tests.rs b/crates/tinybox-bus/src/storage/mod_tests.rs index 57469714..15f8ff36 100644 --- a/crates/tinybox-bus/src/storage/mod_tests.rs +++ b/crates/tinybox-bus/src/storage/mod_tests.rs @@ -24,9 +24,26 @@ fn current_and_legacy_collection_names_are_distinct() { } #[test] -fn every_declared_collection_has_a_valid_v2_spec() { - for spec in collections() { - assert!(spec.validate().is_ok(), "invalid collection {}", spec.name); +fn storage_declarations_are_serializable_names_without_backend_policy() { + let declaration = CollectionDeclaration::new(BOX_COLLECTION); + + assert_eq!( + serde_json::to_value(&declaration).expect("serialize declaration"), + serde_json::json!({ "name": BOX_COLLECTION }) + ); + assert_eq!( + serde_json::from_value::( + serde_json::json!({ "name": BOX_COLLECTION }) + ) + .expect("deserialize declaration"), + declaration + ); +} + +#[test] +fn every_declared_collection_has_a_nonempty_name() { + for declaration in collections() { + assert!(!declaration.name.is_empty()); } assert_eq!(BODY_VERSION, 2); } From 29e92752b92058fa88add2892a0203f552d5d100 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 30 Oct 2026 12:19:02 +0000 Subject: [PATCH 202/236] chore(vendor): bump tinybus submodule Update the vendored tinybus submodule to the latest upstream commit. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- vendor/tinybus | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinybus b/vendor/tinybus index e5f1cd2d..06895661 160000 --- a/vendor/tinybus +++ b/vendor/tinybus @@ -1 +1 @@ -Subproject commit e5f1cd2d2a7b905ee82410a61219292ca8a85c1f +Subproject commit 0689566109edfc28c416ba927b2af4b88f1fb4cf From ddaea54ea11090c2d27271c372557166ec8dbcd1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 30 Oct 2026 12:19:06 +0000 Subject: [PATCH 203/236] refactor(native-shell): move native shell orchestration into the host crate The runtime-neutral native shell supervision logic and its tests now live in tinybox-host instead of tinybox-bus, so the bus crate stays focused on transport types while the host owns execution orchestration. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/{tinybox-bus => tinybox-host}/src/native_shell.rs | 0 crates/{tinybox-bus => tinybox-host}/src/native_shell_tests.rs | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename crates/{tinybox-bus => tinybox-host}/src/native_shell.rs (100%) rename crates/{tinybox-bus => tinybox-host}/src/native_shell_tests.rs (100%) diff --git a/crates/tinybox-bus/src/native_shell.rs b/crates/tinybox-host/src/native_shell.rs similarity index 100% rename from crates/tinybox-bus/src/native_shell.rs rename to crates/tinybox-host/src/native_shell.rs diff --git a/crates/tinybox-bus/src/native_shell_tests.rs b/crates/tinybox-host/src/native_shell_tests.rs similarity index 100% rename from crates/tinybox-bus/src/native_shell_tests.rs rename to crates/tinybox-host/src/native_shell_tests.rs From fb41247ab68bd53092fbdf09248c3f46bda19e9e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 30 Oct 2026 12:19:37 +0000 Subject: [PATCH 204/236] refactor(tinybox-host): move native-shell orchestration out of the bus contract The runtime-neutral native-shell client and its async polling helpers now live in tinybox-host, so the bus crate keeps only serialized request and result types and no longer pulls in futures dependencies. Storage collection declarations are now a TinyBox-owned type instead of borrowing the storage driver's spec, removing that optional dependency from the contract. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/Cargo.toml | 8 +------- crates/tinybox-bus/src/lib.rs | 9 +-------- crates/tinybox-bus/src/storage/mod.rs | 22 +++++++++++++++++++--- crates/tinybox-host/Cargo.toml | 4 ++++ crates/tinybox-host/src/lib.rs | 7 +++++++ crates/tinybox-host/src/native_shell.rs | 5 ++--- 6 files changed, 34 insertions(+), 21 deletions(-) diff --git a/crates/tinybox-bus/Cargo.toml b/crates/tinybox-bus/Cargo.toml index 6bb7b1b9..e48423dc 100644 --- a/crates/tinybox-bus/Cargo.toml +++ b/crates/tinybox-bus/Cargo.toml @@ -12,23 +12,17 @@ repository.workspace = true serde.workspace = true # OpenHuman config operations share this contract type for schema generation. schemars = "1" -# Runtime-neutral future combinators for the optional native-shell client helper. -futures-util = { version = "0.3", features = ["async-await"] } -# Runtime-neutral delay used between bounded output polls. -futures-timer = "3" # Only the `storage` feature: pure collection and legacy-body vocabulary. serde_json = { workspace = true, optional = true } -tinystoragedrivers-types = { workspace = true, optional = true } [features] default = [] # `collections()` and pure v1-to-v2 body converters. Off by default so the # contract's default dependency closure stays serde only; a host enables it. -storage = ["dep:serde_json", "dep:tinystoragedrivers-types"] +storage = ["dep:serde_json"] [dev-dependencies] serde_json.workspace = true -futures-executor = "0.3" [lints] workspace = true diff --git a/crates/tinybox-bus/src/lib.rs b/crates/tinybox-bus/src/lib.rs index 20eb96af..04342a7c 100644 --- a/crates/tinybox-bus/src/lib.rs +++ b/crates/tinybox-bus/src/lib.rs @@ -1,5 +1,4 @@ -//! Serialized requests and results for the `TinyBox` module, plus runtime-neutral -//! native-shell lifecycle orchestration over a caller-supplied client. +//! Serialized requests and results for the `TinyBox` module. //! //! This crate has no bus connection, executor, native backend, or policy. //! A caller applies its own authorization before sending these requests. @@ -19,12 +18,6 @@ use std::collections::BTreeMap; mod grants; pub use grants::{LocalGrantConfig, LocalGrants, ProtectedPaths, ResolveLocalGrantsRequest}; -mod native_shell; -pub use native_shell::{ - NativeShellClient, NativeShellError, NativeShellOutput, NativeShellRunOutcome, - NativeShellRunRequest, NativeShellRunning, run_native_shell, -}; - mod jail; pub use jail::{JailEnforcement, NativeJailConfig, NativeJailRequest, NativeJailResourceRequest}; diff --git a/crates/tinybox-bus/src/storage/mod.rs b/crates/tinybox-bus/src/storage/mod.rs index 2a4f8345..86c25ff4 100644 --- a/crates/tinybox-bus/src/storage/mod.rs +++ b/crates/tinybox-bus/src/storage/mod.rs @@ -4,7 +4,8 @@ //! legacy rows when their storage adapters open. The bus contract describes //! names and conversions only; it has no engine or migration runner. -use tinystoragedrivers_types::CollectionSpec; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; pub mod box_v1; pub mod jail_index; @@ -29,12 +30,27 @@ pub const V1_BOX_COLLECTION: &str = "tinybox_boxes"; /// The v1 name of [`JAIL_INDEX_COLLECTION`]. pub const V1_JAIL_INDEX_COLLECTION: &str = "tinybox_jail_index"; +/// A TinyBox-owned storage collection name, independent of a storage backend. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct CollectionDeclaration { + /// Stable collection name to provision in the host's bound storage scope. + pub name: String, +} + +impl CollectionDeclaration { + /// Declare a TinyBox collection by name. + #[must_use] + pub fn new(name: impl Into) -> Self { + Self { name: name.into() } + } +} + /// The body schema version used by `TinyBox`'s current documents. pub const BODY_VERSION: u32 = 2; /// Collections owned by `TinyBox`, as pure declarations for the host to ensure. #[must_use] -pub fn collections() -> Vec { +pub fn collections() -> Vec { [ BOX_COLLECTION, JAIL_INDEX_COLLECTION, @@ -43,7 +59,7 @@ pub fn collections() -> Vec { CLI_IMPORTS_COLLECTION, ] .into_iter() - .map(CollectionSpec::new) + .map(CollectionDeclaration::new) .collect() } diff --git a/crates/tinybox-host/Cargo.toml b/crates/tinybox-host/Cargo.toml index a64ec104..dbe15cd2 100644 --- a/crates/tinybox-host/Cargo.toml +++ b/crates/tinybox-host/Cargo.toml @@ -18,6 +18,9 @@ tinybox-core.workspace = true tinybox-bus.workspace = true tinybox-jail.workspace = true async-trait.workspace = true +# The host supervises async native-shell output and bounded poll delays. +futures-util = { version = "0.3", features = ["async-await"] } +futures-timer = "3" # `Dir` keeps workspace file handles beneath the approved root without # resolving untrusted paths through ambient filesystem APIs. cap-std = "4" @@ -36,6 +39,7 @@ command-group = { version = "5", features = ["with-tokio"] } [dev-dependencies] # Command tests run against real temporary directories rather than the CWD. tempfile = "3" +futures-executor = "0.3" [lints] workspace = true diff --git a/crates/tinybox-host/src/lib.rs b/crates/tinybox-host/src/lib.rs index 7fa6699b..0692f733 100644 --- a/crates/tinybox-host/src/lib.rs +++ b/crates/tinybox-host/src/lib.rs @@ -43,6 +43,7 @@ mod files; mod limited; mod local; +mod native_shell; /// Local reach with an enforced combined output budget and supervised cleanup. pub use limited::{JailLocalHost, LimitedLocalHost, ManagedProcess}; @@ -51,3 +52,9 @@ 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}; + +/// Runtime-neutral orchestration for supervised native-shell execution. +pub use native_shell::{ + NativeShellClient, NativeShellError, NativeShellOutput, NativeShellRunOutcome, + NativeShellRunRequest, NativeShellRunning, run_native_shell, +}; diff --git a/crates/tinybox-host/src/native_shell.rs b/crates/tinybox-host/src/native_shell.rs index df725606..b2eaf07e 100644 --- a/crates/tinybox-host/src/native_shell.rs +++ b/crates/tinybox-host/src/native_shell.rs @@ -6,12 +6,11 @@ use std::{ time::{Duration, Instant}, }; -use futures_util::{FutureExt, pin_mut, select}; - -use crate::{ +use tinybox_bus::{ CreateRequest, ExecutionState, OutputBatch, OutputChunk, ProcessRef, ReserveRequest, ResourceId, ShellExecRequest, }; +use crate::native_shell_tests as _; /// Transport operations required to supervise a native shell. /// From 6c58ed6279f5cf948c7bbbb0ff33f07526f78f2a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 30 Oct 2026 12:19:47 +0000 Subject: [PATCH 205/236] refactor(native_shell): import bus types directly instead of via crate root Replaced crate-root paths for ExecutionFailure and OutputStream with direct imports from tinybox_bus, and added the missing futures_util imports. This keeps the module's dependencies explicit and consistent with the other bus types it already imports. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-host/src/native_shell.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/tinybox-host/src/native_shell.rs b/crates/tinybox-host/src/native_shell.rs index b2eaf07e..0a975ba2 100644 --- a/crates/tinybox-host/src/native_shell.rs +++ b/crates/tinybox-host/src/native_shell.rs @@ -5,12 +5,12 @@ use std::{ pin::Pin, time::{Duration, Instant}, }; +use futures_util::{FutureExt, pin_mut, select}; use tinybox_bus::{ - CreateRequest, ExecutionState, OutputBatch, OutputChunk, ProcessRef, ReserveRequest, - ResourceId, ShellExecRequest, + CreateRequest, ExecutionFailure, ExecutionState, OutputBatch, OutputChunk, OutputStream, + ProcessRef, ReserveRequest, ResourceId, ShellExecRequest, }; -use crate::native_shell_tests as _; /// Transport operations required to supervise a native shell. /// @@ -113,7 +113,7 @@ pub enum NativeShellError { source: E, }, /// The module reported a terminal failed state. - Execution(crate::ExecutionFailure), + Execution(ExecutionFailure), /// An output batch did not advance its cursor despite containing chunks. InvalidOutputCursor, /// Cancellation failed, so native cleanup is not acknowledged. @@ -333,7 +333,7 @@ async fn finish_native_shell( ExecutionState::Exited { exit_code } => capture.finish(exit_code, timed_out), ExecutionState::Cancelled => capture.finish(-1, timed_out), ExecutionState::Failed { - code: crate::ExecutionFailure::TimedOut, + code: ExecutionFailure::TimedOut, } => capture.finish(-1, true), ExecutionState::Failed { code } => { client @@ -375,8 +375,8 @@ impl NativeShellCapture { for chunk in &batch.chunks { on_chunk(chunk); match chunk.stream { - crate::OutputStream::Stdout => self.stdout.extend_from_slice(&chunk.bytes), - crate::OutputStream::Stderr => self.stderr.extend_from_slice(&chunk.bytes), + OutputStream::Stdout => self.stdout.extend_from_slice(&chunk.bytes), + OutputStream::Stderr => self.stderr.extend_from_slice(&chunk.bytes), } } self.cursor = batch.next_sequence; From 04ed0304ea1ae245b4c79e3ebe002b3520e54fd1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 30 Oct 2026 12:20:09 +0000 Subject: [PATCH 206/236] refactor: move futures dependencies from tinybox-bus to tinybox-host The futures executor, timer, and util crates are now declared by tinybox-host instead of tinybox-bus, matching where they are actually used. Test helpers were updated to build collection specs from the bus declarations, and imports were reordered. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- Cargo.lock | 7 +++---- crates/tinybox-drivers/src/box_store_tests.rs | 8 +++++--- crates/tinybox-drivers/src/index_store_tests.rs | 8 +++++--- crates/tinybox-host/src/native_shell.rs | 2 +- crates/tinybox-host/src/native_shell_tests.rs | 4 ++-- 5 files changed, 16 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 922c7fc2..bf07295e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1473,13 +1473,9 @@ dependencies = [ name = "tinybox-bus" version = "0.2.4" dependencies = [ - "futures-executor", - "futures-timer", - "futures-util", "schemars", "serde", "serde_json", - "tinystoragedrivers-types", ] [[package]] @@ -1554,6 +1550,9 @@ dependencies = [ "async-trait", "cap-std", "command-group", + "futures-executor", + "futures-timer", + "futures-util", "nix 0.31.3", "tempfile", "tinybox-bus", diff --git a/crates/tinybox-drivers/src/box_store_tests.rs b/crates/tinybox-drivers/src/box_store_tests.rs index a7e52a2e..8fad433e 100644 --- a/crates/tinybox-drivers/src/box_store_tests.rs +++ b/crates/tinybox-drivers/src/box_store_tests.rs @@ -5,7 +5,7 @@ use tinybox_core::store::insert_new; use tinybox_core::{BoxSpec, HostRef, Placement, SandboxRef, WorkspaceSource}; use tinystoragedrivers_api::DocumentStore; use tinystoragedrivers_testkit::memory_storage; -use tinystoragedrivers_types::{Precondition, Scope, StorageError}; +use tinystoragedrivers_types::{CollectionSpec, Precondition, Scope, StorageError}; fn documents() -> Arc { memory_storage(&Scope::new("tinybox-tests").expect("scope")) @@ -18,8 +18,10 @@ fn test_store(docs: Arc) -> Result { let ensure_docs = Arc::clone(&docs); bridge .run(async move { - for spec in tinybox_bus::storage::collections() { - ensure_docs.ensure_collection(&spec).await?; + for declaration in tinybox_bus::storage::collections() { + ensure_docs + .ensure_collection(&CollectionSpec::new(declaration.name)) + .await?; } Ok::<_, StorageError>(()) }) diff --git a/crates/tinybox-drivers/src/index_store_tests.rs b/crates/tinybox-drivers/src/index_store_tests.rs index 9ec432f5..1276d45a 100644 --- a/crates/tinybox-drivers/src/index_store_tests.rs +++ b/crates/tinybox-drivers/src/index_store_tests.rs @@ -4,7 +4,7 @@ use tinybox_core::clock::FixedClock; use tinybox_jail::JailRegistry; use tinystoragedrivers_api::DocumentStore; use tinystoragedrivers_testkit::memory_storage; -use tinystoragedrivers_types::{Scope, StorageError, WriteOptions}; +use tinystoragedrivers_types::{CollectionSpec, Scope, StorageError, WriteOptions}; fn documents() -> Arc { memory_storage(&Scope::new("tinybox-index-tests").expect("scope")) @@ -17,8 +17,10 @@ fn test_store(docs: Arc, registry: &str) -> io::Result(()) }) diff --git a/crates/tinybox-host/src/native_shell.rs b/crates/tinybox-host/src/native_shell.rs index 0a975ba2..7afde475 100644 --- a/crates/tinybox-host/src/native_shell.rs +++ b/crates/tinybox-host/src/native_shell.rs @@ -1,11 +1,11 @@ //! Runtime-neutral orchestration for one supervised native shell execution. +use futures_util::{FutureExt, pin_mut, select}; use std::{ future::Future, pin::Pin, time::{Duration, Instant}, }; -use futures_util::{FutureExt, pin_mut, select}; use tinybox_bus::{ CreateRequest, ExecutionFailure, ExecutionState, OutputBatch, OutputChunk, OutputStream, diff --git a/crates/tinybox-host/src/native_shell_tests.rs b/crates/tinybox-host/src/native_shell_tests.rs index 16079606..4b4522c9 100644 --- a/crates/tinybox-host/src/native_shell_tests.rs +++ b/crates/tinybox-host/src/native_shell_tests.rs @@ -1,10 +1,10 @@ //! Tests for runtime-neutral native shell orchestration. use super::*; -use crate::{ +use std::{collections::BTreeMap, sync::Mutex}; +use tinybox_bus::{ ExecutionFailure, HostConfig, NetworkPolicy, OutputStream, ResourceLimits, SelectShellRequest, Workspace, }; -use std::{collections::BTreeMap, sync::Mutex}; struct MockClient { calls: Mutex>, From ebbd99c144201cd642ce9e2cd5ee9e15ddf21af1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 30 Oct 2026 12:20:51 +0000 Subject: [PATCH 207/236] fix(migrations): build collection specs from declaration names The declare loop now constructs a CollectionSpec from each declaration's name before ensuring the collection, instead of passing the declaration directly. This keeps the migration path aligned with the current collection API. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-drivers/src/migrations.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-drivers/src/migrations.rs b/crates/tinybox-drivers/src/migrations.rs index 5011a9b3..91fc42e3 100644 --- a/crates/tinybox-drivers/src/migrations.rs +++ b/crates/tinybox-drivers/src/migrations.rs @@ -36,8 +36,10 @@ struct MigrationReceipt { /// [`DocumentBoxStore::open_host_managed`]: crate::DocumentBoxStore::open_host_managed pub(crate) async fn declare(storage: &Storage) -> Result<()> { let documents = storage.documents(); - for spec in collections() { - documents.ensure_collection(&spec).await?; + for declaration in collections() { + documents + .ensure_collection(&CollectionSpec::new(declaration.name)) + .await?; } Ok(()) } From 21e5af40d13f7a9e1b32c873bae7210685d6125c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 30 Oct 2026 12:43:08 +0000 Subject: [PATCH 208/236] feat(shell): add supervised native shell execution request and result types Introduce RunNativeShellRequest and RunNativeShellResult so a native shell can be started and supervised through its first completion or yield. A yielded process stays module-owned and can be polled or cancelled, with reservation identifiers supplied by the caller ignored and replaced by the module. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/shell_execution.rs | 38 ++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/tinybox-bus/src/shell_execution.rs b/crates/tinybox-bus/src/shell_execution.rs index bd28c809..1b70523a 100644 --- a/crates/tinybox-bus/src/shell_execution.rs +++ b/crates/tinybox-bus/src/shell_execution.rs @@ -1,5 +1,5 @@ //! Reserved native shell execution; authorization remains caller-owned. -use crate::{NativeJailConfig, ResourceId, SelectShellRequest}; +use crate::{CreateRequest, NativeJailConfig, ProcessRef, ResourceId, SelectShellRequest}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -34,6 +34,42 @@ pub struct ShellExecRequest { #[serde(default)] pub hide_window: bool, } + +/// Start and supervise a native shell through its first completion or yield. +/// Reservation identifiers inside `shell` are ignored and replaced by the +/// module. A yielded process remains module-owned and can be polled through +/// `ReadOutput` or stopped through `Cancel`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunNativeShellRequest { + /// Resource configuration; its reservation identifier is ignored. + pub create: CreateRequest, + /// Shell configuration; resource and process identifiers are ignored. + pub shell: ShellExecRequest, + /// Yield when still running after this duration, if shorter than timeout. + #[serde(default)] + pub yield_after_ms: Option, +} + +/// Result of a supervised shell call. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum RunNativeShellResult { + /// The process exited or timed out and cleanup was acknowledged. + Finished { + exit_code: i32, + stdout: Vec, + stderr: Vec, + timed_out: bool, + }, + /// The process is still running and remains owned by the module. + Running { + resource: ResourceId, + process: ProcessRef, + cursor: u64, + stdout: Vec, + stderr: Vec, + }, +} const fn default_timeout() -> u64 { 30_000 } From 7d572b45b2f8db8ece6c939bb4d88f8d22d43c66 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 30 Oct 2026 12:43:54 +0000 Subject: [PATCH 209/236] feat(tinybox-bus): export native shell run request and result types Re-export RunNativeShellRequest and RunNativeShellResult from the bus crate so the new native shell run module can use them without reaching into the private shell_execution module. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/lib.rs | 2 +- .../src/tinybus_module/native_shell/run.rs | 119 ++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 crates/tinybox-module/src/tinybus_module/native_shell/run.rs diff --git a/crates/tinybox-bus/src/lib.rs b/crates/tinybox-bus/src/lib.rs index 04342a7c..a6e4d48c 100644 --- a/crates/tinybox-bus/src/lib.rs +++ b/crates/tinybox-bus/src/lib.rs @@ -23,7 +23,7 @@ pub use jail::{JailEnforcement, NativeJailConfig, NativeJailRequest, NativeJailR mod shell_execution; mod shell_selection; -pub use shell_execution::ShellExecRequest; +pub use shell_execution::{RunNativeShellRequest, RunNativeShellResult, ShellExecRequest}; pub use shell_selection::{SelectShellRequest, ShellFlavor, ShellInvocation}; mod stdio; diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/run.rs b/crates/tinybox-module/src/tinybus_module/native_shell/run.rs new file mode 100644 index 00000000..6a96b4ab --- /dev/null +++ b/crates/tinybox-module/src/tinybus_module/native_shell/run.rs @@ -0,0 +1,119 @@ +use std::{future::Future, time::Duration}; + +use tinybox_bus::{ + CreateRequest, ProcessRef, ReserveRequest, ResourceId, RunNativeShellRequest, + RunNativeShellResult, ShellExecRequest, +}; +use tinybox_host::{NativeShellClient, NativeShellRunOutcome}; +use tinybus::{Error, Result}; + +use super::super::resources::Resources; + +struct Client(std::sync::Arc); + +impl NativeShellClient for Client { + type Error = String; + + async fn reserve( + &self, + request: ReserveRequest, + ) -> std::result::Result { + self.0.reserve(request).await.map_err(|error| error.to_string()) + } + + async fn create(&self, request: CreateRequest) -> std::result::Result<(), Self::Error> { + self.0 + .create(request) + .await + .map(|_| ()) + .map_err(|error| error.to_string()) + } + + async fn start_native_shell_exec( + &self, + request: ShellExecRequest, + ) -> std::result::Result { + self.0 + .start_shell_exec(request) + .await + .map_err(|error| error.to_string()) + } + + async fn read_output( + &self, + process: ProcessRef, + cursor: u64, + ) -> std::result::Result { + self.0 + .read_output(&process, cursor) + .await + .map_err(|error| error.to_string()) + } + + async fn cancel(&self, process: ProcessRef) -> std::result::Result<(), Self::Error> { + self.0 + .cancel(&process) + .await + .map_err(|error| error.to_string()) + } + + async fn release_output( + &self, + process: ProcessRef, + ) -> std::result::Result<(), Self::Error> { + self.0 + .release_output(&process) + .await + .map_err(|error| error.to_string()) + } + + async fn close(&self, resource: ResourceId) -> std::result::Result<(), Self::Error> { + self.0.close(&resource).await.map_err(|error| error.to_string()) + } +} + +/// Supervises a native shell until it finishes, times out, is cancelled, or +/// reaches the requested yield point. The dropped request receiver is used as +/// the cancellation signal so this future always awaits native cleanup. +pub(super) async fn run( + resources: std::sync::Arc, + request: RunNativeShellRequest, + abandoned: impl Future + Send, +) -> Result { + let outcome = tinybox_host::run_native_shell( + &Client(resources), + tinybox_host::NativeShellRunRequest { + create: request.create, + shell: request.shell, + yield_after: request.yield_after_ms.map(Duration::from_millis), + }, + abandoned, + |_| {}, + ) + .await + .map_err(|error| { + let name = match error { + tinybox_host::NativeShellError::Execution(tinybox_bus::ExecutionFailure::OutputLimit) => { + tinybox_bus::OUTPUT_LIMIT + } + _ => tinybox_bus::BACKEND_ERROR, + }; + super::super::resources::failure(name, &format!("native shell operation failed: {error:?}")) + })?; + + Ok(match outcome { + NativeShellRunOutcome::Finished(output) => RunNativeShellResult::Finished { + exit_code: output.exit_code, + stdout: output.stdout, + stderr: output.stderr, + timed_out: output.timed_out, + }, + NativeShellRunOutcome::Running(running) => RunNativeShellResult::Running { + resource: running.resource, + process: running.process, + cursor: running.cursor, + stdout: running.stdout, + stderr: running.stderr, + }, + }) +} From 395626f40f0de464d98ff8cb5371cda401a42c5e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 30 Oct 2026 12:44:34 +0000 Subject: [PATCH 210/236] feat(bus): add module-owned native shell execution Introduce a RunNativeShell bus member that supervises a native shell through completion or a yield point, with cancellation and cleanup handled by the module-owned worker. Bump the contract version to 2.3 and document the new result fields. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/lib.rs | 8 ++++-- crates/tinybox-bus/src/shell_execution.rs | 9 +++++++ .../tinybox-module/src/tinybus_module/mod.rs | 25 +++++++++++++++++-- .../src/tinybus_module/native_shell/mod.rs | 1 + .../src/tinybus_module/native_shell/run.rs | 4 +-- 5 files changed, 41 insertions(+), 6 deletions(-) diff --git a/crates/tinybox-bus/src/lib.rs b/crates/tinybox-bus/src/lib.rs index a6e4d48c..0045f875 100644 --- a/crates/tinybox-bus/src/lib.rs +++ b/crates/tinybox-bus/src/lib.rs @@ -84,8 +84,9 @@ pub use files::{ /// and transport helpers remain. /// 2.1 adds separate resource-aware native jail request members without /// changing the 2.0 Rust request shapes. 2.2 adds process-scoped lifecycle -/// events for module-owned executions and timeout observations. -pub const CONTRACT_VERSION: (u32, u32) = (2, 2); +/// events for module-owned executions and timeout observations. 2.3 adds +/// module-owned native-shell orchestration through completion or yield. +pub const CONTRACT_VERSION: (u32, u32) = (2, 3); /// Minimum contract version required for resource-aware native jail members. pub const NATIVE_JAIL_RESOURCES_VERSION: (u32, u32) = (2, 1); @@ -206,6 +207,8 @@ pub mod names { pub const START_SHELL_EXEC: &str = "StartShellExec"; /// Wire name of the `StartNativeShellExec` member. pub const START_NATIVE_SHELL_EXEC: &str = "StartNativeShellExec"; + /// Wire name of the module-owned native-shell lifecycle member. + pub const RUN_NATIVE_SHELL: &str = "RunNativeShell"; /// Wire name of the `ValidateDockerPolicy` member. pub const VALIDATE_DOCKER_POLICY: &str = "ValidateDockerPolicy"; /// Wire name of the `StartStdio` member. @@ -274,6 +277,7 @@ pub const METHODS: &[&str] = &[ names::KILL_STDIO, names::STDIO_STATUS, names::START_JAILED_EXEC_WITH_RESOURCES, + names::RUN_NATIVE_SHELL, ]; /// Facts about the detected native directory-jail backend. diff --git a/crates/tinybox-bus/src/shell_execution.rs b/crates/tinybox-bus/src/shell_execution.rs index 1b70523a..7fd9f046 100644 --- a/crates/tinybox-bus/src/shell_execution.rs +++ b/crates/tinybox-bus/src/shell_execution.rs @@ -56,17 +56,26 @@ pub struct RunNativeShellRequest { pub enum RunNativeShellResult { /// The process exited or timed out and cleanup was acknowledged. Finished { + /// Process exit status, including nonzero results. exit_code: i32, + /// Collected standard output bytes. stdout: Vec, + /// Collected standard error bytes. stderr: Vec, + /// Whether the shell exceeded its request timeout. timed_out: bool, }, /// The process is still running and remains owned by the module. Running { + /// Resource to close after process cleanup. resource: ResourceId, + /// Process to poll or cancel. process: ProcessRef, + /// Cursor for the next output read. cursor: u64, + /// Standard output collected before yielding. stdout: Vec, + /// Standard error collected before yielding. stderr: Vec, }, } diff --git a/crates/tinybox-module/src/tinybus_module/mod.rs b/crates/tinybox-module/src/tinybus_module/mod.rs index 9055679f..19ef65b0 100644 --- a/crates/tinybox-module/src/tinybus_module/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/mod.rs @@ -13,7 +13,8 @@ use tinybox_bus::{ CreateRequest, ExecOutput, ExecRequest, FileChunk, FileReadInfo, FileWriteInfo, FileWriteProgress, FinishFileReadRequest, FinishFileWriteRequest, ForwardInfo, ForwardRequest, INTERFACE, OBJECT_PATH, ProcessEvent, ProcessRef, ReadFileChunkRequest, ResourceId, - ResourceInfo, SandboxEvent, ShellAnalysis, SpawnRequest, WriteFileChunkRequest, + ResourceInfo, RunNativeShellRequest, RunNativeShellResult, SandboxEvent, ShellAnalysis, + SpawnRequest, WriteFileChunkRequest, }; mod docker; @@ -369,6 +370,26 @@ impl BoxService { Ok(process) } + /// Supervise a native shell through completion or the requested yield + /// point. Dropping the bus invocation requests cancellation and cleanup in + /// the module-owned worker. + async fn run_native_shell( + &self, + request: RunNativeShellRequest, + ) -> TinyBusResult { + let resources = self.resources.clone(); + let (cancel, cancelled) = tokio::sync::oneshot::channel::(); + let task = tokio::spawn(async move { + native_shell::run::run(resources, request, async move { + let _ = cancelled.await; + }) + .await + }); + let result = finish_operation(task).await; + drop(cancel); + result + } + /// Start one program with piped stdio inside the resource's native jail. async fn start_stdio( &self, @@ -656,7 +677,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", "BeginFileRead", "ReadFileChunk", "FinishFileRead", "BeginFileWrite", "WriteFileChunk", "FinishFileWrite", "AbortFileWrite", "StartExec", "ReadOutput", "ReleaseOutput", "StartJailedExec", "RunDocker", "DockerStatus", "CleanupDocker", "RunDockerWithoutWorkspace", "ResolveLocalGrants", "SelectShell", "StartShellExec", "StartNativeShellExec", "ValidateDockerPolicy", "StartStdio", "WriteStdio", "ReadStdio", "CloseStdin", "KillStdio", "StdioStatus", "StartJailedExecWithResources"], + methods = ["Describe", "Create", "Exec", "Inspect", "Close", "Spawn", "IsRunning", "Cancel", "AnalyzeShell", "Reserve", "Capabilities", "Shutdown", "Forward", "CloseForward", "JailStatus", "BeginFileRead", "ReadFileChunk", "FinishFileRead", "BeginFileWrite", "WriteFileChunk", "FinishFileWrite", "AbortFileWrite", "StartExec", "ReadOutput", "ReleaseOutput", "StartJailedExec", "RunDocker", "DockerStatus", "CleanupDocker", "RunDockerWithoutWorkspace", "ResolveLocalGrants", "SelectShell", "StartShellExec", "StartNativeShellExec", "ValidateDockerPolicy", "StartStdio", "WriteStdio", "ReadStdio", "CloseStdin", "KillStdio", "StdioStatus", "StartJailedExecWithResources", "RunNativeShell"], signals = ["ProcessEvent"], 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 index 6943cd56..5637542b 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs @@ -7,6 +7,7 @@ use tinybox_bus::ShellExecRequest; use tinybus::Result; mod types; +pub(super) mod run; pub(super) use types::ShellContext; use types::ShellHost; impl ShellContext { diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/run.rs b/crates/tinybox-module/src/tinybus_module/native_shell/run.rs index 6a96b4ab..e0fbc458 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/run.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/run.rs @@ -5,7 +5,7 @@ use tinybox_bus::{ RunNativeShellResult, ShellExecRequest, }; use tinybox_host::{NativeShellClient, NativeShellRunOutcome}; -use tinybus::{Error, Result}; +use tinybus::Result; use super::super::resources::Resources; @@ -75,7 +75,7 @@ impl NativeShellClient for Client { /// Supervises a native shell until it finishes, times out, is cancelled, or /// reaches the requested yield point. The dropped request receiver is used as /// the cancellation signal so this future always awaits native cleanup. -pub(super) async fn run( +pub(in crate::tinybus_module) async fn run( resources: std::sync::Arc, request: RunNativeShellRequest, abandoned: impl Future + Send, From 7f6908bdd5d562bd1021607b0584bce6ab267c79 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 30 Oct 2026 12:45:22 +0000 Subject: [PATCH 211/236] test(tinybox-module): cover run native shell completion, yielding and cancellation Adds integration tests for RunNativeShell verifying that short commands return finished output with scratch artifacts cleaned up, that long commands yield an owned process whose output can be read and released through the existing operations, and that dropping an in-flight invocation cancels the process and joins its cleanup. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/tinybus_module/mod_tests.rs | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index 761f175b..37fbe4cc 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -1384,6 +1384,141 @@ async fn existing_shell_method_keeps_its_two_argument_bus_codec() Ok(()) } +fn run_native_shell_request( + command: &str, + state_dir: &std::path::Path, + yield_after_ms: Option, +) -> tinybox_bus::RunNativeShellRequest { + tinybox_bus::RunNativeShellRequest { + create: tinybox_bus::CreateRequest { + resource: tinybox_bus::ResourceId(String::new()), + backend: "passthrough".into(), + host: tinybox_bus::HostConfig::Local, + workspace: tinybox_bus::Workspace::Directory(".".into()), + ..Default::default() + }, + shell: tinybox_bus::ShellExecRequest { + process: tinybox_bus::ResourceId(String::new()), + resource: tinybox_bus::ResourceId(String::new()), + shell: tinybox_bus::SelectShellRequest { + command: command.into(), + preserve_pipeline_status: false, + runtime_path: None, + python_utf8: false, + }, + cwd: None, + extra_env: Default::default(), + state_dir: state_dir.to_string_lossy().into_owned(), + timeout_ms: 5_000, + stdin: None, + jail: None, + hide_window: false, + }, + yield_after_ms, + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn run_native_shell_returns_finished_output_after_module_cleanup() +-> Result<(), Box> { + let state = tempfile::tempdir()?; + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let _storage_host = serve_test_storage(&bus).await?; + 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 result: tinybox_bus::RunNativeShellResult = proxy + .call( + "RunNativeShell", + (run_native_shell_request("printf finished", state.path(), None),), + ) + .await?; + let tinybox_bus::RunNativeShellResult::Finished { + exit_code, + stdout, + timed_out, + .. + } = result + else { + panic!("short shell should finish before returning: {result:?}"); + }; + assert_eq!(exit_code, 0); + assert_eq!(stdout, b"finished"); + assert!(!timed_out); + assert_eq!(std::fs::read_dir(state.path().join("artifacts/sandbox-scratch"))?.count(), 0); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn run_native_shell_yields_an_owned_process_for_existing_output_operations() +-> Result<(), Box> { + let state = tempfile::tempdir()?; + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let _storage_host = serve_test_storage(&bus).await?; + 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 result: tinybox_bus::RunNativeShellResult = proxy + .call( + "RunNativeShell", + (run_native_shell_request("sleep 0.2; printf yielded", state.path(), Some(10)),), + ) + .await?; + let tinybox_bus::RunNativeShellResult::Running { + resource, + process, + mut cursor, + stdout, + stderr, + } = result + else { + panic!("long shell should yield while running: {result:?}"); + }; + let mut output = stdout; + output.extend(stderr); + let batch = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let batch: tinybox_bus::OutputBatch = + proxy.call("ReadOutput", (process.clone(), cursor)).await?; + cursor = batch.next_sequence; + output.extend(batch.chunks.iter().flat_map(|chunk| chunk.bytes.clone())); + if batch.state != tinybox_bus::ExecutionState::Running { + break Ok::<_, tinybus::Error>(batch); + } + } + }) + .await??; + assert_eq!(batch.state, tinybox_bus::ExecutionState::Exited { exit_code: 0 }); + assert_eq!(output, b"yielded"); + proxy.call::<()>("ReleaseOutput", (process,)).await?; + proxy.call::<()>("Close", (resource,)).await?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn dropping_run_native_shell_invocation_cancels_and_joins_cleanup() -> Result<(), Box> { + let state = tempfile::tempdir()?; + let service = std::sync::Arc::new(BoxService::default()); + let task_service = service.clone(); + let request = run_native_shell_request("sleep 30", state.path(), None); + let invocation = tokio::spawn(async move { task_service.run_native_shell(request).await }); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + invocation.abort(); + + tokio::time::timeout( + std::time::Duration::from_secs(5), + service.resources.shutdown(), + ) + .await??; + Ok(()) +} + #[tokio::test] async fn shell_discovery_preserves_the_requested_pipeline_status() -> tinybus::Result<()> { let service = BoxService::default(); From fcd7e7e168681af7717b20a88b6bfa93d17adf5d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 30 Oct 2026 12:45:34 +0000 Subject: [PATCH 212/236] test(tinybox-bus): update contract version assertions to 2.3 Bump the expected contract version to (2, 3) across the bus tests and add RunNativeShell to the method catalogue expectations, reflecting the new method added to the contract. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/lib_tests.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/tinybox-bus/src/lib_tests.rs b/crates/tinybox-bus/src/lib_tests.rs index d9ec4db0..95390fc8 100644 --- a/crates/tinybox-bus/src/lib_tests.rs +++ b/crates/tinybox-bus/src/lib_tests.rs @@ -59,7 +59,7 @@ fn docker_run_vocabulary_is_typed_bounded_and_additive() -> Result<(), serde_jso assert_eq!(METHODS[28], "CleanupDocker"); assert_eq!(METHODS[29], "RunDockerWithoutWorkspace"); assert_eq!(METHODS[33], "StartNativeShellExec"); - assert_eq!(CONTRACT_VERSION, (2, 2)); + assert_eq!(CONTRACT_VERSION, (2, 3)); assert_eq!(MAX_DOCKER_RUN_TIMEOUT_MS, 86_400_000); let serialized = serde_json::to_value(&request)?; assert_eq!(serialized["read_only_rootfs"], false); @@ -137,7 +137,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, (2, 2)); + assert_eq!(CONTRACT_VERSION, (2, 3)); assert!(is_compatible(CONTRACT_VERSION)); assert!(!is_compatible((1, 21))); assert!(!is_compatible((1, 8))); @@ -182,7 +182,7 @@ fn no_workspace_docker_operation_is_present_in_the_contract() { .count(), 1 ); - assert_eq!(CONTRACT_VERSION, (2, 2)); + assert_eq!(CONTRACT_VERSION, (2, 3)); } #[test] @@ -195,9 +195,10 @@ fn shell_analysis_wire_shape_contains_only_structural_facts() -> Result<(), serd #[test] fn contract_two_drops_command_classification_but_keeps_structural_shell_analysis() { - assert_eq!(CONTRACT_VERSION, (2, 2)); + assert_eq!(CONTRACT_VERSION, (2, 3)); assert!(!METHODS.contains(&"AnalyzeCommand")); assert!(METHODS.contains(&"AnalyzeShell")); + assert!(METHODS.contains(&"RunNativeShell")); } #[test] @@ -372,6 +373,7 @@ fn named_members_resolve_to_the_method_catalogue_in_order() { KILL_STDIO, STDIO_STATUS, START_JAILED_EXEC_WITH_RESOURCES, + RUN_NATIVE_SHELL, ]; assert_eq!(METHODS, &expected[..]); assert_eq!(DESCRIBE, "Describe"); From be227374ee1f5692d57a940899fd3c2aacb03f06 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 30 Oct 2026 12:45:57 +0000 Subject: [PATCH 213/236] test(tinybox-bus): cover RunNativeShell result serialization Add a round-trip test asserting that finished and running RunNativeShell results survive JSON serialization with the expected state tag, and tidy formatting in the native shell module and its tests. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/lib_tests.rs | 33 +++++++++++++++++++ .../src/tinybus_module/mod_tests.rs | 25 +++++++++++--- .../src/tinybus_module/native_shell/mod.rs | 2 +- .../src/tinybus_module/native_shell/run.rs | 21 +++++++----- 4 files changed, 66 insertions(+), 15 deletions(-) diff --git a/crates/tinybox-bus/src/lib_tests.rs b/crates/tinybox-bus/src/lib_tests.rs index 95390fc8..5045ac96 100644 --- a/crates/tinybox-bus/src/lib_tests.rs +++ b/crates/tinybox-bus/src/lib_tests.rs @@ -327,6 +327,39 @@ fn native_shell_wire_defaults_close_stdin_and_keep_a_bounded_runtime() Ok(()) } +#[test] +fn run_native_shell_result_preserves_finished_and_yielded_shapes() -> Result<(), serde_json::Error> +{ + let finished = RunNativeShellResult::Finished { + exit_code: 7, + stdout: b"out".to_vec(), + stderr: b"err".to_vec(), + timed_out: false, + }; + assert_eq!( + serde_json::from_value::(serde_json::to_value(&finished)?)?, + finished + ); + let running = RunNativeShellResult::Running { + resource: ResourceId("resource".into()), + process: ProcessRef { + resource: ResourceId("resource".into()), + process: ResourceId("process".into()), + }, + cursor: 4, + stdout: b"before".to_vec(), + stderr: Vec::new(), + }; + let value = serde_json::to_value(&running)?; + assert_eq!(value["state"], "running"); + assert_eq!( + serde_json::from_value::(value)?, + running + ); + assert_eq!(names::RUN_NATIVE_SHELL, "RunNativeShell"); + Ok(()) +} + #[test] fn named_members_resolve_to_the_method_catalogue_in_order() { use names::*; diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index 37fbe4cc..9d1f1df6 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -1433,7 +1433,11 @@ async fn run_native_shell_returns_finished_output_after_module_cleanup() let result: tinybox_bus::RunNativeShellResult = proxy .call( "RunNativeShell", - (run_native_shell_request("printf finished", state.path(), None),), + (run_native_shell_request( + "printf finished", + state.path(), + None, + ),), ) .await?; let tinybox_bus::RunNativeShellResult::Finished { @@ -1448,7 +1452,10 @@ async fn run_native_shell_returns_finished_output_after_module_cleanup() assert_eq!(exit_code, 0); assert_eq!(stdout, b"finished"); assert!(!timed_out); - 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 + ); Ok(()) } @@ -1467,7 +1474,11 @@ async fn run_native_shell_yields_an_owned_process_for_existing_output_operations let result: tinybox_bus::RunNativeShellResult = proxy .call( "RunNativeShell", - (run_native_shell_request("sleep 0.2; printf yielded", state.path(), Some(10)),), + (run_native_shell_request( + "sleep 0.2; printf yielded", + state.path(), + Some(10), + ),), ) .await?; let tinybox_bus::RunNativeShellResult::Running { @@ -1494,7 +1505,10 @@ async fn run_native_shell_yields_an_owned_process_for_existing_output_operations } }) .await??; - assert_eq!(batch.state, tinybox_bus::ExecutionState::Exited { exit_code: 0 }); + assert_eq!( + batch.state, + tinybox_bus::ExecutionState::Exited { exit_code: 0 } + ); assert_eq!(output, b"yielded"); proxy.call::<()>("ReleaseOutput", (process,)).await?; proxy.call::<()>("Close", (resource,)).await?; @@ -1502,7 +1516,8 @@ async fn run_native_shell_yields_an_owned_process_for_existing_output_operations } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn dropping_run_native_shell_invocation_cancels_and_joins_cleanup() -> Result<(), Box> { +async fn dropping_run_native_shell_invocation_cancels_and_joins_cleanup() +-> Result<(), Box> { let state = tempfile::tempdir()?; let service = std::sync::Arc::new(BoxService::default()); let task_service = service.clone(); 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 5637542b..56ce4f85 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs @@ -6,8 +6,8 @@ use std::{ use tinybox_bus::ShellExecRequest; use tinybus::Result; -mod types; pub(super) mod run; +mod types; pub(super) use types::ShellContext; use types::ShellHost; impl ShellContext { diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/run.rs b/crates/tinybox-module/src/tinybus_module/native_shell/run.rs index e0fbc458..8f97d012 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/run.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/run.rs @@ -18,7 +18,10 @@ impl NativeShellClient for Client { &self, request: ReserveRequest, ) -> std::result::Result { - self.0.reserve(request).await.map_err(|error| error.to_string()) + self.0 + .reserve(request) + .await + .map_err(|error| error.to_string()) } async fn create(&self, request: CreateRequest) -> std::result::Result<(), Self::Error> { @@ -57,10 +60,7 @@ impl NativeShellClient for Client { .map_err(|error| error.to_string()) } - async fn release_output( - &self, - process: ProcessRef, - ) -> std::result::Result<(), Self::Error> { + async fn release_output(&self, process: ProcessRef) -> std::result::Result<(), Self::Error> { self.0 .release_output(&process) .await @@ -68,7 +68,10 @@ impl NativeShellClient for Client { } async fn close(&self, resource: ResourceId) -> std::result::Result<(), Self::Error> { - self.0.close(&resource).await.map_err(|error| error.to_string()) + self.0 + .close(&resource) + .await + .map_err(|error| error.to_string()) } } @@ -93,9 +96,9 @@ pub(in crate::tinybus_module) async fn run( .await .map_err(|error| { let name = match error { - tinybox_host::NativeShellError::Execution(tinybox_bus::ExecutionFailure::OutputLimit) => { - tinybox_bus::OUTPUT_LIMIT - } + tinybox_host::NativeShellError::Execution( + tinybox_bus::ExecutionFailure::OutputLimit, + ) => tinybox_bus::OUTPUT_LIMIT, _ => tinybox_bus::BACKEND_ERROR, }; super::super::resources::failure(name, &format!("native shell operation failed: {error:?}")) From 5e4f70b910091e3f718b80094c9e96c3de9c55be Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 30 Oct 2026 12:46:14 +0000 Subject: [PATCH 214/236] test(bus): update contract version assertions to 2.3 Bump the expected contract version to (2, 3) in the event and compatibility tests, and mark (2, 2) as no longer compatible so the snapshots match the new wire contract. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/event_tests.rs | 2 +- crates/tinybox-bus/src/lib_tests.rs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-bus/src/event_tests.rs b/crates/tinybox-bus/src/event_tests.rs index 77801a86..d2298a42 100644 --- a/crates/tinybox-bus/src/event_tests.rs +++ b/crates/tinybox-bus/src/event_tests.rs @@ -44,7 +44,7 @@ fn process_events_keep_their_correlation_and_signal_wire_shape() -> Result<(), s ); assert_eq!(names::PROCESS_EVENT, "ProcessEvent"); assert_eq!(SIGNALS, &["ProcessEvent"]); - assert_eq!(CONTRACT_VERSION, (2, 2)); + assert_eq!(CONTRACT_VERSION, (2, 3)); Ok(()) } diff --git a/crates/tinybox-bus/src/lib_tests.rs b/crates/tinybox-bus/src/lib_tests.rs index 5045ac96..aad99cbe 100644 --- a/crates/tinybox-bus/src/lib_tests.rs +++ b/crates/tinybox-bus/src/lib_tests.rs @@ -153,7 +153,8 @@ fn version_and_capability_wire_snapshot_preserve_contract_compatibility() assert!(!is_compatible((3, 0))); assert!(!is_compatible((2, 0))); assert!(!is_compatible((2, 1))); - assert!(is_compatible((2, 2))); + assert!(!is_compatible((2, 2))); + assert!(is_compatible((2, 3))); assert!(!supports_native_jail_resources((2, 0))); assert!(supports_native_jail_resources((2, 1))); assert!(supports_native_jail_resources((2, 2))); From e25ba211ae905f11f5f837862a269eb3ee7988d6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 30 Oct 2026 12:46:54 +0000 Subject: [PATCH 215/236] test(tinybus-module): use echo in native shell tests Replace printf with echo in the RunNativeShell tests and update the expected stdout to include the trailing newline, so the assertions match the shell's actual output. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-module/src/tinybus_module/mod_tests.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index 9d1f1df6..1b5a545f 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -1434,7 +1434,7 @@ async fn run_native_shell_returns_finished_output_after_module_cleanup() .call( "RunNativeShell", (run_native_shell_request( - "printf finished", + "echo finished", state.path(), None, ),), @@ -1450,7 +1450,7 @@ async fn run_native_shell_returns_finished_output_after_module_cleanup() panic!("short shell should finish before returning: {result:?}"); }; assert_eq!(exit_code, 0); - assert_eq!(stdout, b"finished"); + assert_eq!(stdout, b"finished\n"); assert!(!timed_out); assert_eq!( std::fs::read_dir(state.path().join("artifacts/sandbox-scratch"))?.count(), @@ -1475,7 +1475,7 @@ async fn run_native_shell_yields_an_owned_process_for_existing_output_operations .call( "RunNativeShell", (run_native_shell_request( - "sleep 0.2; printf yielded", + "sleep 0.2; echo yielded", state.path(), Some(10), ),), @@ -1509,7 +1509,7 @@ async fn run_native_shell_yields_an_owned_process_for_existing_output_operations batch.state, tinybox_bus::ExecutionState::Exited { exit_code: 0 } ); - assert_eq!(output, b"yielded"); + assert_eq!(output, b"yielded\n"); proxy.call::<()>("ReleaseOutput", (process,)).await?; proxy.call::<()>("Close", (resource,)).await?; Ok(()) From 3189a6db7e225cc480b00a2302bada3028383476 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 30 Oct 2026 12:47:29 +0000 Subject: [PATCH 216/236] test(tinybus-module): add diagnostics to native shell exit code assertion Include the timeout flag and captured stdout in the assertion message so a failing exit code check shows the surrounding state instead of just the mismatched values. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-module/src/tinybus_module/mod_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index 1b5a545f..eb092d83 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -1449,7 +1449,7 @@ async fn run_native_shell_returns_finished_output_after_module_cleanup() else { panic!("short shell should finish before returning: {result:?}"); }; - assert_eq!(exit_code, 0); + assert_eq!(exit_code, 0, "timed_out={timed_out}, stdout={stdout:?}"); assert_eq!(stdout, b"finished\n"); assert!(!timed_out); assert_eq!( From ea9b2af48302a328a45a58c740bf83380214ae18 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 30 Oct 2026 12:48:35 +0000 Subject: [PATCH 217/236] fix(native_shell): yield before polling output to avoid starvation The in-process adapter returned a ready future on empty reads, which could starve the shell's output collector on a single-worker module. Yield to the scheduler before each read so transport polling behaves like the host helper expects. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-module/src/tinybus_module/native_shell/run.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/tinybox-module/src/tinybus_module/native_shell/run.rs b/crates/tinybox-module/src/tinybus_module/native_shell/run.rs index 8f97d012..22c8db57 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/run.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/run.rs @@ -47,6 +47,10 @@ impl NativeShellClient for Client { process: ProcessRef, cursor: u64, ) -> std::result::Result { + // The host helper expects transport polling to yield between empty + // reads. This in-process adapter otherwise returns a ready future and + // can starve the shell's output collector on a single-worker module. + tokio::task::yield_now().await; self.0 .read_output(&process, cursor) .await From 997b777683ce2cb58ed4f829eb4f856e056a3215 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 30 Oct 2026 12:49:10 +0000 Subject: [PATCH 218/236] test(tinybox-bus): update wire snapshot to contract version 2.3 The capability wire snapshot test still expected contract version 2.2, so it now fails against the current serialization. Bump the expected version to 2.3 to match the contract the code emits. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinybox-bus/src/lib_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinybox-bus/src/lib_tests.rs b/crates/tinybox-bus/src/lib_tests.rs index aad99cbe..bbc45bb8 100644 --- a/crates/tinybox-bus/src/lib_tests.rs +++ b/crates/tinybox-bus/src/lib_tests.rs @@ -164,7 +164,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":[2,2],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]}); + let snapshot = serde_json::json!({"contract_version":[2,3],"create_backends":["passthrough"],"exec_backends":[],"spawn_backends":[]}); assert_eq!(serde_json::to_value(&capabilities)?, snapshot); assert_eq!( serde_json::from_value::(snapshot)?, From 29e08b5908138774bb059498bb0e8374894bc00f Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Mon, 2 Nov 2026 23:10:48 +0530 Subject: [PATCH 219/236] Record the OpenShell backend under contract 2.3 The merge from main adds the openshell backend without a contract bump; this branch already moves the contract to 2.3, so document it there and add the 2.3 spec page. --- crates/tinybox-bus/src/lib.rs | 3 ++- docs/specs/contract-2-3.md | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 docs/specs/contract-2-3.md diff --git a/crates/tinybox-bus/src/lib.rs b/crates/tinybox-bus/src/lib.rs index 0045f875..7b228e0d 100644 --- a/crates/tinybox-bus/src/lib.rs +++ b/crates/tinybox-bus/src/lib.rs @@ -85,7 +85,8 @@ pub use files::{ /// 2.1 adds separate resource-aware native jail request members without /// changing the 2.0 Rust request shapes. 2.2 adds process-scoped lifecycle /// events for module-owned executions and timeout observations. 2.3 adds -/// module-owned native-shell orchestration through completion or yield. +/// module-owned native-shell orchestration through completion or yield, and +/// the `openshell` sandbox backend for `Create`. pub const CONTRACT_VERSION: (u32, u32) = (2, 3); /// Minimum contract version required for resource-aware native jail members. diff --git a/docs/specs/contract-2-3.md b/docs/specs/contract-2-3.md new file mode 100644 index 00000000..cb1fe05d --- /dev/null +++ b/docs/specs/contract-2-3.md @@ -0,0 +1,12 @@ +# TinyBox contract 2.3 + +Contract 2.3 adds two things to `ai.tinyhumans.tinybox.Box`, without changing +any 2.2 request or reply shape: + +| Addition | What a host may rely on | +| --- | --- | +| `RunNativeShell` | The module runs a native shell through completion, or until the requested yield point, and cleans up when the caller abandons it. | +| `openshell` backend | `Create { backend: "openshell", .. }` places the box in an NVIDIA OpenShell sandbox behind the configured gateway. `Capabilities` lists `openshell` in the create and exec backends. | + +A host that needs either one requires `contract_version >= (2, 3)`. Modules +that report 2.1 or 2.2 answer an `openshell` create with `UnsupportedBackend`. From 76fca970301ce19ce9f20d077bf9ebc64cea629e Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Mon, 2 Nov 2026 23:10:48 +0530 Subject: [PATCH 220/236] Fail the unsafe boundary check on every spelling of a relaxation The guard matched only #[allow(... unsafe_code ...)], so cfg_attr, expect, warn, a multi-line attribute or a crate manifest override passed it. Treat any non-comment mention of unsafe_code as a relaxation unless it is the one file-scoped allowance in windows.rs or the workspace deny, add behavioral tests for each spelling, and correct AGENTS.md, which still said no crate relaxes the lint. --- .github/workflows/ci.yml | 2 + AGENTS.md | 13 ++-- scripts/ci/check-windows-unsafe-boundary.sh | 44 +++++++++--- .../ci/check-windows-unsafe-boundary_tests.sh | 67 +++++++++++++++++++ 4 files changed, 112 insertions(+), 14 deletions(-) create mode 100755 scripts/ci/check-windows-unsafe-boundary_tests.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 126277ef..353c2aa5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - name: Test the Windows FFI allowance guard + run: scripts/ci/check-windows-unsafe-boundary_tests.sh - name: Check the Windows FFI allowance boundary run: scripts/ci/check-windows-unsafe-boundary.sh module-ci: diff --git a/AGENTS.md b/AGENTS.md index b7750263..1ce4ff08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,12 +145,15 @@ Use standard `rustfmt` output and Rust 2024 idioms. Do not hand-format around `impl Into` at boundaries; return owned, concrete types. - Keep the public surface minimal: default to private, and export deliberately from `src/lib.rs`. -- `unsafe` is forbidden across the workspace by `[workspace.lints]` in the root +- `unsafe` is denied across the workspace by `[workspace.lints]` in the root `Cargo.toml`, which every crate inherits with `[lints] workspace = true`. - **No crate relaxes it.** ADR 0003 reserved `tinybox-linux` as the one that - might; ADR 0005 records why it did not need to. If you find yourself wanting - `unsafe`, check first whether an existing tool already does the dangerous part - correctly — that is how the namespace backend avoided it. + **Only `crates/tinybox-jail/src/windows.rs` relaxes it**, with one file-scoped + `#![allow(unsafe_code)]` for the `AppContainer` FFI (ADR 0003); + `scripts/ci/check-windows-unsafe-boundary.sh` fails CI on any other spelling + of a relaxation. ADR 0005 records why `tinybox-linux` did not need one. If + you find yourself wanting `unsafe`, check first whether an existing tool + already does the dangerous part correctly — that is how the namespace + backend avoided it. ### Errors diff --git a/scripts/ci/check-windows-unsafe-boundary.sh b/scripts/ci/check-windows-unsafe-boundary.sh index 1398d249..dcba445f 100755 --- a/scripts/ci/check-windows-unsafe-boundary.sh +++ b/scripts/ci/check-windows-unsafe-boundary.sh @@ -1,15 +1,41 @@ #!/usr/bin/env bash +# The workspace denies `unsafe_code`; only the Windows AppContainer FFI file may +# allow it, file-scoped. Any other code mention of the lint is a relaxation, +# whatever its spelling: `allow`, `expect`, `warn`, `cfg_attr(.., allow(..))`, +# a multi-line attribute, or a crate manifest override. Comment lines are prose. set -euo pipefail +root="${1:-.}" +cd "$root" + allowed_file="crates/tinybox-jail/src/windows.rs" -mapfile -t allowance_files < <( - grep -RIlE '#[[:space:]]*!?[[:space:]]*\[[[:space:]]*allow[[:space:]]*\([^]]*unsafe_code' \ - crates --include='*.rs' || true -) +allowed_line='#![allow(unsafe_code)]' + +found="$( + grep -RIn --include='*.rs' --include='Cargo.toml' -e 'unsafe_code' . \ + --exclude-dir=vendor --exclude-dir=target --exclude-dir=.git | + grep -Ev '^[^:]+:[0-9]+:[[:space:]]*//' || true +)" +expected="./$allowed_file:" + +violations="$( + printf '%s\n' "$found" | grep -v '^$' | while IFS= read -r hit; do + file="${hit%%:*}" + text="${hit#*:}" + text="${text#*:}" + if [[ "$file" == ./Cargo.toml && "$text" =~ ^unsafe_code\ =\ \"deny\"$ ]]; then + continue + fi + if [[ "$hit" == "$expected"* && "$text" == "$allowed_line" ]]; then + continue + fi + printf '%s\n' "$hit" + done +)" -if [[ "${#allowance_files[@]}" -ne 1 || "${allowance_files[0]}" != "$allowed_file" ]]; then - printf 'unsafe_code allowances must appear only in %s\n' "$allowed_file" >&2 - printf 'found: %s\n' "${allowance_files[*]:-(none)}" >&2 +if [[ -n "$violations" ]]; then + printf 'unsafe_code may be relaxed only by `%s` in %s\nfound:\n%s\n' \ + "$allowed_line" "$allowed_file" "$violations" >&2 exit 1 fi @@ -18,7 +44,7 @@ if ! grep -Eq '^unsafe_code = "deny"$' Cargo.toml; then exit 1 fi -if ! grep -Eq '^#!\[allow\(unsafe_code\)\]$' "$allowed_file"; then - echo 'Windows FFI exception must remain a file-scoped allowance' >&2 +if [[ "$(grep -cxF "$allowed_line" "$allowed_file")" != 1 ]]; then + echo 'Windows FFI exception must remain one file-scoped allowance' >&2 exit 1 fi diff --git a/scripts/ci/check-windows-unsafe-boundary_tests.sh b/scripts/ci/check-windows-unsafe-boundary_tests.sh new file mode 100755 index 00000000..aa20f3e3 --- /dev/null +++ b/scripts/ci/check-windows-unsafe-boundary_tests.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Behavioral checks for check-windows-unsafe-boundary.sh: every spelling of a +# relaxation fails, prose and the one allowance pass. +set -euo pipefail + +guard="$(cd "$(dirname "$0")" && pwd)/check-windows-unsafe-boundary.sh" +scratch="$(mktemp -d)" +trap 'rm -rf "$scratch"' EXIT +failures=0 + +tree() { + local dir="$scratch/$1" + mkdir -p "$dir/crates/tinybox-jail/src" "$dir/crates/other/src" + printf '[workspace.lints.rust]\nunsafe_code = "deny"\n' > "$dir/Cargo.toml" + printf '//! Windows.\n#![allow(unsafe_code)]\n' > "$dir/crates/tinybox-jail/src/windows.rs" + printf '//! Mentions `unsafe_code` in prose only.\n' > "$dir/crates/other/src/lib.rs" + printf '[package]\nname = "other"\n' > "$dir/crates/other/Cargo.toml" + printf '%s' "$dir" +} + +expect() { + local want="$1" name="$2" dir="$3" + if bash "$guard" "$dir" >/dev/null 2>&1; then got=pass; else got=fail; fi + if [[ "$got" != "$want" ]]; then + printf 'FAIL %s: expected %s, got %s\n' "$name" "$want" "$got" >&2 + failures=$((failures + 1)) + fi +} + +expect pass baseline "$(tree baseline)" + +for spelling in \ + '#![allow(unsafe_code)]' \ + '#[expect(unsafe_code)]' \ + '#![warn(unsafe_code)]' \ + '#[cfg_attr(windows, allow(unsafe_code))]' \ + '#[allow(clippy::all, unsafe_code)]'; do + dir="$(tree "case$RANDOM$RANDOM")" + printf '%s\nfn f() {}\n' "$spelling" >> "$dir/crates/other/src/lib.rs" + expect fail "$spelling" "$dir" +done + +dir="$(tree multiline)" +printf '#[cfg_attr(\n windows,\n allow(unsafe_code)\n)]\nfn f() {}\n' >> "$dir/crates/other/src/lib.rs" +expect fail multi-line-attribute "$dir" + +dir="$(tree manifest)" +printf '[lints.rust]\nunsafe_code = "allow"\n' >> "$dir/crates/other/Cargo.toml" +expect fail crate-manifest-override "$dir" + +dir="$(tree workspace-allow)" +printf '[workspace.lints.rust]\nunsafe_code = "allow"\n' > "$dir/Cargo.toml" +expect fail workspace-relaxed "$dir" + +dir="$(tree second-allow)" +printf '#[allow(unsafe_code)]\nfn g() {}\n' >> "$dir/crates/tinybox-jail/src/windows.rs" +expect fail item-scoped-allowance-in-windows "$dir" + +dir="$(tree vendored)" +mkdir -p "$dir/vendor/x/src" +printf '#![allow(unsafe_code)]\n' > "$dir/vendor/x/src/lib.rs" +expect pass vendored-code-ignored "$dir" + +if [[ "$failures" -ne 0 ]]; then + exit 1 +fi +echo 'unsafe boundary guard: all cases behave' From b46ca5e9b74cb274d3ccaf99ed72a224f3d460eb Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Mon, 2 Nov 2026 23:12:00 +0530 Subject: [PATCH 221/236] Pin the storage crates to canonical tinystoragedrivers v2.1.2 The six tinystoragedrivers crates pointed at a personal fork rev. The canonical v2.1.2 commit main uses (6226b94) carries the same zstd advisory fix; the fork's extra commits touch only crates tinybox does not build (secrets, kv, queue, blob, fence), and every lockfile entry has the same dependencies, so only the source changes. --- Cargo.lock | 22 +++++++++++----------- Cargo.toml | 15 ++++++++------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fe9bb7e9..3fb3ebfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1719,7 +1719,7 @@ dependencies = [ [[package]] name = "tinystoragedrivers-api" version = "2.1.2" -source = "git+https://github.com/senamakel/tinystoragedrivers?rev=16a4c521aaf2ab9e3756bbaecc32a6563dda2440#16a4c521aaf2ab9e3756bbaecc32a6563dda2440" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=6226b9484c8bd01c58741718cd89ba90a13a36bb#6226b9484c8bd01c58741718cd89ba90a13a36bb" dependencies = [ "async-trait", "bytes", @@ -1733,7 +1733,7 @@ dependencies = [ [[package]] name = "tinystoragedrivers-blob" version = "2.1.2" -source = "git+https://github.com/senamakel/tinystoragedrivers?rev=16a4c521aaf2ab9e3756bbaecc32a6563dda2440#16a4c521aaf2ab9e3756bbaecc32a6563dda2440" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=6226b9484c8bd01c58741718cd89ba90a13a36bb#6226b9484c8bd01c58741718cd89ba90a13a36bb" dependencies = [ "async-trait", "bytes", @@ -1747,7 +1747,7 @@ dependencies = [ [[package]] name = "tinystoragedrivers-bus" version = "2.1.2" -source = "git+https://github.com/senamakel/tinystoragedrivers?rev=16a4c521aaf2ab9e3756bbaecc32a6563dda2440#16a4c521aaf2ab9e3756bbaecc32a6563dda2440" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=6226b9484c8bd01c58741718cd89ba90a13a36bb#6226b9484c8bd01c58741718cd89ba90a13a36bb" dependencies = [ "serde", "serde_json", @@ -1758,7 +1758,7 @@ dependencies = [ [[package]] name = "tinystoragedrivers-busproxy" version = "2.1.2" -source = "git+https://github.com/senamakel/tinystoragedrivers?rev=16a4c521aaf2ab9e3756bbaecc32a6563dda2440#16a4c521aaf2ab9e3756bbaecc32a6563dda2440" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=6226b9484c8bd01c58741718cd89ba90a13a36bb#6226b9484c8bd01c58741718cd89ba90a13a36bb" dependencies = [ "async-trait", "bytes", @@ -1776,7 +1776,7 @@ dependencies = [ [[package]] name = "tinystoragedrivers-connect" version = "2.1.2" -source = "git+https://github.com/senamakel/tinystoragedrivers?rev=16a4c521aaf2ab9e3756bbaecc32a6563dda2440#16a4c521aaf2ab9e3756bbaecc32a6563dda2440" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=6226b9484c8bd01c58741718cd89ba90a13a36bb#6226b9484c8bd01c58741718cd89ba90a13a36bb" dependencies = [ "rusqlite", "serde_json", @@ -1788,7 +1788,7 @@ dependencies = [ [[package]] name = "tinystoragedrivers-document" version = "2.1.2" -source = "git+https://github.com/senamakel/tinystoragedrivers?rev=16a4c521aaf2ab9e3756bbaecc32a6563dda2440#16a4c521aaf2ab9e3756bbaecc32a6563dda2440" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=6226b9484c8bd01c58741718cd89ba90a13a36bb#6226b9484c8bd01c58741718cd89ba90a13a36bb" dependencies = [ "async-trait", "data-encoding", @@ -1806,7 +1806,7 @@ dependencies = [ [[package]] name = "tinystoragedrivers-kv" version = "2.1.2" -source = "git+https://github.com/senamakel/tinystoragedrivers?rev=16a4c521aaf2ab9e3756bbaecc32a6563dda2440#16a4c521aaf2ab9e3756bbaecc32a6563dda2440" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=6226b9484c8bd01c58741718cd89ba90a13a36bb#6226b9484c8bd01c58741718cd89ba90a13a36bb" dependencies = [ "async-trait", "serde", @@ -1818,7 +1818,7 @@ dependencies = [ [[package]] name = "tinystoragedrivers-queue" version = "2.1.2" -source = "git+https://github.com/senamakel/tinystoragedrivers?rev=16a4c521aaf2ab9e3756bbaecc32a6563dda2440#16a4c521aaf2ab9e3756bbaecc32a6563dda2440" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=6226b9484c8bd01c58741718cd89ba90a13a36bb#6226b9484c8bd01c58741718cd89ba90a13a36bb" dependencies = [ "async-trait", "data-encoding", @@ -1834,7 +1834,7 @@ dependencies = [ [[package]] name = "tinystoragedrivers-secrets" version = "2.1.2" -source = "git+https://github.com/senamakel/tinystoragedrivers?rev=16a4c521aaf2ab9e3756bbaecc32a6563dda2440#16a4c521aaf2ab9e3756bbaecc32a6563dda2440" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=6226b9484c8bd01c58741718cd89ba90a13a36bb#6226b9484c8bd01c58741718cd89ba90a13a36bb" dependencies = [ "async-trait", "tinystoragedrivers-api", @@ -1845,7 +1845,7 @@ dependencies = [ [[package]] name = "tinystoragedrivers-testkit" version = "2.1.2" -source = "git+https://github.com/senamakel/tinystoragedrivers?rev=16a4c521aaf2ab9e3756bbaecc32a6563dda2440#16a4c521aaf2ab9e3756bbaecc32a6563dda2440" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=6226b9484c8bd01c58741718cd89ba90a13a36bb#6226b9484c8bd01c58741718cd89ba90a13a36bb" dependencies = [ "async-trait", "bytes", @@ -1866,7 +1866,7 @@ dependencies = [ [[package]] name = "tinystoragedrivers-types" version = "2.1.2" -source = "git+https://github.com/senamakel/tinystoragedrivers?rev=16a4c521aaf2ab9e3756bbaecc32a6563dda2440#16a4c521aaf2ab9e3756bbaecc32a6563dda2440" +source = "git+https://github.com/tinyhumansai/tinystoragedrivers?rev=6226b9484c8bd01c58741718cd89ba90a13a36bb#6226b9484c8bd01c58741718cd89ba90a13a36bb" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index b9bce482..864224b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,13 +69,14 @@ tinybox-docker = { path = "crates/tinybox-docker", version = "0.2.4" } tinybox-openshell = { path = "crates/tinybox-openshell", version = "0.2.4" } tinybox-ssh = { path = "crates/tinybox-ssh", version = "0.2.4" } tinybox-sync = { path = "crates/tinybox-sync", version = "0.2.4" } -# Pin the storage contract crates to the zstd advisory fix on the fork's security epic. -tinystoragedrivers-types = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "16a4c521aaf2ab9e3756bbaecc32a6563dda2440" } -tinystoragedrivers-api = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "16a4c521aaf2ab9e3756bbaecc32a6563dda2440" } -tinystoragedrivers-busproxy = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "16a4c521aaf2ab9e3756bbaecc32a6563dda2440" } -tinystoragedrivers = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "16a4c521aaf2ab9e3756bbaecc32a6563dda2440", default-features = false } -tinystoragedrivers-testkit = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "16a4c521aaf2ab9e3756bbaecc32a6563dda2440" } -tinystoragedrivers-document = { version = "2.1.2", git = "https://github.com/senamakel/tinystoragedrivers", rev = "16a4c521aaf2ab9e3756bbaecc32a6563dda2440" } +# Pin the storage contract crates to the canonical TSD v2.1.2 release commit, +# which carries the zstd advisory fix. +tinystoragedrivers-types = { version = "2.1.2", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "6226b9484c8bd01c58741718cd89ba90a13a36bb" } +tinystoragedrivers-api = { version = "2.1.2", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "6226b9484c8bd01c58741718cd89ba90a13a36bb" } +tinystoragedrivers-busproxy = { version = "2.1.2", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "6226b9484c8bd01c58741718cd89ba90a13a36bb" } +tinystoragedrivers = { version = "2.1.2", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "6226b9484c8bd01c58741718cd89ba90a13a36bb", default-features = false } +tinystoragedrivers-testkit = { version = "2.1.2", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "6226b9484c8bd01c58741718cd89ba90a13a36bb" } +tinystoragedrivers-document = { version = "2.1.2", git = "https://github.com/tinyhumansai/tinystoragedrivers", rev = "6226b9484c8bd01c58741718cd89ba90a13a36bb" } tinybox-drivers = { path = "crates/tinybox-drivers", version = "0.2.4" } # Lints apply to every crate in the workspace via `[lints] workspace = true`. From 465353e5edd2f9a786f009edca107c00901fb2fb Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 01:56:20 +0530 Subject: [PATCH 222/236] Satisfy clippy in the storage collection contract and its tests Backticks around TinyBox in a doc comment, tests that return Result instead of calling expect(), and assert_ne! for the empty-name check. --- crates/tinybox-bus/src/storage/mod.rs | 2 +- crates/tinybox-bus/src/storage/mod_tests.rs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/tinybox-bus/src/storage/mod.rs b/crates/tinybox-bus/src/storage/mod.rs index 86c25ff4..f7ae4c75 100644 --- a/crates/tinybox-bus/src/storage/mod.rs +++ b/crates/tinybox-bus/src/storage/mod.rs @@ -38,7 +38,7 @@ pub struct CollectionDeclaration { } impl CollectionDeclaration { - /// Declare a TinyBox collection by name. + /// Declare a `TinyBox` collection by name. #[must_use] pub fn new(name: impl Into) -> Self { Self { name: name.into() } diff --git a/crates/tinybox-bus/src/storage/mod_tests.rs b/crates/tinybox-bus/src/storage/mod_tests.rs index 15f8ff36..a1ed8e57 100644 --- a/crates/tinybox-bus/src/storage/mod_tests.rs +++ b/crates/tinybox-bus/src/storage/mod_tests.rs @@ -24,26 +24,26 @@ fn current_and_legacy_collection_names_are_distinct() { } #[test] -fn storage_declarations_are_serializable_names_without_backend_policy() { +fn storage_declarations_are_serializable_names_without_backend_policy() -> serde_json::Result<()> { let declaration = CollectionDeclaration::new(BOX_COLLECTION); assert_eq!( - serde_json::to_value(&declaration).expect("serialize declaration"), + serde_json::to_value(&declaration)?, serde_json::json!({ "name": BOX_COLLECTION }) ); assert_eq!( serde_json::from_value::( serde_json::json!({ "name": BOX_COLLECTION }) - ) - .expect("deserialize declaration"), + )?, declaration ); + Ok(()) } #[test] fn every_declared_collection_has_a_nonempty_name() { for declaration in collections() { - assert!(!declaration.name.is_empty()); + assert_ne!(declaration.name, ""); } assert_eq!(BODY_VERSION, 2); } From dcb63278f87de41a314858d95aa048b9810ba7e9 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 01:56:21 +0530 Subject: [PATCH 223/236] Cover a native shell run that fails to start RunNativeShell maps a failure before the shell runs to the bus's backend error; a request naming an unsupported backend exercises it. --- .../src/tinybus_module/mod_tests.rs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index 5e1625a2..d844edd2 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -1494,6 +1494,34 @@ async fn run_native_shell_returns_finished_output_after_module_cleanup() Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn run_native_shell_reports_a_failed_start_as_a_backend_error() +-> Result<(), Box> { + let state = tempfile::tempdir()?; + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let _storage_host = serve_test_storage(&bus).await?; + 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 mut request = run_native_shell_request("echo never", state.path(), None); + request.create.backend = "no-such-backend".into(); + + let failed = proxy + .call::("RunNativeShell", (request,)) + .await; + + assert!( + matches!( + &failed, + Err(tinybus::Error::MethodFailed { name, .. }) if name == tinybox_bus::BACKEND_ERROR + ), + "{failed:?}" + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn run_native_shell_yields_an_owned_process_for_existing_output_operations() -> Result<(), Box> { From 2871c47952c3d45cd35492fc921f14d3b52253f0 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 01:57:30 +0530 Subject: [PATCH 224/236] Cover a native shell run cancelled at its timeout A shell still running at its timeout is cancelled through the module's own adapter and reported as a finished run that timed out. --- .../src/tinybus_module/mod_tests.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index d844edd2..42af0de9 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -1494,6 +1494,39 @@ async fn run_native_shell_returns_finished_output_after_module_cleanup() Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn run_native_shell_cancels_a_shell_that_outlives_its_timeout() +-> Result<(), Box> { + let state = tempfile::tempdir()?; + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + let _storage_host = serve_test_storage(&bus).await?; + 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 mut request = run_native_shell_request("sleep 30", state.path(), None); + request.shell.timeout_ms = 300; + + let result: tinybox_bus::RunNativeShellResult = tokio::time::timeout( + std::time::Duration::from_secs(20), + proxy.call("RunNativeShell", (request,)), + ) + .await??; + + assert!( + matches!( + result, + tinybox_bus::RunNativeShellResult::Finished { + timed_out: true, + .. + } + ), + "{result:?}" + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn run_native_shell_reports_a_failed_start_as_a_backend_error() -> Result<(), Box> { From c7af6997e383e4b9378904f19fba3719795b69e6 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 02:12:00 +0530 Subject: [PATCH 225/236] Satisfy clippy in the native shell run tests The request helper names BTreeMap::default(), and the two tests that expect a finished or a running shell return an error for the other outcome instead of panicking. --- crates/tinybox-module/src/tinybus_module/mod_tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index 42af0de9..53e44e57 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -1442,7 +1442,7 @@ fn run_native_shell_request( python_utf8: false, }, cwd: None, - extra_env: Default::default(), + extra_env: std::collections::BTreeMap::default(), state_dir: state_dir.to_string_lossy().into_owned(), timeout_ms: 5_000, stdin: None, @@ -1482,7 +1482,7 @@ async fn run_native_shell_returns_finished_output_after_module_cleanup() .. } = result else { - panic!("short shell should finish before returning: {result:?}"); + return Err(format!("short shell should finish before returning: {result:?}").into()); }; assert_eq!(exit_code, 0, "timed_out={timed_out}, stdout={stdout:?}"); assert_eq!(stdout, b"finished\n"); @@ -1585,7 +1585,7 @@ async fn run_native_shell_yields_an_owned_process_for_existing_output_operations stderr, } = result else { - panic!("long shell should yield while running: {result:?}"); + return Err(format!("long shell should yield while running: {result:?}").into()); }; let mut output = stdout; output.extend(stderr); From 496dca4eb4525877d3e0f259ca8c73055ef8b503 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 12:22:22 +0530 Subject: [PATCH 226/236] Fail the unsafe boundary check on a crate that drops the workspace lints A member without `[lints] workspace = true` has no unsafe_code lint at all, so the root deny never reaches it. --- scripts/ci/check-windows-unsafe-boundary.sh | 8 ++++++++ scripts/ci/check-windows-unsafe-boundary_tests.sh | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/ci/check-windows-unsafe-boundary.sh b/scripts/ci/check-windows-unsafe-boundary.sh index dcba445f..f8837069 100755 --- a/scripts/ci/check-windows-unsafe-boundary.sh +++ b/scripts/ci/check-windows-unsafe-boundary.sh @@ -3,6 +3,7 @@ # allow it, file-scoped. Any other code mention of the lint is a relaxation, # whatever its spelling: `allow`, `expect`, `warn`, `cfg_attr(.., allow(..))`, # a multi-line attribute, or a crate manifest override. Comment lines are prose. +# Every crate must inherit the workspace lints, or it has no unsafe_code lint. set -euo pipefail root="${1:-.}" @@ -44,6 +45,13 @@ if ! grep -Eq '^unsafe_code = "deny"$' Cargo.toml; then exit 1 fi +for manifest in crates/*/Cargo.toml; do + if ! awk '/^\[lints\]$/ { getline; found = $0 == "workspace = true" } END { exit !found }' "$manifest"; then + printf '%s must inherit the workspace lints: [lints] workspace = true\n' "$manifest" >&2 + exit 1 + fi +done + if [[ "$(grep -cxF "$allowed_line" "$allowed_file")" != 1 ]]; then echo 'Windows FFI exception must remain one file-scoped allowance' >&2 exit 1 diff --git a/scripts/ci/check-windows-unsafe-boundary_tests.sh b/scripts/ci/check-windows-unsafe-boundary_tests.sh index aa20f3e3..fe1fc7fe 100755 --- a/scripts/ci/check-windows-unsafe-boundary_tests.sh +++ b/scripts/ci/check-windows-unsafe-boundary_tests.sh @@ -14,7 +14,7 @@ tree() { printf '[workspace.lints.rust]\nunsafe_code = "deny"\n' > "$dir/Cargo.toml" printf '//! Windows.\n#![allow(unsafe_code)]\n' > "$dir/crates/tinybox-jail/src/windows.rs" printf '//! Mentions `unsafe_code` in prose only.\n' > "$dir/crates/other/src/lib.rs" - printf '[package]\nname = "other"\n' > "$dir/crates/other/Cargo.toml" + printf '[package]\nname = "other"\n\n[lints]\nworkspace = true\n' > "$dir/crates/other/Cargo.toml" printf '%s' "$dir" } @@ -48,6 +48,10 @@ dir="$(tree manifest)" printf '[lints.rust]\nunsafe_code = "allow"\n' >> "$dir/crates/other/Cargo.toml" expect fail crate-manifest-override "$dir" +dir="$(tree no-inheritance)" +printf '[package]\nname = "other"\n' > "$dir/crates/other/Cargo.toml" +expect fail crate-without-workspace-lints "$dir" + dir="$(tree workspace-allow)" printf '[workspace.lints.rust]\nunsafe_code = "allow"\n' > "$dir/Cargo.toml" expect fail workspace-relaxed "$dir" From e756e4fc8b16f0d92a3adc2651498163fd1ef511 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 12:23:05 +0530 Subject: [PATCH 227/236] Start a native shell with its normalized timeout and check chunk order A zero timeout was floored for collection but sent as zero to the module, which refuses it. Output chunks must now continue exactly at the cursor, so a replayed or skipped chunk fails as an invalid cursor. The failing-cancel test keeps its read pending so cancellation always wins the select. --- crates/tinybox-host/src/native_shell.rs | 16 +++-- crates/tinybox-host/src/native_shell_tests.rs | 64 ++++++++++++------- 2 files changed, 53 insertions(+), 27 deletions(-) diff --git a/crates/tinybox-host/src/native_shell.rs b/crates/tinybox-host/src/native_shell.rs index 7afde475..e1f783f0 100644 --- a/crates/tinybox-host/src/native_shell.rs +++ b/crates/tinybox-host/src/native_shell.rs @@ -114,7 +114,8 @@ pub enum NativeShellError { }, /// The module reported a terminal failed state. Execution(ExecutionFailure), - /// An output batch did not advance its cursor despite containing chunks. + /// An output batch did not continue exactly at the cursor: a replayed or + /// skipped chunk, or a next sequence that disagrees with its chunks. InvalidOutputCursor, /// Cancellation failed, so native cleanup is not acknowledged. Cancellation(E), @@ -167,9 +168,10 @@ where operation: "reserve resource", source, })?; + let mut shell = request.shell; + shell.timeout_ms = timeout_ms; let result = async { - let process = - start_native_shell(client, resource.clone(), request.create, request.shell).await?; + let process = start_native_shell(client, resource.clone(), request.create, shell).await?; collect_native_shell( client, process, @@ -369,7 +371,13 @@ impl NativeShellCapture { where F: FnMut(&OutputChunk), { - if !batch.chunks.is_empty() && batch.next_sequence <= self.cursor { + // Chunks must continue exactly at the cursor, with no replay or gap. + let contiguous = batch + .chunks + .iter() + .zip(self.cursor..) + .all(|(chunk, sequence)| chunk.sequence == sequence); + if !contiguous || batch.next_sequence != self.cursor + batch.chunks.len() as u64 { return false; } for chunk in &batch.chunks { diff --git a/crates/tinybox-host/src/native_shell_tests.rs b/crates/tinybox-host/src/native_shell_tests.rs index 4b4522c9..3ea14ce7 100644 --- a/crates/tinybox-host/src/native_shell_tests.rs +++ b/crates/tinybox-host/src/native_shell_tests.rs @@ -33,6 +33,7 @@ enum MockBehavior { InvalidCursorCancelFail, InvalidCursorReleaseFail, RunningStderr, + SkippedChunk, } impl NativeShellClient for MockClient { @@ -72,6 +73,7 @@ impl NativeShellClient for MockClient { std::future::ready(()).await; self.calls.lock().map_err(|_| "poisoned")?.push("start"); assert_eq!(request.process, ResourceId("process-1".into())); + assert!(request.timeout_ms > 0, "the module refuses a zero timeout"); if matches!(self.behavior, MockBehavior::FailStart) { return Err("start failed"); } @@ -89,7 +91,12 @@ impl NativeShellClient for MockClient { .lock() .map_err(|_| "poisoned")? .contains(&"cancel"); - if matches!(self.behavior, MockBehavior::TimedOut) && !cancelled { + // A cancellation test must reach `cancel`, so its read never wins the race. + if matches!( + self.behavior, + MockBehavior::TimedOut | MockBehavior::FailCancel + ) && !cancelled + { std::future::pending::<()>().await; } if matches!(self.behavior, MockBehavior::FailRead) { @@ -130,7 +137,7 @@ impl NativeShellClient for MockClient { Ok(OutputBatch { chunks: if cursor == 0 { vec![OutputChunk { - sequence: 0, + sequence: u64::from(matches!(self.behavior, MockBehavior::SkippedChunk)), stream: if matches!( self.behavior, MockBehavior::RunningStderr @@ -436,6 +443,13 @@ fn invalid_cursor_and_cancel_cleanup_errors_are_reported() { Err(NativeShellError::InvalidOutputCursor) )); }); + futures_executor::block_on(async { + let client = mock(MockBehavior::SkippedChunk); + assert!(matches!( + run_native_shell(&client, request(), never_cancel(), ignore_chunk).await, + Err(NativeShellError::InvalidOutputCursor) + )); + }); futures_executor::block_on(async { let client = mock(MockBehavior::FailCancel); assert!(matches!( @@ -474,27 +488,31 @@ fn invalid_cursor_and_cancel_cleanup_errors_are_reported() { #[test] fn timeout_cancels_and_returns_collected_output() -> Result<(), &'static str> { - futures_executor::block_on(async { - let client = mock(MockBehavior::TimedOut); - let mut req = request(); - req.shell.timeout_ms = 1; - let outcome = run_native_shell(&client, req, never_cancel(), ignore_chunk) - .await - .map_err(|_| "run failed")?; - let NativeShellRunOutcome::Finished(output) = outcome else { - return Err("expected completion"); - }; - assert!(output.timed_out); - assert_eq!(output.exit_code, -1); - assert!( - client - .calls - .lock() - .map_err(|_| "poisoned")? - .contains(&"cancel") - ); - Ok(()) - }) + // A zero timeout starts with the same one-millisecond floor it collects with. + for timeout_ms in [0, 1] { + futures_executor::block_on(async { + let client = mock(MockBehavior::TimedOut); + let mut req = request(); + req.shell.timeout_ms = timeout_ms; + let outcome = run_native_shell(&client, req, never_cancel(), ignore_chunk) + .await + .map_err(|_| "run failed")?; + let NativeShellRunOutcome::Finished(output) = outcome else { + return Err("expected completion"); + }; + assert!(output.timed_out); + assert_eq!(output.exit_code, -1); + assert!( + client + .calls + .lock() + .map_err(|_| "poisoned")? + .contains(&"cancel") + ); + Ok(()) + })?; + } + Ok(()) } #[test] From 92c989e95f82a87ec6a351920f3d86e025d1f5e0 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 12:23:45 +0530 Subject: [PATCH 228/236] Give a jailed native shell its approved environment and keep timeout events honest The jailed arm called run_command_observed, which supplies an empty map, and the jail launcher then cleared the shell's approved environment. It now passes that environment explicitly. A timed-out stream keeps its CPU limit event and reports its exit only once cleanup is acknowledged. The lifecycle event test uses cmd on Windows, like the test beside it. --- crates/tinybox-host/src/limited/jailed.rs | 4 +- .../src/tinybus_module/mod_tests.rs | 6 +- .../src/tinybus_module/native_shell/mod.rs | 9 ++- .../tinybus_module/native_shell/mod_tests.rs | 5 ++ .../src/tinybus_module/resources_stream.rs | 19 ++++--- .../tinybus_module/resources_stream_tests.rs | 57 ++++++++++++++----- 6 files changed, 71 insertions(+), 29 deletions(-) diff --git a/crates/tinybox-host/src/limited/jailed.rs b/crates/tinybox-host/src/limited/jailed.rs index d612bdd3..c64ddc33 100644 --- a/crates/tinybox-host/src/limited/jailed.rs +++ b/crates/tinybox-host/src/limited/jailed.rs @@ -469,8 +469,8 @@ impl JailLocalHost { .await } - /// Collect a prepared shell inside this host's explicit native jail. - /// The caller clears and supplies the approved environment before invocation. + /// Collect a prepared shell inside this host's explicit native jail with an + /// empty environment; [`Self::run_command_observed_with_env`] passes one. /// # Errors /// Reports unavailable spawn, output bounds and retained kill/reap failures. pub async fn run_command_observed( diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index 53e44e57..65b04efe 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -959,11 +959,15 @@ async fn start_exec_broadcasts_correlated_process_lifecycle_events() -> tinybus: resource: resource.clone(), process: process_id.clone(), }; + #[cfg(windows)] + let argv = vec!["cmd".into(), "/C".into(), "exit 7".into()]; + #[cfg(not(windows))] + let argv = vec!["/bin/sh".into(), "-c".into(), "exit 7".into()]; let request = SpawnRequest { process: process_id.clone(), command: ExecRequest { resource: resource.clone(), - argv: vec!["/bin/sh".into(), "-c".into(), "exit 7".into()], + argv, cwd: None, env: std::collections::BTreeMap::new(), stdin: None, 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 56ce4f85..f18cbb1f 100644 --- a/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs +++ b/crates/tinybox-module/src/tinybus_module/native_shell/mod.rs @@ -158,8 +158,13 @@ impl ShellContext { .await } ShellHost::Jailed(host) => { - host.run_command_observed(command, self.request.stdin.clone(), observer) - .await + host.run_command_observed_with_env( + command, + self.request.stdin.clone(), + observer, + self.environment.clone(), + ) + .await } } } 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 89ea694d..485dbb02 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 @@ -125,6 +125,11 @@ async fn native_shell_jail_adds_owned_scratch_and_refuses_unsupported_constraint let mut resolved = tinybox_core::ExecRequest::new(["unused"]); resolved.cwd = Some(state.path().to_path_buf()); let mut request = request(state.path()); + // The approved environment reaches the jailed shell. + request.shell.command = r#"test "$TINYBOX_APPROVED" = visible"#.into(); + request + .extra_env + .insert("TINYBOX_APPROVED".into(), "visible".into()); request.jail = Some(tinybox_bus::NativeJailConfig { read_only: vec![], read_write: vec![], diff --git a/crates/tinybox-module/src/tinybus_module/resources_stream.rs b/crates/tinybox-module/src/tinybus_module/resources_stream.rs index 77fdbd2f..3a2b5add 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream.rs @@ -382,21 +382,22 @@ impl StreamExecution { ) -> Vec { use tinybox_bus::{LimitKind, SandboxEvent}; - if self.timed_out() { - return vec![ - SandboxEvent::LimitHit { - limit: LimitKind::Timeout, - }, - SandboxEvent::Exited { status: None }, - ]; - } - let mut events = Vec::new(); if self.journal.cpu_quota_hit() { events.push(SandboxEvent::LimitHit { limit: LimitKind::Cpu, }); } + if self.timed_out() { + events.push(SandboxEvent::LimitHit { + limit: LimitKind::Timeout, + }); + // A timed-out process has ended only once its cleanup is acknowledged. + if self.journal.cleanup_acknowledged() { + events.push(SandboxEvent::Exited { status: None }); + } + return events; + } if let Some(status) = status { events.push(SandboxEvent::Exited { status: Some(status), 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 0bf76fc0..3fc1a016 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -78,13 +78,50 @@ async fn live_admission_is_bounded_until_outputs_are_explicitly_released() -> Re fn cpu_quota_observer_cause_is_forwarded_before_process_exit() { use tinybox_core::ExecutionObserver; + let journal = Arc::new(OutputJournal::default()); + journal.termination_cause(tinybox_core::TerminationCause::CpuQuota); + let stream = stream_with(journal, false); + + assert_eq!( + stream.terminal_events(Some(0)), + vec![ + tinybox_bus::SandboxEvent::LimitHit { + limit: tinybox_bus::LimitKind::Cpu, + }, + tinybox_bus::SandboxEvent::Exited { status: Some(0) }, + ] + ); +} + +#[test] +fn a_timed_out_stream_keeps_its_cpu_event_and_exits_only_once_cleaned_up() { + use tinybox_bus::{LimitKind, SandboxEvent}; + use tinybox_core::ExecutionObserver; + + let cpu = SandboxEvent::LimitHit { + limit: LimitKind::Cpu, + }; + let timeout = SandboxEvent::LimitHit { + limit: LimitKind::Timeout, + }; + let journal = Arc::new(OutputJournal::default()); + journal.termination_cause(tinybox_core::TerminationCause::CpuQuota); + journal.finish_failed(ExecutionFailure::CleanupFailed); + let stream = stream_with(journal.clone(), true); + assert_eq!(stream.terminal_events(None), vec![cpu.clone(), timeout.clone()]); + journal.finish_failed(ExecutionFailure::TimedOut); + assert_eq!( + stream.terminal_events(None), + vec![cpu, timeout, SandboxEvent::Exited { status: None }] + ); +} + +fn stream_with(journal: Arc, timed_out: bool) -> StreamExecution { let process = ProcessRef { resource: ResourceId("resource".into()), process: ResourceId("process".into()), }; - let journal = Arc::new(OutputJournal::default()); - journal.termination_cause(tinybox_core::TerminationCause::CpuQuota); - let stream = StreamExecution { + StreamExecution { request: SpawnRequest { process: process.process.clone(), command: ExecRequest { @@ -101,19 +138,9 @@ fn cpu_quota_observer_cause_is_forwarded_before_process_exit() { journal, task: Mutex::new(None), collector: Collector::Host(Arc::new(tinybox_host::LimitedLocalHost::new(64)), None), - timed_out: Arc::new(std::sync::atomic::AtomicBool::new(false)), + timed_out: Arc::new(std::sync::atomic::AtomicBool::new(timed_out)), event_publisher_claimed: std::sync::atomic::AtomicBool::new(false), - }; - - assert_eq!( - stream.terminal_events(Some(0)), - vec![ - tinybox_bus::SandboxEvent::LimitHit { - limit: tinybox_bus::LimitKind::Cpu, - }, - tinybox_bus::SandboxEvent::Exited { status: Some(0) }, - ] - ); + } } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] From 1496e57b2a20bacdcbab017256ab324399b87f7b Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 12:24:18 +0530 Subject: [PATCH 229/236] Report the setrlimit fallback's memory cap as best effort and check prlimit at create RLIMIT_AS bounds each process, not the box, so a hard memory requirement is now refused under the fallback. A fallback box is refused at create when no trusted prlimit exists, instead of at its first exec. The cgroup CPU report reads cpu.stat with a bash builtin rather than a cat found through PATH. --- crates/tinybox-linux/src/sandbox/args.rs | 10 +++--- crates/tinybox-linux/src/sandbox/mod.rs | 11 ++++-- crates/tinybox-linux/src/sandbox/mod_tests.rs | 35 ++++++++++++++++--- 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/crates/tinybox-linux/src/sandbox/args.rs b/crates/tinybox-linux/src/sandbox/args.rs index 878ce231..03f3c1bf 100644 --- a/crates/tinybox-linux/src/sandbox/args.rs +++ b/crates/tinybox-linux/src/sandbox/args.rs @@ -57,11 +57,11 @@ pub(super) const fn resource_constraint_support( .with(Constraint::Cpu, Enforcement::Enforced) .with(Constraint::Memory, Enforcement::Enforced) .with(Constraint::Pids, Enforcement::Enforced), - // RLIMIT_AS gives each process in the box a hard address-space ceiling. - // It cannot cap aggregate memory, CPU rate or process count, so those - // constraints must stay unsupported in the preflight contract. + // RLIMIT_AS gives each process in the box a hard address-space ceiling, + // but the box's processes together can exceed it, so memory is only + // best effort. CPU rate and process count have no equivalent at all. ResourceEnforcement::Setrlimit => { - ConstraintSupport::NONE.with(Constraint::Memory, Enforcement::Enforced) + ConstraintSupport::NONE.with(Constraint::Memory, Enforcement::BestEffort) } ResourceEnforcement::Unavailable => ConstraintSupport::NONE, } @@ -392,7 +392,7 @@ if [ -n "$report" ]; then stats= case $control_group in /*) stats=/sys/fs/cgroup${control_group}/cpu.stat;; esac if [ -r "$stats" ]; then - cat "$stats" > "$report" 2>/dev/null || : + { stat=$(<"$stats") && printf '%s\n' "$stat" > "$report"; } 2>/dev/null || : fi fi exit "$status" diff --git a/crates/tinybox-linux/src/sandbox/mod.rs b/crates/tinybox-linux/src/sandbox/mod.rs index 28eaad0a..4b0c5fb5 100644 --- a/crates/tinybox-linux/src/sandbox/mod.rs +++ b/crates/tinybox-linux/src/sandbox/mod.rs @@ -83,8 +83,9 @@ impl NamespaceSandbox { } /// Use `RLIMIT_AS` for a per-process memory ceiling when cgroup v2 is not - /// selected. CPU quota and per-box PID limits remain unsupported in this - /// fallback because `setrlimit` has no equivalent aggregate controls. + /// selected. Memory is best effort, since processes together can exceed + /// it; CPU quota and per-box PID limits remain unsupported because + /// `setrlimit` has no equivalent aggregate controls. #[must_use] pub fn with_setrlimit_fallback(mut self) -> Self { self.setrlimit_fallback = true; @@ -143,6 +144,12 @@ impl Sandbox for NamespaceSandbox { // source now means an unusable spec fails at `create` rather than // surprising the caller at the first `exec`. args::workspace_dir(spec)?; + if !self.limits && self.setrlimit_fallback { + args::trusted_prlimit_program().map_err(|_| Error::Unsupported { + sandbox: NAME.to_owned(), + capability: Capability::ResourceLimits, + })?; + } tinybox_core::insert_new(self.store.as_ref(), BoxState::Ready, spec, self.clock.now()) } diff --git a/crates/tinybox-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index 63aa2e62..15d53c2f 100644 --- a/crates/tinybox-linux/src/sandbox/mod_tests.rs +++ b/crates/tinybox-linux/src/sandbox/mod_tests.rs @@ -487,6 +487,10 @@ fn native_jail_command_keeps_limits_and_target_environment_after_namespace_setup .any(|part| part.contains("TINYBOX_INTERNAL_CPU_EVENT_FILE")) ); assert!(argv.iter().any(|part| part.contains("cpu.stat"))); + assert!( + !argv.iter().any(|part| part.contains("cat \"")), + "the CPU report reads its stats with a shell builtin, not PATH" + ); assert!(has(&argv, "--ro-bind")); assert!(has(&argv, grant.to_str().ok_or("grant path is not UTF-8")?)); assert!(has(&argv, "BASH_ENV")); @@ -907,7 +911,7 @@ fn resource_enforcement_selection_uses_cgroups_then_setrlimit_fallback() { } assert_eq!( fallback.enforcement(Constraint::Memory), - Enforcement::Enforced + Enforcement::BestEffort ); for constraint in [Constraint::Cpu, Constraint::Pids] { assert_eq!(fallback.enforcement(constraint), Enforcement::Unsupported); @@ -923,7 +927,7 @@ fn resource_enforcement_selection_uses_cgroups_then_setrlimit_fallback() { } #[tokio::test] -async fn setrlimit_fallback_refuses_cpu_and_pids_hard_requirements() -> Result<()> { +async fn setrlimit_fallback_refuses_resource_hard_requirements() -> Result<()> { use tinybox_core::Constraint; let sandbox = NamespaceSandbox::new( @@ -932,9 +936,8 @@ async fn setrlimit_fallback_refuses_cpu_and_pids_hard_requirements() -> Result<( ) .with_setrlimit_fallback(); - let memory = spec()?.with_hard_requirements([Constraint::Memory]); - sandbox.require_spec(&memory)?; - for constraint in [Constraint::Cpu, Constraint::Pids] { + // A per-process address-space ceiling is not a cap on the whole box. + for constraint in [Constraint::Memory, Constraint::Cpu, Constraint::Pids] { let requested = spec()?.with_hard_requirements([constraint]); assert!(matches!( sandbox.create(&requested).await, @@ -944,6 +947,28 @@ async fn setrlimit_fallback_refuses_cpu_and_pids_hard_requirements() -> Result<( Ok(()) } +#[tokio::test] +async fn setrlimit_fallback_creates_a_box_only_with_a_trusted_prlimit() -> Result<()> { + let sandbox = NamespaceSandbox::new( + Arc::new(RecordingHost::default()), + Arc::new(MemoryStore::new()), + ) + .with_setrlimit_fallback(); + let created = sandbox.create(&spec()?).await; + if args::trusted_prlimit_program().is_ok() { + assert_eq!(created?.state, BoxState::Ready); + } else { + assert!(matches!( + created, + Err(Error::Unsupported { + capability: tinybox_core::Capability::ResourceLimits, + .. + }) + )); + } + Ok(()) +} + #[test] fn setrlimit_fallback_wraps_commands_with_address_space_limit() -> Result<()> { let spec = spec()?.with_resources(Resources { From 609d9c3cf69b6fc9ab71c0048bba2a72e973b81c Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 12:26:11 +0530 Subject: [PATCH 230/236] Deny what a jail's hard requirements deny and apply the approved environment A hard Network or Subprocess requirement now clears allow_net or allow_subprocess, so a backend never launches with access its preflight said was denied. The default owned spawn with an environment clears the command's own environment and applies the approved map. The spawns without a map go to spawn_owned_captured, so the child keeps the environment the command sets. The README and lib comment describe the Windows backend as it now ships. --- crates/tinybox-jail/README.md | 6 ++-- crates/tinybox-jail/src/jail.rs | 27 +++++++++++----- crates/tinybox-jail/src/jail_tests.rs | 46 +++++++++++++++++++++++++++ crates/tinybox-jail/src/lib.rs | 28 ++++++++-------- 4 files changed, 81 insertions(+), 26 deletions(-) diff --git a/crates/tinybox-jail/README.md b/crates/tinybox-jail/README.md index c8fb96a6..879d4f5e 100644 --- a/crates/tinybox-jail/README.md +++ b/crates/tinybox-jail/README.md @@ -3,8 +3,8 @@ Directory-jail facade. Given a declarative description of a workspace (`Jail`), it spawns a child through an available sandbox backend. Linux (Landlock) and macOS (Seatbelt) backends are compiled and selected by -`pick_backend()`. The Windows AppContainer backend is still not compiled (see -below). When no backend is usable the default backend is `unsupported` +`pick_backend()`. On Windows the AppContainer backend serves the owned-child +API (`spawn_owned_captured`); the legacy `spawn` refuses there (see below). When no backend is usable the default backend is `unsupported` (`is_available() == false`, `spawn` fails with `Unsupported`); callers can explicitly select `NoopBackend` when unrestricted execution is intended. It is a per-process complement to the box-level isolation in `tinybox-linux`: @@ -44,7 +44,7 @@ spawns, never the core process itself. | `crates/tinybox-jail/src/bwrap/` | Bubblewrap backend (Linux only): user/pid/ipc/uts (+net) namespaces, grants as bind mounts, `deny_read` masked, `--die-with-parent`. `isolation()` is the minimum over configurations (`Process` with `--disable-userns`, else `None`); `Kernel` only per jail without `allow_net` (`isolation_for` / `is_suitable_for_untrusted_jail`; hosts must use the per-jail form). Never looks `bwrap` up on `PATH`; the probe is a sanity check, not authentication, and `with_program` skips the ownership check. The caller's environment and credentials travel in a private `--args` file, not in argv or any host process environment (`launcher.rs`), and inherited descriptors above 2 are closed by that launcher. No seccomp filter and no resource limits yet. Landlock is the fallback. Landlock is not layered inside because the kernel denies `mount` to a Landlock-restricted thread. | | `crates/tinybox-jail/src/linux.rs` | Landlock backend (Linux only, `landlock` feature, on by default). Applies the ruleset to a dedicated spawn thread so no `unsafe` `pre_exec` is needed. | | `crates/tinybox-jail/src/macos.rs` | Seatbelt backend via `sandbox-exec`. Compiled on every host so the profile renderer is unit-tested everywhere; selected only on macOS. | -| `crates/tinybox-jail/src/windows.rs` | AppContainer implementation; **not compiled**: it needs `unsafe` FFI the workspace forbids and cannot return a waitable `std::process::Child` yet. | +| `crates/tinybox-jail/src/windows.rs` | `AppContainerBackend`, compiled on Windows only under the one file-scoped `unsafe_code` allowance. Available through `spawn_owned_captured` / `spawn_owned_captured_with`; the legacy `spawn` returns `Unsupported` because it cannot return a waitable `std::process::Child`. | | `crates/tinybox-jail/src/registry.rs` | `JailRegistry` and `JailRecord`: multi-jail manager persisted to `index.json`, with atomic-rename writes and containment checks. | | `crates/tinybox-jail/src/{lib,jail,noop,macos,windows,registry}_tests.rs` | Sibling test suites, each `#[path]`-included from its source file. | diff --git a/crates/tinybox-jail/src/jail.rs b/crates/tinybox-jail/src/jail.rs index 81045361..3b9aa4dc 100644 --- a/crates/tinybox-jail/src/jail.rs +++ b/crates/tinybox-jail/src/jail.rs @@ -109,15 +109,25 @@ impl Jail { requirements: impl IntoIterator, ) -> Self { self.hard_requirements = requirements.into_iter().collect(); - self + self.deny_required() } /// Copy resource defaults and hard constraints from a box specification. - /// Filesystem roots, grants, and network policy remain explicit Jail data. + /// Filesystem roots and grants remain explicit Jail data; a hard network + /// or subprocess requirement denies that access. #[must_use] pub fn with_box_spec(mut self, spec: &tinybox_core::BoxSpec) -> Self { self.resources = Some(spec.resources); self.hard_requirements.clone_from(&spec.hard_requirements); + self.deny_required() + } + + /// A hard `Network` or `Subprocess` requirement is a denial, so the flags + /// backends launch from agree with what preflight checked. + fn deny_required(mut self) -> Self { + use tinybox_core::Constraint; + self.allow_net &= !self.hard_requirements.contains(&Constraint::Network); + self.allow_subprocess &= !self.hard_requirements.contains(&Constraint::Subprocess); self } @@ -512,18 +522,19 @@ pub trait JailBackend: Send + Sync { } } - /// Spawn captured work with an explicit environment approved by the host. - /// Backends that launch through `std::process::Command` already receive - /// this map through `cmd`; native launchers must use it instead of - /// inheriting the parent environment. + /// Spawn captured work with exactly the environment approved by the host. + /// The default clears `cmd`'s environment and applies `env` before + /// delegating; native launchers must build the child environment from + /// `env` instead of inheriting the parent's. /// # Errors /// Returns a confinement or native startup error. fn spawn_owned_captured_with_env( &self, jail: &Jail, - cmd: Command, - _env: &std::collections::BTreeMap, + mut cmd: Command, + env: &std::collections::BTreeMap, ) -> std::io::Result { + cmd.env_clear().envs(env); self.spawn_owned_captured(jail, cmd) } } diff --git a/crates/tinybox-jail/src/jail_tests.rs b/crates/tinybox-jail/src/jail_tests.rs index e6e9938d..348c472d 100644 --- a/crates/tinybox-jail/src/jail_tests.rs +++ b/crates/tinybox-jail/src/jail_tests.rs @@ -36,6 +36,25 @@ fn soft_resource_values_do_not_become_hard_constraints() { ); } +#[test] +fn hard_network_and_subprocess_requirements_deny_that_access() { + use tinybox_core::{Constraint, HostRef, Placement, SandboxRef, WorkspaceSource}; + + let jail = + Jail::new("/tmp", "x").with_hard_requirements([Constraint::Network, Constraint::Cpu]); + assert!(!jail.allow_net); + assert!(jail.allow_subprocess); + let placement = Placement::new( + HostRef::new("local").expect("valid host ref"), + SandboxRef::new("noop").expect("valid sandbox ref"), + ); + let spec = tinybox_core::BoxSpec::new(placement, WorkspaceSource::LocalDir("/tmp".into())) + .with_hard_requirements([Constraint::Subprocess]); + let jail = Jail::new("/tmp", "x").with_box_spec(&spec); + assert!(jail.allow_net); + assert!(!jail.allow_subprocess); +} + #[test] fn deny_net_is_idempotent() { let j = Jail::new("/tmp", "x").deny_net().deny_net(); @@ -168,6 +187,33 @@ fn owned_captured_spawn_preserves_both_pipes_and_exit_status() -> std::io::Resul Ok(()) } +#[cfg(unix)] +#[test] +fn owned_spawn_with_an_approved_environment_drops_everything_else() -> std::io::Result<()> { + use std::io::Read; + + let root = tempfile::tempdir()?; + let jail = Jail::new(root.path(), "owned-approved-env"); + let mut command = std::process::Command::new("/bin/sh"); + command + .args(["-c", r#"printf '%s|%s' "${APPROVED-}" "${UNAPPROVED-unset}""#]) + .env("UNAPPROVED", "leaked"); + let approved = std::collections::BTreeMap::from([("APPROVED".to_owned(), "yes".to_owned())]); + let mut child = crate::spawn_owned_captured_with_backend_and_env( + &crate::NoopBackend, + &jail, + command, + &approved, + )?; + let mut stdout = String::new(); + child + .stdout_mut() + .expect("captured stdout") + .read_to_string(&mut stdout)?; + assert_eq!(stdout, "yes|unset"); + Ok(()) +} + #[cfg(windows)] #[test] fn owned_captured_spawn_does_not_claim_the_child_pid_as_a_process_group() -> std::io::Result<()> { diff --git a/crates/tinybox-jail/src/lib.rs b/crates/tinybox-jail/src/lib.rs index 69d88633..eae82bed 100644 --- a/crates/tinybox-jail/src/lib.rs +++ b/crates/tinybox-jail/src/lib.rs @@ -59,8 +59,9 @@ pub mod registry; // module is plain `std` (it shells out to `sandbox-exec`), so it compiles // everywhere and its profile renderer is unit-tested on every host; it is only // *selected* on macOS. The Windows AppContainer module (`windows.rs`) is -// deliberately not compiled: it needs `unsafe` FFI the workspace forbids and -// cannot yet return a waitable `std::process::Child`. +// compiled on Windows only, under the single file-scoped `unsafe_code` +// allowance; it serves the owned-child API, since it cannot return a waitable +// `std::process::Child` for the legacy `spawn`. #[cfg(target_os = "linux")] pub mod bwrap; #[cfg(target_os = "linux")] @@ -163,6 +164,7 @@ pub fn spawn_captured_with( /// The backend is responsible for retaining the process handle while callers /// consume the returned standard pipes. This is the extensible path for /// process owners that cannot be represented by `std::process::Child`. +/// The child gets the environment `cmd` describes. /// # Errors /// Returns canonicalization, confinement, or native startup errors. pub fn spawn_owned_captured_with( @@ -170,12 +172,7 @@ pub fn spawn_owned_captured_with( jail: &Jail, cmd: Command, ) -> std::io::Result { - spawn_owned_captured_with_backend_and_env( - backend, - jail, - cmd, - &std::collections::BTreeMap::new(), - ) + backend.spawn_owned_captured(&checked_owned_jail(backend, jail)?, cmd) } /// Spawn captured work through an explicit backend and approved environment. @@ -189,24 +186,25 @@ pub fn spawn_owned_captured_with_backend_and_env( cmd: Command, env: &std::collections::BTreeMap, ) -> std::io::Result { + backend.spawn_owned_captured_with_env(&checked_owned_jail(backend, jail)?, cmd, env) +} + +/// Canonicalize `jail` and run the hard preflight before an owned spawn. +fn checked_owned_jail(backend: &dyn JailBackend, jail: &Jail) -> std::io::Result { let mut jail = jail.clone(); jail.canonicalize()?; require_resources(backend, &jail)?; jail.validate_denials(backend.carves_denials(), &backend.implicit_grants())?; - backend.spawn_owned_captured_with_env(&jail, cmd, env) + Ok(jail) } /// Spawn captured work with the detected backend that supports owned children. +/// The child gets the environment `cmd` describes. /// /// # Errors /// Returns an error if no owned-child backend is available or jail setup fails. pub fn spawn_owned_captured(jail: &Jail, cmd: Command) -> std::io::Result { - spawn_owned_captured_with_backend_and_env( - default_owned_backend().as_ref(), - jail, - cmd, - &std::collections::BTreeMap::new(), - ) + spawn_owned_captured_with(default_owned_backend().as_ref(), jail, cmd) } /// Spawn captured work with the detected owned-child backend and approved environment. From fadf8337448ebcb179f19c8c81cf2a28e00645eb Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 12:26:11 +0530 Subject: [PATCH 231/236] Keep Windows profiles distinct, CPU rates honest and command env intact Profile names carry a hash of the full label and stay within 64 characters, so labels that sanitize alike no longer share a SID. A CPU limit below the smallest job rate is refused instead of being rounded up. A spawn without an environment map passes the variables the command sets. --- crates/tinybox-jail/src/windows.rs | 48 +++++++++++++++++++----- crates/tinybox-jail/src/windows_tests.rs | 32 ++++++++++++++-- 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/crates/tinybox-jail/src/windows.rs b/crates/tinybox-jail/src/windows.rs index 10e79370..d11e5574 100644 --- a/crates/tinybox-jail/src/windows.rs +++ b/crates/tinybox-jail/src/windows.rs @@ -161,7 +161,8 @@ impl JailBackend for AppContainerBackend { jail: &Jail, cmd: Command, ) -> io::Result { - self.spawn_owned_captured_with_env(jail, cmd, &BTreeMap::new()) + let env = command_environment(&cmd)?; + self.spawn_owned_captured_with_env(jail, cmd, &env) } fn spawn_owned_captured_with_env( @@ -186,6 +187,21 @@ impl JailBackend for AppContainerBackend { } } +/// The variables `cmd` sets explicitly. An `AppContainer` child inherits +/// nothing from the parent, so a removal has nothing to remove. +fn command_environment(cmd: &Command) -> io::Result> { + cmd.get_envs() + .filter_map(|(key, value)| Some((key, value?))) + .map(|(key, value)| match (key.to_str(), value.to_str()) { + (Some(key), Some(value)) => Ok((key.to_owned(), value.to_owned())), + _ => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Windows environment entries must be Unicode", + )), + }) + .collect() +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct JobObjectLimitPlan { process_memory_bytes: Option, @@ -204,11 +220,19 @@ fn job_object_limit_plan( .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; let processor_count = u64::from(processor_count.max(1)); let total_cpu_millis = processor_count * 1_000; - let cpu_rate = hard_requirements - .contains(&tinybox_core::Constraint::Cpu) - .then(|| { - ((u64::from(resources.cpu_millis) * 10_000) / total_cpu_millis).clamp(1, 10_000) as u32 - }); + let cpu_rate = if hard_requirements.contains(&tinybox_core::Constraint::Cpu) { + // Rounding up to the smallest rate would grant more than requested. + let rate = (u64::from(resources.cpu_millis) * 10_000) / total_cpu_millis; + if rate == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "CPU limit is below the smallest Windows job CPU rate", + )); + } + Some(rate.min(10_000) as u32) + } else { + None + }; let process_memory_bytes = if hard_requirements.contains(&tinybox_core::Constraint::Memory) { Some(usize::try_from(resources.memory_bytes).map_err(|_| { io::Error::new( @@ -803,7 +827,12 @@ fn to_wide(s: &str) -> Vec { fn sanitize_profile_name(label: &str) -> String { // AppContainer profile names must be ≤ 64 chars and use a restricted - // charset. Keep ASCII alnum + dot; map everything else to `_`. + // charset. Keep ASCII alnum + dot and map everything else to `_`; then a + // hash of the full label keeps labels that sanitize or truncate alike + // (`a-b` and `a_b`) on separate profiles, and so separate SIDs. + let hash = label.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3) + }); let mut s: String = label .chars() .map(|c| { @@ -814,8 +843,9 @@ fn sanitize_profile_name(label: &str) -> String { } }) .collect(); - s.truncate(60); - format!("openhuman.{s}") + // "openhuman." (10) + 37 + "." (1) + 16 hex digits = 64. + s.truncate(37); + format!("openhuman.{s}.{hash:016x}") } struct SidGuard(PSID); diff --git a/crates/tinybox-jail/src/windows_tests.rs b/crates/tinybox-jail/src/windows_tests.rs index 8da7ff1f..f322fccc 100644 --- a/crates/tinybox-jail/src/windows_tests.rs +++ b/crates/tinybox-jail/src/windows_tests.rs @@ -40,16 +40,32 @@ fn sanitize_profile_name_keeps_alnum_and_dot() { assert!(s.starts_with("openhuman.")); // hyphen mapped to underscore assert!(s.contains("agent.delegate_7")); - assert!(s.len() <= 70); // "openhuman." (10) + label (≤60) + assert!(s.len() <= 64); } #[test] fn sanitize_profile_name_truncates_long_labels() { let long: String = "a".repeat(200); let s = sanitize_profile_name(&long); - // Per the in-function comment: truncate to 60 then prefix. - assert!(s.len() <= 70); + assert_eq!(s.len(), 64); assert!(s.starts_with("openhuman.")); + assert_ne!(s, sanitize_profile_name(&"a".repeat(201))); +} + +#[test] +fn labels_that_sanitize_alike_get_separate_profiles() { + assert_ne!(sanitize_profile_name("a-b"), sanitize_profile_name("a_b")); + assert_eq!(sanitize_profile_name("a-b"), sanitize_profile_name("a-b")); +} + +#[test] +fn a_command_environment_without_a_map_reaches_the_child_map() { + let mut command = Command::new("cmd"); + command.env("TOKEN", "approved").env_remove("GONE"); + assert_eq!( + command_environment(&command).expect("Unicode environment"), + BTreeMap::from([("TOKEN".to_owned(), "approved".to_owned())]) + ); } /// PR #4723 review — `AppContainerBackend::is_available()` must @@ -131,6 +147,16 @@ fn job_object_limit_plan_sets_memory_process_cpu_and_kill_on_close() { assert!(limits.kill_on_close); } +#[test] +fn a_cpu_limit_below_the_smallest_job_rate_is_refused() { + let resources = tinybox_core::Resources { + cpu_millis: 1, + ..tinybox_core::Resources::DEFAULT + }; + assert!(job_object_limit_plan(resources, 128, &[tinybox_core::Constraint::Cpu]).is_err()); + assert!(job_object_limit_plan(resources, 1, &[tinybox_core::Constraint::Cpu]).is_ok()); +} + #[test] fn soft_resource_values_do_not_create_hard_job_limits() { let limits = job_object_limit_plan(tinybox_core::Resources::DEFAULT, 8, &[]) From 027aa84fb357ca6d4273f2e189a8d841f6701bf8 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 12:26:51 +0530 Subject: [PATCH 232/236] Allow the canonical tinystoragedrivers git source The storage crates moved back to tinyhumansai/tinystoragedrivers, but deny.toml still allowed only the fork, so cargo-deny's source check failed. Nothing in Cargo.lock uses the fork any more. --- deny.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deny.toml b/deny.toml index ea4b021a..9452d443 100644 --- a/deny.toml +++ b/deny.toml @@ -39,7 +39,7 @@ unknown-registry = "deny" unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"] # `tinybox-drivers` (never published) takes the storage ports from the -# forked tinystoragedrivers v2.1.2 commit used by the security epic. +# canonical tinystoragedrivers v2.1 commit. allow-git = [ - "https://github.com/senamakel/tinystoragedrivers", + "https://github.com/tinyhumansai/tinystoragedrivers", ] From 0d6c04c7ca1f8327856c784a7757596238097e48 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 12:26:51 +0530 Subject: [PATCH 233/236] Run the native shell output tests portably on Windows cmd ends echo lines with CRLF, and does not take `;` as a command separator, so the yield test exited 1 there. Line endings are normalized and the yield test waits with ping under cmd. --- .../src/tinybus_module/mod_tests.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/tinybox-module/src/tinybus_module/mod_tests.rs b/crates/tinybox-module/src/tinybus_module/mod_tests.rs index 65b04efe..23aea3b1 100644 --- a/crates/tinybox-module/src/tinybus_module/mod_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/mod_tests.rs @@ -1489,7 +1489,11 @@ async fn run_native_shell_returns_finished_output_after_module_cleanup() return Err(format!("short shell should finish before returning: {result:?}").into()); }; assert_eq!(exit_code, 0, "timed_out={timed_out}, stdout={stdout:?}"); - assert_eq!(stdout, b"finished\n"); + // cmd ends `echo` lines with CRLF. + assert_eq!( + String::from_utf8_lossy(&stdout).replace("\r\n", "\n"), + "finished\n" + ); assert!(!timed_out); assert_eq!( std::fs::read_dir(state.path().join("artifacts/sandbox-scratch"))?.count(), @@ -1575,7 +1579,12 @@ async fn run_native_shell_yields_an_owned_process_for_existing_output_operations .call( "RunNativeShell", (run_native_shell_request( - "sleep 0.2; echo yielded", + // The shell is cmd on Windows, where `;` does not separate commands. + if cfg!(windows) { + "ping -n 2 127.0.0.1 >nul & echo yielded" + } else { + "sleep 0.2; echo yielded" + }, state.path(), Some(10), ),), @@ -1609,7 +1618,10 @@ async fn run_native_shell_yields_an_owned_process_for_existing_output_operations batch.state, tinybox_bus::ExecutionState::Exited { exit_code: 0 } ); - assert_eq!(output, b"yielded\n"); + assert_eq!( + String::from_utf8_lossy(&output).replace("\r\n", "\n"), + "yielded\n" + ); proxy.call::<()>("ReleaseOutput", (process,)).await?; proxy.call::<()>("Close", (resource,)).await?; Ok(()) From b0c3884e74a2f3699739178aece79bd4c2c77836 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 12:27:40 +0530 Subject: [PATCH 234/236] Say why tinybox-host's tests depend on futures-executor --- crates/tinybox-host/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tinybox-host/Cargo.toml b/crates/tinybox-host/Cargo.toml index dbe15cd2..4a65d08c 100644 --- a/crates/tinybox-host/Cargo.toml +++ b/crates/tinybox-host/Cargo.toml @@ -39,6 +39,7 @@ command-group = { version = "5", features = ["with-tokio"] } [dev-dependencies] # Command tests run against real temporary directories rather than the CWD. tempfile = "3" +# Drives the runtime-neutral native shell orchestration tests without a runtime. futures-executor = "0.3" [lints] From 4a9b24c5819881414d6803ffb7444b26bfe68fc9 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 12:28:20 +0530 Subject: [PATCH 235/236] Report a jailed run's CPU throttling before returning its collection error An output-limit or cancelled collection returned early and skipped the CPU report, so a throttled run lost its CpuQuota cause. --- crates/tinybox-host/src/limited/jailed.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinybox-host/src/limited/jailed.rs b/crates/tinybox-host/src/limited/jailed.rs index c64ddc33..c29cd944 100644 --- a/crates/tinybox-host/src/limited/jailed.rs +++ b/crates/tinybox-host/src/limited/jailed.rs @@ -597,10 +597,10 @@ impl JailLocalHost { .await .map_err(|error| failure("collect native jail", error.to_string())); drop(cancel); - let output = result??; + // A failed collection (output limit, cancellation) may still have been throttled. #[cfg(target_os = "linux")] report_cpu_termination(cpu_event_report.as_ref(), event_observer.as_ref()); - Ok(output) + result? } } From 3dc5fdb5a6d028ec7c664f51f76d196ed10a1bb0 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 3 Nov 2026 12:39:25 +0530 Subject: [PATCH 236/236] Apply rustfmt and share the setrlimit fallback's prlimit check create and exec now resolve the trusted prlimit through one helper, and a test covers each case of it, including the refusal when none exists. --- crates/tinybox-jail/src/jail_tests.rs | 5 +++- crates/tinybox-linux/src/sandbox/args.rs | 29 ++++++++++++++----- crates/tinybox-linux/src/sandbox/mod.rs | 11 ++++--- crates/tinybox-linux/src/sandbox/mod_tests.rs | 20 +++++++++++++ .../tinybus_module/resources_stream_tests.rs | 5 +++- 5 files changed, 54 insertions(+), 16 deletions(-) diff --git a/crates/tinybox-jail/src/jail_tests.rs b/crates/tinybox-jail/src/jail_tests.rs index 348c472d..f46d08f4 100644 --- a/crates/tinybox-jail/src/jail_tests.rs +++ b/crates/tinybox-jail/src/jail_tests.rs @@ -196,7 +196,10 @@ fn owned_spawn_with_an_approved_environment_drops_everything_else() -> std::io:: let jail = Jail::new(root.path(), "owned-approved-env"); let mut command = std::process::Command::new("/bin/sh"); command - .args(["-c", r#"printf '%s|%s' "${APPROVED-}" "${UNAPPROVED-unset}""#]) + .args([ + "-c", + r#"printf '%s|%s' "${APPROVED-}" "${UNAPPROVED-unset}""#, + ]) .env("UNAPPROVED", "leaked"); let approved = std::collections::BTreeMap::from([("APPROVED".to_owned(), "yes".to_owned())]); let mut child = crate::spawn_owned_captured_with_backend_and_env( diff --git a/crates/tinybox-linux/src/sandbox/args.rs b/crates/tinybox-linux/src/sandbox/args.rs index 03f3c1bf..b0af54bf 100644 --- a/crates/tinybox-linux/src/sandbox/args.rs +++ b/crates/tinybox-linux/src/sandbox/args.rs @@ -142,14 +142,7 @@ pub(super) fn exec_with_prlimit_resolver( supports_expand_environment: bool, resolve_prlimit: impl FnOnce() -> std::io::Result, ) -> Result> { - let prlimit = if !limits && setrlimit_fallback { - Some(resolve_prlimit().map_err(|_| Error::Unsupported { - sandbox: NAME.to_owned(), - capability: Capability::ResourceLimits, - })?) - } else { - None - }; + let prlimit = fallback_prlimit(limits, setrlimit_fallback, resolve_prlimit)?; exec_with_prlimit_program( spec, request, @@ -159,6 +152,26 @@ pub(super) fn exec_with_prlimit_resolver( ) } +/// The trusted `prlimit` the setrlimit fallback launches through, when that +/// fallback is in use. +/// +/// # Errors +/// Returns [`Error::Unsupported`] for resource limits when no trusted +/// `prlimit` exists. +pub(super) fn fallback_prlimit( + limits: bool, + setrlimit_fallback: bool, + resolve_prlimit: impl FnOnce() -> std::io::Result, +) -> Result> { + if limits || !setrlimit_fallback { + return Ok(None); + } + resolve_prlimit().map(Some).map_err(|_| Error::Unsupported { + sandbox: NAME.to_owned(), + capability: Capability::ResourceLimits, + }) +} + pub(super) fn exec_with_prlimit_program( spec: &BoxSpec, request: &ExecRequest, diff --git a/crates/tinybox-linux/src/sandbox/mod.rs b/crates/tinybox-linux/src/sandbox/mod.rs index 4b0c5fb5..01f63bab 100644 --- a/crates/tinybox-linux/src/sandbox/mod.rs +++ b/crates/tinybox-linux/src/sandbox/mod.rs @@ -144,12 +144,11 @@ impl Sandbox for NamespaceSandbox { // source now means an unusable spec fails at `create` rather than // surprising the caller at the first `exec`. args::workspace_dir(spec)?; - if !self.limits && self.setrlimit_fallback { - args::trusted_prlimit_program().map_err(|_| Error::Unsupported { - sandbox: NAME.to_owned(), - capability: Capability::ResourceLimits, - })?; - } + args::fallback_prlimit( + self.limits, + self.setrlimit_fallback, + args::trusted_prlimit_program, + )?; tinybox_core::insert_new(self.store.as_ref(), BoxState::Ready, spec, self.clock.now()) } diff --git a/crates/tinybox-linux/src/sandbox/mod_tests.rs b/crates/tinybox-linux/src/sandbox/mod_tests.rs index 15d53c2f..5655327b 100644 --- a/crates/tinybox-linux/src/sandbox/mod_tests.rs +++ b/crates/tinybox-linux/src/sandbox/mod_tests.rs @@ -969,6 +969,26 @@ async fn setrlimit_fallback_creates_a_box_only_with_a_trusted_prlimit() -> Resul Ok(()) } +#[test] +fn create_and_exec_need_a_trusted_prlimit_only_under_the_fallback() -> Result<()> { + let missing = || Err(std::io::Error::from(std::io::ErrorKind::NotFound)); + assert!(args::fallback_prlimit(true, true, missing)?.is_none()); + assert!(args::fallback_prlimit(false, false, missing)?.is_none()); + assert!(matches!( + args::fallback_prlimit(false, true, missing), + Err(Error::Unsupported { + capability: Capability::ResourceLimits, + .. + }) + )); + let trusted = || Ok(Path::new("/trusted/prlimit").to_path_buf()); + assert_eq!( + args::fallback_prlimit(false, true, trusted)?.as_deref(), + Some(Path::new("/trusted/prlimit")) + ); + Ok(()) +} + #[test] fn setrlimit_fallback_wraps_commands_with_address_space_limit() -> Result<()> { let spec = spec()?.with_resources(Resources { 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 3fc1a016..30863208 100644 --- a/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs +++ b/crates/tinybox-module/src/tinybus_module/resources_stream_tests.rs @@ -108,7 +108,10 @@ fn a_timed_out_stream_keeps_its_cpu_event_and_exits_only_once_cleaned_up() { journal.termination_cause(tinybox_core::TerminationCause::CpuQuota); journal.finish_failed(ExecutionFailure::CleanupFailed); let stream = stream_with(journal.clone(), true); - assert_eq!(stream.terminal_events(None), vec![cpu.clone(), timeout.clone()]); + assert_eq!( + stream.terminal_events(None), + vec![cpu.clone(), timeout.clone()] + ); journal.finish_failed(ExecutionFailure::TimedOut); assert_eq!( stream.terminal_events(None),