UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

454 lines (453 loc) 18.7 kB
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 };