Files
nixos-configs/packages/openclaw-image/default.nix
John Ogle af496ce9ca
Some checks failed
CI / check (push) Failing after 2m5s
CI / build-and-cache (push) Has been skipped
CI / Build & Push OpenClaw Image (push) Has been skipped
CI / Deploy OpenClaw to Cluster (push) Has been skipped
fix(openclaw): copy /app as real directory to avoid symlink escape check
The OpenClaw runtime validates that resolved symlinks stay within
/app/dist/extensions/. When /app was a Nix store symlink, realpath
resolved to /nix/store/ which 'escaped' the boundary. Now we copy
the app files into /app as a real directory in extraCommands.
2026-04-19 16:58:07 -07:00

234 lines
7.0 KiB
Nix

{
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
# 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
];
# 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"
];
};
}