2026-01-18 02:51:42 +00:00
|
|
|
import fs from "node:fs";
|
|
|
|
|
import path from "node:path";
|
|
|
|
|
import { fileURLToPath } from "node:url";
|
2026-02-18 01:34:35 +00:00
|
|
|
import { createJiti } from "jiti";
|
2026-01-30 03:15:10 +01:00
|
|
|
import type { OpenClawConfig } from "../config/config.js";
|
2026-01-11 12:11:12 +00:00
|
|
|
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
|
2026-01-18 23:24:42 +00:00
|
|
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
2026-02-19 15:34:58 +01:00
|
|
|
import { isPathInsideWithRealpath } from "../security/scan-paths.js";
|
2026-01-11 12:11:12 +00:00
|
|
|
import { resolveUserPath } from "../utils.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
import { clearPluginCommands } from "./commands.js";
|
2026-01-19 21:13:51 -06:00
|
|
|
import {
|
2026-02-07 07:57:50 +00:00
|
|
|
applyTestPluginDefaults,
|
2026-01-19 21:13:51 -06:00
|
|
|
normalizePluginsConfig,
|
|
|
|
|
resolveEnableState,
|
|
|
|
|
resolveMemorySlotDecision,
|
|
|
|
|
type NormalizedPluginsConfig,
|
|
|
|
|
} from "./config-state.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
import { discoverOpenClawPlugins } from "./discovery.js";
|
2026-01-18 05:40:58 +00:00
|
|
|
import { initializeGlobalHookRunner } from "./hook-runner-global.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
import { loadPluginManifestRegistry } from "./manifest-registry.js";
|
2026-02-19 15:24:02 +01:00
|
|
|
import { isPathInside, safeStatSync } from "./path-safety.js";
|
2026-01-14 14:31:43 +00:00
|
|
|
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
|
2026-01-15 02:42:41 +00:00
|
|
|
import { setActivePluginRegistry } from "./runtime.js";
|
2026-02-01 10:03:47 +09:00
|
|
|
import { createPluginRuntime } from "./runtime/index.js";
|
2026-01-19 21:13:51 -06:00
|
|
|
import { validateJsonSchemaValue } from "./schema-validator.js";
|
2026-02-18 01:34:35 +00:00
|
|
|
import type {
|
|
|
|
|
OpenClawPluginDefinition,
|
|
|
|
|
OpenClawPluginModule,
|
|
|
|
|
PluginDiagnostic,
|
|
|
|
|
PluginLogger,
|
|
|
|
|
} from "./types.js";
|
2026-01-11 12:11:12 +00:00
|
|
|
|
|
|
|
|
export type PluginLoadResult = PluginRegistry;
|
|
|
|
|
|
|
|
|
|
export type PluginLoadOptions = {
|
2026-01-30 03:15:10 +01:00
|
|
|
config?: OpenClawConfig;
|
2026-01-11 12:11:12 +00:00
|
|
|
workspaceDir?: string;
|
|
|
|
|
logger?: PluginLogger;
|
|
|
|
|
coreGatewayHandlers?: Record<string, GatewayRequestHandler>;
|
|
|
|
|
cache?: boolean;
|
2026-01-19 03:38:51 +00:00
|
|
|
mode?: "full" | "validate";
|
2026-01-11 12:11:12 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const registryCache = new Map<string, PluginRegistry>();
|
|
|
|
|
|
|
|
|
|
const defaultLogger = () => createSubsystemLogger("plugins");
|
|
|
|
|
|
2026-02-15 05:29:39 +00:00
|
|
|
const resolvePluginSdkAliasFile = (params: {
|
|
|
|
|
srcFile: string;
|
|
|
|
|
distFile: string;
|
|
|
|
|
}): string | null => {
|
2026-01-18 02:51:42 +00:00
|
|
|
try {
|
2026-01-19 13:12:33 -06:00
|
|
|
const modulePath = fileURLToPath(import.meta.url);
|
chore: Migrate to tsdown, speed up JS bundling by ~10x (thanks @hyf0).
The previous migration to tsdown was reverted because it caused a ~20x slowdown when running OpenClaw from the repo. @hyf0 investigated and found that simply renaming the `dist` folder also caused the same slowdown. It turns out the Plugin script loader has a bunch of voodoo vibe logic to determine if it should load files from source and compile them, or if it should load them from dist. When building with tsdown, the filesystem layout is different (bundled), and so some files weren't in the right location, and the Plugin script loader decided to compile source files from scratch using Jiti.
The new implementation uses tsdown to embed `NODE_ENV: 'production'`, which we now use to determine if we are running OpenClaw from a "production environmen" (ie. from dist). This removes the slop in favor of a deterministic toggle, and doesn't rely on directory names or similar.
There is some code reaching into `dist` to load specific modules, primarily in the voice-call extension, which I simplified into loading an "officially" exported `extensionAPI.js` file. With tsdown, entry points need to be explicitly configured, so we should be able to avoid sloppy code reaching into internals from now on. This might break some existing users, but if it does, it's because they were using "private" APIs.
2026-02-02 17:20:24 +09:00
|
|
|
const isProduction = process.env.NODE_ENV === "production";
|
|
|
|
|
const isTest = process.env.VITEST || process.env.NODE_ENV === "test";
|
2026-01-19 13:12:33 -06:00
|
|
|
let cursor = path.dirname(modulePath);
|
2026-01-18 02:51:42 +00:00
|
|
|
for (let i = 0; i < 6; i += 1) {
|
2026-02-15 05:29:39 +00:00
|
|
|
const srcCandidate = path.join(cursor, "src", "plugin-sdk", params.srcFile);
|
|
|
|
|
const distCandidate = path.join(cursor, "dist", "plugin-sdk", params.distFile);
|
chore: Migrate to tsdown, speed up JS bundling by ~10x (thanks @hyf0).
The previous migration to tsdown was reverted because it caused a ~20x slowdown when running OpenClaw from the repo. @hyf0 investigated and found that simply renaming the `dist` folder also caused the same slowdown. It turns out the Plugin script loader has a bunch of voodoo vibe logic to determine if it should load files from source and compile them, or if it should load them from dist. When building with tsdown, the filesystem layout is different (bundled), and so some files weren't in the right location, and the Plugin script loader decided to compile source files from scratch using Jiti.
The new implementation uses tsdown to embed `NODE_ENV: 'production'`, which we now use to determine if we are running OpenClaw from a "production environmen" (ie. from dist). This removes the slop in favor of a deterministic toggle, and doesn't rely on directory names or similar.
There is some code reaching into `dist` to load specific modules, primarily in the voice-call extension, which I simplified into loading an "officially" exported `extensionAPI.js` file. With tsdown, entry points need to be explicitly configured, so we should be able to avoid sloppy code reaching into internals from now on. This might break some existing users, but if it does, it's because they were using "private" APIs.
2026-02-02 17:20:24 +09:00
|
|
|
const orderedCandidates = isProduction
|
|
|
|
|
? isTest
|
|
|
|
|
? [distCandidate, srcCandidate]
|
|
|
|
|
: [distCandidate]
|
2026-01-19 00:03:42 +00:00
|
|
|
: [srcCandidate, distCandidate];
|
|
|
|
|
for (const candidate of orderedCandidates) {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (fs.existsSync(candidate)) {
|
|
|
|
|
return candidate;
|
|
|
|
|
}
|
2026-01-19 00:03:42 +00:00
|
|
|
}
|
2026-01-18 02:51:42 +00:00
|
|
|
const parent = path.dirname(cursor);
|
2026-01-31 16:19:20 +09:00
|
|
|
if (parent === cursor) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
2026-01-18 02:51:42 +00:00
|
|
|
cursor = parent;
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-15 05:29:39 +00:00
|
|
|
const resolvePluginSdkAlias = (): string | null =>
|
|
|
|
|
resolvePluginSdkAliasFile({ srcFile: "index.ts", distFile: "index.js" });
|
|
|
|
|
|
2026-02-14 14:15:37 +01:00
|
|
|
const resolvePluginSdkAccountIdAlias = (): string | null => {
|
2026-02-15 05:29:39 +00:00
|
|
|
return resolvePluginSdkAliasFile({ srcFile: "account-id.ts", distFile: "account-id.js" });
|
2026-02-14 14:15:37 +01:00
|
|
|
};
|
|
|
|
|
|
2026-01-11 12:11:12 +00:00
|
|
|
function buildCacheKey(params: {
|
|
|
|
|
workspaceDir?: string;
|
|
|
|
|
plugins: NormalizedPluginsConfig;
|
|
|
|
|
}): string {
|
2026-01-14 14:31:43 +00:00
|
|
|
const workspaceKey = params.workspaceDir ? resolveUserPath(params.workspaceDir) : "";
|
2026-01-11 12:11:12 +00:00
|
|
|
return `${workspaceKey}::${JSON.stringify(params.plugins)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validatePluginConfig(params: {
|
2026-01-19 21:13:51 -06:00
|
|
|
schema?: Record<string, unknown>;
|
|
|
|
|
cacheKey?: string;
|
|
|
|
|
value?: unknown;
|
2026-01-11 12:11:12 +00:00
|
|
|
}): { ok: boolean; value?: Record<string, unknown>; errors?: string[] } {
|
|
|
|
|
const schema = params.schema;
|
2026-01-19 21:13:51 -06:00
|
|
|
if (!schema) {
|
|
|
|
|
return { ok: true, value: params.value as Record<string, unknown> | undefined };
|
2026-01-11 12:11:12 +00:00
|
|
|
}
|
2026-01-19 21:13:51 -06:00
|
|
|
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 };
|
2026-01-11 12:11:12 +00:00
|
|
|
}
|
2026-01-19 21:13:51 -06:00
|
|
|
return { ok: false, errors: result.errors };
|
2026-01-11 12:11:12 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolvePluginModuleExport(moduleExport: unknown): {
|
2026-01-30 03:15:10 +01:00
|
|
|
definition?: OpenClawPluginDefinition;
|
|
|
|
|
register?: OpenClawPluginDefinition["register"];
|
2026-01-11 12:11:12 +00:00
|
|
|
} {
|
|
|
|
|
const resolved =
|
|
|
|
|
moduleExport &&
|
|
|
|
|
typeof moduleExport === "object" &&
|
|
|
|
|
"default" in (moduleExport as Record<string, unknown>)
|
|
|
|
|
? (moduleExport as { default: unknown }).default
|
|
|
|
|
: moduleExport;
|
|
|
|
|
if (typeof resolved === "function") {
|
|
|
|
|
return {
|
2026-01-30 03:15:10 +01:00
|
|
|
register: resolved as OpenClawPluginDefinition["register"],
|
2026-01-11 12:11:12 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
if (resolved && typeof resolved === "object") {
|
2026-01-30 03:15:10 +01:00
|
|
|
const def = resolved as OpenClawPluginDefinition;
|
2026-01-11 12:11:12 +00:00
|
|
|
const register = def.register ?? def.activate;
|
|
|
|
|
return { definition: def, register };
|
|
|
|
|
}
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createPluginRecord(params: {
|
|
|
|
|
id: string;
|
|
|
|
|
name?: string;
|
|
|
|
|
description?: string;
|
|
|
|
|
version?: string;
|
|
|
|
|
source: 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,
|
|
|
|
|
source: params.source,
|
|
|
|
|
origin: params.origin,
|
|
|
|
|
workspaceDir: params.workspaceDir,
|
|
|
|
|
enabled: params.enabled,
|
|
|
|
|
status: params.enabled ? "loaded" : "disabled",
|
|
|
|
|
toolNames: [],
|
2026-01-18 05:56:59 +00:00
|
|
|
hookNames: [],
|
2026-01-15 02:42:41 +00:00
|
|
|
channelIds: [],
|
2026-01-16 03:15:07 +00:00
|
|
|
providerIds: [],
|
2026-01-11 12:11:12 +00:00
|
|
|
gatewayMethods: [],
|
|
|
|
|
cliCommands: [],
|
|
|
|
|
services: [],
|
2026-01-23 03:17:10 +00:00
|
|
|
commands: [],
|
2026-01-15 05:03:50 +00:00
|
|
|
httpHandlers: 0,
|
2026-01-18 05:40:58 +00:00
|
|
|
hookCount: 0,
|
2026-01-11 12:11:12 +00:00
|
|
|
configSchema: params.configSchema,
|
2026-01-12 01:16:39 +00:00
|
|
|
configUiHints: undefined,
|
2026-01-16 14:13:30 -06:00
|
|
|
configJsonSchema: undefined,
|
2026-01-11 12:11:12 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 14:31:43 +00:00
|
|
|
function pushDiagnostics(diagnostics: PluginDiagnostic[], append: PluginDiagnostic[]) {
|
2026-01-11 12:11:12 +00:00
|
|
|
diagnostics.push(...append);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 15:24:02 +01:00
|
|
|
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): void {
|
|
|
|
|
const trimmed = rawPath.trim();
|
|
|
|
|
if (!trimmed) {
|
|
|
|
|
return;
|
2026-02-19 15:13:34 +01:00
|
|
|
}
|
2026-02-19 15:24:02 +01:00
|
|
|
const resolved = resolveUserPath(trimmed);
|
|
|
|
|
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);
|
2026-02-19 15:13:34 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 15:24:02 +01:00
|
|
|
function matchesPathMatcher(matcher: PathMatcher, sourcePath: string): boolean {
|
|
|
|
|
if (matcher.exact.has(sourcePath)) {
|
2026-02-19 15:13:34 +01:00
|
|
|
return true;
|
|
|
|
|
}
|
2026-02-19 15:24:02 +01:00
|
|
|
return matcher.dirs.some((dirPath) => isPathInside(dirPath, sourcePath));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildProvenanceIndex(params: {
|
|
|
|
|
config: OpenClawConfig;
|
|
|
|
|
normalizedLoadPaths: string[];
|
|
|
|
|
}): PluginProvenanceIndex {
|
|
|
|
|
const loadPathMatcher = createPathMatcher();
|
|
|
|
|
for (const loadPath of params.normalizedLoadPaths) {
|
|
|
|
|
addPathToMatcher(loadPathMatcher, loadPath);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
2026-02-19 15:13:34 +01:00
|
|
|
}
|
2026-02-19 15:24:02 +01:00
|
|
|
installRules.set(pluginId, rule);
|
2026-02-19 15:13:34 +01:00
|
|
|
}
|
2026-02-19 15:24:02 +01:00
|
|
|
|
|
|
|
|
return { loadPathMatcher, installRules };
|
2026-02-19 15:13:34 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 15:24:02 +01:00
|
|
|
function isTrackedByProvenance(params: {
|
2026-02-19 15:13:34 +01:00
|
|
|
pluginId: string;
|
|
|
|
|
source: string;
|
2026-02-19 15:24:02 +01:00
|
|
|
index: PluginProvenanceIndex;
|
2026-02-19 15:13:34 +01:00
|
|
|
}): boolean {
|
2026-02-19 15:24:02 +01:00
|
|
|
const sourcePath = resolveUserPath(params.source);
|
|
|
|
|
const installRule = params.index.installRules.get(params.pluginId);
|
|
|
|
|
if (installRule) {
|
|
|
|
|
if (installRule.trackedWithoutPaths) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if (matchesPathMatcher(installRule.matcher, sourcePath)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-02-19 15:13:34 +01:00
|
|
|
}
|
2026-02-19 15:24:02 +01:00
|
|
|
return matchesPathMatcher(params.index.loadPathMatcher, sourcePath);
|
2026-02-19 15:13:34 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function warnWhenAllowlistIsOpen(params: {
|
|
|
|
|
logger: PluginLogger;
|
|
|
|
|
pluginsEnabled: boolean;
|
|
|
|
|
allow: 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;
|
|
|
|
|
}
|
|
|
|
|
const preview = nonBundled
|
|
|
|
|
.slice(0, 6)
|
|
|
|
|
.map((entry) => `${entry.id} (${entry.source})`)
|
|
|
|
|
.join(", ");
|
|
|
|
|
const extra = nonBundled.length > 6 ? ` (+${nonBundled.length - 6} more)` : "";
|
|
|
|
|
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;
|
2026-02-19 15:24:02 +01:00
|
|
|
provenance: PluginProvenanceIndex;
|
2026-02-19 15:13:34 +01:00
|
|
|
logger: PluginLogger;
|
|
|
|
|
}) {
|
|
|
|
|
for (const plugin of params.registry.plugins) {
|
|
|
|
|
if (plugin.status !== "loaded" || plugin.origin === "bundled") {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (
|
2026-02-19 15:24:02 +01:00
|
|
|
isTrackedByProvenance({
|
2026-02-19 15:13:34 +01:00
|
|
|
pluginId: plugin.id,
|
|
|
|
|
source: plugin.source,
|
2026-02-19 15:24:02 +01:00
|
|
|
index: params.provenance,
|
2026-02-19 15:13:34 +01:00
|
|
|
})
|
|
|
|
|
) {
|
|
|
|
|
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})`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 03:15:10 +01:00
|
|
|
export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegistry {
|
2026-02-07 07:57:50 +00:00
|
|
|
// 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 ?? {}, process.env);
|
2026-01-11 12:11:12 +00:00
|
|
|
const logger = options.logger ?? defaultLogger();
|
2026-01-19 03:38:51 +00:00
|
|
|
const validateOnly = options.mode === "validate";
|
2026-01-11 12:11:12 +00:00
|
|
|
const normalized = normalizePluginsConfig(cfg.plugins);
|
|
|
|
|
const cacheKey = buildCacheKey({
|
|
|
|
|
workspaceDir: options.workspaceDir,
|
|
|
|
|
plugins: normalized,
|
|
|
|
|
});
|
|
|
|
|
const cacheEnabled = options.cache !== false;
|
|
|
|
|
if (cacheEnabled) {
|
|
|
|
|
const cached = registryCache.get(cacheKey);
|
2026-01-15 02:42:41 +00:00
|
|
|
if (cached) {
|
|
|
|
|
setActivePluginRegistry(cached, cacheKey);
|
|
|
|
|
return cached;
|
|
|
|
|
}
|
2026-01-11 12:11:12 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-23 12:43:39 +00:00
|
|
|
// Clear previously registered plugin commands before reloading
|
|
|
|
|
clearPluginCommands();
|
|
|
|
|
|
2026-01-18 02:14:07 +00:00
|
|
|
const runtime = createPluginRuntime();
|
2026-01-11 12:11:12 +00:00
|
|
|
const { registry, createApi } = createPluginRegistry({
|
|
|
|
|
logger,
|
2026-01-18 02:14:07 +00:00
|
|
|
runtime,
|
2026-01-14 14:31:43 +00:00
|
|
|
coreGatewayHandlers: options.coreGatewayHandlers as Record<string, GatewayRequestHandler>,
|
2026-01-11 12:11:12 +00:00
|
|
|
});
|
|
|
|
|
|
2026-01-30 03:15:10 +01:00
|
|
|
const discovery = discoverOpenClawPlugins({
|
2026-01-11 12:11:12 +00:00
|
|
|
workspaceDir: options.workspaceDir,
|
|
|
|
|
extraPaths: normalized.loadPaths,
|
|
|
|
|
});
|
2026-01-19 21:13:51 -06:00
|
|
|
const manifestRegistry = loadPluginManifestRegistry({
|
|
|
|
|
config: cfg,
|
|
|
|
|
workspaceDir: options.workspaceDir,
|
|
|
|
|
cache: options.cache,
|
|
|
|
|
candidates: discovery.candidates,
|
|
|
|
|
diagnostics: discovery.diagnostics,
|
|
|
|
|
});
|
|
|
|
|
pushDiagnostics(registry.diagnostics, manifestRegistry.diagnostics);
|
2026-02-19 15:13:34 +01:00
|
|
|
warnWhenAllowlistIsOpen({
|
|
|
|
|
logger,
|
|
|
|
|
pluginsEnabled: normalized.enabled,
|
|
|
|
|
allow: normalized.allow,
|
|
|
|
|
discoverablePlugins: manifestRegistry.plugins.map((plugin) => ({
|
|
|
|
|
id: plugin.id,
|
|
|
|
|
source: plugin.source,
|
|
|
|
|
origin: plugin.origin,
|
|
|
|
|
})),
|
|
|
|
|
});
|
2026-02-19 15:24:02 +01:00
|
|
|
const provenance = buildProvenanceIndex({
|
|
|
|
|
config: cfg,
|
|
|
|
|
normalizedLoadPaths: normalized.loadPaths,
|
|
|
|
|
});
|
2026-01-11 12:11:12 +00:00
|
|
|
|
2026-02-15 19:18:02 +00:00
|
|
|
// 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 pluginSdkAccountIdAlias = resolvePluginSdkAccountIdAlias();
|
|
|
|
|
jitiLoader = createJiti(import.meta.url, {
|
|
|
|
|
interopDefault: true,
|
|
|
|
|
extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"],
|
|
|
|
|
...(pluginSdkAlias || pluginSdkAccountIdAlias
|
|
|
|
|
? {
|
|
|
|
|
alias: {
|
|
|
|
|
...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}),
|
|
|
|
|
...(pluginSdkAccountIdAlias
|
|
|
|
|
? { "openclaw/plugin-sdk/account-id": pluginSdkAccountIdAlias }
|
|
|
|
|
: {}),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
: {}),
|
|
|
|
|
});
|
|
|
|
|
return jitiLoader;
|
|
|
|
|
};
|
2026-01-11 12:11:12 +00:00
|
|
|
|
2026-01-19 21:13:51 -06:00
|
|
|
const manifestByRoot = new Map(
|
|
|
|
|
manifestRegistry.plugins.map((record) => [record.rootDir, record]),
|
2026-01-20 08:47:44 +00:00
|
|
|
);
|
|
|
|
|
|
2026-01-17 09:33:56 +00:00
|
|
|
const seenIds = new Map<string, PluginRecord["origin"]>();
|
2026-01-18 02:12:01 +00:00
|
|
|
const memorySlot = normalized.slots.memory;
|
|
|
|
|
let selectedMemoryPluginId: string | null = null;
|
|
|
|
|
let memorySlotMatched = false;
|
2026-01-17 09:33:56 +00:00
|
|
|
|
2026-01-11 12:11:12 +00:00
|
|
|
for (const candidate of discovery.candidates) {
|
2026-01-19 21:13:51 -06:00
|
|
|
const manifestRecord = manifestByRoot.get(candidate.rootDir);
|
|
|
|
|
if (!manifestRecord) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
const pluginId = manifestRecord.id;
|
|
|
|
|
const existingOrigin = seenIds.get(pluginId);
|
2026-01-17 09:33:56 +00:00
|
|
|
if (existingOrigin) {
|
|
|
|
|
const record = createPluginRecord({
|
2026-01-19 21:13:51 -06:00
|
|
|
id: pluginId,
|
|
|
|
|
name: manifestRecord.name ?? pluginId,
|
|
|
|
|
description: manifestRecord.description,
|
|
|
|
|
version: manifestRecord.version,
|
2026-01-17 09:33:56 +00:00
|
|
|
source: candidate.source,
|
|
|
|
|
origin: candidate.origin,
|
|
|
|
|
workspaceDir: candidate.workspaceDir,
|
|
|
|
|
enabled: false,
|
2026-01-19 21:13:51 -06:00
|
|
|
configSchema: Boolean(manifestRecord.configSchema),
|
2026-01-17 09:33:56 +00:00
|
|
|
});
|
|
|
|
|
record.status = "disabled";
|
|
|
|
|
record.error = `overridden by ${existingOrigin} plugin`;
|
|
|
|
|
registry.plugins.push(record);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-19 21:13:51 -06:00
|
|
|
const enableState = resolveEnableState(pluginId, candidate.origin, normalized);
|
|
|
|
|
const entry = normalized.entries[pluginId];
|
2026-01-11 12:11:12 +00:00
|
|
|
const record = createPluginRecord({
|
2026-01-19 21:13:51 -06:00
|
|
|
id: pluginId,
|
|
|
|
|
name: manifestRecord.name ?? pluginId,
|
|
|
|
|
description: manifestRecord.description,
|
|
|
|
|
version: manifestRecord.version,
|
2026-01-11 12:11:12 +00:00
|
|
|
source: candidate.source,
|
|
|
|
|
origin: candidate.origin,
|
|
|
|
|
workspaceDir: candidate.workspaceDir,
|
|
|
|
|
enabled: enableState.enabled,
|
2026-01-19 21:13:51 -06:00
|
|
|
configSchema: Boolean(manifestRecord.configSchema),
|
2026-01-11 12:11:12 +00:00
|
|
|
});
|
2026-01-19 21:13:51 -06:00
|
|
|
record.kind = manifestRecord.kind;
|
|
|
|
|
record.configUiHints = manifestRecord.configUiHints;
|
|
|
|
|
record.configJsonSchema = manifestRecord.configSchema;
|
2026-01-11 12:11:12 +00:00
|
|
|
|
|
|
|
|
if (!enableState.enabled) {
|
|
|
|
|
record.status = "disabled";
|
|
|
|
|
record.error = enableState.reason;
|
|
|
|
|
registry.plugins.push(record);
|
2026-01-19 21:13:51 -06:00
|
|
|
seenIds.set(pluginId, candidate.origin);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!manifestRecord.configSchema) {
|
|
|
|
|
record.status = "error";
|
|
|
|
|
record.error = "missing config schema";
|
|
|
|
|
registry.plugins.push(record);
|
|
|
|
|
seenIds.set(pluginId, candidate.origin);
|
|
|
|
|
registry.diagnostics.push({
|
|
|
|
|
level: "error",
|
|
|
|
|
pluginId: record.id,
|
|
|
|
|
source: record.source,
|
|
|
|
|
message: record.error,
|
|
|
|
|
});
|
2026-01-11 12:11:12 +00:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 15:34:58 +01:00
|
|
|
if (
|
|
|
|
|
!isPathInsideWithRealpath(candidate.rootDir, candidate.source, {
|
|
|
|
|
requireRealpath: true,
|
|
|
|
|
})
|
|
|
|
|
) {
|
|
|
|
|
record.status = "error";
|
|
|
|
|
record.error = "plugin entry path escapes plugin root";
|
|
|
|
|
registry.plugins.push(record);
|
|
|
|
|
seenIds.set(pluginId, candidate.origin);
|
|
|
|
|
registry.diagnostics.push({
|
|
|
|
|
level: "error",
|
|
|
|
|
pluginId: record.id,
|
|
|
|
|
source: record.source,
|
|
|
|
|
message: record.error,
|
|
|
|
|
});
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 03:15:10 +01:00
|
|
|
let mod: OpenClawPluginModule | null = null;
|
2026-01-11 12:11:12 +00:00
|
|
|
try {
|
2026-02-15 19:18:02 +00:00
|
|
|
mod = getJiti()(candidate.source) as OpenClawPluginModule;
|
2026-01-11 12:11:12 +00:00
|
|
|
} catch (err) {
|
2026-01-19 00:34:16 +00:00
|
|
|
logger.error(`[plugins] ${record.id} failed to load from ${record.source}: ${String(err)}`);
|
2026-01-11 12:11:12 +00:00
|
|
|
record.status = "error";
|
|
|
|
|
record.error = String(err);
|
|
|
|
|
registry.plugins.push(record);
|
2026-01-19 21:13:51 -06:00
|
|
|
seenIds.set(pluginId, candidate.origin);
|
2026-01-11 12:11:12 +00:00
|
|
|
registry.diagnostics.push({
|
|
|
|
|
level: "error",
|
|
|
|
|
pluginId: record.id,
|
|
|
|
|
source: record.source,
|
|
|
|
|
message: `failed to load plugin: ${String(err)}`,
|
|
|
|
|
});
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const resolved = resolvePluginModuleExport(mod);
|
|
|
|
|
const definition = resolved.definition;
|
|
|
|
|
const register = resolved.register;
|
|
|
|
|
|
|
|
|
|
if (definition?.id && definition.id !== record.id) {
|
|
|
|
|
registry.diagnostics.push({
|
|
|
|
|
level: "warn",
|
|
|
|
|
pluginId: record.id,
|
|
|
|
|
source: record.source,
|
|
|
|
|
message: `plugin id mismatch (config uses "${record.id}", export uses "${definition.id}")`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
record.name = definition?.name ?? record.name;
|
|
|
|
|
record.description = definition?.description ?? record.description;
|
|
|
|
|
record.version = definition?.version ?? record.version;
|
2026-01-19 21:13:51 -06:00
|
|
|
const manifestKind = record.kind as string | undefined;
|
|
|
|
|
const exportKind = definition?.kind as string | undefined;
|
|
|
|
|
if (manifestKind && exportKind && exportKind !== manifestKind) {
|
2026-01-19 03:38:51 +00:00
|
|
|
registry.diagnostics.push({
|
2026-01-19 21:13:51 -06:00
|
|
|
level: "warn",
|
2026-01-19 03:38:51 +00:00
|
|
|
pluginId: record.id,
|
|
|
|
|
source: record.source,
|
2026-01-19 21:13:51 -06:00
|
|
|
message: `plugin kind mismatch (manifest uses "${manifestKind}", export uses "${exportKind}")`,
|
2026-01-19 03:38:51 +00:00
|
|
|
});
|
|
|
|
|
}
|
2026-01-19 21:13:51 -06:00
|
|
|
record.kind = definition?.kind ?? record.kind;
|
2026-01-19 03:38:51 +00:00
|
|
|
|
2026-01-18 02:12:01 +00:00
|
|
|
if (record.kind === "memory" && memorySlot === record.id) {
|
|
|
|
|
memorySlotMatched = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const memoryDecision = resolveMemorySlotDecision({
|
|
|
|
|
id: record.id,
|
|
|
|
|
kind: record.kind,
|
|
|
|
|
slot: memorySlot,
|
|
|
|
|
selectedId: selectedMemoryPluginId,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!memoryDecision.enabled) {
|
|
|
|
|
record.enabled = false;
|
|
|
|
|
record.status = "disabled";
|
|
|
|
|
record.error = memoryDecision.reason;
|
|
|
|
|
registry.plugins.push(record);
|
2026-01-19 21:13:51 -06:00
|
|
|
seenIds.set(pluginId, candidate.origin);
|
2026-01-18 02:12:01 +00:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (memoryDecision.selected && record.kind === "memory") {
|
|
|
|
|
selectedMemoryPluginId = record.id;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-11 12:11:12 +00:00
|
|
|
const validatedConfig = validatePluginConfig({
|
2026-01-19 21:13:51 -06:00
|
|
|
schema: manifestRecord.configSchema,
|
|
|
|
|
cacheKey: manifestRecord.schemaCacheKey,
|
2026-01-11 12:11:12 +00:00
|
|
|
value: entry?.config,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!validatedConfig.ok) {
|
2026-01-19 00:34:16 +00:00
|
|
|
logger.error(`[plugins] ${record.id} invalid config: ${validatedConfig.errors?.join(", ")}`);
|
2026-01-11 12:11:12 +00:00
|
|
|
record.status = "error";
|
|
|
|
|
record.error = `invalid config: ${validatedConfig.errors?.join(", ")}`;
|
|
|
|
|
registry.plugins.push(record);
|
2026-01-19 21:13:51 -06:00
|
|
|
seenIds.set(pluginId, candidate.origin);
|
2026-01-11 12:11:12 +00:00
|
|
|
registry.diagnostics.push({
|
|
|
|
|
level: "error",
|
|
|
|
|
pluginId: record.id,
|
|
|
|
|
source: record.source,
|
|
|
|
|
message: record.error,
|
|
|
|
|
});
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-19 03:38:51 +00:00
|
|
|
if (validateOnly) {
|
|
|
|
|
registry.plugins.push(record);
|
2026-01-19 21:13:51 -06:00
|
|
|
seenIds.set(pluginId, candidate.origin);
|
2026-01-19 03:38:51 +00:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-11 12:11:12 +00:00
|
|
|
if (typeof register !== "function") {
|
2026-01-19 00:15:15 +00:00
|
|
|
logger.error(`[plugins] ${record.id} missing register/activate export`);
|
2026-01-11 12:11:12 +00:00
|
|
|
record.status = "error";
|
|
|
|
|
record.error = "plugin export missing register/activate";
|
|
|
|
|
registry.plugins.push(record);
|
2026-01-19 21:13:51 -06:00
|
|
|
seenIds.set(pluginId, candidate.origin);
|
2026-01-11 12:11:12 +00:00
|
|
|
registry.diagnostics.push({
|
|
|
|
|
level: "error",
|
|
|
|
|
pluginId: record.id,
|
|
|
|
|
source: record.source,
|
|
|
|
|
message: record.error,
|
|
|
|
|
});
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const api = createApi(record, {
|
|
|
|
|
config: cfg,
|
|
|
|
|
pluginConfig: validatedConfig.value,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = register(api);
|
2026-01-31 16:03:28 +09:00
|
|
|
if (result && typeof result.then === "function") {
|
2026-01-11 12:11:12 +00:00
|
|
|
registry.diagnostics.push({
|
|
|
|
|
level: "warn",
|
|
|
|
|
pluginId: record.id,
|
|
|
|
|
source: record.source,
|
2026-01-14 14:31:43 +00:00
|
|
|
message: "plugin register returned a promise; async registration is ignored",
|
2026-01-11 12:11:12 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
registry.plugins.push(record);
|
2026-01-19 21:13:51 -06:00
|
|
|
seenIds.set(pluginId, candidate.origin);
|
2026-01-11 12:11:12 +00:00
|
|
|
} catch (err) {
|
2026-01-19 00:15:15 +00:00
|
|
|
logger.error(
|
|
|
|
|
`[plugins] ${record.id} failed during register from ${record.source}: ${String(err)}`,
|
|
|
|
|
);
|
2026-01-11 12:11:12 +00:00
|
|
|
record.status = "error";
|
|
|
|
|
record.error = String(err);
|
|
|
|
|
registry.plugins.push(record);
|
2026-01-19 21:13:51 -06:00
|
|
|
seenIds.set(pluginId, candidate.origin);
|
2026-01-11 12:11:12 +00:00
|
|
|
registry.diagnostics.push({
|
|
|
|
|
level: "error",
|
|
|
|
|
pluginId: record.id,
|
|
|
|
|
source: record.source,
|
|
|
|
|
message: `plugin failed during register: ${String(err)}`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 02:12:01 +00:00
|
|
|
if (typeof memorySlot === "string" && !memorySlotMatched) {
|
|
|
|
|
registry.diagnostics.push({
|
|
|
|
|
level: "warn",
|
|
|
|
|
message: `memory slot plugin not found or not marked as memory: ${memorySlot}`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 15:13:34 +01:00
|
|
|
warnAboutUntrackedLoadedPlugins({
|
|
|
|
|
registry,
|
2026-02-19 15:24:02 +01:00
|
|
|
provenance,
|
2026-02-19 15:13:34 +01:00
|
|
|
logger,
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-11 12:11:12 +00:00
|
|
|
if (cacheEnabled) {
|
|
|
|
|
registryCache.set(cacheKey, registry);
|
|
|
|
|
}
|
2026-01-15 02:42:41 +00:00
|
|
|
setActivePluginRegistry(registry, cacheKey);
|
2026-01-18 05:40:58 +00:00
|
|
|
initializeGlobalHookRunner(registry);
|
2026-01-11 12:11:12 +00:00
|
|
|
return registry;
|
|
|
|
|
}
|