Plugins: extract loader host seams

This commit is contained in:
Gustavo Madeira Santana 2026-03-15 11:27:05 +00:00
parent fb9a0383d1
commit bcb74de2ff
No known key found for this signature in database
11 changed files with 1255 additions and 76 deletions

View File

@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it } from "vitest";
import { getGlobalHookRunner, resetGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import { activateExtensionHostRegistry } from "./activation.js";
import {
getActiveExtensionHostRegistry,
getActiveExtensionHostRegistryKey,
} from "./active-registry.js";
describe("extension host activation", () => {
beforeEach(() => {
resetGlobalHookRunner();
});
it("activates the registry through the host boundary", () => {
const registry = createEmptyPluginRegistry();
registry.plugins.push({
id: "activation-test",
name: "activation-test",
source: "test",
origin: "workspace",
enabled: true,
status: "loaded",
toolNames: [],
hookNames: [],
channelIds: [],
providerIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpRoutes: 0,
hookCount: 0,
configSchema: false,
});
activateExtensionHostRegistry(registry, "activation-key");
expect(getActiveExtensionHostRegistry()).toBe(registry);
expect(getActiveExtensionHostRegistryKey()).toBe("activation-key");
expect(getGlobalHookRunner()).toBeDefined();
});
});

View File

@ -0,0 +1,8 @@
import { initializeGlobalHookRunner } from "../plugins/hook-runner-global.js";
import type { PluginRegistry } from "../plugins/registry.js";
import { setActiveExtensionHostRegistry } from "./active-registry.js";
export function activateExtensionHostRegistry(registry: PluginRegistry, cacheKey: string): void {
setActiveExtensionHostRegistry(registry, cacheKey);
initializeGlobalHookRunner(registry);
}

View File

@ -0,0 +1,110 @@
# Extension Host Cutover Inventory
Date: 2026-03-15
## Purpose
This document is the Phase 0 cutover inventory for the extension-host migration.
It tracks:
- the current plugin-owned surfaces in the repo
- where ownership lives today
- where ownership should move
- what has already moved
- what is still blocked on later phases
This is an implementation checklist, not a future-design spec.
## Status Legend
- `moved`: the host owns the boundary now, with compatibility preserved
- `partial`: host-owned types or views exist, but the legacy plugin path is still the active writer
- `compat-only`: old surface still exists only to preserve callers while the host boundary takes over
- `not started`: no meaningful migration has landed yet
## Current Inventory
| Surface | Current implementation | Target owner | Status | How it has been handled so far |
| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Active runtime registry state | `src/plugins/runtime.ts` plus global plugin runtime state | `src/extension-host/active-registry.ts` | `moved` | Host-owned active registry exists; `src/plugins/runtime.ts` is now a compatibility facade. |
| Normalized extension descriptor model | plugin manifests and package metadata interpreted ad hoc across `src/plugins/manifest.ts`, `src/plugins/discovery.ts`, `src/plugins/install.ts` | `src/extension-host/schema.ts` | `partial` | `ResolvedExtension`, `ResolvedContribution`, and `ContributionPolicy` exist; current manifests project into them through compatibility adapters. |
| Resolved static registry | flat rows in `src/plugins/manifest-registry.ts` | `src/extension-host/resolved-registry.ts` | `partial` | Manifest records now carry `resolvedExtension`; a host-owned resolved registry view exists for static consumers. |
| 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 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. |
| Dock lookup | `src/channels/dock.ts` | host-owned static descriptors | `partial` | Runtime lookup now uses the host boundary; dock ownership itself has not moved yet. |
| Message-channel normalization | `src/utils/message-channel.ts` | host-owned channel registry view | `partial` | Lookup path now reads through the host-owned active registry. |
| Default plugin HTTP route lookup | `src/plugins/http-registry.ts` | host-owned route registry | `partial` | Default registry resolution now uses the host boundary; route registration compatibility still flows through the legacy plugin API. |
| Channel catalog static metadata | `src/channels/plugins/catalog.ts` | host-owned static descriptors | `partial` | Package metadata parsing now flows through host schema helpers; full canonical catalog migration has not started. |
| Plugin skill discovery | `src/agents/skills/plugin-skills.ts` | host-owned resolved registry | `moved` | Static consumer now reads only resolved-extension data for skill paths and enablement filtering. |
| Plugin auto-enable | `src/config/plugin-auto-enable.ts` | host-owned resolved registry | `partial` | Primary logic runs on resolved-extension data; old manifest-registry injection remains as a compatibility input for older callers and tests. |
| Config validation indexing | `src/config/validation.ts`, `src/config/resolved-extension-validation.ts` | host-owned resolved registry | `moved` | Validation indexing now builds from resolved-extension records instead of flat manifest rows. |
| Config doc baseline generation | `src/config/doc-baseline.ts` | host-owned resolved registry | `moved` | Bundled plugin and channel metadata now load through the resolved-extension registry. |
| Plugin loader activation | `src/plugins/loader.ts` | extension host lifecycle + compatibility loader | `partial` | Activation now routes through `src/extension-host/activation.ts`, but discovery, enablement, provenance, module loading, and policy still live in the legacy plugin loader. |
| Channel registration writes | `src/plugins/registry.ts` | host-owned channel registry | `partial` | Validation and normalization now delegate to `src/extension-host/runtime-registrations.ts`, but the legacy plugin API still performs the write. |
| Provider registration writes | `src/plugins/registry.ts` | host-owned provider registry | `partial` | Provider normalization still happens in plugin-era validation, but duplicate detection and normalized registration shape now delegate to `src/extension-host/runtime-registrations.ts`. |
| HTTP route registration writes | `src/plugins/registry.ts` | host-owned route registry | `partial` | Route validation and normalization now delegate to `src/extension-host/runtime-registrations.ts`, but the legacy plugin API still performs the write. |
| Gateway method registration writes | `src/plugins/registry.ts` | host-owned runtime contribution registry | `partial` | Duplicate detection and normalized method registration now delegate to `src/extension-host/runtime-registrations.ts`, but the legacy plugin API still performs the write. |
| Tool registration writes | `src/plugins/registry.ts` | host-owned tool registry | `partial` | Tool-name normalization and tool-factory shaping now delegate to `src/extension-host/runtime-registrations.ts`, but duplicate handling still follows the legacy tool path. |
| CLI registration writes | `src/plugins/registry.ts` | host-owned CLI registry | `partial` | CLI command-name normalization now delegates to `src/extension-host/runtime-registrations.ts`, but the legacy plugin API still performs the write. |
| Service registration writes | `src/plugins/registry.ts` | host-owned service registry | `partial` | Service-id normalization now delegates to `src/extension-host/runtime-registrations.ts`, but lifecycle remains legacy-owned. |
| Command registration writes | `src/plugins/registry.ts` | host-owned command registry | `partial` | Command-name normalization now delegates to `src/extension-host/runtime-registrations.ts`, but duplicate enforcement still depends on the legacy plugin command registry. |
| Context-engine registration writes | `src/plugins/registry.ts` | host-owned context-engine registry | `partial` | Context-engine id normalization now delegates to `src/extension-host/runtime-registrations.ts`, but the actual context-engine registry remains legacy-owned. |
| Legacy hook registration writes | `src/plugins/registry.ts` | host-owned hook registry | `partial` | Hook-entry construction and event normalization now delegate to `src/extension-host/runtime-registrations.ts`, but internal-hook bridging still remains in the legacy plugin registry. |
| Typed-hook registration writes | `src/plugins/registry.ts` | host-owned typed-hook registry | `partial` | Typed-hook record construction and hook-name validation now delegate to `src/extension-host/runtime-registrations.ts`, but prompt-injection policy and execution semantics remain legacy-owned. |
| Hook execution and global runner | `src/plugins/hook-runner-global.ts`, `src/hooks/internal-hooks.ts`, plugin hook registration in `src/plugins/registry.ts` | canonical kernel event stages + host bridges | `not started` | No canonical event-stage migration has landed yet. |
| Service lifecycle | `src/plugins/services.ts` and plugin service registration | extension host lifecycle | `not started` | Service startup and teardown still depend on legacy plugin registry/service ownership. |
| CLI registration | plugin CLI registration in `src/plugins/registry.ts` and CLI loaders | extension host registry + static descriptors where possible | `not started` | No host-owned CLI registry exists yet. |
| Gateway/server methods | `src/plugins/registry.ts` gateway handler registration | host-owned runtime contribution registry | `not started` | Still registered directly into the legacy plugin registry. |
| Slot arbitration | `src/plugins/slots.ts` | host-owned arbitration model | `not started` | Current slot selection remains plugin-era logic. |
| ACP backend registry | `src/acp/runtime/registry.ts` | host-owned runtime-backend registry | `not started` | ACP backends still mutate a global ACP runtime registry directly. |
| Onboarding/install/setup surfaces | `src/plugins/install.ts`, package manifests, channel catalog, onboarding commands | host-owned static descriptors | `partial` | Static metadata normalization has started; full setup/install descriptor migration is not done. |
| Pilot migrations | `extensions/thread-ownership`, `extensions/telegram`, `extensions/acpx` | extension-host path with parity tracking | `not started` | No pilot runs through the host path yet. |
## Completed Pattern So Far
The migration pattern used so far is intentional:
1. Extract a host-owned boundary module.
2. Keep the old plugin-era entry point as a compatibility facade.
3. Move static or lookup-heavy readers first.
4. Add focused seam tests where the dependency graph allows it.
5. Delay loader/lifecycle/event rewrites until more readers already depend on one host-owned boundary.
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
## 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.
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.
## Explicitly Not Done Yet
This inventory should not be read as proof that the extension host is fully in charge already.
The following remain legacy-owned today:
- activation ordering
- policy gates
- typed and legacy hook execution
- service lifecycle
- CLI registration
- gateway/server method registration
- slot arbitration
- ACP backend registration
- channel runtime compatibility bridges
- pilot parity tracking

View File

@ -0,0 +1,114 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
type PluginSdkAliasCandidateKind = "dist" | "src";
const cachedPluginSdkExportedSubpaths = new Map<string, string[]>();
export function resolvePluginSdkAliasCandidateOrder(params: {
modulePath: string;
isProduction: boolean;
}): PluginSdkAliasCandidateKind[] {
const normalizedModulePath = params.modulePath.replace(/\\/g, "/");
const isDistRuntime = normalizedModulePath.includes("/dist/");
return isDistRuntime || params.isProduction ? ["dist", "src"] : ["src", "dist"];
}
export function listPluginSdkAliasCandidates(params: {
srcFile: string;
distFile: string;
modulePath: string;
}): string[] {
const orderedKinds = resolvePluginSdkAliasCandidateOrder({
modulePath: params.modulePath,
isProduction: process.env.NODE_ENV === "production",
});
let cursor = path.dirname(params.modulePath);
const candidates: string[] = [];
for (let i = 0; i < 6; i += 1) {
const candidateMap = {
src: path.join(cursor, "src", "plugin-sdk", params.srcFile),
dist: path.join(cursor, "dist", "plugin-sdk", params.distFile),
} as const;
for (const kind of orderedKinds) {
candidates.push(candidateMap[kind]);
}
const parent = path.dirname(cursor);
if (parent === cursor) {
break;
}
cursor = parent;
}
return candidates;
}
export function resolvePluginSdkAliasFile(params: {
srcFile: string;
distFile: string;
modulePath?: string;
}): string | null {
try {
const modulePath = params.modulePath ?? fileURLToPath(import.meta.url);
for (const candidate of listPluginSdkAliasCandidates({
srcFile: params.srcFile,
distFile: params.distFile,
modulePath,
})) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
} catch {
// ignore
}
return null;
}
export function resolvePluginSdkAlias(): string | null {
return resolvePluginSdkAliasFile({ srcFile: "root-alias.cjs", distFile: "root-alias.cjs" });
}
export function listPluginSdkExportedSubpaths(params: { modulePath?: string } = {}): string[] {
const modulePath = params.modulePath ?? fileURLToPath(import.meta.url);
const packageRoot = resolveOpenClawPackageRootSync({
cwd: path.dirname(modulePath),
});
if (!packageRoot) {
return [];
}
const cached = cachedPluginSdkExportedSubpaths.get(packageRoot);
if (cached) {
return cached;
}
try {
const pkgRaw = fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8");
const pkg = JSON.parse(pkgRaw) as {
exports?: Record<string, unknown>;
};
const subpaths = Object.keys(pkg.exports ?? {})
.filter((key) => key.startsWith("./plugin-sdk/"))
.map((key) => key.slice("./plugin-sdk/".length))
.filter((subpath) => Boolean(subpath) && !subpath.includes("/"))
.toSorted();
cachedPluginSdkExportedSubpaths.set(packageRoot, subpaths);
return subpaths;
} catch {
return [];
}
}
export function resolvePluginSdkScopedAliasMap(): Record<string, string> {
const aliasMap: Record<string, string> = {};
for (const subpath of listPluginSdkExportedSubpaths()) {
const resolved = resolvePluginSdkAliasFile({
srcFile: `${subpath}.ts`,
distFile: `${subpath}.js`,
});
if (resolved) {
aliasMap[`openclaw/plugin-sdk/${subpath}`] = resolved;
}
}
return aliasMap;
}

View File

@ -0,0 +1,177 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { PluginCandidate } from "../plugins/discovery.js";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import {
buildExtensionHostProvenanceIndex,
compareExtensionHostDuplicateCandidateOrder,
createExtensionHostPluginRecord,
warnAboutUntrackedLoadedExtensions,
warnWhenExtensionAllowlistIsOpen,
} from "./loader-policy.js";
const tempDirs: string[] = [];
function makeTempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-loader-policy-"));
tempDirs.push(dir);
return dir;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("extension host loader policy", () => {
it("creates normalized plugin records", () => {
const record = createExtensionHostPluginRecord({
id: "demo-plugin",
source: "/plugins/demo/index.js",
origin: "workspace",
enabled: true,
configSchema: true,
});
expect(record).toMatchObject({
id: "demo-plugin",
name: "demo-plugin",
source: "/plugins/demo/index.js",
origin: "workspace",
enabled: true,
status: "loaded",
configSchema: true,
});
});
it("prefers explicit global installs over auto-discovered globals", () => {
const installDir = makeTempDir();
const autoDir = makeTempDir();
const env = { ...process.env, HOME: makeTempDir() };
const provenance = buildExtensionHostProvenanceIndex({
config: {
plugins: {
installs: {
demo: {
installPath: installDir,
},
},
},
},
normalizedLoadPaths: [],
env,
});
const manifestByRoot = new Map<string, { id: string }>([
[installDir, { id: "demo" }],
[autoDir, { id: "demo" }],
]);
const explicitCandidate: PluginCandidate = {
idHint: "demo",
source: path.join(installDir, "index.js"),
rootDir: installDir,
origin: "global",
};
const autoCandidate: PluginCandidate = {
idHint: "demo",
source: path.join(autoDir, "index.js"),
rootDir: autoDir,
origin: "global",
};
expect(
compareExtensionHostDuplicateCandidateOrder({
left: explicitCandidate,
right: autoCandidate,
manifestByRoot,
provenance,
env,
}),
).toBeLessThan(0);
});
it("warns when allowlist is open for non-bundled discoverable plugins", () => {
const warnings: string[] = [];
const warningCache = new Set<string>();
warnWhenExtensionAllowlistIsOpen({
logger: {
info: () => {},
warn: (message) => warnings.push(message),
error: () => {},
},
pluginsEnabled: true,
allow: [],
warningCacheKey: "warn-key",
warningCache,
discoverablePlugins: [
{ id: "bundled", source: "/bundled/index.js", origin: "bundled" },
{ id: "workspace-demo", source: "/workspace/demo.js", origin: "workspace" },
],
});
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain("plugins.allow is empty");
expect(warningCache.has("warn-key")).toBe(true);
});
it("warns about loaded untracked non-bundled plugins", () => {
const trackedDir = makeTempDir();
const untrackedDir = makeTempDir();
const trackedFile = path.join(trackedDir, "tracked.js");
const untrackedFile = path.join(untrackedDir, "untracked.js");
fs.writeFileSync(trackedFile, "export {};\n", "utf8");
fs.writeFileSync(untrackedFile, "export {};\n", "utf8");
const registry = createEmptyPluginRegistry();
registry.plugins.push(
{
...createExtensionHostPluginRecord({
id: "tracked",
source: trackedFile,
origin: "workspace",
enabled: true,
configSchema: false,
}),
status: "loaded",
},
{
...createExtensionHostPluginRecord({
id: "untracked",
source: untrackedFile,
origin: "workspace",
enabled: true,
configSchema: false,
}),
status: "loaded",
},
);
const warnings: string[] = [];
const env = { ...process.env, HOME: makeTempDir() };
const provenance = buildExtensionHostProvenanceIndex({
config: {},
normalizedLoadPaths: [trackedDir],
env,
});
warnAboutUntrackedLoadedExtensions({
registry,
provenance,
logger: {
info: () => {},
warn: (message) => warnings.push(message),
error: () => {},
},
env,
});
expect(registry.diagnostics).toHaveLength(1);
expect(registry.diagnostics[0]?.pluginId).toBe("untracked");
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain("untracked");
});
});

View File

@ -0,0 +1,324 @@
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import type { PluginCandidate } from "../plugins/discovery.js";
import { isPathInside, safeStatSync } from "../plugins/path-safety.js";
import type { PluginRecord, PluginRegistry } from "../plugins/registry.js";
import type { PluginDiagnostic, PluginLogger } from "../plugins/types.js";
import { resolveUserPath } from "../utils.js";
function safeRealpathOrResolve(value: string): string {
try {
return fs.realpathSync(value);
} catch {
return path.resolve(value);
}
}
type PathMatcher = {
exact: Set<string>;
dirs: string[];
};
type InstallTrackingRule = {
trackedWithoutPaths: boolean;
matcher: PathMatcher;
};
export type ExtensionHostProvenanceIndex = {
loadPathMatcher: PathMatcher;
installRules: Map<string, InstallTrackingRule>;
};
export function createExtensionHostPluginRecord(params: {
id: string;
name?: string;
description?: string;
version?: string;
source: string;
origin: PluginRecord["origin"];
workspaceDir?: string;
enabled: boolean;
configSchema: boolean;
}): PluginRecord {
return {
id: params.id,
name: params.name ?? params.id,
description: params.description,
version: params.version,
source: params.source,
origin: params.origin,
workspaceDir: params.workspaceDir,
enabled: params.enabled,
status: params.enabled ? "loaded" : "disabled",
toolNames: [],
hookNames: [],
channelIds: [],
providerIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpRoutes: 0,
hookCount: 0,
configSchema: params.configSchema,
configUiHints: undefined,
configJsonSchema: undefined,
};
}
export function recordExtensionHostPluginError(params: {
logger: PluginLogger;
registry: PluginRegistry;
record: PluginRecord;
seenIds: Map<string, PluginRecord["origin"]>;
pluginId: string;
origin: PluginRecord["origin"];
error: unknown;
logPrefix: string;
diagnosticMessagePrefix: string;
}): void {
const errorText = String(params.error);
const deprecatedApiHint =
errorText.includes("api.registerHttpHandler") && errorText.includes("is not a function")
? "deprecated api.registerHttpHandler(...) was removed; use api.registerHttpRoute(...) for plugin-owned routes or registerPluginHttpRoute(...) for dynamic lifecycle routes"
: null;
const displayError = deprecatedApiHint ? `${deprecatedApiHint} (${errorText})` : errorText;
params.logger.error(`${params.logPrefix}${displayError}`);
params.record.status = "error";
params.record.error = displayError;
params.registry.plugins.push(params.record);
params.seenIds.set(params.pluginId, params.origin);
params.registry.diagnostics.push({
level: "error",
pluginId: params.record.id,
source: params.record.source,
message: `${params.diagnosticMessagePrefix}${displayError}`,
});
}
export function pushExtensionHostDiagnostics(
diagnostics: PluginDiagnostic[],
append: PluginDiagnostic[],
): void {
diagnostics.push(...append);
}
function createPathMatcher(): PathMatcher {
return { exact: new Set<string>(), dirs: [] };
}
function addPathToMatcher(
matcher: PathMatcher,
rawPath: string,
env: NodeJS.ProcessEnv = process.env,
): void {
const trimmed = rawPath.trim();
if (!trimmed) {
return;
}
const resolved = resolveUserPath(trimmed, env);
if (!resolved) {
return;
}
if (matcher.exact.has(resolved) || matcher.dirs.includes(resolved)) {
return;
}
const stat = safeStatSync(resolved);
if (stat?.isDirectory()) {
matcher.dirs.push(resolved);
return;
}
matcher.exact.add(resolved);
}
function matchesPathMatcher(matcher: PathMatcher, sourcePath: string): boolean {
if (matcher.exact.has(sourcePath)) {
return true;
}
return matcher.dirs.some((dirPath) => isPathInside(dirPath, sourcePath));
}
export function buildExtensionHostProvenanceIndex(params: {
config: OpenClawConfig;
normalizedLoadPaths: string[];
env: NodeJS.ProcessEnv;
}): ExtensionHostProvenanceIndex {
const loadPathMatcher = createPathMatcher();
for (const loadPath of params.normalizedLoadPaths) {
addPathToMatcher(loadPathMatcher, loadPath, params.env);
}
const installRules = new Map<string, InstallTrackingRule>();
const installs = params.config.plugins?.installs ?? {};
for (const [pluginId, install] of Object.entries(installs)) {
const rule: InstallTrackingRule = {
trackedWithoutPaths: false,
matcher: createPathMatcher(),
};
const trackedPaths = [install.installPath, install.sourcePath]
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
.filter(Boolean);
if (trackedPaths.length === 0) {
rule.trackedWithoutPaths = true;
} else {
for (const trackedPath of trackedPaths) {
addPathToMatcher(rule.matcher, trackedPath, params.env);
}
}
installRules.set(pluginId, rule);
}
return { loadPathMatcher, installRules };
}
function isTrackedByProvenance(params: {
pluginId: string;
source: string;
index: ExtensionHostProvenanceIndex;
env: NodeJS.ProcessEnv;
}): boolean {
const sourcePath = resolveUserPath(params.source, params.env);
const installRule = params.index.installRules.get(params.pluginId);
if (installRule) {
if (installRule.trackedWithoutPaths) {
return true;
}
if (matchesPathMatcher(installRule.matcher, sourcePath)) {
return true;
}
}
return matchesPathMatcher(params.index.loadPathMatcher, sourcePath);
}
function matchesExplicitInstallRule(params: {
pluginId: string;
source: string;
index: ExtensionHostProvenanceIndex;
env: NodeJS.ProcessEnv;
}): boolean {
const sourcePath = resolveUserPath(params.source, params.env);
const installRule = params.index.installRules.get(params.pluginId);
if (!installRule || installRule.trackedWithoutPaths) {
return false;
}
return matchesPathMatcher(installRule.matcher, sourcePath);
}
function resolveCandidateDuplicateRank(params: {
candidate: PluginCandidate;
manifestByRoot: Map<string, { id: string }>;
provenance: ExtensionHostProvenanceIndex;
env: NodeJS.ProcessEnv;
}): number {
const manifestRecord = params.manifestByRoot.get(params.candidate.rootDir);
const pluginId = manifestRecord?.id;
const isExplicitInstall =
params.candidate.origin === "global" &&
pluginId !== undefined &&
matchesExplicitInstallRule({
pluginId,
source: params.candidate.source,
index: params.provenance,
env: params.env,
});
switch (params.candidate.origin) {
case "config":
return 0;
case "workspace":
return 1;
case "global":
return isExplicitInstall ? 2 : 4;
case "bundled":
return 3;
}
}
export function compareExtensionHostDuplicateCandidateOrder(params: {
left: PluginCandidate;
right: PluginCandidate;
manifestByRoot: Map<string, { id: string }>;
provenance: ExtensionHostProvenanceIndex;
env: NodeJS.ProcessEnv;
}): number {
const leftPluginId = params.manifestByRoot.get(params.left.rootDir)?.id;
const rightPluginId = params.manifestByRoot.get(params.right.rootDir)?.id;
if (!leftPluginId || leftPluginId !== rightPluginId) {
return 0;
}
return (
resolveCandidateDuplicateRank({
candidate: params.left,
manifestByRoot: params.manifestByRoot,
provenance: params.provenance,
env: params.env,
}) -
resolveCandidateDuplicateRank({
candidate: params.right,
manifestByRoot: params.manifestByRoot,
provenance: params.provenance,
env: params.env,
})
);
}
export function warnWhenExtensionAllowlistIsOpen(params: {
logger: PluginLogger;
pluginsEnabled: boolean;
allow: string[];
warningCacheKey: string;
warningCache: Set<string>;
discoverablePlugins: Array<{ id: string; source: string; origin: PluginRecord["origin"] }>;
}): void {
if (!params.pluginsEnabled || params.allow.length > 0) {
return;
}
const nonBundled = params.discoverablePlugins.filter((entry) => entry.origin !== "bundled");
if (nonBundled.length === 0 || params.warningCache.has(params.warningCacheKey)) {
return;
}
const preview = nonBundled
.slice(0, 6)
.map((entry) => `${entry.id} (${entry.source})`)
.join(", ");
const extra = nonBundled.length > 6 ? ` (+${nonBundled.length - 6} more)` : "";
params.warningCache.add(params.warningCacheKey);
params.logger.warn(
`[plugins] plugins.allow is empty; discovered non-bundled plugins may auto-load: ${preview}${extra}. Set plugins.allow to explicit trusted ids.`,
);
}
export function warnAboutUntrackedLoadedExtensions(params: {
registry: PluginRegistry;
provenance: ExtensionHostProvenanceIndex;
logger: PluginLogger;
env: NodeJS.ProcessEnv;
}): void {
for (const plugin of params.registry.plugins) {
if (plugin.status !== "loaded" || plugin.origin === "bundled") {
continue;
}
if (
isTrackedByProvenance({
pluginId: plugin.id,
source: plugin.source,
index: params.provenance,
env: params.env,
})
) {
continue;
}
const message =
"loaded without install/load-path provenance; treat as untracked local code and pin trust via plugins.allow or install records";
params.registry.diagnostics.push({
level: "warn",
pluginId: plugin.id,
source: plugin.source,
message,
});
params.logger.warn(
`[plugins] ${plugin.id}: ${message} (${safeRealpathOrResolve(plugin.source)})`,
);
}
}

View File

@ -0,0 +1,128 @@
import { describe, expect, it } from "vitest";
import { createExtensionHostPluginRecord } from "./loader-policy.js";
import {
applyExtensionHostDefinitionToRecord,
resolveExtensionHostEarlyMemoryDecision,
resolveExtensionHostMemoryDecision,
resolveExtensionHostModuleExport,
validateExtensionHostConfig,
} from "./loader-runtime.js";
describe("extension host loader runtime", () => {
it("resolves function exports as register handlers", () => {
const register = () => {};
expect(resolveExtensionHostModuleExport(register)).toEqual({
register,
});
});
it("resolves object exports with default values", () => {
const register = () => {};
const definition = {
id: "demo",
register,
};
expect(resolveExtensionHostModuleExport({ default: definition })).toEqual({
definition,
register,
});
});
it("applies export metadata to plugin records", () => {
const record = createExtensionHostPluginRecord({
id: "demo",
source: "/plugins/demo.js",
origin: "workspace",
enabled: true,
configSchema: true,
});
record.kind = "memory";
const diagnostics: Array<{ level: "warn" | "error"; message: string }> = [];
const result = applyExtensionHostDefinitionToRecord({
record,
definition: {
id: "demo",
name: "Demo Plugin",
description: "demo desc",
version: "1.2.3",
kind: "memory",
},
diagnostics,
});
expect(result).toEqual({ ok: true });
expect(record.name).toBe("Demo Plugin");
expect(record.description).toBe("demo desc");
expect(record.version).toBe("1.2.3");
expect(diagnostics).toEqual([]);
});
it("rejects export id mismatches", () => {
const record = createExtensionHostPluginRecord({
id: "demo",
source: "/plugins/demo.js",
origin: "workspace",
enabled: true,
configSchema: true,
});
expect(
applyExtensionHostDefinitionToRecord({
record,
definition: {
id: "other",
},
diagnostics: [],
}),
).toEqual({
ok: false,
message: 'plugin id mismatch (config uses "demo", export uses "other")',
});
});
it("validates config through the host helper", () => {
expect(
validateExtensionHostConfig({
schema: {
type: "object",
properties: {
enabled: { type: "boolean" },
},
additionalProperties: false,
},
value: { enabled: true },
}),
).toMatchObject({
ok: true,
value: { enabled: true },
});
});
it("can disable bundled memory plugins early based on slot policy", () => {
const result = resolveExtensionHostEarlyMemoryDecision({
origin: "bundled",
manifestKind: "memory",
recordId: "memory-b",
memorySlot: "memory-a",
selectedMemoryPluginId: null,
});
expect(result.enabled).toBe(false);
expect(result.reason).toContain('memory slot set to "memory-a"');
});
it("returns the post-definition memory slot decision", () => {
const result = resolveExtensionHostMemoryDecision({
recordId: "memory-a",
recordKind: "memory",
memorySlot: "memory-a",
selectedMemoryPluginId: null,
});
expect(result).toEqual({
enabled: true,
selected: true,
});
});
});

View File

@ -0,0 +1,125 @@
import { resolveMemorySlotDecision } from "../plugins/config-state.js";
import type { PluginRecord } from "../plugins/registry.js";
import { validateJsonSchemaValue } from "../plugins/schema-validator.js";
import type { OpenClawPluginDefinition, PluginDiagnostic } from "../plugins/types.js";
export function validateExtensionHostConfig(params: {
schema?: Record<string, unknown>;
cacheKey?: string;
value?: unknown;
}): { ok: boolean; value?: Record<string, unknown>; errors?: string[] } {
const schema = params.schema;
if (!schema) {
return { ok: true, value: params.value as Record<string, unknown> | undefined };
}
const cacheKey = params.cacheKey ?? JSON.stringify(schema);
const result = validateJsonSchemaValue({
schema,
cacheKey,
value: params.value ?? {},
});
if (result.ok) {
return { ok: true, value: params.value as Record<string, unknown> | undefined };
}
return { ok: false, errors: result.errors.map((error) => error.text) };
}
export function resolveExtensionHostModuleExport(moduleExport: unknown): {
definition?: OpenClawPluginDefinition;
register?: OpenClawPluginDefinition["register"];
} {
const resolved =
moduleExport &&
typeof moduleExport === "object" &&
"default" in (moduleExport as Record<string, unknown>)
? (moduleExport as { default: unknown }).default
: moduleExport;
if (typeof resolved === "function") {
return {
register: resolved as OpenClawPluginDefinition["register"],
};
}
if (resolved && typeof resolved === "object") {
const def = resolved as OpenClawPluginDefinition;
const register = def.register ?? def.activate;
return { definition: def, register };
}
return {};
}
export function applyExtensionHostDefinitionToRecord(params: {
record: PluginRecord;
definition?: OpenClawPluginDefinition;
diagnostics: PluginDiagnostic[];
}):
| {
ok: true;
}
| {
ok: false;
message: string;
} {
if (params.definition?.id && params.definition.id !== params.record.id) {
return {
ok: false,
message: `plugin id mismatch (config uses "${params.record.id}", export uses "${params.definition.id}")`,
};
}
params.record.name = params.definition?.name ?? params.record.name;
params.record.description = params.definition?.description ?? params.record.description;
params.record.version = params.definition?.version ?? params.record.version;
const manifestKind = params.record.kind as string | undefined;
const exportKind = params.definition?.kind as string | undefined;
if (manifestKind && exportKind && exportKind !== manifestKind) {
params.diagnostics.push({
level: "warn",
pluginId: params.record.id,
source: params.record.source,
message: `plugin kind mismatch (manifest uses "${manifestKind}", export uses "${exportKind}")`,
});
}
params.record.kind = params.definition?.kind ?? params.record.kind;
return { ok: true };
}
export function resolveExtensionHostEarlyMemoryDecision(params: {
origin: PluginRecord["origin"];
manifestKind?: PluginRecord["kind"];
recordId: string;
memorySlot?: string;
selectedMemoryPluginId: string | null;
}): { enabled: boolean; reason?: string } {
if (params.origin !== "bundled" || params.manifestKind !== "memory") {
return { enabled: true };
}
const decision = resolveMemorySlotDecision({
id: params.recordId,
kind: "memory",
slot: params.memorySlot,
selectedId: params.selectedMemoryPluginId,
});
return {
enabled: decision.enabled,
...(decision.enabled ? {} : { reason: decision.reason }),
};
}
export function resolveExtensionHostMemoryDecision(params: {
recordId: string;
recordKind?: PluginRecord["kind"];
memorySlot?: string;
selectedMemoryPluginId: string | null;
}): { enabled: boolean; selected: boolean; reason?: string } {
const decision = resolveMemorySlotDecision({
id: params.recordId,
kind: params.recordKind,
slot: params.memorySlot,
selectedId: params.selectedMemoryPluginId,
});
return {
enabled: decision.enabled,
selected: decision.selected,
...(decision.enabled ? {} : { reason: decision.reason }),
};
}

View File

@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import type { PluginRegistry } from "../plugins/registry.js";
import { createExtensionHostPluginRecord } from "./loader-policy.js";
import {
appendExtensionHostPluginRecord,
setExtensionHostPluginRecordDisabled,
setExtensionHostPluginRecordError,
} from "./loader-state.js";
function createRegistry(): PluginRegistry {
return {
plugins: [],
tools: [],
hooks: [],
typedHooks: [],
channels: [],
providers: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],
services: [],
commands: [],
diagnostics: [],
};
}
describe("extension host loader state", () => {
it("marks plugin records disabled", () => {
const record = createExtensionHostPluginRecord({
id: "demo",
source: "/plugins/demo.js",
origin: "workspace",
enabled: true,
configSchema: true,
});
expect(setExtensionHostPluginRecordDisabled(record, "disabled by policy")).toMatchObject({
enabled: false,
status: "disabled",
error: "disabled by policy",
});
});
it("marks plugin records as errors", () => {
const record = createExtensionHostPluginRecord({
id: "demo",
source: "/plugins/demo.js",
origin: "workspace",
enabled: true,
configSchema: true,
});
expect(setExtensionHostPluginRecordError(record, "failed to load")).toMatchObject({
status: "error",
error: "failed to load",
});
});
it("appends records and optionally updates seen ids", () => {
const registry = createRegistry();
const seenIds = new Map<string, "workspace" | "global" | "bundled" | "config">();
const record = createExtensionHostPluginRecord({
id: "demo",
source: "/plugins/demo.js",
origin: "workspace",
enabled: true,
configSchema: true,
});
appendExtensionHostPluginRecord({
registry,
record,
seenIds,
pluginId: "demo",
origin: "workspace",
});
expect(registry.plugins).toEqual([record]);
expect(seenIds.get("demo")).toBe("workspace");
});
});

