From 50ff5de9bdd6a4f81c058528b4924a141cdfc400 Mon Sep 17 00:00:00 2001 From: Simon Wasle Date: Fri, 23 Jan 2026 18:18:17 +0100 Subject: [PATCH 01/34] fix: resolve ~ paths in systemd service config Paths like ~/.clawdbot need to be expanded to absolute paths for systemd, which cannot interpret literal ~. --- nix/modules/home-manager/openclaw/config.nix | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/nix/modules/home-manager/openclaw/config.nix b/nix/modules/home-manager/openclaw/config.nix index c9fbaa71..e1e476f1 100644 --- a/nix/modules/home-manager/openclaw/config.nix +++ b/nix/modules/home-manager/openclaw/config.nix @@ -156,23 +156,23 @@ let }; Service = { ExecStart = "${gatewayWrapper}/bin/openclaw-gateway-${name} gateway --port ${toString inst.gatewayPort}"; - WorkingDirectory = inst.stateDir; + WorkingDirectory = openclawLib.resolvePath inst.stateDir; Restart = "always"; RestartSec = "1s"; Environment = [ "HOME=${homeDir}" - "OPENCLAW_CONFIG_PATH=${inst.configPath}" - "OPENCLAW_STATE_DIR=${inst.stateDir}" + "OPENCLAW_CONFIG_PATH=${openclawLib.resolvePath inst.configPath}" + "OPENCLAW_STATE_DIR=${openclawLib.resolvePath inst.stateDir}" "OPENCLAW_NIX_MODE=1" - "MOLTBOT_CONFIG_PATH=${inst.configPath}" - "MOLTBOT_STATE_DIR=${inst.stateDir}" + "MOLTBOT_CONFIG_PATH=${openclawLib.resolvePath inst.configPath}" + "MOLTBOT_STATE_DIR=${openclawLib.resolvePath inst.stateDir}" "MOLTBOT_NIX_MODE=1" - "CLAWDBOT_CONFIG_PATH=${inst.configPath}" - "CLAWDBOT_STATE_DIR=${inst.stateDir}" + "CLAWDBOT_CONFIG_PATH=${openclawLib.resolvePath inst.configPath}" + "CLAWDBOT_STATE_DIR=${openclawLib.resolvePath inst.stateDir}" "CLAWDBOT_NIX_MODE=1" ]; - StandardOutput = "append:${inst.logPath}"; - StandardError = "append:${inst.logPath}"; + StandardOutput = "append:${openclawLib.resolvePath inst.logPath}"; + StandardError = "append:${openclawLib.resolvePath inst.logPath}"; }; }; }; From 006317def05329942e6830dc77cc0bc6c0ec2098 Mon Sep 17 00:00:00 2001 From: Simon Wasle Date: Wed, 21 Jan 2026 16:55:05 +0100 Subject: [PATCH 02/34] fix: bundle extensions dir --- nix/scripts/gateway-install.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/nix/scripts/gateway-install.sh b/nix/scripts/gateway-install.sh index 7ea5d1ce..c104e9d0 100755 --- a/nix/scripts/gateway-install.sh +++ b/nix/scripts/gateway-install.sh @@ -25,6 +25,14 @@ bash -e -c '. "$STDENV_SETUP"; patchShebangs "$out/lib/openclaw/node_modules/.bi if [ -d "$out/lib/openclaw/ui/node_modules/.bin" ]; then bash -e -c '. "$STDENV_SETUP"; patchShebangs "$out/lib/openclaw/ui/node_modules/.bin"' fi +# Patch shebangs in extensions node_modules if present +if [ -d "$out/lib/openclaw/extensions" ]; then + for ext_bin in "$out/lib/openclaw/extensions"/*/node_modules/.bin; do + if [ -d "$ext_bin" ]; then + bash -e -c '. "$STDENV_SETUP"; patchShebangs "'"$ext_bin"'"' + fi + done +fi # Work around missing dependency declaration in pi-coding-agent (strip-ansi). # Ensure it is resolvable at runtime without changing upstream. From d8fc64b6964e28a7a17f4e62529b47ce69058ba7 Mon Sep 17 00:00:00 2001 From: Simon Wasle Date: Fri, 23 Jan 2026 18:37:07 +0100 Subject: [PATCH 03/34] ci: auto-update version strings in update-pins.sh Automatically update version strings in clawdbot-gateway.nix and check derivations when running update-pins.sh, keeping them in sync with the upstream release tag. --- scripts/update-pins.sh | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/scripts/update-pins.sh b/scripts/update-pins.sh index e1f02b06..4f712604 100755 --- a/scripts/update-pins.sh +++ b/scripts/update-pins.sh @@ -145,6 +145,18 @@ if [[ -z "$release_tag" ]]; then fi log "Latest app release tag with asset: $release_tag" +# Update version strings in gateway package and check derivations +gateway_version="${release_tag#v}" +log "Updating gateway version to: $gateway_version" + +gateway_file="$repo_root/nix/packages/openclaw-gateway.nix" +tests_file="$repo_root/nix/checks/openclaw-gateway-tests.nix" +options_file="$repo_root/nix/checks/openclaw-config-options.nix" + +perl -0pi -e "s|version = \"[^\"]+\";|version = \"${gateway_version}\";|" "$gateway_file" +perl -0pi -e "s|version = \"[^\"]+\";|version = \"${gateway_version}\";|" "$tests_file" +perl -0pi -e "s|version = \"[^\"]+\";|version = \"${gateway_version}\";|" "$options_file" + app_url=$(printf '%s' "$release_json" | jq -r '[.[] | select([.assets[]?.name | (test("^Clawdbot-.*\\.zip$") and (test("dSYM") | not))] | any)][0].assets[] | select(.name | (test("^Clawdbot-.*\\.zip$") and (test("dSYM") | not))) | .browser_download_url' | head -n 1 || true) if [[ -z "$app_url" ]]; then echo "Failed to resolve Clawdbot app asset URL from latest release" >&2 @@ -238,7 +250,14 @@ if git diff --quiet; then fi log "Committing updated pins" +<<<<<<< HEAD git add "$source_file" "$app_file" "$repo_root/nix/generated/openclaw-config-options.nix" "$repo_root/flake.lock" +||||||| parent of 7fba1ec (ci: auto-update version strings in update-pins.sh) +git add "$source_file" "$app_file" "$repo_root/nix/generated/moltbot-config-options.nix" "$repo_root/flake.lock" +======= +git add "$source_file" "$app_file" "$gateway_file" "$tests_file" "$options_file" \ + "$repo_root/nix/generated/moltbot-config-options.nix" "$repo_root/flake.lock" +>>>>>>> 7fba1ec (ci: auto-update version strings in update-pins.sh) git commit -F - <<'EOF' 🤖 codex: bump openclaw pins (no-issue) @@ -246,6 +265,7 @@ What: - pin openclaw source to latest upstream main - refresh macOS app pin to latest release asset - update source and app hashes +- update version strings in gateway and check derivations - regenerate config options from upstream schema Why: From b608a06b2f41bcbc4ed3dda0220eca2d0555a701 Mon Sep 17 00:00:00 2001 From: Simon Wasle Date: Fri, 23 Jan 2026 18:18:34 +0100 Subject: [PATCH 04/34] feat: add aarch64-linux support --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index ef502a26..2ba1d8b2 100644 --- a/flake.nix +++ b/flake.nix @@ -20,7 +20,7 @@ let overlay = import ./nix/overlay.nix; sourceInfoStable = import ./nix/sources/openclaw-source.nix; - systems = [ "x86_64-linux" "aarch64-darwin" ]; + systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ]; in flake-utils.lib.eachSystem systems (system: let From e46f5a2f29148fa53701a9810ae27859a9278bab Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 11:41:54 -0800 Subject: [PATCH 05/34] CLAUDE.md --- CLAUDE.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..b8f3cdf4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,59 @@ +# AGENTS.md — nix-clawdbot + +Single source of truth for product direction: `README.md`. + +Documentation policy: +- Keep the surface area small. +- Avoid duplicate “pointer‑only” files. +- Update `README.md` first, then adjust references. + +Defaults: +- Nix‑first, no sudo. +- Declarative config only. +- Batteries‑included install is the baseline. +- Breaking changes are acceptable pre‑1.0.0 (move fast, keep docs accurate). +- NO INLINE SCRIPTS EVER. +- NEVER send any message (iMessage, email, SMS, etc.) without explicit user confirmation: + - Always show the full message text and ask: “I’m going to send this: . Send? (y/n)” + +Clawdbot packaging: +- The gateway package must include Control UI assets (run `pnpm ui:build` in the Nix build). + +Golden path for pins (yolo + manual bumps): +- Hourly GitHub Action **Yolo Update Pins** runs `scripts/update-pins.sh`, which: + - Picks latest upstream clawdbot SHA with green non-Windows checks + - Rebuilds gateway to refresh `pnpmDepsHash` + - Regenerates `nix/generated/clawdbot-config-options.nix` from upstream schema + - Updates app pin/hash, commits, rebases, pushes to `main` +- Manual bump (rare): `GH_TOKEN=... scripts/update-pins.sh` (same steps as above). Use only if yolo is blocked. +- To verify freshness: `git pull --ff-only` and check `nix/sources/clawdbot-source.nix` vs `git ls-remote https://github.com/clawdbot/clawdbot.git refs/heads/main`. +- If upstream is moving fast and tighter freshness is needed, trigger yolo manually: `gh workflow run "Yolo Update Pins"`. + +Philosophy: + +The Zen of ~~Python~~ Clawdbot, ~~by~~ shamelessly stolen from Tim Peters + +Beautiful is better than ugly. +Explicit is better than implicit. +Simple is better than complex. +Complex is better than complicated. +Flat is better than nested. +Sparse is better than dense. +Readability counts. +Special cases aren't special enough to break the rules. +Although practicality beats purity. +Errors should never pass silently. +Unless explicitly silenced. +In the face of ambiguity, refuse the temptation to guess. +There should be one-- and preferably only one --obvious way to do it. +Although that way may not be obvious at first unless you're Dutch. +Now is better than never. +Although never is often better than *right* now. +If the implementation is hard to explain, it's a bad idea. +If the implementation is easy to explain, it may be a good idea. +Namespaces are one honking great idea -- let's do more of those! + +Nix file policy: +- No inline file contents in Nix code, ever. +- Always reference explicit file paths (keep docs as real files in the repo). +- No inline scripts in Nix code, ever (use repo scripts and reference their paths). From 4742475095b791257e1f460dfe30aa237e77bdd3 Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 11:51:06 -0800 Subject: [PATCH 06/34] PR.md --- PR.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 PR.md diff --git a/PR.md b/PR.md new file mode 100644 index 00000000..94fb48e9 --- /dev/null +++ b/PR.md @@ -0,0 +1,40 @@ +# PR: Add NixOS module for isolated system user + +## Issue + +https://github.com/clawdbot/nix-clawdbot/issues/22 + +Upstream issue: https://github.com/clawdbot/clawdbot/issues/2341 + +## Goal + +Add a NixOS module (`nixosModules.clawdbot`) that runs the gateway as an isolated system user instead of the personal user account. + +## Security Motivation + +Currently the gateway runs as the user's personal account, giving the LLM full access to SSH keys, credentials, personal files, etc. Running as a dedicated locked-down user contains the blast radius if the LLM is compromised. + +## Implementation Plan + +1. Create `nix/modules/nixos/clawdbot.nix` (new NixOS module) +2. Create dedicated `clawdbot` system user with minimal privileges +3. Run gateway as system-level systemd service (not user service) +4. Apply systemd hardening: + - `DynamicUser=true` or dedicated user + - `ProtectHome=true` + - `PrivateTmp=true` + - `NoNewPrivileges=true` + - `ProtectSystem=strict` +5. Handle credential management (Claude OAuth in isolated user's home) +6. Export as `nixosModules.clawdbot` in flake.nix + +## Reference + +- Existing home-manager module: `nix/modules/home-manager/clawdbot.nix` +- Systemd service definition: lines 803-829 +- The home-manager module can coexist for users who prefer user-level service + +## Notes + +- This branch has PR #10 cherry-picked (NixOS/aarch64 support fixes) +- Claude OAuth credentials need separate setup for the clawdbot user From 43c0bee378939f68c5dd3548f6662a8506734a55 Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 12:08:24 -0800 Subject: [PATCH 07/34] feat(nixos): add option definitions for system service module Duplicated option definitions from home-manager module, adapted for NixOS system service: - Namespace: services.clawdbot (not programs.clawdbot) - Default paths: /var/lib/clawdbot (not ~/.clawdbot) - Added: user, group options for system user - Removed: launchd.*, app.*, appDefaults.* (macOS-specific) - Skills default to "copy" mode (no user home for symlinks) Marked with TODO for future consolidation with home-manager options. --- nix/modules/nixos/options.nix | 337 ++++++++++++++++++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 nix/modules/nixos/options.nix diff --git a/nix/modules/nixos/options.nix b/nix/modules/nixos/options.nix new file mode 100644 index 00000000..7d794f56 --- /dev/null +++ b/nix/modules/nixos/options.nix @@ -0,0 +1,337 @@ +# NixOS module options for Clawdbot system service +# +# TODO: Consolidate with home-manager/clawdbot.nix options +# This file duplicates option definitions for NixOS system service support. +# The duplication is intentional to avoid risking the stable home-manager module +# while adding NixOS support. Once patterns stabilize, extract shared options. +# +# Key differences from home-manager: +# - Namespace: services.clawdbot (not programs.clawdbot) +# - Paths: /var/lib/clawdbot (not ~/.clawdbot) +# - Adds: user, group options for system user +# - Removes: launchd.*, app.*, appDefaults.* (macOS-specific) +# - systemd options are for system services (not user services) + +{ lib, cfg, defaultPackage, generatedConfigOptions }: + +let + stateDir = "/var/lib/clawdbot"; + + instanceModule = { name, config, ... }: { + options = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Enable this Clawdbot instance."; + }; + + package = lib.mkOption { + type = lib.types.package; + default = defaultPackage; + description = "Clawdbot batteries-included package."; + }; + + stateDir = lib.mkOption { + type = lib.types.str; + default = if name == "default" + then stateDir + else "${stateDir}-${name}"; + description = "State directory for this Clawdbot instance."; + }; + + workspaceDir = lib.mkOption { + type = lib.types.str; + default = "${config.stateDir}/workspace"; + description = "Workspace directory for this Clawdbot instance."; + }; + + configPath = lib.mkOption { + type = lib.types.str; + default = "${config.stateDir}/clawdbot.json"; + description = "Path to generated Clawdbot config JSON."; + }; + + gatewayPort = lib.mkOption { + type = lib.types.int; + default = 18789; + description = "Gateway port for this Clawdbot instance."; + }; + + providers.telegram = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Enable Telegram provider."; + }; + + botTokenFile = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Path to Telegram bot token file."; + }; + + allowFrom = lib.mkOption { + type = lib.types.listOf lib.types.int; + default = []; + description = "Allowed Telegram chat IDs."; + }; + + groups = lib.mkOption { + type = lib.types.attrs; + default = {}; + description = "Per-group Telegram overrides."; + }; + }; + + providers.anthropic = { + apiKeyFile = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Path to Anthropic API key file."; + }; + }; + + plugins = lib.mkOption { + type = lib.types.listOf (lib.types.submodule { + options = { + source = lib.mkOption { + type = lib.types.str; + description = "Plugin source pointer (e.g., github:owner/repo)."; + }; + config = lib.mkOption { + type = lib.types.attrs; + default = {}; + description = "Plugin-specific configuration."; + }; + }; + }); + default = []; + description = "Plugins enabled for this instance."; + }; + + agent = { + model = lib.mkOption { + type = lib.types.str; + default = cfg.defaults.model; + description = "Default model for this instance."; + }; + thinkingDefault = lib.mkOption { + type = lib.types.enum [ "off" "minimal" "low" "medium" "high" ]; + default = cfg.defaults.thinkingDefault; + description = "Default thinking level for this instance."; + }; + }; + + routing.queue = { + mode = lib.mkOption { + type = lib.types.enum [ "queue" "interrupt" ]; + default = "interrupt"; + description = "Queue mode when a run is active."; + }; + + byChannel = lib.mkOption { + type = lib.types.attrs; + default = { + telegram = "interrupt"; + discord = "queue"; + webchat = "queue"; + }; + description = "Per-channel queue mode overrides."; + }; + }; + + configOverrides = lib.mkOption { + type = lib.types.attrs; + default = {}; + description = "Additional config to merge into generated JSON."; + }; + + config = lib.mkOption { + type = lib.types.submodule { options = generatedConfigOptions; }; + default = {}; + description = "Upstream Clawdbot config (generated from schema)."; + }; + }; + }; + +in { + inherit instanceModule; + + # Top-level options for services.clawdbot + topLevelOptions = { + enable = lib.mkEnableOption "Clawdbot system service"; + + package = lib.mkOption { + type = lib.types.package; + description = "Clawdbot batteries-included package."; + }; + + user = lib.mkOption { + type = lib.types.str; + default = "clawdbot"; + description = "System user to run the Clawdbot gateway."; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "clawdbot"; + description = "System group for the Clawdbot user."; + }; + + toolNames = lib.mkOption { + type = lib.types.nullOr (lib.types.listOf lib.types.str); + default = null; + description = "Override the built-in toolchain names."; + }; + + excludeTools = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = []; + description = "Tool names to remove from the built-in toolchain."; + }; + + stateDir = lib.mkOption { + type = lib.types.str; + default = stateDir; + description = "State directory for Clawdbot."; + }; + + workspaceDir = lib.mkOption { + type = lib.types.str; + default = "${stateDir}/workspace"; + description = "Workspace directory for Clawdbot agent skills."; + }; + + documents = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to documents directory (AGENTS.md, SOUL.md, TOOLS.md)."; + }; + + skills = lib.mkOption { + type = lib.types.listOf (lib.types.submodule { + options = { + name = lib.mkOption { + type = lib.types.str; + description = "Skill name (directory name)."; + }; + description = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Short description for skill frontmatter."; + }; + homepage = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Optional homepage URL."; + }; + body = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Optional skill body (markdown)."; + }; + clawdbot = lib.mkOption { + type = lib.types.nullOr lib.types.attrs; + default = null; + description = "Optional clawdbot metadata."; + }; + mode = lib.mkOption { + type = lib.types.enum [ "symlink" "copy" "inline" ]; + default = "copy"; # Default to copy for system service (no user home) + description = "Install mode for the skill."; + }; + source = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Source path for the skill (required for symlink/copy)."; + }; + }; + }); + default = []; + description = "Declarative skills installed into workspace."; + }; + + plugins = lib.mkOption { + type = lib.types.listOf (lib.types.submodule { + options = { + source = lib.mkOption { + type = lib.types.str; + description = "Plugin source pointer."; + }; + config = lib.mkOption { + type = lib.types.attrs; + default = {}; + description = "Plugin-specific configuration."; + }; + }; + }); + default = []; + description = "Plugins enabled for the default instance."; + }; + + defaults = { + model = lib.mkOption { + type = lib.types.str; + default = "anthropic/claude-sonnet-4-20250514"; + description = "Default model for all instances."; + }; + thinkingDefault = lib.mkOption { + type = lib.types.enum [ "off" "minimal" "low" "medium" "high" ]; + default = "high"; + description = "Default thinking level for all instances."; + }; + }; + + providers.telegram = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Enable Telegram provider."; + }; + + botTokenFile = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Path to Telegram bot token file."; + }; + + allowFrom = lib.mkOption { + type = lib.types.listOf lib.types.int; + default = []; + description = "Allowed Telegram chat IDs."; + }; + }; + + providers.anthropic = { + apiKeyFile = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Path to Anthropic API key file."; + }; + }; + + routing.queue = { + mode = lib.mkOption { + type = lib.types.enum [ "queue" "interrupt" ]; + default = "interrupt"; + description = "Queue mode when a run is active."; + }; + + byChannel = lib.mkOption { + type = lib.types.attrs; + default = { + telegram = "interrupt"; + discord = "queue"; + webchat = "queue"; + }; + description = "Per-channel queue mode overrides."; + }; + }; + + instances = lib.mkOption { + type = lib.types.attrsOf (lib.types.submodule instanceModule); + default = {}; + description = "Named Clawdbot instances."; + }; + }; +} From e745cda7e49221f371197075015099d9c3abadba Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 12:09:17 -0800 Subject: [PATCH 08/34] feat(nixos): add main module with systemd hardening Implements the core NixOS module: - System user/group creation (static, not DynamicUser) - Systemd service with comprehensive hardening: - ProtectHome, ProtectSystem=strict, PrivateTmp - Capability restrictions (empty CapabilityBoundingSet) - System call filtering (@system-service) - Network restrictions (AF_INET/AF_INET6/AF_UNIX only) - Namespace restrictions - Note: MemoryDenyWriteExecute disabled for Node.js JIT - Gateway wrapper script for credential loading - Config generation (mirrored from home-manager patterns) - tmpfiles rules for state directories - Config files in /etc/clawdbot/ with symlinks to state dir --- nix/modules/nixos/clawdbot.nix | 287 +++++++++++++++++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 nix/modules/nixos/clawdbot.nix diff --git a/nix/modules/nixos/clawdbot.nix b/nix/modules/nixos/clawdbot.nix new file mode 100644 index 00000000..94c62e43 --- /dev/null +++ b/nix/modules/nixos/clawdbot.nix @@ -0,0 +1,287 @@ +# NixOS module for Clawdbot system service +# +# Runs the Clawdbot gateway as an isolated system user with systemd hardening. +# This contains the blast radius if the LLM is compromised. +# +# Example usage: +# services.clawdbot = { +# enable = true; +# providers.anthropic.apiKeyFile = "/run/agenix/anthropic-api-key"; +# providers.telegram = { +# enable = true; +# botTokenFile = "/run/agenix/telegram-bot-token"; +# allowFrom = [ 12345678 ]; +# }; +# }; + +{ config, lib, pkgs, ... }: + +let + cfg = config.services.clawdbot; + + # Tool overrides (same pattern as home-manager) + toolOverrides = { + toolNamesOverride = cfg.toolNames; + excludeToolNames = cfg.excludeTools; + }; + toolOverridesEnabled = cfg.toolNames != null || cfg.excludeTools != []; + toolSets = import ../../tools/extended.nix ({ inherit pkgs; } // toolOverrides); + defaultPackage = + if toolOverridesEnabled && cfg.package == pkgs.clawdbot + then (pkgs.clawdbotPackages.withTools toolOverrides).clawdbot + else cfg.package; + + generatedConfigOptions = import ../../generated/clawdbot-config-options.nix { inherit lib; }; + + # Import option definitions + optionsDef = import ./options.nix { + inherit lib cfg defaultPackage generatedConfigOptions; + }; + + # Default instance when no explicit instances are defined + defaultInstance = { + enable = cfg.enable; + package = cfg.package; + stateDir = cfg.stateDir; + workspaceDir = cfg.workspaceDir; + configPath = "${cfg.stateDir}/clawdbot.json"; + gatewayPort = 18789; + providers = cfg.providers; + routing = cfg.routing; + plugins = cfg.plugins; + configOverrides = {}; + config = {}; + agent = { + model = cfg.defaults.model; + thinkingDefault = cfg.defaults.thinkingDefault; + }; + }; + + instances = if cfg.instances != {} + then cfg.instances + else lib.optionalAttrs cfg.enable { default = defaultInstance; }; + + enabledInstances = lib.filterAttrs (_: inst: inst.enable) instances; + + # Config generation helpers (mirrored from home-manager) + mkBaseConfig = workspaceDir: inst: { + gateway = { mode = "local"; }; + agents = { + defaults = { + workspace = workspaceDir; + model = { primary = inst.agent.model; }; + thinkingDefault = inst.agent.thinkingDefault; + }; + list = [ + { + id = "main"; + default = true; + } + ]; + }; + }; + + mkTelegramConfig = inst: lib.optionalAttrs inst.providers.telegram.enable { + channels.telegram = { + enabled = true; + tokenFile = inst.providers.telegram.botTokenFile; + allowFrom = inst.providers.telegram.allowFrom; + groups = inst.providers.telegram.groups; + }; + }; + + mkRoutingConfig = inst: { + messages = { + queue = { + mode = inst.routing.queue.mode; + byChannel = inst.routing.queue.byChannel; + }; + }; + }; + + # Build instance configuration + mkInstanceConfig = name: inst: + let + gatewayPackage = inst.package; + + baseConfig = mkBaseConfig inst.workspaceDir inst; + mergedConfig = lib.recursiveUpdate + (lib.recursiveUpdate baseConfig (lib.recursiveUpdate (mkTelegramConfig inst) (mkRoutingConfig inst))) + inst.configOverrides; + configJson = builtins.toJSON mergedConfig; + configFile = pkgs.writeText "clawdbot-${name}.json" configJson; + + # Gateway wrapper script that loads credentials at runtime + gatewayWrapper = pkgs.writeShellScriptBin "clawdbot-gateway-${name}" '' + set -euo pipefail + + # Load Anthropic API key if configured + if [ -n "${inst.providers.anthropic.apiKeyFile}" ] && [ -f "${inst.providers.anthropic.apiKeyFile}" ]; then + ANTHROPIC_API_KEY="$(cat "${inst.providers.anthropic.apiKeyFile}")" + if [ -z "$ANTHROPIC_API_KEY" ]; then + echo "Anthropic API key file is empty: ${inst.providers.anthropic.apiKeyFile}" >&2 + exit 1 + fi + export ANTHROPIC_API_KEY + fi + + exec "${gatewayPackage}/bin/clawdbot" "$@" + ''; + + unitName = if name == "default" + then "clawdbot-gateway" + else "clawdbot-gateway-${name}"; + in { + inherit configFile configJson unitName gatewayWrapper; + configPath = inst.configPath; + stateDir = inst.stateDir; + workspaceDir = inst.workspaceDir; + gatewayPort = inst.gatewayPort; + package = gatewayPackage; + }; + + instanceConfigs = lib.mapAttrs mkInstanceConfig enabledInstances; + + # Assertions + assertions = lib.flatten (lib.mapAttrsToList (name: inst: [ + { + assertion = !inst.providers.telegram.enable || inst.providers.telegram.botTokenFile != ""; + message = "services.clawdbot.instances.${name}.providers.telegram.botTokenFile must be set when Telegram is enabled."; + } + { + assertion = !inst.providers.telegram.enable || (lib.length inst.providers.telegram.allowFrom > 0); + message = "services.clawdbot.instances.${name}.providers.telegram.allowFrom must be non-empty when Telegram is enabled."; + } + ]) enabledInstances); + +in { + options.services.clawdbot = optionsDef.topLevelOptions // { + package = lib.mkOption { + type = lib.types.package; + default = pkgs.clawdbot; + description = "Clawdbot batteries-included package."; + }; + + instances = lib.mkOption { + type = lib.types.attrsOf (lib.types.submodule optionsDef.instanceModule); + default = {}; + description = "Named Clawdbot instances."; + }; + }; + + config = lib.mkIf (cfg.enable || cfg.instances != {}) { + inherit assertions; + + # Create system user and group + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + home = cfg.stateDir; + createHome = true; + description = "Clawdbot gateway service user"; + }; + + users.groups.${cfg.group} = {}; + + # Create state directories via tmpfiles + systemd.tmpfiles.rules = lib.flatten (lib.mapAttrsToList (name: instCfg: [ + "d ${instCfg.stateDir} 0750 ${cfg.user} ${cfg.group} -" + "d ${instCfg.workspaceDir} 0750 ${cfg.user} ${cfg.group} -" + ]) instanceConfigs); + + # Systemd services with hardening + systemd.services = lib.mapAttrs' (name: instCfg: lib.nameValuePair instCfg.unitName { + description = "Clawdbot gateway (${name})"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + ExecStart = "${instCfg.gatewayWrapper}/bin/clawdbot-gateway-${name} gateway --port ${toString instCfg.gatewayPort}"; + WorkingDirectory = instCfg.stateDir; + Restart = "always"; + RestartSec = "5s"; + + # Environment + Environment = [ + "CLAWDBOT_CONFIG_PATH=${instCfg.configPath}" + "CLAWDBOT_STATE_DIR=${instCfg.stateDir}" + "CLAWDBOT_NIX_MODE=1" + # Backward-compatible env names + "CLAWDIS_CONFIG_PATH=${instCfg.configPath}" + "CLAWDIS_STATE_DIR=${instCfg.stateDir}" + "CLAWDIS_NIX_MODE=1" + ]; + + # Hardening options + ProtectHome = true; + ProtectSystem = "strict"; + PrivateTmp = true; + PrivateDevices = true; + NoNewPrivileges = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectControlGroups = true; + ProtectProc = "invisible"; + ProcSubset = "pid"; + ProtectHostname = true; + ProtectClock = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + RemoveIPC = true; + LockPersonality = true; + + # Filesystem access + ReadWritePaths = [ instCfg.stateDir ]; + # Allow reading credential files (e.g., from agenix) + ReadOnlyPaths = [ + "/run/agenix" + "/run/secrets" + ]; + + # Capability restrictions + CapabilityBoundingSet = ""; + AmbientCapabilities = ""; + + # Network restrictions (gateway needs network) + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; + IPAddressDeny = "multicast"; + + # System call filtering + SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ]; + SystemCallArchitectures = "native"; + + # Memory protection + # Note: MemoryDenyWriteExecute may break Node.js JIT - disabled for now + # MemoryDenyWriteExecute = true; + + # Restrict namespaces + RestrictNamespaces = true; + + # UMask for created files + UMask = "0027"; + }; + }) instanceConfigs; + + # Write config files + environment.etc = lib.mapAttrs' (name: instCfg: + lib.nameValuePair "clawdbot/${name}.json" { + text = instCfg.configJson; + user = cfg.user; + group = cfg.group; + mode = "0640"; + } + ) instanceConfigs; + + # Symlink config from /etc to state dir (activation script) + system.activationScripts.clawdbotConfig = lib.stringAfter [ "etc" ] '' + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: instCfg: '' + ln -sfn /etc/clawdbot/${name}.json ${instCfg.configPath} + '') instanceConfigs)} + ''; + }; +} From 9e022e4a91186a4bfacd9dcc3824f3c6cbca5d2f Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 12:09:29 -0800 Subject: [PATCH 09/34] feat(flake): export nixosModules.clawdbot --- flake.nix | 1 + .../nixos/{clawdbot.nix => openclaw.nix} | 50 +++++++++---------- nix/modules/nixos/options.nix | 50 +++++++++---------- 3 files changed, 51 insertions(+), 50 deletions(-) rename nix/modules/nixos/{clawdbot.nix => openclaw.nix} (85%) diff --git a/flake.nix b/flake.nix index 2ba1d8b2..f2f9e16c 100644 --- a/flake.nix +++ b/flake.nix @@ -78,5 +78,6 @@ overlays.default = overlay; homeManagerModules.openclaw = import ./nix/modules/home-manager/openclaw.nix; darwinModules.openclaw = import ./nix/modules/darwin/openclaw.nix; + nixosModules.openclaw = import ./nix/modules/nixos/openclaw.nix; }; } diff --git a/nix/modules/nixos/clawdbot.nix b/nix/modules/nixos/openclaw.nix similarity index 85% rename from nix/modules/nixos/clawdbot.nix rename to nix/modules/nixos/openclaw.nix index 94c62e43..b55783e5 100644 --- a/nix/modules/nixos/clawdbot.nix +++ b/nix/modules/nixos/openclaw.nix @@ -1,10 +1,10 @@ -# NixOS module for Clawdbot system service +# NixOS module for Openclaw system service # -# Runs the Clawdbot gateway as an isolated system user with systemd hardening. +# Runs the Openclaw gateway as an isolated system user with systemd hardening. # This contains the blast radius if the LLM is compromised. # # Example usage: -# services.clawdbot = { +# services.openclaw = { # enable = true; # providers.anthropic.apiKeyFile = "/run/agenix/anthropic-api-key"; # providers.telegram = { @@ -17,7 +17,7 @@ { config, lib, pkgs, ... }: let - cfg = config.services.clawdbot; + cfg = config.services.openclaw; # Tool overrides (same pattern as home-manager) toolOverrides = { @@ -27,11 +27,11 @@ let toolOverridesEnabled = cfg.toolNames != null || cfg.excludeTools != []; toolSets = import ../../tools/extended.nix ({ inherit pkgs; } // toolOverrides); defaultPackage = - if toolOverridesEnabled && cfg.package == pkgs.clawdbot - then (pkgs.clawdbotPackages.withTools toolOverrides).clawdbot + if toolOverridesEnabled && cfg.package == pkgs.openclaw + then (pkgs.openclawPackages.withTools toolOverrides).openclaw else cfg.package; - generatedConfigOptions = import ../../generated/clawdbot-config-options.nix { inherit lib; }; + generatedConfigOptions = import ../../generated/openclaw-config-options.nix { inherit lib; }; # Import option definitions optionsDef = import ./options.nix { @@ -44,7 +44,7 @@ let package = cfg.package; stateDir = cfg.stateDir; workspaceDir = cfg.workspaceDir; - configPath = "${cfg.stateDir}/clawdbot.json"; + configPath = "${cfg.stateDir}/openclaw.json"; gatewayPort = 18789; providers = cfg.providers; routing = cfg.routing; @@ -109,10 +109,10 @@ let (lib.recursiveUpdate baseConfig (lib.recursiveUpdate (mkTelegramConfig inst) (mkRoutingConfig inst))) inst.configOverrides; configJson = builtins.toJSON mergedConfig; - configFile = pkgs.writeText "clawdbot-${name}.json" configJson; + configFile = pkgs.writeText "openclaw-${name}.json" configJson; # Gateway wrapper script that loads credentials at runtime - gatewayWrapper = pkgs.writeShellScriptBin "clawdbot-gateway-${name}" '' + gatewayWrapper = pkgs.writeShellScriptBin "openclaw-gateway-${name}" '' set -euo pipefail # Load Anthropic API key if configured @@ -125,12 +125,12 @@ let export ANTHROPIC_API_KEY fi - exec "${gatewayPackage}/bin/clawdbot" "$@" + exec "${gatewayPackage}/bin/openclaw" "$@" ''; unitName = if name == "default" - then "clawdbot-gateway" - else "clawdbot-gateway-${name}"; + then "openclaw-gateway" + else "openclaw-gateway-${name}"; in { inherit configFile configJson unitName gatewayWrapper; configPath = inst.configPath; @@ -146,26 +146,26 @@ let assertions = lib.flatten (lib.mapAttrsToList (name: inst: [ { assertion = !inst.providers.telegram.enable || inst.providers.telegram.botTokenFile != ""; - message = "services.clawdbot.instances.${name}.providers.telegram.botTokenFile must be set when Telegram is enabled."; + message = "services.openclaw.instances.${name}.providers.telegram.botTokenFile must be set when Telegram is enabled."; } { assertion = !inst.providers.telegram.enable || (lib.length inst.providers.telegram.allowFrom > 0); - message = "services.clawdbot.instances.${name}.providers.telegram.allowFrom must be non-empty when Telegram is enabled."; + message = "services.openclaw.instances.${name}.providers.telegram.allowFrom must be non-empty when Telegram is enabled."; } ]) enabledInstances); in { - options.services.clawdbot = optionsDef.topLevelOptions // { + options.services.openclaw = optionsDef.topLevelOptions // { package = lib.mkOption { type = lib.types.package; - default = pkgs.clawdbot; - description = "Clawdbot batteries-included package."; + default = pkgs.openclaw; + description = "Openclaw batteries-included package."; }; instances = lib.mkOption { type = lib.types.attrsOf (lib.types.submodule optionsDef.instanceModule); default = {}; - description = "Named Clawdbot instances."; + description = "Named Openclaw instances."; }; }; @@ -178,7 +178,7 @@ in { group = cfg.group; home = cfg.stateDir; createHome = true; - description = "Clawdbot gateway service user"; + description = "Openclaw gateway service user"; }; users.groups.${cfg.group} = {}; @@ -191,7 +191,7 @@ in { # Systemd services with hardening systemd.services = lib.mapAttrs' (name: instCfg: lib.nameValuePair instCfg.unitName { - description = "Clawdbot gateway (${name})"; + description = "Openclaw gateway (${name})"; wantedBy = [ "multi-user.target" ]; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; @@ -200,7 +200,7 @@ in { Type = "simple"; User = cfg.user; Group = cfg.group; - ExecStart = "${instCfg.gatewayWrapper}/bin/clawdbot-gateway-${name} gateway --port ${toString instCfg.gatewayPort}"; + ExecStart = "${instCfg.gatewayWrapper}/bin/openclaw-gateway-${name} gateway --port ${toString instCfg.gatewayPort}"; WorkingDirectory = instCfg.stateDir; Restart = "always"; RestartSec = "5s"; @@ -269,7 +269,7 @@ in { # Write config files environment.etc = lib.mapAttrs' (name: instCfg: - lib.nameValuePair "clawdbot/${name}.json" { + lib.nameValuePair "openclaw/${name}.json" { text = instCfg.configJson; user = cfg.user; group = cfg.group; @@ -278,9 +278,9 @@ in { ) instanceConfigs; # Symlink config from /etc to state dir (activation script) - system.activationScripts.clawdbotConfig = lib.stringAfter [ "etc" ] '' + system.activationScripts.openclawConfig = lib.stringAfter [ "etc" ] '' ${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: instCfg: '' - ln -sfn /etc/clawdbot/${name}.json ${instCfg.configPath} + ln -sfn /etc/openclaw/${name}.json ${instCfg.configPath} '') instanceConfigs)} ''; }; diff --git a/nix/modules/nixos/options.nix b/nix/modules/nixos/options.nix index 7d794f56..cb0aac5f 100644 --- a/nix/modules/nixos/options.nix +++ b/nix/modules/nixos/options.nix @@ -1,13 +1,13 @@ -# NixOS module options for Clawdbot system service +# NixOS module options for Openclaw system service # -# TODO: Consolidate with home-manager/clawdbot.nix options +# TODO: Consolidate with home-manager/openclaw.nix options # This file duplicates option definitions for NixOS system service support. # The duplication is intentional to avoid risking the stable home-manager module # while adding NixOS support. Once patterns stabilize, extract shared options. # # Key differences from home-manager: -# - Namespace: services.clawdbot (not programs.clawdbot) -# - Paths: /var/lib/clawdbot (not ~/.clawdbot) +# - Namespace: services.openclaw (not programs.openclaw) +# - Paths: /var/lib/openclaw (not ~/.openclaw) # - Adds: user, group options for system user # - Removes: launchd.*, app.*, appDefaults.* (macOS-specific) # - systemd options are for system services (not user services) @@ -15,20 +15,20 @@ { lib, cfg, defaultPackage, generatedConfigOptions }: let - stateDir = "/var/lib/clawdbot"; + stateDir = "/var/lib/openclaw"; instanceModule = { name, config, ... }: { options = { enable = lib.mkOption { type = lib.types.bool; default = true; - description = "Enable this Clawdbot instance."; + description = "Enable this Openclaw instance."; }; package = lib.mkOption { type = lib.types.package; default = defaultPackage; - description = "Clawdbot batteries-included package."; + description = "Openclaw batteries-included package."; }; stateDir = lib.mkOption { @@ -36,25 +36,25 @@ let default = if name == "default" then stateDir else "${stateDir}-${name}"; - description = "State directory for this Clawdbot instance."; + description = "State directory for this Openclaw instance."; }; workspaceDir = lib.mkOption { type = lib.types.str; default = "${config.stateDir}/workspace"; - description = "Workspace directory for this Clawdbot instance."; + description = "Workspace directory for this Openclaw instance."; }; configPath = lib.mkOption { type = lib.types.str; - default = "${config.stateDir}/clawdbot.json"; - description = "Path to generated Clawdbot config JSON."; + default = "${config.stateDir}/openclaw.json"; + description = "Path to generated Openclaw config JSON."; }; gatewayPort = lib.mkOption { type = lib.types.int; default = 18789; - description = "Gateway port for this Clawdbot instance."; + description = "Gateway port for this Openclaw instance."; }; providers.telegram = { @@ -149,7 +149,7 @@ let config = lib.mkOption { type = lib.types.submodule { options = generatedConfigOptions; }; default = {}; - description = "Upstream Clawdbot config (generated from schema)."; + description = "Upstream Openclaw config (generated from schema)."; }; }; }; @@ -157,25 +157,25 @@ let in { inherit instanceModule; - # Top-level options for services.clawdbot + # Top-level options for services.openclaw topLevelOptions = { - enable = lib.mkEnableOption "Clawdbot system service"; + enable = lib.mkEnableOption "Openclaw system service"; package = lib.mkOption { type = lib.types.package; - description = "Clawdbot batteries-included package."; + description = "Openclaw batteries-included package."; }; user = lib.mkOption { type = lib.types.str; - default = "clawdbot"; - description = "System user to run the Clawdbot gateway."; + default = "openclaw"; + description = "System user to run the Openclaw gateway."; }; group = lib.mkOption { type = lib.types.str; - default = "clawdbot"; - description = "System group for the Clawdbot user."; + default = "openclaw"; + description = "System group for the Openclaw user."; }; toolNames = lib.mkOption { @@ -193,13 +193,13 @@ in { stateDir = lib.mkOption { type = lib.types.str; default = stateDir; - description = "State directory for Clawdbot."; + description = "State directory for Openclaw."; }; workspaceDir = lib.mkOption { type = lib.types.str; default = "${stateDir}/workspace"; - description = "Workspace directory for Clawdbot agent skills."; + description = "Workspace directory for Openclaw agent skills."; }; documents = lib.mkOption { @@ -230,10 +230,10 @@ in { default = ""; description = "Optional skill body (markdown)."; }; - clawdbot = lib.mkOption { + openclaw = lib.mkOption { type = lib.types.nullOr lib.types.attrs; default = null; - description = "Optional clawdbot metadata."; + description = "Optional openclaw metadata."; }; mode = lib.mkOption { type = lib.types.enum [ "symlink" "copy" "inline" ]; @@ -331,7 +331,7 @@ in { instances = lib.mkOption { type = lib.types.attrsOf (lib.types.submodule instanceModule); default = {}; - description = "Named Clawdbot instances."; + description = "Named Openclaw instances."; }; }; } From b9cfedde7fe310a2479de65c9ec6b8d5750e8472 Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 12:13:10 -0800 Subject: [PATCH 10/34] test(nixos): add VM integration test for system service module Tests: - Service starts successfully - User/group created (clawdbot:clawdbot) - State directories exist with correct ownership - Config file symlinked to /var/lib/clawdbot - Hardening: ProtectHome hides /home from service - Service runs as clawdbot user Run with: nix build .#checks.x86_64-linux.nixos-module -L Interactive: nix build .#checks.x86_64-linux.nixos-module.driverInteractive --- flake.nix | 4 ++ nix/checks/nixos-module-test.nix | 81 ++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 nix/checks/nixos-module-test.nix diff --git a/flake.nix b/flake.nix index f2f9e16c..290ff215 100644 --- a/flake.nix +++ b/flake.nix @@ -64,6 +64,10 @@ hm-activation = import ./nix/checks/openclaw-hm-activation.nix { inherit pkgs home-manager; }; + nixos-module = import ./nix/checks/nixos-module-test.nix { + inherit pkgs; + openclawModule = self.nixosModules.openclaw; + }; } else {}); devShells.default = pkgs.mkShell { diff --git a/nix/checks/nixos-module-test.nix b/nix/checks/nixos-module-test.nix new file mode 100644 index 00000000..87c6fc8b --- /dev/null +++ b/nix/checks/nixos-module-test.nix @@ -0,0 +1,81 @@ +# NixOS VM integration test for openclaw module +# +# Tests that: +# 1. Service starts successfully +# 2. User/group are created +# 3. State directories exist with correct permissions +# 4. Hardening prevents reading /home +# 5. Gateway responds on configured port +# +# Run with: nix build .#checks.x86_64-linux.nixos-module -L +# Or interactively: nix build .#checks.x86_64-linux.nixos-module.driverInteractive && ./result/bin/nixos-test-driver + +{ pkgs, openclawModule }: + +pkgs.testers.nixosTest { + name = "openclaw-nixos-module"; + + nodes.server = { pkgs, ... }: { + imports = [ openclawModule ]; + + # Use the gateway-only package to avoid toolset issues + services.openclaw = { + enable = true; + package = pkgs.openclaw-gateway; + # No API key - service will start but won't be fully functional + # That's fine for testing systemd/hardening + }; + + # Create a test file in /home to verify hardening + users.users.testuser = { + isNormalUser = true; + home = "/home/testuser"; + }; + + system.activationScripts.testSecrets = '' + mkdir -p /home/testuser + echo "secret-data" > /home/testuser/secret.txt + chown testuser:users /home/testuser/secret.txt + chmod 600 /home/testuser/secret.txt + ''; + }; + + testScript = '' + start_all() + + with subtest("Service starts"): + server.wait_for_unit("openclaw-gateway.service", timeout=60) + + with subtest("User and group exist"): + server.succeed("id openclaw") + server.succeed("getent group openclaw") + + with subtest("State directories exist with correct ownership"): + server.succeed("test -d /var/lib/openclaw") + server.succeed("test -d /var/lib/openclaw/workspace") + server.succeed("stat -c '%U:%G' /var/lib/openclaw | grep -q 'openclaw:openclaw'") + + with subtest("Config file exists"): + server.succeed("test -f /var/lib/openclaw/openclaw.json") + + with subtest("Hardening: cannot read /home"): + # The service should not be able to read files in /home due to ProtectHome=true + # We test this by checking the service's view of the filesystem + server.succeed( + "nsenter -t $(systemctl show -p MainPID --value openclaw-gateway.service) -m " + "sh -c 'test ! -e /home/testuser/secret.txt' || " + "echo 'ProtectHome working: /home is hidden from service'" + ) + + with subtest("Service is running as openclaw user"): + server.succeed( + "ps -o user= -p $(systemctl show -p MainPID --value openclaw-gateway.service) | grep -q openclaw" + ) + + # Note: We don't test the gateway HTTP response because we don't have an API key + # The service will be running but not fully functional without credentials + + server.log(server.succeed("systemctl status openclaw-gateway.service")) + server.log(server.succeed("journalctl -u openclaw-gateway.service --no-pager | tail -50")) + ''; +} From c9e262d2a036a9ab58003147badb424b3427ac1f Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 12:17:42 -0800 Subject: [PATCH 11/34] fix(nixos): remove hardcoded ReadOnlyPaths that may not exist The /run/agenix and /run/secrets paths were causing service startup failure when they don't exist. With ProtectSystem=strict, /run/* is already readable, so these explicit paths are unnecessary. --- nix/modules/nixos/openclaw.nix | 5 ----- 1 file changed, 5 deletions(-) diff --git a/nix/modules/nixos/openclaw.nix b/nix/modules/nixos/openclaw.nix index b55783e5..8b6c1cd5 100644 --- a/nix/modules/nixos/openclaw.nix +++ b/nix/modules/nixos/openclaw.nix @@ -237,11 +237,6 @@ in { # Filesystem access ReadWritePaths = [ instCfg.stateDir ]; - # Allow reading credential files (e.g., from agenix) - ReadOnlyPaths = [ - "/run/agenix" - "/run/secrets" - ]; # Capability restrictions CapabilityBoundingSet = ""; From 4c2fef331683aa13575db0392c0c706226012484 Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 12:35:31 -0800 Subject: [PATCH 12/34] feat(nixos): add oauthCredentialsDir for Claude CLI OAuth support Adds providers.anthropic.oauthCredentialsDir option that bind-mounts the user's ~/.claude directory into the service's sandbox. This allows using Claude Pro/Max subscription via OAuth while maintaining isolation. The service: - Still has ProtectHome=true (can't see /home generally) - Gets read-write access to ONLY the specified .claude dir via BindPaths - Can refresh OAuth tokens as needed Example: services.clawdbot.providers.anthropic.oauthCredentialsDir = "/home/user/.claude"; --- nix/modules/nixos/openclaw.nix | 19 ++++++++++++++++--- nix/modules/nixos/options.nix | 24 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/nix/modules/nixos/openclaw.nix b/nix/modules/nixos/openclaw.nix index 8b6c1cd5..b5b9f70d 100644 --- a/nix/modules/nixos/openclaw.nix +++ b/nix/modules/nixos/openclaw.nix @@ -3,16 +3,23 @@ # Runs the Openclaw gateway as an isolated system user with systemd hardening. # This contains the blast radius if the LLM is compromised. # -# Example usage: +# Example usage (OAuth - recommended, uses Claude Pro/Max subscription): # services.openclaw = { # enable = true; -# providers.anthropic.apiKeyFile = "/run/agenix/anthropic-api-key"; +# # Use Claude CLI OAuth credentials (run `claude` to authenticate first) +# providers.anthropic.oauthCredentialsDir = "/home/myuser/.claude"; # providers.telegram = { # enable = true; # botTokenFile = "/run/agenix/telegram-bot-token"; # allowFrom = [ 12345678 ]; # }; # }; +# +# Example usage (API key): +# services.openclaw = { +# enable = true; +# providers.anthropic.apiKeyFile = "/run/agenix/anthropic-api-key"; +# }; { config, lib, pkgs, ... }: @@ -103,6 +110,8 @@ let mkInstanceConfig = name: inst: let gatewayPackage = inst.package; + oauthDir = inst.providers.anthropic.oauthCredentialsDir; + hasOauth = oauthDir != null; baseConfig = mkBaseConfig inst.workspaceDir inst; mergedConfig = lib.recursiveUpdate @@ -132,7 +141,7 @@ let then "openclaw-gateway" else "openclaw-gateway-${name}"; in { - inherit configFile configJson unitName gatewayWrapper; + inherit configFile configJson unitName gatewayWrapper hasOauth oauthDir; configPath = inst.configPath; stateDir = inst.stateDir; workspaceDir = inst.workspaceDir; @@ -259,6 +268,10 @@ in { # UMask for created files UMask = "0027"; + } // lib.optionalAttrs instCfg.hasOauth { + # Bind-mount OAuth credentials dir into service's home + # This allows the service to use Claude CLI OAuth while remaining sandboxed + BindPaths = [ "${instCfg.oauthDir}:${cfg.stateDir}/.claude" ]; }; }) instanceConfigs; diff --git a/nix/modules/nixos/options.nix b/nix/modules/nixos/options.nix index cb0aac5f..282d4af8 100644 --- a/nix/modules/nixos/options.nix +++ b/nix/modules/nixos/options.nix @@ -89,6 +89,18 @@ let default = ""; description = "Path to Anthropic API key file."; }; + + oauthCredentialsDir = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + Path to Claude CLI credentials directory (typically ~/.claude). + When set, this directory is bind-mounted into the service's sandbox, + allowing the service to use OAuth credentials from `claude` CLI auth. + The service can read and write to this directory (for token refresh). + ''; + example = "/home/myuser/.claude"; + }; }; plugins = lib.mkOption { @@ -308,6 +320,18 @@ in { default = ""; description = "Path to Anthropic API key file."; }; + + oauthCredentialsDir = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + Path to Claude CLI credentials directory (typically ~/.claude). + When set, this directory is bind-mounted into the service's sandbox, + allowing the service to use OAuth credentials from `claude` CLI auth. + The service can read and write to this directory (for token refresh). + ''; + example = "/home/myuser/.claude"; + }; }; routing.queue = { From 49dfb48d3143a7c9bb945a7db093b1465e9355c9 Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 12:36:30 -0800 Subject: [PATCH 13/34] test(nixos): add OAuth bind-mount verification to VM test Adds second test node (oauth) that verifies: - Service starts with oauthCredentialsDir configured - Credentials are accessible via bind-mount at /var/lib/clawdbot/.claude - Other files in /home remain inaccessible (ProtectHome still works) --- nix/checks/nixos-module-test.nix | 55 ++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/nix/checks/nixos-module-test.nix b/nix/checks/nixos-module-test.nix index 87c6fc8b..05e2392d 100644 --- a/nix/checks/nixos-module-test.nix +++ b/nix/checks/nixos-module-test.nix @@ -4,8 +4,8 @@ # 1. Service starts successfully # 2. User/group are created # 3. State directories exist with correct permissions -# 4. Hardening prevents reading /home -# 5. Gateway responds on configured port +# 4. Hardening prevents reading /home (basic mode) +# 5. OAuth bind-mount exposes only .claude dir (oauth mode) # # Run with: nix build .#checks.x86_64-linux.nixos-module -L # Or interactively: nix build .#checks.x86_64-linux.nixos-module.driverInteractive && ./result/bin/nixos-test-driver @@ -40,6 +40,37 @@ pkgs.testers.nixosTest { ''; }; + # Second node: test OAuth bind-mount functionality + nodes.oauth = { pkgs, ... }: { + imports = [ openclawModule ]; + + services.openclaw = { + enable = true; + package = pkgs.openclaw-gateway; + # OAuth credentials dir bind-mount + providers.anthropic.oauthCredentialsDir = "/home/oauthuser/.claude"; + }; + + users.users.oauthuser = { + isNormalUser = true; + home = "/home/oauthuser"; + }; + + # Create fake OAuth credentials and a secret file + system.activationScripts.oauthSetup = '' + mkdir -p /home/oauthuser/.claude + echo '{"token": "fake-oauth-token"}' > /home/oauthuser/.claude/credentials.json + chown -R oauthuser:users /home/oauthuser/.claude + chmod 700 /home/oauthuser/.claude + chmod 600 /home/oauthuser/.claude/credentials.json + + # Also create a secret file outside .claude to verify it's NOT accessible + echo "secret-data" > /home/oauthuser/secret.txt + chown oauthuser:users /home/oauthuser/secret.txt + chmod 600 /home/oauthuser/secret.txt + ''; + }; + testScript = '' start_all() @@ -77,5 +108,25 @@ pkgs.testers.nixosTest { server.log(server.succeed("systemctl status openclaw-gateway.service")) server.log(server.succeed("journalctl -u openclaw-gateway.service --no-pager | tail -50")) + + # OAuth node tests + with subtest("OAuth: Service starts with bind-mount"): + oauth.wait_for_unit("openclaw-gateway.service", timeout=60) + + with subtest("OAuth: Can read credentials via bind-mount"): + # The service should be able to read the .claude directory + oauth.succeed( + "nsenter -t $(systemctl show -p MainPID --value openclaw-gateway.service) -m " + "cat /var/lib/openclaw/.claude/credentials.json | grep -q fake-oauth-token" + ) + + with subtest("OAuth: Cannot read other files in /home"): + # Despite the bind-mount, other files in /home should still be hidden + oauth.succeed( + "nsenter -t $(systemctl show -p MainPID --value openclaw-gateway.service) -m " + "sh -c 'test ! -e /home/oauthuser/secret.txt'" + ) + + oauth.log(oauth.succeed("systemctl status openclaw-gateway.service")) ''; } From f900fea29b2c7c6c125d451ad4ab6a606f254a8b Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 12:43:51 -0800 Subject: [PATCH 14/34] fix(nixos): allow AF_NETLINK for Node.js networkInterfaces() The gateway uses os.networkInterfaces() which requires AF_NETLINK sockets to enumerate interfaces. Without this, the service fails with: uv_interface_addresses returned Unknown system error 97 --- nix/modules/nixos/openclaw.nix | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nix/modules/nixos/openclaw.nix b/nix/modules/nixos/openclaw.nix index b5b9f70d..b79b50c2 100644 --- a/nix/modules/nixos/openclaw.nix +++ b/nix/modules/nixos/openclaw.nix @@ -252,7 +252,8 @@ in { AmbientCapabilities = ""; # Network restrictions (gateway needs network) - RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; + # AF_NETLINK required for os.networkInterfaces() in Node.js + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" "AF_NETLINK" ]; IPAddressDeny = "multicast"; # System call filtering From d79442abef5b4d43fa5dee2563a660139484b528 Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 12:50:38 -0800 Subject: [PATCH 15/34] fix(nixos): default gateway.auth.mode to none for system service System services aren't exposed externally, so disable gateway auth by default. Users can override via configOverrides if needed. --- nix/modules/nixos/openclaw.nix | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/nix/modules/nixos/openclaw.nix b/nix/modules/nixos/openclaw.nix index b79b50c2..e1c1535f 100644 --- a/nix/modules/nixos/openclaw.nix +++ b/nix/modules/nixos/openclaw.nix @@ -72,7 +72,10 @@ let # Config generation helpers (mirrored from home-manager) mkBaseConfig = workspaceDir: inst: { - gateway = { mode = "local"; }; + gateway = { + mode = "local"; + auth.mode = "none"; # System service, not exposed externally + }; agents = { defaults = { workspace = workspaceDir; From 91bf1c9204c7aad561a9a5ffa93f0c0c4b6e391b Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 12:54:31 -0800 Subject: [PATCH 16/34] refactor(nixos): don't default gateway.auth.mode, let users configure Match home-manager behavior - don't set gateway.auth.mode in the module. Users can configure via configOverrides based on their use case. --- nix/modules/nixos/openclaw.nix | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/nix/modules/nixos/openclaw.nix b/nix/modules/nixos/openclaw.nix index e1c1535f..b79b50c2 100644 --- a/nix/modules/nixos/openclaw.nix +++ b/nix/modules/nixos/openclaw.nix @@ -72,10 +72,7 @@ let # Config generation helpers (mirrored from home-manager) mkBaseConfig = workspaceDir: inst: { - gateway = { - mode = "local"; - auth.mode = "none"; # System service, not exposed externally - }; + gateway = { mode = "local"; }; agents = { defaults = { workspace = workspaceDir; From e1adff71d48306be4891f72d1d4a9e7449cfac35 Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 13:15:36 -0800 Subject: [PATCH 17/34] feat(nixos): add gateway.auth options for upstream auth requirement Upstream now requires gateway authentication by default (c4a80f4ed). Add gateway.auth.{mode,tokenFile,passwordFile} options to support this. Users can either: - Set tokenFile/passwordFile to load credentials from files at runtime - Use configOverrides to set gateway.auth.token directly in the config The wrapper script loads credentials from files and sets the appropriate environment variables (CLAWDBOT_GATEWAY_TOKEN/CLAWDBOT_GATEWAY_PASSWORD). --- nix/modules/nixos/openclaw.nix | 36 ++++++++++++++++++++++ nix/modules/nixos/options.nix | 56 ++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/nix/modules/nixos/openclaw.nix b/nix/modules/nixos/openclaw.nix index b79b50c2..069acd4d 100644 --- a/nix/modules/nixos/openclaw.nix +++ b/nix/modules/nixos/openclaw.nix @@ -55,6 +55,7 @@ let gatewayPort = 18789; providers = cfg.providers; routing = cfg.routing; + gateway = cfg.gateway; plugins = cfg.plugins; configOverrides = {}; config = {}; @@ -120,6 +121,11 @@ let configJson = builtins.toJSON mergedConfig; configFile = pkgs.writeText "openclaw-${name}.json" configJson; + # Gateway auth configuration + gatewayAuthMode = inst.gateway.auth.mode; + gatewayTokenFile = inst.gateway.auth.tokenFile or null; + gatewayPasswordFile = inst.gateway.auth.passwordFile or null; + # Gateway wrapper script that loads credentials at runtime gatewayWrapper = pkgs.writeShellScriptBin "openclaw-gateway-${name}" '' set -euo pipefail @@ -134,6 +140,36 @@ let export ANTHROPIC_API_KEY fi + # Load gateway token if configured + ${lib.optionalString (gatewayTokenFile != null) '' + if [ -f "${gatewayTokenFile}" ]; then + OPENCLAW_GATEWAY_TOKEN="$(cat "${gatewayTokenFile}")" + if [ -z "$OPENCLAW_GATEWAY_TOKEN" ]; then + echo "Gateway token file is empty: ${gatewayTokenFile}" >&2 + exit 1 + fi + export OPENCLAW_GATEWAY_TOKEN + else + echo "Gateway token file not found: ${gatewayTokenFile}" >&2 + exit 1 + fi + ''} + + # Load gateway password if configured + ${lib.optionalString (gatewayPasswordFile != null) '' + if [ -f "${gatewayPasswordFile}" ]; then + OPENCLAW_GATEWAY_PASSWORD="$(cat "${gatewayPasswordFile}")" + if [ -z "$OPENCLAW_GATEWAY_PASSWORD" ]; then + echo "Gateway password file is empty: ${gatewayPasswordFile}" >&2 + exit 1 + fi + export OPENCLAW_GATEWAY_PASSWORD + else + echo "Gateway password file not found: ${gatewayPasswordFile}" >&2 + exit 1 + fi + ''} + exec "${gatewayPackage}/bin/openclaw" "$@" ''; diff --git a/nix/modules/nixos/options.nix b/nix/modules/nixos/options.nix index 282d4af8..89219a5b 100644 --- a/nix/modules/nixos/options.nix +++ b/nix/modules/nixos/options.nix @@ -152,6 +152,34 @@ let }; }; + gateway.auth = { + mode = lib.mkOption { + type = lib.types.enum [ "token" "password" ]; + default = cfg.gateway.auth.mode; + description = "Gateway authentication mode."; + }; + + tokenFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = cfg.gateway.auth.tokenFile; + description = '' + Path to file containing the gateway authentication token. + Required when auth mode is "token". + ''; + example = "/run/agenix/clawdbot-gateway-token"; + }; + + passwordFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = cfg.gateway.auth.passwordFile; + description = '' + Path to file containing the gateway authentication password. + Required when auth mode is "password". + ''; + example = "/run/agenix/clawdbot-gateway-password"; + }; + }; + configOverrides = lib.mkOption { type = lib.types.attrs; default = {}; @@ -352,6 +380,34 @@ in { }; }; + gateway.auth = { + mode = lib.mkOption { + type = lib.types.enum [ "token" "password" ]; + default = "token"; + description = "Gateway authentication mode."; + }; + + tokenFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + Path to file containing the gateway authentication token. + Required when auth mode is "token". + ''; + example = "/run/agenix/clawdbot-gateway-token"; + }; + + passwordFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + Path to file containing the gateway authentication password. + Required when auth mode is "password". + ''; + example = "/run/agenix/clawdbot-gateway-password"; + }; + }; + instances = lib.mkOption { type = lib.types.attrsOf (lib.types.submodule instanceModule); default = {}; From b6e50f371b103943f6525e039e03e43784d43c2e Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 13:21:03 -0800 Subject: [PATCH 18/34] fix(nixos): relax syscall filter for clipboard module The @mariozechner/clipboard native module (transitive dependency from pi-tui) needs resource syscalls that were blocked by ~@resources filter, causing SIGSYS crashes on headless systems. --- nix/modules/nixos/openclaw.nix | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nix/modules/nixos/openclaw.nix b/nix/modules/nixos/openclaw.nix index 069acd4d..62e43be2 100644 --- a/nix/modules/nixos/openclaw.nix +++ b/nix/modules/nixos/openclaw.nix @@ -293,7 +293,8 @@ in { IPAddressDeny = "multicast"; # System call filtering - SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ]; + # Note: @resources filter removed - clipboard native module needs resource syscalls + SystemCallFilter = [ "@system-service" "~@privileged" ]; SystemCallArchitectures = "native"; # Memory protection From dc6f1b102539d595e95e902a8c39559f19e624b8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 13:28:50 -0800 Subject: [PATCH 19/34] fix(nixos): relax syscall filter for Node.js compatibility Remove ~@privileged and ~@resources filters - Node.js with native modules needs these syscalls. Security is maintained through: - CapabilityBoundingSet = "" (no capabilities) - NoNewPrivileges = true - ProtectHome, ProtectSystem = strict - RestrictNamespaces, PrivateDevices, PrivateTmp --- nix/modules/nixos/openclaw.nix | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nix/modules/nixos/openclaw.nix b/nix/modules/nixos/openclaw.nix index 62e43be2..f2cf203d 100644 --- a/nix/modules/nixos/openclaw.nix +++ b/nix/modules/nixos/openclaw.nix @@ -293,8 +293,9 @@ in { IPAddressDeny = "multicast"; # System call filtering - # Note: @resources filter removed - clipboard native module needs resource syscalls - SystemCallFilter = [ "@system-service" "~@privileged" ]; + # Only @system-service - Node.js with native modules needs more syscalls + # Security comes from capability restrictions and namespace isolation instead + SystemCallFilter = [ "@system-service" ]; SystemCallArchitectures = "native"; # Memory protection From 2d1ce3a1f79d201a3ac915c55a63a006b5df8a72 Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 13:35:43 -0800 Subject: [PATCH 20/34] fix(nixos): inherit top-level provider settings in instances Instance-level providers.telegram and providers.anthropic options now default to the top-level cfg values instead of hardcoded defaults. This ensures that when users set top-level providers and only override specific instance options (like configOverrides), the provider settings are still inherited. --- nix/modules/nixos/options.nix | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/nix/modules/nixos/options.nix b/nix/modules/nixos/options.nix index 89219a5b..41632399 100644 --- a/nix/modules/nixos/options.nix +++ b/nix/modules/nixos/options.nix @@ -60,19 +60,19 @@ let providers.telegram = { enable = lib.mkOption { type = lib.types.bool; - default = false; + default = cfg.providers.telegram.enable; description = "Enable Telegram provider."; }; botTokenFile = lib.mkOption { type = lib.types.str; - default = ""; + default = cfg.providers.telegram.botTokenFile; description = "Path to Telegram bot token file."; }; allowFrom = lib.mkOption { type = lib.types.listOf lib.types.int; - default = []; + default = cfg.providers.telegram.allowFrom; description = "Allowed Telegram chat IDs."; }; @@ -86,13 +86,13 @@ let providers.anthropic = { apiKeyFile = lib.mkOption { type = lib.types.str; - default = ""; + default = cfg.providers.anthropic.apiKeyFile; description = "Path to Anthropic API key file."; }; oauthCredentialsDir = lib.mkOption { type = lib.types.nullOr lib.types.str; - default = null; + default = cfg.providers.anthropic.oauthCredentialsDir; description = '' Path to Claude CLI credentials directory (typically ~/.claude). When set, this directory is bind-mounted into the service's sandbox, From d6d8df9a61dcdd53bb865e82281b528a6145c114 Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 13:38:18 -0800 Subject: [PATCH 21/34] fix(nixos): disable ProtectHome when OAuth credentials in /home BindPaths can't access /home when ProtectHome=true, so disable it when oauthCredentialsDir is configured (typically points to ~/.claude). --- nix/modules/nixos/openclaw.nix | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nix/modules/nixos/openclaw.nix b/nix/modules/nixos/openclaw.nix index f2cf203d..b677f54b 100644 --- a/nix/modules/nixos/openclaw.nix +++ b/nix/modules/nixos/openclaw.nix @@ -262,7 +262,9 @@ in { ]; # Hardening options - ProtectHome = true; + # ProtectHome disabled when OAuth credentials are in /home + # (BindPaths can't access /home when ProtectHome=true) + ProtectHome = !instCfg.hasOauth; ProtectSystem = "strict"; PrivateTmp = true; PrivateDevices = true; From 795656774a96ad0191d88f3cf78adf0185479cae Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 13:56:05 -0800 Subject: [PATCH 22/34] refactor(nixos): replace oauthCredentialsDir with oauthTokenFile The short-lived OAuth tokens from Claude CLI's .credentials.json aren't practical for server deployments. Replace with oauthTokenFile which loads a long-lived token from `claude setup-token`. - Remove oauthCredentialsDir option and bind mount logic - Add oauthTokenFile option (sets ANTHROPIC_OAUTH_TOKEN env var) - Restore ProtectHome=true now that we don't need /home access - Update module header with new example usage --- nix/modules/nixos/openclaw.nix | 34 +++++++++++++++++++++------------- nix/modules/nixos/options.nix | 28 +++++++++++++--------------- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/nix/modules/nixos/openclaw.nix b/nix/modules/nixos/openclaw.nix index b677f54b..cf82154a 100644 --- a/nix/modules/nixos/openclaw.nix +++ b/nix/modules/nixos/openclaw.nix @@ -3,11 +3,11 @@ # Runs the Openclaw gateway as an isolated system user with systemd hardening. # This contains the blast radius if the LLM is compromised. # -# Example usage (OAuth - recommended, uses Claude Pro/Max subscription): +# Example usage (setup-token - recommended for servers): # services.openclaw = { # enable = true; -# # Use Claude CLI OAuth credentials (run `claude` to authenticate first) -# providers.anthropic.oauthCredentialsDir = "/home/myuser/.claude"; +# # Run `claude setup-token` once, store in agenix +# providers.anthropic.oauthTokenFile = "/run/agenix/openclaw-anthropic-token"; # providers.telegram = { # enable = true; # botTokenFile = "/run/agenix/telegram-bot-token"; @@ -111,8 +111,7 @@ let mkInstanceConfig = name: inst: let gatewayPackage = inst.package; - oauthDir = inst.providers.anthropic.oauthCredentialsDir; - hasOauth = oauthDir != null; + oauthTokenFile = inst.providers.anthropic.oauthTokenFile; baseConfig = mkBaseConfig inst.workspaceDir inst; mergedConfig = lib.recursiveUpdate @@ -140,6 +139,21 @@ let export ANTHROPIC_API_KEY fi + # Load Anthropic OAuth token if configured (from claude setup-token) + ${lib.optionalString (oauthTokenFile != null) '' + if [ -f "${oauthTokenFile}" ]; then + ANTHROPIC_OAUTH_TOKEN="$(cat "${oauthTokenFile}")" + if [ -z "$ANTHROPIC_OAUTH_TOKEN" ]; then + echo "Anthropic OAuth token file is empty: ${oauthTokenFile}" >&2 + exit 1 + fi + export ANTHROPIC_OAUTH_TOKEN + else + echo "Anthropic OAuth token file not found: ${oauthTokenFile}" >&2 + exit 1 + fi + ''} + # Load gateway token if configured ${lib.optionalString (gatewayTokenFile != null) '' if [ -f "${gatewayTokenFile}" ]; then @@ -177,7 +191,7 @@ let then "openclaw-gateway" else "openclaw-gateway-${name}"; in { - inherit configFile configJson unitName gatewayWrapper hasOauth oauthDir; + inherit configFile configJson unitName gatewayWrapper; configPath = inst.configPath; stateDir = inst.stateDir; workspaceDir = inst.workspaceDir; @@ -262,9 +276,7 @@ in { ]; # Hardening options - # ProtectHome disabled when OAuth credentials are in /home - # (BindPaths can't access /home when ProtectHome=true) - ProtectHome = !instCfg.hasOauth; + ProtectHome = true; ProtectSystem = "strict"; PrivateTmp = true; PrivateDevices = true; @@ -309,10 +321,6 @@ in { # UMask for created files UMask = "0027"; - } // lib.optionalAttrs instCfg.hasOauth { - # Bind-mount OAuth credentials dir into service's home - # This allows the service to use Claude CLI OAuth while remaining sandboxed - BindPaths = [ "${instCfg.oauthDir}:${cfg.stateDir}/.claude" ]; }; }) instanceConfigs; diff --git a/nix/modules/nixos/options.nix b/nix/modules/nixos/options.nix index 41632399..6978534b 100644 --- a/nix/modules/nixos/options.nix +++ b/nix/modules/nixos/options.nix @@ -87,19 +87,18 @@ let apiKeyFile = lib.mkOption { type = lib.types.str; default = cfg.providers.anthropic.apiKeyFile; - description = "Path to Anthropic API key file."; + description = "Path to Anthropic API key file (sets ANTHROPIC_API_KEY)."; }; - oauthCredentialsDir = lib.mkOption { + oauthTokenFile = lib.mkOption { type = lib.types.nullOr lib.types.str; - default = cfg.providers.anthropic.oauthCredentialsDir; + default = cfg.providers.anthropic.oauthTokenFile; description = '' - Path to Claude CLI credentials directory (typically ~/.claude). - When set, this directory is bind-mounted into the service's sandbox, - allowing the service to use OAuth credentials from `claude` CLI auth. - The service can read and write to this directory (for token refresh). + Path to file containing an Anthropic OAuth token (sets ANTHROPIC_OAUTH_TOKEN). + Generate with `claude setup-token` - these tokens are long-lived. + This is the recommended auth method for headless/server deployments. ''; - example = "/home/myuser/.claude"; + example = "/run/agenix/clawdbot-anthropic-token"; }; }; @@ -346,19 +345,18 @@ in { apiKeyFile = lib.mkOption { type = lib.types.str; default = ""; - description = "Path to Anthropic API key file."; + description = "Path to Anthropic API key file (sets ANTHROPIC_API_KEY)."; }; - oauthCredentialsDir = lib.mkOption { + oauthTokenFile = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = '' - Path to Claude CLI credentials directory (typically ~/.claude). - When set, this directory is bind-mounted into the service's sandbox, - allowing the service to use OAuth credentials from `claude` CLI auth. - The service can read and write to this directory (for token refresh). + Path to file containing an Anthropic OAuth token (sets ANTHROPIC_OAUTH_TOKEN). + Generate with `claude setup-token` - these tokens are long-lived. + This is the recommended auth method for headless/server deployments. ''; - example = "/home/myuser/.claude"; + example = "/run/agenix/clawdbot-anthropic-token"; }; }; From 84ec091bd1b4307c4952b7d60715bbe08620a41b Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 14:05:18 -0800 Subject: [PATCH 23/34] docs: update PR.md with implementation status --- PR.md | 73 ++++++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 52 insertions(+), 21 deletions(-) diff --git a/PR.md b/PR.md index 94fb48e9..9c2e9ede 100644 --- a/PR.md +++ b/PR.md @@ -14,27 +14,58 @@ Add a NixOS module (`nixosModules.clawdbot`) that runs the gateway as an isolate Currently the gateway runs as the user's personal account, giving the LLM full access to SSH keys, credentials, personal files, etc. Running as a dedicated locked-down user contains the blast radius if the LLM is compromised. -## Implementation Plan - -1. Create `nix/modules/nixos/clawdbot.nix` (new NixOS module) -2. Create dedicated `clawdbot` system user with minimal privileges -3. Run gateway as system-level systemd service (not user service) -4. Apply systemd hardening: - - `DynamicUser=true` or dedicated user - - `ProtectHome=true` - - `PrivateTmp=true` - - `NoNewPrivileges=true` - - `ProtectSystem=strict` -5. Handle credential management (Claude OAuth in isolated user's home) -6. Export as `nixosModules.clawdbot` in flake.nix - -## Reference - -- Existing home-manager module: `nix/modules/home-manager/clawdbot.nix` -- Systemd service definition: lines 803-829 -- The home-manager module can coexist for users who prefer user-level service +## Status: Working + +Tested and deployed successfully. The service runs with full systemd hardening. + +## Implementation + +### Files + +- `nix/modules/nixos/clawdbot.nix` - Main module +- `nix/modules/nixos/options.nix` - Option definitions + +### Features + +- Dedicated `clawdbot` system user with minimal privileges +- System-level systemd service with hardening: + - `ProtectHome=true` + - `ProtectSystem=strict` + - `PrivateTmp=true`, `PrivateDevices=true` + - `NoNewPrivileges=true` + - `CapabilityBoundingSet=""` (no capabilities) + - `SystemCallFilter=@system-service` + - `RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK` + - Full namespace/kernel protection +- Multi-instance support via `instances.` +- Credential loading from files at runtime (wrapper script) + +### Credential Management + +Uses `providers.anthropic.oauthTokenFile` - a long-lived token from `claude setup-token`. + +```nix +services.clawdbot = { + enable = true; + providers.anthropic.oauthTokenFile = config.age.secrets.clawdbot-token.path; + providers.telegram = { + enable = true; + botTokenFile = config.age.secrets.telegram-token.path; + allowFrom = [ 12345678 ]; + }; +}; +``` + +The deprecated `anthropic:claude-cli` profile (which tried to sync OAuth from `~/.claude/`) was not implemented - upstream deprecated it in favor of `setup-token` flow. + +### Gateway Auth + +Upstream now requires gateway authentication. Options: + +- `gateway.auth.tokenFile` / `gateway.auth.passwordFile` - load from file +- `instances..configOverrides.gateway.auth` - inline in config (for non-sensitive cases) ## Notes -- This branch has PR #10 cherry-picked (NixOS/aarch64 support fixes) -- Claude OAuth credentials need separate setup for the clawdbot user +- Node.js JIT requires `SystemCallFilter=@system-service` (can't use `~@privileged`) +- `AF_NETLINK` needed for `os.networkInterfaces()` in Node.js From 8d20c22f49cf2f2edae9c16fb86964d5b904ccbf Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 14:07:39 -0800 Subject: [PATCH 24/34] .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b2be92b7..5f8c7595 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ result +.claude/settings.local.json From 3de85940ddd12d4cd740b16a1b540ff4688ff286 Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 14:16:21 -0800 Subject: [PATCH 25/34] fix(test): remove obsolete oauthCredentialsDir test The OAuth bind-mount test referenced oauthCredentialsDir which was removed in ddc7bd5. The new oauthTokenFile approach doesn't need bind mounts - tokens are loaded via env vars from paths outside /home. --- nix/checks/nixos-module-test.nix | 54 +------------------------------- 1 file changed, 1 insertion(+), 53 deletions(-) diff --git a/nix/checks/nixos-module-test.nix b/nix/checks/nixos-module-test.nix index 05e2392d..ac7a4808 100644 --- a/nix/checks/nixos-module-test.nix +++ b/nix/checks/nixos-module-test.nix @@ -4,8 +4,7 @@ # 1. Service starts successfully # 2. User/group are created # 3. State directories exist with correct permissions -# 4. Hardening prevents reading /home (basic mode) -# 5. OAuth bind-mount exposes only .claude dir (oauth mode) +# 4. Hardening prevents reading /home (ProtectHome=true) # # Run with: nix build .#checks.x86_64-linux.nixos-module -L # Or interactively: nix build .#checks.x86_64-linux.nixos-module.driverInteractive && ./result/bin/nixos-test-driver @@ -40,37 +39,6 @@ pkgs.testers.nixosTest { ''; }; - # Second node: test OAuth bind-mount functionality - nodes.oauth = { pkgs, ... }: { - imports = [ openclawModule ]; - - services.openclaw = { - enable = true; - package = pkgs.openclaw-gateway; - # OAuth credentials dir bind-mount - providers.anthropic.oauthCredentialsDir = "/home/oauthuser/.claude"; - }; - - users.users.oauthuser = { - isNormalUser = true; - home = "/home/oauthuser"; - }; - - # Create fake OAuth credentials and a secret file - system.activationScripts.oauthSetup = '' - mkdir -p /home/oauthuser/.claude - echo '{"token": "fake-oauth-token"}' > /home/oauthuser/.claude/credentials.json - chown -R oauthuser:users /home/oauthuser/.claude - chmod 700 /home/oauthuser/.claude - chmod 600 /home/oauthuser/.claude/credentials.json - - # Also create a secret file outside .claude to verify it's NOT accessible - echo "secret-data" > /home/oauthuser/secret.txt - chown oauthuser:users /home/oauthuser/secret.txt - chmod 600 /home/oauthuser/secret.txt - ''; - }; - testScript = '' start_all() @@ -108,25 +76,5 @@ pkgs.testers.nixosTest { server.log(server.succeed("systemctl status openclaw-gateway.service")) server.log(server.succeed("journalctl -u openclaw-gateway.service --no-pager | tail -50")) - - # OAuth node tests - with subtest("OAuth: Service starts with bind-mount"): - oauth.wait_for_unit("openclaw-gateway.service", timeout=60) - - with subtest("OAuth: Can read credentials via bind-mount"): - # The service should be able to read the .claude directory - oauth.succeed( - "nsenter -t $(systemctl show -p MainPID --value openclaw-gateway.service) -m " - "cat /var/lib/openclaw/.claude/credentials.json | grep -q fake-oauth-token" - ) - - with subtest("OAuth: Cannot read other files in /home"): - # Despite the bind-mount, other files in /home should still be hidden - oauth.succeed( - "nsenter -t $(systemctl show -p MainPID --value openclaw-gateway.service) -m " - "sh -c 'test ! -e /home/oauthuser/secret.txt'" - ) - - oauth.log(oauth.succeed("systemctl status openclaw-gateway.service")) ''; } From 6ecb958f9a0b3c4383f897f83925c63672fdcd2e Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 14:16:48 -0800 Subject: [PATCH 26/34] refactor(nixos): remove unimplemented documents/skills options These options were defined but never wired up to the config generation. Rather than ship dead options, remove them and add a note pointing to the home-manager module which has the full implementation. --- nix/modules/nixos/options.nix | 50 ++--------------------------------- 1 file changed, 2 insertions(+), 48 deletions(-) diff --git a/nix/modules/nixos/options.nix b/nix/modules/nixos/options.nix index 6978534b..bd06f671 100644 --- a/nix/modules/nixos/options.nix +++ b/nix/modules/nixos/options.nix @@ -241,54 +241,8 @@ in { description = "Workspace directory for Openclaw agent skills."; }; - documents = lib.mkOption { - type = lib.types.nullOr lib.types.path; - default = null; - description = "Path to documents directory (AGENTS.md, SOUL.md, TOOLS.md)."; - }; - - skills = lib.mkOption { - type = lib.types.listOf (lib.types.submodule { - options = { - name = lib.mkOption { - type = lib.types.str; - description = "Skill name (directory name)."; - }; - description = lib.mkOption { - type = lib.types.str; - default = ""; - description = "Short description for skill frontmatter."; - }; - homepage = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Optional homepage URL."; - }; - body = lib.mkOption { - type = lib.types.str; - default = ""; - description = "Optional skill body (markdown)."; - }; - openclaw = lib.mkOption { - type = lib.types.nullOr lib.types.attrs; - default = null; - description = "Optional openclaw metadata."; - }; - mode = lib.mkOption { - type = lib.types.enum [ "symlink" "copy" "inline" ]; - default = "copy"; # Default to copy for system service (no user home) - description = "Install mode for the skill."; - }; - source = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Source path for the skill (required for symlink/copy)."; - }; - }; - }); - default = []; - description = "Declarative skills installed into workspace."; - }; + # NOTE: documents and skills options are not yet implemented for NixOS module. + # See home-manager module for the full implementation. PRs welcome. plugins = lib.mkOption { type = lib.types.listOf (lib.types.submodule { From 5c804b79ce02708ebdeef0a715a652098e36bb99 Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 14:25:35 -0800 Subject: [PATCH 27/34] feat(nixos): implement documents and skills support Add documents-skills.nix with parallel implementation to home-manager: - Documents: copies AGENTS.md, SOUL.md, TOOLS.md to workspace with appended Nix-managed tools report - Skills: supports copy and inline modes (symlink omitted for system service where it doesn't make sense) - Uses systemd-tmpfiles for installation The implementation is kept separate to ease future consolidation with the home-manager module. --- nix/modules/nixos/documents-skills.nix | 161 +++++++++++++++++++++++++ nix/modules/nixos/openclaw.nix | 14 ++- nix/modules/nixos/options.nix | 50 +++++++- 3 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 nix/modules/nixos/documents-skills.nix diff --git a/nix/modules/nixos/documents-skills.nix b/nix/modules/nixos/documents-skills.nix new file mode 100644 index 00000000..e1f98d9c --- /dev/null +++ b/nix/modules/nixos/documents-skills.nix @@ -0,0 +1,161 @@ +# Documents and skills implementation for NixOS module +# +# Parallel implementation to home-manager's documents/skills handling. +# TODO: Consolidate with home-manager into shared lib once patterns stabilize. + +{ lib, pkgs, cfg, instanceConfigs, toolSets }: + +let + documentsEnabled = cfg.documents != null; + + # Render a skill to markdown with frontmatter + renderSkill = skill: + let + metadataLine = + if skill.clawdbot != null + then "metadata: ${builtins.toJSON { clawdbot = skill.clawdbot; }}" + else null; + homepageLine = + if skill.homepage != null + then "homepage: ${skill.homepage}" + else null; + frontmatterLines = lib.filter (line: line != null) [ + "---" + "name: ${skill.name}" + "description: ${skill.description}" + homepageLine + metadataLine + "---" + ]; + frontmatter = lib.concatStringsSep "\n" frontmatterLines; + body = skill.body or ""; + in + "${frontmatter}\n\n${body}\n"; + + # Generate tools report (appended to TOOLS.md) + toolsReport = + let + toolNames = toolSets.toolNames or []; + reportLines = [ + "" + "" + "## Nix-managed tools" + "" + "### Built-in toolchain" + ] + ++ (if toolNames == [] then [ "- (none)" ] else map (name: "- " + name) toolNames) + ++ [ + "" + "" + ]; + in + lib.concatStringsSep "\n" reportLines; + + toolsWithReport = + if documentsEnabled then + pkgs.runCommand "clawdbot-tools-with-report.md" {} '' + cat ${cfg.documents + "/TOOLS.md"} > $out + echo "" >> $out + cat <<'EOF' >> $out +${toolsReport} +EOF + '' + else + null; + + # Assertions for documents + documentsAssertions = lib.optionals documentsEnabled [ + { + assertion = builtins.pathExists cfg.documents; + message = "services.clawdbot.documents must point to an existing directory."; + } + { + assertion = builtins.pathExists (cfg.documents + "/AGENTS.md"); + message = "Missing AGENTS.md in services.clawdbot.documents."; + } + { + assertion = builtins.pathExists (cfg.documents + "/SOUL.md"); + message = "Missing SOUL.md in services.clawdbot.documents."; + } + { + assertion = builtins.pathExists (cfg.documents + "/TOOLS.md"); + message = "Missing TOOLS.md in services.clawdbot.documents."; + } + ]; + + # Assertions for skills + skillAssertions = + let + names = map (skill: skill.name) cfg.skills; + nameCounts = lib.foldl' (acc: name: acc // { "${name}" = (acc.${name} or 0) + 1; }) {} names; + duplicateNames = lib.attrNames (lib.filterAttrs (_: v: v > 1) nameCounts); + copySkillsWithoutSource = lib.filter (s: s.mode == "copy" && s.source == null) cfg.skills; + in + (if duplicateNames == [] then [] else [ + { + assertion = false; + message = "services.clawdbot.skills has duplicate names: ${lib.concatStringsSep ", " duplicateNames}"; + } + ]) + ++ (map (s: { + assertion = false; + message = "services.clawdbot.skills: skill '${s.name}' uses copy mode but has no source."; + }) copySkillsWithoutSource); + + # Build skill derivations for each instance + # Returns: { "" = [ { path = "skills/"; drv = ; } ... ]; } + skillDerivations = + lib.mapAttrs (instName: instCfg: + map (skill: + let + skillDrv = if skill.mode == "inline" then + pkgs.writeTextDir "SKILL.md" (renderSkill skill) + else + # copy mode - use the source directly + skill.source; + in { + path = "skills/${skill.name}"; + drv = skillDrv; + mode = skill.mode; + } + ) cfg.skills + ) instanceConfigs; + + # Build documents derivations for each instance + # Returns: { "" = { agents = ; soul = ; tools = ; } or null; } + documentsDerivations = + if !documentsEnabled then + lib.mapAttrs (_: _: null) instanceConfigs + else + lib.mapAttrs (instName: instCfg: { + agents = cfg.documents + "/AGENTS.md"; + soul = cfg.documents + "/SOUL.md"; + tools = toolsWithReport; + }) instanceConfigs; + + # Generate tmpfiles rules for skills and documents + tmpfilesRules = + let + rulesForInstance = instName: instCfg: + let + workspaceDir = instCfg.workspaceDir; + skillRules = lib.flatten (map (entry: + if entry.mode == "inline" then + [ "C ${workspaceDir}/${entry.path} 0750 ${cfg.user} ${cfg.group} - ${entry.drv}" ] + else + [ "C ${workspaceDir}/${entry.path} 0750 ${cfg.user} ${cfg.group} - ${entry.drv}" ] + ) (skillDerivations.${instName} or [])); + docRules = if documentsDerivations.${instName} == null then [] else + let docs = documentsDerivations.${instName}; in [ + "C ${workspaceDir}/AGENTS.md 0640 ${cfg.user} ${cfg.group} - ${docs.agents}" + "C ${workspaceDir}/SOUL.md 0640 ${cfg.user} ${cfg.group} - ${docs.soul}" + "C ${workspaceDir}/TOOLS.md 0640 ${cfg.user} ${cfg.group} - ${docs.tools}" + ]; + in + skillRules ++ docRules; + in + lib.flatten (lib.mapAttrsToList rulesForInstance instanceConfigs); + +in { + inherit documentsAssertions skillAssertions tmpfilesRules; +} diff --git a/nix/modules/nixos/openclaw.nix b/nix/modules/nixos/openclaw.nix index cf82154a..957674af 100644 --- a/nix/modules/nixos/openclaw.nix +++ b/nix/modules/nixos/openclaw.nix @@ -201,6 +201,11 @@ let instanceConfigs = lib.mapAttrs mkInstanceConfig enabledInstances; + # Documents and skills implementation + documentsSkills = import ./documents-skills.nix { + inherit lib pkgs cfg instanceConfigs toolSets; + }; + # Assertions assertions = lib.flatten (lib.mapAttrsToList (name: inst: [ { @@ -229,7 +234,9 @@ in { }; config = lib.mkIf (cfg.enable || cfg.instances != {}) { - inherit assertions; + assertions = assertions + ++ documentsSkills.documentsAssertions + ++ documentsSkills.skillAssertions; # Create system user and group users.users.${cfg.user} = { @@ -242,11 +249,12 @@ in { users.groups.${cfg.group} = {}; - # Create state directories via tmpfiles + # Create state directories and install documents/skills via tmpfiles systemd.tmpfiles.rules = lib.flatten (lib.mapAttrsToList (name: instCfg: [ "d ${instCfg.stateDir} 0750 ${cfg.user} ${cfg.group} -" "d ${instCfg.workspaceDir} 0750 ${cfg.user} ${cfg.group} -" - ]) instanceConfigs); + "d ${instCfg.workspaceDir}/skills 0750 ${cfg.user} ${cfg.group} -" + ]) instanceConfigs) ++ documentsSkills.tmpfilesRules; # Systemd services with hardening systemd.services = lib.mapAttrs' (name: instCfg: lib.nameValuePair instCfg.unitName { diff --git a/nix/modules/nixos/options.nix b/nix/modules/nixos/options.nix index bd06f671..006c0fc8 100644 --- a/nix/modules/nixos/options.nix +++ b/nix/modules/nixos/options.nix @@ -241,8 +241,54 @@ in { description = "Workspace directory for Openclaw agent skills."; }; - # NOTE: documents and skills options are not yet implemented for NixOS module. - # See home-manager module for the full implementation. PRs welcome. + documents = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to documents directory (AGENTS.md, SOUL.md, TOOLS.md)."; + }; + + skills = lib.mkOption { + type = lib.types.listOf (lib.types.submodule { + options = { + name = lib.mkOption { + type = lib.types.str; + description = "Skill name (directory name)."; + }; + description = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Short description for skill frontmatter."; + }; + homepage = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Optional homepage URL."; + }; + body = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Optional skill body (markdown)."; + }; + clawdbot = lib.mkOption { + type = lib.types.nullOr lib.types.attrs; + default = null; + description = "Optional clawdbot metadata."; + }; + mode = lib.mkOption { + type = lib.types.enum [ "copy" "inline" ]; + default = "copy"; + description = "Install mode for the skill (symlink not supported for system service)."; + }; + source = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Source path for the skill (required for copy mode)."; + }; + }; + }); + default = []; + description = "Declarative skills installed into workspace."; + }; plugins = lib.mkOption { type = lib.types.listOf (lib.types.submodule { From a7c0547253046984edd9b5973f686ba5e4a097ff Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 14:28:03 -0800 Subject: [PATCH 28/34] refactor: remove unused config option from both modules The `config` option (using generatedConfigOptions from upstream schema) was defined but never wired into config generation. Users setting `inst.config.*` would see no effect, which is confusing. The `configOverrides` option remains as the escape hatch for arbitrary JSON config. If typed schema options are needed later, they can be added with actual implementation. --- nix/modules/nixos/openclaw.nix | 5 +---- nix/modules/nixos/options.nix | 24 +++++++++--------------- 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/nix/modules/nixos/openclaw.nix b/nix/modules/nixos/openclaw.nix index 957674af..7af4c352 100644 --- a/nix/modules/nixos/openclaw.nix +++ b/nix/modules/nixos/openclaw.nix @@ -38,11 +38,9 @@ let then (pkgs.openclawPackages.withTools toolOverrides).openclaw else cfg.package; - generatedConfigOptions = import ../../generated/openclaw-config-options.nix { inherit lib; }; - # Import option definitions optionsDef = import ./options.nix { - inherit lib cfg defaultPackage generatedConfigOptions; + inherit lib cfg defaultPackage; }; # Default instance when no explicit instances are defined @@ -58,7 +56,6 @@ let gateway = cfg.gateway; plugins = cfg.plugins; configOverrides = {}; - config = {}; agent = { model = cfg.defaults.model; thinkingDefault = cfg.defaults.thinkingDefault; diff --git a/nix/modules/nixos/options.nix b/nix/modules/nixos/options.nix index 006c0fc8..18f50da7 100644 --- a/nix/modules/nixos/options.nix +++ b/nix/modules/nixos/options.nix @@ -12,7 +12,7 @@ # - Removes: launchd.*, app.*, appDefaults.* (macOS-specific) # - systemd options are for system services (not user services) -{ lib, cfg, defaultPackage, generatedConfigOptions }: +{ lib, cfg, defaultPackage }: let stateDir = "/var/lib/openclaw"; @@ -98,7 +98,7 @@ let Generate with `claude setup-token` - these tokens are long-lived. This is the recommended auth method for headless/server deployments. ''; - example = "/run/agenix/clawdbot-anthropic-token"; + example = "/run/agenix/openclaw-anthropic-token"; }; }; @@ -165,7 +165,7 @@ let Path to file containing the gateway authentication token. Required when auth mode is "token". ''; - example = "/run/agenix/clawdbot-gateway-token"; + example = "/run/agenix/openclaw-gateway-token"; }; passwordFile = lib.mkOption { @@ -175,7 +175,7 @@ let Path to file containing the gateway authentication password. Required when auth mode is "password". ''; - example = "/run/agenix/clawdbot-gateway-password"; + example = "/run/agenix/openclaw-gateway-password"; }; }; @@ -184,12 +184,6 @@ let default = {}; description = "Additional config to merge into generated JSON."; }; - - config = lib.mkOption { - type = lib.types.submodule { options = generatedConfigOptions; }; - default = {}; - description = "Upstream Openclaw config (generated from schema)."; - }; }; }; @@ -269,10 +263,10 @@ in { default = ""; description = "Optional skill body (markdown)."; }; - clawdbot = lib.mkOption { + openclaw = lib.mkOption { type = lib.types.nullOr lib.types.attrs; default = null; - description = "Optional clawdbot metadata."; + description = "Optional openclaw metadata."; }; mode = lib.mkOption { type = lib.types.enum [ "copy" "inline" ]; @@ -356,7 +350,7 @@ in { Generate with `claude setup-token` - these tokens are long-lived. This is the recommended auth method for headless/server deployments. ''; - example = "/run/agenix/clawdbot-anthropic-token"; + example = "/run/agenix/openclaw-anthropic-token"; }; }; @@ -392,7 +386,7 @@ in { Path to file containing the gateway authentication token. Required when auth mode is "token". ''; - example = "/run/agenix/clawdbot-gateway-token"; + example = "/run/agenix/openclaw-gateway-token"; }; passwordFile = lib.mkOption { @@ -402,7 +396,7 @@ in { Path to file containing the gateway authentication password. Required when auth mode is "password". ''; - example = "/run/agenix/clawdbot-gateway-password"; + example = "/run/agenix/openclaw-gateway-password"; }; }; From 34145ef4bae70cf7ce83709b2d4e7eb848d69d3c Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 14:32:27 -0800 Subject: [PATCH 29/34] feat(nixos): add assertions for auth configuration Fail early with clear messages when: - Neither apiKeyFile nor oauthTokenFile is set for Anthropic - gateway.auth.tokenFile is missing when mode is "token" - gateway.auth.passwordFile is missing when mode is "password" Update test to provide dummy tokens to satisfy assertions. --- nix/checks/nixos-module-test.nix | 12 ++++++++++-- nix/modules/nixos/openclaw.nix | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/nix/checks/nixos-module-test.nix b/nix/checks/nixos-module-test.nix index ac7a4808..dc2ee796 100644 --- a/nix/checks/nixos-module-test.nix +++ b/nix/checks/nixos-module-test.nix @@ -21,10 +21,18 @@ pkgs.testers.nixosTest { services.openclaw = { enable = true; package = pkgs.openclaw-gateway; - # No API key - service will start but won't be fully functional - # That's fine for testing systemd/hardening + # Dummy token for testing - service won't be fully functional but will start + providers.anthropic.oauthTokenFile = "/run/openclaw-test-token"; + gateway.auth.tokenFile = "/run/openclaw-gateway-token"; }; + # Create dummy token files for testing + system.activationScripts.openclawTestTokens = '' + echo "test-oauth-token" > /run/openclaw-test-token + echo "test-gateway-token" > /run/openclaw-gateway-token + chmod 600 /run/openclaw-test-token /run/openclaw-gateway-token + ''; + # Create a test file in /home to verify hardening users.users.testuser = { isNormalUser = true; diff --git a/nix/modules/nixos/openclaw.nix b/nix/modules/nixos/openclaw.nix index 7af4c352..3ac02060 100644 --- a/nix/modules/nixos/openclaw.nix +++ b/nix/modules/nixos/openclaw.nix @@ -205,6 +205,7 @@ let # Assertions assertions = lib.flatten (lib.mapAttrsToList (name: inst: [ + # Telegram assertions { assertion = !inst.providers.telegram.enable || inst.providers.telegram.botTokenFile != ""; message = "services.openclaw.instances.${name}.providers.telegram.botTokenFile must be set when Telegram is enabled."; @@ -213,6 +214,20 @@ let assertion = !inst.providers.telegram.enable || (lib.length inst.providers.telegram.allowFrom > 0); message = "services.openclaw.instances.${name}.providers.telegram.allowFrom must be non-empty when Telegram is enabled."; } + # Anthropic auth assertions + { + assertion = inst.providers.anthropic.apiKeyFile != "" || inst.providers.anthropic.oauthTokenFile != null; + message = "services.openclaw.instances.${name}: either providers.anthropic.apiKeyFile or providers.anthropic.oauthTokenFile must be set."; + } + # Gateway auth assertions + { + assertion = inst.gateway.auth.mode != "token" || inst.gateway.auth.tokenFile != null; + message = "services.openclaw.instances.${name}.gateway.auth.tokenFile must be set when auth mode is 'token'."; + } + { + assertion = inst.gateway.auth.mode != "password" || inst.gateway.auth.passwordFile != null; + message = "services.openclaw.instances.${name}.gateway.auth.passwordFile must be set when auth mode is 'password'."; + } ]) enabledInstances); in { From 0c2f6c14e99109db00a305999ed7836a56cca1a8 Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 14:35:14 -0800 Subject: [PATCH 30/34] refactor(nixos): remove duplicate instances option definition The instances option was defined in both options.nix and clawdbot.nix. The one in clawdbot.nix always overrode the one in options.nix, making the latter dead code. --- nix/modules/nixos/options.nix | 6 ------ 1 file changed, 6 deletions(-) diff --git a/nix/modules/nixos/options.nix b/nix/modules/nixos/options.nix index 18f50da7..dcdcf450 100644 --- a/nix/modules/nixos/options.nix +++ b/nix/modules/nixos/options.nix @@ -399,11 +399,5 @@ in { example = "/run/agenix/openclaw-gateway-password"; }; }; - - instances = lib.mkOption { - type = lib.types.attrsOf (lib.types.submodule instanceModule); - default = {}; - description = "Named Openclaw instances."; - }; }; } From 323de2d66b824cd201f3c25c2ba0d7ae9bab3f25 Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 14:40:07 -0800 Subject: [PATCH 31/34] docs(nixos): clarify CLAWDIS_* env var comment --- nix/modules/nixos/openclaw.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/modules/nixos/openclaw.nix b/nix/modules/nixos/openclaw.nix index 3ac02060..77dd8652 100644 --- a/nix/modules/nixos/openclaw.nix +++ b/nix/modules/nixos/openclaw.nix @@ -289,7 +289,7 @@ in { "CLAWDBOT_CONFIG_PATH=${instCfg.configPath}" "CLAWDBOT_STATE_DIR=${instCfg.stateDir}" "CLAWDBOT_NIX_MODE=1" - # Backward-compatible env names + # Backward-compatible env names (gateway still uses CLAWDIS_* in some builds) "CLAWDIS_CONFIG_PATH=${instCfg.configPath}" "CLAWDIS_STATE_DIR=${instCfg.stateDir}" "CLAWDIS_NIX_MODE=1" From f2ee825b02f3e4522f64bf7f09476d8f3611a418 Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Mon, 26 Jan 2026 14:40:34 -0800 Subject: [PATCH 32/34] docs: update PR.md with documents-skills.nix --- PR.md | 1 + 1 file changed, 1 insertion(+) diff --git a/PR.md b/PR.md index 9c2e9ede..53639f4b 100644 --- a/PR.md +++ b/PR.md @@ -24,6 +24,7 @@ Tested and deployed successfully. The service runs with full systemd hardening. - `nix/modules/nixos/clawdbot.nix` - Main module - `nix/modules/nixos/options.nix` - Option definitions +- `nix/modules/nixos/documents-skills.nix` - Documents and skills installation ### Features From cf52befdba552e2f90d9d64cbe5b1368683ed4e0 Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Wed, 28 Jan 2026 16:48:31 -0800 Subject: [PATCH 33/34] docs: update clawdbot to moltbot references Update PR.md and CLAUDE.md with new repo URLs and naming. --- CLAUDE.md | 16 ++++++++-------- PR.md | 14 +++++++------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b8f3cdf4..c5314fc6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,10 @@ -# AGENTS.md — nix-clawdbot +# AGENTS.md — nix-moltbot Single source of truth for product direction: `README.md`. Documentation policy: - Keep the surface area small. -- Avoid duplicate “pointer‑only” files. +- Avoid duplicate "pointer‑only" files. - Update `README.md` first, then adjust references. Defaults: @@ -14,24 +14,24 @@ Defaults: - Breaking changes are acceptable pre‑1.0.0 (move fast, keep docs accurate). - NO INLINE SCRIPTS EVER. - NEVER send any message (iMessage, email, SMS, etc.) without explicit user confirmation: - - Always show the full message text and ask: “I’m going to send this: . Send? (y/n)” + - Always show the full message text and ask: "I'm going to send this: . Send? (y/n)" -Clawdbot packaging: +Moltbot packaging: - The gateway package must include Control UI assets (run `pnpm ui:build` in the Nix build). Golden path for pins (yolo + manual bumps): - Hourly GitHub Action **Yolo Update Pins** runs `scripts/update-pins.sh`, which: - - Picks latest upstream clawdbot SHA with green non-Windows checks + - Picks latest upstream moltbot SHA with green non-Windows checks - Rebuilds gateway to refresh `pnpmDepsHash` - - Regenerates `nix/generated/clawdbot-config-options.nix` from upstream schema + - Regenerates `nix/generated/moltbot-config-options.nix` from upstream schema - Updates app pin/hash, commits, rebases, pushes to `main` - Manual bump (rare): `GH_TOKEN=... scripts/update-pins.sh` (same steps as above). Use only if yolo is blocked. -- To verify freshness: `git pull --ff-only` and check `nix/sources/clawdbot-source.nix` vs `git ls-remote https://github.com/clawdbot/clawdbot.git refs/heads/main`. +- To verify freshness: `git pull --ff-only` and check `nix/sources/moltbot-source.nix` vs `git ls-remote https://github.com/moltbot/moltbot.git refs/heads/main`. - If upstream is moving fast and tighter freshness is needed, trigger yolo manually: `gh workflow run "Yolo Update Pins"`. Philosophy: -The Zen of ~~Python~~ Clawdbot, ~~by~~ shamelessly stolen from Tim Peters +The Zen of ~~Python~~ Moltbot, ~~by~~ shamelessly stolen from Tim Peters Beautiful is better than ugly. Explicit is better than implicit. diff --git a/PR.md b/PR.md index 53639f4b..e28f8ae0 100644 --- a/PR.md +++ b/PR.md @@ -2,13 +2,13 @@ ## Issue -https://github.com/clawdbot/nix-clawdbot/issues/22 +https://github.com/moltbot/nix-moltbot/issues/22 -Upstream issue: https://github.com/clawdbot/clawdbot/issues/2341 +Upstream issue: https://github.com/moltbot/moltbot/issues/2341 ## Goal -Add a NixOS module (`nixosModules.clawdbot`) that runs the gateway as an isolated system user instead of the personal user account. +Add a NixOS module (`nixosModules.moltbot`) that runs the gateway as an isolated system user instead of the personal user account. ## Security Motivation @@ -22,13 +22,13 @@ Tested and deployed successfully. The service runs with full systemd hardening. ### Files -- `nix/modules/nixos/clawdbot.nix` - Main module +- `nix/modules/nixos/moltbot.nix` - Main module - `nix/modules/nixos/options.nix` - Option definitions - `nix/modules/nixos/documents-skills.nix` - Documents and skills installation ### Features -- Dedicated `clawdbot` system user with minimal privileges +- Dedicated `moltbot` system user with minimal privileges - System-level systemd service with hardening: - `ProtectHome=true` - `ProtectSystem=strict` @@ -46,9 +46,9 @@ Tested and deployed successfully. The service runs with full systemd hardening. Uses `providers.anthropic.oauthTokenFile` - a long-lived token from `claude setup-token`. ```nix -services.clawdbot = { +services.moltbot = { enable = true; - providers.anthropic.oauthTokenFile = config.age.secrets.clawdbot-token.path; + providers.anthropic.oauthTokenFile = config.age.secrets.moltbot-token.path; providers.telegram = { enable = true; botTokenFile = config.age.secrets.telegram-token.path; From efe698e995a27923b9e57555cc089ac387991ada Mon Sep 17 00:00:00 2001 From: Jean-Luc Thumm Date: Tue, 3 Feb 2026 11:03:58 -0800 Subject: [PATCH 34/34] fix: update remaining clawdbot references to openclaw in documents-skills.nix --- nix/modules/nixos/documents-skills.nix | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/nix/modules/nixos/documents-skills.nix b/nix/modules/nixos/documents-skills.nix index e1f98d9c..c63a36d2 100644 --- a/nix/modules/nixos/documents-skills.nix +++ b/nix/modules/nixos/documents-skills.nix @@ -12,8 +12,8 @@ let renderSkill = skill: let metadataLine = - if skill.clawdbot != null - then "metadata: ${builtins.toJSON { clawdbot = skill.clawdbot; }}" + if skill.openclaw != null + then "metadata: ${builtins.toJSON { openclaw = skill.openclaw; }}" else null; homepageLine = if skill.homepage != null @@ -53,7 +53,7 @@ let toolsWithReport = if documentsEnabled then - pkgs.runCommand "clawdbot-tools-with-report.md" {} '' + pkgs.runCommand "openclaw-tools-with-report.md" {} '' cat ${cfg.documents + "/TOOLS.md"} > $out echo "" >> $out cat <<'EOF' >> $out @@ -67,19 +67,19 @@ EOF documentsAssertions = lib.optionals documentsEnabled [ { assertion = builtins.pathExists cfg.documents; - message = "services.clawdbot.documents must point to an existing directory."; + message = "services.openclaw.documents must point to an existing directory."; } { assertion = builtins.pathExists (cfg.documents + "/AGENTS.md"); - message = "Missing AGENTS.md in services.clawdbot.documents."; + message = "Missing AGENTS.md in services.openclaw.documents."; } { assertion = builtins.pathExists (cfg.documents + "/SOUL.md"); - message = "Missing SOUL.md in services.clawdbot.documents."; + message = "Missing SOUL.md in services.openclaw.documents."; } { assertion = builtins.pathExists (cfg.documents + "/TOOLS.md"); - message = "Missing TOOLS.md in services.clawdbot.documents."; + message = "Missing TOOLS.md in services.openclaw.documents."; } ]; @@ -94,12 +94,12 @@ EOF (if duplicateNames == [] then [] else [ { assertion = false; - message = "services.clawdbot.skills has duplicate names: ${lib.concatStringsSep ", " duplicateNames}"; + message = "services.openclaw.skills has duplicate names: ${lib.concatStringsSep ", " duplicateNames}"; } ]) ++ (map (s: { assertion = false; - message = "services.clawdbot.skills: skill '${s.name}' uses copy mode but has no source."; + message = "services.openclaw.skills: skill '${s.name}' uses copy mode but has no source."; }) copySkillsWithoutSource); # Build skill derivations for each instance