From a1efd43b0ef2502e87a0eea53834d7a2a1438beb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 14:11:36 +0000 Subject: [PATCH] fix(nix): detect nix at known locations, not just $PATH (#2787) Devbox ran nix commands via resolvePath(), which searches $PATH and then falls back to well-known install locations, invoking nix by absolute path. But installation detection (BinaryInstalled) used a bare `exec.LookPath("nix")` $PATH check. The two disagreed: on NixOS, or when a non-POSIX login shell such as fish hasn't sourced the Nix profile, nix exists at a known location but isn't on $PATH, so devbox aborted with "nix binary is not in your PATH" even though it could have run nix fine. - Point BinaryInstalled at the same resolver used to run nix commands, via a new exported nix.(*Nix).LookPath / nix.LookPath. - Fix resolvePath's fallback list: the NixOS entry was the bin *directory* (/run/current-system/sw/bin), which the "not a directory" check always rejected, so it never matched. It now points at the nix binary itself. - Extract the fallback paths into nixBinaryFallbackPaths() and add a regression test asserting each entry is an absolute path to the nix binary. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01BXJuDH6keqvU1ydywuyizq --- internal/nix/install.go | 9 ++++++-- nix/nix.go | 51 +++++++++++++++++++++++++++++++---------- nix/nix_test.go | 23 +++++++++++++++++++ 3 files changed, 69 insertions(+), 14 deletions(-) diff --git a/internal/nix/install.go b/internal/nix/install.go index d714b8b5646..642b27d9848 100644 --- a/internal/nix/install.go +++ b/internal/nix/install.go @@ -16,13 +16,18 @@ import ( "github.com/mattn/go-isatty" "go.jetify.com/devbox/internal/boxcli/usererr" - "go.jetify.com/devbox/internal/cmdutil" "go.jetify.com/devbox/internal/fileutil" "go.jetify.com/devbox/nix" ) func BinaryInstalled() bool { - return cmdutil.Exists("nix") + // Use the same resolver that Devbox uses to run nix commands (searching + // $PATH and well-known install locations), rather than a bare $PATH lookup. + // Otherwise Devbox can wrongly report nix as missing when it exists at a + // known location but isn't on $PATH — e.g. on NixOS, or when a non-POSIX + // login shell such as fish hasn't sourced the Nix profile (issue #2787). + _, err := nix.Default.LookPath() + return err == nil } func dirExistsAndIsNotEmpty(dir string) bool { diff --git a/nix/nix.go b/nix/nix.go index 0e37730ef89..ab0223a4df0 100644 --- a/nix/nix.go +++ b/nix/nix.go @@ -34,6 +34,11 @@ func System() string { return Default.System() } +// LookPath calls [Nix.LookPath] on the default Nix installation. +func LookPath() (string, error) { + return Default.LookPath() +} + // Version calls [Nix.Version] on the default Nix installation. func Version() string { return Default.Version() @@ -88,24 +93,46 @@ func (n *Nix) resolvePath() (string, error) { return path, nil } - try := []string{ - "/nix/var/nix/profiles/default/bin/nix", - "/run/current-system/sw/bin", - } - for _, path := range try { + for _, path := range nixBinaryFallbackPaths() { stat, err := os.Stat(path) - if err == nil { - // Is it executable and not a directory? - m := stat.Mode() - if !m.IsDir() && m.Perm()&0o111 != 0 { - n.lookPath.Store(&path) - return path, nil - } + if err != nil { + continue + } + // Is it an executable file (and not a directory)? + m := stat.Mode() + if !m.IsDir() && m.Perm()&0o111 != 0 { + n.lookPath.Store(&path) + return path, nil } } return "", pathErr } +// LookPath returns the absolute path to the nix executable. It searches $PATH +// (after attempting to source the Nix profile) and, failing that, the +// well-known installation locations in [nixBinaryFallbackPaths]. It returns an +// error if nix cannot be found. +// +// Because Devbox invokes nix by absolute path, a non-error result here means +// nix commands will run even when nix is not on $PATH — which happens, for +// example, when the login shell has not sourced the Nix profile (common with +// non-POSIX shells such as fish). +func (n *Nix) LookPath() (string, error) { + return n.resolvePath() +} + +// nixBinaryFallbackPaths returns well-known absolute paths to the nix +// executable, searched in order when nix is not found on $PATH. Each entry must +// point at the nix binary itself, not the directory that contains it. +func nixBinaryFallbackPaths() []string { + return []string{ + "/nix/var/nix/profiles/default/bin/nix", + // On NixOS, nix is provided through the current system profile rather + // than /nix/var/nix/profiles/default. + "/run/current-system/sw/bin/nix", + } +} + func (n *Nix) logger() *slog.Logger { if n.Logger == nil { return slog.Default() diff --git a/nix/nix_test.go b/nix/nix_test.go index 1604edf39a6..8e44dc6a774 100644 --- a/nix/nix_test.go +++ b/nix/nix_test.go @@ -2,6 +2,7 @@ package nix import ( + "path/filepath" "slices" "testing" ) @@ -204,3 +205,25 @@ func TestVersionInfoAtLeast(t *testing.T) { info.AtLeast(v) }) } + +func TestNixBinaryFallbackPaths(t *testing.T) { + paths := nixBinaryFallbackPaths() + if len(paths) == 0 { + t.Fatal("expected at least one fallback path") + } + // Every fallback must point at the nix binary itself, not a directory that + // contains it. resolvePath rejects directories, so a bare bin dir here + // would silently never match (regression guard for issue #2787). + for _, p := range paths { + if filepath.Base(p) != "nix" { + t.Errorf("fallback path %q must end in the nix binary, got base %q", p, filepath.Base(p)) + } + if !filepath.IsAbs(p) { + t.Errorf("fallback path %q must be absolute", p) + } + } + // The NixOS system-profile location must be covered. + if !slices.Contains(paths, "/run/current-system/sw/bin/nix") { + t.Errorf("expected NixOS system-profile nix path in fallbacks, got %v", paths) + } +}