From 5f783d7ddd85cae5776f1ec14c3b48325ea9d163 Mon Sep 17 00:00:00 2001 From: hcl Date: Wed, 6 May 2026 13:37:09 +0800 Subject: [PATCH] Plugin skills: use Windows junction links Fixes #77958.\n\nMaintainer-prepped by narrowing the branch to the Windows plugin-skills junction fix, rebasing onto current main, adding cleanup/idempotence regression coverage and changelog, and verifying local gates plus green CI.\n\nCo-authored-by: hcl <7755017+hclsys@users.noreply.github.com>\nCo-authored-by: Brad Groux <3053586+BradGroux@users.noreply.github.com> --- CHANGELOG.md | 1 + src/agents/skills/plugin-skills.test.ts | 52 ++++++++++++++++++++++++- src/agents/skills/plugin-skills.ts | 34 ++++++++++++---- 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97fc17aa3f5..fddb10e8a59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Plugin skills/Windows: publish plugin-provided skill directories as junctions on Windows so standard users without Developer Mode can register plugin skills without symlink EPERM failures. Fixes #77958. (#77971) Thanks @hclsys and @jarro. - MS Teams: surface blocked Bot Framework egress by logging JWKS fetch network failures and adding a Bot Connector send hint for transport-level reply failures. Fixes #77674. (#78081) Thanks @Beandon13. - PR triage: mark external pull requests with `proof: supplied` when Barnacle finds structured real behavior proof, keep stale negative proof labels in sync across CRLF-edited PR bodies, and let ClawSweeper own the stronger `proof: sufficient` judgement. - Sessions CLI: show the selected agent runtime in the `openclaw sessions` table so terminal output matches the runtime visibility already present in JSON/status surfaces. Thanks @vincentkoc. diff --git a/src/agents/skills/plugin-skills.test.ts b/src/agents/skills/plugin-skills.test.ts index d68d4f39bbe..b9269dbddd1 100644 --- a/src/agents/skills/plugin-skills.test.ts +++ b/src/agents/skills/plugin-skills.test.ts @@ -1,4 +1,4 @@ -import fsSync from "node:fs"; +import fsSync, { type Dirent } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -366,7 +366,18 @@ describe("resolvePluginSkillDirs", () => { }); describe("publishPluginSkills", () => { - const { publishPluginSkills } = __testing; + const { isGeneratedPluginSkillEntry, publishPluginSkills, resolvePluginSkillLinkType } = + __testing; + + function withPlatform(platform: NodeJS.Platform, fn: () => T): T { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { configurable: true, value: platform }); + try { + return fn(); + } finally { + Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform }); + } + } async function writeSkillDir( parentDir: string, @@ -399,6 +410,12 @@ describe("publishPluginSkills", () => { expect(fsSync.readlinkSync(linkB)).toBe(dirB); }); + it("uses junction links for plugin skill directories on Windows", async () => { + expect(resolvePluginSkillLinkType("win32")).toBe("junction"); + expect(resolvePluginSkillLinkType("linux")).toBe("dir"); + expect(resolvePluginSkillLinkType("darwin")).toBe("dir"); + }); + it("is idempotent: skips symlinks that already point to the same target", async () => { const skillParent = await tempDirs.make("plugin-skills-"); const managedDir = await tempDirs.make("managed-skills-"); @@ -446,6 +463,37 @@ describe("publishPluginSkills", () => { expect(fsSync.existsSync(path.join(managedDir, "stale-skill"))).toBe(false); }); + it("cleans up stale generated junction-like directories on Windows", 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(managedDir, "stale-skill"); + await fs.mkdir(staleDir, { recursive: true }); + + await withPlatform("win32", async () => { + publishPluginSkills([dir], { pluginSkillsDir: managedDir }); + }); + + expect(fsSync.existsSync(path.join(managedDir, "current-skill"))).toBe(true); + expect(fsSync.existsSync(staleDir)).toBe(false); + }); + + it("treats Windows directory entries as generated plugin skill entries", () => { + const directoryEntry = { + isDirectory: () => true, + isSymbolicLink: () => false, + } as Dirent; + const regularEntry = { + isDirectory: () => false, + isSymbolicLink: () => false, + } as Dirent; + + expect(withPlatform("win32", () => isGeneratedPluginSkillEntry(directoryEntry))).toBe(true); + expect(withPlatform("linux", () => isGeneratedPluginSkillEntry(directoryEntry))).toBe(false); + expect(withPlatform("win32", () => isGeneratedPluginSkillEntry(regularEntry))).toBe(false); + }); + it("cleans up broken symlinks (dangling)", async () => { const skillParent = await tempDirs.make("plugin-skills-"); const managedDir = await tempDirs.make("managed-skills-"); diff --git a/src/agents/skills/plugin-skills.ts b/src/agents/skills/plugin-skills.ts index 5c2fc3e8480..d7875d6a9b6 100644 --- a/src/agents/skills/plugin-skills.ts +++ b/src/agents/skills/plugin-skills.ts @@ -16,6 +16,8 @@ import { CONFIG_DIR } from "../../utils.js"; const log = createSubsystemLogger("skills"); +type PluginSkillLinkType = "dir" | "junction"; + export function resolvePluginSkillDirs(params: { workspaceDir: string | undefined; config?: OpenClawConfig; @@ -111,6 +113,12 @@ function resolveDefaultPluginSkillsDir(): string { return path.join(CONFIG_DIR, "plugin-skills"); } +function resolvePluginSkillLinkType( + platform: NodeJS.Platform = process.platform, +): PluginSkillLinkType { + return platform === "win32" ? "junction" : "dir"; +} + /** * Collect skill dir targets from a resolved directory. * If the directory contains a direct SKILL.md it is published as-is. @@ -205,7 +213,7 @@ function publishPluginSkills(skillDirs: string[], opts?: { pluginSkillsDir?: str if (existingTarget === target) { continue; } - fs.unlinkSync(linkPath); + removeGeneratedPluginSkillEntry(linkPath); } catch (err) { if (!isNotFoundError(err)) { log.warn(`failed to inspect plugin skill symlink "${linkPath}": ${String(err)}`); @@ -213,7 +221,7 @@ function publishPluginSkills(skillDirs: string[], opts?: { pluginSkillsDir?: str } } try { - fs.symlinkSync(target, linkPath, "dir"); + fs.symlinkSync(target, linkPath, resolvePluginSkillLinkType()); } catch (err) { log.warn(`failed to create plugin skill symlink "${linkPath}" → "${target}": ${String(err)}`); } @@ -229,18 +237,26 @@ function publishPluginSkills(skillDirs: string[], opts?: { pluginSkillsDir?: str return; } for (const entry of existingEntries) { - if (!entry.isSymbolicLink()) { + if (!isGeneratedPluginSkillEntry(entry)) { continue; } if (managedTargets.has(entry.name)) { continue; } const linkPath = path.join(pluginSkillsDir, entry.name); - try { - fs.unlinkSync(linkPath); - } catch { - // best-effort cleanup - } + removeGeneratedPluginSkillEntry(linkPath); + } +} + +function isGeneratedPluginSkillEntry(entry: fs.Dirent): boolean { + return entry.isSymbolicLink() || (process.platform === "win32" && entry.isDirectory()); +} + +function removeGeneratedPluginSkillEntry(linkPath: string): void { + try { + fs.rmSync(linkPath, { recursive: true, force: true }); + } catch { + // best-effort cleanup } } @@ -253,5 +269,7 @@ function isNotFoundError(err: unknown): boolean { } export const __testing = { + isGeneratedPluginSkillEntry, publishPluginSkills, + resolvePluginSkillLinkType, };