From e1b207f4cff2ea9643b7c6b6bba5d055fd007064 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 15 Mar 2026 11:43:44 +0000 Subject: [PATCH] Plugins: extract loader candidate orchestration --- src/extension-host/cutover-inventory.md | 5 +- src/extension-host/loader-flow.test.ts | 206 ++++++++++++++++++ src/extension-host/loader-flow.ts | 277 ++++++++++++++++++++++++ src/plugins/loader.ts | 200 +---------------- 4 files changed, 494 insertions(+), 194 deletions(-) create mode 100644 src/extension-host/loader-flow.test.ts create mode 100644 src/extension-host/loader-flow.ts diff --git a/src/extension-host/cutover-inventory.md b/src/extension-host/cutover-inventory.md index 88a8e67b251..d5e6a77113d 100644 --- a/src/extension-host/cutover-inventory.md +++ b/src/extension-host/cutover-inventory.md @@ -37,6 +37,7 @@ This is an implementation checklist, not a future-design spec. | Loader entry-path opening and module import | `src/plugins/loader.ts` | `src/extension-host/loader-import.ts` | `partial` | Boundary-checked entry opening and module import now delegate through host-owned loader-import helpers while preserving the current trusted in-process loading model. | | Loader module-export, config-validation, and memory-slot decisions | `src/plugins/loader.ts` | `src/extension-host/loader-runtime.ts` | `partial` | Module export resolution, export-metadata application, config validation, and early or final memory-slot decisions now delegate through host-owned loader-runtime helpers. | | Loader post-import planning and register execution | `src/plugins/loader.ts` | `src/extension-host/loader-register.ts` | `partial` | Definition application, post-import validation planning, and `register(...)` execution now delegate through host-owned loader-register helpers while preserving current plugin behavior. | +| Loader per-candidate orchestration | `src/plugins/loader.ts` | `src/extension-host/loader-flow.ts` | `partial` | The per-candidate load flow now runs through a host-owned orchestrator that composes planning, import, runtime validation, register execution, and record-state helpers. | | Loader record-state transitions | `src/plugins/loader.ts` | `src/extension-host/loader-state.ts` | `partial` | Disabled, error, and appended plugin-record state transitions now delegate through host-owned loader-state helpers; a real lifecycle state machine still does not exist. | | Channel lookup | `src/channels/plugins/index.ts`, `src/channels/plugins/registry-loader.ts`, `src/channels/registry.ts` | extension-host-backed registries plus kernel channel contracts | `partial` | Readers now consume the host-owned active registry, but writes still originate from plugin registration. | | Dock lookup | `src/channels/dock.ts` | host-owned static descriptors | `partial` | Runtime lookup now uses the host boundary; dock ownership itself has not moved yet. | @@ -83,14 +84,14 @@ That pattern has been used for: - active registry ownership - normalized extension schema and resolved-extension records - static consumers such as skills, validation, auto-enable, and config baseline generation -- loader compatibility, initial candidate planning, entry-path import, policy, runtime decisions, post-import register flow, and record-state transitions +- loader compatibility, initial candidate planning, entry-path import, policy, runtime decisions, post-import register flow, per-candidate orchestration, and record-state transitions ## Immediate Next Targets These are the next lowest-risk cutover steps: 1. Replace remaining static-only manifest-registry injections with resolved-extension registry inputs where practical. -2. Move the remaining loader orchestration into `src/extension-host/*`, especially cache wiring, final registry append flow, enablement completion, and lifecycle-state transitions. +2. Move the remaining loader orchestration into `src/extension-host/*`, especially cache wiring, final registry finalization, and lifecycle-state transitions. 3. Introduce explicit host-owned registration surfaces for runtime writes, starting with the least-coupled registries. 4. Move minimal SDK compatibility and loader normalization into `src/extension-host/*` without breaking current `openclaw/plugin-sdk/*` loading. 5. Start the first pilot on `extensions/thread-ownership` only after the host-side registry and lifecycle seams are explicit. diff --git a/src/extension-host/loader-flow.test.ts b/src/extension-host/loader-flow.test.ts new file mode 100644 index 00000000000..d6fc6ca8dd9 --- /dev/null +++ b/src/extension-host/loader-flow.test.ts @@ -0,0 +1,206 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { normalizePluginsConfig } from "../plugins/config-state.js"; +import type { PluginCandidate } from "../plugins/discovery.js"; +import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; +import type { PluginRegistry } from "../plugins/registry.js"; +import { processExtensionHostPluginCandidate } from "./loader-flow.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +function createTempPluginFixture() { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-loader-flow-")); + tempDirs.push(rootDir); + const entryPath = path.join(rootDir, "index.js"); + fs.writeFileSync(entryPath, "export default {}"); + return { rootDir, entryPath }; +} + +function createCandidate( + rootDir: string, + entryPath: string, + overrides: Partial = {}, +): PluginCandidate { + return { + source: entryPath, + rootDir, + packageDir: rootDir, + origin: "workspace", + workspaceDir: "/workspace", + ...overrides, + }; +} + +function createManifestRecord( + rootDir: string, + entryPath: string, + overrides: Partial = {}, +): PluginManifestRecord { + return { + id: "demo", + name: "Demo", + description: "Demo plugin", + version: "1.0.0", + kind: "context-engine", + channels: [], + providers: [], + skills: [], + origin: "workspace", + workspaceDir: "/workspace", + rootDir, + source: entryPath, + manifestPath: path.join(rootDir, "openclaw.plugin.json"), + schemaCacheKey: "demo-schema", + configSchema: { + type: "object", + properties: { + enabled: { type: "boolean" }, + }, + additionalProperties: false, + }, + resolvedExtension: { + id: "demo", + source: "/plugins/demo/index.ts", + origin: "workspace", + rootDir: "/plugins/demo", + workspaceDir: "/workspace", + static: { + package: {}, + config: {}, + setup: {}, + }, + runtime: { + kind: "context-engine", + contributions: [], + }, + policy: {}, + }, + ...overrides, + }; +} + +function createRegistry(): PluginRegistry { + return { + plugins: [], + tools: [], + hooks: [], + typedHooks: [], + channels: [], + providers: [], + gatewayHandlers: {}, + httpRoutes: [], + cliRegistrars: [], + services: [], + commands: [], + diagnostics: [], + }; +} + +describe("extension host loader flow", () => { + it("handles validate-only candidates through the host orchestrator", () => { + const { rootDir, entryPath } = createTempPluginFixture(); + const registry = createRegistry(); + + const result = processExtensionHostPluginCandidate({ + candidate: createCandidate(rootDir, entryPath), + manifestRecord: createManifestRecord(rootDir, entryPath), + normalizedConfig: normalizePluginsConfig({ + entries: { + demo: { + enabled: true, + config: { enabled: true }, + }, + }, + }), + rootConfig: { + plugins: { + entries: { + demo: { + enabled: true, + config: { enabled: true }, + }, + }, + }, + }, + validateOnly: true, + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + }, + registry, + seenIds: new Map(), + selectedMemoryPluginId: null, + createApi: () => ({}) as never, + loadModule: () => + ({ + default: { + id: "demo", + register: () => {}, + }, + }) as never, + }); + + expect(result).toEqual({ + selectedMemoryPluginId: null, + memorySlotMatched: false, + }); + expect(registry.plugins).toHaveLength(1); + expect(registry.plugins[0]?.id).toBe("demo"); + expect(registry.plugins[0]?.status).toBe("loaded"); + }); + + it("records import failures through the existing plugin error path", () => { + const { rootDir, entryPath } = createTempPluginFixture(); + const registry = createRegistry(); + + processExtensionHostPluginCandidate({ + candidate: createCandidate(rootDir, entryPath), + manifestRecord: createManifestRecord(rootDir, entryPath), + normalizedConfig: normalizePluginsConfig({ + entries: { + demo: { + enabled: true, + }, + }, + }), + rootConfig: { + plugins: { + entries: { + demo: { + enabled: true, + }, + }, + }, + }, + validateOnly: false, + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + }, + registry, + seenIds: new Map(), + selectedMemoryPluginId: null, + createApi: () => ({}) as never, + loadModule: () => { + throw new Error("boom"); + }, + }); + + expect(registry.plugins).toHaveLength(1); + expect(registry.plugins[0]?.status).toBe("error"); + expect(registry.diagnostics[0]?.message).toContain("failed to load plugin"); + }); +}); diff --git a/src/extension-host/loader-flow.ts b/src/extension-host/loader-flow.ts new file mode 100644 index 00000000000..83312f23da0 --- /dev/null +++ b/src/extension-host/loader-flow.ts @@ -0,0 +1,277 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { PluginCandidate } from "../plugins/discovery.js"; +import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; +import type { PluginRecord, PluginRegistry } from "../plugins/registry.js"; +import type { OpenClawPluginApi, OpenClawPluginModule, PluginLogger } from "../plugins/types.js"; +import { importExtensionHostPluginModule } from "./loader-import.js"; +import { recordExtensionHostPluginError } from "./loader-policy.js"; +import { prepareExtensionHostPluginCandidate } from "./loader-records.js"; +import { + planExtensionHostLoadedPlugin, + runExtensionHostPluginRegister, +} from "./loader-register.js"; +import { + resolveExtensionHostEarlyMemoryDecision, + resolveExtensionHostModuleExport, +} from "./loader-runtime.js"; +import { + appendExtensionHostPluginRecord, + setExtensionHostPluginRecordDisabled, + setExtensionHostPluginRecordError, +} from "./loader-state.js"; + +export function processExtensionHostPluginCandidate(params: { + candidate: PluginCandidate; + manifestRecord: PluginManifestRecord; + normalizedConfig: { + entries: Record< + string, + { + enabled?: boolean; + hooks?: { + allowPromptInjection?: boolean; + }; + config?: unknown; + } + >; + slots: { + memory?: string | null; + }; + }; + rootConfig: OpenClawConfig; + validateOnly: boolean; + logger: PluginLogger; + registry: PluginRegistry; + seenIds: Map; + selectedMemoryPluginId: string | null; + createApi: ( + record: PluginRecord, + options: { + config: OpenClawConfig; + pluginConfig?: Record; + hookPolicy?: { allowPromptInjection?: boolean }; + }, + ) => OpenClawPluginApi; + loadModule: (safeSource: string) => OpenClawPluginModule; +}): { selectedMemoryPluginId: string | null; memorySlotMatched: boolean } { + const { candidate, manifestRecord } = params; + const pluginId = manifestRecord.id; + const preparedCandidate = prepareExtensionHostPluginCandidate({ + candidate, + manifestRecord, + normalizedConfig: params.normalizedConfig, + rootConfig: params.rootConfig, + seenIds: params.seenIds, + }); + if (preparedCandidate.kind === "duplicate") { + appendExtensionHostPluginRecord({ + registry: params.registry, + record: preparedCandidate.record, + }); + return { + selectedMemoryPluginId: params.selectedMemoryPluginId, + memorySlotMatched: false, + }; + } + + const { record, entry, enableState } = preparedCandidate; + const pushPluginLoadError = (message: string) => { + setExtensionHostPluginRecordError(record, message); + appendExtensionHostPluginRecord({ + registry: params.registry, + record, + seenIds: params.seenIds, + pluginId, + origin: candidate.origin, + }); + params.registry.diagnostics.push({ + level: "error", + pluginId: record.id, + source: record.source, + message: record.error, + }); + }; + + if (!enableState.enabled) { + setExtensionHostPluginRecordDisabled(record, enableState.reason); + appendExtensionHostPluginRecord({ + registry: params.registry, + record, + seenIds: params.seenIds, + pluginId, + origin: candidate.origin, + }); + return { + selectedMemoryPluginId: params.selectedMemoryPluginId, + memorySlotMatched: false, + }; + } + + const earlyMemoryDecision = resolveExtensionHostEarlyMemoryDecision({ + origin: candidate.origin, + manifestKind: manifestRecord.kind, + recordId: record.id, + memorySlot: params.normalizedConfig.slots.memory, + selectedMemoryPluginId: params.selectedMemoryPluginId, + }); + if (!earlyMemoryDecision.enabled) { + setExtensionHostPluginRecordDisabled(record, earlyMemoryDecision.reason); + appendExtensionHostPluginRecord({ + registry: params.registry, + record, + seenIds: params.seenIds, + pluginId, + origin: candidate.origin, + }); + return { + selectedMemoryPluginId: params.selectedMemoryPluginId, + memorySlotMatched: false, + }; + } + + if (!manifestRecord.configSchema) { + pushPluginLoadError("missing config schema"); + return { + selectedMemoryPluginId: params.selectedMemoryPluginId, + memorySlotMatched: false, + }; + } + + const moduleImport = importExtensionHostPluginModule({ + rootDir: candidate.rootDir, + source: candidate.source, + origin: candidate.origin, + loadModule: params.loadModule, + }); + if (!moduleImport.ok) { + if (moduleImport.message !== "failed to load plugin") { + pushPluginLoadError(moduleImport.message); + return { + selectedMemoryPluginId: params.selectedMemoryPluginId, + memorySlotMatched: false, + }; + } + recordExtensionHostPluginError({ + logger: params.logger, + registry: params.registry, + record, + seenIds: params.seenIds, + pluginId, + origin: candidate.origin, + error: moduleImport.error, + logPrefix: `[plugins] ${record.id} failed to load from ${record.source}: `, + diagnosticMessagePrefix: "failed to load plugin: ", + }); + return { + selectedMemoryPluginId: params.selectedMemoryPluginId, + memorySlotMatched: false, + }; + } + + const resolved = resolveExtensionHostModuleExport(moduleImport.module); + const loadedPlan = planExtensionHostLoadedPlugin({ + record, + manifestRecord, + definition: resolved.definition, + register: resolved.register, + diagnostics: params.registry.diagnostics, + memorySlot: params.normalizedConfig.slots.memory, + selectedMemoryPluginId: params.selectedMemoryPluginId, + entryConfig: entry?.config, + validateOnly: params.validateOnly, + }); + + if (loadedPlan.kind === "error") { + pushPluginLoadError(loadedPlan.message); + return { + selectedMemoryPluginId: loadedPlan.selectedMemoryPluginId, + memorySlotMatched: loadedPlan.memorySlotMatched, + }; + } + + if (loadedPlan.kind === "disabled") { + setExtensionHostPluginRecordDisabled(record, loadedPlan.reason); + appendExtensionHostPluginRecord({ + registry: params.registry, + record, + seenIds: params.seenIds, + pluginId, + origin: candidate.origin, + }); + return { + selectedMemoryPluginId: loadedPlan.selectedMemoryPluginId, + memorySlotMatched: loadedPlan.memorySlotMatched, + }; + } + + if (loadedPlan.kind === "invalid-config") { + params.logger.error(`[plugins] ${record.id} ${loadedPlan.message}`); + pushPluginLoadError(loadedPlan.message); + return { + selectedMemoryPluginId: loadedPlan.selectedMemoryPluginId, + memorySlotMatched: loadedPlan.memorySlotMatched, + }; + } + + if (loadedPlan.kind === "validate-only") { + appendExtensionHostPluginRecord({ + registry: params.registry, + record, + seenIds: params.seenIds, + pluginId, + origin: candidate.origin, + }); + return { + selectedMemoryPluginId: loadedPlan.selectedMemoryPluginId, + memorySlotMatched: loadedPlan.memorySlotMatched, + }; + } + + if (loadedPlan.kind === "missing-register") { + params.logger.error(`[plugins] ${record.id} missing register/activate export`); + pushPluginLoadError(loadedPlan.message); + return { + selectedMemoryPluginId: loadedPlan.selectedMemoryPluginId, + memorySlotMatched: loadedPlan.memorySlotMatched, + }; + } + + const registerResult = runExtensionHostPluginRegister({ + register: loadedPlan.register, + createApi: params.createApi, + record, + config: params.rootConfig, + pluginConfig: loadedPlan.pluginConfig, + hookPolicy: entry?.hooks, + diagnostics: params.registry.diagnostics, + }); + if (!registerResult.ok) { + recordExtensionHostPluginError({ + logger: params.logger, + registry: params.registry, + record, + seenIds: params.seenIds, + pluginId, + origin: candidate.origin, + error: registerResult.error, + logPrefix: `[plugins] ${record.id} failed during register from ${record.source}: `, + diagnosticMessagePrefix: "plugin failed during register: ", + }); + return { + selectedMemoryPluginId: loadedPlan.selectedMemoryPluginId, + memorySlotMatched: loadedPlan.memorySlotMatched, + }; + } + + appendExtensionHostPluginRecord({ + registry: params.registry, + record, + seenIds: params.seenIds, + pluginId, + origin: candidate.origin, + }); + return { + selectedMemoryPluginId: loadedPlan.selectedMemoryPluginId, + memorySlotMatched: loadedPlan.memorySlotMatched, + }; +} diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 177d5881680..b9880264109 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -10,29 +10,14 @@ import { resolvePluginSdkAliasFile, resolvePluginSdkScopedAliasMap, } from "../extension-host/loader-compat.js"; -import { importExtensionHostPluginModule } from "../extension-host/loader-import.js"; +import { processExtensionHostPluginCandidate } from "../extension-host/loader-flow.js"; import { buildExtensionHostProvenanceIndex, compareExtensionHostDuplicateCandidateOrder, pushExtensionHostDiagnostics, - recordExtensionHostPluginError, warnAboutUntrackedLoadedExtensions, warnWhenExtensionAllowlistIsOpen, } from "../extension-host/loader-policy.js"; -import { prepareExtensionHostPluginCandidate } from "../extension-host/loader-records.js"; -import { - planExtensionHostLoadedPlugin, - runExtensionHostPluginRegister, -} from "../extension-host/loader-register.js"; -import { - resolveExtensionHostEarlyMemoryDecision, - resolveExtensionHostModuleExport, -} from "../extension-host/loader-runtime.js"; -import { - appendExtensionHostPluginRecord, - setExtensionHostPluginRecordDisabled, - setExtensionHostPluginRecordError, -} from "../extension-host/loader-state.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveUserPath } from "../utils.js"; @@ -287,190 +272,21 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi if (!manifestRecord) { continue; } - const pluginId = manifestRecord.id; - const preparedCandidate = prepareExtensionHostPluginCandidate({ + const processed = processExtensionHostPluginCandidate({ candidate, manifestRecord, normalizedConfig: normalized, rootConfig: cfg, - seenIds, - }); - if (preparedCandidate.kind === "duplicate") { - const { record } = preparedCandidate; - appendExtensionHostPluginRecord({ registry, record }); - continue; - } - const { record, entry, enableState } = preparedCandidate; - 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; - } - - // 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, - }); - if (loadedPlan.memorySlotMatched) { - memorySlotMatched = true; - } - selectedMemoryPluginId = loadedPlan.selectedMemoryPluginId; - - if (loadedPlan.kind === "error") { - pushPluginLoadError(loadedPlan.message); - continue; - } - - if (loadedPlan.kind === "disabled") { - setExtensionHostPluginRecordDisabled(record, loadedPlan.reason); - appendExtensionHostPluginRecord({ - registry, - record, - seenIds, - pluginId, - origin: candidate.origin, - }); - continue; - } - - if (loadedPlan.kind === "invalid-config") { - logger.error(`[plugins] ${record.id} ${loadedPlan.message}`); - pushPluginLoadError(loadedPlan.message); - continue; - } - - if (loadedPlan.kind === "validate-only") { - appendExtensionHostPluginRecord({ - registry, - record, - seenIds, - pluginId, - origin: candidate.origin, - }); - continue; - } - - if (loadedPlan.kind === "missing-register") { - logger.error(`[plugins] ${record.id} missing register/activate export`); - pushPluginLoadError(loadedPlan.message); - continue; - } - - const registerResult = runExtensionHostPluginRegister({ - register: loadedPlan.register, - createApi, - record, - config: cfg, - pluginConfig: loadedPlan.pluginConfig, - hookPolicy: entry?.hooks, - diagnostics: registry.diagnostics, - }); - if (!registerResult.ok) { - recordExtensionHostPluginError({ - logger, - registry, - record, - seenIds, - pluginId, - origin: candidate.origin, - error: registerResult.error, - logPrefix: `[plugins] ${record.id} failed during register from ${record.source}: `, - diagnosticMessagePrefix: "plugin failed during register: ", - }); - continue; - } - appendExtensionHostPluginRecord({ + logger, registry, - record, seenIds, - pluginId, - origin: candidate.origin, + selectedMemoryPluginId, + createApi, + loadModule: (safeSource) => getJiti()(safeSource) as OpenClawPluginModule, }); + selectedMemoryPluginId = processed.selectedMemoryPluginId; + memorySlotMatched ||= processed.memorySlotMatched; } if (typeof memorySlot === "string" && !memorySlotMatched) {