2026-08-17 The Last nixpkgs Import and Nix’s Hardcoded Search Path
What I set out to do
Answer what looked like a config-philosophy question: is there any benefit to moving to “pure Nix flakes” versus the current “nix-env plus flakes” arrangement? The honest answer required inspecting the live system rather than reasoning about the repo, so that is where it started.
The premise was already false
There is no hybrid. nix-channel --list is empty, NIX_PATH is empty, nix config show --json reports nix-path as value: [], and a repo-wide search for nix-env / nix-shell / nix-channel turns up only permission rules in claude.nix, docs, and nix profile diff-closures calls. Nothing imperative anywhere.
The one piece of nix-env left is not a choice. ~/.nix-profile carries manifest.nix rather than manifest.json, and Home Manager’s activate script branches on exactly that file (381-390): absent manifest.json, it picks nix-env -i for you. Migrating buys nothing concrete. The profile holds a single entry, home-manager-path, so nix profile list would show one entry too, and nix profile diff-closures already works against the nix-env profile (verified: it printed 541 → 542 → 543). activate:267 also runs nix-env --profile --set on both branches, so migrating would not even remove nix-env from the picture.
The actual impurity
The real finding was one line: nix/home-manager/modules/mutable-file/tests/test_resolve_backup.nix:9 did inherit ((import <nixpkgs> {})) lib. With no channels and empty nix-path, that resolved to /nix/var/nix/profiles/per-user/root/channels/nixpkgs, nixpkgs 25.11pre883899.02f2cb8e0feb, placed by the installer and present in no flake.lock.
It passed, which is the problem. In an otherwise fully-pinned repo it looked pinned, while actually evaluating lib from a tree that drifts on its own schedule. Nothing would have caught the drift either: the test was in neither nix flake check (21 checks, not among them) nor just check, which only runs prek. Same family as 2026-08-04 The Self-Exclusion Glob That Outlived Its Filename, where the gate existed but silently did not cover the thing it was assumed to cover.
The fix
The test is now a function of { lib }, wrapped by a new nix/tests/resolve-backup.nix and imported from nix/tests/default.nix. Checks went 21 → 22.
One detail worth keeping: the wrapper passes the test’s result string as a derivation attribute (pkgs.runCommand name { inherit results; } ...) rather than interpolating it into the builder script. That still forces evaluation, so the derivation cannot instantiate unless the assertions passed, but it sidesteps heredoc quoting entirely.
just test-eval in that directory now builds the flake check instead of calling nix-instantiate, so the local target and nix flake check share one nixpkgs. It prints nothing when the derivation is cached, which is normal nix behaviour; nix log replays the PASS lines.
Verified in both directions rather than just watching it go green. Deliberately broke the helper (.bak → .WRONG) and confirmed the check fails at eval time naming the exact case and both values, then reverted and confirmed a clean git diff. A new check that has only ever passed is an untested check.
Being wrong about the cleanup
I also proposed removing ~/.nix-defexpr/channels_root on the theory that it was what made <nixpkgs> resolve, so a future stray import would hard-error instead of silently finding the root channel. That reasoning was wrong, and it is worth recording precisely because it was plausible.
With channels_root gone and ~/.nix-defexpr completely empty, nix-instantiate --find-file nixpkgs still returns the same root-channel store path. Nix hardcodes that fallback into the eval search path whenever pure-eval is off, independent of the symlink and independent of the nix-path setting. The symlink only ever fed nix-env’s default-expression traversal, not <...> lookup.
The follow-on attempt failed too: pointing nix-path at a nonexistent path does nothing, because nix prints warning: Nix search path entry '...' does not exist, ignoring and falls straight through to the fallback. A poison path is silently inert. Only an entry pointing at a directory that exists shadows it, at which point import <nixpkgs> {} fails with path '<dir>/default.nix' does not exist.
Offered that as a declarative option (a tracked shadow dir with a throwing default.nix, wired through nix.settings.nix-path), but it would break ad-hoc nix-shell -p on every host, and with the last repo reference gone and a flake check now guarding it, the cost outweighed the benefit. Left the root channel alone.
What was striking
Two things. First, the question as asked had no answer worth acting on, and the useful finding was one layer down: not “should the profile change” but “what is actually unpinned here”. Second, the empty-looking evidence was the trap. Empty channels, empty NIX_PATH, and an empty nix-path all point at “<nixpkgs> cannot resolve”, and all three are compatible with it resolving fine. The only honest check is nix-instantiate --find-file nixpkgs, which is the same lesson as feedback_null_result_needs_a_control: absence of a configured source is not absence of the thing.
Also removed three genuinely dangling channel symlinks (~/.nix-defexpr/channels, ~/.local/state/nix/profiles/channels, and channels-48-link, all pointing at a store path collected long ago). nix-env -q and the profile were unaffected.
Captured to project memory as reference_nix_nixpkgs_searchpath_hardcoded_fallback and project_mutable_file_resolve_backup_flake_check. Changes staged, just check green, not yet committed.
Related
- 2026-07-01 IPython Config Test Matrix in Nix Flake Checks
- 2026-08-16 A DNS Outage Fakes a 2137-Path Source Build
- 2026-08-04 The Self-Exclusion Glob That Outlived Its Filename
Follow-up: the - package and three duplicated entries
Reading nix profile list afterwards raised a fair question: what should be there? One entry, home-manager-path, which is correct. HM aggregates 110 declared packages and 203 binaries into that single derivation, so the profile is deliberately not a per-package listing. The flake provenance lines (Flake attribute, Original/Locked flake URL) are absent for two independent reasons: the legacy manifest.nix element has no field for it, and HM installs a bare store path rather than a flake ref, so migrating to manifest.json would not populate them either. A third reason the profile migration buys nothing.
Listing the actual packages surfaced two oddities, both resolved with one technique:
nix eval --json <flake>#homeConfigurations.<sys>.options.home.packages.definitionsWithLocations \
--apply 'ds: map (d: { file = toString d.file; names = map (p: p.name or "?") d.value; }) ds'
definitionsWithLocations gives per-file attribution for any option, which turns “where does this come from” into a single eval instead of a grep hunt.
The empty-named package was a false alarm, and an instructive one. A package rendering as -, drv /nix/store/wh36qrpx5p5b4igds45s0b0l41k289h8--.drv, body "$@". It traces to my own programs.zsh.siteFunctions."$" in shell.nix:578, the function that lets a pasted $ some command just run. HM maps each site function through pkgs.writeTextDir "share/zsh/site-functions/${name}", writeTextDir takes the derivation name from baseNameOf path ($), and $ is illegal in a store path name, so lib.strings.sanitizeDerivationName "$" rewrites it to -. Verified the identity directly, and the function is deployed fine at ~/.nix-profile/share/zsh/site-functions/$.
Worth remembering as a general rule: a mangled or empty derivation name in a package list is usually sanitizeDerivationName doing its job on a legal-but-unusual attribute name, not a malformed package. I had flagged it as a probable defect after failing to find it by grepping writeText/writeShellScript, which was the wrong instrument — the grep could never have found it, because the name is computed from an attribute key, not written literally.
The duplicates were real but benign. nodejs-24.19.0, aerospace-0.21.3-Beta, and JankyBorders-1.9.0 each appear twice: once in my packages.nix (lines 128, 185, 186) and once from the HM module that owns them (programs.npm, programs.aerospace, services.jankyborders), all three enabled. The pairs resolve to identical drvPaths, so nix dedupes and there is no collision. Removable, with one caveat worth stating: the explicit entry is what keeps the binary on PATH independent of the module’s enable flag, so deleting it couples the package’s presence to the module staying enabled.
A related non-anomaly: 115 raw definitions merge to 110 packages. The 5-entry gap is modules/targets/generic-linux/nixgl.nix, Linux-only and inert on darwin. Checking that before reporting it avoided a second false positive.
Landing the dedup, and what the profile generations actually cost
Removed the three redundant entries (b28c166) and applied. Verification worth repeating on this kind of change: the dry-run rebuilt 7 home-manager aggregation derivations and zero packages, home.packages went 110 → 107 with each name present exactly once, and the built home-manager-path contained node, npm, aerospace, and borders at unchanged store paths. Stripping the explanatory comments afterwards produced a byte-identical store path, which is a cheap way to prove a comment edit is eval-inert. hm switch then moved profile 543 → 544 and home-manager 2223 → 2224, and all five binaries (including yarn) resolve.
Checked the nix-env generation links while there. Current state is lean: 3 profile generations, 2 home-manager, all from today, and nix-collect-garbage --dry-run finds nothing dead. Deleting the old ones would reclaim almost nothing, since all three profile generations share a ~9.0 GB closure and the inter-generation delta was +24.7 KiB.
The structural asymmetry from 2026-08-16 A DNS Outage Fakes a 2137-Path Source Build’s neighbourhood is still real, and I confirmed it in HM’s source rather than from memory. Both code paths hardcode the home-manager prefix:
# home-manager:894 doExpireGenerations
find "$HM_PROFILE_DIR" -name 'home-manager-*-link' -not -newermt "$1"
# home-manager:874 doRmGenerations
local linkName="home-manager-$generationId-link"The profile-*-link generations sit in the same directory and are invisible to both, so only nix-env --delete-generations clears them. That is the mechanism behind a store that grows while GC reports zero dead paths.
Auditing which declared packages have a module
packages.nix declares 49 packages directly; the other 58 in the config come from other modules. Ten name-match an HM module, but name-matching is a poor oracle and two were false positives: programs.just is removed upstream (its own error text says “simply add pkgs.just to home.packages instead”, which is a useful upstream signal that a package-only module has no value), and programs.zotero has no enable at all.
The ground truth came from a two-state extendModules probe, and the first version of it was wrong in an instructive way. Diffing package names as a set returned addsPackages: [] for every module, which reads as “installs nothing” but actually means “installs what is already declared”, because the set difference hides it. Counting occurrences instead gave the real answer: 1 → 2 for all seven live candidates. Recorded in reference_nix_hm_two_state_module_testing.
yarn is the counter-example that justifies the whole exercise: programs.yarn.enable is already true, yet yarn appears in home.packages exactly once, because that module writes .yarnrc.yml and does not install the package. Same name plus enabled module does not imply duplicate, and its packages.nix entry is load-bearing.
Of the seven, four (htop, jq, ty, obsidian) write no config at all, so enabling them would trade a one-line package entry for a one-line enable with identical effect. Pure churn.
The one that mattered: XDG_CONFIG_HOME is the repo root
That left cargo, go, and ruff as the only real decisions, and the answer turned on something easy to miss: xdg.configHome is /Users/achhina/.config, which is this repo’s root. So two of the three would write inside the working tree:
cargo→~/.cargo/config.toml, outside the repo, no complicationgo→~/.config/go/env=./go/env, andgit check-ignoreconfirms it is not ignoredruff→~/.config/ruff/ruff.toml=./ruff/ruff.toml, likewise untracked
ruff is an active no, and the repo already said so. nix/tests/ipython-config/ruff.toml exists precisely because the root was ruled out, in its own words: “Scoped to this directory rather than the repo root: a root config would newly govern every loose .py file in the tree, which is a much larger change than this warrants.” Enabling programs.ruff does exactly that through ruff’s user-level fallback, and 30 loose tracked .py files outside nix/projects/ currently have no ruff config in any ancestor.
Verified the fallback empirically instead of citing docs, in an isolated XDG_CONFIG_HOME: Found 2 errors. became All checks passed! and --show-settings named the user-level file as the Settings path. The first attempt at that test was invalid because a CLI --select overrides config ignore; dropping it gave the real result. A test that cannot distinguish the two states is worse than no test, which is the same shape as the set-difference mistake earlier in the session.
Left all three alone.
What was striking, second pass
Three separate times today the obvious-looking evidence pointed the wrong way, and each needed a control rather than more reasoning: empty channels did not mean <nixpkgs> was unresolvable, an empty set-difference did not mean a module installs nothing, and a passing ruff run did not mean the user config was ignored. The unifying habit is the one already written down in feedback_null_result_needs_a_control — construct the state where the answer must differ, and check that it does.
Also notable that the repo’s own comments carried the decision I needed twice: the ipython-config rationale and the writeTextDir chain both answered questions faster than any amount of code reading would have.
Part two: a flake update breaks mcpvault
A routine update -c later the same day died in nix flake check: error: attribute 'resolved' missing, thrown from nixpkgs’ import-npm-lock while evaluating packages.aarch64-darwin.mcpvault. Two candidate inputs had moved in the same run (nixpkgs and mcpvault-src), which is the shape that invites blaming the wrong one.
Three pieces of evidence settled it. The old mcpvault lockfile had 1 entry of 288 missing resolved; the new one had 134 of 216. nixpkgs’ import-npm-lock/default.nix last changed 2026-08-02, before both nixpkgs revs in play. And the decisive control: new nixpkgs plus old mcpvault source builds clean, exit 0. Not nixpkgs.
The mechanism is that upstream keeps a bun.lock alongside its package-lock.json, and the 0.16.0 merge regenerated the latter without the fields Nix needs.
Why “just ignore the missing field” was never an option
The obvious question was whether resolved could simply be skipped. It cannot, and the reason is sharper than “importNpmLock is strict”: resolved and integrity are written and dropped as a pair, 286/287 present in the old lock, 81/215 in the new, with resolved-only and integrity-only both exactly zero. resolved is the URL, integrity is the hash, and importNpmLock turns each pair into one fixed-output fetch:
(fetchurl { url = module.resolved; hash = module.integrity; })A missing URL is reconstructible from name and version. A missing hash is not. That is the whole of it.
The follow-up question, whether an importBunLock could sidestep it, has a satisfying answer: the format genuinely has the slot (["name@ver", "", {deps}, "sha512-..."]), so such a function is buildable in principle, and bun2nix essentially is one. But mcpvault’s bun.lock records "" in that slot for 133 of 214 packages, the same 81 complete ones as the npm export. The gap originates in the source lockfile. An importBunLock would fail identically. (Also: builtins.fromJSON cannot read bun.lock at all, since it is JSONC with trailing commas, which is why bun2nix is Rust.)
And “do it dynamically” is circular in the most fundamental way Nix has. Getting the hash needs network; network needs a fixed-output derivation; a fixed-output derivation needs the hash declared up front. Vendoring is not a workaround for that loop, it is the loop broken at the only point where it can be: the lookup runs at update time, on a machine with network and a human, and the answer gets committed.
The fix, and the regression inside the fix
Wrote nix/scripts/mcpvault-complete-lock.py to refill the fields from the npm registry for the versions upstream already pinned, rather than deleting the lock and re-resolving (which would silently drift dependencies). All 131 distinct packages are published with real hashes: 134 filled, 0 failed. importNpmLock accepted the result and built 0.16.0.
Then I broke nix flake check myself, in a way worth remembering. I swapped the lockfile in with a runCommand, which converts an eval-time read into a build-time step. nixosConfigurations.obsidian-mcp builds mcpvault for aarch64-linux, so that made it a cross-system build:
error: Cannot build '...-mcpvault-src-vendored-lock.drv'.
Required system: 'aarch64-linux' Current system: 'aarch64-darwin'
importNpmLock takes a packageLock argument, so lib.importJSON keeps it at eval time with no derivation and no system binding. The trap is that my local nix build for darwin passed throughout and told me nothing; only the Linux output exercised the broken path.
Worse, I reported that check as passing. The backgrounded job returned “exit code 0” from the harness’s shell while nix had exited 1 with the error sitting in the output. I only caught it by re-running as nix flake check > log 2>&1; echo $?. A wrapper’s status is not the command’s status, and neither is a | tail pipeline’s.
Two traps in the plumbing
Because the input stays unpinned, nix/scripts/update now regenerates the lockfile automatically. Two things nearly went wrong there.
The regeneration has to run before the flake checks, not after: a moved mcpvault-src makes the vendored lock stale, mcpvault.nix asserts on exactly that, so a check-then-regenerate order would fail on the condition the step exists to resolve. And commit_flake_lock is deliberately pathspec-scoped, so without adding the vendored lock to that pathspec the commit would have recorded a bumped nix/flake.lock while leaving the regenerated file dirty and inconsistent with it.
The script also could not stay a loose file. update is resholve-gated and the repo requires scripts be wrapped in derivations, so calling $CONFIG_HOME/nix/scripts/...py was both unvalidated and off-pattern; it became mcpvaultLockBin invoked by name, matching updateChangelogsBin. That in turn broke its repo lookup, since __file__ under a derivation resolves to a lone store path whose parent is /nix/store — it now derives the repo from XDG_CONFIG_HOME.
The commit that refused to commit
The final trap was the best one. just check was green, git commit aborted, and prettier had reformatted the generated lockfile (155 insertions, 465 deletions) despite the exclusion I had just added for it.
.pre-commit-config.yaml is a gitignored symlink into the nix store, refreshed only by just precommit-regen — which just check runs first and git commit does not. So just check tested the new rules while the commit ran the old ones. A green just check is not evidence that git commit will run the same hooks.
The underlying need is real regardless: a generated file compared byte-for-byte by its own --check must be excluded from every formatter, or it reports stale forever. Same shape as the prettier/yamlfmt fight over copier’s answers file.
Closing state
Four commits, all applied and verified live: mcpvault 0.15.0 → 0.16.0, nix flake check exit 0, ai.litellm.proxy answering HTTP 200 on /health/liveliness (the langfuse wrapt breakage I had flagged as first suspect did not materialise), generations at 2225/545.
A standing instruction landed too: never file upstream issues, and never offer to. I had suggested it three times across this session as the way to eventually retire the vendored lockfile. Captured as feedback_never_file_upstream_issues. Documenting the cause in the code comment is where that thread stops.
What was striking, third pass
The day’s theme held to the end. Every wrong turn came from evidence that looked conclusive: two inputs moved so either could be blamed, an empty set-difference read as “installs nothing”, a passing ruff run read as “config ignored”, a green just check read as “the commit will pass”, and a wrapper’s exit 0 read as nix’s. The fix each time was a control — the state where the answer must differ — not more careful reading of the same output.