diff --git a/src/extension-host/activation.test.ts b/src/extension-host/activation.test.ts new file mode 100644 index 00000000000..99fb8d7fbf4 --- /dev/null +++ b/src/extension-host/activation.test.ts @@ -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(); + }); +}); diff --git a/src/extension-host/activation.ts b/src/extension-host/activation.ts new file mode 100644 index 00000000000..9ef28146e4b --- /dev/null +++ b/src/extension-host/activation.ts @@ -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); +} diff --git a/src/extension-host/cutover-inventory.md b/src/extension-host/cutover-inventory.md new file mode 100644 index 00000000000..258b20b5785 --- /dev/null +++ b/src/extension-host/cutover-inventory.md @@ -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 diff --git a/src/extension-host/loader-compat.ts b/src/extension-host/loader-compat.ts new file mode 100644 index 00000000000..60f750ed2f7 --- /dev/null +++ b/src/extension-host/loader-compat.ts @@ -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(); + +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; + }; + 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 { + const aliasMap: Record = {}; + for (const subpath of listPluginSdkExportedSubpaths()) { + const resolved = resolvePluginSdkAliasFile({ + srcFile: `${subpath}.ts`, + distFile: `${subpath}.js`, + }); + if (resolved) { + aliasMap[`openclaw/plugin-sdk/${subpath}`] = resolved; + } + } + return aliasMap; +} diff --git a/src/extension-host/loader-policy.test.ts b/src/extension-host/loader-policy.test.ts new file mode 100644 index 00000000000..8bbcfc51546 --- /dev/null +++ b/src/extension-host/loader-policy.test.ts @@ -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([ + [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(); + + 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"); + }); +}); diff --git a/src/extension-host/loader-policy.ts b/src/extension-host/loader-policy.ts new file mode 100644 index 00000000000..48194fffbfa --- /dev/null +++ b/src/extension-host/loader-policy.ts @@ -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; + dirs: string[]; +}; + +type InstallTrackingRule = { + trackedWithoutPaths: boolean; + matcher: PathMatcher; +}; + +export type ExtensionHostProvenanceIndex = { + loadPathMatcher: PathMatcher; + installRules: Map; +}; + +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; + 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(), 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(); + 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; + 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; + 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; + 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)})`, + ); + } +} diff --git a/src/extension-host/loader-runtime.test.ts b/src/extension-host/loader-runtime.test.ts new file mode 100644 index 00000000000..74908641de8 --- /dev/null +++ b/src/extension-host/loader-runtime.test.ts @@ -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, + }); + }); +}); diff --git a/src/extension-host/loader-runtime.ts b/src/extension-host/loader-runtime.ts new file mode 100644 index 00000000000..799b97c59be --- /dev/null +++ b/src/extension-host/loader-runtime.ts @@ -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; + cacheKey?: string; + value?: unknown; +}): { ok: boolean; value?: Record; errors?: string[] } { + const schema = params.schema; + if (!schema) { + return { ok: true, value: params.value as Record | 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 | 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) + ? (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 }), + }; +} diff --git a/src/extension-host/loader-state.test.ts b/src/extension-host/loader-state.test.ts new file mode 100644 index 00000000000..956f794bbc8 --- /dev/null +++ b/src/extension-host/loader-state.test.ts @@ -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(); + 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"); + }); +}); diff --git a/src/extension-host/loader-state.ts b/src/extension-host/loader-state.ts new file mode 100644 index 00000000000..9fbf7637434 --- /dev/null +++ b/src/extension-host/loader-state.ts @@ -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; + 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); + } +} diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 319b0ae90d7..c3560cd850f 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -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; }