Files
openclaw/src/plugins/discovery.ts
2026-05-04 03:17:57 -07:00

1191 lines
35 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../shared/string-coerce.js";
import { resolveUserPath } from "../utils.js";
import { detectBundleManifestFormat, loadBundleManifest } from "./bundle-manifest.js";
import { resolveSourceCheckoutDependencyDiagnostic } from "./bundled-dir.js";
import {
buildLegacyBundledRootPath,
resolvePackagedBundledLoadPathAlias,
} from "./bundled-load-path-aliases.js";
import { listBundledSourceOverlayDirs } from "./bundled-source-overlays.js";
import type { PluginBundleFormat, PluginDiagnostic, PluginFormat } from "./manifest-types.js";
import {
DEFAULT_PLUGIN_ENTRY_CANDIDATES,
getPackageManifestMetadata,
loadPluginManifest,
type PluginManifest,
resolvePackageExtensionEntries,
type OpenClawPackageManifest,
type PackageManifest,
} from "./manifest.js";
import {
resolvePackageRuntimeExtensionSources,
resolvePackageSetupSource,
} from "./package-entry-resolution.js";
import { formatPosixMode, isPathInside, safeRealpathSync, safeStatSync } from "./path-safety.js";
import { tracePluginLifecyclePhase } from "./plugin-lifecycle-trace.js";
import type { PluginOrigin } from "./plugin-origin.types.js";
import { resolvePluginSourceRoots } from "./roots.js";
import {
normalizePluginDependencySpecs,
type PluginDependencySpecMap,
} from "./status-dependencies.js";
const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]);
const SCANNED_DIRECTORY_IGNORE_NAMES = new Set([
".git",
".hg",
".svn",
".turbo",
".yarn",
".yarn-cache",
"build",
"coverage",
"dist",
"node_modules",
]);
export type PluginCandidate = {
idHint: string;
source: string;
setupSource?: string;
rootDir: string;
origin: PluginOrigin;
format?: PluginFormat;
bundleFormat?: PluginBundleFormat;
workspaceDir?: string;
packageName?: string;
packageVersion?: string;
packageDescription?: string;
packageDir?: string;
packageManifest?: OpenClawPackageManifest;
packageDependencies?: PluginDependencySpecMap;
packageOptionalDependencies?: PluginDependencySpecMap;
bundledManifest?: PluginManifest;
bundledManifestPath?: string;
};
export type PluginDiscoveryResult = {
candidates: PluginCandidate[];
diagnostics: PluginDiagnostic[];
};
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;
realpathCache: Map<string, string>;
}): CandidateBlockIssue | null {
const sourceRealPath = safeRealpathSync(params.source, params.realpathCache);
const rootRealPath = safeRealpathSync(params.rootDir, params.realpathCache);
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);
let stat = safeStatSync(targetPath);
if (!stat) {
return {
reason: "path_stat_failed",
sourcePath: params.source,
rootPath: params.rootDir,
targetPath,
};
}
let modeBits = stat.mode & 0o777;
if ((modeBits & 0o002) !== 0 && params.origin === "bundled") {
// npm/global installs can create package-managed extension dirs without
// directory entries in the tarball, which may widen them to 0777.
// Tighten bundled dirs in place before applying the normal safety gate.
try {
fs.chmodSync(targetPath, modeBits & ~0o022);
const repairedStat = safeStatSync(targetPath);
if (!repairedStat) {
return {
reason: "path_stat_failed",
sourcePath: params.source,
rootPath: params.rootDir,
targetPath,
};
}
stat = repairedStat;
modeBits = repairedStat.mode & 0o777;
} catch {
// Fall through to the normal block path below when repair is not possible.
}
}
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;
realpathCache: Map<string, string>;
}): CandidateBlockIssue | null {
const escaped = checkSourceEscapesRoot({
source: params.source,
rootDir: params.rootDir,
realpathCache: params.realpathCache,
});
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;
pluginId?: string;
diagnostics: PluginDiagnostic[];
ownershipUid?: number | null;
realpathCache: Map<string, string>;
}): boolean {
const issue = findCandidateBlockIssue({
source: params.source,
rootDir: params.rootDir,
origin: params.origin,
ownershipUid: params.ownershipUid,
realpathCache: params.realpathCache,
});
if (!issue) {
return false;
}
params.diagnostics.push({
level: "warn",
...(params.pluginId ? { pluginId: params.pluginId } : {}),
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;
}
if (filePath.endsWith(".d.ts")) {
return false;
}
const baseName = normalizeLowercaseStringOrEmpty(path.basename(filePath));
return (
!baseName.includes(".test.") &&
!baseName.includes(".live.test.") &&
!baseName.includes(".e2e.test.")
);
}
function shouldIgnoreScannedDirectory(dirName: string): boolean {
const normalized = normalizeLowercaseStringOrEmpty(dirName);
if (!normalized) {
return true;
}
if (SCANNED_DIRECTORY_IGNORE_NAMES.has(normalized)) {
return true;
}
if (normalized.endsWith(".bak")) {
return true;
}
if (normalized.includes(".backup-")) {
return true;
}
if (normalized.includes(".disabled")) {
return true;
}
return false;
}
function resolveScannedEntryType(entry: fs.Dirent, fullPath: string): "file" | "directory" | null {
if (entry.isFile()) {
return "file";
}
if (entry.isDirectory()) {
return "directory";
}
if (!entry.isSymbolicLink()) {
return null;
}
const stat = safeStatSync(fullPath);
if (!stat) {
return null;
}
if (stat.isFile()) {
return "file";
}
if (stat.isDirectory()) {
return "directory";
}
return null;
}
function resolvesToSameDirectory(
left: string | undefined,
right: string | undefined,
realpathCache: Map<string, string>,
): boolean {
if (!left || !right) {
return false;
}
const leftRealPath = safeRealpathSync(left, realpathCache);
const rightRealPath = safeRealpathSync(right, realpathCache);
if (leftRealPath && rightRealPath) {
return leftRealPath === rightRealPath;
}
return path.resolve(left) === path.resolve(right);
}
function createDiscoveryResult(): PluginDiscoveryResult {
return {
candidates: [],
diagnostics: [],
};
}
function mergeDiscoveryResult(
target: PluginDiscoveryResult,
source: PluginDiscoveryResult,
seenSources: Set<string>,
seenDiagnostics: Set<string>,
): void {
for (const candidate of source.candidates) {
const key = candidate.source;
if (seenSources.has(key)) {
continue;
}
seenSources.add(key);
target.candidates.push(candidate);
}
for (const diagnostic of source.diagnostics) {
const key = [
diagnostic.level,
diagnostic.pluginId ?? "",
diagnostic.source ?? "",
diagnostic.message,
].join("\0");
if (seenDiagnostics.has(key)) {
continue;
}
seenDiagnostics.add(key);
target.diagnostics.push(diagnostic);
}
}
function collectInstalledPluginRecordPaths(
installRecords: Record<string, PluginInstallRecord> | undefined,
env: NodeJS.ProcessEnv,
): string[] {
const paths: string[] = [];
const seen = new Set<string>();
for (const record of Object.values(installRecords ?? {})) {
const rawPath =
typeof record.installPath === "string" && record.installPath.trim()
? record.installPath
: typeof record.sourcePath === "string" && record.sourcePath.trim()
? record.sourcePath
: undefined;
if (!rawPath) {
continue;
}
const resolved = resolveUserPath(rawPath, env);
if (seen.has(resolved) || !fs.existsSync(resolved)) {
continue;
}
seen.add(resolved);
paths.push(resolved);
}
return paths;
}
function readPackageManifest(
dir: string,
rejectHardlinks = true,
rootRealPath?: string,
): PackageManifest | null {
const manifestPath = path.join(dir, "package.json");
const opened = openBoundaryFileSync({
absolutePath: manifestPath,
rootPath: dir,
...(rootRealPath !== undefined ? { rootRealPath } : {}),
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 readTrustedPackageManifest(dir: string): PackageManifest | null {
try {
return JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")) as PackageManifest;
} catch {
return null;
}
}
function readCandidatePackageManifest(params: {
dir: string;
origin: PluginOrigin;
rejectHardlinks: boolean;
rootRealPath?: string;
}): PackageManifest | null {
if (params.origin === "bundled") {
return readTrustedPackageManifest(params.dir);
}
return readPackageManifest(params.dir, params.rejectHardlinks, params.rootRealPath);
}
function deriveIdHint(params: {
filePath: string;
manifestId?: string;
packageName?: string;
hasMultipleExtensions: boolean;
}): string {
const base = path.basename(params.filePath, path.extname(params.filePath));
const rawManifestId = params.manifestId?.trim();
if (rawManifestId) {
return params.hasMultipleExtensions ? `${rawManifestId}/${base}` : rawManifestId;
}
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;
const normalizedPackageId =
unscoped.endsWith("-provider") && unscoped.length > "-provider".length
? unscoped.slice(0, -"-provider".length)
: unscoped;
if (!params.hasMultipleExtensions) {
return normalizedPackageId;
}
return `${normalizedPackageId}/${base}`;
}
function derivePackagePluginIdHint(params: {
manifestId?: string;
packageName?: string;
}): string | undefined {
const rawManifestId = params.manifestId?.trim();
if (rawManifestId) {
return rawManifestId;
}
const rawPackageName = params.packageName?.trim();
if (!rawPackageName) {
return undefined;
}
const unscoped = rawPackageName.includes("/")
? (rawPackageName.split("/").pop() ?? rawPackageName)
: rawPackageName;
return unscoped.endsWith("-provider") && unscoped.length > "-provider".length
? unscoped.slice(0, -"-provider".length)
: unscoped;
}
function resolveIdHintManifestId(
rootDir: string,
rejectHardlinks: boolean,
rootRealPath?: string,
): string | undefined {
const manifest = loadPluginManifest(rootDir, rejectHardlinks, rootRealPath);
return manifest.ok ? manifest.manifest.id : undefined;
}
function addCandidate(params: {
candidates: PluginCandidate[];
diagnostics: PluginDiagnostic[];
seen: Set<string>;
idHint: string;
source: string;
setupSource?: string;
rootDir: string;
origin: PluginOrigin;
format?: PluginFormat;
bundleFormat?: PluginBundleFormat;
ownershipUid?: number | null;
workspaceDir?: string;
manifest?: PackageManifest | null;
packageDir?: string;
bundledManifest?: PluginManifest;
bundledManifestPath?: string;
realpathCache: Map<string, string>;
}) {
const resolved = path.resolve(params.source);
if (params.seen.has(resolved)) {
return;
}
const resolvedRoot =
safeRealpathSync(params.rootDir, params.realpathCache) ?? path.resolve(params.rootDir);
if (
isUnsafePluginCandidate({
source: resolved,
rootDir: resolvedRoot,
origin: params.origin,
pluginId: params.idHint,
diagnostics: params.diagnostics,
ownershipUid: params.ownershipUid,
realpathCache: params.realpathCache,
})
) {
params.seen.add(resolved);
return;
}
params.seen.add(resolved);
const manifest = params.manifest ?? null;
const packageDependencies = normalizePluginDependencySpecs({
dependencies: manifest?.dependencies,
optionalDependencies: manifest?.optionalDependencies,
});
params.candidates.push({
idHint: params.idHint,
source: resolved,
setupSource: params.setupSource,
rootDir: resolvedRoot,
origin: params.origin,
format: params.format ?? "openclaw",
bundleFormat: params.bundleFormat,
workspaceDir: params.workspaceDir,
packageName: normalizeOptionalString(manifest?.name),
packageVersion: normalizeOptionalString(manifest?.version),
packageDescription: normalizeOptionalString(manifest?.description),
packageDir: params.packageDir,
packageManifest: getPackageManifestMetadata(manifest ?? undefined),
packageDependencies: packageDependencies.dependencies,
packageOptionalDependencies: packageDependencies.optionalDependencies,
bundledManifest: params.bundledManifest,
bundledManifestPath: params.bundledManifestPath,
});
}
function discoverBundleInRoot(params: {
rootDir: string;
origin: PluginOrigin;
ownershipUid?: number | null;
workspaceDir?: string;
manifest?: PackageManifest | null;
candidates: PluginCandidate[];
diagnostics: PluginDiagnostic[];
seen: Set<string>;
realpathCache: Map<string, string>;
}): "added" | "invalid" | "none" {
const bundleFormat = detectBundleManifestFormat(params.rootDir);
if (!bundleFormat) {
return "none";
}
const rootRealPath = safeRealpathSync(params.rootDir, params.realpathCache) ?? undefined;
const bundleManifest = loadBundleManifest({
rootDir: params.rootDir,
...(rootRealPath !== undefined ? { rootRealPath } : {}),
bundleFormat,
rejectHardlinks: params.origin !== "bundled",
});
if (!bundleManifest.ok) {
params.diagnostics.push({
level: "error",
message: bundleManifest.error,
source: bundleManifest.manifestPath,
});
return "invalid";
}
addCandidate({
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
idHint: bundleManifest.manifest.id,
source: params.rootDir,
rootDir: params.rootDir,
origin: params.origin,
format: "bundle",
bundleFormat,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
manifest: params.manifest,
packageDir: params.rootDir,
realpathCache: params.realpathCache,
});
return "added";
}
function discoverInDirectory(params: {
dir: string;
origin: PluginOrigin;
ownershipUid?: number | null;
workspaceDir?: string;
candidates: PluginCandidate[];
diagnostics: PluginDiagnostic[];
seen: Set<string>;
realpathCache: Map<string, string>;
recurseDirectories?: boolean;
skipDirectories?: Set<string>;
visitedDirectories?: Set<string>;
}) {
if (!fs.existsSync(params.dir)) {
return;
}
const resolvedDir =
safeRealpathSync(params.dir, params.realpathCache) ?? path.resolve(params.dir);
if (params.recurseDirectories) {
if (params.visitedDirectories?.has(resolvedDir)) {
return;
}
params.visitedDirectories?.add(resolvedDir);
}
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);
const entryType = resolveScannedEntryType(entry, fullPath);
if (entryType === "file") {
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,
realpathCache: params.realpathCache,
});
continue;
}
if (entryType !== "directory") {
continue;
}
if (params.skipDirectories?.has(entry.name)) {
continue;
}
if (shouldIgnoreScannedDirectory(entry.name)) {
continue;
}
const rejectHardlinks = params.origin !== "bundled";
const fullPathRealPath = safeRealpathSync(fullPath, params.realpathCache) ?? undefined;
const manifest = readCandidatePackageManifest({
dir: fullPath,
origin: params.origin,
rejectHardlinks,
...(fullPathRealPath !== undefined ? { rootRealPath: fullPathRealPath } : {}),
});
const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined);
const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : [];
const manifestId = resolveIdHintManifestId(fullPath, rejectHardlinks, fullPathRealPath);
const setupSource = resolvePackageSetupSource({
packageDir: fullPath,
...(fullPathRealPath !== undefined ? { packageRootRealPath: fullPathRealPath } : {}),
manifest,
origin: params.origin,
sourceLabel: fullPath,
diagnostics: params.diagnostics,
rejectHardlinks,
});
if (extensions.length > 0) {
const resolvedRuntimeSources = resolvePackageRuntimeExtensionSources({
packageDir: fullPath,
...(fullPathRealPath !== undefined ? { packageRootRealPath: fullPathRealPath } : {}),
manifest,
extensions,
origin: params.origin,
pluginIdHint: derivePackagePluginIdHint({ manifestId, packageName: manifest?.name }),
sourceLabel: fullPath,
diagnostics: params.diagnostics,
rejectHardlinks,
});
for (const resolved of resolvedRuntimeSources) {
addCandidate({
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
idHint: deriveIdHint({
filePath: resolved,
manifestId,
packageName: manifest?.name,
hasMultipleExtensions: extensions.length > 1,
}),
source: resolved,
...(setupSource ? { setupSource } : {}),
rootDir: fullPath,
origin: params.origin,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
manifest,
packageDir: fullPath,
realpathCache: params.realpathCache,
});
}
continue;
}
const bundleDiscovery = discoverBundleInRoot({
rootDir: fullPath,
origin: params.origin,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
manifest,
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
realpathCache: params.realpathCache,
});
if (bundleDiscovery === "added") {
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: manifestId ?? entry.name,
source: indexFile,
...(setupSource ? { setupSource } : {}),
rootDir: fullPath,
origin: params.origin,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
manifest,
packageDir: fullPath,
realpathCache: params.realpathCache,
});
continue;
}
if (params.recurseDirectories) {
discoverInDirectory({
...params,
dir: fullPath,
});
}
}
}
function hasDiscoverablePluginTree(pluginsDir: string): boolean {
try {
return fs.readdirSync(pluginsDir, { withFileTypes: true }).some((entry) => {
if (!entry.isDirectory()) {
return false;
}
const pluginDir = path.join(pluginsDir, entry.name);
return (
fs.existsSync(path.join(pluginDir, "package.json")) ||
fs.existsSync(path.join(pluginDir, "openclaw.plugin.json"))
);
});
} catch {
return false;
}
}
function isSourceCheckoutExtensionsDir(extensionsDir: string): boolean {
const packageRoot = path.dirname(extensionsDir);
return (
fs.existsSync(path.join(packageRoot, ".git")) &&
fs.existsSync(path.join(packageRoot, "pnpm-workspace.yaml")) &&
fs.existsSync(path.join(packageRoot, "src")) &&
fs.existsSync(extensionsDir) &&
hasDiscoverablePluginTree(extensionsDir)
);
}
function resolveBundledSourceCheckoutExtensionsDir(bundledRoot?: string): string | undefined {
if (!bundledRoot) {
return undefined;
}
const legacyRoot = buildLegacyBundledRootPath(bundledRoot);
if (!legacyRoot || !isSourceCheckoutExtensionsDir(legacyRoot)) {
return undefined;
}
return legacyRoot;
}
function readChildDirectoryNames(dir: string | undefined): Set<string> {
if (!dir || !fs.existsSync(dir)) {
return new Set();
}
try {
return new Set(
fs
.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name),
);
} catch {
return new Set();
}
}
function discoverFromPath(params: {
rawPath: string;
origin: PluginOrigin;
ownershipUid?: number | null;
workspaceDir?: string;
env: NodeJS.ProcessEnv;
candidates: PluginCandidate[];
diagnostics: PluginDiagnostic[];
seen: Set<string>;
realpathCache: Map<string, string>;
}) {
const resolved = resolveUserPath(params.rawPath, params.env);
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,
realpathCache: params.realpathCache,
});
return;
}
if (stat.isDirectory()) {
const rejectHardlinks = params.origin !== "bundled";
const resolvedRealPath = safeRealpathSync(resolved, params.realpathCache) ?? undefined;
const manifest = readCandidatePackageManifest({
dir: resolved,
origin: params.origin,
rejectHardlinks,
...(resolvedRealPath !== undefined ? { rootRealPath: resolvedRealPath } : {}),
});
const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined);
const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : [];
const manifestId = resolveIdHintManifestId(resolved, rejectHardlinks, resolvedRealPath);
const setupSource = resolvePackageSetupSource({
packageDir: resolved,
...(resolvedRealPath !== undefined ? { packageRootRealPath: resolvedRealPath } : {}),
manifest,
origin: params.origin,
sourceLabel: resolved,
diagnostics: params.diagnostics,
rejectHardlinks,
});
if (extensions.length > 0) {
const resolvedRuntimeSources = resolvePackageRuntimeExtensionSources({
packageDir: resolved,
...(resolvedRealPath !== undefined ? { packageRootRealPath: resolvedRealPath } : {}),
manifest,
extensions,
origin: params.origin,
pluginIdHint: derivePackagePluginIdHint({ manifestId, packageName: manifest?.name }),
sourceLabel: resolved,
diagnostics: params.diagnostics,
rejectHardlinks,
});
for (const source of resolvedRuntimeSources) {
addCandidate({
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
idHint: deriveIdHint({
filePath: source,
manifestId,
packageName: manifest?.name,
hasMultipleExtensions: extensions.length > 1,
}),
source,
...(setupSource ? { setupSource } : {}),
rootDir: resolved,
origin: params.origin,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
manifest,
packageDir: resolved,
realpathCache: params.realpathCache,
});
}
return;
}
const bundleDiscovery = discoverBundleInRoot({
rootDir: resolved,
origin: params.origin,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
manifest,
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
realpathCache: params.realpathCache,
});
if (bundleDiscovery === "added") {
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: manifestId ?? path.basename(resolved),
source: indexFile,
...(setupSource ? { setupSource } : {}),
rootDir: resolved,
origin: params.origin,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
manifest,
packageDir: resolved,
realpathCache: params.realpathCache,
});
return;
}
discoverInDirectory({
dir: resolved,
origin: params.origin,
ownershipUid: params.ownershipUid,
workspaceDir: params.workspaceDir,
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
realpathCache: params.realpathCache,
});
return;
}
}
export function discoverOpenClawPlugins(params: {
workspaceDir?: string;
extraPaths?: string[];
installRecords?: Record<string, PluginInstallRecord>;
ownershipUid?: number | null;
env?: NodeJS.ProcessEnv;
}): PluginDiscoveryResult {
const env = params.env ?? process.env;
const workspaceDir = normalizeOptionalString(params.workspaceDir);
const workspaceRoot = workspaceDir ? resolveUserPath(workspaceDir, env) : undefined;
const roots = resolvePluginSourceRoots({ workspaceDir: workspaceRoot, env });
const scopedResult = tracePluginLifecyclePhase(
"discovery scan",
() => {
const result = createDiscoveryResult();
const seen = new Set<string>();
const realpathCache = new Map<string, string>();
const extra = params.extraPaths ?? [];
for (const extraPath of extra) {
if (typeof extraPath !== "string") {
continue;
}
const trimmed = extraPath.trim();
if (!trimmed) {
continue;
}
const bundledAlias = resolvePackagedBundledLoadPathAlias({
bundledRoot: roots.stock,
loadPath: resolveUserPath(trimmed, env),
});
if (bundledAlias) {
result.diagnostics.push({
level: "warn",
source: trimmed,
message: `ignored plugins.load.paths entry that points at OpenClaw's ${bundledAlias.kind} bundled plugin directory; remove this redundant path or run openclaw doctor --fix`,
});
continue;
}
discoverFromPath({
rawPath: trimmed,
origin: "config",
ownershipUid: params.ownershipUid,
workspaceDir,
env,
candidates: result.candidates,
diagnostics: result.diagnostics,
seen,
realpathCache,
});
}
const workspaceMatchesBundledRoot = resolvesToSameDirectory(
workspaceRoot,
roots.stock,
realpathCache,
);
if (roots.workspace && workspaceRoot && !workspaceMatchesBundledRoot) {
// Keep workspace auto-discovery constrained to the OpenClaw extensions root.
// Recursively scanning the full workspace treats arbitrary project folders as
// plugin candidates and causes noisy "plugin manifest not found" validation failures.
discoverInDirectory({
dir: roots.workspace,
origin: "workspace",
ownershipUid: params.ownershipUid,
workspaceDir: workspaceRoot,
candidates: result.candidates,
diagnostics: result.diagnostics,
seen,
realpathCache,
});
}
return result;
},
{ scope: "scoped", extraPathCount: params.extraPaths?.length ?? 0 },
);
const sharedResult = tracePluginLifecyclePhase(
"discovery scan",
() => {
const result = createDiscoveryResult();
const seen = new Set<string>();
const realpathCache = new Map<string, string>();
for (const sourceOverlayDir of listBundledSourceOverlayDirs({
bundledRoot: roots.stock,
env,
})) {
discoverFromPath({
rawPath: sourceOverlayDir,
origin: "bundled",
ownershipUid: params.ownershipUid,
workspaceDir,
env,
candidates: result.candidates,
diagnostics: result.diagnostics,
seen,
realpathCache,
});
result.diagnostics.push({
level: "warn",
source: sourceOverlayDir,
message:
"using bind-mounted bundled plugin source overlay; this source overrides the packaged dist bundle for the same plugin id",
});
}
const sourceCheckoutDependencyDiagnostic = resolveSourceCheckoutDependencyDiagnostic(env);
if (sourceCheckoutDependencyDiagnostic) {
result.diagnostics.push({
level: "warn",
source: sourceCheckoutDependencyDiagnostic.source,
message: sourceCheckoutDependencyDiagnostic.message,
});
}
if (roots.stock) {
discoverInDirectory({
dir: roots.stock,
origin: "bundled",
ownershipUid: params.ownershipUid,
candidates: result.candidates,
diagnostics: result.diagnostics,
seen,
realpathCache,
});
}
const sourceCheckoutExtensionsDir = resolveBundledSourceCheckoutExtensionsDir(roots.stock);
const sourceCheckoutMatchesBundledRoot = resolvesToSameDirectory(
sourceCheckoutExtensionsDir,
roots.stock,
realpathCache,
);
if (sourceCheckoutExtensionsDir && !sourceCheckoutMatchesBundledRoot) {
discoverInDirectory({
dir: sourceCheckoutExtensionsDir,
origin: "bundled",
ownershipUid: params.ownershipUid,
candidates: result.candidates,
diagnostics: result.diagnostics,
seen,
realpathCache,
skipDirectories: readChildDirectoryNames(roots.stock),
});
}
for (const installedPath of collectInstalledPluginRecordPaths(params.installRecords, env)) {
discoverFromPath({
rawPath: installedPath,
origin: "global",
ownershipUid: params.ownershipUid,
workspaceDir,
env,
candidates: result.candidates,
diagnostics: result.diagnostics,
seen,
realpathCache,
});
}
// Keep auto-discovered global extensions behind bundled plugins.
// Users can still intentionally override via plugins.load.paths (origin=config).
discoverInDirectory({
dir: roots.global,
origin: "global",
ownershipUid: params.ownershipUid,
candidates: result.candidates,
diagnostics: result.diagnostics,
seen,
realpathCache,
});
return result;
},
{ scope: "shared" },
);
const result = createDiscoveryResult();
const seenSources = new Set<string>();
const seenDiagnostics = new Set<string>();
mergeDiscoveryResult(result, scopedResult, seenSources, seenDiagnostics);
mergeDiscoveryResult(result, sharedResult, seenSources, seenDiagnostics);
return result;
}