From 7471da32e530fb7ede0a97b248c5277184da2db1 Mon Sep 17 00:00:00 2001 From: joshp123 Date: Tue, 5 May 2026 13:43:56 +0200 Subject: [PATCH] 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 --- nix/checks/openclaw-default-instance.nix | 17 +++- nix/checks/openclaw-hm-activation.nix | 16 ++++ .../openclaw-materialize-workspace-files.sh | 58 +++++++++++++ nix/modules/home-manager/openclaw/config.nix | 16 ++-- nix/modules/home-manager/openclaw/files.nix | 82 +++++++------------ nix/tests/documents/AGENTS.md | 3 + nix/tests/documents/SOUL.md | 3 + nix/tests/documents/TOOLS.md | 3 + nix/tests/hm-activation.py | 4 + 9 files changed, 136 insertions(+), 66 deletions(-) create mode 100755 nix/modules/home-manager/openclaw-materialize-workspace-files.sh create mode 100644 nix/tests/documents/AGENTS.md create mode 100644 nix/tests/documents/SOUL.md create mode 100644 nix/tests/documents/TOOLS.md diff --git a/nix/checks/openclaw-default-instance.nix b/nix/checks/openclaw-default-instance.nix index 9436b92..d92bbea 100644 --- a/nix/checks/openclaw-default-instance.nix +++ b/nix/checks/openclaw-default-instance.nix @@ -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}." ); diff --git a/nix/checks/openclaw-hm-activation.nix b/nix/checks/openclaw-hm-activation.nix index ee2be1d..4a5ebaa 100644 --- a/nix/checks/openclaw-hm-activation.nix +++ b/nix/checks/openclaw-hm-activation.nix @@ -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 = { diff --git a/nix/modules/home-manager/openclaw-materialize-workspace-files.sh b/nix/modules/home-manager/openclaw-materialize-workspace-files.sh new file mode 100755 index 0000000..f3c2338 --- /dev/null +++ b/nix/modules/home-manager/openclaw-materialize-workspace-files.sh @@ -0,0 +1,58 @@ +#!/bin/sh +set -eu + +if [ "$#" -lt 3 ]; then + echo "usage: openclaw-materialize-workspace-files ..." >&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" diff --git a/nix/modules/home-manager/openclaw/config.nix b/nix/modules/home-manager/openclaw/config.nix index 7774d66..7d1a61c 100644 --- a/nix/modules/home-manager/openclaw/config.nix +++ b/nix/modules/home-manager/openclaw/config.nix @@ -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 ( diff --git a/nix/modules/home-manager/openclaw/files.nix b/nix/modules/home-manager/openclaw/files.nix index 096ceee..1f4a6a8 100644 --- a/nix/modules/home-manager/openclaw/files.nix +++ b/nix/modules/home-manager/openclaw/files.nix @@ -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 ; } diff --git a/nix/tests/documents/AGENTS.md b/nix/tests/documents/AGENTS.md new file mode 100644 index 0000000..5d39d28 --- /dev/null +++ b/nix/tests/documents/AGENTS.md @@ -0,0 +1,3 @@ +# Test Agent + +Home Manager activation fixture. diff --git a/nix/tests/documents/SOUL.md b/nix/tests/documents/SOUL.md new file mode 100644 index 0000000..6f713a7 --- /dev/null +++ b/nix/tests/documents/SOUL.md @@ -0,0 +1,3 @@ +# Test Soul + +Home Manager activation fixture. diff --git a/nix/tests/documents/TOOLS.md b/nix/tests/documents/TOOLS.md new file mode 100644 index 0000000..46e0d70 --- /dev/null +++ b/nix/tests/documents/TOOLS.md @@ -0,0 +1,3 @@ +# Test Tools + +Home Manager activation fixture. diff --git a/nix/tests/hm-activation.py b/nix/tests/hm-activation.py index 0827ab1..f3e114d 100644 --- a/nix/tests/hm-activation.py +++ b/nix/tests/hm-activation.py @@ -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")