From e50a3e1b929d1adbe606fc939850f814ea745f02 Mon Sep 17 00:00:00 2001 From: Ethan Date: Tue, 10 Mar 2026 21:51:51 -0700 Subject: [PATCH] fix: add runtime-aware sudo_prefix for Lima/macOS compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous PRs removed all sudo for Incus (which runs as root), but this broke Lima (macOS) where limactl shell runs as user and needs sudo for system operations. Added exec_runs_as_root() and sudo_prefix() to Runtime trait — Incus returns "" (already root), Lima returns "sudo ". All system commands now use sudo_prefix() + bash -lc for proper PATH. Co-Authored-By: Claude Opus 4.6 --- src/nix/rebuild.rs | 11 +++-- src/runtime/incus.rs | 4 ++ src/runtime/mod.rs | 12 ++++++ src/sandbox/overlay.rs | 50 ++++++++++++++-------- src/sandbox/provision.rs | 91 ++++++++++++++++------------------------ 5 files changed, 91 insertions(+), 77 deletions(-) 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.