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:
parent
bf7764385a
commit
7471da32e5
@ -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}."
|
||||
);
|
||||
|
||||
@ -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 = {
|
||||
|
||||
58
nix/modules/home-manager/openclaw-materialize-workspace-files.sh
Executable file
58
nix/modules/home-manager/openclaw-materialize-workspace-files.sh
Executable 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"
|
||||
@ -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 (
|
||||
|
||||
@ -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
|
||||
;
|
||||
}
|
||||
|
||||
3
nix/tests/documents/AGENTS.md
Normal file
3
nix/tests/documents/AGENTS.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Test Agent
|
||||
|
||||
Home Manager activation fixture.
|
||||
3
nix/tests/documents/SOUL.md
Normal file
3
nix/tests/documents/SOUL.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Test Soul
|
||||
|
||||
Home Manager activation fixture.
|
||||
3
nix/tests/documents/TOOLS.md
Normal file
3
nix/tests/documents/TOOLS.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Test Tools
|
||||
|
||||
Home Manager activation fixture.
|
||||
@ -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")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user