openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
505 lines (504 loc) • 20.1 kB
JavaScript
import { D as withPluginCache, d as getPluginMetadataSnapshotCache } from "./plugin-cache-Bn9I3v9Q.mjs";
import { t as containsAsciiControlCharacter } from "./string-normalization-YMwDjufr.mjs";
import { t as FsSafeError, w as root } from "./fs-safe-CS1DrPTK.mjs";
import { r as isPathInside } from "./path-guards-CH7noY0Z.mjs";
import { r as isMissingPathError } from "./errno-FR4CRyya.mjs";
import { p as isPathInside$1 } from "./includes-Dj3t0m8d.mjs";
import { r as resolveStateDir } from "./state-dir-CfAW6S2K.mjs";
import "./paths-FklbMVOH.mjs";
import "./errors-BSdPMFao.mjs";
import { t as createSubsystemLogger } from "./subsystem-BlrgtxyM.mjs";
import { n as registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle-CBT_QJEA.mjs";
import { c as pluginCacheLstatSync, d as readPluginCacheDirectory, l as pluginCacheRealpathSync, s as pluginCacheExistsSync } from "./package-manifest-zwWxdzub.mjs";
import { t as shouldRejectHardlinkedPluginFiles } from "./hardlink-policy-uhkgtTrg.mjs";
import { u as resolvePluginMetadataSnapshot } from "./plugin-metadata-snapshot-D_ni58BI.mjs";
import "./skill-library-_14Y6Z0i.mjs";
import { r as resolveSkillTelemetrySourceValue } from "./source-DeRhM-ro.mjs";
import { t as SKILL_LIBRARY_MAX_TREE_ENTRIES } from "./bundle-J5dIJvxT.mjs";
import { t as isAcpRuntimeSpawnAvailable } from "./availability-DxsBYErz.mjs";
import { l as loadSingleSkillDirectory, n as compactSkillPath, r as resolvePluginSkillsDir, s as resolveAllowedSkillSymlinkTargetRealPaths } from "./skill-paths-5mSgftmu.mjs";
import { a as resolveSkillDiscoveryLimits, n as discoverPluginSkills, r as discoverSkillCandidates, t as canonicalSkillDirForSource } from "./skill-root-discovery-DrSrxsr-.mjs";
import { t as iteratePluginRootContributions } from "./plugin-root-contributions-CQOfw3Fm.mjs";
import fs from "node:fs";
import path from "node:path";
import { TextDecoder } from "node:util";
//#region src/skills/loading/skill-root-loader.ts
const skillsLogger = createSubsystemLogger("skills");
function warnInvalidSkill(source, diagnostic) {
skillsLogger.warn("Skipping invalid skill.", {
source,
filePath: diagnostic.path,
error: diagnostic.message,
consoleMessage: `Skipping invalid skill: file=${compactSkillPath(diagnostic.path)} error=${diagnostic.message}`
});
}
function loadContainedSkillRecord(params) {
const loaded = loadSingleSkillDirectory({
skillDir: params.skillDir,
rootRealPath: params.skillDirRealPath,
source: params.source,
maxBytes: params.maxSkillFileBytes,
rejectHardlinks: params.rejectHardlinks,
onDiagnostic: params.onDiagnostic ?? ((diagnostic) => warnInvalidSkill(params.source, diagnostic))
});
if (!loaded) return null;
const record = {
skill: loaded.skill,
frontmatter: loaded.frontmatter
};
const canonicalSkillDir = params.canonicalSkillDir;
return canonicalSkillDir ? canonicalizeLoadedSkillRecord(record, canonicalSkillDir) : record;
}
function canonicalizeLoadedSkillRecord(record, canonicalSkillDir) {
const originalBaseDir = path.resolve(record.skill.baseDir);
const canonicalBaseDir = path.resolve(canonicalSkillDir);
if (originalBaseDir === canonicalBaseDir) return record;
const filePath = path.join(canonicalBaseDir, path.relative(originalBaseDir, record.skill.filePath));
return {
...record,
syncSourceDir: canonicalBaseDir,
syncDirName: path.basename(originalBaseDir),
skill: {
...record.skill,
filePath,
baseDir: canonicalBaseDir,
sourceInfo: record.skill.sourceInfo ? {
...record.skill.sourceInfo,
path: filePath,
baseDir: canonicalBaseDir
} : record.skill.sourceInfo
}
};
}
/** Loads one skill root under the configured discovery limits and symlink/hardlink policy. */
function loadSkillRootRecords(params) {
const discoveryRoot = {
path: path.resolve(params.dir),
worktree: params.worktree ?? isPathInside(params.config?.worktreeRoot ?? path.join(resolveStateDir(), "worktrees"), params.dir)
};
const limits = resolveSkillDiscoveryLimits(params.config);
if (params.mode === "audit") {
const defaults = resolveSkillDiscoveryLimits();
limits.maxCandidatesPerRoot = Math.max(limits.maxCandidatesPerRoot, defaults.maxCandidatesPerRoot);
limits.maxSkillsLoadedPerSource = Math.max(limits.maxSkillsLoadedPerSource, defaults.maxSkillsLoadedPerSource);
limits.maxSkillFileBytes = defaults.maxSkillFileBytes;
}
const rejectHardlinks = params.rejectHardlinks ?? shouldRejectHardlinkedPluginFiles({
origin: resolveSkillTelemetrySourceValue(params.source) === "bundled" ? "bundled" : "workspace",
rootDir: params.dir
});
const discovered = discoverSkillCandidates({
dir: params.dir,
source: params.source,
limits,
allowedSymlinkTargetRealPaths: resolveAllowedSkillSymlinkTargetRealPaths(params.config),
onDiagnostic: params.onDiagnostic
});
const maxSkillsLoadedPerSource = Math.max(0, limits.maxSkillsLoadedPerSource);
const loadCandidate = (candidate) => {
const record = loadContainedSkillRecord({
skillDir: candidate.skillDir,
skillDirRealPath: candidate.skillDirRealPath,
source: params.source,
maxSkillFileBytes: limits.maxSkillFileBytes,
canonicalSkillDir: params.mode === "audit" ? candidate.skillDirRealPath : canonicalSkillDirForSource(params.source, candidate.skillDirRealPath),
rejectHardlinks,
onDiagnostic: params.onDiagnostic
});
if (record) record.skill.discoveryRoot = discoveryRoot;
return record;
};
if (discovered.configuredRootCandidate) {
const rootRecord = loadCandidate(discovered.configuredRootCandidate);
if (rootRecord) return [rootRecord];
}
const loadedSkills = [];
for (const candidate of discovered.candidates) {
if (params.mode !== "audit" && !discovered.rootIsSkill && loadedSkills.length >= maxSkillsLoadedPerSource) break;
const record = loadCandidate(candidate);
if (record) loadedSkills.push(record);
}
return loadedSkills;
}
function loadGeneratedPluginSkillRecords(params) {
const candidates = discoverPluginSkills(params);
const maxSkillsLoadedPerSource = Math.max(0, params.limits.maxSkillsLoadedPerSource);
const loadedSkills = [];
for (const candidate of candidates) {
const record = loadContainedSkillRecord({
skillDir: candidate.skillDir,
skillDirRealPath: candidate.skillDirRealPath,
source: params.source,
maxSkillFileBytes: params.limits.maxSkillFileBytes,
rejectHardlinks: candidate.rejectHardlinks
});
if (record) {
record.skill.discoveryRoot = {
path: path.resolve(params.pluginSkillsDir),
worktree: false
};
loadedSkills.push({
...record,
syncSourceDir: candidate.skillDirRealPath,
syncDirName: path.basename(record.skill.baseDir)
});
}
if (loadedSkills.length >= maxSkillsLoadedPerSource) break;
}
return loadedSkills;
}
//#endregion
//#region src/skills/loading/plugin-skill-bundle.ts
/** Portable relative paths keep catalog paths and installed reads on the same boundary. */
function validatePluginSkillPath(value) {
const parts = value.split("/");
if (!value || value.length > 512 || parts.length > 16 || parts.some((part) => !part || part === "." || part === "..") || containsAsciiControlCharacter(value) || /[\\:]/u.test(value)) throw new Error("Invalid plugin skill bundle path.");
}
function pluginSkillFileFromBytes(filePath, buffer) {
try {
const content = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
if (buffer.some((byte) => byte < 32 && byte !== 9 && byte !== 10 && byte !== 13)) throw new Error("Binary content");
return {
path: filePath,
sizeBytes: buffer.length,
status: "ready",
content
};
} catch {
return {
path: filePath,
sizeBytes: buffer.length,
status: "binary"
};
}
}
/** Inventory and reads share one pinned plugin root; links are represented but never followed. */
async function readPluginSkillBundle(params) {
if (params.rootPath !== ".") validatePluginSkillPath(params.rootPath);
const selectedPath = params.path ?? "SKILL.md";
validatePluginSkillPath(selectedPath);
const owner = await root(params.pluginRoot);
const result = {
name: params.name,
rootPath: params.rootPath,
entryPath: "SKILL.md",
files: [],
directories: [],
inventoryComplete: true
};
let entries = 0;
let bytes = 0;
async function visit(directory, depth) {
if (depth > 16) throw new Error("Skill bundle exceeds directory depth limit.");
const relative = directory ? `${params.rootPath}/${directory}` : params.rootPath;
for await (const entry of owner.entries(relative, {
order: "sorted",
maxEntries: SKILL_LIBRARY_MAX_TREE_ENTRIES - entries,
symlinks: "reject"
})) {
if (++entries > SKILL_LIBRARY_MAX_TREE_ENTRIES) throw new Error("Skill bundle exceeds inventory limits.");
const filePath = directory ? `${directory}/${entry.name}` : entry.name;
validatePluginSkillPath(filePath);
if (entry.isDirectory && !entry.isSymbolicLink) {
result.directories.push(filePath);
await visit(filePath, depth + 1);
continue;
}
if (result.files.length >= 256) throw new Error("Skill bundle exceeds file count limit.");
const file = {
path: filePath,
sizeBytes: entry.size,
status: "unavailable"
};
result.files.push(file);
if (!entry.isFile || entry.isSymbolicLink) continue;
if (entry.size > 1048576 || bytes + entry.size > 8388608) {
file.status = "too-large";
continue;
}
bytes += entry.size;
file.status = "deferred";
}
}
await visit("", 0);
if (params.path !== void 0 && !result.files.some((file) => file.path === selectedPath)) throw new Error("Plugin skill file not found.");
result.files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
const selected = result.files.find((file) => file.path === selectedPath);
if (selected?.status === "deferred") {
selected.status = "unavailable";
try {
const read = await owner.read(`${params.rootPath}/${selectedPath}`, {
symlinks: "reject",
hardlinks: params.rejectHardlinks ? "reject" : "allow",
maxBytes: selected.sizeBytes
});
Object.assign(selected, pluginSkillFileFromBytes(selectedPath, read.buffer));
} catch (error) {
if (error instanceof FsSafeError && error.code === "too-large") selected.status = "too-large";
}
}
return result;
}
//#endregion
//#region src/skills/loading/plugin-skills.ts
const log = createSubsystemLogger("skills");
let lastDefaultPluginSkillsPublication = null;
registerPluginMetadataProcessMemoLifecycleClear(() => {
lastDefaultPluginSkillsPublication = null;
});
function resolvePluginSkillRoots(params) {
const workspaceDir = (params.workspaceDir ?? "").trim();
if (!workspaceDir || params.config?.plugins?.enabled === false) {
publishPluginSkills([], { pluginSkillsDir: params.pluginSkillsDir });
return [];
}
const metadataSnapshot = resolvePluginMetadataSnapshot({
workspaceDir,
config: params.config,
env: process.env
});
return resolvePluginSkillRootsFromMetadata({
...params,
metadataSnapshot
});
}
function resolvePluginSkillRootsFromMetadata(params) {
return withPluginCache(getPluginMetadataSnapshotCache(params.metadataSnapshot), () => resolvePluginSkillRootsInOwner(params));
}
function resolvePluginSkillRootsInOwner(params) {
if (!(params.workspaceDir ?? "").trim()) {
publishPluginSkills([], { pluginSkillsDir: params.pluginSkillsDir });
return [];
}
const config = params.config ?? {};
const metadataSnapshot = params.metadataSnapshot;
if (metadataSnapshot.manifestRegistry.plugins.length === 0) {
publishPluginSkills([], { pluginSkillsDir: params.pluginSkillsDir });
return [];
}
const acpRuntimeAvailable = isAcpRuntimeSpawnAvailable({ config });
const seen = /* @__PURE__ */ new Set();
const resolved = [];
for (const { record, roots } of iteratePluginRootContributions({
metadataSnapshot,
config,
contribution: "skills",
isAvailable: (candidate) => acpRuntimeAvailable || candidate.id !== "acpx"
})) {
const rejectHardlinks = shouldRejectHardlinkedPluginFiles({
origin: record.origin,
rootDir: record.rootDir
});
for (const raw of roots) {
const trimmed = raw.trim();
if (!trimmed) continue;
const candidate = path.resolve(record.rootDir, trimmed);
if (!pluginCacheExistsSync(candidate)) {
log.warn(`plugin skill path not found (${record.id}): ${candidate}`);
continue;
}
if (!isPluginSkillPathInside(record.rootDir, candidate)) {
log.warn(`plugin skill path escapes plugin root (${record.id}): ${candidate}`);
continue;
}
const candidates = record.bundleFormat === "agent" ? collectAgentSkillTargets(candidate) : [candidate];
for (const resolvedCandidate of candidates) {
if (seen.has(resolvedCandidate)) continue;
seen.add(resolvedCandidate);
resolved.push({
dir: resolvedCandidate,
rejectHardlinks
});
}
}
}
publishPluginSkills(resolved.map((root) => root.dir), { pluginSkillsDir: params.pluginSkillsDir });
return resolved;
}
function isPluginSkillPathInside(rootDir, candidate) {
if (!isPathInside$1(rootDir, candidate)) return false;
const rootRealPath = pluginCacheRealpathSync(rootDir);
const candidateRealPath = pluginCacheRealpathSync(candidate);
return Boolean(rootRealPath && candidateRealPath && isPathInside$1(rootRealPath, candidateRealPath));
}
/** Resolve manifest skill roots to the names users and agents actually see. */
function resolvePluginSkillRecords(record) {
const rejectHardlinks = shouldRejectHardlinkedPluginFiles({
origin: record.origin,
rootDir: record.rootDir
});
const records = /* @__PURE__ */ new Map();
for (const raw of record.skills) {
const candidate = path.resolve(record.rootDir, raw.trim());
if (!raw.trim() || !pluginCacheExistsSync(candidate)) continue;
if (!isPluginSkillPathInside(record.rootDir, candidate)) {
log.warn(`plugin skill path escapes plugin root (${record.id}): ${candidate}`);
continue;
}
for (const loaded of loadSkillRootRecords({
dir: candidate,
source: record.origin === "bundled" ? "bundled" : "plugin",
mode: "audit",
rejectHardlinks
})) records.set(loaded.skill.filePath, loaded);
}
return [...records.values()];
}
function resolvePluginSkillDetails(record) {
return [...new Map(resolvePluginSkillRecords(record).map(({ skill }) => [skill.name, {
name: skill.name,
...skill.description ? { description: skill.description } : {}
}])).values()].toSorted((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
}
async function readPluginSkill(record, name, selection) {
if (selection?.version !== void 0 && selection.version !== record.version) throw new Error("Installed plugin version changed. Reopen the skill preview.");
const matches = resolvePluginSkillRecords(record).filter(({ skill }) => skill.name === name);
if (matches.length !== 1) throw new Error(matches.length ? "Plugin skill name is ambiguous." : "Plugin skill not found.");
const pluginRoot = pluginCacheRealpathSync(record.rootDir);
if (!pluginRoot) throw new Error("Installed plugin root not found.");
return {
...await readPluginSkillBundle({
pluginRoot,
rootPath: path.relative(pluginRoot, matches[0].skill.baseDir).split(path.sep).join("/") || ".",
name,
rejectHardlinks: shouldRejectHardlinkedPluginFiles(record),
path: selection?.path
}),
...record.version ? { version: record.version } : {}
};
}
function listSkillChildDirectories(dir) {
try {
return readPluginCacheDirectory(dir).filter((entry) => entry.isDirectory()).map((entry) => ({
name: entry.name,
path: path.join(dir, entry.name)
}));
} catch {
return [];
}
}
function collectAgentSkillTargets(skillsRoot) {
const targets = [];
for (const entry of listSkillChildDirectories(skillsRoot)) {
if (hasPublishableSkillFile({
skillDir: entry.path,
rootDir: skillsRoot
})) {
targets.push(entry.path);
continue;
}
log.warn(`agent plugin skill skipped because SKILL.md is missing or invalid: ${entry.path}`);
}
return targets;
}
function collectSkillTargets(dir, targets) {
const candidates = hasPublishableSkillFile({
skillDir: dir,
rootDir: dir
}) ? [{
name: path.basename(dir),
path: dir
}] : listSkillChildDirectories(dir).filter((entry) => hasPublishableSkillFile({
skillDir: entry.path,
rootDir: dir
}));
for (const entry of candidates) {
const existing = targets.get(entry.name);
if (existing) {
log.warn(`plugin skill name collision: "${entry.name}" resolves to both ${existing} and ${entry.path}; only the first will be published`);
continue;
}
targets.set(entry.name, entry.path);
}
}
function hasPublishableSkillFile(params) {
const skillMd = path.join(params.skillDir, "SKILL.md");
const skillMdStat = pluginCacheLstatSync(skillMd);
if (!skillMdStat) return false;
if (!skillMdStat.isFile() || skillMdStat.isSymbolicLink()) {
log.warn(`plugin skill SKILL.md is not a regular file: ${skillMd}`);
return false;
}
if (!isPluginSkillPathInside(params.rootDir, skillMd)) {
log.warn(`plugin skill SKILL.md escapes declared skill root: ${skillMd}`);
return false;
}
return true;
}
/**
* Creates symlinks from each resolved plugin skill directory into the
* plugin skills directory (~/.openclaw/plugin-skills/) so the agent SDK can
* discover them at the conventional file-system path.
*
* The plugin-skills directory is fully owned by OpenClaw — every entry is
* a generated symlink. Cleanup of stale links is therefore safe.
*/
function publishPluginSkills(skillDirs, opts) {
const pluginSkillsDir = opts?.pluginSkillsDir ?? resolvePluginSkillsDir();
const managedTargets = /* @__PURE__ */ new Map();
for (const dir of skillDirs) collectSkillTargets(dir, managedTargets);
if (opts?.pluginSkillsDir === void 0 && lastDefaultPluginSkillsPublication?.directory === pluginSkillsDir && lastDefaultPluginSkillsPublication.targets.size === managedTargets.size && [...managedTargets].every(([name, target]) => lastDefaultPluginSkillsPublication?.targets.get(name) === target)) return;
for (const [name, target] of managedTargets) {
const linkPath = path.join(pluginSkillsDir, name);
try {
fs.mkdirSync(pluginSkillsDir, { recursive: true });
} catch {}
try {
const existingEntry = fs.lstatSync(linkPath);
if (existingEntry.isSymbolicLink()) {
if (fs.readlinkSync(linkPath) === target) continue;
removeGeneratedPluginSkillEntry(linkPath);
} else if (isGeneratedPluginSkillEntry(existingEntry)) removeGeneratedPluginSkillEntry(linkPath);
else {
log.warn(`plugin skill entry is not a generated symlink: ${linkPath}`);
continue;
}
} catch (err) {
if (!isMissingPathError(err)) {
log.warn(`failed to inspect plugin skill symlink "${linkPath}": ${String(err)}`);
continue;
}
}
try {
fs.symlinkSync(target, linkPath, process.platform === "win32" ? "junction" : "dir");
} catch (err) {
log.warn(`failed to create plugin skill symlink "${linkPath}" → "${target}": ${String(err)}`);
}
}
let existingEntries;
try {
existingEntries = fs.readdirSync(pluginSkillsDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of existingEntries) {
if (!isGeneratedPluginSkillEntry(entry)) continue;
if (managedTargets.has(entry.name)) continue;
removeGeneratedPluginSkillEntry(path.join(pluginSkillsDir, entry.name));
}
if (opts?.pluginSkillsDir === void 0) lastDefaultPluginSkillsPublication = {
directory: pluginSkillsDir,
targets: managedTargets
};
}
function isGeneratedPluginSkillEntry(entry) {
return entry.isSymbolicLink() || process.platform === "win32" && entry.isDirectory();
}
function removeGeneratedPluginSkillEntry(linkPath) {
try {
if (fs.lstatSync(linkPath).isSymbolicLink()) {
fs.unlinkSync(linkPath);
return;
}
} catch (err) {
if (isMissingPathError(err)) return;
}
try {
fs.rmSync(linkPath, {
recursive: true,
force: true
});
} catch {}
}
//#endregion
export { pluginSkillFileFromBytes as a, loadSkillRootRecords as c, resolvePluginSkillRootsFromMetadata as i, warnInvalidSkill as l, resolvePluginSkillDetails as n, validatePluginSkillPath as o, resolvePluginSkillRoots as r, loadGeneratedPluginSkillRecords as s, readPluginSkill as t };