// notes
Nix devShell in fish: PATH is silently ignored
devShell packages are not picked up in fish because PATH points inside the mount namespace. How to diagnose the silent fallback and re-exec the shell
The jq version is unexpectedly old even though the flake (a Nix environment definition) builds without errors. The build is not the problem: PATH points at paths that do not exist outside the mount namespace (an isolated tree of mount points), so the shell takes system binaries. Below: how to spot it, why it happens, and how to re-exec fish inside the namespace once
Context: rootless Nix, direnv, nix-direnv and fish in a project whose .envrc contains use flake (loading an environment from a flake)
Symptom
There is no error, and that is what misleads. direnv allow finishes, variables from .env are loaded, but the tools come from the system rather than from the devShell (the development environment Nix builds)
$ echo $APP_ENV
development # dotenv works
$ command -s jq
/usr/bin/jq # system binary, not the devShell oneMeanwhile PATH looks correct: the right entries are present and come first
$ string split ":" $PATH | head -3
/nix/store/<hash>-git-2.55.0/bin
/nix/store/<hash>-jq-1.8.2-bin/bin
/nix/store/<hash>-curl-8.22.0-dev/binDiagnosis
First, check where the shell actually takes the binary from
$ command -s jq
/usr/bin/jqIf the command resolves to /usr/bin instead of /nix/store, part of PATH is dead. The second test tells whether you are inside the namespace
$ test -d /nix/store; and echo inside; or echo outside
outsideThis is the only reliable marker. Do not trust $NIX_STORE_DIR: it can be set outside the namespace as well
When a command fails without naming the culprit, intercepting the binary helps: Who calls unshare
Cause
Rootless Nix mounts /nix inside a mount namespace, an isolated tree of mount points that is created without root privileges. Outside the namespace the /nix directory does not exist at all
$ ls -d /nix/store/<hash>-jq-1.8.2-bin
ls: cannot access: No such file or directory
$ nix-enter ls -d /nix/store/<hash>-jq-1.8.2-bin
/nix/store/<hash>-jq-1.8.2-binThe shell does not treat a missing directory as an error: it skips the dead PATH entry and takes the next match. That is why the fallback goes unnoticed
Dynamically linked binaries have a hard-coded interpreter, /nix/store/<glibc>/lib/ld-linux-x86-64.so.2, so rewriting PATH to ~/.nix/store/... does not help
$ ~/.nix/store/<hash>-jq-1.8.2-bin/bin/jq --version
bash: .../jq: cannot execute: required file not foundIf direnv fails with an explicit unshare error, that is a separate scenario: nix-direnv: "unshare failed: Operation not permitted"
And why running direnv outside breaks the package's embedded paths is explained in another note: nix-direnv: "minimum required bash version is 4.4"
Fix
Running the flake build inside the namespace is not enough: the shell itself must run there too. Add this to ~/.config/fish/config.fish
if status is-interactive
and not set -q __NIX_NS_REEXEC
and test -x "$HOME/.local/bin/nix-enter"
and test -d "$HOME/.nix/store"
and not test -d /nix/store
set -gx __NIX_NS_REEXEC 1
exec "$HOME/.local/bin/nix-enter" /usr/bin/fish -i -C "set -gx __NIX_NS_REEXEC 1"
endWhat happens here. If /nix/store is not mounted yet, the shell re-executes itself once (a re-exec, an explicit restart) through the nix-enter wrapper and passes the __NIX_NS_REEXEC flag. After that fish runs inside the namespace, where the /nix/store paths are valid. The name nix-enter is an example wrapper, yours may be named differently, what matters is the check itself
The $HOME/.nix/store check assumes the typical rootless Nix layout. If your store lives elsewhere, adjust the path in the condition
The four conditions in if cover four different scenarios
status is-interactivekeeps non-interactive scripts from being re-executed unexpectedlynot set -q __NIX_NS_REEXECprevents an infinite looptest -d "$HOME/.nix/store"checks that Nix is installed at allnot test -d /nix/storechecks that we are still outside the namespace
After the re-exec the binary comes from the devShell
$ command -s jq
/nix/store/<hash>-jq-1.8.2-bin/bin/jqCheck
Run the three checks below. The normal picture: no dead PATH entries, command -s jq points to /nix/store, and test -d /nix/store prints "inside"
# 1) dead PATH entries
for p in (string split ":" $PATH)
test -n "$p"; or continue
test -d $p; or echo "DEAD: $p"
end
# 2) where the binary actually comes from
command -s jq
type -a jq
# 3) are we inside the namespace
test -d /nix/store; and echo inside; or echo outsideIf the first loop prints DEAD for /nix/store paths, and command -s jq returns the system binary, the shell is running outside the namespace
Pitfalls
- The most dangerous failure is the silent one: the tool version may quietly be the system one. If a version looks unexpectedly old, check
command -sfirst - For non-interactive runs, in scripts and CI, the re-exec does not fire: that is intentional. There you must start the shell inside the namespace explicitly
Takeaway
If you run rootless Nix, direnv and fish, and flake packages seem invisible, PATH almost certainly points inside the namespace while the shell runs outside. A transparent re-exec fixes it without breaking scripts
Related notes
nix-direnv: "unshare failed: Operation not permitted"
direnv with use flake fails on unshare failed while dotenv works. I trace the cause (a nested user namespace) and show the fix for the nix wrapper
Who calls unshare: intercepting a binary through PATH
A command fails without naming the culprit. Here is the trick: replace the binary with a logging script via PATH and find the caller without tracing or root
nix-direnv: "minimum required bash version is 4.4"
nix-direnv fails with "minimum required bash version is 4.4" even though bash is newer. I break down the hard-coded /nix/store paths, the cause, and the fix