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>
This commit is contained in:
hcl
2026-05-06 13:37:09 +08:00
committed by GitHub
parent 03e6a029ab
commit 5f783d7ddd
3 changed files with 77 additions and 10 deletions

View File

@@ -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.

View File

@@ -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<T>(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-");

View File

@@ -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,
};