This commit is contained in:
Ruben Fiszel
2026-02-19 15:05:10 +00:00
parent 95cbf3f047
commit 2bb669c0e9
19 changed files with 1202 additions and 78 deletions
+3
View File
@@ -0,0 +1,3 @@
BACKEND_PORT=8010
FRONTEND_PORT=3010
REMOTE=http://localhost:8010
+1
View File
@@ -17462,6 +17462,7 @@ dependencies = [
"sha2 0.10.9",
"sqlx",
"tar",
"tempfile",
"tiberius",
"tokio",
"tokio-postgres 0.7.13",
+71 -14
View File
@@ -33,8 +33,11 @@ pub fn build_sandbox_mounts(setup: &SandboxSetupState) -> String {
/// 2. Move the overlay root mount (`dst: "/"`) to be the FIRST mount block.
/// nsjail applies mounts in order, so the root bind must come before
/// tmpfs/bind mounts that go on top of it.
/// 3. Re-add read-only bind mounts for `runtime_bins` paths that fall under
/// stripped system dirs (e.g. `/usr/bin/bun`). These are layered on top of
/// the overlay so the host runtime binary is available inside the sandbox.
/// If no overlay marker is present, returns the config unchanged.
pub fn finalize_nsjail_config(config: &str) -> String {
pub fn finalize_nsjail_config(config: &str, runtime_bins: &[&str]) -> String {
if !config.contains(OVERLAY_MARKER) {
return config.to_string();
}
@@ -87,8 +90,7 @@ pub fn finalize_nsjail_config(config: &str) -> String {
if should_remove_block {
result_lines.truncate(mount_block_start);
} else if is_overlay_root {
overlay_root_lines
.extend_from_slice(&result_lines[mount_block_start..]);
overlay_root_lines.extend_from_slice(&result_lines[mount_block_start..]);
overlay_root_lines.push(lines[i]);
result_lines.truncate(mount_block_start);
} else {
@@ -129,7 +131,22 @@ pub fn finalize_nsjail_config(config: &str) -> String {
}
}
result_lines.join("\n")
// Re-add bind mounts for runtime binaries that fall under stripped system dirs.
// These are appended at the end so they layer on top of the overlay root.
let runtime_mounts: String = runtime_bins
.iter()
.filter(|bin| system_dirs.iter().any(|d| bin.starts_with(d)))
.map(|bin| {
format!("\nmount {{\n src: \"{bin}\"\n dst: \"{bin}\"\n is_bind: true\n}}")
})
.collect();
let mut result = result_lines.join("\n");
if !runtime_mounts.is_empty() {
result.push_str(&runtime_mounts);
result.push('\n');
}
result
}
#[cfg(test)]
@@ -155,7 +172,10 @@ mod tests {
let mut setup = SandboxSetupState::default();
setup.volume_mounts.insert(
"data".to_string(),
(PathBuf::from("/job/volumes/data"), "/workspace/data".to_string()),
(
PathBuf::from("/job/volumes/data"),
"/workspace/data".to_string(),
),
);
let mounts = build_sandbox_mounts(&setup);
assert!(mounts.contains("dst: \"/workspace/data\""));
@@ -170,11 +190,17 @@ mod tests {
let mut setup = SandboxSetupState::default();
setup.volume_mounts.insert(
"input".to_string(),
(PathBuf::from("/job/volumes/input"), "/mnt/input".to_string()),
(
PathBuf::from("/job/volumes/input"),
"/mnt/input".to_string(),
),
);
setup.volume_mounts.insert(
"output".to_string(),
(PathBuf::from("/job/volumes/output"), "/mnt/output".to_string()),
(
PathBuf::from("/job/volumes/output"),
"/mnt/output".to_string(),
),
);
let mounts = build_sandbox_mounts(&setup);
assert!(mounts.contains("/mnt/input"));
@@ -212,7 +238,10 @@ mod tests {
};
setup.volume_mounts.insert(
"data".to_string(),
(PathBuf::from("/job/volumes/data"), "/workspace/data".to_string()),
(
PathBuf::from("/job/volumes/data"),
"/workspace/data".to_string(),
),
);
let mounts = build_sandbox_mounts(&setup);
assert!(mounts.contains(OVERLAY_MARKER));
@@ -230,7 +259,7 @@ mod tests {
let config = "name: \"test\"\n\
mount {\n src: \"/bin\"\n dst: \"/bin\"\n is_bind: true\n}\n\
mount {\n dst: \"/tmp\"\n fstype: \"tmpfs\"\n rw: true\n}\n";
let result = finalize_nsjail_config(config);
let result = finalize_nsjail_config(config, &[]);
assert_eq!(result, config);
}
@@ -245,14 +274,17 @@ mod tests {
# SANDBOX_OVERLAY_ACTIVE\n\
mount {\n src: \"/job/merged\"\n dst: \"/\"\n is_bind: true\n rw: true\n}\n\
mount {\n dst: \"/tmp\"\n fstype: \"tmpfs\"\n rw: true\n}\n";
let result = finalize_nsjail_config(config);
let result = finalize_nsjail_config(config, &[]);
for dir in &["/bin", "/lib", "/lib64", "/usr", "/etc"] {
assert!(
!result.contains(&format!("dst: \"{dir}\"")),
"System dir {dir} should be stripped"
);
}
assert!(result.contains("dst: \"/\""), "Overlay root mount preserved");
assert!(
result.contains("dst: \"/\""),
"Overlay root mount preserved"
);
assert!(result.contains("dst: \"/tmp\""), "tmpfs mount preserved");
}
@@ -265,10 +297,13 @@ mod tests {
mount {\n dst: \"/tmp\"\n fstype: \"tmpfs\"\n rw: true\n}\n\
# SANDBOX_OVERLAY_ACTIVE\n\
mount {\n src: \"/job/merged\"\n dst: \"/\"\n is_bind: true\n rw: true\n}\n";
let result = finalize_nsjail_config(config);
let result = finalize_nsjail_config(config, &[]);
assert!(!result.contains("dst: \"/bin\""), "/bin stripped");
assert!(result.contains("dst: \"/dev/null\""), "/dev/null kept");
assert!(result.contains("dst: \"/opt/microsoft\""), "/opt/microsoft kept");
assert!(
result.contains("dst: \"/opt/microsoft\""),
"/opt/microsoft kept"
);
assert!(result.contains("dst: \"/tmp\""), "/tmp tmpfs kept");
}
@@ -277,11 +312,33 @@ mod tests {
let config = "name: \"test\"\n\
# SANDBOX_OVERLAY_ACTIVE\n\
mount {\n src: \"/job/merged\"\n dst: \"/\"\n is_bind: true\n}\n";
let result = finalize_nsjail_config(config);
let result = finalize_nsjail_config(config, &[]);
assert!(!result.contains(OVERLAY_MARKER));
assert!(result.contains("dst: \"/\""));
}
#[test]
fn test_finalize_readds_runtime_bins_under_stripped_dirs() {
let config = "name: \"test\"\n\
mount {\n src: \"/usr\"\n dst: \"/usr\"\n is_bind: true\n}\n\
# SANDBOX_OVERLAY_ACTIVE\n\
mount {\n src: \"/job/merged\"\n dst: \"/\"\n is_bind: true\n rw: true\n}\n";
let result = finalize_nsjail_config(config, &["/usr/bin/bun"]);
assert!(!result.contains("dst: \"/usr\""), "/usr stripped");
assert!(result.contains("dst: \"/usr/bin/bun\""), "bun re-added");
assert!(result.contains("src: \"/usr/bin/bun\""), "bun src set");
}
#[test]
fn test_finalize_skips_runtime_bins_not_under_system_dirs() {
let config = "name: \"test\"\n\
# SANDBOX_OVERLAY_ACTIVE\n\
mount {\n src: \"/job/merged\"\n dst: \"/\"\n is_bind: true\n}\n";
let result = finalize_nsjail_config(config, &["/tmp/windmill/cache/py_runtime"]);
// Should NOT add an extra mount since /tmp is not a stripped system dir
assert_eq!(result.matches("mount {").count(), 1);
}
#[test]
fn test_mount_block_syntax() {
let setup = SandboxSetupState {
+3
View File
@@ -143,5 +143,8 @@ hyper-tls = { workspace = true, optional = true }
hyper-util = { workspace = true, optional = true }
rcgen = { workspace = true, optional = true }
[dev-dependencies]
tempfile.workspace = true
[build-dependencies]
libffi-sys = { workspace = true, optional = true }
@@ -33,9 +33,9 @@ use crate::{
read_and_check_result, start_child_process, transform_json, OccupancyMetrics,
},
handle_child::handle_child,
is_sandboxing_enabled,
python_executor::{create_dependencies_dir, handle_python_reqs, uv_pip_compile},
is_sandboxing_enabled, DISABLE_NUSER, GIT_PATH, HOME_ENV, NSJAIL_PATH, PATH_ENV, PROXY_ENVS,
PY_INSTALL_DIR, TZ_ENV,
DISABLE_NUSER, GIT_PATH, HOME_ENV, NSJAIL_PATH, PATH_ENV, PROXY_ENVS, PY_INSTALL_DIR, TZ_ENV,
};
use windmill_common::client::AuthedClient;
@@ -1192,6 +1192,7 @@ mount {{
"{ADDITIONAL_PYTHON_PATHS}",
additional_python_paths_folders.as_str(),
),
&[],
);
let _ = write_file(job_dir, "run.config.proto", &nsjail_config)?;
} else {
+3 -2
View File
@@ -186,8 +186,8 @@ exit $exit_status
// Use nsjail if globally enabled, script has #sandbox annotation,
// or sandbox mounts are present (snapshot/volume annotations)
let nsjail =
(is_sandboxing_enabled() || annotation.sandbox || !shared_mount.is_empty()) && is_regular_job;
let nsjail = (is_sandboxing_enabled() || annotation.sandbox || !shared_mount.is_empty())
&& is_regular_job;
let child = if nsjail {
let nsjail_config = windmill_sandbox::finalize_nsjail_config(
&NSJAIL_CONFIG_RUN_BASH_CONTENT
@@ -196,6 +196,7 @@ exit $exit_status
.replace("{SHARED_MOUNT}", shared_mount)
.replace("{TRACING_PROXY_CA_CERT_PATH}", TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL),
&[],
);
let _ = write_file(job_dir, "run.config.proto", &nsjail_config)?;
let mut cmd_args = vec![
+8 -3
View File
@@ -47,9 +47,9 @@ use windmill_common::{
DB,
};
use crate::global_cache::{exists_in_cache, save_cache};
#[cfg(all(feature = "enterprise", feature = "parquet"))]
use windmill_object_store::attempt_fetch_bytes;
use crate::global_cache::{exists_in_cache, save_cache};
use windmill_parser::Typ;
@@ -977,8 +977,7 @@ pub async fn handle_bun_job(
}
};
let (cache, logs) =
crate::global_cache::load_cache(&local_path, &remote_path, false).await;
let (cache, logs) = crate::global_cache::load_cache(&local_path, &remote_path, false).await;
(cache, logs, local_path, remote_path)
} else {
(false, "".to_string(), "".to_string(), "".to_string())
@@ -1452,6 +1451,11 @@ try {{
//do not cache local dependencies
let child = if is_sandboxing_enabled() || !shared_mount.is_empty() {
let runtime_bin = if annotation.nodejs {
&*NODE_BIN_PATH
} else {
&*BUN_PATH
};
let nsjail_config = windmill_sandbox::finalize_nsjail_config(
&NSJAIL_CONFIG_RUN_BUN_CONTENT
.replace("{LANG}", if annotation.nodejs { "nodejs" } else { "bun" })
@@ -1470,6 +1474,7 @@ try {{
)
.replace("{TRACING_PROXY_CA_CERT_PATH}", TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL),
&[runtime_bin],
);
let _ = write_file(job_dir, "run.config.proto", &nsjail_config)?;
@@ -13,14 +13,11 @@ use itertools::Itertools;
#[cfg(feature = "csharp")]
use tokio::{fs::File, io::AsyncReadExt, process::Command};
#[cfg(feature = "csharp")]
use windmill_common::{
utils::calculate_hash,
worker::write_file,
};
use windmill_common::{utils::calculate_hash, worker::write_file};
use windmill_common::error::{self, Error};
#[cfg(feature = "csharp")]
use crate::global_cache::save_cache;
use windmill_common::error::{self, Error};
#[cfg(feature = "csharp")]
use windmill_queue::append_logs;
@@ -519,8 +516,7 @@ pub async fn handle_csharp_job(
let bin_path = format!("{}/{hash}", CSHARP_CACHE_DIR);
let remote_path = format!("{CSHARP_OBJECT_STORE_PREFIX}{hash}");
let (cache, cache_logs) =
crate::global_cache::load_cache(&bin_path, &remote_path, false).await;
let (cache, cache_logs) = crate::global_cache::load_cache(&bin_path, &remote_path, false).await;
let cache_logs = if cache {
#[cfg(unix)]
@@ -595,6 +591,7 @@ pub async fn handle_csharp_job(
.replace("{SHARED_MOUNT}", shared_mount)
.replace("{TRACING_PROXY_CA_CERT_PATH}", TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL),
&[],
);
write_file(job_dir, "run.config.proto", &nsjail_config)?;
let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str());
+3 -3
View File
@@ -2,6 +2,7 @@ use crate::{common::MaybeLock, get_proxy_envs_for_lang};
use std::{collections::HashMap, fs::DirBuilder, process::Stdio};
use windmill_common::scripts::ScriptLang;
use crate::global_cache::save_cache;
use itertools::Itertools;
use serde_json::value::RawValue;
use tokio::{
@@ -15,7 +16,6 @@ use windmill_common::{
utils::calculate_hash,
worker::{write_file, Connection, GoAnnotations},
};
use crate::global_cache::save_cache;
use windmill_parser_go::{parse_go_imports, REQUIRE_PARSE};
use windmill_queue::{append_logs, CanceledBy, MiniPulledJob};
@@ -110,8 +110,7 @@ pub async fn handle_go_job(
let hash = calculate_hash(&format!("{}{:?}v2", inner_content, &maybe_lock));
let bin_path = format!("{}/{hash}", GO_BIN_CACHE_DIR);
let remote_path = format!("{GO_OBJECT_STORE_PREFIX}{hash}");
let (cache, cache_logs) =
crate::global_cache::load_cache(&bin_path, &remote_path, false).await;
let (cache, cache_logs) = crate::global_cache::load_cache(&bin_path, &remote_path, false).await;
let (skip_go_mod, skip_tidy) = if cache {
(true, true)
@@ -347,6 +346,7 @@ func Run(req Req) (interface{{}}, error){{
.replace("{SHARED_MOUNT}", shared_mount)
.replace("{TRACING_PROXY_CA_CERT_PATH}", TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL),
&[],
);
let _ = write_file(job_dir, "run.config.proto", &nsjail_config)?;
let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str());
+2 -1
View File
@@ -1,5 +1,6 @@
use std::{collections::HashMap, path::PathBuf, process::Stdio};
use crate::global_cache::save_cache;
use anyhow::{anyhow, bail};
use async_recursion::async_recursion;
use itertools::Itertools;
@@ -15,7 +16,6 @@ use windmill_common::{
utils::calculate_hash,
worker::{copy_dir_recursively, write_file, Connection},
};
use crate::global_cache::save_cache;
use windmill_parser::Arg;
use windmill_parser_java::parse_java_sig_meta;
use windmill_queue::{append_logs, CanceledBy, MiniPulledJob};
@@ -605,6 +605,7 @@ async fn run<'a>(
.replace("{CACHE_DIR}", JAVA_CACHE_DIR)
.replace("{SHARED_MOUNT}", &shared_mount)
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()),
&[],
);
write_file(job_dir, "run.config.proto", &nsjail_config)?;
let mut cmd = Command::new(NSJAIL_PATH.as_str());
+3 -2
View File
@@ -16,8 +16,8 @@ use crate::{
build_command_with_isolation, create_args_and_out_file, get_reserved_variables,
read_result, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL,
},
get_proxy_envs_for_lang, handle_child, is_sandboxing_enabled, DISABLE_NUSER, NSJAIL_PATH, PATH_ENV,
TRACING_PROXY_CA_CERT_PATH,
get_proxy_envs_for_lang, handle_child, is_sandboxing_enabled, DISABLE_NUSER, NSJAIL_PATH,
PATH_ENV, TRACING_PROXY_CA_CERT_PATH,
};
use windmill_common::client::AuthedClient;
use windmill_common::scripts::ScriptLang;
@@ -253,6 +253,7 @@ async fn run<'a>(
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string())
.replace("{TRACING_PROXY_CA_CERT_PATH}", TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL),
&[],
);
write_file(job_dir, "run.config.proto", &nsjail_config)?;
let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str());
+2 -1
View File
@@ -22,7 +22,7 @@ use crate::{
get_reserved_variables, read_result, start_child_process, MaybeLock, OccupancyMetrics,
},
handle_child::handle_child,
COMPOSER_CACHE_DIR, COMPOSER_PATH, is_sandboxing_enabled, DISABLE_NUSER, NSJAIL_PATH, PHP_PATH,
is_sandboxing_enabled, COMPOSER_CACHE_DIR, COMPOSER_PATH, DISABLE_NUSER, NSJAIL_PATH, PHP_PATH,
};
use windmill_common::client::AuthedClient;
@@ -299,6 +299,7 @@ try {{
.replace("{JOB_DIR}", job_dir)
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string())
.replace("{SHARED_MOUNT}", shared_mount),
&[],
);
let _ = write_file(job_dir, "run.config.proto", &nsjail_config)?;
@@ -524,6 +524,7 @@ $env:PSModulePath = \"{};$PSModulePathBackup\"",
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string())
.replace("{SHARED_MOUNT}", shared_mount)
.replace("{CACHE_DIR}", POWERSHELL_CACHE_DIR),
&[],
);
let _ = write_file(job_dir, "run.config.proto", &nsjail_config)?;
let cmd_args = vec![
@@ -784,7 +784,7 @@ except BaseException as e:
#[cfg(windows)]
let additional_python_paths_folders = additional_python_paths_folders.replace(":", ";");
if is_sandboxing_enabled() {
if is_sandboxing_enabled() || !shared_mount.is_empty() {
let shared_deps = additional_python_paths
.into_iter()
.map(|pp| {
@@ -815,6 +815,7 @@ mount {{
)
.replace("{TRACING_PROXY_CA_CERT_PATH}", TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL),
&[],
);
let _ = write_file(job_dir, "run.config.proto", &nsjail_config)?;
} else {
@@ -801,6 +801,7 @@ mount {{
.replace("{TRACING_PROXY_CA_CERT_PATH}", TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL)
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()),
&[],
);
write_file(job_dir, "run.config.proto", &nsjail_config)?;
let mut cmd = Command::new(NSJAIL_PATH.as_str());
+3 -3
View File
@@ -5,6 +5,7 @@ use std::{collections::HashMap, process::Stdio};
use uuid::Uuid;
use windmill_parser_rust::parse_rust_deps_into_manifest;
use crate::global_cache::save_cache;
use itertools::Itertools;
use tokio::{
fs::{create_dir_all, File},
@@ -16,7 +17,6 @@ use windmill_common::{
utils::calculate_hash,
worker::{write_file, Connection},
};
use crate::global_cache::save_cache;
use windmill_queue::MiniPulledJob;
use windmill_queue::{append_logs, CanceledBy};
@@ -611,8 +611,7 @@ pub async fn handle_rust_job(
let reserved_variables =
get_reserved_variables(job, &client.token, conn, parent_runnable_path).await?;
let (cache, cache_logs) =
crate::global_cache::load_cache(&bin_path, &remote_path, false).await;
let (cache, cache_logs) = crate::global_cache::load_cache(&bin_path, &remote_path, false).await;
let cache_logs = if cache {
let target = format!("{job_dir}/main");
@@ -673,6 +672,7 @@ pub async fn handle_rust_job(
.replace("{TRACING_PROXY_CA_CERT_PATH}", TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL)
.replace("{SHARED_MOUNT}", shared_mount),
&[],
);
let _ = write_file(job_dir, "run.config.proto", &nsjail_config)?;
let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str());
File diff suppressed because it is too large Load Diff
+22 -15
View File
@@ -3415,8 +3415,7 @@ pub async fn handle_queued_job(
},
JobKind::SnapshotBuild => match conn {
Connection::Sql(db) => {
Box::pin(crate::snapshot_build::handle_snapshot_build(&job, db, conn))
.await
Box::pin(crate::snapshot_build::handle_snapshot_build(&job, db, conn)).await
}
Connection::Http(_) => {
return Err(Error::internal_err(
@@ -4193,18 +4192,19 @@ mount {{
if let Some(ref snap) = sandbox_config.snapshot {
let snapshot_path = windmill_sandbox::ensure_snapshot_cached(
&job.workspace_id, &snap.name, &snap.tag, db,
&job.workspace_id,
&snap.name,
&snap.tag,
db,
)
.await?;
setup.overlay =
Some(windmill_sandbox::mount_overlay(&snapshot_path, job_dir).await?);
setup.overlay = Some(windmill_sandbox::mount_overlay(&snapshot_path, job_dir).await?);
}
for (vol_name, mount_path) in &sandbox_config.volumes {
let local_dir = format!("{job_dir}/volumes/{vol_name}");
std::fs::create_dir_all(&local_dir).map_err(|e| {
Error::ExecutionErr(format!("Failed to create volume dir: {e}"))
})?;
std::fs::create_dir_all(&local_dir)
.map_err(|e| Error::ExecutionErr(format!("Failed to create volume dir: {e}")))?;
windmill_sandbox::download_volume(
&job.workspace_id,
vol_name,
@@ -4224,6 +4224,17 @@ mount {{
None
};
if sandbox_config.snapshot.is_some() || !sandbox_config.volumes.is_empty() {
let mut sandbox_logs = "\n--- SANDBOX ---\n".to_string();
if let Some(ref snap) = sandbox_config.snapshot {
sandbox_logs.push_str(&format!("Snapshot: {}:{}\n", snap.name, snap.tag));
}
for (vol_name, mount_path) in &sandbox_config.volumes {
sandbox_logs.push_str(&format!("Volume: {} -> {}\n", vol_name, mount_path));
}
append_logs(&job.id, &job.workspace_id, sandbox_logs, conn).await;
}
let envs = build_envs(envs.as_ref())?;
let Some(language) = language else {
@@ -4682,13 +4693,9 @@ mount {{
if let Some(ref setup) = sandbox_setup {
if let Some(db) = conn.as_sql() {
for (vol_name, (local_dir, _)) in &setup.volume_mounts {
if let Err(e) = windmill_sandbox::upload_volume(
&job.workspace_id,
vol_name,
local_dir,
db,
)
.await
if let Err(e) =
windmill_sandbox::upload_volume(&job.workspace_id, vol_name, local_dir, db)
.await
{
tracing::error!("Failed to upload volume {vol_name}: {e}");
}
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -euo pipefail
port_in_use() {
lsof -nP -iTCP:"$1" -sTCP:LISTEN &>/dev/null
}
find_port() {
local port=$1
while port_in_use "$port"; do
((port++))
done
echo "$port"
}
if [[ -z "${WM_SLOT:-}" ]]; then
# Auto-assign: find the first slot (1-99) where both ports are free
# Slot 0 (8000/3000) is reserved for the main worktree
for slot in $(seq 1 99); do
bp=$((8000 + slot * 10))
fp=$((3000 + slot * 10))
if ! port_in_use "$bp" && ! port_in_use "$fp"; then
WM_SLOT=$slot
break
fi
done
if [[ -z "${WM_SLOT:-}" ]]; then
echo "ERROR: No available slot found (tried 1-99)" >&2
exit 1
fi
echo "Auto-assigned slot $WM_SLOT"
fi
# Slot-based: predictable ports for SSH forwarding
# Slot 0 = 8000/3000, slot 1 = 8010/3010, slot 2 = 8020/3020, etc.
backend_port=$((8000 + WM_SLOT * 10))
frontend_port=$((3000 + WM_SLOT * 10))
if port_in_use "$backend_port" || port_in_use "$frontend_port"; then
echo "ERROR: Slot $WM_SLOT ports ($backend_port/$frontend_port) already in use" >&2
exit 1
fi
# Generate .env.local with port overrides
cat > .env.local <<EOF
BACKEND_PORT=$backend_port
FRONTEND_PORT=$frontend_port
REMOTE=http://localhost:$backend_port
EOF
echo "Created .env.local with ports: backend=$backend_port, frontend=$frontend_port"