Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "devbox"
version = "0.1.5"
version = "0.1.6"
edition = "2024"
description = "Isolated developer VMs for AI coding agents and humans. Safe by default."
license = "Apache-2.0"
Expand Down
20 changes: 20 additions & 0 deletions nix/devbox-module.nix
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,26 @@ in {
virtualisation.docker.enable = lib.mkDefault (sets.container or false);
services.tailscale.enable = lib.mkDefault (sets.network or false);

# ── Incus/LXD Agent ─────────────────────────────────
# Required for `incus exec` to work after nixos-rebuild.
# The agent binary is mounted at /run/incus_agent/ by the hypervisor;
# this service starts it on boot so the host can communicate with the VM.
systemd.services.incus-agent = {
description = "Incus VM Agent";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" "local-fs.target" ];
serviceConfig = {
Type = "notify";
ExecStart = "/run/incus_agent/incus-agent";
Restart = "always";
RestartSec = 5;
WorkingDirectory = "/run/incus_agent";
};
unitConfig = {
ConditionPathExists = "/run/incus_agent/incus-agent";
};
};

# ── Dynamic linker compat ─────────────────────────
# Required for VS Code Server, Cursor, and other dynamically linked
# binaries that expect a standard FHS layout (ld-linux, libc, etc.).
Expand Down
117 changes: 117 additions & 0 deletions src/cli/doctor.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
use anyhow::Result;
use clap::Args;

use crate::runtime::cmd::run_cmd;
use crate::runtime::detect::detect_runtime;
use crate::sandbox::SandboxManager;

#[derive(Args, Debug)]
pub struct DoctorArgs {}

pub async fn run(_args: DoctorArgs, manager: &SandboxManager) -> Result<()> {
#[allow(unused_assignments)]
let mut has_incus = false;
println!("devbox doctor\n");

let os = std::env::consts::OS;
Expand All @@ -23,6 +26,7 @@ pub async fn run(_args: DoctorArgs, manager: &SandboxManager) -> Result<()> {
"sudo apt install incus # or: snap install incus",
);
has_any_runtime |= found;
has_incus = found;

// QEMU and virtiofsd are required for Incus VMs on Linux
if found {
Expand Down Expand Up @@ -126,10 +130,123 @@ pub async fn run(_args: DoctorArgs, manager: &SandboxManager) -> Result<()> {
"curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh",
);

// Incus network diagnostics (Linux only)
if os == "linux" && has_incus {
println!("\nIncus network:");
check_incus_network().await;
}

println!("\nAll checks complete.");
Ok(())
}

/// Check Incus network configuration: bridge, NAT, IP forwarding, iptables FORWARD rules.
async fn check_incus_network() {
// 1. Check incusbr0 exists and has NAT enabled
let bridge = run_cmd("incus", &["network", "show", "incusbr0"]).await;
match bridge {
Ok(r) if r.exit_code == 0 => {
let has_nat = r.stdout.contains("ipv4.nat") && r.stdout.contains("\"true\"");
if has_nat {
println!(" Bridge (incusbr0): \x1b[32mok\x1b[0m (NAT enabled)");
} else {
println!(" Bridge (incusbr0): \x1b[33mexists but NAT may be off\x1b[0m");
println!(" Fix: incus network set incusbr0 ipv4.nat true");
}
}
_ => {
println!(" Bridge (incusbr0): \x1b[31mnot found\x1b[0m");
println!(" Fix: incus network create incusbr0");
return;
}
}

// 2. Check IP forwarding
let fwd = run_cmd("sysctl", &["-n", "net.ipv4.ip_forward"]).await;
match fwd {
Ok(r) if r.stdout.trim() == "1" => {
println!(" IP forwarding: \x1b[32menabled\x1b[0m");
}
_ => {
println!(" IP forwarding: \x1b[31mdisabled\x1b[0m");
println!(" Fix: sudo sysctl -w net.ipv4.ip_forward=1");
println!(" Persist: echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-incus.conf");
}
}

// 3. Check iptables FORWARD chain for incusbr0 rules
let fwd_rules = run_cmd("iptables", &["-S", "FORWARD"]).await;
let has_forward_rule = match &fwd_rules {
Ok(r) => r.stdout.contains("incusbr0") && r.stdout.contains("ACCEPT"),
Err(_) => false,
};

if has_forward_rule {
println!(" iptables FORWARD: \x1b[32mincusbr0 allowed\x1b[0m");
} else {
// Check FORWARD policy
let policy_drop = match &fwd_rules {
Ok(r) => r.stdout.contains("-P FORWARD DROP"),
Err(_) => false,
};
if policy_drop {
println!(" iptables FORWARD: \x1b[31mDROP policy, no incusbr0 rule\x1b[0m");
println!(" VM traffic is being blocked by the firewall.");
println!(" Fix:");
println!(" sudo iptables -I FORWARD -i incusbr0 -j ACCEPT");
println!(" sudo iptables -I FORWARD -o incusbr0 -m state --state RELATED,ESTABLISHED -j ACCEPT");
} else {
println!(" iptables FORWARD: \x1b[32mACCEPT policy\x1b[0m");
}
}

// 4. Check NAT masquerade for Incus subnet
let nat_rules = run_cmd("iptables", &["-t", "nat", "-S", "POSTROUTING"]).await;
let has_masq = match &nat_rules {
Ok(r) => r.stdout.contains("incusbr0") || r.stdout.contains("10.195.64"),
Err(_) => false,
};

if has_masq {
println!(" iptables NAT: \x1b[32mmasquerade configured\x1b[0m");
} else {
println!(" iptables NAT: \x1b[33mno masquerade for Incus subnet\x1b[0m");
println!(" Fix: sudo iptables -t nat -A POSTROUTING -s 10.195.64.0/24 ! -o incusbr0 -j MASQUERADE");
}

// 5. Quick connectivity test if any running VM exists
let list = run_cmd("incus", &["list", "devbox-", "--format", "json"]).await;
if let Ok(r) = list {
if let Ok(arr) = serde_json::from_str::<Vec<serde_json::Value>>(&r.stdout) {
for v in &arr {
if v["status"].as_str() == Some("Running") {
let vm_name = v["name"].as_str().unwrap_or("");
if !vm_name.is_empty() {
let ping = run_cmd(
"incus",
&["exec", vm_name, "--", "ping", "-c", "1", "-W", "3", "8.8.8.8"],
)
.await;
match ping {
Ok(p) if p.exit_code == 0 => {
println!(
" VM connectivity ({vm_name}): \x1b[32mok\x1b[0m"
);
}
_ => {
println!(
" VM connectivity ({vm_name}): \x1b[31mno internet\x1b[0m"
);
}
}
break; // Only test one VM
}
}
}
}
}
}

/// Check if a binary is available. If missing, print install instructions.
/// Returns true if found.
fn check_binary_with_install(label: &str, name: &str, install_hint: &str) -> bool {
Expand Down
64 changes: 59 additions & 5 deletions src/runtime/incus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,33 @@ impl IncusRuntime {
println!("Image '{alias}' imported successfully.");
Ok(())
}
/// Detect the UID of the first non-root user in the VM.
async fn detect_vm_uid(vm: &str) -> Option<String> {
// Find first user with UID >= 1000 (standard non-system user)
let result = run_cmd(
"incus",
&["exec", vm, "--", "bash", "-c",
"awk -F: '$3 >= 1000 && $3 < 65534 { print $3; exit }' /etc/passwd"],
).await.ok()?;
let uid = result.stdout.trim().to_string();
if uid.is_empty() { None } else { Some(uid) }
}

/// Detect the HOME directory for a given UID in the VM.
async fn detect_vm_home(vm: &str, uid: &str) -> String {
let result = run_cmd(
"incus",
&["exec", vm, "--", "bash", "-c",
&format!("getent passwd {uid} | cut -d: -f6")],
).await;
match result {
Ok(r) if !r.stdout.trim().is_empty() => {
format!("HOME={}", r.stdout.trim())
}
_ => format!("HOME=/home/dev"),
}
}

/// Wait for the Incus VM agent to become ready (up to 120 seconds).
/// The agent starts after the guest OS boots and runs incus-agent.
async fn wait_for_agent(vm: &str) -> Result<()> {
Expand Down Expand Up @@ -132,12 +159,13 @@ impl Runtime for IncusRuntime {
launch_args.push(&cpu_str);
}

// Default to 4GiB memory for Incus VMs — NixOS rebuild needs 2-4GB
// for evaluating the full module system. The default Incus 1GB is too little.
let mem_str;
if !opts.memory.is_empty() {
mem_str = format!("limits.memory={}", opts.memory);
launch_args.push("-c");
launch_args.push(&mem_str);
}
let memory = if opts.memory.is_empty() { "4GiB" } else { &opts.memory };
mem_str = format!("limits.memory={memory}");
launch_args.push("-c");
launch_args.push(&mem_str);

run_ok("incus", &launch_args).await?;

Expand Down Expand Up @@ -200,6 +228,32 @@ impl Runtime for IncusRuntime {
}
}

/// Execute an interactive command as the non-root user.
/// Incus exec defaults to root, so we detect the first UID >= 1000
/// and set --user, HOME, and CWD for proper user sessions.
async fn exec_as_user(&self, name: &str, cmd: &[&str]) -> Result<ExecResult> {
let vm = Self::vm_name(name);
let uid_str = Self::detect_vm_uid(&vm).await.unwrap_or("1000".to_string());
let home_env = Self::detect_vm_home(&vm, &uid_str).await;

let mut args = vec![
"exec".to_string(),
vm,
"--user".to_string(),
uid_str,
"--cwd".to_string(),
"/workspace".to_string(),
"--env".to_string(),
home_env,
"--".to_string(),
];
for c in cmd {
args.push(c.to_string());
}
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
run_interactive("incus", &arg_refs).await
}

async fn destroy(&self, name: &str) -> Result<()> {
let vm = Self::vm_name(name);
// Stop first (ignore errors if already stopped)
Expand Down
7 changes: 7 additions & 0 deletions src/runtime/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,4 +126,11 @@ pub trait Runtime: Send + Sync {
/// Update mount points for an existing sandbox.
/// Stops the VM, updates mounts in the config, and restarts.
async fn update_mounts(&self, name: &str, mounts: &[Mount]) -> Result<()>;

/// Execute an interactive command as the non-root user.
/// Used for shell attach — defaults to exec_cmd with interactive=true.
/// Runtimes like Incus override this to set --user, HOME, and CWD.
async fn exec_as_user(&self, name: &str, cmd: &[&str]) -> Result<ExecResult> {
self.exec_cmd(name, cmd, true).await
}
}
9 changes: 4 additions & 5 deletions src/sandbox/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ impl SandboxManager {
if layout == "plain" {
println!("Attaching to sandbox '{name}'...");
let shell = Self::probe_shell(runtime.as_ref(), name).await;
runtime.exec_cmd(name, &[&shell, "-l"], true).await?;
runtime.exec_as_user(name, &[&shell, "-l"]).await?;
return Ok(());
}

Expand All @@ -249,7 +249,7 @@ impl SandboxManager {
// No Zellij — fall back to raw shell
println!("Attaching to sandbox '{name}'...");
let shell = Self::probe_shell(runtime.as_ref(), name).await;
runtime.exec_cmd(name, &[&shell, "-l"], true).await?;
runtime.exec_as_user(name, &[&shell, "-l"]).await?;
return Ok(());
}

Expand Down Expand Up @@ -317,7 +317,7 @@ impl SandboxManager {
// Reattach to existing live session
println!("Reattaching to sandbox '{name}'...");
runtime
.exec_cmd(name, &["zellij", "attach", &session_name], true)
.exec_as_user(name, &["zellij", "attach", &session_name])
.await?;
} else {
// Create new named session with layout.
Expand All @@ -336,10 +336,9 @@ impl SandboxManager {

println!("Attaching to sandbox '{name}' (layout: {effective_layout})...");
runtime
.exec_cmd(
.exec_as_user(
name,
&["zellij", "--config", &config_path, "--layout", &layout_path],
true,
)
.await?;
}
Expand Down
Loading
Loading