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_PATHenv -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

  1. jupyter_server_config.py (the ty LSP registration) and jupyter-ai’s config still sit in ~/.config/jupyter, so they need JUPYTER_CONFIG_PATH. Every zsh has it, so this is marginal hardening rather than a bug — deprioritized.
  2. Audit whether other HM modules write config into a search path never verified end-to-end.
  3. Consider whether the verify-the-read step deserves to be a documented habit for search-path config, not just a one-off.

JupyterLab Claude Code · Migrate uvx.nix to uv2nix · Notebook V7 Rebuilt the Classic Experience on JupyterLab