Files
openclaw/src/agents/skills.loadworkspaceskillentries.test.ts
zhang-guiping 1df2ac442a fix #77296: [Bug]: Plugin manifest skills field not published to agent skill discovery paths (#77328)
Summary:
- The PR publishes enabled plugin-declared skill directories into a generated `~/.openclaw/plugin-skills` syml ... plugin-skill precedence, cleans stale generated links, adds regression coverage, and updates the changelog.
- Reproducibility: yes. source-based. Current main resolves plugin-declared skill directories for prompt loadi ... ble generated discovery path, and the linked issue provides a concrete ENOENT path for a plugin `SKILL.md`.

Automerge notes:
- Ran the ClawSweeper repair loop before final review.
- Included post-review commit in the final squash: fix: resolve issue #77296
- Included post-review commit in the final squash: fix: publish plugin manifest skills for agent discovery
- Included post-review commit in the final squash: fix(clawsweeper): address review for automerge-openclaw-openclaw-7732…

Validation:
- ClawSweeper review passed for head 0f52865ee3.
- Required merge gates passed before the squash merge.

Prepared head SHA: 0f52865ee3
Review: https://github.com/openclaw/openclaw/pull/77328#issuecomment-4371415857

Co-authored-by: zhang-guiping <zhang.guiping@xydigit.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
2026-05-04 15:31:53 +00:00

