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.
234 lines
7.0 KiB
Nix
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"
|
|
];
|
|
};
|
|
}
|