openclaw/src/plugins/loader.ts
Hunter Miller 3bf74611c2 fix(plugins): auto-install dependencies for bundled plugins
Bundled plugins ship with source but not node_modules. When a bundled
plugin is enabled and has external dependencies, the loader now
automatically runs npm install on first load.

This fixes the issue where plugins like tlon (which depend on
@urbit/http-api and @urbit/aura) would fail to load from a fresh
npm install because their dependencies weren't installed.

Changes:
- loader.ts: Add ensureBundledPluginDeps() to install deps before loading
- docs/channels/tlon.md: Update to reflect bundled status and auto-install
- docs/plugin.md: Add tlon to plugin list, update dep install guidance
- docs/reference/RELEASING.md: Clarify bundled vs npm-published plugins
2026-01-26 12:31:26 -06:00

532 lines
17 KiB
TypeScript

import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createJiti } from "jiti";
import type { ClawdbotConfig } from "../config/config.js";
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveUserPath } from "../utils.js";
import { discoverClawdbotPlugins } from "./discovery.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import {
normalizePluginsConfig,
resolveEnableState,
resolveMemorySlotDecision,
type NormalizedPluginsConfig,
} from "./config-state.js";
import { initializeGlobalHookRunner } from "./hook-runner-global.js";
import { clearPluginCommands } from "./commands.js";
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
import { createPluginRuntime } from "./runtime/index.js";
import { setActivePluginRegistry } from "./runtime.js";
import { validateJsonSchemaValue } from "./schema-validator.js";
import type {
ClawdbotPluginDefinition,
ClawdbotPluginModule,
PluginDiagnostic,
PluginLogger,
} from "./types.js";
export type PluginLoadResult = PluginRegistry;
export type PluginLoadOptions = {
config?: ClawdbotConfig;
workspaceDir?: string;
logger?: PluginLogger;
coreGatewayHandlers?: Record<string, GatewayRequestHandler>;
cache?: boolean;
mode?: "full" | "validate";
};
const registryCache = new Map<string, PluginRegistry>();
const defaultLogger = () => createSubsystemLogger("plugins");
/**
* Ensures plugin dependencies are installed for bundled plugins.
* Bundled plugins ship with source but not node_modules; this installs
* deps on first load if the plugin has dependencies in package.json.
*/
function ensureBundledPluginDeps(params: {
rootDir: string;
pluginId: string;
logger: PluginLogger;
}): { ok: boolean; error?: string } {
const { rootDir, pluginId, logger } = params;
const packageJsonPath = path.join(rootDir, "package.json");
if (!fs.existsSync(packageJsonPath)) {
return { ok: true }; // No package.json, nothing to install
}
let manifest: { dependencies?: Record<string, string> };
try {
manifest = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
} catch {
return { ok: true }; // Can't parse, skip
}
const deps = manifest.dependencies ?? {};
const depNames = Object.keys(deps).filter(
(name) => !name.startsWith("clawdbot") && name !== "clawdbot",
);
if (depNames.length === 0) {
return { ok: true }; // No external dependencies
}
// Check if first dependency exists in node_modules
const firstDep = depNames[0];
const depPath = path.join(rootDir, "node_modules", ...firstDep.split("/"));
if (fs.existsSync(depPath)) {
return { ok: true }; // Dependencies already installed
}
logger.info?.(`[plugins] ${pluginId}: installing dependencies…`);
try {
const result = spawnSync("npm", ["install", "--omit=dev", "--silent"], {
cwd: rootDir,
encoding: "utf-8",
timeout: 300_000,
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
const errorMsg = result.stderr?.trim() || result.stdout?.trim() || "unknown error";
return { ok: false, error: `npm install failed: ${errorMsg}` };
}
logger.info?.(`[plugins] ${pluginId}: dependencies installed`);
return { ok: true };
} catch (err) {
return { ok: false, error: `failed to install dependencies: ${String(err)}` };
}
}
const resolvePluginSdkAlias = (): string | null => {
try {
const modulePath = fileURLToPath(import.meta.url);
const isDistRuntime = modulePath.split(path.sep).includes("dist");
const preferDist = process.env.VITEST || process.env.NODE_ENV === "test" || isDistRuntime;
let cursor = path.dirname(modulePath);
for (let i = 0; i < 6; i += 1) {
const srcCandidate = path.join(cursor, "src", "plugin-sdk", "index.ts");
const distCandidate = path.join(cursor, "dist", "plugin-sdk", "index.js");
const orderedCandidates = preferDist
? [distCandidate, srcCandidate]
: [srcCandidate, distCandidate];
for (const candidate of orderedCandidates) {
if (fs.existsSync(candidate)) return candidate;
}
const parent = path.dirname(cursor);
if (parent === cursor) break;
cursor = parent;
}
} catch {
// ignore
}
return null;
};
function buildCacheKey(params: {
workspaceDir?: string;
plugins: NormalizedPluginsConfig;
}): string {
const workspaceKey = params.workspaceDir ? resolveUserPath(params.workspaceDir) : "";
return `${workspaceKey}::${JSON.stringify(params.plugins)}`;
}
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 };
}
function resolvePluginModuleExport(moduleExport: unknown): {
definition?: ClawdbotPluginDefinition;
register?: ClawdbotPluginDefinition["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 ClawdbotPluginDefinition["register"],
};
}
if (resolved && typeof resolved === "object") {
const def = resolved as ClawdbotPluginDefinition;
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: [],
hookNames: [],
channelIds: [],
providerIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpHandlers: 0,
hookCount: 0,
configSchema: params.configSchema,
configUiHints: undefined,
configJsonSchema: undefined,
};
}
function pushDiagnostics(diagnostics: PluginDiagnostic[], append: PluginDiagnostic[]) {
diagnostics.push(...append);
}
export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegistry {
const cfg = options.config ?? {};
const logger = options.logger ?? defaultLogger();
const validateOnly = options.mode === "validate";
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);
if (cached) {
setActivePluginRegistry(cached, cacheKey);
return cached;
}
}
// Clear previously registered plugin commands before reloading
clearPluginCommands();
const runtime = createPluginRuntime();
const { registry, createApi } = createPluginRegistry({
logger,
runtime,
coreGatewayHandlers: options.coreGatewayHandlers as Record<string, GatewayRequestHandler>,
});
const discovery = discoverClawdbotPlugins({
workspaceDir: options.workspaceDir,
extraPaths: normalized.loadPaths,
});
const manifestRegistry = loadPluginManifestRegistry({
config: cfg,
workspaceDir: options.workspaceDir,
cache: options.cache,
candidates: discovery.candidates,
diagnostics: discovery.diagnostics,
});
pushDiagnostics(registry.diagnostics, manifestRegistry.diagnostics);
const pluginSdkAlias = resolvePluginSdkAlias();
const jiti = createJiti(import.meta.url, {
interopDefault: true,
extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"],
...(pluginSdkAlias ? { alias: { "clawdbot/plugin-sdk": pluginSdkAlias } } : {}),
});
const manifestByRoot = new Map(
manifestRegistry.plugins.map((record) => [record.rootDir, record]),
);
const seenIds = new Map<string, PluginRecord["origin"]>();
const memorySlot = normalized.slots.memory;
let selectedMemoryPluginId: string | null = null;
let memorySlotMatched = false;
for (const candidate of discovery.candidates) {
const manifestRecord = manifestByRoot.get(candidate.rootDir);
if (!manifestRecord) {
continue;
}
const pluginId = manifestRecord.id;
const existingOrigin = seenIds.get(pluginId);
if (existingOrigin) {
const record = createPluginRecord({
id: pluginId,
name: manifestRecord.name ?? pluginId,
description: manifestRecord.description,
version: manifestRecord.version,
source: candidate.source,
origin: candidate.origin,
workspaceDir: candidate.workspaceDir,
enabled: false,
configSchema: Boolean(manifestRecord.configSchema),
});
record.status = "disabled";
record.error = `overridden by ${existingOrigin} plugin`;
registry.plugins.push(record);
continue;
}
const enableState = resolveEnableState(pluginId, candidate.origin, normalized);
const entry = normalized.entries[pluginId];
const record = createPluginRecord({
id: pluginId,
name: manifestRecord.name ?? pluginId,
description: manifestRecord.description,
version: manifestRecord.version,
source: candidate.source,
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;
if (!enableState.enabled) {
record.status = "disabled";
record.error = enableState.reason;
registry.plugins.push(record);
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,
});
continue;
}
// Ensure bundled plugin dependencies are installed before loading
if (candidate.origin === "bundled") {
const depsResult = ensureBundledPluginDeps({
rootDir: candidate.rootDir,
pluginId,
logger,
});
if (!depsResult.ok) {
logger.error(`[plugins] ${record.id} failed to install deps: ${depsResult.error}`);
record.status = "error";
record.error = depsResult.error ?? "failed to install dependencies";
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
registry.diagnostics.push({
level: "error",
pluginId: record.id,
source: record.source,
message: record.error,
});
continue;
}
}
let mod: ClawdbotPluginModule | null = null;
try {
mod = jiti(candidate.source) as ClawdbotPluginModule;
} catch (err) {
logger.error(`[plugins] ${record.id} failed to load from ${record.source}: ${String(err)}`);
record.status = "error";
record.error = String(err);
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
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;
const manifestKind = record.kind as string | undefined;
const exportKind = definition?.kind as string | undefined;
if (manifestKind && exportKind && exportKind !== manifestKind) {
registry.diagnostics.push({
level: "warn",
pluginId: record.id,
source: record.source,
message: `plugin kind mismatch (manifest uses "${manifestKind}", export uses "${exportKind}")`,
});
}
record.kind = definition?.kind ?? record.kind;
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);
seenIds.set(pluginId, candidate.origin);
continue;
}
if (memoryDecision.selected && record.kind === "memory") {
selectedMemoryPluginId = record.id;
}
const validatedConfig = validatePluginConfig({
schema: manifestRecord.configSchema,
cacheKey: manifestRecord.schemaCacheKey,
value: entry?.config,
});
if (!validatedConfig.ok) {
logger.error(`[plugins] ${record.id} invalid config: ${validatedConfig.errors?.join(", ")}`);
record.status = "error";
record.error = `invalid config: ${validatedConfig.errors?.join(", ")}`;
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
registry.diagnostics.push({
level: "error",
pluginId: record.id,
source: record.source,
message: record.error,
});
continue;
}
if (validateOnly) {
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
continue;
}
if (typeof register !== "function") {
logger.error(`[plugins] ${record.id} missing register/activate export`);
record.status = "error";
record.error = "plugin export missing register/activate";
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
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);
if (result && typeof (result as Promise<void>).then === "function") {
registry.diagnostics.push({
level: "warn",
pluginId: record.id,
source: record.source,
message: "plugin register returned a promise; async registration is ignored",
});
}
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
} catch (err) {
logger.error(
`[plugins] ${record.id} failed during register from ${record.source}: ${String(err)}`,
);
record.status = "error";
record.error = String(err);
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
registry.diagnostics.push({
level: "error",
pluginId: record.id,
source: record.source,
message: `plugin failed during register: ${String(err)}`,
});
}
}
if (typeof memorySlot === "string" && !memorySlotMatched) {
registry.diagnostics.push({
level: "warn",
message: `memory slot plugin not found or not marked as memory: ${memorySlot}`,
});
}
if (cacheEnabled) {
registryCache.set(cacheKey, registry);
}
setActivePluginRegistry(registry, cacheKey);
initializeGlobalHookRunner(registry);
return registry;
}