





















@@ -0,0 +1,183 @@
1+import fs from "node:fs";
2+import fsPromises from "node:fs/promises";
3+import os from "node:os";
4+import path from "node:path";
5+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
6+import { writeSkill } from "./skills.e2e-test-helpers.js";
7+import { buildWorkspaceSkillsPrompt, syncSkillsToWorkspace } from "./skills/workspace.js";
8+9+// Mock resolvePluginSkillDirs to return our test plugin skill directories
10+const mockResolvePluginSkillDirs = vi.hoisted(() => vi.fn(() => [] as string[]));
11+12+vi.mock("./skills/plugin-skills.js", () => ({
13+resolvePluginSkillDirs: mockResolvePluginSkillDirs,
14+}));
15+16+let fixtureRoot = "";
17+let fixtureCount = 0;
18+19+async function createCaseDir(prefix: string): Promise<string> {
20+const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
21+await fsPromises.mkdir(dir, { recursive: true });
22+return dir;
23+}
24+25+async function pathExists(filePath: string): Promise<boolean> {
26+try {
27+await fsPromises.access(filePath);
28+return true;
29+} catch {
30+return false;
31+}
32+}
33+34+beforeAll(async () => {
35+fixtureRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-skills-sync-"));
36+});
37+38+afterAll(async () => {
39+await fsPromises.rm(fixtureRoot, { recursive: true, force: true });
40+});
41+42+describe("syncSkillsToWorkspace for plugin skills", () => {
43+it("syncs plugin skills from symlinked directories to sandbox workspace", async () => {
44+const sourceWorkspace = await createCaseDir("source");
45+const targetWorkspace = await createCaseDir("target");
46+47+const realPluginSkillDir = await createCaseDir("real-plugin-skill");
48+await writeSkill({
49+dir: realPluginSkillDir,
50+name: "wiki-maintainer",
51+description: "Wiki maintenance skill for sandboxed agents",
52+});
53+54+const pluginSkillsDir = path.join(sourceWorkspace, ".openclaw", "plugin-skills");
55+await fsPromises.mkdir(pluginSkillsDir, { recursive: true });
56+const symlinkPath = path.join(pluginSkillsDir, "wiki-maintainer");
57+58+fs.symlinkSync(
59+realPluginSkillDir,
60+symlinkPath,
61+process.platform === "win32" ? "junction" : "dir",
62+);
63+64+mockResolvePluginSkillDirs.mockReturnValueOnce([realPluginSkillDir]);
65+66+await syncSkillsToWorkspace({
67+sourceWorkspaceDir: sourceWorkspace,
68+targetWorkspaceDir: targetWorkspace,
69+pluginSkillsDir: pluginSkillsDir,
70+bundledSkillsDir: path.join(sourceWorkspace, ".bundled"),
71+managedSkillsDir: path.join(sourceWorkspace, ".managed"),
72+});
73+74+const syncedSkillDir = path.join(targetWorkspace, "skills", "wiki-maintainer");
75+const syncedSkillMd = path.join(syncedSkillDir, "SKILL.md");
76+const syncedStat = await fsPromises.lstat(syncedSkillDir);
77+const prompt = buildWorkspaceSkillsPrompt(targetWorkspace, {
78+bundledSkillsDir: path.join(targetWorkspace, ".bundled"),
79+managedSkillsDir: path.join(targetWorkspace, ".managed"),
80+}).replaceAll("\\", "/");
81+82+expect(await pathExists(syncedSkillMd)).toBe(true);
83+expect(syncedStat.isSymbolicLink()).toBe(false);
84+expect(prompt).toContain("Wiki maintenance skill for sandboxed agents");
85+expect(prompt).toContain("skills/wiki-maintainer/SKILL.md");
86+expect(prompt).not.toContain(realPluginSkillDir.replaceAll("\\", "/"));
87+expect(prompt).not.toContain(pluginSkillsDir.replaceAll("\\", "/"));
88+expect(prompt).not.toContain(symlinkPath.replaceAll("\\", "/"));
89+});
90+91+it("syncs multiple plugin skills directories to sandbox workspace", async () => {
92+const sourceWorkspace = await createCaseDir("source-multi");
93+const targetWorkspace = await createCaseDir("target-multi");
94+95+// Create multiple real plugin skill directories
96+const realSkillA = await createCaseDir("skill-a");
97+await writeSkill({
98+dir: realSkillA,
99+name: "browser-automation",
100+description: "Browser automation skill",
101+});
102+103+const realSkillB = await createCaseDir("skill-b");
104+await writeSkill({
105+dir: realSkillB,
106+name: "obsidian-vault",
107+description: "Obsidian vault maintenance skill",
108+});
109+110+// Create plugin-skills directory with symlinks
111+const pluginSkillsDir = path.join(sourceWorkspace, ".openclaw", "plugin-skills");
112+await fsPromises.mkdir(pluginSkillsDir, { recursive: true });
113+114+fs.symlinkSync(
115+realSkillA,
116+path.join(pluginSkillsDir, "browser-automation"),
117+process.platform === "win32" ? "junction" : "dir",
118+);
119+fs.symlinkSync(
120+realSkillB,
121+path.join(pluginSkillsDir, "obsidian-vault"),
122+process.platform === "win32" ? "junction" : "dir",
123+);
124+125+mockResolvePluginSkillDirs.mockReturnValueOnce([realSkillA, realSkillB]);
126+127+await syncSkillsToWorkspace({
128+sourceWorkspaceDir: sourceWorkspace,
129+targetWorkspaceDir: targetWorkspace,
130+pluginSkillsDir: pluginSkillsDir,
131+bundledSkillsDir: path.join(sourceWorkspace, ".bundled"),
132+managedSkillsDir: path.join(sourceWorkspace, ".managed"),
133+});
134+135+// Both skills should be synced
136+expect(
137+await pathExists(path.join(targetWorkspace, "skills", "browser-automation", "SKILL.md")),
138+).toBe(true);
139+expect(
140+await pathExists(path.join(targetWorkspace, "skills", "obsidian-vault", "SKILL.md")),
141+).toBe(true);
142+});
143+144+it("does not sync plugin skills that escape allowed root", async () => {
145+const sourceWorkspace = await createCaseDir("source-escape");
146+const targetWorkspace = await createCaseDir("target-escape");
147+148+// Create a skill outside any allowed root
149+const outsideRoot = await createCaseDir("outside-root");
150+const escapedSkillDir = path.join(outsideRoot, "escaped-skill");
151+await writeSkill({
152+dir: escapedSkillDir,
153+name: "escaped-skill",
154+description: "Should not be synced",
155+});
156+157+// Create plugin-skills with symlink to escaped skill
158+const pluginSkillsDir = path.join(sourceWorkspace, ".openclaw", "plugin-skills");
159+await fsPromises.mkdir(pluginSkillsDir, { recursive: true });
160+fs.symlinkSync(
161+escapedSkillDir,
162+path.join(pluginSkillsDir, "escaped-skill"),
163+process.platform === "win32" ? "junction" : "dir",
164+);
165+166+// Mock returns an allowed root that doesn't include the escaped skill
167+const allowedRoot = await createCaseDir("allowed-root");
168+mockResolvePluginSkillDirs.mockReturnValueOnce([allowedRoot]);
169+170+await syncSkillsToWorkspace({
171+sourceWorkspaceDir: sourceWorkspace,
172+targetWorkspaceDir: targetWorkspace,
173+pluginSkillsDir: pluginSkillsDir,
174+bundledSkillsDir: path.join(sourceWorkspace, ".bundled"),
175+managedSkillsDir: path.join(sourceWorkspace, ".managed"),
176+});
177+178+// Escaped skill should NOT be synced
179+expect(
180+await pathExists(path.join(targetWorkspace, "skills", "escaped-skill", "SKILL.md")),
181+).toBe(false);
182+});
183+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。