Plugins: extract loader candidate orchestration

This commit is contained in:
Gustavo Madeira Santana 2026-03-15 11:43:44 +00:00
parent d9a0fea97e
commit e1b207f4cf
No known key found for this signature in database
4 changed files with 494 additions and 194 deletions

View File

@ -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.

View File

@ -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> = {},
): PluginCandidate {
return {
source: entryPath,
rootDir,
packageDir: rootDir,
origin: "workspace",
workspaceDir: "/workspace",
...overrides,
};
}
function createManifestRecord(
rootDir: string,
entryPath: string,
overrides: Partial<PluginManifestRecord> = {},
): 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");
});
});

View File

@ -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<string, PluginRecord["origin"]>;
selectedMemoryPluginId: string | null;
createApi: (
record: PluginRecord,
options: {
config: OpenClawConfig;
pluginConfig?: Record<string, unknown>;
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,
};
}

View File

@ -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) {