Plugins: add extension host registry boundary
This commit is contained in:
parent
963237a18f
commit
fb9a0383d1
@ -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<string, unknown>;
|
||||
skills?: string[];
|
||||
};
|
||||
staticMetadata: {
|
||||
configSchema: Record<string, unknown>;
|
||||
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,
|
||||
|
||||
@ -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<string>();
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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<string>();
|
||||
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;
|
||||
|
||||
@ -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<PluginOrigin, number> = {
|
||||
bundled: 3,
|
||||
};
|
||||
|
||||
type ExternalCatalogEntry = {
|
||||
name?: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
} & Partial<Record<ManifestKey, OpenClawPackageManifest>>;
|
||||
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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<TValue> = (
|
||||
@ -13,7 +13,7 @@ export function createChannelRegistryLoader<TValue>(
|
||||
let lastRegistry: PluginRegistry | null = null;
|
||||
|
||||
return async (id: ChannelId): Promise<TValue | undefined> => {
|
||||
const registry = getActivePluginRegistry();
|
||||
const registry = getActiveExtensionHostRegistry();
|
||||
if (registry !== lastRegistry) {
|
||||
cache.clear();
|
||||
lastRegistry = registry;
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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<ConfigSchemaResponse>
|
||||
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<ConfigSchemaResponse> {
|
||||
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,
|
||||
|
||||
@ -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<string, string> {
|
||||
function buildChannelToPluginIdMap(registry: ResolvedExtensionRegistry): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
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: [] };
|
||||
|
||||
47
src/config/resolved-extension-validation.test.ts
Normal file
47
src/config/resolved-extension-validation.test.ts
Normal file
@ -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",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
54
src/config/resolved-extension-validation.ts
Normal file
54
src/config/resolved-extension-validation.ts
Normal file
@ -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<string, unknown>;
|
||||
manifestPath: string;
|
||||
schemaCacheKey?: string;
|
||||
};
|
||||
|
||||
export type ResolvedExtensionValidationIndex = {
|
||||
knownIds: Set<string>;
|
||||
channelIds: Set<string>;
|
||||
lowercaseChannelIds: Set<string>;
|
||||
entries: ResolvedExtensionValidationEntry[];
|
||||
};
|
||||
|
||||
export function buildResolvedExtensionValidationIndex(
|
||||
registry: ResolvedExtensionRegistry,
|
||||
): ResolvedExtensionValidationIndex {
|
||||
const knownIds = new Set<string>();
|
||||
const channelIds = new Set<string>();
|
||||
const lowercaseChannelIds = new Set<string>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
@ -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<typeof loadPluginManifestRegistry>;
|
||||
registry: ReturnType<typeof loadResolvedExtensionRegistry>;
|
||||
validationIndex?: ReturnType<typeof buildResolvedExtensionValidationIndex>;
|
||||
knownIds?: Set<string>;
|
||||
normalizedPlugins?: ReturnType<typeof normalizePluginsConfig>;
|
||||
};
|
||||
@ -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<string> => {
|
||||
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<typeof buildResolvedExtensionValidationIndex> => {
|
||||
const info = ensureRegistry();
|
||||
if (!info.validationIndex) {
|
||||
info.validationIndex = buildResolvedExtensionValidationIndex(info.registry);
|
||||
}
|
||||
return info.validationIndex;
|
||||
};
|
||||
|
||||
const ensureNormalizedPlugins = (): ReturnType<typeof normalizePluginsConfig> => {
|
||||
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<string>();
|
||||
for (const record of registry.plugins) {
|
||||
for (const record of validationIndex.entries) {
|
||||
const pluginId = record.id;
|
||||
if (seenPlugins.has(pluginId)) {
|
||||
continue;
|
||||
|
||||
58
src/extension-host/active-registry.test.ts
Normal file
58
src/extension-host/active-registry.test.ts
Normal file
@ -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());
|
||||
});
|
||||
});
|
||||
58
src/extension-host/active-registry.ts
Normal file
58
src/extension-host/active-registry.ts
Normal file
@ -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;
|
||||
}
|
||||
52
src/extension-host/manifest-registry.ts
Normal file
52
src/extension-host/manifest-registry.ts
Normal file
@ -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<string, unknown>;
|
||||
}): 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,
|
||||
};
|
||||
}
|
||||
70
src/extension-host/resolved-registry.ts
Normal file
70
src/extension-host/resolved-registry.ts
Normal file
@ -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));
|
||||
}
|
||||
524
src/extension-host/runtime-registrations.test.ts
Normal file
524
src/extension-host/runtime-registrations.test.ts
Normal file
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
553
src/extension-host/runtime-registrations.ts
Normal file
553
src/extension-host/runtime-registrations.ts
Normal file
@ -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<K extends PluginHookName>(params: {
|
||||
ownerPluginId: string;
|
||||
ownerSource: string;
|
||||
hookName: unknown;
|
||||
handler: PluginHookHandlerMap[K];
|
||||
priority?: number;
|
||||
}):
|
||||
| {
|
||||
ok: true;
|
||||
hookName: K;
|
||||
entry: PluginHookRegistration<K>;
|
||||
}
|
||||
| {
|
||||
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<string>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
112
src/extension-host/schema.test.ts
Normal file
112
src/extension-host/schema.test.ts
Normal file
@ -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",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
181
src/extension-host/schema.ts
Normal file
181
src/extension-host/schema.ts
Normal file
@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
export type ResolvedExtensionPackageMetadata = {
|
||||
entries: string[];
|
||||
manifest?: OpenClawPackageManifest;
|
||||
};
|
||||
|
||||
export type ResolvedExtensionStaticMetadata = {
|
||||
configSchema: Record<string, unknown>;
|
||||
configUiHints?: Record<string, PluginConfigUiHint>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<string, unknown>;
|
||||
configUiHints?: Record<string, PluginConfigUiHint>;
|
||||
resolvedExtension: ResolvedExtensionRecord["extension"];
|
||||
};
|
||||
|
||||
export type PluginManifestRegistry = {
|
||||
@ -129,6 +134,7 @@ function buildRecord(params: {
|
||||
schemaCacheKey?: string;
|
||||
configSchema?: Record<string, unknown>;
|
||||
}): 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -172,6 +172,27 @@ export type PackageManifest = {
|
||||
description?: string;
|
||||
} & Partial<Record<ManifestKey, OpenClawPackageManifest>>;
|
||||
|
||||
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 {
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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 [];
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user