mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:50:43 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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-");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user