View File

@ -0,0 +1,33 @@
import type { PluginRecord, PluginRegistry } from "../plugins/registry.js";
export function setExtensionHostPluginRecordDisabled(
record: PluginRecord,
reason?: string,
): PluginRecord {
record.enabled = false;
record.status = "disabled";
record.error = reason;
return record;
}
export function setExtensionHostPluginRecordError(
record: PluginRecord,
message: string,
): PluginRecord {
record.status = "error";
record.error = message;
return record;
}
export function appendExtensionHostPluginRecord(params: {
registry: PluginRegistry;
record: PluginRecord;
seenIds?: Map<string, PluginRecord["origin"]>;
pluginId?: string;
origin?: PluginRecord["origin"];
}): void {
params.registry.plugins.push(params.record);
if (params.seenIds && params.pluginId && params.origin) {
params.seenIds.set(params.pluginId, params.origin);
}
}

View File

@ -1,12 +1,40 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createJiti } from "jiti";
import type { OpenClawConfig } from "../config/config.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { activateExtensionHostRegistry } from "../extension-host/activation.js";
import {
listPluginSdkAliasCandidates,
listPluginSdkExportedSubpaths,
resolvePluginSdkAlias,
resolvePluginSdkAliasCandidateOrder,
resolvePluginSdkAliasFile,
resolvePluginSdkScopedAliasMap,
} from "../extension-host/loader-compat.js";
import {
buildExtensionHostProvenanceIndex,
compareExtensionHostDuplicateCandidateOrder,
createExtensionHostPluginRecord,
pushExtensionHostDiagnostics,
recordExtensionHostPluginError,
warnAboutUntrackedLoadedExtensions,
warnWhenExtensionAllowlistIsOpen,
} from "../extension-host/loader-policy.js";
import {
applyExtensionHostDefinitionToRecord,
resolveExtensionHostEarlyMemoryDecision,
resolveExtensionHostMemoryDecision,
resolveExtensionHostModuleExport,
validateExtensionHostConfig,
} from "../extension-host/loader-runtime.js";
import {
appendExtensionHostPluginRecord,
setExtensionHostPluginRecordDisabled,
setExtensionHostPluginRecordError,
} from "../extension-host/loader-state.js";
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveUserPath } from "../utils.js";
import { clearPluginCommands } from "./commands.js";
@ -14,17 +42,14 @@ import {
applyTestPluginDefaults,
normalizePluginsConfig,
resolveEffectiveEnableState,
resolveMemorySlotDecision,
type NormalizedPluginsConfig,
} from "./config-state.js";
import { discoverOpenClawPlugins } from "./discovery.js";
import { initializeGlobalHookRunner } from "./hook-runner-global.js";
import { clearPluginInteractiveHandlers } from "./interactive.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import { isPathInside, safeStatSync } from "./path-safety.js";
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
import { resolvePluginCacheInputs } from "./roots.js";
import { setActivePluginRegistry } from "./runtime.js";
import { createPluginRuntime, type CreatePluginRuntimeOptions } from "./runtime/index.js";
import type { PluginRuntime } from "./runtime/types.js";
import { validateJsonSchemaValue } from "./schema-validator.js";
@ -657,7 +682,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
if (cacheEnabled) {
const cached = getCachedPluginRegistry(cacheKey);
if (cached) {
activatePluginRegistry(cached, cacheKey);
activateExtensionHostRegistry(cached, cacheKey);
return cached;
}
}
@ -719,19 +744,20 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
candidates: discovery.candidates,
diagnostics: discovery.diagnostics,
});
pushDiagnostics(registry.diagnostics, manifestRegistry.diagnostics);
warnWhenAllowlistIsOpen({
pushExtensionHostDiagnostics(registry.diagnostics, manifestRegistry.diagnostics);
warnWhenExtensionAllowlistIsOpen({
logger,
pluginsEnabled: normalized.enabled,
allow: normalized.allow,
warningCacheKey: cacheKey,
warningCache: openAllowlistWarningCache,
discoverablePlugins: manifestRegistry.plugins.map((plugin) => ({
id: plugin.id,
source: plugin.source,
origin: plugin.origin,
})),
});
const provenance = buildProvenanceIndex({
const provenance = buildExtensionHostProvenanceIndex({
config: cfg,
normalizedLoadPaths: normalized.loadPaths,
env,
@ -766,7 +792,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
manifestRegistry.plugins.map((record) => [record.rootDir, record]),
);
const orderedCandidates = [...discovery.candidates].toSorted((left, right) => {
return compareDuplicateCandidateOrder({
return compareExtensionHostDuplicateCandidateOrder({
left,
right,
manifestByRoot,
@ -788,7 +814,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
const pluginId = manifestRecord.id;
const existingOrigin = seenIds.get(pluginId);
if (existingOrigin) {
const record = createPluginRecord({
const record = createExtensionHostPluginRecord({
id: pluginId,
name: manifestRecord.name ?? pluginId,
description: manifestRecord.description,
@ -803,9 +829,8 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
enabled: false,
configSchema: Boolean(manifestRecord.configSchema),
});
record.status = "disabled";
record.error = `overridden by ${existingOrigin} plugin`;
registry.plugins.push(record);
setExtensionHostPluginRecordDisabled(record, `overridden by ${existingOrigin} plugin`);
appendExtensionHostPluginRecord({ registry, record });
continue;
}
@ -816,7 +841,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
rootConfig: cfg,
});
const entry = normalized.entries[pluginId];
const record = createPluginRecord({
const record = createExtensionHostPluginRecord({
id: pluginId,
name: manifestRecord.name ?? pluginId,
description: manifestRecord.description,
@ -835,10 +860,14 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
record.configUiHints = manifestRecord.configUiHints;
record.configJsonSchema = manifestRecord.configSchema;
const pushPluginLoadError = (message: string) => {
record.status = "error";
record.error = message;
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
setExtensionHostPluginRecordError(record, message);
appendExtensionHostPluginRecord({
registry,
record,
seenIds,
pluginId,
origin: candidate.origin,
});
registry.diagnostics.push({
level: "error",
pluginId: record.id,
@ -848,10 +877,14 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
};
if (!enableState.enabled) {
record.status = "disabled";
record.error = enableState.reason;
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
setExtensionHostPluginRecordDisabled(record, enableState.reason);
appendExtensionHostPluginRecord({
registry,
record,
seenIds,
pluginId,
origin: candidate.origin,
});
continue;
}
@ -881,21 +914,23 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
// Fast-path bundled memory plugins that are guaranteed disabled by slot policy.
// This avoids opening/importing heavy memory plugin modules that will never register.
if (candidate.origin === "bundled" && manifestRecord.kind === "memory") {
const earlyMemoryDecision = resolveMemorySlotDecision({
id: record.id,
kind: "memory",
slot: memorySlot,
selectedId: selectedMemoryPluginId,
const earlyMemoryDecision = resolveExtensionHostEarlyMemoryDecision({
origin: candidate.origin,
manifestKind: manifestRecord.kind,
recordId: record.id,
memorySlot,
selectedMemoryPluginId,
});
if (!earlyMemoryDecision.enabled) {
setExtensionHostPluginRecordDisabled(record, earlyMemoryDecision.reason);
appendExtensionHostPluginRecord({
registry,
record,
seenIds,
pluginId,
origin: candidate.origin,
});
if (!earlyMemoryDecision.enabled) {
record.enabled = false;
record.status = "disabled";
record.error = earlyMemoryDecision.reason;
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
continue;
}
continue;
}
if (!manifestRecord.configSchema) {
@ -922,7 +957,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
try {
mod = getJiti()(safeSource) as OpenClawPluginModule;
} catch (err) {
recordPluginError({
recordExtensionHostPluginError({
logger,
registry,
record,
@ -936,49 +971,40 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
continue;
}
const resolved = resolvePluginModuleExport(mod);
const resolved = resolveExtensionHostModuleExport(mod);
const definition = resolved.definition;
const register = resolved.register;
if (definition?.id && definition.id !== record.id) {
pushPluginLoadError(
`plugin id mismatch (config uses "${record.id}", export uses "${definition.id}")`,
);
const definitionResult = applyExtensionHostDefinitionToRecord({
record,
definition,
diagnostics: registry.diagnostics,
});
if (!definitionResult.ok) {
pushPluginLoadError(definitionResult.message);
continue;
}
record.name = definition?.name ?? record.name;
record.description = definition?.description ?? record.description;
record.version = definition?.version ?? record.version;
const manifestKind = record.kind as string | undefined;
const exportKind = definition?.kind as string | undefined;
if (manifestKind && exportKind && exportKind !== manifestKind) {
registry.diagnostics.push({
level: "warn",
pluginId: record.id,
source: record.source,
message: `plugin kind mismatch (manifest uses "${manifestKind}", export uses "${exportKind}")`,
});
}
record.kind = definition?.kind ?? record.kind;
if (record.kind === "memory" && memorySlot === record.id) {
memorySlotMatched = true;
}
const memoryDecision = resolveMemorySlotDecision({
id: record.id,
kind: record.kind,
slot: memorySlot,
selectedId: selectedMemoryPluginId,
const memoryDecision = resolveExtensionHostMemoryDecision({
recordId: record.id,
recordKind: record.kind,
memorySlot,
selectedMemoryPluginId,
});
if (!memoryDecision.enabled) {
record.enabled = false;
record.status = "disabled";
record.error = memoryDecision.reason;
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
setExtensionHostPluginRecordDisabled(record, memoryDecision.reason);
appendExtensionHostPluginRecord({
registry,
record,
seenIds,
pluginId,
origin: candidate.origin,
});
continue;
}
@ -986,7 +1012,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
selectedMemoryPluginId = record.id;
}
const validatedConfig = validatePluginConfig({
const validatedConfig = validateExtensionHostConfig({
schema: manifestRecord.configSchema,
cacheKey: manifestRecord.schemaCacheKey,
value: entry?.config,
@ -999,8 +1025,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
}
if (validateOnly) {
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
appendExtensionHostPluginRecord({
registry,
record,
seenIds,
pluginId,
origin: candidate.origin,
});
continue;
}
@ -1026,10 +1057,15 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
message: "plugin register returned a promise; async registration is ignored",
});
}
registry.plugins.push(record);
seenIds.set(pluginId, candidate.origin);
appendExtensionHostPluginRecord({
registry,
record,
seenIds,
pluginId,
origin: candidate.origin,
});
} catch (err) {
recordPluginError({
recordExtensionHostPluginError({
logger,
registry,
record,
@ -1050,7 +1086,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
});
}
warnAboutUntrackedLoadedPlugins({
warnAboutUntrackedLoadedExtensions({
registry,
provenance,
logger,
@ -1060,7 +1096,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
if (cacheEnabled) {
setCachedPluginRegistry(cacheKey, registry);
}
activatePluginRegistry(registry, cacheKey);
activateExtensionHostRegistry(registry, cacheKey);
return registry;
}