From 39afd5ca529081d344a090e5d9806eac6cf5539c Mon Sep 17 00:00:00 2001 From: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 15:06:09 +0000 Subject: [PATCH] fix: publish plugin manifest skills for agent discovery --- CHANGELOG.md | 2 +- .../skills.loadworkspaceskillentries.test.ts | 10 +++++++- src/agents/skills/plugin-skills.test.ts | 23 +++++++------------ src/agents/skills/plugin-skills.ts | 18 +++------------ 4 files changed, 21 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 645c9734eff..12a4a0f9c13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/skills.loadworkspaceskillentries.test.ts b/src/agents/skills.loadworkspaceskillentries.test.ts index eabaf41eeef..ca8e2d59cb7 100644 --- a/src/agents/skills.loadworkspaceskillentries.test.ts +++ b/src/agents/skills.loadworkspaceskillentries.test.ts @@ -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 () => { diff --git a/src/agents/skills/plugin-skills.test.ts b/src/agents/skills/plugin-skills.test.ts index a690d3a1dd8..f5058110c89 100644 --- a/src/agents/skills/plugin-skills.test.ts +++ b/src/agents/skills/plugin-skills.test.ts @@ -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); }); diff --git a/src/agents/skills/plugin-skills.ts b/src/agents/skills/plugin-skills.ts index 7d65cbc50f8..9ef5cdfcf40 100644 --- a/src/agents/skills/plugin-skills.ts +++ b/src/agents/skills/plugin-skills.ts @@ -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 } } }