fix: materialize workspace docs and skills

What:
- copy Nix-managed documents and skills into the OpenClaw workspace as real files
- replace Home Manager symlink installs with an activation-time materialization helper
- extend checks to assert custom plugin skills and document files are not symlinks

Why:
- OpenClaw rejects workspace files that resolve back into the Nix store
- custom plugin skills and documents must satisfy the gateway workspace boundary

Tests:
- git diff --cached --check: passed
- nix/modules/home-manager/openclaw-materialize-workspace-files.sh smoke: copied docs and skill dirs as non-symlinks, rerun idempotent
- temporary worktree with only this staged patch: nix build #checks.x86_64-linux.default-instance --accept-flake-config --no-link --print-out-paths: /nix/store/2zihci7mhlk3mcbczmyw0s401n162vk7-openclaw-default-instance-1
- temporary worktree with only this staged patch: nix build #checks.x86_64-linux.hm-activation --accept-flake-config --no-link --print-out-paths: materialization assertions passed; later gateway open-port wait timed out under local TCG VM after 900s
This commit is contained in:
joshp123 2026-05-05 13:43:56 +02:00
parent bf7764385a
commit 7471da32e5
9 changed files with 136 additions and 66 deletions

View File

@ -5,6 +5,17 @@
}:
let
testLib = lib.extend (
_final: _prev: {
hm.dag = {
entryAfter = after: data: {
inherit after data;
before = [ ];
};
};
}
);
lockedPathFlake =
name: path: narHash:
let
@ -76,7 +87,7 @@ let
moduleEval =
openclawConfig:
lib.evalModules {
testLib.evalModules {
modules = [
stubModule
../modules/home-manager/openclaw.nix
@ -137,7 +148,9 @@ let
];
};
customPluginSkill = ".openclaw/workspace/skills/skill";
hasCustomPluginSkill = builtins.hasAttr customPluginSkill customPluginEval.config.home.file;
customPluginTarget = "/tmp/${customPluginSkill}";
customPluginActivation = builtins.toJSON customPluginEval.config.home.activation.openclawWorkspaceFiles;
hasCustomPluginSkill = lib.hasInfix customPluginTarget customPluginActivation;
customPluginCheck = builtins.deepSeq (requireNoAssertionFailures "customPlugins" customPluginEval) (
if hasCustomPluginSkill then "ok" else throw "customPlugins did not install ${customPluginSkill}."
);

View File

@ -3,6 +3,18 @@
let
openclawModule = ../modules/home-manager/openclaw.nix;
testScript = builtins.readFile ../tests/hm-activation.py;
lockedPathFlake =
name: path: narHash:
let
storePath = builtins.path {
inherit name path;
sha256 = narHash;
};
in
"path:${builtins.unsafeDiscardStringContext (toString storePath)}?narHash=${narHash}";
alphaPluginSource =
lockedPathFlake "openclaw-test-plugin-alpha" ../tests/plugins/alpha
"sha256-FV4UN38sPy2Yp/HhqUxd0HW5l2PcIBBmUz4JzxTAOXY=";
in
pkgs.testers.nixosTest {
@ -37,6 +49,10 @@ pkgs.testers.nixosTest {
programs.openclaw = {
enable = true;
documents = ../tests/documents;
customPlugins = [
{ source = alphaPluginSource; }
];
installApp = false;
launchd.enable = false;
instances.default = {

View File

@ -0,0 +1,58 @@
#!/bin/sh
set -eu
if [ "$#" -lt 3 ]; then
echo "usage: openclaw-materialize-workspace-files <manifest> <source> <target>..." >&2
exit 1
fi
manifest="$1"
shift
if [ $(( $# % 2 )) -ne 0 ]; then
echo "openclaw-materialize-workspace-files requires source/target pairs" >&2
exit 1
fi
manifest_dir="$(dirname "$manifest")"
mkdir -p "$manifest_dir"
old_manifest="$(mktemp)"
new_manifest="$(mktemp)"
trap 'rm -f "$old_manifest" "$new_manifest"' EXIT
if [ -f "$manifest" ]; then
cp "$manifest" "$old_manifest"
fi
was_managed() {
grep -Fx -- "$1" "$old_manifest" >/dev/null 2>&1
}
copy_path() {
source="$1"
target="$2"
if [ -e "$target" ] && [ ! -L "$target" ] && ! was_managed "$target"; then
echo "OpenClaw workspace path exists and is not managed by Nix: $target" >&2
echo "Move it into programs.openclaw.documents or remove it before switching." >&2
exit 1
fi
rm -rf "$target"
mkdir -p "$(dirname "$target")"
if [ -d "$source" ]; then
cp -RL "$source" "$target"
else
cp -L "$source" "$target"
fi
printf '%s\n' "$target" >> "$new_manifest"
}
while [ "$#" -gt 0 ]; do
copy_path "$1" "$2"
shift 2
done
sort -u "$new_manifest" > "$manifest"

View File

@ -56,7 +56,6 @@ let
files = import ./files.nix {
inherit
config
lib
pkgs
openclawLib
@ -280,8 +279,6 @@ in
};
})
(lib.listToAttrs appInstalls)
files.documentsFiles
files.skillFiles
plugins.pluginConfigFiles
(lib.optionalAttrs cfg.reloadScript.enable {
".local/bin/openclaw-reload" = {
@ -291,13 +288,6 @@ in
})
];
home.activation.openclawDocumentGuard = lib.mkIf files.documentsEnabled (
lib.hm.dag.entryBefore [ "writeBoundary" ] ''
set -euo pipefail
${files.documentsGuard}
''
);
home.activation.openclawDirs = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
run --quiet ${lib.getExe' pkgs.coreutils "mkdir"} -p ${
lib.concatStringsSep " " (lib.concatMap (item: item.dirs) instanceConfigs)
@ -307,6 +297,12 @@ in
}
'';
home.activation.openclawWorkspaceFiles = lib.mkIf (files.materializedEntries != [ ]) (
lib.hm.dag.entryAfter [ "openclawDirs" ] ''
run --quiet ${../openclaw-materialize-workspace-files.sh} ${lib.escapeShellArg "${homeDir}/.local/state/nix-openclaw/managed-workspace-files"} ${files.materializedArgs}
''
);
home.activation.openclawConfigFiles = lib.hm.dag.entryAfter [ "openclawDirs" ] ''
${lib.concatStringsSep "\n" (
map (

View File

@ -1,5 +1,4 @@
{
config,
lib,
pkgs,
openclawLib,
@ -61,12 +60,11 @@ let
}
];
skillFiles =
skillEntries =
let
entriesForInstance =
instName: inst:
let
base = "${toRelative (resolvePath inst.workspaceDir)}/skills";
entryFor =
skill:
let
@ -75,44 +73,30 @@ let
in
if mode == "inline" then
{
name = "${base}/${skill.name}/SKILL.md";
value = {
text = renderSkill skill;
};
source = pkgs.writeText "openclaw-skill-${skill.name}.md" (renderSkill skill);
target = "${resolvePath inst.workspaceDir}/skills/${skill.name}/SKILL.md";
}
else if mode == "copy" then
else if mode == "copy" || mode == "symlink" then
{
name = "${base}/${skill.name}";
value = {
source = builtins.path {
name = "openclaw-skill-${skill.name}";
path = source;
};
recursive = true;
source = builtins.path {
name = "openclaw-skill-${skill.name}";
path = source;
};
target = "${resolvePath inst.workspaceDir}/skills/${skill.name}";
}
else
{
name = "${base}/${skill.name}";
value = {
source = config.lib.file.mkOutOfStoreSymlink source;
recursive = true;
};
};
throw "Unsupported OpenClaw skill mode: ${mode}";
pluginEntriesFor =
p:
map (skillPath: {
name = "${base}/${builtins.baseNameOf skillPath}";
value = {
source = skillPath;
recursive = true;
};
source = skillPath;
target = "${resolvePath inst.workspaceDir}/skills/${builtins.baseNameOf skillPath}";
}) p.skills;
pluginsForInstance = plugins.resolvedPluginsByInstance.${instName} or [ ];
in
(map entryFor cfg.skills) ++ (lib.flatten (map pluginEntriesFor pluginsForInstance));
in
lib.listToAttrs (lib.flatten (lib.mapAttrsToList entriesForInstance enabledInstances));
lib.flatten (lib.mapAttrsToList entriesForInstance enabledInstances);
documentsRequiredFiles = [
"AGENTS.md"
@ -156,21 +140,6 @@ let
}
];
documentsGuard = lib.optionalString documentsEnabled (
let
guardLine = file: ''
if [ -e "${file}" ] && [ ! -L "${file}" ]; then
echo "OpenClaw documents are managed by Nix. Please adopt ${file} into your documents directory and re-run." >&2
exit 1
fi
'';
guardForDir = dir: ''
${lib.concatStringsSep "\n" (map (name: guardLine "${dir}/${name}") documentsFileNames)}
'';
in
lib.concatStringsSep "\n" (map guardForDir instanceWorkspaceDirs)
);
toolsReport =
if documentsEnabled then
let
@ -231,33 +200,38 @@ let
else
null;
documentsFiles =
documentEntries =
if documentsEnabled then
let
mkDocFiles =
dir:
let
mkDoc = name: {
name = toRelative (dir + "/${name}");
value = {
source = if name == "TOOLS.md" then toolsWithReport else cfg.documents + "/${name}";
};
source = if name == "TOOLS.md" then toolsWithReport else cfg.documents + "/${name}";
target = dir + "/${name}";
};
in
lib.listToAttrs (map mkDoc documentsFileNames);
map mkDoc documentsFileNames;
in
lib.mkMerge (map mkDocFiles instanceWorkspaceDirs)
lib.flatten (map mkDocFiles instanceWorkspaceDirs)
else
{ };
[ ];
materializedEntries = documentEntries ++ skillEntries;
materializedArgs =
let
renderEntry =
entry: "${lib.escapeShellArg (toString entry.source)} ${lib.escapeShellArg entry.target}";
in
lib.concatStringsSep " " (map renderEntry materializedEntries);
in
{
inherit
documentsEnabled
documentsAssertions
documentsGuard
documentsFiles
materializedArgs
materializedEntries
duplicateSkillAssertion
skillFiles
;
}

View File

@ -0,0 +1,3 @@
# Test Agent
Home Manager activation fixture.

View File

@ -0,0 +1,3 @@
# Test Soul
Home Manager activation fixture.

View File

@ -0,0 +1,3 @@
# Test Tools
Home Manager activation fixture.

View File

@ -5,6 +5,10 @@ machine.wait_until_succeeds(
)
machine.wait_until_succeeds("test -f /home/alice/.openclaw/openclaw.json")
machine.wait_until_succeeds("test -f /home/alice/.openclaw/workspace/AGENTS.md")
machine.succeed("test ! -L /home/alice/.openclaw/workspace/AGENTS.md")
machine.wait_until_succeeds("test -f /home/alice/.openclaw/workspace/skills/skill/SKILL.md")
machine.succeed("test ! -L /home/alice/.openclaw/workspace/skills/skill")
uid = machine.succeed("id -u alice").strip()
machine.succeed("loginctl enable-linger alice")