Files
openclaw/src/plugins/setup-registry.ts
2026-04-26 14:28:15 -07:00

844 lines
25 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { normalizeProviderId } from "../agents/provider-id.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { buildPluginApi } from "./api-builder.js";
import { collectPluginConfigContractMatches } from "./config-contracts.js";
import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js";
import type { PluginManifestRecord } from "./manifest-registry.js";
import { PluginLruCache, type PluginLruCacheResult } from "./plugin-lru-cache.js";
import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js";
import { resolvePluginCacheInputs } from "./roots.js";
import type { PluginRuntime } from "./runtime/types.js";
import { listSetupCliBackendIds, listSetupProviderIds } from "./setup-descriptors.js";
import type {
CliBackendPlugin,
OpenClawPluginModule,
PluginConfigMigration,
PluginLogger,
PluginSetupAutoEnableProbe,
ProviderPlugin,
} from "./types.js";
const SETUP_API_EXTENSIONS = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"] as const;
const CURRENT_MODULE_PATH = fileURLToPath(import.meta.url);
const RUNNING_FROM_BUILT_ARTIFACT =
CURRENT_MODULE_PATH.includes(`${path.sep}dist${path.sep}`) ||
CURRENT_MODULE_PATH.includes(`${path.sep}dist-runtime${path.sep}`);
type SetupProviderEntry = {
pluginId: string;
provider: ProviderPlugin;
};
type SetupCliBackendEntry = {
pluginId: string;
backend: CliBackendPlugin;
};
type SetupConfigMigrationEntry = {
pluginId: string;
migrate: PluginConfigMigration;
};
type SetupAutoEnableProbeEntry = {
pluginId: string;
probe: PluginSetupAutoEnableProbe;
};
export type PluginSetupRegistryDiagnosticCode =
| "setup-descriptor-runtime-disabled"
| "setup-descriptor-provider-missing-runtime"
| "setup-descriptor-provider-runtime-undeclared"
| "setup-descriptor-cli-backend-missing-runtime"
| "setup-descriptor-cli-backend-runtime-undeclared";
export type PluginSetupRegistryDiagnostic = {
pluginId: string;
code: PluginSetupRegistryDiagnosticCode;
declaredId?: string;
runtimeId?: string;
message: string;
};
type PluginSetupRegistry = {
providers: SetupProviderEntry[];
cliBackends: SetupCliBackendEntry[];
configMigrations: SetupConfigMigrationEntry[];
autoEnableProbes: SetupAutoEnableProbeEntry[];
diagnostics: PluginSetupRegistryDiagnostic[];
};
type SetupAutoEnableReason = {
pluginId: string;
reason: string;
};
type PluginApiBuildParams = Parameters<typeof buildPluginApi>[0];
const EMPTY_RUNTIME = {} as PluginRuntime;
const NOOP_LOGGER: PluginLogger = {
info() {},
warn() {},
error() {},
};
const MAX_SETUP_LOOKUP_CACHE_ENTRIES = 128;
const jitiLoaders: PluginJitiLoaderCache = new Map();
const setupRegistryCache = new PluginLruCache<PluginSetupRegistry>(MAX_SETUP_LOOKUP_CACHE_ENTRIES);
const setupProviderCache = new PluginLruCache<ProviderPlugin | null>(
MAX_SETUP_LOOKUP_CACHE_ENTRIES,
);
const setupCliBackendCache = new PluginLruCache<SetupCliBackendEntry | null>(
MAX_SETUP_LOOKUP_CACHE_ENTRIES,
);
export const __testing = {
get maxSetupLookupCacheEntries() {
return setupRegistryCache.maxEntries;
},
setMaxSetupLookupCacheEntriesForTest(value?: number) {
setupRegistryCache.setMaxEntriesForTest(value);
setupProviderCache.setMaxEntriesForTest(value);
setupCliBackendCache.setMaxEntriesForTest(value);
},
getCacheSizes() {
return {
setupRegistry: setupRegistryCache.size,
setupProvider: setupProviderCache.size,
setupCliBackend: setupCliBackendCache.size,
};
},
} as const;
export function clearPluginSetupRegistryCache(): void {
jitiLoaders.clear();
setupRegistryCache.clear();
setupProviderCache.clear();
setupCliBackendCache.clear();
}
function getJiti(modulePath: string) {
return getCachedPluginJitiLoader({
cache: jitiLoaders,
modulePath,
importerUrl: import.meta.url,
});
}
function getCachedSetupValue<T>(cache: PluginLruCache<T>, key: string): PluginLruCacheResult<T> {
return cache.getResult(key);
}
function setCachedSetupValue<T>(cache: PluginLruCache<T>, key: string, value: T): void {
cache.set(key, value);
}
function buildSetupRegistryCacheKey(params: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
pluginIds?: readonly string[];
}): string {
const { roots, loadPaths } = resolvePluginCacheInputs({
workspaceDir: params.workspaceDir,
env: params.env,
loadPaths: params.config?.plugins?.load?.paths,
});
return JSON.stringify({
roots,
loadPaths,
hasConfig: Boolean(params.config),
pluginIds: params.pluginIds ? [...new Set(params.pluginIds)].toSorted() : null,
});
}
function buildSetupProviderCacheKey(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
pluginIds?: readonly string[];
}): string {
return JSON.stringify({
provider: normalizeProviderId(params.provider),
registry: buildSetupRegistryCacheKey(params),
});
}
function buildSetupCliBackendCacheKey(params: {
backend: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): string {
return JSON.stringify({
backend: normalizeProviderId(params.backend),
registry: buildSetupRegistryCacheKey(params),
});
}
function resolveSetupApiPath(
rootDir: string,
options?: { includeBundledSourceFallback?: boolean },
): string | null {
const orderedExtensions = RUNNING_FROM_BUILT_ARTIFACT
? SETUP_API_EXTENSIONS
: ([...SETUP_API_EXTENSIONS.slice(3), ...SETUP_API_EXTENSIONS.slice(0, 3)] as const);
const findSetupApi = (candidateRootDir: string): string | null => {
for (const extension of orderedExtensions) {
const candidate = path.join(candidateRootDir, `setup-api${extension}`);
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
};
const direct = findSetupApi(rootDir);
if (direct) {
return direct;
}
if (options?.includeBundledSourceFallback === false) {
return null;
}
const bundledExtensionDir = path.basename(rootDir);
const repoRootCandidates = [path.resolve(path.dirname(CURRENT_MODULE_PATH), "..", "..")];
for (const repoRoot of repoRootCandidates) {
const sourceExtensionRoot = path.join(repoRoot, "extensions", bundledExtensionDir);
if (sourceExtensionRoot === rootDir) {
continue;
}
const sourceFallback = findSetupApi(sourceExtensionRoot);
if (sourceFallback) {
return sourceFallback;
}
}
return null;
}
function collectConfiguredPluginEntryIds(config: OpenClawConfig): string[] {
const entries = config.plugins?.entries;
if (!entries || typeof entries !== "object") {
return [];
}
return Object.keys(entries)
.map((pluginId) => pluginId.trim())
.filter(Boolean)
.toSorted();
}
function resolveRelevantSetupMigrationPluginIds(params: {
config: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): string[] {
const ids = new Set<string>(collectConfiguredPluginEntryIds(params.config));
const registry = loadSetupManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
for (const plugin of registry.plugins) {
const paths = plugin.configContracts?.compatibilityMigrationPaths;
if (!paths?.length) {
continue;
}
if (
paths.some(
(pathPattern) =>
collectPluginConfigContractMatches({
root: params.config,
pathPattern,
}).length > 0,
)
) {
ids.add(plugin.id);
}
}
return [...ids].toSorted();
}
function resolveRegister(mod: OpenClawPluginModule): {
definition?: { id?: string };
register?: (api: ReturnType<typeof buildPluginApi>) => void | Promise<void>;
} {
if (typeof mod === "function") {
return { register: mod };
}
if (mod && typeof mod === "object" && typeof mod.register === "function") {
return {
definition: mod as { id?: string },
register: mod.register.bind(mod),
};
}
return {};
}
function resolveLoadableSetupRuntimeSource(record: PluginManifestRecord): string | null {
return record.setupSource ?? resolveSetupApiPath(record.rootDir);
}
function resolveDeclaredSetupRuntimeSource(record: PluginManifestRecord): string | null {
return (
record.setupSource ??
resolveSetupApiPath(record.rootDir, {
includeBundledSourceFallback: false,
})
);
}
function resolveSetupRegistration(record: PluginManifestRecord): {
setupSource: string;
register: (api: ReturnType<typeof buildPluginApi>) => void | Promise<void>;
} | null {
if (record.setup?.requiresRuntime === false) {
return null;
}
const setupSource = resolveLoadableSetupRuntimeSource(record);
if (!setupSource) {
return null;
}
let mod: OpenClawPluginModule;
try {
mod = getJiti(setupSource)(setupSource) as OpenClawPluginModule;
} catch {
return null;
}
const resolved = resolveRegister((mod as { default?: OpenClawPluginModule }).default ?? mod);
if (!resolved.register) {
return null;
}
if (resolved.definition?.id && resolved.definition.id !== record.id) {
return null;
}
return {
setupSource,
register: resolved.register,
};
}
function buildSetupPluginApi(params: {
record: PluginManifestRecord;
setupSource: string;
handlers: PluginApiBuildParams["handlers"];
}): ReturnType<typeof buildPluginApi> {
return buildPluginApi({
id: params.record.id,
name: params.record.name ?? params.record.id,
version: params.record.version,
description: params.record.description,
source: params.setupSource,
rootDir: params.record.rootDir,
registrationMode: "setup-only",
config: {} as OpenClawConfig,
runtime: EMPTY_RUNTIME,
logger: NOOP_LOGGER,
resolvePath: (input) => input,
handlers: params.handlers,
});
}
function ignoreAsyncSetupRegisterResult(result: void | Promise<void>): void {
if (!result || typeof result.then !== "function") {
return;
}
// Setup-only registration is sync-only. Swallow async rejections so they do
// not trip the global unhandledRejection fatal path.
void Promise.resolve(result).catch(() => undefined);
}
function matchesProvider(provider: ProviderPlugin, providerId: string): boolean {
const normalized = normalizeProviderId(providerId);
if (normalizeProviderId(provider.id) === normalized) {
return true;
}
return [...(provider.aliases ?? []), ...(provider.hookAliases ?? [])].some(
(alias) => normalizeProviderId(alias) === normalized,
);
}
function loadSetupManifestRegistry(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
pluginIds?: readonly string[];
}) {
const env = params?.env ?? process.env;
return loadPluginManifestRegistryForPluginRegistry({
config: params?.config,
workspaceDir: params?.workspaceDir,
env,
pluginIds: params?.pluginIds,
includeDisabled: true,
});
}
function findUniqueSetupManifestOwner(params: {
registry: ReturnType<typeof loadSetupManifestRegistry>;
normalizedId: string;
listIds: (record: PluginManifestRecord) => readonly string[];
}): PluginManifestRecord | undefined {
const matches = params.registry.plugins.filter((entry) =>
params.listIds(entry).some((id) => normalizeProviderId(id) === params.normalizedId),
);
if (matches.length === 0) {
return undefined;
}
// Setup lookup can execute plugin code. Refuse ambiguous ownership instead of
// depending on manifest ordering across bundled/workspace/global sources.
return matches.length === 1 ? matches[0] : undefined;
}
function mapNormalizedIds(ids: readonly string[]): Map<string, string> {
const mapped = new Map<string, string>();
for (const id of ids) {
const normalized = normalizeProviderId(id);
if (!normalized || mapped.has(normalized)) {
continue;
}
mapped.set(normalized, id);
}
return mapped;
}
function pushDescriptorRuntimeDisabledDiagnostic(params: {
record: PluginManifestRecord;
diagnostics: PluginSetupRegistryDiagnostic[];
}): void {
if (!resolveDeclaredSetupRuntimeSource(params.record)) {
return;
}
params.diagnostics.push({
pluginId: params.record.id,
code: "setup-descriptor-runtime-disabled",
message:
"setup.requiresRuntime is false, so OpenClaw ignored the plugin setup runtime entry. Remove setup-api/openclaw.setupEntry or set requiresRuntime true if setup lookup still needs plugin code.",
});
}
function pushSetupDescriptorDriftDiagnostics(params: {
record: PluginManifestRecord;
providers: readonly ProviderPlugin[];
cliBackends: readonly CliBackendPlugin[];
diagnostics: PluginSetupRegistryDiagnostic[];
}): void {
const declaredProviderIds = params.record.setup?.providers?.map((entry) => entry.id);
if (declaredProviderIds) {
for (const declaredId of declaredProviderIds) {
if (!params.providers.some((provider) => matchesProvider(provider, declaredId))) {
params.diagnostics.push({
pluginId: params.record.id,
code: "setup-descriptor-provider-missing-runtime",
declaredId,
message: `setup.providers declares "${declaredId}" but setup runtime did not register a matching provider.`,
});
}
}
for (const provider of params.providers) {
if (!declaredProviderIds.some((declaredId) => matchesProvider(provider, declaredId))) {
params.diagnostics.push({
pluginId: params.record.id,
code: "setup-descriptor-provider-runtime-undeclared",
runtimeId: provider.id,
message: `setup runtime registered provider "${provider.id}" but setup.providers does not declare it.`,
});
}
}
}
const declaredCliBackendIds = params.record.setup?.cliBackends;
if (declaredCliBackendIds) {
const declaredCliBackends = mapNormalizedIds(declaredCliBackendIds);
const runtimeCliBackends = mapNormalizedIds(params.cliBackends.map((backend) => backend.id));
for (const [normalized, declaredId] of declaredCliBackends) {
if (!runtimeCliBackends.has(normalized)) {
params.diagnostics.push({
pluginId: params.record.id,
code: "setup-descriptor-cli-backend-missing-runtime",
declaredId,
message: `setup.cliBackends declares "${declaredId}" but setup runtime did not register a matching CLI backend.`,
});
}
}
for (const [normalized, runtimeId] of runtimeCliBackends) {
if (!declaredCliBackends.has(normalized)) {
params.diagnostics.push({
pluginId: params.record.id,
code: "setup-descriptor-cli-backend-runtime-undeclared",
runtimeId,
message: `setup runtime registered CLI backend "${runtimeId}" but setup.cliBackends does not declare it.`,
});
}
}
}
}
export function resolvePluginSetupRegistry(params?: {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
pluginIds?: readonly string[];
}): PluginSetupRegistry {
const env = params?.env ?? process.env;
const cacheKey = buildSetupRegistryCacheKey({
config: params?.config,
workspaceDir: params?.workspaceDir,
env,
pluginIds: params?.pluginIds,
});
const cached = getCachedSetupValue(setupRegistryCache, cacheKey);
if (cached.hit) {
return cached.value;
}
const selectedPluginIds = params?.pluginIds
? new Set(params.pluginIds.map((pluginId) => pluginId.trim()).filter(Boolean))
: null;
if (selectedPluginIds && selectedPluginIds.size === 0) {
const empty = {
providers: [],
cliBackends: [],
configMigrations: [],
autoEnableProbes: [],
diagnostics: [],
} satisfies PluginSetupRegistry;
setCachedSetupValue(setupRegistryCache, cacheKey, empty);
return empty;
}
const providers: SetupProviderEntry[] = [];
const cliBackends: SetupCliBackendEntry[] = [];
const configMigrations: SetupConfigMigrationEntry[] = [];
const autoEnableProbes: SetupAutoEnableProbeEntry[] = [];
const diagnostics: PluginSetupRegistryDiagnostic[] = [];
const providerKeys = new Set<string>();
const cliBackendKeys = new Set<string>();
const manifestRegistry = loadSetupManifestRegistry({
config: params?.config,
workspaceDir: params?.workspaceDir,
env,
pluginIds: params?.pluginIds,
});
for (const record of manifestRegistry.plugins) {
if (selectedPluginIds && !selectedPluginIds.has(record.id)) {
continue;
}
if (record.setup?.requiresRuntime === false) {
pushDescriptorRuntimeDisabledDiagnostic({
record,
diagnostics,
});
continue;
}
const setupRegistration = resolveSetupRegistration(record);
if (!setupRegistration) {
continue;
}
const recordProviders: ProviderPlugin[] = [];
const recordCliBackends: CliBackendPlugin[] = [];
const api = buildSetupPluginApi({
record,
setupSource: setupRegistration.setupSource,
handlers: {
registerProvider(provider) {
const key = `${record.id}:${normalizeProviderId(provider.id)}`;
if (providerKeys.has(key)) {
return;
}
providerKeys.add(key);
providers.push({
pluginId: record.id,
provider,
});
recordProviders.push(provider);
},
registerCliBackend(backend) {
const key = `${record.id}:${normalizeProviderId(backend.id)}`;
if (cliBackendKeys.has(key)) {
return;
}
cliBackendKeys.add(key);
cliBackends.push({
pluginId: record.id,
backend,
});
recordCliBackends.push(backend);
},
registerConfigMigration(migrate) {
configMigrations.push({
pluginId: record.id,
migrate,
});
},
registerAutoEnableProbe(probe) {
autoEnableProbes.push({
pluginId: record.id,
probe,
});
},
},
});
try {
const result = setupRegistration.register(api);
if (result && typeof result.then === "function") {
// Keep setup registration sync-only.
ignoreAsyncSetupRegisterResult(result);
}
} catch {
continue;
}
pushSetupDescriptorDriftDiagnostics({
record,
providers: recordProviders,
cliBackends: recordCliBackends,
diagnostics,
});
}
const registry = {
providers,
cliBackends,
configMigrations,
autoEnableProbes,
diagnostics,
} satisfies PluginSetupRegistry;
setCachedSetupValue(setupRegistryCache, cacheKey, registry);
return registry;
}
export function resolvePluginSetupProvider(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
pluginIds?: readonly string[];
}): ProviderPlugin | undefined {
const cacheKey = buildSetupProviderCacheKey(params);
const cached = getCachedSetupValue(setupProviderCache, cacheKey);
if (cached.hit) {
return cached.value ?? undefined;
}
const env = params.env ?? process.env;
const normalizedProvider = normalizeProviderId(params.provider);
const manifestRegistry = loadSetupManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env,
pluginIds: params.pluginIds,
});
const record = findUniqueSetupManifestOwner({
registry: manifestRegistry,
normalizedId: normalizedProvider,
listIds: listSetupProviderIds,
});
if (!record) {
setCachedSetupValue(setupProviderCache, cacheKey, null);
return undefined;
}
const setupRegistration = resolveSetupRegistration(record);
if (!setupRegistration) {
setCachedSetupValue(setupProviderCache, cacheKey, null);
return undefined;
}
let matchedProvider: ProviderPlugin | undefined;
const localProviderKeys = new Set<string>();
const api = buildSetupPluginApi({
record,
setupSource: setupRegistration.setupSource,
handlers: {
registerProvider(provider) {
const key = normalizeProviderId(provider.id);
if (localProviderKeys.has(key)) {
return;
}
localProviderKeys.add(key);
if (matchesProvider(provider, normalizedProvider)) {
matchedProvider = provider;
}
},
registerConfigMigration() {},
registerAutoEnableProbe() {},
},
});
try {
const result = setupRegistration.register(api);
if (result && typeof result.then === "function") {
// Keep setup registration sync-only.
ignoreAsyncSetupRegisterResult(result);
}
} catch {
setCachedSetupValue(setupProviderCache, cacheKey, null);
return undefined;
}
setCachedSetupValue(setupProviderCache, cacheKey, matchedProvider ?? null);
return matchedProvider;
}
export function resolvePluginSetupCliBackend(params: {
backend: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): SetupCliBackendEntry | undefined {
const cacheKey = buildSetupCliBackendCacheKey(params);
const cached = getCachedSetupValue(setupCliBackendCache, cacheKey);
if (cached.hit) {
return cached.value ?? undefined;
}
const normalized = normalizeProviderId(params.backend);
const env = params.env ?? process.env;
// Narrow setup lookup from manifest-owned descriptors before executing any
// plugin setup module. This avoids booting every setup-api just to find one
// backend owner.
const manifestRegistry = loadSetupManifestRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env,
});
const record = findUniqueSetupManifestOwner({
registry: manifestRegistry,
normalizedId: normalized,
listIds: listSetupCliBackendIds,
});
if (!record) {
setCachedSetupValue(setupCliBackendCache, cacheKey, null);
return undefined;
}
const setupRegistration = resolveSetupRegistration(record);
if (!setupRegistration) {
setCachedSetupValue(setupCliBackendCache, cacheKey, null);
return undefined;
}
let matchedBackend: CliBackendPlugin | undefined;
const localBackendKeys = new Set<string>();
const api = buildSetupPluginApi({
record,
setupSource: setupRegistration.setupSource,
handlers: {
registerProvider() {},
registerConfigMigration() {},
registerAutoEnableProbe() {},
registerCliBackend(backend) {
const key = normalizeProviderId(backend.id);
if (localBackendKeys.has(key)) {
return;
}
localBackendKeys.add(key);
if (key === normalized) {
matchedBackend = backend;
}
},
},
});
try {
const result = setupRegistration.register(api);
if (result && typeof result.then === "function") {
// Keep setup registration sync-only.
ignoreAsyncSetupRegisterResult(result);
}
} catch {
setCachedSetupValue(setupCliBackendCache, cacheKey, null);
return undefined;
}
const resolvedEntry = matchedBackend ? { pluginId: record.id, backend: matchedBackend } : null;
setCachedSetupValue(setupCliBackendCache, cacheKey, resolvedEntry);
return resolvedEntry ?? undefined;
}
export function runPluginSetupConfigMigrations(params: {
config: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): {
config: OpenClawConfig;
changes: string[];
} {
let next = params.config;
const changes: string[] = [];
const pluginIds = resolveRelevantSetupMigrationPluginIds(params);
if (pluginIds.length === 0) {
return { config: next, changes };
}
for (const entry of resolvePluginSetupRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
pluginIds,
}).configMigrations) {
const migration = entry.migrate(next);
if (!migration || migration.changes.length === 0) {
continue;
}
next = migration.config;
changes.push(...migration.changes);
}
return { config: next, changes };
}
export function resolvePluginSetupAutoEnableReasons(params: {
config: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
pluginIds?: readonly string[];
}): SetupAutoEnableReason[] {
const env = params.env ?? process.env;
const reasons: SetupAutoEnableReason[] = [];
const seen = new Set<string>();
for (const entry of resolvePluginSetupRegistry({
config: params.config,
workspaceDir: params.workspaceDir,
env,
pluginIds: params.pluginIds,
}).autoEnableProbes) {
const raw = entry.probe({
config: params.config,
env,
});
const values = Array.isArray(raw) ? raw : raw ? [raw] : [];
for (const reason of values) {
const normalized = reason.trim();
if (!normalized) {
continue;
}
const key = `${entry.pluginId}:${normalized}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
reasons.push({
pluginId: entry.pluginId,
reason: normalized,
});
}
}
return reasons;
}