fix: resolve issue #77296

This commit is contained in:
zhang-guiping
2026-05-04 21:06:03 +08:00
committed by clawsweeper
parent cb38535875
commit e2be054172
4 changed files with 271 additions and 0 deletions

View File

@@ -52,6 +52,7 @@ Docs: https://docs.openclaw.ai
- Cron: surface failed isolated-run diagnostics in `cron show`, status, and run history when requested tools are unavailable, so blocked cron runs report the actual tool-policy failure instead of a misleading green result. Fixes #75763. Thanks @RyanSandoval.
- TUI/escape abort: track the in-flight runId after `chat.send` resolves so pressing Esc during the gap before the first gateway event aborts the run instead of repeatedly printing `no active run`. Fixes #1296. Thanks @Lukavyi and @romneyda.
- TUI/render: stop the long-token sanitizer from injecting literal spaces inside inline code spans, fenced code blocks, table borders, and bare hyphenated/dotted identifiers, so copied package names, entity IDs, and shell line-continuations stay byte-for-byte intact while narrow-terminal protection still chunks unidentifiable long prose tokens. Fixes #48432, #39505. Thanks @DocOellerson, @xeusoc, @CCcassiusdjs, @akramcodez, @brokemac79, @romneyda.
- Plugin skills: publish plugin-declared skills into the managed skills directory (`~/.openclaw/skills/`) via symlinks at resolution time, so the agent SDK file-based discovery paths find plugin skill SKILL.md files and stop logging ENOENT when the agent tries to read them. Fixes #77296.
- Gateway/status: label Linux managed gateway services as `systemd user`, making status output explicit about the user-service scope instead of implying a system-level unit. Thanks @vincentkoc.
- Plugins/install: remove the previous managed plugin directory when a reinstall switches sources, so stale ClawHub and npm copies no longer keep duplicate plugin ids in discovery after the new install wins. Thanks @vincentkoc.
- Plugins/install: let official plugin reinstall recovery repair source-only installed runtime shadows, so `openclaw plugins install npm:@openclaw/discord --force` can replace the bad package instead of stopping at stale config validation. Thanks @vincentkoc.

View File

