openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
454 lines (453 loc) • 18.7 kB
JavaScript
import { O as walkDirectorySync } from "./fs-safe-B6pvPGnf.js";
import { r as isPathInside } from "./path-guards-Cp-mGr3-.js";
import { t as createSubsystemLogger } from "./subsystem-Dy2tqXOS.js";
import { r as tryRealpath, t as findContainingAllowedSkillSymlinkTarget } from "./symlink-targets-JaagYXQi.js";
import { r as compactSkillPath } from "./local-loader-D2Y-takC.js";
import fs from "node:fs";
import path from "node:path";
//#region src/skills/loading/skill-root-discovery.ts
const skillsLogger = createSubsystemLogger("skills");
const DEFAULT_MAX_CANDIDATES_PER_ROOT = 300;
const DEFAULT_MAX_SKILLS_LOADED_PER_SOURCE = 200;
const DEFAULT_MAX_SKILL_FILE_BYTES = 256e3;
const DEFAULT_MIN_RAW_ENTRIES_PER_DIRECTORY_SCAN = 1e3;
const DEFAULT_MAX_RAW_ENTRIES_PER_DIRECTORY_SCAN = 1e4;
const MAX_GROUPED_SKILL_SCAN_DEPTH = 6;
const MAX_CONFIGURED_ROOT_GROUPED_SKILL_SCAN_DEPTH = 2;
function resolveSkillDiscoveryLimits(config) {
const limits = config?.skills?.limits;
return {
maxCandidatesPerRoot: limits?.maxCandidatesPerRoot ?? DEFAULT_MAX_CANDIDATES_PER_ROOT,
maxSkillsLoadedPerSource: limits?.maxSkillsLoadedPerSource ?? DEFAULT_MAX_SKILLS_LOADED_PER_SOURCE,
maxSkillFileBytes: limits?.maxSkillFileBytes ?? DEFAULT_MAX_SKILL_FILE_BYTES
};
}
function listChildDirectories(dir, opts) {
const maxRawEntriesToScan = opts?.maxRawEntriesToScan === void 0 ? resolveRawEntryScanLimit(opts?.maxCandidateDirs) : Math.max(0, opts.maxRawEntriesToScan);
const scan = walkDirectorySync(dir, {
maxDepth: 1,
maxEntries: maxRawEntriesToScan,
symlinks: opts?.followSymlinks === false ? "skip" : "follow",
include: (entry) => entry.kind === "directory" && !entry.name.startsWith(".") && entry.name !== "node_modules"
});
if (scan.scannedEntryCount === 0 && scan.entries.length === 0) return {
dirs: [],
scannedEntryCount: 0,
truncated: false
};
return {
dirs: scan.entries.map((entry) => entry.name),
scannedEntryCount: scan.scannedEntryCount,
truncated: scan.truncated
};
}
function resolveRawEntryScanLimit(maxCandidateDirs) {
if (maxCandidateDirs === void 0) return Number.POSITIVE_INFINITY;
const normalized = Math.max(0, maxCandidateDirs);
if (normalized === 0) return 0;
return Math.min(DEFAULT_MAX_RAW_ENTRIES_PER_DIRECTORY_SCAN, Math.max(DEFAULT_MIN_RAW_ENTRIES_PER_DIRECTORY_SCAN, normalized * 10));
}
function createSkillDiscoveryBudget(maxCandidateDirs) {
const normalized = Math.max(0, maxCandidateDirs);
return {
remainingDirectoryScans: normalized * MAX_GROUPED_SKILL_SCAN_DEPTH,
remainingRawEntries: resolveRawEntryScanLimit(normalized) * (normalized + 1),
truncated: false
};
}
function hasSkillFileCandidate(skillDir) {
try {
fs.lstatSync(path.join(skillDir, "SKILL.md"));
return true;
} catch (error) {
const code = error && typeof error === "object" && "code" in error ? error.code : void 0;
return code !== "ENOENT" && code !== "ENOTDIR";
}
}
function listBudgetedChildDirectories(dir, budget, opts) {
if (budget.remainingDirectoryScans <= 0 || budget.remainingRawEntries <= 0) {
budget.truncated = true;
return {
dirs: [],
scannedEntryCount: 0,
truncated: false
};
}
budget.remainingDirectoryScans -= 1;
const maxRawEntriesToScan = Math.min(resolveRawEntryScanLimit(opts.maxCandidateDirs), budget.remainingRawEntries);
const scan = listChildDirectories(dir, {
followSymlinks: opts.followSymlinks,
maxCandidateDirs: opts.maxCandidateDirs,
maxRawEntriesToScan
});
budget.remainingRawEntries = Math.max(0, budget.remainingRawEntries - scan.scannedEntryCount);
budget.truncated ||= scan.truncated;
return scan;
}
function containsDiscoverableSkill(dir, opts) {
const discoveryBudget = createSkillDiscoveryBudget(opts.maxCandidateDirs);
const queue = [{
dir,
depth: 0
}];
for (const candidate of queue) {
if (!candidate) continue;
if (candidate.depth > 0 && hasSkillFileCandidate(candidate.dir)) return true;
if (candidate.depth >= MAX_GROUPED_SKILL_SCAN_DEPTH) continue;
if (hasCandidateSymlinkChild(candidate.dir, candidate.depth === 0 ? opts.skipTopLevelDirName : void 0, resolveRawEntryScanLimit(opts.maxCandidateDirs))) return true;
const childDirs = listBudgetedChildDirectories(candidate.dir, discoveryBudget, {
followSymlinks: false,
maxCandidateDirs: opts.maxCandidateDirs
}).dirs;
for (const childDir of childDirs.toSorted().slice(0, opts.maxCandidateDirs)) {
if (candidate.depth === 0 && childDir === opts.skipTopLevelDirName) continue;
queue.push({
dir: path.join(candidate.dir, childDir),
depth: candidate.depth + 1
});
}
}
return false;
}
function hasCandidateSymlinkChild(dir, skipName, maxEntriesToScan) {
const maxEntries = Math.max(0, maxEntriesToScan);
if (maxEntries === 0) return false;
let handle;
try {
handle = fs.opendirSync(dir);
for (let scanned = 0; scanned < maxEntries; scanned += 1) {
const entry = handle.readSync();
if (!entry) break;
if (entry.name === skipName || entry.name.startsWith(".") || entry.name === "node_modules") continue;
if (entry.isSymbolicLink()) return true;
}
} catch {
return false;
} finally {
handle?.closeSync();
}
return false;
}
function isSymlinkPath(filePath) {
try {
return fs.lstatSync(filePath).isSymbolicLink();
} catch {
return false;
}
}
function buildEscapedSkillPathReason(params) {
const candidateIsSymlink = isSymlinkPath(params.candidatePath);
if (params.source === "openclaw-bundled" && candidateIsSymlink) return {
reason: "bundled-symlink-escape",
consoleHint: "reason=bundled-symlink-escape hint=likely-stray-local-symlink-or-checkout-mutation"
};
if (candidateIsSymlink) return {
reason: "symlink-escape",
consoleHint: "reason=symlink-escape"
};
if (params.source === "openclaw-bundled") return {
reason: "bundled-root-escape",
consoleHint: "reason=bundled-root-escape hint=likely-stray-local-symlink-or-checkout-mutation"
};
return {
reason: "path-escape",
consoleHint: "reason=path-escape"
};
}
function warnEscapedSkillPath(params) {
const compactRootDir = compactSkillPath(params.rootDir);
const compactRootRealPath = compactSkillPath(params.rootRealPath);
const compactCandidatePath = compactSkillPath(params.candidatePath);
const compactCandidateRealPath = compactSkillPath(params.candidateRealPath);
const rootResolved = path.resolve(params.rootDir) === params.rootRealPath ? "" : ` rootResolved=${compactRootRealPath}`;
const escapeReason = buildEscapedSkillPathReason({
source: params.source,
candidatePath: params.candidatePath
});
skillsLogger.warn("Skipping escaped skill path outside its configured root.", {
source: params.source,
rootDir: params.rootDir,
rootRealPath: params.rootRealPath,
path: params.candidatePath,
realPath: params.candidateRealPath,
reason: escapeReason.reason,
consoleMessage: `Skipping escaped skill path outside its configured root: source=${params.source} root=${compactRootDir}${rootResolved} ${escapeReason.consoleHint} requested=${compactCandidatePath} resolved=${compactCandidateRealPath}`
});
}
function resolveContainedSkillPath(params) {
const candidateRealPath = tryRealpath(params.candidatePath);
if (!candidateRealPath) return null;
if (isPathInside(params.rootRealPath, candidateRealPath) || findContainingAllowedSkillSymlinkTarget(params.allowedSymlinkTargetRealPaths ?? [], candidateRealPath) !== null) return candidateRealPath;
warnEscapedSkillPath({
source: params.source,
rootDir: params.rootDir,
rootRealPath: params.rootRealPath,
candidatePath: path.resolve(params.candidatePath),
candidateRealPath
});
return null;
}
function resolveNestedSkillsRoot(dir, opts) {
const rootSkillMdExists = hasSkillFileCandidate(dir);
const nested = path.join(dir, "skills");
try {
if (!fs.existsSync(nested) || !fs.statSync(nested).isDirectory()) return { baseDir: dir };
} catch {
return { baseDir: dir };
}
const scanLimit = Math.max(0, opts?.maxEntriesToScan ?? 100);
if (!rootSkillMdExists && containsDiscoverableSkill(dir, {
maxCandidateDirs: scanLimit,
skipTopLevelDirName: "skills"
})) return { baseDir: dir };
const discoveryBudget = createSkillDiscoveryBudget(scanLimit);
const queue = [{
dir: nested,
depth: 0
}];
for (const candidate of queue) {
if (!candidate) continue;
if (hasSkillFileCandidate(candidate.dir)) return {
baseDir: nested,
note: `Detected nested skills root at ${nested}`
};
if (candidate.depth >= MAX_GROUPED_SKILL_SCAN_DEPTH) continue;
const childDirs = listBudgetedChildDirectories(candidate.dir, discoveryBudget, {
followSymlinks: false,
maxCandidateDirs: scanLimit
}).dirs;
for (const childDir of childDirs.toSorted().slice(0, scanLimit)) queue.push({
dir: path.join(candidate.dir, childDir),
depth: candidate.depth + 1
});
}
return { baseDir: dir };
}
function shouldEnforceConfiguredSkillRootContainment(source) {
return source !== "openclaw-managed" && source !== "agents-skills-personal";
}
function shouldUseConfiguredSymlinkTargets(source) {
return source === "openclaw-workspace" || source === "openclaw-extra" || source === "agents-skills-project";
}
function resolveSkillRootCandidatePath(params) {
if (!shouldEnforceConfiguredSkillRootContainment(params.source)) return tryRealpath(params.candidatePath);
return resolveContainedSkillPath({
source: params.source,
rootDir: params.rootDir,
rootRealPath: params.rootRealPath,
candidatePath: params.candidatePath,
allowedSymlinkTargetRealPaths: shouldUseConfiguredSymlinkTargets(params.source) ? params.allowedSymlinkTargetRealPaths : []
});
}
function canonicalSkillDirForSource(source, skillDirRealPath) {
return shouldEnforceConfiguredSkillRootContainment(source) ? void 0 : skillDirRealPath;
}
function resolveSkillFilePath(params) {
const resolved = resolveContainedSkillPath({
source: params.source,
rootDir: params.skillDir,
rootRealPath: params.skillDirRealPath,
candidatePath: params.candidatePath
});
if (resolved || tryRealpath(params.candidatePath)) return resolved;
return path.resolve(params.candidatePath);
}
/** Discover validated skill directory candidates below one configured source root. */
function discoverSkillCandidates(params) {
const rootDir = path.resolve(params.dir);
if (!fs.existsSync(rootDir)) return {
candidates: [],
rootIsSkill: false
};
const rootRealPath = tryRealpath(rootDir) ?? rootDir;
const configuredRootSkillMd = path.join(rootDir, "SKILL.md");
const baseDir = resolveNestedSkillsRoot(params.dir, { maxEntriesToScan: params.limits.maxCandidatesPerRoot }).baseDir;
const baseDirRealPath = resolveSkillRootCandidatePath({
source: params.source,
rootDir,
rootRealPath,
candidatePath: baseDir,
allowedSymlinkTargetRealPaths: params.allowedSymlinkTargetRealPaths
});
if (!baseDirRealPath) return {
candidates: [],
rootIsSkill: false
};
const rootSkillMd = path.join(baseDir, "SKILL.md");
if (hasSkillFileCandidate(baseDir)) return {
candidates: resolveSkillFilePath({
source: params.source,
skillDir: baseDir,
skillDirRealPath: baseDirRealPath,
candidatePath: rootSkillMd
}) ? [{
skillDir: baseDir,
skillDirRealPath: baseDirRealPath,
name: path.basename(baseDir)
}] : [],
rootIsSkill: true
};
const maxCandidatesPerRoot = Math.max(0, params.limits.maxCandidatesPerRoot);
const maxSkillsLoadedPerSource = Math.max(0, params.limits.maxSkillsLoadedPerSource);
const nestedSkillsRootPath = path.resolve(baseDir, "skills");
const baseDirIsNestedSkillsRoot = path.resolve(baseDir) === path.resolve(rootDir, "skills");
const baseDirLooksLikeSkillsRoot = path.basename(baseDir) === "skills";
const discoveryBudget = createSkillDiscoveryBudget(maxCandidatesPerRoot);
const childDirScan = listBudgetedChildDirectories(baseDir, discoveryBudget, { maxCandidateDirs: maxCandidatesPerRoot });
const childDirs = childDirScan.dirs;
const sortedChildDirs = childDirs.toSorted();
const limitedChildren = maxSkillsLoadedPerSource === 0 ? [] : sortedChildDirs.slice(0, maxCandidatesPerRoot);
if (maxSkillsLoadedPerSource > 0 && sortedChildDirs.includes("skills") && !limitedChildren.includes("skills")) limitedChildren.push("skills");
if (childDirScan.truncated) skillsLogger.warn("Skills root looks suspiciously large, truncating discovery.", {
dir: params.dir,
baseDir,
childDirCount: childDirs.length,
scannedEntryCount: childDirScan.scannedEntryCount,
maxEntriesToScan: resolveRawEntryScanLimit(maxCandidatesPerRoot),
maxCandidatesPerRoot: params.limits.maxCandidatesPerRoot,
maxSkillsLoadedPerSource: params.limits.maxSkillsLoadedPerSource
});
else if (childDirs.length > maxCandidatesPerRoot) skillsLogger.warn("Skills root has many entries, truncating discovery.", {
dir: params.dir,
baseDir,
childDirCount: childDirs.length,
maxCandidatesPerRoot: params.limits.maxCandidatesPerRoot,
maxSkillsLoadedPerSource: params.limits.maxSkillsLoadedPerSource
});
let configuredRootCandidate;
if (path.resolve(baseDir) !== rootDir && hasSkillFileCandidate(rootDir)) {
if (resolveSkillFilePath({
source: params.source,
skillDir: rootDir,
skillDirRealPath: rootRealPath,
candidatePath: configuredRootSkillMd
})) configuredRootCandidate = {
skillDir: rootDir,
skillDirRealPath: rootRealPath,
name: path.basename(rootDir)
};
}
const skillCandidates = [];
const scanQueue = limitedChildren.map((name) => ({
skillDir: path.join(baseDir, name),
name,
depth: name === "skills" && !hasSkillFileCandidate(path.join(baseDir, name)) ? 0 : 1
}));
for (const candidate of scanQueue) {
if (!candidate) continue;
const skillDirRealPath = resolveSkillRootCandidatePath({
source: params.source,
rootDir,
rootRealPath: baseDirRealPath,
candidatePath: candidate.skillDir,
allowedSymlinkTargetRealPaths: params.allowedSymlinkTargetRealPaths
});
if (!skillDirRealPath) continue;
const skillMd = path.join(candidate.skillDir, "SKILL.md");
if (hasSkillFileCandidate(candidate.skillDir)) {
if (resolveSkillFilePath({
source: params.source,
skillDir: candidate.skillDir,
skillDirRealPath,
candidatePath: skillMd
})) skillCandidates.push({
skillDir: candidate.skillDir,
skillDirRealPath,
name: candidate.name
});
continue;
}
const candidatePath = path.resolve(candidate.skillDir);
const maxGroupedDepth = params.source === "openclaw-extra" && !baseDirIsNestedSkillsRoot && !baseDirLooksLikeSkillsRoot && candidatePath !== nestedSkillsRootPath && !isPathInside(nestedSkillsRootPath, candidatePath) ? MAX_CONFIGURED_ROOT_GROUPED_SKILL_SCAN_DEPTH : MAX_GROUPED_SKILL_SCAN_DEPTH;
if (candidate.depth >= maxGroupedDepth) continue;
const nestedChildScan = listBudgetedChildDirectories(candidate.skillDir, discoveryBudget, { maxCandidateDirs: maxCandidatesPerRoot });
const nestedChildren = nestedChildScan.dirs;
if (nestedChildScan.truncated) skillsLogger.warn("Nested skills directory looks suspiciously large, truncating discovery.", {
dir: params.dir,
baseDir,
nestedDir: candidate.skillDir,
nestedChildDirCount: nestedChildren.length,
scannedEntryCount: nestedChildScan.scannedEntryCount,
maxEntriesToScan: resolveRawEntryScanLimit(maxCandidatesPerRoot),
maxCandidatesPerRoot: params.limits.maxCandidatesPerRoot,
maxSkillsLoadedPerSource: params.limits.maxSkillsLoadedPerSource,
maxGroupedSkillScanDepth: MAX_GROUPED_SKILL_SCAN_DEPTH
});
else if (nestedChildren.length > maxCandidatesPerRoot) skillsLogger.warn("Nested skills directory has many entries, truncating discovery.", {
dir: params.dir,
baseDir,
nestedDir: candidate.skillDir,
nestedChildDirCount: nestedChildren.length,
maxCandidatesPerRoot: params.limits.maxCandidatesPerRoot,
maxSkillsLoadedPerSource: params.limits.maxSkillsLoadedPerSource,
maxGroupedSkillScanDepth: MAX_GROUPED_SKILL_SCAN_DEPTH
});
for (const nestedName of nestedChildren.toSorted().slice(0, maxCandidatesPerRoot)) scanQueue.push({
skillDir: path.join(candidate.skillDir, nestedName),
name: `${candidate.name}/${nestedName}`,
depth: candidate.depth + 1
});
}
if (discoveryBudget.truncated) skillsLogger.warn("Skills root hit recursive discovery budget, truncating discovery.", {
dir: params.dir,
baseDir,
maxCandidatesPerRoot: params.limits.maxCandidatesPerRoot,
maxSkillsLoadedPerSource: params.limits.maxSkillsLoadedPerSource,
maxGroupedSkillScanDepth: MAX_GROUPED_SKILL_SCAN_DEPTH
});
return {
candidates: skillCandidates.toSorted((a, b) => a.name.localeCompare(b.name)),
rootIsSkill: false,
...configuredRootCandidate ? { configuredRootCandidate } : {}
};
}
/** Discover validated generated plugin-skill symlink candidates. */
function discoverPluginSkills(params) {
const allowedRoots = params.pluginSkillRoots.map(({ dir, rejectHardlinks }) => ({
realPath: tryRealpath(dir),
rejectHardlinks
}));
if (!allowedRoots.some(({ realPath }) => realPath !== null)) return [];
const rootDir = path.resolve(params.pluginSkillsDir);
if (!fs.existsSync(rootDir)) return [];
const rootRealPath = tryRealpath(rootDir) ?? rootDir;
const maxCandidatesPerRoot = Math.max(0, params.limits.maxCandidatesPerRoot);
const maxSkillsLoadedPerSource = Math.max(0, params.limits.maxSkillsLoadedPerSource);
const childDirScan = listChildDirectories(rootDir, { maxCandidateDirs: maxCandidatesPerRoot });
const childDirs = maxSkillsLoadedPerSource === 0 ? [] : childDirScan.dirs.toSorted().slice(0, maxCandidatesPerRoot);
const candidates = [];
for (const name of childDirs) {
const skillDir = path.join(rootDir, name);
if (!isSymlinkPath(skillDir)) continue;
const skillDirRealPath = tryRealpath(skillDir);
const pluginRoot = skillDirRealPath && allowedRoots.find(({ realPath }) => realPath !== null && isPathInside(realPath, skillDirRealPath));
if (!skillDirRealPath || !pluginRoot) {
if (skillDirRealPath) warnEscapedSkillPath({
source: params.source,
rootDir,
rootRealPath,
candidatePath: path.resolve(skillDir),
candidateRealPath: skillDirRealPath
});
continue;
}
const skillMd = path.join(skillDir, "SKILL.md");
let skillMdStat;
try {
skillMdStat = fs.lstatSync(skillMd);
} catch {
continue;
}
if (!skillMdStat.isFile() || skillMdStat.isSymbolicLink()) continue;
const skillMdRealPath = tryRealpath(skillMd);
if (!skillMdRealPath || !isPathInside(skillDirRealPath, skillMdRealPath)) continue;
candidates.push({
skillDir,
skillDirRealPath,
rejectHardlinks: pluginRoot.rejectHardlinks
});
}
return candidates;
}
//#endregion
export { resolveSkillDiscoveryLimits as a, isSymlinkPath as i, discoverPluginSkills as n, discoverSkillCandidates as r, canonicalSkillDirForSource as t };