From 2a9afeff7c4ccf6739c05da923c9c7360f41d259 Mon Sep 17 00:00:00 2001 From: Tyler Payne Date: Wed, 8 Jul 2026 11:42:58 -0400 Subject: [PATCH 1/2] feat: multi-user support in sandboxes Add OS-user management to the Quicksand API. Sandbox.create_user() and Sandbox.delete_user() manage accounts inside the guest, and execute() accepts a user= parameter (or use the returned SandboxUser handle) to run commands with that user's uid/gid/groups and HOME. Multiple users share one VM, giving per-user isolation without per-user VM overhead. The guest agent implements the accounts natively by editing /etc/passwd, /etc/group and /etc/shadow, so behaviour is identical across distros with no dependency on adduser/useradd. Commands drop privileges via initgroups/setgid/setuid in a pre_exec hook. Both the HTTP and virtio-serial transports expose create_user/delete_user and the user field on execute/execute_stream. Since the Dockerfiles compile the agent from a build-context copy the Dockerfile text does not reference, the image cache key now folds in a hash of the agent source, with a sidecar file to validate caller-supplied output paths. The ubuntu hatch hook now always delegates to build_image so a cached-but-stale qcow2 is rebuilt instead of packaged. Co-Authored-By: Claude Fable 5 --- .../contrib/quicksand-ubuntu/hatch_build.py | 9 +- .../quicksand_image_tools/build.py | 50 ++- .../quicksand-guest-agent/Cargo.toml | 2 + .../quicksand-guest-agent/src/main.rs | 371 +++++++++++++++++- .../quicksand-core/quicksand_core/__init__.py | 3 +- .../quicksand-core/quicksand_core/_types.py | 3 + .../host/quicksand_guest_agent_client.py | 2 + .../host/virtio_serial_agent_client.py | 2 + .../quicksand_core/sandbox/__init__.py | 3 +- .../quicksand_core/sandbox/_execution.py | 113 +++++- .../quicksand_core/sandbox/_protocol.py | 1 + packages/quicksand/quicksand/__init__.py | 2 + tests/unit/test_checkpoint_ops.py | 2 + tests/unit/test_dev_build.py | 9 +- tests/unit/test_disk.py | 1 + tests/unit/test_mounts.py | 1 + tests/unit/test_multiuser.py | 100 +++++ 17 files changed, 660 insertions(+), 14 deletions(-) create mode 100644 tests/unit/test_multiuser.py diff --git a/packages/contrib/quicksand-ubuntu/hatch_build.py b/packages/contrib/quicksand-ubuntu/hatch_build.py index 6f0b7af..fed02cf 100644 --- a/packages/contrib/quicksand-ubuntu/hatch_build.py +++ b/packages/contrib/quicksand-ubuntu/hatch_build.py @@ -42,9 +42,12 @@ def initialize(self, version: str, build_data: dict) -> None: image_path = images_dir / f"ubuntu-{distro_version}-{arch}.qcow2" dockerfile_path = Path(self.root) / "quicksand_ubuntu" / "docker" / "Dockerfile" - if not image_path.exists(): - self.app.display_info(f"Image not found: {image_path.name}, building...") - self._build_image(dockerfile_path, image_path) + # Always delegate to build_image: it reuses the cached qcow2 only when + # its sidecar hash matches the current Dockerfile + agent source, and + # rebuilds otherwise. Gating on mere existence here would package a + # stale image after an agent-only change. + self.app.display_info(f"Ensuring image is up to date: {image_path.name}") + self._build_image(dockerfile_path, image_path) self.app.display_info(f"Including image: {image_path}") diff --git a/packages/dev/quicksand-image-tools/quicksand_image_tools/build.py b/packages/dev/quicksand-image-tools/quicksand_image_tools/build.py index 3eab1b0..dafe5ed 100644 --- a/packages/dev/quicksand-image-tools/quicksand_image_tools/build.py +++ b/packages/dev/quicksand-image-tools/quicksand_image_tools/build.py @@ -31,6 +31,28 @@ def get_agent_source_dir() -> Path: return AGENT_SOURCE_DIR +def _agent_source_hash() -> str: + """Hash of the Rust agent source (Cargo manifests + all .rs files). + + Folded into the image cache key so that changes to the agent — which the + Dockerfile compiles but does not itself reference — invalidate cached + images. Without this, an agent-only change reuses a stale qcow2. + """ + h = hashlib.sha256() + if AGENT_SOURCE_DIR.exists(): + paths = sorted( + p + for p in AGENT_SOURCE_DIR.rglob("*") + if p.is_file() + and "target" not in p.relative_to(AGENT_SOURCE_DIR).parts + and (p.suffix == ".rs" or p.name in ("Cargo.toml", "Cargo.lock")) + ) + for p in paths: + h.update(p.relative_to(AGENT_SOURCE_DIR).as_posix().encode()) + h.update(p.read_bytes()) + return h.hexdigest() + + def build_image( dockerfile: str | Path, output_path: Path | None = None, @@ -79,15 +101,35 @@ def build_image( dockerfile_content = dockerfile_path.read_text() context_dir = dockerfile_path.parent - # Compute hash for caching - content_hash = hashlib.sha256(dockerfile_content.encode()).hexdigest()[:16] + # Compute hash for caching. Includes the agent source because the + # Dockerfile compiles the agent from a build-context copy that the + # Dockerfile text doesn't reference — so agent changes must bust the cache. + content_hash = hashlib.sha256( + dockerfile_content.encode() + _agent_source_hash().encode() + ).hexdigest()[:16] + explicit_output = output_path is not None if output_path is None: output_path = cache / f"custom-{content_hash}.qcow2" + # A sidecar records the content hash of the inputs (Dockerfile + agent + # source) that produced ``output_path``. A caller-supplied output path has + # a fixed name, so without the sidecar a stale image (e.g. built before an + # agent change) would be silently reused. The default ``custom-`` + # path already encodes the hash in its name, so existence alone proves + # freshness there. + hash_sidecar = output_path.with_name(output_path.name + ".buildhash") + # Check cache if output_path.exists(): - if force: + if explicit_output: + cached_hash = hash_sidecar.read_text().strip() if hash_sidecar.exists() else None + stale = cached_hash != content_hash + else: + stale = False + if force or stale: + reason = "forced" if force else "inputs changed" + log.info("Rebuilding image (%s): %s", reason, output_path) output_path.unlink() else: log.info("Using cached image: %s", output_path) @@ -128,6 +170,8 @@ def build_image( _remove_docker_image(tag) log.info("[5/5] Done!") + # Record the inputs' hash so a later build can detect staleness. + hash_sidecar.write_text(content_hash) final_size_mb = output_path.stat().st_size / (1024 * 1024) log.info("Output: %s (%.1f MB)", output_path, final_size_mb) diff --git a/packages/dev/quicksand-image-tools/quicksand_image_tools/quicksand-guest-agent/Cargo.toml b/packages/dev/quicksand-image-tools/quicksand_image_tools/quicksand-guest-agent/Cargo.toml index 0c3b3b1..074cb1a 100644 --- a/packages/dev/quicksand-image-tools/quicksand_image_tools/quicksand-guest-agent/Cargo.toml +++ b/packages/dev/quicksand-image-tools/quicksand_image_tools/quicksand-guest-agent/Cargo.toml @@ -23,6 +23,8 @@ tokio-stream = "0.1" # single open handle and a buffered `tokio::fs::File` seeks when reads and # writes interleave (ESPIPE on a non-seekable char device), so the transport # drives one non-blocking fd through `tokio::io::unix::AsyncFd` instead. +# Also used for privilege drop (setuid/setgid/initgroups) and chown/kill in +# multi-user mode. libc = "0.2" [profile.release] diff --git a/packages/dev/quicksand-image-tools/quicksand_image_tools/quicksand-guest-agent/src/main.rs b/packages/dev/quicksand-image-tools/quicksand_image_tools/quicksand-guest-agent/src/main.rs index 570a775..e197386 100644 --- a/packages/dev/quicksand-image-tools/quicksand_image_tools/quicksand-guest-agent/src/main.rs +++ b/packages/dev/quicksand-image-tools/quicksand_image_tools/quicksand-guest-agent/src/main.rs @@ -16,13 +16,16 @@ use axum::{ use serde::{Deserialize, Serialize}; use std::{ convert::Infallible, + ffi::CString, fs, io::Write, net::SocketAddr, + os::unix::fs::{MetadataExt, PermissionsExt}, + os::unix::process::CommandExt, process::{Command, Stdio}, sync::{ atomic::{AtomicBool, Ordering}, - Arc, + Arc, Mutex, }, time::Duration, }; @@ -93,12 +96,32 @@ struct ExecuteRequest { /// When true, reject all other execute requests while this one is running. #[serde(default)] exclusive: bool, + /// Optional OS user to run the command as. When set, the command is + /// executed with that user's uid/gid/groups and HOME, defaulting cwd to + /// the user's home directory. + #[serde(default)] + user: Option, } fn default_timeout() -> f64 { 30.0 } +#[derive(Deserialize)] +struct UserRequest { + name: String, + /// On delete, also remove the user's home directory. + #[serde(default)] + remove_home: bool, +} + +#[derive(Serialize)] +struct UserCreatedResponse { + uid: u32, + gid: u32, + home: String, +} + #[derive(Serialize)] struct ExecuteResponse { stdout: String, @@ -141,6 +164,231 @@ fn verify_token(headers: &axum::http::HeaderMap, expected: &str) -> Result<(), ( } } +// ============================================================================ +// Multi-user support +// ============================================================================ +// +// Users are managed by editing the standard POSIX account files directly +// (/etc/passwd, /etc/group, /etc/shadow). This keeps the behaviour identical +// across every distro (Alpine, Ubuntu, ...) because those formats are +// standardised and every minimal guest defaults to the `files` nsswitch +// backend — no dependency on distro-specific `adduser`/`useradd` tools. + +/// uid/gid range for quicksand-managed users. +const UID_MIN: u32 = 1000; +const UID_MAX: u32 = 60000; + +/// Serializes account-file mutations so concurrent create/delete requests +/// (the agent multiplexes requests) can't produce a torn /etc/passwd write. +static USER_MGMT_LOCK: Mutex<()> = Mutex::new(()); + +#[derive(Clone)] +struct PwEntry { + uid: u32, + gid: u32, + home: String, +} + +/// Look up an existing user in /etc/passwd. Returns None if absent. +fn lookup_user(name: &str) -> Option { + let content = fs::read_to_string("/etc/passwd").ok()?; + for line in content.lines() { + let f: Vec<&str> = line.split(':').collect(); + if f.len() >= 7 && f[0] == name { + return Some(PwEntry { + uid: f[2].parse().ok()?, + gid: f[3].parse().ok()?, + home: f[5].to_string(), + }); + } + } + None +} + +/// Validate a username to prevent injection into the colon/newline-delimited +/// account files. POSIX-portable subset: starts with [a-z_], then [a-z0-9_-]. +fn valid_username(name: &str) -> bool { + !name.is_empty() + && name.len() <= 32 + && name.bytes().enumerate().all(|(i, b)| match b { + b'a'..=b'z' | b'_' => true, + b'0'..=b'9' | b'-' if i > 0 => true, + _ => false, + }) +} + +/// Highest id in [UID_MIN, UID_MAX) across passwd+group, plus one. +fn next_free_id() -> u32 { + let mut max = UID_MIN - 1; + for (path, field) in [("/etc/passwd", 2usize), ("/etc/group", 2usize)] { + if let Ok(content) = fs::read_to_string(path) { + for line in content.lines() { + let f: Vec<&str> = line.split(':').collect(); + if f.len() > field { + if let Ok(id) = f[field].parse::() { + if (UID_MIN..UID_MAX).contains(&id) && id > max { + max = id; + } + } + } + } + } + } + max + 1 +} + +fn append_line(path: &str, line: &str) -> std::io::Result<()> { + let mut f = fs::OpenOptions::new().append(true).create(true).open(path)?; + f.write_all(line.as_bytes())?; + f.write_all(b"\n") +} + +fn remove_lines_for_user(path: &str, name: &str) -> std::io::Result<()> { + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return Ok(()), // file may not exist (e.g. shadow); nothing to do + }; + let prefix = format!("{}:", name); + let mut out: String = content + .lines() + .filter(|l| !l.starts_with(&prefix)) + .collect::>() + .join("\n"); + if !out.is_empty() { + out.push('\n'); + } + fs::write(path, out) +} + +/// SIGKILL every process owned by `uid` (each /proc/ dir is owned by the +/// process's real uid). +fn kill_user_processes(uid: u32) { + if let Ok(entries) = fs::read_dir("/proc") { + for entry in entries.flatten() { + let fname = entry.file_name(); + let pid = match fname.to_string_lossy().parse::() { + Ok(p) => p, + Err(_) => continue, + }; + if let Ok(meta) = fs::metadata(format!("/proc/{}", pid)) { + if meta.uid() == uid { + unsafe { + libc::kill(pid, libc::SIGKILL); + } + } + } + } + } +} + +/// Create a new user account (distro-agnostic, native file manipulation). +fn create_user(name: &str) -> Result { + let _guard = USER_MGMT_LOCK.lock().unwrap(); + + if !valid_username(name) { + return Err(format!("Invalid username: {}", name)); + } + if lookup_user(name).is_some() { + return Err(format!("User already exists: {}", name)); + } + + let id = next_free_id(); + if id >= UID_MAX { + return Err("No free uid available".to_string()); + } + let home = format!("/home/{}", name); + + // group: name:x:gid: + append_line("/etc/group", &format!("{}:x:{}:", name, id)) + .map_err(|e| format!("write /etc/group: {}", e))?; + // passwd: name:x:uid:gid:gecos:home:shell (gecos empty; /bin/sh is universal) + append_line( + "/etc/passwd", + &format!("{}:x:{}:{}::{}:/bin/sh", name, id, id, home), + ) + .map_err(|e| format!("write /etc/passwd: {}", e))?; + // shadow: locked password (`!`); we never password-auth, only setuid. + let _ = append_line("/etc/shadow", &format!("{}:!::0:99999:7:::", name)); + + fs::create_dir_all(&home).map_err(|e| format!("create {}: {}", home, e))?; + let c_home = CString::new(home.as_str()).map_err(|e| e.to_string())?; + unsafe { + if libc::chown(c_home.as_ptr(), id as _, id as _) != 0 { + return Err(format!("chown {}: {}", home, std::io::Error::last_os_error())); + } + } + fs::set_permissions(&home, fs::Permissions::from_mode(0o700)) + .map_err(|e| format!("chmod {}: {}", home, e))?; + + Ok(PwEntry { uid: id, gid: id, home }) +} + +/// Delete a user: kill its processes, strip account-file entries, optionally +/// remove its home directory. +fn delete_user(name: &str, remove_home: bool) -> Result<(), String> { + let _guard = USER_MGMT_LOCK.lock().unwrap(); + + if !valid_username(name) { + return Err(format!("Invalid username: {}", name)); + } + let pw = lookup_user(name).ok_or_else(|| format!("No such user: {}", name))?; + + kill_user_processes(pw.uid); + + remove_lines_for_user("/etc/passwd", name).map_err(|e| format!("edit /etc/passwd: {}", e))?; + remove_lines_for_user("/etc/group", name).map_err(|e| format!("edit /etc/group: {}", e))?; + let _ = remove_lines_for_user("/etc/shadow", name); + + if remove_home { + let _ = fs::remove_dir_all(&pw.home); + } + Ok(()) +} + +/// Resolve an optional username into a `PwEntry`, returning an error string if +/// the user is requested but doesn't exist. +fn resolve_user(user: &Option) -> Result, String> { + match user { + None => Ok(None), + Some(u) => match lookup_user(u) { + Some(pw) => Ok(Some((u.clone(), pw))), + None => Err(format!("No such user: {}", u)), + }, + } +} + +/// Configure a Command (std or tokio) to run as `name`/`pw`: set HOME/USER and +/// register a pre_exec hook that, in order, joins the user's supplementary +/// groups then drops gid and uid. Everything happens while still root in the +/// forked child, before exec — ordering is explicit so the privilege drop is +/// correct regardless of std internals. +macro_rules! configure_user { + ($cmd:expr, $pw:expr, $name:expr) => {{ + let pw_ref: &PwEntry = $pw; + let nm: &str = $name; + let uid = pw_ref.uid; + let gid = pw_ref.gid; + let name_c = CString::new(nm).expect("validated username"); + $cmd.env("HOME", &pw_ref.home).env("USER", nm).env("LOGNAME", nm); + unsafe { + $cmd.pre_exec(move || { + // `as _` adapts to the target's libc types (e.g. initgroups' + // basegroup is gid_t on Linux but c_int on macOS). + if libc::initgroups(name_c.as_ptr(), gid as _) != 0 { + return Err(std::io::Error::last_os_error()); + } + if libc::setgid(gid as _) != 0 { + return Err(std::io::Error::last_os_error()); + } + if libc::setuid(uid as _) != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + }}; +} + // ============================================================================ // Handlers // ============================================================================ @@ -165,6 +413,13 @@ async fn execute( return e.into_response(); } + // Resolve the target user (if any) before touching the exclusive lock so a + // bad username can't leave the lock claimed. + let user_pw = match resolve_user(&req.user) { + Ok(v) => v, + Err(e) => return (StatusCode::BAD_REQUEST, Json(ErrorResponse { detail: e })).into_response(), + }; + // Reject if an exclusive command is already running. if state.exclusive_busy.load(Ordering::SeqCst) { return ( @@ -203,6 +458,11 @@ async fn execute( if let Some(cwd) = &req.cwd { cmd.current_dir(cwd); + } else if let Some((_, pw)) = &user_pw { + cmd.current_dir(&pw.home); + } + if let Some((name, pw)) = &user_pw { + configure_user!(cmd, pw, name.as_str()); } cmd.output() @@ -244,6 +504,12 @@ async fn execute_stream( return e.into_response(); } + // Resolve the target user (if any) before touching the exclusive lock. + let user_pw = match resolve_user(&req.user) { + Ok(v) => v, + Err(e) => return (StatusCode::BAD_REQUEST, Json(ErrorResponse { detail: e })).into_response(), + }; + // Reject if an exclusive command is already running. if state.exclusive_busy.load(Ordering::SeqCst) { return ( @@ -286,6 +552,11 @@ async fn execute_stream( if let Some(cwd) = &req.cwd { cmd.current_dir(cwd); + } else if let Some((_, pw)) = &user_pw { + cmd.current_dir(&pw.home); + } + if let Some((name, pw)) = &user_pw { + configure_user!(cmd, pw, name.as_str()); } let mut child = match cmd.spawn() { @@ -379,6 +650,42 @@ async fn ping( .into_response() } +async fn create_user_handler( + State(state): State, + headers: axum::http::HeaderMap, + Json(req): Json, +) -> impl IntoResponse { + if let Err(e) = verify_token(&headers, &state.token) { + return e.into_response(); + } + match create_user(&req.name) { + Ok(pw) => ( + StatusCode::OK, + Json(UserCreatedResponse { + uid: pw.uid, + gid: pw.gid, + home: pw.home, + }), + ) + .into_response(), + Err(e) => (StatusCode::BAD_REQUEST, Json(ErrorResponse { detail: e })).into_response(), + } +} + +async fn delete_user_handler( + State(state): State, + headers: axum::http::HeaderMap, + Json(req): Json, +) -> impl IntoResponse { + if let Err(e) = verify_token(&headers, &state.token) { + return e.into_response(); + } + match delete_user(&req.name, req.remove_home) { + Ok(()) => (StatusCode::OK, Json(serde_json::json!({"removed": true}))).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, Json(ErrorResponse { detail: e })).into_response(), + } +} + // ============================================================================ // Shared command execution (used by both HTTP and virtio-serial transports) // ============================================================================ @@ -389,7 +696,23 @@ struct ExecResult { exit_code: i32, } -async fn run_command(command: &str, timeout_secs: f64, cwd: Option<&str>) -> ExecResult { +async fn run_command( + command: &str, + timeout_secs: f64, + cwd: Option<&str>, + user: Option<&str>, +) -> ExecResult { + let user_pw = match resolve_user(&user.map(|s| s.to_string())) { + Ok(v) => v, + Err(e) => { + return ExecResult { + stdout: String::new(), + stderr: e, + exit_code: -1, + } + } + }; + let timeout_duration = Duration::from_secs_f64(timeout_secs); let result = timeout(timeout_duration, async { @@ -399,6 +722,11 @@ async fn run_command(command: &str, timeout_secs: f64, cwd: Option<&str>) -> Exe cmd.stderr(Stdio::piped()); if let Some(dir) = cwd { cmd.current_dir(dir); + } else if let Some((_, pw)) = &user_pw { + cmd.current_dir(&pw.home); + } + if let Some((name, pw)) = &user_pw { + configure_user!(cmd, pw, name.as_str()); } cmd.output() }) @@ -614,6 +942,7 @@ async fn handle_virtio_serial(token: String, exclusive_busy: Arc) { let command = params.get("command").and_then(|v| v.as_str()).unwrap_or("").to_string(); let timeout_secs = params.get("timeout").and_then(|v| v.as_f64()).unwrap_or(30.0); let cwd = params.get("cwd").and_then(|v| v.as_str()).map(|s| s.to_string()); + let user = params.get("user").and_then(|v| v.as_str()).map(|s| s.to_string()); let is_exclusive = params.get("exclusive").and_then(|v| v.as_bool()).unwrap_or(false); if exclusive_busy.load(Ordering::SeqCst) { @@ -632,7 +961,8 @@ async fn handle_virtio_serial(token: String, exclusive_busy: Arc) { let tx = tx.clone(); let exclusive_busy = Arc::clone(&exclusive_busy); tokio::spawn(async move { - let result = run_command(&command, timeout_secs, cwd.as_deref()).await; + let result = + run_command(&command, timeout_secs, cwd.as_deref(), user.as_deref()).await; if is_exclusive { exclusive_busy.store(false, Ordering::SeqCst); @@ -652,8 +982,19 @@ async fn handle_virtio_serial(token: String, exclusive_busy: Arc) { let command = params.get("command").and_then(|v| v.as_str()).unwrap_or("").to_string(); let timeout_secs = params.get("timeout").and_then(|v| v.as_f64()).unwrap_or(30.0); let cwd = params.get("cwd").and_then(|v| v.as_str()).map(|s| s.to_string()); + let user = params.get("user").and_then(|v| v.as_str()).map(|s| s.to_string()); let is_exclusive = params.get("exclusive").and_then(|v| v.as_bool()).unwrap_or(false); + // Resolve the target user before claiming the exclusive lock. + let user_pw = match resolve_user(&user) { + Ok(v) => v, + Err(e) => { + send_frame(&tx, serde_json::json!({"id": id, "stream": "stderr", "data": format!("{}\n", e)})); + send_frame(&tx, serde_json::json!({"id": id, "stream": "exit", "exit_code": -1})); + continue; + } + }; + if exclusive_busy.load(Ordering::SeqCst) { send_frame(&tx, serde_json::json!({"id": id, "error": {"message": "Exclusive command in progress"}})); continue; @@ -679,6 +1020,11 @@ async fn handle_virtio_serial(token: String, exclusive_busy: Arc) { cmd.stderr(Stdio::piped()); if let Some(dir) = &cwd { cmd.current_dir(dir); + } else if let Some((_, pw)) = &user_pw { + cmd.current_dir(&pw.home); + } + if let Some((name, pw)) = &user_pw { + configure_user!(cmd, pw, name.as_str()); } let mut child = match cmd.spawn() { @@ -738,6 +1084,23 @@ async fn handle_virtio_serial(token: String, exclusive_busy: Arc) { } }); } + "create_user" if authenticated => { + let name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let resp = match create_user(name) { + Ok(pw) => serde_json::json!({"id": id, "result": {"uid": pw.uid, "gid": pw.gid, "home": pw.home}}), + Err(e) => serde_json::json!({"id": id, "error": {"message": e}}), + }; + send_frame(&tx, resp); + } + "delete_user" if authenticated => { + let name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let remove_home = params.get("remove_home").and_then(|v| v.as_bool()).unwrap_or(false); + let resp = match delete_user(name, remove_home) { + Ok(()) => serde_json::json!({"id": id, "result": {"removed": true}}), + Err(e) => serde_json::json!({"id": id, "error": {"message": e}}), + }; + send_frame(&tx, resp); + } _ if !authenticated => { send_frame(&tx, serde_json::json!({"id": id, "error": {"message": "Not authenticated"}})); } @@ -808,6 +1171,8 @@ async fn main() { .route("/authenticate", post(authenticate)) .route("/execute", post(execute)) .route("/execute_stream", post(execute_stream)) + .route("/create_user", post(create_user_handler)) + .route("/delete_user", post(delete_user_handler)) .route("/ping", get(ping)) .with_state(state); diff --git a/packages/quicksand-core/quicksand_core/__init__.py b/packages/quicksand-core/quicksand_core/__init__.py index f265cf4..964b883 100644 --- a/packages/quicksand-core/quicksand_core/__init__.py +++ b/packages/quicksand-core/quicksand_core/__init__.py @@ -52,7 +52,7 @@ get_runtime, is_runtime_available, ) -from .sandbox import ExecuteResult, Sandbox, SandboxConfig, SandboxConfigParams +from .sandbox import ExecuteResult, Sandbox, SandboxConfig, SandboxConfigParams, SandboxUser __all__ = [ # noqa: RUF022 # Types module alias @@ -66,6 +66,7 @@ "MountType", "PortForward", "ExecuteResult", + "SandboxUser", # Boot timing "BootTiming", # Save diff --git a/packages/quicksand-core/quicksand_core/_types.py b/packages/quicksand-core/quicksand_core/_types.py index a8ab9a1..fd6bfd7 100644 --- a/packages/quicksand-core/quicksand_core/_types.py +++ b/packages/quicksand-core/quicksand_core/_types.py @@ -237,6 +237,8 @@ class QuicksandGuestAgentMethod(StrEnum): EXECUTE_STREAM = "execute_stream" PING = "ping" AUTHENTICATE = "authenticate" + CREATE_USER = "create_user" + DELETE_USER = "delete_user" # ============================================================================= @@ -404,6 +406,7 @@ class ExecuteParams: shell: str cwd: str | None = None exclusive: bool = False + user: str | None = None @dataclass diff --git a/packages/quicksand-core/quicksand_core/host/quicksand_guest_agent_client.py b/packages/quicksand-core/quicksand_core/host/quicksand_guest_agent_client.py index fa17c9e..5884ed3 100644 --- a/packages/quicksand-core/quicksand_core/host/quicksand_guest_agent_client.py +++ b/packages/quicksand-core/quicksand_core/host/quicksand_guest_agent_client.py @@ -185,6 +185,8 @@ async def send_request( endpoint_map = { QuicksandGuestAgentMethod.EXECUTE: "/execute", QuicksandGuestAgentMethod.PING: "/ping", + QuicksandGuestAgentMethod.CREATE_USER: "/create_user", + QuicksandGuestAgentMethod.DELETE_USER: "/delete_user", } endpoint = endpoint_map.get(method) diff --git a/packages/quicksand-core/quicksand_core/host/virtio_serial_agent_client.py b/packages/quicksand-core/quicksand_core/host/virtio_serial_agent_client.py index cfc09e4..bd780d4 100644 --- a/packages/quicksand-core/quicksand_core/host/virtio_serial_agent_client.py +++ b/packages/quicksand-core/quicksand_core/host/virtio_serial_agent_client.py @@ -294,6 +294,8 @@ async def send_request( method_map = { QuicksandGuestAgentMethod.EXECUTE: "execute", QuicksandGuestAgentMethod.PING: "ping", + QuicksandGuestAgentMethod.CREATE_USER: "create_user", + QuicksandGuestAgentMethod.DELETE_USER: "delete_user", } method_name = method_map.get(method) if method_name is None: diff --git a/packages/quicksand-core/quicksand_core/sandbox/__init__.py b/packages/quicksand-core/quicksand_core/sandbox/__init__.py index 16530b9..5cff96a 100644 --- a/packages/quicksand-core/quicksand_core/sandbox/__init__.py +++ b/packages/quicksand-core/quicksand_core/sandbox/__init__.py @@ -1,6 +1,7 @@ """Sandbox package — re-exports the public surface.""" from .._types import ExecuteResult, SandboxConfig, SandboxConfigParams +from ._execution import SandboxUser from ._sandbox import Sandbox -__all__ = ["ExecuteResult", "Sandbox", "SandboxConfig", "SandboxConfigParams"] +__all__ = ["ExecuteResult", "Sandbox", "SandboxConfig", "SandboxConfigParams", "SandboxUser"] diff --git a/packages/quicksand-core/quicksand_core/sandbox/_execution.py b/packages/quicksand-core/quicksand_core/sandbox/_execution.py index c0472ab..35fe2af 100644 --- a/packages/quicksand-core/quicksand_core/sandbox/_execution.py +++ b/packages/quicksand-core/quicksand_core/sandbox/_execution.py @@ -36,6 +36,7 @@ async def execute( on_stdout: Callable[[str], None] | None = None, on_stderr: Callable[[str], None] | None = None, exclusive: bool = False, + user: str | None = None, ) -> ExecuteResult: """ Execute a shell command inside the sandbox. @@ -55,6 +56,11 @@ async def execute( exclusive: If True, the guest agent will reject other requests while this command is running. Used for system commands like sync/fstrim that need exclusive access. + user: Optional OS user to run the command as. The command runs + with that user's uid/gid/groups and HOME, defaulting cwd to + the user's home directory. The user must already exist (see + ``create_user``). Prefer obtaining a ``SandboxUser`` via + ``create_user`` and calling ``.execute()`` on it. Returns: ExecuteResult with stdout, stderr, and exit_code. @@ -63,7 +69,7 @@ async def execute( raise RuntimeError("Sandbox is not running") params = ExecuteParams( - command=command, timeout=timeout, shell=shell, cwd=cwd, exclusive=exclusive + command=command, timeout=timeout, shell=shell, cwd=cwd, exclusive=exclusive, user=user ) params_dict = {k: v for k, v in asdict(params).items() if v is not None} @@ -90,3 +96,108 @@ async def execute( result = ExecuteResponseResult(**response["result"]) return ExecuteResult(stdout=result.stdout, stderr=result.stderr, exit_code=result.exit_code) + + async def create_user(self, name: str) -> SandboxUser: + """Create an OS user in the sandbox and return a handle scoped to it. + + Distro-agnostic: the guest agent creates the account by writing the + standard POSIX files (``/etc/passwd``, ``/etc/group``, ``/etc/shadow``), + so it behaves identically on Alpine, Ubuntu, and any other guest with + no dependency on distro-specific ``adduser``/``useradd`` tools. + + Multiple users share one VM, giving logical per-user isolation + (separate ``$HOME``, ownership, credentials) without the memory cost of + a separate VM per user. + + Args: + name: Username. Must match ``[a-z_][a-z0-9_-]*`` (max 32 chars). + + Returns: + A ``SandboxUser`` whose ``execute`` runs commands as this user. + + Raises: + RuntimeError: If the sandbox is not running, or creation fails + (e.g. invalid name or the user already exists). + """ + if not self.is_running: + raise RuntimeError("Sandbox is not running") + response = await self._send_request(QuicksandGuestAgentMethod.CREATE_USER, {"name": name}) + if "error" in response: + message = response["error"].get("message", "unknown error") + raise RuntimeError(f"Failed to create user {name!r}: {message}") + result = response["result"] + return SandboxUser(self, name, uid=result["uid"], gid=result["gid"], home=result["home"]) + + async def delete_user(self, name: str, *, remove_home: bool = True) -> None: + """Delete an OS user from the sandbox. + + Kills the user's running processes, removes the account entries, and + (by default) deletes the home directory. + + Args: + name: Username to delete. + remove_home: If True (default), also remove ``/home/``. + + Raises: + RuntimeError: If the sandbox is not running, or deletion fails. + """ + if not self.is_running: + raise RuntimeError("Sandbox is not running") + response = await self._send_request( + QuicksandGuestAgentMethod.DELETE_USER, + {"name": name, "remove_home": remove_home}, + ) + if "error" in response: + message = response["error"].get("message", "unknown error") + raise RuntimeError(f"Failed to delete user {name!r}: {message}") + + +class SandboxUser: + """A handle to an OS user inside a running sandbox. + + Returned by :meth:`Sandbox.create_user`. Its :meth:`execute` runs commands + as this user (uid/gid/supplementary groups and ``HOME``), defaulting the + working directory to the user's home. It is a thin view over the sandbox's + single control channel — every user multiplexes over the same agent + connection, so there is no per-user VM overhead. + """ + + def __init__( + self, + sandbox: _ExecutionMixin, + name: str, + *, + uid: int, + gid: int, + home: str, + ): + self._sandbox = sandbox + self.name = name + self.uid = uid + self.gid = gid + self.home = home + + async def execute( + self, + command: str, + timeout: float = Timeouts.GUEST_AGENT_REQUEST, + cwd: str | None = None, + shell: str = GuestCommands.SHELL, + on_stdout: Callable[[str], None] | None = None, + on_stderr: Callable[[str], None] | None = None, + exclusive: bool = False, + ) -> ExecuteResult: + """Execute a command as this user. See :meth:`Sandbox.execute`.""" + return await self._sandbox.execute( + command, + timeout=timeout, + cwd=cwd, + shell=shell, + on_stdout=on_stdout, + on_stderr=on_stderr, + exclusive=exclusive, + user=self.name, + ) + + def __repr__(self) -> str: + return f"SandboxUser(name={self.name!r}, uid={self.uid}, home={self.home!r})" diff --git a/packages/quicksand-core/quicksand_core/sandbox/_protocol.py b/packages/quicksand-core/quicksand_core/sandbox/_protocol.py index 63ed8d6..9714407 100644 --- a/packages/quicksand-core/quicksand_core/sandbox/_protocol.py +++ b/packages/quicksand-core/quicksand_core/sandbox/_protocol.py @@ -77,6 +77,7 @@ async def execute( on_stdout: Callable[[str], None] | None = None, on_stderr: Callable[[str], None] | None = None, exclusive: bool = False, + user: str | None = None, ) -> ExecuteResult: ... async def save( diff --git a/packages/quicksand/quicksand/__init__.py b/packages/quicksand/quicksand/__init__.py index 0461b9f..4eb79b9 100644 --- a/packages/quicksand/quicksand/__init__.py +++ b/packages/quicksand/quicksand/__init__.py @@ -52,6 +52,7 @@ async def main(): Sandbox, SandboxConfig, SandboxConfigParams, + SandboxUser, SaveManifest, detect_accelerator, ensure_runtime, @@ -109,6 +110,7 @@ def __repr__(self): "Sandbox", "SandboxConfig", "SandboxConfigParams", + "SandboxUser", "Mount", "MountType", "PortForward", diff --git a/tests/unit/test_checkpoint_ops.py b/tests/unit/test_checkpoint_ops.py index 18125ef..dfe1b39 100644 --- a/tests/unit/test_checkpoint_ops.py +++ b/tests/unit/test_checkpoint_ops.py @@ -206,6 +206,7 @@ async def execute( on_stdout=None, on_stderr=None, exclusive=False, + user=None, ): return MagicMock(stdout="", stderr="", exit_code=0) @@ -372,6 +373,7 @@ async def execute( on_stdout=None, on_stderr=None, exclusive=False, + user=None, ): return MagicMock(stdout="", stderr="", exit_code=0) diff --git a/tests/unit/test_dev_build.py b/tests/unit/test_dev_build.py index 9351029..4a744c9 100644 --- a/tests/unit/test_dev_build.py +++ b/tests/unit/test_dev_build.py @@ -157,10 +157,15 @@ def test_dockerfile_path_input(self, tmp_dir, cache_dir): dockerfile_path = tmp_dir / "Dockerfile" dockerfile_path.write_text("FROM alpine:3.20\n") - # Create cached image + # Create cached image. The cache key covers the Dockerfile plus the + # agent source the build copies into the context. import hashlib - content_hash = hashlib.sha256(b"FROM alpine:3.20\n").hexdigest()[:16] + from quicksand_image_tools.build import _agent_source_hash + + content_hash = hashlib.sha256( + b"FROM alpine:3.20\n" + _agent_source_hash().encode() + ).hexdigest()[:16] cached_image = cache_dir / f"custom-{content_hash}.qcow2" cached_image.touch() diff --git a/tests/unit/test_disk.py b/tests/unit/test_disk.py index b043d44..6f50577 100644 --- a/tests/unit/test_disk.py +++ b/tests/unit/test_disk.py @@ -54,6 +54,7 @@ async def execute( on_stdout=None, on_stderr=None, exclusive=False, + user=None, ): return self._execute_fn(command, timeout) diff --git a/tests/unit/test_mounts.py b/tests/unit/test_mounts.py index b269b48..af3b01f 100644 --- a/tests/unit/test_mounts.py +++ b/tests/unit/test_mounts.py @@ -58,6 +58,7 @@ async def execute( on_stdout=None, on_stderr=None, exclusive=False, + user=None, ): return self._execute_fn(command, timeout) diff --git a/tests/unit/test_multiuser.py b/tests/unit/test_multiuser.py new file mode 100644 index 0000000..4427ab8 --- /dev/null +++ b/tests/unit/test_multiuser.py @@ -0,0 +1,100 @@ +"""Unit tests for multi-user support (SandboxUser, create/delete, exec routing). + +These exercise the host-side wiring with a mocked agent client, so no VM boots. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from quicksand_core import Sandbox, SandboxUser +from quicksand_core._types import QuicksandGuestAgentMethod + + +def _running_sandbox(send_request: AsyncMock) -> Sandbox: + """A Sandbox marked running with a mocked agent transport.""" + sb = Sandbox(image="alpine") + sb._is_running = True + sb._process_manager = MagicMock(is_running=True) + client = AsyncMock() + client.send_request = send_request + sb._agent_client = client + return sb + + +@pytest.mark.asyncio +async def test_execute_threads_user_into_params(): + send = AsyncMock(return_value={"result": {"stdout": "alice", "stderr": "", "exit_code": 0}}) + sb = _running_sandbox(send) + + result = await sb.execute("whoami", user="alice") + + assert result.stdout == "alice" + method, params, *_ = send.call_args.args + assert method == QuicksandGuestAgentMethod.EXECUTE + assert params["user"] == "alice" + + +@pytest.mark.asyncio +async def test_execute_omits_user_when_none(): + send = AsyncMock(return_value={"result": {"stdout": "", "stderr": "", "exit_code": 0}}) + sb = _running_sandbox(send) + + await sb.execute("true") + + _, params, *_ = send.call_args.args + assert "user" not in params # None fields are dropped before sending + + +@pytest.mark.asyncio +async def test_create_user_returns_handle_and_routes(): + send = AsyncMock(return_value={"result": {"uid": 1000, "gid": 1000, "home": "/home/alice"}}) + sb = _running_sandbox(send) + + user = await sb.create_user("alice") + + assert isinstance(user, SandboxUser) + assert (user.name, user.uid, user.gid, user.home) == ("alice", 1000, 1000, "/home/alice") + method, params, *_ = send.call_args.args + assert method == QuicksandGuestAgentMethod.CREATE_USER + assert params == {"name": "alice"} + + +@pytest.mark.asyncio +async def test_create_user_raises_on_agent_error(): + send = AsyncMock(return_value={"error": {"message": "User already exists: alice"}}) + sb = _running_sandbox(send) + + with pytest.raises(RuntimeError, match="already exists"): + await sb.create_user("alice") + + +@pytest.mark.asyncio +async def test_sandbox_user_execute_injects_user(): + send = AsyncMock() + # First call: create_user; subsequent: execute. + send.side_effect = [ + {"result": {"uid": 1000, "gid": 1000, "home": "/home/alice"}}, + {"result": {"stdout": "alice", "stderr": "", "exit_code": 0}}, + ] + sb = _running_sandbox(send) + + user = await sb.create_user("alice") + await user.execute("whoami") + + method, params, *_ = send.call_args.args + assert method == QuicksandGuestAgentMethod.EXECUTE + assert params["user"] == "alice" + + +@pytest.mark.asyncio +async def test_delete_user_routes_with_remove_home(): + send = AsyncMock(return_value={"result": {"removed": True}}) + sb = _running_sandbox(send) + + await sb.delete_user("alice", remove_home=False) + + method, params, *_ = send.call_args.args + assert method == QuicksandGuestAgentMethod.DELETE_USER + assert params == {"name": "alice", "remove_home": False} From f9b7912dc62ee3ec30fe3d85bcf3d0852c213258 Mon Sep 17 00:00:00 2001 From: Tyler Payne Date: Wed, 8 Jul 2026 11:43:10 -0400 Subject: [PATCH 2/2] fix: derive manylinux tag from bundled binaries' glibc requirement The quicksand-qemu wheel hardcoded manylinux_2_17, but its bundled binaries link against the build runner's glibc, which can be newer. pip would then install a wheel that fails to load on older hosts instead of rejecting it at install time. BinaryBundler now scans the bundled ELF files for the glibc symbol versions they reference and asks auditwheel which manylinux policy that implies, raising on any failure rather than guessing. auditwheel becomes a Linux-only build dependency of quicksand-qemu. Co-Authored-By: Claude Fable 5 --- .../quicksand_build_tools/__init__.py | 80 ++++++++++++++++++- packages/quicksand-qemu/hatch_build.py | 2 +- packages/quicksand-qemu/pyproject.toml | 8 +- 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/packages/dev/quicksand-build-tools/quicksand_build_tools/__init__.py b/packages/dev/quicksand-build-tools/quicksand_build_tools/__init__.py index ed749e0..c560123 100644 --- a/packages/dev/quicksand-build-tools/quicksand_build_tools/__init__.py +++ b/packages/dev/quicksand-build-tools/quicksand_build_tools/__init__.py @@ -322,11 +322,19 @@ def _verify_windows_isolation(self, binary: Path, bin_dir: Path) -> None: "would break on hosts without it on PATH." ) - def set_platform_wheel_tag(self, build_data: dict) -> None: + def set_platform_wheel_tag(self, build_data: dict, bin_dir: Path | None = None) -> None: """Set build_data fields for a platform-specific py3-none wheel. On Windows ARM64, overrides the tag from ``win_amd64`` to ``win_arm64`` when native hardware is ARM64 (Python may report amd64 under emulation). + + On Linux, the manylinux level is derived from the actual glibc symbol + versions the bundled binaries require (via :func:`_linux_manylinux_tag`) + rather than hardcoded. The binaries link against the build runner's + glibc, so the tag must reflect their real floor. A hardcoded + ``manylinux_2_17`` lets pip install wheels that then fail to load on + hosts with an older glibc than the runner. ``bin_dir`` (the directory + holding the bundled binaries) is required for Linux wheels. """ build_data["pure_python"] = False platform_tag = sysconfig.get_platform().replace("-", "_").replace(".", "_") @@ -349,12 +357,78 @@ def set_platform_wheel_tag(self, build_data: dict) -> None: except Exception: pass - # PyPI requires manylinux tags for Linux wheels (PEP 600) if platform_tag.startswith("linux_"): - platform_tag = platform_tag.replace("linux_", "manylinux_2_17_", 1) + if bin_dir is None: + raise RuntimeError( + "set_platform_wheel_tag requires bin_dir for Linux wheels to " + "derive the manylinux tag from the bundled binaries' glibc " + "requirement." + ) + platform_tag = self._linux_manylinux_tag(bin_dir, platform_tag) build_data["tag"] = f"py3-none-{platform_tag}" + def _linux_manylinux_tag(self, bin_dir: Path, platform_tag: str) -> str: + """Derive the manylinux platform tag from the bundled ELF binaries. + + Scans every ELF file under ``bin_dir`` for the glibc symbol versions it + references and asks auditwheel which manylinux policy that implies. The + result (e.g. ``manylinux_2_38_aarch64``) is the lowest manylinux level + the binaries can actually run on. auditwheel is a hard build dependency + on Linux; if it or the analysis fails we raise rather than guess, since + a wrong tag produces wheels that crash on load instead of being + rejected at install time. + """ + from collections import defaultdict + + # Linux-only build deps, absent from the dev venv on other platforms. + from auditwheel.architecture import Architecture # ty: ignore[unresolved-import] + from auditwheel.elfutils import elf_find_versioned_symbols # ty: ignore[unresolved-import] + from auditwheel.libc import Libc # ty: ignore[unresolved-import] + from auditwheel.policy import WheelPolicies # ty: ignore[unresolved-import] + from elftools.common.exceptions import ELFError # ty: ignore[unresolved-import] + from elftools.elf.elffile import ELFFile # ty: ignore[unresolved-import] + + arch_name = platform_tag[len("linux_") :] + try: + arch = Architecture(arch_name) + except ValueError as exc: + raise RuntimeError( + f"Unknown architecture {arch_name!r} for manylinux tagging." + ) from exc + + versioned_symbols: dict[str, set[str]] = defaultdict(set) + elf_count = 0 + for path in sorted(bin_dir.rglob("*")): + if path.is_symlink() or not path.is_file(): + continue + try: + with path.open("rb") as fh: + if fh.read(4) != b"\x7fELF": + continue + fh.seek(0) + elf = ELFFile(fh) + for soname, version in elf_find_versioned_symbols(elf): + versioned_symbols[soname].add(version) + except (ELFError, OSError): + continue + elf_count += 1 + + if elf_count == 0: + raise RuntimeError(f"No ELF binaries found under {bin_dir} to derive a glibc tag from.") + + policies = WheelPolicies(libc=Libc.GLIBC, arch=arch) + policy_name = policies.versioned_symbols_policy(dict(versioned_symbols)).name + if not policy_name.startswith("manylinux_"): + raise RuntimeError( + f"Bundled binaries require a glibc newer than any manylinux policy " + f"auditwheel knows ({policy_name!r}). Upgrade auditwheel or build " + f"against an older glibc. Required symbol versions: " + f"{dict(versioned_symbols)}" + ) + self.app.display_info(f"Derived manylinux tag from glibc usage: {policy_name}") + return policy_name + def force_include_bin_dir(self, bin_dir: Path, root: Path, build_data: dict) -> None: """Add all files in bin_dir to the wheel's force_include.""" force_include = build_data.setdefault("force_include", {}) diff --git a/packages/quicksand-qemu/hatch_build.py b/packages/quicksand-qemu/hatch_build.py index 3cac89b..b1ed7df 100644 --- a/packages/quicksand-qemu/hatch_build.py +++ b/packages/quicksand-qemu/hatch_build.py @@ -1192,4 +1192,4 @@ def initialize(self, version: str, build_data: dict) -> None: # Mark as platform-specific wheel if version != "editable": - bundler.set_platform_wheel_tag(build_data) + bundler.set_platform_wheel_tag(build_data, bin_dir=bin_dir) diff --git a/packages/quicksand-qemu/pyproject.toml b/packages/quicksand-qemu/pyproject.toml index fb94f16..afcdf7d 100644 --- a/packages/quicksand-qemu/pyproject.toml +++ b/packages/quicksand-qemu/pyproject.toml @@ -8,7 +8,13 @@ license = "MIT" dependencies = [] [build-system] -requires = ["hatchling", "quicksand-build-tools>=0.5.3,<0.6.0"] +requires = [ + "hatchling", + "quicksand-build-tools>=0.5.3,<0.6.0", + # auditwheel derives the wheel's manylinux tag from the bundled binaries' + # glibc requirement on Linux. It pulls in pyelftools for ELF inspection. + "auditwheel; sys_platform == 'linux'", +] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel]