Merge v3-t96: --no-builder-sandbox host switch (builder without bubblewrap, for the Nix sandbox) (T96)

This commit is contained in:
2026-11-12 09:34:16 +00:00
11 changed files with 197 additions and 84 deletions
+1
View File
@@ -10,6 +10,7 @@ Keep the complete world folder between starts.
Run host commands from a checkout of the `v3` branch on Citadel.
Use Nix with flakes. The package supplies the native Rust builder and its tools.
Use `sausage run --no-builder-sandbox WORLD` only when the host must run the builder without namespace isolation.
The package entry point is:
+7 -2
View File
@@ -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());
+7 -2
View File
@@ -29,9 +29,14 @@ It also owns a registry and kache cache.
These directories live under `<dir>/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.
+64 -44
View File
@@ -47,15 +47,23 @@ pub struct Builder {
root: Arc<dyn Layer>,
directory: PathBuf,
lock: Mutex<()>,
no_sandbox: bool,
}
impl Builder {
pub fn new(root: Arc<dyn Layer>, host: &Path) -> Result<Self> {
Self::new_with_options(root, host, false)
}
pub fn new_with_options(root: Arc<dyn Layer>, host: &Path, no_sandbox: bool) -> Result<Self> {
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<Event>) -> 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 [&registry, &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 [&registry, &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(&registry)
.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!(
+11
View File
@@ -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()?;
+16 -3
View File
@@ -21,7 +21,12 @@ enum Command {
userland: Option<PathBuf>,
},
/// 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,
+6 -3
View File
@@ -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 });
+38 -9
View File
@@ -154,20 +154,36 @@ struct Started {
output: Option<DuplexStream>,
error: Option<DuplexStream>,
}
/// 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<Arc<Self>> {
Self::new(World::open(path)?)
}
pub fn new(world: World) -> anyhow::Result<Arc<Self>> {
Self::with_compiler(world, cache::shared()?)
Self::new_with_options(world, RuntimeOptions::default())
}
pub fn new_with_options(world: World, options: RuntimeOptions) -> anyhow::Result<Arc<Self>> {
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<Arc<Self>> {
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<cache::Compiler>) -> anyhow::Result<Arc<Self>> {
fn with_compiler(
world: World,
compiler: Arc<cache::Compiler>,
options: RuntimeOptions,
) -> anyhow::Result<Arc<Self>> {
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<dyn crate::cred::script::Script>,
router: Option<Router>,
) -> anyhow::Result<Arc<Self>> {
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<crate::cred::Cred>,
script: Arc<dyn crate::cred::script::Script>,
router: Option<Router>,
options: RuntimeOptions,
) -> anyhow::Result<Arc<Self>> {
Self::with_hooks(world, cache::shared()?, cred, script, router, options)
}
fn with_hooks(
world: World,
@@ -184,6 +209,7 @@ impl Runtime {
cred: Arc<crate::cred::Cred>,
script: Arc<dyn crate::cred::script::Script>,
router: Option<http::Router>,
options: RuntimeOptions,
) -> anyhow::Result<Arc<Self>> {
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));
+19 -15
View File
@@ -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");
}
+22 -6
View File
@@ -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()]),
+6
View File
@@ -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 =