2026-08-13 Tmux Plugins Pin Their Source and Nothing Else
What I set out to do
Check a hunch that the tmux plugins in nix/home-manager/modules/tmux.nix don’t pin their inputs, starting with the bash they run under.
What I actually did
The hunch was right, and worse than stated. mkTmuxPlugin in nixpkgs is literally cp -r . $out/share/tmux-plugins/<name> with no runtime deps declared, and fixupPhase’s patchShebangsAuto runs in --host mode, so with no buildInputs it finds no interpreter and leaves every #!/usr/bin/env bash alone. The store path pins a plugin’s source tarball and nothing else.
Rather than reason about what that implies, I probed the live tmux server’s job environment directly. Every plugin runs under /bin/bash 3.2.57 (2007) with BSD awk/sed/grep from /usr/bin. There is no bash in ~/.nix-profile/bin at all. fzf was the one thing resolving into the nix profile, and only by accident of being in home.packages.
Then the part that changed the framing: I scanned all ten plugins for bash-4-only constructs (declare -A, mapfile, ${x^^}, globstar) and GNU-only flags (sed -r, grep -P, stat -c, date -d). Zero hits. These plugins are written POSIX-conservatively precisely because they target macOS. So nothing was broken. This was a hermeticity gap waiting on a plugin update or a Linux host, not a live bug, and saying so plainly mattered more than the fix.
Fixed with pinRuntime in a new nix/home-manager/lib/tmux-plugins.nix plus a build-time injector at nix/scripts/tmux/pin-plugin-runtime.sh, wired as plugins = map pinPlugin (...). It adds patchShebangs --build and a PATH export after the shebang of every shell file.
The design question worth the time was what not to pin. Three plugins probe for their tools rather than use them: yank picks among pbcopy/xclip/xsel/wl-copy, battery among pmset/upower/acpi, open between open and xdg-open. Pinning one arm of a probe answers the question before it is asked. Those stay on the ambient PATH, as do ps/pgrep/gdb, which walk the host process tree. That is the same rule the file’s own comment already states for ps in my scripts.
Verified after hm switch that all ten entrypoints ran and registered their bindings, battery renders 100% / plugged / fully charged under gawk, and open still bound darwin’s open rather than xdg-open, which is the probe proving it survived. Committed 7931776.
What was striking
patchShebangs with no mode flag silently does nothing. My first attempt built green and changed not one byte of output. Only patchShebangs --build works. That is a textbook case of feedback_null_result_needs_a_control: the build succeeding is not evidence the build did anything, and I only caught it because I read the actual store output instead of trusting the exit code. Same shape as the lesson in 2026-07-22 Tmux Picker Single-Click and the scratch-popup 127.
The other one: “pin everything” is the wrong instinct. Half the interesting work here was deciding which dependencies are supposed to be ambient. A platform probe is a question about the host, and hermeticity applied to it isn’t rigor, it’s a wrong answer with a store path attached. reference_nix_script_runtime_deps already encoded that for my own scripts; I just hadn’t seen that third-party plugins need the same distinction drawn for them, because nixpkgs draws no line at all.
One gap I chose not to close: vim-tmux-navigator bakes its is_vim grep into a tmux if-shell string, and the open plugin bakes xargs/open into copy-pipe-and-cancel. tmux runs those itself with the ambient PATH, so no in-script export reaches them. I checked the one that could actually bite (\S in the navigator’s ERE) against both BSD and GNU grep and macOS handles it, so patching those strings would be fragile work buying nothing today.
Related
Continues the tmux thread from 2026-07-22 Tmux Picker Single-Click and the scratch-popup 127 and 2026-06-29 Tmux Width-Responsive Statusline. The dependency-declaration principle is reference_nix_script_runtime_deps.
The sweep afterwards
Having found the defect in one place, I checked the rest of the config for it. Six surfaces came back clean: launchd agents (every one execs a store path behind the standard wait4path wrapper), Claude/codex/agy/pi hook commands, the prek-generated git hook, aerospace, and every home.file raw source (all data files, no executables). The one zsh plugin, fzf-tab, is sourced in an interactive shell whose PATH the user controls, which is a different risk class rather than a defect.
Of 204 binaries in the home-manager profile, exactly one had a non-store interpreter: fzf-tmux, which nixpkgs ships as #!/usr/bin/env bash.
The useful part was auditing my own scripts. writeShellApplication prefixes PATH rather than replacing it, so an undeclared tool is satisfied silently by whatever the host has, which is the same failure mode one layer in. I audited it by reading each wrapped script’s own export PATH= line, listing the command words in its body, and reporting any that resolve under the full system PATH but not the declared one. That last filter is what made it usable: without it the output was swamped by English words out of comments and embedded awk. 29 scripts, four findings, and every remaining hit turned out to be documented-deliberate already (ps in the agent scripts, and nix/home-manager/uv in update, both with comments saying exactly why).
Two were real. direnv-load called env -u without declaring coreutils. And update called rsync -a undeclared, which mattered more than I expected: rsync isn’t in the nix profile at all, so it was resolving to /usr/bin/rsync, which on this machine is openrsync, a BSD reimplementation reporting protocol 29. Not GNU rsync, and not close in flag surface. It now gets rsync 3.4.4, protocol 32. I tested the exact flags against openrsync first and they did work, so this was another latent gap rather than a live break, but it is the one I would least have expected to find by reading the code.
The third finding was in the fix I had shipped an hour earlier. Patching shebangs doesn’t help a plugin that shells out to another #!/usr/bin/env bash script, because env searches PATH and my injected PATH had no bash in it. fzf-url.sh calling fzf-tmux is precisely that path. Adding pkgs.bash closed it, which also neutralises the one unpinned profile binary for the case that actually reaches it.
Worth noting for its own sake: the “deliberately ambient” set is now three separate comments in three files (ps in tmux.nix, nix/home-manager/uv in shell.nix, and the probe pairs in the new tmux-plugins.nix) all encoding the same rule from different angles. That rule is doing real work and has no single home. If it grows a fourth instance it should probably become an atomic note rather than a fourth comment.
Shipped as 49c127a.
Making it systematic
Asked how to catch this class systematically, my first answer was to build a bespoke checker. That was wrong, and being pushed on it was the useful part: there is a purpose-built tool, resholve, and I had waved it away in one line as “hold it in reserve.”
resholve resolves every command in a shell script to a store path at build time and fails the build if it cannot. That eliminates the class rather than detecting instances of it, which is a different kind of answer than another grep.
The thing worth keeping from the detour is why nothing had caught this. Every check in the repo runs inside a nix build sandbox, and I probed that sandbox’s PATH: bash 5.3, coreutils, findutils, gnused, gnugrep, gawk. That is almost exactly the set that goes missing at runtime. The repo even has a check that loads the rendered tmux.conf into a real headless tmux and executes every plugin, and it passed contentedly on the broken plugins, because the environment it runs in is generous in precisely the way the real one is not. A behavioural test cannot see a PATH defect. That is the general lesson and it is not specific to tmux.
Two smaller traps fell out of the same probing. env -i does not neutralise an interpreter, because /usr/bin/env has a compiled-in fallback PATH and still finds /bin/bash. And exit status is worthless here: the old resurrect plugin under a hostile PATH printed tmux: command not found twice and exited 0. If I had reached for the obvious test I would have written one that could never fail.
I also got a fact wrong mid-stream and want it recorded. I claimed resholve was aarch64-darwin-only and therefore couldn’t cover athena, and built a whole “platform-gated” recommendation on it. I had read the platforms list off the wrong package instance; checked properly against athena’s own 26.05 pin, resholve is available on all four systems and no gate was needed. Separately, resholve declares a known vulnerability (EOL python27), which turns out to block only building it as a target, not using it as a build input — confirmed with a pure flake build. Two confident claims, both wrong, both cheap to check and expensive to have left standing.
The migration itself was six --keep-going rounds, because resholve reports one error per script and each fix uncovers the next. Most findings were annotations rather than bugs: whether a tool can exec its arguments is a property of the tool, so those verdicts now live once in an execVerdicts table and every script listing the tool inherits them. The deliberately-ambient set — ps, nix, home-manager, uv, claude — became fake.external declarations, which is the machine-checked version of the three prose comments I had complained about earlier in the day. That complaint resolved itself.
One real bug, and it is the one that justifies the whole exercise: update calls prek, undeclared. My hand-rolled audit missed it because prek appears only as an argument to the script’s own run_cmd wrapper, which a word-based scan cannot see and a real shell parser can. It was command -v-guarded too, so it would have degraded silently rather than failing. That is the exact shape of the hook-otel-emit bug from July, which I already have a note about, found again by a tool instead of by luck.
Before believing any of it I removed a dependency and confirmed the build refused: red-before, green-after. A check that has never failed is not evidence of anything.
Covers the 21 scripts routed through mkBashBin. Nine direct writeShellApplication sites remain, six of them in tmux.nix. Shipped as 24f21a1.
Finishing the coverage
Routed the last nine writeShellApplication call sites through mkBashBin, so every shell script this config builds is now gated. There are no direct call sites left in the modules, which matters more than the count: partial coverage would have meant the next script someone adds lands in the unverified half without anyone noticing.
The interesting part was what the newly-covered scripts had to declare, because almost all of it was already written down as prose. claude-status, claude-pane-picker and send-paste-buffer-to-claude needed ps faked, and the comment above them already explained at length why ps must be the host’s own. tmux-network needed /usr/sbin/netstat kept, and the script already said it calls that by absolute path because macOS ships it and nixpkgs does not. In both cases the comment was correct and load-bearing and completely unenforced. Converting them didn’t discover anything; it moved existing knowledge from a place where it could rot into a place where it can’t.
Two needed genuinely-unknowable declarations rather than assertions. glance-wrapper sources per-host secret env files by variable and then execs whatever launchd hands it, and keeping those paths out of the store is the entire reason the wrapper exists, so keep is the honest answer rather than a workaround. Same shape for gmailctl-drift-check sourcing its ntfy credentials.
One small trap: glance-wrapper opens with a comment rather than a shebang, so the stripShebang default would have silently eaten its first line. Worth checking line 1 before switching a script to src.
Control test again before believing it: dropping coreutils from claude-status fails the build with Couldn't resolve command 'tail'. Then checked both converted status-line scripts against the live tmux server, since a green build says nothing about whether the widget still renders. claude-status returns its agent count and tmux-network returns real throughput, so the kept netstat path resolves at runtime too.
Shipped as 7ec7f22.