fix: run bun and npm installs under nsjail sandboxing (#11845)

* fix: pass --save-text-lockfile only to bun installs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: run bun and npm installs under nsjail sandboxing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: mount npmrc cafile paths given with ${VAR} or ~ in the install jail

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf: keep bun's cache hardlinks for jailed installs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: harden the bun install staging dir and npm-mode jail mounts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: reach the bun staging dir only through no-follow descriptors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: create bun staging files new and check outputs after the move

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: add DISABLE_BUN_INSTALL_SANDBOX to run bun installs unjailed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-10-17 09:59:33 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 7a8cb62fa1
commit 6646c86351
4 changed files with 694 additions and 10 deletions
@@ -639,6 +639,7 @@ pub fn parse_allowed_origins_setting(
pub const ENV_SETTINGS: &[&str] = &[
"DISABLE_NSJAIL",
"DISABLE_BUN_INSTALL_SANDBOX",
"MODE",
"NUM_WORKERS",
"METRICS_ADDR",
@@ -0,0 +1,118 @@
name: "bun install"
mode: ONCE
hostname: "bun"
log_level: ERROR
time_limit: {TIMEOUT}
# Bun/JavaScriptCore reserves large virtual ranges up front, and native builds
# size their pools to the host: an address-space cap breaks the install itself.
disable_rl: true
cwd: "{INSTALL_DIR}"
clone_newnet: false
clone_newuser: {CLONE_NEWUSER}
skip_setsid: true
keep_caps: false
keep_env: true
mount_proc: true
mount {
src: "/bin"
dst: "/bin"
is_bind: true
}
mount {
src: "/lib"
dst: "/lib"
is_bind: true
}
mount {
src: "/lib64"
dst: "/lib64"
is_bind: true
mandatory: false
}
mount {
src: "/usr"
dst: "/usr"
is_bind: true
}
mount {
src: "/etc"
dst: "/etc"
is_bind: true
}
# Container runtimes bind exactly these 3 files as separate submounts over
# /etc; nsjail's ro remount of /etc is non-recursive so they stay writable.
# Load-bearing -- do not remove as redundant with the /etc bind above.
mount {
src: "/etc/resolv.conf"
dst: "/etc/resolv.conf"
is_bind: true
mandatory: false
}
mount {
src: "/etc/hosts"
dst: "/etc/hosts"
is_bind: true
mandatory: false
}
mount {
src: "/etc/hostname"
dst: "/etc/hostname"
is_bind: true
mandatory: false
}
mount {
src: "/dev/null"
dst: "/dev/null"
is_bind: true
rw: true
}
mount {
src: "/dev/urandom"
dst: "/dev/urandom"
is_bind: true
}
mount {
src: "/dev/random"
dst: "/dev/random"
is_bind: true
}
{TMP_MOUNT_BLOCK}
# HOME keeps the worker's path, so the package manager finds the operator's
# ~/.npmrc / ~/.bunfig.toml, but is otherwise a private tmpfs.
{HOME_MOUNT_BLOCK}
# The install dir (package.json, lockfile, registry config; node_modules is
# written there) and, for bun, the shared bun cache, writable like the other
# install jails' shared caches. A bun install dir inside the cache gets no bind
# of its own: that would be a second mount, and bun can only hardlink from its
# cache within one.
{INSTALL_MOUNTS}
mount {
src: "{TRACING_PROXY_CA_CERT_PATH}"
dst: "{TRACING_PROXY_CA_CERT_PATH}"
is_bind: true
mandatory: false
}
{CA_MOUNT}
#{DEV}
+566 -10
View File
@@ -22,16 +22,17 @@ use crate::{
common::{
build_command_with_isolation, create_args_and_out_file, get_reserved_variables,
parse_npm_config, read_file, read_file_content, read_result, resolve_nsjail_timeout,
resolve_nsjail_tmp_mount_block, script_envars, start_child_process, write_file_binary,
MaybeLock, OccupancyMetrics, StreamNotifier, DEV_CONF_NSJAIL,
resolve_nsjail_tmp_mount_block, resolve_nsjail_tmpfs_size_bytes, script_envars,
start_child_process, write_file_binary, MaybeLock, OccupancyMetrics, StreamNotifier,
DEV_CONF_NSJAIL,
},
get_proxy_envs_for_lang, get_run_proxy_envs,
handle_child::handle_child,
is_sandboxing_enabled, nsjail_clone_newnet, read_ee_registry_with_workspace_override,
BUNFIG_INSTALL_SCOPES, BUN_BUNDLE_CACHE_DIR, BUN_CACHE_DIR, BUN_INSTALL_MIN_RELEASE_AGE,
BUN_NO_CACHE, BUN_PATH, DISABLE_NUSER, HOME_ENV, NODE_BIN_PATH, NODE_PATH, NPMRC,
NPM_CONFIG_REGISTRY, NPM_PATH, NSJAIL_AVAILABLE, NSJAIL_PATH, PATH_ENV, PROXY_ENVS,
TRACING_PROXY_CA_CERT_PATH, TZ_ENV,
BUN_NO_CACHE, BUN_PATH, DISABLE_BUN_INSTALL_SANDBOX, DISABLE_NUSER, HOME_ENV, NODE_BIN_PATH,
NODE_PATH, NPMRC, NPM_CONFIG_REGISTRY, NPM_PATH, NSJAIL_AVAILABLE, NSJAIL_PATH, PATH_ENV,
PROXY_ENVS, TRACING_PROXY_CA_CERT_PATH, TZ_ENV,
};
use windmill_common::{
cache,
@@ -75,6 +76,7 @@ pub const RELATIVE_BUN_LOADER: &str = include_str!("../loader.bun.windows.js");
pub const RELATIVE_BUN_BUILDER: &str = include_str!("../loader_builder.bun.js");
const NSJAIL_CONFIG_RUN_BUN_CONTENT: &str = include_str!("../nsjail/run.bun.config.proto");
const NSJAIL_CONFIG_INSTALL_BUN_CONTENT: &str = include_str!("../nsjail/install.bun.config.proto");
pub const BUN_LOCK_SPLIT: &str = "\n//bun.lock\n";
pub const BUN_LOCKB_SPLIT: &str = "\n//bun.lockb\n";
@@ -733,6 +735,358 @@ registry = {}
Ok(())
}
/// An nsjail bind mount of `path` onto itself, skipped when the host path is missing.
/// Empty for a path that is not absolute or that the proto string cannot carry verbatim.
fn optional_bind_mount(path: &str, rw: bool) -> String {
if !path.starts_with('/') || path.contains(['"', '\\', '\n', '\r']) {
return String::new();
}
format!(
"mount {{\n src: \"{path}\"\n dst: \"{path}\"\n is_bind: true\n rw: {rw}\n mandatory: false\n}}\n"
)
}
/// node-gyp's header cache under HOME. Kept across jailed installs like the package
/// caches: without it every native build downloads the headers again, which a worker
/// with no internet access cannot.
const NODE_GYP_CACHE: &str = ".cache/node-gyp";
/// The install jail's HOME: a private tmpfs at the worker's HOME path, with the
/// package manager's user config read-only and the caches kept there writable (npm's
/// `~/.npm` in npm mode, node-gyp's headers in both).
fn install_home_mount_block(home: &str, npm_mode: bool, tmpfs_size_bytes: &str) -> String {
let home = home.trim_end_matches('/');
let mut block = String::new();
// A HOME under /tmp is already private: the jail's /tmp is.
if home.starts_with('/')
&& home != "/tmp"
&& !home.starts_with("/tmp/")
&& !home.contains(['"', '\\', '\n', '\r'])
{
block.push_str(&format!(
"mount {{\n dst: \"{home}\"\n fstype: \"tmpfs\"\n rw: true\n options: \"size={tmpfs_size_bytes}\"\n}}\n"
));
}
block.push_str(&optional_bind_mount(&format!("{home}/.npmrc"), false));
block.push_str(&optional_bind_mount(&format!("{home}/.bunfig.toml"), false));
block.push_str(&optional_bind_mount(
&format!("{home}/{NODE_GYP_CACHE}"),
true,
));
if npm_mode {
block.push_str(&optional_bind_mount(&format!("{home}/.npm"), true));
}
block
}
/// An `.npmrc` value as npm reads it: `${VAR}` from the install env, and a leading `~/`
/// as HOME. Without it, a `cafile=~/…` or `${HOME}/…` is never mounted and the jail's
/// private HOME hides the certificate.
fn expand_npmrc_value(value: &str, home: &str, envs: &HashMap<String, String>) -> String {
let mut out = String::new();
let mut rest = value;
while let Some(start) = rest.find("${") {
let Some(len) = rest[start..].find('}') else {
break;
};
out.push_str(&rest[..start]);
let name = rest[start + 2..start + len].trim_end_matches('?');
out.push_str(envs.get(name).map(String::as_str).unwrap_or_default());
rest = &rest[start + len + 1..];
}
out.push_str(rest);
match out.strip_prefix("~/") {
Some(p) => format!("{}/{p}", home.trim_end_matches('/')),
None => out,
}
}
/// TLS files the install is pointed at (CA bundles, registry client certificates) live
/// outside the jail's mounts: bind them in, read-only, so private registries keep working.
fn install_ca_mounts(job_dir: &str, home: &str, envs: &HashMap<String, String>) -> String {
const CA_ENVS: [&str; 4] = [
"NODE_EXTRA_CA_CERTS",
"SSL_CERT_FILE",
"SSL_CERT_DIR",
"NPM_CONFIG_CAFILE",
];
// Also registry-scoped (`//host/:certfile=`), hence the match on the last segment.
const TLS_FILE_KEYS: [&str; 3] = ["cafile", "certfile", "keyfile"];
let from_env = crate::worker::WHITELIST_ENVS
.iter()
.filter(|(k, _)| CA_ENVS.iter().any(|c| c.eq_ignore_ascii_case(k)))
.map(|(_, v)| v.trim().to_string());
// `.npmrc` lines and bunfig's `[install] cafile = "…"` share the `key = value` shape.
let from_config = [
format!("{job_dir}/{BUN_NPMRC_FILE}"),
format!("{home}/.npmrc"),
format!("{job_dir}/{BUN_CONFIG_FILE}"),
format!("{home}/.bunfig.toml"),
]
.into_iter()
.filter_map(|f| fs::read_to_string(f).ok())
.flat_map(|content| {
content
.lines()
.filter_map(|l| {
let (k, v) = l.split_once('=')?;
let key = k.trim().rsplit(':').next()?;
TLS_FILE_KEYS
.iter()
.any(|t| t.eq_ignore_ascii_case(key))
.then(|| expand_npmrc_value(v.trim().trim_matches(['"', '\'']), home, envs))
})
.collect::<Vec<_>>()
});
from_env
.chain(from_config)
.filter(|p| !p.is_empty())
.unique()
.map(|p| optional_bind_mount(&p, false))
.collect()
}
/// The files `bun install` reads from, and writes to, the directory it installs in.
const BUN_INSTALL_INPUTS: [&str; 3] = ["package.json", "bun.lock", "bun.lockb"];
const BUN_INSTALL_OUTPUTS: [&str; 3] = ["node_modules", "bun.lock", "bun.lockb"];
/// What the worker reads or mounts from the job dir after an install that ran in it.
const BUN_JOB_DIR_OUTPUTS: [&str; 5] = [
"node_modules",
"package.json",
"bun.lock",
"bun.lockb",
"package-lock.json",
];
/// Whether an install left `name` at `path`, refusing anything but a real directory
/// (node_modules) or regular file there: the install jail could write it, and the worker
/// follows what it then reads, renames or mounts.
fn check_install_output(path: &str, name: &str) -> Result<bool> {
let expect_dir = name == "node_modules";
match fs::symlink_metadata(path) {
Ok(m) if (expect_dir && m.is_dir()) || (!expect_dir && m.is_file()) => Ok(true),
Ok(_) => Err(error::Error::ExecutionErr(format!(
"dependency install left an unexpected {name}: expected a regular {}",
if expect_dir { "directory" } else { "file" }
))),
Err(_) => Ok(false),
}
}
/// Registry configuration bun reads from the install dir. It can hold an auth token, so
/// a staged install gets it bind-mounted read-only from the job dir instead of a copy in
/// the cache, which every later install jail mounts.
const BUN_REGISTRY_FILES: [&str; 2] = [BUN_CONFIG_FILE, BUN_NPMRC_FILE];
/// Open a directory without following a symlink at `path`.
#[cfg(unix)]
fn open_dir_nofollow(path: &str) -> std::io::Result<fs::File> {
use std::os::unix::fs::OpenOptionsExt;
fs::OpenOptions::new()
.read(true)
.custom_flags(nix::libc::O_DIRECTORY | nix::libc::O_NOFOLLOW)
.open(path)
}
/// Delete entry `name` of the directory open as `dir`, off the async worker thread: a
/// staging dir can hold a whole node_modules. A symlink there is removed, never followed.
#[cfg(unix)]
fn remove_entry_detached(dir: Arc<fs::File>, name: String) {
use std::os::fd::AsRawFd;
let remove = move || {
let path = format!("/proc/self/fd/{}/{name}", dir.as_raw_fd());
let _ = match fs::symlink_metadata(&path) {
Ok(m) if m.is_dir() => fs::remove_dir_all(&path),
Ok(_) => fs::remove_file(&path),
Err(e) => Err(e),
};
};
match tokio::runtime::Handle::try_current() {
Ok(rt) => {
rt.spawn_blocking(remove);
}
Err(_) => remove(),
}
}
/// A directory for one jailed bun install inside the bun cache, removed when dropped.
///
/// The jail binds the job dir and the cache as two mounts, and link(2) fails across
/// mounts, so bun would copy every file out of the cache instead of hardlinking it.
/// Installing here and renaming node_modules into the job dir keeps the hardlinks.
///
/// Every install jail can write the cache, so any path through it may since have become a
/// symlink. The worker reaches this dir only through descriptors opened without following
/// links before the jail runs ([`Self::entry`]); `path` is for the jail config alone.
struct BunStagingDir {
#[cfg(unix)]
root: Arc<fs::File>,
#[cfg(unix)]
dir: fs::File,
name: String,
path: String,
}
impl BunStagingDir {
/// Entry `f` of the staging dir, resolved through its descriptor.
fn entry(&self, f: &str) -> String {
#[cfg(unix)]
{
use std::os::fd::AsRawFd;
format!("/proc/self/fd/{}/{f}", self.dir.as_raw_fd())
}
#[cfg(not(unix))]
format!("{}/{f}", self.path)
}
}
#[cfg(unix)]
impl Drop for BunStagingDir {
fn drop(&mut self) {
remove_entry_detached(self.root.clone(), std::mem::take(&mut self.name));
}
}
/// How long a staging dir may outlive its install before the next one deletes it: a
/// worker killed mid-install leaves it behind. Well past the longest install.
const BUN_STAGING_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(6 * 60 * 60);
/// Whether a bun install needs nothing of the job dir beyond [`BUN_INSTALL_INPUTS`]: no
/// dependency by path, no workspaces or patches, and no root lifecycle script, which bun
/// runs next to the manifest and which may read the job's other files.
fn bun_install_can_stage(package_json: &str, lock: Option<&str>) -> bool {
const DEP_SECTIONS: [&str; 6] = [
"dependencies",
"devDependencies",
"optionalDependencies",
"peerDependencies",
"overrides",
"resolutions",
];
const ROOT_SCRIPTS: [&str; 6] = [
"preinstall",
"install",
"postinstall",
"preprepare",
"prepare",
"postprepare",
];
const LOCAL_SPECS: [&str; 8] = [
"file:",
"link:",
"workspace:",
"portal:",
"./",
"../",
"/",
"~/",
];
fn has_local_spec(v: &serde_json::Value) -> bool {
match v {
serde_json::Value::String(s) => LOCAL_SPECS.iter().any(|p| s.trim().starts_with(p)),
serde_json::Value::Object(m) => m.values().any(has_local_spec),
_ => false,
}
}
let Ok(serde_json::Value::Object(pkg)) = serde_json::from_str(package_json) else {
return false;
};
let root_script = pkg
.get("scripts")
.and_then(|s| s.as_object())
.is_some_and(|s| ROOT_SCRIPTS.iter().any(|k| s.contains_key(*k)));
if root_script
|| pkg.contains_key("workspaces")
|| pkg.contains_key("patchedDependencies")
|| DEP_SECTIONS
.iter()
.any(|k| pkg.get(*k).is_some_and(has_local_spec))
{
return false;
}
// Every text lockfile has a root `"workspaces"` entry, so only these mark local
// packages: as a spec (`"file:x"`) or in a resolution (`"x@file:x"`).
const LOCK_LOCAL_PROTOCOLS: [&str; 4] = ["file:", "link:", "workspace:", "portal:"];
!lock.is_some_and(|l| {
l.contains("\"patchedDependencies\"")
|| LOCK_LOCAL_PROTOCOLS
.iter()
.any(|p| l.contains(&format!("\"{p}")) || l.contains(&format!("@{p}")))
})
}
/// None when node_modules could not be renamed from the cache into the job dir (another
/// filesystem, or another mount of the same one): moving it back would be a copy, which
/// is what installing in the job dir costs anyway. Also None when
/// [`bun_install_can_stage`] says the install needs the job dir.
fn bun_staging_dir(cache_dir: &str, job_dir: &str) -> Option<BunStagingDir> {
#[cfg(unix)]
{
use std::os::fd::AsRawFd;
let package_json = fs::read_to_string(format!("{job_dir}/package.json")).ok()?;
let lock = fs::read_to_string(format!("{job_dir}/bun.lock")).ok();
if !bun_install_can_stage(&package_json, lock.as_deref()) {
return None;
}
// `cache_dir` is the jail's mount point, which no jail can replace; what is under
// it can be anything.
let root_path = format!("{cache_dir}/.wm-staging");
if fs::symlink_metadata(&root_path).is_ok_and(|m| !m.is_dir()) {
fs::remove_file(&root_path).ok()?;
}
let _ = fs::create_dir(&root_path);
let root = Arc::new(open_dir_nofollow(&root_path).ok()?);
let root_fd = format!("/proc/self/fd/{}", root.as_raw_fd());
for e in fs::read_dir(&root_fd).ok()?.flatten() {
let stale = e
.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.elapsed().ok())
.is_some_and(|age| age > BUN_STAGING_MAX_AGE);
if stale {
remove_entry_detached(root.clone(), e.file_name().to_string_lossy().into_owned());
}
}
let name = Uuid::new_v4().to_string();
fs::create_dir(format!("{root_fd}/{name}")).ok()?;
let dir = open_dir_nofollow(&format!("{root_fd}/{name}")).ok()?;
let path = format!("{root_path}/{name}");
let staging = BunStagingDir { root, dir, name, path };
// A jail sharing the cache can list the new dir and plant a symlink in it before
// the worker writes there: every file is created new, never opened as found.
let create_new = |path: &str| {
fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
};
// The move back is a rename, which is the only test that holds: two mounts of
// one filesystem share st_dev and still refuse it.
let probe = staging.entry(".rename-probe");
let probe_dst = format!("{job_dir}/.wm-staging-probe-{}", Uuid::new_v4());
create_new(&probe).ok()?;
let renamed = fs::rename(&probe, &probe_dst).is_ok();
let _ = fs::remove_file(&probe_dst);
if !renamed {
return None;
}
for f in BUN_INSTALL_INPUTS {
let src = format!("{job_dir}/{f}");
if fs::symlink_metadata(&src).is_ok_and(|m| m.is_file()) {
let mut out = create_new(&staging.entry(f)).ok()?;
std::io::copy(&mut fs::File::open(&src).ok()?, &mut out).ok()?;
}
}
Some(staging)
}
#[cfg(not(unix))]
{
let _ = (cache_dir, job_dir);
None
}
}
pub async fn install_bun_lockfile(
mem_peak: &mut i32,
canceled_by: &mut Option<CanceledBy>,
@@ -746,9 +1100,37 @@ pub async fn install_bun_lockfile(
occupancy_metrics: &mut Option<&mut OccupancyMetrics>,
quiet: bool,
) -> Result<()> {
let mut child_cmd = Command::new(if npm_mode { &*NPM_PATH } else { &*BUN_PATH });
let package_manager = if npm_mode { &*NPM_PATH } else { &*BUN_PATH };
// The install runs the lifecycle scripts of dependencies (npm: all of them; bun: its
// default trusted list and `trustedDependencies`), so when sandboxing is on it is
// confined under nsjail like the run step. The env is the same either way.
let sandboxed = is_sandboxing_enabled() && !*DISABLE_BUN_INSTALL_SANDBOX;
if is_sandboxing_enabled() && *DISABLE_BUN_INSTALL_SANDBOX {
static WARNED: std::sync::Once = std::sync::Once::new();
WARNED.call_once(|| {
tracing::warn!(
"DISABLE_BUN_INSTALL_SANDBOX is set: Bun and npm dependency installs run \
outside nsjail, with their lifecycle scripts unconfined"
)
});
}
let home = common_bun_proc_envs
.get("HOME")
.cloned()
.unwrap_or_else(|| HOME_ENV.clone());
let mut child_cmd = if sandboxed {
let mut cmd = Command::new(NSJAIL_PATH.as_str());
cmd.args(["--config", "install.config.proto", "--", package_manager]);
cmd
} else {
Command::new(package_manager)
};
let mut args = vec!["install", "--save-text-lockfile"];
let mut args = vec!["install"];
// A bun flag: npm 12 rejects unknown cli flags instead of warning.
if !npm_mode {
args.push("--save-text-lockfile");
}
let no_cache = !npm_mode && *BUN_NO_CACHE;
@@ -851,7 +1233,86 @@ pub async fn install_bun_lockfile(
gen_bunfig(job_dir, job_id, w_id, db).await?;
}
let child_process = start_child_process(child_cmd, &*BUN_PATH, false).await?;
let mut staging = None;
if sandboxed {
// Bind-mounted into the jail, so they must exist before nsjail builds the mount
// tree.
let _ = std::fs::create_dir_all(format!("{home}/{NODE_GYP_CACHE}"));
if npm_mode {
let _ = std::fs::create_dir_all(format!("{home}/.npm"));
} else {
std::fs::create_dir_all(&*BUN_CACHE_DIR)?;
staging = bun_staging_dir(&BUN_CACHE_DIR, job_dir);
}
let install_dir = staging.as_ref().map_or(job_dir, |s| s.path.as_str());
let bind = |src: &str, dst: &str, rw: bool| {
format!(
"mount {{\n src: \"{src}\"\n dst: \"{dst}\"\n is_bind: true\n rw: {rw}\n}}\n"
)
};
// npm never reads the bun cache, and its install scripts get no write to it.
let mut install_mounts = if npm_mode {
String::new()
} else {
bind(&BUN_CACHE_DIR, &BUN_CACHE_DIR, true)
};
match staging.as_ref() {
// After the cache bind: these land inside it.
Some(s) => {
for f in BUN_REGISTRY_FILES {
let src = format!("{job_dir}/{f}");
if fs::symlink_metadata(&src).is_ok_and(|m| m.is_file()) {
install_mounts.push_str(&bind(&src, &format!("{}/{f}", s.path), false));
}
}
}
None => install_mounts.push_str(&bind(job_dir, job_dir, true)),
}
let install_envs: HashMap<String, String> = child_cmd
.as_std()
.get_envs()
.filter_map(|(k, v)| Some((k.to_str()?.to_string(), v?.to_str()?.to_string())))
.collect();
let nsjail_timeout = match db {
Some(conn) => resolve_nsjail_timeout(conn, w_id, *job_id, None).await,
// no job to bound it: the caller waits for the install unbounded too
None => "0".to_string(),
};
write_file(
job_dir,
"install.config.proto",
&NSJAIL_CONFIG_INSTALL_BUN_CONTENT
.replace("{TIMEOUT}", &nsjail_timeout)
.replace("{INSTALL_MOUNTS}", &install_mounts)
.replace("{INSTALL_DIR}", install_dir)
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string())
.replace(
"{HOME_MOUNT_BLOCK}",
&install_home_mount_block(
&home,
npm_mode,
&resolve_nsjail_tmpfs_size_bytes().await,
),
)
.replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH)
.replace(
"{CA_MOUNT}",
&install_ca_mounts(job_dir, &home, &install_envs),
)
.replace("#{DEV}", DEV_CONF_NSJAIL)
.replace(
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
),
)?;
}
let child_executable = if sandboxed {
NSJAIL_PATH.as_str()
} else {
package_manager.as_str()
};
let child_process = start_child_process(child_cmd, child_executable, false).await?;
if let Some(db) = db {
let mut quiet_buf = String::new();
let result = handle_child(
@@ -860,7 +1321,8 @@ pub async fn install_bun_lockfile(
mem_peak,
canceled_by,
child_process,
false,
// jailed when sandboxing is on, so mem sampling finds the real process
sandboxed,
worker_name,
w_id,
"bun install",
@@ -895,6 +1357,34 @@ pub async fn install_bun_lockfile(
}
}
if let Some(staging) = staging {
// Leave the job dir as an install in place would have: what bun wrote or kept
// replaces the job dir's copy, what it removed is removed there too.
for f in BUN_INSTALL_OUTPUTS {
let src = staging.entry(f);
let dst = format!("{job_dir}/{f}");
match fs::symlink_metadata(&dst) {
Ok(m) if m.is_dir() => tokio::fs::remove_dir_all(&dst).await?,
Ok(_) => tokio::fs::remove_file(&dst).await?,
Err(_) => {}
}
if fs::symlink_metadata(&src).is_ok() {
fs::rename(&src, &dst)?;
// Checked once out of the cache, where no other jail can swap it.
if let Err(e) = check_install_output(&dst, f) {
let _ = fs::remove_file(&dst);
return Err(e);
}
}
}
} else if sandboxed {
// The jail could write the job dir: what the worker reads or mounts from it next
// must still be what an install leaves there.
for f in BUN_JOB_DIR_OUTPUTS {
check_install_output(&format!("{job_dir}/{f}"), f)?;
}
}
if has_file {
tokio::fs::remove_file(format!("{job_dir}/.npmrc")).await?;
}
@@ -1679,7 +2169,10 @@ pub async fn handle_bun_job(
&& (annotation.nobundling || *DISABLE_BUNDLING)
{
let script_path = crate::common::use_flow_root_path(job.runnable_path());
let script_dir = script_path.rsplit_once('/').map(|(dir, _)| dir).unwrap_or("");
let script_dir = script_path
.rsplit_once('/')
.map(|(dir, _)| dir)
.unwrap_or("");
let imports = std::iter::once((script_path.clone(), inner_content.as_str()))
.chain(modules.iter().flatten().map(|(key, module)| {
let stem = key.rsplit_once('.').map(|(stem, _)| stem).unwrap_or(key);
@@ -4502,6 +4995,69 @@ pub async fn start_worker(
mod tests {
use super::*;
#[test]
fn bun_install_stages_only_self_contained_manifests() {
let lock = r#"{"lockfileVersion": 1, "workspaces": {"": {"dependencies": {"zod": "^4"}}}}"#;
assert!(bun_install_can_stage(
r#"{"dependencies": {"zod": "^4"}}"#,
Some(lock)
));
for manifest in [
r#"{"dependencies": {"x": "file:./x"}}"#,
r#"{"dependencies": {"x": "../x"}}"#,
r#"{"overrides": {"y": {"x": "link:x"}}}"#,
r#"{"workspaces": ["packages/*"]}"#,
r#"{"patchedDependencies": {"zod@4.0.0": "patches/zod.patch"}}"#,
r#"{"scripts": {"postinstall": "test -f main.ts"}}"#,
"not json",
] {
assert!(!bun_install_can_stage(manifest, None), "{manifest}");
}
let local_lock = r#"{"packages": {"x": ["x@file:x", {}]}}"#;
assert!(!bun_install_can_stage(
r#"{"dependencies": {}}"#,
Some(local_lock)
));
}
// Install jails can write the bun cache, so the worker must never follow a path
// through it: neither a staging root nor a staging dir swapped for a symlink may
// lead its cleanup outside the cache.
#[cfg(target_os = "linux")]
#[test]
fn bun_staging_cleanup_does_not_follow_symlinks_out_of_the_cache() {
let tmp = tempfile::tempdir().unwrap();
let (cache, job, outside) = (
tmp.path().join("cache"),
tmp.path().join("job"),
tmp.path().join("outside"),
);
for d in [&cache, &job, &outside.join("victim")] {
fs::create_dir_all(d).unwrap();
}
fs::write(job.join("package.json"), r#"{"dependencies": {}}"#).unwrap();
// Old enough for the stale sweep if it were reached through the symlink.
fs::File::open(outside.join("victim"))
.unwrap()
.set_modified(std::time::SystemTime::UNIX_EPOCH)
.unwrap();
std::os::unix::fs::symlink(&outside, cache.join(".wm-staging")).unwrap();
let staging =
bun_staging_dir(cache.to_str().unwrap(), job.to_str().unwrap()).expect("staging dir");
assert!(outside.join("victim").exists());
assert!(fs::symlink_metadata(cache.join(".wm-staging"))
.unwrap()
.is_dir());
let staged = cache.join(".wm-staging").join(&staging.name);
fs::remove_dir_all(&staged).unwrap();
std::os::unix::fs::symlink(&outside, &staged).unwrap();
drop(staging);
assert!(outside.join("victim").exists());
assert!(fs::symlink_metadata(&staged).is_err());
}
#[test]
fn test_bun_lockfile_version() {
assert_eq!(bun_lockfile_version(EMPTY_V1_BUN_LOCK), Some(1));
+9
View File
@@ -358,6 +358,15 @@ lazy_static::lazy_static! {
.and_then(|x| x.parse::<bool>().ok())
.unwrap_or(false);
/// Runs Bun scripts' dependency installs (bun and `//npm`) outside nsjail even when
/// sandboxing is on, for a worker whose installs need host files the install jail
/// does not mount (keys for private git dependencies, a package cache outside HOME).
/// The run step stays jailed; the installs' lifecycle scripts see the worker again.
pub static ref DISABLE_BUN_INSTALL_SANDBOX: bool = std::env::var("DISABLE_BUN_INSTALL_SANDBOX")
.ok()
.and_then(|x| x.parse::<bool>().ok())
.unwrap_or(false);
/// Per-language override for the nsjail `rlimit_as` (virtual address space) cap.
/// Value is in MiB, or `unlimited`/`none`/`inf`/`0` to uncap. Unset keeps the
/// historical default baked into the proto. See `render_nsjail_rlimit_as`.