Plugins: extract loader candidate planning

This commit is contained in:
Gustavo Madeira Santana 2026-03-15 11:30:50 +00:00
parent 032b5dee20
commit bce8b67777
No known key found for this signature in database
4 changed files with 258 additions and 4 deletions

View File

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

View File

@ -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> = {}): 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 records", () => {
it("prepares duplicate candidates as disabled compatibility records", () => {
const seenIds = new Map<string, "workspace" | "global" | "bundled" | "config">([
["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",
},
});
});
});

View File

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

View File

@ -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";