diff --git a/src/nix/rebuild.rs b/src/nix/rebuild.rs index c7f247c..79cf4d2 100644 --- a/src/nix/rebuild.rs +++ b/src/nix/rebuild.rs @@ -7,8 +7,9 @@ use crate::runtime::Runtime; pub async fn nixos_rebuild(runtime: &dyn Runtime, sandbox_name: &str) -> Result<()> { println!("Running nixos-rebuild switch..."); + let sudo = runtime.sudo_prefix(); let result = runtime - .exec_cmd(sandbox_name, &["bash", "-lc", "nixos-rebuild switch"], false) + .exec_cmd(sandbox_name, &["bash", "-lc", &format!("{sudo}nixos-rebuild switch")], false) .await?; if result.exit_code != 0 { @@ -18,7 +19,7 @@ pub async fn nixos_rebuild(runtime: &dyn Runtime, sandbox_name: &str) -> Result< let rollback = runtime .exec_cmd( sandbox_name, - &["bash", "-lc", "nixos-rebuild switch --rollback"], + &["bash", "-lc", &format!("{sudo}nixos-rebuild switch --rollback")], false, ) .await; @@ -50,13 +51,14 @@ pub async fn write_state_toml( toml_content: &str, ) -> Result<()> { // Use tee to write the file as root + let sudo = runtime.sudo_prefix(); let result = runtime .exec_cmd( sandbox_name, &[ "bash", "-lc", &format!( - "mkdir -p /etc/devbox && cat > /etc/devbox/devbox-state.toml << 'DEVBOX_EOF'\n{toml_content}\nDEVBOX_EOF" + "{sudo}mkdir -p /etc/devbox && {sudo}tee /etc/devbox/devbox-state.toml > /dev/null << 'DEVBOX_EOF'\n{toml_content}\nDEVBOX_EOF" ), ], false, @@ -80,13 +82,14 @@ pub async fn write_nix_file( filename: &str, content: &str, ) -> Result<()> { + let sudo = runtime.sudo_prefix(); let result = runtime .exec_cmd( sandbox_name, &[ "bash", "-lc", &format!( - "mkdir -p /etc/devbox/sets && cat > /etc/devbox/sets/{filename} << 'DEVBOX_EOF'\n{content}\nDEVBOX_EOF" + "{sudo}mkdir -p /etc/devbox/sets && {sudo}tee /etc/devbox/sets/{filename} > /dev/null << 'DEVBOX_EOF'\n{content}\nDEVBOX_EOF" ), ], false, diff --git a/src/runtime/incus.rs b/src/runtime/incus.rs index d287433..000b444 100644 --- a/src/runtime/incus.rs +++ b/src/runtime/incus.rs @@ -133,6 +133,10 @@ impl Runtime for IncusRuntime { 30 } + fn exec_runs_as_root(&self) -> bool { + true + } + async fn create(&self, opts: &CreateOpts) -> Result { let vm = Self::vm_name(&opts.name); diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 25f3538..ff6a06e 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -133,4 +133,16 @@ pub trait Runtime: Send + Sync { async fn exec_as_user(&self, name: &str, cmd: &[&str]) -> Result { self.exec_cmd(name, cmd, true).await } + + /// Whether exec_cmd runs as root by default. + /// Incus: true (incus exec defaults to root) + /// Lima: false (limactl shell runs as the configured user) + fn exec_runs_as_root(&self) -> bool { + false + } + + /// Returns "sudo " if exec_cmd runs as user (needs elevation), "" if already root. + fn sudo_prefix(&self) -> &str { + if self.exec_runs_as_root() { "" } else { "sudo " } + } } diff --git a/src/sandbox/overlay.rs b/src/sandbox/overlay.rs index c0384c7..cc958b5 100644 --- a/src/sandbox/overlay.rs +++ b/src/sandbox/overlay.rs @@ -15,12 +15,13 @@ const STASH_DIR: &str = "/var/devbox/overlay/stash"; /// Returns a list of (status, path) tuples. pub async fn diff(runtime: &dyn Runtime, sandbox_name: &str) -> Result> { // List all files in the upper directory + let sudo = runtime.sudo_prefix(); let result = runtime .exec_cmd( sandbox_name, &[ "bash", "-lc", - &format!("find {UPPER} -not -path {UPPER} -printf '%y %P\\n'"), + &format!("{sudo}find {UPPER} -not -path {UPPER} -printf '%y %P\\n'"), ], false, ) @@ -161,6 +162,7 @@ pub async fn commit( // Sync: rsync from upper to lower for each changed file // We need to handle additions, modifications, and deletions + let sudo = runtime.sudo_prefix(); let mut committed = 0; for change in &filtered { @@ -170,8 +172,9 @@ pub async fn commit( match change.status { ChangeStatus::Added | ChangeStatus::Modified => { if change.is_dir { + let cmd = format!("{sudo}mkdir -p {lower_path}"); let result = runtime - .exec_cmd(sandbox_name, &["mkdir", "-p", &lower_path], false) + .exec_cmd(sandbox_name, &["bash", "-lc", &cmd], false) .await?; if result.exit_code != 0 { eprintln!( @@ -191,17 +194,15 @@ pub async fn commit( .unwrap_or_default() ); if !parent.is_empty() && parent != LOWER { + let cmd = format!("{sudo}mkdir -p {parent}"); let _ = runtime - .exec_cmd(sandbox_name, &["mkdir", "-p", &parent], false) + .exec_cmd(sandbox_name, &["bash", "-lc", &cmd], false) .await; } + let cmd = format!("{sudo}cp -a {upper_path} {lower_path}"); let result = runtime - .exec_cmd( - sandbox_name, - &["cp", "-a", &upper_path, &lower_path], - false, - ) + .exec_cmd(sandbox_name, &["bash", "-lc", &cmd], false) .await?; if result.exit_code != 0 { eprintln!( @@ -214,8 +215,9 @@ pub async fn commit( } } ChangeStatus::Deleted => { + let cmd = format!("{sudo}rm -rf {lower_path}"); let result = runtime - .exec_cmd(sandbox_name, &["rm", "-rf", &lower_path], false) + .exec_cmd(sandbox_name, &["bash", "-lc", &cmd], false) .await?; if result.exit_code != 0 { eprintln!( @@ -248,12 +250,14 @@ pub async fn discard( sandbox_name: &str, paths: Option<&[String]>, ) -> Result { + let sudo = runtime.sudo_prefix(); if let Some(filter_paths) = paths { let mut discarded = 0; for path in filter_paths { let upper_path = format!("{UPPER}/{}", path.trim_start_matches('/')); + let cmd = format!("{sudo}rm -rf {upper_path}"); let result = runtime - .exec_cmd(sandbox_name, &["rm", "-rf", &upper_path], false) + .exec_cmd(sandbox_name, &["bash", "-lc", &cmd], false) .await?; if result.exit_code == 0 { println!(" Discarded: {path}"); @@ -272,7 +276,7 @@ pub async fn discard( &[ "bash", "-lc", - &format!("rm -rf {UPPER}/* {UPPER}/.[!.]* 2>/dev/null; true"), + &format!("{sudo}rm -rf {UPPER}/* {UPPER}/.[!.]* 2>/dev/null; true"), ], false, ) @@ -294,9 +298,12 @@ pub async fn stash(runtime: &dyn Runtime, sandbox_name: &str) -> Result<()> { bail!("A stash already exists. Pop or discard it first (`devbox layer stash-pop`)."); } + let sudo = runtime.sudo_prefix(); + // Move upper to stash + let cmd = format!("{sudo}mv {UPPER} {STASH_DIR}"); let result = runtime - .exec_cmd(sandbox_name, &["mv", UPPER, STASH_DIR], false) + .exec_cmd(sandbox_name, &["bash", "-lc", &cmd], false) .await?; if result.exit_code != 0 { @@ -304,8 +311,9 @@ pub async fn stash(runtime: &dyn Runtime, sandbox_name: &str) -> Result<()> { } // Recreate empty upper directory + let cmd = format!("{sudo}mkdir -p {UPPER}"); let result = runtime - .exec_cmd(sandbox_name, &["mkdir", "-p", UPPER], false) + .exec_cmd(sandbox_name, &["bash", "-lc", &cmd], false) .await?; if result.exit_code != 0 { @@ -325,9 +333,11 @@ pub async fn stash_pop(runtime: &dyn Runtime, sandbox_name: &str) -> Result<()> bail!("No stash found. Nothing to pop."); } + let sudo = runtime.sudo_prefix(); + // Merge stash back into upper (copy hidden and regular files) let merge_cmd = format!( - "cp -a {STASH_DIR}/* {UPPER}/ 2>/dev/null; cp -a {STASH_DIR}/.[!.]* {UPPER}/ 2>/dev/null; true" + "{sudo}cp -a {STASH_DIR}/* {UPPER}/ 2>/dev/null; {sudo}cp -a {STASH_DIR}/.[!.]* {UPPER}/ 2>/dev/null; true" ); let result = runtime .exec_cmd(sandbox_name, &["bash", "-lc", &merge_cmd], false) @@ -338,8 +348,9 @@ pub async fn stash_pop(runtime: &dyn Runtime, sandbox_name: &str) -> Result<()> } // Remove the stash directory + let cmd = format!("{sudo}rm -rf {STASH_DIR}"); let result = runtime - .exec_cmd(sandbox_name, &["rm", "-rf", STASH_DIR], false) + .exec_cmd(sandbox_name, &["bash", "-lc", &cmd], false) .await?; if result.exit_code != 0 { @@ -367,11 +378,13 @@ pub async fn has_stash(runtime: &dyn Runtime, sandbox_name: &str) -> Result Result<()> { + let sudo = runtime.sudo_prefix(); + // Try simple remount first (works on older kernels) let result = runtime .exec_cmd( sandbox_name, - &["bash", "-lc", &format!("mount -o remount {WORKSPACE}")], + &["bash", "-lc", &format!("{sudo}mount -o remount {WORKSPACE}")], false, ) .await?; @@ -384,7 +397,7 @@ pub async fn refresh(runtime: &dyn Runtime, sandbox_name: &str) -> Result<()> { // Remount not supported — unmount and remount manually. // The upper layer is on disk, so nothing is lost. let remount_cmd = format!( - "umount {WORKSPACE} && mount -t overlay overlay \ + "{sudo}umount {WORKSPACE} && {sudo}mount -t overlay overlay \ -o lowerdir={LOWER},upperdir={UPPER},workdir={WORK} {WORKSPACE}" ); let result = runtime @@ -455,8 +468,9 @@ pub async fn lower_layer_changes(runtime: &dyn Runtime, sandbox_name: &str) -> R // Compare the lower layer mtime against a timestamp file we create on mount. // If no timestamp exists, we can't detect changes — just check for stale handles. // Simpler approach: find files in lower newer than the overlay work dir (created at mount time). + let sudo = runtime.sudo_prefix(); let cmd = format!( - "find {} -newer {} -not -path {} -type f -printf '%P\\n' 2>/dev/null | head -50", + "{sudo}find {} -newer {} -not -path {} -type f -printf '%P\\n' 2>/dev/null | head -50", LOWER, WORK, LOWER ); let result = runtime diff --git a/src/sandbox/provision.rs b/src/sandbox/provision.rs index 2992ce2..e830b87 100644 --- a/src/sandbox/provision.rs +++ b/src/sandbox/provision.rs @@ -241,12 +241,7 @@ async fn provision_nixos( // 1. Create directory structure println!("Setting up NixOS configuration..."); - let mkdir_cmd = format!( - "{NIXOS_PATH_PREFIX}mkdir -p /etc/devbox/sets /etc/devbox/help" - ); - runtime - .exec_cmd(name, &["bash", "-c", &mkdir_cmd], false) - .await?; + run_in_vm(runtime, name, "mkdir -p /etc/devbox/sets /etc/devbox/help", false).await?; // 2. Ensure NixOS channel is available (images:nixos/* may not have it) // nixos-rebuild needs `` in NIX_PATH, which comes from @@ -296,16 +291,16 @@ async fn provision_nixos( // - images:nixos/* may not have channels in the default NIX_PATH // - After nix-channel --update, nixpkgs lives at the channel profile path println!("Installing packages via nixos-rebuild (this may take a few minutes)..."); + let sudo = runtime.sudo_prefix(); let rebuild_cmd = format!( - "{NIXOS_PATH_PREFIX}\ - export NIX_PATH=\"nixpkgs=/nix/var/nix/profiles/per-user/root/channels/nixos:\ + "export NIX_PATH=\"nixpkgs=/nix/var/nix/profiles/per-user/root/channels/nixos:\ nixos-config=/etc/nixos/configuration.nix:\ /nix/var/nix/profiles/per-user/root/channels\" && \ export NIXPKGS_ALLOW_UNFREE=1 && \ - nixos-rebuild switch" + {sudo}nixos-rebuild switch" ); let result = runtime - .exec_cmd(name, &["bash", "-c", &rebuild_cmd], true) + .exec_cmd(name, &["bash", "-lc", &rebuild_cmd], true) .await; // nixos-rebuild switch stops incus-agent during activation, which @@ -461,9 +456,7 @@ fi"#; setup_git_config(runtime, name).await?; // 7. Create devbox directories and copy binary + help - runtime - .exec_cmd(name, &["mkdir", "-p", "/etc/devbox/help"], false) - .await?; + run_in_vm(runtime, name, "mkdir -p /etc/devbox/help", false).await?; println!("Copying devbox into VM..."); copy_devbox_to_vm(runtime, name).await?; @@ -484,12 +477,13 @@ async fn install_ubuntu_services(runtime: &dyn Runtime, name: &str, sets: &[Stri if needs_docker { print!(" Setting up Docker service..."); - let cmd = "export DEBIAN_FRONTEND=noninteractive && \ - apt-get update -qq && \ - apt-get install -y -qq docker.io >/dev/null 2>&1 && \ - usermod -aG docker $(whoami) && \ - systemctl enable --now docker"; - let result = runtime.exec_cmd(name, &["bash", "-c", cmd], false).await; + let sudo = runtime.sudo_prefix(); + let cmd = format!("export DEBIAN_FRONTEND=noninteractive && \ + {sudo}apt-get update -qq && \ + {sudo}apt-get install -y -qq docker.io >/dev/null 2>&1 && \ + {sudo}usermod -aG docker $(whoami) && \ + {sudo}systemctl enable --now docker"); + let result = runtime.exec_cmd(name, &["bash", "-lc", &cmd], false).await; match result { Ok(r) if r.exit_code == 0 => println!(" done"), _ => println!(" skipped"), @@ -498,9 +492,10 @@ async fn install_ubuntu_services(runtime: &dyn Runtime, name: &str, sets: &[Stri if needs_tailscale { print!(" Setting up Tailscale service..."); - let cmd = "curl -fsSL https://tailscale.com/install.sh | sh && \ - systemctl enable --now tailscaled"; - let result = runtime.exec_cmd(name, &["bash", "-c", cmd], false).await; + let sudo = runtime.sudo_prefix(); + let cmd = format!("curl -fsSL https://tailscale.com/install.sh | {sudo}sh && \ + {sudo}systemctl enable --now tailscaled"); + let result = runtime.exec_cmd(name, &["bash", "-lc", &cmd], false).await; match result { Ok(r) if r.exit_code == 0 => println!(" done"), _ => println!(" skipped"), @@ -570,7 +565,7 @@ ZSHRC "# ); - let result = runtime.exec_cmd(name, &["bash", "-c", &setup], false).await; + let result = run_in_vm(runtime, name, &setup, false).await; if let Ok(r) = result && r.exit_code != 0 { @@ -626,10 +621,7 @@ export DEVBOX_RUNTIME="${DEVBOX_RUNTIME:-unknown}" "#; write_file_to_vm(runtime, name, &zshrc_path, zshrc).await?; - let chown_cmd = format!("{NIXOS_PATH_PREFIX}chown {username}:users {zshrc_path}"); - runtime - .exec_cmd(name, &["bash", "-c", &chown_cmd], false) - .await?; + run_in_vm(runtime, name, &format!("chown {username}:users {zshrc_path}"), false).await?; } // Also create .profile for bash login shells (used by layout panes with bash -lc) @@ -643,10 +635,7 @@ export DEVBOX_RUNTIME="${DEVBOX_RUNTIME:-unknown}" export PATH="$HOME/.npm-global/bin:$HOME/.local/bin:$HOME/.claude/bin:$PATH" "#; write_file_to_vm(runtime, name, &profile_path, profile).await?; - let chown_cmd = format!("{NIXOS_PATH_PREFIX}chown {username}:users {profile_path}"); - runtime - .exec_cmd(name, &["bash", "-c", &chown_cmd], false) - .await?; + run_in_vm(runtime, name, &format!("chown {username}:users {profile_path}"), false).await?; } Ok(()) @@ -952,27 +941,22 @@ fn generate_aichat_config_from_credentials(home: &std::path::Path) -> Option Result { - let full_cmd = format!("{NIXOS_PATH_PREFIX}{cmd}"); - runtime.exec_cmd(name, &["bash", "-c", &full_cmd], interactive).await + let sudo = runtime.sudo_prefix(); + let full_cmd = format!("{sudo}{cmd}"); + runtime.exec_cmd(name, &["bash", "-lc", &full_cmd], interactive).await } /// Write a file into the VM using base64-encoded content via exec_cmd. +/// Uses sudo on runtimes where exec_cmd doesn't run as root. async fn write_file_to_vm( runtime: &dyn Runtime, name: &str, @@ -981,8 +965,9 @@ async fn write_file_to_vm( ) -> Result<()> { use base64::Engine; let encoded = base64::engine::general_purpose::STANDARD.encode(content.as_bytes()); - let cmd = format!("{NIXOS_PATH_PREFIX}echo '{encoded}' | base64 -d | tee {path} > /dev/null"); - let result = runtime.exec_cmd(name, &["bash", "-c", &cmd], false).await?; + let sudo = runtime.sudo_prefix(); + let cmd = format!("echo '{encoded}' | base64 -d | {sudo}tee {path} > /dev/null"); + let result = runtime.exec_cmd(name, &["bash", "-lc", &cmd], false).await?; if result.exit_code != 0 { eprintln!("Warning: failed to write {path}: {}", result.stderr.trim()); } @@ -1115,10 +1100,7 @@ async fn ensure_nixos_config(runtime: &dyn Runtime, name: &str) -> Result<()> { if hw_check.exit_code != 0 { println!(" Generating hardware configuration..."); - let gen_cmd = format!("{NIXOS_PATH_PREFIX}nixos-generate-config"); - let result = runtime - .exec_cmd(name, &["bash", "-c", &gen_cmd], false) - .await?; + let result = run_in_vm(runtime, name, "nixos-generate-config", false).await?; if result.exit_code != 0 { eprintln!( "Warning: nixos-generate-config failed: {}", @@ -1345,12 +1327,11 @@ async fn install_latest_claude_code(runtime: &dyn Runtime, name: &str) { .await; } // Fix ownership - let chown_cmd = format!( - "{NIXOS_PATH_PREFIX}chown {username}:users /home/{username}/.zshrc /home/{username}/.profile 2>/dev/null; true" - ); - let _ = runtime - .exec_cmd(name, &["bash", "-c", &chown_cmd], false) - .await; + let _ = run_in_vm( + runtime, name, + &format!("chown {username}:users /home/{username}/.zshrc /home/{username}/.profile 2>/dev/null; true"), + false, + ).await; } /// Write embedded help files to /etc/devbox/help/ inside the VM.