diff --git a/src/extension-host/cutover-inventory.md b/src/extension-host/cutover-inventory.md index 3a0f81cb99a..5abef2b290f 100644 --- a/src/extension-host/cutover-inventory.md +++ b/src/extension-host/cutover-inventory.md @@ -40,6 +40,7 @@ This is an implementation checklist, not a future-design spec. | 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 top-level load orchestration | `src/plugins/loader.ts` | `src/extension-host/loader-orchestrator.ts` | `partial` | Cache hits, runtime creation, discovery, manifest loading, candidate ordering, candidate processing, and finalization now route through a host-owned loader orchestrator while `src/plugins/loader.ts` remains the compatibility facade. | +| Loader mutable activation state session | local variables in `src/plugins/loader.ts` and `src/extension-host/loader-orchestrator.ts` | `src/extension-host/loader-session.ts` | `partial` | Seen-id tracking, memory-slot selection state, and finalization inputs now live in a host-owned loader session instead of being spread across top-level loader variables. | | Loader record-state transitions | `src/plugins/loader.ts` | `src/extension-host/loader-state.ts` | `partial` | The loader now enforces an explicit lifecycle transition model (`prepared -> imported -> validated -> registered -> ready`, plus terminal `disabled` and `error`) while still mapping back to compatibility `PluginRecord.status` values. | | Loader final cache, warning, and activation finalization | `src/plugins/loader.ts` | `src/extension-host/loader-finalize.ts` | `partial` | Cache writes, untracked-extension warnings, final memory-slot warnings, readiness promotion, and registry activation now delegate through a host-owned loader-finalize helper; broader host lifecycle and policy semantics are still pending. | | 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. | @@ -87,14 +88,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, cache control, initial candidate planning, entry-path import, policy, runtime decisions, post-import register flow, per-candidate orchestration, top-level load orchestration, explicit loader lifecycle transitions, and final cache plus activation finalization +- loader compatibility, cache control, initial candidate planning, entry-path import, policy, runtime decisions, post-import register flow, per-candidate orchestration, top-level load orchestration, session-owned activation state, explicit loader lifecycle transitions, and final cache plus activation finalization ## 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. Extend the new loader lifecycle state machine into broader activation-state and policy ownership in `src/extension-host/*`. +2. Extend the new loader lifecycle state machine and session-owned activation state into broader activation-state and policy ownership in `src/extension-host/*`. 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-orchestrator.ts b/src/extension-host/loader-orchestrator.ts index 0f0ffcd88ab..2016ad69e48 100644 --- a/src/extension-host/loader-orchestrator.ts +++ b/src/extension-host/loader-orchestrator.ts @@ -7,8 +7,6 @@ import { getCachedExtensionHostRegistry, setCachedExtensionHostRegistry, } from "../extension-host/loader-cache.js"; -import { finalizeExtensionHostRegistryLoad } from "../extension-host/loader-finalize.js"; -import { processExtensionHostPluginCandidate } from "../extension-host/loader-flow.js"; import { buildExtensionHostProvenanceIndex, compareExtensionHostDuplicateCandidateOrder, @@ -21,15 +19,16 @@ import { clearPluginCommands } from "../plugins/commands.js"; import { applyTestPluginDefaults, normalizePluginsConfig } from "../plugins/config-state.js"; import { discoverOpenClawPlugins } from "../plugins/discovery.js"; import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; -import { - createPluginRegistry, - type PluginRecord, - type PluginRegistry, -} from "../plugins/registry.js"; +import { createPluginRegistry, type PluginRegistry } from "../plugins/registry.js"; import { createPluginRuntime, type CreatePluginRuntimeOptions } from "../plugins/runtime/index.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; import type { OpenClawPluginModule, PluginLogger } from "../plugins/types.js"; import { resolvePluginSdkAlias, resolvePluginSdkScopedAliasMap } from "./loader-compat.js"; +import { + createExtensionHostLoaderSession, + finalizeExtensionHostLoaderSession, + processExtensionHostLoaderSessionCandidate, +} from "./loader-session.js"; export type ExtensionHostPluginLoadOptions = { config?: OpenClawConfig; @@ -187,43 +186,34 @@ export function loadExtensionHostPluginRegistry( }); }); - const seenIds = new Map(); - const memorySlot = normalized.slots.memory; - let selectedMemoryPluginId: string | null = null; - let memorySlotMatched = false; + const session = createExtensionHostLoaderSession({ + registry, + logger, + env, + provenance, + cacheEnabled, + cacheKey, + memorySlot: normalized.slots.memory, + setCachedRegistry: setCachedExtensionHostRegistry, + activateRegistry: activateExtensionHostRegistry, + }); for (const candidate of orderedCandidates) { const manifestRecord = manifestByRoot.get(candidate.rootDir); if (!manifestRecord) { continue; } - const processed = processExtensionHostPluginCandidate({ + processExtensionHostLoaderSessionCandidate({ + session, candidate, manifestRecord, normalizedConfig: normalized, rootConfig: cfg, validateOnly, - logger, - registry, - seenIds, - selectedMemoryPluginId, createApi, loadModule: (safeSource) => getJiti()(safeSource) as OpenClawPluginModule, }); - selectedMemoryPluginId = processed.selectedMemoryPluginId; - memorySlotMatched ||= processed.memorySlotMatched; } - return finalizeExtensionHostRegistryLoad({ - registry, - memorySlot, - memorySlotMatched, - provenance, - logger, - env, - cacheEnabled, - cacheKey, - setCachedRegistry: setCachedExtensionHostRegistry, - activateRegistry: activateExtensionHostRegistry, - }); + return finalizeExtensionHostLoaderSession(session); } diff --git a/src/extension-host/loader-session.test.ts b/src/extension-host/loader-session.test.ts new file mode 100644 index 00000000000..e4fe23830ca --- /dev/null +++ b/src/extension-host/loader-session.test.ts @@ -0,0 +1,164 @@ +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 { PluginManifestRecord } from "../plugins/manifest-registry.js"; +import type { PluginRegistry } from "../plugins/registry.js"; +import { + createExtensionHostLoaderSession, + finalizeExtensionHostLoaderSession, + processExtensionHostLoaderSessionCandidate, +} from "./loader-session.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-session-")); + tempDirs.push(rootDir); + const entryPath = path.join(rootDir, "index.js"); + fs.writeFileSync(entryPath, "export default { id: 'demo', register() {} }"); + return { rootDir, entryPath }; +} + +function createManifestRecord(rootDir: string, entryPath: string): PluginManifestRecord { + return { + id: "demo", + name: "Demo", + description: "Demo plugin", + version: "1.0.0", + kind: "memory", + channels: [], + providers: [], + skills: [], + origin: "bundled", + rootDir, + source: entryPath, + manifestPath: path.join(rootDir, "openclaw.plugin.json"), + schemaCacheKey: "demo-schema", + configSchema: { + type: "object", + properties: {}, + }, + resolvedExtension: { + id: "demo", + source: entryPath, + origin: "bundled", + rootDir, + static: { + package: {}, + config: {}, + setup: {}, + }, + runtime: { + kind: "memory", + contributions: [], + }, + policy: {}, + }, + }; +} + +function createRegistry(): PluginRegistry { + return { + plugins: [], + tools: [], + hooks: [], + typedHooks: [], + channels: [], + providers: [], + gatewayHandlers: {}, + httpRoutes: [], + cliRegistrars: [], + services: [], + commands: [], + diagnostics: [], + }; +} + +describe("extension host loader session", () => { + it("owns mutable activation state for memory-slot selection", () => { + const { rootDir, entryPath } = createTempPluginFixture(); + const session = createExtensionHostLoaderSession({ + registry: createRegistry(), + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + }, + env: process.env, + provenance: { + loadPathMatcher: { exact: new Set(), dirs: [] }, + installRules: new Map(), + }, + cacheEnabled: false, + cacheKey: "cache-key", + memorySlot: "demo", + setCachedRegistry: () => {}, + activateRegistry: () => {}, + }); + + processExtensionHostLoaderSessionCandidate({ + session, + candidate: { + source: entryPath, + rootDir, + packageDir: rootDir, + origin: "bundled", + }, + manifestRecord: createManifestRecord(rootDir, entryPath), + normalizedConfig: normalizePluginsConfig({ + slots: { + memory: "demo", + }, + }), + rootConfig: {}, + validateOnly: true, + createApi: () => ({}) as never, + loadModule: () => + ({ + default: { + id: "demo", + register: () => {}, + }, + }) as never, + }); + + expect(session.selectedMemoryPluginId).toBe("demo"); + expect(session.memorySlotMatched).toBe(true); + expect(session.registry.plugins[0]?.lifecycleState).toBe("validated"); + }); + + it("finalizes the session through the shared finalizer", () => { + const session = createExtensionHostLoaderSession({ + registry: createRegistry(), + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + }, + env: process.env, + provenance: { + loadPathMatcher: { exact: new Set(), dirs: [] }, + installRules: new Map(), + }, + cacheEnabled: false, + cacheKey: "cache-key", + setCachedRegistry: () => {}, + activateRegistry: () => {}, + }); + + const result = finalizeExtensionHostLoaderSession(session); + + expect(result).toBe(session.registry); + }); +}); diff --git a/src/extension-host/loader-session.ts b/src/extension-host/loader-session.ts new file mode 100644 index 00000000000..e8eaec3ee4c --- /dev/null +++ b/src/extension-host/loader-session.ts @@ -0,0 +1,115 @@ +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 { finalizeExtensionHostRegistryLoad } from "./loader-finalize.js"; +import { processExtensionHostPluginCandidate } from "./loader-flow.js"; +import type { ExtensionHostProvenanceIndex } from "./loader-policy.js"; + +export type ExtensionHostLoaderSession = { + registry: PluginRegistry; + logger: PluginLogger; + env: NodeJS.ProcessEnv; + provenance: ExtensionHostProvenanceIndex; + cacheEnabled: boolean; + cacheKey: string; + memorySlot?: string | null; + seenIds: Map; + selectedMemoryPluginId: string | null; + memorySlotMatched: boolean; + setCachedRegistry: (cacheKey: string, registry: PluginRegistry) => void; + activateRegistry: (registry: PluginRegistry, cacheKey: string) => void; +}; + +export function createExtensionHostLoaderSession(params: { + registry: PluginRegistry; + logger: PluginLogger; + env: NodeJS.ProcessEnv; + provenance: ExtensionHostProvenanceIndex; + cacheEnabled: boolean; + cacheKey: string; + memorySlot?: string | null; + setCachedRegistry: (cacheKey: string, registry: PluginRegistry) => void; + activateRegistry: (registry: PluginRegistry, cacheKey: string) => void; +}): ExtensionHostLoaderSession { + return { + registry: params.registry, + logger: params.logger, + env: params.env, + provenance: params.provenance, + cacheEnabled: params.cacheEnabled, + cacheKey: params.cacheKey, + memorySlot: params.memorySlot, + seenIds: new Map(), + selectedMemoryPluginId: null, + memorySlotMatched: false, + setCachedRegistry: params.setCachedRegistry, + activateRegistry: params.activateRegistry, + }; +} + +export function processExtensionHostLoaderSessionCandidate(params: { + session: ExtensionHostLoaderSession; + candidate: PluginCandidate; + manifestRecord: PluginManifestRecord; + normalizedConfig: { + entries: Record< + string, + { + enabled?: boolean; + hooks?: { + allowPromptInjection?: boolean; + }; + config?: unknown; + } + >; + slots: { + memory?: string | null; + }; + }; + rootConfig: OpenClawConfig; + validateOnly: boolean; + createApi: ( + record: PluginRecord, + options: { + config: OpenClawConfig; + pluginConfig?: Record; + hookPolicy?: { allowPromptInjection?: boolean }; + }, + ) => OpenClawPluginApi; + loadModule: (safeSource: string) => OpenClawPluginModule; +}): void { + const processed = processExtensionHostPluginCandidate({ + candidate: params.candidate, + manifestRecord: params.manifestRecord, + normalizedConfig: params.normalizedConfig, + rootConfig: params.rootConfig, + validateOnly: params.validateOnly, + logger: params.session.logger, + registry: params.session.registry, + seenIds: params.session.seenIds, + selectedMemoryPluginId: params.session.selectedMemoryPluginId, + createApi: params.createApi, + loadModule: params.loadModule, + }); + params.session.selectedMemoryPluginId = processed.selectedMemoryPluginId; + params.session.memorySlotMatched ||= processed.memorySlotMatched; +} + +export function finalizeExtensionHostLoaderSession( + session: ExtensionHostLoaderSession, +): PluginRegistry { + return finalizeExtensionHostRegistryLoad({ + registry: session.registry, + memorySlot: session.memorySlot, + memorySlotMatched: session.memorySlotMatched, + provenance: session.provenance, + logger: session.logger, + env: session.env, + cacheEnabled: session.cacheEnabled, + cacheKey: session.cacheKey, + setCachedRegistry: session.setCachedRegistry, + activateRegistry: session.activateRegistry, + }); +}