Rebase: reconcile latest main compat

This commit is contained in:
Gustavo Madeira Santana
2026-03-15 23:20:53 +00:00
parent 5e4c974696
commit cefbd498da
11 changed files with 81 additions and 1349 deletions

View File

@@ -3,6 +3,7 @@ import type { ResolvedExtensionRegistry } from "../extension-host/resolved-regis
export type ResolvedExtensionValidationEntry = {
id: string;
origin: "workspace" | "bundled" | "global" | "config";
format?: "bundle" | "openclaw";
kind?: string;
channels: string[];
configSchema?: Record<string, unknown>;
@@ -37,6 +38,7 @@ export function buildResolvedExtensionValidationIndex(
return {
id: extension.id,
origin: extension.origin ?? "workspace",
format: record.manifestPath.endsWith("package.json") ? "openclaw" : "bundle",
kind: extension.kind,
channels,
configSchema: extension.staticMetadata.configSchema,

View File

@@ -210,6 +210,12 @@ export async function executeExtensionHostPluginCommand(params: {
to: params.to,
accountId: params.accountId,
messageThreadId: params.messageThreadId,
requestConversationBinding: async () => ({
status: "error" as const,
message: "Conversation binding is unavailable for this command surface.",
}),
detachConversationBinding: async () => ({ removed: false }),
getCurrentConversationBinding: async () => null,
};
extensionHostCommandRegistryLocked = true;

View File

@@ -7,6 +7,7 @@ import type {
OpenClawPluginCliRegistrar,
OpenClawPluginCommandDefinition,
OpenClawPluginHttpRouteParams,
PluginInteractiveHandlerRegistration,
OpenClawPluginService,
OpenClawPluginToolFactory,
PluginLogger,
@@ -49,6 +50,7 @@ export function createExtensionHostPluginApi(params: {
? H
: never,
) => void;
registerInteractiveHandler: (registration: PluginInteractiveHandlerRegistration) => void;
registerCli: (registrar: OpenClawPluginCliRegistrar, opts?: { commands?: string[] }) => void;
registerService: (service: OpenClawPluginService) => void;
registerCommand: (command: OpenClawPluginCommandDefinition) => void;
@@ -78,6 +80,7 @@ export function createExtensionHostPluginApi(params: {
registerChannel: (registration) => params.registerChannel(registration),
registerProvider: (provider) => params.registerProvider(provider),
registerGatewayMethod: (method, handler) => params.registerGatewayMethod(method, handler),
registerInteractiveHandler: (registration) => params.registerInteractiveHandler(registration),
registerCli: (registrar, opts) => params.registerCli(registrar, opts),
registerService: (service) => params.registerService(service),
registerCommand: (command) => params.registerCommand(command),

View File

@@ -1,8 +1,10 @@
import { registerPluginInteractiveHandler } from "../../plugins/interactive.js";
import type { PluginRecord, PluginRegistry, PluginRegistryParams } from "../../plugins/registry.js";
import type {
PluginDiagnostic,
OpenClawPluginApi,
OpenClawPluginCommandDefinition,
PluginInteractiveHandlerRegistration,
ProviderPlugin,
} from "../../plugins/types.js";
import {
@@ -85,6 +87,20 @@ export function createExtensionHostPluginRegistry(params: {
registerProvider: (provider) => registerProvider(record, provider),
registerGatewayMethod: (method, handler) =>
actions.registerGatewayMethod(record, method, handler),
registerInteractiveHandler: (registration: PluginInteractiveHandlerRegistration) => {
const result = registerPluginInteractiveHandler(record.id, registration, {
pluginName: record.name,
pluginRoot: record.rootDir,
});
if (!result.ok) {
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message: result.error ?? "interactive handler registration failed",
});
}
},
registerCli: (registrar, opts) => actions.registerCli(record, registrar, opts),
registerService: (service) => actions.registerService(record, service),
registerCommand: (command) => registerCommand(record, command),

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./runtime-backend-catalog.js", () => ({
vi.mock("../runtime-backend-catalog.js", () => ({
listExtensionHostMediaRuntimeBackendCatalogEntries: vi.fn(() => [
{
id: "capability.runtime-backend:media.audio:deepgram",
@@ -72,7 +72,7 @@ vi.mock("./runtime-backend-catalog.js", () => ({
),
}));
vi.mock("./media-runtime-registry.js", () => ({
vi.mock("../media-runtime-registry.js", () => ({
normalizeExtensionHostMediaProviderId: vi.fn((id: string) =>
id.trim().toLowerCase() === "gemini" ? "google" : id.trim().toLowerCase(),
),

View File

@@ -4,7 +4,7 @@ import {
resolveExtensionHostTtsFallbackProviders,
} from "./tts-runtime-policy.js";
vi.mock("./runtime-backend-catalog.js", () => ({
vi.mock("../runtime-backend-catalog.js", () => ({
listExtensionHostTtsRuntimeBackendCatalogEntries: vi.fn(() => [
{
id: "capability.runtime-backend:tts:openai",
@@ -39,7 +39,7 @@ vi.mock("./runtime-backend-catalog.js", () => ({
]),
}));
vi.mock("./tts-runtime-registry.js", () => ({
vi.mock("../tts-runtime-registry.js", () => ({
isExtensionHostTtsProviderConfigured: vi.fn(
(
config: {

View File

@@ -1,7 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import {
DEFAULT_EXTENSION_ENTRY_CANDIDATES,
getExtensionPackageMetadata,
resolveExtensionEntryCandidates,
type PackageManifest,
@@ -10,13 +9,7 @@ import {
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { resolveUserPath } from "../utils.js";
import { detectBundleManifestFormat, loadBundleManifest } from "./bundle-manifest.js";
import {
DEFAULT_PLUGIN_ENTRY_CANDIDATES,
getPackageManifestMetadata,
resolvePackageExtensionEntries,
type OpenClawPackageManifest,
type PackageManifest,
} from "./manifest.js";
import { DEFAULT_PLUGIN_ENTRY_CANDIDATES, loadPackageManifest } from "./manifest.js";
import { formatPosixMode, isPathInside, safeRealpathSync, safeStatSync } from "./path-safety.js";
import { resolvePluginCacheInputs, resolvePluginSourceRoots } from "./roots.js";
import type { PluginBundleFormat, PluginDiagnostic, PluginFormat, PluginOrigin } from "./types.js";

View File

@@ -36,11 +36,7 @@ import { extensionUsesSkippedScannerPath, isPathInside } from "../security/scan-
import * as skillScanner from "../security/skill-scanner.js";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
import { detectBundleManifestFormat, loadBundleManifest } from "./bundle-manifest.js";
import {
loadPluginManifest,
resolvePackageExtensionEntries,
type PackageManifest as PluginPackageManifest,
} from "./manifest.js";
import { loadPluginManifest } from "./manifest.js";
type PluginInstallLogger = {
info?: (message: string) => void;

View File

@@ -2,38 +2,18 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { MAX_EXTENSION_HOST_REGISTRY_CACHE_ENTRIES } from "../extension-host/activation/loader-cache.js";
import {
clearExtensionHostLoaderState,
loadExtensionHostPluginRegistry,
} from "../extension-host/activation/loader-orchestrator.js";
import {
listPluginSdkAliasCandidates,
listPluginSdkExportedSubpaths,
resolvePluginSdkAliasCandidateOrder,
resolvePluginSdkAliasFile,
} from "../extension-host/loader-compat.js";
import {
buildExtensionHostProvenanceIndex,
compareExtensionHostDuplicateCandidateOrder,
pushExtensionHostDiagnostics,
warnWhenExtensionAllowlistIsOpen,
} from "../extension-host/loader-policy.js";
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { clearPluginCommands } from "./commands.js";
import { applyTestPluginDefaults, normalizePluginsConfig } from "./config-state.js";
import { discoverOpenClawPlugins } from "./discovery.js";
import { initializeGlobalHookRunner } from "./hook-runner-global.js";
import { clearPluginInteractiveHandlers } from "./interactive.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
import { createPluginRuntime, type CreatePluginRuntimeOptions } from "./runtime/index.js";
import type { PluginRuntime } from "./runtime/types.js";
import { validateJsonSchemaValue } from "./schema-validator.js";
import type {
OpenClawPluginDefinition,
OpenClawPluginModule,
PluginDiagnostic,
PluginBundleFormat,
PluginFormat,
PluginLogger,
} from "./types.js";
} from "../extension-host/compat/loader-compat.js";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import type { PluginRegistry } from "./registry.js";
export type PluginLoadResult = PluginRegistry;
@@ -86,769 +66,3 @@ export const __testing = {
resolvePluginSdkAliasFile,
maxPluginRegistryCacheEntries: MAX_EXTENSION_HOST_REGISTRY_CACHE_ENTRIES,
};
function getCachedPluginRegistry(cacheKey: string): PluginRegistry | undefined {
const cached = registryCache.get(cacheKey);
if (!cached) {
return undefined;
}
// Refresh insertion order so frequently reused registries survive eviction.
registryCache.delete(cacheKey);
registryCache.set(cacheKey, cached);
return cached;
}
function setCachedPluginRegistry(cacheKey: string, registry: PluginRegistry): void {
if (registryCache.has(cacheKey)) {
registryCache.delete(cacheKey);
}
registryCache.set(cacheKey, registry);
while (registryCache.size > MAX_PLUGIN_REGISTRY_CACHE_ENTRIES) {
const oldestKey = registryCache.keys().next().value;
if (!oldestKey) {
break;
}
registryCache.delete(oldestKey);
}
}
function buildCacheKey(params: {
workspaceDir?: string;
plugins: NormalizedPluginsConfig;
installs?: Record<string, PluginInstallRecord>;
env: NodeJS.ProcessEnv;
}): string {
const { roots, loadPaths } = resolvePluginCacheInputs({
workspaceDir: params.workspaceDir,
loadPaths: params.plugins.loadPaths,
env: params.env,
});
const installs = Object.fromEntries(
Object.entries(params.installs ?? {}).map(([pluginId, install]) => [
pluginId,
{
...install,
installPath:
typeof install.installPath === "string"
? resolveUserPath(install.installPath, params.env)
: install.installPath,
sourcePath:
typeof install.sourcePath === "string"
? resolveUserPath(install.sourcePath, params.env)
: install.sourcePath,
},
]),
);
return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({
...params.plugins,
installs,
loadPaths,
})}`;
}
function validatePluginConfig(params: {
schema?: Record<string, unknown>;
cacheKey?: string;
value?: unknown;
}): { ok: boolean; value?: Record<string, unknown>; errors?: string[] } {
const schema = params.schema;
if (!schema) {
return { ok: true, value: params.value as Record<string, unknown> | undefined };
}
const cacheKey = params.cacheKey ?? JSON.stringify(schema);
const result = validateJsonSchemaValue({
schema,
cacheKey,
value: params.value ?? {},
});
if (result.ok) {
return { ok: true, value: params.value as Record<string, unknown> | undefined };
}
return { ok: false, errors: result.errors.map((error) => error.text) };
}
function resolvePluginModuleExport(moduleExport: unknown): {
definition?: OpenClawPluginDefinition;
register?: OpenClawPluginDefinition["register"];
} {
const resolved =
moduleExport &&
typeof moduleExport === "object" &&
"default" in (moduleExport as Record<string, unknown>)
? (moduleExport as { default: unknown }).default
: moduleExport;
if (typeof resolved === "function") {
return {
register: resolved as OpenClawPluginDefinition["register"],
};
}
if (resolved && typeof resolved === "object") {
const def = resolved as OpenClawPluginDefinition;
const register = def.register ?? def.activate;
return { definition: def, register };
}
return {};
}
function createPluginRecord(params: {
id: string;
name?: string;
description?: string;
version?: string;
format?: PluginFormat;
bundleFormat?: PluginBundleFormat;
bundleCapabilities?: string[];
source: string;
rootDir?: string;
origin: PluginRecord["origin"];
workspaceDir?: string;
enabled: boolean;
configSchema: boolean;
}): PluginRecord {
return {
id: params.id,
name: params.name ?? params.id,
description: params.description,
version: params.version,
format: params.format ?? "openclaw",
bundleFormat: params.bundleFormat,
bundleCapabilities: params.bundleCapabilities,
source: params.source,
rootDir: params.rootDir,
origin: params.origin,
workspaceDir: params.workspaceDir,
enabled: params.enabled,
status: params.enabled ? "loaded" : "disabled",
toolNames: [],
hookNames: [],
channelIds: [],
providerIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpRoutes: 0,
hookCount: 0,
configSchema: params.configSchema,
configUiHints: undefined,
configJsonSchema: undefined,
};
}
function recordPluginError(params: {
logger: PluginLogger;
registry: PluginRegistry;
record: PluginRecord;
seenIds: Map<string, PluginRecord["origin"]>;
pluginId: string;
origin: PluginRecord["origin"];
error: unknown;
logPrefix: string;
diagnosticMessagePrefix: string;
}) {
const errorText = String(params.error);
const deprecatedApiHint =
errorText.includes("api.registerHttpHandler") && errorText.includes("is not a function")
? "deprecated api.registerHttpHandler(...) was removed; use api.registerHttpRoute(...) for plugin-owned routes or registerPluginHttpRoute(...) for dynamic lifecycle routes"
: null;
const displayError = deprecatedApiHint ? `${deprecatedApiHint} (${errorText})` : errorText;
params.logger.error(`${params.logPrefix}${displayError}`);
params.record.status = "error";
params.record.error = displayError;
params.registry.plugins.push(params.record);
params.seenIds.set(params.pluginId, params.origin);
params.registry.diagnostics.push({
level: "error",
pluginId: params.record.id,
source: params.record.source,
message: `${params.diagnosticMessagePrefix}${displayError}`,
});
}
function pushDiagnostics(diagnostics: PluginDiagnostic[], append: PluginDiagnostic[]) {
diagnostics.push(...append);
}
type PathMatcher = {
exact: Set<string>;
dirs: string[];
};
type InstallTrackingRule = {
trackedWithoutPaths: boolean;
matcher: PathMatcher;
};
type PluginProvenanceIndex = {
loadPathMatcher: PathMatcher;
installRules: Map<string, InstallTrackingRule>;
};
function createPathMatcher(): PathMatcher {
return { exact: new Set<string>(), dirs: [] };
}
function addPathToMatcher(
matcher: PathMatcher,
rawPath: string,
env: NodeJS.ProcessEnv = process.env,
): void {
const trimmed = rawPath.trim();
if (!trimmed) {
return;
}
const resolved = resolveUserPath(trimmed, env);
if (!resolved) {
return;
}
if (matcher.exact.has(resolved) || matcher.dirs.includes(resolved)) {
return;
}
const stat = safeStatSync(resolved);
if (stat?.isDirectory()) {
matcher.dirs.push(resolved);
return;
}
matcher.exact.add(resolved);
}
function matchesPathMatcher(matcher: PathMatcher, sourcePath: string): boolean {
if (matcher.exact.has(sourcePath)) {
return true;
}
return matcher.dirs.some((dirPath) => isPathInside(dirPath, sourcePath));
}
function buildProvenanceIndex(params: {
config: OpenClawConfig;
normalizedLoadPaths: string[];
env: NodeJS.ProcessEnv;
}): PluginProvenanceIndex {
const loadPathMatcher = createPathMatcher();
for (const loadPath of params.normalizedLoadPaths) {
addPathToMatcher(loadPathMatcher, loadPath, params.env);
}
const installRules = new Map<string, InstallTrackingRule>();
const installs = params.config.plugins?.installs ?? {};
for (const [pluginId, install] of Object.entries(installs)) {
const rule: InstallTrackingRule = {
trackedWithoutPaths: false,
matcher: createPathMatcher(),
};
const trackedPaths = [install.installPath, install.sourcePath]
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
.filter(Boolean);
if (trackedPaths.length === 0) {
rule.trackedWithoutPaths = true;
} else {
for (const trackedPath of trackedPaths) {
addPathToMatcher(rule.matcher, trackedPath, params.env);
}
}
installRules.set(pluginId, rule);
}
return { loadPathMatcher, installRules };
}
function isTrackedByProvenance(params: {
pluginId: string;
source: string;
index: PluginProvenanceIndex;
env: NodeJS.ProcessEnv;
}): boolean {
const sourcePath = resolveUserPath(params.source, params.env);
const installRule = params.index.installRules.get(params.pluginId);
if (installRule) {
if (installRule.trackedWithoutPaths) {
return true;
}
if (matchesPathMatcher(installRule.matcher, sourcePath)) {
return true;
}
}
return matchesPathMatcher(params.index.loadPathMatcher, sourcePath);
}
function matchesExplicitInstallRule(params: {
pluginId: string;
source: string;
index: PluginProvenanceIndex;
env: NodeJS.ProcessEnv;
}): boolean {
const sourcePath = resolveUserPath(params.source, params.env);
const installRule = params.index.installRules.get(params.pluginId);
if (!installRule || installRule.trackedWithoutPaths) {
return false;
}
return matchesPathMatcher(installRule.matcher, sourcePath);
}
function resolveCandidateDuplicateRank(params: {
candidate: ReturnType<typeof discoverOpenClawPlugins>["candidates"][number];
manifestByRoot: Map<string, ReturnType<typeof loadPluginManifestRegistry>["plugins"][number]>;
provenance: PluginProvenanceIndex;
env: NodeJS.ProcessEnv;
}): number {
const manifestRecord = params.manifestByRoot.get(params.candidate.rootDir);
const pluginId = manifestRecord?.id;
const isExplicitInstall =
params.candidate.origin === "global" &&
pluginId !== undefined &&
matchesExplicitInstallRule({
pluginId,
source: params.candidate.source,
index: params.provenance,
env: params.env,
});
if (params.candidate.origin === "config") {
return 0;
}
if (params.candidate.origin === "global" && isExplicitInstall) {
return 1;
}
if (params.candidate.origin === "bundled") {
// Bundled plugin ids stay reserved unless the operator configured an override.
return 2;
}
if (params.candidate.origin === "workspace") {
return 3;
}
return 4;
}
function compareDuplicateCandidateOrder(params: {
left: ReturnType<typeof discoverOpenClawPlugins>["candidates"][number];
right: ReturnType<typeof discoverOpenClawPlugins>["candidates"][number];
manifestByRoot: Map<string, ReturnType<typeof loadPluginManifestRegistry>["plugins"][number]>;
provenance: PluginProvenanceIndex;
env: NodeJS.ProcessEnv;
}): number {
const leftPluginId = params.manifestByRoot.get(params.left.rootDir)?.id;
const rightPluginId = params.manifestByRoot.get(params.right.rootDir)?.id;
if (!leftPluginId || leftPluginId !== rightPluginId) {
return 0;
}
return (
resolveCandidateDuplicateRank({
candidate: params.left,
manifestByRoot: params.manifestByRoot,
provenance: params.provenance,
env: params.env,
}) -
resolveCandidateDuplicateRank({
candidate: params.right,
manifestByRoot: params.manifestByRoot,
provenance: params.provenance,
env: params.env,
})
);
}
function warnWhenAllowlistIsOpen(params: {
logger: PluginLogger;
pluginsEnabled: boolean;
allow: string[];
warningCacheKey: string;
discoverablePlugins: Array<{ id: string; source: string; origin: PluginRecord["origin"] }>;
}) {
if (!params.pluginsEnabled) {
return;
}
if (params.allow.length > 0) {
return;
}
const nonBundled = params.discoverablePlugins.filter((entry) => entry.origin !== "bundled");
if (nonBundled.length === 0) {
return;
}
if (openAllowlistWarningCache.has(params.warningCacheKey)) {
return;
}
const preview = nonBundled
.slice(0, 6)
.map((entry) => `${entry.id} (${entry.source})`)
.join(", ");
const extra = nonBundled.length > 6 ? ` (+${nonBundled.length - 6} more)` : "";
openAllowlistWarningCache.add(params.warningCacheKey);
params.logger.warn(
`[plugins] plugins.allow is empty; discovered non-bundled plugins may auto-load: ${preview}${extra}. Set plugins.allow to explicit trusted ids.`,
);
}
function warnAboutUntrackedLoadedPlugins(params: {
registry: PluginRegistry;
provenance: PluginProvenanceIndex;
logger: PluginLogger;
env: NodeJS.ProcessEnv;
}) {
for (const plugin of params.registry.plugins) {
if (plugin.status !== "loaded" || plugin.origin === "bundled") {
continue;
}
if (
isTrackedByProvenance({
pluginId: plugin.id,
source: plugin.source,
index: params.provenance,
env: params.env,
})
) {
continue;
}
const message =
"loaded without install/load-path provenance; treat as untracked local code and pin trust via plugins.allow or install records";
params.registry.diagnostics.push({
level: "warn",
pluginId: plugin.id,
source: plugin.source,
message,
});
params.logger.warn(`[plugins] ${plugin.id}: ${message} (${plugin.source})`);
}
}
function activatePluginRegistry(registry: PluginRegistry, cacheKey: string): void {
setActivePluginRegistry(registry, cacheKey);
initializeGlobalHookRunner(registry);
}
export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegistry {
const env = options.env ?? process.env;
// Test env: default-disable plugins unless explicitly configured.
// This keeps unit/gateway suites fast and avoids loading heavyweight plugin deps by accident.
const cfg = applyTestPluginDefaults(options.config ?? {}, env);
const logger = options.logger ?? defaultLogger();
const validateOnly = options.mode === "validate";
const normalized = normalizePluginsConfig(cfg.plugins);
const cacheKey = buildExtensionHostRegistryCacheKey({
workspaceDir: options.workspaceDir,
plugins: normalized,
installs: cfg.plugins?.installs,
env,
});
const cacheEnabled = options.cache !== false;
if (cacheEnabled) {
const cached = getCachedExtensionHostRegistry(cacheKey);
if (cached) {
activateExtensionHostRegistry(cached, cacheKey);
return cached;
}
}
// Clear previously registered plugin commands before reloading
clearPluginCommands();
clearPluginInteractiveHandlers();
// Lazily initialize the runtime so startup paths that discover/skip plugins do
// not eagerly load every channel runtime dependency.
let resolvedRuntime: PluginRuntime | null = null;
const resolveRuntime = (): PluginRuntime => {
resolvedRuntime ??= createPluginRuntime(options.runtimeOptions);
return resolvedRuntime;
};
const runtime = new Proxy({} as PluginRuntime, {
get(_target, prop, receiver) {
return Reflect.get(resolveRuntime(), prop, receiver);
},
set(_target, prop, value, receiver) {
return Reflect.set(resolveRuntime(), prop, value, receiver);
},
has(_target, prop) {
return Reflect.has(resolveRuntime(), prop);
},
ownKeys() {
return Reflect.ownKeys(resolveRuntime() as object);
},
getOwnPropertyDescriptor(_target, prop) {
return Reflect.getOwnPropertyDescriptor(resolveRuntime() as object, prop);
},
defineProperty(_target, prop, attributes) {
return Reflect.defineProperty(resolveRuntime() as object, prop, attributes);
},
deleteProperty(_target, prop) {
return Reflect.deleteProperty(resolveRuntime() as object, prop);
},
getPrototypeOf() {
return Reflect.getPrototypeOf(resolveRuntime() as object);
},
});
const { registry, createApi } = createPluginRegistry({
logger,
runtime,
coreGatewayHandlers: options.coreGatewayHandlers as Record<string, GatewayRequestHandler>,
});
const discovery = discoverOpenClawPlugins({
workspaceDir: options.workspaceDir,
extraPaths: normalized.loadPaths,
cache: options.cache,
env,
});
const manifestRegistry = loadPluginManifestRegistry({
config: cfg,
workspaceDir: options.workspaceDir,
cache: options.cache,
env,
candidates: discovery.candidates,
diagnostics: discovery.diagnostics,
});
pushExtensionHostDiagnostics(registry.diagnostics, manifestRegistry.diagnostics);
warnWhenExtensionAllowlistIsOpen({
logger,
pluginsEnabled: normalized.enabled,
allow: normalized.allow,
warningCacheKey: cacheKey,
warningCache: openAllowlistWarningCache,
discoverablePlugins: manifestRegistry.plugins.map((plugin) => ({
id: plugin.id,
source: plugin.source,
origin: plugin.origin,
})),
});
const provenance = buildExtensionHostProvenanceIndex({
config: cfg,
normalizedLoadPaths: normalized.loadPaths,
env,
});
// Lazy: avoid creating the Jiti loader when all plugins are disabled (common in unit tests).
let jitiLoader: ReturnType<typeof createJiti> | null = null;
const getJiti = () => {
if (jitiLoader) {
return jitiLoader;
}
const pluginSdkAlias = resolvePluginSdkAlias();
const extensionApiAlias = resolveExtensionApiAlias();
const aliasMap = {
...(extensionApiAlias ? { "openclaw/extension-api": extensionApiAlias } : {}),
...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}),
...resolvePluginSdkScopedAliasMap(),
};
jitiLoader = createJiti(import.meta.url, {
interopDefault: true,
extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"],
...(Object.keys(aliasMap).length > 0
? {
alias: aliasMap,
}
: {}),
});
return jitiLoader;
};
const manifestByRoot = new Map(
manifestRegistry.plugins.map((record) => [record.rootDir, record]),
);
const orderedCandidates = [...discovery.candidates].toSorted((left, right) => {
return compareExtensionHostDuplicateCandidateOrder({
left,
right,
manifestByRoot,
provenance,
env,
});
});
const seenIds = new Map<string, PluginRecord["origin"]>();
const memorySlot = normalized.slots.memory;
let selectedMemoryPluginId: string | null = null;
let memorySlotMatched = false;
for (const candidate of orderedCandidates) {
const manifestRecord = manifestByRoot.get(candidate.rootDir);
if (!manifestRecord) {
continue;
}
const pluginId = manifestRecord.id;
const existingOrigin = seenIds.get(pluginId);
if (existingOrigin) {
const record = createExtensionHostPluginRecord({
id: pluginId,
name: manifestRecord.name ?? pluginId,
description: manifestRecord.description,
version: manifestRecord.version,
format: manifestRecord.format,
bundleFormat: manifestRecord.bundleFormat,
bundleCapabilities: manifestRecord.bundleCapabilities,
source: candidate.source,
rootDir: candidate.rootDir,
origin: candidate.origin,
workspaceDir: candidate.workspaceDir,
enabled: false,
configSchema: Boolean(manifestRecord.configSchema),
});
setExtensionHostPluginRecordDisabled(record, `overridden by ${existingOrigin} plugin`);
appendExtensionHostPluginRecord({ registry, record });
continue;
}
const enableState = resolveEffectiveEnableState({
id: pluginId,
origin: candidate.origin,
config: normalized,
rootConfig: cfg,
});
const entry = normalized.entries[pluginId];
const record = createExtensionHostPluginRecord({
id: pluginId,
name: manifestRecord.name ?? pluginId,
description: manifestRecord.description,
version: manifestRecord.version,
format: manifestRecord.format,
bundleFormat: manifestRecord.bundleFormat,
bundleCapabilities: manifestRecord.bundleCapabilities,
source: candidate.source,
rootDir: candidate.rootDir,
origin: candidate.origin,
workspaceDir: candidate.workspaceDir,
enabled: enableState.enabled,
configSchema: Boolean(manifestRecord.configSchema),
});
record.kind = manifestRecord.kind;
record.configUiHints = manifestRecord.configUiHints;
record.configJsonSchema = manifestRecord.configSchema;
const pushPluginLoadError = (message: string) => {
setExtensionHostPluginRecordError(record, message);
appendExtensionHostPluginRecord({
registry,
record,
seenIds,
pluginId,
origin: candidate.origin,
});
registry.diagnostics.push({
level: "error",
pluginId: record.id,
source: record.source,
message: record.error,
});
};
if (!enableState.enabled) {
setExtensionHostPluginRecordDisabled(record, enableState.reason);
appendExtensionHostPluginRecord({
registry,
record,
seenIds,
pluginId,
origin: candidate.origin,
});
continue;
}
if (record.format === "bundle") {
const unsupportedCapabilities = (record.bundleCapabilities ?? []).filter(
(capability) =>
capability !== "skills" &&
capability !== "settings" &&
!(
capability === "commands" &&
(record.bundleFormat === "claude" || record.bundleFormat === "cursor")
) &&
!(capability === "hooks" && record.bundleFormat === "codex"),
);
for (const capability of unsupportedCapabilities) {
registry.diagnostics.push({
level: "warn",
pluginId: record.id,
source: record.source,
message: `bundle capability detected but not wired into OpenClaw yet: ${capability}`,
});
}
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
continue;
}
// Fast-path bundled memory plugins that are guaranteed disabled by slot policy.
// This avoids opening/importing heavy memory plugin modules that will never register.
const earlyMemoryDecision = resolveExtensionHostEarlyMemoryDecision({
origin: candidate.origin,
manifestKind: manifestRecord.kind,
recordId: record.id,
memorySlot,
selectedMemoryPluginId,
});
if (!earlyMemoryDecision.enabled) {
setExtensionHostPluginRecordDisabled(record, earlyMemoryDecision.reason);
appendExtensionHostPluginRecord({
registry,
record,
seenIds,
pluginId,
origin: candidate.origin,
});
continue;
}
if (!manifestRecord.configSchema) {
pushPluginLoadError("missing config schema");
continue;
}
const moduleImport = importExtensionHostPluginModule({
rootDir: candidate.rootDir,
source: candidate.source,
origin: candidate.origin,
loadModule: (safeSource) => getJiti()(safeSource),
});
if (!moduleImport.ok) {
if (moduleImport.message !== "failed to load plugin") {
pushPluginLoadError(moduleImport.message);
continue;
}
recordExtensionHostPluginError({
logger,
registry,
record,
seenIds,
pluginId,
origin: candidate.origin,
error: moduleImport.error,
logPrefix: `[plugins] ${record.id} failed to load from ${record.source}: `,
diagnosticMessagePrefix: "failed to load plugin: ",
});
continue;
}
const resolved = resolveExtensionHostModuleExport(moduleImport.module as OpenClawPluginModule);
const definition = resolved.definition;
const register = resolved.register;
const loadedPlan = planExtensionHostLoadedPlugin({
record,
manifestRecord,
definition,
register,
diagnostics: registry.diagnostics,
memorySlot,
selectedMemoryPluginId,
entryConfig: entry?.config,
validateOnly,
logger,
registry,
seenIds,
selectedMemoryPluginId,
createApi,
loadModule: (safeSource) => getJiti()(safeSource) as OpenClawPluginModule,
});
selectedMemoryPluginId = processed.selectedMemoryPluginId;
memorySlotMatched ||= processed.memorySlotMatched;
}
return finalizeExtensionHostRegistryLoad({
registry,
memorySlot,
memorySlotMatched,
provenance,
logger,
env,
cacheEnabled,
cacheKey,
setCachedRegistry: setCachedExtensionHostRegistry,
activateRegistry: activateExtensionHostRegistry,
});
}

View File

@@ -4,11 +4,12 @@ import {
buildResolvedExtensionRecord,
type ResolvedExtensionRecord,
} from "../extension-host/manifest-registry.js";
import { resolveLegacyExtensionDescriptor } from "../extension-host/schema.js";
import { resolveUserPath } from "../utils.js";
import { loadBundleManifest } from "./bundle-manifest.js";
import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js";
import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js";
import { loadPluginManifest, type PluginManifest } from "./manifest.js";
import { loadPluginManifest, type PackageManifest, type PluginManifest } from "./manifest.js";
import { isPathInside, safeRealpathSync } from "./path-safety.js";
import { resolvePluginCacheInputs } from "./roots.js";
import type {
@@ -175,6 +176,35 @@ function buildBundleRecord(params: {
candidate: PluginCandidate;
manifestPath: string;
}): PluginManifestRecord {
const packageManifest =
params.candidate.packageManifest ||
params.candidate.packageName ||
params.candidate.packageVersion ||
params.candidate.packageDescription
? ({
openclaw: params.candidate.packageManifest,
name: params.candidate.packageName,
version: params.candidate.packageVersion,
description: params.candidate.packageDescription,
} as PackageManifest)
: undefined;
const resolvedExtension = resolveLegacyExtensionDescriptor({
manifest: {
id: params.manifest.id,
configSchema: {},
channels: [],
providers: [],
skills: params.manifest.skills ?? [],
name: params.manifest.name,
description: params.manifest.description,
version: params.manifest.version,
},
packageManifest,
origin: params.candidate.origin,
rootDir: params.candidate.rootDir,
source: params.candidate.source,
workspaceDir: params.candidate.workspaceDir,
});
return {
id: params.manifest.id,
name: normalizeManifestLabel(params.manifest.name) ?? params.candidate.idHint,
@@ -196,6 +226,7 @@ function buildBundleRecord(params: {
schemaCacheKey: undefined,
configSchema: undefined,
configUiHints: undefined,
resolvedExtension,
};
}

View File

@@ -1,16 +1,8 @@
import type { ChannelDock } from "../channels/dock.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import { registerContextEngineForOwner } from "../context-engine/registry.js";
import type {
GatewayRequestHandler,
GatewayRequestHandlers,
} from "../gateway/server-methods/types.js";
import { registerInternalHook } from "../hooks/internal-hooks.js";
import { registerPluginCommand } from "./commands.js";
import { normalizePluginHttpPath } from "./http-path.js";
import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js";
import { registerPluginInteractiveHandler } from "./interactive.js";
import { normalizeRegisteredProvider } from "./provider-validation.js";
import { createExtensionHostPluginRegistry } from "../extension-host/compat/plugin-registry.js";
import type { GatewayRequestHandlers } from "../gateway/server-methods/types.js";
import type { HookEntry } from "../hooks/types.js";
import type { PluginRuntime } from "./runtime/types.js";
import type {
OpenClawPluginCliRegistrar,
@@ -20,10 +12,11 @@ import type {
OpenClawPluginHttpRouteMatch,
OpenClawPluginService,
OpenClawPluginToolFactory,
PluginBundleFormat,
PluginConfigUiHint,
PluginDiagnostic,
PluginBundleFormat,
PluginFormat,
PluginKind,
PluginLogger,
PluginOrigin,
PluginHookRegistration as TypedPluginHookRegistration,
@@ -179,530 +172,8 @@ export function createEmptyPluginRegistry(): PluginRegistry {
}
export function createPluginRegistry(registryParams: PluginRegistryParams) {
const registry = createEmptyPluginRegistry();
const coreGatewayMethods = new Set(Object.keys(registryParams.coreGatewayHandlers ?? {}));
const pushDiagnostic = (diag: PluginDiagnostic) => {
registry.diagnostics.push(diag);
};
const registerTool = (
record: PluginRecord,
tool: AnyAgentTool | OpenClawPluginToolFactory,
opts?: { name?: string; names?: string[]; optional?: boolean },
) => {
const names = opts?.names ?? (opts?.name ? [opts.name] : []);
const optional = opts?.optional === true;
const factory: OpenClawPluginToolFactory =
typeof tool === "function" ? tool : (_ctx: OpenClawPluginToolContext) => tool;
if (typeof tool !== "function") {
names.push(tool.name);
}
const normalized = names.map((name) => name.trim()).filter(Boolean);
if (normalized.length > 0) {
record.toolNames.push(...normalized);
}
registry.tools.push({
pluginId: record.id,
pluginName: record.name,
factory,
names: normalized,
optional,
source: record.source,
rootDir: record.rootDir,
});
addExtensionToolRegistration({ registry, record, names: result.names, entry: result.entry });
};
const registerHook = (
record: PluginRecord,
events: string | string[],
handler: Parameters<typeof registerInternalHook>[1],
opts: OpenClawPluginHookOptions | undefined,
config: OpenClawPluginApi["config"],
) => {
const normalized = resolveExtensionLegacyHookRegistration({
ownerPluginId: record.id,
ownerSource: record.source,
events,
handler,
opts,
});
if (!normalized.ok) {
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message: normalized.message,
});
return;
}
const existingHook = registry.hooks.find((entry) => entry.entry.hook.name === name);
if (existingHook) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `hook already registered: ${name} (${existingHook.pluginId})`,
});
return;
}
const description = entry?.hook.description ?? opts?.description ?? "";
const hookEntry: HookEntry = entry
? {
...entry,
hook: {
...entry.hook,
name,
description,
source: "openclaw-plugin",
pluginId: record.id,
},
metadata: {
...entry.metadata,
events: normalizedEvents,
},
}
: {
hook: {
name,
description,
source: "openclaw-plugin",
pluginId: record.id,
filePath: record.source,
baseDir: path.dirname(record.source),
handlerPath: record.source,
},
frontmatter: {},
metadata: { events: normalizedEvents },
invocation: { enabled: true },
};
record.hookNames.push(name);
registry.hooks.push({
pluginId: normalized.entry.pluginId,
entry: normalized.entry.entry,
events: normalized.events,
});
bridgeExtensionHostLegacyHooks({
events: normalized.events,
handler,
hookSystemEnabled: config?.hooks?.internal?.enabled === true,
register: opts?.register,
registerHook: registerInternalHook,
});
};
const registerGatewayMethod = (
record: PluginRecord,
method: string,
handler: GatewayRequestHandler,
) => {
const result = resolveExtensionGatewayMethodRegistration({
existing: registry.gatewayHandlers,
coreGatewayMethods,
method,
handler,
});
if (!result.ok) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: result.message,
});
return;
}
addExtensionGatewayMethodRegistration({
registry,
record,
method: result.method,
handler: result.handler,
});
};
const registerHttpRoute = (record: PluginRecord, params: OpenClawPluginHttpRouteParams) => {
const result = resolveExtensionHttpRouteRegistration({
existing: registry.httpRoutes,
ownerPluginId: record.id,
ownerSource: record.source,
route: params,
});
if (!result.ok) {
pushDiagnostic({
level: result.message === "http route registration missing path" ? "warn" : "error",
pluginId: record.id,
source: record.source,
message: result.message,
});
return;
}
if (result.action === "replace") {
addExtensionHttpRouteRegistration({
registry,
record,
action: "replace",
existingIndex: result.existingIndex,
entry: result.entry,
});
return;
}
addExtensionHttpRouteRegistration({
registry,
record,
action: "append",
entry: result.entry,
});
};
const registerChannel = (
record: PluginRecord,
registration: OpenClawPluginChannelRegistration | ChannelPlugin,
) => {
const normalized =
typeof (registration as OpenClawPluginChannelRegistration).plugin === "object"
? (registration as OpenClawPluginChannelRegistration)
: { plugin: registration as ChannelPlugin };
const plugin = normalized.plugin;
const id = typeof plugin?.id === "string" ? plugin.id.trim() : String(plugin?.id ?? "").trim();
if (!id) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: "channel registration missing id",
});
return;
}
const existing = registry.channels.find((entry) => entry.plugin.id === id);
if (existing) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `channel already registered: ${id} (${existing.pluginId})`,
});
return;
}
record.channelIds.push(id);
registry.channels.push({
pluginId: record.id,
pluginName: record.name,
plugin,
dock: normalized.dock,
source: record.source,
rootDir: record.rootDir,
});
if (!result.ok) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: result.message,
});
return;
}
addExtensionChannelRegistration({
registry,
record,
channelId: result.channelId,
entry: result.entry,
});
};
const registerProvider = (record: PluginRecord, provider: ProviderPlugin) => {
const normalizedProvider = normalizeRegisteredProvider({
pluginId: record.id,
source: record.source,
provider,
pushDiagnostic,
});
if (!normalizedProvider) {
return;
}
const result = resolveExtensionProviderRegistration({
existing: registry.providers,
ownerPluginId: record.id,
ownerSource: record.source,
provider: normalizedProvider,
});
if (!result.ok) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: result.message,
});
return;
}
record.providerIds.push(id);
registry.providers.push({
pluginId: record.id,
pluginName: record.name,
provider: normalizedProvider,
source: record.source,
rootDir: record.rootDir,
});
};
const registerCli = (
record: PluginRecord,
registrar: OpenClawPluginCliRegistrar,
opts?: { commands?: string[] },
) => {
const commands = (opts?.commands ?? []).map((cmd) => cmd.trim()).filter(Boolean);
if (commands.length === 0) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: "cli registration missing explicit commands metadata",
});
return;
}
const existing = registry.cliRegistrars.find((entry) =>
entry.commands.some((command) => commands.includes(command)),
);
if (existing) {
const overlap = commands.find((command) => existing.commands.includes(command));
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `cli command already registered: ${overlap ?? commands[0]} (${existing.pluginId})`,
});
return;
}
record.cliCommands.push(...commands);
registry.cliRegistrars.push({
pluginId: record.id,
pluginName: record.name,
register: registrar,
commands,
source: record.source,
rootDir: record.rootDir,
});
addExtensionCliRegistration({
registry,
record,
commands: result.commands,
entry: result.entry,
});
};
const registerService = (record: PluginRecord, service: OpenClawPluginService) => {
const result = resolveExtensionServiceRegistration({
ownerPluginId: record.id,
ownerSource: record.source,
service,
});
if (!result.ok) {
return;
}
const existing = registry.services.find((entry) => entry.service.id === id);
if (existing) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `service already registered: ${id} (${existing.pluginId})`,
});
return;
}
record.services.push(id);
registry.services.push({
pluginId: record.id,
pluginName: record.name,
service,
source: record.source,
rootDir: record.rootDir,
});
};
const registerCommand = (record: PluginRecord, command: OpenClawPluginCommandDefinition) => {
const normalized = resolveExtensionCommandRegistration({
ownerPluginId: record.id,
ownerSource: record.source,
command,
});
if (!normalized.ok) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: normalized.message,
});
return;
}
// Register with the plugin command system (validates name and checks for duplicates)
const result = registerPluginCommand(record.id, command, {
pluginName: record.name,
pluginRoot: record.rootDir,
});
if (!result.ok) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `command registration failed: ${result.error}`,
});
return;
}
record.commands.push(name);
registry.commands.push({
pluginId: record.id,
pluginName: record.name,
command,
source: record.source,
rootDir: record.rootDir,
});
};
const registerTypedHook = <K extends PluginHookName>(
record: PluginRecord,
hookName: K,
handler: PluginHookHandlerMap[K],
opts?: { priority?: number },
policy?: PluginTypedHookPolicy,
) => {
const normalized = resolveExtensionTypedHookRegistration({
ownerPluginId: record.id,
ownerSource: record.source,
hookName,
handler,
priority: opts?.priority,
});
if (!normalized.ok) {
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message: normalized.message,
});
return;
}
const policyResult = applyExtensionHostTypedHookPolicy({
hookName: normalized.hookName,
handler,
policy,
blockedMessage: `typed hook "${normalized.hookName}" blocked by plugins.entries.${record.id}.hooks.allowPromptInjection=false`,
constrainedMessage: `typed hook "${normalized.hookName}" prompt fields constrained by plugins.entries.${record.id}.hooks.allowPromptInjection=false`,
});
if (!policyResult.ok) {
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message: policyResult.message,
});
return;
}
if (policyResult.warningMessage) {
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message: policyResult.warningMessage,
});
}
addExtensionTypedHookRegistration({
registry,
record,
entry: {
...normalized.entry,
pluginId: record.id,
hookName: normalized.hookName,
handler: policyResult.entryHandler,
} as TypedPluginHookRegistration,
});
};
const createApi = (
record: PluginRecord,
params: {
config: OpenClawPluginApi["config"];
pluginConfig?: Record<string, unknown>;
hookPolicy?: PluginTypedHookPolicy;
},
): OpenClawPluginApi => {
return {
id: record.id,
name: record.name,
version: record.version,
description: record.description,
source: record.source,
rootDir: record.rootDir,
config: params.config,
pluginConfig: params.pluginConfig,
registerTool: (tool, opts) => registerTool(record, tool, opts),
registerHook: (events, handler, opts) =>
registerHook(record, events, handler, opts, params.config),
registerHttpRoute: (routeParams) => registerHttpRoute(record, routeParams),
registerChannel: (registration) => registerChannel(record, registration as never),
registerProvider: (provider) => registerProvider(record, provider),
registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler),
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
registerService: (service) => registerService(record, service),
registerInteractiveHandler: (registration) => {
const result = registerPluginInteractiveHandler(record.id, registration, {
pluginName: record.name,
pluginRoot: record.rootDir,
});
if (!result.ok) {
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message: result.error ?? "interactive handler registration failed",
});
}
},
registerCommand: (command) => registerCommand(record, command),
registerContextEngine: (id, factory) => {
if (id === defaultSlotIdForKey("contextEngine")) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `context engine id reserved by core: ${id}`,
});
return;
}
const result = registerContextEngineForOwner(id, factory, `plugin:${record.id}`, {
allowSameOwnerRefresh: true,
});
if (!result.ok) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `context engine already registered: ${id} (${result.existingOwner})`,
});
}
},
on: (hookName, handler, opts) =>
registerTypedHook(record, hookName, handler, opts, params.hookPolicy),
});
};
return {
registry,
createApi,
pushDiagnostic,
registerTool,
registerChannel,
registerProvider,
registerGatewayMethod,
registerCli,
registerService,
registerCommand,
registerHook,
registerTypedHook,
};
return createExtensionHostPluginRegistry({
registry: createEmptyPluginRegistry(),
registryParams,
});
}