diff --git a/src/extension-host/cutover-inventory.md b/src/extension-host/cutover-inventory.md index f5005c61a5c..c295457b6a9 100644 --- a/src/extension-host/cutover-inventory.md +++ b/src/extension-host/cutover-inventory.md @@ -35,6 +35,7 @@ This is an implementation checklist, not a future-design spec. | Loader provenance and duplicate-order policy | `src/plugins/loader.ts` | `src/extension-host/loader-policy.ts` | `partial` | Plugin-record creation, duplicate precedence, provenance indexing, and allowlist/untracked warnings now live in host-owned loader-policy helpers. | | Loader initial candidate planning and record creation | `src/plugins/loader.ts` | `src/extension-host/loader-records.ts` | `partial` | Duplicate detection, initial record creation, manifest metadata attachment, and first-pass enable-state planning now delegate through host-owned loader-records helpers. | | 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 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. | @@ -81,14 +82,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, policy, runtime decisions, and record-state transitions +- loader compatibility, initial candidate planning, policy, runtime decisions, post-import register flow, 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 per-plugin import and registration flow, enablement completion, and lifecycle-state transitions. +2. Move the remaining loader orchestration into `src/extension-host/*`, especially entry-path opening and import flow, enablement completion, 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-register.test.ts b/src/extension-host/loader-register.test.ts new file mode 100644 index 00000000000..7139299bc4b --- /dev/null +++ b/src/extension-host/loader-register.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import type { PluginDiagnostic } from "../plugins/types.js"; +import { createExtensionHostPluginRecord } from "./loader-policy.js"; +import { + planExtensionHostLoadedPlugin, + runExtensionHostPluginRegister, +} from "./loader-register.js"; + +describe("extension host loader register", () => { + it("returns a register plan for valid loaded plugins", () => { + const record = createExtensionHostPluginRecord({ + id: "demo", + source: "/plugins/demo.js", + origin: "workspace", + enabled: true, + configSchema: true, + }); + const diagnostics: PluginDiagnostic[] = []; + + const plan = planExtensionHostLoadedPlugin({ + record, + manifestRecord: { + configSchema: { + type: "object", + properties: { + enabled: { type: "boolean" }, + }, + additionalProperties: false, + }, + }, + definition: { + id: "demo", + }, + register: () => {}, + diagnostics, + selectedMemoryPluginId: null, + entryConfig: { enabled: true }, + validateOnly: false, + }); + + expect(plan).toMatchObject({ + kind: "register", + pluginConfig: { enabled: true }, + selectedMemoryPluginId: null, + }); + }); + + it("returns invalid-config plans with the normalized message", () => { + const record = createExtensionHostPluginRecord({ + id: "demo", + source: "/plugins/demo.js", + origin: "workspace", + enabled: true, + configSchema: true, + }); + + const plan = planExtensionHostLoadedPlugin({ + record, + manifestRecord: { + configSchema: { + type: "object", + properties: { + enabled: { type: "boolean" }, + }, + additionalProperties: false, + }, + }, + diagnostics: [], + selectedMemoryPluginId: null, + entryConfig: { nope: true }, + validateOnly: false, + }); + + expect(plan.kind).toBe("invalid-config"); + expect(plan.message).toContain("invalid config:"); + }); + + it("returns missing-register plans when validation passes but no register function exists", () => { + const record = createExtensionHostPluginRecord({ + id: "demo", + source: "/plugins/demo.js", + origin: "workspace", + enabled: true, + configSchema: true, + }); + + expect( + planExtensionHostLoadedPlugin({ + record, + manifestRecord: { + configSchema: { + type: "object", + }, + }, + diagnostics: [], + selectedMemoryPluginId: null, + validateOnly: false, + }), + ).toMatchObject({ + kind: "missing-register", + message: "plugin export missing register/activate", + }); + }); + + it("runs register through the provided api factory and records async warnings", () => { + const record = createExtensionHostPluginRecord({ + id: "demo", + source: "/plugins/demo.js", + origin: "workspace", + enabled: true, + configSchema: true, + }); + const diagnostics: PluginDiagnostic[] = []; + let apiSeen = false; + + const result = runExtensionHostPluginRegister({ + register: async (api) => { + apiSeen = api.id === "demo"; + }, + createApi: (pluginRecord, options) => + ({ + id: pluginRecord.id, + name: pluginRecord.name, + source: pluginRecord.source, + config: options.config, + pluginConfig: options.pluginConfig, + }) as never, + record, + config: {}, + pluginConfig: { enabled: true }, + diagnostics, + }); + + expect(result).toEqual({ ok: true }); + expect(apiSeen).toBe(true); + expect(diagnostics).toContainEqual({ + level: "warn", + pluginId: "demo", + source: "/plugins/demo.js", + message: "plugin register returned a promise; async registration is ignored", + }); + }); +}); diff --git a/src/extension-host/loader-register.ts b/src/extension-host/loader-register.ts new file mode 100644 index 00000000000..04596f3ff0f --- /dev/null +++ b/src/extension-host/loader-register.ts @@ -0,0 +1,186 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; +import type { PluginRecord } from "../plugins/registry.js"; +import type { + OpenClawPluginApi, + OpenClawPluginDefinition, + OpenClawPluginHookOptions, + PluginDiagnostic, +} from "../plugins/types.js"; +import { + applyExtensionHostDefinitionToRecord, + resolveExtensionHostMemoryDecision, + validateExtensionHostConfig, +} from "./loader-runtime.js"; + +export type ExtensionHostLoadedPluginPlan = + | { + kind: "disabled"; + reason?: string; + memorySlotMatched: boolean; + selectedMemoryPluginId: string | null; + } + | { + kind: "invalid-config"; + message: string; + errors: string[]; + memorySlotMatched: boolean; + selectedMemoryPluginId: string | null; + } + | { + kind: "validate-only"; + memorySlotMatched: boolean; + selectedMemoryPluginId: string | null; + } + | { + kind: "missing-register"; + message: string; + memorySlotMatched: boolean; + selectedMemoryPluginId: string | null; + } + | { + kind: "register"; + register: NonNullable; + pluginConfig?: Record; + memorySlotMatched: boolean; + selectedMemoryPluginId: string | null; + } + | { + kind: "error"; + message: string; + memorySlotMatched: boolean; + selectedMemoryPluginId: string | null; + }; + +export function planExtensionHostLoadedPlugin(params: { + record: PluginRecord; + manifestRecord: Pick; + definition?: OpenClawPluginDefinition; + register?: OpenClawPluginDefinition["register"]; + diagnostics: PluginDiagnostic[]; + memorySlot?: string | null; + selectedMemoryPluginId: string | null; + entryConfig?: unknown; + validateOnly: boolean; +}): ExtensionHostLoadedPluginPlan { + const definitionResult = applyExtensionHostDefinitionToRecord({ + record: params.record, + definition: params.definition, + diagnostics: params.diagnostics, + }); + const memorySlotMatched = + params.record.kind === "memory" && params.memorySlot === params.record.id; + if (!definitionResult.ok) { + return { + kind: "error", + message: definitionResult.message, + memorySlotMatched, + selectedMemoryPluginId: params.selectedMemoryPluginId, + }; + } + + const memoryDecision = resolveExtensionHostMemoryDecision({ + recordId: params.record.id, + recordKind: params.record.kind, + memorySlot: params.memorySlot, + selectedMemoryPluginId: params.selectedMemoryPluginId, + }); + const nextSelectedMemoryPluginId = + memoryDecision.selected && params.record.kind === "memory" + ? params.record.id + : params.selectedMemoryPluginId; + + if (!memoryDecision.enabled) { + return { + kind: "disabled", + reason: memoryDecision.reason, + memorySlotMatched, + selectedMemoryPluginId: nextSelectedMemoryPluginId, + }; + } + + const validatedConfig = validateExtensionHostConfig({ + schema: params.manifestRecord.configSchema, + cacheKey: params.manifestRecord.schemaCacheKey, + value: params.entryConfig, + }); + if (!validatedConfig.ok) { + return { + kind: "invalid-config", + message: `invalid config: ${validatedConfig.errors.join(", ")}`, + errors: validatedConfig.errors, + memorySlotMatched, + selectedMemoryPluginId: nextSelectedMemoryPluginId, + }; + } + + if (params.validateOnly) { + return { + kind: "validate-only", + memorySlotMatched, + selectedMemoryPluginId: nextSelectedMemoryPluginId, + }; + } + + if (typeof params.register !== "function") { + return { + kind: "missing-register", + message: "plugin export missing register/activate", + memorySlotMatched, + selectedMemoryPluginId: nextSelectedMemoryPluginId, + }; + } + + return { + kind: "register", + register: params.register, + pluginConfig: validatedConfig.value, + memorySlotMatched, + selectedMemoryPluginId: nextSelectedMemoryPluginId, + }; +} + +export function runExtensionHostPluginRegister(params: { + register: NonNullable; + createApi: ( + record: PluginRecord, + options: { + config: OpenClawConfig; + pluginConfig?: Record; + hookPolicy?: OpenClawPluginHookOptions; + }, + ) => OpenClawPluginApi; + record: PluginRecord; + config: OpenClawConfig; + pluginConfig?: Record; + hookPolicy?: OpenClawPluginHookOptions; + diagnostics: PluginDiagnostic[]; +}): + | { + ok: true; + } + | { + ok: false; + error: unknown; + } { + try { + const result = params.register( + params.createApi(params.record, { + config: params.config, + pluginConfig: params.pluginConfig, + hookPolicy: params.hookPolicy, + }), + ); + if (result && typeof result === "object" && "then" in result) { + params.diagnostics.push({ + level: "warn", + pluginId: params.record.id, + source: params.record.source, + message: "plugin register returned a promise; async registration is ignored", + }); + } + return { ok: true }; + } catch (error) { + return { ok: false, error }; + } +} diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index c5a9809c796..6d32d94d125 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -22,11 +22,12 @@ import { } from "../extension-host/loader-policy.js"; import { prepareExtensionHostPluginCandidate } from "../extension-host/loader-records.js"; import { - applyExtensionHostDefinitionToRecord, + planExtensionHostLoadedPlugin, + runExtensionHostPluginRegister, +} from "../extension-host/loader-register.js"; +import { resolveExtensionHostEarlyMemoryDecision, - resolveExtensionHostMemoryDecision, resolveExtensionHostModuleExport, - validateExtensionHostConfig, } from "../extension-host/loader-runtime.js"; import { appendExtensionHostPluginRecord, @@ -394,29 +395,29 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi const definition = resolved.definition; const register = resolved.register; - const definitionResult = applyExtensionHostDefinitionToRecord({ + const loadedPlan = planExtensionHostLoadedPlugin({ record, + manifestRecord, definition, + register, diagnostics: registry.diagnostics, - }); - if (!definitionResult.ok) { - pushPluginLoadError(definitionResult.message); - continue; - } - - if (record.kind === "memory" && memorySlot === record.id) { - memorySlotMatched = true; - } - - const memoryDecision = resolveExtensionHostMemoryDecision({ - recordId: record.id, - recordKind: record.kind, memorySlot, selectedMemoryPluginId, + entryConfig: entry?.config, + validateOnly, }); + if (loadedPlan.memorySlotMatched) { + memorySlotMatched = true; + } + selectedMemoryPluginId = loadedPlan.selectedMemoryPluginId; - if (!memoryDecision.enabled) { - setExtensionHostPluginRecordDisabled(record, memoryDecision.reason); + if (loadedPlan.kind === "error") { + pushPluginLoadError(loadedPlan.message); + continue; + } + + if (loadedPlan.kind === "disabled") { + setExtensionHostPluginRecordDisabled(record, loadedPlan.reason); appendExtensionHostPluginRecord({ registry, record, @@ -427,23 +428,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi continue; } - if (memoryDecision.selected && record.kind === "memory") { - selectedMemoryPluginId = record.id; - } - - const validatedConfig = validateExtensionHostConfig({ - schema: manifestRecord.configSchema, - cacheKey: manifestRecord.schemaCacheKey, - value: entry?.config, - }); - - if (!validatedConfig.ok) { - logger.error(`[plugins] ${record.id} invalid config: ${validatedConfig.errors?.join(", ")}`); - pushPluginLoadError(`invalid config: ${validatedConfig.errors?.join(", ")}`); + if (loadedPlan.kind === "invalid-config") { + logger.error(`[plugins] ${record.id} ${loadedPlan.message}`); + pushPluginLoadError(loadedPlan.message); continue; } - if (validateOnly) { + if (loadedPlan.kind === "validate-only") { appendExtensionHostPluginRecord({ registry, record, @@ -454,36 +445,22 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi continue; } - if (typeof register !== "function") { + if (loadedPlan.kind === "missing-register") { logger.error(`[plugins] ${record.id} missing register/activate export`); - pushPluginLoadError("plugin export missing register/activate"); + pushPluginLoadError(loadedPlan.message); continue; } - const api = createApi(record, { + const registerResult = runExtensionHostPluginRegister({ + register: loadedPlan.register, + createApi, + record, config: cfg, - pluginConfig: validatedConfig.value, + pluginConfig: loadedPlan.pluginConfig, hookPolicy: entry?.hooks, + diagnostics: registry.diagnostics, }); - - try { - const result = register(api); - if (result && typeof result.then === "function") { - registry.diagnostics.push({ - level: "warn", - pluginId: record.id, - source: record.source, - message: "plugin register returned a promise; async registration is ignored", - }); - } - appendExtensionHostPluginRecord({ - registry, - record, - seenIds, - pluginId, - origin: candidate.origin, - }); - } catch (err) { + if (!registerResult.ok) { recordExtensionHostPluginError({ logger, registry, @@ -491,11 +468,19 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi seenIds, pluginId, origin: candidate.origin, - error: err, + error: registerResult.error, logPrefix: `[plugins] ${record.id} failed during register from ${record.source}: `, diagnosticMessagePrefix: "plugin failed during register: ", }); + continue; } + appendExtensionHostPluginRecord({ + registry, + record, + seenIds, + pluginId, + origin: candidate.origin, + }); } if (typeof memorySlot === "string" && !memorySlotMatched) {