From 3faad15a02240613765c4195ac915e49c3f51abb Mon Sep 17 00:00:00 2001 From: John Ogle Date: Sun, 19 Apr 2026 16:38:04 -0700 Subject: [PATCH] feat(openclaw): add Nix-built Docker image with app extraction from upstream Pure Nix buildLayeredImage that extracts /app from upstream ghcr.io/openclaw/openclaw via manifest-aware Python script. Avoids fromImage which breaks Debian dynamic linker by shadowing /lib -> usr/lib symlink. Includes: nix, nodejs_22, kubectl, jq, curl, git, emacs, python3+pymupdf, tea. Custom NSS with node user (UID 1000). Replicated docker-entrypoint.sh. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- flake.nix | 2 + packages/default.nix | 1 + packages/openclaw-image/default.nix | 224 ++++++++++++++++++++++++++++ 3 files changed, 227 insertions(+) create mode 100644 packages/openclaw-image/default.nix diff --git a/flake.nix b/flake.nix index 06f5994..b4afb8a 100644 --- a/flake.nix +++ b/flake.nix @@ -304,6 +304,8 @@ "custom-nextcloud-talk-desktop" = pkgs.custom.nextcloud-talk-desktop; # nix-deck kernel from Jovian-NixOS (Steam Deck) - expensive to build "nix-deck-kernel" = self.nixosConfigurations.nix-deck.config.boot.kernelPackages.kernel; + # OpenClaw docker image (pulled + augmented with nix tools) + "openclaw-image" = pkgs.custom.openclaw-image; } else { } diff --git a/packages/default.nix b/packages/default.nix index 560379a..c0a7c6c 100644 --- a/packages/default.nix +++ b/packages/default.nix @@ -8,4 +8,5 @@ pi-coding-agent = pkgs.callPackage ./pi-coding-agent { }; nextcloud-talk-desktop = pkgs.callPackage ./nextcloud-talk-desktop { }; opencode = pkgs.callPackage ./opencode { }; + openclaw-image = pkgs.callPackage ./openclaw-image { }; } diff --git a/packages/openclaw-image/default.nix b/packages/openclaw-image/default.nix new file mode 100644 index 0000000..872a56b --- /dev/null +++ b/packages/openclaw-image/default.nix @@ -0,0 +1,224 @@ +{ + pkgs, + lib, +}: + +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 + + # The openclaw application extracted from the upstream image + openclawApp + + # 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 when packaged, matrix-bot-sdk) + pkgs.nodejs_22 + + # Python with pymupdf (PDF-to-image for Claude vision) + pythonEnv + + # Gitea CLI (PR workflow) + pkgs.tea + ]; + + 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 + ''; + + 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" + ]; + }; +}