mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-25 08:52:12 +00:00
Rebase: reconcile latest main compat
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user