From bce8b67777fecff7679275a7e4ec34608cf757d3 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 15 Mar 2026 11:30:50 +0000 Subject: [PATCH] Plugins: extract loader candidate planning --- src/extension-host/cutover-inventory.md | 5 +- src/extension-host/loader-records.test.ts | 161 ++++++++++++++++++++++ src/extension-host/loader-records.ts | 93 +++++++++++++ src/plugins/loader.ts | 3 +- 4 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 src/extension-host/loader-records.test.ts create mode 100644 src/extension-host/loader-records.ts diff --git a/src/extension-host/cutover-inventory.md b/src/extension-host/cutover-inventory.md index 258b20b5785..f5005c61a5c 100644 --- a/src/extension-host/cutover-inventory.md +++ b/src/extension-host/cutover-inventory.md @@ -33,6 +33,7 @@ This is an implementation checklist, not a future-design spec. | Manifest/package metadata loading | `src/plugins/manifest.ts`, `src/plugins/discovery.ts`, `src/plugins/install.ts` | `src/extension-host/schema.ts` and `src/extension-host/manifest-registry.ts` | `partial` | Package metadata parsing is routed through host schema helpers; legacy loader flow still supplies the source manifests. | | Loader SDK alias compatibility | `src/plugins/loader.ts` | `src/extension-host/loader-compat.ts` | `partial` | Plugin-SDK alias candidate ordering, alias-file resolution, and scoped alias-map construction now live in host-owned loader compatibility helpers. | | 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 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. | @@ -80,14 +81,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, policy, runtime decisions, and record-state transitions +- loader compatibility, initial candidate planning, policy, runtime decisions, 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 load flow, enablement, and lifecycle-state transitions. +2. Move the remaining loader orchestration into `src/extension-host/*`, especially per-plugin import and registration 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-records.test.ts b/src/extension-host/loader-records.test.ts new file mode 100644 index 00000000000..ba0f423e241 --- /dev/null +++ b/src/extension-host/loader-records.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { normalizePluginsConfig } from "../plugins/config-state.js"; +import type { PluginCandidate } from "../plugins/discovery.js"; +import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; +import { prepareExtensionHostPluginCandidate } from "./loader-records.js"; + +function createCandidate(overrides: Partial = {}): PluginCandidate { + return { + source: "/plugins/demo/index.ts", + rootDir: "/plugins/demo", + packageDir: "/plugins/demo", + origin: "workspace", + workspaceDir: "/workspace", + ...overrides, + }; +} + +function createManifestRecord(overrides: Partial = {}): PluginManifestRecord { + return { + id: "demo", + name: "Demo", + description: "Demo plugin", + version: "1.0.0", + kind: "tool", + channels: [], + providers: [], + skills: [], + origin: "workspace", + workspaceDir: "/workspace", + rootDir: "/plugins/demo", + source: "/plugins/demo/index.ts", + manifestPath: "/plugins/demo/openclaw.plugin.json", + configSchema: { + type: "object", + properties: { + enabled: { type: "boolean" }, + }, + }, + configUiHints: { + enabled: { sensitive: false }, + }, + resolvedExtension: { + id: "demo", + source: "/plugins/demo/index.ts", + origin: "workspace", + rootDir: "/plugins/demo", + workspaceDir: "/workspace", + static: { + package: {}, + config: {}, + setup: {}, + }, + runtime: { + kind: "tool", + contributions: [], + }, + policy: {}, + }, + ...overrides, + }; +} + +describe("extension host loader records", () => { + it("prepares duplicate candidates as disabled compatibility records", () => { + const seenIds = new Map([ + ["demo", "bundled"], + ]); + + const prepared = prepareExtensionHostPluginCandidate({ + candidate: createCandidate(), + manifestRecord: createManifestRecord(), + normalizedConfig: normalizePluginsConfig({}), + rootConfig: {}, + seenIds, + }); + + expect(prepared).toMatchObject({ + kind: "duplicate", + pluginId: "demo", + record: { + enabled: false, + status: "disabled", + error: "overridden by bundled plugin", + }, + }); + }); + + it("prepares candidate records with manifest metadata and config entry", () => { + const rootConfig: OpenClawConfig = { + plugins: { + entries: { + demo: { + enabled: true, + config: { enabled: true }, + }, + }, + }, + }; + + const prepared = prepareExtensionHostPluginCandidate({ + candidate: createCandidate({ origin: "bundled" }), + manifestRecord: createManifestRecord({ origin: "bundled" }), + normalizedConfig: normalizePluginsConfig(rootConfig.plugins), + rootConfig, + seenIds: new Map(), + }); + + expect(prepared).toMatchObject({ + kind: "candidate", + pluginId: "demo", + entry: { + enabled: true, + config: { enabled: true }, + }, + enableState: { + enabled: true, + }, + record: { + id: "demo", + name: "Demo", + kind: "tool", + configJsonSchema: { + type: "object", + }, + }, + }); + }); + + it("preserves disabled-by-config decisions in the prepared record", () => { + const rootConfig: OpenClawConfig = { + plugins: { + entries: { + demo: { + enabled: false, + }, + }, + }, + }; + + const prepared = prepareExtensionHostPluginCandidate({ + candidate: createCandidate({ origin: "bundled" }), + manifestRecord: createManifestRecord({ origin: "bundled" }), + normalizedConfig: normalizePluginsConfig(rootConfig.plugins), + rootConfig, + seenIds: new Map(), + }); + + expect(prepared).toMatchObject({ + kind: "candidate", + enableState: { + enabled: false, + reason: "disabled in config", + }, + record: { + enabled: false, + status: "disabled", + }, + }); + }); +}); diff --git a/src/extension-host/loader-records.ts b/src/extension-host/loader-records.ts new file mode 100644 index 00000000000..46e7904dd4c --- /dev/null +++ b/src/extension-host/loader-records.ts @@ -0,0 +1,93 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { + resolveEffectiveEnableState, + type NormalizedPluginsConfig, +} from "../plugins/config-state.js"; +import type { PluginCandidate } from "../plugins/discovery.js"; +import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; +import type { PluginRecord } from "../plugins/registry.js"; +import { createExtensionHostPluginRecord } from "./loader-policy.js"; +import { setExtensionHostPluginRecordDisabled } from "./loader-state.js"; + +type CandidateEntry = NormalizedPluginsConfig["entries"][string]; + +export type ExtensionHostPreparedPluginCandidate = + | { + kind: "duplicate"; + pluginId: string; + record: PluginRecord; + } + | { + kind: "candidate"; + pluginId: string; + record: PluginRecord; + entry: CandidateEntry | undefined; + enableState: { enabled: boolean; reason?: string }; + }; + +export function prepareExtensionHostPluginCandidate(params: { + candidate: PluginCandidate; + manifestRecord: PluginManifestRecord; + normalizedConfig: NormalizedPluginsConfig; + rootConfig: OpenClawConfig; + seenIds: Map; +}): ExtensionHostPreparedPluginCandidate { + const pluginId = params.manifestRecord.id; + const existingOrigin = params.seenIds.get(pluginId); + if (existingOrigin) { + const record = createBasePluginRecord({ + candidate: params.candidate, + manifestRecord: params.manifestRecord, + enabled: false, + }); + setExtensionHostPluginRecordDisabled(record, `overridden by ${existingOrigin} plugin`); + return { + kind: "duplicate", + pluginId, + record, + }; + } + + const enableState = resolveEffectiveEnableState({ + id: pluginId, + origin: params.candidate.origin, + config: params.normalizedConfig, + rootConfig: params.rootConfig, + }); + const entry = params.normalizedConfig.entries[pluginId]; + const record = createBasePluginRecord({ + candidate: params.candidate, + manifestRecord: params.manifestRecord, + enabled: enableState.enabled, + }); + return { + kind: "candidate", + pluginId, + record, + entry, + enableState, + }; +} + +function createBasePluginRecord(params: { + candidate: PluginCandidate; + manifestRecord: PluginManifestRecord; + enabled: boolean; +}): PluginRecord { + const pluginId = params.manifestRecord.id; + const record = createExtensionHostPluginRecord({ + id: pluginId, + name: params.manifestRecord.name ?? pluginId, + description: params.manifestRecord.description, + version: params.manifestRecord.version, + source: params.candidate.source, + origin: params.candidate.origin, + workspaceDir: params.candidate.workspaceDir, + enabled: params.enabled, + configSchema: Boolean(params.manifestRecord.configSchema), + }); + record.kind = params.manifestRecord.kind; + record.configUiHints = params.manifestRecord.configUiHints; + record.configJsonSchema = params.manifestRecord.configSchema; + return record; +} diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index c3560cd850f..29456755483 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -15,12 +15,12 @@ import { import { buildExtensionHostProvenanceIndex, compareExtensionHostDuplicateCandidateOrder, - createExtensionHostPluginRecord, pushExtensionHostDiagnostics, recordExtensionHostPluginError, warnAboutUntrackedLoadedExtensions, warnWhenExtensionAllowlistIsOpen, } from "../extension-host/loader-policy.js"; +import { prepareExtensionHostPluginCandidate } from "../extension-host/loader-records.js"; import { applyExtensionHostDefinitionToRecord, resolveExtensionHostEarlyMemoryDecision, @@ -41,7 +41,6 @@ import { clearPluginCommands } from "./commands.js"; import { applyTestPluginDefaults, normalizePluginsConfig, - resolveEffectiveEnableState, type NormalizedPluginsConfig, } from "./config-state.js"; import { discoverOpenClawPlugins } from "./discovery.js";