mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-14 22:54:01 -06:00
0f01930c8e
* fix(plugins): discover config load.paths plugins when index has entries Installing a managed npm plugin populates the persisted plugin index. Once the index has entries (index.plugins.length > 0), loadPluginManifestRegistryForInstalledIndex is used instead of the full discovery path. This path only processes plugins that already exist in the index — if workspace plugins from config plugins.load.paths are missing from the index, they are silently dropped from the manifest registry, producing 'plugin not found (stale config entry ignored)' warnings on gateway startup and in CLI commands. Fix: after converting index records to plugin candidates, also discover plugins from config plugins.load.paths and merge any candidates that are not already represented in the index-based list. This ensures config-origin workspace plugins are always included in the manifest registry regardless of index completeness. Fixes #99185 * fix(plugins): address clawsweeper review — config-only scope, pluginId scoping, regression tests - P1: Limit extra discovery scope to config-origin candidates only (filter by origin === 'config') instead of full discoverOpenClawPlugins - P2: Preserve pluginId scoping for load-path candidates (filter extra candidates by pluginIdSet when present) - Add 3 regression tests: load.paths discovery, empty-load no-op, pluginId scoping preservation - Add L2 real behavior proof with direct function call evidence 🦞 diamond lobster: L2 evidence (real function call + objects) Ref. https://github.com/openclaw/openclaw/pull/99196 * fix(plugins): replace discoverOpenClawPlugins with config-only discoverFromConfigPaths P1: The installed-index fallback previously called discoverOpenClawPlugins, which scans bundled/global/workspace roots and returns their diagnostics. This allowed unrelated shared-root diagnostics to leak into the installed-index config validation surface. Replace it with discoverFromConfigPaths — a new helper that scans only the user-configured plugins.load.paths entries via discoverFromPath directly, producing zero bundled/global candidates or diagnostics. P2: pluginId scoping is preserved for load-path candidates. Added discoverFromConfigPaths to src/plugins/discovery.ts to keep the config-only discovery path reusable and explicit. 🦞 diamond lobster: L2 evidence (terminal output from real dev build) Ref. https://github.com/openclaw/openclaw/pull/99196 * fix(plugins): preserve bundled-load-path alias filtering in discoverFromConfigPaths P2: The new discoverFromConfigPaths helper (added in Round 1 to fix the P1 full-discovery issue) bypassed normal discovery's bundled-load-path alias guard. If a user had a bundled plugin directory in their plugins.load.paths, the installed-index fallback would treat it as a valid config-origin candidate and potentially override/duplicate bundled plugins. Add resolvePackagedBundledLoadPathAlias check in discoverFromConfigPaths before calling discoverFromPath, so bundled paths are skipped with a warning diagnostic matching normal discovery behavior. Add P2 regression test verifying bundled plugin load paths are ignored on the installed-index path while real workspace load paths still work. Ref. https://github.com/openclaw/openclaw/pull/99196 * fix(plugins): forward load-path diagnostics unconditionally in installed-index path Previously, diagnostics from discoverFromConfigPaths were only forwarded when at least one extra candidate survived filtering. Configs whose plugins.load.paths contained only bundled aliases, missing paths, duplicates already in the index, or scoped-out entries silently lost the normal discovery warning or doctor hint. Now extraDiagnostics is assigned outside the extraCandidates.length > 0 guard, so every config load-path diagnostic reaches the caller regardless of whether a new candidate is merged. Adds a focused regression test verifying that a bundled-only load path still surfaces the expected alias-guard warning diagnostic. 🦞 diamond lobster: P2 fix, no new L2 evidence needed (regression test covers the diagnostic-forwarding contract directly) Ref. https://github.com/openclaw/openclaw/pull/99196 * fix(plugins): preserve requiresPlugins diagnostics for load paths in installed-index path - [P2] Export addMissingRequiredPluginDiagnostics from discovery.ts for reuse - [P2] Call addMissingRequiredPluginDiagnostics on combined index + load-path candidates before passing to loadPluginManifestRegistry - [P2] Add regression tests: warns when load-path plugin requires missing plugin, does not false-warn when required plugin is already in index 🦞 diamond lobster: L2 + test coverage Ref. https://github.com/openclaw/openclaw/pull/99196 * fix(plugins): add missing discovery.js mock exports for addMissingRequiredPluginDiagnostics plugin-install.test.ts mocks discovery.js but the mock didn't include the newly exported addMissingRequiredPluginDiagnostics, causing CI failures in tests that exercise loadPluginManifestRegistryForInstalledIndex. Also add discoverFromConfigPaths to the mock (already imported but was only latent because the mock's code path wasn't triggered with load paths). Ref. https://github.com/openclaw/openclaw/pull/99196 * fix(plugins): rebuild stale configured plugin indexes --------- Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
1688 lines
52 KiB
TypeScript
1688 lines
52 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 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,
|
|
resolvePackagedBundledLoadPathAlias,
|
|
} 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 { tracePluginLifecyclePhase } from "./plugin-lifecycle-trace.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 packageManifestProcessCache = new Map<
|
|
string,
|
|
{ mtimeMs: number; size: number; manifest: PackageManifest | null }
|
|
>();
|
|
|
|
/** One potential plugin root discovered before manifest validation and registry normalization. */
|
|
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;
|
|
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[];
|
|
};
|
|
|
|
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 addMissingRequiredPluginDiagnostics(result: PluginDiscoveryResult): void {
|
|
const candidateIds = new Set(result.candidates.map((candidate) => candidate.idHint));
|
|
const seen = new Set<string>();
|
|
for (const candidate of result.candidates) {
|
|
for (const requiredPluginId of candidate.requiredPluginIds ?? []) {
|
|
if (candidateIds.has(requiredPluginId) || requiredPluginId === candidate.idHint) {
|
|
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 readPackageManifestStat(dir: string): { mtimeMs: number; size: number } | null {
|
|
try {
|
|
const stat = fs.statSync(path.join(dir, "package.json"));
|
|
return stat.isFile() ? { mtimeMs: stat.mtimeMs, size: stat.size } : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function prunePackageManifestProcessCache(): void {
|
|
while (packageManifestProcessCache.size > PACKAGE_MANIFEST_CACHE_MAX_ENTRIES) {
|
|
const oldest = packageManifestProcessCache.keys().next().value;
|
|
if (oldest === undefined) {
|
|
return;
|
|
}
|
|
packageManifestProcessCache.delete(oldest);
|
|
}
|
|
}
|
|
|
|
function readCandidatePackageManifest(params: {
|
|
dir: string;
|
|
origin: PluginOrigin;
|
|
rejectHardlinks: boolean;
|
|
rootRealPath?: string;
|
|
packageManifestCache?: Map<string, PackageManifest | null>;
|
|
}): PackageManifest | null {
|
|
const trustMode =
|
|
params.origin === "bundled"
|
|
? "trusted"
|
|
: params.rejectHardlinks
|
|
? "external-reject"
|
|
: "external-allow";
|
|
const cacheKey = `${trustMode}:${params.rootRealPath ?? path.resolve(params.dir)}`;
|
|
const cached = params.packageManifestCache?.get(cacheKey);
|
|
if (cached !== undefined) {
|
|
return cached;
|
|
}
|
|
const canUseProcessCache = params.origin === "bundled" || !params.rejectHardlinks;
|
|
const manifestStat = readPackageManifestStat(params.dir);
|
|
if (canUseProcessCache && manifestStat !== null) {
|
|
const processCached = packageManifestProcessCache.get(cacheKey);
|
|
if (
|
|
processCached?.mtimeMs === manifestStat.mtimeMs &&
|
|
processCached.size === manifestStat.size
|
|
) {
|
|
params.packageManifestCache?.set(cacheKey, processCached.manifest);
|
|
return processCached.manifest;
|
|
}
|
|
}
|
|
const manifest =
|
|
params.origin === "bundled"
|
|
? readTrustedPackageManifest(params.dir)
|
|
: readPackageManifest(params.dir, params.rejectHardlinks, params.rootRealPath);
|
|
params.packageManifestCache?.set(cacheKey, manifest);
|
|
if (canUseProcessCache && manifestStat !== null) {
|
|
packageManifestProcessCache.set(cacheKey, { ...manifestStat, manifest });
|
|
prunePackageManifestProcessCache();
|
|
}
|
|
return manifest;
|
|
}
|
|
|
|
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.endsWith("-plugin") && unscoped.length > "-plugin".length
|
|
? unscoped.slice(0, -"-plugin".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 pushInvalidPackageExtensionDiagnostic(params: {
|
|
resolution: PackageExtensionResolution;
|
|
source: string;
|
|
diagnostics: PluginDiagnostic[];
|
|
}): boolean {
|
|
if (params.resolution.status === "invalid") {
|
|
params.diagnostics.push({
|
|
level: "error",
|
|
source: params.source,
|
|
message: params.resolution.error,
|
|
});
|
|
return true;
|
|
}
|
|
if (params.resolution.status === "empty") {
|
|
params.diagnostics.push({
|
|
level: "error",
|
|
source: params.source,
|
|
message: "package.json openclaw.extensions is empty",
|
|
});
|
|
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;
|
|
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,
|
|
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;
|
|
manifest: PackageManifest | null;
|
|
packageDir: string;
|
|
env: NodeJS.ProcessEnv;
|
|
diagnostics: PluginDiagnostic[];
|
|
}): boolean {
|
|
if (params.origin === "bundled") {
|
|
return false;
|
|
}
|
|
const packageManifest = getPackageManifestMetadata(params.manifest ?? undefined);
|
|
const packagePluginApiRangeCheck = resolvePackagePluginApiRange(packageManifest);
|
|
if (!packagePluginApiRangeCheck.ok) {
|
|
const pluginId =
|
|
normalizeOptionalString(packageManifest?.plugin?.id) ??
|
|
derivePackagePluginIdHint({ packageName: params.manifest?.name });
|
|
params.diagnostics.push({
|
|
level: "warn",
|
|
source: path.join(params.packageDir, "package.json"),
|
|
message: `invalid package plugin API metadata: ${packagePluginApiRangeCheck.error}; skipping discovery`,
|
|
...(pluginId ? { pluginId } : {}),
|
|
});
|
|
return true;
|
|
}
|
|
const packagePluginApiRange = packagePluginApiRangeCheck.range;
|
|
if (!packagePluginApiRange) {
|
|
return false;
|
|
}
|
|
const compatibilityHostVersion = resolveCompatibilityHostVersion(params.env);
|
|
if (satisfiesPluginApiRange(compatibilityHostVersion, packagePluginApiRange)) {
|
|
return false;
|
|
}
|
|
const pluginId =
|
|
normalizeOptionalString(packageManifest?.plugin?.id) ??
|
|
derivePackagePluginIdHint({ packageName: params.manifest?.name });
|
|
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`,
|
|
...(pluginId ? { pluginId } : {}),
|
|
});
|
|
return true;
|
|
}
|
|
|
|
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 });
|
|
} 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;
|
|
}
|
|
const requireBuiltRuntimeEntry =
|
|
params.requireBuiltRuntimeEntry ??
|
|
isManagedPluginDir({
|
|
dir: fullPath,
|
|
realpath: fullPathRealPath,
|
|
managedPluginDirs: params.managedPluginDirs,
|
|
realpathCache: params.realpathCache,
|
|
});
|
|
const rejectHardlinks = shouldRejectHardlinkedPluginFiles({
|
|
origin: params.origin,
|
|
rootDir: fullPath,
|
|
env: params.env,
|
|
realpathCache: params.realpathCache,
|
|
});
|
|
const manifest = readCandidatePackageManifest({
|
|
dir: fullPath,
|
|
origin: params.origin,
|
|
rejectHardlinks,
|
|
...(fullPathRealPath !== undefined ? { rootRealPath: fullPathRealPath } : {}),
|
|
packageManifestCache: params.packageManifestCache,
|
|
});
|
|
if (
|
|
shouldSkipIncompatiblePackagePluginApi({
|
|
origin: params.origin,
|
|
manifest,
|
|
packageDir: fullPath,
|
|
env: params.env,
|
|
diagnostics: params.diagnostics,
|
|
})
|
|
) {
|
|
continue;
|
|
}
|
|
const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined);
|
|
if (
|
|
pushInvalidPackageExtensionDiagnostic({
|
|
resolution: extensionResolution,
|
|
source: fullPath,
|
|
diagnostics: params.diagnostics,
|
|
})
|
|
) {
|
|
continue;
|
|
}
|
|
const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : [];
|
|
const candidateManifest = resolveCandidateManifest(fullPath, rejectHardlinks, fullPathRealPath);
|
|
const manifestId = candidateManifest?.manifest.id;
|
|
const setupSource = resolvePackageSetupSource({
|
|
packageDir: fullPath,
|
|
...(fullPathRealPath !== undefined ? { packageRootRealPath: fullPathRealPath } : {}),
|
|
manifest,
|
|
origin: params.origin,
|
|
requireBuiltRuntimeEntry,
|
|
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 }),
|
|
requireBuiltRuntimeEntry,
|
|
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,
|
|
requiredPluginIds: candidateManifest?.manifest.requiresPlugins,
|
|
requiredPluginSource: candidateManifest?.manifestPath,
|
|
realpathCache: params.realpathCache,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const bundleDiscovery = discoverBundleInRoot({
|
|
rootDir: fullPath,
|
|
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,
|
|
});
|
|
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,
|
|
requiredPluginIds: candidateManifest?.manifest.requiresPlugins,
|
|
requiredPluginSource: candidateManifest?.manifestPath,
|
|
realpathCache: params.realpathCache,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
addLegacyNpmDeclarationDiagnostic({
|
|
pluginDir: fullPath,
|
|
diagnostics: params.diagnostics,
|
|
})
|
|
) {
|
|
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;
|
|
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()) {
|
|
const resolvedRealPath = safeRealpathSync(resolved, params.realpathCache) ?? undefined;
|
|
const requireBuiltRuntimeEntry =
|
|
params.requireBuiltRuntimeEntry ??
|
|
isManagedPluginDir({
|
|
dir: resolved,
|
|
realpath: resolvedRealPath,
|
|
managedPluginDirs: params.managedPluginDirs,
|
|
realpathCache: params.realpathCache,
|
|
});
|
|
const rejectHardlinks = shouldRejectHardlinkedPluginFiles({
|
|
origin: params.origin,
|
|
rootDir: resolved,
|
|
env: params.env,
|
|
realpathCache: params.realpathCache,
|
|
});
|
|
const manifest = readCandidatePackageManifest({
|
|
dir: resolved,
|
|
origin: params.origin,
|
|
rejectHardlinks,
|
|
...(resolvedRealPath !== undefined ? { rootRealPath: resolvedRealPath } : {}),
|
|
packageManifestCache: params.packageManifestCache,
|
|
});
|
|
if (
|
|
shouldSkipIncompatiblePackagePluginApi({
|
|
origin: params.origin,
|
|
manifest,
|
|
packageDir: resolved,
|
|
env: params.env,
|
|
diagnostics: params.diagnostics,
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined);
|
|
if (
|
|
pushInvalidPackageExtensionDiagnostic({
|
|
resolution: extensionResolution,
|
|
source: resolved,
|
|
diagnostics: params.diagnostics,
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : [];
|
|
const candidateManifest = resolveCandidateManifest(resolved, rejectHardlinks, resolvedRealPath);
|
|
const manifestId = candidateManifest?.manifest.id;
|
|
const setupSource = resolvePackageSetupSource({
|
|
packageDir: resolved,
|
|
...(resolvedRealPath !== undefined ? { packageRootRealPath: resolvedRealPath } : {}),
|
|
manifest,
|
|
origin: params.origin,
|
|
requireBuiltRuntimeEntry,
|
|
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 }),
|
|
requireBuiltRuntimeEntry,
|
|
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,
|
|
requiredPluginIds: candidateManifest?.manifest.requiresPlugins,
|
|
requiredPluginSource: candidateManifest?.manifestPath,
|
|
realpathCache: params.realpathCache,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
const bundleDiscovery = discoverBundleInRoot({
|
|
rootDir: resolved,
|
|
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,
|
|
});
|
|
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,
|
|
requiredPluginIds: candidateManifest?.manifest.requiresPlugins,
|
|
requiredPluginSource: candidateManifest?.manifestPath,
|
|
realpathCache: params.realpathCache,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (
|
|
addLegacyNpmDeclarationDiagnostic({
|
|
pluginDir: resolved,
|
|
diagnostics: params.diagnostics,
|
|
})
|
|
) {
|
|
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;
|
|
}
|
|
const bundledAlias = resolvePackagedBundledLoadPathAlias({
|
|
bundledRoot: params.bundledRoot,
|
|
loadPath: resolveUserPath(trimmed, params.env),
|
|
});
|
|
if (bundledAlias) {
|
|
params.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: 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;
|
|
}): 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 = 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,
|
|
});
|
|
}
|
|
if (roots.stock) {
|
|
discoverInDirectory({
|
|
dir: roots.stock,
|
|
origin: "bundled",
|
|
env,
|
|
ownershipUid: params.ownershipUid,
|
|
candidates: result.candidates,
|
|
diagnostics: result.diagnostics,
|
|
seen,
|
|
realpathCache,
|
|
packageManifestCache,
|
|
});
|
|
}
|
|
const sourceCheckoutExtensionsDir = resolveBundledSourceCheckoutExtensionsDir(roots.stock);
|
|
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),
|
|
});
|
|
}
|
|
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);
|
|
return result;
|
|
}
|