Files
openclaw/src/plugins/loader-runtime-candidate.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

594 lines
21 KiB
TypeScript

import fs from "node:fs";
import { openRootFileSync } from "../infra/boundary-file-read.js";
import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js";
import {
resolveEffectiveEnableState,
resolveEffectivePluginActivationState,
resolveMemorySlotDecision,
} from "./config-state.js";
import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js";
import type { PluginCandidate } from "./discovery.js";
import { shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js";
import { loadSetupRuntimeChannelCandidate } from "./loader-channel-runtime.js";
import type { PluginLoadCacheContext } from "./loader-load-context.js";
import {
formatBundledChannelWrongLoaderError,
type PluginModuleLoader,
resolvePluginModuleExport,
runPluginRegisterSync,
} from "./loader-module-runtime.js";
import {
formatAutoEnabledActivationReason,
formatMissingPluginRegisterError,
markPluginActivationDisabled,
recordPluginError,
} from "./loader-records.js";
import { resolvePluginRegistrationPlan } from "./loader-registration-plan.js";
import {
applyManifestSnapshotMetadata,
applyPluginManifestRecordDetails,
type AuthorizedDreamingSidecar,
createManifestPluginRecord,
detailPluginStartupTrace,
isAuthorizedDreamingSidecarPlugin,
matchesScopedPluginOrDreamingSidecar,
pushPluginValidationError,
safeRealpathOrResolve,
validatePluginConfig,
} from "./loader-shared.js";
import type { PluginLoadOptions } from "./loader-types.js";
import {
hasExplicitManifestOwnerTrust,
resolveManifestOwnerBasePolicyBlock,
} from "./manifest-owner-policy.js";
import type { PluginManifestRecord } from "./manifest-registry.js";
import { withProfile } from "./plugin-load-profile.js";
import { createPluginRegistrationTransaction } from "./plugin-registration-transaction.js";
import {
resolveCanonicalDistRuntimeSource,
resolvePluginRuntimeArtifact,
} from "./plugin-runtime-artifact-resolution.js";
import type { createPluginRegistry, PluginRecord } from "./registry.js";
import { recordImportedPluginId } from "./runtime.js";
import { hasKind, kindsEqual } from "./slots.js";
import type { OpenClawPluginModule, PluginLogger } from "./types.js";
type PluginRegistryBuilder = ReturnType<typeof createPluginRegistry>;
export type PluginLoadLoopState = {
seenIds: Map<string, PluginRecord["origin"]>;
selectedMemoryPluginId: string | null;
memorySlotMatched: boolean;
pluginLoadAttemptCount: number;
};
export function loadRuntimePluginCandidate(params: {
candidate: PluginCandidate;
manifestRecord: PluginManifestRecord;
context: PluginLoadCacheContext;
options: PluginLoadOptions;
onlyPluginIdSet: ReadonlySet<string> | null;
dreamingSidecar: AuthorizedDreamingSidecar | null;
validateOnly: boolean;
registryBuilder: PluginRegistryBuilder;
loadPluginModule: PluginModuleLoader;
logger: PluginLogger;
state: PluginLoadLoopState;
}): void {
const { candidate, manifestRecord, context, state } = params;
const { registry } = params.registryBuilder;
const pluginId = manifestRecord.id;
// Manifest filtering scopes diagnostics; this final guard also blocks imports
// and registration outside the requested snapshot.
if (
!matchesScopedPluginOrDreamingSidecar({
onlyPluginIdSet: params.onlyPluginIdSet,
pluginId,
sidecar: params.dreamingSidecar,
})
) {
return;
}
const isDreamingSidecar = isAuthorizedDreamingSidecarPlugin({
sidecar: params.dreamingSidecar,
pluginId,
});
const activationState = isDreamingSidecar
? {
enabled: true,
activated: true,
explicitlyEnabled: false,
source: "auto" as const,
reason: `dreaming sidecar for selected memory slot "${params.dreamingSidecar?.selectedMemoryPluginId ?? ""}"`,
}
: resolveEffectivePluginActivationState({
id: pluginId,
origin: candidate.origin,
config: context.normalized,
rootConfig: context.cfg,
enabledByDefault: isPluginEnabledByDefaultForPlatform(manifestRecord),
activationSource: context.activationSource,
autoEnabledReason: formatAutoEnabledActivationReason(context.autoEnabledReasons[pluginId]),
});
const existingOrigin = state.seenIds.get(pluginId);
if (existingOrigin) {
const duplicate = createManifestPluginRecord({
candidate,
manifestRecord,
enabled: false,
activationState,
});
duplicate.status = "disabled";
duplicate.error = `overridden by ${existingOrigin} plugin`;
markPluginActivationDisabled(duplicate, duplicate.error);
registry.plugins.push(duplicate);
return;
}
const enableState = isDreamingSidecar
? { enabled: true }
: resolveEffectiveEnableState({
id: pluginId,
origin: candidate.origin,
config: context.normalized,
rootConfig: context.cfg,
enabledByDefault: isPluginEnabledByDefaultForPlatform(manifestRecord),
activationSource: context.activationSource,
});
const entry = context.normalized.entries[pluginId];
const record = createManifestPluginRecord({
candidate,
manifestRecord,
enabled: enableState.enabled,
activationState,
});
applyPluginManifestRecordDetails(record, manifestRecord);
const localSetupBasePolicyBlock = resolveManifestOwnerBasePolicyBlock({
plugin: { id: pluginId },
normalizedConfig: context.normalized,
});
const trustedLocalScopedChannelSetupImport =
localSetupBasePolicyBlock === null &&
(hasExplicitManifestOwnerTrust({
plugin: { id: pluginId },
normalizedConfig: context.normalized,
}) ||
(candidate.origin === "workspace" && activationState.source === "auto"));
// Setup-only loads bypass normal activation, so reapply trust before importing
// non-bundled local plugins.
const blockUntrustedLocalScopedChannelSetupImport =
context.includeSetupOnlyChannelPlugins &&
!params.validateOnly &&
Boolean(params.onlyPluginIdSet) &&
manifestRecord.channels.length > 0 &&
candidate.origin !== "bundled" &&
!trustedLocalScopedChannelSetupImport;
const pushPluginLoadError = (message: string) =>
pushPluginValidationError({
registry,
seenIds: state.seenIds,
pluginId,
origin: candidate.origin,
record,
message,
});
if (blockUntrustedLocalScopedChannelSetupImport) {
record.status = "disabled";
record.error =
activationState.reason ??
enableState.reason ??
"local plugin requires explicit trust for setup";
markPluginActivationDisabled(record, record.error);
// Do not claim seenIds: a different-id trusted fallback may still load later.
registry.plugins.push(record);
return;
}
const pluginRoot = safeRealpathOrResolve(candidate.rootDir);
const runtimeCandidateEntry = resolvePluginRuntimeArtifact({
pluginId,
entryKind: "runtime",
source: candidate.source,
rootDir: pluginRoot,
origin: candidate.origin,
preferBuiltPluginArtifacts: context.preferBuiltPluginArtifacts,
packageManifest: candidate.packageManifest,
});
const runtimeSetupEntry = manifestRecord.setupSource
? resolvePluginRuntimeArtifact({
pluginId,
entryKind: "setup",
source: manifestRecord.setupSource,
rootDir: pluginRoot,
origin: candidate.origin,
preferBuiltPluginArtifacts: context.preferBuiltPluginArtifacts,
packageManifest: candidate.packageManifest,
})
: undefined;
const scopedSetupOnlyChannelPluginRequested =
context.includeSetupOnlyChannelPlugins &&
!params.validateOnly &&
Boolean(params.onlyPluginIdSet) &&
manifestRecord.channels.length > 0 &&
(!enableState.enabled || context.forceSetupOnlyChannelPlugins);
const canLoadScopedSetupOnlyChannelPlugin =
scopedSetupOnlyChannelPluginRequested &&
(candidate.origin !== "workspace" || enableState.enabled) &&
(!context.requireSetupEntryForSetupOnlyChannelPlugins || Boolean(manifestRecord.setupSource));
const registrationPlan = resolvePluginRegistrationPlan({
canLoadScopedSetupOnlyChannelPlugin,
scopedSetupOnlyChannelPluginRequested,
requireSetupEntryForSetupOnlyChannelPlugins:
context.requireSetupEntryForSetupOnlyChannelPlugins,
enableStateEnabled: enableState.enabled,
shouldLoadModules: context.shouldLoadModules,
validateOnly: params.validateOnly,
shouldActivate: context.shouldActivate,
manifestRecord,
cfg: context.cfg,
env: context.env,
preferSetupRuntimeForChannelPlugins: context.forceFullRuntimeForChannelPlugins
? false
: context.preferSetupRuntimeForChannelPlugins,
forceFullRuntimeForChannelPlugins: context.forceFullRuntimeForChannelPlugins,
toolDiscovery: params.options.toolDiscovery === true,
});
if (!registrationPlan) {
record.status = "disabled";
record.error = enableState.reason;
markPluginActivationDisabled(record, enableState.reason);
registry.plugins.push(record);
state.seenIds.set(pluginId, candidate.origin);
return;
}
if (!enableState.enabled) {
record.status = "disabled";
record.error = enableState.reason;
markPluginActivationDisabled(record, enableState.reason);
}
if (record.format === "bundle") {
recordBundleDiagnostics({ record, registry });
state.seenIds.set(pluginId, candidate.origin);
return;
}
const memorySlot = context.normalized.slots.memory;
if (
registrationPlan.runRuntimeCapabilityPolicy &&
candidate.origin === "bundled" &&
hasKind(manifestRecord.kind, "memory") &&
!isDreamingSidecar
) {
// Skip bundled memory modules already disabled by slot policy. The authorized
// dreaming sidecar remains loadable alongside the selected memory plugin.
const earlyMemoryDecision = resolveMemorySlotDecision({
id: record.id,
kind: manifestRecord.kind,
slot: memorySlot,
selectedId: state.selectedMemoryPluginId,
});
if (!earlyMemoryDecision.enabled) {
record.enabled = false;
record.status = "disabled";
record.error = earlyMemoryDecision.reason;
markPluginActivationDisabled(record, earlyMemoryDecision.reason);
registry.plugins.push(record);
state.seenIds.set(pluginId, candidate.origin);
return;
}
}
if (!manifestRecord.configSchema) {
pushPluginLoadError("missing config schema");
return;
}
if (!context.shouldLoadModules && registrationPlan.runRuntimeCapabilityPolicy) {
const memoryDecision = resolveMemorySlotDecision({
id: record.id,
kind: record.kind,
slot: memorySlot,
selectedId: state.selectedMemoryPluginId,
});
if (!memoryDecision.enabled && !isDreamingSidecar) {
record.enabled = false;
record.status = "disabled";
record.error = memoryDecision.reason;
markPluginActivationDisabled(record, memoryDecision.reason);
registry.plugins.push(record);
state.seenIds.set(pluginId, candidate.origin);
return;
}
if (memoryDecision.selected && hasKind(record.kind, "memory")) {
state.selectedMemoryPluginId = record.id;
state.memorySlotMatched = true;
record.memorySlotSelected = true;
}
}
const validatedConfig = validatePluginConfig({
schema: manifestRecord.configSchema,
cacheKey: manifestRecord.schemaCacheKey,
value: entry?.config,
});
if (!validatedConfig.ok) {
params.logger.error(
`[plugins] ${record.id} invalid config: ${validatedConfig.error.join(", ")}`,
);
pushPluginLoadError(`invalid config: ${validatedConfig.error.join(", ")}`);
return;
}
if (!context.shouldLoadModules) {
applyManifestSnapshotMetadata(record, manifestRecord);
registry.plugins.push(record);
state.seenIds.set(pluginId, candidate.origin);
return;
}
const loadEntry =
registrationPlan.loadSetupEntry && runtimeSetupEntry
? runtimeSetupEntry
: runtimeCandidateEntry;
const moduleLoadSource = resolveCanonicalDistRuntimeSource(loadEntry.source);
const moduleRoot = resolveCanonicalDistRuntimeSource(loadEntry.rootDir);
const rejectHardlinks = shouldRejectHardlinkedPluginFiles({
origin: candidate.origin,
rootDir: candidate.rootDir,
env: context.env,
});
const opened = openRootFileSync({
absolutePath: moduleLoadSource,
rootPath: moduleRoot,
boundaryLabel: "plugin root",
rejectHardlinks,
skipLexicalRootCheck: true,
});
if (!opened.ok) {
pushPluginLoadError("plugin entry path escapes plugin root or fails alias checks");
return;
}
const safeSource = opened.path;
fs.closeSync(opened.fd);
let mod: OpenClawPluginModule | null = null;
let moduleLoadMs: number;
let moduleLoadFailed = false;
const beforeModuleLoad = performance.now();
try {
// Top-level code may execute before module evaluation throws, so record the
// import attempt before invoking the loader.
recordImportedPluginId(record.id);
state.pluginLoadAttemptCount++;
params.logger.debug?.(`[plugins] loading ${record.id} from ${safeSource}`);
mod = withProfile(
{ pluginId: record.id, source: safeSource },
registrationPlan.mode,
() => params.loadPluginModule(safeSource) as OpenClawPluginModule,
);
} catch (error) {
recordPluginError({
logger: params.logger,
registry,
record,
seenIds: state.seenIds,
pluginId,
origin: candidate.origin,
phase: "load",
error,
logPrefix: `[plugins] ${record.id} failed to load from ${record.source}: `,
diagnosticMessagePrefix: "failed to load plugin: ",
});
moduleLoadFailed = true;
return;
} finally {
moduleLoadMs = performance.now() - beforeModuleLoad;
detailPluginStartupTrace(params.options.startupTrace, record.id, [
["loadMs", moduleLoadMs],
["loadFailedCount", moduleLoadFailed ? 1 : 0],
]);
}
if (
loadSetupRuntimeChannelCandidate({
mod,
manifestRecord,
record,
registrationPlan,
runtimeCandidateEntry,
safeSource,
rejectHardlinks,
loadPluginModule: params.loadPluginModule,
registryBuilder: params.registryBuilder,
cfg: context.cfg,
entry,
env: context.env,
preferSetupRuntimeForChannelPlugins: context.preferSetupRuntimeForChannelPlugins,
seenIds: state.seenIds,
candidateOrigin: candidate.origin,
logger: params.logger,
pushPluginLoadError,
})
) {
return;
}
const resolved = resolvePluginModuleExport(mod);
const { definition, register } = resolved;
if (definition?.id && definition.id !== record.id) {
pushPluginLoadError(
`plugin id mismatch (config uses "${record.id}", export uses "${definition.id}")`,
);
return;
}
record.name = definition?.name ?? record.name;
record.description = definition?.description ?? record.description;
record.version = definition?.version ?? record.version;
const manifestKind = record.kind;
const exportKind = definition?.kind;
if (manifestKind && exportKind && !kindsEqual(manifestKind, exportKind)) {
registry.diagnostics.push({
level: "warn",
pluginId: record.id,
source: record.source,
message: `plugin kind mismatch (manifest uses "${String(manifestKind)}", export uses "${String(exportKind)}")`,
});
}
record.kind = definition?.kind ?? record.kind;
if (hasKind(record.kind, "memory") && memorySlot === record.id) {
state.memorySlotMatched = true;
}
if (registrationPlan.runRuntimeCapabilityPolicy && !isDreamingSidecar) {
const memoryDecision = resolveMemorySlotDecision({
id: record.id,
kind: record.kind,
slot: memorySlot,
selectedId: state.selectedMemoryPluginId,
});
if (!memoryDecision.enabled) {
record.enabled = false;
record.status = "disabled";
record.error = memoryDecision.reason;
markPluginActivationDisabled(record, memoryDecision.reason);
registry.plugins.push(record);
state.seenIds.set(pluginId, candidate.origin);
return;
}
if (memoryDecision.selected && hasKind(record.kind, "memory")) {
state.selectedMemoryPluginId = record.id;
record.memorySlotSelected = true;
}
}
if (registrationPlan.runFullActivationOnlyRegistrations) {
if (definition?.reload) {
params.registryBuilder.registerReload(record, definition.reload);
}
for (const nodeHostCommand of definition?.nodeHostCommands ?? []) {
params.registryBuilder.registerNodeHostCommand(record, nodeHostCommand);
}
for (const collector of definition?.securityAuditCollectors ?? []) {
params.registryBuilder.registerSecurityAuditCollector(record, collector);
}
}
if (params.validateOnly) {
registry.plugins.push(record);
state.seenIds.set(pluginId, candidate.origin);
return;
}
if (typeof register !== "function") {
const wrongLoaderError = formatBundledChannelWrongLoaderError(record.kind);
if (wrongLoaderError) {
params.logger.error(
`[plugins] ${record.id} ${wrongLoaderError}; ensure plugin is loaded via bundled channel discovery, not legacy plugin loader`,
);
pushPluginLoadError(wrongLoaderError);
} else {
params.logger.error(`[plugins] ${record.id} missing register/activate export`);
pushPluginLoadError(formatMissingPluginRegisterError(mod, context.env));
}
return;
}
const api = params.registryBuilder.createApi(record, {
config: context.cfg,
pluginConfig: validatedConfig.value,
hookPolicy: entry?.hooks,
registrationMode: registrationPlan.mode,
});
const transaction = createPluginRegistrationTransaction({
registry,
rollbackGlobalSideEffects: () =>
params.registryBuilder.rollbackPluginGlobalSideEffects(record.id),
});
const beforeRegister = performance.now();
let registerFailed = false;
try {
withProfile(
{ pluginId: record.id, source: record.source },
`${registrationPlan.mode}:register`,
() => runPluginRegisterSync(register, api),
);
registry.plugins.push(record);
state.seenIds.set(pluginId, candidate.origin);
transaction.commit({ activate: context.shouldActivate });
} catch (error) {
transaction.rollback();
recordPluginError({
logger: params.logger,
registry,
record,
seenIds: state.seenIds,
pluginId,
origin: candidate.origin,
phase: "register",
error,
logPrefix: `[plugins] ${record.id} failed during register from ${record.source}: `,
diagnosticMessagePrefix: "plugin failed during register: ",
});
registerFailed = true;
} finally {
const registerMs = performance.now() - beforeRegister;
detailPluginStartupTrace(params.options.startupTrace, record.id, [
["registerMs", registerMs],
["loadAndRegisterMs", moduleLoadMs + registerMs],
["registerFailedCount", registerFailed ? 1 : 0],
]);
}
}
function recordBundleDiagnostics(params: {
record: PluginRecord;
registry: PluginRegistryBuilder["registry"];
}): void {
const unsupportedCapabilities = (params.record.bundleCapabilities ?? []).filter(
(capability) =>
capability !== "skills" &&
capability !== "mcpServers" &&
capability !== "settings" &&
!(
(capability === "commands" ||
capability === "agents" ||
capability === "outputStyles" ||
capability === "lspServers") &&
(params.record.bundleFormat === "claude" || params.record.bundleFormat === "cursor")
) &&
!(
capability === "hooks" &&
(params.record.bundleFormat === "codex" || params.record.bundleFormat === "claude")
),
);
for (const capability of unsupportedCapabilities) {
params.registry.diagnostics.push({
level: "warn",
pluginId: params.record.id,
source: params.record.source,
message: `bundle capability detected but not wired into OpenClaw yet: ${capability}`,
});
}
if (
params.record.enabled &&
params.record.rootDir &&
params.record.bundleFormat &&
(params.record.bundleCapabilities ?? []).includes("mcpServers")
) {
const runtimeSupport = inspectBundleMcpRuntimeSupport({
pluginId: params.record.id,
rootDir: params.record.rootDir,
bundleFormat: params.record.bundleFormat,
});
for (const message of runtimeSupport.diagnostics) {
params.registry.diagnostics.push({
level: "warn",
pluginId: params.record.id,
source: params.record.source,
message,
});
}
if (runtimeSupport.unsupportedServerNames.length > 0) {
params.registry.diagnostics.push({
level: "warn",
pluginId: params.record.id,
source: params.record.source,
message:
"bundle MCP servers use unsupported transports or incomplete configs " +
`(stdio only today): ${runtimeSupport.unsupportedServerNames.join(", ")}`,
});
}
}
params.registry.plugins.push(params.record);
}