diff --git a/docs/RUNNING.md b/docs/RUNNING.md
index 30295b8..4f81da1 100644
--- a/docs/RUNNING.md
+++ b/docs/RUNNING.md
@@ -17,6 +17,7 @@ The userland pipeline targets `wasm32-wasip2`.
Separate Go derivations build awk and esbuild for WASI preview1.
QuickJS builds from its vendored C sources with WASI clang.
Nix assembles the final bundle without the development bundle script.
+Use `sausage run --no-builder-sandbox WORLD` only when the host must run the builder without namespace isolation.
The package entry point is:
diff --git a/host/src/api/server.rs b/host/src/api/server.rs
index 9036687..52a22b0 100644
--- a/host/src/api/server.rs
+++ b/host/src/api/server.rs
@@ -4,7 +4,11 @@ use crate::cred::Cred;
use std::{path::Path, sync::Arc};
/// Preserve the console lifecycle with one API for guests and host pages.
-pub async fn run_with_userland(path: &Path, userland: Option<&Path>) -> anyhow::Result<()> {
+pub async fn run_with_userland(
+ path: &Path,
+ userland: Option<&Path>,
+ options: crate::process::RuntimeOptions,
+) -> anyhow::Result<()> {
let world = crate::world::World::open_with_userland(path, userland)?;
let cred = Arc::new(Cred::open(&world.host_dir)?);
let api = Arc::new(Api::open(
@@ -13,7 +17,7 @@ pub async fn run_with_userland(path: &Path, userland: Option<&Path>) -> anyhow::
"localhost",
"http://localhost:8080",
)?);
- let runtime = crate::process::Runtime::new_with_hooks(
+ let runtime = crate::process::Runtime::new_with_hooks_and_options(
crate::world::World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
@@ -22,6 +26,7 @@ pub async fn run_with_userland(path: &Path, userland: Option<&Path>) -> anyhow::
cred.clone(),
crate::process::default_script(),
Some(api.clone()),
+ options,
)?;
let (sender, receiver) = crate::harness::input::channel();
api.attach_harness(sender.clone());
diff --git a/host/src/builder/README.md b/host/src/builder/README.md
index 4353501..ffbd9f1 100644
--- a/host/src/builder/README.md
+++ b/host/src/builder/README.md
@@ -29,9 +29,14 @@ It also owns a registry and kache cache.
These directories live under `
/host/builds/`.
Cargo fetches dependencies with network access.
-The command then runs offline inside bubblewrap namespaces.
+By default, the command then runs offline inside bubblewrap namespaces.
+The host-only `sausage run --no-builder-sandbox WORLD` switch runs Cargo directly instead.
+Tests use `RuntimeOptions.no_builder_sandbox` and select it with `SAUSAGE_TEST_NO_BUILDER_SANDBOX=1`.
+The switch is not a world config setting.
+Direct mode uses no user, mount, network, or PID namespaces.
+It retains the source copy, working directory, private registry, kache settings, timeout, output, and artifact publication.
-The source copy and target directory permit writes.
+In the default sandboxed mode, the source copy and target directory permit writes.
The private kache cache also permits writes.
The registry and Nix store remain read-only.
Host home directories and the host network are absent.
diff --git a/host/src/builder/mod.rs b/host/src/builder/mod.rs
index 81b6e35..97866f2 100644
--- a/host/src/builder/mod.rs
+++ b/host/src/builder/mod.rs
@@ -47,15 +47,23 @@ pub struct Builder {
root: Arc,
directory: PathBuf,
lock: Mutex<()>,
+ no_sandbox: bool,
}
impl Builder {
pub fn new(root: Arc, host: &Path) -> Result {
+ Self::new_with_options(root, host, false)
+ }
+ pub fn new_with_options(root: Arc, host: &Path, no_sandbox: bool) -> Result {
Ok(Self {
root,
directory: std::fs::canonicalize(host)?.join("builds"),
lock: Mutex::new(()),
+ no_sandbox,
})
}
+ fn command(&self) -> Command {
+ Command::new(if self.no_sandbox { "cargo" } else { "bwrap" })
+ }
pub async fn execute(&self, request: Request, tx: &mpsc::Sender) -> Result<()> {
let _guard = self.lock.lock().await;
let action = request
@@ -221,60 +229,72 @@ impl Builder {
if action == "run" && run_path.exists() {
std::fs::remove_file(&run_path)?;
}
- let mut cmd = Command::new("bwrap");
- cmd.args([
- "--unshare-user",
- "--unshare-pid",
- "--unshare-net",
- "--die-with-parent",
- "--ro-bind",
- "/nix/store",
- "/nix/store",
- ])
- .arg("--bind")
- .arg(&temporary)
- .arg("/tmp")
- .arg("--ro-bind")
- .arg(executable("sh")?)
- .arg("/bin/sh");
- for path in [&source, &target_mount, &cache] {
- cmd.arg("--bind").arg(path).arg(path);
+ let mut cmd = self.command();
+ if !self.no_sandbox {
+ cmd.args([
+ "--unshare-user",
+ "--unshare-pid",
+ "--unshare-net",
+ "--die-with-parent",
+ "--ro-bind",
+ "/nix/store",
+ "/nix/store",
+ ])
+ .arg("--bind")
+ .arg(&temporary)
+ .arg("/tmp")
+ .arg("--ro-bind")
+ .arg(executable("sh")?)
+ .arg("/bin/sh");
+ for path in [&source, &target_mount, &cache] {
+ cmd.arg("--bind").arg(path).arg(path);
+ }
+ for path in [®istry, &tools] {
+ cmd.arg("--ro-bind").arg(path).arg(path);
+ }
+ cmd.args([
+ "--proc",
+ "/proc",
+ "--dev",
+ "/dev",
+ "--remount-ro",
+ "/",
+ "--chdir",
+ ])
+ .arg(&cwd)
+ .arg("--clearenv");
+ } else {
+ cmd.current_dir(&cwd);
}
- for path in [®istry, &tools] {
- cmd.arg("--ro-bind").arg(path).arg(path);
- }
- cmd.args([
- "--proc",
- "/proc",
- "--dev",
- "/dev",
- "--remount-ro",
- "/",
- "--chdir",
- ])
- .arg(&cwd)
- .args(["--clearenv", "--setenv", "PATH"])
- .arg(std::env::var("PATH")?)
- .args(["--setenv", "HOME", "/nonexistent", "--setenv", "CARGO_HOME"])
- .arg(®istry)
- .args(["--setenv", "CARGO_TARGET_DIR"])
- .arg(&target)
- .args(["--setenv", "KACHE_CACHE_DIR"])
- .arg(&cache)
- .args(["--setenv", "SAUSAGE_CWD_INIT"])
- .arg(tools.join("init.o"))
- .args(["--setenv", "SAUSAGE_RUN_PATH"])
- .arg(&run_path);
+ let mut environment = vec![
+ ("PATH", std::env::var_os("PATH").context("PATH missing")?),
+ ("HOME", "/nonexistent".into()),
+ ("CARGO_HOME", registry.clone().into_os_string()),
+ ("CARGO_TARGET_DIR", target.clone().into_os_string()),
+ ("KACHE_CACHE_DIR", cache.clone().into_os_string()),
+ ("SAUSAGE_CWD_INIT", tools.join("init.o").into_os_string()),
+ ("SAUSAGE_RUN_PATH", run_path.clone().into_os_string()),
+ ];
for name in [
"CC_wasm32_wasip2",
"AR_wasm32_wasip2",
"CFLAGS_wasm32_wasip2",
] {
if let Ok(value) = std::env::var(name) {
+ environment.push((name, value.into()));
+ }
+ }
+ for (name, value) in environment {
+ if self.no_sandbox {
+ cmd.env(name, value);
+ } else {
cmd.args(["--setenv", name]).arg(value);
}
}
- cmd.args(["cargo", "--offline", "--config"])
+ if !self.no_sandbox {
+ cmd.arg("cargo");
+ }
+ cmd.args(["--offline", "--config"])
.arg(format!("build.rustc-wrapper={:?}", kache))
.arg("--config")
.arg(format!(
diff --git a/host/src/builder/tests.rs b/host/src/builder/tests.rs
index 38dad64..c9cddf8 100644
--- a/host/src/builder/tests.rs
+++ b/host/src/builder/tests.rs
@@ -1,5 +1,16 @@
//! Focused tests for source discovery, argument forwarding, eviction, and process cancellation.
use super::*;
+#[test]
+fn native_command_defaults_to_bwrap_and_can_bypass_it() -> Result<()> {
+ let temp = tempfile::tempdir()?;
+ let world = crate::world::World::open(temp.path())?;
+ assert!(!crate::process::RuntimeOptions::default().no_builder_sandbox);
+ let default = Builder::new(world.root.clone(), &world.host_dir)?;
+ assert_eq!(default.command().as_std().get_program(), "bwrap");
+ let direct = Builder::new_with_options(world.root, &world.host_dir, true)?;
+ assert_eq!(direct.command().as_std().get_program(), "cargo");
+ Ok(())
+}
#[tokio::test]
async fn source_links_survive_round_trip() -> Result<()> {
let temp = tempfile::tempdir()?;
diff --git a/host/src/main.rs b/host/src/main.rs
index e95df79..da38d88 100644
--- a/host/src/main.rs
+++ b/host/src/main.rs
@@ -21,7 +21,12 @@ enum Command {
userland: Option,
},
/// Start a world: the harness, its services and a console.
- Run { world: PathBuf },
+ Run {
+ world: PathBuf,
+ /// Run the native Rust builder without namespace isolation.
+ #[arg(long)]
+ no_builder_sandbox: bool,
+ },
/// Run a shell in a world. With `-c`, run one command and exit with its status.
Sh {
world: PathBuf,
@@ -79,14 +84,22 @@ async fn main() -> anyhow::Result<()> {
);
Ok(())
}
- Command::Run { world } => {
+ Command::Run {
+ world,
+ no_builder_sandbox,
+ } => {
anyhow::ensure!(
world.join("world").is_dir(),
"No world at {}. Run sausage init {} first.",
world.display(),
world.display()
);
- sausage::api::run_with_userland(&world, None).await
+ sausage::api::run_with_userland(
+ &world,
+ None,
+ sausage::process::RuntimeOptions { no_builder_sandbox },
+ )
+ .await
}
Command::Sh {
world,
diff --git a/host/src/process/cache.rs b/host/src/process/cache.rs
index 315cf5d..4a2b8f7 100644
--- a/host/src/process/cache.rs
+++ b/host/src/process/cache.rs
@@ -289,7 +289,7 @@ mod tests {
let _ = started_tx.send(());
let _ = finish_rx.recv();
}));
- let runtime = super::super::Runtime::with_compiler(world, compiler)?;
+ let runtime = super::super::Runtime::with_compiler(world, compiler, Default::default())?;
started_rx.await?;
let command_runtime = runtime.clone();
let mut command = tokio::spawn(async move {
@@ -353,7 +353,8 @@ mod tests {
wat::parse_str(r#"(module (memory (export "memory") 1) (func (export "_start")))"#)?,
)?;
compiler.get(parent).await?;
- let runtime = super::super::Runtime::with_compiler(world, compiler.clone())?;
+ let runtime =
+ super::super::Runtime::with_compiler(world, compiler.clone(), Default::default())?;
let deadline = Deadline::new(Duration::ZERO);
let request = |name: &str| Request {
program: name.into(),
@@ -471,7 +472,9 @@ mod tests {
finish_rx.recv().unwrap();
}));
// Startup returns while the compiler remains blocked behind the test gate.
- let runtime = super::super::Runtime::with_compiler(world, compiler.clone()).unwrap();
+ let runtime =
+ super::super::Runtime::with_compiler(world, compiler.clone(), Default::default())
+ .unwrap();
started_rx.await.unwrap();
let background = compiler.clone();
let precompile = tokio::spawn(async move { background.get(bytes).await });
diff --git a/host/src/process/mod.rs b/host/src/process/mod.rs
index 1d7339a..656bda4 100644
--- a/host/src/process/mod.rs
+++ b/host/src/process/mod.rs
@@ -154,20 +154,36 @@ struct Started {
output: Option,
error: Option,
}
+/// Host-only switches for native builder execution. Defaults retain namespace isolation.
+#[derive(Clone, Copy, Debug, Default)]
+pub struct RuntimeOptions {
+ pub no_builder_sandbox: bool,
+}
impl Runtime {
pub fn open(path: &Path) -> anyhow::Result> {
Self::new(World::open(path)?)
}
pub fn new(world: World) -> anyhow::Result> {
- Self::with_compiler(world, cache::shared()?)
+ Self::new_with_options(world, RuntimeOptions::default())
+ }
+ pub fn new_with_options(world: World, options: RuntimeOptions) -> anyhow::Result> {
+ Self::with_compiler(world, cache::shared()?, options)
}
/// Open an independent engine using an explicit shared disk cache.
pub fn new_with_cache(world: World, directory: &Path) -> anyhow::Result> {
- Self::with_compiler(world, cache::Compiler::new(directory)?)
+ Self::with_compiler(
+ world,
+ cache::Compiler::new(directory)?,
+ RuntimeOptions::default(),
+ )
}
- fn with_compiler(world: World, compiler: Arc) -> anyhow::Result> {
+ fn with_compiler(
+ world: World,
+ compiler: Arc,
+ options: RuntimeOptions,
+ ) -> anyhow::Result> {
let cred = Arc::new(crate::cred::Cred::open(&world.host_dir)?);
- Self::with_hooks(world, compiler, cred, default_script(), None)
+ Self::with_hooks(world, compiler, cred, default_script(), None, options)
}
/// Share credentials and the host API router with the host's other subsystems.
pub fn new_with_hooks(
@@ -176,7 +192,16 @@ impl Runtime {
script: Arc,
router: Option,
) -> anyhow::Result> {
- Self::with_hooks(world, cache::shared()?, cred, script, router)
+ Self::new_with_hooks_and_options(world, cred, script, router, RuntimeOptions::default())
+ }
+ pub fn new_with_hooks_and_options(
+ world: World,
+ cred: Arc,
+ script: Arc,
+ router: Option,
+ options: RuntimeOptions,
+ ) -> anyhow::Result> {
+ Self::with_hooks(world, cache::shared()?, cred, script, router, options)
}
fn with_hooks(
world: World,
@@ -184,6 +209,7 @@ impl Runtime {
cred: Arc,
script: Arc,
router: Option,
+ options: RuntimeOptions,
) -> anyhow::Result> {
let router = match router {
Some(api) => api,
@@ -195,10 +221,13 @@ impl Runtime {
)?),
};
router.attach_journal(world.root.journal());
- let _ = router.builder.set(Arc::new(crate::builder::Builder::new(
- world.root.clone(),
- &world.host_dir,
- )?));
+ let _ = router
+ .builder
+ .set(Arc::new(crate::builder::Builder::new_with_options(
+ world.root.clone(),
+ &world.host_dir,
+ options.no_builder_sandbox,
+ )?));
let network = Arc::new(net::NetPolicy::open(&world.host_dir)?);
let engine = compiler.engine.clone();
let alive = Arc::new(AtomicBool::new(true));
diff --git a/host/tests/builder-fixture/build.rs b/host/tests/builder-fixture/build.rs
index 1d1f519..c087c92 100644
--- a/host/tests/builder-fixture/build.rs
+++ b/host/tests/builder-fixture/build.rs
@@ -1,19 +1,24 @@
//! Assert the native build script's namespace boundaries.
fn main() {
- assert!(!std::path::Path::new("/home/surma/.ssh").exists());
- assert!(
- !std::path::Path::new(&std::env::var("HOME").unwrap())
- .join(".ssh")
- .exists()
- );
- assert!(std::fs::write("/outside-probe", b"no").is_err());
- assert!(
- std::net::TcpStream::connect_timeout(
- &"1.1.1.1:443".parse().unwrap(),
- std::time::Duration::from_millis(100)
- )
- .is_err()
- );
+ if std::env::var("SAUSAGE_TEST_NO_BUILDER_SANDBOX").as_deref() == Ok("1") {
+ println!("cargo:warning=namespace assertions skipped: builder sandbox disabled");
+ } else {
+ assert!(!std::path::Path::new("/home/surma/.ssh").exists());
+ assert!(
+ !std::path::Path::new(&std::env::var("HOME").unwrap())
+ .join(".ssh")
+ .exists()
+ );
+ assert!(std::fs::write("/outside-probe", b"no").is_err());
+ assert!(
+ std::net::TcpStream::connect_timeout(
+ &"1.1.1.1:443".parse().unwrap(),
+ std::time::Duration::from_millis(100)
+ )
+ .is_err()
+ );
+ println!("cargo:warning=namespace assertions passed");
+ }
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
let object = out.join("add.o");
assert!(
@@ -35,5 +40,4 @@ fn main() {
);
println!("cargo:rustc-link-search=native={}", out.display());
println!("cargo:rustc-link-lib=static=add");
- println!("cargo:warning=namespace assertions passed");
}
diff --git a/host/tests/it/builder.rs b/host/tests/it/builder.rs
index 83f5568..6668657 100644
--- a/host/tests/it/builder.rs
+++ b/host/tests/it/builder.rs
@@ -16,7 +16,14 @@ fn programs() -> PathBuf {
crate::common::cwd_init();
assert!(
std::process::Command::new("cargo")
- .args(["build", "--release", "-p", "world-cargo"])
+ .args([
+ "build",
+ "--release",
+ "--target",
+ "wasm32-wasip2",
+ "-p",
+ "world-cargo"
+ ])
.current_dir(root.join("userland"))
.status()
.unwrap()
@@ -63,7 +70,7 @@ async fn cargo_scaffold_and_human_output() -> anyhow::Result<()> {
std::fs::create_dir_all(&bin)?;
std::fs::copy(binaries.join("cargo.wasm"), bin.join("cargo.wasm"))?;
std::fs::copy(binaries.join("brush.wasm"), bin.join("sh.wasm"))?;
- let runtime = Runtime::new(world)?;
+ let runtime = Runtime::new_with_options(world, crate::common::runtime_options())?;
for (script, cwd, expected, success) in [
(
"cargo new hello && cd hello && cargo run",
@@ -201,10 +208,15 @@ async fn cargo_build_test_and_source_changes() -> anyhow::Result<()> {
] {
std::fs::write(source.join(path), bytes)?;
}
- let runtime = Runtime::new(world)?;
+ let runtime = Runtime::new_with_options(world, crate::common::runtime_options())?;
let (code, out) = cargo(&runtime, &["build"]).await?;
assert_eq!(code, 0, "{out}");
- assert!(out.contains("namespace assertions passed"), "{out}");
+ if crate::common::runtime_options().no_builder_sandbox {
+ println!("SKIP namespace isolation assertions: SAUSAGE_TEST_NO_BUILDER_SANDBOX=1");
+ assert!(out.contains("namespace assertions skipped"), "{out}");
+ } else {
+ assert!(out.contains("namespace assertions passed"), "{out}");
+ }
assert!(out.contains("(/project)"), "{out}");
assert!(!out.contains("/host/builds/"), "{out}");
assert_eq!(
@@ -349,7 +361,11 @@ async fn timeout_and_cancellation() -> anyhow::Result<()> {
source.join("build.rs"),
"fn main() { std::process::Command::new(\"sh\").args([\"-c\", \"echo $$ > child.pid; exec sleep 1000\"]).status().unwrap(); }",
)?;
- let builder = Arc::new(Builder::new(world.root.clone(), &world.host_dir)?);
+ let builder = Arc::new(Builder::new_with_options(
+ world.root.clone(),
+ &world.host_dir,
+ crate::common::runtime_options().no_builder_sandbox,
+ )?);
let (tx, mut rx) = tokio::sync::mpsc::channel(16);
let task = tokio::spawn(async move {
builder
@@ -375,7 +391,7 @@ async fn timeout_and_cancellation() -> anyhow::Result<()> {
let bin = temp.path().join("world/bin");
std::fs::create_dir_all(&bin)?;
std::fs::copy(binaries.join("cargo.wasm"), bin.join("cargo.wasm"))?;
- let runtime = Runtime::new(world)?;
+ let runtime = Runtime::new_with_options(world, crate::common::runtime_options())?;
let error = runtime
.exec(
Command::Argv(vec!["/bin/cargo.wasm".into(), "build".into()]),
diff --git a/host/tests/it/common.rs b/host/tests/it/common.rs
index 76b570c..6d7a480 100644
--- a/host/tests/it/common.rs
+++ b/host/tests/it/common.rs
@@ -8,6 +8,12 @@ use std::{
};
pub static BUILD: Mutex<()> = Mutex::new(());
+pub fn runtime_options() -> sausage::process::RuntimeOptions {
+ sausage::process::RuntimeOptions {
+ no_builder_sandbox: std::env::var("SAUSAGE_TEST_NO_BUILDER_SANDBOX").as_deref() == Ok("1"),
+ }
+}
+
/// Keep the real shared prompt in every roster fixture, with an ephemeral approval port.
pub fn write_config(dir: &Path, standing: &str, roster: serde_json::Value) -> anyhow::Result<()> {
let mut config: serde_json::Value =