mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 20:11:37 +00:00
362 lines
12 KiB
TypeScript
362 lines
12 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
|
|
import { openRootFileSync } from "../infra/boundary-file-read.js";
|
|
import { resolveUserPath } from "../utils.js";
|
|
import { buildPluginApi } from "./api-builder.js";
|
|
import {
|
|
resolveEffectiveEnableState,
|
|
resolveEffectivePluginActivationState,
|
|
resolveMemorySlotDecision,
|
|
} from "./config-state.js";
|
|
import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js";
|
|
import { shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js";
|
|
import { resolvePluginLoadDiscovery } from "./loader-discovery.js";
|
|
import { resolvePluginLoadCacheContext } from "./loader-load-context.js";
|
|
import {
|
|
createPluginModuleLoader,
|
|
formatBundledChannelWrongLoaderError,
|
|
resolvePluginModuleExport,
|
|
runPluginRegisterSync,
|
|
} from "./loader-module-runtime.js";
|
|
import {
|
|
formatAutoEnabledActivationReason,
|
|
formatMissingPluginRegisterError,
|
|
markPluginActivationDisabled,
|
|
recordPluginError,
|
|
} from "./loader-records.js";
|
|
import {
|
|
applyPluginManifestRecordDetails,
|
|
createManifestPluginRecord,
|
|
createPluginLoaderLogger,
|
|
isAuthorizedDreamingSidecarPlugin,
|
|
matchesScopedPluginOrDreamingSidecar,
|
|
pushPluginValidationError,
|
|
resolveAuthorizedDreamingSidecar,
|
|
safeRealpathOrResolve,
|
|
validatePluginConfig,
|
|
} from "./loader-shared.js";
|
|
import type { PluginLoadOptions } from "./loader-types.js";
|
|
import { withProfile } from "./plugin-load-profile.js";
|
|
import { normalizePluginPolicyId } from "./plugin-policy-id.js";
|
|
import { createPluginRegistrationTransaction } from "./plugin-registration-transaction.js";
|
|
import { createPluginIdScopeSet } from "./plugin-scope.js";
|
|
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
|
|
import type { PluginRuntime } from "./runtime/types.js";
|
|
import { hasKind, kindsEqual } from "./slots.js";
|
|
import type { OpenClawPluginModule } from "./types.js";
|
|
|
|
const CLI_METADATA_ENTRY_BASENAMES = [
|
|
"cli-metadata.ts",
|
|
"cli-metadata.js",
|
|
"cli-metadata.mjs",
|
|
"cli-metadata.cjs",
|
|
] as const;
|
|
|
|
export async function loadOpenClawPluginCliRegistry(
|
|
options: PluginLoadOptions = {},
|
|
): Promise<PluginRegistry> {
|
|
const context = resolvePluginLoadCacheContext({ ...options, activate: false });
|
|
const logger = options.logger ?? createPluginLoaderLogger();
|
|
const onlyPluginIdSet = createPluginIdScopeSet(context.onlyPluginIds);
|
|
const loadPluginModule = createPluginModuleLoader({
|
|
devSourceRoot: context.devSourceRoot,
|
|
pluginSdkResolution: options.pluginSdkResolution,
|
|
});
|
|
const { registry, registerCli } = createPluginRegistry({
|
|
logger,
|
|
runtime: {} as PluginRuntime,
|
|
coreGatewayHandlers: options.coreGatewayHandlers as Record<string, GatewayRequestHandler>,
|
|
...(options.coreGatewayMethodNames !== undefined && {
|
|
coreGatewayMethodNames: options.coreGatewayMethodNames,
|
|
}),
|
|
activateGlobalSideEffects: false,
|
|
});
|
|
const { manifestRegistry, orderedCandidates, manifestBySource } = resolvePluginLoadDiscovery({
|
|
options,
|
|
context,
|
|
diagnostics: registry.diagnostics,
|
|
logger,
|
|
onlyPluginIdSet,
|
|
emitWarning: false,
|
|
warningCacheKey: `${context.cacheKey}::cli-metadata`,
|
|
});
|
|
const seenIds = new Map<string, PluginRecord["origin"]>();
|
|
const memorySlot = context.normalized.slots.memory;
|
|
let selectedMemoryPluginId: string | null = null;
|
|
const dreamingSidecar = resolveAuthorizedDreamingSidecar({
|
|
cfg: context.cfg,
|
|
normalized: context.normalized,
|
|
activationSource: context.activationSource,
|
|
manifestRegistry,
|
|
memorySlot,
|
|
});
|
|
|
|
for (const candidate of orderedCandidates) {
|
|
const manifestRecord = manifestBySource.get(candidate.source);
|
|
if (!manifestRecord) {
|
|
continue;
|
|
}
|
|
const pluginId = manifestRecord.id;
|
|
const policyId = normalizePluginPolicyId(pluginId);
|
|
if (
|
|
!matchesScopedPluginOrDreamingSidecar({
|
|
onlyPluginIdSet,
|
|
pluginId,
|
|
sidecar: dreamingSidecar,
|
|
})
|
|
) {
|
|
continue;
|
|
}
|
|
const isDreamingSidecar = isAuthorizedDreamingSidecarPlugin({
|
|
sidecar: dreamingSidecar,
|
|
pluginId,
|
|
});
|
|
const activationState = isDreamingSidecar
|
|
? {
|
|
enabled: true,
|
|
activated: true,
|
|
explicitlyEnabled: false,
|
|
source: "auto" as const,
|
|
reason: `dreaming sidecar for selected memory slot "${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 = 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);
|
|
continue;
|
|
}
|
|
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[policyId];
|
|
const record = createManifestPluginRecord({
|
|
candidate,
|
|
manifestRecord,
|
|
enabled: enableState.enabled,
|
|
activationState,
|
|
});
|
|
applyPluginManifestRecordDetails(record, manifestRecord);
|
|
const pushPluginLoadError = (message: string) =>
|
|
pushPluginValidationError({
|
|
registry,
|
|
seenIds,
|
|
pluginId,
|
|
origin: candidate.origin,
|
|
record,
|
|
message,
|
|
});
|
|
if (!enableState.enabled) {
|
|
record.status = "disabled";
|
|
record.error = enableState.reason;
|
|
markPluginActivationDisabled(record, enableState.reason);
|
|
registry.plugins.push(record);
|
|
seenIds.set(pluginId, candidate.origin);
|
|
continue;
|
|
}
|
|
if (record.format === "bundle") {
|
|
registry.plugins.push(record);
|
|
seenIds.set(pluginId, candidate.origin);
|
|
continue;
|
|
}
|
|
if (!manifestRecord.configSchema) {
|
|
pushPluginLoadError("missing config schema");
|
|
continue;
|
|
}
|
|
const validatedConfig = validatePluginConfig({
|
|
schema: manifestRecord.configSchema,
|
|
cacheKey: manifestRecord.schemaCacheKey,
|
|
value: entry?.config,
|
|
});
|
|
if (!validatedConfig.ok) {
|
|
logger.error(`[plugins] ${record.id} invalid config: ${validatedConfig.error.join(", ")}`);
|
|
pushPluginLoadError(`invalid config: ${validatedConfig.error.join(", ")}`);
|
|
continue;
|
|
}
|
|
const cliMetadataSource = resolveCliMetadataEntrySource(candidate.rootDir);
|
|
const sourceForCliMetadata =
|
|
candidate.origin === "bundled"
|
|
? cliMetadataSource
|
|
? safeRealpathOrResolve(cliMetadataSource)
|
|
: null
|
|
: (cliMetadataSource ?? candidate.source);
|
|
if (!sourceForCliMetadata) {
|
|
record.status = "loaded";
|
|
registry.plugins.push(record);
|
|
seenIds.set(pluginId, candidate.origin);
|
|
continue;
|
|
}
|
|
const opened = openRootFileSync({
|
|
absolutePath: sourceForCliMetadata,
|
|
rootPath: safeRealpathOrResolve(candidate.rootDir),
|
|
boundaryLabel: "plugin root",
|
|
rejectHardlinks: shouldRejectHardlinkedPluginFiles({
|
|
origin: candidate.origin,
|
|
rootDir: candidate.rootDir,
|
|
env: context.env,
|
|
}),
|
|
skipLexicalRootCheck: true,
|
|
});
|
|
if (!opened.ok) {
|
|
pushPluginLoadError("plugin entry path escapes plugin root or fails alias checks");
|
|
continue;
|
|
}
|
|
const safeSource = opened.path;
|
|
fs.closeSync(opened.fd);
|
|
let mod: OpenClawPluginModule | null;
|
|
try {
|
|
mod = withProfile(
|
|
{ pluginId: record.id, source: safeSource },
|
|
"cli-metadata",
|
|
() => loadPluginModule(safeSource) as OpenClawPluginModule,
|
|
);
|
|
} catch (error) {
|
|
recordPluginError({
|
|
logger,
|
|
registry,
|
|
record,
|
|
seenIds,
|
|
pluginId,
|
|
origin: candidate.origin,
|
|
phase: "load",
|
|
error,
|
|
logPrefix: `[plugins] ${record.id} failed to load from ${record.source}: `,
|
|
diagnosticMessagePrefix: "failed to load plugin: ",
|
|
});
|
|
continue;
|
|
}
|
|
const { definition, register } = resolvePluginModuleExport(mod);
|
|
if (definition?.id && definition.id !== record.id) {
|
|
pushPluginLoadError(
|
|
`plugin id mismatch (config uses "${record.id}", export uses "${definition.id}")`,
|
|
);
|
|
continue;
|
|
}
|
|
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 (!isDreamingSidecar) {
|
|
const memoryDecision = resolveMemorySlotDecision({
|
|
id: record.id,
|
|
kind: record.kind,
|
|
slot: memorySlot,
|
|
selectedId: selectedMemoryPluginId,
|
|
});
|
|
if (!memoryDecision.enabled) {
|
|
record.enabled = false;
|
|
record.status = "disabled";
|
|
record.error = memoryDecision.reason;
|
|
markPluginActivationDisabled(record, memoryDecision.reason);
|
|
registry.plugins.push(record);
|
|
seenIds.set(pluginId, candidate.origin);
|
|
continue;
|
|
}
|
|
if (memoryDecision.selected && hasKind(record.kind, "memory")) {
|
|
selectedMemoryPluginId = record.id;
|
|
record.memorySlotSelected = true;
|
|
}
|
|
}
|
|
if (typeof register !== "function") {
|
|
const wrongLoaderError = formatBundledChannelWrongLoaderError(record.kind);
|
|
if (wrongLoaderError) {
|
|
logger.error(
|
|
`[plugins] ${record.id} ${wrongLoaderError}; ensure plugin is loaded via bundled channel discovery, not legacy plugin loader`,
|
|
);
|
|
pushPluginLoadError(wrongLoaderError);
|
|
} else {
|
|
logger.error(`[plugins] ${record.id} missing register/activate export`);
|
|
pushPluginLoadError(formatMissingPluginRegisterError(mod, context.env));
|
|
}
|
|
continue;
|
|
}
|
|
const api = buildPluginApi({
|
|
id: record.id,
|
|
name: record.name,
|
|
version: record.version,
|
|
description: record.description,
|
|
source: record.source,
|
|
rootDir: record.rootDir,
|
|
registrationMode: "cli-metadata",
|
|
config: context.cfg,
|
|
pluginConfig: validatedConfig.value,
|
|
runtime: {} as PluginRuntime,
|
|
logger,
|
|
resolvePath: (input) => resolveUserPath(input),
|
|
handlers: {
|
|
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
|
|
},
|
|
});
|
|
const transaction = createPluginRegistrationTransaction({ registry });
|
|
try {
|
|
withProfile({ pluginId: record.id, source: record.source }, "cli-metadata:register", () =>
|
|
runPluginRegisterSync(register, api),
|
|
);
|
|
registry.plugins.push(record);
|
|
seenIds.set(pluginId, candidate.origin);
|
|
transaction.commit({ activate: true });
|
|
} catch (error) {
|
|
transaction.rollback();
|
|
recordPluginError({
|
|
logger,
|
|
registry,
|
|
record,
|
|
seenIds,
|
|
pluginId,
|
|
origin: candidate.origin,
|
|
phase: "register",
|
|
error,
|
|
logPrefix: `[plugins] ${record.id} failed during register from ${record.source}: `,
|
|
diagnosticMessagePrefix: "plugin failed during register: ",
|
|
});
|
|
}
|
|
}
|
|
return registry;
|
|
}
|
|
|
|
function resolveCliMetadataEntrySource(rootDir: string): string | null {
|
|
for (const basename of CLI_METADATA_ENTRY_BASENAMES) {
|
|
const candidate = path.join(rootDir, basename);
|
|
if (fs.existsSync(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
return null;
|
|
}
|