Files
aaddrick--claude-desktop-de…/docs/learnings/nix.md
T
wehub-resource-sync 3e076d4dd9
Deploy Worker / deploy (push) Failing after 1s
Shellcheck / Check shell scripts (push) Failing after 1s
CI / Build Packages (amd64 - appimage) (push) Has been skipped
CI / Build Packages (amd64 - deb) (push) Has been skipped
CI / Build Packages (amd64 - rpm) (push) Has been skipped
CI / Build Packages (arm64 - appimage) (push) Has been skipped
CI / Build Packages (arm64 - deb) (push) Has been skipped
CI / Test Flags Parsing (push) Failing after 1s
BATS Tests / BATS unit tests (push) Failing after 1s
CI / Build Packages (arm64 - rpm) (push) Has been skipped
CI / Test Build Artifacts (amd64) (push) Has been skipped
CI / Test Build Artifacts (arm64) (push) Has been skipped
CI / Update APT Repository (push) Has been cancelled
CI / Mirror Official .deb to Release (push) Has been cancelled
CI / Update DNF Repository (push) Has been cancelled
CI / Update AUR Package (push) Has been cancelled
CI / Create Release (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:29:21 +08:00

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:

Current state (v3.0.0 branch)

  • nix/claude-desktop.nix implements 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 — qemuPath and firmwarePath — 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 has vhost_vsock loaded. 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 in nix/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.nix is decoupled from node-pty; nix/node-pty.nix was deleted. The official .deb ships its own native bindings, so nothing in the flake compiles node modules anymore. The rework needed no flake.nix changes (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 a version = "..." line — keep the sed contract below intact or the auto-bump corrupts the file.

  • Extraction gotcha the implementation hit: dpkg-deb -x fails in the Nix sandbox because chrome-sandbox is recorded SUID in data.tar and tar's mode-restore is refused; use dpkg-deb --fsys-tarfile | tar -x --no-same-owner --no-same-permissions instead.

The design contract

Per official-deb-rebase-verification.md:

  • fetchurl the official .deb from the official APT pool (https://downloads.claude.ai/claude-desktop/apt/stable/pool/...). The SRI hash comes from the APT Packages index — authoritative, no download needed to compute it. The pins in scripts/setup/official-deb.sh (OFFICIAL_DEB_POOL_*, OFFICIAL_DEB_SHA256_*) are the same values in hex form.
  • Unpack and autoPatchelfHook the official co-located tree. nixpkgs precedent (verified against the nixpkgs tree): discord and vscode — both unpack a vendor tarball and autoPatchelfHook the bundled Chromium ELF in place. (signal-desktop is not precedent despite older notes here saying so: it is a source build run under nixpkgs electron_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 nixpkgs electron.
  • No resourcesPath hack. The official ELF already sits next to its resources/ directory; /proc/self/exe resolves 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 renaming OVMF_CODEOVMF_VARS / AAVMF_CODEAAVMF_VARS (Qgi = A => A.replace("OVMF_CODE","OVMF_VARS").replace("AAVMF_CODE","AAVMF_VARS")) and copies it per VM to seed efivars — coworkd aborts 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 drops OVMF_VARS beside it. nix/fhs.nix closes it with a runCommand shim in targetPkgs: nixpkgs' OVMF.fd lands firmware at FV/*.fd — not under share/ — so a bare OVMF never hits the probe; the shim symlinks the matched CODE+VARS pair into share/OVMF/… (x86_64, both Debian names aliased onto the single 4M-sized nixpkgs build) and share/AAVMF/… (aarch64: nixpkgs ships 64 MiB pflash-padded AAVMF_{CODE,VARS}.fd — verified present; the old QEMU_EFI.fd fallback was dropped, it is unpadded and has no matching VARS name). A build-time guard fails loudly if a source FV/*.fd is 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 for qemu-system-x86_64 / qemu-system-aarch64. coworkd (static Go) then launches a real accel=kvm guest — pflash OVMF, vhost-vsock-pci, virtiofsd --shared-dir, slirp usermode net. nix/fhs.nix adds qemu_kvm (the host-cpu-only build, ~1.5 GB closure vs 2.1 GB for the all-targets qemu) to targetPkgs, which lands the arch's qemu-system-* on /usr/bin. /dev/kvm and /dev/vhost-vsock are reachable inside the env — buildFHSEnv binds the whole /dev (--dev-bind /dev /dev) — but the host still has to provide them: /dev/kvm is root:kvm 0660, so a user outside the kvm group gets EACCES and coworkd's accel=kvm fails, and /dev/vhost-vsock doesn't exist until vhost_vsock is loaded (not a NixOS default). --doctor flags both. Firmware alone is necessary but not sufficient: without qemu the gate returns requirement_missing and 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 it dlopen()s the glvnd dispatcher libEGL.so.1 by bare soname.
  • A dlopen resolves against the calling object's DT_RUNPATH (verified: DT_RUNPATH is honored for dlopen, unlike the common "RPATH only" lore — but it is not transitive). The ANGLE libs carry only their own DT_NEEDED on their runpath, not libGL, so the dispatcher is unfindable.
  • runtimeDependencies does not fix this: autoPatchelf appends it to dynamic executables only (auto-patchelf.py, if file_is_dynamic_executable: rpath += runtime_deps), so it landed libGL on the main ELF but never on the .so that issues the dlopen.

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