16 KiB
NixOS / Nix Flake Learnings
The Nix derivation repackages the official Claude Desktop .deb
(fetchurl + autoPatchelfHook over the bare co-located tree). The
v3.0.0 implementation is a best-attempt draft (ACQ-1) — @typedrat owns
the subsystem and the final shape. This page records the design
contract the implementation follows, the SRI auto-bump sed anchors,
and the resource-path knowledge from the deleted Windows-pipeline
derivation so nobody re-introduces the hack it needed.
Source files:
nix/claude-desktop.nix— the stub; its comment block is the design contractnix/fhs.nix—buildFHSEnvwrapper, still the flake's default outputflake.nix— outputs and overlay wiringscripts/setup/official-deb.sh— the pinned URL + SHA-256 the derivation will mirror.github/workflows/check-claude-version.yml— the stub-guarded SRI auto-bump step
Current state (v3.0.0 branch)
-
nix/claude-desktop.niximplements the design below. Build- and runtime-verified on x86_64 + nvidia (real NixOS, both flake outputs: the app launches, GPU/EGL init is clean, locales load — see "The ANGLE GL trap" below for the fix that got it there). Cowork now boots a VM on x86_64 too: both gate requirements —qemuPathandfirmwarePath— are satisfied in the FHS env, and a live boot with KVM acceleration and usermode networking has been confirmed on a host that already grants kvm-group access and hasvhost_vsockloaded. Not yet verified: mesa (Intel/AMD, i.e. most NixOS users — the failing path is ANGLE's native-GL backend, and mesa picks backends differently, so it's the one GL config that didn't get exercised) and the aarch64 leg. One open question stays flagged inline: aarch64 firmware naming innix/fhs.nix. -
The Windows-installer derivation (7z-extract the exe, stock nixpkgs Electron, hand-built co-located resources tree, node-pty build) was deleted in the acquisition swap. Recover it from history:
git log --oneline -- nix/claude-desktop.nix -
flake.nixis decoupled from node-pty;nix/node-pty.nixwas deleted. The official.debships its own native bindings, so nothing in the flake compiles node modules anymore. The rework needed noflake.nixchanges (outputs/overlay wiring unchanged:claude-desktop,claude-desktop-fhs, default = FHS env). -
check-claude-version's "Update Nix SRI hashes" step is live now that the file carries aversion = "..."line — keep the sed contract below intact or the auto-bump corrupts the file. -
Extraction gotcha the implementation hit:
dpkg-deb -xfails in the Nix sandbox because chrome-sandbox is recorded SUID indata.tarand tar's mode-restore is refused; usedpkg-deb --fsys-tarfile | tar -x --no-same-owner --no-same-permissionsinstead.
The design contract
Per official-deb-rebase-verification.md:
fetchurlthe official.debfrom the official APT pool (https://downloads.claude.ai/claude-desktop/apt/stable/pool/...). The SRI hash comes from the APTPackagesindex — authoritative, no download needed to compute it. The pins inscripts/setup/official-deb.sh(OFFICIAL_DEB_POOL_*,OFFICIAL_DEB_SHA256_*) are the same values in hex form.- Unpack and
autoPatchelfHookthe official co-located tree. nixpkgs precedent (verified against the nixpkgs tree):discordandvscode— both unpack a vendor tarball andautoPatchelfHookthe bundled Chromium ELF in place. (signal-desktopis not precedent despite older notes here saying so: it is a source build run under nixpkgselectron_42, which Claude Desktop cannot use — see the resourcesPath section.) The official tree is bare co-located (/usr/lib/claude-desktop/{claude-desktop, chrome-sandbox, resources/app.asar}), so the derivation patches the shipped ELF instead of marrying the app to a nixpkgselectron. - No resourcesPath hack. The official ELF already sits next to its
resources/directory;/proc/self/exeresolves inside the app's own store path. See the retained section below for why this used to be the hard part. buildFHSEnv(nix/fhs.nix) stays the default output. MCP servers spawned by the app expect an FHS world (nodejs,uv,docker, ...); that rationale is unchanged from the Windows era.- The FHS env must bind-provide the OVMF CODE+VARS pair at the probed
path. Cowork's firmware probe list is hardcoded with no env
override: x86_64 →
/usr/share/OVMF/OVMF_CODE_4M.fd,/usr/share/OVMF/OVMF_CODE.fd; arm64 →/usr/share/AAVMF/AAVMF_CODE.fd. It then derives the writable VARS template beside the CODE file it found by renamingOVMF_CODE→OVMF_VARS/AAVMF_CODE→AAVMF_VARS(Qgi = A => A.replace("OVMF_CODE","OVMF_VARS").replace("AAVMF_CODE","AAVMF_VARS")) and copies it per VM to seed efivars —coworkdaborts with "no EFI variable-store template configured" if that sibling is missing. So the shim must ship both halves; deb/rpm get away with a CODE-only symlink (CW-1) only because the distro's edk2 package already dropsOVMF_VARSbeside it.nix/fhs.nixcloses it with arunCommandshim intargetPkgs: nixpkgs'OVMF.fdlands firmware atFV/*.fd— not undershare/— so a bare OVMF never hits the probe; the shim symlinks the matched CODE+VARS pair intoshare/OVMF/…(x86_64, both Debian names aliased onto the single 4M-sized nixpkgs build) andshare/AAVMF/…(aarch64: nixpkgs ships 64 MiB pflash-paddedAAVMF_{CODE,VARS}.fd— verified present; the oldQEMU_EFI.fdfallback was dropped, it is unpadded and has no matching VARS name). A build-time guard fails loudly if a sourceFV/*.fdis gone rather than ship a dangling symlink that only bites at VM boot — a build-behavior change for pinned aarch64 consumers, chosen because there is no clean fallback. Both arches' shim output is verified, and x86_64 now boots a VM live with it; aarch64 stays unverified. - The FHS env must also ship qemu. Cowork's VM-boot gate checks a
second requirement beyond firmware:
qemuPath, found by searching PATH forqemu-system-x86_64/qemu-system-aarch64.coworkd(static Go) then launches a realaccel=kvmguest — pflash OVMF,vhost-vsock-pci, virtiofsd--shared-dir, slirp usermode net.nix/fhs.nixaddsqemu_kvm(the host-cpu-only build, ~1.5 GB closure vs 2.1 GB for the all-targetsqemu) totargetPkgs, which lands the arch'sqemu-system-*on/usr/bin./dev/kvmand/dev/vhost-vsockare reachable inside the env — buildFHSEnv binds the whole/dev(--dev-bind /dev /dev) — but the host still has to provide them:/dev/kvmisroot:kvm 0660, so a user outside thekvmgroup getsEACCESand coworkd'saccel=kvmfails, and/dev/vhost-vsockdoesn't exist untilvhost_vsockis loaded (not a NixOS default).--doctorflags both. Firmware alone is necessary but not sufficient: without qemu the gate returnsrequirement_missingand the VM never boots.
Settled by the implementation: autoPatchelfHook covers the full
dependency surface (zero unsatisfied deps — main ELF, virtiofsd,
chrome-native-host; coworkd is static Go and skipped), and
chrome-sandbox SUID is dropped in favor of unprivileged user
namespaces (the NixOS default; standard stance for nixpkgs'
Chromium-based apps — no --no-sandbox anywhere).
The ANGLE GL trap
The autoPatchelf-satisfied build still crash-looped at startup on
real NixOS: the GPU process failed EGL init with Could not dlopen native EGL: libEGL.so.1, exited, and relaunched forever. Root cause,
traced on 1.18286.0:
- Chromium's bundled ANGLE lives in the co-located
libEGL.so/libGLESv2.so. At GPU init itdlopen()s the glvnd dispatcherlibEGL.so.1by bare soname. - A
dlopenresolves against the calling object'sDT_RUNPATH(verified:DT_RUNPATHis honored fordlopen, unlike the common "RPATH only" lore — but it is not transitive). The ANGLE libs carry only their ownDT_NEEDEDon their runpath, notlibGL, so the dispatcher is unfindable. runtimeDependenciesdoes not fix this:autoPatchelfappends it to dynamic executables only (auto-patchelf.py,if file_is_dynamic_executable: rpath += runtime_deps), so it landedlibGLon the main ELF but never on the.sothat issues thedlopen.
Fix: appendRunpaths (which autoPatchelf applies to every patched
file) adds ${lib.getLib libGL}/lib and
${addDriverRunpath.driverLink}/lib to all runpaths. Once ANGLE can
load glvnd's libEGL.so.1, NixOS-patched glvnd self-locates the vendor
ICD under /run/opengl-driver, so the second hop needs no extra wiring.
Chosen over a makeWrapper --suffix LD_LIBRARY_PATH (which nixpkgs'
discord uses) because the app spawns MCP servers (node, uv,
docker) — a wrapper would leak the driver tree into their environment;
a runpath edit is scoped to the ELFs that need it. Verified: both flake
outputs launch with clean GPU/EGL init on real NixOS x86_64 (nvidia).
The Vulkan half needs a wrapper anyway. ANGLE's native-GL backend is
what nvidia exercised. On mesa, if ANGLE falls through to its Vulkan
backend (or Chromium lands on SwiftShader), the co-located
libvulkan.so.1 — the stock Khronos loader — searches the standard FHS
ICD dirs (/usr/share/vulkan/icd.d, …), which are empty on NixOS, and
finds no hardware driver. Runpath can't fix this: the loader keys on the
VK_ADD_DRIVER_FILES/VK_DRIVER_FILES env vars and fixed filesystem
paths, never DT_RUNPATH. So installPhase wraps the launcher to
prepend ${addDriverRunpath.driverLink}/share/vulkan/icd.d to
VK_ADD_DRIVER_FILES. That var is additive (put before the standard
search, not replacing it), so a missing dir or a user's own setting still
wins — same dangling-safe property as appendRunpaths. This reintroduces
the one wrapper the GL fix avoided, but the leak is benign here: the
spawned MCP servers are CLI processes that never init Vulkan, and the ICD
dir is the correct value for any that did. This path is unverified —
the nvidia box never took the Vulkan branch; it's wired defensively for
the mesa configs that might.
The SRI auto-bump contract
Once the stub is replaced, check-claude-version expects this shape
(from the workflow's sed anchors):
version = "1.18286.0";
# one hash per arch block, each closed by };
x86_64-linux = { url = "..."; hash = "sha256-..."; };
aarch64-linux = { url = "..."; hash = "sha256-..."; };
The workflow converts the Packages-index hex digest to SRI
(xxd -r -p | base64) and range-seds each arch block. Diverge from
this shape and the auto-bump silently rewrites the wrong hash — keep
exactly one hash = "..." per arch block.
glibc floors the derivation inherits
From objdump on the official 1.18286.0 tree: the main Electron ELF
needs glibc 2.25; virtiofsd and chrome-native-host need
2.34 (matching the official libc6 (>= 2.34) Depends); coworkd
is static. On Nix this is mostly moot (nixpkgs glibc is well past
2.34), but it is the support boundary for anyone pinning an old
nixpkgs: the core app is more portable than the Depends line suggests,
and Cowork/browser-bridge are the 2.34-bound parts.
Why the resourcesPath hack existed — and why it must not return
Kept from the Windows-pipeline era. The old derivation's central problem is gone, but only because of how the official tree is laid out — this section is the guard against reintroducing the hack (or the failure it fixed) in the rework.
The old problem: the Windows-era derivation ran the app under the
nixpkgs electron package, so Electron and the app lived in separate
Nix store paths. Chromium computes process.resourcesPath from
/proc/self/exe, which resolved to electron-unwrapped's store path;
the app's locale files, tray icons, and other resources lived
elsewhere and weren't found.
/proc/self/exe resolves symlinks. This is why symlinkJoin and
symlink-based trees don't work: the kernel follows symlinks to the
real binary, so resourcesPath always pointed at
electron-unwrapped's directory. The only fix was a real copy of the
ELF into a tree that also contained the merged resources/ (PR
#368).
The ENOENT was JS, not C++. The isPackaged=true failure was
readFileSync loading en-US.json from process.resourcesPath at
module top-level in the minified bundle — before any wrapper could
correct the path. Claude Desktop is unusual among Electron apps in
loading locale JSONs from resourcesPath at module init with no
fallback, which is why the standard nixpkgs
makeWrapper electron --add-flags app.asar pattern (Obsidian, Vesktop)
was never enough here.
And it's broader than locales. Verified against the shipping
1.18286.0 bundle: when isPackaged, process.resourcesPath (no
fallback) also resolves the loose native helpers virtiofsd,
cowork-linux-helper, and the smol-bin.*.img Cowork VM images — all
shipped in resources/ outside app.asar. Under a nixpkgs
electron, resourcesPath points at electron's own dir and every one
of these orphans, not just the locale JSONs. The locale loader is
function _0t(){return isPackaged?process.resourcesPath:…} feeding a
readdirSync/readFileSync of ${lang}.json.
There is no override. No Electron env var or CLI flag overrides
resourcesPath; a --resources-path PR
(electron/electron#36114)
was closed in Nov 2025 over security concerns, and the property was
made read-only in Electron 28.2.1.
Why it's moot now: the official .deb ships its own Electron ELF
bare co-located with resources/ in one tree. The derivation copies
that tree into a single store path and patches the ELF in place, so
/proc/self/exe resolves inside the app's own tree and
resourcesPath is correct by construction. No nixpkgs electron, no
ELF-copy-plus-symlink-merge, no wrapper surgery. If a future rework is
ever tempted to swap the bundled ELF for a nixpkgs electron (e.g.
for CVE turnaround), this whole section becomes load-bearing again —
that path requires the PR #368 tree-merge technique, and the locale
JSONs (shipped loose in the official tree) are the first thing to
break.
One related constraint survives unchanged: the Nix store is read-only, so any file-layout fix (firmware symlinks, resource merges) must happen at build time in the derivation or via the FHS env's bind layer — never "at runtime, add a symlink into the store."
Testing Nix changes without NixOS
Kept from the Windows-pipeline era; the technique is unchanged.
A Fedora distrobox with the Nix package manager (Determinate Systems
installer, --init none for no-systemd containers) can build and run
the flake. The derivation produces identical store paths whether built
on NixOS or standalone Nix. Start the daemon manually with
sudo nix-daemon & before building.
This validates build success and basic app startup, but is not a
substitute for real NixOS testing (system integration, desktop
environment, Cowork's KVM path). The v3.0.0 x86_64 build verification
ran exactly this way (container nixtest, Fedora 43 + Determinate
Nix); the remaining validation gaps in "Current state" above are the
things a container cannot prove.
References
official-deb-rebase-verification.md— install-layout facts (bare co-located tree, OVMF probe list, glibc floors, per-arch dependency contract)cowork-vm-daemon.md— the Cowork VM daemon that consumes the OVMF firmware- #368 — the old ELF-copy resourcesPath fix (historical)
- electron/electron#36114
— the rejected
--resources-pathoverride