@@ -1,3 +1,4 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
@@ -8,6 +9,7 @@ import {
import type { OpenClawConfig } from "../../config/config.js";
import type { PluginManifestRegistry } from "../../plugins/manifest-registry.js";
import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js";
import { __testing } from "./plugin-skills.js";
const hoisted = vi.hoisted(() => {
const loadManifestRegistry = vi.fn();
@@ -337,3 +339,152 @@ describe("resolvePluginSkillDirs", () => {
expect(dirs).toEqual([path.resolve(pluginRoot, "skills")]);
});
});
describe("publishPluginSkillsToManagedSkillsDir", () => {
const { publishPluginSkillsToManagedSkillsDir } = __testing;
async function writeSkillDir(
parentDir: string,
name: string,
description = `${name} description`,
) {
const dir = path.join(parentDir, name);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(
path.join(dir, "SKILL.md"),
`---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n`,
);
return dir;
}
it("creates symlinks for each plugin skill dir", async () => {
const skillParent = await tempDirs.make("plugin-skills-");
const managedDir = await tempDirs.make("managed-skills-");
const dirA = await writeSkillDir(skillParent, "skill-a");
const dirB = await writeSkillDir(skillParent, "skill-b");
publishPluginSkillsToManagedSkillsDir([dirA, dirB], {
managedSkillsDir: managedDir,
});
const linkA = path.join(managedDir, "skill-a");
const linkB = path.join(managedDir, "skill-b");
expect(fsSync.readlinkSync(linkA)).toBe(dirA);
expect(fsSync.readlinkSync(linkB)).toBe(dirB);
});
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-");
const dir = await writeSkillDir(skillParent, "my-skill");
publishPluginSkillsToManagedSkillsDir([dir], { managedSkillsDir: managedDir });
const mtimeAfterFirst = (await fs.lstat(path.join(managedDir, "my-skill"))).mtimeMs;
// Second call with same input should preserve the existing symlink.
publishPluginSkillsToManagedSkillsDir([dir], { managedSkillsDir: managedDir });
const mtimeAfterSecond = (await fs.lstat(path.join(managedDir, "my-skill"))).mtimeMs;
expect(mtimeAfterSecond).toBe(mtimeAfterFirst);
expect(fsSync.readlinkSync(path.join(managedDir, "my-skill"))).toBe(dir);
});
it("replaces a symlink that points to a different target", async () => {
const skillParent = await tempDirs.make("plugin-skills-");
const managedDir = await tempDirs.make("managed-skills-");
const dir1 = await writeSkillDir(skillParent, "skill-v1", "old");
const dir2 = await writeSkillDir(skillParent, "my-skill", "new");
// Manually create a symlink to dir1 under the same name as dir2's basename.
fsSync.symlinkSync(dir1, path.join(managedDir, "my-skill"), "dir");
// Now publish dir2 (basename "my-skill"); should replace the symlink.
publishPluginSkillsToManagedSkillsDir([dir2], { managedSkillsDir: managedDir });
expect(fsSync.readlinkSync(path.join(managedDir, "my-skill"))).toBe(dir2);
});
it("cleans up stale symlinks whose targets no longer exist", 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(skillParent, "stale-skill");
await fs.mkdir(staleDir, { recursive: true });
// Create a stale symlink pointing to a directory we'll delete.
fsSync.symlinkSync(staleDir, path.join(managedDir, "stale-skill"), "dir");
await fs.rm(staleDir, { recursive: true, force: true });
// Publish only the current skill; stale should be cleaned up.
publishPluginSkillsToManagedSkillsDir([dir], { managedSkillsDir: managedDir });
expect(fsSync.existsSync(path.join(managedDir, "current-skill"))).toBe(true);
// Stale symlink pointing to nonexistent target should be removed.
expect(fsSync.existsSync(path.join(managedDir, "stale-skill"))).toBe(false);
});
it("cleans up broken symlinks (dangling)", async () => {
const skillParent = await tempDirs.make("plugin-skills-");
const managedDir = await tempDirs.make("managed-skills-");
const dir = await writeSkillDir(skillParent, "current-skill");
const nonexistentDir = path.join(skillParent, "nonexistent");
// Create a symlink to a nonexistent directory.
fsSync.symlinkSync(nonexistentDir, path.join(managedDir, "broken-skill"), "dir");
publishPluginSkillsToManagedSkillsDir([dir], { managedSkillsDir: managedDir });
expect(fsSync.existsSync(path.join(managedDir, "current-skill"))).toBe(true);
// Broken symlink pointing to nonexistent target should be removed.
expect(fsSync.existsSync(path.join(managedDir, "broken-skill"))).toBe(false);
});
it("does not create managed skills dir when skill dirs list is empty", async () => {
const parent = await tempDirs.make("parent-");
const managedDir = path.join(parent, "does-not-exist");
publishPluginSkillsToManagedSkillsDir([], { managedSkillsDir: managedDir });
expect(fsSync.existsSync(managedDir)).toBe(false);
});
it("skips directories that do not contain a SKILL.md", async () => {
const skillParent = await tempDirs.make("plugin-skills-");
const managedDir = await tempDirs.make("managed-skills-");
// Create a dir without SKILL.md should be skipped.
const emptyDir = path.join(skillParent, "empty-dir");
await fs.mkdir(emptyDir, { recursive: true });
publishPluginSkillsToManagedSkillsDir([emptyDir], {
managedSkillsDir: managedDir,
});
expect(fsSync.existsSync(path.join(managedDir, "empty-dir"))).toBe(false);
});
it("handles empty skill dirs list without error", async () => {
const managedDir = await tempDirs.make("managed-skills-");
publishPluginSkillsToManagedSkillsDir([], { managedSkillsDir: managedDir });
// No error expected. The managed dir may or may not be created.
});
it("handles collision: same basename from different plugins uses first one", async () => {
const skillParent1 = await tempDirs.make("plugin-skills-1-");
const skillParent2 = await tempDirs.make("plugin-skills-2-");
const managedDir = await tempDirs.make("managed-skills-");
const dir1 = await writeSkillDir(skillParent1, "shared-name", "first");
const dir2 = await writeSkillDir(skillParent2, "shared-name", "second");
publishPluginSkillsToManagedSkillsDir([dir1, dir2], {
managedSkillsDir: managedDir,
});
// First one wins.
expect(fsSync.readlinkSync(path.join(managedDir, "shared-name"))).toBe(dir1);
});
});

