fix: publish plugin manifest skills for agent discovery

This commit is contained in:
clawsweeper
2026-05-04 15:06:09 +00:00
parent a5abb25ef0
commit 39afd5ca52
4 changed files with 21 additions and 32 deletions

View File

@@ -52,7 +52,7 @@ Docs: https://docs.openclaw.ai
- Cron: surface failed isolated-run diagnostics in `cron show`, status, and run history when requested tools are unavailable, so blocked cron runs report the actual tool-policy failure instead of a misleading green result. Fixes #75763. Thanks @RyanSandoval.
- TUI/escape abort: track the in-flight runId after `chat.send` resolves so pressing Esc during the gap before the first gateway event aborts the run instead of repeatedly printing `no active run`. Fixes #1296. Thanks @Lukavyi and @romneyda.
- TUI/render: stop the long-token sanitizer from injecting literal spaces inside inline code spans, fenced code blocks, table borders, and bare hyphenated/dotted identifiers, so copied package names, entity IDs, and shell line-continuations stay byte-for-byte intact while narrow-terminal protection still chunks unidentifiable long prose tokens. Fixes #48432, #39505. Thanks @DocOellerson, @xeusoc, @CCcassiusdjs, @akramcodez, @brokemac79, @romneyda.
- Plugin skills: publish plugin-declared skills into the managed skills directory (`~/.openclaw/skills/`) via symlinks at resolution time, so the agent SDK file-based discovery paths find plugin skill SKILL.md files and stop logging ENOENT when the agent tries to read them. Fixes #77296.
- Plugin skills: publish plugin-declared skills through the generated plugin skills directory (`~/.openclaw/plugin-skills/`) while keeping direct prompt loading intact, so agent file-based discovery paths find plugin skill `SKILL.md` files and inactive plugin links are cleaned up. Fixes #77296. (#77328) Thanks @zhangguiping-xydt.
- Gateway/status: label Linux managed gateway services as `systemd user`, making status output explicit about the user-service scope instead of implying a system-level unit. Thanks @vincentkoc.
- Plugins/install: remove the previous managed plugin directory when a reinstall switches sources, so stale ClawHub and npm copies no longer keep duplicate plugin ids in discovery after the new install wins. Thanks @vincentkoc.
- Plugins/install: let official plugin reinstall recovery repair source-only installed runtime shadows, so `openclaw plugins install npm:@openclaw/discord --force` can replace the bad package instead of stopping at stale config validation. Thanks @vincentkoc.

View File

@@ -196,7 +196,12 @@ describe("loadWorkspaceSkillEntries", () => {
managedSkillsDir: managedDir,
});
expect(enabledEntries.map((entry) => entry.skill.name)).toContain("browser-automation");
const browserEntry = enabledEntries.find((entry) => entry.skill.name === "browser-automation");
const browserSkillDir = path.join(pluginRoot, "skills", "browser-automation");
expect(browserEntry?.skill.baseDir).toBe(browserSkillDir);
await expect(
fs.readlink(path.join(workspaceDir, ".plugin-skills", "browser-automation")),
).resolves.toBe(browserSkillDir);
const blockedEntries = loadTestWorkspaceSkillEntries(workspaceDir, {
config: {
@@ -208,6 +213,9 @@ describe("loadWorkspaceSkillEntries", () => {
});
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 () => {

View File

@@ -391,40 +391,33 @@ describe("publishPluginSkills", () => {
expect(fsSync.readlinkSync(path.join(managedDir, "my-skill"))).toBe(dir);
});
it("preserves existing managed skill symlinks when a plugin skill has the same name", async () => {
const skillParent = await tempDirs.make("plugin-skills-");
it("replaces owned generated symlinks when a plugin skill target moves", async () => {
const skillParent1 = await tempDirs.make("plugin-skills-1-");
const skillParent2 = await tempDirs.make("plugin-skills-2-");
const managedDir = await tempDirs.make("managed-skills-");
const dir1 = await writeSkillDir(skillParent, "skill-v1", "old");
const dir2 = await writeSkillDir(skillParent, "my-skill", "new");
const dir1 = await writeSkillDir(skillParent1, "my-skill", "old");
const dir2 = await writeSkillDir(skillParent2, "my-skill", "new");
// Manually create a symlink to dir1 under the same name as dir2's basename.
fsSync.symlinkSync(dir1, path.join(managedDir, "my-skill"), "dir");
// Now publish dir2 (basename "my-skill"); must NOT replace existing symlink.
publishPluginSkills([dir2], { pluginSkillsDir: managedDir });
// Existing managed symlink is preserved.
expect(fsSync.readlinkSync(path.join(managedDir, "my-skill"))).toBe(dir1);
expect(fsSync.readlinkSync(path.join(managedDir, "my-skill"))).toBe(dir2);
});
it("cleans up stale symlinks whose targets no longer exist", async () => {
it("cleans up stale symlinks whose targets still exist", async () => {
const skillParent = await tempDirs.make("plugin-skills-");
const managedDir = await tempDirs.make("managed-skills-");
const dir = await writeSkillDir(skillParent, "current-skill");
const staleDir = path.join(skillParent, "stale-skill");
await fs.mkdir(staleDir, { recursive: true });
const staleDir = await writeSkillDir(skillParent, "stale-skill");
// Create a stale symlink pointing to a directory we'll delete.
fsSync.symlinkSync(staleDir, path.join(managedDir, "stale-skill"), "dir");
await fs.rm(staleDir, { recursive: true, force: true });
// Publish only the current skill; stale should be cleaned up.
publishPluginSkills([dir], { pluginSkillsDir: managedDir });
expect(fsSync.existsSync(path.join(managedDir, "current-skill"))).toBe(true);
// Stale symlink pointing to nonexistent target should be removed.
expect(fsSync.existsSync(path.join(managedDir, "stale-skill"))).toBe(false);
});

View File

@@ -184,10 +184,7 @@ function publishPluginSkills(skillDirs: string[], opts?: { pluginSkillsDir?: str
if (existingTarget === target) {
continue;
}
log.warn(
`plugin skill symlink "${linkPath}" already exists, skipping plugin skill "${target}"`,
);
continue;
fs.unlinkSync(linkPath);
} catch (err) {
if (!isNotFoundError(err)) {
log.warn(`failed to inspect plugin skill symlink "${linkPath}": ${String(err)}`);
@@ -219,18 +216,9 @@ function publishPluginSkills(skillDirs: string[], opts?: { pluginSkillsDir?: str
}
const linkPath = path.join(pluginSkillsDir, entry.name);
try {
const target = fs.readlinkSync(linkPath);
// Only remove symlinks that point to directories that no longer exist.
if (!fs.existsSync(target)) {
fs.unlinkSync(linkPath);
}
fs.unlinkSync(linkPath);
} catch {
// Broken symlink or other issue — best-effort cleanup.
try {
fs.unlinkSync(linkPath);
} catch {
// ignore
}
// best-effort cleanup
}
}
}