2026-09-06 JupyterLab Overrides Were Never Read
What I set out to do
Add “always start preferred kernel” to the JupyterLab settings managed in
nix/home-manager/modules/jupyter.nix. Expected a one-line change.
What I actually did
The setting was already there. autoStartDefaultKernel = true has been in the
module’s @jupyterlab/notebook-extension:tracker block the whole time — it is
the schema key behind the “Automatically Start Preferred Kernel” toggle. But it
had visibly not been working, which is why I’d toggled it by hand in the UI.
That reframed the task from “add a setting” to “why is the declarative config
inert.” The answer: none of the module’s JupyterLab overrides have ever
applied. JupyterLab resolves overrides.json from exactly one directory,
LabApp.app_settings_dir, defaulting to <app_dir>/settings inside the uv tool
venv. jupyterlab_server/handlers.py calls _get_overrides(app_settings_dir)
with that single path. JUPYTER_CONFIG_PATH is never consulted for overrides.
The module was writing to ~/.config/jupyter/lab/settings/overrides.json; the
directory JupyterLab actually reads did not exist at all.
Proved it with a live server rather than by reading source — an isolated config
dir, curl /lab/api/settings/@jupyterlab/notebook-extension:tracker, reading
.schema.properties.<key>.default. Control run with the old layout served the
upstream default; test run with c.LabApp.app_settings_dir set served the
override.
Fixed by relocating the trait instead of writing into the venv (which
uv tool upgrade would wipe):
home.file.".config/jupyter/jupyter_lab_config.py".text = ''
c.LabApp.app_settings_dir = "${labSettingsDir}"
'';jupyter_lab_config.py is the correct hook — extension apps read
jupyter_<name>_config.py off the config path, but do not read
jupyter_server_config.py. Applied, verified live, just check green.
Then Adam corrected the architecture, and he was right. The point of the HM
module is to be our user overrides. overrides.json is the admin layer and it
lives in an install-dependent directory; the user layer is respected by every
instance of Lab. My fix also depended on JUPYTER_CONFIG_PATH — env -u JUPYTER_CONFIG_PATH jupyter --paths drops ~/.config/jupyter entirely — while
~/.jupyter/lab/user-settings resolves from jupyter_config_dir() with no
environment dependency.
I overstated that one and had to walk it back. I claimed the variable was
login-shell-only, on the strength of env -i /bin/sh -c returning unset. But
that just proves sh doesn’t read zsh config. HM sources hm-session-vars.sh
from .zshenv, which zsh reads on every invocation, so zsh -c, zsh -lc
and zsh -ic all have it. The real gap is only processes with no zsh in the
launch path at all: launchd/GUI apps, Login Items, /bin/sh, cron. Real, but
narrow — and I’d written the wrong version into a code comment as a constraint,
which is precisely the failure mode that comment style is supposed to prevent.
Lesson: a negative result needs a control that varies one thing. I varied
the shell and read it as varying the login mode.
His second point dissolved my main objection to the user layer. I had argued that
owning those files blocks the UI from saving keys we don’t declare — true for a
read-only home.file symlink, but that is exactly what mutableFile patch mode
exists for: merge into a file another application owns, so undeclared keys
survive and a UI edit holds until the next hm switch re-asserts ours. The
“reverts on switch” behaviour is the feature, not the cost.
Rewrote it that way: one settings attrset keyed by plugin id, mapped onto
per-plugin mutableFile entries; overrides.json and jupyter_lab_config.py
both deleted. The one real blocker was that the Settings editor writes JSON5
comments into its own files and merge.py parsed with strict json, so I added
a --dialect flag and a format = "json5" value to the mutable-file module
(output stays strict JSON, which JSON5 readers accept). Verified the whole
contract on a scratch file: comments parse, a declared key resets 999 → 50, an
undeclared key survives, and --dialect json still fails loudly for the other
three consumers.
Then: “instead of having that as a comment, can we add this as a test?” Which is
the right end state for a constraint like this. nix/tests/jupyter-settings-path/
now asserts every deployed target against JupyterLab’s own resolvers
(get_user_settings_dir + settings_utils._path) rather than a second copy of
the convention — a copy would have agreed with the broken code. Plus
format = "json5" per entry and a non-vacuity guard. Falsified all three
branches before believing it.
Which is also where I embarrassed myself twice. The check was broken when I
first called it green: runCommand name attrs (optionalString …) + ''…'' parses
as (runCommand …) + string, so the flake rejected the non-package — and I
missed it because I read nix build | tail -2, and tail exits 0 no matter what
the pipeline did. Second time this session I checked a proxy instead of the
thing. And a blanket git add -A swept someone else’s in-flight
docker-egress-recover work into my index; caught it at commit time and unstaged
it. Two commits landed on main: feat(mutable-file) for the json5 capability,
fix(jupyter) for the layer move plus the test.
Also: told me inline comments are a code smell, mid-turn. Third time in my notes. Trimmed to 8 comment lines in 170, keeping only the two a reader would break by deleting — and found a pre-existing rotted one (“Register ruff and ty” when only ty is registered) while auditing.
What was striking
Three things made this survivable for months:
The wrong path was more plausible than the right one. It mirrors Jupyter’s
config-dir layout, jupyter --paths lists that dir first, and a neighbour file
in the same directory (jupyter_server_config.py, the ty LSP registration) is
genuinely honored. So the directory was live; exactly one mechanism in it was not.
The failure is silent by construction — _get_overrides skips non-existent
paths without a warning. A missing overrides file is indistinguishable from no
overrides.
And the workaround destroyed the evidence. Every time a default didn’t take,
toggling it in the UI fixed it in seconds and wrote a user-settings file that
masked the defect permanently. Eight plugins accumulated that way; six of the
module’s twelve declarations were silently superseded by the time I noticed.
The generalizable lesson, and the one worth carrying past Jupyter: home.file
proves the file was written, never that anything reads it. For any app
that resolves config through a search path, deployment and consumption are
separate claims and only the first one is checked. Same shape as the
session-variable and passthru.tests gotchas. A green switch is not evidence of
effect. The cheap counter is to hit the app’s own API once and read back the
effective value.
Top 3 tomorrow
jupyter_server_config.py(the ty LSP registration) and jupyter-ai’s config still sit in~/.config/jupyter, so they needJUPYTER_CONFIG_PATH. Every zsh has it, so this is marginal hardening rather than a bug — deprioritized.- Audit whether other HM modules write config into a search path never verified end-to-end.
- Consider whether the verify-the-read step deserves to be a documented habit for search-path config, not just a one-off.
Related
JupyterLab Claude Code · Migrate uvx.nix to uv2nix · Notebook V7 Rebuilt the Classic Experience on JupyterLab