From fb9a0383d131c00738325467a1cc2b73405e7a45 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 15 Mar 2026 11:26:41 +0000 Subject: [PATCH] Plugins: add extension host registry boundary --- src/agents/skills/plugin-skills.test.ts | 85 +-- src/agents/skills/plugin-skills.ts | 69 ++- src/channels/dock.ts | 6 +- src/channels/plugins/catalog.ts | 17 +- src/channels/plugins/index.ts | 10 +- src/channels/plugins/registry-loader.ts | 4 +- src/channels/registry.ts | 4 +- src/config/doc-baseline.ts | 45 +- src/config/plugin-auto-enable.ts | 48 +- .../resolved-extension-validation.test.ts | 47 ++ src/config/resolved-extension-validation.ts | 54 ++ src/config/validation.ts | 44 +- src/extension-host/active-registry.test.ts | 58 ++ src/extension-host/active-registry.ts | 58 ++ src/extension-host/manifest-registry.ts | 52 ++ src/extension-host/resolved-registry.ts | 70 +++ .../runtime-registrations.test.ts | 524 +++++++++++++++++ src/extension-host/runtime-registrations.ts | 553 ++++++++++++++++++ src/extension-host/schema.test.ts | 112 ++++ src/extension-host/schema.ts | 181 ++++++ src/plugins/discovery.ts | 38 +- src/plugins/http-registry.ts | 4 +- src/plugins/install.ts | 6 +- src/plugins/manifest-registry.ts | 7 + src/plugins/manifest.ts | 21 + src/plugins/registry.ts | 209 +++---- src/plugins/runtime.ts | 49 +- src/utils/message-channel.ts | 8 +- 28 files changed, 2066 insertions(+), 317 deletions(-) create mode 100644 src/config/resolved-extension-validation.test.ts create mode 100644 src/config/resolved-extension-validation.ts create mode 100644 src/extension-host/active-registry.test.ts create mode 100644 src/extension-host/active-registry.ts create mode 100644 src/extension-host/manifest-registry.ts create mode 100644 src/extension-host/resolved-registry.ts create mode 100644 src/extension-host/runtime-registrations.test.ts create mode 100644 src/extension-host/runtime-registrations.ts create mode 100644 src/extension-host/schema.test.ts create mode 100644 src/extension-host/schema.ts diff --git a/src/agents/skills/plugin-skills.test.ts b/src/agents/skills/plugin-skills.test.ts index 9edcd463c22..d47510c40c0 100644 --- a/src/agents/skills/plugin-skills.test.ts +++ b/src/agents/skills/plugin-skills.test.ts @@ -1,26 +1,43 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; -import type { PluginManifestRegistry } from "../../plugins/manifest-registry.js"; import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js"; - -const hoisted = vi.hoisted(() => ({ - loadPluginManifestRegistry: vi.fn(), -})); - -vi.mock("../../plugins/manifest-registry.js", () => ({ - loadPluginManifestRegistry: (...args: unknown[]) => hoisted.loadPluginManifestRegistry(...args), -})); - -const { resolvePluginSkillDirs } = await import("./plugin-skills.js"); +const { collectPluginSkillDirsFromRegistry } = await import("./plugin-skills.js"); const tempDirs = createTrackedTempDirs(); -function buildRegistry(params: { acpxRoot: string; helperRoot: string }): PluginManifestRegistry { +type MockResolvedExtensionRegistry = { + diagnostics: unknown[]; + extensions: Array<{ + extension: { + id: string; + name?: string; + kind?: string; + origin?: "workspace" | "bundled" | "global" | "config"; + rootDir?: string; + manifest: { + id: string; + configSchema: Record; + skills?: string[]; + }; + staticMetadata: { + configSchema: Record; + package: { entries: string[] }; + }; + contributions: unknown[]; + }; + manifestPath: string; + }>; +}; + +function buildRegistry(params: { + acpxRoot: string; + helperRoot: string; +}): MockResolvedExtensionRegistry { return { diagnostics: [], - plugins: [ + extensions: [ { id: "acpx", name: "ACPX Runtime", @@ -56,7 +73,7 @@ function createSinglePluginRegistry(params: { }): PluginManifestRegistry { return { diagnostics: [], - plugins: [ + extensions: [ { id: "helper", name: "Helper", @@ -75,25 +92,21 @@ function createSinglePluginRegistry(params: { } async function setupAcpxAndHelperRegistry() { - const workspaceDir = await tempDirs.make("openclaw-"); const acpxRoot = await tempDirs.make("openclaw-acpx-plugin-"); const helperRoot = await tempDirs.make("openclaw-helper-plugin-"); await fs.mkdir(path.join(acpxRoot, "skills"), { recursive: true }); await fs.mkdir(path.join(helperRoot, "skills"), { recursive: true }); - hoisted.loadPluginManifestRegistry.mockReturnValue(buildRegistry({ acpxRoot, helperRoot })); - return { workspaceDir, acpxRoot, helperRoot }; + return { registry: buildRegistry({ acpxRoot, helperRoot }), acpxRoot, helperRoot }; } async function setupPluginOutsideSkills() { - const workspaceDir = await tempDirs.make("openclaw-"); const pluginRoot = await tempDirs.make("openclaw-plugin-"); const outsideDir = await tempDirs.make("openclaw-outside-"); const outsideSkills = path.join(outsideDir, "skills"); - return { workspaceDir, pluginRoot, outsideSkills }; + return { pluginRoot, outsideSkills }; } afterEach(async () => { - hoisted.loadPluginManifestRegistry.mockReset(); await tempDirs.cleanup(); }); @@ -115,10 +128,10 @@ describe("resolvePluginSkillDirs", () => { ], }, ])("$name", async ({ acpEnabled, expectedDirs }) => { - const { workspaceDir, acpxRoot, helperRoot } = await setupAcpxAndHelperRegistry(); + const { registry, acpxRoot, helperRoot } = await setupAcpxAndHelperRegistry(); - const dirs = resolvePluginSkillDirs({ - workspaceDir, + const dirs = collectPluginSkillDirsFromRegistry({ + registry, config: { acp: { enabled: acpEnabled }, plugins: { @@ -134,17 +147,15 @@ describe("resolvePluginSkillDirs", () => { }); it("rejects plugin skill paths that escape the plugin root", async () => { - const { workspaceDir, pluginRoot, outsideSkills } = await setupPluginOutsideSkills(); + const { pluginRoot, outsideSkills } = await setupPluginOutsideSkills(); await fs.mkdir(path.join(pluginRoot, "skills"), { recursive: true }); await fs.mkdir(outsideSkills, { recursive: true }); const escapePath = path.relative(pluginRoot, outsideSkills); - hoisted.loadPluginManifestRegistry.mockReturnValue( - createSinglePluginRegistry({ - pluginRoot, - skills: ["./skills", escapePath], - }), - ); + const registry = createSinglePluginRegistry({ + pluginRoot, + skills: ["./skills", escapePath], + }); const dirs = resolvePluginSkillDirs({ workspaceDir, @@ -161,7 +172,7 @@ describe("resolvePluginSkillDirs", () => { }); it("rejects plugin skill symlinks that resolve outside plugin root", async () => { - const { workspaceDir, pluginRoot, outsideSkills } = await setupPluginOutsideSkills(); + const { pluginRoot, outsideSkills } = await setupPluginOutsideSkills(); const linkPath = path.join(pluginRoot, "skills-link"); await fs.mkdir(outsideSkills, { recursive: true }); await fs.symlink( @@ -170,12 +181,10 @@ describe("resolvePluginSkillDirs", () => { process.platform === "win32" ? ("junction" as const) : ("dir" as const), ); - hoisted.loadPluginManifestRegistry.mockReturnValue( - createSinglePluginRegistry({ - pluginRoot, - skills: ["./skills-link"], - }), - ); + const registry = createSinglePluginRegistry({ + pluginRoot, + skills: ["./skills-link"], + }); const dirs = resolvePluginSkillDirs({ workspaceDir, diff --git a/src/agents/skills/plugin-skills.ts b/src/agents/skills/plugin-skills.ts index 5a02737e5cd..c26c68fc9c2 100644 --- a/src/agents/skills/plugin-skills.ts +++ b/src/agents/skills/plugin-skills.ts @@ -1,30 +1,26 @@ import fs from "node:fs"; import path from "node:path"; import type { OpenClawConfig } from "../../config/config.js"; +import { + loadResolvedExtensionRegistry, + type ResolvedExtensionRegistry, +} from "../../extension-host/resolved-registry.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { normalizePluginsConfig, resolveEffectiveEnableState, resolveMemorySlotDecision, } from "../../plugins/config-state.js"; -import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js"; import { isPathInsideWithRealpath } from "../../security/scan-paths.js"; const log = createSubsystemLogger("skills"); -export function resolvePluginSkillDirs(params: { - workspaceDir: string | undefined; +export function collectPluginSkillDirsFromRegistry(params: { + registry: ResolvedExtensionRegistry; config?: OpenClawConfig; }): string[] { - const workspaceDir = (params.workspaceDir ?? "").trim(); - if (!workspaceDir) { - return []; - } - const registry = loadPluginManifestRegistry({ - workspaceDir, - config: params.config, - }); - if (registry.plugins.length === 0) { + const registry = params.registry; + if (registry.extensions.length === 0) { return []; } const normalizedPlugins = normalizePluginsConfig(params.config?.plugins); @@ -34,13 +30,15 @@ export function resolvePluginSkillDirs(params: { const seen = new Set(); const resolved: string[] = []; - for (const record of registry.plugins) { - if (!record.skills || record.skills.length === 0) { + for (const record of registry.extensions) { + const extension = record.extension; + const skillPaths = extension.manifest.skills ?? []; + if (skillPaths.length === 0) { continue; } const enableState = resolveEffectiveEnableState({ - id: record.id, - origin: record.origin, + id: extension.id, + origin: extension.origin ?? "workspace", config: normalizedPlugins, rootConfig: params.config, }); @@ -48,33 +46,34 @@ export function resolvePluginSkillDirs(params: { continue; } // ACP router skills should not be attached when ACP is explicitly disabled. - if (!acpEnabled && record.id === "acpx") { + if (!acpEnabled && extension.id === "acpx") { continue; } const memoryDecision = resolveMemorySlotDecision({ - id: record.id, - kind: record.kind, + id: extension.id, + kind: extension.kind, slot: memorySlot, selectedId: selectedMemoryPluginId, }); if (!memoryDecision.enabled) { continue; } - if (memoryDecision.selected && record.kind === "memory") { - selectedMemoryPluginId = record.id; + if (memoryDecision.selected && extension.kind === "memory") { + selectedMemoryPluginId = extension.id; } - for (const raw of record.skills) { + const rootDir = extension.rootDir ?? path.dirname(record.manifestPath); + for (const raw of skillPaths) { const trimmed = raw.trim(); if (!trimmed) { continue; } - const candidate = path.resolve(record.rootDir, trimmed); + const candidate = path.resolve(rootDir, trimmed); if (!fs.existsSync(candidate)) { - log.warn(`plugin skill path not found (${record.id}): ${candidate}`); + log.warn(`plugin skill path not found (${extension.id}): ${candidate}`); continue; } - if (!isPathInsideWithRealpath(record.rootDir, candidate, { requireRealpath: true })) { - log.warn(`plugin skill path escapes plugin root (${record.id}): ${candidate}`); + if (!isPathInsideWithRealpath(rootDir, candidate, { requireRealpath: true })) { + log.warn(`plugin skill path escapes plugin root (${extension.id}): ${candidate}`); continue; } if (seen.has(candidate)) { @@ -87,3 +86,21 @@ export function resolvePluginSkillDirs(params: { return resolved; } + +export function resolvePluginSkillDirs(params: { + workspaceDir: string | undefined; + config?: OpenClawConfig; +}): string[] { + const workspaceDir = (params.workspaceDir ?? "").trim(); + if (!workspaceDir) { + return []; + } + const registry = loadResolvedExtensionRegistry({ + workspaceDir, + config: params.config, + }); + return collectPluginSkillDirsFromRegistry({ + registry, + config: params.config, + }); +} diff --git a/src/channels/dock.ts b/src/channels/dock.ts index 2e63583ca1b..11ba8873aa5 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -8,6 +8,7 @@ import { resolveChannelGroupRequireMention, resolveChannelGroupToolsPolicy, } from "../config/group-policy.js"; +import { requireActiveExtensionHostRegistry } from "../extension-host/active-registry.js"; import { formatAllowFromLowercase, formatNormalizedAllowFromEntries, @@ -22,7 +23,6 @@ import { resolveWhatsAppConfigAllowFrom, resolveWhatsAppConfigDefaultTo, } from "../plugin-sdk/channel-config-helpers.js"; -import { requireActivePluginRegistry } from "../plugins/runtime.js"; import { normalizeAccountId } from "../routing/session-key.js"; import { normalizeE164 } from "../utils.js"; import { @@ -582,7 +582,7 @@ function buildDockFromPlugin(plugin: ChannelPlugin): ChannelDock { } function listPluginDockEntries(): Array<{ id: ChannelId; dock: ChannelDock; order?: number }> { - const registry = requireActivePluginRegistry(); + const registry = requireActiveExtensionHostRegistry(); const entries: Array<{ id: ChannelId; dock: ChannelDock; order?: number }> = []; const seen = new Set(); for (const entry of registry.channels) { @@ -627,7 +627,7 @@ export function getChannelDock(id: ChannelId): ChannelDock | undefined { if (core) { return core; } - const registry = requireActivePluginRegistry(); + const registry = requireActiveExtensionHostRegistry(); const pluginEntry = registry.channels.find((entry) => entry.plugin.id === id); if (!pluginEntry) { return undefined; diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index a853dcdf805..0ab7cfb3304 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -1,8 +1,11 @@ import fs from "node:fs"; import path from "node:path"; -import { MANIFEST_KEY } from "../../compat/legacy-names.js"; +import { + getExtensionPackageMetadata, + type OpenClawPackageManifest, + type PackageManifest, +} from "../../extension-host/schema.js"; import { discoverOpenClawPlugins } from "../../plugins/discovery.js"; -import type { OpenClawPackageManifest } from "../../plugins/manifest.js"; import type { PluginOrigin } from "../../plugins/types.js"; import { isRecord, resolveConfigDir, resolveUserPath } from "../../utils.js"; import type { ChannelMeta } from "./types.js"; @@ -46,16 +49,10 @@ const ORIGIN_PRIORITY: Record = { bundled: 3, }; -type ExternalCatalogEntry = { - name?: string; - version?: string; - description?: string; -} & Partial>; +type ExternalCatalogEntry = PackageManifest; const ENV_CATALOG_PATHS = ["OPENCLAW_PLUGIN_CATALOG_PATHS", "OPENCLAW_MPM_CATALOG_PATHS"]; -type ManifestKey = typeof MANIFEST_KEY; - function parseCatalogEntries(raw: unknown): ExternalCatalogEntry[] { if (Array.isArray(raw)) { return raw.filter((entry): entry is ExternalCatalogEntry => isRecord(entry)); @@ -227,7 +224,7 @@ function buildCatalogEntry(candidate: { } function buildExternalCatalogEntry(entry: ExternalCatalogEntry): ChannelPluginCatalogEntry | null { - const manifest = entry[MANIFEST_KEY]; + const manifest = getExtensionPackageMetadata(entry); return buildCatalogEntry({ packageName: entry.name, packageManifest: manifest, diff --git a/src/channels/plugins/index.ts b/src/channels/plugins/index.ts index 43b0aa99452..feeb34a4324 100644 --- a/src/channels/plugins/index.ts +++ b/src/channels/plugins/index.ts @@ -1,7 +1,7 @@ import { - getActivePluginRegistryVersion, - requireActivePluginRegistry, -} from "../../plugins/runtime.js"; + getActiveExtensionHostRegistryVersion, + requireActiveExtensionHostRegistry, +} from "../../extension-host/active-registry.js"; import { CHAT_CHANNEL_ORDER, type ChatChannelId, normalizeAnyChannelId } from "../registry.js"; import type { ChannelId, ChannelPlugin } from "./types.js"; @@ -40,8 +40,8 @@ const EMPTY_CHANNEL_PLUGIN_CACHE: CachedChannelPlugins = { let cachedChannelPlugins = EMPTY_CHANNEL_PLUGIN_CACHE; function resolveCachedChannelPlugins(): CachedChannelPlugins { - const registry = requireActivePluginRegistry(); - const registryVersion = getActivePluginRegistryVersion(); + const registry = requireActiveExtensionHostRegistry(); + const registryVersion = getActiveExtensionHostRegistryVersion(); const cached = cachedChannelPlugins; if (cached.registryVersion === registryVersion) { return cached; diff --git a/src/channels/plugins/registry-loader.ts b/src/channels/plugins/registry-loader.ts index 9f23c5fa09e..cb4130787b5 100644 --- a/src/channels/plugins/registry-loader.ts +++ b/src/channels/plugins/registry-loader.ts @@ -1,5 +1,5 @@ +import { getActiveExtensionHostRegistry } from "../../extension-host/active-registry.js"; import type { PluginChannelRegistration, PluginRegistry } from "../../plugins/registry.js"; -import { getActivePluginRegistry } from "../../plugins/runtime.js"; import type { ChannelId } from "./types.js"; type ChannelRegistryValueResolver = ( @@ -13,7 +13,7 @@ export function createChannelRegistryLoader( let lastRegistry: PluginRegistry | null = null; return async (id: ChannelId): Promise => { - const registry = getActivePluginRegistry(); + const registry = getActiveExtensionHostRegistry(); if (registry !== lastRegistry) { cache.clear(); lastRegistry = registry; diff --git a/src/channels/registry.ts b/src/channels/registry.ts index 16ba6514397..052b869f732 100644 --- a/src/channels/registry.ts +++ b/src/channels/registry.ts @@ -1,4 +1,4 @@ -import { requireActivePluginRegistry } from "../plugins/runtime.js"; +import { requireActiveExtensionHostRegistry } from "../extension-host/active-registry.js"; import type { ChannelMeta } from "./plugins/types.js"; import type { ChannelId } from "./plugins/types.js"; @@ -169,7 +169,7 @@ export function normalizeAnyChannelId(raw?: string | null): ChannelId | null { return null; } - const registry = requireActivePluginRegistry(); + const registry = requireActiveExtensionHostRegistry(); const hit = registry.channels.find((entry) => { const id = String(entry.plugin.id ?? "") .trim() diff --git a/src/config/doc-baseline.ts b/src/config/doc-baseline.ts index 4ff03af91e0..58bff4269ae 100644 --- a/src/config/doc-baseline.ts +++ b/src/config/doc-baseline.ts @@ -3,8 +3,11 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import type { ChannelPlugin } from "../channels/plugins/index.js"; +import { + loadResolvedExtensionRegistry, + type ResolvedExtensionRegistry, +} from "../extension-host/resolved-registry.js"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; -import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { FIELD_HELP } from "./schema.help.js"; import { buildConfigSchema, type ConfigSchemaResponse } from "./schema.js"; @@ -355,29 +358,41 @@ async function loadBundledConfigSchemaResponse(): Promise OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(repoRoot, "extensions"), }; - const manifestRegistry = loadPluginManifestRegistry({ + const registry = loadResolvedExtensionRegistry({ cache: false, env, config: {}, }); + return buildBundledConfigSchemaResponseFromRegistry(registry); +} + +async function buildBundledConfigSchemaResponseFromRegistry( + registry: ResolvedExtensionRegistry, +): Promise { const channelPlugins = await Promise.all( - manifestRegistry.plugins - .filter((plugin) => plugin.origin === "bundled" && plugin.channels.length > 0) - .map(async (plugin) => ({ - id: plugin.id, - channel: await importChannelPluginModule(plugin.rootDir), + registry.extensions + .filter( + (record) => + record.extension.origin === "bundled" && + (record.extension.manifest.channels?.length ?? 0) > 0, + ) + .map(async (record) => ({ + id: record.extension.id, + channel: await importChannelPluginModule( + record.extension.rootDir ?? path.dirname(record.manifestPath), + ), })), ); return buildConfigSchema({ - plugins: manifestRegistry.plugins - .filter((plugin) => plugin.origin === "bundled") - .map((plugin) => ({ - id: plugin.id, - name: plugin.name, - description: plugin.description, - configUiHints: plugin.configUiHints, - configSchema: plugin.configSchema, + plugins: registry.extensions + .filter((record) => record.extension.origin === "bundled") + .map((record) => ({ + id: record.extension.id, + name: record.extension.name, + description: record.extension.description, + configUiHints: record.extension.staticMetadata.configUiHints, + configSchema: record.extension.staticMetadata.configSchema, })), channels: channelPlugins.map((entry) => ({ id: entry.channel.id, diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 4e0cae1209f..cdcbdeace4a 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -10,9 +10,11 @@ import { normalizeChatChannelId, } from "../channels/registry.js"; import { - loadPluginManifestRegistry, - type PluginManifestRegistry, -} from "../plugins/manifest-registry.js"; + loadResolvedExtensionRegistry, + resolvedExtensionRegistryFromPluginManifestRegistry, + type ResolvedExtensionRegistry, +} from "../extension-host/resolved-registry.js"; +import { type PluginManifestRegistry } from "../plugins/manifest-registry.js"; import { isRecord } from "../utils.js"; import type { OpenClawConfig } from "./config.js"; import { ensurePluginAllowlisted } from "./plugins-allowlist.js"; @@ -283,12 +285,12 @@ function isProviderConfigured(cfg: OpenClawConfig, providerId: string): boolean return false; } -function buildChannelToPluginIdMap(registry: PluginManifestRegistry): Map { +function buildChannelToPluginIdMap(registry: ResolvedExtensionRegistry): Map { const map = new Map(); - for (const record of registry.plugins) { - for (const channelId of record.channels) { + for (const record of registry.extensions) { + for (const channelId of record.extension.manifest.channels ?? []) { if (channelId && !map.has(channelId)) { - map.set(channelId, record.id); + map.set(channelId, record.extension.id); } } } @@ -336,7 +338,7 @@ function collectCandidateChannelIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv) function resolveConfiguredPlugins( cfg: OpenClawConfig, env: NodeJS.ProcessEnv, - registry: PluginManifestRegistry, + registry: ResolvedExtensionRegistry, ): PluginEnableChange[] { const changes: PluginEnableChange[] = []; // Build reverse map: channel ID → plugin ID from installed plugin manifests. @@ -471,17 +473,39 @@ function formatAutoEnableChange(entry: PluginEnableChange): string { return `${reason}, enabled automatically.`; } +function resolveAutoEnableRegistry(params: { + config: OpenClawConfig; + env: NodeJS.ProcessEnv; + resolvedRegistry?: ResolvedExtensionRegistry; + manifestRegistry?: PluginManifestRegistry; +}): ResolvedExtensionRegistry { + if (params.resolvedRegistry) { + return params.resolvedRegistry; + } + if (params.manifestRegistry) { + return resolvedExtensionRegistryFromPluginManifestRegistry(params.manifestRegistry); + } + return loadResolvedExtensionRegistry({ config: params.config, env: params.env }); +} + export function applyPluginAutoEnable(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv; + /** Pre-loaded resolved-extension registry. Prefer this over manifestRegistry + * for new callers so static consumers stay on the host-owned boundary. */ + resolvedRegistry?: ResolvedExtensionRegistry; /** Pre-loaded manifest registry. When omitted, the registry is loaded from - * the installed plugins on disk. Pass an explicit registry in tests to - * avoid filesystem access and control what plugins are "installed". */ + * the installed plugins on disk. This remains as a compatibility input for + * older callers; prefer resolvedRegistry for new code. */ manifestRegistry?: PluginManifestRegistry; }): PluginAutoEnableResult { const env = params.env ?? process.env; - const registry = - params.manifestRegistry ?? loadPluginManifestRegistry({ config: params.config, env }); + const registry = resolveAutoEnableRegistry({ + config: params.config, + env, + resolvedRegistry: params.resolvedRegistry, + manifestRegistry: params.manifestRegistry, + }); const configured = resolveConfiguredPlugins(params.config, env, registry); if (configured.length === 0) { return { config: params.config, changes: [] }; diff --git a/src/config/resolved-extension-validation.test.ts b/src/config/resolved-extension-validation.test.ts new file mode 100644 index 00000000000..4216eb53779 --- /dev/null +++ b/src/config/resolved-extension-validation.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { buildResolvedExtensionValidationIndex } from "./resolved-extension-validation.js"; + +describe("buildResolvedExtensionValidationIndex", () => { + it("collects known ids, channel ids, and schema-bearing entries from resolved extensions", () => { + const index = buildResolvedExtensionValidationIndex({ + diagnostics: [], + extensions: [ + { + extension: { + id: "helper-plugin", + origin: "config", + manifest: { + id: "helper-plugin", + configSchema: { type: "object" }, + channels: ["apn", "custom-chat"], + }, + staticMetadata: { + configSchema: { + type: "object", + properties: { + enabledFlag: { type: "boolean" }, + }, + }, + package: { entries: ["index.ts"] }, + }, + contributions: [], + }, + manifestPath: "/tmp/helper/openclaw.plugin.json", + schemaCacheKey: "helper-schema", + }, + ], + }); + + expect(index.knownIds).toEqual(new Set(["helper-plugin"])); + expect(index.channelIds).toEqual(new Set(["apn", "custom-chat"])); + expect(index.lowercaseChannelIds).toEqual(new Set(["apn", "custom-chat"])); + expect(index.entries).toEqual([ + expect.objectContaining({ + id: "helper-plugin", + origin: "config", + channels: ["apn", "custom-chat"], + schemaCacheKey: "helper-schema", + }), + ]); + }); +}); diff --git a/src/config/resolved-extension-validation.ts b/src/config/resolved-extension-validation.ts new file mode 100644 index 00000000000..725f585a989 --- /dev/null +++ b/src/config/resolved-extension-validation.ts @@ -0,0 +1,54 @@ +import type { ResolvedExtensionRegistry } from "../extension-host/resolved-registry.js"; + +export type ResolvedExtensionValidationEntry = { + id: string; + origin: "workspace" | "bundled" | "global" | "config"; + kind?: string; + channels: string[]; + configSchema?: Record; + manifestPath: string; + schemaCacheKey?: string; +}; + +export type ResolvedExtensionValidationIndex = { + knownIds: Set; + channelIds: Set; + lowercaseChannelIds: Set; + entries: ResolvedExtensionValidationEntry[]; +}; + +export function buildResolvedExtensionValidationIndex( + registry: ResolvedExtensionRegistry, +): ResolvedExtensionValidationIndex { + const knownIds = new Set(); + const channelIds = new Set(); + const lowercaseChannelIds = new Set(); + const entries: ResolvedExtensionValidationEntry[] = registry.extensions.map((record) => { + const extension = record.extension; + const channels = [...(extension.manifest.channels ?? [])]; + knownIds.add(extension.id); + for (const channelId of channels) { + channelIds.add(channelId); + const trimmed = channelId.trim(); + if (trimmed) { + lowercaseChannelIds.add(trimmed.toLowerCase()); + } + } + return { + id: extension.id, + origin: extension.origin ?? "workspace", + kind: extension.kind, + channels, + configSchema: extension.staticMetadata.configSchema, + manifestPath: record.manifestPath, + schemaCacheKey: record.schemaCacheKey, + }; + }); + + return { + knownIds, + channelIds, + lowercaseChannelIds, + entries, + }; +} diff --git a/src/config/validation.ts b/src/config/validation.ts index e97bd8cbedf..b481c199b77 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -1,12 +1,12 @@ import path from "node:path"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { CHANNEL_IDS, normalizeChatChannelId } from "../channels/registry.js"; +import { loadResolvedExtensionRegistry } from "../extension-host/resolved-registry.js"; import { normalizePluginsConfig, resolveEffectiveEnableState, resolveMemorySlotDecision, } from "../plugins/config-state.js"; -import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { validateJsonSchemaValue } from "../plugins/schema-validator.js"; import { hasAvatarUriScheme, @@ -21,6 +21,7 @@ import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-di import { appendAllowedValuesHint, summarizeAllowedValues } from "./allowed-values.js"; import { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.js"; import { findLegacyConfigIssues } from "./legacy.js"; +import { buildResolvedExtensionValidationIndex } from "./resolved-extension-validation.js"; import type { OpenClawConfig, ConfigValidationIssue } from "./types.js"; import { OpenClawSchema } from "./zod-schema.js"; @@ -335,7 +336,8 @@ function validateConfigObjectWithPluginsBase( }; type RegistryInfo = { - registry: ReturnType; + registry: ReturnType; + validationIndex?: ReturnType; knownIds?: Set; normalizedPlugins?: ReturnType; }; @@ -348,7 +350,7 @@ function validateConfigObjectWithPluginsBase( } const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)); - const registry = loadPluginManifestRegistry({ + const registry = loadResolvedExtensionRegistry({ config, workspaceDir: workspaceDir ?? undefined, env: opts.env, @@ -374,12 +376,21 @@ function validateConfigObjectWithPluginsBase( const ensureKnownIds = (): Set => { const info = ensureRegistry(); - if (!info.knownIds) { - info.knownIds = new Set(info.registry.plugins.map((record) => record.id)); + if (!info.validationIndex) { + info.validationIndex = buildResolvedExtensionValidationIndex(info.registry); } + info.knownIds ??= info.validationIndex.knownIds; return info.knownIds; }; + const ensureValidationIndex = (): ReturnType => { + const info = ensureRegistry(); + if (!info.validationIndex) { + info.validationIndex = buildResolvedExtensionValidationIndex(info.registry); + } + return info.validationIndex; + }; + const ensureNormalizedPlugins = (): ReturnType => { const info = ensureRegistry(); if (!info.normalizedPlugins) { @@ -397,11 +408,9 @@ function validateConfigObjectWithPluginsBase( continue; } if (!allowedChannels.has(trimmed)) { - const { registry } = ensureRegistry(); - for (const record of registry.plugins) { - for (const channelId of record.channels) { - allowedChannels.add(channelId); - } + const validationIndex = ensureValidationIndex(); + for (const channelId of validationIndex.channelIds) { + allowedChannels.add(channelId); } } if (!allowedChannels.has(trimmed)) { @@ -435,14 +444,9 @@ function validateConfigObjectWithPluginsBase( return; } if (!heartbeatChannelIds.has(normalized)) { - const { registry } = ensureRegistry(); - for (const record of registry.plugins) { - for (const channelId of record.channels) { - const pluginChannel = channelId.trim(); - if (pluginChannel) { - heartbeatChannelIds.add(pluginChannel.toLowerCase()); - } - } + const validationIndex = ensureValidationIndex(); + for (const channelId of validationIndex.lowercaseChannelIds) { + heartbeatChannelIds.add(channelId); } } if (heartbeatChannelIds.has(normalized)) { @@ -468,7 +472,7 @@ function validateConfigObjectWithPluginsBase( return { ok: true, config, warnings }; } - const { registry } = ensureRegistry(); + const validationIndex = ensureValidationIndex(); const knownIds = ensureKnownIds(); const normalizedPlugins = ensureNormalizedPlugins(); const pushMissingPluginIssue = ( @@ -544,7 +548,7 @@ function validateConfigObjectWithPluginsBase( let selectedMemoryPluginId: string | null = null; const seenPlugins = new Set(); - for (const record of registry.plugins) { + for (const record of validationIndex.entries) { const pluginId = record.id; if (seenPlugins.has(pluginId)) { continue; diff --git a/src/extension-host/active-registry.test.ts b/src/extension-host/active-registry.test.ts new file mode 100644 index 00000000000..5b4c59c106c --- /dev/null +++ b/src/extension-host/active-registry.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; +import { + createEmptyExtensionHostRegistry, + getActiveExtensionHostRegistry, + getActiveExtensionHostRegistryKey, + getActiveExtensionHostRegistryVersion, + requireActiveExtensionHostRegistry, + setActiveExtensionHostRegistry, +} from "./active-registry.js"; + +describe("extension host active registry", () => { + it("initializes with an empty registry", () => { + const emptyRegistry = createEmptyExtensionHostRegistry(); + setActiveExtensionHostRegistry(emptyRegistry, "empty"); + const registry = requireActiveExtensionHostRegistry(); + expect(registry).toBeDefined(); + expect(registry).toBe(emptyRegistry); + expect(registry.channels).toEqual([]); + expect(registry.plugins).toEqual([]); + }); + + it("tracks registry replacement and cache keys", () => { + const baseVersion = getActiveExtensionHostRegistryVersion(); + const registry = createEmptyPluginRegistry(); + registry.plugins.push({ + id: "host-test", + name: "host-test", + source: "test", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }); + + setActiveExtensionHostRegistry(registry, "host-registry"); + + expect(getActiveExtensionHostRegistry()).toBe(registry); + expect(getActiveExtensionHostRegistryKey()).toBe("host-registry"); + expect(getActiveExtensionHostRegistryVersion()).toBe(baseVersion + 1); + }); + + it("can create a fresh empty registry", () => { + const registry = createEmptyExtensionHostRegistry(); + expect(registry).not.toBe(getActiveExtensionHostRegistry()); + expect(registry).toEqual(createEmptyPluginRegistry()); + }); +}); diff --git a/src/extension-host/active-registry.ts b/src/extension-host/active-registry.ts new file mode 100644 index 00000000000..2385130c2e7 --- /dev/null +++ b/src/extension-host/active-registry.ts @@ -0,0 +1,58 @@ +import { createEmptyPluginRegistry, type PluginRegistry } from "../plugins/registry.js"; + +const EXTENSION_HOST_REGISTRY_STATE = Symbol.for("openclaw.extensionHostRegistryState"); + +export type ExtensionHostRegistry = PluginRegistry; + +type ExtensionHostRegistryState = { + registry: ExtensionHostRegistry | null; + key: string | null; + version: number; +}; + +const state: ExtensionHostRegistryState = (() => { + const globalState = globalThis as typeof globalThis & { + [EXTENSION_HOST_REGISTRY_STATE]?: ExtensionHostRegistryState; + }; + if (!globalState[EXTENSION_HOST_REGISTRY_STATE]) { + globalState[EXTENSION_HOST_REGISTRY_STATE] = { + registry: createEmptyExtensionHostRegistry(), + key: null, + version: 0, + }; + } + return globalState[EXTENSION_HOST_REGISTRY_STATE]; +})(); + +export function createEmptyExtensionHostRegistry(): ExtensionHostRegistry { + return createEmptyPluginRegistry(); +} + +export function setActiveExtensionHostRegistry( + registry: ExtensionHostRegistry, + cacheKey?: string, +): void { + state.registry = registry; + state.key = cacheKey ?? null; + state.version += 1; +} + +export function getActiveExtensionHostRegistry(): ExtensionHostRegistry | null { + return state.registry; +} + +export function requireActiveExtensionHostRegistry(): ExtensionHostRegistry { + if (!state.registry) { + state.registry = createEmptyExtensionHostRegistry(); + state.version += 1; + } + return state.registry; +} + +export function getActiveExtensionHostRegistryKey(): string | null { + return state.key; +} + +export function getActiveExtensionHostRegistryVersion(): number { + return state.version; +} diff --git a/src/extension-host/manifest-registry.ts b/src/extension-host/manifest-registry.ts new file mode 100644 index 00000000000..462f218faaa --- /dev/null +++ b/src/extension-host/manifest-registry.ts @@ -0,0 +1,52 @@ +import type { PluginCandidate } from "../plugins/discovery.js"; +import { + loadPackageManifest, + type PackageManifest, + type PluginManifest, +} from "../plugins/manifest.js"; +import { resolveLegacyExtensionDescriptor, type ResolvedExtension } from "./schema.js"; + +export type ResolvedExtensionRecord = { + extension: ResolvedExtension; + manifestPath: string; + schemaCacheKey?: string; +}; + +export function buildResolvedExtensionRecord(params: { + manifest: PluginManifest; + candidate: PluginCandidate; + manifestPath: string; + schemaCacheKey?: string; + configSchema?: Record; +}): ResolvedExtensionRecord { + const packageDir = params.candidate.packageDir ?? params.candidate.rootDir; + const packageManifest = + params.candidate.packageManifest || + params.candidate.packageName || + params.candidate.packageVersion + ? ({ + openclaw: params.candidate.packageManifest, + name: params.candidate.packageName, + version: params.candidate.packageVersion, + description: params.candidate.packageDescription, + } as PackageManifest) + : (loadPackageManifest(packageDir, params.candidate.origin !== "bundled") ?? undefined); + + const extension = resolveLegacyExtensionDescriptor({ + manifest: { + ...params.manifest, + configSchema: params.configSchema ?? params.manifest.configSchema, + }, + packageManifest, + origin: params.candidate.origin, + rootDir: params.candidate.rootDir, + source: params.candidate.source, + workspaceDir: params.candidate.workspaceDir, + }); + + return { + extension, + manifestPath: params.manifestPath, + schemaCacheKey: params.schemaCacheKey, + }; +} diff --git a/src/extension-host/resolved-registry.ts b/src/extension-host/resolved-registry.ts new file mode 100644 index 00000000000..e61b1e842fe --- /dev/null +++ b/src/extension-host/resolved-registry.ts @@ -0,0 +1,70 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { + loadPluginManifestRegistry, + type PluginManifestRegistry, +} from "../plugins/manifest-registry.js"; +import type { PluginDiagnostic } from "../plugins/types.js"; +import type { ResolvedExtension } from "./schema.js"; + +export type ResolvedExtensionRegistryEntry = { + extension: ResolvedExtension; + manifestPath: string; + schemaCacheKey?: string; +}; + +export type ResolvedExtensionRegistry = { + extensions: ResolvedExtensionRegistryEntry[]; + diagnostics: PluginDiagnostic[]; +}; + +export function resolvedExtensionRegistryFromPluginManifestRegistry( + registry: PluginManifestRegistry, +): ResolvedExtensionRegistry { + return { + diagnostics: registry.diagnostics, + extensions: registry.plugins.map((plugin) => ({ + extension: + plugin.resolvedExtension ?? + ({ + id: plugin.id, + name: plugin.name, + description: plugin.description, + version: plugin.version, + kind: plugin.kind, + origin: plugin.origin, + rootDir: plugin.rootDir, + source: plugin.source, + workspaceDir: plugin.workspaceDir, + manifest: { + id: plugin.id, + name: plugin.name, + description: plugin.description, + version: plugin.version, + kind: plugin.kind, + channels: plugin.channels, + providers: plugin.providers, + skills: plugin.skills, + configSchema: plugin.configSchema ?? {}, + uiHints: plugin.configUiHints, + }, + staticMetadata: { + configSchema: plugin.configSchema ?? {}, + configUiHints: plugin.configUiHints, + package: { entries: [] }, + }, + contributions: [], + } satisfies ResolvedExtension), + manifestPath: plugin.manifestPath, + schemaCacheKey: plugin.schemaCacheKey, + })), + }; +} + +export function loadResolvedExtensionRegistry(params: { + config?: OpenClawConfig; + workspaceDir?: string; + cache?: boolean; + env?: NodeJS.ProcessEnv; +}): ResolvedExtensionRegistry { + return resolvedExtensionRegistryFromPluginManifestRegistry(loadPluginManifestRegistry(params)); +} diff --git a/src/extension-host/runtime-registrations.test.ts b/src/extension-host/runtime-registrations.test.ts new file mode 100644 index 00000000000..9b75e2fdf09 --- /dev/null +++ b/src/extension-host/runtime-registrations.test.ts @@ -0,0 +1,524 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AnyAgentTool } from "../agents/tools/common.js"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; +import type { ContextEngineFactory } from "../context-engine/registry.js"; +import type { InternalHookHandler } from "../hooks/internal-hooks.js"; +import type { HookEntry } from "../hooks/types.js"; +import type { + OpenClawPluginCliContext, + OpenClawPluginCommandDefinition, + OpenClawPluginHookOptions, + OpenClawPluginService, + PluginHookRegistration, + ProviderPlugin, +} from "../plugins/types.js"; +import { + resolveExtensionChannelRegistration, + resolveExtensionCliRegistration, + resolveExtensionCommandRegistration, + resolveExtensionContextEngineRegistration, + resolveExtensionGatewayMethodRegistration, + resolveExtensionLegacyHookRegistration, + resolveExtensionHttpRouteRegistration, + resolveExtensionProviderRegistration, + resolveExtensionServiceRegistration, + resolveExtensionToolRegistration, + resolveExtensionTypedHookRegistration, + type ExtensionHostChannelRegistration, + type ExtensionHostHttpRouteRegistration, + type ExtensionHostProviderRegistration, +} from "./runtime-registrations.js"; + +function createChannelPlugin(id: string): ChannelPlugin { + return { + id, + meta: { + id, + label: id, + selectionLabel: id, + docsPath: `/channels/${id}`, + blurb: "test", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + }; +} + +function createProviderPlugin(id: string): ProviderPlugin { + return { + id, + label: id, + auth: [], + }; +} + +function createService(id: string): OpenClawPluginService { + return { + id, + start: vi.fn(), + }; +} + +function createCommand(name: string): OpenClawPluginCommandDefinition { + return { + name, + description: "demo command", + handler: vi.fn(), + }; +} + +function createLegacyHookEntry(name: string): HookEntry { + return { + hook: { + name, + description: "hook description", + source: "openclaw-plugin", + pluginId: "demo-plugin", + filePath: "/demo/plugin.ts", + baseDir: "/demo", + handlerPath: "/demo/plugin.ts", + }, + frontmatter: {}, + metadata: { events: ["message:received"] }, + invocation: { enabled: true }, + }; +} + +describe("runtime registration helpers", () => { + it("normalizes tool registration metadata", () => { + const tool = { name: "demo-tool" } as AnyAgentTool; + const result = resolveExtensionToolRegistration({ + ownerPluginId: "tool-plugin", + ownerSource: "tool-source", + tool, + opts: { + names: [" demo-tool ", "alias"], + optional: true, + }, + }); + + expect(result).toMatchObject({ + names: ["demo-tool", "alias"], + entry: { + pluginId: "tool-plugin", + names: ["demo-tool", "alias"], + optional: true, + source: "tool-source", + }, + }); + expect(result.entry.factory({} as never)).toBe(tool); + }); + + it("normalizes cli registration metadata", () => { + const registrar = (_ctx: OpenClawPluginCliContext) => {}; + const result = resolveExtensionCliRegistration({ + ownerPluginId: "cli-plugin", + ownerSource: "cli-source", + registrar, + opts: { commands: [" foo ", "bar", "foo"] }, + }); + + expect(result).toEqual({ + commands: ["foo", "bar"], + entry: { + pluginId: "cli-plugin", + register: registrar, + commands: ["foo", "bar"], + source: "cli-source", + }, + }); + }); + + it("normalizes service registrations", () => { + const result = resolveExtensionServiceRegistration({ + ownerPluginId: "service-plugin", + ownerSource: "service-source", + service: createService(" demo-service "), + }); + + expect(result).toMatchObject({ + ok: true, + serviceId: "demo-service", + entry: { + pluginId: "service-plugin", + source: "service-source", + service: { id: "demo-service" }, + }, + }); + }); + + it("rejects service registrations without ids", () => { + const result = resolveExtensionServiceRegistration({ + ownerPluginId: "service-plugin", + ownerSource: "service-source", + service: createService(" "), + }); + + expect(result).toEqual({ + ok: false, + message: "service registration missing id", + }); + }); + + it("normalizes command registrations", () => { + const result = resolveExtensionCommandRegistration({ + ownerPluginId: "command-plugin", + ownerSource: "command-source", + command: createCommand(" demo "), + }); + + expect(result).toMatchObject({ + ok: true, + commandName: "demo", + entry: { + pluginId: "command-plugin", + source: "command-source", + command: { name: "demo" }, + }, + }); + }); + + it("rejects command registrations without names", () => { + const result = resolveExtensionCommandRegistration({ + ownerPluginId: "command-plugin", + ownerSource: "command-source", + command: createCommand(" "), + }); + + expect(result).toEqual({ + ok: false, + message: "command registration missing name", + }); + }); + + it("normalizes context-engine registrations", () => { + const factory = vi.fn() as unknown as ContextEngineFactory; + const result = resolveExtensionContextEngineRegistration({ + engineId: " demo-engine ", + factory, + }); + + expect(result).toEqual({ + ok: true, + entry: { + engineId: "demo-engine", + factory, + }, + }); + }); + + it("rejects context-engine registrations without ids", () => { + const result = resolveExtensionContextEngineRegistration({ + engineId: " ", + factory: vi.fn() as unknown as ContextEngineFactory, + }); + + expect(result).toEqual({ + ok: false, + message: "context engine registration missing id", + }); + }); + + it("normalizes legacy hook registrations", () => { + const handler = vi.fn() as unknown as InternalHookHandler; + const result = resolveExtensionLegacyHookRegistration({ + ownerPluginId: "hook-plugin", + ownerSource: "/plugins/hook.ts", + events: [" message:received ", "message:received", "message:sent"], + handler, + opts: { + name: "demo-hook", + description: "hook description", + } satisfies OpenClawPluginHookOptions, + }); + + expect(result).toMatchObject({ + ok: true, + hookName: "demo-hook", + events: ["message:received", "message:sent"], + entry: { + pluginId: "hook-plugin", + source: "/plugins/hook.ts", + }, + }); + }); + + it("preserves explicit legacy hook entries while normalizing events", () => { + const result = resolveExtensionLegacyHookRegistration({ + ownerPluginId: "hook-plugin", + ownerSource: "/plugins/hook.ts", + events: " message:received ", + handler: vi.fn() as unknown as InternalHookHandler, + opts: { + entry: createLegacyHookEntry("demo-hook"), + }, + }); + + expect(result).toMatchObject({ + ok: true, + hookName: "demo-hook", + events: ["message:received"], + }); + if (result.ok) { + expect(result.entry.entry.hook.pluginId).toBe("hook-plugin"); + expect(result.entry.entry.metadata?.events).toEqual(["message:received"]); + } + }); + + it("rejects legacy hook registrations without names", () => { + const result = resolveExtensionLegacyHookRegistration({ + ownerPluginId: "hook-plugin", + ownerSource: "/plugins/hook.ts", + events: "message:received", + handler: vi.fn() as unknown as InternalHookHandler, + opts: {}, + }); + + expect(result).toEqual({ + ok: false, + message: "hook registration missing name", + }); + }); + + it("normalizes typed hook registrations", () => { + const handler = vi.fn() as PluginHookRegistration<"before_prompt_build">["handler"]; + const result = resolveExtensionTypedHookRegistration({ + ownerPluginId: "typed-hook-plugin", + ownerSource: "/plugins/typed-hook.ts", + hookName: "before_prompt_build", + handler, + priority: 10, + }); + + expect(result).toEqual({ + ok: true, + hookName: "before_prompt_build", + entry: { + pluginId: "typed-hook-plugin", + hookName: "before_prompt_build", + handler, + priority: 10, + source: "/plugins/typed-hook.ts", + }, + }); + }); + + it("rejects unknown typed hook registrations", () => { + const result = resolveExtensionTypedHookRegistration({ + ownerPluginId: "typed-hook-plugin", + ownerSource: "/plugins/typed-hook.ts", + hookName: "totally_unknown_hook_name", + handler: vi.fn() as never, + priority: 10, + }); + + expect(result).toEqual({ + ok: false, + message: 'unknown typed hook "totally_unknown_hook_name" ignored', + }); + }); + + it("normalizes and accepts a unique channel registration", () => { + const result = resolveExtensionChannelRegistration({ + existing: [], + ownerPluginId: "demo-plugin", + ownerSource: "demo-source", + registration: createChannelPlugin("demo-channel"), + }); + + expect(result).toMatchObject({ + ok: true, + channelId: "demo-channel", + entry: { + pluginId: "demo-plugin", + source: "demo-source", + }, + }); + }); + + it("rejects duplicate channel registrations", () => { + const existing: ExtensionHostChannelRegistration[] = [ + { + pluginId: "demo-a", + plugin: createChannelPlugin("demo-channel"), + source: "demo-a-source", + }, + ]; + + const result = resolveExtensionChannelRegistration({ + existing, + ownerPluginId: "demo-b", + ownerSource: "demo-b-source", + registration: createChannelPlugin("demo-channel"), + }); + + expect(result).toEqual({ + ok: false, + message: "channel already registered: demo-channel (demo-a)", + }); + }); + + it("accepts a unique provider registration", () => { + const result = resolveExtensionProviderRegistration({ + existing: [], + ownerPluginId: "provider-plugin", + ownerSource: "provider-source", + provider: createProviderPlugin("demo-provider"), + }); + + expect(result).toMatchObject({ + ok: true, + providerId: "demo-provider", + entry: { + pluginId: "provider-plugin", + source: "provider-source", + }, + }); + }); + + it("rejects duplicate provider registrations", () => { + const existing: ExtensionHostProviderRegistration[] = [ + { + pluginId: "provider-a", + provider: createProviderPlugin("demo-provider"), + source: "provider-a-source", + }, + ]; + + const result = resolveExtensionProviderRegistration({ + existing, + ownerPluginId: "provider-b", + ownerSource: "provider-b-source", + provider: createProviderPlugin("demo-provider"), + }); + + expect(result).toEqual({ + ok: false, + message: "provider already registered: demo-provider (provider-a)", + }); + }); + + it("accepts a unique http route registration", () => { + const result = resolveExtensionHttpRouteRegistration({ + existing: [], + ownerPluginId: "route-plugin", + ownerSource: "route-source", + route: { + path: "/demo", + auth: "plugin", + handler: vi.fn(), + }, + }); + + expect(result).toMatchObject({ + ok: true, + action: "append", + entry: { + pluginId: "route-plugin", + path: "/demo", + auth: "plugin", + match: "exact", + source: "route-source", + }, + }); + }); + + it("rejects conflicting http routes owned by another plugin", () => { + const existing: ExtensionHostHttpRouteRegistration[] = [ + { + pluginId: "route-a", + path: "/demo", + auth: "plugin", + match: "exact", + handler: vi.fn(), + source: "route-a-source", + }, + ]; + + const result = resolveExtensionHttpRouteRegistration({ + existing, + ownerPluginId: "route-b", + ownerSource: "route-b-source", + route: { + path: "/demo", + auth: "plugin", + handler: vi.fn(), + }, + }); + + expect(result).toEqual({ + ok: false, + message: "http route already registered: /demo (exact) by route-a (route-a-source)", + }); + }); + + it("supports same-owner http route replacement", () => { + const existing: ExtensionHostHttpRouteRegistration[] = [ + { + pluginId: "route-plugin", + path: "/demo", + auth: "plugin", + match: "exact", + handler: vi.fn(), + source: "route-source", + }, + ]; + + const result = resolveExtensionHttpRouteRegistration({ + existing, + ownerPluginId: "route-plugin", + ownerSource: "route-source", + route: { + path: "/demo", + auth: "plugin", + replaceExisting: true, + handler: vi.fn(), + }, + }); + + expect(result).toMatchObject({ + ok: true, + action: "replace", + existingIndex: 0, + entry: { + pluginId: "route-plugin", + path: "/demo", + }, + }); + }); + + it("accepts a unique gateway method registration", () => { + const handler = vi.fn(); + const result = resolveExtensionGatewayMethodRegistration({ + existing: {}, + coreGatewayMethods: new Set(["core.method"]), + method: "plugin.method", + handler, + }); + + expect(result).toEqual({ + ok: true, + method: "plugin.method", + handler, + }); + }); + + it("rejects duplicate gateway method registrations", () => { + const result = resolveExtensionGatewayMethodRegistration({ + existing: { + "plugin.method": vi.fn(), + }, + coreGatewayMethods: new Set(["core.method"]), + method: "plugin.method", + handler: vi.fn(), + }); + + expect(result).toEqual({ + ok: false, + message: "gateway method already registered: plugin.method", + }); + }); +}); diff --git a/src/extension-host/runtime-registrations.ts b/src/extension-host/runtime-registrations.ts new file mode 100644 index 00000000000..cd7342f4466 --- /dev/null +++ b/src/extension-host/runtime-registrations.ts @@ -0,0 +1,553 @@ +import path from "node:path"; +import type { AnyAgentTool } from "../agents/tools/common.js"; +import type { ChannelDock } from "../channels/dock.js"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; +import type { ContextEngineFactory } from "../context-engine/registry.js"; +import type { + GatewayRequestHandler, + GatewayRequestHandlers, +} from "../gateway/server-methods/types.js"; +import type { InternalHookHandler } from "../hooks/internal-hooks.js"; +import type { HookEntry } from "../hooks/types.js"; +import { normalizePluginHttpPath } from "../plugins/http-path.js"; +import { findOverlappingPluginHttpRoute } from "../plugins/http-route-overlap.js"; +import type { + OpenClawPluginCliRegistrar, + OpenClawPluginCommandDefinition, + OpenClawPluginChannelRegistration, + OpenClawPluginHookOptions, + OpenClawPluginHttpRouteAuth, + OpenClawPluginHttpRouteHandler, + OpenClawPluginHttpRouteMatch, + OpenClawPluginHttpRouteParams, + OpenClawPluginService, + OpenClawPluginToolContext, + OpenClawPluginToolFactory, + PluginHookHandlerMap, + PluginHookName, + PluginHookRegistration, + ProviderPlugin, +} from "../plugins/types.js"; +import { isPluginHookName } from "../plugins/types.js"; + +export type ExtensionHostChannelRegistration = { + pluginId: string; + plugin: ChannelPlugin; + dock?: ChannelDock; + source: string; +}; + +export type ExtensionHostProviderRegistration = { + pluginId: string; + provider: ProviderPlugin; + source: string; +}; + +export type ExtensionHostToolRegistration = { + pluginId: string; + factory: OpenClawPluginToolFactory; + names: string[]; + optional: boolean; + source: string; +}; + +export type ExtensionHostCliRegistration = { + pluginId: string; + register: OpenClawPluginCliRegistrar; + commands: string[]; + source: string; +}; + +export type ExtensionHostServiceRegistration = { + pluginId: string; + service: OpenClawPluginService; + source: string; +}; + +export type ExtensionHostCommandRegistration = { + pluginId: string; + command: OpenClawPluginCommandDefinition; + source: string; +}; + +export type ExtensionHostContextEngineRegistration = { + engineId: string; + factory: ContextEngineFactory; +}; + +export type ExtensionHostLegacyHookRegistration = { + pluginId: string; + entry: HookEntry; + events: string[]; + source: string; + handler: InternalHookHandler; +}; + +export type ExtensionHostHttpRouteRegistration = { + pluginId?: string; + path: string; + handler: OpenClawPluginHttpRouteHandler; + auth: OpenClawPluginHttpRouteAuth; + match: OpenClawPluginHttpRouteMatch; + source?: string; +}; + +function normalizeNameList(names: string[]): string[] { + return Array.from(new Set(names.map((name) => name.trim()).filter(Boolean))); +} + +export function resolveExtensionToolRegistration(params: { + ownerPluginId: string; + ownerSource: string; + tool: AnyAgentTool | OpenClawPluginToolFactory; + opts?: { name?: string; names?: string[]; optional?: boolean }; +}): { + names: string[]; + entry: ExtensionHostToolRegistration; +} { + const names = [...(params.opts?.names ?? []), ...(params.opts?.name ? [params.opts.name] : [])]; + if (typeof params.tool !== "function") { + names.push(params.tool.name); + } + const normalizedNames = normalizeNameList(names); + const factory: OpenClawPluginToolFactory = + typeof params.tool === "function" + ? params.tool + : (_ctx: OpenClawPluginToolContext) => params.tool; + + return { + names: normalizedNames, + entry: { + pluginId: params.ownerPluginId, + factory, + names: normalizedNames, + optional: params.opts?.optional === true, + source: params.ownerSource, + }, + }; +} + +export function resolveExtensionCliRegistration(params: { + ownerPluginId: string; + ownerSource: string; + registrar: OpenClawPluginCliRegistrar; + opts?: { commands?: string[] }; +}): { + commands: string[]; + entry: ExtensionHostCliRegistration; +} { + const commands = normalizeNameList(params.opts?.commands ?? []); + return { + commands, + entry: { + pluginId: params.ownerPluginId, + register: params.registrar, + commands, + source: params.ownerSource, + }, + }; +} + +export function resolveExtensionServiceRegistration(params: { + ownerPluginId: string; + ownerSource: string; + service: OpenClawPluginService; +}): + | { + ok: true; + serviceId: string; + entry: ExtensionHostServiceRegistration; + } + | { + ok: false; + message: string; + } { + const serviceId = params.service.id.trim(); + if (!serviceId) { + return { ok: false, message: "service registration missing id" }; + } + return { + ok: true, + serviceId, + entry: { + pluginId: params.ownerPluginId, + service: { + ...params.service, + id: serviceId, + }, + source: params.ownerSource, + }, + }; +} + +export function resolveExtensionCommandRegistration(params: { + ownerPluginId: string; + ownerSource: string; + command: OpenClawPluginCommandDefinition; +}): + | { + ok: true; + commandName: string; + entry: ExtensionHostCommandRegistration; + } + | { + ok: false; + message: string; + } { + const commandName = params.command.name.trim(); + if (!commandName) { + return { ok: false, message: "command registration missing name" }; + } + return { + ok: true, + commandName, + entry: { + pluginId: params.ownerPluginId, + command: { + ...params.command, + name: commandName, + }, + source: params.ownerSource, + }, + }; +} + +export function resolveExtensionContextEngineRegistration(params: { + engineId: string; + factory: ContextEngineFactory; +}): + | { + ok: true; + entry: ExtensionHostContextEngineRegistration; + } + | { + ok: false; + message: string; + } { + const engineId = params.engineId.trim(); + if (!engineId) { + return { ok: false, message: "context engine registration missing id" }; + } + return { + ok: true, + entry: { + engineId, + factory: params.factory, + }, + }; +} + +export function resolveExtensionLegacyHookRegistration(params: { + ownerPluginId: string; + ownerSource: string; + events: string | string[]; + handler: InternalHookHandler; + opts?: OpenClawPluginHookOptions; +}): + | { + ok: true; + hookName: string; + events: string[]; + entry: ExtensionHostLegacyHookRegistration; + } + | { + ok: false; + message: string; + } { + const eventList = Array.isArray(params.events) ? params.events : [params.events]; + const normalizedEvents = normalizeNameList(eventList); + const entry = params.opts?.entry ?? null; + const hookName = entry?.hook.name ?? params.opts?.name?.trim(); + if (!hookName) { + return { ok: false, message: "hook registration missing name" }; + } + + const description = entry?.hook.description ?? params.opts?.description ?? ""; + const hookEntry: HookEntry = entry + ? { + ...entry, + hook: { + ...entry.hook, + name: hookName, + description, + source: "openclaw-plugin", + pluginId: params.ownerPluginId, + }, + metadata: { + ...entry.metadata, + events: normalizedEvents, + }, + } + : { + hook: { + name: hookName, + description, + source: "openclaw-plugin", + pluginId: params.ownerPluginId, + filePath: params.ownerSource, + baseDir: path.dirname(params.ownerSource), + handlerPath: params.ownerSource, + }, + frontmatter: {}, + metadata: { events: normalizedEvents }, + invocation: { enabled: true }, + }; + + return { + ok: true, + hookName, + events: normalizedEvents, + entry: { + pluginId: params.ownerPluginId, + entry: hookEntry, + events: normalizedEvents, + source: params.ownerSource, + handler: params.handler, + }, + }; +} + +export function resolveExtensionTypedHookRegistration(params: { + ownerPluginId: string; + ownerSource: string; + hookName: unknown; + handler: PluginHookHandlerMap[K]; + priority?: number; +}): + | { + ok: true; + hookName: K; + entry: PluginHookRegistration; + } + | { + ok: false; + message: string; + } { + if (!isPluginHookName(params.hookName)) { + return { + ok: false, + message: `unknown typed hook "${String(params.hookName)}" ignored`, + }; + } + return { + ok: true, + hookName: params.hookName as K, + entry: { + pluginId: params.ownerPluginId, + hookName: params.hookName as K, + handler: params.handler, + priority: params.priority, + source: params.ownerSource, + }, + }; +} + +export function resolveExtensionGatewayMethodRegistration(params: { + existing: GatewayRequestHandlers; + coreGatewayMethods: ReadonlySet; + method: string; + handler: GatewayRequestHandler; +}): + | { + ok: true; + method: string; + handler: GatewayRequestHandler; + } + | { + ok: false; + message: string; + } { + const method = params.method.trim(); + if (!method) { + return { ok: false, message: "gateway method registration missing name" }; + } + if (params.coreGatewayMethods.has(method) || params.existing[method]) { + return { + ok: false, + message: `gateway method already registered: ${method}`, + }; + } + return { + ok: true, + method, + handler: params.handler, + }; +} + +function normalizeChannelRegistration( + registration: OpenClawPluginChannelRegistration | ChannelPlugin, +): { plugin: ChannelPlugin; dock?: ChannelDock } { + return typeof (registration as OpenClawPluginChannelRegistration).plugin === "object" + ? (registration as OpenClawPluginChannelRegistration) + : { plugin: registration as ChannelPlugin }; +} + +export function resolveExtensionChannelRegistration(params: { + existing: ExtensionHostChannelRegistration[]; + ownerPluginId: string; + ownerSource: string; + registration: OpenClawPluginChannelRegistration | ChannelPlugin; +}): + | { + ok: true; + channelId: string; + entry: ExtensionHostChannelRegistration; + } + | { + ok: false; + message: string; + } { + const normalized = normalizeChannelRegistration(params.registration); + const plugin = normalized.plugin; + const channelId = + typeof plugin?.id === "string" ? plugin.id.trim() : String(plugin?.id ?? "").trim(); + if (!channelId) { + return { ok: false, message: "channel registration missing id" }; + } + const existing = params.existing.find((entry) => entry.plugin.id === channelId); + if (existing) { + return { + ok: false, + message: `channel already registered: ${channelId} (${existing.pluginId})`, + }; + } + return { + ok: true, + channelId, + entry: { + pluginId: params.ownerPluginId, + plugin, + dock: normalized.dock, + source: params.ownerSource, + }, + }; +} + +export function resolveExtensionProviderRegistration(params: { + existing: ExtensionHostProviderRegistration[]; + ownerPluginId: string; + ownerSource: string; + provider: ProviderPlugin; +}): + | { + ok: true; + providerId: string; + entry: ExtensionHostProviderRegistration; + } + | { + ok: false; + message: string; + } { + const providerId = params.provider.id; + const existing = params.existing.find((entry) => entry.provider.id === providerId); + if (existing) { + return { + ok: false, + message: `provider already registered: ${providerId} (${existing.pluginId})`, + }; + } + return { + ok: true, + providerId, + entry: { + pluginId: params.ownerPluginId, + provider: params.provider, + source: params.ownerSource, + }, + }; +} + +function describeHttpRouteOwner(entry: ExtensionHostHttpRouteRegistration): string { + const plugin = entry.pluginId?.trim() || "unknown-plugin"; + const source = entry.source?.trim() || "unknown-source"; + return `${plugin} (${source})`; +} + +export function resolveExtensionHttpRouteRegistration(params: { + existing: ExtensionHostHttpRouteRegistration[]; + ownerPluginId: string; + ownerSource: string; + route: OpenClawPluginHttpRouteParams; +}): + | { + ok: true; + action: "append" | "replace"; + entry: ExtensionHostHttpRouteRegistration; + existingIndex?: number; + } + | { + ok: false; + message: string; + } { + const normalizedPath = normalizePluginHttpPath(params.route.path); + if (!normalizedPath) { + return { ok: false, message: "http route registration missing path" }; + } + if (params.route.auth !== "gateway" && params.route.auth !== "plugin") { + return { + ok: false, + message: `http route registration missing or invalid auth: ${normalizedPath}`, + }; + } + + const match = params.route.match ?? "exact"; + const overlappingRoute = findOverlappingPluginHttpRoute(params.existing, { + path: normalizedPath, + match, + }); + if (overlappingRoute && overlappingRoute.auth !== params.route.auth) { + return { + ok: false, + message: + `http route overlap rejected: ${normalizedPath} (${match}, ${params.route.auth}) ` + + `overlaps ${overlappingRoute.path} (${overlappingRoute.match}, ${overlappingRoute.auth}) ` + + `owned by ${describeHttpRouteOwner(overlappingRoute)}`, + }; + } + + const existingIndex = params.existing.findIndex( + (entry) => entry.path === normalizedPath && entry.match === match, + ); + const nextEntry: ExtensionHostHttpRouteRegistration = { + pluginId: params.ownerPluginId, + path: normalizedPath, + handler: params.route.handler, + auth: params.route.auth, + match, + source: params.ownerSource, + }; + + if (existingIndex >= 0) { + const existing = params.existing[existingIndex]; + if (!existing) { + return { + ok: false, + message: `http route registration missing existing route: ${normalizedPath}`, + }; + } + if (!params.route.replaceExisting) { + return { + ok: false, + message: `http route already registered: ${normalizedPath} (${match}) by ${describeHttpRouteOwner(existing)}`, + }; + } + if (existing.pluginId && existing.pluginId !== params.ownerPluginId) { + return { + ok: false, + message: `http route replacement rejected: ${normalizedPath} (${match}) owned by ${describeHttpRouteOwner(existing)}`, + }; + } + return { + ok: true, + action: "replace", + existingIndex, + entry: nextEntry, + }; + } + + return { + ok: true, + action: "append", + entry: nextEntry, + }; +} diff --git a/src/extension-host/schema.test.ts b/src/extension-host/schema.test.ts new file mode 100644 index 00000000000..89cf344c7d8 --- /dev/null +++ b/src/extension-host/schema.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_EXTENSION_ENTRY_CANDIDATES, + getExtensionPackageMetadata, + resolveExtensionEntryCandidates, + resolveLegacyExtensionDescriptor, +} from "./schema.js"; + +describe("extension host schema helpers", () => { + it("normalizes package metadata through the host boundary", () => { + const metadata = getExtensionPackageMetadata({ + openclaw: { + channel: { + id: "telegram", + label: "Telegram", + }, + install: { + npmSpec: "@openclaw/telegram", + defaultChoice: "npm", + }, + }, + }); + + expect(metadata).toEqual({ + channel: { + id: "telegram", + label: "Telegram", + }, + install: { + npmSpec: "@openclaw/telegram", + defaultChoice: "npm", + }, + }); + }); + + it("preserves current extension entry resolution semantics", () => { + expect(resolveExtensionEntryCandidates(undefined)).toEqual({ + status: "missing", + entries: [], + }); + expect(DEFAULT_EXTENSION_ENTRY_CANDIDATES).toContain("index.ts"); + expect( + resolveExtensionEntryCandidates({ + openclaw: { + extensions: ["./dist/index.js"], + }, + }), + ).toEqual({ + status: "ok", + entries: ["./dist/index.js"], + }); + }); + + it("builds a normalized legacy extension descriptor", () => { + const resolved = resolveLegacyExtensionDescriptor({ + manifest: { + id: "telegram", + name: "Telegram", + configSchema: { type: "object" }, + channels: ["telegram"], + providers: ["telegram-provider"], + }, + packageManifest: { + openclaw: { + channel: { + id: "telegram", + label: "Telegram", + }, + install: { + npmSpec: "@openclaw/telegram", + defaultChoice: "npm", + }, + }, + }, + origin: "bundled", + rootDir: "/tmp/telegram", + source: "/tmp/telegram/index.ts", + }); + + expect(resolved.id).toBe("telegram"); + expect(resolved.staticMetadata.package.entries).toEqual([ + "index.ts", + "index.js", + "index.mjs", + "index.cjs", + ]); + expect(resolved.contributions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "telegram/config", + kind: "surface.config", + }), + expect.objectContaining({ + id: "telegram/channel/telegram", + kind: "adapter.runtime", + }), + expect.objectContaining({ + id: "telegram/provider/telegram-provider", + kind: "capability.provider-integration", + }), + expect.objectContaining({ + id: "telegram/channel-catalog", + kind: "surface.channel-catalog", + }), + expect.objectContaining({ + id: "telegram/install", + kind: "surface.install", + }), + ]), + ); + }); +}); diff --git a/src/extension-host/schema.ts b/src/extension-host/schema.ts new file mode 100644 index 00000000000..7472ed3f4da --- /dev/null +++ b/src/extension-host/schema.ts @@ -0,0 +1,181 @@ +import { + DEFAULT_PLUGIN_ENTRY_CANDIDATES, + getPackageManifestMetadata, + resolvePackageExtensionEntries, + type OpenClawPackageManifest, + type PackageExtensionResolution, + type PackageManifest, + type PluginManifest, +} from "../plugins/manifest.js"; +import type { PluginConfigUiHint, PluginKind, PluginOrigin } from "../plugins/types.js"; + +export type { OpenClawPackageManifest, PackageExtensionResolution, PackageManifest }; + +export const DEFAULT_EXTENSION_ENTRY_CANDIDATES = DEFAULT_PLUGIN_ENTRY_CANDIDATES; + +export type ContributionPolicy = { + promptMutation?: "none" | "append-only" | "replace-allowed"; + routeEffect?: "observe-only" | "augment" | "veto" | "resolve"; + executionMode?: "sync-hot-path" | "sequential" | "parallel"; +}; + +export type ResolvedContributionKind = + | "adapter.runtime" + | "capability.context-engine" + | "capability.memory" + | "capability.provider-integration" + | "surface.channel-catalog" + | "surface.config" + | "surface.install"; + +export type ResolvedContribution = { + id: string; + kind: ResolvedContributionKind; + source: "manifest" | "package"; + policy?: ContributionPolicy; + metadata?: Record; +}; + +export type ResolvedExtensionPackageMetadata = { + entries: string[]; + manifest?: OpenClawPackageManifest; +}; + +export type ResolvedExtensionStaticMetadata = { + configSchema: Record; + configUiHints?: Record; + package: ResolvedExtensionPackageMetadata; +}; + +export type ResolvedExtension = { + id: string; + name?: string; + description?: string; + version?: string; + kind?: PluginKind; + origin?: PluginOrigin; + rootDir?: string; + source?: string; + workspaceDir?: string; + manifest: PluginManifest; + staticMetadata: ResolvedExtensionStaticMetadata; + contributions: ResolvedContribution[]; +}; + +export function getExtensionPackageMetadata( + manifest: PackageManifest | undefined, +): OpenClawPackageManifest | undefined { + return getPackageManifestMetadata(manifest); +} + +export function resolveExtensionEntryCandidates( + manifest: PackageManifest | undefined, +): PackageExtensionResolution { + return resolvePackageExtensionEntries(manifest); +} + +function normalizeResolvedEntries( + packageManifest: PackageManifest | undefined, +): ResolvedExtensionPackageMetadata { + const manifest = getExtensionPackageMetadata(packageManifest); + const entries = resolveExtensionEntryCandidates(packageManifest); + return { + entries: + entries.status === "ok" ? entries.entries : Array.from(DEFAULT_EXTENSION_ENTRY_CANDIDATES), + manifest, + }; +} + +export function resolveLegacyExtensionDescriptor(params: { + manifest: PluginManifest; + packageManifest?: PackageManifest; + origin?: PluginOrigin; + rootDir?: string; + source?: string; + workspaceDir?: string; +}): ResolvedExtension { + const packageMetadata = normalizeResolvedEntries(params.packageManifest); + const contributions: ResolvedContribution[] = [ + { + id: `${params.manifest.id}/config`, + kind: "surface.config", + source: "manifest", + }, + ]; + + for (const channelId of params.manifest.channels ?? []) { + contributions.push({ + id: `${params.manifest.id}/channel/${channelId}`, + kind: "adapter.runtime", + source: "manifest", + metadata: { channelId }, + }); + } + + for (const providerId of params.manifest.providers ?? []) { + contributions.push({ + id: `${params.manifest.id}/provider/${providerId}`, + kind: "capability.provider-integration", + source: "manifest", + metadata: { providerId }, + }); + } + + if (params.manifest.kind === "memory") { + contributions.push({ + id: `${params.manifest.id}/memory`, + kind: "capability.memory", + source: "manifest", + }); + } + + if (params.manifest.kind === "context-engine") { + contributions.push({ + id: `${params.manifest.id}/context-engine`, + kind: "capability.context-engine", + source: "manifest", + }); + } + + if (packageMetadata.manifest?.channel) { + contributions.push({ + id: `${params.manifest.id}/channel-catalog`, + kind: "surface.channel-catalog", + source: "package", + metadata: { + channelId: packageMetadata.manifest.channel.id, + }, + }); + } + + if (packageMetadata.manifest?.install) { + contributions.push({ + id: `${params.manifest.id}/install`, + kind: "surface.install", + source: "package", + metadata: { + defaultChoice: packageMetadata.manifest.install.defaultChoice, + npmSpec: packageMetadata.manifest.install.npmSpec, + }, + }); + } + + return { + id: params.manifest.id, + name: params.manifest.name, + description: params.manifest.description, + version: params.manifest.version, + kind: params.manifest.kind, + origin: params.origin, + rootDir: params.rootDir, + source: params.source, + workspaceDir: params.workspaceDir, + manifest: params.manifest, + staticMetadata: { + configSchema: params.manifest.configSchema, + configUiHints: params.manifest.uiHints, + package: packageMetadata, + }, + contributions, + }; +} diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index c102ffc80c7..f6aa84dd350 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -1,5 +1,12 @@ import fs from "node:fs"; import path from "node:path"; +import { + DEFAULT_EXTENSION_ENTRY_CANDIDATES, + getExtensionPackageMetadata, + resolveExtensionEntryCandidates, + type PackageManifest, + type OpenClawPackageManifest, +} from "../extension-host/schema.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { resolveUserPath } from "../utils.js"; import { detectBundleManifestFormat, loadBundleManifest } from "./bundle-manifest.js"; @@ -299,27 +306,6 @@ function shouldIgnoreScannedDirectory(dirName: string): boolean { return false; } -function readPackageManifest(dir: string, rejectHardlinks = true): PackageManifest | null { - const manifestPath = path.join(dir, "package.json"); - const opened = openBoundaryFileSync({ - absolutePath: manifestPath, - rootPath: dir, - boundaryLabel: "plugin package directory", - rejectHardlinks, - }); - if (!opened.ok) { - return null; - } - try { - const raw = fs.readFileSync(opened.fd, "utf-8"); - return JSON.parse(raw) as PackageManifest; - } catch { - return null; - } finally { - fs.closeSync(opened.fd); - } -} - function deriveIdHint(params: { filePath: string; packageName?: string; @@ -394,7 +380,7 @@ function addCandidate(params: { packageVersion: manifest?.version?.trim() || undefined, packageDescription: manifest?.description?.trim() || undefined, packageDir: params.packageDir, - packageManifest: getPackageManifestMetadata(manifest ?? undefined), + packageManifest: getExtensionPackageMetadata(manifest ?? undefined), }); } @@ -517,8 +503,8 @@ function discoverInDirectory(params: { } const rejectHardlinks = params.origin !== "bundled"; - const manifest = readPackageManifest(fullPath, rejectHardlinks); - const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined); + const manifest = loadPackageManifest(fullPath, rejectHardlinks); + const extensionResolution = resolveExtensionEntryCandidates(manifest ?? undefined); const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : []; if (extensions.length > 0) { @@ -634,8 +620,8 @@ function discoverFromPath(params: { if (stat.isDirectory()) { const rejectHardlinks = params.origin !== "bundled"; - const manifest = readPackageManifest(resolved, rejectHardlinks); - const extensionResolution = resolvePackageExtensionEntries(manifest ?? undefined); + const manifest = loadPackageManifest(resolved, rejectHardlinks); + const extensionResolution = resolveExtensionEntryCandidates(manifest ?? undefined); const extensions = extensionResolution.status === "ok" ? extensionResolution.entries : []; if (extensions.length > 0) { diff --git a/src/plugins/http-registry.ts b/src/plugins/http-registry.ts index bf45f1b076a..0a460ba3607 100644 --- a/src/plugins/http-registry.ts +++ b/src/plugins/http-registry.ts @@ -1,8 +1,8 @@ import type { IncomingMessage, ServerResponse } from "node:http"; +import { requireActiveExtensionHostRegistry } from "../extension-host/active-registry.js"; import { normalizePluginHttpPath } from "./http-path.js"; import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js"; import type { PluginHttpRouteRegistration, PluginRegistry } from "./registry.js"; -import { requireActivePluginRegistry } from "./runtime.js"; export type PluginHttpRouteHandler = ( req: IncomingMessage, @@ -22,7 +22,7 @@ export function registerPluginHttpRoute(params: { log?: (message: string) => void; registry?: PluginRegistry; }): () => void { - const registry = params.registry ?? requireActivePluginRegistry(); + const registry = params.registry ?? requireActiveExtensionHostRegistry(); const routes = registry.httpRoutes ?? []; registry.httpRoutes = routes; diff --git a/src/plugins/install.ts b/src/plugins/install.ts index e6b66381970..5f6ef6612df 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -1,5 +1,9 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { + resolveExtensionEntryCandidates, + type PackageManifest as PluginPackageManifest, +} from "../extension-host/schema.js"; import { fileExists, readJsonFile, resolveArchiveKind } from "../infra/archive.js"; import { writeFileFromPathWithinRoot } from "../infra/fs-safe.js"; import { resolveExistingInstallPath, withExtractedArchiveRoot } from "../infra/install-flow.js"; @@ -158,7 +162,7 @@ function ensureOpenClawExtensions(params: { manifest: PackageManifest }): error: string; code: PluginInstallErrorCode; } { - const resolved = resolvePackageExtensionEntries(params.manifest); + const resolved = resolveExtensionEntryCandidates(params.manifest); if (resolved.status === "missing") { return { ok: false, diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index b0f98b3beef..2669fc98a32 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -1,5 +1,9 @@ import fs from "node:fs"; import type { OpenClawConfig } from "../config/config.js"; +import { + buildResolvedExtensionRecord, + type ResolvedExtensionRecord, +} from "../extension-host/manifest-registry.js"; import { resolveUserPath } from "../utils.js"; import { loadBundleManifest } from "./bundle-manifest.js"; import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js"; @@ -52,6 +56,7 @@ export type PluginManifestRecord = { schemaCacheKey?: string; configSchema?: Record; configUiHints?: Record; + resolvedExtension: ResolvedExtensionRecord["extension"]; }; export type PluginManifestRegistry = { @@ -129,6 +134,7 @@ function buildRecord(params: { schemaCacheKey?: string; configSchema?: Record; }): PluginManifestRecord { + const resolved = buildResolvedExtensionRecord(params); return { id: params.manifest.id, name: normalizeManifestLabel(params.manifest.name) ?? params.candidate.packageName, @@ -151,6 +157,7 @@ function buildRecord(params: { schemaCacheKey: params.schemaCacheKey, configSchema: params.configSchema, configUiHints: params.manifest.uiHints, + resolvedExtension: resolved.extension, }; } diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 3a3abe0a620..19490a19b25 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -172,6 +172,27 @@ export type PackageManifest = { description?: string; } & Partial>; +export function loadPackageManifest(dir: string, rejectHardlinks = true): PackageManifest | null { + const manifestPath = path.join(dir, "package.json"); + const opened = openBoundaryFileSync({ + absolutePath: manifestPath, + rootPath: dir, + boundaryLabel: "plugin package directory", + rejectHardlinks, + }); + if (!opened.ok) { + return null; + } + try { + const raw = fs.readFileSync(opened.fd, "utf-8"); + return JSON.parse(raw) as PackageManifest; + } catch { + return null; + } finally { + fs.closeSync(opened.fd); + } +} + export function getPackageManifestMetadata( manifest: PackageManifest | undefined, ): OpenClawPackageManifest | undefined { diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index d754d928f15..24dfac32918 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -1,4 +1,3 @@ -import path from "node:path"; import type { AnyAgentTool } from "../agents/tools/common.js"; import type { ChannelDock } from "../channels/dock.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; @@ -8,7 +7,6 @@ import type { GatewayRequestHandlers, } from "../gateway/server-methods/types.js"; import { registerInternalHook } from "../hooks/internal-hooks.js"; -import type { HookEntry } from "../hooks/types.js"; import { resolveUserPath } from "../utils.js"; import { registerPluginCommand } from "./commands.js"; import { normalizePluginHttpPath } from "./http-path.js"; @@ -18,7 +16,6 @@ import { normalizeRegisteredProvider } from "./provider-validation.js"; import type { PluginRuntime } from "./runtime/types.js"; import { defaultSlotIdForKey } from "./slots.js"; import { - isPluginHookName, isPromptInjectionHookName, stripPromptMutationFieldsFromLegacyHookResult, } from "./types.js"; @@ -34,7 +31,6 @@ import type { OpenClawPluginHookOptions, ProviderPlugin, OpenClawPluginService, - OpenClawPluginToolContext, OpenClawPluginToolFactory, PluginConfigUiHint, PluginDiagnostic, @@ -239,6 +235,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { source: record.source, rootDir: record.rootDir, }); + if (result.names.length > 0) { + record.toolNames.push(...result.names); + } + registry.tools.push(result.entry); }; const registerHook = ( @@ -248,16 +248,19 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { opts: OpenClawPluginHookOptions | undefined, config: OpenClawPluginApi["config"], ) => { - const eventList = Array.isArray(events) ? events : [events]; - const normalizedEvents = eventList.map((event) => event.trim()).filter(Boolean); - const entry = opts?.entry ?? null; - const name = entry?.hook.name ?? opts?.name?.trim(); - if (!name) { + const normalized = resolveExtensionLegacyHookRegistration({ + ownerPluginId: record.id, + ownerSource: record.source, + events, + handler, + opts, + }); + if (!normalized.ok) { pushDiagnostic({ level: "warn", pluginId: record.id, source: record.source, - message: "hook registration missing name", + message: normalized.message, }); return; } @@ -305,10 +308,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { record.hookNames.push(name); registry.hooks.push({ - pluginId: record.id, - entry: hookEntry, - events: normalizedEvents, - source: record.source, + pluginId: normalized.entry.pluginId, + entry: normalized.entry.entry, + events: normalized.events, + source: normalized.entry.source, }); const hookSystemEnabled = config?.hooks?.internal?.enabled === true; @@ -316,7 +319,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { return; } - for (const event of normalizedEvents) { + for (const event of normalized.events) { registerInternalHook(event, handler); } }; @@ -326,111 +329,50 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { method: string, handler: GatewayRequestHandler, ) => { - const trimmed = method.trim(); - if (!trimmed) { - return; - } - if (coreGatewayMethods.has(trimmed) || registry.gatewayHandlers[trimmed]) { + const result = resolveExtensionGatewayMethodRegistration({ + existing: registry.gatewayHandlers, + coreGatewayMethods, + method, + handler, + }); + if (!result.ok) { pushDiagnostic({ level: "error", pluginId: record.id, source: record.source, - message: `gateway method already registered: ${trimmed}`, + message: result.message, }); return; } - registry.gatewayHandlers[trimmed] = handler; - record.gatewayMethods.push(trimmed); - }; - - const describeHttpRouteOwner = (entry: PluginHttpRouteRegistration): string => { - const plugin = entry.pluginId?.trim() || "unknown-plugin"; - const source = entry.source?.trim() || "unknown-source"; - return `${plugin} (${source})`; + registry.gatewayHandlers[result.method] = result.handler; + record.gatewayMethods.push(result.method); }; const registerHttpRoute = (record: PluginRecord, params: OpenClawPluginHttpRouteParams) => { - const normalizedPath = normalizePluginHttpPath(params.path); - if (!normalizedPath) { - pushDiagnostic({ - level: "warn", - pluginId: record.id, - source: record.source, - message: "http route registration missing path", - }); - return; - } - if (params.auth !== "gateway" && params.auth !== "plugin") { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: `http route registration missing or invalid auth: ${normalizedPath}`, - }); - return; - } - const match = params.match ?? "exact"; - const overlappingRoute = findOverlappingPluginHttpRoute(registry.httpRoutes, { - path: normalizedPath, - match, + const result = resolveExtensionHttpRouteRegistration({ + existing: registry.httpRoutes, + ownerPluginId: record.id, + ownerSource: record.source, + route: params, }); - if (overlappingRoute && overlappingRoute.auth !== params.auth) { + if (!result.ok) { pushDiagnostic({ - level: "error", + level: result.message === "http route registration missing path" ? "warn" : "error", pluginId: record.id, source: record.source, - message: - `http route overlap rejected: ${normalizedPath} (${match}, ${params.auth}) ` + - `overlaps ${overlappingRoute.path} (${overlappingRoute.match}, ${overlappingRoute.auth}) ` + - `owned by ${describeHttpRouteOwner(overlappingRoute)}`, + message: result.message, }); return; } - const existingIndex = registry.httpRoutes.findIndex( - (entry) => entry.path === normalizedPath && entry.match === match, - ); - if (existingIndex >= 0) { - const existing = registry.httpRoutes[existingIndex]; - if (!existing) { + if (result.action === "replace") { + if (result.existingIndex === undefined) { return; } - if (!params.replaceExisting) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: `http route already registered: ${normalizedPath} (${match}) by ${describeHttpRouteOwner(existing)}`, - }); - return; - } - if (existing.pluginId && existing.pluginId !== record.id) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: `http route replacement rejected: ${normalizedPath} (${match}) owned by ${describeHttpRouteOwner(existing)}`, - }); - return; - } - registry.httpRoutes[existingIndex] = { - pluginId: record.id, - path: normalizedPath, - handler: params.handler, - auth: params.auth, - match, - source: record.source, - }; + registry.httpRoutes[result.existingIndex] = result.entry; return; } record.httpRoutes += 1; - registry.httpRoutes.push({ - pluginId: record.id, - path: normalizedPath, - handler: params.handler, - auth: params.auth, - match, - source: record.source, - }); + registry.httpRoutes.push(result.entry); }; const registerChannel = ( @@ -471,6 +413,17 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { source: record.source, rootDir: record.rootDir, }); + if (!result.ok) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: result.message, + }); + return; + } + record.channelIds.push(result.channelId); + registry.channels.push(result.entry); }; const registerProvider = (record: PluginRecord, provider: ProviderPlugin) => { @@ -483,14 +436,18 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { if (!normalizedProvider) { return; } - const id = normalizedProvider.id; - const existing = registry.providers.find((entry) => entry.provider.id === id); - if (existing) { + const result = resolveExtensionProviderRegistration({ + existing: registry.providers, + ownerPluginId: record.id, + ownerSource: record.source, + provider: normalizedProvider, + }); + if (!result.ok) { pushDiagnostic({ level: "error", pluginId: record.id, source: record.source, - message: `provider already registered: ${id} (${existing.pluginId})`, + message: result.message, }); return; } @@ -541,11 +498,17 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { source: record.source, rootDir: record.rootDir, }); + record.cliCommands.push(...result.commands); + registry.cliRegistrars.push(result.entry); }; const registerService = (record: PluginRecord, service: OpenClawPluginService) => { - const id = service.id.trim(); - if (!id) { + const result = resolveExtensionServiceRegistration({ + ownerPluginId: record.id, + ownerSource: record.source, + service, + }); + if (!result.ok) { return; } const existing = registry.services.find((entry) => entry.service.id === id); @@ -569,13 +532,17 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }; const registerCommand = (record: PluginRecord, command: OpenClawPluginCommandDefinition) => { - const name = command.name.trim(); - if (!name) { + const normalized = resolveExtensionCommandRegistration({ + ownerPluginId: record.id, + ownerSource: record.source, + command, + }); + if (!normalized.ok) { pushDiagnostic({ level: "error", pluginId: record.id, source: record.source, - message: "command registration missing name", + message: normalized.message, }); return; } @@ -612,32 +579,39 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { opts?: { priority?: number }, policy?: PluginTypedHookPolicy, ) => { - if (!isPluginHookName(hookName)) { + const normalized = resolveExtensionTypedHookRegistration({ + ownerPluginId: record.id, + ownerSource: record.source, + hookName, + handler, + priority: opts?.priority, + }); + if (!normalized.ok) { pushDiagnostic({ level: "warn", pluginId: record.id, source: record.source, - message: `unknown typed hook "${String(hookName)}" ignored`, + message: normalized.message, }); return; } - let effectiveHandler = handler; - if (policy?.allowPromptInjection === false && isPromptInjectionHookName(hookName)) { - if (hookName === "before_prompt_build") { + let effectiveHandler = normalized.entry.handler; + if (policy?.allowPromptInjection === false && isPromptInjectionHookName(normalized.hookName)) { + if (normalized.hookName === "before_prompt_build") { pushDiagnostic({ level: "warn", pluginId: record.id, source: record.source, - message: `typed hook "${hookName}" blocked by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, + message: `typed hook "${normalized.hookName}" blocked by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, }); return; } - if (hookName === "before_agent_start") { + if (normalized.hookName === "before_agent_start") { pushDiagnostic({ level: "warn", pluginId: record.id, source: record.source, - message: `typed hook "${hookName}" prompt fields constrained by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, + message: `typed hook "${normalized.hookName}" prompt fields constrained by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, }); effectiveHandler = constrainLegacyPromptInjectionHook( handler as PluginHookHandlerMap["before_agent_start"], @@ -646,11 +620,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { } record.hookCount += 1; registry.typedHooks.push({ + ...normalized.entry, pluginId: record.id, - hookName, + hookName: normalized.hookName, handler: effectiveHandler, - priority: opts?.priority, - source: record.source, } as TypedPluginHookRegistration); }; diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index 752908ddf75..b10c7c9106d 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -1,49 +1,32 @@ -import { createEmptyPluginRegistry, type PluginRegistry } from "./registry.js"; +import { + getActiveExtensionHostRegistry, + getActiveExtensionHostRegistryKey, + getActiveExtensionHostRegistryVersion, + requireActiveExtensionHostRegistry, + setActiveExtensionHostRegistry, + type ExtensionHostRegistry, +} from "../extension-host/active-registry.js"; -const REGISTRY_STATE = Symbol.for("openclaw.pluginRegistryState"); - -type RegistryState = { - registry: PluginRegistry | null; - key: string | null; - version: number; -}; - -const state: RegistryState = (() => { - const globalState = globalThis as typeof globalThis & { - [REGISTRY_STATE]?: RegistryState; - }; - if (!globalState[REGISTRY_STATE]) { - globalState[REGISTRY_STATE] = { - registry: createEmptyPluginRegistry(), - key: null, - version: 0, - }; - } - return globalState[REGISTRY_STATE]; -})(); +export type PluginRegistry = ExtensionHostRegistry; +// Compatibility facade: legacy plugin runtime callers still import from this module, +// but the active registry now lives under the extension-host boundary. export function setActivePluginRegistry(registry: PluginRegistry, cacheKey?: string) { - state.registry = registry; - state.key = cacheKey ?? null; - state.version += 1; + setActiveExtensionHostRegistry(registry, cacheKey); } export function getActivePluginRegistry(): PluginRegistry | null { - return state.registry; + return getActiveExtensionHostRegistry(); } export function requireActivePluginRegistry(): PluginRegistry { - if (!state.registry) { - state.registry = createEmptyPluginRegistry(); - state.version += 1; - } - return state.registry; + return requireActiveExtensionHostRegistry(); } export function getActivePluginRegistryKey(): string | null { - return state.key; + return getActiveExtensionHostRegistryKey(); } export function getActivePluginRegistryVersion(): number { - return state.version; + return getActiveExtensionHostRegistryVersion(); } diff --git a/src/utils/message-channel.ts b/src/utils/message-channel.ts index ed580960ad4..df7941f30bc 100644 --- a/src/utils/message-channel.ts +++ b/src/utils/message-channel.ts @@ -4,6 +4,7 @@ import { listChatChannelAliases, normalizeChatChannelId, } from "../channels/registry.js"; +import { getActiveExtensionHostRegistry } from "../extension-host/active-registry.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES, @@ -12,7 +13,6 @@ import { normalizeGatewayClientMode, normalizeGatewayClientName, } from "../gateway/protocol/client-info.js"; -import { getActivePluginRegistry } from "../plugins/runtime.js"; export const INTERNAL_MESSAGE_CHANNEL = "webchat" as const; export type InternalMessageChannel = typeof INTERNAL_MESSAGE_CHANNEL; @@ -64,7 +64,7 @@ export function normalizeMessageChannel(raw?: string | null): string | undefined if (builtIn) { return builtIn; } - const registry = getActivePluginRegistry(); + const registry = getActiveExtensionHostRegistry(); const pluginMatch = registry?.channels.find((entry) => { if (entry.plugin.id.toLowerCase() === normalized) { return true; @@ -77,7 +77,7 @@ export function normalizeMessageChannel(raw?: string | null): string | undefined } const listPluginChannelIds = (): string[] => { - const registry = getActivePluginRegistry(); + const registry = getActiveExtensionHostRegistry(); if (!registry) { return []; } @@ -85,7 +85,7 @@ const listPluginChannelIds = (): string[] => { }; const listPluginChannelAliases = (): string[] => { - const registry = getActivePluginRegistry(); + const registry = getActiveExtensionHostRegistry(); if (!registry) { return []; }