diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b055e8d1c4..218ad6e4b0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugin skills: replace generated Windows plugin-skill directories before publishing the current skill link, avoiding repeated `EINVAL` warnings from stale non-symlink entries. Fixes #81432. (#81446) Thanks @hclsys and @vincentkoc. - Channels/config: treat channel entries with only `enabled: true` as configured state so plugin-backed channels can auto-enable from an explicit on switch. Fixes #81323. (#81331) Thanks @EvanYao826 and @vincentkoc. - CLI/update: add an update finalization path for externally swapped core runtimes, running update-time doctor repair and plugin convergence from post-doctor config and install-record state before reporting completion. Thanks @shakkernerd. - Agents/WebChat: stop a successful assistant turn whose stale `errorMessage` matches a billing, auth, or rate-limit pattern from rotating profiles, falling back, or surfacing a hard `FailoverError` unless the current attempt has a real failover failure. (#70900) Thanks @truffle-dev. diff --git a/src/agents/skills/plugin-skills.test.ts b/src/agents/skills/plugin-skills.test.ts index 73eaa340eeb..a9cf5afa9dc 100644 --- a/src/agents/skills/plugin-skills.test.ts +++ b/src/agents/skills/plugin-skills.test.ts @@ -473,6 +473,22 @@ describe("publishPluginSkills", () => { expect(fsSync.readlinkSync(path.join(managedDir, "my-skill"))).toBe(dir2); }); + it("replaces generated Windows directory entries before publishing a current skill", async () => { + const skillParent = await tempDirs.make("plugin-skills-"); + const managedDir = await tempDirs.make("managed-skills-"); + + const dir = await writeSkillDir(skillParent, "my-skill"); + const existingDir = path.join(managedDir, "my-skill"); + await fs.mkdir(existingDir, { recursive: true }); + await fs.writeFile(path.join(existingDir, "stale.txt"), "stale"); + + withPlatform("win32", () => { + publishPluginSkills([dir], { pluginSkillsDir: managedDir }); + }); + + expect(fsSync.readlinkSync(existingDir)).toBe(dir); + }); + it("cleans up stale symlinks whose targets still exist", 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 0a172de8c40..67376b97b66 100644 --- a/src/agents/skills/plugin-skills.ts +++ b/src/agents/skills/plugin-skills.ts @@ -220,11 +220,19 @@ function publishPluginSkills(skillDirs: string[], opts?: { pluginSkillsDir?: str // best-effort; symlink will fail below if dir is truly unusable } try { - const existingTarget = fs.readlinkSync(linkPath); - if (existingTarget === target) { + const existingEntry = fs.lstatSync(linkPath); + if (existingEntry.isSymbolicLink()) { + const existingTarget = fs.readlinkSync(linkPath); + if (existingTarget === target) { + continue; + } + removeGeneratedPluginSkillEntry(linkPath); + } else if (isGeneratedPluginSkillEntry(existingEntry)) { + removeGeneratedPluginSkillEntry(linkPath); + } else { + log.warn(`plugin skill entry is not a generated symlink: ${linkPath}`); continue; } - removeGeneratedPluginSkillEntry(linkPath); } catch (err) { if (!isNotFoundError(err)) { log.warn(`failed to inspect plugin skill symlink "${linkPath}": ${String(err)}`); @@ -259,7 +267,10 @@ function publishPluginSkills(skillDirs: string[], opts?: { pluginSkillsDir?: str } } -function isGeneratedPluginSkillEntry(entry: fs.Dirent): boolean { +function isGeneratedPluginSkillEntry( + entry: Pick, +): boolean { + // Windows directory symlinks are junctions and lstat reports them as directories. return entry.isSymbolicLink() || (process.platform === "win32" && entry.isDirectory()); }