From 911e844c2f26caded49ab70f64294582d34a67b5 Mon Sep 17 00:00:00 2001 From: "randomizedcoder dave.seddon.ca@gmail.com" Date: Mon, 23 Mar 2026 11:08:40 -0700 Subject: [PATCH 1/7] feat(nix): add Nix flake for reproducible builds and containers Add modular Nix infrastructure that provides: - Dev shell with all build/lint/test tools (nix develop) - NemoClaw package build mirroring Dockerfile's 2-stage pattern - OCI container image via dockerTools.buildLayeredImage - 27-check container smoke test suite - Sphinx documentation build (best-effort) OpenClaw is sourced from nixpkgs (pkgs.openclaw). All config is centralized in constants.nix with filtered sources for cache efficiency. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 + flake.lock | 61 +++++++++++ flake.nix | 68 ++++++++++++ nix/README.md | 234 +++++++++++++++++++++++++++++++++++++++++ nix/constants.nix | 63 +++++++++++ nix/container-test.nix | 131 +++++++++++++++++++++++ nix/container.nix | 131 +++++++++++++++++++++++ nix/docs.nix | 39 +++++++ nix/package.nix | 92 ++++++++++++++++ nix/shell.nix | 40 +++++++ nix/source-filter.nix | 55 ++++++++++ 11 files changed, 917 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 nix/README.md create mode 100644 nix/constants.nix create mode 100644 nix/container-test.nix create mode 100644 nix/container.nix create mode 100644 nix/docs.nix create mode 100644 nix/package.nix create mode 100644 nix/shell.nix create mode 100644 nix/source-filter.nix diff --git a/.gitignore b/.gitignore index 9ddd809b8..bd819e453 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ secrets.json secrets.yaml service-account*.json token.json + +# Nix +/result diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..9735ce2a1 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1773821835, + "narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..e3befa6ab --- /dev/null +++ b/flake.nix @@ -0,0 +1,68 @@ +{ + description = "NemoClaw — run OpenClaw inside OpenShell with NVIDIA inference"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + config.permittedInsecurePackages = [ + "openclaw-2026.3.12" + ]; + }; + + # Pure data — no pkgs needed + constants = import ./nix/constants.nix; + + # Centralized runtime dependencies (single source of truth) + nodejs = pkgs.nodejs_22; + python = pkgs.python314.withPackages (ps: [ ps.pyyaml ]); + + # Source filters + sources = pkgs.callPackage ./nix/source-filter.nix { inherit constants; }; + + # OpenClaw CLI from nixpkgs + openclaw = pkgs.openclaw; + + # NemoClaw package (TS plugin + assembly) + nemoclaw = pkgs.callPackage ./nix/package.nix { + inherit constants sources openclaw nodejs python; + }; + + # OCI container image + container = pkgs.callPackage ./nix/container.nix { + inherit constants nemoclaw openclaw nodejs python; + }; + + # Documentation (best-effort, uses its own Python with Sphinx packages) + docs = pkgs.callPackage ./nix/docs.nix { inherit constants sources; }; + + # Container smoke-test script + container-test = pkgs.callPackage ./nix/container-test.nix { + inherit constants container; + docker = pkgs.docker-client; + }; + + in + { + packages = { + default = nemoclaw; + inherit nemoclaw openclaw container container-test docs; + }; + + devShells.default = pkgs.callPackage ./nix/shell.nix { + inherit nemoclaw nodejs python; + }; + + checks = { + inherit nemoclaw; + shell = self.devShells.${system}.default; + }; + } + ); +} diff --git a/nix/README.md b/nix/README.md new file mode 100644 index 000000000..37716a540 --- /dev/null +++ b/nix/README.md @@ -0,0 +1,234 @@ +# NemoClaw Nix Infrastructure + +Modular Nix flake for building, developing, and containerizing NemoClaw. + +## What is Nix? + +[Nix](https://nixos.org) is a package manager that provides **reproducible, isolated** +environments. It tracks all dependencies and pins exact versions so every developer +gets the same toolchain — no more "it worked on my machine". Nix packages live in +`/nix/store/` and do not interfere with your system packages. When you exit the +Nix shell, everything goes back to normal. + +## Quick Start + +### 1. Install Nix + +If you don't have Nix installed, grab it from . + +**Multi-user install** (recommended): + +```bash +bash <(curl -L https://nixos.org/nix/install) --daemon +``` + +**Single-user install** (no root required): + +```bash +bash <(curl -L https://nixos.org/nix/install) --no-daemon +``` + +#### Video Tutorials + +| Platform | Video | +|----------|-------| +| Ubuntu | [Installing Nix on Ubuntu](https://youtu.be/cb7BBZLhuUY) | +| Fedora | [Installing Nix on Fedora](https://youtu.be/RvaTxMa4IiY) | + +### 2. Enable Flakes + +NemoClaw uses Nix **flakes**, which are still marked "experimental" in Nix. +You can enable them per-command or permanently. + +**Per-command** (no config changes needed): + +```bash +nix --extra-experimental-features 'nix-command flakes' develop +``` + +**Permanently** (recommended — add once, forget about it): + +```bash +# Create the config directory if it doesn't exist +test -d /etc/nix || sudo mkdir /etc/nix + +# Enable flakes +echo 'experimental-features = nix-command flakes' | sudo tee -a /etc/nix/nix.conf +``` + +After that, all `nix` commands work without the extra flag. See also the +[Nix Flakes wiki page](https://nixos.wiki/wiki/flakes). + +### 3. Build and Develop + +All commands below assume flakes are enabled. If not, prepend +`--extra-experimental-features 'nix-command flakes'` to each `nix` command. + +```bash +# Enter dev shell with all build/lint/test tools +nix develop + +# Build NemoClaw package +nix build + +# Build OpenClaw CLI (from nixpkgs) +nix build .#openclaw + +# Build OCI container image +nix build .#container + +# Smoke-test the container (requires Docker daemon) +nix run .#container-test + +# Build documentation (best-effort, may need extra packages) +nix build .#docs +``` + +### First Run + +On the first run, Nix downloads and builds all dependencies — this can take +several minutes. Subsequent runs reuse the cache in `/nix/store/` and are +nearly instant. + +Nix will **not** touch your system packages. Everything is isolated and +disappears when you exit the shell. + +## Container Usage + +### Image Size + +| Metric | Size | +|--------|------| +| Compressed tarball (`result`) | ~1.1 GiB | +| Uncompressed (Docker) | ~3.1 GiB | + +The image uses `dockerTools.buildLayeredImage` with 120 max layers for +efficient Docker layer caching. Most of the size comes from Node.js, Python, +Git, and the OpenClaw CLI. + +### Smoke Test + +```bash +# Build, load, and run 27 structural checks (requires Docker daemon) +nix run .#container-test +``` + +The test verifies: binaries on PATH, Node.js version, filesystem layout +(sandbox home, `.openclaw`/`.openclaw-data` split, plugin dir, blueprints), +symlink integrity, user/group IDs, SSL certs, and Python packages. + +### Manual Usage + +```bash +# Build and load into Docker +nix build .#container +docker load < result + +# Run with default settings +docker run -it nemoclaw:0.1.0 + +# Run with custom model and API key +docker run -it \ + -e NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b \ + -e NVIDIA_API_KEY=your-key \ + -e CHAT_UI_URL=http://127.0.0.1:18789 \ + -p 18789:18789 \ + nemoclaw:0.1.0 \ + /usr/local/bin/nemoclaw-start + +# Or use the nemoclaw CLI directly +docker run -it nemoclaw:0.1.0 nemoclaw --help +``` + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `NEMOCLAW_MODEL` | `nvidia/nemotron-3-super-120b-a12b` | Inference model | +| `CHAT_UI_URL` | `http://127.0.0.1:18789` | Chat UI origin | +| `NVIDIA_API_KEY` | (none) | API key for NVIDIA-hosted inference | + +## Architecture + +``` +flake.nix # Coordinator — imports from ./nix/ + | + +-- nix/constants.nix # Pure data: versions, paths, user config + +-- nix/source-filter.nix # Filtered sources for reproducible builds + +-- nix/package.nix # NemoClaw: TS plugin build + assembly + +-- nix/shell.nix # Dev shell (mkShell + inputsFrom) + +-- nix/container.nix # OCI image (dockerTools.buildLayeredImage) + +-- nix/container-test.nix # Container smoke tests (writeShellApplication) + +-- nix/docs.nix # Sphinx documentation build +``` + +OpenClaw comes directly from nixpkgs (`pkgs.openclaw`). + +### Module Dependencies + +``` +constants.nix --+---> source-filter.nix --+---> package.nix --+---> container.nix + | | | + | pkgs.openclaw ---------+-------------------+ + | | | + | +---> docs.nix +---> shell.nix + | + +---> (all modules read constants) +``` + +## File Reference + +| File | Purpose | +|------|---------| +| `constants.nix` | Shared config: versions, user, paths, defaults | +| `source-filter.nix` | `lib.cleanSourceWith` filters for each sub-project | +| `package.nix` | Two-phase build: `buildNpmPackage` (TS) + assembly | +| `shell.nix` | Dev shell via `mkShell` with `inputsFrom` | +| `container.nix` | OCI image via `dockerTools.buildLayeredImage` | +| `container-test.nix` | Container smoke tests via `writeShellApplication` | +| `docs.nix` | Sphinx documentation (best-effort) | + +## Updating npm Hashes + +When `nemoclaw/package-lock.json` changes, the `npmDepsHash` in `package.nix` +will become invalid. To update: + +1. Set `npmDepsHash = lib.fakeHash;` in `nix/package.nix` +2. Run `nix build` — it will fail and print the correct hash +3. Replace `lib.fakeHash` with the printed hash + +## Troubleshooting + +**"error: experimental Nix feature 'flakes' is disabled"** + +You haven't enabled flakes yet. Either pass the flag per-command: + +```bash +nix --extra-experimental-features 'nix-command flakes' build +``` + +Or enable permanently (see [Enable Flakes](#2-enable-flakes) above). + +**First build is slow** + +This is expected. Nix is downloading and building all dependencies from +scratch. After the first build, everything is cached in `/nix/store/` and +rebuilds only what changed. + +**"hash mismatch in fixed-output derivation"** + +The npm dependency hash is stale. See [Updating npm Hashes](#updating-npm-hashes). + +**Container test fails with "Cannot connect to the Docker daemon"** + +The `container-test` target requires a running Docker daemon. Make sure Docker +is installed and your user is in the `docker` group (or use `sudo`). + +## Design Decisions + +- **`callPackage` for all modules** (except `constants.nix`) — auto-injects nixpkgs deps +- **Two-derivation package build** — plugin compiled separately, then assembled +- **OpenClaw from nixpkgs** — uses the upstream `pkgs.openclaw` package +- **Runtime config generation** — `openclaw.json` written by start script, not baked in +- **nixpkgs for standalone tools** (ruff, shellcheck); npm-local for version-locked devDeps +- **120-layer OCI image** — maximizes Docker layer cache efficiency diff --git a/nix/constants.nix b/nix/constants.nix new file mode 100644 index 000000000..b9e9134ff --- /dev/null +++ b/nix/constants.nix @@ -0,0 +1,63 @@ +# Shared configuration for all NemoClaw Nix modules. +# Pure data — no nixpkgs dependency. +rec { + # Version pins — reference only; actual package selection is in flake.nix. + # openclaw version is controlled by nixpkgs. + nodeVersion = "22"; + pythonVersion = "314"; + nemoclawVersion = "0.1.0"; + + # Container user + user = { + name = "sandbox"; + group = "sandbox"; + uid = 1000; + gid = 1000; + home = "/sandbox"; + shell = "/bin/bash"; + }; + + # Filesystem paths + paths = { + pluginDir = "/opt/nemoclaw"; + blueprintDir = "/opt/nemoclaw-blueprint"; + openclawConfig = "/sandbox/.openclaw"; + openclawData = "/sandbox/.openclaw-data"; + nemoclawState = "/sandbox/.nemoclaw"; + }; + + # Runtime defaults + defaults = { + model = "nvidia/nemotron-3-super-120b-a12b"; + chatUiUrl = "http://127.0.0.1:18789"; + }; + + # Directories under .openclaw-data that get symlinked into .openclaw + openclawDataDirs = [ + "agents/main/agent" + "extensions" + "workspace" + "skills" + "hooks" + "identity" + "devices" + "canvas" + "cron" + ]; + + # Top-level symlink targets (derived from openclawDataDirs at the first path component) + openclawSymlinks = builtins.attrNames (builtins.listToAttrs + (map (d: { name = builtins.head (builtins.split "/" d); value = true; }) + openclawDataDirs)); + + # Patterns excluded from source filtering + excludePatterns = [ + ".git" + "node_modules" + "dist" + "__pycache__" + "nix" + "flake.nix" + "flake.lock" + ]; +} diff --git a/nix/container-test.nix b/nix/container-test.nix new file mode 100644 index 000000000..3cf7e57cb --- /dev/null +++ b/nix/container-test.nix @@ -0,0 +1,131 @@ +# Smoke-test script for the NemoClaw OCI container. +# Loads the image into Docker, runs structural checks, and reports image size. +# +# Usage: nix run .#container-test +{ writeShellApplication, docker, coreutils, gawk +, constants, container }: + +writeShellApplication { + name = "nemoclaw-container-test"; + + runtimeInputs = [ docker coreutils gawk ]; + + text = '' + set -euo pipefail + + IMAGE="nemoclaw:${constants.nemoclawVersion}" + CONTAINER="" + PASS=0 + FAIL=0 + + cleanup() { + if [ -n "$CONTAINER" ]; then + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true + fi + } + trap cleanup EXIT + + check() { + local desc="$1"; shift + if "$@" >/dev/null 2>&1; then + echo " PASS $desc" + PASS=$((PASS + 1)) + else + echo " FAIL $desc" + FAIL=$((FAIL + 1)) + fi + } + + check_output() { + local desc="$1" expected="$2"; shift 2 + local output + output=$("$@" 2>&1) || true + if echo "$output" | grep -qF "$expected"; then + echo " PASS $desc" + PASS=$((PASS + 1)) + else + echo " FAIL $desc (expected '$expected', got '$output')" + FAIL=$((FAIL + 1)) + fi + } + + run_in() { + docker exec "$CONTAINER" bash -c "$1" + } + + # ── Load image ────────────────────────────────────────────── + echo "Loading container image..." + docker load < ${container} + + # ── Report image size ─────────────────────────────────────── + echo "" + echo "=== Image Size ===" + docker image inspect "$IMAGE" --format='{{.Size}}' \ + | awk '{ printf " Uncompressed: %.0f MiB\n", $1/1024/1024 }' + TARBALL_SIZE=$(stat -c%s ${container}) + echo " Compressed tarball: $((TARBALL_SIZE / 1024 / 1024)) MiB" + echo "" + + # ── Start container ───────────────────────────────────────── + echo "Starting container..." + CONTAINER=$(docker create --name nemoclaw-nix-test "$IMAGE" -c "sleep 300") + docker start "$CONTAINER" + + echo "" + echo "=== Structural Checks ===" + + # Binaries present + check "node is on PATH" run_in "command -v node" + check "python3 is on PATH" run_in "command -v python3" + check "openclaw is on PATH" run_in "command -v openclaw" + check "nemoclaw is on PATH" run_in "command -v nemoclaw" + check "git is on PATH" run_in "command -v git" + check "curl is on PATH" run_in "command -v curl" + check "bash is on PATH" run_in "command -v bash" + + # Runtime versions + check "node is v${constants.nodeVersion}.x" run_in "node -e \"assert(process.version.startsWith('v${constants.nodeVersion}.'))\"" + + # Filesystem layout + check "/sandbox exists" run_in "test -d /sandbox" + check ".openclaw dir exists" run_in "test -d ${constants.paths.openclawConfig}" + check ".openclaw-data dir exists" run_in "test -d ${constants.paths.openclawData}" + check "plugin dir exists" run_in "test -d ${constants.paths.pluginDir}" + check "plugin dist/ exists" run_in "test -d ${constants.paths.pluginDir}/dist" + check "plugin package.json exists" run_in "test -f ${constants.paths.pluginDir}/package.json" + check "plugin openclaw.plugin.json exists" run_in "test -f ${constants.paths.pluginDir}/openclaw.plugin.json" + check "plugin registry exists" run_in "test -f ${constants.paths.openclawData}/extensions/plugins.json" + check "nemoclaw-start script exists" run_in "test -x /usr/local/bin/nemoclaw-start" + check "blueprints dir exists" run_in "test -d ${constants.paths.nemoclawState}/blueprints/${constants.nemoclawVersion}" + + # Symlinks from .openclaw -> .openclaw-data + check "agents symlink" run_in "test -L ${constants.paths.openclawConfig}/agents" + check "extensions symlink" run_in "test -L ${constants.paths.openclawConfig}/extensions" + check "workspace symlink" run_in "test -L ${constants.paths.openclawConfig}/workspace" + + # User/permissions + check_output "runs as uid ${toString constants.user.uid}" "${toString constants.user.uid}" run_in "id -u" + check_output "runs as gid ${toString constants.user.gid}" "${toString constants.user.gid}" run_in "id -g" + + # passwd/group + check "/etc/passwd exists" run_in "test -f /etc/passwd" + check "/etc/group exists" run_in "test -f /etc/group" + + # SSL certs (needed for API calls) + check "CA certs available" run_in "test -f /etc/ssl/certs/ca-bundle.crt" + + # pyyaml importable + check "pyyaml importable" run_in "python3 -c 'import yaml'" + + # ── Summary ───────────────────────────────────────────────── + echo "" + TOTAL=$((PASS + FAIL)) + echo "=== Results: $PASS/$TOTAL passed ===" + if [ "$FAIL" -gt 0 ]; then + echo "FAILED: $FAIL check(s) did not pass." + exit 1 + else + echo "All checks passed." + fi + ''; +} diff --git a/nix/container.nix b/nix/container.nix new file mode 100644 index 000000000..3182af9a3 --- /dev/null +++ b/nix/container.nix @@ -0,0 +1,131 @@ +# OCI container image via dockerTools.buildLayeredImage. +# Replicates the Dockerfile layout: sandbox user, openclaw config/data split, +# plugin registration, and DAC lockdown. +{ lib, dockerTools, runCommand, writeTextFile +, bash, coreutils, findutils, cacert, git, curl, iproute2 +, constants, nemoclaw, openclaw, nodejs, python }: + +let + # Generate /etc/passwd and /etc/group entries + passwdEntry = '' + root:x:0:0:root:/root:/bin/bash + ${constants.user.name}:x:${toString constants.user.uid}:${toString constants.user.gid}:${constants.user.name}:${constants.user.home}:${constants.user.shell} + ''; + + groupEntry = '' + root:x:0: + ${constants.user.group}:x:${toString constants.user.gid}: + ''; + + passwd = writeTextFile { name = "passwd"; text = passwdEntry; }; + group = writeTextFile { name = "group"; text = groupEntry; }; + + # Runtime config generator — writes openclaw.json on first run if missing. + # This keeps the image reproducible (no secrets/tokens baked in). + startScript = writeTextFile { + name = "nemoclaw-start"; + executable = true; + destination = "/usr/local/bin/nemoclaw-start"; + text = builtins.readFile ../scripts/nemoclaw-start.sh; + }; + + # Pre-populate the openclaw plugin registry so we don't need to run + # `openclaw plugins install` at build time (which requires network). + pluginRegistry = writeTextFile { + name = "openclaw-plugins.json"; + text = builtins.toJSON { + plugins = [{ + id = "nemoclaw"; + name = "NemoClaw"; + version = constants.nemoclawVersion; + path = constants.paths.pluginDir; + enabled = true; + }]; + }; + }; + + # Filesystem layout derivation — creates the sandbox home directory tree + sandboxFs = runCommand "nemoclaw-sandbox-fs" { } '' + mkdir -p $out${constants.user.home} + + # .openclaw-data directories + ${lib.concatMapStringsSep "\n" (d: "mkdir -p $out${constants.paths.openclawData}/${d}") constants.openclawDataDirs} + + # .openclaw directory with symlinks to .openclaw-data + mkdir -p $out${constants.paths.openclawConfig} + ${lib.concatMapStringsSep "\n" (d: "ln -s ${constants.paths.openclawData}/${d} $out${constants.paths.openclawConfig}/${d}") constants.openclawSymlinks} + + # update-check.json symlink + touch $out${constants.paths.openclawData}/update-check.json + ln -s ${constants.paths.openclawData}/update-check.json $out${constants.paths.openclawConfig}/update-check.json + + # Pre-populate plugin registry + mkdir -p $out${constants.paths.openclawData}/extensions + cp ${pluginRegistry} $out${constants.paths.openclawData}/extensions/plugins.json + + # Blueprint files + mkdir -p $out${constants.paths.nemoclawState}/blueprints/${constants.nemoclawVersion} + cp -r ${nemoclaw}/lib/nemoclaw-blueprint/* $out${constants.paths.nemoclawState}/blueprints/${constants.nemoclawVersion}/ + + # Plugin files at /opt/nemoclaw + mkdir -p $out${constants.paths.pluginDir} + cp -r ${nemoclaw}/lib/nemoclaw/* $out${constants.paths.pluginDir}/ + ''; + +in +dockerTools.buildLayeredImage { + name = "nemoclaw"; + tag = constants.nemoclawVersion; + maxLayers = 120; + + contents = [ + bash + coreutils + findutils + cacert + nodejs + python + git + curl + iproute2 + openclaw + nemoclaw + sandboxFs + startScript + ]; + + # Create passwd/group, set ownership, apply DAC lockdown + fakeRootCommands = '' + # /etc entries + mkdir -p ./etc + cp ${passwd} ./etc/passwd + cp ${group} ./etc/group + + # Sandbox user owns their home + chown -R ${toString constants.user.uid}:${toString constants.user.gid} .${constants.user.home} + + # DAC lockdown: root owns .openclaw so sandbox user cannot modify config + chown -R root:root .${constants.paths.openclawConfig} + chmod 1777 .${constants.paths.openclawConfig} + + # .openclaw-data stays writable by sandbox user + chown -R ${toString constants.user.uid}:${toString constants.user.gid} .${constants.paths.openclawData} + + # Start script + chmod +x ./usr/local/bin/nemoclaw-start + ''; + + config = { + Entrypoint = [ "/bin/bash" ]; + Cmd = [ ]; + User = "${toString constants.user.uid}:${toString constants.user.gid}"; + WorkingDir = constants.user.home; + Env = [ + "NEMOCLAW_MODEL=${constants.defaults.model}" + "CHAT_UI_URL=${constants.defaults.chatUiUrl}" + "PATH=/usr/local/bin:/usr/bin:/bin:${lib.makeBinPath [ nodejs python git curl openclaw nemoclaw ]}" + "NODE_PATH=${nodejs}/lib/node_modules" + "SSL_CERT_FILE=${cacert}/etc/ssl/certs/ca-bundle.crt" + ]; + }; +} diff --git a/nix/docs.nix b/nix/docs.nix new file mode 100644 index 000000000..ace27a88a --- /dev/null +++ b/nix/docs.nix @@ -0,0 +1,39 @@ +# Sphinx documentation build (best-effort). +# nvidia-sphinx-theme and sphinx-llm may not be in nixpkgs yet; +# add buildPythonPackage stubs for them when needed. +{ lib, stdenv, python314, constants, sources }: + +let + python = python314.withPackages (ps: [ + ps.sphinx + ps.myst-parser + ps.sphinx-copybutton + ps.sphinx-design + ps.sphinxcontrib-mermaid + # TODO: package nvidia-sphinx-theme and sphinx-llm for nixpkgs + # ps.nvidia-sphinx-theme + # ps.sphinx-llm + ]); + +in +stdenv.mkDerivation { + pname = "nemoclaw-docs"; + version = constants.nemoclawVersion; + src = sources.docsSrc; + + nativeBuildInputs = [ python ]; + + buildPhase = '' + runHook preBuild + sphinx-build -W -b html . $out/html + runHook postBuild + ''; + + # sphinx-build outputs directly to $out/html + dontInstall = true; + + meta = { + description = "NemoClaw documentation (HTML)"; + license = lib.licenses.asl20; + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 000000000..9e5d9329f --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,92 @@ +# NemoClaw package: two-phase build mirroring the Dockerfile's 2-stage pattern. +# +# Phase A — compile the TypeScript plugin (buildNpmPackage) +# Phase B — assemble plugin + blueprint + bin + scripts into a single derivation +{ lib, stdenv, buildNpmPackage, makeWrapper +, constants, sources, openclaw, nodejs, python }: + +let + # Phase A: compile the TypeScript OpenClaw plugin + plugin = buildNpmPackage { + pname = "nemoclaw-plugin"; + version = constants.nemoclawVersion; + src = sources.pluginSrc; + + npmDepsHash = "sha256-htKa54tdIhoJ/44buuF7bRZ2HXVwha/H9mFBV9X+weg="; + + # Build with tsc, then prune devDependencies + buildPhase = '' + runHook preBuild + npm run build + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + + mkdir -p $out + cp -r dist $out/dist + cp openclaw.plugin.json $out/ + cp package.json $out/ + + # Prune devDependencies offline, then copy production deps + npm prune --omit=dev + cp -r node_modules $out/node_modules + + runHook postInstall + ''; + + meta.description = "NemoClaw TypeScript plugin (compiled)"; + }; + +in +stdenv.mkDerivation { + pname = "nemoclaw"; + version = constants.nemoclawVersion; + src = sources.projectSrc; + + nativeBuildInputs = [ makeWrapper ]; + + dontBuild = true; + + installPhase = '' + runHook preInstall + + # Plugin files + mkdir -p $out/lib/nemoclaw + cp -r ${plugin}/dist $out/lib/nemoclaw/dist + cp -r ${plugin}/node_modules $out/lib/nemoclaw/node_modules + cp ${plugin}/openclaw.plugin.json $out/lib/nemoclaw/ + cp ${plugin}/package.json $out/lib/nemoclaw/ + + # Blueprint + mkdir -p $out/lib/nemoclaw-blueprint + cp -r nemoclaw-blueprint/* $out/lib/nemoclaw-blueprint/ + + # CLI entrypoint and supporting scripts + mkdir -p $out/lib/bin + cp bin/nemoclaw.js $out/lib/bin/ + cp -r bin/lib $out/lib/bin/lib + + mkdir -p $out/lib/scripts + cp -r scripts/* $out/lib/scripts/ + + # Root package.json — nemoclaw.js reads it via ../package.json + cp package.json $out/lib/package.json + + # Wrapper binary + mkdir -p $out/bin + makeWrapper ${nodejs}/bin/node $out/bin/nemoclaw \ + --add-flags "$out/lib/bin/nemoclaw.js" \ + --prefix PATH : ${lib.makeBinPath [ nodejs python openclaw ]} + + runHook postInstall + ''; + + meta = { + description = "NemoClaw — run OpenClaw inside OpenShell with NVIDIA inference"; + homepage = "https://github.com/NVIDIA/NemoClaw"; + license = lib.licenses.asl20; + mainProgram = "nemoclaw"; + }; +} diff --git a/nix/shell.nix b/nix/shell.nix new file mode 100644 index 000000000..eda2d3407 --- /dev/null +++ b/nix/shell.nix @@ -0,0 +1,40 @@ +# Dev shell with all build, lint, and test tools. +{ mkShell, gnumake, git, curl +, shellcheck, shfmt, hadolint, ruff, pyright +, nemoclaw, nodejs, python }: + +mkShell { + # Inherit build dependencies from the nemoclaw package + inputsFrom = [ nemoclaw ]; + + packages = [ + # Core + nodejs + python + gnumake + git + curl + + # Linters / formatters (standalone binaries from nixpkgs) + shellcheck + shfmt + hadolint + ruff + pyright + ]; + + shellHook = '' + echo "NemoClaw dev shell" + echo " node: $(node --version)" + echo " python: $(python3 --version)" + echo " ruff: $(ruff --version)" + echo " shellcheck: $(shellcheck --version | head -2 | tail -1)" + echo "" + echo "Quick start:" + echo " npm install — install JS dependencies" + echo " cd nemoclaw && npm run build — compile TS plugin" + echo " npm test — run test suite" + echo " nix build — build nemoclaw package" + echo " nix build .#container — build OCI container image" + ''; +} diff --git a/nix/source-filter.nix b/nix/source-filter.nix new file mode 100644 index 000000000..8d02c92a2 --- /dev/null +++ b/nix/source-filter.nix @@ -0,0 +1,55 @@ +# Reusable source filtering for NemoClaw builds. +# Keeps derivations from rebuilding when unrelated files change. +{ lib, constants }: + +let + root = ./..; + + # Filter that excludes patterns listed in constants.excludePatterns + excludeFilter = path: _type: + let + baseName = baseNameOf (toString path); + in + !builtins.elem baseName constants.excludePatterns; + + # Full project source minus excluded directories + projectSrc = lib.cleanSourceWith { + src = root; + filter = excludeFilter; + name = "nemoclaw-source"; + }; + + # Sub-source filters are intentionally specific to each directory's build artifacts, + # independent of constants.excludePatterns which covers the full project source. + + # Just the nemoclaw/ TypeScript plugin directory + pluginSrc = lib.cleanSourceWith { + src = root + "/nemoclaw"; + filter = path: type: + let baseName = baseNameOf (toString path); + in !builtins.elem baseName [ "node_modules" "dist" ]; + name = "nemoclaw-plugin-source"; + }; + + # Just the nemoclaw-blueprint/ directory + blueprintSrc = lib.cleanSourceWith { + src = root + "/nemoclaw-blueprint"; + filter = path: type: + let baseName = baseNameOf (toString path); + in !builtins.elem baseName [ "__pycache__" ".ruff_cache" ]; + name = "nemoclaw-blueprint-source"; + }; + + # Just the docs/ directory + docsSrc = lib.cleanSourceWith { + src = root + "/docs"; + filter = path: type: + let baseName = baseNameOf (toString path); + in !builtins.elem baseName [ "_build" "__pycache__" ]; + name = "nemoclaw-docs-source"; + }; + +in +{ + inherit projectSrc pluginSrc blueprintSrc docsSrc; +} From 76af45e5a923b4b0d152f49831f4df8cc22944c3 Mon Sep 17 00:00:00 2001 From: "randomizedcoder dave.seddon.ca@gmail.com" Date: Mon, 23 Mar 2026 13:25:30 -0700 Subject: [PATCH 2/7] fix(nix): address PR review feedback and fix excludePatterns - Remove flake.nix and flake.lock from excludePatterns so changes to locked inputs and build logic properly invalidate the source hash - Fix chmod 1777 (world-writable) on .openclaw to 755/644 so the sandbox user gets read+execute only, matching the security intent - Add text language specifier to bare code fences in README - Document why openclaw is marked as insecure in permittedInsecurePackages - Add commented container-test entry in flake checks (requires Docker) Co-Authored-By: Claude Opus 4.6 --- flake.nix | 5 +++++ nix/README.md | 4 ++-- nix/constants.nix | 7 ++++--- nix/container.nix | 3 ++- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/flake.nix b/flake.nix index e3befa6ab..f787a8a63 100644 --- a/flake.nix +++ b/flake.nix @@ -11,6 +11,9 @@ let pkgs = import nixpkgs { inherit system; + # openclaw is an autonomous AI agent marked insecure in nixpkgs because + # it can execute arbitrary code. Acceptable here: NemoClaw is itself an + # openclaw extension that requires the CLI, and the container runs sandboxed. config.permittedInsecurePackages = [ "openclaw-2026.3.12" ]; @@ -62,6 +65,8 @@ checks = { inherit nemoclaw; shell = self.devShells.${system}.default; + # Requires Docker; uncomment for CI environments with Docker available: + # inherit container-test; }; } ); diff --git a/nix/README.md b/nix/README.md index 37716a540..017ae5267 100644 --- a/nix/README.md +++ b/nix/README.md @@ -150,7 +150,7 @@ docker run -it nemoclaw:0.1.0 nemoclaw --help ## Architecture -``` +```text flake.nix # Coordinator — imports from ./nix/ | +-- nix/constants.nix # Pure data: versions, paths, user config @@ -166,7 +166,7 @@ OpenClaw comes directly from nixpkgs (`pkgs.openclaw`). ### Module Dependencies -``` +```text constants.nix --+---> source-filter.nix --+---> package.nix --+---> container.nix | | | | pkgs.openclaw ---------+-------------------+ diff --git a/nix/constants.nix b/nix/constants.nix index b9e9134ff..d95152e84 100644 --- a/nix/constants.nix +++ b/nix/constants.nix @@ -50,14 +50,15 @@ rec { (map (d: { name = builtins.head (builtins.split "/" d); value = true; }) openclawDataDirs)); - # Patterns excluded from source filtering + # Patterns excluded from the project source derivation (projectSrc). + # Only build artifacts and nix infrastructure (evaluated before filtering). + # Note: flake.nix and flake.lock are intentionally NOT excluded — changes + # to locked inputs or build logic should invalidate the source hash. excludePatterns = [ ".git" "node_modules" "dist" "__pycache__" "nix" - "flake.nix" - "flake.lock" ]; } diff --git a/nix/container.nix b/nix/container.nix index 3182af9a3..7129e5cf6 100644 --- a/nix/container.nix +++ b/nix/container.nix @@ -106,7 +106,8 @@ dockerTools.buildLayeredImage { # DAC lockdown: root owns .openclaw so sandbox user cannot modify config chown -R root:root .${constants.paths.openclawConfig} - chmod 1777 .${constants.paths.openclawConfig} + find .${constants.paths.openclawConfig} -type d -exec chmod 755 {} + + find .${constants.paths.openclawConfig} -type f -exec chmod 644 {} + # .openclaw-data stays writable by sandbox user chown -R ${toString constants.user.uid}:${toString constants.user.gid} .${constants.paths.openclawData} From b745c7daf0e9e4f614d6007a1335ef8f48c3c916 Mon Sep 17 00:00:00 2001 From: "randomizedcoder dave.seddon.ca@gmail.com" Date: Mon, 23 Mar 2026 14:17:28 -0700 Subject: [PATCH 3/7] style(nix): add formatter and apply nixfmt Co-Authored-By: Claude Opus 4.6 --- flake.nix | 36 +++++++++++++++++++---- nix/constants.nix | 11 +++++-- nix/container-test.nix | 16 ++++++++-- nix/container.nix | 66 ++++++++++++++++++++++++++++++++---------- nix/docs.nix | 8 ++++- nix/package.nix | 21 ++++++++++++-- nix/shell.nix | 17 +++++++++-- nix/source-filter.nix | 46 ++++++++++++++++++++++------- 8 files changed, 177 insertions(+), 44 deletions(-) diff --git a/flake.nix b/flake.nix index f787a8a63..d331cf553 100644 --- a/flake.nix +++ b/flake.nix @@ -6,8 +6,14 @@ flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { self, nixpkgs, flake-utils }: - flake-utils.lib.eachDefaultSystem (system: + outputs = + { + self, + nixpkgs, + flake-utils, + }: + flake-utils.lib.eachDefaultSystem ( + system: let pkgs = import nixpkgs { inherit system; @@ -34,12 +40,24 @@ # NemoClaw package (TS plugin + assembly) nemoclaw = pkgs.callPackage ./nix/package.nix { - inherit constants sources openclaw nodejs python; + inherit + constants + sources + openclaw + nodejs + python + ; }; # OCI container image container = pkgs.callPackage ./nix/container.nix { - inherit constants nemoclaw openclaw nodejs python; + inherit + constants + nemoclaw + openclaw + nodejs + python + ; }; # Documentation (best-effort, uses its own Python with Sphinx packages) @@ -55,13 +73,21 @@ { packages = { default = nemoclaw; - inherit nemoclaw openclaw container container-test docs; + inherit + nemoclaw + openclaw + container + container-test + docs + ; }; devShells.default = pkgs.callPackage ./nix/shell.nix { inherit nemoclaw nodejs python; }; + formatter = pkgs.nixfmt; + checks = { inherit nemoclaw; shell = self.devShells.${system}.default; diff --git a/nix/constants.nix b/nix/constants.nix index d95152e84..38cbf2d32 100644 --- a/nix/constants.nix +++ b/nix/constants.nix @@ -46,9 +46,14 @@ rec { ]; # Top-level symlink targets (derived from openclawDataDirs at the first path component) - openclawSymlinks = builtins.attrNames (builtins.listToAttrs - (map (d: { name = builtins.head (builtins.split "/" d); value = true; }) - openclawDataDirs)); + openclawSymlinks = builtins.attrNames ( + builtins.listToAttrs ( + map (d: { + name = builtins.head (builtins.split "/" d); + value = true; + }) openclawDataDirs + ) + ); # Patterns excluded from the project source derivation (projectSrc). # Only build artifacts and nix infrastructure (evaluated before filtering). diff --git a/nix/container-test.nix b/nix/container-test.nix index 3cf7e57cb..48d23a5ba 100644 --- a/nix/container-test.nix +++ b/nix/container-test.nix @@ -2,13 +2,23 @@ # Loads the image into Docker, runs structural checks, and reports image size. # # Usage: nix run .#container-test -{ writeShellApplication, docker, coreutils, gawk -, constants, container }: +{ + writeShellApplication, + docker, + coreutils, + gawk, + constants, + container, +}: writeShellApplication { name = "nemoclaw-container-test"; - runtimeInputs = [ docker coreutils gawk ]; + runtimeInputs = [ + docker + coreutils + gawk + ]; text = '' set -euo pipefail diff --git a/nix/container.nix b/nix/container.nix index 7129e5cf6..be9396052 100644 --- a/nix/container.nix +++ b/nix/container.nix @@ -1,9 +1,24 @@ # OCI container image via dockerTools.buildLayeredImage. # Replicates the Dockerfile layout: sandbox user, openclaw config/data split, # plugin registration, and DAC lockdown. -{ lib, dockerTools, runCommand, writeTextFile -, bash, coreutils, findutils, cacert, git, curl, iproute2 -, constants, nemoclaw, openclaw, nodejs, python }: +{ + lib, + dockerTools, + runCommand, + writeTextFile, + bash, + coreutils, + findutils, + cacert, + git, + curl, + iproute2, + constants, + nemoclaw, + openclaw, + nodejs, + python, +}: let # Generate /etc/passwd and /etc/group entries @@ -17,8 +32,14 @@ let ${constants.user.group}:x:${toString constants.user.gid}: ''; - passwd = writeTextFile { name = "passwd"; text = passwdEntry; }; - group = writeTextFile { name = "group"; text = groupEntry; }; + passwd = writeTextFile { + name = "passwd"; + text = passwdEntry; + }; + group = writeTextFile { + name = "group"; + text = groupEntry; + }; # Runtime config generator — writes openclaw.json on first run if missing. # This keeps the image reproducible (no secrets/tokens baked in). @@ -34,13 +55,15 @@ let pluginRegistry = writeTextFile { name = "openclaw-plugins.json"; text = builtins.toJSON { - plugins = [{ - id = "nemoclaw"; - name = "NemoClaw"; - version = constants.nemoclawVersion; - path = constants.paths.pluginDir; - enabled = true; - }]; + plugins = [ + { + id = "nemoclaw"; + name = "NemoClaw"; + version = constants.nemoclawVersion; + path = constants.paths.pluginDir; + enabled = true; + } + ]; }; }; @@ -49,11 +72,15 @@ let mkdir -p $out${constants.user.home} # .openclaw-data directories - ${lib.concatMapStringsSep "\n" (d: "mkdir -p $out${constants.paths.openclawData}/${d}") constants.openclawDataDirs} + ${lib.concatMapStringsSep "\n" ( + d: "mkdir -p $out${constants.paths.openclawData}/${d}" + ) constants.openclawDataDirs} # .openclaw directory with symlinks to .openclaw-data mkdir -p $out${constants.paths.openclawConfig} - ${lib.concatMapStringsSep "\n" (d: "ln -s ${constants.paths.openclawData}/${d} $out${constants.paths.openclawConfig}/${d}") constants.openclawSymlinks} + ${lib.concatMapStringsSep "\n" ( + d: "ln -s ${constants.paths.openclawData}/${d} $out${constants.paths.openclawConfig}/${d}" + ) constants.openclawSymlinks} # update-check.json symlink touch $out${constants.paths.openclawData}/update-check.json @@ -124,7 +151,16 @@ dockerTools.buildLayeredImage { Env = [ "NEMOCLAW_MODEL=${constants.defaults.model}" "CHAT_UI_URL=${constants.defaults.chatUiUrl}" - "PATH=/usr/local/bin:/usr/bin:/bin:${lib.makeBinPath [ nodejs python git curl openclaw nemoclaw ]}" + "PATH=/usr/local/bin:/usr/bin:/bin:${ + lib.makeBinPath [ + nodejs + python + git + curl + openclaw + nemoclaw + ] + }" "NODE_PATH=${nodejs}/lib/node_modules" "SSL_CERT_FILE=${cacert}/etc/ssl/certs/ca-bundle.crt" ]; diff --git a/nix/docs.nix b/nix/docs.nix index ace27a88a..0999bd609 100644 --- a/nix/docs.nix +++ b/nix/docs.nix @@ -1,7 +1,13 @@ # Sphinx documentation build (best-effort). # nvidia-sphinx-theme and sphinx-llm may not be in nixpkgs yet; # add buildPythonPackage stubs for them when needed. -{ lib, stdenv, python314, constants, sources }: +{ + lib, + stdenv, + python314, + constants, + sources, +}: let python = python314.withPackages (ps: [ diff --git a/nix/package.nix b/nix/package.nix index 9e5d9329f..3dc9b3c1f 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -2,8 +2,17 @@ # # Phase A — compile the TypeScript plugin (buildNpmPackage) # Phase B — assemble plugin + blueprint + bin + scripts into a single derivation -{ lib, stdenv, buildNpmPackage, makeWrapper -, constants, sources, openclaw, nodejs, python }: +{ + lib, + stdenv, + buildNpmPackage, + makeWrapper, + constants, + sources, + openclaw, + nodejs, + python, +}: let # Phase A: compile the TypeScript OpenClaw plugin @@ -78,7 +87,13 @@ stdenv.mkDerivation { mkdir -p $out/bin makeWrapper ${nodejs}/bin/node $out/bin/nemoclaw \ --add-flags "$out/lib/bin/nemoclaw.js" \ - --prefix PATH : ${lib.makeBinPath [ nodejs python openclaw ]} + --prefix PATH : ${ + lib.makeBinPath [ + nodejs + python + openclaw + ] + } runHook postInstall ''; diff --git a/nix/shell.nix b/nix/shell.nix index eda2d3407..002eddb3c 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -1,7 +1,18 @@ # Dev shell with all build, lint, and test tools. -{ mkShell, gnumake, git, curl -, shellcheck, shfmt, hadolint, ruff, pyright -, nemoclaw, nodejs, python }: +{ + mkShell, + gnumake, + git, + curl, + shellcheck, + shfmt, + hadolint, + ruff, + pyright, + nemoclaw, + nodejs, + python, +}: mkShell { # Inherit build dependencies from the nemoclaw package diff --git a/nix/source-filter.nix b/nix/source-filter.nix index 8d02c92a2..42356d7e3 100644 --- a/nix/source-filter.nix +++ b/nix/source-filter.nix @@ -6,7 +6,8 @@ let root = ./..; # Filter that excludes patterns listed in constants.excludePatterns - excludeFilter = path: _type: + excludeFilter = + path: _type: let baseName = baseNameOf (toString path); in @@ -25,31 +26,54 @@ let # Just the nemoclaw/ TypeScript plugin directory pluginSrc = lib.cleanSourceWith { src = root + "/nemoclaw"; - filter = path: type: - let baseName = baseNameOf (toString path); - in !builtins.elem baseName [ "node_modules" "dist" ]; + filter = + path: type: + let + baseName = baseNameOf (toString path); + in + !builtins.elem baseName [ + "node_modules" + "dist" + ]; name = "nemoclaw-plugin-source"; }; # Just the nemoclaw-blueprint/ directory blueprintSrc = lib.cleanSourceWith { src = root + "/nemoclaw-blueprint"; - filter = path: type: - let baseName = baseNameOf (toString path); - in !builtins.elem baseName [ "__pycache__" ".ruff_cache" ]; + filter = + path: type: + let + baseName = baseNameOf (toString path); + in + !builtins.elem baseName [ + "__pycache__" + ".ruff_cache" + ]; name = "nemoclaw-blueprint-source"; }; # Just the docs/ directory docsSrc = lib.cleanSourceWith { src = root + "/docs"; - filter = path: type: - let baseName = baseNameOf (toString path); - in !builtins.elem baseName [ "_build" "__pycache__" ]; + filter = + path: type: + let + baseName = baseNameOf (toString path); + in + !builtins.elem baseName [ + "_build" + "__pycache__" + ]; name = "nemoclaw-docs-source"; }; in { - inherit projectSrc pluginSrc blueprintSrc docsSrc; + inherit + projectSrc + pluginSrc + blueprintSrc + docsSrc + ; } From d285097b32e6fc12b3d0b63b24cb9cfd3be71fc2 Mon Sep 17 00:00:00 2001 From: "randomizedcoder dave.seddon.ca@gmail.com" Date: Mon, 23 Mar 2026 16:46:44 -0700 Subject: [PATCH 4/7] feat(onboard): support configurable gateway port via OPENSHELL_GATEWAY_PORT The OpenShell gateway port was hardcoded to 8080 in both the preflight port check and the gateway start command. Users whose port 8080 is already occupied had no way to proceed with onboarding. Read OPENSHELL_GATEWAY_PORT from the environment (default 8080) and pass it through to `openshell gateway start --port`. This aligns with the upstream OpenShell CLI which already supports --port. Co-Authored-By: Claude Opus 4.6 --- bin/lib/onboard.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 4a8e17147..a4b0024e3 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -366,9 +366,10 @@ async function preflight() { console.log(" ✓ Previous session cleaned up"); } - // Required ports — gateway (8080) and dashboard (18789) + // Required ports — gateway (default 8080, configurable) and dashboard (18789) + const gatewayPort = parseInt(process.env.OPENSHELL_GATEWAY_PORT || "8080", 10); const requiredPorts = [ - { port: 8080, label: "OpenShell gateway" }, + { port: gatewayPort, label: "OpenShell gateway" }, { port: 18789, label: "NemoClaw dashboard" }, ]; for (const { port, label } of requiredPorts) { @@ -423,7 +424,8 @@ async function startGateway(gpu) { // Destroy old gateway run("openshell gateway destroy -g nemoclaw 2>/dev/null || true", { ignoreError: true }); - const gwArgs = ["--name", "nemoclaw"]; + const gatewayPort = parseInt(process.env.OPENSHELL_GATEWAY_PORT || "8080", 10); + const gwArgs = ["--name", "nemoclaw", "--port", String(gatewayPort)]; // Do NOT pass --gpu here. On DGX Spark (and most GPU hosts), inference is // routed through a host-side provider (Ollama, vLLM, or cloud API) — the // sandbox itself does not need direct GPU access. Passing --gpu causes From 8e78bd402f32b2e24d1cb2e5fcb1195741b0718d Mon Sep 17 00:00:00 2001 From: "randomizedcoder dave.seddon.ca@gmail.com" Date: Mon, 23 Mar 2026 16:47:00 -0700 Subject: [PATCH 5/7] fix(container): use nemoclaw-start as default entrypoint The OCI container previously used /bin/bash as its entrypoint, requiring users to manually invoke nemoclaw-start. Change the entrypoint to /usr/local/bin/nemoclaw-start so the container runs the app by default. Users can still get a shell with: docker run --entrypoint /bin/bash ... Also run the gateway in the foreground (exec) so the container stays alive with the gateway as PID 1, and update the container smoke test to override the entrypoint for its sleep-based test harness and verify the new entrypoint config. Co-Authored-By: Claude Opus 4.6 --- nix/container-test.nix | 6 +++++- nix/container.nix | 4 ++-- scripts/nemoclaw-start.sh | 3 +-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/nix/container-test.nix b/nix/container-test.nix index 48d23a5ba..ef5361690 100644 --- a/nix/container-test.nix +++ b/nix/container-test.nix @@ -78,7 +78,7 @@ writeShellApplication { # ── Start container ───────────────────────────────────────── echo "Starting container..." - CONTAINER=$(docker create --name nemoclaw-nix-test "$IMAGE" -c "sleep 300") + CONTAINER=$(docker create --name nemoclaw-nix-test --entrypoint /bin/bash "$IMAGE" -c "sleep 300") docker start "$CONTAINER" echo "" @@ -121,6 +121,10 @@ writeShellApplication { check "/etc/passwd exists" run_in "test -f /etc/passwd" check "/etc/group exists" run_in "test -f /etc/group" + # Entrypoint + check_output "entrypoint is nemoclaw-start" "/usr/local/bin/nemoclaw-start" \ + docker inspect "$IMAGE" --format '{{join .Config.Entrypoint " "}}' + # SSL certs (needed for API calls) check "CA certs available" run_in "test -f /etc/ssl/certs/ca-bundle.crt" diff --git a/nix/container.nix b/nix/container.nix index be9396052..36ccb1757 100644 --- a/nix/container.nix +++ b/nix/container.nix @@ -144,8 +144,8 @@ dockerTools.buildLayeredImage { ''; config = { - Entrypoint = [ "/bin/bash" ]; - Cmd = [ ]; + Entrypoint = [ "/usr/local/bin/nemoclaw-start" ]; + Cmd = [ ]; # Override with: docker run --entrypoint /bin/bash ... User = "${toString constants.user.uid}:${toString constants.user.gid}"; WorkingDir = constants.user.home; Env = [ diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index f98d4c6f2..05e4a014f 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -139,7 +139,6 @@ if [ ${#NEMOCLAW_CMD[@]} -gt 0 ]; then exec "${NEMOCLAW_CMD[@]}" fi -nohup openclaw gateway run >/tmp/gateway.log 2>&1 & -echo "[gateway] openclaw gateway launched (pid $!)" start_auto_pair print_dashboard_urls +exec openclaw gateway run From 3b8e3e634ac265307620f8df6359e02d21cf72b1 Mon Sep 17 00:00:00 2001 From: "randomizedcoder dave.seddon.ca@gmail.com" Date: Mon, 23 Mar 2026 16:47:15 -0700 Subject: [PATCH 6/7] fix(nix): package Dockerfile and plugin sources for onboard The nix-built nemoclaw package was missing files that onboard.js needs to build the sandbox Docker image: the Dockerfile, TypeScript source files (tsconfig.json, src/, package-lock.json). Also fix two nix-store compatibility issues: - Patch cp -r calls to use --no-preserve=mode so read-only nix store files become writable when copied into the temp build context - Restore portable shebangs (#!/usr/bin/env bash) in postFixup for scripts that get copied into non-nix Docker containers Wire OPENSHELL_GATEWAY_PORT through the makeWrapper with a default from constants.nix (8080). Co-Authored-By: Claude Opus 4.6 --- nix/constants.nix | 1 + nix/package.nix | 28 +++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/nix/constants.nix b/nix/constants.nix index 38cbf2d32..e114c5b80 100644 --- a/nix/constants.nix +++ b/nix/constants.nix @@ -30,6 +30,7 @@ rec { defaults = { model = "nvidia/nemotron-3-super-120b-a12b"; chatUiUrl = "http://127.0.0.1:18789"; + gatewayPort = "8080"; }; # Directories under .openclaw-data that get symlinked into .openclaw diff --git a/nix/package.nix b/nix/package.nix index 3dc9b3c1f..28d70ba5f 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -61,13 +61,20 @@ stdenv.mkDerivation { installPhase = '' runHook preInstall - # Plugin files + # Plugin files (compiled output for direct use) mkdir -p $out/lib/nemoclaw cp -r ${plugin}/dist $out/lib/nemoclaw/dist cp -r ${plugin}/node_modules $out/lib/nemoclaw/node_modules cp ${plugin}/openclaw.plugin.json $out/lib/nemoclaw/ cp ${plugin}/package.json $out/lib/nemoclaw/ + # Plugin source files — the Dockerfile has its own multi-stage build that + # compiles TypeScript from source inside the sandbox image, so onboard.js + # needs tsconfig.json, src/, and package-lock.json in the build context. + cp nemoclaw/tsconfig.json $out/lib/nemoclaw/ + cp nemoclaw/package-lock.json $out/lib/nemoclaw/ + cp -r nemoclaw/src $out/lib/nemoclaw/src + # Blueprint mkdir -p $out/lib/nemoclaw-blueprint cp -r nemoclaw-blueprint/* $out/lib/nemoclaw-blueprint/ @@ -83,10 +90,20 @@ stdenv.mkDerivation { # Root package.json — nemoclaw.js reads it via ../package.json cp package.json $out/lib/package.json + # Dockerfile — onboard.js copies it into a temp build context + cp Dockerfile $out/lib/Dockerfile + + # Nix store files are read-only; onboard.js copies them into a temp build + # context with cp -r, preserving the read-only mode, then rm fails on + # cleanup. Patch the installed copy to use --no-preserve=mode. + substituteInPlace $out/lib/bin/lib/onboard.js \ + --replace-fail 'cp -r "' 'cp -r --no-preserve=mode "' + # Wrapper binary mkdir -p $out/bin makeWrapper ${nodejs}/bin/node $out/bin/nemoclaw \ --add-flags "$out/lib/bin/nemoclaw.js" \ + --set-default OPENSHELL_GATEWAY_PORT "${constants.defaults.gatewayPort}" \ --prefix PATH : ${ lib.makeBinPath [ nodejs @@ -98,6 +115,15 @@ stdenv.mkDerivation { runHook postInstall ''; + # Nix auto-patches shebangs in fixupPhase to point at /nix/store/…/bash. + # Scripts under lib/scripts/ are copied into non-nix Docker containers by + # onboard.js, so restore portable shebangs after fixup runs. + postFixup = '' + for f in $out/lib/scripts/*.sh; do + sed -i '1s|^#!.*/bin/bash|#!/usr/bin/env bash|' "$f" + done + ''; + meta = { description = "NemoClaw — run OpenClaw inside OpenShell with NVIDIA inference"; homepage = "https://github.com/NVIDIA/NemoClaw"; From 7172ba50249ad00507b0181d162229fa3952660f Mon Sep 17 00:00:00 2001 From: "randomizedcoder dave.seddon.ca@gmail.com" Date: Mon, 23 Mar 2026 16:47:36 -0700 Subject: [PATCH 7/7] docs(nix): add quick start examples and update container docs Add a quick-start comment block to flake.nix with common nix commands. Update nix/README.md to reflect the new nemoclaw-start entrypoint, add a "Running with Nix" section documenting the .# -- syntax for passing subcommands, and mention the entrypoint check in the smoke test description. Co-Authored-By: Claude Opus 4.6 --- flake.nix | 16 ++++++++++++++++ nix/README.md | 26 ++++++++++++++++++-------- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/flake.nix b/flake.nix index d331cf553..6e117079b 100644 --- a/flake.nix +++ b/flake.nix @@ -1,3 +1,19 @@ +# Quick start: +# +# nix build Build the NemoClaw package (default) +# nix run Show NemoClaw CLI help +# nix run .# -- onboard Configure inference endpoint and credentials +# nix develop Enter the dev shell +# +# nix build .#container Build the OCI container image (creates ./result) +# docker load < result Load it into Docker +# docker run --rm -it nemoclaw:0.1.0 Run (starts nemoclaw-start) +# docker run --rm -it --entrypoint /bin/bash nemoclaw:0.1.0 Interactive shell +# +# nix run .#container-test Run container smoke tests (requires Docker) +# nix build .#docs Build Sphinx documentation +# nix fmt Format all Nix files +# { description = "NemoClaw — run OpenClaw inside OpenShell with NVIDIA inference"; diff --git a/nix/README.md b/nix/README.md index 017ae5267..9a44ca460 100644 --- a/nix/README.md +++ b/nix/README.md @@ -115,7 +115,7 @@ nix run .#container-test The test verifies: binaries on PATH, Node.js version, filesystem layout (sandbox home, `.openclaw`/`.openclaw-data` split, plugin dir, blueprints), -symlink integrity, user/group IDs, SSL certs, and Python packages. +symlink integrity, user/group IDs, container entrypoint, SSL certs, and Python packages. ### Manual Usage @@ -124,20 +124,30 @@ symlink integrity, user/group IDs, SSL certs, and Python packages. nix build .#container docker load < result -# Run with default settings -docker run -it nemoclaw:0.1.0 +# Run (starts nemoclaw-start entrypoint by default) +docker run --rm -it nemoclaw:0.1.0 # Run with custom model and API key -docker run -it \ +docker run --rm -it \ -e NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b \ -e NVIDIA_API_KEY=your-key \ -e CHAT_UI_URL=http://127.0.0.1:18789 \ -p 18789:18789 \ - nemoclaw:0.1.0 \ - /usr/local/bin/nemoclaw-start + nemoclaw:0.1.0 -# Or use the nemoclaw CLI directly -docker run -it nemoclaw:0.1.0 nemoclaw --help +# Interactive shell (override entrypoint) +docker run --rm -it --entrypoint /bin/bash nemoclaw:0.1.0 +``` + +### Running with Nix + +```bash +# Show CLI help +nix run + +# Run a subcommand (note the .# -- syntax) +nix run .# -- onboard +nix run .# -- list ``` ### Environment Variables