Plugins: add loader activation policy

This commit is contained in:
Gustavo Madeira Santana 2026-03-15 14:48:47 +00:00
parent 90ef0afe14
commit 2777895047
No known key found for this signature in database
4 changed files with 263 additions and 37 deletions

View File

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

View File

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

View File

@ -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<string, PluginRecord["origin"]>;
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,
};
}

View File

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