mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-19 00:52:10 -06:00
bf6548bc8e
* fix(plugins): filter .d.mts and .d.cts declaration files in extension discovery
isExtensionFile only checked for .d.ts but not .d.mts or .d.cts.
path.extname('.d.mts') returns '.mts' so these declaration files
passed through all checks and were treated as valid plugin entries.
Replaced the single .d.ts check with a regex covering all TS
declaration file variants.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update d.mts test to use distinct filenames so it fails on old code
---------
Co-authored-by: Claude <noreply@anthropic.com>
1610 lines
50 KiB
TypeScript
1610 lines
50 KiB
TypeScript
/** Discovers plugin candidates from bundled, workspace, global, package, and bundle roots. */
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import {
|
|
normalizeLowercaseStringOrEmpty,
|
|
normalizeOptionalString,
|
|
} from "@openclaw/normalization-core/string-coerce";
|
|
import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
|
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
|
import { satisfiesPluginApiRange } from "../infra/clawhub.js";
|
|
import { readRootJsonObjectSync } from "../infra/json-files.js";
|
|
import { tryReadJsonSync } from "../infra/json-files.js";
|
|
import { resolveUserPath } from "../utils.js";
|
|
import { resolveCompatibilityHostVersion } from "../version.js";
|
|
import { detectBundleManifestFormat, loadBundleManifest } from "./bundle-manifest.js";
|
|
import { resolveSourceCheckoutDependencyDiagnostic } from "./bundled-dir.js";
|
|
import { buildLegacyBundledRootPath } from "./bundled-load-path-aliases.js";
|
|
import { listBundledSourceOverlayDirs } from "./bundled-source-overlays.js";
|
|
import { shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js";
|
|
import { readLegacyNpmPluginDeclaration } from "./legacy-npm-declaration.js";
|
|
import type { PluginBundleFormat, PluginDiagnostic, PluginFormat } from "./manifest-types.js";
|
|
import {
|
|
DEFAULT_PLUGIN_ENTRY_CANDIDATES,
|
|
getPackageManifestMetadata,
|
|
loadPluginManifest,
|
|
type PluginManifest,
|
|
resolvePackageExtensionEntries,
|
|
type OpenClawPackageManifest,
|
|
type PackageExtensionResolution,
|
|
type PackageManifest,
|
|
} from "./manifest.js";
|
|
import { resolvePackagePluginApiRange } from "./package-compat.js";
|
|
import {
|
|
resolvePackageRuntimeExtensionSources,
|
|
resolvePackageSetupSource,
|
|
} from "./package-entry-resolution.js";
|
|
import { formatPosixMode, isPathInside, safeRealpathSync, safeStatSync } from "./path-safety.js";
|
|
import { createPluginCacheKey, PluginLruCache } from "./plugin-cache-primitives.js";
|
|
import { tracePluginLifecyclePhase } from "./plugin-lifecycle-trace.js";
|
|
import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js";
|
|
import type { PluginOrigin } from "./plugin-origin.types.js";
|
|
import { withPluginScanExistenceCache } from "./plugin-scan-existence-cache.js";
|
|
import { resolvePluginSourceRoots } from "./roots.js";
|
|
import {
|
|
normalizePluginDependencySpecs,
|
|
type PluginDependencySpecMap,
|
|
} from "./status-dependencies-core.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",
|
|
]);
|
|
const PACKAGE_MANIFEST_CACHE_MAX_ENTRIES = 512;
|
|
const IMMUTABLE_NIX_STORE_ROOT = "/nix/store";
|
|
const packageManifestProcessCache = new PluginLruCache<PackageManifest | null>(
|
|
PACKAGE_MANIFEST_CACHE_MAX_ENTRIES,
|
|
);
|
|
|
|
registerPluginMetadataProcessMemoLifecycleClear(() => {
|
|
packageManifestProcessCache.clear();
|
|
});
|
|
|
|
/** One potential plugin root discovered before manifest validation and registry normalization. */
|
|
export type PluginCandidate = {
|
|
idHint: string;
|
|
diagnosticIdHint?: 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;
|
|
bundledManifestId?: string;
|
|
bundledManifest?: PluginManifest;
|
|
bundledManifestPath?: string;
|
|
requiredPluginIds?: string[];
|
|
requiredPluginSource?: string;
|
|
rawPackageManifest?: PackageManifest;
|
|
};
|
|
|
|
/** Discovery candidates plus warnings/errors emitted while scanning roots. */
|
|
export type PluginDiscoveryResult = {
|
|
candidates: PluginCandidate[];
|
|
diagnostics: PluginDiagnostic[];
|
|
};
|
|
|
|
type PluginDiscoveryRootScope = "all" | "bundled";
|
|
|
|
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();
|
|
}
|
|
|
|
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 (/\.d\.[cm]?ts$/.test(filePath)) {
|
|
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 addMissingRequiredPluginDiagnostics(
|
|
result: PluginDiscoveryResult,
|
|
params: { env: NodeJS.ProcessEnv; realpathCache: Map<string, string> },
|
|
): void {
|
|
const candidateIds = new Set(result.candidates.map((candidate) => candidate.idHint));
|
|
const seen = new Set<string>();
|
|
let configuredFileManifestIds: Set<string> | undefined;
|
|
for (const candidate of result.candidates) {
|
|
for (const requiredPluginId of candidate.requiredPluginIds ?? []) {
|
|
if (candidateIds.has(requiredPluginId) || requiredPluginId === candidate.idHint) {
|
|
continue;
|
|
}
|
|
if (!configuredFileManifestIds) {
|
|
configuredFileManifestIds = new Set();
|
|
// Explicit files keep filename hints; only a validated root manifest
|
|
// can establish their canonical identity for a missing dependency.
|
|
for (const configuredCandidate of result.candidates) {
|
|
if (configuredCandidate.origin !== "config" || configuredCandidate.packageDir) {
|
|
continue;
|
|
}
|
|
const rejectHardlinks = shouldRejectHardlinkedPluginFiles({
|
|
origin: configuredCandidate.origin,
|
|
rootDir: configuredCandidate.rootDir,
|
|
env: params.env,
|
|
realpathCache: params.realpathCache,
|
|
});
|
|
const manifest = resolveCandidateManifest(configuredCandidate.rootDir, rejectHardlinks);
|
|
if (manifest) {
|
|
configuredFileManifestIds.add(manifest.manifest.id);
|
|
}
|
|
}
|
|
}
|
|
if (configuredFileManifestIds.has(requiredPluginId)) {
|
|
continue;
|
|
}
|
|
const key = `${candidate.idHint}\0${requiredPluginId}`;
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
result.diagnostics.push({
|
|
level: "warn",
|
|
pluginId: candidate.idHint,
|
|
source: candidate.requiredPluginSource ?? candidate.source,
|
|
message: `plugin "${candidate.idHint}" requires plugin "${requiredPluginId}"; install "${requiredPluginId}" to use it`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
type InstalledPluginRecordPath = {
|
|
path: string;
|
|
requireBuiltRuntimeEntry: boolean;
|
|
};
|
|
|
|
function isLinkedLocalPluginRecord(params: {
|
|
record: PluginInstallRecord;
|
|
env: NodeJS.ProcessEnv;
|
|
realpathCache: Map<string, string>;
|
|
}): boolean {
|
|
if (params.record.source !== "path") {
|
|
return false;
|
|
}
|
|
if (
|
|
typeof params.record.sourcePath !== "string" ||
|
|
!params.record.sourcePath.trim() ||
|
|
typeof params.record.installPath !== "string" ||
|
|
!params.record.installPath.trim()
|
|
) {
|
|
return false;
|
|
}
|
|
return resolvesToSameDirectory(
|
|
resolveUserPath(params.record.sourcePath, params.env),
|
|
resolveUserPath(params.record.installPath, params.env),
|
|
params.realpathCache,
|
|
);
|
|
}
|
|
|
|
function collectInstalledPluginRecordPaths(
|
|
installRecords: Record<string, PluginInstallRecord> | undefined,
|
|
env: NodeJS.ProcessEnv,
|
|
realpathCache: Map<string, string>,
|
|
): InstalledPluginRecordPath[] {
|
|
const paths: InstalledPluginRecordPath[] = [];
|
|
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({
|
|
path: resolved,
|
|
requireBuiltRuntimeEntry: !isLinkedLocalPluginRecord({ record, env, realpathCache }),
|
|
});
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
// Discovery follows the install ledger's primary path choice; managed
|
|
// classification needs every recorded path so a sourcePath under the global
|
|
// extensions root does not get rescanned as an untracked local plugin.
|
|
function collectManagedPluginRecordPaths(
|
|
installRecords: Record<string, PluginInstallRecord> | undefined,
|
|
env: NodeJS.ProcessEnv,
|
|
): string[] {
|
|
const paths: string[] = [];
|
|
const seen = new Set<string>();
|
|
for (const record of Object.values(installRecords ?? {})) {
|
|
for (const rawPath of [record.installPath, record.sourcePath]) {
|
|
if (typeof rawPath !== "string" || !rawPath.trim()) {
|
|
continue;
|
|
}
|
|
const resolved = resolveUserPath(rawPath, env);
|
|
if (seen.has(resolved) || !fs.existsSync(resolved)) {
|
|
continue;
|
|
}
|
|
seen.add(resolved);
|
|
paths.push(resolved);
|
|
}
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
function resolveManagedPluginDirKey(
|
|
installedPath: string,
|
|
realpathCache: Map<string, string>,
|
|
): string | null {
|
|
const stat = safeStatSync(installedPath);
|
|
if (!stat) {
|
|
return null;
|
|
}
|
|
const pluginDir = stat.isFile() ? path.dirname(installedPath) : installedPath;
|
|
return safeRealpathSync(pluginDir, realpathCache) ?? path.resolve(pluginDir);
|
|
}
|
|
|
|
function collectManagedPluginDirKeys(
|
|
installedPaths: readonly string[],
|
|
realpathCache: Map<string, string>,
|
|
): Set<string> {
|
|
const dirs = new Set<string>();
|
|
for (const installedPath of installedPaths) {
|
|
const key = resolveManagedPluginDirKey(installedPath, realpathCache);
|
|
if (key) {
|
|
dirs.add(key);
|
|
}
|
|
}
|
|
return dirs;
|
|
}
|
|
|
|
function isManagedPluginDir(params: {
|
|
dir: string;
|
|
realpath?: string;
|
|
managedPluginDirs?: Set<string>;
|
|
realpathCache: Map<string, string>;
|
|
}): boolean {
|
|
if (!params.managedPluginDirs || params.managedPluginDirs.size === 0) {
|
|
return false;
|
|
}
|
|
const key =
|
|
params.realpath ??
|
|
safeRealpathSync(params.dir, params.realpathCache) ??
|
|
path.resolve(params.dir);
|
|
return params.managedPluginDirs.has(key);
|
|
}
|
|
|
|
function readPackageManifest(
|
|
dir: string,
|
|
rejectHardlinks = true,
|
|
rootRealPath?: string,
|
|
): PackageManifest | null {
|
|
const result = readRootJsonObjectSync({
|
|
rootDir: dir,
|
|
...(rootRealPath !== undefined ? { rootRealPath } : {}),
|
|
relativePath: "package.json",
|
|
boundaryLabel: "plugin package directory",
|
|
rejectHardlinks,
|
|
});
|
|
return result.ok ? (result.value as PackageManifest) : null;
|
|
}
|
|
|
|
function readTrustedPackageManifest(dir: string): PackageManifest | null {
|
|
return tryReadJsonSync<PackageManifest>(path.join(dir, "package.json"));
|
|
}
|
|
|
|
function readCandidatePackageManifest(params: {
|
|
dir: string;
|
|
origin: PluginOrigin;
|
|
rejectHardlinks: boolean;
|
|
rootRealPath?: string;
|
|
packageManifestCache?: Map<string, PackageManifest | null>;
|
|
}): PackageManifest | null {
|
|
const rootRealPath = params.rootRealPath ?? safeRealpathSync(params.dir);
|
|
const trustMode =
|
|
params.origin === "bundled"
|
|
? "trusted"
|
|
: params.rejectHardlinks
|
|
? "external-reject"
|
|
: "external-allow";
|
|
const cacheKey = createPluginCacheKey([trustMode, rootRealPath ?? path.resolve(params.dir)]);
|
|
const cached = params.packageManifestCache?.get(cacheKey);
|
|
if (cached !== undefined) {
|
|
return cached;
|
|
}
|
|
// Relaxed hardlink validation does not make a mutable external root immutable.
|
|
// Only bundled plugins and verified Nix store roots survive a metadata generation.
|
|
const canUseProcessCache =
|
|
params.origin === "bundled" ||
|
|
(!params.rejectHardlinks &&
|
|
typeof rootRealPath === "string" &&
|
|
(rootRealPath === IMMUTABLE_NIX_STORE_ROOT ||
|
|
rootRealPath.startsWith(`${IMMUTABLE_NIX_STORE_ROOT}/`)));
|
|
if (canUseProcessCache) {
|
|
const processCached = packageManifestProcessCache.getResult(cacheKey);
|
|
if (processCached.hit) {
|
|
params.packageManifestCache?.set(cacheKey, processCached.value);
|
|
return processCached.value;
|
|
}
|
|
}
|
|
const manifest =
|
|
params.origin === "bundled"
|
|
? readTrustedPackageManifest(params.dir)
|
|
: readPackageManifest(params.dir, params.rejectHardlinks, params.rootRealPath);
|
|
params.packageManifestCache?.set(cacheKey, manifest);
|
|
if (canUseProcessCache) {
|
|
packageManifestProcessCache.set(cacheKey, manifest);
|
|
}
|
|
return manifest;
|
|
}
|
|
|
|
function deriveIdHint(params: {
|
|
filePath: string;
|
|
manifestId?: string;
|
|
packageName?: string;
|
|
fallbackId: string;
|
|
hasMultipleExtensions: boolean;
|
|
}): string {
|
|
const base = path.basename(params.filePath, path.extname(params.filePath));
|
|
// Channel ids own transport diagnostics; package candidates retain their shipped plugin identity.
|
|
const pluginId =
|
|
normalizeOptionalString(params.manifestId) ??
|
|
derivePackagePluginIdHint(params.packageName) ??
|
|
params.fallbackId;
|
|
return params.hasMultipleExtensions ? `${pluginId}/${base}` : pluginId;
|
|
}
|
|
|
|
function derivePackagePluginIdHint(packageName: unknown): string | undefined {
|
|
const rawPackageName = normalizeOptionalString(packageName);
|
|
if (!rawPackageName) {
|
|
return undefined;
|
|
}
|
|
// Scoped package names must normalize to their unscoped runtime owner so
|
|
// diagnostics, config keys, and discovered plugin identities cannot diverge.
|
|
const unscoped = rawPackageName.includes("/")
|
|
? (rawPackageName.split("/").pop() ?? rawPackageName)
|
|
: rawPackageName;
|
|
for (const suffix of ["-provider", "-plugin"]) {
|
|
if (unscoped.endsWith(suffix) && unscoped.length > suffix.length) {
|
|
return unscoped.slice(0, -suffix.length);
|
|
}
|
|
}
|
|
return normalizeOptionalString(unscoped);
|
|
}
|
|
|
|
function pushInvalidPackageExtensionDiagnostic(params: {
|
|
resolution: PackageExtensionResolution;
|
|
source: string;
|
|
pluginId?: string;
|
|
diagnostics: PluginDiagnostic[];
|
|
}): boolean {
|
|
if (params.resolution.status === "invalid") {
|
|
params.diagnostics.push({
|
|
level: "error",
|
|
source: params.source,
|
|
message: params.resolution.error,
|
|
...(params.pluginId ? { pluginId: params.pluginId } : {}),
|
|
});
|
|
return true;
|
|
}
|
|
if (params.resolution.status === "empty") {
|
|
params.diagnostics.push({
|
|
level: "error",
|
|
source: params.source,
|
|
message: "package.json openclaw.extensions is empty",
|
|
...(params.pluginId ? { pluginId: params.pluginId } : {}),
|
|
});
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
type ResolvedCandidateManifest = {
|
|
manifest: PluginManifest;
|
|
manifestPath: string;
|
|
};
|
|
|
|
function resolveCandidateManifest(
|
|
rootDir: string,
|
|
rejectHardlinks: boolean,
|
|
rootRealPath?: string,
|
|
): ResolvedCandidateManifest | undefined {
|
|
const manifest = loadPluginManifest(rootDir, rejectHardlinks, rootRealPath);
|
|
return manifest.ok
|
|
? { manifest: manifest.manifest, manifestPath: manifest.manifestPath }
|
|
: undefined;
|
|
}
|
|
|
|
function addCandidate(params: {
|
|
candidates: PluginCandidate[];
|
|
diagnostics: PluginDiagnostic[];
|
|
seen: Set<string>;
|
|
idHint: string;
|
|
diagnosticIdHint?: string;
|
|
source: string;
|
|
setupSource?: string;
|
|
rootDir: string;
|
|
origin: PluginOrigin;
|
|
format?: PluginFormat;
|
|
bundleFormat?: PluginBundleFormat;
|
|
ownershipUid?: number | null;
|
|
workspaceDir?: string;
|
|
manifest?: PackageManifest | null;
|
|
packageDir?: string;
|
|
bundledManifestId?: string;
|
|
bundledManifest?: PluginManifest;
|
|
bundledManifestPath?: string;
|
|
requiredPluginIds?: string[];
|
|
requiredPluginSource?: 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 packageManifest = getPackageManifestMetadata(manifest ?? undefined);
|
|
const packageDependencies = normalizePluginDependencySpecs({
|
|
dependencies: manifest?.dependencies,
|
|
optionalDependencies: manifest?.optionalDependencies,
|
|
});
|
|
params.candidates.push({
|
|
idHint: params.idHint,
|
|
...(params.diagnosticIdHint && params.diagnosticIdHint !== params.idHint
|
|
? { diagnosticIdHint: params.diagnosticIdHint }
|
|
: {}),
|
|
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,
|
|
packageDependencies: packageDependencies.dependencies,
|
|
packageOptionalDependencies: packageDependencies.optionalDependencies,
|
|
rawPackageManifest: manifest ?? undefined,
|
|
bundledManifestId: params.bundledManifestId,
|
|
bundledManifest: params.bundledManifest,
|
|
bundledManifestPath: params.bundledManifestPath,
|
|
...(params.requiredPluginIds && params.requiredPluginIds.length > 0
|
|
? { requiredPluginIds: params.requiredPluginIds }
|
|
: {}),
|
|
...(params.requiredPluginSource ? { requiredPluginSource: params.requiredPluginSource } : {}),
|
|
});
|
|
}
|
|
|
|
function discoverBundleInRoot(params: {
|
|
rootDir: string;
|
|
origin: PluginOrigin;
|
|
env: NodeJS.ProcessEnv;
|
|
ownershipUid?: number | null;
|
|
workspaceDir?: string;
|
|
manifest?: PackageManifest | null;
|
|
candidates: PluginCandidate[];
|
|
diagnostics: PluginDiagnostic[];
|
|
seen: Set<string>;
|
|
realpathCache: Map<string, string>;
|
|
}): "added" | "invalid" | "none" {
|
|
return withPluginScanExistenceCache(() => {
|
|
const bundleFormat = detectBundleManifestFormat(params.rootDir);
|
|
if (!bundleFormat) {
|
|
return "none";
|
|
}
|
|
const rootRealPath = safeRealpathSync(params.rootDir, params.realpathCache) ?? undefined;
|
|
const rejectHardlinks = shouldRejectHardlinkedPluginFiles({
|
|
origin: params.origin,
|
|
rootDir: params.rootDir,
|
|
env: params.env,
|
|
realpathCache: params.realpathCache,
|
|
});
|
|
const bundleManifest = loadBundleManifest({
|
|
rootDir: params.rootDir,
|
|
...(rootRealPath !== undefined ? { rootRealPath } : {}),
|
|
bundleFormat,
|
|
rejectHardlinks,
|
|
});
|
|
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,
|
|
bundledManifestId: bundleManifest.manifest.id,
|
|
bundledManifestPath: bundleManifest.manifestPath,
|
|
realpathCache: params.realpathCache,
|
|
});
|
|
return "added";
|
|
});
|
|
}
|
|
|
|
function addLegacyNpmDeclarationDiagnostic(params: {
|
|
pluginDir: string;
|
|
diagnostics: PluginDiagnostic[];
|
|
}): boolean {
|
|
const declaration = readLegacyNpmPluginDeclaration(params.pluginDir);
|
|
if (!declaration) {
|
|
return false;
|
|
}
|
|
params.diagnostics.push({
|
|
level: "warn",
|
|
pluginId: declaration.pluginId,
|
|
source: declaration.source,
|
|
message: `legacy npm plugin declaration ignored for "${declaration.pluginId}"; run "openclaw doctor --fix" to install ${declaration.npmSpec} into the managed plugin root`,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
function shouldSkipIncompatiblePackagePluginApi(params: {
|
|
origin: PluginOrigin;
|
|
packageManifest: OpenClawPackageManifest | undefined;
|
|
pluginId: string;
|
|
packageDir: string;
|
|
env: NodeJS.ProcessEnv;
|
|
diagnostics: PluginDiagnostic[];
|
|
}): boolean {
|
|
if (params.origin === "bundled") {
|
|
return false;
|
|
}
|
|
const packagePluginApiRangeCheck = resolvePackagePluginApiRange(params.packageManifest);
|
|
if (!packagePluginApiRangeCheck.ok) {
|
|
params.diagnostics.push({
|
|
level: "warn",
|
|
source: path.join(params.packageDir, "package.json"),
|
|
message: `invalid package plugin API metadata: ${packagePluginApiRangeCheck.error}; skipping discovery (check package.json openclaw.compat.pluginApi)`,
|
|
pluginId: params.pluginId,
|
|
});
|
|
return true;
|
|
}
|
|
const packagePluginApiRange = packagePluginApiRangeCheck.range;
|
|
if (!packagePluginApiRange) {
|
|
return false;
|
|
}
|
|
const compatibilityHostVersion = resolveCompatibilityHostVersion(params.env);
|
|
if (satisfiesPluginApiRange(compatibilityHostVersion, packagePluginApiRange)) {
|
|
return false;
|
|
}
|
|
params.diagnostics.push({
|
|
level: "warn",
|
|
source: path.join(params.packageDir, "package.json"),
|
|
message: `plugin requires plugin API ${packagePluginApiRange}, but this host is ${compatibilityHostVersion}; skipping discovery (check "openclaw --version", OPENCLAW_COMPATIBILITY_HOST_VERSION, or run "openclaw doctor")`,
|
|
pluginId: params.pluginId,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
type PluginDirectoryDiscoveryParams = {
|
|
dir: string;
|
|
rootRealPath: string | undefined;
|
|
origin: PluginOrigin;
|
|
env: NodeJS.ProcessEnv;
|
|
ownershipUid?: number | null;
|
|
workspaceDir?: string;
|
|
requireBuiltRuntimeEntry?: boolean;
|
|
managedPluginDirs?: Set<string>;
|
|
candidates: PluginCandidate[];
|
|
diagnostics: PluginDiagnostic[];
|
|
seen: Set<string>;
|
|
realpathCache: Map<string, string>;
|
|
packageManifestCache?: Map<string, PackageManifest | null>;
|
|
};
|
|
|
|
function discoverPluginDirectory(params: PluginDirectoryDiscoveryParams): boolean {
|
|
const { dir, rootRealPath } = params;
|
|
const requireBuiltRuntimeEntry =
|
|
params.requireBuiltRuntimeEntry ??
|
|
isManagedPluginDir({
|
|
dir,
|
|
realpath: rootRealPath,
|
|
managedPluginDirs: params.managedPluginDirs,
|
|
realpathCache: params.realpathCache,
|
|
});
|
|
const rejectHardlinks = shouldRejectHardlinkedPluginFiles({
|
|
origin: params.origin,
|
|
rootDir: dir,
|
|
env: params.env,
|
|
realpathCache: params.realpathCache,
|
|
});
|
|
const manifest = readCandidatePackageManifest({
|
|
dir,
|
|
origin: params.origin,
|
|
rejectHardlinks,
|
|
...(rootRealPath !== undefined ? { rootRealPath } : {}),
|
|
packageManifestCache: params.packageManifestCache,
|
|
});
|
|
const packageMetadata = getPackageManifestMetadata(manifest ?? undefined);
|
|
// Compatibility can return early, so resolve one canonical diagnostic owner before every check.
|
|
const candidateManifest = resolveCandidateManifest(dir, rejectHardlinks, rootRealPath);
|
|
const manifestId = candidateManifest?.manifest.id;
|
|
const pluginIdHint =
|
|
normalizeOptionalString(manifestId) ??
|
|
normalizeOptionalString(packageMetadata?.plugin?.id) ??
|
|
normalizeOptionalString(packageMetadata?.channel?.id) ??
|
|
derivePackagePluginIdHint(manifest?.name) ??
|
|
path.basename(dir);
|
|
if (
|
|
shouldSkipIncompatiblePackagePluginApi({
|
|
origin: params.origin,
|
|
packageManifest: packageMetadata,
|
|
pluginId: pluginIdHint,
|
|
packageDir: dir,
|
|
env: params.env,
|
|
diagnostics: params.diagnostics,
|
|
})
|
|
) {
|
|
return true;
|
|
}
|
|
const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined);
|
|
if (
|
|
pushInvalidPackageExtensionDiagnostic({
|
|
resolution: extensionResolution,
|
|
source: dir,
|
|
pluginId: pluginIdHint,
|
|
diagnostics: params.diagnostics,
|
|
})
|
|
) {
|
|
return true;
|
|
}
|
|
const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : [];
|
|
const setupSource = resolvePackageSetupSource({
|
|
packageDir: dir,
|
|
...(rootRealPath !== undefined ? { packageRootRealPath: rootRealPath } : {}),
|
|
manifest,
|
|
pluginIdHint,
|
|
origin: params.origin,
|
|
requireBuiltRuntimeEntry,
|
|
sourceLabel: dir,
|
|
diagnostics: params.diagnostics,
|
|
rejectHardlinks,
|
|
});
|
|
const addPackageCandidate = (source: string, idHint: string): void => {
|
|
addCandidate({
|
|
candidates: params.candidates,
|
|
diagnostics: params.diagnostics,
|
|
seen: params.seen,
|
|
idHint,
|
|
diagnosticIdHint: pluginIdHint,
|
|
source,
|
|
...(setupSource ? { setupSource } : {}),
|
|
rootDir: dir,
|
|
origin: params.origin,
|
|
ownershipUid: params.ownershipUid,
|
|
workspaceDir: params.workspaceDir,
|
|
manifest,
|
|
packageDir: dir,
|
|
requiredPluginIds: candidateManifest?.manifest.requiresPlugins,
|
|
requiredPluginSource: candidateManifest?.manifestPath,
|
|
realpathCache: params.realpathCache,
|
|
});
|
|
};
|
|
|
|
if (extensions.length > 0) {
|
|
const resolvedRuntimeSources = resolvePackageRuntimeExtensionSources({
|
|
packageDir: dir,
|
|
...(rootRealPath !== undefined ? { packageRootRealPath: rootRealPath } : {}),
|
|
manifest,
|
|
extensions,
|
|
origin: params.origin,
|
|
pluginIdHint,
|
|
requireBuiltRuntimeEntry,
|
|
sourceLabel: dir,
|
|
diagnostics: params.diagnostics,
|
|
rejectHardlinks,
|
|
});
|
|
for (const source of resolvedRuntimeSources) {
|
|
addPackageCandidate(
|
|
source,
|
|
deriveIdHint({
|
|
filePath: source,
|
|
manifestId: manifestId ?? normalizeOptionalString(packageMetadata?.plugin?.id),
|
|
packageName: manifest?.name,
|
|
fallbackId: path.basename(dir),
|
|
hasMultipleExtensions: extensions.length > 1,
|
|
}),
|
|
);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
discoverBundleInRoot({
|
|
rootDir: dir,
|
|
origin: params.origin,
|
|
env: params.env,
|
|
ownershipUid: params.ownershipUid,
|
|
workspaceDir: params.workspaceDir,
|
|
manifest,
|
|
candidates: params.candidates,
|
|
diagnostics: params.diagnostics,
|
|
seen: params.seen,
|
|
realpathCache: params.realpathCache,
|
|
}) === "added"
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
const indexFile = [...DEFAULT_PLUGIN_ENTRY_CANDIDATES]
|
|
.map((candidate) => path.join(dir, candidate))
|
|
.find((candidate) => fs.existsSync(candidate));
|
|
if (indexFile && isExtensionFile(indexFile)) {
|
|
addPackageCandidate(indexFile, manifestId ?? path.basename(dir));
|
|
return true;
|
|
}
|
|
return addLegacyNpmDeclarationDiagnostic({ pluginDir: dir, diagnostics: params.diagnostics });
|
|
}
|
|
|
|
function discoverInDirectory(params: {
|
|
dir: string;
|
|
origin: PluginOrigin;
|
|
env: NodeJS.ProcessEnv;
|
|
ownershipUid?: number | null;
|
|
workspaceDir?: string;
|
|
requireBuiltRuntimeEntry?: boolean;
|
|
managedPluginDirs?: Set<string>;
|
|
skipRootDirKeys?: Set<string>;
|
|
candidates: PluginCandidate[];
|
|
diagnostics: PluginDiagnostic[];
|
|
seen: Set<string>;
|
|
realpathCache: Map<string, string>;
|
|
packageManifestCache?: Map<string, PackageManifest | null>;
|
|
scanFiles?: boolean;
|
|
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 })
|
|
.toSorted((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
|
|
} 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") {
|
|
const shouldScanFile = params.scanFiles ?? params.origin === "bundled";
|
|
if (!shouldScanFile || !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 fullPathRealPath = safeRealpathSync(fullPath, params.realpathCache) ?? undefined;
|
|
const fullPathDirKey = fullPathRealPath ?? path.resolve(fullPath);
|
|
if (params.skipRootDirKeys?.has(fullPathDirKey)) {
|
|
continue;
|
|
}
|
|
if (
|
|
discoverPluginDirectory({
|
|
...params,
|
|
dir: fullPath,
|
|
rootRealPath: fullPathRealPath,
|
|
})
|
|
) {
|
|
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(
|
|
sortUniqueStrings(
|
|
fs
|
|
.readdirSync(dir, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => entry.name),
|
|
),
|
|
);
|
|
} catch {
|
|
return new Set();
|
|
}
|
|
}
|
|
|
|
function readBundledDistOptOutDirectoryNames(sourceExtensionsDir: string | undefined): Set<string> {
|
|
const names = new Set<string>();
|
|
if (!sourceExtensionsDir) {
|
|
return names;
|
|
}
|
|
for (const name of readChildDirectoryNames(sourceExtensionsDir)) {
|
|
const packageManifest = getPackageManifestMetadata(
|
|
readTrustedPackageManifest(path.join(sourceExtensionsDir, name)) ?? undefined,
|
|
);
|
|
if (packageManifest?.build?.bundledDist === false) {
|
|
names.add(name);
|
|
}
|
|
}
|
|
return names;
|
|
}
|
|
|
|
function discoverFromPath(params: {
|
|
rawPath: string;
|
|
origin: PluginOrigin;
|
|
ownershipUid?: number | null;
|
|
workspaceDir?: string;
|
|
requireBuiltRuntimeEntry?: boolean;
|
|
managedPluginDirs?: Set<string>;
|
|
skipRootDirKeys?: Set<string>;
|
|
scanFiles?: boolean;
|
|
env: NodeJS.ProcessEnv;
|
|
candidates: PluginCandidate[];
|
|
diagnostics: PluginDiagnostic[];
|
|
seen: Set<string>;
|
|
realpathCache: Map<string, string>;
|
|
packageManifestCache?: Map<string, PackageManifest | null>;
|
|
}) {
|
|
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()) {
|
|
if (
|
|
discoverPluginDirectory({
|
|
...params,
|
|
dir: resolved,
|
|
rootRealPath: safeRealpathSync(resolved, params.realpathCache) ?? undefined,
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
|
|
discoverInDirectory({
|
|
dir: resolved,
|
|
origin: params.origin,
|
|
env: params.env,
|
|
ownershipUid: params.ownershipUid,
|
|
workspaceDir: params.workspaceDir,
|
|
candidates: params.candidates,
|
|
diagnostics: params.diagnostics,
|
|
seen: params.seen,
|
|
realpathCache: params.realpathCache,
|
|
packageManifestCache: params.packageManifestCache,
|
|
...(params.scanFiles !== undefined || params.origin === "config"
|
|
? { scanFiles: params.scanFiles ?? true }
|
|
: {}),
|
|
...(params.requireBuiltRuntimeEntry !== undefined
|
|
? { requireBuiltRuntimeEntry: params.requireBuiltRuntimeEntry }
|
|
: {}),
|
|
...(params.managedPluginDirs ? { managedPluginDirs: params.managedPluginDirs } : {}),
|
|
...(params.skipRootDirKeys ? { skipRootDirKeys: params.skipRootDirKeys } : {}),
|
|
});
|
|
}
|
|
}
|
|
|
|
function discoverConfiguredPluginLoadPathsInto(params: {
|
|
loadPaths: readonly string[];
|
|
bundledRoot?: string;
|
|
ownershipUid?: number | null;
|
|
workspaceDir?: string;
|
|
env: NodeJS.ProcessEnv;
|
|
result: PluginDiscoveryResult;
|
|
seen: Set<string>;
|
|
realpathCache: Map<string, string>;
|
|
packageManifestCache: Map<string, PackageManifest | null>;
|
|
}): void {
|
|
for (const loadPath of params.loadPaths) {
|
|
if (typeof loadPath !== "string") {
|
|
continue;
|
|
}
|
|
const trimmed = loadPath.trim();
|
|
if (!trimmed) {
|
|
continue;
|
|
}
|
|
discoverFromPath({
|
|
rawPath: trimmed,
|
|
origin: "config",
|
|
ownershipUid: params.ownershipUid,
|
|
workspaceDir: params.workspaceDir,
|
|
env: params.env,
|
|
candidates: params.result.candidates,
|
|
diagnostics: params.result.diagnostics,
|
|
seen: params.seen,
|
|
realpathCache: params.realpathCache,
|
|
packageManifestCache: params.packageManifestCache,
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Discovers only explicit plugins.load.paths candidates without scanning shared roots. */
|
|
export function discoverConfiguredPluginLoadPaths(params: {
|
|
loadPaths: readonly string[];
|
|
workspaceDir?: string;
|
|
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 result = createDiscoveryResult();
|
|
discoverConfiguredPluginLoadPathsInto({
|
|
loadPaths: params.loadPaths,
|
|
bundledRoot: roots.stock,
|
|
ownershipUid: params.ownershipUid,
|
|
workspaceDir,
|
|
env,
|
|
result,
|
|
seen: new Set<string>(),
|
|
realpathCache: new Map<string, string>(),
|
|
packageManifestCache: new Map<string, PackageManifest | null>(),
|
|
});
|
|
return result;
|
|
}
|
|
|
|
export function discoverOpenClawPlugins(params: {
|
|
workspaceDir?: string;
|
|
extraPaths?: string[];
|
|
installRecords?: Record<string, PluginInstallRecord>;
|
|
ownershipUid?: number | null;
|
|
env?: NodeJS.ProcessEnv;
|
|
rootScope?: PluginDiscoveryRootScope;
|
|
}): 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 realpathCache = new Map<string, string>();
|
|
const packageManifestCache = new Map<string, PackageManifest | null>();
|
|
const scopedResult =
|
|
params.rootScope === "bundled"
|
|
? createDiscoveryResult()
|
|
: tracePluginLifecyclePhase(
|
|
"discovery scan",
|
|
() => {
|
|
const result = createDiscoveryResult();
|
|
const seen = new Set<string>();
|
|
discoverConfiguredPluginLoadPathsInto({
|
|
loadPaths: params.extraPaths ?? [],
|
|
bundledRoot: roots.stock,
|
|
ownershipUid: params.ownershipUid,
|
|
workspaceDir,
|
|
env,
|
|
result,
|
|
seen,
|
|
realpathCache,
|
|
packageManifestCache,
|
|
});
|
|
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",
|
|
env,
|
|
ownershipUid: params.ownershipUid,
|
|
workspaceDir: workspaceRoot,
|
|
candidates: result.candidates,
|
|
diagnostics: result.diagnostics,
|
|
seen,
|
|
realpathCache,
|
|
packageManifestCache,
|
|
});
|
|
}
|
|
return result;
|
|
},
|
|
{ scope: "scoped", extraPathCount: params.extraPaths?.length ?? 0 },
|
|
);
|
|
const sharedResult = tracePluginLifecyclePhase(
|
|
"discovery scan",
|
|
() => {
|
|
const result = createDiscoveryResult();
|
|
const seen = new Set<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,
|
|
packageManifestCache,
|
|
});
|
|
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,
|
|
});
|
|
}
|
|
const sourceCheckoutExtensionsDir = resolveBundledSourceCheckoutExtensionsDir(roots.stock);
|
|
const bundledDistOptOutDirectories = readBundledDistOptOutDirectoryNames(
|
|
sourceCheckoutExtensionsDir,
|
|
);
|
|
if (sourceCheckoutExtensionsDir) {
|
|
for (const dirName of bundledDistOptOutDirectories) {
|
|
discoverFromPath({
|
|
rawPath: path.join(sourceCheckoutExtensionsDir, dirName),
|
|
origin: "bundled",
|
|
ownershipUid: params.ownershipUid,
|
|
workspaceDir,
|
|
env,
|
|
candidates: result.candidates,
|
|
diagnostics: result.diagnostics,
|
|
seen,
|
|
realpathCache,
|
|
packageManifestCache,
|
|
});
|
|
}
|
|
}
|
|
if (roots.stock) {
|
|
discoverInDirectory({
|
|
dir: roots.stock,
|
|
origin: "bundled",
|
|
env,
|
|
ownershipUid: params.ownershipUid,
|
|
candidates: result.candidates,
|
|
diagnostics: result.diagnostics,
|
|
seen,
|
|
realpathCache,
|
|
packageManifestCache,
|
|
skipDirectories: bundledDistOptOutDirectories,
|
|
});
|
|
}
|
|
const sourceCheckoutMatchesBundledRoot = resolvesToSameDirectory(
|
|
sourceCheckoutExtensionsDir,
|
|
roots.stock,
|
|
realpathCache,
|
|
);
|
|
if (sourceCheckoutExtensionsDir && !sourceCheckoutMatchesBundledRoot) {
|
|
discoverInDirectory({
|
|
dir: sourceCheckoutExtensionsDir,
|
|
origin: "bundled",
|
|
env,
|
|
ownershipUid: params.ownershipUid,
|
|
candidates: result.candidates,
|
|
diagnostics: result.diagnostics,
|
|
seen,
|
|
realpathCache,
|
|
packageManifestCache,
|
|
skipDirectories: readChildDirectoryNames(roots.stock),
|
|
});
|
|
}
|
|
if (params.rootScope !== "bundled") {
|
|
const installedPaths = collectInstalledPluginRecordPaths(
|
|
params.installRecords,
|
|
env,
|
|
realpathCache,
|
|
);
|
|
const installedPluginDirKeys = collectManagedPluginDirKeys(
|
|
installedPaths.map((installedPath) => installedPath.path),
|
|
realpathCache,
|
|
);
|
|
const managedPluginDirs = collectManagedPluginDirKeys(
|
|
collectManagedPluginRecordPaths(params.installRecords, env),
|
|
realpathCache,
|
|
);
|
|
for (const installedPath of installedPaths) {
|
|
discoverFromPath({
|
|
rawPath: installedPath.path,
|
|
origin: "global",
|
|
ownershipUid: params.ownershipUid,
|
|
workspaceDir,
|
|
requireBuiltRuntimeEntry: installedPath.requireBuiltRuntimeEntry,
|
|
managedPluginDirs,
|
|
scanFiles: true,
|
|
env,
|
|
candidates: result.candidates,
|
|
diagnostics: result.diagnostics,
|
|
seen,
|
|
realpathCache,
|
|
packageManifestCache,
|
|
});
|
|
}
|
|
// 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",
|
|
env,
|
|
ownershipUid: params.ownershipUid,
|
|
managedPluginDirs,
|
|
skipRootDirKeys: installedPluginDirKeys,
|
|
candidates: result.candidates,
|
|
diagnostics: result.diagnostics,
|
|
seen,
|
|
realpathCache,
|
|
packageManifestCache,
|
|
});
|
|
}
|
|
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);
|
|
addMissingRequiredPluginDiagnostics(result, { env, realpathCache });
|
|
return result;
|
|
}
|
|
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|