diff --git a/src/extension-host/cutover-inventory.md b/src/extension-host/cutover-inventory.md index 5abef2b290f..e637eecaf4f 100644 --- a/src/extension-host/cutover-inventory.md +++ b/src/extension-host/cutover-inventory.md @@ -41,6 +41,7 @@ This is an implementation checklist, not a future-design spec. | 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 activation policy outcomes | open-coded in `src/plugins/loader.ts` and `src/extension-host/loader-flow.ts` | `src/extension-host/loader-activation-policy.ts` | `partial` | Duplicate precedence, config enablement, and early memory-slot gating now resolve through explicit host-owned activation-policy outcomes instead of remaining as inline loader decisions. | | 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. | @@ -88,14 +89,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, session-owned activation state, explicit loader lifecycle transitions, and final cache plus activation finalization +- loader compatibility, cache control, initial candidate planning, entry-path import, explicit activation-policy outcomes, 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 and session-owned activation state into broader activation-state and policy ownership in `src/extension-host/*`. +2. Extend the new loader lifecycle state machine, session-owned activation state, and activation-policy outcomes 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-activation-policy.test.ts b/src/extension-host/loader-activation-policy.test.ts new file mode 100644 index 00000000000..3ca49964f26 --- /dev/null +++ b/src/extension-host/loader-activation-policy.test.ts @@ -0,0 +1,138 @@ +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 { resolveExtensionHostActivationPolicy } from "./loader-activation-policy.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 activation policy", () => { + it("returns duplicate policy outcomes", () => { + const outcome = resolveExtensionHostActivationPolicy({ + candidate: createCandidate(), + manifestRecord: createManifestRecord(), + normalizedConfig: normalizePluginsConfig({}), + rootConfig: {}, + seenIds: new Map([["demo", "bundled" as const]]), + selectedMemoryPluginId: null, + }); + + expect(outcome).toMatchObject({ + kind: "duplicate", + pluginId: "demo", + record: { + status: "disabled", + error: "overridden by bundled plugin", + }, + }); + }); + + it("returns disabled policy outcomes for config-disabled plugins", () => { + const rootConfig: OpenClawConfig = { + plugins: { + entries: { + demo: { + enabled: false, + }, + }, + }, + }; + + const outcome = resolveExtensionHostActivationPolicy({ + candidate: createCandidate(), + manifestRecord: createManifestRecord(), + normalizedConfig: normalizePluginsConfig(rootConfig.plugins), + rootConfig, + seenIds: new Map(), + selectedMemoryPluginId: null, + }); + + expect(outcome).toMatchObject({ + kind: "disabled", + pluginId: "demo", + reason: "disabled in config", + record: { + status: "disabled", + lifecycleState: "disabled", + }, + }); + }); + + it("returns candidate outcomes when policy allows activation", () => { + const outcome = resolveExtensionHostActivationPolicy({ + candidate: createCandidate({ origin: "bundled" }), + manifestRecord: createManifestRecord({ origin: "bundled", kind: "memory" }), + normalizedConfig: normalizePluginsConfig({ + slots: { + memory: "demo", + }, + }), + rootConfig: {}, + seenIds: new Map(), + selectedMemoryPluginId: null, + }); + + expect(outcome).toMatchObject({ + kind: "candidate", + pluginId: "demo", + record: { + lifecycleState: "prepared", + }, + }); + }); +}); diff --git a/src/extension-host/loader-activation-policy.ts b/src/extension-host/loader-activation-policy.ts new file mode 100644 index 00000000000..68d102108b1 --- /dev/null +++ b/src/extension-host/loader-activation-policy.ts @@ -0,0 +1,114 @@ +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 } from "../plugins/registry.js"; +import { prepareExtensionHostPluginCandidate } from "./loader-records.js"; +import { resolveExtensionHostEarlyMemoryDecision } from "./loader-runtime.js"; +import { setExtensionHostPluginRecordDisabled } from "./loader-state.js"; + +export type ExtensionHostActivationPolicyOutcome = + | { + kind: "duplicate"; + pluginId: string; + record: PluginRecord; + } + | { + kind: "disabled"; + pluginId: string; + record: PluginRecord; + entry: + | { + enabled?: boolean; + hooks?: { + allowPromptInjection?: boolean; + }; + config?: unknown; + } + | undefined; + reason?: string; + } + | { + kind: "candidate"; + pluginId: string; + record: PluginRecord; + entry: + | { + enabled?: boolean; + hooks?: { + allowPromptInjection?: boolean; + }; + config?: unknown; + } + | undefined; + }; + +export function resolveExtensionHostActivationPolicy(params: { + candidate: PluginCandidate; + manifestRecord: PluginManifestRecord; + normalizedConfig: { + entries: Record< + string, + { + enabled?: boolean; + hooks?: { + allowPromptInjection?: boolean; + }; + config?: unknown; + } + >; + slots: { + memory?: string | null; + }; + }; + rootConfig: OpenClawConfig; + seenIds: Map; + selectedMemoryPluginId: string | null; +}): ExtensionHostActivationPolicyOutcome { + const preparedCandidate = prepareExtensionHostPluginCandidate({ + candidate: params.candidate, + manifestRecord: params.manifestRecord, + normalizedConfig: params.normalizedConfig, + rootConfig: params.rootConfig, + seenIds: params.seenIds, + }); + if (preparedCandidate.kind === "duplicate") { + return preparedCandidate; + } + + const { pluginId, record, entry, enableState } = preparedCandidate; + if (!enableState.enabled) { + setExtensionHostPluginRecordDisabled(record, enableState.reason); + return { + kind: "disabled", + pluginId, + record, + entry, + reason: enableState.reason, + }; + } + + const earlyMemoryDecision = resolveExtensionHostEarlyMemoryDecision({ + origin: params.candidate.origin, + manifestKind: params.manifestRecord.kind, + recordId: record.id, + memorySlot: params.normalizedConfig.slots.memory, + selectedMemoryPluginId: params.selectedMemoryPluginId, + }); + if (!earlyMemoryDecision.enabled) { + setExtensionHostPluginRecordDisabled(record, earlyMemoryDecision.reason); + return { + kind: "disabled", + pluginId, + record, + entry, + reason: earlyMemoryDecision.reason, + }; + } + + return { + kind: "candidate", + pluginId, + record, + entry, + }; +} diff --git a/src/extension-host/loader-flow.ts b/src/extension-host/loader-flow.ts index 15257533cd8..85bf3e410a9 100644 --- a/src/extension-host/loader-flow.ts +++ b/src/extension-host/loader-flow.ts @@ -3,21 +3,17 @@ 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 { resolveExtensionHostActivationPolicy } from "./loader-activation-policy.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 { resolveExtensionHostModuleExport } from "./loader-runtime.js"; import { appendExtensionHostPluginRecord, setExtensionHostPluginRecordLifecycleState, - setExtensionHostPluginRecordDisabled, setExtensionHostPluginRecordError, } from "./loader-state.js"; @@ -56,18 +52,18 @@ export function processExtensionHostPluginCandidate(params: { loadModule: (safeSource: string) => OpenClawPluginModule; }): { selectedMemoryPluginId: string | null; memorySlotMatched: boolean } { const { candidate, manifestRecord } = params; - const pluginId = manifestRecord.id; - const preparedCandidate = prepareExtensionHostPluginCandidate({ + const activationPolicy = resolveExtensionHostActivationPolicy({ candidate, manifestRecord, normalizedConfig: params.normalizedConfig, rootConfig: params.rootConfig, seenIds: params.seenIds, + selectedMemoryPluginId: params.selectedMemoryPluginId, }); - if (preparedCandidate.kind === "duplicate") { + if (activationPolicy.kind === "duplicate") { appendExtensionHostPluginRecord({ registry: params.registry, - record: preparedCandidate.record, + record: activationPolicy.record, }); return { selectedMemoryPluginId: params.selectedMemoryPluginId, @@ -75,7 +71,7 @@ export function processExtensionHostPluginCandidate(params: { }; } - const { record, entry, enableState } = preparedCandidate; + const { pluginId, record, entry } = activationPolicy; const pushPluginLoadError = (message: string) => { setExtensionHostPluginRecordError(record, message); appendExtensionHostPluginRecord({ @@ -93,30 +89,7 @@ export function processExtensionHostPluginCandidate(params: { }); }; - 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); + if (activationPolicy.kind === "disabled") { appendExtensionHostPluginRecord({ registry: params.registry, record,