Files
Moltbot/src/plugins/discovery.ts
2026-03-04 01:20:48 -05:00

712 lines
20 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { resolveConfigDir, resolveUserPath } from "../utils.js";
import { resolveBundledPluginsDir } from "./bundled-dir.js";
import {
DEFAULT_PLUGIN_ENTRY_CANDIDATES,
getPackageManifestMetadata,
resolvePackageExtensionEntries,
type OpenClawPackageManifest,
type PackageManifest,
} from "./manifest.js";
import { formatPosixMode, isPathInside, safeRealpathSync, safeStatSync } from "./path-safety.js";
import type { PluginDiagnostic, PluginOrigin } from "./types.js";
const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]);
export type PluginCandidate = {
idHint: string;
source: string;
rootDir: string;
origin: PluginOrigin;
workspaceDir?: string;
packageName?: string;
packageVersion?: string;
packageDescription?: string;
packageDir?: string;
packageManifest?: OpenClawPackageManifest;
};
export type PluginDiscoveryResult = {
candidates: PluginCandidate[];
diagnostics: PluginDiagnostic[];
};
const discoveryCache = new Map<string, { expiresAt: number; result: PluginDiscoveryResult }>();
// Keep a short cache window to collapse bursty reloads during startup flows.
const DEFAULT_DISCOVERY_CACHE_MS = 1000;
export function clearPluginDiscoveryCache(): void {
discoveryCache.clear();
}
function resolveDiscoveryCacheMs(env: NodeJS.ProcessEnv): number {
const raw = env.OPENCLAW_PLUGIN_DISCOVERY_CACHE_MS?.trim();
if (raw === "" || raw === "0") {
return 0;
}
if (!raw) {
return DEFAULT_DISCOVERY_CACHE_MS;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed)) {
return DEFAULT_DISCOVERY_CACHE_MS;
}
return Math.max(0, parsed);
}
function shouldUseDiscoveryCache(env: NodeJS.ProcessEnv): boolean {
const disabled = env.OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE?.trim();
if (disabled) {
return false;
}
return resolveDiscoveryCacheMs(env) > 0;
}
function buildDiscoveryCacheKey(params: {
workspaceDir?: string;
extraPaths?: string[];
ownershipUid?: number | null;
}): string {
const workspaceKey = params.workspaceDir ? resolveUserPath(params.workspaceDir) : "";
const configExtensionsRoot = path.join(resolveConfigDir(), "extensions");
const bundledRoot = resolveBundledPluginsDir() ?? "";
const normalizedExtraPaths = (params.extraPaths ?? [])
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => resolveUserPath(entry))
.toSorted();
const ownershipUid = params.ownershipUid ?? currentUid();
return `${workspaceKey}::${ownershipUid ?? "none"}::${configExtensionsRoot}::${bundledRoot}::${JSON.stringify(normalizedExtraPaths)}`;
}
function currentUid(overrideUid?: number | null): number | null {
if (overrideUid !== undefined) {
return overrideUid;
}
if (process.platform === "win32") {
return null;
}
if (typeof process.getuid !== "function") {
return null;
}
return process.getuid();
}
export type CandidateBlockReason =
| "source_escapes_root"
| "path_stat_failed"
| "path_world_writable"
| "path_suspicious_ownership";
type CandidateBlockIssue = {
reason: CandidateBlockReason;
sourcePath: string;
rootPath: string;
targetPath: string;
sourceRealPath?: string;
rootRealPath?: string;
modeBits?: number;
foundUid?: number;
expectedUid?: number;
};
function checkSourceEscapesRoot(params: {
source: string;
rootDir: string;
}): CandidateBlockIssue | null {
const sourceRealPath = safeRealpathSync(params.source);
const rootRealPath = safeRealpathSync(params.rootDir);
if (!sourceRealPath || !rootRealPath) {
return null;
}
if (isPathInside(rootRealPath, sourceRealPath)) {
return null;
}
return {
reason: "source_escapes_root",
sourcePath: params.source,
rootPath: params.rootDir,
targetPath: params.source,
sourceRealPath,
rootRealPath,
};
}
function checkPathStatAndPermissions(params: {
source: string;
rootDir: string;
origin: PluginOrigin;
uid: number | null;
}): CandidateBlockIssue | null {
if (process.platform === "win32") {
return null;
}
const pathsToCheck = [params.rootDir, params.source];
const seen = new Set<string>();
for (const targetPath of pathsToCheck) {
const normalized = path.resolve(targetPath);
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
const stat = safeStatSync(targetPath);
if (!stat) {
return {
reason: "path_stat_failed",
sourcePath: params.source,
rootPath: params.rootDir,
targetPath,
};
}
const modeBits = stat.mode & 0o777;
if ((modeBits & 0o002) !== 0) {
return {
reason: "path_world_writable",
sourcePath: params.source,
rootPath: params.rootDir,
targetPath,
modeBits,
};
}
if (
params.origin !== "bundled" &&
params.uid !== null &&
typeof stat.uid === "number" &&
stat.uid !== params.uid &&
stat.uid !== 0
) {
return {
reason: "path_suspicious_ownership",
sourcePath: params.source,
rootPath: params.rootDir,
targetPath,
foundUid: stat.uid,
expectedUid: params.uid,
};
}
}
return null;
}
function findCandidateBlockIssue(params: {
source: string;
rootDir: string;
origin: PluginOrigin;
ownershipUid?: number | null;
}): CandidateBlockIssue | null {
const escaped = checkSourceEscapesRoot({
source: params.source,
rootDir: params.rootDir,
});
if (escaped) {
return escaped;
}
return checkPathStatAndPermissions({
source: params.source,
rootDir: params.rootDir,
origin: params.origin,
uid: currentUid(params.ownershipUid),
});
}
function formatCandidateBlockMessage(issue: CandidateBlockIssue): string {
if (issue.reason === "source_escapes_root") {
return `blocked plugin candidate: source escapes plugin root (${issue.sourcePath} -> ${issue.sourceRealPath}; root=${issue.rootRealPath})`;
}
if (issue.reason === "path_stat_failed") {
return `blocked plugin candidate: cannot stat path (${issue.targetPath})`;
}
if (issue.reason === "path_world_writable") {
return `blocked plugin candidate: world-writable path (${issue.targetPath}, mode=${formatPosixMode(issue.modeBits ?? 0)})`;
}
return `blocked plugin candidate: suspicious ownership (${issue.targetPath}, uid=${issue.foundUid}, expected uid=${issue.expectedUid} or root)`;
}
function isUnsafePluginCandidate(params: {
source: string;
rootDir: string;
origin: PluginOrigin;
diagnostics: PluginDiagnostic[];
ownershipUid?: number | null;
}): boolean {
const issue = findCandidateBlockIssue({
source: params.source,
rootDir: params.rootDir,
origin: params.origin,
ownershipUid: params.ownershipUid,
});
if (!issue) {
return false;
}
params.diagnostics.push({
level: "warn",
source: issue.targetPath,
message: formatCandidateBlockMessage(issue),
});
return true;
}
function isExtensionFile(filePath: string): boolean {
const ext = path.extname(filePath);
if (!EXTENSION_EXTS.has(ext)) {
return false;
}
return !filePath.endsWith(".d.ts");
}
function shouldIgnoreScannedDirectory(dirName: string): boolean {
const normalized = dirName.trim().toLowerCase();
if (!normalized) {
return true;
}
if (normalized.endsWith(".bak")) {
return true;
}
if (normalized.includes(".backup-")) {
return true;
}
if (normalized.includes(".disabled")) {
return true;
}
return false;
}
function readPackageManifest(dir: string, rejectHardlinks = true): PackageManifest | null {
const manifestPath = path.join(dir, "package.json");
const opened = openBoundaryFileSync({
absolutePath: manifestPath,
rootPath: dir,
boundaryLabel: "plugin package directory",
rejectHardlinks,
});
if (!opened.ok) {
return null;
}
try {
const raw = fs.readFileSync(opened.fd, "utf-8");
return JSON.parse(raw) as PackageManifest;
} catch {
return null;
} finally {
fs.closeSync(opened.fd);
}
}
function deriveIdHint(params: {
filePath: string;
packageName?: string;
hasMultipleExtensions: boolean;
}): string {
const base = path.basename(params.filePath, path.extname(params.filePath));
const rawPackageName = params.packageName?.trim();
if (!rawPackageName) {
return base;
}
// Prefer the unscoped name so config keys stay stable even when the npm
// package is scoped (example: @openclaw/voice-call -> voice-call).
const unscoped = rawPackageName.includes("/")
? (rawPackageName.split("/").pop() ?? rawPackageName)
: rawPackageName;
if (!params.hasMultipleExtensions) {
return unscoped;
}
return `${unscoped}/${base}`;
}
function addCandidate(params: {
candidates: PluginCandidate[];
diagnostics: PluginDiagnostic[];
seen: Set<string>;
idHint: string;
source: string;
rootDir: string;
origin: PluginOrigin;
ownershipUid?: number | null;
workspaceDir?: string;
manifest?: PackageManifest | null;
packageDir?: string;
}) {
const resolved = path.resolve(params.source);
if (params.seen.has(resolved)) {
return;
}
const resolvedRoot = safeRealpathSync(params.rootDir) ?? path.resolve(params.rootDir);
if (
isUnsafePluginCandidate({
source: resolved,
rootDir: resolvedRoot,
origin: params.origin,
diagnostics: params.diagnostics,
ownershipUid: params.ownershipUid,
})
) {
return;
}
params.seen.add(resolved);
const manifest = params.manifest ?? null;
params.candidates.push({
idHint: params.idHint,
source: resolved,
rootDir: resolvedRoot,
origin: params.origin,
workspaceDir: params.workspaceDir,
packageName: manifest?.name?.trim() || undefined,
packageVersion: manifest?.version?.trim() || undefined,
packageDescription: manifest?.description?.trim() || undefined,
packageDir: params.packageDir,
packageManifest: getPackageManifestMetadata(manifest ?? undefined),
});
}
function resolvePackageEntrySource(params: {
packageDir: string;
entryPath: string;
sourceLabel: string;
diagnostics: PluginDiagnostic[];
rejectHardlinks?: boolean;
}): string | null {
const source = path.resolve(params.packageDir, params.entryPath);
const opened = openBoundaryFileSync({
absolutePath: source,
rootPath: params.packageDir,
boundaryLabel: "plugin package directory",
rejectHardlinks: params.rejectHardlinks ?? true,
});
if (!opened.ok) {
params.diagnostics.push({
level: "error",
message: `extension entry escapes package directory: ${params.entryPath}`,
source: params.sourceLabel,
});
return null;
}
const safeSource = opened.path;
fs.closeSync(opened.fd);
return safeSource;
}
function discoverInDirectory(params: {
dir: string;
origin: PluginOrigin;
ownershipUid?: number | null;
workspaceDir?: string;
candidates: PluginCandidate[];
diagnostics: PluginDiagnostic[];
seen: Set<string>;
}) {
if (!fs.existsSync(params.dir)) {
return;
}
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(params.dir, { withFileTypes: true });
} catch (err) {
params.diagnostics.push({
level: "warn",
message: `failed to read extensions dir: ${params.dir} (${String(err)})`,
source: params.dir,
});
return;
}
for (const entry of entries) {
const fullPath = path.join(params.dir, entry.name);
if (entry.isFile()) {
if (!isExtensionFile(fullPath)) {
continue;
}
addCandidate({
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
idHint: path.basename(entry.name, path.extname(entry.name)),
source: fullPath,
rootDir: path.dirname(fullPath),
origin: params.origin,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
});
}
if (!entry.isDirectory()) {
continue;
}
if (shouldIgnoreScannedDirectory(entry.name)) {
continue;
}
const rejectHardlinks = params.origin !== "bundled";
const manifest = readPackageManifest(fullPath, rejectHardlinks);
const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined);
const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : [];
if (extensions.length > 0) {
for (const extPath of extensions) {
const resolved = resolvePackageEntrySource({
packageDir: fullPath,
entryPath: extPath,
sourceLabel: fullPath,
diagnostics: params.diagnostics,
rejectHardlinks,
});
if (!resolved) {
continue;
}
addCandidate({
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
idHint: deriveIdHint({
filePath: resolved,
packageName: manifest?.name,
hasMultipleExtensions: extensions.length > 1,
}),
source: resolved,
rootDir: fullPath,
origin: params.origin,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
manifest,
packageDir: fullPath,
});
}
continue;
}
const indexFile = [...DEFAULT_PLUGIN_ENTRY_CANDIDATES]
.map((candidate) => path.join(fullPath, candidate))
.find((candidate) => fs.existsSync(candidate));
if (indexFile && isExtensionFile(indexFile)) {
addCandidate({
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
idHint: entry.name,
source: indexFile,
rootDir: fullPath,
origin: params.origin,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
manifest,
packageDir: fullPath,
});
}
}
}
function discoverFromPath(params: {
rawPath: string;
origin: PluginOrigin;
ownershipUid?: number | null;
workspaceDir?: string;
candidates: PluginCandidate[];
diagnostics: PluginDiagnostic[];
seen: Set<string>;
}) {
const resolved = resolveUserPath(params.rawPath);
if (!fs.existsSync(resolved)) {
params.diagnostics.push({
level: "error",
message: `plugin path not found: ${resolved}`,
source: resolved,
});
return;
}
const stat = fs.statSync(resolved);
if (stat.isFile()) {
if (!isExtensionFile(resolved)) {
params.diagnostics.push({
level: "error",
message: `plugin path is not a supported file: ${resolved}`,
source: resolved,
});
return;
}
addCandidate({
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
idHint: path.basename(resolved, path.extname(resolved)),
source: resolved,
rootDir: path.dirname(resolved),
origin: params.origin,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
});
return;
}
if (stat.isDirectory()) {
const rejectHardlinks = params.origin !== "bundled";
const manifest = readPackageManifest(resolved, rejectHardlinks);
const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined);
const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : [];
if (extensions.length > 0) {
for (const extPath of extensions) {
const source = resolvePackageEntrySource({
packageDir: resolved,
entryPath: extPath,
sourceLabel: resolved,
diagnostics: params.diagnostics,
rejectHardlinks,
});
if (!source) {
continue;
}
addCandidate({
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
idHint: deriveIdHint({
filePath: source,
packageName: manifest?.name,
hasMultipleExtensions: extensions.length > 1,
}),
source,
rootDir: resolved,
origin: params.origin,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
manifest,
packageDir: resolved,
});
}
return;
}
const indexFile = [...DEFAULT_PLUGIN_ENTRY_CANDIDATES]
.map((candidate) => path.join(resolved, candidate))
.find((candidate) => fs.existsSync(candidate));
if (indexFile && isExtensionFile(indexFile)) {
addCandidate({
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
idHint: path.basename(resolved),
source: indexFile,
rootDir: resolved,
origin: params.origin,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
manifest,
packageDir: resolved,
});
return;
}
discoverInDirectory({
dir: resolved,
origin: params.origin,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
});
return;
}
}
export function discoverOpenClawPlugins(params: {
workspaceDir?: string;
extraPaths?: string[];
ownershipUid?: number | null;
cache?: boolean;
env?: NodeJS.ProcessEnv;
}): PluginDiscoveryResult {
const env = params.env ?? process.env;
const cacheEnabled = params.cache !== false && shouldUseDiscoveryCache(env);
const cacheKey = buildDiscoveryCacheKey({
workspaceDir: params.workspaceDir,
extraPaths: params.extraPaths,
ownershipUid: params.ownershipUid,
});
if (cacheEnabled) {
const cached = discoveryCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.result;
}
}
const candidates: PluginCandidate[] = [];
const diagnostics: PluginDiagnostic[] = [];
const seen = new Set<string>();
const workspaceDir = params.workspaceDir?.trim();
const extra = params.extraPaths ?? [];
for (const extraPath of extra) {
if (typeof extraPath !== "string") {
continue;
}
const trimmed = extraPath.trim();
if (!trimmed) {
continue;
}
discoverFromPath({
rawPath: trimmed,
origin: "config",
ownershipUid: params.ownershipUid,
workspaceDir: workspaceDir?.trim() || undefined,
candidates,
diagnostics,
seen,
});
}
if (workspaceDir) {
const workspaceRoot = resolveUserPath(workspaceDir);
const workspaceExtDirs = [path.join(workspaceRoot, ".openclaw", "extensions")];
for (const dir of workspaceExtDirs) {
discoverInDirectory({
dir,
origin: "workspace",
ownershipUid: params.ownershipUid,
workspaceDir: workspaceRoot,
candidates,
diagnostics,
seen,
});
}
}
const bundledDir = resolveBundledPluginsDir();
if (bundledDir) {
discoverInDirectory({
dir: bundledDir,
origin: "bundled",
ownershipUid: params.ownershipUid,
candidates,
diagnostics,
seen,
});
}
// Keep auto-discovered global extensions behind bundled plugins.
// Users can still intentionally override via plugins.load.paths (origin=config).
const globalDir = path.join(resolveConfigDir(), "extensions");
discoverInDirectory({
dir: globalDir,
origin: "global",
ownershipUid: params.ownershipUid,
candidates,
diagnostics,
seen,
});
const result = { candidates, diagnostics };
if (cacheEnabled) {
const ttl = resolveDiscoveryCacheMs(env);
if (ttl > 0) {
discoveryCache.set(cacheKey, { expiresAt: Date.now() + ttl, result });
}
}
return result;
}