Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0402309
feat(nixos): add NixOS module with systemd hardening
jeanlucthumm Feb 9, 2026
2288c7a
test(nixos): add VM integration test for system service module
jeanlucthumm Jan 26, 2026
5afee18
fix(nixos): remove hardcoded ReadOnlyPaths that may not exist
jeanlucthumm Jan 26, 2026
50af4e9
feat(nixos): add oauthCredentialsDir for Claude CLI OAuth support
jeanlucthumm Jan 26, 2026
d3517db
test(nixos): add OAuth bind-mount verification to VM test
jeanlucthumm Jan 26, 2026
bfbf1fd
fix(nixos): allow AF_NETLINK for Node.js networkInterfaces()
jeanlucthumm Jan 26, 2026
1e216ae
fix(nixos): default gateway.auth.mode to none for system service
jeanlucthumm Jan 26, 2026
d7bcabc
refactor(nixos): don't default gateway.auth.mode, let users configure
jeanlucthumm Jan 26, 2026
4a705f4
feat(nixos): add gateway.auth options for upstream auth requirement
jeanlucthumm Jan 26, 2026
f8fbe6a
fix(nixos): relax syscall filter for clipboard module
jeanlucthumm Jan 26, 2026
2b67520
fix(nixos): relax syscall filter for Node.js compatibility
jeanlucthumm Jan 26, 2026
9ac6bf4
fix(nixos): inherit top-level provider settings in instances
jeanlucthumm Jan 26, 2026
9ca0b8c
fix(nixos): disable ProtectHome when OAuth credentials in /home
jeanlucthumm Jan 26, 2026
ffcc16e
refactor(nixos): replace oauthCredentialsDir with oauthTokenFile
jeanlucthumm Jan 26, 2026
bb0fc4e
fix(test): remove obsolete oauthCredentialsDir test
jeanlucthumm Jan 26, 2026
399db01
refactor(nixos): remove unimplemented documents/skills options
jeanlucthumm Jan 26, 2026
15b5506
feat(nixos): implement documents and skills support
jeanlucthumm Jan 26, 2026
4207aa0
refactor: remove unused config option from both modules
jeanlucthumm Jan 26, 2026
eee9805
feat(nixos): add assertions for auth configuration
jeanlucthumm Jan 26, 2026
a4d538c
refactor(nixos): remove duplicate instances option definition
jeanlucthumm Jan 26, 2026
a90a2f7
docs(nixos): clarify CLAWDIS_* env var comment
jeanlucthumm Jan 26, 2026
f44a02d
fix: update remaining clawdbot references to openclaw in documents-sk…
jeanlucthumm Feb 3, 2026
6ee9f7c
docs: add NixOS module to What This Fork Adds
jeanlucthumm Feb 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ This fork builds on top of the base nix-openclaw package. Here's what's differen

Full Linux support including `aarch64-linux` and working systemd user services out of the box.

### NixOS Module

Run Openclaw as an isolated system user with systemd hardening. Contains the blast radius if the LLM is compromised — `ProtectHome`, `PrivateTmp`, `NoNewPrivileges`, syscall filtering, and more.

```nix
services.openclaw = {
enable = true;
providers.anthropic.oauthTokenFile = config.age.secrets.openclaw-token.path;
};
```

---

## Contributions (read this first)
Expand Down
5 changes: 5 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,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 {
Expand All @@ -79,5 +83,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;
};
}
88 changes: 88 additions & 0 deletions nix/checks/nixos-module-test.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# 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 (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

{ 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;
# 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;
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"))
'';
}
161 changes: 161 additions & 0 deletions nix/modules/nixos/documents-skills.nix
Original file line number Diff line number Diff line change
@@ -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.openclaw != null
then "metadata: ${builtins.toJSON { openclaw = skill.openclaw; }}"
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 = [
"<!-- BEGIN NIX-REPORT -->"
""
"## Nix-managed tools"
""
"### Built-in toolchain"
]
++ (if toolNames == [] then [ "- (none)" ] else map (name: "- " + name) toolNames)
++ [
""
"<!-- END NIX-REPORT -->"
];
in
lib.concatStringsSep "\n" reportLines;

toolsWithReport =
if documentsEnabled then
pkgs.runCommand "openclaw-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.openclaw.documents must point to an existing directory.";
}
{
assertion = builtins.pathExists (cfg.documents + "/AGENTS.md");
message = "Missing AGENTS.md in services.openclaw.documents.";
}
{
assertion = builtins.pathExists (cfg.documents + "/SOUL.md");
message = "Missing SOUL.md in services.openclaw.documents.";
}
{
assertion = builtins.pathExists (cfg.documents + "/TOOLS.md");
message = "Missing TOOLS.md in services.openclaw.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.openclaw.skills has duplicate names: ${lib.concatStringsSep ", " duplicateNames}";
}
])
++ (map (s: {
assertion = false;
message = "services.openclaw.skills: skill '${s.name}' uses copy mode but has no source.";
}) copySkillsWithoutSource);

# Build skill derivations for each instance
# Returns: { "<instanceName>" = [ { path = "skills/<name>"; drv = <derivation>; } ... ]; }
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: { "<instanceName>" = { agents = <drv>; soul = <drv>; tools = <drv>; } 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;
}
Loading
Loading