Skip to content

Commit 2a21a37

Browse files
committed
Skills: use Windows junctions for plugin skills
1 parent 6da5eda commit 2a21a37

3 files changed

Lines changed: 77 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
66

77
### Changes
88

9+
- 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.
910
- 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.
1011
- 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.
1112
- 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.

src/agents/skills/plugin-skills.test.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import fsSync from "node:fs";
1+
import fsSync, { type Dirent } from "node:fs";
22
import fs from "node:fs/promises";
33
import path from "node:path";
44
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
@@ -366,7 +366,18 @@ describe("resolvePluginSkillDirs", () => {
366366
});
367367

368368
describe("publishPluginSkills", () => {
369-
const { publishPluginSkills } = __testing;
369+
const { isGeneratedPluginSkillEntry, publishPluginSkills, resolvePluginSkillLinkType } =
370+
__testing;
371+
372+
function withPlatform<T>(platform: NodeJS.Platform, fn: () => T): T {
373+
const originalPlatform = process.platform;
374+
Object.defineProperty(process, "platform", { configurable: true, value: platform });
375+
try {
376+
return fn();
377+
} finally {
378+
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
379+
}
380+
}
370381

371382
async function writeSkillDir(
372383
parentDir: string,
@@ -399,6 +410,12 @@ describe("publishPluginSkills", () => {
399410
expect(fsSync.readlinkSync(linkB)).toBe(dirB);
400411
});
401412

413+
it("uses junction links for plugin skill directories on Windows", async () => {
414+
expect(resolvePluginSkillLinkType("win32")).toBe("junction");
415+
expect(resolvePluginSkillLinkType("linux")).toBe("dir");
416+
expect(resolvePluginSkillLinkType("darwin")).toBe("dir");
417+
});
418+
402419
it("is idempotent: skips symlinks that already point to the same target", async () => {
403420
const skillParent = await tempDirs.make("plugin-skills-");
404421
const managedDir = await tempDirs.make("managed-skills-");
@@ -446,6 +463,37 @@ describe("publishPluginSkills", () => {
446463
expect(fsSync.existsSync(path.join(managedDir, "stale-skill"))).toBe(false);
447464
});
448465

466+
it("cleans up stale generated junction-like directories on Windows", async () => {
467+
const skillParent = await tempDirs.make("plugin-skills-");
468+
const managedDir = await tempDirs.make("managed-skills-");
469+
470+
const dir = await writeSkillDir(skillParent, "current-skill");
471+
const staleDir = path.join(managedDir, "stale-skill");
472+
await fs.mkdir(staleDir, { recursive: true });
473+
474+
await withPlatform("win32", async () => {
475+
publishPluginSkills([dir], { pluginSkillsDir: managedDir });
476+
});
477+
478+
expect(fsSync.existsSync(path.join(managedDir, "current-skill"))).toBe(true);
479+
expect(fsSync.existsSync(staleDir)).toBe(false);
480+
});
481+
482+
it("treats Windows directory entries as generated plugin skill entries", () => {
483+
const directoryEntry = {
484+
isDirectory: () => true,
485+
isSymbolicLink: () => false,
486+
} as Dirent;
487+
const regularEntry = {
488+
isDirectory: () => false,
489+
isSymbolicLink: () => false,
490+
} as Dirent;
491+
492+
expect(withPlatform("win32", () => isGeneratedPluginSkillEntry(directoryEntry))).toBe(true);
493+
expect(withPlatform("linux", () => isGeneratedPluginSkillEntry(directoryEntry))).toBe(false);
494+
expect(withPlatform("win32", () => isGeneratedPluginSkillEntry(regularEntry))).toBe(false);
495+
});
496+
449497
it("cleans up broken symlinks (dangling)", async () => {
450498
const skillParent = await tempDirs.make("plugin-skills-");
451499
const managedDir = await tempDirs.make("managed-skills-");

src/agents/skills/plugin-skills.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import { CONFIG_DIR } from "../../utils.js";
1616

1717
const log = createSubsystemLogger("skills");
1818

19+
type PluginSkillLinkType = "dir" | "junction";
20+
1921
export function resolvePluginSkillDirs(params: {
2022
workspaceDir: string | undefined;
2123
config?: OpenClawConfig;
@@ -111,6 +113,12 @@ function resolveDefaultPluginSkillsDir(): string {
111113
return path.join(CONFIG_DIR, "plugin-skills");
112114
}
113115

116+
function resolvePluginSkillLinkType(
117+
platform: NodeJS.Platform = process.platform,
118+
): PluginSkillLinkType {
119+
return platform === "win32" ? "junction" : "dir";
120+
}
121+
114122
/**
115123
* Collect skill dir targets from a resolved directory.
116124
* If the directory contains a direct SKILL.md it is published as-is.
@@ -205,15 +213,15 @@ function publishPluginSkills(skillDirs: string[], opts?: { pluginSkillsDir?: str
205213
if (existingTarget === target) {
206214
continue;
207215
}
208-
fs.unlinkSync(linkPath);
216+
removeGeneratedPluginSkillEntry(linkPath);
209217
} catch (err) {
210218
if (!isNotFoundError(err)) {
211219
log.warn(`failed to inspect plugin skill symlink "${linkPath}": ${String(err)}`);
212220
continue;
213221
}
214222
}
215223
try {
216-
fs.symlinkSync(target, linkPath, "dir");
224+
fs.symlinkSync(target, linkPath, resolvePluginSkillLinkType());
217225
} catch (err) {
218226
log.warn(`failed to create plugin skill symlink "${linkPath}" → "${target}": ${String(err)}`);
219227
}
@@ -229,18 +237,26 @@ function publishPluginSkills(skillDirs: string[], opts?: { pluginSkillsDir?: str
229237
return;
230238
}
231239
for (const entry of existingEntries) {
232-
if (!entry.isSymbolicLink()) {
240+
if (!isGeneratedPluginSkillEntry(entry)) {
233241
continue;
234242
}
235243
if (managedTargets.has(entry.name)) {
236244
continue;
237245
}
238246
const linkPath = path.join(pluginSkillsDir, entry.name);
239-
try {
240-
fs.unlinkSync(linkPath);
241-
} catch {
242-
// best-effort cleanup
243-
}
247+
removeGeneratedPluginSkillEntry(linkPath);
248+
}
249+
}
250+
251+
function isGeneratedPluginSkillEntry(entry: fs.Dirent): boolean {
252+
return entry.isSymbolicLink() || (process.platform === "win32" && entry.isDirectory());
253+
}
254+
255+
function removeGeneratedPluginSkillEntry(linkPath: string): void {
256+
try {
257+
fs.rmSync(linkPath, { recursive: true, force: true });
258+
} catch {
259+
// best-effort cleanup
244260
}
245261
}
246262

@@ -253,5 +269,7 @@ function isNotFoundError(err: unknown): boolean {
253269
}
254270

255271
export const __testing = {
272+
isGeneratedPluginSkillEntry,
256273
publishPluginSkills,
274+
resolvePluginSkillLinkType,
257275
};

0 commit comments

Comments
 (0)