Files
openclaw/src/plugins/plugin-runtime-artifact-resolution.ts
Peter Steinberger 9ff855db05 fix(plugins): canonicalize plugin entry path per process to prevent duplicate register() (#108508)
On git-channel installs where both extensions/<id>/index.ts and
dist/extensions/<id>/index.js exist, two registry assemblies resolved the same
plugin id to different physical files (preferBuiltPluginArtifacts drives both the
artifact choice and the load-cache key), so the path-keyed module cache
evaluated the entry twice — two live plugin instances with separate state,
binding hooks to one and registerTool to the other. Memoize the first resolved
runtime entry path per plugin-id + real root + entry-kind so both assemblies
converge on one module instance; clear the memo on activated-runtime-state and
registry-load-cache resets so reload/install/doctor re-resolve. No-op on
dist-only installs.

Closes #107933
2026-07-15 21:58:13 -07:00

195 lines
6.8 KiB
TypeScript

/** Resolves the exact root and entry selected by the plugin runtime loader. */
import fs from "node:fs";
import path from "node:path";
import type { OpenClawPackageManifest } from "./manifest.js";
import type { PluginOrigin } from "./plugin-origin.types.js";
type ResolvedPluginRuntimeArtifact = { source: string; rootDir: string };
type PluginRuntimeArtifactEntryKind = "runtime" | "setup";
// Pin one physical path per plugin id and logical entry for this runtime lifecycle.
// Registry surfaces may disagree on artifact preference, but hooks and tools must
// share one evaluated module instance so register() runs once.
const resolvedPluginRuntimeArtifacts = new Map<string, ResolvedPluginRuntimeArtifact>();
function safeRealpathOrResolve(value: string): string {
try {
return fs.realpathSync(value);
} catch {
return path.resolve(value);
}
}
export function clearPluginRuntimeArtifactResolutionMemo(): void {
resolvedPluginRuntimeArtifacts.clear();
}
/** Canonical packaged runtime replaces staging-only dist-runtime artifacts. */
export function resolveCanonicalDistRuntimeSource(source: string): string {
const marker = `${path.sep}dist-runtime${path.sep}extensions${path.sep}`;
const index = source.indexOf(marker);
if (index === -1) {
return source;
}
const candidate = `${source.slice(0, index)}${path.sep}dist${path.sep}extensions${path.sep}${source.slice(index + marker.length)}`;
return fs.existsSync(candidate) ? candidate : source;
}
function rewriteBundledRuntimeArtifactRelativePath(relativePath: string): string {
return relativePath.replace(/\.[^.]+$/u, ".js");
}
function listPackageLocalRuntimeArtifactOutputExtensions(sourceExt: string): string[] {
switch (sourceExt) {
case ".mts":
case ".mjs":
return [".mjs", ".js", ".cjs"];
case ".cts":
case ".cjs":
return [".cjs", ".js", ".mjs"];
default:
return [".js", ".mjs", ".cjs"];
}
}
function listPackageLocalRuntimeArtifactRelativePathBases(relativePath: string): string[] {
const ext = path.extname(relativePath).toLowerCase();
const withoutExt = ext ? relativePath.slice(0, -ext.length) : relativePath;
if (!withoutExt.startsWith(`src${path.sep}`) && !withoutExt.startsWith("src/")) {
return [withoutExt];
}
return [withoutExt.slice(4), withoutExt];
}
function listPackageLocalDistRuntimeArtifactRelativePaths(relativePath: string): string[] {
const ext = path.extname(relativePath).toLowerCase();
const candidates = new Set<string>();
for (const base of listPackageLocalRuntimeArtifactRelativePathBases(relativePath)) {
for (const outputExt of listPackageLocalRuntimeArtifactOutputExtensions(ext)) {
candidates.add(`${base}${outputExt}`);
}
}
return [...candidates];
}
function shouldPreferPackageLocalDistRuntimeArtifact(source: string): boolean {
switch (path.extname(source).toLowerCase()) {
case ".ts":
case ".tsx":
case ".mts":
case ".cts":
return true;
default:
return false;
}
}
function resolvePackageLocalDistRuntimeArtifact(params: {
source: string;
rootDir: string;
}): string | null {
const relativeSource = path.relative(params.rootDir, params.source);
if (
!shouldPreferPackageLocalDistRuntimeArtifact(relativeSource) ||
relativeSource === "" ||
relativeSource.startsWith("..") ||
path.isAbsolute(relativeSource)
) {
return null;
}
const artifactRoot = path.join(params.rootDir, "dist");
for (const artifactRelativePath of listPackageLocalDistRuntimeArtifactRelativePaths(
relativeSource,
)) {
const artifactSource = path.join(artifactRoot, artifactRelativePath);
if (fs.existsSync(artifactSource)) {
return safeRealpathOrResolve(artifactSource);
}
}
return null;
}
function resolvePreferredBuiltRuntimeArtifact(params: {
source: string;
rootDir: string;
origin: PluginOrigin;
preferBuiltPluginArtifacts: boolean;
packageManifest?: OpenClawPackageManifest;
}): { source: string; rootDir: string } {
const rootDir = safeRealpathOrResolve(params.rootDir);
const source = safeRealpathOrResolve(params.source);
if (!params.preferBuiltPluginArtifacts) {
return { source, rootDir };
}
if (params.origin !== "bundled") {
const artifactSource = resolvePackageLocalDistRuntimeArtifact({ source, rootDir });
if (artifactSource) {
return { source: artifactSource, rootDir };
}
return { source, rootDir };
}
if (params.packageManifest?.build?.bundledDist === false) {
return { source, rootDir };
}
const packageLocalArtifactSource = resolvePackageLocalDistRuntimeArtifact({ source, rootDir });
if (packageLocalArtifactSource) {
return { source: packageLocalArtifactSource, rootDir };
}
const extensionsDir = path.dirname(rootDir);
if (path.basename(extensionsDir) !== "extensions") {
return { source, rootDir };
}
const packageRoot = path.dirname(extensionsDir);
if (path.basename(packageRoot) === "dist" || path.basename(packageRoot) === "dist-runtime") {
return { source, rootDir };
}
const relativeSource = path.relative(rootDir, source);
if (relativeSource === "" || relativeSource.startsWith("..") || path.isAbsolute(relativeSource)) {
return { source, rootDir };
}
const artifactRelativePath = rewriteBundledRuntimeArtifactRelativePath(relativeSource);
for (const artifactRootName of ["dist-runtime", "dist"] as const) {
const artifactRoot = path.join(
packageRoot,
artifactRootName,
"extensions",
path.basename(rootDir),
);
const artifactSource = path.join(artifactRoot, artifactRelativePath);
if (fs.existsSync(artifactSource)) {
return {
source: safeRealpathOrResolve(artifactSource),
rootDir: safeRealpathOrResolve(artifactRoot),
};
}
}
return { source, rootDir };
}
/** Applies both loader selection phases in their runtime order. */
export function resolvePluginRuntimeArtifact(params: {
pluginId: string;
entryKind: PluginRuntimeArtifactEntryKind;
source: string;
rootDir: string;
origin: PluginOrigin;
preferBuiltPluginArtifacts: boolean;
packageManifest?: OpenClawPackageManifest;
}): { source: string; rootDir: string } {
const rootDir = resolveCanonicalDistRuntimeSource(safeRealpathOrResolve(params.rootDir));
const source = resolveCanonicalDistRuntimeSource(safeRealpathOrResolve(params.source));
const memoKey = JSON.stringify([params.pluginId, rootDir, params.entryKind]);
const cached = resolvedPluginRuntimeArtifacts.get(memoKey);
if (cached) {
return { ...cached };
}
const preferred = resolvePreferredBuiltRuntimeArtifact({ ...params, source, rootDir });
const resolved = {
source: resolveCanonicalDistRuntimeSource(preferred.source),
rootDir: resolveCanonicalDistRuntimeSource(preferred.rootDir),
};
resolvedPluginRuntimeArtifacts.set(memoKey, resolved);
return { ...resolved };
}