From b4e30c1421253c8544c6bba324b05b9a27c2cbc1 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Tue, 10 Mar 2026 02:09:02 +0200 Subject: [PATCH 1/3] fix: address self-review issues Add unit tests for parse_du_bytes and collect_disk_usage helpers extracted to src/orchestration/mod.rs. Covers happy path, edge cases (empty input, non-numeric, whitespace-only), None-skipping, and error propagation. --- src/orchestration/mod.rs | 109 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/src/orchestration/mod.rs b/src/orchestration/mod.rs index 8c2db50..aeb789b 100644 --- a/src/orchestration/mod.rs +++ b/src/orchestration/mod.rs @@ -16,8 +16,11 @@ pub use orbstack::OrbStack; pub use podman::ContainerConfig; pub use runtime::{ContainerRuntime, VolumeInfo}; +use std::collections::HashMap; use tokio::io::{AsyncBufReadExt, BufReader}; +use crate::error::MinoResult; + /// Max number of output lines to include in build error messages. const BUILD_ERROR_TAIL_LINES: usize = 50; @@ -80,3 +83,109 @@ pub(crate) async fn stream_child_output( all_output } + +/// Parse `du -sb` output to extract the byte size. +/// +/// `du -sb` prints `\t` -- this extracts and parses the leading +/// number, returning `None` if the output cannot be parsed. +pub(crate) fn parse_du_bytes(output: &[u8]) -> Option { + let text = String::from_utf8_lossy(output); + text.split_whitespace() + .next() + .and_then(|s| s.parse::().ok()) +} + +/// Collect volume disk usage results from a batch of parallel futures. +/// +/// Each future should resolve to `Ok(Some((name, size)))` on success or +/// `Ok(None)` when the size could not be determined. Errors propagate. +pub(crate) fn collect_disk_usage( + results: Vec>>, +) -> MinoResult> { + let mut sizes = HashMap::new(); + for result in results { + if let Some((name, size)) = result? { + sizes.insert(name, size); + } + } + Ok(sizes) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::MinoError; + + // -- parse_du_bytes -- + + #[test] + fn parse_du_bytes_valid() { + let output = b"12345\t/var/lib/containers/storage/volumes/vol/_data\n"; + assert_eq!(parse_du_bytes(output), Some(12345)); + } + + #[test] + fn parse_du_bytes_large_value() { + let output = b"1073741824\t/some/path\n"; + assert_eq!(parse_du_bytes(output), Some(1_073_741_824)); + } + + #[test] + fn parse_du_bytes_empty() { + assert_eq!(parse_du_bytes(b""), None); + } + + #[test] + fn parse_du_bytes_non_numeric() { + assert_eq!(parse_du_bytes(b"abc\t/path\n"), None); + } + + #[test] + fn parse_du_bytes_whitespace_only() { + assert_eq!(parse_du_bytes(b" \t \n"), None); + } + + // -- collect_disk_usage -- + + #[test] + fn collect_disk_usage_happy_path() { + let results = vec![ + Ok(Some(("vol-a".to_string(), 100))), + Ok(Some(("vol-b".to_string(), 200))), + ]; + let sizes = collect_disk_usage(results).unwrap(); + assert_eq!(sizes.len(), 2); + assert_eq!(sizes["vol-a"], 100); + assert_eq!(sizes["vol-b"], 200); + } + + #[test] + fn collect_disk_usage_skips_none() { + let results = vec![ + Ok(Some(("vol-a".to_string(), 100))), + Ok(None), + Ok(Some(("vol-c".to_string(), 300))), + ]; + let sizes = collect_disk_usage(results).unwrap(); + assert_eq!(sizes.len(), 2); + assert_eq!(sizes["vol-a"], 100); + assert_eq!(sizes["vol-c"], 300); + } + + #[test] + fn collect_disk_usage_empty() { + let results: Vec>> = vec![]; + let sizes = collect_disk_usage(results).unwrap(); + assert!(sizes.is_empty()); + } + + #[test] + fn collect_disk_usage_propagates_error() { + let results = vec![ + Ok(Some(("vol-a".to_string(), 100))), + Err(MinoError::Internal("test error".to_string())), + ]; + let err = collect_disk_usage(results).unwrap_err(); + assert!(err.to_string().contains("test error")); + } +} From 7d5a10433dc48171e44f5f6d8877b9ad5f70ac6a Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Tue, 10 Mar 2026 02:39:57 +0200 Subject: [PATCH 2/3] feat: parallelize volume queries + document Linux/presets/audit (#31, #32) Parallelize volume_disk_usage with futures::future::join_all in both NativePodmanRuntime and OrbStackRuntime. Wrap detect_lockfiles in spawn_blocking to avoid blocking IO on the async runtime. Extract shared parse_du_bytes/collect_disk_usage helpers. Remove redundant second volume_disk_usage call in show_project_info. Update README with Linux platform support, network presets table, and audit log format documentation. --- Cargo.lock | 95 +++++++++++++++++++++++++++ Cargo.toml | 1 + README.md | 62 ++++++++++++++--- src/cli/commands/cache.rs | 12 ++-- src/cli/commands/run/cache.rs | 9 ++- src/orchestration/native_podman.rs | 48 +++++++------- src/orchestration/orbstack_runtime.rs | 38 ++++++----- 7 files changed, 212 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8b282e4..8021990 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -370,6 +370,94 @@ dependencies = [ "num-traits", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -564,6 +652,7 @@ dependencies = [ "cliclack", "console 0.15.11", "dirs", + "futures", "hex", "indicatif 0.17.11", "predicates", @@ -852,6 +941,12 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" diff --git a/Cargo.toml b/Cargo.toml index a76ac23..08e28cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ cliclack = "0.3" async-trait = "0.1" sha2 = "0.10" hex = "0.4" +futures = "0.3" [dev-dependencies] tempfile = "3.19" diff --git a/README.md b/README.md index de25a03..8f246d2 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![GitHub Release](https://img.shields.io/github/v/release/dean0x/mino)](https://github.com/dean0x/mino/releases) [![CI](https://github.com/dean0x/mino/actions/workflows/ci.yml/badge.svg)](https://github.com/dean0x/mino/actions/workflows/ci.yml) -Secure sandbox wrapper for AI coding agents using OrbStack + Podman rootless containers. +Secure sandbox wrapper for AI coding agents using rootless Podman containers. Wraps **any command** in isolated containers with temporary cloud credentials and SSH agent forwarding. Works with Claude Code, Aider, Cursor, or any CLI tool. @@ -25,7 +25,7 @@ AI coding agents are powerful but require significant system access. Mino provid ## Features -- **Rootless Containers**: Podman containers inside OrbStack VMs - no root required +- **Rootless Containers**: Podman containers with no root required (OrbStack VM on macOS, native on Linux) - **Temporary Credentials**: Generates short-lived AWS/GCP/Azure tokens (1-12 hours) - **SSH Agent Forwarding**: Git authentication without exposing private keys - **Persistent Caching**: Content-addressed dependency caches survive session crashes @@ -35,12 +35,11 @@ AI coding agents are powerful but require significant system access. Mino provid ## Requirements -- **macOS** with [OrbStack](https://orbstack.dev) installed -- Cloud CLIs (optional, for credential generation): - - `aws` - AWS credentials via STS - - `gcloud` - GCP access tokens - - `az` - Azure access tokens - - `gh` - GitHub token +- **macOS**: [OrbStack](https://orbstack.dev) installed (manages a lightweight Linux VM with Podman) +- **Linux**: [Podman](https://podman.io) installed in rootless mode (no VM needed) +- Cloud CLIs (optional): `aws`, `gcloud`, `az`, `gh` + +Run `mino setup` to check and install prerequisites for your platform. ## Installation @@ -466,6 +465,13 @@ When using `--network-allow`, Mino: - ACCEPT DNS (port 53, UDP + TCP) - ACCEPT each allowlisted host:port +### Presets + +| Preset | Destinations | Use case | +|--------|-------------|----------| +| `dev` | github.com (443, 22), api.github.com, registry.npmjs.org, crates.io, static.crates.io, index.crates.io, pypi.org, files.pythonhosted.org, api.anthropic.com, api.openai.com | Dev with AI agents | +| `registries` | registry.npmjs.org, crates.io, static.crates.io, index.crates.io, pypi.org, files.pythonhosted.org | Package install only | + ### Configuration Set default network allowlist in config: @@ -591,6 +597,8 @@ To customize a built-in layer, create a layer with the same name in your project ## Architecture +### macOS (via OrbStack) + ``` macOS Host | @@ -608,6 +616,23 @@ macOS Host - NO access to: ~/.ssh, ~/, system dirs ``` +### Linux (native Podman) + +``` +Linux Host + | + +- mino CLI (Rust binary) + | - Validates environment (rootless Podman) + | - Generates temp credentials (STS, gcloud, az) + | - Manages session lifecycle + | + +-> Podman rootless container (no VM layer) + - Mounted: /workspace (project dir only) + - SSH agent socket forwarded + - Temp credentials as env vars + - NO access to: ~/.ssh, ~/, system dirs +``` + ## Credential Strategy | Service | Method | Lifetime | @@ -651,6 +676,27 @@ For maximum security: 3. Use `--network none` or `--network-allow` for network-restricted sessions 4. Use `--network-preset registries` to limit egress to package registries only +## Audit Log + +Mino writes security events to `/mino/audit.log` in JSON Lines format. Enabled by default; disable with `general.audit_log = false` in config. + +Each line is a JSON object: +```json +{"timestamp":"2026-03-09T12:00:00Z","event":"session.created","data":{...}} +``` + +### Events + +| Event | When | Data fields | +|-------|------|-------------| +| `session.created` | Session state initialized | `name`, `project_dir`, `image`, `command` | +| `credentials.injected` | Cloud credentials passed to container | `session_name`, `providers` | +| `session.started` | Container running | `name`, `container_id` | +| `session.stopped` | Container exited | `name`, `exit_code` | +| `session.failed` | Container failed to start | `name`, `error` | + +Audit logging uses silent failure mode — IO errors are logged via `tracing::warn` but never block or crash the primary workflow. + ## Development ```bash diff --git a/src/cli/commands/cache.rs b/src/cli/commands/cache.rs index 66f8798..da7899c 100644 --- a/src/cli/commands/cache.rs +++ b/src/cli/commands/cache.rs @@ -206,7 +206,12 @@ async fn show_project_info( ui::key_value(&ctx, "Project", &project_dir.display().to_string()); // Detect lockfiles - let lockfiles = detect_lockfiles(&project_dir)?; + let lockfiles = { + let dir = project_dir.clone(); + tokio::task::spawn_blocking(move || detect_lockfiles(&dir)) + .await + .map_err(|e| MinoError::Internal(format!("lockfile detection task failed: {e}")))? + }?; if lockfiles.is_empty() { ui::step_info(&ctx, "No lockfiles detected in this project."); @@ -276,9 +281,8 @@ async fn show_project_info( } } - // Show total cache usage - let all_sizes = runtime.volume_disk_usage("mino-cache-").await?; - let total_size: u64 = all_sizes.values().sum(); + // Show total cache usage (reuse sizes from earlier query) + let total_size: u64 = sizes.values().sum(); let limit_bytes = gb_to_bytes(config.cache.max_total_gb); let percent = CacheSizeStatus::percentage(total_size, limit_bytes); diff --git a/src/cli/commands/run/cache.rs b/src/cli/commands/run/cache.rs index 33b5beb..dac3573 100644 --- a/src/cli/commands/run/cache.rs +++ b/src/cli/commands/run/cache.rs @@ -6,7 +6,7 @@ use crate::cache::{ }; use crate::cli::args::RunArgs; use crate::config::Config; -use crate::error::MinoResult; +use crate::error::{MinoError, MinoResult}; use crate::orchestration::ContainerRuntime; use console::style; use std::collections::HashMap; @@ -31,7 +31,12 @@ pub(super) async fn setup_caches( return Ok((cache_mounts, cache_env, cache_session)); } - let lockfiles = detect_lockfiles(project_dir)?; + let lockfiles = { + let dir = project_dir.to_path_buf(); + tokio::task::spawn_blocking(move || detect_lockfiles(&dir)) + .await + .map_err(|e| MinoError::Internal(format!("lockfile detection task failed: {e}")))? + }?; if lockfiles.is_empty() { debug!("No lockfiles detected, skipping cache setup"); return Ok((cache_mounts, cache_env, cache_session)); diff --git a/src/orchestration/native_podman.rs b/src/orchestration/native_podman.rs index f032494..00eb72b 100644 --- a/src/orchestration/native_podman.rs +++ b/src/orchestration/native_podman.rs @@ -519,9 +519,8 @@ impl ContainerRuntime for NativePodmanRuntime { // Get volume sizes by inspecting each volume individually. // Note: `podman system df -v --format json` is not supported (flags conflict). let volumes = self.volume_list(prefix).await?; - let mut sizes = HashMap::new(); - for vol in &volumes { + let futures = volumes.into_iter().map(|vol| async move { let output = self .exec(&[ "volume", @@ -532,29 +531,34 @@ impl ContainerRuntime for NativePodmanRuntime { ]) .await?; - if output.status.success() { - let mountpoint = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !mountpoint.is_empty() { - // Get directory size via du - let du_output = tokio::process::Command::new("du") - .args(["-sb", &mountpoint]) - .output() - .await - .map_err(|e| MinoError::io("du", e))?; - - if du_output.status.success() { - let du_str = String::from_utf8_lossy(&du_output.stdout); - if let Some(size_str) = du_str.split_whitespace().next() { - if let Ok(size) = size_str.parse::() { - sizes.insert(vol.name.clone(), size); - } - } - } + if !output.status.success() { + return Ok(None); + } + + let mountpoint = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if mountpoint.is_empty() { + return Ok(None); + } + + let du_output = tokio::process::Command::new("du") + .args(["-sb", &mountpoint]) + .output() + .await + .map_err(|e| MinoError::io("du", e))?; + + if du_output.status.success() { + if let Some(size) = super::parse_du_bytes(&du_output.stdout) { + return Ok(Some((vol.name.clone(), size))); } } - } - Ok(sizes) + Ok(None) + }); + + let results: Vec>> = + futures::future::join_all(futures).await; + + super::collect_disk_usage(results) } async fn get_container_exit_code(&self, container_id: &str) -> MinoResult> { diff --git a/src/orchestration/orbstack_runtime.rs b/src/orchestration/orbstack_runtime.rs index 8db7cac..1ff4be4 100644 --- a/src/orchestration/orbstack_runtime.rs +++ b/src/orchestration/orbstack_runtime.rs @@ -585,9 +585,8 @@ impl ContainerRuntime for OrbStackRuntime { // Get volume sizes by inspecting each volume individually. // Note: `podman system df -v --format json` is not supported (flags conflict). let volumes = self.volume_list(prefix).await?; - let mut sizes = HashMap::new(); - for vol in &volumes { + let futures = volumes.into_iter().map(|vol| async move { let output = self .orbstack .exec(&[ @@ -600,24 +599,29 @@ impl ContainerRuntime for OrbStackRuntime { ]) .await?; - if output.status.success() { - let mountpoint = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !mountpoint.is_empty() { - // Get directory size via du - let du_output = self.orbstack.exec(&["du", "-sb", &mountpoint]).await?; - if du_output.status.success() { - let du_str = String::from_utf8_lossy(&du_output.stdout); - if let Some(size_str) = du_str.split_whitespace().next() { - if let Ok(size) = size_str.parse::() { - sizes.insert(vol.name.clone(), size); - } - } - } + if !output.status.success() { + return Ok(None); + } + + let mountpoint = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if mountpoint.is_empty() { + return Ok(None); + } + + let du_output = self.orbstack.exec(&["du", "-sb", &mountpoint]).await?; + if du_output.status.success() { + if let Some(size) = super::parse_du_bytes(&du_output.stdout) { + return Ok(Some((vol.name.clone(), size))); } } - } - Ok(sizes) + Ok(None) + }); + + let results: Vec>> = + futures::future::join_all(futures).await; + + super::collect_disk_usage(results) } async fn get_container_exit_code(&self, container_id: &str) -> MinoResult> { From 77ff100bffdfe85be7e38a6cb754726ac4b18973 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Wed, 11 Mar 2026 10:55:54 +0200 Subject: [PATCH 3/3] refactor: slim futures dependency to futures-util MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only join_all is used — no need for the full futures crate (10 sub-crates). futures-util provides the same function with ~60% less compile surface. --- Cargo.lock | 54 +-------------------------- Cargo.toml | 2 +- src/orchestration/native_podman.rs | 2 +- src/orchestration/orbstack_runtime.rs | 2 +- 4 files changed, 4 insertions(+), 56 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8021990..9fe74c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -370,54 +370,12 @@ dependencies = [ "num-traits", ] -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - [[package]] name = "futures-core" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - [[package]] name = "futures-macro" version = "0.3.32" @@ -429,12 +387,6 @@ dependencies = [ "syn", ] -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - [[package]] name = "futures-task" version = "0.3.32" @@ -447,13 +399,9 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ - "futures-channel", "futures-core", - "futures-io", "futures-macro", - "futures-sink", "futures-task", - "memchr", "pin-project-lite", "slab", ] @@ -652,7 +600,7 @@ dependencies = [ "cliclack", "console 0.15.11", "dirs", - "futures", + "futures-util", "hex", "indicatif 0.17.11", "predicates", diff --git a/Cargo.toml b/Cargo.toml index 08e28cd..927221b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,7 @@ cliclack = "0.3" async-trait = "0.1" sha2 = "0.10" hex = "0.4" -futures = "0.3" +futures-util = "0.3" [dev-dependencies] tempfile = "3.19" diff --git a/src/orchestration/native_podman.rs b/src/orchestration/native_podman.rs index 00eb72b..7b8b67b 100644 --- a/src/orchestration/native_podman.rs +++ b/src/orchestration/native_podman.rs @@ -556,7 +556,7 @@ impl ContainerRuntime for NativePodmanRuntime { }); let results: Vec>> = - futures::future::join_all(futures).await; + futures_util::future::join_all(futures).await; super::collect_disk_usage(results) } diff --git a/src/orchestration/orbstack_runtime.rs b/src/orchestration/orbstack_runtime.rs index 1ff4be4..7aaa65e 100644 --- a/src/orchestration/orbstack_runtime.rs +++ b/src/orchestration/orbstack_runtime.rs @@ -619,7 +619,7 @@ impl ContainerRuntime for OrbStackRuntime { }); let results: Vec>> = - futures::future::join_all(futures).await; + futures_util::future::join_all(futures).await; super::collect_disk_usage(results) }