View File

@@ -11,12 +11,15 @@ import {
import { loadPluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js";
import { hasKind } from "../../plugins/slots.js";
import { isPathInsideWithRealpath } from "../../security/scan-paths.js";
import { CONFIG_DIR } from "../../utils.js";
const log = createSubsystemLogger("skills");
export function resolvePluginSkillDirs(params: {
workspaceDir: string | undefined;
config?: OpenClawConfig;
/** Override the managed skills directory for testing. */
managedSkillsDir?: string;
}): string[] {
const workspaceDir = (params.workspaceDir ?? "").trim();
if (!workspaceDir) {
@@ -93,5 +96,120 @@ export function resolvePluginSkillDirs(params: {
}
}
publishPluginSkillsToManagedSkillsDir(resolved, {
managedSkillsDir: params.managedSkillsDir,
});
return resolved;
}
function resolveDefaultManagedSkillsDir(): string {
return path.join(CONFIG_DIR, "skills");
}
/**
* Creates symlinks from each resolved plugin skill directory into the
* managed skills directory (~/.openclaw/skills/) so the agent SDK can
* discover them at the conventional file-system path.
*/
function publishPluginSkillsToManagedSkillsDir(
skillDirs: string[],
opts?: { managedSkillsDir?: string },
): void {
const managedSkillsDir = opts?.managedSkillsDir ?? resolveDefaultManagedSkillsDir();
const managedTargets = new Map<string, string>();
// Collect basename → target mappings, reporting collisions.
// Only publish directories that contain a SKILL.md (actual skill dirs,
// not parent containers like ./skills/ that hold multiple skills).
for (const dir of skillDirs) {
if (!fs.existsSync(path.join(dir, "SKILL.md"))) {
continue;
}
const basename = path.basename(dir);
const existing = managedTargets.get(basename);
if (existing) {
log.warn(
`plugin skill name collision: "${basename}" resolves to both ${existing} and ${dir}; ` +
`only the first will be published to managed skills`,
);
continue;
}
managedTargets.set(basename, dir);
}
// Create or update symlinks.
for (const [name, target] of managedTargets) {
const linkPath = path.join(managedSkillsDir, name);
try {
fs.mkdirSync(managedSkillsDir, { recursive: true });
} catch {
// best-effort; symlink will fail below if dir is truly unusable
}
try {
const existingTarget = fs.readlinkSync(linkPath);
if (existingTarget === target) {
continue;
}
log.warn(
`managed skill symlink "${linkPath}" points to ${existingTarget}, replacing with ${target}`,
);
fs.unlinkSync(linkPath);
} catch (err) {
if (!isNotFoundError(err)) {
log.warn(`failed to inspect managed skill symlink "${linkPath}": ${String(err)}`);
continue;
}
}
try {
fs.symlinkSync(target, linkPath, "dir");
} catch (err) {
log.warn(
`failed to create managed skill symlink "${linkPath}" → "${target}": ${String(err)}`,
);
}
}
// Clean up stale symlinks for plugin skills that are no longer active.
let managedEntries: fs.Dirent[];
try {
managedEntries = fs.readdirSync(managedSkillsDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of managedEntries) {
if (!entry.isSymbolicLink()) {
continue;
}
if (managedTargets.has(entry.name)) {
continue;
}
const linkPath = path.join(managedSkillsDir, entry.name);
try {
const target = fs.readlinkSync(linkPath);
// Only remove symlinks that point to directories that no longer exist.
if (!fs.existsSync(target)) {
fs.unlinkSync(linkPath);
}
} catch {
// Broken symlink or other issue — best-effort cleanup.
try {
fs.unlinkSync(linkPath);
} catch {
// ignore
}
}
}
}
function isNotFoundError(err: unknown): boolean {
if (!err || typeof err !== "object") {
return false;
}
const code = (err as Record<string, unknown>).code;
return code === "ENOENT" || code === "ENOTDIR";
}
export const __testing = {
publishPluginSkillsToManagedSkillsDir,
};

View File

@@ -636,6 +636,7 @@ function loadSkillEntries(
const pluginSkillDirs = resolvePluginSkillDirs({
workspaceDir,
config: opts?.config,
managedSkillsDir,
});
const mergedExtraDirs = [...extraDirs, ...pluginSkillDirs];