From 125723a3ab943e7d341b0e657d4158a6641b87b5 Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:31:16 +0800 Subject: [PATCH 01/45] feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX=local, for a VM or CI box without Docker Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch). This commit: core/crates/layerfs-sandbox/Cargo.toml (not production (test, manifest or docs)). No tests changed or deleted. Production LOC: 108,451 -> 108,451 (delta 0) Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68ddc1. --- core/crates/layerfs-sandbox/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/crates/layerfs-sandbox/Cargo.toml b/core/crates/layerfs-sandbox/Cargo.toml index e75b3fd4ce..05b1387cbe 100644 --- a/core/crates/layerfs-sandbox/Cargo.toml +++ b/core/crates/layerfs-sandbox/Cargo.toml @@ -7,4 +7,4 @@ rust-version.workspace = true license.workspace = true [dependencies] -nix = { version = "=0.31.3", features = ["poll", "socket", "fs"] } +nix = { version = "=0.31.3", features = ["poll", "socket", "fs", "signal", "mount", "user"] } From e4cbcad34c0e18c211364e5840e65561e8503959 Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:31:17 +0800 Subject: [PATCH 02/45] feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX=local, for a VM or CI box without Docker Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch). This commit: core/crates/layerfs-sandbox/src/backend/local/mod.rs (production). No tests changed or deleted. Production LOC: 108,451 -> 108,460 (delta +9) Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68ddc1. --- .../layerfs-sandbox/src/backend/local/mod.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 core/crates/layerfs-sandbox/src/backend/local/mod.rs diff --git a/core/crates/layerfs-sandbox/src/backend/local/mod.rs b/core/crates/layerfs-sandbox/src/backend/local/mod.rs new file mode 100644 index 0000000000..8de8a6f18c --- /dev/null +++ b/core/crates/layerfs-sandbox/src/backend/local/mod.rs @@ -0,0 +1,17 @@ +//! Local process runtime: each Sandbox is one daemon process on this kernel, +//! started from a private directory, with no container, image or Engine. +//! +//! It is for a machine that is itself the isolation boundary (a microVM, a +//! CI job). It isolates nothing: the daemon and its commands share this +//! kernel's processes, network, users and filesystem with everything else on +//! it. Commands still run as their own nonroot identity. One attempt per +//! operation, as in the Docker runtime; nothing is retried. +mod create; +mod exec; +mod mounts; +mod process; +mod sandbox; +pub use create::{Local, LocalCreateFailure, LocalPaths, LocalPhase}; +pub use exec::LocalExit; +pub use process::LocalProcess; +pub use sandbox::Sandbox; From c43060fef206602fddb55a3478e13cf1b62c1089 Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:31:19 +0800 Subject: [PATCH 03/45] feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX=local, for a VM or CI box without Docker Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch). This commit: core/crates/layerfs-sandbox/src/backend/local/process.rs (production). No tests changed or deleted. Production LOC: 108,460 -> 108,505 (delta +45) Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68ddc1. --- .../src/backend/local/process.rs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 core/crates/layerfs-sandbox/src/backend/local/process.rs diff --git a/core/crates/layerfs-sandbox/src/backend/local/process.rs b/core/crates/layerfs-sandbox/src/backend/local/process.rs new file mode 100644 index 0000000000..67eb36d4c1 --- /dev/null +++ b/core/crates/layerfs-sandbox/src/backend/local/process.rs @@ -0,0 +1,60 @@ +//! A local process named by its id and kernel start time, so a reused id is +//! never taken for the process that was started. +use nix::{sys::signal::Signal, unistd::Pid}; +use std::io; + +/// One started process: its id and its start time in clock ticks since boot +/// (`/proc//stat` field 22). Both must match for it to count as alive. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LocalProcess { + pub pid: u32, + pub start: u64, +} + +impl LocalProcess { + /// The process running now under `pid`, read once from `/proc`. + pub fn observe(pid: u32) -> io::Result { + let (_, start) = stat(pid)?; + Ok(Self { pid, start }) + } + /// True while this exact process exists and has not exited: a zombie + /// (or a dead entry) is not alive, and another process under the same id + /// has another start time. + pub fn alive(self) -> bool { + match stat(self.pid) { + Ok((state, start)) => start == self.start && !matches!(state, b'Z' | b'X' | b'x'), + Err(_) => false, + } + } + /// Sends `signal` once, only after this exact process was observed alive. + pub(super) fn signal(self, signal: Signal) -> io::Result<()> { + if !self.alive() { + return Ok(()); + } + let pid = i32::try_from(self.pid) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "process id width"))?; + match nix::sys::signal::kill(Pid::from_raw(pid), signal) { + Ok(()) | Err(nix::errno::Errno::ESRCH) => Ok(()), + Err(errno) => Err(errno.into()), + } + } +} + +/// State and start time of `pid`. The command name may hold spaces and +/// parentheses, so fields are counted after its last `)`. +fn stat(pid: u32) -> io::Result<(u8, u64)> { + let text = std::fs::read_to_string(format!("/proc/{pid}/stat"))?; + let invalid = || io::Error::new(io::ErrorKind::InvalidData, "process stat fields"); + let rest = text.rsplit_once(')').ok_or_else(invalid)?.1; + let mut fields = rest.split_ascii_whitespace(); + let state = fields + .next() + .and_then(|s| s.bytes().next()) + .ok_or_else(invalid)?; + // field 3 was the state; field 22 is 19 fields further on + let start = fields + .nth(18) + .and_then(|s| s.parse::().ok()) + .ok_or_else(invalid)?; + Ok((state, start)) +} From 0604b72c8950834793c41e4c171f2595a092f533 Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:31:21 +0800 Subject: [PATCH 04/45] feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX=local, for a VM or CI box without Docker Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch). This commit: core/crates/layerfs-sandbox/src/backend/local/mounts.rs (production). No tests changed or deleted. Production LOC: 108,505 -> 108,576 (delta +71) Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68ddc1. --- .../src/backend/local/mounts.rs | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 core/crates/layerfs-sandbox/src/backend/local/mounts.rs diff --git a/core/crates/layerfs-sandbox/src/backend/local/mounts.rs b/core/crates/layerfs-sandbox/src/backend/local/mounts.rs new file mode 100644 index 0000000000..e7b84b8ded --- /dev/null +++ b/core/crates/layerfs-sandbox/src/backend/local/mounts.rs @@ -0,0 +1,85 @@ +//! Kernel mounts left under a local Sandbox's directory once its daemon is +//! gone. A container's mounts end with its namespace; a local daemon's stay +//! in this one ("transport endpoint is not connected") until detached. +use std::{ + io, + path::{Path, PathBuf}, +}; + +/// Mount points at or under `directory`, deepest first, from this +/// process's mount table. +pub(super) fn under(directory: &Path) -> io::Result> { + let table = std::fs::read_to_string("/proc/self/mountinfo")?; + let mut found = Vec::new(); + for line in table.lines() { + // field 5 is the mount point, with space, tab, newline and backslash octal-escaped + let Some(point) = line.split(' ').nth(4) else { + continue; + }; + let point = PathBuf::from(unescape(point)); + if point.starts_with(directory) { + found.push(point); + } + } + found.sort_by_key(|point| std::cmp::Reverse(point.components().count())); + found.dedup(); + Ok(found) +} + +fn unescape(text: &str) -> String { + let bytes = text.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + let octal = bytes + .get(i + 1..i + 4) + // one byte is at most octal 377, so its first digit is 0 to 3 + .filter(|digits| { + bytes[i] == b'\\' + && digits[0] <= b'3' + && digits.iter().all(|d| (b'0'..=b'7').contains(d)) + }); + match octal { + Some(digits) => { + out.push(digits.iter().fold(0u8, |n, d| n * 8 + (d - b'0'))); + i += 4; + } + None => { + out.push(bytes[i]); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +/// Detaches every mount under `directory` once (lazy unmount: a process +/// still inside keeps its view; new lookups no longer reach it), then checks +/// that none is left. +#[cfg(target_os = "linux")] +pub(super) fn detach(directory: &Path) -> io::Result<()> { + use nix::mount::{umount2, MntFlags}; + for point in under(directory)? { + match umount2(&point, MntFlags::MNT_DETACH) { + Ok(()) | Err(nix::errno::Errno::EINVAL) | Err(nix::errno::Errno::ENOENT) => {} + Err(errno) => return Err(errno.into()), + } + } + if under(directory)?.is_empty() { + Ok(()) + } else { + Err(io::Error::other("a mount under the local Sandbox remains")) + } +} + +#[cfg(not(target_os = "linux"))] +pub(super) fn detach(directory: &Path) -> io::Result<()> { + if under(directory)?.is_empty() { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "detaching a local Sandbox mount needs Linux", + )) + } +} From 509987190f06caccfc3775fa6d60ad13aa9314fa Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:31:23 +0800 Subject: [PATCH 05/45] feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX=local, for a VM or CI box without Docker Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch). This commit: core/crates/layerfs-sandbox/src/backend/local/create.rs (production). No tests changed or deleted. Production LOC: 108,576 -> 108,834 (delta +258) Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68ddc1. --- .../src/backend/local/create.rs | 311 ++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 core/crates/layerfs-sandbox/src/backend/local/create.rs diff --git a/core/crates/layerfs-sandbox/src/backend/local/create.rs b/core/crates/layerfs-sandbox/src/backend/local/create.rs new file mode 100644 index 0000000000..eadff04d72 --- /dev/null +++ b/core/crates/layerfs-sandbox/src/backend/local/create.rs @@ -0,0 +1,311 @@ +//! One local daemon start: the Sandbox's private directory, its 0600 setup, +//! one spawn and its listener line. Nothing is retried. +use super::{ + process::LocalProcess, + sandbox::{Sandbox, LOG}, +}; +use crate::{ContainerId, RuntimeError}; +use nix::fcntl::OFlag; +use std::{ + fs::{self, File, OpenOptions, Permissions}, + io::{self, Read, Seek, SeekFrom, Write}, + net::SocketAddr, + os::unix::{ + fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt}, + process::CommandExt, + }, + path::{Component, Path, PathBuf}, + process::{Command, Stdio}, + time::{Duration, Instant}, +}; + +/// The daemon's startup line; the address follows it. +const MARKER: &[u8] = b"LAYERFS_DAEMON_LISTEN "; +/// Pause between two reads of a starting daemon's output. +const PAUSE: Duration = Duration::from_millis(10); +/// The daemon's environment: a search path and nothing of this process's. +const PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; + +/// A local runtime root: one directory per Sandbox under it. +#[derive(Clone, Debug)] +pub struct Local { + root: PathBuf, + name: Option, +} + +/// Where one Sandbox keeps its parts, all under its directory. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LocalPaths { + /// The Sandbox directory, `/`, 0755. + pub directory: PathBuf, + /// Its daemon setup, `config/daemon.setup`, 0600 in a 0700 directory. + pub config: PathBuf, + /// Its daemon overlay template, `overlay/overlay.sqlite`, in a 0700 directory. + pub overlay: PathBuf, + /// Its Workspace mount home, `workspaces`, made 0755 by the daemon. + pub mounts: PathBuf, + /// Its daemon's output, `daemon.log`, 0600. + pub log: PathBuf, + /// Its commands' scratch directory, `tmp`, 1777 like a container's + /// `/tmp`, so what they leave there goes with the Sandbox. + pub tmp: PathBuf, +} + +/// The boundary one failed create reached. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LocalPhase { + /// Local checks; nothing made. + Validate, + /// The directory, setup and log file. + Create, + /// The daemon spawn. + Start, + /// The wait for the daemon's listener line. + Listener, +} + +/// A failed create. `sandbox` holds what it made (its directory, and its +/// daemon once spawned), for the caller to stop and delete once. +#[derive(Debug)] +pub struct LocalCreateFailure { + pub phase: LocalPhase, + pub sandbox: Option, + pub cause: RuntimeError, +} + +impl Local { + /// Selects `root`: an existing absolute directory owned by this user, + /// writable by it alone and traversable by others (commands run as + /// another identity and must reach their Workspace below it). + pub fn new(root: impl AsRef) -> Result { + let root = root.as_ref(); + if !root.is_absolute() + || root + .components() + .any(|c| matches!(c, Component::ParentDir | Component::CurDir)) + { + return Err(RuntimeError::Protocol("absolute local runtime root")); + } + let metadata = fs::symlink_metadata(root)?; + if !metadata.is_dir() + || metadata.uid() != nix::unistd::Uid::effective().as_raw() + || metadata.mode() & 0o022 != 0 + || metadata.mode() & 0o001 == 0 + { + return Err(RuntimeError::Protocol( + "local runtime root must be a directory of this user, not group/other writable, traversable by others (0755)", + )); + } + Ok(Self { + root: root.to_owned(), + name: None, + }) + } + /// Names the next Sandbox's directory `name`, under the Docker name rule + /// (an ASCII letter or digit, then letters, digits, `_`, `.`, `-`, at + /// most 128), so it is one plain path component. + pub fn naming(mut self, name: &str) -> Result { + let bytes = name.as_bytes(); + if bytes.is_empty() + || bytes.len() > 128 + || !bytes[0].is_ascii_alphanumeric() + || !bytes + .iter() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-')) + { + return Err(RuntimeError::Protocol("local Sandbox name")); + } + self.name = Some(name.to_owned()); + Ok(self) + } + /// The named Sandbox's paths, for its daemon setup. + pub fn paths(&self) -> Result { + let name = self + .name + .as_deref() + .ok_or(RuntimeError::Protocol("local Sandbox name"))?; + let directory = self.root.join(name); + Ok(LocalPaths { + config: directory.join("config").join("daemon.setup"), + overlay: directory.join("overlay").join("overlay.sqlite"), + mounts: directory.join("workspaces"), + log: directory.join(LOG), + tmp: directory.join("tmp"), + directory, + }) + } + /// Makes the named Sandbox's directory, writes `config` (the encoded + /// daemon setup naming [`Self::paths`]) 0600, spawns `executable + /// --config ` in its own process group with no inherited + /// environment, and waits up to `wait` for its listener line. Once + /// anything is made, a failure returns it. + pub fn create( + &self, + executable: &Path, + config: &[u8], + wait: Duration, + labels: Vec<(String, String)>, + ) -> Result> { + let fail = |phase, sandbox, cause| { + Box::new(LocalCreateFailure { + phase, + sandbox, + cause, + }) + }; + let paths = self + .paths() + .map_err(|cause| fail(LocalPhase::Validate, None, cause))?; + let checked = (|| { + let metadata = fs::metadata(executable)?; + if !executable.is_absolute() || !metadata.is_file() || metadata.len() == 0 { + return Err(RuntimeError::Protocol("local daemon executable")); + } + if config.is_empty() || config.len() > 8192 || wait.is_zero() { + return Err(RuntimeError::Protocol("local daemon setup/startup wait")); + } + match fs::symlink_metadata(&paths.directory) { + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + Ok(_) => Err(RuntimeError::Protocol("local Sandbox directory exists")), + } + })(); + checked.map_err(|cause| fail(LocalPhase::Validate, None, cause))?; + let id = identity().map_err(|cause| fail(LocalPhase::Validate, None, cause))?; + // one create of the directory: a name another start holds fails here + fs::DirBuilder::new() + .mode(0o755) + .create(&paths.directory) + .map_err(|error| fail(LocalPhase::Create, None, error.into()))?; + let mut sandbox = Sandbox::unstarted(id, paths.directory.clone(), labels); + let log = match prepare(&paths, config) { + Ok(log) => log, + Err(error) => return Err(fail(LocalPhase::Create, Some(sandbox), error.into())), + }; + let spawned = log.try_clone().and_then(|output| { + Command::new(executable) + .arg("--config") + .arg(&paths.config) + .env_clear() + .env("PATH", PATH) + .current_dir(&paths.directory) + .stdin(Stdio::null()) + .stdout(output) + .stderr(log) + .process_group(0) + .spawn() + }); + let mut child = match spawned { + Ok(child) => child, + Err(error) => return Err(fail(LocalPhase::Start, Some(sandbox), error.into())), + }; + let process = match LocalProcess::observe(child.id()) { + Ok(process) => process, + Err(error) => { + // spawned but unobservable: end it here, it has no identity to record + let _ = child.kill(); + let _ = child.wait(); + return Err(fail(LocalPhase::Start, Some(sandbox), error.into())); + } + }; + sandbox.process = Some(process); + *sandbox + .child + .get_mut() + .unwrap_or_else(|error| error.into_inner()) = Some(child); + match listener(&sandbox, &paths.log, wait) { + Ok(port) => { + sandbox.port = port; + Ok(sandbox) + } + Err(cause) => Err(fail(LocalPhase::Listener, Some(sandbox), cause)), + } + } +} + +/// A fresh random identity in the Engine's form (64 lowercase hex, not all +/// zero), so records and names treat a local Sandbox like a container. +fn identity() -> Result { + let mut bytes = [0u8; 32]; + File::open("/dev/urandom")?.read_exact(&mut bytes)?; + const HEX: &[u8; 16] = b"0123456789abcdef"; + let text: String = (bytes.iter()) + .flat_map(|b| [HEX[usize::from(b >> 4)], HEX[usize::from(b & 15)]]) + .map(char::from) + .collect(); + ContainerId::parse(&text) +} + +/// The setup and overlay directories (0700), the scratch directory (1777), +/// the setup file (0600, written once) and the empty log file (0600) it +/// returns. +fn prepare(paths: &LocalPaths, config: &[u8]) -> io::Result { + // the process umask may narrow the create mode + fs::set_permissions(&paths.directory, Permissions::from_mode(0o755))?; + for file in [&paths.config, &paths.overlay] { + let parent = file + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "local path"))?; + fs::DirBuilder::new().mode(0o700).create(parent)?; + } + fs::DirBuilder::new().mode(0o700).create(&paths.tmp)?; + fs::set_permissions(&paths.tmp, Permissions::from_mode(0o1777))?; + let private = |path: &Path| { + OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags((OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC).bits()) + .open(path) + }; + private(&paths.config)?.write_all(config)?; + private(&paths.log) +} + +/// Waits until the daemon's output names its loopback address, the daemon +/// exits, or `wait` ends. Each look reads only what was appended since. +fn listener(sandbox: &Sandbox, log: &Path, wait: Duration) -> Result { + let deadline = Instant::now() + wait; + let mut file = File::open(log)?; + let mut offset = 0u64; + let mut line = Vec::new(); + let mut chunk = [0u8; 4096]; + loop { + file.seek(SeekFrom::Start(offset))?; + loop { + let n = file.read(&mut chunk)?; + if n == 0 { + break; + } + offset += n as u64; + for &byte in &chunk[..n] { + if byte != b'\n' { + // a startup line is short; a longer one is not the marker + if line.len() < 256 { + line.push(byte); + } + continue; + } + if let Some(address) = line.strip_prefix(MARKER) { + let address = std::str::from_utf8(address) + .ok() + .and_then(|text| text.parse::().ok()) + .filter(|a| a.ip().is_loopback() && a.port() != 0) + .ok_or(RuntimeError::Protocol("local daemon loopback listener"))?; + return Ok(address.port()); + } + line.clear(); + } + } + sandbox.reap(); + if !sandbox.process.is_some_and(LocalProcess::alive) { + return Err(RuntimeError::Protocol( + "local daemon exited before its listener line", + )); + } + if Instant::now() >= deadline { + return Err(io::Error::from(io::ErrorKind::TimedOut).into()); + } + std::thread::sleep(PAUSE); + } +} From 12a734abedb30abbe6f8eec79e19cadf502f3add Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:31:25 +0800 Subject: [PATCH 06/45] feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX=local, for a VM or CI box without Docker Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch). This commit: core/crates/layerfs-sandbox/src/backend/local/sandbox.rs (production). No tests changed or deleted. Production LOC: 108,834 -> 109,057 (delta +223) Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68ddc1. --- .../src/backend/local/sandbox.rs | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 core/crates/layerfs-sandbox/src/backend/local/sandbox.rs diff --git a/core/crates/layerfs-sandbox/src/backend/local/sandbox.rs b/core/crates/layerfs-sandbox/src/backend/local/sandbox.rs new file mode 100644 index 0000000000..51c1bd26a7 --- /dev/null +++ b/core/crates/layerfs-sandbox/src/backend/local/sandbox.rs @@ -0,0 +1,259 @@ +//! One local Sandbox: its directory, its daemon process and its exact +//! attempts. No Drop cleanup or replay, as for a container. +use super::{mounts, process::LocalProcess}; +use crate::{ContainerId, RuntimeError, WireFailure}; +use nix::sys::signal::Signal; +use std::{ + fs::{self, OpenOptions}, + io::{self, Read, Seek, SeekFrom}, + net::{Ipv4Addr, SocketAddr}, + os::unix::fs::OpenOptionsExt, + path::{Path, PathBuf}, + process::Child, + sync::Mutex, + time::{Duration, Instant}, +}; + +/// The daemon's output file in a Sandbox directory, stdout and stderr together. +pub(super) const LOG: &str = "daemon.log"; +/// Pause between two looks at a stopping process. +const PAUSE: Duration = Duration::from_millis(20); +/// Wait for a killed process to be gone. +const KILL_WAIT: Duration = Duration::from_secs(5); + +/// One acknowledged local Sandbox directory and, once started, its daemon. +#[derive(Debug)] +pub struct Sandbox { + pub(super) id: ContainerId, + pub(super) directory: PathBuf, + pub(super) labels: Vec<(String, String)>, + pub(super) process: Option, + pub(super) port: u16, + /// The daemon this process started, kept so its exit is reaped here and + /// it does not stay a zombie of this process. `None` after a reopen. + pub(super) child: Mutex>, + pub(super) stop_attempted: bool, + pub(super) stopped: bool, + pub(super) delete_attempted: bool, + pub(super) deleted: bool, +} + +impl Sandbox { + /// A directory made by a start that has not spawned its daemon yet. + pub(super) fn unstarted( + id: ContainerId, + directory: PathBuf, + labels: Vec<(String, String)>, + ) -> Self { + Self { + id, + directory, + labels, + process: None, + port: 0, + child: Mutex::new(None), + stop_attempted: false, + stopped: false, + delete_attempted: false, + deleted: false, + } + } + /// Reopens a recorded Sandbox whose daemon still runs: the same process + /// (id and start time) and its loopback port. It starts nothing. + pub fn reopen( + directory: PathBuf, + id: ContainerId, + process: LocalProcess, + port: u16, + labels: Vec<(String, String)>, + ) -> Result> { + let mut owner = Self::unstarted(id, directory, labels); + owner.process = Some(process); + owner.port = port; + if !owner.directory.is_absolute() || port == 0 { + return Err(owner.invalid("saved local Sandbox")); + } + owner.endpoint()?; + Ok(owner) + } + /// A recorded Sandbox that [`Self::reopen`] found stopped + /// ([`WireFailure::container_stopped`]): only its one Delete is admitted. + pub fn stopped( + directory: PathBuf, + id: ContainerId, + process: LocalProcess, + port: u16, + labels: Vec<(String, String)>, + ) -> Self { + let mut owner = Self::unstarted(id, directory, labels); + owner.process = Some(process); + owner.port = port; + owner.stop_attempted = true; + owner.stopped = true; + owner + } + /// Its identity: random at create, recorded, never an Engine's. + pub fn identity(&self) -> ContainerId { + self.id + } + /// Its private directory (setup, overlay, Workspace mounts, daemon log). + pub fn directory(&self) -> &Path { + &self.directory + } + /// Its commands' scratch directory (1777), deleted with it. + pub fn tmp(&self) -> PathBuf { + self.directory.join("tmp") + } + /// Its daemon process, once started. + pub fn process(&self) -> Option { + self.process + } + /// The loopback port its daemon listens on, once observed. + pub fn port(&self) -> u16 { + self.port + } + /// The labels it was created with, kept in its record. + pub fn labels(&self) -> &[(String, String)] { + &self.labels + } + /// The daemon's endpoint while that exact process runs. A daemon that + /// exited (idle close, crash, kill) is the stopped observation + /// ([`WireFailure::container_stopped`]): its Workspaces went with it. + pub fn endpoint(&self) -> Result> { + let Some(process) = self.process else { + return Err(self.invalid("running endpoint admission")); + }; + if self.port == 0 || self.stop_attempted || self.delete_attempted { + return Err(self.invalid("running endpoint admission")); + } + self.reap(); + if !process.alive() { + let mut failure = self.invalid(crate::types::STOPPED); + failure.observed_container = Some(self.id); + return Err(failure); + } + Ok(SocketAddr::from((Ipv4Addr::LOCALHOST, self.port))) + } + /// Stops the daemon once: TERM, up to `seconds` for it to end, then + /// KILL. A daemon already gone is the same stopped state. Crash + /// teardown, not a graceful filesystem drain. + pub fn stop(&mut self, seconds: u16) -> Result<(), Box> { + if self.stop_attempted || self.delete_attempted { + return Err(self.invalid("original local Stop admission")); + } + self.stop_attempted = true; + if let Some(process) = self.process { + self.end( + process, + Signal::SIGTERM, + Duration::from_secs(u64::from(seconds)), + )?; + if process.alive() { + self.end(process, Signal::SIGKILL, KILL_WAIT)?; + } + if process.alive() { + return Err(self.invalid("local daemon still runs after KILL")); + } + } + self.stopped = true; + Ok(()) + } + /// Deletes its directory once, after a known stop: any mount its daemon + /// left is detached first, so nothing is removed through a mount. The + /// shared Store is outside it and never touched. + pub fn delete(&mut self) -> Result<(), Box> { + if self.delete_attempted || (self.process.is_some() && !self.stopped) { + return Err(self.invalid("original local Delete admission; known stop required")); + } + self.delete_attempted = true; + self.reap(); + if self.process.is_some_and(LocalProcess::alive) { + return Err(self.invalid("local daemon runs again; nothing was deleted")); + } + mounts::detach(&self.directory).map_err(|error| self.io(error))?; + match fs::remove_dir_all(&self.directory) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(self.io(error)), + } + self.deleted = true; + Ok(()) + } + /// The daemon output's last `lines` lines, keeping only the final `cap` + /// bytes. Read-only and never retried. + pub fn log_tail(&self, lines: u16, cap: usize) -> Result, Box> { + if self.delete_attempted { + return Err(self.invalid("log read before Delete")); + } + let read = || -> io::Result> { + let mut file = OpenOptions::new() + .read(true) + .custom_flags(nix::fcntl::OFlag::O_NOFOLLOW.bits()) + .open(self.directory.join(LOG))?; + let length = file.metadata()?.len(); + // a line is rarely longer than this; the window bounds the read + let window = (cap as u64).saturating_mul(4).max(64 * 1024); + file.seek(SeekFrom::Start(length.saturating_sub(window)))?; + let mut bytes = Vec::new(); + file.take(window).read_to_end(&mut bytes)?; + Ok(bytes) + }; + let bytes = read().map_err(|error| self.io(error))?; + let body = bytes.strip_suffix(b"\n").unwrap_or(&bytes); + let start = (body.iter().enumerate().rev()) + .filter(|(_, b)| **b == b'\n') + .nth(usize::from(lines).saturating_sub(1)) + .map_or(0, |(i, _)| i + 1); + let tail = &bytes[start..]; + Ok(tail[tail.len().saturating_sub(cap)..].to_vec()) + } + /// Reaps the daemon this process started, if it exited. + pub(super) fn reap(&self) { + let mut child = self.child.lock().unwrap_or_else(|error| error.into_inner()); + if let Some(started) = child.as_mut() { + if matches!(started.try_wait(), Ok(Some(_))) { + *child = None; + } + } + } + /// Sends `signal` once and waits up to `wait` for the process to end. + fn end( + &self, + process: LocalProcess, + signal: Signal, + wait: Duration, + ) -> Result<(), Box> { + process.signal(signal).map_err(|error| self.io(error))?; + let deadline = Instant::now() + wait; + loop { + self.reap(); + if !process.alive() || Instant::now() >= deadline { + return Ok(()); + } + std::thread::sleep(PAUSE); + } + } + pub(super) fn invalid(&self, message: &'static str) -> Box { + self.failure(RuntimeError::Protocol(message)) + } + pub(super) fn io(&self, error: io::Error) -> Box { + self.failure(RuntimeError::Io(error)) + } + pub(super) fn failure(&self, cause: RuntimeError) -> Box { + Box::new(WireFailure { + attempted: false, + sent_bytes: 0, + request_header_bytes: 0, + sent_body_bytes: 0, + pending_request: Default::default(), + status: None, + cause, + fence_error: None, + observed_exec: None, + observed_container: None, + requested_container: Some(self.id), + requested_exec: None, + error_body: None, + }) + } +} From e20bb9348c8799a5491cc3a10176ce36251baa31 Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:31:27 +0800 Subject: [PATCH 07/45] feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX=local, for a VM or CI box without Docker Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch). This commit: core/crates/layerfs-sandbox/src/backend/local/exec.rs (production). No tests changed or deleted. Production LOC: 109,057 -> 109,166 (delta +109) Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68ddc1. --- .../layerfs-sandbox/src/backend/local/exec.rs | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 core/crates/layerfs-sandbox/src/backend/local/exec.rs diff --git a/core/crates/layerfs-sandbox/src/backend/local/exec.rs b/core/crates/layerfs-sandbox/src/backend/local/exec.rs new file mode 100644 index 0000000000..0673564715 --- /dev/null +++ b/core/crates/layerfs-sandbox/src/backend/local/exec.rs @@ -0,0 +1,130 @@ +//! One ordinary command to its end in a local Sandbox: the command identity, +//! the Workspace directory and a search path; nothing of this process's +//! environment, no shell of its own and no implicit timeout. +use super::sandbox::Sandbox; +use crate::{backend::docker::ExecRequest, RuntimeError, WireFailure}; +use std::{ + io::{self, Read, Write}, + os::unix::process::{CommandExt, ExitStatusExt}, + path::Path, + process::{Command, Stdio}, +}; + +/// The search path a command starts with, before its own environment. +const PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; + +/// One command's end: its exit code (128 + the signal for one a signal +/// ended, as the Engine reports it), its process id (diagnostic only) and the +/// bytes each stream delivered. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LocalExit { + pub code: i32, + pub pid: u32, + pub stdout: u64, + pub stderr: u64, +} + +impl Sandbox { + /// Runs `request` once while this Sandbox's daemon runs: as its uid/gid + /// with no supplementary groups, in its directory, its stdout and stderr + /// streamed to the sinks as they arrive, then waits for its exit. A sink + /// failure closes the command's pipes, still waits for its exit, and + /// returns the sink's error. Standard input is not offered. + pub fn run( + &self, + request: &ExecRequest, + stdout: &mut (dyn Write + Send), + stderr: &mut (dyn Write + Send), + ) -> Result> { + check(request).map_err(|cause| self.failure(cause))?; + self.endpoint()?; + let mut command = Command::new(&request.arguments[0]); + command + .args(&request.arguments[1..]) + .env_clear() + .env("PATH", PATH) + .current_dir(Path::new(&request.directory)) + // std drops supplementary groups (setgroups 0) when it sets a uid as root + .uid(request.identity.uid) + .gid(request.identity.gid) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + for entry in &request.environment { + if let Some((name, value)) = entry.split_once('=') { + command.env(name, value); + } + } + let mut child = command.spawn().map_err(|error| { + let mut failure = self.io(error); + failure.attempted = true; + failure + })?; + let pid = child.id(); + let out = child.stdout.take(); + let err = child.stderr.take(); + let copied = std::thread::scope(|scope| { + let errors = scope.spawn(move || copy(err, stderr)); + let outs = copy(out, stdout); + let errs = errors + .join() + .unwrap_or_else(|_| Err(io::Error::other("stderr copy panicked"))); + Ok::<_, io::Error>((outs?, errs?)) + }); + // the pipes are closed here either way; the exit is still waited for + let status = child.wait(); + let (out_bytes, err_bytes) = copied.map_err(|error| self.io(error))?; + let status = status.map_err(|error| self.io(error))?; + let code = status + .code() + .or_else(|| status.signal().map(|signal| 128 + signal)) + .ok_or_else(|| self.invalid("local command exit status"))?; + Ok(LocalExit { + code, + pid, + stdout: out_bytes, + stderr: err_bytes, + }) + } +} + +/// The Engine's own checks of an ordinary command, and no stdin. +fn check(request: &ExecRequest) -> Result<(), RuntimeError> { + request.identity.check()?; + if request.arguments.is_empty() + || request.arguments[0].is_empty() + || !Path::new(&request.directory).is_absolute() + || (request.arguments.iter()) + .chain(&request.environment) + .chain(std::iter::once(&request.directory)) + .any(|v| v.as_bytes().contains(&0)) + { + return Err(RuntimeError::Protocol("ordinary command fields")); + } + if (request.environment.iter()).any(|v| !v.contains('=') || v.starts_with('=')) { + return Err(RuntimeError::Protocol("ordinary environment field")); + } + if request.stdin { + return Err(RuntimeError::Unsupported("local command standard input")); + } + Ok(()) +} + +/// Copies one stream to its sink until end of stream; the bytes delivered. +fn copy(source: Option, sink: &mut (dyn Write + Send)) -> io::Result { + let Some(mut source) = source else { + return Ok(0); + }; + let mut total = 0u64; + let mut window = [0u8; 8192]; + loop { + let n = match source.read(&mut window) { + Ok(0) => return Ok(total), + Ok(n) => n, + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + }; + sink.write_all(&window[..n])?; + total = total.saturating_add(n as u64); + } +} From c5e150253ff6e25f058479f929709e1087a25a9b Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:31:29 +0800 Subject: [PATCH 08/45] feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX=local, for a VM or CI box without Docker Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch). This commit: core/crates/layerfs-sandbox/src/backend/mod.rs (production). No tests changed or deleted. Production LOC: 109,166 -> 109,167 (delta +1) Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68ddc1. --- core/crates/layerfs-sandbox/src/backend/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/core/crates/layerfs-sandbox/src/backend/mod.rs b/core/crates/layerfs-sandbox/src/backend/mod.rs index 509d7e29fa..7fe356b929 100644 --- a/core/crates/layerfs-sandbox/src/backend/mod.rs +++ b/core/crates/layerfs-sandbox/src/backend/mod.rs @@ -1,2 +1,3 @@ //! Concrete ordinary runtime providers. pub mod docker; +pub mod local; From f8324908284c6ab200cdfb902156af90e03abf86 Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:31:31 +0800 Subject: [PATCH 09/45] feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX=local, for a VM or CI box without Docker Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch). This commit: core/crates/layerfs-sandbox/tests/local_runtime.rs (not production (test, manifest or docs)). No tests changed or deleted. Production LOC: 109,167 -> 109,167 (delta 0) Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68ddc1. --- .../layerfs-sandbox/tests/local_runtime.rs | 413 ++++++++++++++++++ 1 file changed, 413 insertions(+) create mode 100644 core/crates/layerfs-sandbox/tests/local_runtime.rs diff --git a/core/crates/layerfs-sandbox/tests/local_runtime.rs b/core/crates/layerfs-sandbox/tests/local_runtime.rs new file mode 100644 index 0000000000..f90e682433 --- /dev/null +++ b/core/crates/layerfs-sandbox/tests/local_runtime.rs @@ -0,0 +1,413 @@ +//! The local process runtime (`backend::local`): one daemon process per +//! Sandbox directory, observed by process id and start time, its commands run +//! as their own identity, and its stop and delete each attempted once. A +//! small shell script stands in for the daemon; nothing here needs root. +use layerfs_sandbox::{ + backend::{ + docker::ExecRequest, + local::{Local, LocalPhase, LocalProcess, Sandbox}, + }, + CommandIdentity, RuntimeError, +}; +use std::{ + fs, + os::unix::fs::{MetadataExt, PermissionsExt}, + path::{Path, PathBuf}, + process::Command, + sync::atomic::{AtomicUsize, Ordering}, + time::{Duration, Instant}, +}; + +static NEXT: AtomicUsize = AtomicUsize::new(0); +const WAIT: Duration = Duration::from_secs(10); + +/// A fresh 0755 runtime root with a stand-in daemon `daemon.sh` beside it. +fn fixture(script: &str) -> (PathBuf, PathBuf) { + let base = std::env::temp_dir().join(format!( + "layerfs-local-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let _ = fs::remove_dir_all(&base); + fs::create_dir_all(base.join("root")).unwrap(); + fs::set_permissions(&base, fs::Permissions::from_mode(0o755)).unwrap(); + fs::set_permissions(base.join("root"), fs::Permissions::from_mode(0o755)).unwrap(); + let daemon = base.join("daemon.sh"); + fs::write(&daemon, format!("#!/bin/sh\n{script}\n")).unwrap(); + fs::set_permissions(&daemon, fs::Permissions::from_mode(0o755)).unwrap(); + (base.join("root"), daemon) +} + +/// Prints the listener line after checking its arguments, then waits. +const LISTENS: &str = r#"[ "$1" = --config ] && [ -f "$2" ] || exit 3 +env >&2 +echo "starting" +echo "LAYERFS_DAEMON_LISTEN 127.0.0.1:40404" +exec sleep 60"#; + +/// The command identity: this user, or nobody when the tests run as root. +fn me() -> CommandIdentity { + let meta = fs::metadata("/proc/self").unwrap(); + if meta.uid() == 0 { + CommandIdentity { + uid: 65534, + gid: 65534, + } + } else { + CommandIdentity { + uid: meta.uid(), + gid: meta.gid(), + } + } +} + +fn mode(path: &Path) -> u32 { + fs::symlink_metadata(path).unwrap().mode() & 0o7777 +} + +fn started(root: &Path, daemon: &Path, name: &str) -> Sandbox { + Local::new(root) + .unwrap() + .naming(name) + .unwrap() + .create( + daemon, + b"setup bytes", + WAIT, + vec![("layerfs.role".into(), "sandbox".into())], + ) + .unwrap() +} + +fn run(sandbox: &Sandbox, arguments: &[&str], environment: &[&str]) -> (i32, String, String) { + let request = ExecRequest { + identity: me(), + arguments: arguments.iter().map(|a| a.to_string()).collect(), + environment: environment.iter().map(|a| a.to_string()).collect(), + directory: sandbox.directory().display().to_string(), + stdin: false, + }; + let (mut out, mut err) = (Vec::new(), Vec::new()); + let exit = sandbox.run(&request, &mut out, &mut err).unwrap(); + assert_eq!(exit.stdout, out.len() as u64); + assert_eq!(exit.stderr, err.len() as u64); + ( + exit.code, + String::from_utf8(out).unwrap(), + String::from_utf8(err).unwrap(), + ) +} + +#[test] +fn a_root_must_be_absolute_owned_private_to_write_and_traversable() { + let (root, _) = fixture("exit 0"); + assert!(matches!( + Local::new("relative/root"), + Err(RuntimeError::Protocol(_)) + )); + assert!(matches!( + Local::new(root.join("..").join("root")), + Err(RuntimeError::Protocol(_)) + )); + assert!(matches!( + Local::new(root.join("missing")), + Err(RuntimeError::Io(_)) + )); + fs::set_permissions(&root, fs::Permissions::from_mode(0o775)).unwrap(); + assert!(matches!(Local::new(&root), Err(RuntimeError::Protocol(_)))); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + assert!(matches!(Local::new(&root), Err(RuntimeError::Protocol(_)))); + fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).unwrap(); + let local = Local::new(&root).unwrap(); + for bad in ["", ".hidden", "-x", "a/b", "..", "a b", &"x".repeat(129)] { + assert!(local.clone().naming(bad).is_err(), "{bad:?} admitted"); + } + assert!(local.paths().is_err(), "paths before a name"); + let paths = local.naming("layerfs-shop-1").unwrap().paths().unwrap(); + assert_eq!(paths.directory, root.join("layerfs-shop-1")); + assert_eq!(paths.config, paths.directory.join("config/daemon.setup")); + assert_eq!( + paths.overlay, + paths.directory.join("overlay/overlay.sqlite") + ); + assert_eq!(paths.mounts, paths.directory.join("workspaces")); + assert_eq!(paths.log, paths.directory.join("daemon.log")); + assert_eq!(paths.tmp, paths.directory.join("tmp")); +} + +#[test] +fn a_start_writes_a_private_setup_spawns_once_and_reads_the_listener() { + let (root, daemon) = fixture(LISTENS); + let mut sandbox = started(&root, &daemon, "layerfs-shop-1"); + let directory = root.join("layerfs-shop-1"); + assert_eq!(sandbox.directory(), directory); + assert_eq!(sandbox.port(), 40404); + assert_eq!( + sandbox.endpoint().unwrap(), + "127.0.0.1:40404".parse().unwrap() + ); + assert_eq!( + sandbox.labels(), + [("layerfs.role".to_owned(), "sandbox".to_owned())] + ); + let process = sandbox.process().unwrap(); + assert!(process.alive()); + assert_eq!(LocalProcess::observe(process.pid).unwrap(), process); + assert!(!LocalProcess { + start: process.start + 1, + ..process + } + .alive()); + // private setup, private overlay home, traversable Sandbox directory + assert_eq!(mode(&directory), 0o755); + assert_eq!(mode(&directory.join("config")), 0o700); + assert_eq!(mode(&directory.join("config/daemon.setup")), 0o600); + assert_eq!( + fs::read(directory.join("config/daemon.setup")).unwrap(), + b"setup bytes" + ); + assert_eq!(mode(&directory.join("overlay")), 0o700); + assert_eq!(mode(&directory.join("daemon.log")), 0o600); + assert_eq!(mode(&directory.join("tmp")), 0o1777); + assert_eq!(sandbox.tmp(), directory.join("tmp")); + // the daemon inherits nothing of this process's environment but PATH + let log = String::from_utf8(sandbox.log_tail(100, 4096).unwrap()).unwrap(); + assert!(log.contains("PATH=/usr/local/sbin:"), "{log}"); + assert!(!log.contains("CARGO"), "{log}"); + assert_eq!( + String::from_utf8(sandbox.log_tail(1, 4096).unwrap()).unwrap(), + "LAYERFS_DAEMON_LISTEN 127.0.0.1:40404\n" + ); + assert_eq!(sandbox.log_tail(1, 5).unwrap(), b"0404\n"); + // the same name again is refused before anything is made + let again = Local::new(&root) + .unwrap() + .naming("layerfs-shop-1") + .unwrap() + .create(&daemon, b"x", WAIT, Vec::new()) + .unwrap_err(); + assert_eq!(again.phase, LocalPhase::Validate); + assert!(again.sandbox.is_none()); + // delete needs a known stop; stop ends the process; each is attempted once + assert!(sandbox.delete().is_err()); + let mut sandbox = Sandbox::reopen( + directory.clone(), + sandbox.identity(), + process, + 40404, + Vec::new(), + ) + .unwrap(); + sandbox.stop(1).unwrap(); + assert!(!process.alive()); + assert!(sandbox.stop(1).is_err()); + assert!(sandbox.endpoint().is_err()); + sandbox.delete().unwrap(); + assert!(!directory.exists()); + assert!(sandbox.delete().is_err()); +} + +#[test] +fn a_command_runs_as_its_identity_in_its_directory_with_only_its_environment() { + let (root, daemon) = fixture(LISTENS); + let mut sandbox = started(&root, &daemon, "layerfs-shop-2"); + let identity = me(); + let (code, out, err) = run( + &sandbox, + &[ + "bash", + "-c", + "id -u; id -g; pwd; echo \"$HOME|${CARGO:-none}\"; id -G >&2; echo oops >&2; exit 7", + ], + &["HOME=/tmp"], + ); + assert_eq!(code, 7); + assert_eq!( + out, + format!( + "{}\n{}\n{}\n/tmp|none\n", + identity.uid, + identity.gid, + sandbox.directory().display() + ) + ); + let (groups, err) = err.split_once('\n').unwrap(); + assert_eq!(err, "oops\n"); + // as root, the command keeps no supplementary group of this process + if fs::metadata("/proc/self").unwrap().uid() == 0 { + assert_eq!(groups, identity.gid.to_string()); + } + // a command a signal ended reports 128 + the signal, as the Engine does + let (code, _, _) = run(&sandbox, &["bash", "-c", "kill -9 $$"], &[]); + assert_eq!(code, 137); + // more output than one pipe holds, on both streams at once + let (code, out, err) = run( + &sandbox, + &[ + "bash", + "-c", + "head -c 300000 /dev/zero | tr '\\0' a; head -c 200000 /dev/zero | tr '\\0' b >&2", + ], + &[], + ); + assert_eq!((code, out.len(), err.len()), (0, 300000, 200000)); + // refused before any effect: root, stdin, a relative directory, a bad variable + let mut request = ExecRequest { + identity, + arguments: vec!["true".into()], + environment: Vec::new(), + directory: "/".into(), + stdin: false, + }; + for change in [ + (|r: &mut ExecRequest| r.identity = CommandIdentity { uid: 0, gid: 0 }) + as fn(&mut ExecRequest), + |r| r.stdin = true, + |r| r.directory = "relative".into(), + |r| r.environment = vec!["NOEQUALS".into()], + |r| r.arguments = Vec::new(), + ] { + let mut changed = request.clone(); + change(&mut changed); + let failure = sandbox + .run(&changed, &mut Vec::new(), &mut Vec::new()) + .unwrap_err(); + assert!(!failure.attempted, "{failure:?}"); + } + sandbox.stop(1).unwrap(); + // no command once the daemon is stopped + request.directory = "/".into(); + assert!(sandbox + .run(&request, &mut Vec::new(), &mut Vec::new()) + .is_err()); + sandbox.delete().unwrap(); +} + +#[test] +fn a_daemon_that_ends_is_the_stopped_observation_and_only_its_delete_remains() { + let (root, daemon) = fixture(LISTENS); + let sandbox = started(&root, &daemon, "layerfs-shop-3"); + let process = sandbox.process().unwrap(); + let directory = sandbox.directory().to_owned(); + // killed from outside, as a crash or the OOM killer would + assert!(Command::new("kill") + .args(["-9", &process.pid.to_string()]) + .status() + .unwrap() + .success()); + let deadline = Instant::now() + WAIT; + let failure = loop { + match sandbox.endpoint() { + Err(failure) => break failure, + Ok(_) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)), + Ok(_) => panic!("killed daemon still observed running"), + } + }; + assert!(failure.container_stopped(), "{failure:?}"); + assert!(!failure.container_gone()); + // this process reaped its child: no zombie remains + let stat = fs::read_to_string(format!("/proc/{}/stat", process.pid)); + assert!(stat.is_err() || !stat.unwrap().contains(") Z ")); + // a reopen from the record observes the same + let reopened = Sandbox::reopen( + directory.clone(), + sandbox.identity(), + process, + 40404, + Vec::new(), + ) + .unwrap_err(); + assert!(reopened.container_stopped()); + let mut stopped = Sandbox::stopped( + directory.clone(), + sandbox.identity(), + process, + 40404, + Vec::new(), + ); + assert!(stopped.endpoint().is_err()); + stopped.delete().unwrap(); + assert!(!directory.exists()); +} + +#[test] +fn a_failed_start_returns_what_it_made() { + // exits before its listener line + let (root, daemon) = fixture("echo broken >&2; exit 9"); + let failure = Local::new(&root) + .unwrap() + .naming("layerfs-shop-4") + .unwrap() + .create(&daemon, b"x", WAIT, Vec::new()) + .unwrap_err(); + assert_eq!(failure.phase, LocalPhase::Listener); + let mut sandbox = failure.sandbox.unwrap(); + assert_eq!(sandbox.log_tail(5, 100).unwrap(), b"broken\n"); + sandbox.stop(1).unwrap(); + sandbox.delete().unwrap(); + assert!(!root.join("layerfs-shop-4").exists()); + // never prints it: the wait ends + let (root, daemon) = fixture("exec sleep 60"); + let failure = Local::new(&root) + .unwrap() + .naming("layerfs-shop-5") + .unwrap() + .create(&daemon, b"x", Duration::from_millis(300), Vec::new()) + .unwrap_err(); + assert_eq!(failure.phase, LocalPhase::Listener); + assert!( + matches!(&failure.cause, RuntimeError::Io(e) if e.kind() == std::io::ErrorKind::TimedOut) + ); + let mut sandbox = failure.sandbox.unwrap(); + let process = sandbox.process().unwrap(); + assert!(process.alive()); + sandbox.stop(1).unwrap(); + assert!(!process.alive()); + sandbox.delete().unwrap(); + // a listener that is not loopback is refused + let (root, daemon) = fixture("echo 'LAYERFS_DAEMON_LISTEN 0.0.0.0:5'; exec sleep 60"); + let failure = Local::new(&root) + .unwrap() + .naming("layerfs-shop-6") + .unwrap() + .create(&daemon, b"x", WAIT, Vec::new()) + .unwrap_err(); + assert_eq!(failure.phase, LocalPhase::Listener); + let mut sandbox = failure.sandbox.unwrap(); + sandbox.stop(1).unwrap(); + sandbox.delete().unwrap(); + // a missing executable or an empty setup makes nothing + let failure = Local::new(&root) + .unwrap() + .naming("layerfs-shop-7") + .unwrap() + .create(&root.join("missing"), b"x", WAIT, Vec::new()) + .unwrap_err(); + assert_eq!( + (failure.phase, failure.sandbox.is_none()), + (LocalPhase::Validate, true) + ); + let failure = Local::new(&root) + .unwrap() + .naming("layerfs-shop-7") + .unwrap() + .create(&daemon, b"", WAIT, Vec::new()) + .unwrap_err(); + assert_eq!( + (failure.phase, failure.sandbox.is_none()), + (LocalPhase::Validate, true) + ); + assert!(!root.join("layerfs-shop-7").exists()); +} + +#[test] +fn a_zombie_is_not_alive() { + let mut child = Command::new("true").spawn().unwrap(); + let process = LocalProcess::observe(child.id()).unwrap(); + let deadline = Instant::now() + WAIT; + while process.alive() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert!(!process.alive(), "an exited, unreaped child counted alive"); + child.wait().unwrap(); +} From 726869a14b779e393309a4eaf814675cad3fb731 Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:31:33 +0800 Subject: [PATCH 10/45] feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX=local, for a VM or CI box without Docker Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch). This commit: sdk/js/native/src/runtime.rs (production). No tests changed or deleted. Production LOC: 109,167 -> 109,274 (delta +107) Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68ddc1. --- sdk/js/native/src/runtime.rs | 140 +++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 sdk/js/native/src/runtime.rs diff --git a/sdk/js/native/src/runtime.rs b/sdk/js/native/src/runtime.rs new file mode 100644 index 0000000000..ef9737c76e --- /dev/null +++ b/sdk/js/native/src/runtime.rs @@ -0,0 +1,140 @@ +//! A Sandbox's runtime: a Docker container (the default), or with +//! `deploy.sandbox` `local` (LAYERFS_SANDBOX=local) a daemon process on this +//! kernel, for a VM or CI box that is itself the isolation boundary. Both run +//! the same daemon and answer the same calls; only how it is started, how +//! commands are launched and how it is observed differ. +use crate::job::{failed, refused}; +use crate::output::Spool; +use layerfs_bridge::control::{SdkRefusal, SdkValue}; +use layerfs_sandbox::backend::{docker, local}; +use layerfs_sandbox::{CommandIdentity, ContainerId, WireFailure}; +use std::net::SocketAddr; + +/// The image a local Sandbox records: placement keeps local and Docker +/// Sandboxes of one project apart by it. +pub(crate) const LOCAL: &str = "local"; + +/// One Sandbox's runtime owner. +pub(crate) enum Runtime { + Docker(docker::Sandbox), + Local(local::Sandbox), +} + +/// Whether the deployment selects the local runtime: `deploy.sandbox` unset +/// or `docker` is Docker, `local` is local; anything else is refused before +/// any effect. +pub(crate) fn local(deploy: &SdkValue) -> Result { + match deploy.get_text("sandbox") { + None | Some("docker") => Ok(false), + Some("local") => Ok(true), + Some(other) => Err(refused( + "InvalidInput", + format!("deploy.sandbox (LAYERFS_SANDBOX) is docker or local, not {other}"), + )), + } +} + +/// The image a new Workspace of this deployment is placed by: `local` for +/// the local runtime, else `deploy.image`. +pub(crate) fn image(args: &SdkValue) -> Result, SdkRefusal> { + let Some(deploy) = args.get("deploy") else { + return Ok(None); + }; + Ok(if local(deploy)? { + Some(LOCAL.to_owned()) + } else { + deploy.get_text("image").map(str::to_owned) + }) +} + +impl Runtime { + /// The recorded identity: the container's, or a local Sandbox's own. + pub(crate) fn identity(&self) -> ContainerId { + match self { + Self::Docker(sandbox) => sandbox.identity(), + Self::Local(sandbox) => sandbox.identity(), + } + } + /// The image it runs; `local` for a local Sandbox. + pub(crate) fn image(&self) -> &str { + match self { + Self::Docker(sandbox) => &sandbox.request().image, + Self::Local(_) => LOCAL, + } + } + /// Its daemon's endpoint while it runs, observed once; a container or + /// daemon found gone or stopped is [`WireFailure::container_gone`] or + /// [`WireFailure::container_stopped`]. + pub(crate) fn endpoint(&self) -> Result> { + match self { + Self::Docker(sandbox) => sandbox.endpoint(), + Self::Local(sandbox) => sandbox.endpoint(), + } + } + /// Where its commands keep scratch files: `/tmp` in a container, the + /// Sandbox's own `tmp` directory for a local one (deleted with it), so + /// a local Sandbox leaves nothing in this machine's `/tmp`. + pub(crate) fn scratch(&self) -> String { + match self { + Self::Docker(_) => "/tmp".to_owned(), + Self::Local(sandbox) => sandbox.tmp().display().to_string(), + } + } + /// Deletes a Sandbox found stopped, once. + pub(crate) fn delete(&mut self) -> Result<(), Box> { + match self { + Self::Docker(sandbox) => sandbox.delete(), + Self::Local(sandbox) => sandbox.delete(), + } + } + /// One command to its end: exit code, stdout, stderr. `spool` sends + /// every non-empty stream to its file instead of keeping it inline. + pub(crate) fn run( + &self, + identity: CommandIdentity, + arguments: Vec, + environment: Vec, + directory: &str, + spool: bool, + ) -> Result<(i32, Spool, Spool), SdkRefusal> { + let (mut stdout, mut stderr) = (Spool::new(spool), Spool::new(spool)); + let code = match self { + Self::Docker(sandbox) => { + let created = layerfs_api::internal::exec( + sandbox, + identity, + arguments, + environment, + directory.to_owned(), + false, + ) + .map_err(failed)?; + let attached = created.start().map_err(failed)?; + let (_input, output, handle) = attached.into_parts().map_err(failed)?; + output.copy_to(&mut stdout, &mut stderr).map_err(failed)?; + let inspected = handle.inspect().map_err(failed)?; + inspected + .known_root_exit() + .ok_or_else(|| refused("Unknown", "the command's exit code is not known"))? + } + Self::Local(sandbox) => { + let request = docker::ExecRequest { + identity, + arguments, + environment, + directory: directory.to_owned(), + stdin: false, + }; + sandbox + .run(&request, &mut stdout, &mut stderr) + .map_err(failed)? + .code + } + }; + Ok((code, stdout, stderr)) + } +} + +#[cfg(test)] +#[path = "../tests/unit/runtime.rs"] +mod runtime_tests; From 13d863124653b53a86e118abb8da72828f121afe Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:31:35 +0800 Subject: [PATCH 11/45] feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX=local, for a VM or CI box without Docker Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch). This commit: sdk/js/native/src/local.rs (production). No tests changed or deleted. Production LOC: 109,274 -> 109,527 (delta +253) Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68ddc1. --- sdk/js/native/src/local.rs | 288 +++++++++++++++++++++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 sdk/js/native/src/local.rs diff --git a/sdk/js/native/src/local.rs b/sdk/js/native/src/local.rs new file mode 100644 index 0000000000..4ca4201219 --- /dev/null +++ b/sdk/js/native/src/local.rs @@ -0,0 +1,288 @@ +//! Local Sandboxes (`deploy.sandbox` `local`, LAYERFS_SANDBOX=local): the +//! project's daemon runs as a process of this kernel instead of in a +//! container, for a VM or CI box that is itself the isolation boundary. It +//! isolates nothing from the rest of that machine. +//! +//! Each Sandbox gets a directory `/-` +//! with what a container held at fixed paths: its setup +//! (`/layerfs-local/config`), overlay template (`/layerfs-local/overlay`) and +//! Workspace mounts (`/workspaces`). The daemon opens the installed Store +//! where this process opens it (`storePath`), so the daemon setup and the +//! Store's locator name that path instead of `/layerfs-store`. The daemon +//! listens on 127.0.0.1 only. Commands run as the same nonroot identity as +//! in a container. Start, failure and reclaim follow the Docker route: one +//! attempt, a creation intent while the outcome is open, and a failed start's +//! daemon stopped and its directory deleted, each once. +use crate::job::{failed, refused}; +use crate::registry::{lock, text, SandboxState, SANDBOXES}; +use crate::runtime::Runtime; +use layerfs_api::internal::Control; +use layerfs_bridge::control::{DaemonPhase, HelloRequest, Reply, Request, SdkRefusal, SdkValue}; +use layerfs_bridge::duplex::initiate_observed; +use layerfs_bridge::provision::StoreManifest; +use layerfs_sandbox::backend::local::{Local, LocalCreateFailure, Sandbox}; +use layerfs_sandbox::CommandIdentity; +use std::net::{SocketAddr, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// Starts the project's next Sandbox as a local daemon. Called by +/// [`crate::sandbox::start`] under the project's guard, with the project +/// and its installed manifest already read. +pub(crate) fn start( + args: &SdkValue, + directory: &Path, + project: (&crate::registry::Project, StoreManifest), +) -> Result, SdkRefusal> { + let deploy = args + .get("deploy") + .ok_or_else(|| refused("InvalidInput", "missing `deploy`"))?; + // Inputs are read first, so a refused request leaves no creation intent. + if !crate::discovery::root() { + return Err(refused( + "Unsupported", + "LAYERFS_SANDBOX=local needs root: its daemon mounts FUSE and its commands run as \ + uid 501, gid 20. Run it as root inside the VM or CI box that isolates it", + )); + } + let root = deploy.get_text("localDir").ok_or_else(|| { + refused( + "InvalidInput", + "deploy.localDir is not configured (LAYERFS_LOCAL_DIR); a local Sandbox needs it", + ) + })?; + let local = Local::new(root) + .map_err(|error| refused("InvalidInput", format!("LAYERFS_LOCAL_DIR {root}: {error}")))?; + let executable = PathBuf::from(text(deploy, "daemon")?); + let store = match args.get_text("storePath") { + Some(path) if Path::new(path).is_absolute() => path.to_owned(), + _ => { + return Err(refused( + "InvalidInput", + "a local Sandbox's daemon opens the client's storePath (LAYERFS_STORE_PATH), an absolute path", + )) + } + }; + crate::sandbox::unresolved( + directory, + &format!("a directory under {root} named for it, and its daemon process"), + )?; + let (id, saved_directory) = crate::discovery::claim_next(args)?; + let made = start_claimed( + args, + directory, + &saved_directory, + &id, + project, + (local, executable, store), + ); + if made.is_err() { + let _ = std::fs::remove_dir(&saved_directory); + } + made +} + +/// Starts the claimed Sandbox `id`: directory and daemon on the installed +/// Store, its first control connection, then its records. +fn start_claimed( + args: &SdkValue, + directory: &Path, + saved_directory: &Path, + id: &str, + (project, installed): (&crate::registry::Project, StoreManifest), + (local, executable, store): (Local, PathBuf, String), +) -> Result, SdkRefusal> { + let name = text(args, "project")?; + let prefix = args + .get("deploy") + .and_then(|d| d.get_text("containerPrefix")) + .filter(|prefix| !prefix.is_empty()) + .unwrap_or(crate::sandbox::PREFIX); + let sandbox_name = format!("{prefix}{name}-{id}"); + let local = local + .naming(&sandbox_name) + .map_err(|_| refused("InvalidInput", format!("local Sandbox name {sandbox_name}")))?; + let paths = local.paths().map_err(failed)?; + let path_text = |path: &Path| { + path.to_str() + .map(str::to_owned) + .ok_or_else(|| refused("InvalidInput", "LAYERFS_LOCAL_DIR must be UTF-8")) + }; + let controller = layerfs_bridge::duplex::generate_keypair().map_err(failed)?; + let daemon = layerfs_bridge::duplex::generate_keypair().map_err(failed)?; + let mut setup = crate::sandbox::setup(daemon.private, controller.public); + setup.listen = "127.0.0.1:0".into(); + setup.store = store.clone(); + setup.overlay = path_text(&paths.overlay)?; + setup.mounts = path_text(&paths.mounts)?; + let mut existing = project.manifest.clone(); + existing.daemon_sqlite = installed.daemon_sqlite; + // the Store's path in the daemon's filesystem, which is this one + existing.locator = store; + setup.existing_store = Some(existing); + let identity = CommandIdentity { + uid: setup.command_uid, + gid: setup.command_gid, + }; + let config = setup.encode().map_err(failed)?; + // Read only if this process ends before the intent is resolved. + crate::discovery::save( + &directory.join("create.intent"), + SdkValue::text(format!("creation of Sandbox {id} began and never finished")), + )?; + let labels = crate::sandbox::labels(name, id); + let sandbox = local + .create(&executable, &config, crate::sandbox::STARTUP, labels) + .map_err(|failure| { + let LocalCreateFailure { + phase, + sandbox, + cause, + } = *failure; + abandon( + directory, + id, + format!("local sandbox create stopped at {phase:?}: {cause:?}"), + sandbox, + ) + })?; + let first = sandbox.endpoint().map_err(failed).and_then(|endpoint| { + first(endpoint, controller.private, daemon.public).map(|c| (endpoint, c)) + }); + let (endpoint, (control, instance)) = match first { + Ok(opened) => opened, + Err(refusal) => { + return Err(abandon( + directory, + id, + format!("local sandbox first connection: {}", refusal.message), + Some(sandbox), + )) + } + }; + let dial = crate::pool::Dial { + endpoint, + host: None, + secret: controller.private, + peer: daemon.public, + instance, + }; + let state = Arc::new(SandboxState { + id: id.to_owned(), + project: name.to_owned(), + name: sandbox_name, + directory: saved_directory.to_owned(), + runtime: Runtime::Local(sandbox), + identity, + controls: crate::pool::Controls::new(control, dial), + mounts: Default::default(), + processes: Default::default(), + }); + crate::discovery::save_sandbox( + &state.directory, + &state, + args, + controller.private, + daemon.public, + instance, + )?; + crate::discovery::save_text( + &state.directory.join("container"), + &state.runtime.identity().to_string(), + )?; + std::fs::remove_file(directory.join("create.intent")).map_err(failed)?; + lock(&SANDBOXES)?.insert(state.directory.clone(), state.clone()); + Ok(state) +} + +/// The first control connection to a new daemon: one connect, one +/// authentication, one Hello that waits for the Store and must find the +/// daemon ControlReady; its instance identifies the daemon from then on. +fn first( + endpoint: SocketAddr, + secret: [u8; 32], + peer: [u8; 32], +) -> Result<(Control, [u8; 32]), SdkRefusal> { + let wait = crate::sandbox::STARTUP; + let stream = TcpStream::connect_timeout(&endpoint, wait).map_err(failed)?; + stream.set_read_timeout(Some(wait)).map_err(failed)?; + stream.set_write_timeout(Some(wait)).map_err(failed)?; + let connection = initiate_observed(stream, &secret, peer) + .result + .map_err(failed)?; + connection.clear_io_waits().map_err(failed)?; + let mut control = Control::new(connection); + match control + .call(Request::Hello(HelloRequest { + expected_instance: None, + wait_for_store: true, + })) + .map_err(failed)? + { + Reply::Hello(status) if status.phase == DaemonPhase::ControlReady => { + Ok((control, status.instance)) + } + reply => Err(refused( + "Failed", + format!("the new daemon is not ControlReady: {reply:?}"), + )), + } +} + +/// The refusal for a failed start. Everything a local start makes is its +/// own (a directory and a child process), so each effect is known: the +/// daemon's last log lines are read once, then it is stopped and its +/// directory deleted, each once, and the intent removed. If that cleanup +/// fails, the intent stays and says what is left. +fn abandon(directory: &Path, id: &str, mut cause: String, sandbox: Option) -> SdkRefusal { + let container = sandbox.as_ref().map(|s| s.identity().to_string()); + let mut log = None; + let mut left = None; + if let Some(mut sandbox) = sandbox { + match sandbox.log_tail(crate::sandbox::LOG_LINES, crate::sandbox::LOG_BYTES) { + Ok(bytes) => { + let text = String::from_utf8_lossy(&bytes).trim_end().to_owned(); + cause = format!("{cause}; its daemon's last log lines:\n{text}"); + log = Some(text); + } + Err(error) => cause = format!("{cause}; its daemon log was unreadable: {error:?}"), + } + let removed = match sandbox.process() { + Some(_) => sandbox.stop(1).and_then(|()| sandbox.delete()), + None => sandbox.delete(), + }; + left = removed.err().map(|error| { + format!( + "its local Sandbox {} could not be removed: {error:?}", + sandbox.directory().display() + ) + }); + } + let intent = directory.join("create.intent"); + let (message, removed) = match left { + None => match std::fs::remove_file(&intent) { + Ok(()) => (cause, container.is_some()), + Err(error) => ( + format!("{cause}; its creation intent stays: {error}"), + container.is_some(), + ), + }, + Some(left) => { + let state = format!("creation of Sandbox {id} failed ({cause}); {left}"); + let kept = crate::discovery::save(&intent, SdkValue::text(&state)) + .err() + .map(|error| format!(" (recording it failed: {})", error.message)) + .unwrap_or_default(); + (format!("{state}; its creation intent stays{kept}"), false) + } + }; + SdkRefusal { + code: "Failed".to_owned(), + message, + detail: SdkValue::map([ + ("container", SdkValue::opt_text(container)), + ("removed", SdkValue::Bool(removed)), + ("log", SdkValue::opt_text(log)), + ]), + } +} From eda15378d750f8ae1f80efb4f2edebf80335828e Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:31:37 +0800 Subject: [PATCH 12/45] feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX=local, for a VM or CI box without Docker Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch). This commit: sdk/js/native/src/registry.rs (production). No tests changed or deleted. Production LOC: 109,527 -> 109,527 (delta 0) Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68ddc1. --- sdk/js/native/src/registry.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/sdk/js/native/src/registry.rs b/sdk/js/native/src/registry.rs index aeeb25bc47..5f1e902287 100644 --- a/sdk/js/native/src/registry.rs +++ b/sdk/js/native/src/registry.rs @@ -2,7 +2,8 @@ //! project itself is only its home entry (`//`), so a project //! removed there can be initialized again without a restart. //! -//! A Sandbox is one container and its daemon. Its runtime runs commands +//! A Sandbox is one container and its daemon (or, with the local runtime, +//! one daemon process: `runtime`). Its runtime runs commands //! directly; its control connections (`pool`) each carry one exchange at a //! time, and concurrent calls take separate connections. The Sandbox runtime owner (this //! binding) also keeps the background processes it started; the daemon holds @@ -11,7 +12,7 @@ use crate::job::refused; use layerfs_api::MountedWorkspace; use layerfs_bridge::control::{SdkRefusal, SdkValue, WorkspaceToken}; use layerfs_bridge::provision::StoreManifest; -use layerfs_sandbox::{backend::docker::Sandbox, CommandIdentity}; +use layerfs_sandbox::CommandIdentity; use std::collections::BTreeMap; use std::sync::{Arc, LazyLock, Mutex, MutexGuard}; @@ -42,10 +43,11 @@ pub(crate) struct Process { pub(crate) struct SandboxState { pub(crate) id: String, pub(crate) project: String, - /// Its container's name, `-`. + /// Its container's name, `-` (a local Sandbox's + /// directory name). pub(crate) name: String, pub(crate) directory: std::path::PathBuf, - pub(crate) runtime: Sandbox, + pub(crate) runtime: crate::runtime::Runtime, pub(crate) identity: CommandIdentity, pub(crate) controls: crate::pool::Controls, pub(crate) mounts: Mutex>, From 4f0ce6dddb7ffc5a149aedba76d0e05fc484db96 Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:31:39 +0800 Subject: [PATCH 13/45] feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX=local, for a VM or CI box without Docker Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch). This commit: sdk/js/native/src/discovery.rs (production). No tests changed or deleted. Production LOC: 109,527 -> 109,644 (delta +117) Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68ddc1. --- sdk/js/native/src/discovery.rs | 227 +++++++++++++++++++++++++-------- 1 file changed, 177 insertions(+), 50 deletions(-) diff --git a/sdk/js/native/src/discovery.rs b/sdk/js/native/src/discovery.rs index ff9c77f1c8..7862715723 100644 --- a/sdk/js/native/src/discovery.rs +++ b/sdk/js/native/src/discovery.rs @@ -1,4 +1,5 @@ //! Private, permission-authorized project and Sandbox discovery. No mutation replay. +use crate::runtime::Runtime; use crate::{ job::{failed, refused}, registry::{self, Mount, Process, Project, SandboxState}, @@ -11,7 +12,8 @@ use layerfs_bridge::{ use layerfs_persistence::{SealedStore, SqlitePersistenceProfile}; use layerfs_sandbox::{ backend::docker::{Docker, Sandbox, SandboxRequest}, - CommandIdentity, ContainerId, + backend::local::{self, LocalProcess}, + CommandIdentity, ContainerId, WireFailure, }; use std::{ fs::{self, File, OpenOptions}, @@ -68,6 +70,11 @@ pub(crate) fn directory(path: &Path) -> Result<()> { } private(path, true) } +/// Whether this process runs as root, which the local runtime needs: its +/// daemon mounts FUSE and its commands run as another uid/gid. +pub(crate) fn root() -> bool { + unsafe { geteuid() == 0 } +} /// Where a project's Sandboxes live: `LAYERFS_HOME//sandboxes//` /// (#373), numbered from 1 within the project. fn sandboxes(args: &SdkValue) -> Result { @@ -339,39 +346,64 @@ pub(crate) fn save_sandbox( let deploy = args .get("deploy") .ok_or_else(|| refused("InvalidInput", "deploy"))?; - save( - &directory.join("sandbox.record"), - SdkValue::map([ - ("id", SdkValue::text(&state.id)), - ("project", SdkValue::text(&state.project)), - ("name", SdkValue::text(&state.name)), - ( - "container", - SdkValue::text(state.runtime.identity().to_string()), - ), + let labels = |labels: &[(String, String)]| { + SdkValue::List( + (labels.iter()) + .map(|(key, value)| { + SdkValue::map([ + ("key", SdkValue::text(key)), + ("value", SdkValue::text(value)), + ]) + }) + .collect(), + ) + }; + let mut fields = vec![ + ("id", SdkValue::text(&state.id)), + ("project", SdkValue::text(&state.project)), + ("name", SdkValue::text(&state.name)), + ( + "container", + SdkValue::text(state.runtime.identity().to_string()), + ), + ]; + match &state.runtime { + Runtime::Docker(runtime) => fields.extend([ ("docker", SdkValue::text(registry::text(deploy, "docker")?)), - ("image", SdkValue::text(&state.runtime.request().image)), - ( - "volume", - SdkValue::text(&state.runtime.request().store_volume), - ), - ( - "labels", - SdkValue::List( - (state.runtime.request().labels.iter()) - .map(|(key, value)| { - SdkValue::map([ - ("key", SdkValue::text(key)), - ("value", SdkValue::text(value)), - ]) - }) - .collect(), - ), - ), - ("secret", SdkValue::Bytes(secret.to_vec())), - ("peer", SdkValue::Bytes(peer.to_vec())), - ("instance", SdkValue::Bytes(instance.to_vec())), + ("image", SdkValue::text(&runtime.request().image)), + ("volume", SdkValue::text(&runtime.request().store_volume)), + ("labels", labels(&runtime.request().labels)), ]), + Runtime::Local(runtime) => { + let process = runtime + .process() + .ok_or_else(|| refused("Failed", "local Sandbox has no daemon process"))?; + fields.extend([ + ("runtime", SdkValue::text(crate::runtime::LOCAL)), + ( + "directory", + SdkValue::text(runtime.directory().display().to_string()), + ), + ("pid", SdkValue::Int(i64::from(process.pid))), + ("start", SdkValue::text(process.start.to_string())), + ("port", SdkValue::Int(i64::from(runtime.port()))), + ("image", SdkValue::text(crate::runtime::LOCAL)), + ("labels", labels(runtime.labels())), + ]); + } + } + fields.extend([ + ("secret", SdkValue::Bytes(secret.to_vec())), + ("peer", SdkValue::Bytes(peer.to_vec())), + ("instance", SdkValue::Bytes(instance.to_vec())), + ]); + save( + &directory.join("sandbox.record"), + SdkValue::Map( + (fields.into_iter()) + .map(|(name, value)| (name.to_owned(), value)) + .collect(), + ), ) } /// A Sandbox whose container no longer runs it: the Engine no longer has the @@ -406,31 +438,121 @@ pub(crate) fn gone_container(refusal: &SdkRefusal) -> Option<(&str, bool)> { _ => None, } } -/// The Engine, container and deployment a Sandbox record names. -pub(crate) fn runtime(value: &SdkValue) -> Result<(Docker, ContainerId, SandboxRequest)> { +/// The labels a Sandbox record keeps; a record written before #373 has none. +fn labels(value: &SdkValue) -> Result> { + match value.get("labels") { + Some(SdkValue::List(items)) => items + .iter() + .map(|item| { + Ok(( + registry::text(item, "key")?.to_owned(), + registry::text(item, "value")?.to_owned(), + )) + }) + .collect(), + _ => Ok(Vec::new()), + } +} +/// The Engine, container and deployment a Docker Sandbox record names. +fn runtime(value: &SdkValue) -> Result<(Docker, ContainerId, SandboxRequest)> { let docker = Docker::new(registry::text(value, "docker")?, Duration::from_secs(30)).map_err(failed)?; let request = SandboxRequest { image: registry::text(value, "image")?.to_owned(), store_volume: registry::text(value, "volume")?.to_owned(), port: 30421, - // a record written before #373 has no labels, nor has its container - labels: match value.get("labels") { - Some(SdkValue::List(items)) => items - .iter() - .map(|item| { - Ok(( - registry::text(item, "key")?.to_owned(), - registry::text(item, "value")?.to_owned(), - )) - }) - .collect::>()?, - _ => Vec::new(), - }, + labels: labels(value)?, }; let container = ContainerId::parse(registry::text(value, "container")?).map_err(failed)?; Ok((docker, container, request)) } +/// What a Sandbox record names: a Docker container, or (`runtime` `local`) +/// a local daemon process, its directory and port. +pub(crate) enum Recorded { + Docker(Docker, ContainerId, SandboxRequest), + Local { + directory: PathBuf, + id: ContainerId, + process: LocalProcess, + port: u16, + labels: Vec<(String, String)>, + }, +} +/// Reads a Sandbox record's runtime. A local record's directory must be +/// absolute, plain and named for its Sandbox, so a damaged record never +/// points a delete elsewhere. +pub(crate) fn recorded(value: &SdkValue) -> Result { + if value.get_text("runtime") != Some(crate::runtime::LOCAL) { + let (docker, container, request) = runtime(value)?; + return Ok(Recorded::Docker(docker, container, request)); + } + let directory = PathBuf::from(registry::text(value, "directory")?); + let name = registry::text(value, "name")?; + if name.is_empty() + || !directory.is_absolute() + || directory.file_name().and_then(|n| n.to_str()) != Some(name) + || directory.components().any(|c| { + matches!( + c, + std::path::Component::ParentDir | std::path::Component::CurDir + ) + }) + { + return Err(refused("InvalidInput", "local Sandbox record directory")); + } + let number = |key: &str| { + value + .get_int(key) + .ok_or_else(|| refused("InvalidInput", format!("local Sandbox record {key}"))) + }; + let process = LocalProcess { + pid: u32::try_from(number("pid")?).map_err(failed)?, + start: registry::text(value, "start")? + .parse::() + .map_err(failed)?, + }; + Ok(Recorded::Local { + directory, + id: ContainerId::parse(registry::text(value, "container")?).map_err(failed)?, + process, + port: u16::try_from(number("port")?).map_err(failed)?, + labels: labels(value)?, + }) +} +impl Recorded { + /// The runtime, observed running once (it starts nothing). + pub(crate) fn reopen(self) -> std::result::Result> { + match self { + Self::Docker(docker, id, request) => { + Sandbox::reopen(docker, id, request).map(Runtime::Docker) + } + Self::Local { + directory, + id, + process, + port, + labels, + } => local::Sandbox::reopen(directory, id, process, port, labels).map(Runtime::Local), + } + } + /// The runtime found stopped: only its one Delete is admitted. + pub(crate) fn stopped(self) -> Runtime { + match self { + Self::Docker(docker, id, request) => { + Runtime::Docker(Sandbox::stopped(docker, id, request)) + } + Self::Local { + directory, + id, + process, + port, + labels, + } => Runtime::Local(local::Sandbox::stopped( + directory, id, process, port, labels, + )), + } + } +} pub(crate) fn reopen(args: &SdkValue) -> Result> { let id = registry::text(args, "sandbox")?; let directory = sandbox_dir(args, id)?; @@ -447,8 +569,13 @@ pub(crate) fn reopen(args: &SdkValue) -> Result> { } let project = registry::text(&value, "project")?; let container = registry::text(&value, "container")?; - let (docker, selector, request) = runtime(&value)?; - let runtime = Sandbox::reopen(docker, selector, request).map_err(|failure| { + let recorded = recorded(&value)?; + // a local daemon listens on this kernel's loopback; no other host reaches it + let host = match recorded { + Recorded::Docker(..) => crate::pool::daemon_host(args), + Recorded::Local { .. } => None, + }; + let runtime = recorded.reopen().map_err(|failure| { if failure.container_gone() || failure.container_stopped() { gone(id, container, failure.container_stopped()) } else { @@ -457,7 +584,7 @@ pub(crate) fn reopen(args: &SdkValue) -> Result> { })?; let dial = crate::pool::Dial { endpoint: runtime.endpoint().map_err(failed)?, - host: crate::pool::daemon_host(args), + host, secret: bytes(&value, "secret")?, peer: bytes(&value, "peer")?, instance: bytes(&value, "instance")?, From 8d2473c19a35464be1aef4febade2d5c1e94a061 Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:31:41 +0800 Subject: [PATCH 14/45] feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX=local, for a VM or CI box without Docker Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch). This commit: sdk/js/native/src/exec.rs (production). No tests changed or deleted. Production LOC: 109,644 -> 109,633 (delta -11) Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68ddc1. --- sdk/js/native/src/exec.rs | 44 +++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/sdk/js/native/src/exec.rs b/sdk/js/native/src/exec.rs index a0803ca58e..1ee27b45dd 100644 --- a/sdk/js/native/src/exec.rs +++ b/sdk/js/native/src/exec.rs @@ -5,46 +5,37 @@ //! daemon is not involved. A background command is started in its own session //! with its output in a log file, and this binding keeps its process id. The //! Engine offers no per-exec cancel, so `cancel` signals the process group -//! from a second command and waits (bounded) until it is gone. -use crate::job::{failed, refused, Outcome}; +//! from a second command and waits (bounded) until it is gone. A local +//! Sandbox (`runtime::Runtime::Local`) runs the same commands as processes of +//! this kernel, so the same probes and signals apply. +use crate::job::{refused, Outcome}; use crate::output::Spool; use crate::registry::{lock, naming, sandbox, sandbox_live, text, Process, SandboxState}; use layerfs_bridge::control::{SdkRefusal, SdkValue}; use std::time::Duration; -/// Home of the command identity, which has no account in the image. -const HOME: &str = "HOME=/tmp"; /// Checks that a cancelled process group is gone. const GONE_CHECKS: u32 = 50; /// Pause between those checks. const GONE_PAUSE: Duration = Duration::from_millis(100); /// One command to its end: exit code, stdout, stderr. `spool` sends every -/// non-empty stream to its file instead of keeping it inline. +/// non-empty stream to its file instead of keeping it inline. Its home is +/// the Sandbox's scratch directory (`/tmp` in a container), since the +/// command identity has no account there. fn run( state: &SandboxState, directory: &str, arguments: Vec, spool: bool, ) -> Result<(i32, Spool, Spool), SdkRefusal> { - let created = layerfs_api::internal::exec( - &state.runtime, + state.runtime.run( state.identity, arguments, - vec![HOME.to_owned()], - directory.to_owned(), - false, + vec![format!("HOME={}", state.runtime.scratch())], + directory, + spool, ) - .map_err(failed)?; - let attached = created.start().map_err(failed)?; - let (_input, output, handle) = attached.into_parts().map_err(failed)?; - let (mut stdout, mut stderr) = (Spool::new(spool), Spool::new(spool)); - output.copy_to(&mut stdout, &mut stderr).map_err(failed)?; - let inspected = handle.inspect().map_err(failed)?; - let code = inspected - .known_root_exit() - .ok_or_else(|| refused("Unknown", "the command's exit code is not known"))?; - Ok((code, stdout, stderr)) } /// `workspace.exec(ws, command, { background?, spool? })`; with @@ -91,11 +82,14 @@ pub(crate) fn exec(args: &SdkValue) -> Outcome { state.id, layerfs_bridge::sdk_values::hex(&crate::registry::random()?[..12]) ); - let script = format!("setsid bash -c \"$0\" >/tmp/layerfs-{id}.log 2>&1 \"$1\" 2>&1 Outcome { /// The container's first process is the daemon, which does not reap orphans, /// so a finished member can stay a zombie that `kill -0` still finds. fn start_time(state: &SandboxState, pid: u32) -> Result, SdkRefusal> { - let (code, out, _) = run(state, "/tmp", vec!["bash".into(), "-c".into(), format!("s=$(cat /proc/{pid}/stat 2>/dev/null) || exit 1; set -- ${{s##*) }}; shift 19; printf '%s' \"$1\"")], false)?; + let (code, out, _) = run(state, &state.runtime.scratch(), vec!["bash".into(), "-c".into(), format!("s=$(cat /proc/{pid}/stat 2>/dev/null) || exit 1; set -- ${{s##*) }}; shift 19; printf '%s' \"$1\"")], false)?; if code == 0 { Ok(Some(String::from_utf8_lossy(out.kept()).into_owned())) } else { @@ -151,7 +145,7 @@ fn alive(state: &SandboxState, pid: u32) -> Result { ); let (code, _, _) = run( state, - "/tmp", + &state.runtime.scratch(), vec!["bash".into(), "-c".into(), probe], false, )?; @@ -283,7 +277,7 @@ pub(crate) fn cancel(args: &SdkValue) -> Outcome { } run( &state, - "/tmp", + &state.runtime.scratch(), vec![ "bash".into(), "-c".into(), From 055655ded77b83f4f49ebe6df6e048a13189d572 Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:31:43 +0800 Subject: [PATCH 15/45] feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX=local, for a VM or CI box without Docker Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch). This commit: sdk/js/native/src/sandbox.rs (production). No tests changed or deleted. Production LOC: 109,633 -> 109,641 (delta +8) Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68ddc1. --- sdk/js/native/src/sandbox.rs | 65 +++++++++++++++++++++--------------- 1 file changed, 38 insertions(+), 27 deletions(-) diff --git a/sdk/js/native/src/sandbox.rs b/sdk/js/native/src/sandbox.rs index 5f74266e1f..90d665e27f 100644 --- a/sdk/js/native/src/sandbox.rs +++ b/sdk/js/native/src/sandbox.rs @@ -28,12 +28,12 @@ use std::time::Duration; /// Port the daemon listens on inside its container. const PORT: u16 = 30421; /// Wait for the daemon's listener after Start. -const STARTUP: Duration = Duration::from_secs(30); +pub(crate) const STARTUP: Duration = Duration::from_secs(30); /// Wait for one Engine control response. const ENGINE: Duration = Duration::from_secs(30); /// A failed create's report keeps its daemon's last log lines, up to this many bytes. -const LOG_LINES: u16 = 20; -const LOG_BYTES: usize = 2048; +pub(crate) const LOG_LINES: u16 = 20; +pub(crate) const LOG_BYTES: usize = 2048; pub(crate) fn operation(failure: Box) -> SdkRefusal { values::refusal(&failure).unwrap_or_else(|| failed(failure)) @@ -52,7 +52,7 @@ pub(crate) const WORKSPACES: u32 = 16; /// for this process and the rest for others (#352 F-017). pub(crate) const DAEMON_CONNECTIONS: u16 = 8; -fn setup(private: [u8; 32], peer: [u8; 32]) -> DaemonSetup { +pub(crate) fn setup(private: [u8; 32], peer: [u8; 32]) -> DaemonSetup { DaemonSetup { listen: format!("0.0.0.0:{PORT}"), private_key: private, @@ -82,7 +82,7 @@ fn setup(private: [u8; 32], peer: [u8; 32]) -> DaemonSetup { } /// A container name prefix when the deployment sets none. -const PREFIX: &str = "layerfs-"; +pub(crate) const PREFIX: &str = "layerfs-"; /// The container labels of Sandbox `id` of `project`: what it is, whose, /// and which. Saved in its record, so a reopen expects the same. @@ -113,6 +113,9 @@ pub(crate) fn start(args: &SdkValue, directory: &Path) -> Result Result state, - _ => "its record is unreadable".to_owned(), - }; - return Err(refused( - "Unknown", - format!( - "a prior Sandbox creation has an unresolved discovery intent: {state}. Its outcome \ - is unknown, so it is neither retried nor taken as done. Remove any container it \ - left (docker ps -a --filter volume={store_volume}), then delete {}", - intent.display() - ), - )); - } + unresolved( + directory, + &format!("docker ps -a --filter volume={store_volume}"), + )?; // The number is claimed before anything is made; a failed start gives it // back (its directory holds no record yet), so the number is used again. let (id, saved_directory) = crate::discovery::claim_next(args)?; @@ -172,6 +162,29 @@ pub(crate) fn start(args: &SdkValue, directory: &Path) -> Result Result<(), SdkRefusal> { + let intent = directory.join("create.intent"); + if intent.exists() { + let state = match crate::discovery::load(&intent) { + Ok(SdkValue::Text(state)) => state, + _ => "its record is unreadable".to_owned(), + }; + return Err(refused( + "Unknown", + format!( + "a prior Sandbox creation has an unresolved discovery intent: {state}. Its outcome \ + is unknown, so it is neither retried nor taken as done. Remove any container it \ + left ({left}), then delete {}", + intent.display() + ), + )); + } + Ok(()) +} + /// Starts the claimed Sandbox `id`: container, daemon on the installed /// Store, then its records. fn start_claimed( @@ -241,7 +254,7 @@ fn start_claimed( project: name.to_owned(), name: container_name, directory: saved_directory.to_owned(), - runtime, + runtime: crate::runtime::Runtime::Docker(runtime), identity, controls: crate::pool::Controls::new(control, dial), mounts: Default::default(), @@ -339,7 +352,7 @@ fn abandon(directory: &Path, id: &str, mut failure: Box) -> SdkR } } -/// Reclaims Sandbox `id`, whose container a reopen found gone or stopped +/// Reclaims Sandbox `id`, whose container (or local daemon) a reopen found gone or stopped /// (`container`, `stopped`): one unforced Delete of a stopped container, then /// its records, so its number is free again. Called under the project's /// guard; the Sandbox's own guard is taken too. The record is read again @@ -364,9 +377,7 @@ pub(crate) fn reclaim(args: &SdkValue, id: &str, container: &str, stopped: bool) )); } if stopped { - let (docker, selector, request) = crate::discovery::runtime(&value)?; - match layerfs_sandbox::backend::docker::Sandbox::stopped(docker, selector, request).delete() - { + match crate::discovery::recorded(&value)?.stopped().delete() { Err(failure) if !failure.container_gone() => return Err(failed(failure)), _ => {} } From 4906f639f233e5c1658240cddb96429562f6983f Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:31:45 +0800 Subject: [PATCH 16/45] feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX=local, for a VM or CI box without Docker Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch). This commit: sdk/js/native/src/workspace.rs (production). No tests changed or deleted. Production LOC: 109,641 -> 109,636 (delta -5) Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68ddc1. --- sdk/js/native/src/workspace.rs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/sdk/js/native/src/workspace.rs b/sdk/js/native/src/workspace.rs index 83e5b31b5d..696e7de15f 100644 --- a/sdk/js/native/src/workspace.rs +++ b/sdk/js/native/src/workspace.rs @@ -35,9 +35,9 @@ fn recorded(directory: &Path) -> Result { /// lowest-numbered running one of the call's image with a free slot, else a /// new one. fn place(args: &SdkValue, project: &Path) -> Result, SdkRefusal> { - let image = args - .get("deploy") - .and_then(|deploy| deploy.get_text("image")); + // `local` for the local runtime: local and Docker Sandboxes never share + let image = crate::runtime::image(args)?; + let image = image.as_deref(); for id in crate::discovery::sandbox_ids(args)? { let directory = crate::discovery::sandbox_dir(args, &id)?; let record = directory.join("sandbox.record"); @@ -81,7 +81,7 @@ pub(crate) fn mount(args: &SdkValue) -> Outcome { if let SdkValue::Map(fields) = &mut value { fields.push(("sandbox".to_owned(), SdkValue::text(state.id.as_str()))); fields.push(("container".to_owned(), SdkValue::text(state.name.as_str()))); - let image = state.runtime.request().image.as_str(); + let image = state.runtime.image(); fields.push(("image".to_owned(), SdkValue::text(image))); } crate::discovery::save_workspace(&state, &mounted, branch)?; @@ -96,7 +96,8 @@ pub(crate) fn mount(args: &SdkValue) -> Outcome { } /// `operator.sandbox.list()`: the project's running Sandboxes, lowest -/// number first: `{ id, name, image }`. Each recorded container is inspected once +/// number first: `{ id, name, image }`. Each recorded container (or local +/// daemon) is inspected once /// (no daemon is dialed); one found gone or stopped, an idle-closed /// Sandbox's, is reclaimed under the project's guard and left out. It starts /// nothing. @@ -123,11 +124,7 @@ pub(crate) fn list(args: &SdkValue) -> Outcome { let cached = lock(&crate::registry::SANDBOXES)?.get(&directory).cloned(); let observed = match cached { Some(state) => state.runtime.endpoint().map(drop), - None => { - let (docker, selector, request) = crate::discovery::runtime(&value)?; - layerfs_sandbox::backend::docker::Sandbox::reopen(docker, selector, request) - .map(drop) - } + None => crate::discovery::recorded(&value)?.reopen().map(drop), }; match observed { Ok(()) => items.push(item), From de16c451c09b8a3ba0850a175684292b86f44cef Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:31:47 +0800 Subject: [PATCH 17/45] feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX=local, for a VM or CI box without Docker Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch). This commit: sdk/js/native/src/lib.rs (production). No tests changed or deleted. Production LOC: 109,636 -> 109,638 (delta +2) Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68ddc1. --- sdk/js/native/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sdk/js/native/src/lib.rs b/sdk/js/native/src/lib.rs index 0c8c45c5c9..5f9d2570ec 100644 --- a/sdk/js/native/src/lib.rs +++ b/sdk/js/native/src/lib.rs @@ -5,10 +5,12 @@ mod discovery; mod dispatch; mod exec; mod job; +mod local; mod output; mod pool; mod project; mod registry; +mod runtime; mod sandbox; mod trace; mod workspace; From 3466de35718b6d44138c6ab53e9fde7a7b153e71 Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:31:49 +0800 Subject: [PATCH 18/45] feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX=local, for a VM or CI box without Docker Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch). This commit: sdk/js/native/tests/unit/runtime.rs (not production (test, manifest or docs)). No tests changed or deleted. Production LOC: 109,638 -> 109,638 (delta 0) Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68ddc1. --- sdk/js/native/tests/unit/runtime.rs | 95 +++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 sdk/js/native/tests/unit/runtime.rs diff --git a/sdk/js/native/tests/unit/runtime.rs b/sdk/js/native/tests/unit/runtime.rs new file mode 100644 index 0000000000..c2dc1518a7 --- /dev/null +++ b/sdk/js/native/tests/unit/runtime.rs @@ -0,0 +1,95 @@ +//! Which runtime a deployment selects, and what a local Sandbox record may +//! name. Included by runtime.rs. +use super::{image, local, LOCAL}; +use layerfs_bridge::control::SdkValue; + +fn deploy(fields: &[(&str, &str)]) -> SdkValue { + SdkValue::Map( + (fields.iter()) + .map(|(k, v)| ((*k).to_owned(), SdkValue::text(*v))) + .collect(), + ) +} + +#[test] +fn docker_stays_the_default_and_only_local_selects_local() { + let image_digest = format!("sha256:{}", "a".repeat(64)); + // unset and `docker` are Docker, placed by the deployment's image as before + for fields in [vec![], vec![("sandbox", "docker")]] { + let mut fields = fields; + fields.push(("image", image_digest.as_str())); + let deploy = deploy(&fields); + assert!(!local(&deploy).unwrap()); + let args = SdkValue::map([("deploy", deploy)]); + assert_eq!( + image(&args).unwrap().as_deref(), + Some(image_digest.as_str()) + ); + } + // no deployment: no image filter, as before + assert_eq!( + image(&SdkValue::map([("project", SdkValue::text("p"))])).unwrap(), + None + ); + // local ignores any image: its Sandboxes are placed as `local` + let deploy_local = deploy(&[("sandbox", "local"), ("image", image_digest.as_str())]); + assert!(local(&deploy_local).unwrap()); + let args = SdkValue::map([("deploy", deploy_local)]); + assert_eq!(image(&args).unwrap().as_deref(), Some(LOCAL)); + // anything else is refused, never taken as Docker or local + for typo in ["Local", "lokal", "LOCAL", " local", "docker "] { + let refusal = local(&deploy(&[("sandbox", typo)])).unwrap_err(); + assert_eq!(refusal.code, "InvalidInput"); + assert!( + refusal.message.contains("docker or local"), + "{}", + refusal.message + ); + } +} + +fn record(directory: &str, name: &str) -> SdkValue { + SdkValue::map([ + ("id", SdkValue::text("1")), + ("name", SdkValue::text(name)), + ("container", SdkValue::text("c".repeat(64))), + ("runtime", SdkValue::text("local")), + ("directory", SdkValue::text(directory)), + ("pid", SdkValue::Int(4242)), + ("start", SdkValue::text("123456")), + ("port", SdkValue::Int(40404)), + ]) +} + +#[test] +fn a_local_record_names_its_own_directory_only() { + match crate::discovery::recorded(&record("/lfs/local/layerfs-p-1", "layerfs-p-1")) { + Ok(crate::discovery::Recorded::Local { + directory, + process, + port, + .. + }) => { + assert_eq!(directory, std::path::Path::new("/lfs/local/layerfs-p-1")); + assert_eq!((process.pid, process.start, port), (4242, 123456, 40404)); + } + _ => panic!("a valid local record was refused"), + } + for (directory, name) in [ + ("relative/layerfs-p-1", "layerfs-p-1"), + ("/lfs/local/other", "layerfs-p-1"), + ("/lfs/../etc/layerfs-p-1", "layerfs-p-1"), + ("/", ""), + ] { + assert!( + crate::discovery::recorded(&record(directory, name)).is_err(), + "{directory} admitted for {name}" + ); + } + let mut bad_port = record("/lfs/local/layerfs-p-1", "layerfs-p-1"); + if let SdkValue::Map(fields) = &mut bad_port { + fields.retain(|(k, _)| k != "port"); + fields.push(("port".into(), SdkValue::Int(70000))); + } + assert!(crate::discovery::recorded(&bad_port).is_err()); +} From 41a6c201adf8fd21bca012739bcc87ca06130c5f Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:31:51 +0800 Subject: [PATCH 19/45] feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX=local, for a VM or CI box without Docker Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch). This commit: sdk/js/src/client.js (production). No tests changed or deleted. Production LOC: 109,638 -> 109,656 (delta +18) Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68ddc1. --- sdk/js/src/client.js | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/sdk/js/src/client.js b/sdk/js/src/client.js index 4aa6ab3a24..266dbaeebb 100644 --- a/sdk/js/src/client.js +++ b/sdk/js/src/client.js @@ -40,9 +40,18 @@ export function workspaceArgs(ws) { /** Deployment settings from the client options, else the environment * (LAYERFS_, else DSH_LAYERFS_). A Workspace's Sandbox needs - * every one but the container prefix and daemon host. */ + * every one but the container prefix and daemon host. With `sandbox` + * (LAYERFS_SANDBOX) `local`, a Sandbox is a daemon process on this machine + * instead of a container: it needs the daemon and `localDir` + * (LAYERFS_LOCAL_DIR) and no Docker setting. Unset or `docker` is Docker; + * any other value is refused. */ export function deployment(options = {}) { const env = (name) => process.env[`LAYERFS_${name}`] || process.env[`DSH_LAYERFS_${name}`] || undefined; + const sandbox = options.sandbox ?? env("SANDBOX"); + if (sandbox === "local") return localDeployment(options, env); + if (sandbox !== undefined && sandbox !== "docker") { + throw new LayerFSError("InvalidInput", `deploy.sandbox (LAYERFS_SANDBOX) is docker or local, not ${sandbox}`, null); + } const deploy = { docker: options.docker ?? env("DOCKER") ?? "/var/run/docker.sock", image: options.image ?? env("IMAGE"), @@ -60,6 +69,22 @@ export function deployment(options = {}) { return deploy; } +/** A local Sandbox's settings: the daemon, the directory its Sandboxes + * live in, and the optional name prefix. */ +function localDeployment(options, env) { + const deploy = { + sandbox: "local", + daemon: options.daemon ?? env("DAEMON"), + localDir: options.localDir ?? env("LOCAL_DIR"), + containerPrefix: options.containerPrefix ?? env("CONTAINER_PREFIX"), + }; + for (const [name, setting] of [["daemon", "DAEMON"], ["localDir", "LOCAL_DIR"]]) { + if (!deploy[name]) throw new LayerFSError("InvalidInput", `deploy.${name} is not configured (LAYERFS_${setting})`, null); + } + if (!deploy.containerPrefix) delete deploy.containerPrefix; + return deploy; +} + /** One client's deployment selection, including cross-process discovery * home and the Store this process opens. */ export function invokeClient(client, op, args) { From 6d9dd417babf33836a14788f09ef6adff272ed88 Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:31:53 +0800 Subject: [PATCH 20/45] feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX=local, for a VM or CI box without Docker Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch). This commit: sdk/js/src/index.js (production). No tests changed or deleted. Production LOC: 109,656 -> 109,658 (delta +2) Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68ddc1. --- sdk/js/src/index.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sdk/js/src/index.js b/sdk/js/src/index.js index f9098d5053..a1b66c645b 100644 --- a/sdk/js/src/index.js +++ b/sdk/js/src/index.js @@ -35,7 +35,10 @@ export class LayerFS { * settings read LAYERFS_HOME, _DOCKER, _IMAGE, _VOLUME and _DAEMON, each * else its DSH_LAYERFS_ twin; a Sandbox this client starts runs _IMAGE * on _VOLUME. LAYERFS_CONTAINER_PREFIX (default `layerfs-`) names every - * container it starts `-`. + * container it starts `-`. LAYERFS_SANDBOX=local + * starts each Sandbox as a daemon process on this machine instead of a + * container, in a directory under LAYERFS_LOCAL_DIR (no Docker setting is + * read; it isolates nothing, so use it only inside a VM or CI box). * `options.daemonHost` (else LAYERFS_DAEMON_HOST) is the host this process * dials for a daemon's port, published on the Docker host's 127.0.0.1 only; * set it when the client runs in a container (`host.docker.internal` on @@ -64,6 +67,8 @@ export class LayerFS { volume: env("VOLUME"), daemon: env("DAEMON"), ...(env("CONTAINER_PREFIX") ? { containerPrefix: env("CONTAINER_PREFIX") } : {}), + ...(env("SANDBOX") ? { sandbox: env("SANDBOX") } : {}), + ...(env("LOCAL_DIR") ? { localDir: env("LOCAL_DIR") } : {}), ...((options.daemonHost ?? env("DAEMON_HOST")) ? { daemonHost: options.daemonHost ?? env("DAEMON_HOST") } : {}), }, storePolicy: options.storePolicy, From 4a5caa7939904d23927b38be7ce7546ced563e8a Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:31:55 +0800 Subject: [PATCH 21/45] feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX=local, for a VM or CI box without Docker Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch). This commit: sdk/js/test/local_deployment.mjs (not production (test, manifest or docs)). No tests changed or deleted. Production LOC: 109,658 -> 109,658 (delta 0) Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68ddc1. --- sdk/js/test/local_deployment.mjs | 71 ++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 sdk/js/test/local_deployment.mjs diff --git a/sdk/js/test/local_deployment.mjs b/sdk/js/test/local_deployment.mjs new file mode 100644 index 0000000000..17b332fb72 --- /dev/null +++ b/sdk/js/test/local_deployment.mjs @@ -0,0 +1,71 @@ +// Which runtime a Workspace's Sandbox uses. No addon, Store or Docker: +// node sdk/js/test/local_deployment.mjs +// LAYERFS_SANDBOX unset or `docker` gives exactly the Docker deployment it +// always gave; `local` needs only the daemon and LAYERFS_LOCAL_DIR (no Docker +// setting); any other value is refused rather than taken as either. +import assert from "node:assert/strict"; +import { LayerFS } from "@layerfs/sdk"; +import { deployment } from "../src/client.js"; + +const clear = () => { + for (const name of Object.keys(process.env)) { + if (name.startsWith("LAYERFS_") || name.startsWith("DSH_LAYERFS_")) delete process.env[name]; + } +}; +const docker = { docker: "/run/docker.sock", image: `sha256:${"a".repeat(64)}`, volume: "lfs-v", daemon: "/opt/layerfs-daemon" }; +const setDocker = () => { + process.env.LAYERFS_DOCKER = docker.docker; + process.env.LAYERFS_IMAGE = docker.image; + process.env.LAYERFS_VOLUME = docker.volume; + process.env.LAYERFS_DAEMON = docker.daemon; +}; + +// unset: the Docker deployment, field for field, and the client options carry no new key +clear(); +setDocker(); +assert.deepEqual(deployment(), docker); +assert.deepEqual(new LayerFS("website").options.deploy, docker); +// `docker` is the same deployment +process.env.LAYERFS_SANDBOX = "docker"; +assert.deepEqual(deployment(), docker); +// the Docker settings are still required there +delete process.env.LAYERFS_IMAGE; +assert.throws(() => deployment(), (e) => e.code === "InvalidInput" && /LAYERFS_IMAGE/.test(e.message)); + +// local: daemon + localDir, no Docker setting read or required +clear(); +process.env.LAYERFS_SANDBOX = "local"; +process.env.LAYERFS_DAEMON = "/opt/layerfs-daemon"; +process.env.LAYERFS_LOCAL_DIR = "/store/local"; +assert.deepEqual(deployment(), { sandbox: "local", daemon: "/opt/layerfs-daemon", localDir: "/store/local" }); +const client = new LayerFS("website"); +assert.equal(client.options.deploy.sandbox, "local"); +assert.equal(client.options.deploy.localDir, "/store/local"); +assert.deepEqual(deployment(client.options.deploy), { sandbox: "local", daemon: "/opt/layerfs-daemon", localDir: "/store/local" }); +// an image (workspace.mount's option) does not change a local deployment +assert.deepEqual(deployment({ ...client.options.deploy, image: docker.image }), { sandbox: "local", daemon: "/opt/layerfs-daemon", localDir: "/store/local" }); +// the container prefix names local Sandbox directories too +process.env.LAYERFS_CONTAINER_PREFIX = "ci-"; +assert.equal(deployment().containerPrefix, "ci-"); +delete process.env.LAYERFS_CONTAINER_PREFIX; +// each required setting is named when missing +delete process.env.LAYERFS_LOCAL_DIR; +assert.throws(() => deployment(), (e) => e.code === "InvalidInput" && /LAYERFS_LOCAL_DIR/.test(e.message)); +process.env.LAYERFS_LOCAL_DIR = "/store/local"; +delete process.env.LAYERFS_DAEMON; +assert.throws(() => deployment(), (e) => e.code === "InvalidInput" && /LAYERFS_DAEMON/.test(e.message)); +// the DSH_ twins work as for every other setting +clear(); +process.env.DSH_LAYERFS_SANDBOX = "local"; +process.env.DSH_LAYERFS_DAEMON = "/opt/d"; +process.env.DSH_LAYERFS_LOCAL_DIR = "/l"; +assert.deepEqual(deployment(), { sandbox: "local", daemon: "/opt/d", localDir: "/l" }); + +// anything else is refused, never silently Docker +for (const typo of ["Local", "lokal", "LOCAL", "podman"]) { + clear(); + setDocker(); + process.env.LAYERFS_SANDBOX = typo; + assert.throws(() => deployment(), (e) => e.code === "InvalidInput" && e.message.includes(`docker or local, not ${typo}`)); +} +console.log("local_deployment: ok"); From 819bd2c9cd347496d61a5dba708c608356463d59 Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 12:31:57 +0800 Subject: [PATCH 22/45] feat(sandbox): a local process runtime for Sandboxes, LAYERFS_SANDBOX=local, for a VM or CI box without Docker Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole branch (or the single-commit patch). This commit: sdk/js/README.md (not production (test, manifest or docs)). No tests changed or deleted. Production LOC: 109,658 -> 109,658 (delta 0) Scope: combined core+sdk+dsh, tools/production_loc.py, base 5fb68ddc1. --- sdk/js/README.md | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/sdk/js/README.md b/sdk/js/README.md index a0c6f290c4..0daaec449e 100644 --- a/sdk/js/README.md +++ b/sdk/js/README.md @@ -68,10 +68,12 @@ Keep each agent's operations on a Workspace sequential. | `LAYERFS_STORE_PATH` | The installed Store file this client's Store calls open in this process; the client option `storePath` wins. It is `global/store.sqlite` in the project's volume, `/layerfs-store/global/store.sqlite` where that volume is mounted at `/layerfs-store`. Linux only, on a local filesystem the daemons share; elsewhere Store calls fail `Unsupported`. Unset, they fail `InvalidInput`. | | `LAYERFS_CONTAINER_PREFIX` | Optional. Names every container this client creates `-` (default prefix `layerfs-`); another LayerFS home on the same Docker engine sets its own, so names never collide. Docker's name rule applies (an ASCII letter or digit, then letters, digits, `_`, `.`, `-`, at most 128). A name another container holds refuses the create before any effect. | | `LAYERFS_DAEMON_HOST` | Optional; the client option `daemonHost` wins. The host this process dials for a daemon's port. Each daemon publishes its port on the Docker host's `127.0.0.1` only, and that stays so. A client inside a container does not share that loopback, so it names the host that reaches it, `host.docker.internal` on Docker Desktop. One resolution, its first address, one connect; nothing is tried again. Unset, the client dials `127.0.0.1`. | +| `LAYERFS_SANDBOX` | Optional; the client option `sandbox` wins. Unset or `docker`: a Sandbox is a Docker container, exactly as described here. `local`: a Sandbox is a daemon process on this machine, with no container and no Docker setting read (see [Local Sandboxes](#local-sandboxes-no-docker)). Any other value is refused as `InvalidInput` before anything is made. | +| `LAYERFS_LOCAL_DIR` | With `LAYERFS_SANDBOX=local`, required: an existing absolute directory, owned by the running user, not group/other writable and traversable by others (0755). Each local Sandbox gets its own directory under it. | | `LAYERFS_TRACE` | Optional per-call trace file (see [Call trace](#call-trace)); unset means no trace. | | `LAYERFS_TRACE_STEP` | Optional step label put on each trace line as `step`; read at every call. | | `LAYERFS_TRACE_START` | `1` adds a start line before each traced call; read at every call. | -| `DSH_LAYERFS_HOME`, `DSH_LAYERFS_DOCKER`, `DSH_LAYERFS_IMAGE`, `DSH_LAYERFS_VOLUME`, `DSH_LAYERFS_DAEMON`, `DSH_LAYERFS_STORE_PATH`, `DSH_LAYERFS_TRACE`, `DSH_LAYERFS_TRACE_STEP`, `DSH_LAYERFS_TRACE_START` | Each is used when its `LAYERFS_` name is unset or empty. A host that may only set `DSH_*` names in a command's environment (deepseek-harness) configures and traces the SDK through these. | +| `DSH_LAYERFS_HOME`, `DSH_LAYERFS_DOCKER`, `DSH_LAYERFS_IMAGE`, `DSH_LAYERFS_VOLUME`, `DSH_LAYERFS_DAEMON`, `DSH_LAYERFS_STORE_PATH`, `DSH_LAYERFS_SANDBOX`, `DSH_LAYERFS_LOCAL_DIR`, `DSH_LAYERFS_TRACE`, `DSH_LAYERFS_TRACE_STEP`, `DSH_LAYERFS_TRACE_START` | Each is used when its `LAYERFS_` name is unset or empty. A host that may only set `DSH_*` names in a command's environment (deepseek-harness) configures and traces the SDK through these. | A Sandbox is a container running one daemon on the project's Store. The project has numbered Sandboxes, `1`, `2`, …, each on one image: @@ -230,6 +232,42 @@ keeps its markers in that Workspace; inspect them through ordinary bounded `exec` commands and Commit each command. An omitted diagnostic is not a clean merge, and a failed/unknown operation must be inspected before a new operation. +## Local Sandboxes (no Docker) + +`LAYERFS_SANDBOX=local` runs each Sandbox's daemon as a process of this +machine instead of in a container, for a Linux VM or CI job that has no Docker +and is itself the isolation boundary (a microVM, for instance). **It isolates +nothing**: the daemon runs as root in this machine's namespaces, and commands +share its processes, network and filesystem; they only run as their own uid +501 and gid 20, with no supplementary groups. Use it only where everything on +the machine belongs to the same trust domain. Docker stays the default. + +It needs root (the daemon mounts FUSE and commands run as another identity; +otherwise `workspace.mount` is refused `Unsupported`), `/dev/fuse` (the +daemon narrows it to 0600, as it does in a container), the daemon executable +(`LAYERFS_DAEMON`), `LAYERFS_LOCAL_DIR` and the client's `storePath`, which +the daemon opens directly: the Store calls and the daemons use the same file, +on ext4, xfs or btrfs. `LAYERFS_DOCKER`, `_IMAGE`, `_VOLUME` and +`_DAEMON_HOST` are not read. Everything else above holds: numbered +Sandboxes of up to 16 Workspaces, placement, idle close after 2 minutes, +reclaim, records under `LAYERFS_HOME//sandboxes//`, background +processes, start intents and their refusals. + +Sandbox `n` lives in `LAYERFS_LOCAL_DIR/-/`: +`config/daemon.setup` (its daemon setup, 0600 in a 0700 directory), +`overlay/` (0700), `workspaces/` (the Workspace mounts, so a Workspace's +`path` is under this directory), `tmp/` (1777, the commands' `HOME` and the +background processes' logs) and `daemon.log` (the daemon's output, what +`docker logs` shows for a container; a failed start's report carries its last +20 lines). The daemon listens on 127.0.0.1 only. Its records name the daemon +by process id and start time, so a reused id is never taken for it. A mount +result's `image` is `local`; local and Docker Sandboxes of one project are +never mixed. A daemon that ends (idle close, crash, kill) is the stopped +observation: Workspace calls to it are refused, and the next mount or +`operator.sandbox.list()` reclaims it, detaching any mount the dead daemon +left before deleting its directory. Nothing outside that directory is +removed; the Store is never touched. + ## Internal operator ```js From 13a791abff5f6cbeb8560cddf2515a2a2d515a84 Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:46:07 +0800 Subject: [PATCH 23/45] fix(sandbox): address the first review of the local runtime (#402) Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch). This commit: core/crates/layerfs-sandbox/src/backend/local/process.rs (production). No tests changed or deleted except additions. Production LOC: 109,658 -> 109,696 (delta +38) Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e. --- .../src/backend/local/process.rs | 106 +++++++++++++----- 1 file changed, 80 insertions(+), 26 deletions(-) diff --git a/core/crates/layerfs-sandbox/src/backend/local/process.rs b/core/crates/layerfs-sandbox/src/backend/local/process.rs index 67eb36d4c1..d56fee6882 100644 --- a/core/crates/layerfs-sandbox/src/backend/local/process.rs +++ b/core/crates/layerfs-sandbox/src/backend/local/process.rs @@ -1,60 +1,114 @@ -//! A local process named by its id and kernel start time, so a reused id is +//! A local process named by its id, kernel start time and boot, so a reused +//! id, or the same id and start time after a reboot or snapshot restore, is //! never taken for the process that was started. use nix::{sys::signal::Signal, unistd::Pid}; -use std::io; +use std::{io, sync::OnceLock}; -/// One started process: its id and its start time in clock ticks since boot -/// (`/proc//stat` field 22). Both must match for it to count as alive. +/// One started process: its id, its start time in clock ticks since boot +/// (`/proc//stat` field 22) and the boot it started in +/// (`/proc/sys/kernel/random/boot_id`). All must match for it to count as +/// alive. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct LocalProcess { pub pid: u32, pub start: u64, + /// The boot id as a number. `None` only for a record written before boot + /// ids were kept: it is matched by id and start time alone, as it was + /// then, and never signalled. + pub boot: Option, } impl LocalProcess { - /// The process running now under `pid`, read once from `/proc`. + /// The process running now under `pid`, read once from `/proc`, in this + /// boot. pub fn observe(pid: u32) -> io::Result { - let (_, start) = stat(pid)?; - Ok(Self { pid, start }) + let stat = stat(pid)?; + Ok(Self { + pid, + start: stat.start, + boot: Some(boot()?), + }) } /// True while this exact process exists and has not exited: a zombie /// (or a dead entry) is not alive, and another process under the same id - /// has another start time. + /// has another start time or another boot. pub fn alive(self) -> bool { + let this_boot = self + .boot + .is_none_or(|recorded| boot().ok() == Some(recorded)); match stat(self.pid) { - Ok((state, start)) => start == self.start && !matches!(state, b'Z' | b'X' | b'x'), + Ok(stat) => this_boot && stat.start == self.start && !stat.ended(), Err(_) => false, } } /// Sends `signal` once, only after this exact process was observed alive. pub(super) fn signal(self, signal: Signal) -> io::Result<()> { + if self.boot.is_none() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "a local process recorded without its boot is never signalled", + )); + } if !self.alive() { return Ok(()); } - let pid = i32::try_from(self.pid) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "process id width"))?; - match nix::sys::signal::kill(Pid::from_raw(pid), signal) { + match nix::sys::signal::kill(pid(self.pid)?, signal) { Ok(()) | Err(nix::errno::Errno::ESRCH) => Ok(()), Err(errno) => Err(errno.into()), } } } -/// State and start time of `pid`. The command name may hold spaces and -/// parentheses, so fields are counted after its last `)`. -fn stat(pid: u32) -> io::Result<(u8, u64)> { +/// This boot's id (`/proc/sys/kernel/random/boot_id`, a UUID) as a number, +/// read once per process. +pub(super) fn boot() -> io::Result { + static BOOT: OnceLock = OnceLock::new(); + if let Some(boot) = BOOT.get() { + return Ok(*boot); + } + let text = std::fs::read_to_string("/proc/sys/kernel/random/boot_id")?; + let hex: String = text.trim().chars().filter(|c| *c != '-').collect(); + let boot = (hex.len() == 32) + .then(|| u128::from_str_radix(&hex, 16).ok()) + .flatten() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "boot id"))?; + Ok(*BOOT.get_or_init(|| boot)) +} + +/// What `/proc//stat` says of one process. +#[derive(Clone, Copy, Debug)] +pub(super) struct Stat { + pub(super) state: u8, + pub(super) group: u32, + pub(super) start: u64, +} + +impl Stat { + /// A zombie or a dead entry: it runs nothing any more. + pub(super) fn ended(self) -> bool { + matches!(self.state, b'Z' | b'X' | b'x') + } +} + +/// State, process group and start time of `pid`. The command name may hold +/// spaces and parentheses, so fields are counted after its last `)`. +pub(super) fn stat(pid: u32) -> io::Result { let text = std::fs::read_to_string(format!("/proc/{pid}/stat"))?; let invalid = || io::Error::new(io::ErrorKind::InvalidData, "process stat fields"); let rest = text.rsplit_once(')').ok_or_else(invalid)?.1; - let mut fields = rest.split_ascii_whitespace(); - let state = fields - .next() - .and_then(|s| s.bytes().next()) - .ok_or_else(invalid)?; - // field 3 was the state; field 22 is 19 fields further on - let start = fields - .nth(18) - .and_then(|s| s.parse::().ok()) - .ok_or_else(invalid)?; - Ok((state, start)) + // field 3 (the state) is the first after the name, so field n is at n - 3 + let fields: Vec<&str> = rest.split_ascii_whitespace().collect(); + let field = |n: usize| fields.get(n - 3).copied().ok_or_else(invalid); + Ok(Stat { + state: field(3)?.bytes().next().ok_or_else(invalid)?, + group: field(5)?.parse().map_err(|_| invalid())?, + start: field(22)?.parse().map_err(|_| invalid())?, + }) +} + +/// A process id as the kernel's signed type. +pub(super) fn pid(id: u32) -> io::Result { + i32::try_from(id) + .map(Pid::from_raw) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "process id width")) } From 13361ba5abc990f21d5a7e3d169ca05244f27f2c Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:46:10 +0800 Subject: [PATCH 24/45] fix(sandbox): address the first review of the local runtime (#402) Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch). This commit: core/crates/layerfs-sandbox/src/backend/local/groups.rs (production). No tests changed or deleted except additions. Production LOC: 109,696 -> 109,818 (delta +122) Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e. --- .../src/backend/local/groups.rs | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 core/crates/layerfs-sandbox/src/backend/local/groups.rs diff --git a/core/crates/layerfs-sandbox/src/backend/local/groups.rs b/core/crates/layerfs-sandbox/src/backend/local/groups.rs new file mode 100644 index 0000000000..e307f7e763 --- /dev/null +++ b/core/crates/layerfs-sandbox/src/backend/local/groups.rs @@ -0,0 +1,157 @@ +//! The process groups a local Sandbox's processes run in. A container's +//! processes end with it; a local Sandbox's would outlive it on this kernel, +//! so each command starts its own process group, named in the Sandbox's +//! `groups` directory while it may have members, and every named group (and +//! the daemon's own) is killed when the Sandbox stops or is deleted. +//! +//! A group is named by its leader: id, start time and boot. Its id stays the +//! group's while any member lives, so a group whose leader is gone is still +//! known by it; a leader found with another start time means the id was +//! reused and the group is not this Sandbox's. A process that leaves its +//! group (`setsid`) is not followed unless it is named here too +//! ([`super::Sandbox::track`]). +use super::process::{self, LocalProcess}; +use nix::{fcntl::OFlag, sys::signal::Signal}; +use std::{ + fs::{self, OpenOptions}, + io, + os::unix::fs::{DirBuilderExt, OpenOptionsExt}, + path::Path, + time::{Duration, Instant}, +}; + +/// The directory of a Sandbox that names its groups, one empty file each. +pub(super) const GROUPS: &str = "groups"; +/// Pause between two looks at a killed group. +const PAUSE: Duration = Duration::from_millis(20); + +/// Names the group `leader` leads (its id is the group's), once. +pub(super) fn name(directory: &Path, leader: LocalProcess) -> io::Result<()> { + let groups = directory.join(GROUPS); + // a Sandbox started before groups were named has no directory yet + match fs::DirBuilder::new().mode(0o700).create(&groups) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error), + } + OpenOptions::new() + .write(true) + .create(true) + .mode(0o600) + .custom_flags((OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC).bits()) + .open(groups.join(entry(leader)?)) + .map(drop) +} + +/// Forgets the group `leader` led once no member of it is left. Called after +/// the leader was reaped; a group that still has members stays named. +pub(super) fn forget_if_empty(directory: &Path, leader: LocalProcess) -> io::Result<()> { + match nix::sys::signal::killpg(process::pid(leader.pid)?, None) { + Err(nix::errno::Errno::ESRCH) => {} + _ => return Ok(()), + } + match fs::remove_file(directory.join(GROUPS).join(entry(leader)?)) { + Err(error) if error.kind() != io::ErrorKind::NotFound => Err(error), + _ => Ok(()), + } +} + +/// Kills every group the Sandbox at `directory` named, and the group of +/// `daemon` (its leader), once with KILL, as a container's end kills what +/// ran in it, then waits up to `wait` for their members to be gone. Groups +/// of another boot ended with it. +pub(super) fn end( + directory: &Path, + daemon: Option, + wait: Duration, +) -> io::Result<()> { + let mut leaders: Vec = daemon.into_iter().collect(); + match fs::read_dir(directory.join(GROUPS)) { + Ok(items) => { + for item in items { + let name = item?.file_name(); + let leader = name.to_str().and_then(parse).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "local Sandbox group name") + })?; + leaders.push(leader); + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + let this_boot = process::boot()?; + leaders.retain(|leader| leader.boot == Some(this_boot)); + let table = processes()?; + let mut killed = Vec::new(); + for leader in leaders { + if !members(&table, leader).is_empty() { + match nix::sys::signal::killpg(process::pid(leader.pid)?, Signal::SIGKILL) { + Ok(()) | Err(nix::errno::Errno::ESRCH) => killed.push(leader), + Err(errno) => return Err(errno.into()), + } + } + } + let deadline = Instant::now() + wait; + loop { + let table = processes()?; + if killed + .iter() + .all(|leader| members(&table, *leader).is_empty()) + { + return Ok(()); + } + if Instant::now() >= deadline { + return Err(io::Error::other( + "a process of the local Sandbox still runs after KILL", + )); + } + std::thread::sleep(PAUSE); + } +} + +/// The running members of `leader`'s group: none when the leader's id now +/// names another process (the id was reused, so the group is another's). +fn members(table: &[(u32, process::Stat)], leader: LocalProcess) -> Vec { + let reused = (table.iter()).any(|(pid, stat)| *pid == leader.pid && stat.start != leader.start); + if reused { + return Vec::new(); + } + (table.iter()) + .filter(|(_, stat)| stat.group == leader.pid && !stat.ended()) + .map(|(pid, _)| *pid) + .collect() +} + +/// Every process of this kernel now, with its stat. One that ends while +/// being read is left out. +fn processes() -> io::Result> { + let mut table = Vec::new(); + for item in fs::read_dir("/proc")? { + let Some(pid) = item?.file_name().to_str().and_then(|n| n.parse().ok()) else { + continue; + }; + if let Ok(stat) = process::stat(pid) { + table.push((pid, stat)); + } + } + Ok(table) +} + +/// A group's file name: `--`. +fn entry(leader: LocalProcess) -> io::Result { + let boot = leader + .boot + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "a group leader's boot"))?; + Ok(format!("{boot:032x}-{}-{}", leader.pid, leader.start)) +} + +fn parse(name: &str) -> Option { + let mut parts = name.split('-'); + let boot = parts.next().filter(|b| b.len() == 32)?; + let process = LocalProcess { + boot: Some(u128::from_str_radix(boot, 16).ok()?), + pid: parts.next()?.parse().ok()?, + start: parts.next()?.parse().ok()?, + }; + parts.next().is_none().then_some(process) +} From fed02493f2e5cec5f3fb2110f44a8daab3cdde01 Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:46:12 +0800 Subject: [PATCH 25/45] fix(sandbox): address the first review of the local runtime (#402) Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch). This commit: core/crates/layerfs-sandbox/src/backend/local/exec.rs (production). No tests changed or deleted except additions. Production LOC: 109,818 -> 109,835 (delta +17) Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e. --- .../layerfs-sandbox/src/backend/local/exec.rs | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/core/crates/layerfs-sandbox/src/backend/local/exec.rs b/core/crates/layerfs-sandbox/src/backend/local/exec.rs index 0673564715..ec099ddb89 100644 --- a/core/crates/layerfs-sandbox/src/backend/local/exec.rs +++ b/core/crates/layerfs-sandbox/src/backend/local/exec.rs @@ -1,8 +1,11 @@ //! One ordinary command to its end in a local Sandbox: the command identity, //! the Workspace directory and a search path; nothing of this process's -//! environment, no shell of its own and no implicit timeout. -use super::sandbox::Sandbox; +//! environment, no shell of its own and no implicit timeout. Each command +//! leads a process group of its own, named in the Sandbox until it is empty, +//! so what it leaves running ends with the Sandbox. +use super::{groups, process, process::LocalProcess, sandbox::Sandbox}; use crate::{backend::docker::ExecRequest, RuntimeError, WireFailure}; +use nix::sys::signal::{killpg, Signal}; use std::{ io::{self, Read, Write}, os::unix::process::{CommandExt, ExitStatusExt}, @@ -47,6 +50,7 @@ impl Sandbox { // std drops supplementary groups (setgroups 0) when it sets a uid as root .uid(request.identity.uid) .gid(request.identity.gid) + .process_group(0) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -61,6 +65,23 @@ impl Sandbox { failure })?; let pid = child.id(); + // named before anything is waited for: the leader is not reaped + // before the wait below, so it is observable even once it exited + let named = LocalProcess::observe(pid) + .and_then(|leader| groups::name(self.directory(), leader).map(|()| leader)); + let leader = match named { + Ok(leader) => leader, + Err(error) => { + // an unnamed group would outlive the Sandbox: it ends here, once + if let Ok(group) = process::pid(pid) { + let _ = killpg(group, Signal::SIGKILL); + } + let _ = child.wait(); + let mut failure = self.io(error); + failure.attempted = true; + return Err(failure); + } + }; let out = child.stdout.take(); let err = child.stderr.take(); let copied = std::thread::scope(|scope| { @@ -73,6 +94,8 @@ impl Sandbox { }); // the pipes are closed here either way; the exit is still waited for let status = child.wait(); + // a name left behind is harmless: its group is checked again when ended + let _ = groups::forget_if_empty(self.directory(), leader); let (out_bytes, err_bytes) = copied.map_err(|error| self.io(error))?; let status = status.map_err(|error| self.io(error))?; let code = status From 603b561f13b3b758b0c1d4320c40ac7fad9b9c38 Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:46:15 +0800 Subject: [PATCH 26/45] fix(sandbox): address the first review of the local runtime (#402) Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch). This commit: core/crates/layerfs-sandbox/src/backend/local/sandbox.rs (production). No tests changed or deleted except additions. Production LOC: 109,835 -> 109,868 (delta +33) Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e. --- .../src/backend/local/sandbox.rs | 90 +++++++++++++++---- 1 file changed, 73 insertions(+), 17 deletions(-) diff --git a/core/crates/layerfs-sandbox/src/backend/local/sandbox.rs b/core/crates/layerfs-sandbox/src/backend/local/sandbox.rs index 51c1bd26a7..2f85dc9a13 100644 --- a/core/crates/layerfs-sandbox/src/backend/local/sandbox.rs +++ b/core/crates/layerfs-sandbox/src/backend/local/sandbox.rs @@ -1,6 +1,9 @@ //! One local Sandbox: its directory, its daemon process and its exact -//! attempts. No Drop cleanup or replay, as for a container. -use super::{mounts, process::LocalProcess}; +//! attempts. No Drop cleanup or replay, as for a container; a Drop only +//! reaps a daemon this process started that has already exited. A stop +//! returns only once a daemon this process started is reaped, so it never +//! stays a zombie of this process. +use super::{groups, mounts, process::LocalProcess}; use crate::{ContainerId, RuntimeError, WireFailure}; use nix::sys::signal::Signal; use std::{ @@ -34,6 +37,8 @@ pub struct Sandbox { pub(super) child: Mutex>, pub(super) stop_attempted: bool, pub(super) stopped: bool, + /// Its process groups were killed and found empty (by its stop). + pub(super) ended: bool, pub(super) delete_attempted: bool, pub(super) deleted: bool, } @@ -54,6 +59,7 @@ impl Sandbox { child: Mutex::new(None), stop_attempted: false, stopped: false, + ended: false, delete_attempted: false, deleted: false, } @@ -135,8 +141,10 @@ impl Sandbox { Ok(SocketAddr::from((Ipv4Addr::LOCALHOST, self.port))) } /// Stops the daemon once: TERM, up to `seconds` for it to end, then - /// KILL. A daemon already gone is the same stopped state. Crash - /// teardown, not a graceful filesystem drain. + /// KILL (a daemon this process started has ended once reaped); then + /// KILL to every process group of its commands and its daemon, as a + /// container's end kills what ran in it. A daemon already gone is the + /// same stopped state. Crash teardown, not a graceful filesystem drain. pub fn stop(&mut self, seconds: u16) -> Result<(), Box> { if self.stop_attempted || self.delete_attempted { return Err(self.invalid("original local Stop admission")); @@ -148,7 +156,7 @@ impl Sandbox { Signal::SIGTERM, Duration::from_secs(u64::from(seconds)), )?; - if process.alive() { + if !self.gone(process) { self.end(process, Signal::SIGKILL, KILL_WAIT)?; } if process.alive() { @@ -156,11 +164,15 @@ impl Sandbox { } } self.stopped = true; + groups::end(&self.directory, self.process, KILL_WAIT).map_err(|error| self.io(error))?; + self.ended = true; Ok(()) } - /// Deletes its directory once, after a known stop: any mount its daemon - /// left is detached first, so nothing is removed through a mount. The - /// shared Store is outside it and never touched. + /// Deletes its directory once, after a known stop: any process left in + /// its groups is killed first (when its stop did not run here, as for a + /// daemon found stopped), and any mount its daemon left is detached, so + /// nothing writes into it or is removed through a mount. The shared + /// Store is outside it and never touched. pub fn delete(&mut self) -> Result<(), Box> { if self.delete_attempted || (self.process.is_some() && !self.stopped) { return Err(self.invalid("original local Delete admission; known stop required")); @@ -170,6 +182,11 @@ impl Sandbox { if self.process.is_some_and(LocalProcess::alive) { return Err(self.invalid("local daemon runs again; nothing was deleted")); } + if !self.ended { + groups::end(&self.directory, self.process, KILL_WAIT) + .map_err(|error| self.io(error))?; + self.ended = true; + } mounts::detach(&self.directory).map_err(|error| self.io(error))?; match fs::remove_dir_all(&self.directory) { Ok(()) => {} @@ -207,16 +224,41 @@ impl Sandbox { let tail = &bytes[start..]; Ok(tail[tail.len().saturating_sub(cap)..].to_vec()) } - /// Reaps the daemon this process started, if it exited. - pub(super) fn reap(&self) { - let mut child = self.child.lock().unwrap_or_else(|error| error.into_inner()); - if let Some(started) = child.as_mut() { - if matches!(started.try_wait(), Ok(Some(_))) { - *child = None; + /// Names the process group `pid` leads or is about to lead: a process + /// started at `start` by a command of this Sandbox in a session of its + /// own (the SDK's `setsid` background process), so it ends with the + /// Sandbox too. One already gone needs nothing. + pub fn track(&self, pid: u32, start: u64) -> Result<(), Box> { + match LocalProcess::observe(pid) { + Ok(process) if process.start == start && process.alive() => { + groups::name(&self.directory, process).map_err(|error| self.io(error)) } + _ => Ok(()), + } + } + /// Reaps the daemon this process started, if it exited. True when no + /// daemon this process started is left unreaped. + pub(super) fn reap(&self) -> bool { + let mut child = self.child.lock().unwrap_or_else(|error| error.into_inner()); + let Some(started) = child.as_mut() else { + return true; + }; + let reaped = matches!(started.try_wait(), Ok(Some(_))); + if reaped { + *child = None; } + reaped } - /// Sends `signal` once and waits up to `wait` for the process to end. + /// The daemon has ended and, if this process started it, is reaped. Its + /// main thread can end (a zombie id, so not alive) while its other + /// threads still run and it cannot be reaped yet: not gone. + fn gone(&self, process: LocalProcess) -> bool { + self.reap() && !process.alive() + } + /// Sends `signal` once and waits up to `wait` for the process to be gone. + /// A KILL also goes to the daemon this process started and has not + /// reaped: its id cannot be reused until reaped, so it is still the one + /// started, though its main thread may have ended already. fn end( &self, process: LocalProcess, @@ -224,10 +266,15 @@ impl Sandbox { wait: Duration, ) -> Result<(), Box> { process.signal(signal).map_err(|error| self.io(error))?; + if signal == Signal::SIGKILL { + let mut child = self.child.lock().unwrap_or_else(|error| error.into_inner()); + if let Some(started) = child.as_mut() { + let _ = started.kill(); + } + } let deadline = Instant::now() + wait; loop { - self.reap(); - if !process.alive() || Instant::now() >= deadline { + if self.gone(process) || Instant::now() >= deadline { return Ok(()); } std::thread::sleep(PAUSE); @@ -257,3 +304,12 @@ impl Sandbox { }) } } + +impl Drop for Sandbox { + /// A daemon this process started and that has exited is reaped, so a + /// Sandbox dropped on any path leaves no zombie of it. A running daemon + /// is left alone: stopping it is [`Sandbox::stop`]'s, once. + fn drop(&mut self) { + self.reap(); + } +} From 1f2307e83d194a99e679dd745b575d3fb64035f4 Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:46:17 +0800 Subject: [PATCH 27/45] fix(sandbox): address the first review of the local runtime (#402) Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch). This commit: core/crates/layerfs-sandbox/src/backend/local/create.rs (production). No tests changed or deleted except additions. Production LOC: 109,868 -> 109,882 (delta +14) Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e. --- .../src/backend/local/create.rs | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/core/crates/layerfs-sandbox/src/backend/local/create.rs b/core/crates/layerfs-sandbox/src/backend/local/create.rs index eadff04d72..b352dadcdc 100644 --- a/core/crates/layerfs-sandbox/src/backend/local/create.rs +++ b/core/crates/layerfs-sandbox/src/backend/local/create.rs @@ -75,8 +75,10 @@ pub struct LocalCreateFailure { impl Local { /// Selects `root`: an existing absolute directory owned by this user, - /// writable by it alone and traversable by others (commands run as - /// another identity and must reach their Workspace below it). + /// writable by it alone and traversable by others, as is every directory + /// above it (commands enter their Workspace below it only after taking + /// another identity, so one closed directory on the way, such as a 0700 + /// `/root`, would fail every command at spawn). pub fn new(root: impl AsRef) -> Result { let root = root.as_ref(); if !root.is_absolute() @@ -96,6 +98,21 @@ impl Local { "local runtime root must be a directory of this user, not group/other writable, traversable by others (0755)", )); } + // every directory on the path as given and on the path it resolves to + let resolved = fs::canonicalize(root)?; + for directory in root.ancestors().chain(resolved.ancestors()) { + let metadata = fs::metadata(directory)?; + if !metadata.is_dir() || metadata.mode() & 0o001 == 0 { + return Err(RuntimeError::Io(io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "{} is not traversable by others (o+x); local Sandbox commands run as \ + another identity and must pass it to reach their Workspace", + directory.display() + ), + ))); + } + } Ok(Self { root: root.to_owned(), name: None, From 4a66f35cd502fdeff64925ecf9aad7e8b1e35d5c Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:46:19 +0800 Subject: [PATCH 28/45] fix(sandbox): address the first review of the local runtime (#402) Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch). This commit: core/crates/layerfs-sandbox/src/backend/local/mounts.rs (production). No tests changed or deleted except additions. Production LOC: 109,882 -> 109,870 (delta -12) Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e. --- .../layerfs-sandbox/src/backend/local/mounts.rs | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/core/crates/layerfs-sandbox/src/backend/local/mounts.rs b/core/crates/layerfs-sandbox/src/backend/local/mounts.rs index e7b84b8ded..290d94beaf 100644 --- a/core/crates/layerfs-sandbox/src/backend/local/mounts.rs +++ b/core/crates/layerfs-sandbox/src/backend/local/mounts.rs @@ -56,7 +56,6 @@ fn unescape(text: &str) -> String { /// Detaches every mount under `directory` once (lazy unmount: a process /// still inside keeps its view; new lookups no longer reach it), then checks /// that none is left. -#[cfg(target_os = "linux")] pub(super) fn detach(directory: &Path) -> io::Result<()> { use nix::mount::{umount2, MntFlags}; for point in under(directory)? { @@ -71,15 +70,3 @@ pub(super) fn detach(directory: &Path) -> io::Result<()> { Err(io::Error::other("a mount under the local Sandbox remains")) } } - -#[cfg(not(target_os = "linux"))] -pub(super) fn detach(directory: &Path) -> io::Result<()> { - if under(directory)?.is_empty() { - Ok(()) - } else { - Err(io::Error::new( - io::ErrorKind::Unsupported, - "detaching a local Sandbox mount needs Linux", - )) - } -} From cba6af04ab11edbdec1aea7e68147ac115240d70 Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:46:21 +0800 Subject: [PATCH 29/45] fix(sandbox): address the first review of the local runtime (#402) Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch). This commit: core/crates/layerfs-sandbox/src/backend/local/mod.rs (production). No tests changed or deleted except additions. Production LOC: 109,870 -> 109,871 (delta +1) Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e. --- core/crates/layerfs-sandbox/src/backend/local/mod.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/core/crates/layerfs-sandbox/src/backend/local/mod.rs b/core/crates/layerfs-sandbox/src/backend/local/mod.rs index 8de8a6f18c..870b54c0fd 100644 --- a/core/crates/layerfs-sandbox/src/backend/local/mod.rs +++ b/core/crates/layerfs-sandbox/src/backend/local/mod.rs @@ -4,10 +4,13 @@ //! It is for a machine that is itself the isolation boundary (a microVM, a //! CI job). It isolates nothing: the daemon and its commands share this //! kernel's processes, network, users and filesystem with everything else on -//! it. Commands still run as their own nonroot identity. One attempt per -//! operation, as in the Docker runtime; nothing is retried. +//! it. Commands still run as their own nonroot identity, and what they leave +//! running ends with the Sandbox. One attempt per operation, as in the Docker +//! runtime; nothing is retried. Linux only: it reads `/proc` and detaches +//! mounts. mod create; mod exec; +mod groups; mod mounts; mod process; mod sandbox; From f71df4a084a0fc0e647e04865ee402d8810f6962 Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:46:23 +0800 Subject: [PATCH 30/45] fix(sandbox): address the first review of the local runtime (#402) Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch). This commit: core/crates/layerfs-sandbox/src/backend/mod.rs (production). No tests changed or deleted except additions. Production LOC: 109,871 -> 109,872 (delta +1) Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e. --- core/crates/layerfs-sandbox/src/backend/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/core/crates/layerfs-sandbox/src/backend/mod.rs b/core/crates/layerfs-sandbox/src/backend/mod.rs index 7fe356b929..1512ff5338 100644 --- a/core/crates/layerfs-sandbox/src/backend/mod.rs +++ b/core/crates/layerfs-sandbox/src/backend/mod.rs @@ -1,3 +1,4 @@ //! Concrete ordinary runtime providers. pub mod docker; +#[cfg(target_os = "linux")] pub mod local; From 0466af2cbb2dfc74280ee0e2906c55e9994154c2 Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:46:25 +0800 Subject: [PATCH 31/45] fix(sandbox): address the first review of the local runtime (#402) Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch). This commit: core/crates/layerfs-sandbox/tests/local_runtime.rs (not production (test)). No tests changed or deleted except additions. Production LOC: 109,872 -> 109,872 (delta 0) Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e. --- .../layerfs-sandbox/tests/local_runtime.rs | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) diff --git a/core/crates/layerfs-sandbox/tests/local_runtime.rs b/core/crates/layerfs-sandbox/tests/local_runtime.rs index f90e682433..c948c7ff5e 100644 --- a/core/crates/layerfs-sandbox/tests/local_runtime.rs +++ b/core/crates/layerfs-sandbox/tests/local_runtime.rs @@ -2,6 +2,8 @@ //! Sandbox directory, observed by process id and start time, its commands run //! as their own identity, and its stop and delete each attempted once. A //! small shell script stands in for the daemon; nothing here needs root. +//! The runtime is Linux only, and so are these tests. +#![cfg(target_os = "linux")] use layerfs_sandbox::{ backend::{ docker::ExecRequest, @@ -411,3 +413,224 @@ fn a_zombie_is_not_alive() { assert!(!process.alive(), "an exited, unreaped child counted alive"); child.wait().unwrap(); } + +#[test] +fn every_directory_above_a_root_must_be_traversable_by_others() { + let (root, _) = fixture("exit 0"); + let base = root.parent().unwrap().to_owned(); + // a root inside a directory others cannot pass, as `/root` (0700) is + let closed = base.join("closed"); + fs::create_dir(&closed).unwrap(); + fs::create_dir(closed.join("root")).unwrap(); + fs::set_permissions(closed.join("root"), fs::Permissions::from_mode(0o755)).unwrap(); + fs::set_permissions(&closed, fs::Permissions::from_mode(0o700)).unwrap(); + let refused = |path: &Path| match Local::new(path) { + Err(RuntimeError::Io(error)) => { + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + error.to_string() + } + other => panic!("{} admitted: {other:?}", path.display()), + }; + let message = refused(&closed.join("root")); + assert!( + message.contains(&format!("{} is not traversable", closed.display())), + "{message}" + ); + // the same through a symlinked parent: the directory it resolves to counts + std::os::unix::fs::symlink(&closed, base.join("link")).unwrap(); + let message = refused(&base.join("link").join("root")); + assert!(message.contains("is not traversable"), "{message}"); + // traversable without being listable is enough + fs::set_permissions(&closed, fs::Permissions::from_mode(0o711)).unwrap(); + Local::new(closed.join("root")).unwrap(); + Local::new(base.join("link").join("root")).unwrap(); +} + +/// The process ids a command printed, one per line. +fn pids(out: &str) -> Vec { + out.lines() + .map(|line| line.trim().parse().unwrap()) + .collect() +} + +/// True once none of `pids` runs any more (a zombie has ended). +fn gone(pids: &[u32]) -> bool { + pids.iter().all( + |pid| match fs::read_to_string(format!("/proc/{pid}/stat")) { + Ok(stat) => matches!( + stat.rsplit_once(") ").map(|(_, rest)| &rest[..1]), + Some("Z" | "X") + ), + Err(_) => true, + }, + ) +} + +/// Leaves behind what an exec can: a `nohup` server writing into the +/// Sandbox's scratch directory, and a `setsid` one named with +/// [`Sandbox::track`] as the SDK names its background processes. +fn leave_running(sandbox: &Sandbox) -> Vec { + let home = format!("HOME={}", sandbox.tmp().display()); + let (code, out, err) = run( + sandbox, + &[ + "bash", + "-c", + "cd \"$HOME\"; nohup sh -c 'while :; do echo x >> \"$HOME/server.out\"; sleep 0.01; done' >/dev/null 2>&1 & echo $!", + ], + &[&home], + ); + assert_eq!(code, 0, "{err}"); + let mut left = pids(&out); + let (code, out, err) = run( + sandbox, + &[ + "bash", + "-c", + "setsid sleep 300 >/dev/null 2>&1 = 2, "{named}"); + let (code, _, _) = run(sandbox, &["true"], &[]); + assert_eq!(code, 0); + assert_eq!(groups(), named); + let deadline = Instant::now() + WAIT; + while !sandbox.tmp().join("server.out").exists() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert!(!gone(&left)); + left +} + +#[test] +fn what_commands_leave_running_ends_with_the_sandbox() { + // stopped and deleted by its owner + let (root, daemon) = fixture(LISTENS); + let mut sandbox = started(&root, &daemon, "layerfs-shop-8"); + let left = leave_running(&sandbox); + sandbox.stop(1).unwrap(); + assert!(gone(&left), "{left:?} outlived the stop"); + sandbox.delete().unwrap(); + assert!(!root.join("layerfs-shop-8").exists()); + // its daemon killed from outside, then found stopped from its record + // and deleted, as a reclaim does + let sandbox = started(&root, &daemon, "layerfs-shop-9"); + let left = leave_running(&sandbox); + let process = sandbox.process().unwrap(); + assert!(Command::new("kill") + .args(["-9", &process.pid.to_string()]) + .status() + .unwrap() + .success()); + let deadline = Instant::now() + WAIT; + while sandbox.endpoint().is_ok() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + assert!(!gone(&left), "a command ended with its daemon"); + let directory = sandbox.directory().to_owned(); + let mut stopped = Sandbox::stopped( + directory.clone(), + sandbox.identity(), + process, + 40404, + Vec::new(), + ); + stopped.delete().unwrap(); + assert!(gone(&left), "{left:?} outlived the delete"); + assert!(!directory.exists()); +} + +#[test] +fn a_process_of_another_boot_is_not_alive_and_one_without_a_boot_is_never_signalled() { + let (root, daemon) = fixture(LISTENS); + let sandbox = started(&root, &daemon, "layerfs-shop-10"); + let process = sandbox.process().unwrap(); + let boot = fs::read_to_string("/proc/sys/kernel/random/boot_id").unwrap(); + let boot = u128::from_str_radix(&boot.trim().replace('-', ""), 16).unwrap(); + assert_eq!(process.boot, Some(boot)); + // the same id and start time in another boot (a reboot or a restored + // snapshot) is another process + let other = LocalProcess { + boot: Some(boot ^ 1), + ..process + }; + assert!(!other.alive()); + assert!(Sandbox::reopen( + root.join("layerfs-shop-10"), + sandbox.identity(), + other, + 40404, + Vec::new() + ) + .unwrap_err() + .container_stopped()); + // a record from before boot ids is matched by id and start as then, and + // a stop through it signals nothing + let legacy = LocalProcess { + boot: None, + ..process + }; + assert!(legacy.alive()); + let mut reopened = Sandbox::reopen( + root.join("layerfs-shop-10"), + sandbox.identity(), + legacy, + 40404, + Vec::new(), + ) + .unwrap(); + assert!(reopened.stop(1).is_err()); + assert!(process.alive()); + let mut sandbox = sandbox; + sandbox.stop(1).unwrap(); + assert!(!process.alive()); + sandbox.delete().unwrap(); +} + +/// A daemon whose main thread ends while another thread still runs: its +/// id is then a zombie that cannot be reaped yet, so it is not alive, but +/// its exit has not happened. A stop ends and reaps it before returning, +/// so it never stays a zombie of this process (the stand-in needs python3). +#[test] +fn a_stop_reaps_a_daemon_whose_main_thread_ended_first() { + let python = Command::new("python3").args(["-c", ""]).status(); + if !python.is_ok_and(|status| status.success()) { + eprintln!("skipped: the stand-in daemon needs python3"); + return; + } + let (root, daemon) = fixture( + r#"echo "LAYERFS_DAEMON_LISTEN 127.0.0.1:40404" +exec python3 -c 'import ctypes, threading, time; threading.Thread(target=time.sleep, args=(60,)).start(); ctypes.CDLL(None).pthread_exit(None)'"#, + ); + let mut sandbox = started(&root, &daemon, "layerfs-shop-11"); + let process = sandbox.process().unwrap(); + let deadline = Instant::now() + WAIT; + while process.alive() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert!(!process.alive(), "its main thread did not end"); + let tasks = fs::read_dir(format!("/proc/{}/task", process.pid)) + .unwrap() + .count(); + assert!(tasks >= 2, "its other thread ended already ({tasks})"); + sandbox.stop(1).unwrap(); + assert!( + fs::metadata(format!("/proc/{}", process.pid)).is_err(), + "the stopped daemon is still a process of this kernel (unreaped)" + ); + sandbox.delete().unwrap(); + assert!(!root.join("layerfs-shop-11").exists()); +} From 9e0b6a022de683e28be6aa976d78a27da4460217 Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:46:27 +0800 Subject: [PATCH 32/45] fix(sandbox): address the first review of the local runtime (#402) Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch). This commit: sdk/js/native/src/runtime.rs (production). No tests changed or deleted except additions. Production LOC: 109,872 -> 109,906 (delta +34) Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e. --- sdk/js/native/src/runtime.rs | 69 +++++++++++++++++++++++++++++------- 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/sdk/js/native/src/runtime.rs b/sdk/js/native/src/runtime.rs index ef9737c76e..e3d460592a 100644 --- a/sdk/js/native/src/runtime.rs +++ b/sdk/js/native/src/runtime.rs @@ -2,21 +2,25 @@ //! `deploy.sandbox` `local` (LAYERFS_SANDBOX=local) a daemon process on this //! kernel, for a VM or CI box that is itself the isolation boundary. Both run //! the same daemon and answer the same calls; only how it is started, how -//! commands are launched and how it is observed differ. +//! commands are launched and how it is observed differ. The local runtime +//! is Linux only; elsewhere selecting it is refused as Unsupported. use crate::job::{failed, refused}; use crate::output::Spool; use layerfs_bridge::control::{SdkRefusal, SdkValue}; -use layerfs_sandbox::backend::{docker, local}; +use layerfs_sandbox::backend::docker; +#[cfg(target_os = "linux")] +use layerfs_sandbox::backend::local; use layerfs_sandbox::{CommandIdentity, ContainerId, WireFailure}; use std::net::SocketAddr; -/// The image a local Sandbox records: placement keeps local and Docker -/// Sandboxes of one project apart by it. +/// A local Sandbox record's `runtime`, and the image it records (a Docker +/// record has no `runtime`). pub(crate) const LOCAL: &str = "local"; /// One Sandbox's runtime owner. pub(crate) enum Runtime { Docker(docker::Sandbox), + #[cfg(target_os = "linux")] Local(local::Sandbox), } @@ -34,17 +38,38 @@ pub(crate) fn local(deploy: &SdkValue) -> Result { } } -/// The image a new Workspace of this deployment is placed by: `local` for -/// the local runtime, else `deploy.image`. -pub(crate) fn image(args: &SdkValue) -> Result, SdkRefusal> { +/// What a new Workspace of a deployment is placed by: its runtime and, for +/// Docker, its image (`deploy.image`). +#[derive(Debug)] +pub(crate) struct Placement { + local: bool, + image: Option, +} + +/// The call's placement; none without a deployment (no filter, as before). +pub(crate) fn placement(args: &SdkValue) -> Result, SdkRefusal> { let Some(deploy) = args.get("deploy") else { return Ok(None); }; - Ok(if local(deploy)? { - Some(LOCAL.to_owned()) - } else { - deploy.get_text("image").map(str::to_owned) - }) + let local = local(deploy)?; + Ok(Some(Placement { + local, + image: (!local) + .then(|| deploy.get_text("image").map(str::to_owned)) + .flatten(), + })) +} + +impl Placement { + /// Whether a Sandbox record may take this Workspace: the same runtime + /// (the record's `runtime`, which a Docker record does not have) and, for + /// Docker, the same image. The image string alone does not tell them + /// apart: `local` is also a valid Docker image name. + pub(crate) fn admits(&self, record: &SdkValue) -> bool { + let local = record.get_text("runtime") == Some(LOCAL); + local == self.local + && (local || self.image.is_none() || record.get_text("image") == self.image.as_deref()) + } } impl Runtime { @@ -52,6 +77,7 @@ impl Runtime { pub(crate) fn identity(&self) -> ContainerId { match self { Self::Docker(sandbox) => sandbox.identity(), + #[cfg(target_os = "linux")] Self::Local(sandbox) => sandbox.identity(), } } @@ -59,6 +85,7 @@ impl Runtime { pub(crate) fn image(&self) -> &str { match self { Self::Docker(sandbox) => &sandbox.request().image, + #[cfg(target_os = "linux")] Self::Local(_) => LOCAL, } } @@ -68,6 +95,7 @@ impl Runtime { pub(crate) fn endpoint(&self) -> Result> { match self { Self::Docker(sandbox) => sandbox.endpoint(), + #[cfg(target_os = "linux")] Self::Local(sandbox) => sandbox.endpoint(), } } @@ -77,6 +105,7 @@ impl Runtime { pub(crate) fn scratch(&self) -> String { match self { Self::Docker(_) => "/tmp".to_owned(), + #[cfg(target_os = "linux")] Self::Local(sandbox) => sandbox.tmp().display().to_string(), } } @@ -84,9 +113,24 @@ impl Runtime { pub(crate) fn delete(&mut self) -> Result<(), Box> { match self { Self::Docker(sandbox) => sandbox.delete(), + #[cfg(target_os = "linux")] Self::Local(sandbox) => sandbox.delete(), } } + /// Names a background process a command started in a session of its own + /// (`pid`, started at `start`), so it ends with a local Sandbox as it + /// ends with a container. + #[cfg_attr(not(target_os = "linux"), allow(unused_variables))] + pub(crate) fn track(&self, pid: u32, start: &str) -> Result<(), SdkRefusal> { + match self { + Self::Docker(_) => Ok(()), + #[cfg(target_os = "linux")] + Self::Local(sandbox) => { + let start = start.parse::().map_err(failed)?; + sandbox.track(pid, start).map_err(failed) + } + } + } /// One command to its end: exit code, stdout, stderr. `spool` sends /// every non-empty stream to its file instead of keeping it inline. pub(crate) fn run( @@ -117,6 +161,7 @@ impl Runtime { .known_root_exit() .ok_or_else(|| refused("Unknown", "the command's exit code is not known"))? } + #[cfg(target_os = "linux")] Self::Local(sandbox) => { let request = docker::ExecRequest { identity, From fd351807a37a51911ee6f4785b38ccf4d0723b7c Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:46:30 +0800 Subject: [PATCH 33/45] fix(sandbox): address the first review of the local runtime (#402) Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch). This commit: sdk/js/native/src/local.rs (production). No tests changed or deleted except additions. Production LOC: 109,906 -> 109,958 (delta +52) Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e. --- sdk/js/native/src/local.rs | 106 +++++++++++++++++++++++++++++-------- 1 file changed, 84 insertions(+), 22 deletions(-) diff --git a/sdk/js/native/src/local.rs b/sdk/js/native/src/local.rs index 4ca4201219..57acba623a 100644 --- a/sdk/js/native/src/local.rs +++ b/sdk/js/native/src/local.rs @@ -23,7 +23,7 @@ use layerfs_bridge::provision::StoreManifest; use layerfs_sandbox::backend::local::{Local, LocalCreateFailure, Sandbox}; use layerfs_sandbox::CommandIdentity; use std::net::{SocketAddr, TcpStream}; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::sync::Arc; /// Starts the project's next Sandbox as a local daemon. Called by @@ -93,12 +93,7 @@ fn start_claimed( (local, executable, store): (Local, PathBuf, String), ) -> Result, SdkRefusal> { let name = text(args, "project")?; - let prefix = args - .get("deploy") - .and_then(|d| d.get_text("containerPrefix")) - .filter(|prefix| !prefix.is_empty()) - .unwrap_or(crate::sandbox::PREFIX); - let sandbox_name = format!("{prefix}{name}-{id}"); + let sandbox_name = sandbox_name(args, id)?; let local = local .naming(&sandbox_name) .map_err(|_| refused("InvalidInput", format!("local Sandbox name {sandbox_name}")))?; @@ -167,7 +162,7 @@ fn start_claimed( peer: daemon.public, instance, }; - let state = Arc::new(SandboxState { + let state = SandboxState { id: id.to_owned(), project: name.to_owned(), name: sandbox_name, @@ -177,24 +172,91 @@ fn start_claimed( controls: crate::pool::Controls::new(control, dial), mounts: Default::default(), processes: Default::default(), - }); - crate::discovery::save_sandbox( - &state.directory, - &state, - args, - controller.private, - daemon.public, - instance, - )?; - crate::discovery::save_text( - &state.directory.join("container"), - &state.runtime.identity().to_string(), - )?; - std::fs::remove_file(directory.join("create.intent")).map_err(failed)?; + }; + let recorded = (|| { + crate::discovery::save_sandbox( + &state.directory, + &state, + args, + controller.private, + daemon.public, + instance, + )?; + crate::discovery::save_text( + &state.directory.join("container"), + &state.runtime.identity().to_string(), + )?; + std::fs::remove_file(directory.join("create.intent")).map_err(failed) + })(); + if let Err(refusal) = recorded { + // The daemon runs and is this start's own: it is stopped and its + // directory deleted like any failed start's, and the records written + // so far are removed, so its number is free again. + let SandboxState { runtime, .. } = state; + let Runtime::Local(sandbox) = runtime else { + unreachable!("a local start owns a local Sandbox") + }; + for record in ["sandbox.record", "container"] { + let _ = std::fs::remove_file(saved_directory.join(record)); + } + return Err(abandon( + directory, + id, + format!("local sandbox records: {}", refusal.message), + Some(sandbox), + )); + } + let state = Arc::new(state); lock(&SANDBOXES)?.insert(state.directory.clone(), state.clone()); Ok(state) } +/// A local Sandbox's directory name: `-`, the name its +/// container would have. +fn sandbox_name(args: &SdkValue, id: &str) -> Result { + let prefix = args + .get("deploy") + .and_then(|d| d.get_text("containerPrefix")) + .filter(|prefix| !prefix.is_empty()) + .unwrap_or(crate::sandbox::PREFIX); + Ok(format!("{prefix}{}-{id}", text(args, "project")?)) +} + +/// Where this deployment puts Sandbox `id`: +/// `/-`, computed from the +/// configuration alone, as a record's directory must be. +pub(crate) fn directory(args: &SdkValue, id: &str) -> Result { + let root = (args.get("deploy")) + .and_then(|deploy| deploy.get_text("localDir")) + .map(Path::new) + .ok_or_else(|| { + refused( + "InvalidInput", + format!( + "Sandbox {id} is a local Sandbox; its record is used only with \ + LAYERFS_LOCAL_DIR (deploy.localDir) configured" + ), + ) + })?; + let name = sandbox_name(args, id)?; + let plain = |path: &Path| { + !path + .components() + .any(|c| matches!(c, Component::ParentDir | Component::CurDir)) + }; + let single = matches!( + Path::new(&name).components().collect::>()[..], + [Component::Normal(_)] + ); + if !root.is_absolute() || !plain(root) || !single { + return Err(refused( + "InvalidInput", + format!("local Sandbox directory {}/{name}", root.display()), + )); + } + Ok(root.join(name)) +} + /// The first control connection to a new daemon: one connect, one /// authentication, one Hello that waits for the Store and must find the /// daemon ControlReady; its instance identifies the daemon from then on. From 0cf539a392fa25bb9a3279bf2d047478689af31d Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:46:32 +0800 Subject: [PATCH 34/45] fix(sandbox): address the first review of the local runtime (#402) Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch). This commit: sdk/js/native/src/discovery.rs (production). No tests changed or deleted except additions. Production LOC: 109,958 -> 109,995 (delta +37) Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e. --- sdk/js/native/src/discovery.rs | 75 ++++++++++++++++++++++++++-------- 1 file changed, 58 insertions(+), 17 deletions(-) diff --git a/sdk/js/native/src/discovery.rs b/sdk/js/native/src/discovery.rs index 7862715723..c2725160f0 100644 --- a/sdk/js/native/src/discovery.rs +++ b/sdk/js/native/src/discovery.rs @@ -10,9 +10,10 @@ use layerfs_bridge::{ provision::StoreManifest, }; use layerfs_persistence::{SealedStore, SqlitePersistenceProfile}; +#[cfg(target_os = "linux")] +use layerfs_sandbox::backend::local::{self, LocalProcess}; use layerfs_sandbox::{ backend::docker::{Docker, Sandbox, SandboxRequest}, - backend::local::{self, LocalProcess}, CommandIdentity, ContainerId, WireFailure, }; use std::{ @@ -72,6 +73,7 @@ pub(crate) fn directory(path: &Path) -> Result<()> { } /// Whether this process runs as root, which the local runtime needs: its /// daemon mounts FUSE and its commands run as another uid/gid. +#[cfg(target_os = "linux")] pub(crate) fn root() -> bool { unsafe { geteuid() == 0 } } @@ -374,10 +376,14 @@ pub(crate) fn save_sandbox( ("volume", SdkValue::text(&runtime.request().store_volume)), ("labels", labels(&runtime.request().labels)), ]), + #[cfg(target_os = "linux")] Runtime::Local(runtime) => { let process = runtime .process() .ok_or_else(|| refused("Failed", "local Sandbox has no daemon process"))?; + let boot = process + .boot + .ok_or_else(|| refused("Failed", "local Sandbox daemon has no boot id"))?; fields.extend([ ("runtime", SdkValue::text(crate::runtime::LOCAL)), ( @@ -386,6 +392,7 @@ pub(crate) fn save_sandbox( ), ("pid", SdkValue::Int(i64::from(process.pid))), ("start", SdkValue::text(process.start.to_string())), + ("boot", SdkValue::text(format!("{boot:032x}"))), ("port", SdkValue::Int(i64::from(runtime.port()))), ("image", SdkValue::text(crate::runtime::LOCAL)), ("labels", labels(runtime.labels())), @@ -470,6 +477,7 @@ fn runtime(value: &SdkValue) -> Result<(Docker, ContainerId, SandboxRequest)> { /// a local daemon process, its directory and port. pub(crate) enum Recorded { Docker(Docker, ContainerId, SandboxRequest), + #[cfg(target_os = "linux")] Local { directory: PathBuf, id: ContainerId, @@ -478,38 +486,61 @@ pub(crate) enum Recorded { labels: Vec<(String, String)>, }, } -/// Reads a Sandbox record's runtime. A local record's directory must be -/// absolute, plain and named for its Sandbox, so a damaged record never -/// points a delete elsewhere. -pub(crate) fn recorded(value: &SdkValue) -> Result { +/// Reads Sandbox `id`'s record's runtime. +pub(crate) fn recorded(args: &SdkValue, id: &str, value: &SdkValue) -> Result { if value.get_text("runtime") != Some(crate::runtime::LOCAL) { let (docker, container, request) = runtime(value)?; return Ok(Recorded::Docker(docker, container, request)); } + local_record(args, id, value) +} +/// A local record is used only where this deployment puts that Sandbox: +/// its directory must be exactly `/-` +/// and named so, so a damaged, edited or stale record (another +/// LAYERFS_LOCAL_DIR or prefix) never points a delete elsewhere. A record +/// without `boot` was written before boot ids were kept: its daemon is +/// matched by id and start time as then, and never signalled. +#[cfg(target_os = "linux")] +fn local_record(args: &SdkValue, id: &str, value: &SdkValue) -> Result { let directory = PathBuf::from(registry::text(value, "directory")?); - let name = registry::text(value, "name")?; - if name.is_empty() - || !directory.is_absolute() - || directory.file_name().and_then(|n| n.to_str()) != Some(name) - || directory.components().any(|c| { - matches!( - c, - std::path::Component::ParentDir | std::path::Component::CurDir - ) - }) + let expected = crate::local::directory(args, id)?; + if directory != expected + || expected.file_name().and_then(|n| n.to_str()) != Some(registry::text(value, "name")?) { - return Err(refused("InvalidInput", "local Sandbox record directory")); + return Err(refused( + "InvalidInput", + format!( + "Sandbox {id}'s local record names {} (as {}), but this deployment puts it at \ + {}: the record is damaged or from another LAYERFS_LOCAL_DIR or prefix, so it is \ + neither used nor deleted. Remove what it left by hand, then delete \ + LAYERFS_HOME/{}/sandboxes/{id}/sandbox.record", + directory.display(), + value.get_text("name").unwrap_or_default(), + expected.display(), + registry::text(args, "project")? + ), + )); } let number = |key: &str| { value .get_int(key) .ok_or_else(|| refused("InvalidInput", format!("local Sandbox record {key}"))) }; + let boot = match value.get_text("boot") { + None => None, + Some(hex) => Some( + (hex.len() == 32) + .then(|| u128::from_str_radix(hex, 16).ok()) + .flatten() + .ok_or_else(|| refused("InvalidInput", "local Sandbox record boot"))?, + ), + }; let process = LocalProcess { pid: u32::try_from(number("pid")?).map_err(failed)?, start: registry::text(value, "start")? .parse::() .map_err(failed)?, + boot, }; Ok(Recorded::Local { directory, @@ -519,6 +550,13 @@ pub(crate) fn recorded(value: &SdkValue) -> Result { labels: labels(value)?, }) } +#[cfg(not(target_os = "linux"))] +fn local_record(_: &SdkValue, id: &str, _: &SdkValue) -> Result { + Err(refused( + "Unsupported", + format!("Sandbox {id} is a local Sandbox (LAYERFS_SANDBOX=local), which needs Linux"), + )) +} impl Recorded { /// The runtime, observed running once (it starts nothing). pub(crate) fn reopen(self) -> std::result::Result> { @@ -526,6 +564,7 @@ impl Recorded { Self::Docker(docker, id, request) => { Sandbox::reopen(docker, id, request).map(Runtime::Docker) } + #[cfg(target_os = "linux")] Self::Local { directory, id, @@ -541,6 +580,7 @@ impl Recorded { Self::Docker(docker, id, request) => { Runtime::Docker(Sandbox::stopped(docker, id, request)) } + #[cfg(target_os = "linux")] Self::Local { directory, id, @@ -569,10 +609,11 @@ pub(crate) fn reopen(args: &SdkValue) -> Result> { } let project = registry::text(&value, "project")?; let container = registry::text(&value, "container")?; - let recorded = recorded(&value)?; + let recorded = recorded(args, id, &value)?; // a local daemon listens on this kernel's loopback; no other host reaches it let host = match recorded { Recorded::Docker(..) => crate::pool::daemon_host(args), + #[cfg(target_os = "linux")] Recorded::Local { .. } => None, }; let runtime = recorded.reopen().map_err(|failure| { From e88bd55aa9a1a05aa5ef7a4a254d83f68cc711e7 Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:46:35 +0800 Subject: [PATCH 35/45] fix(sandbox): address the first review of the local runtime (#402) Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch). This commit: sdk/js/native/src/exec.rs (production). No tests changed or deleted except additions. Production LOC: 109,995 -> 110,005 (delta +10) Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e. --- sdk/js/native/src/exec.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/sdk/js/native/src/exec.rs b/sdk/js/native/src/exec.rs index 1ee27b45dd..985d853975 100644 --- a/sdk/js/native/src/exec.rs +++ b/sdk/js/native/src/exec.rs @@ -110,6 +110,18 @@ pub(crate) fn exec(args: &SdkValue) -> Outcome { "background process exited before its identity was observed", ) })?; + // its own session leaves the command's process group; named, it still + // ends with a local Sandbox. Unnamed it would outlive it: it ends here, once. + if let Err(refusal) = state.runtime.track(pid, &start) { + let end = format!("kill -KILL -- -{pid} 2>/dev/null; true"); + let _ = run( + &state, + &directory, + vec!["bash".into(), "-c".into(), end], + false, + ); + return Err(refusal); + } let process = Process { path: path.to_owned(), command: command.to_owned(), From 0f156e3f6e09b4a2e31e33ba32b2b191d132617e Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:46:37 +0800 Subject: [PATCH 36/45] fix(sandbox): address the first review of the local runtime (#402) Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch). This commit: sdk/js/native/src/sandbox.rs (production). No tests changed or deleted except additions. Production LOC: 110,005 -> 110,015 (delta +10) Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e. --- sdk/js/native/src/sandbox.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/sdk/js/native/src/sandbox.rs b/sdk/js/native/src/sandbox.rs index 90d665e27f..d8d69c7996 100644 --- a/sdk/js/native/src/sandbox.rs +++ b/sdk/js/native/src/sandbox.rs @@ -114,7 +114,14 @@ pub(crate) fn start(args: &SdkValue, directory: &Path) -> Result return Err(failed(failure)), _ => {} } From 3fa366c68f869760b9cc6bc850478f9256dd3400 Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:46:39 +0800 Subject: [PATCH 37/45] fix(sandbox): address the first review of the local runtime (#402) Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch). This commit: sdk/js/native/src/workspace.rs (production). No tests changed or deleted except additions. Production LOC: 110,015 -> 110,018 (delta +3) Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e. --- sdk/js/native/src/workspace.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/sdk/js/native/src/workspace.rs b/sdk/js/native/src/workspace.rs index 696e7de15f..fb52f24210 100644 --- a/sdk/js/native/src/workspace.rs +++ b/sdk/js/native/src/workspace.rs @@ -1,7 +1,8 @@ //! Workspace calls (#373). `workspace.mount(branch)` places the new //! Workspace: under the project's guard it takes the lowest-numbered running -//! Sandbox of the call's image (`deploy.image`) with a free slot, reclaiming -//! any whose container is gone, and starts the next Sandbox, on that image, +//! Sandbox of the call's runtime and image (`deploy.image`) with a free +//! slot, reclaiming any whose container is gone, and starts the next +//! Sandbox, on that image, //! when every such one is full. Sandboxes of one project may run different //! images. A Workspace is //! `{ sandbox, path }`; status, Commit and unmount go to its Sandbox's @@ -35,9 +36,8 @@ fn recorded(directory: &Path) -> Result { /// lowest-numbered running one of the call's image with a free slot, else a /// new one. fn place(args: &SdkValue, project: &Path) -> Result, SdkRefusal> { - // `local` for the local runtime: local and Docker Sandboxes never share - let image = crate::runtime::image(args)?; - let image = image.as_deref(); + // by runtime, then image: local and Docker Sandboxes never share + let placement = crate::runtime::placement(args)?; for id in crate::discovery::sandbox_ids(args)? { let directory = crate::discovery::sandbox_dir(args, &id)?; let record = directory.join("sandbox.record"); @@ -45,8 +45,10 @@ fn place(args: &SdkValue, project: &Path) -> Result, SdkRefusa if !record.exists() || recorded(&directory)? >= WORKSPACES as usize { continue; } - if image.is_some() && crate::discovery::load(&record)?.get_text("image") != image { - continue; + if let Some(placement) = &placement { + if !placement.admits(&crate::discovery::load(&record)?) { + continue; + } } match sandbox_live(&naming(args, &id)) { Ok(state) => return Ok(state), @@ -124,7 +126,9 @@ pub(crate) fn list(args: &SdkValue) -> Outcome { let cached = lock(&crate::registry::SANDBOXES)?.get(&directory).cloned(); let observed = match cached { Some(state) => state.runtime.endpoint().map(drop), - None => crate::discovery::recorded(&value)?.reopen().map(drop), + None => crate::discovery::recorded(args, &id, &value)? + .reopen() + .map(drop), }; match observed { Ok(()) => items.push(item), From d53d4fb6916c83dbe5037b1e3c3918fc83b2da68 Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:46:41 +0800 Subject: [PATCH 38/45] fix(sandbox): address the first review of the local runtime (#402) Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch). This commit: sdk/js/native/src/lib.rs (production). No tests changed or deleted except additions. Production LOC: 110,018 -> 110,019 (delta +1) Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e. --- sdk/js/native/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/js/native/src/lib.rs b/sdk/js/native/src/lib.rs index 5f9d2570ec..94b70f5f88 100644 --- a/sdk/js/native/src/lib.rs +++ b/sdk/js/native/src/lib.rs @@ -5,6 +5,7 @@ mod discovery; mod dispatch; mod exec; mod job; +#[cfg(target_os = "linux")] mod local; mod output; mod pool; From d46306711e0913d52cbc60b1347af137f8b9158c Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:46:43 +0800 Subject: [PATCH 39/45] fix(sandbox): address the first review of the local runtime (#402) Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch). This commit: sdk/js/native/tests/unit/runtime.rs (not production (test)). No tests changed or deleted except additions. Production LOC: 110,019 -> 110,019 (delta 0) Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e. --- sdk/js/native/tests/unit/runtime.rs | 160 ++++++++++++++++++++++------ 1 file changed, 129 insertions(+), 31 deletions(-) diff --git a/sdk/js/native/tests/unit/runtime.rs b/sdk/js/native/tests/unit/runtime.rs index c2dc1518a7..7dc96571bc 100644 --- a/sdk/js/native/tests/unit/runtime.rs +++ b/sdk/js/native/tests/unit/runtime.rs @@ -1,6 +1,6 @@ //! Which runtime a deployment selects, and what a local Sandbox record may //! name. Included by runtime.rs. -use super::{image, local, LOCAL}; +use super::{local, placement}; use layerfs_bridge::control::SdkValue; fn deploy(fields: &[(&str, &str)]) -> SdkValue { @@ -14,28 +14,13 @@ fn deploy(fields: &[(&str, &str)]) -> SdkValue { #[test] fn docker_stays_the_default_and_only_local_selects_local() { let image_digest = format!("sha256:{}", "a".repeat(64)); - // unset and `docker` are Docker, placed by the deployment's image as before + // unset and `docker` are Docker for fields in [vec![], vec![("sandbox", "docker")]] { let mut fields = fields; fields.push(("image", image_digest.as_str())); - let deploy = deploy(&fields); - assert!(!local(&deploy).unwrap()); - let args = SdkValue::map([("deploy", deploy)]); - assert_eq!( - image(&args).unwrap().as_deref(), - Some(image_digest.as_str()) - ); + assert!(!local(&deploy(&fields)).unwrap()); } - // no deployment: no image filter, as before - assert_eq!( - image(&SdkValue::map([("project", SdkValue::text("p"))])).unwrap(), - None - ); - // local ignores any image: its Sandboxes are placed as `local` - let deploy_local = deploy(&[("sandbox", "local"), ("image", image_digest.as_str())]); - assert!(local(&deploy_local).unwrap()); - let args = SdkValue::map([("deploy", deploy_local)]); - assert_eq!(image(&args).unwrap().as_deref(), Some(LOCAL)); + assert!(local(&deploy(&[("sandbox", "local")])).unwrap()); // anything else is refused, never taken as Docker or local for typo in ["Local", "lokal", "LOCAL", " local", "docker "] { let refusal = local(&deploy(&[("sandbox", typo)])).unwrap_err(); @@ -48,6 +33,68 @@ fn docker_stays_the_default_and_only_local_selects_local() { } } +/// A Sandbox record's placement fields: `runtime` (absent on a Docker +/// record) and `image`. +fn placed(runtime: Option<&str>, image: &str) -> SdkValue { + let mut fields = vec![("image".to_owned(), SdkValue::text(image))]; + if let Some(runtime) = runtime { + fields.push(("runtime".to_owned(), SdkValue::text(runtime))); + } + SdkValue::Map(fields) +} + +#[test] +fn placement_keeps_runtimes_apart_even_for_an_image_named_local() { + let digest = format!("sha256:{}", "a".repeat(64)); + let local_record = placed(Some("local"), "local"); + let docker_record = placed(None, &digest); + // a Docker deployment whose image is `local`, a valid Docker image name + let docker_local = SdkValue::map([("deploy", deploy(&[("image", "local")]))]); + let docker_local = placement(&docker_local).unwrap().unwrap(); + assert!(!docker_local.admits(&local_record)); + assert!(docker_local.admits(&placed(None, "local"))); + // the same image is still the Docker filter, as before + let docker = SdkValue::map([("deploy", deploy(&[("image", digest.as_str())]))]); + let docker = placement(&docker).unwrap().unwrap(); + assert!(docker.admits(&docker_record)); + assert!(!docker.admits(&placed(None, &format!("sha256:{}", "b".repeat(64))))); + assert!(!docker.admits(&local_record)); + // a Docker deployment without an image still never takes a local Sandbox + let unset = SdkValue::map([("deploy", deploy(&[]))]); + let unset = placement(&unset).unwrap().unwrap(); + assert!(unset.admits(&docker_record)); + assert!(!unset.admits(&local_record)); + // a local deployment takes only local Sandboxes, whatever image it names + let local = SdkValue::map([( + "deploy", + deploy(&[("sandbox", "local"), ("image", "local")]), + )]); + let local = placement(&local).unwrap().unwrap(); + assert!(local.admits(&local_record)); + assert!(!local.admits(&placed(None, "local"))); + assert!(!local.admits(&docker_record)); + // no deployment: no filter, as before + assert!( + placement(&SdkValue::map([("project", SdkValue::text("p"))])) + .unwrap() + .is_none() + ); +} + +/// A call's fields for Sandbox 1 of project `p` deployed locally under `root`. +#[cfg(target_os = "linux")] +fn call(root: Option<&str>) -> SdkValue { + let mut fields = vec![("sandbox", "local")]; + if let Some(root) = root { + fields.push(("localDir", root)); + } + SdkValue::map([ + ("project", SdkValue::text("p")), + ("deploy", deploy(&fields)), + ]) +} + +#[cfg(target_os = "linux")] fn record(directory: &str, name: &str) -> SdkValue { SdkValue::map([ ("id", SdkValue::text("1")), @@ -57,39 +104,90 @@ fn record(directory: &str, name: &str) -> SdkValue { ("directory", SdkValue::text(directory)), ("pid", SdkValue::Int(4242)), ("start", SdkValue::text("123456")), + ("boot", SdkValue::text("0123456789abcdef0123456789abcdef")), ("port", SdkValue::Int(40404)), ]) } +#[cfg(target_os = "linux")] #[test] -fn a_local_record_names_its_own_directory_only() { - match crate::discovery::recorded(&record("/lfs/local/layerfs-p-1", "layerfs-p-1")) { - Ok(crate::discovery::Recorded::Local { +fn a_local_record_is_used_only_where_this_deployment_puts_its_sandbox() { + use crate::discovery::{recorded, Recorded}; + let args = call(Some("/lfs/local")); + match recorded(&args, "1", &record("/lfs/local/layerfs-p-1", "layerfs-p-1")) { + Ok(Recorded::Local { directory, process, port, .. }) => { assert_eq!(directory, std::path::Path::new("/lfs/local/layerfs-p-1")); - assert_eq!((process.pid, process.start, port), (4242, 123456, 40404)); + assert_eq!( + (process.pid, process.start, process.boot, port), + ( + 4242, + 123456, + Some(0x0123456789abcdef0123456789abcdef), + 40404 + ) + ); } _ => panic!("a valid local record was refused"), } for (directory, name) in [ + // a damaged or edited record naming any other directory by its own name + ("/srv/data", "data"), + ("/lfs/local/data", "data"), + // another LAYERFS_LOCAL_DIR, another Sandbox, another name + ("/old/local/layerfs-p-1", "layerfs-p-1"), + ("/lfs/local/layerfs-p-2", "layerfs-p-2"), + ("/lfs/local/layerfs-p-1", "layerfs-p-2"), + ("/lfs/local/layerfs-p-1/sub", "sub"), ("relative/layerfs-p-1", "layerfs-p-1"), - ("/lfs/local/other", "layerfs-p-1"), ("/lfs/../etc/layerfs-p-1", "layerfs-p-1"), ("/", ""), ] { + let refusal = match recorded(&args, "1", &record(directory, name)) { + Err(refusal) => refusal, + Ok(_) => panic!("{directory} admitted for {name}"), + }; + assert_eq!(refusal.code, "InvalidInput"); assert!( - crate::discovery::recorded(&record(directory, name)).is_err(), - "{directory} admitted for {name}" + refusal.message.contains("neither used nor deleted"), + "{}", + refusal.message ); } - let mut bad_port = record("/lfs/local/layerfs-p-1", "layerfs-p-1"); - if let SdkValue::Map(fields) = &mut bad_port { - fields.retain(|(k, _)| k != "port"); - fields.push(("port".into(), SdkValue::Int(70000))); + // the configured directory is needed to use a local record at all + for root in [None, Some("relative"), Some("/lfs/../local")] { + let refusal = match recorded( + &call(root), + "1", + &record("/lfs/local/layerfs-p-1", "layerfs-p-1"), + ) { + Err(refusal) => refusal, + Ok(_) => panic!("{root:?} admitted"), + }; + assert_eq!(refusal.code, "InvalidInput", "{}", refusal.message); + } + // a record from before boot ids is read without one; a bad one is refused + let mut legacy = record("/lfs/local/layerfs-p-1", "layerfs-p-1"); + if let SdkValue::Map(fields) = &mut legacy { + fields.retain(|(k, _)| k != "boot"); + } + match recorded(&args, "1", &legacy) { + Ok(Recorded::Local { process, .. }) => assert_eq!(process.boot, None), + _ => panic!("a record without a boot id was refused"), + } + for (key, value) in [ + ("boot", SdkValue::text("not-hex")), + ("port", SdkValue::Int(70000)), + ] { + let mut bad = record("/lfs/local/layerfs-p-1", "layerfs-p-1"); + if let SdkValue::Map(fields) = &mut bad { + fields.retain(|(k, _)| k != key); + fields.push((key.into(), value)); + } + assert!(recorded(&args, "1", &bad).is_err(), "{key}"); } - assert!(crate::discovery::recorded(&bad_port).is_err()); } From 8c22d1754f7b7f86fd5b8f8e6b4531338f819813 Mon Sep 17 00:00:00 2001 From: jiangkoumo <152110621+jiangkoumo@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:46:47 +0800 Subject: [PATCH 40/45] fix(sandbox): address the first review of the local runtime (#402) Part of one change pushed file by file through the GitHub contents API; the reviewable unit is the whole set (local commit 9334dbd22, one patch). This commit: sdk/js/native/tests/unit/reclaim.rs (not production (test)). No tests changed or deleted except additions. Production LOC: 110,019 -> 110,019 (delta 0) Scope: combined core+sdk+dsh, tools/production_loc.py, parent 85255766e. --- sdk/js/native/tests/unit/reclaim.rs | 83 +++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/sdk/js/native/tests/unit/reclaim.rs b/sdk/js/native/tests/unit/reclaim.rs index da68c815e1..122df8ec63 100644 --- a/sdk/js/native/tests/unit/reclaim.rs +++ b/sdk/js/native/tests/unit/reclaim.rs @@ -226,3 +226,86 @@ fn a_stopped_container_the_engine_will_not_delete_keeps_its_records() { assert!(dir(&home).join("sandbox.record").exists()); std::fs::remove_dir_all(home).unwrap(); } + +/// Sandbox 1 recorded as a local Sandbox in `directory` named `name`, its +/// daemon long gone (no process has id u32::MAX). +#[cfg(target_os = "linux")] +fn local_record(home: &Path, directory: &Path, name: &str) { + let bytes = |n: u8| SdkValue::Bytes(vec![n; 32]); + crate::discovery::save( + &dir(home).join("sandbox.record"), + SdkValue::map([ + ("id", SdkValue::text(ID)), + ("project", SdkValue::text("proj")), + ("name", SdkValue::text(name)), + ("container", SdkValue::text(CID)), + ("runtime", SdkValue::text("local")), + ("directory", SdkValue::text(directory.to_str().unwrap())), + ("pid", SdkValue::Int(i64::from(u32::MAX))), + ("start", SdkValue::text("1")), + ("port", SdkValue::Int(40404)), + ("image", SdkValue::text("local")), + ("secret", bytes(1)), + ("peer", bytes(2)), + ("instance", bytes(3)), + ]), + ) + .unwrap(); +} + +#[cfg(target_os = "linux")] +#[test] +fn a_local_record_outside_its_configured_directory_is_never_deleted() { + let home = home(); + let root = home.join("local"); + std::fs::create_dir(&root).unwrap(); + let local = || { + let mut args = args(&home); + if let SdkValue::Map(fields) = &mut args { + fields.push(( + "deploy".into(), + SdkValue::map([ + ("sandbox", SdkValue::text("local")), + ("localDir", SdkValue::text(root.to_str().unwrap())), + ]), + )); + } + args + }; + // an edited or damaged record naming a data directory by its own name + let data = home.join("data"); + std::fs::create_dir(&data).unwrap(); + std::fs::write(data.join("keep"), b"x").unwrap(); + local_record(&home, &data, "data"); + let refusal = reclaim(&local(), ID, CID, true).unwrap_err(); + assert_eq!(refusal.code, "InvalidInput"); + assert!( + refusal.message.contains("neither used nor deleted"), + "{}", + refusal.message + ); + assert!( + data.join("keep").exists(), + "a delete reached {}", + data.display() + ); + assert!(dir(&home).join("sandbox.record").exists()); + // the same from a record of another LAYERFS_LOCAL_DIR + let old = home.join("old"); + std::fs::create_dir_all(old.join("layerfs-proj-1")).unwrap(); + local_record(&home, &old.join("layerfs-proj-1"), "layerfs-proj-1"); + assert_eq!( + reclaim(&local(), ID, CID, true).unwrap_err().code, + "InvalidInput" + ); + assert!(old.join("layerfs-proj-1").exists()); + // where this deployment puts it, the stopped Sandbox is deleted and reclaimed + let own = root.join("layerfs-proj-1"); + std::fs::create_dir_all(own.join("tmp")).unwrap(); + local_record(&home, &own, "layerfs-proj-1"); + let reclaimed = reclaim(&local(), ID, CID, true).unwrap(); + assert_eq!(reclaimed.get_text("kind"), Some("Reclaimed")); + assert!(!own.exists()); + assert!(!dir(&home).exists(), "its number is free again"); + std::fs::remove_dir_all(home).unwrap(); +} From 766e67e648e32c74cbc173f367de998c3162008c Mon Sep 17 00:00:00 2001 From: liuyuchen <152110621+jiangkoumo@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:16:17 +0000 Subject: [PATCH 41/45] fix(sandbox): detach local mounts before stopping the daemon Sandbox::stop() ended the daemon first and left the Sandbox's mounts in place until Delete detached them. Detach them first, while the daemon can still answer, then stop the daemon as before. A failed detach is reported and does not block the crash teardown; Delete keeps its own checked detach before removing anything through a mount. On Linux FUSE this is ordering only: Stop now returns with the mounts already gone (measured 21.6 ms with a live holder and a dirty writer; before the change the mounts stayed until Delete), and reclaim is unchanged (0.09-0.10 s). MNT_DETACH does not flush dirty pages, so it adds no data guarantee; Stop stays the crash teardown it was. Same hunk as the local-backend part of the macOS native branch's NFS unmount-hang fix, so merging this branch there stays clean. --- core/crates/layerfs-sandbox/src/backend/local/sandbox.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/core/crates/layerfs-sandbox/src/backend/local/sandbox.rs b/core/crates/layerfs-sandbox/src/backend/local/sandbox.rs index 2f85dc9a13..b11bb16059 100644 --- a/core/crates/layerfs-sandbox/src/backend/local/sandbox.rs +++ b/core/crates/layerfs-sandbox/src/backend/local/sandbox.rs @@ -140,7 +140,8 @@ impl Sandbox { } Ok(SocketAddr::from((Ipv4Addr::LOCALHOST, self.port))) } - /// Stops the daemon once: TERM, up to `seconds` for it to end, then + /// Detaches its mounts while the daemon can still answer, then stops + /// the daemon once: TERM, up to `seconds` for it to end, then /// KILL (a daemon this process started has ended once reaped); then /// KILL to every process group of its commands and its daemon, as a /// container's end kills what ran in it. A daemon already gone is the @@ -150,6 +151,12 @@ impl Sandbox { return Err(self.invalid("original local Stop admission")); } self.stop_attempted = true; + // A live NFS server can finish client writeback during detach. A + // failed detach does not prevent crash teardown; Delete retains its + // existing checked detach before removing anything through a mount. + if let Err(error) = mounts::detach(&self.directory) { + eprintln!("local Stop detach {}: {error}", self.directory.display()); + } if let Some(process) = self.process { self.end( process, From 8b11778396cc860cf48dfccfbca19e4e74da4dd0 Mon Sep 17 00:00:00 2001 From: jiangkoumo Date: Tue, 27 Oct 2026 01:08:49 +0000 Subject: [PATCH 42/45] style: rustfmt --- engine/crates/layerfs-node/src/runtime.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/engine/crates/layerfs-node/src/runtime.rs b/engine/crates/layerfs-node/src/runtime.rs index 471d9f49ea..4f0ea7b82e 100644 --- a/engine/crates/layerfs-node/src/runtime.rs +++ b/engine/crates/layerfs-node/src/runtime.rs @@ -164,7 +164,3 @@ impl Runtime { Ok((code, stdout, stderr)) } } - -#[cfg(test)] -#[path = "../tests/unit/runtime.rs"] -mod runtime_tests; From eb21fbbe7ddd6f53bec3aed04b82803bbac8477c Mon Sep 17 00:00:00 2001 From: jiangkoumo Date: Tue, 27 Oct 2026 02:35:41 +0000 Subject: [PATCH 43/45] test(node): cover local Sandbox records through the built addon (#402) Upstream now keeps the Node binding's tests outside the crate source: they load the ordinary built addon and call its exported methods, never including private binding source (engine/AGENTS.md, engine/crates/layerfs-node/tests). The #402 unit tests that were included by runtime.rs and sandbox.rs (tests/unit/{runtime,reclaim}.rs before D8) are rewritten as engine/crates/layerfs-node/tests/native_local.rs, which writes a Sandbox record as an earlier call would and calls the public `runtime.sandbox.reclaim` with a local deployment: - a local record naming any other directory (a data directory, another LAYERFS_LOCAL_DIR, another Sandbox or name, a relative or `..` path, `/`) is refused InvalidInput, "neither used nor deleted", and nothing is removed; - a local record is used only with an absolute, plain deploy.localDir; - a stopped local Sandbox where this deployment puts it is deleted and its number freed; a record from before boot ids is read without one; a damaged boot or port is refused and removes nothing; - a Docker record is not read as a local Sandbox. The Docker reclaim cases of the old reclaim.rs are covered by upstream's native_discovery.rs. Selection of a misspelled deploy.sandbox is refused only by sandbox.create after the project's Store is installed, so it is not reachable without a Store and is left to the SDK test (local_deployment.mjs). Production LOC: 103762 -> 103762 (delta 0) (tools/production_loc.py --root ., core+engine+api; test-only commit) --- .../crates/layerfs-node/tests/native_local.rs | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 engine/crates/layerfs-node/tests/native_local.rs diff --git a/engine/crates/layerfs-node/tests/native_local.rs b/engine/crates/layerfs-node/tests/native_local.rs new file mode 100644 index 0000000000..031c0a46a0 --- /dev/null +++ b/engine/crates/layerfs-node/tests/native_local.rs @@ -0,0 +1,244 @@ +//! External tests of the built Node binding's local Sandbox records +//! (LAYERFS_SANDBOX=local). No private Rust source is included: every case +//! writes a Sandbox record as a previous call would have, then calls the +//! addon's public `runtime.sandbox.reclaim` and observes the reply and the +//! files it left. Neither Docker, FUSE, nor a Store is needed. +#![cfg(target_os = "linux")] +mod support; +use layerfs_bridge::control::{Answer, Reply, SdkValue}; +use std::{ + io::Write, + os::unix::fs::OpenOptionsExt, + path::{Path, PathBuf}, + time::Duration, +}; +use support::*; + +/// This boot's id as a record keeps it: 32 lowercase hex digits. +fn boot() -> String { + std::fs::read_to_string("/proc/sys/kernel/random/boot_id") + .unwrap() + .trim() + .replace('-', "") +} + +/// Sandbox 1 of `proj` recorded as a `runtime` (local or native) Sandbox +/// in `directory` named `name`, its daemon long gone (no process has id +/// u32::MAX). `edit` changes the fields before the record is written. +fn record( + home: &Home, + runtime: &str, + directory: &str, + name: &str, + edit: impl FnOnce(&mut Vec<(String, SdkValue)>), +) { + let bytes = |n: u8| SdkValue::Bytes(vec![n; 32]); + let mut fields: Vec<(String, SdkValue)> = vec![ + ("id".into(), SdkValue::text("1")), + ("project".into(), SdkValue::text("proj")), + ("name".into(), SdkValue::text(name)), + ("container".into(), SdkValue::text(CID)), + ("runtime".into(), SdkValue::text(runtime)), + ("directory".into(), SdkValue::text(directory)), + ("pid".into(), SdkValue::Int(i64::from(u32::MAX))), + ("start".into(), SdkValue::text("1")), + ("boot".into(), SdkValue::text(boot())), + ("port".into(), SdkValue::Int(40404)), + ("image".into(), SdkValue::text("local")), + ("secret".into(), bytes(1)), + ("peer".into(), bytes(2)), + ("instance".into(), bytes(3)), + ]; + edit(&mut fields); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(home.0.join("proj/sandboxes/1/sandbox.record")) + .unwrap(); + file.write_all( + &Answer { + id: 1, + reply: Reply::Sdk(SdkValue::Map(fields)), + } + .encode() + .unwrap(), + ) + .unwrap(); +} + +/// Replaces (or with `None` removes) one field of a record. +fn set(key: &'static str, value: Option) -> impl FnOnce(&mut Vec<(String, SdkValue)>) { + move |fields| { + fields.retain(|(k, _)| k != key); + if let Some(value) = value { + fields.push((key.into(), value)); + } + } +} + +/// `LAYERFS_LOCAL_DIR` of these tests, created like a deployment's. +fn root(home: &Home) -> PathBuf { + let root = home.0.join("local"); + std::fs::create_dir_all(&root).unwrap(); + root +} + +/// Where a `root` deployment puts Sandbox 1 of `proj`, created with a file. +fn own(root: &Path) -> PathBuf { + let own = root.join("layerfs-proj-1"); + std::fs::create_dir_all(own.join("tmp")).unwrap(); + std::fs::write(own.join("daemon.log"), b"log").unwrap(); + own +} + +/// The public reclaim of a `sandbox` (local or native) deployment, with +/// `localDir` when given, followed by `then` (JavaScript, `r` is the reply). +fn reclaim(home: &Home, sandbox: &str, local_dir: Option<&str>, then: &str) { + let deploy = match local_dir { + Some(dir) => format!("{{sandbox:{sandbox:?}, localDir:{dir:?}}}"), + None => format!("{{sandbox:{sandbox:?}}}"), + }; + home.node( + &format!( + "const r = await native.backendCall('runtime.sandbox.reclaim', {{...args, deploy:{deploy}}});\n{then}" + ), + None, + Duration::from_secs(10), + ); +} + +const NEITHER: &str = "assert.equal(r.refused?.code, 'InvalidInput', JSON.stringify(r)); + assert.match(r.refused.message, /neither used nor deleted/);"; +const RECLAIMED: &str = "assert.deepEqual(r, {value:{kind:'ok',reclaimed:['1']}});"; + +fn text(path: &Path) -> &str { + path.to_str().unwrap() +} + +#[test] +fn a_local_record_naming_another_directory_is_neither_used_nor_deleted() { + let home = Home::new(); + let root = root(&home); + // a data directory a damaged or edited record names by its own name + let data = home.0.join("data"); + std::fs::create_dir(&data).unwrap(); + std::fs::write(data.join("keep"), b"x").unwrap(); + let old = home.0.join("old/layerfs-proj-1"); + std::fs::create_dir_all(&old).unwrap(); + let r = text(&root); + for (directory, name) in [ + (text(&data).to_owned(), "data"), + (format!("{r}/data"), "data"), + // another LAYERFS_LOCAL_DIR, another Sandbox, another name + (text(&old).to_owned(), "layerfs-proj-1"), + (format!("{r}/layerfs-proj-2"), "layerfs-proj-2"), + (format!("{r}/layerfs-proj-1"), "layerfs-proj-2"), + (format!("{r}/layerfs-proj-1/sub"), "sub"), + ("relative/layerfs-proj-1".to_owned(), "layerfs-proj-1"), + (format!("{r}/../etc/layerfs-proj-1"), "layerfs-proj-1"), + ("/".to_owned(), ""), + ] { + record(&home, "local", &directory, name, |_| {}); + reclaim(&home, "local", Some(r), NEITHER); + assert!( + home.0.join("proj/sandboxes/1/sandbox.record").exists(), + "the record of {directory} was removed" + ); + } + assert!(data.join("keep").exists(), "a delete reached {}", data.display()); + assert!(old.exists(), "a delete reached {}", old.display()); +} + +#[test] +fn a_local_record_is_used_only_with_its_deployment_s_absolute_local_dir() { + let home = Home::new(); + let root = root(&home); + let own = own(&root); + record(&home, "local", text(&own), "layerfs-proj-1", |_| {}); + let refused = "assert.equal(r.refused?.code, 'InvalidInput', JSON.stringify(r));"; + reclaim(&home, "local", None, refused); + reclaim(&home, "local", Some("relative"), refused); + reclaim( + &home, + "local", + Some(&format!("{}/../local", text(&root))), + refused, + ); + assert!(own.join("daemon.log").exists()); + assert!(home.0.join("proj/sandboxes/1/sandbox.record").exists()); +} + +#[test] +fn a_stopped_local_sandbox_where_its_deployment_puts_it_is_deleted_and_reclaimed() { + let home = Home::new(); + let root = root(&home); + let own = own(&root); + record(&home, "local", text(&own), "layerfs-proj-1", |_| {}); + reclaim(&home, "local", Some(text(&root)), RECLAIMED); + assert!(!own.exists(), "the stopped Sandbox's directory is deleted"); + assert!( + !home.0.join("proj/sandboxes/1").exists(), + "its number is free again" + ); + assert!(root.exists(), "LAYERFS_LOCAL_DIR itself stays"); +} + +#[test] +fn a_record_from_before_boot_ids_is_read_without_one() { + let home = Home::new(); + let root = root(&home); + let own = own(&root); + record( + &home, + "local", + text(&own), + "layerfs-proj-1", + set("boot", None), + ); + reclaim(&home, "local", Some(text(&root)), RECLAIMED); + assert!(!home.0.join("proj/sandboxes/1").exists()); +} + +#[test] +fn a_damaged_boot_or_port_is_refused_and_removes_nothing() { + let home = Home::new(); + let root = root(&home); + let own = own(&root); + for (key, value, code) in [ + ("boot", SdkValue::text("not-hex"), "InvalidInput"), + ("port", SdkValue::Int(70000), "Failed"), + ] { + record( + &home, + "local", + text(&own), + "layerfs-proj-1", + set(key, Some(value)), + ); + reclaim( + &home, + "local", + Some(text(&root)), + &format!("assert.equal(r.refused?.code, {code:?}, JSON.stringify(r));"), + ); + assert!(own.join("daemon.log").exists(), "{key}"); + assert!(home.0.join("proj/sandboxes/1/sandbox.record").exists(), "{key}"); + } +} + +#[test] +fn a_docker_record_stays_docker_without_a_local_deployment() { + // the default record of Home is a Docker one: with no Engine at its + // socket it is neither reclaimed nor read as a local Sandbox + let home = Home::new(); + let root = root(&home); + reclaim( + &home, + "local", + Some(text(&root)), + "assert.equal(r.refused?.code, 'Failed', JSON.stringify(r));", + ); + assert!(home.0.join("proj/sandboxes/1/sandbox.record").exists()); +} From 8b17038f215aae68b391da7301fd5a892861e218 Mon Sep 17 00:00:00 2001 From: jiangkoumo Date: Tue, 27 Oct 2026 02:37:02 +0000 Subject: [PATCH 44/45] style: rustfmt Production LOC: 103762 -> 103762 (delta 0) (tools/production_loc.py --root ., core+engine+api; formatting of a test file only) --- engine/crates/layerfs-node/tests/native_local.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/engine/crates/layerfs-node/tests/native_local.rs b/engine/crates/layerfs-node/tests/native_local.rs index 031c0a46a0..b4ff932b1b 100644 --- a/engine/crates/layerfs-node/tests/native_local.rs +++ b/engine/crates/layerfs-node/tests/native_local.rs @@ -147,7 +147,11 @@ fn a_local_record_naming_another_directory_is_neither_used_nor_deleted() { "the record of {directory} was removed" ); } - assert!(data.join("keep").exists(), "a delete reached {}", data.display()); + assert!( + data.join("keep").exists(), + "a delete reached {}", + data.display() + ); assert!(old.exists(), "a delete reached {}", old.display()); } @@ -224,7 +228,10 @@ fn a_damaged_boot_or_port_is_refused_and_removes_nothing() { &format!("assert.equal(r.refused?.code, {code:?}, JSON.stringify(r));"), ); assert!(own.join("daemon.log").exists(), "{key}"); - assert!(home.0.join("proj/sandboxes/1/sandbox.record").exists(), "{key}"); + assert!( + home.0.join("proj/sandboxes/1/sandbox.record").exists(), + "{key}" + ); } } From bf50a5f7030a8915a2545583575c24108763ceda Mon Sep 17 00:00:00 2001 From: jiangkoumo Date: Tue, 27 Oct 2026 03:05:01 +0000 Subject: [PATCH 45/45] docs(node): list native_local in the binding test guide (#402) Production LOC: 103762 -> 103762 (delta 0) (tools/production_loc.py --root ., core+engine+api; documentation only) --- engine/crates/layerfs-node/tests/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/engine/crates/layerfs-node/tests/README.md b/engine/crates/layerfs-node/tests/README.md index 75af6d4e58..db6fd370eb 100644 --- a/engine/crates/layerfs-node/tests/README.md +++ b/engine/crates/layerfs-node/tests/README.md @@ -12,6 +12,7 @@ cargo +1.85.1 build --manifest-path Cargo.toml --locked -p layerfs-node cargo +1.85.1 test --manifest-path Cargo.toml --locked -p layerfs-node --tests --no-run timeout 30 cargo +1.85.1 test --manifest-path Cargo.toml --locked -p layerfs-node --test native_discovery timeout 90 cargo +1.85.1 test --manifest-path Cargo.toml --locked -p layerfs-node --test native_admission +timeout 60 cargo +1.85.1 test --manifest-path Cargo.toml --locked -p layerfs-node --test native_local ``` Use the worktree's registered Cargo resources for these commands. Node 20 or @@ -24,6 +25,12 @@ number replacement during inspection, and discovery-lock release while a forked child retains the descriptor. Its C fixture interposes `flock` only in the child Node process; the fixture's fork is bounded and released on test unwinding. +`native_local` covers local Sandbox records (`LAYERFS_SANDBOX=local`) through +`runtime.sandbox.reclaim`: a record naming any directory other than where its +deployment puts that Sandbox is refused and nothing is deleted, the configured +`localDir` must be absolute and plain, a stopped local Sandbox is deleted and its +number freed, and a damaged boot or port is refused. + `native_admission` covers initial connection admission and its `Busy` deadline. The deadline test pays the product's full 60-second readiness wait. The mock daemon has bounded socket waits and stops independently of a test assertion.