{ pkgs, lib, qmd, }: let # Pin the upstream openclaw image version # Updated by Renovate when new versions appear in flake.lock openclawImageTag = "2026.4.14"; openclawImageDigest = "sha256:7ea070b04d1e70811fe8ba15feaad5890b1646021b24e00f4795bd4587a594ed"; # Pull the upstream openclaw Docker image (only to extract /app from it) openclawBase = pkgs.dockerTools.pullImage { imageName = "ghcr.io/openclaw/openclaw"; imageDigest = openclawImageDigest; sha256 = "sha256-mSAa7lmciD6OXd3KHr8pf2VJ0aHPGnjdGbeu+oFhNo8="; finalImageTag = openclawImageTag; os = "linux"; arch = "amd64"; }; # Extract the openclaw application (/app) from the upstream Docker image. # # We don't use fromImage because Nix's copyToRoot creates a /lib/ directory # that shadows the Debian base image's /lib -> usr/lib symlink, breaking # the glibc dynamic linker (/lib64/ld-linux-x86-64.so.2 chain). # Instead, we extract only /app from the upstream image layers, then build # a pure Nix image where everything runs against Nix's glibc. # # Docker image tars use the Image Manifest v2 format: each layer is a # separate .tar within the outer tar. We extract all layers to find /app. openclawApp = pkgs.runCommand "openclaw-app" { nativeBuildInputs = [ pkgs.python3 pkgs.gnutar ]; } '' mkdir -p $out/app # Extract all layers from the Docker image tarball mkdir workdir tar xf ${openclawBase} -C workdir # Python: parse manifest, generate shell script to extract /app from layers python3 << 'PYEOF' import json with open("workdir/manifest.json") as f: manifest = json.load(f) layers = manifest[0]["Layers"] with open("extract.sh", "w") as script: script.write("#!/bin/sh\nset -e\n") for layer in layers: if layer.endswith(".tar"): lp = f"workdir/{layer}" else: lp = f"workdir/{layer}/layer.tar" script.write(f""" if [ -f '{lp}' ]; then if tar tf '{lp}' 2>/dev/null | grep -q '^app/'; then echo "Extracting /app from {layer}..." >&2 tar xf '{lp}' -C "$OUT_DIR" --strip-components=0 app/ 2>/dev/null || true fi fi """) PYEOF OUT_DIR="$out" sh extract.sh if [ ! -f "$out/app/openclaw.mjs" ]; then echo "ERROR: /app/openclaw.mjs not found after extraction" ls -la $out/app/ 2>/dev/null || true exit 1 fi echo "Successfully extracted openclaw app" ''; # Python environment with pymupdf pythonEnv = pkgs.python3.withPackages (ps: [ ps.pymupdf ]); # Custom NSS files that include the "node" user (UID 1000, GID 1000). # fakeNss only creates root/nobody, so we create our own with all three. openclawNss = pkgs.runCommand "openclaw-nss" { } '' mkdir -p $out/etc cat > $out/etc/passwd << 'EOF' root:x:0:0:root user:/var/empty:/bin/sh nobody:x:65534:65534:nobody:/var/empty:/bin/sh node:x:1000:1000::/home/node:/bin/bash EOF cat > $out/etc/group << 'EOF' root:x:0: nobody:x:65534: node:x:1000: EOF cat > $out/etc/shadow << 'EOF' root:!x::::::: nobody:!x::::::: node:!x::::::: EOF ''; # Node user home directory nodeHome = pkgs.runCommand "node-home" { } '' mkdir -p $out/home/node ''; # Docker entrypoint script — equivalent to the upstream docker-entrypoint.sh. # We replicate it as a Nix derivation to avoid extracting the Debian binary # layer and to avoid filesystem conflicts in the image customization layer. dockerEntrypoint = pkgs.writeShellScript "docker-entrypoint.sh" '' #!/bin/sh set -e # Run command with node if the first arg contains a "-" or is not a # system command. The last part inside the "{}" is a workaround for # the following bug in ash/dash: # https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=874264 if [ "''${1#-}" != "$1" ] || [ -z "$(command -v "''${1}")" ] || { [ -f "''${1}" ] && ! [ -x "''${1}" ]; }; then set -- node "$@" fi exec "$@" ''; # Wrap the entrypoint as a derivation so it can be placed via copyToRoot # instead of extraCommands (which can't write to paths that already have # Nix store symlinks from other contents) entrypointPkg = pkgs.runCommand "docker-entrypoint" { } '' mkdir -p $out/usr/local/bin cp ${dockerEntrypoint} $out/usr/local/bin/docker-entrypoint.sh chmod +x $out/usr/local/bin/docker-entrypoint.sh ''; in pkgs.dockerTools.buildLayeredImage { name = "openclaw"; tag = openclawImageTag; # Don't use fromImage — see openclawApp derivation comment maxLayers = 120; contents = [ # System basics pkgs.bashInteractive pkgs.coreutils pkgs.cacert # Custom NSS with node user openclawNss # Node user home directory nodeHome # Docker entrypoint script (in /usr/local/bin) entrypointPkg # Runtime package manager (agents can `nix run` arbitrary packages) pkgs.nix # Tools baked into the image pkgs.kubectl pkgs.jq pkgs.curl pkgs.git pkgs.emacs # Node.js 22+ (for openclaw runtime, QMD, matrix-bot-sdk) pkgs.nodejs_22 # TypeScript runtime (for running TypeScript files directly, e.g. via nix run) pkgs.tsx # Python with pymupdf (PDF-to-image for Claude vision) pythonEnv # Gitea CLI (PR workflow) pkgs.tea # QMD — on-device hybrid search for markdown (built with Node.js 22, not Bun) qmd ]; # NOTE: openclawApp is NOT in contents. It would create /app as a symlink # to /nix/store/..., which breaks OpenClaw's symlink escape security check # (resolved paths "escape" /app/dist/extensions). Instead, extraCommands # copies the real files into /app as a proper directory. extraCommands = '' # Create /tmp with correct permissions (needed by Node.js and nix) mkdir -p tmp chmod 1777 tmp # Create /run for nix-daemon socket mkdir -p run # Create /var/empty (referenced by NSS passwd home dirs) mkdir -p var/empty # Copy OpenClaw app as a REAL directory (not a Nix store symlink). # The app has a symlink escape check: resolved paths must stay within # /app/dist/extensions/. If /app is a symlink to /nix/store/HASH/app/, # realpath resolves to /nix/store/... which "escapes" the boundary. rm -rf app mkdir -p app cp -a ${openclawApp}/app/. app/ ''; config = { Entrypoint = [ "docker-entrypoint.sh" ]; Cmd = [ "node" "openclaw.mjs" "gateway" "--allow-unconfigured" ]; WorkingDir = "/app"; User = "node"; Env = [ # SSL certificates "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" "NIX_SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" # Nix configuration "NIX_PATH=nixpkgs=flake:nixpkgs" # PATH: standard dirs + Nix store bin dirs are appended by buildLayeredImage "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" "NODE_ENV=production" # Home directory (Docker User directive doesn't set HOME from /etc/passwd) "HOME=/home/node" ]; }; }