604 lines
22 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { resetLogger, setLoggerOverride } from "../logging/logger.js";
import { loggingState } from "../logging/state.js";
import { writeSkill, writeWorkspaceSkills } from "./skills.e2e-test-helpers.js";
import {
restoreMockSkillsHomeEnv,
setMockSkillsHomeEnv,
type SkillsHomeEnvSnapshot,
} from "./skills/home-env.test-support.js";
import { readSkillFrontmatterSafe } from "./skills/local-loader.js";
import { loadWorkspaceSkillEntries } from "./skills/workspace.js";
import { writePluginWithSkill } from "./test-helpers/skill-plugin-fixtures.js";
vi.mock("../plugins/manifest-registry.js", async () => {
const fs = await import("node:fs");
const path = await import("node:path");
return {
loadPluginManifestRegistry: (params: { workspaceDir?: string }) => {
const extensionsRoot = path.join(params.workspaceDir ?? "", ".openclaw", "extensions");
const plugins = [];
for (const id of ["open-prose", "browser"]) {
const rootDir = path.join(extensionsRoot, id);
const manifestPath = path.join(rootDir, "openclaw.plugin.json");
if (!fs.existsSync(manifestPath)) {
continue;
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
enabledByDefault?: boolean;
skills?: string[];
};
plugins.push({
id,
origin: id === "browser" ? "bundled" : "workspace",
enabledByDefault: manifest.enabledByDefault,
providers: [],
legacyPluginIds: [],
kind: [],
skills: manifest.skills ?? ["./skills"],
rootDir,
});
}
return { plugins, diagnostics: [] };
},
};
});
let fakeHome = "";
let envSnapshot: SkillsHomeEnvSnapshot;
let tempRoot = "";
let workspaceCaseIndex = 0;
async function createTempWorkspaceDir() {
const workspaceDir = path.join(tempRoot, `workspace-${++workspaceCaseIndex}`);
await fs.mkdir(workspaceDir, { recursive: true });
return workspaceDir;
}
function captureWarningLogger() {
setLoggerOverride({ level: "silent", consoleLevel: "warn" });
const warn = vi.fn();
loggingState.rawConsole = {
log: vi.fn(),
info: vi.fn(),
warn,
error: vi.fn(),
};
return warn;
}
function loadTestWorkspaceSkillEntries(
workspaceDir: string,
opts?: Parameters<typeof loadWorkspaceSkillEntries>[1],
) {
return loadWorkspaceSkillEntries(workspaceDir, {
managedSkillsDir: path.join(workspaceDir, ".managed"),
bundledSkillsDir: "",
pluginSkillsDir: path.join(workspaceDir, ".plugin-skills"),
...opts,
});
}
beforeAll(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-workspace-"));
fakeHome = path.join(tempRoot, "home");
await fs.mkdir(fakeHome, { recursive: true });
envSnapshot = setMockSkillsHomeEnv(fakeHome);
});
afterEach(async () => {
setLoggerOverride(null);
loggingState.rawConsole = null;
resetLogger();
});
afterAll(async () => {
await restoreMockSkillsHomeEnv(envSnapshot, async () => {
if (tempRoot) {
await fs.rm(tempRoot, { recursive: true, force: true });
}
});
});
async function setupWorkspaceWithProsePlugin() {
const workspaceDir = await createTempWorkspaceDir();
const managedDir = path.join(workspaceDir, ".managed");
const bundledDir = path.join(workspaceDir, ".bundled");
const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "open-prose");
await writePluginWithSkill({
pluginRoot,
pluginId: "open-prose",
skillId: "prose",
skillDescription: "test",
});
return { workspaceDir, managedDir, bundledDir };
}
async function createEscapedBundledSkillFixture(params?: {
workspaceDir?: string;
outsideDir?: string;
}) {
const workspaceDir = params?.workspaceDir ?? (await createTempWorkspaceDir());
const outsideDir = params?.outsideDir ?? (await createTempWorkspaceDir());
const bundledDir = path.join(workspaceDir, ".bundled");
const escapedSkillDir = path.join(outsideDir, "outside-bundled-skill");
await writeSkill({
dir: escapedSkillDir,
name: "outside-bundled-skill",
description: "Outside bundled",
});
await fs.mkdir(bundledDir, { recursive: true });
const requestedPath = path.join(bundledDir, "escaped-bundled-skill");
await fs.symlink(escapedSkillDir, requestedPath, "dir");
return { workspaceDir, outsideDir, bundledDir, escapedSkillDir, requestedPath };
}
describe("loadWorkspaceSkillEntries", () => {
it("filters plugin-shipped skills through plugin config", async () => {
const { workspaceDir, managedDir } = await setupWorkspaceWithProsePlugin();
const enabledEntries = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
plugins: {
entries: { "open-prose": { enabled: true } },
},
},
managedSkillsDir: managedDir,
});
expect(enabledEntries.map((entry) => entry.skill.name)).toContain("prose");
const blockedEntries = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
plugins: {
allow: ["something-else"],
},
},
managedSkillsDir: managedDir,
});
expect(blockedEntries.map((entry) => entry.skill.name)).not.toContain("prose");
});
it("loads the browser plugin automation skill when the bundled plugin is enabled", async () => {
const workspaceDir = await createTempWorkspaceDir();
const managedDir = path.join(workspaceDir, ".managed");
const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "browser");
await writePluginWithSkill({
pluginRoot,
pluginId: "browser",
skillId: "browser-automation",
skillDescription: "Browser automation",
});
await fs.writeFile(
path.join(pluginRoot, "openclaw.plugin.json"),
JSON.stringify(
{
id: "browser",
enabledByDefault: true,
skills: ["./skills"],
configSchema: { type: "object", additionalProperties: false, properties: {} },
},
null,
2,
),
"utf8",
);
const enabledEntries = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {},
managedSkillsDir: managedDir,
});
const browserEntry = enabledEntries.find((entry) => entry.skill.name === "browser-automation");
const browserSkillDir = path.join(pluginRoot, "skills", "browser-automation");
expect(browserEntry?.skill.baseDir).toBe(
path.join(workspaceDir, ".plugin-skills", "browser-automation"),
);
expect(browserEntry?.skill.filePath).toBe(
path.join(workspaceDir, ".plugin-skills", "browser-automation", "SKILL.md"),
);
await expect(
fs.readlink(path.join(workspaceDir, ".plugin-skills", "browser-automation")),
).resolves.toBe(browserSkillDir);
const blockedEntries = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
plugins: {
entries: { browser: { enabled: false } },
},
},
managedSkillsDir: managedDir,
});
expect(blockedEntries.map((entry) => entry.skill.name)).not.toContain("browser-automation");
await expect(
fs.lstat(path.join(workspaceDir, ".plugin-skills", "browser-automation")),
).rejects.toMatchObject({ code: "ENOENT" });
});
it("loads frontmatter edge cases in one workspace", async () => {
const workspaceDir = await createTempWorkspaceDir();
const skillDir = path.join(workspaceDir, "skills", "fallback-name");
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(
path.join(skillDir, "SKILL.md"),
["---", "description: Skill without explicit name", "---", "", "# Fallback"].join("\n"),
"utf8",
);
await writeSkill({
dir: path.join(workspaceDir, "skills", "hidden-skill"),
name: "hidden-skill",
description: "Hidden prompt entry",
frontmatterExtra: "disable-model-invocation: true",
});
const entries = loadTestWorkspaceSkillEntries(workspaceDir);
expect(entries.map((entry) => entry.skill.name)).toContain("fallback-name");
const hiddenEntry = entries.find((entry) => entry.skill.name === "hidden-skill");
expect(hiddenEntry?.invocation?.disableModelInvocation).toBe(true);
expect(hiddenEntry?.exposure?.includeInAvailableSkillsPrompt).toBe(false);
});
it("applies agent skill filters and replacement semantics", async () => {
const workspaceDir = await createTempWorkspaceDir();
await writeWorkspaceSkills(workspaceDir, [
{ name: "github", description: "GitHub" },
{ name: "weather", description: "Weather" },
{ name: "docs-search", description: "Docs" },
]);
const defaultEntries = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
agents: {
defaults: {
skills: ["github"],
},
list: [{ id: "writer" }],
},
},
agentId: "writer",
});
expect(defaultEntries.map((entry) => entry.skill.name)).toEqual(["github"]);
const replacementEntries = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
agents: {
defaults: {
skills: ["github"],
},
list: [{ id: "writer", skills: ["docs-search"] }],
},
},
agentId: "writer",
});
expect(replacementEntries.map((entry) => entry.skill.name)).toEqual(["docs-search"]);
});
it("keeps remote-eligible skills when agent filtering is active", async () => {
const workspaceDir = await createTempWorkspaceDir();
await writeSkill({
dir: path.join(workspaceDir, "skills", "remote-only"),
name: "remote-only",
description: "Needs a remote bin",
metadata: '{"openclaw":{"requires":{"anyBins":["missingbin","sandboxbin"]}}}',
});
const entries = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
agents: {
defaults: {
skills: ["remote-only"],
},
list: [{ id: "writer" }],
},
},
agentId: "writer",
eligibility: {
remote: {
platforms: ["linux"],
hasBin: () => false,
hasAnyBin: (bins: string[]) => bins.includes("sandboxbin"),
note: "sandbox",
},
},
});
expect(entries.map((entry) => entry.skill.name)).toEqual(["remote-only"]);
});
it.runIf(process.platform !== "win32")(
"skips workspace skill paths that resolve outside the workspace root",
async () => {
const workspaceDir = await createTempWorkspaceDir();
const outsideDir = await createTempWorkspaceDir();
const escapedSkillDir = path.join(outsideDir, "outside-skill");
await writeSkill({
dir: escapedSkillDir,
name: "outside-skill",
description: "Outside",
});
await fs.mkdir(path.join(workspaceDir, "skills"), { recursive: true });
const requestedPath = path.join(workspaceDir, "skills", "escaped-skill");
await fs.symlink(escapedSkillDir, requestedPath, "dir");
const fileLinkSkillDir = path.join(workspaceDir, "skills", "escaped-file");
await fs.mkdir(fileLinkSkillDir, { recursive: true });
await fs.symlink(path.join(outsideDir, "SKILL.md"), path.join(fileLinkSkillDir, "SKILL.md"));
const targetDir = path.join(workspaceDir, "safe-target");
await writeSkill({
dir: targetDir,
name: "symlink-target",
description: "Target skill",
});
const symlinkedSkillDir = path.join(workspaceDir, "skills", "symlinked");
await fs.mkdir(symlinkedSkillDir, { recursive: true });
await fs.symlink(path.join(targetDir, "SKILL.md"), path.join(symlinkedSkillDir, "SKILL.md"));
const warn = captureWarningLogger();
const entries = loadTestWorkspaceSkillEntries(workspaceDir);
expect(entries.map((entry) => entry.skill.name)).not.toContain("outside-skill");
expect(entries.map((entry) => entry.skill.name)).not.toContain("outside-file-skill");
expect(entries.map((entry) => entry.skill.name)).not.toContain("symlink-target");
const [line] = warn.mock.calls[0] ?? [];
const warningLine = String(line);
expect(warningLine).toContain("Skipping escaped skill path outside its configured root:");
expect(warningLine).toContain("reason=symlink-escape");
expect(warningLine).toContain("source=openclaw-workspace");
expect(warningLine).toContain(`root=${path.join(workspaceDir, "skills")}`);
expect(warningLine).toContain(`requested=${requestedPath}`);
expect(warningLine).toContain("resolved=");
},
);
it.runIf(process.platform !== "win32")(
"calls out bundled symlink escapes with compact home-relative paths",
async () => {
const { workspaceDir, bundledDir, requestedPath } = await createEscapedBundledSkillFixture();
const warn = captureWarningLogger();
const entries = loadTestWorkspaceSkillEntries(workspaceDir, {
bundledSkillsDir: bundledDir,
});
expect(entries.map((entry) => entry.skill.name)).not.toContain("outside-bundled-skill");
const [line] = warn.mock.calls[0] ?? [];
const warningLine = String(line);
expect(warningLine).toContain("Skipping escaped skill path outside its configured root:");
expect(warningLine).toContain("source=openclaw-bundled");
expect(warningLine).toContain("reason=bundled-symlink-escape");
expect(warningLine).toContain("hint=likely-stray-local-symlink-or-checkout-mutation");
expect(warningLine).toContain(`requested=${requestedPath}`);
expect(warningLine).toContain("resolved=");
},
);
it.runIf(process.platform !== "win32")(
"uses compact home-relative paths in escaped skill console warnings",
async () => {
const { workspaceDir, bundledDir } = await createEscapedBundledSkillFixture({
workspaceDir: path.join(fakeHome, "workspace"),
outsideDir: path.join(fakeHome, "outside"),
});
const warn = captureWarningLogger();
loadTestWorkspaceSkillEntries(workspaceDir, {
bundledSkillsDir: bundledDir,
});
const [line] = warn.mock.calls[0] ?? [];
const warningLine = String(line);
expect(warningLine).toContain("root=~/workspace/.bundled");
expect(warningLine).toContain("requested=~/workspace/.bundled/escaped-bundled-skill");
expect(warningLine).toContain("resolved=~/outside/outside-bundled-skill");
},
);
it.runIf(process.platform !== "win32")(
"reads skill frontmatter when the allowed root is the filesystem root",
async () => {
const workspaceDir = await createTempWorkspaceDir();
const skillDir = path.join(workspaceDir, "skills", "root-allowed");
await writeSkill({
dir: skillDir,
name: "root-allowed",
description: "Readable from filesystem root",
});
const frontmatter = readSkillFrontmatterSafe({
rootDir: path.parse(skillDir).root,
filePath: path.join(skillDir, "SKILL.md"),
});
expect(frontmatter).toMatchObject({
name: "root-allowed",
description: "Readable from filesystem root",
});
},
);
describe("nested skill subdirectories", () => {
it("discovers SKILL.md two levels deep under a grouping subfolder", async () => {
const workspaceDir = await createTempWorkspaceDir();
// Grouped layout: skills/group/skill/SKILL.md (no SKILL.md at skills/group/).
await writeSkill({
dir: path.join(workspaceDir, "skills", "group", "nested-skill"),
name: "nested-skill",
description: "Nested under a group folder",
});
const entries = loadTestWorkspaceSkillEntries(workspaceDir);
const names = entries.map((entry) => entry.skill.name);
expect(names).toContain("nested-skill");
});
it("keeps loading direct skills (skills/skill/SKILL.md) unchanged", async () => {
const workspaceDir = await createTempWorkspaceDir();
await writeSkill({
dir: path.join(workspaceDir, "skills", "direct-skill"),
name: "direct-skill",
description: "Direct skill at first level",
});
// Sibling group with a deeper skill.
await writeSkill({
dir: path.join(workspaceDir, "skills", "group", "grouped-skill"),
name: "grouped-skill",
description: "Skill nested under a group",
});
const names = loadTestWorkspaceSkillEntries(workspaceDir).map((entry) => entry.skill.name);
expect(names).toEqual(expect.arrayContaining(["direct-skill", "grouped-skill"]));
});
it("does not count invalid grouped candidates against the loaded skill cap", async () => {
const workspaceDir = await createTempWorkspaceDir();
for (const nestedName of ["a", "b"]) {
const invalidDir = path.join(workspaceDir, "skills", "00-group", nestedName);
await fs.mkdir(invalidDir, { recursive: true });
await fs.writeFile(
path.join(invalidDir, "SKILL.md"),
`---\nname: ${nestedName}\n---\n\n# Invalid\n`,
"utf-8",
);
}
await writeSkill({
dir: path.join(workspaceDir, "skills", "01-valid"),
name: "valid-skill",
description: "Valid sibling after invalid grouped candidates",
});
const names = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
skills: {
limits: {
maxCandidatesPerRoot: 10,
maxSkillsLoadedPerSource: 1,
},
},
},
}).map((entry) => entry.skill.name);
expect(names).toEqual(["valid-skill"]);
});
it("does not descend more than two levels (skills/a/b/c/SKILL.md is ignored)", async () => {
const workspaceDir = await createTempWorkspaceDir();
await writeSkill({
dir: path.join(workspaceDir, "skills", "a", "b", "c"),
name: "too-deep",
description: "Should not be discovered (depth 3)",
});
const names = loadTestWorkspaceSkillEntries(workspaceDir).map((entry) => entry.skill.name);
expect(names).not.toContain("too-deep");
});
it("does not fall through to child skills when an immediate SKILL.md is invalid", async () => {
const workspaceDir = await createTempWorkspaceDir();
const parentDir = path.join(workspaceDir, "skills", "group", "parent");
await fs.mkdir(parentDir, { recursive: true });
await fs.writeFile(path.join(parentDir, "SKILL.md"), "---\nname: parent\n---\n", "utf-8");
await writeSkill({
dir: path.join(parentDir, "child"),
name: "too-deep",
description: "Should not be discovered through invalid parent fallback",
});
const names = loadTestWorkspaceSkillEntries(workspaceDir).map((entry) => entry.skill.name);
expect(names).not.toContain("too-deep");
});
it("prefers the immediate SKILL.md and does not descend when one is present", async () => {
const workspaceDir = await createTempWorkspaceDir();
// skills/group/SKILL.md exists -> treat group as the skill itself.
await writeSkill({
dir: path.join(workspaceDir, "skills", "group"),
name: "group",
description: "Direct skill at the group level",
});
// skills/group/inner/SKILL.md should NOT be loaded as a separate skill.
await writeSkill({
dir: path.join(workspaceDir, "skills", "group", "inner"),
name: "inner",
description: "Should be ignored when parent is itself a skill",
});
const names = loadTestWorkspaceSkillEntries(workspaceDir).map((entry) => entry.skill.name);
expect(names).toContain("group");
expect(names).not.toContain("inner");
});
it("warns and caps discovery in large grouping subfolders", async () => {
const workspaceDir = await createTempWorkspaceDir();
for (let i = 0; i < 3; i += 1) {
const name = `nested-skill-${i}`;
await writeSkill({
dir: path.join(workspaceDir, "skills", "group", name),
name,
description: `Nested skill ${i}`,
});
}
const warn = captureWarningLogger();
const names = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
skills: {
limits: {
maxCandidatesPerRoot: 2,
maxSkillsLoadedPerSource: 10,
},
},
},
}).map((entry) => entry.skill.name);
expect(names.filter((name) => name.startsWith("nested-skill-"))).toHaveLength(2);
expect(
warn.mock.calls
.map(([line]) => String(line))
.some((line) =>
line.includes("Nested skills directory has many entries, truncating discovery."),
),
).toBe(true);
});
it("does not spend nested candidate budget on ignored raw entries", async () => {
const workspaceDir = await createTempWorkspaceDir();
const groupDir = path.join(workspaceDir, "skills", "group");
await fs.mkdir(groupDir, { recursive: true });
for (let i = 0; i < 50; i += 1) {
await fs.writeFile(path.join(groupDir, `ignored-${String(i).padStart(2, "0")}.txt`), "");
}
for (const name of ["valid-a", "valid-b", "valid-c"]) {
await writeSkill({
dir: path.join(groupDir, name),
name,
description: `${name} nested under a group`,
});
}
const names = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
skills: {
limits: {
maxCandidatesPerRoot: 2,
maxSkillsLoadedPerSource: 10,
},
},
},
}).map((entry) => entry.skill.name);
expect(names.filter((name) => name.startsWith("valid-"))).toEqual(["valid-a", "valid-b"]);
});
});
});