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/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 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..6e117079b --- /dev/null +++ b/flake.nix @@ -0,0 +1,115 @@ +# 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"; + + 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; + # 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" + ]; + }; + + # 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; + }; + + formatter = pkgs.nixfmt; + + 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 new file mode 100644 index 000000000..9a44ca460 --- /dev/null +++ b/nix/README.md @@ -0,0 +1,244 @@ +# 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, container entrypoint, SSL certs, and Python packages. + +### Manual Usage + +```bash +# Build and load into Docker +nix build .#container +docker load < result + +# Run (starts nemoclaw-start entrypoint by default) +docker run --rm -it nemoclaw:0.1.0 + +# Run with custom model and API key +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 + +# 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 + +| 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 + +```text +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 + +```text +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..e114c5b80 --- /dev/null +++ b/nix/constants.nix @@ -0,0 +1,70 @@ +# 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"; + gatewayPort = "8080"; + }; + + # 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 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" + ]; +} diff --git a/nix/container-test.nix b/nix/container-test.nix new file mode 100644 index 000000000..ef5361690 --- /dev/null +++ b/nix/container-test.nix @@ -0,0 +1,145 @@ +# 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 --entrypoint /bin/bash "$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" + + # 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" + + # 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..36ccb1757 --- /dev/null +++ b/nix/container.nix @@ -0,0 +1,168 @@ +# 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} + 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} + + # Start script + chmod +x ./usr/local/bin/nemoclaw-start + ''; + + config = { + 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 = [ + "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..0999bd609 --- /dev/null +++ b/nix/docs.nix @@ -0,0 +1,45 @@ +# 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..28d70ba5f --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,133 @@ +# 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 (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/ + + # 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 + + # 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 + python + openclaw + ] + } + + 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"; + license = lib.licenses.asl20; + mainProgram = "nemoclaw"; + }; +} diff --git a/nix/shell.nix b/nix/shell.nix new file mode 100644 index 000000000..002eddb3c --- /dev/null +++ b/nix/shell.nix @@ -0,0 +1,51 @@ +# 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..42356d7e3 --- /dev/null +++ b/nix/source-filter.nix @@ -0,0 +1,79 @@ +# 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 + ; +} 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