Plugins: add host-owned channel storage

This commit is contained in:
Gustavo Madeira Santana 2026-03-15 18:50:32 +00:00
parent 235021766c
commit e109d5ef1b
9 changed files with 297 additions and 32 deletions

View File

@ -9,6 +9,7 @@ import {
resolveChannelGroupToolsPolicy,
} from "../config/group-policy.js";
import { requireActiveExtensionHostRegistry } from "../extension-host/active-registry.js";
import { listExtensionHostChannelRegistrations } from "../extension-host/runtime-registry.js";
import {
formatAllowFromLowercase,
formatNormalizedAllowFromEntries,
@ -585,7 +586,7 @@ function listPluginDockEntries(): Array<{ id: ChannelId; dock: ChannelDock; orde
const registry = requireActiveExtensionHostRegistry();
const entries: Array<{ id: ChannelId; dock: ChannelDock; order?: number }> = [];
const seen = new Set<string>();
for (const entry of registry.channels) {
for (const entry of listExtensionHostChannelRegistrations(registry)) {
const plugin = entry.plugin;
const id = String(plugin.id).trim();
if (!id || seen.has(id)) {
@ -628,7 +629,9 @@ export function getChannelDock(id: ChannelId): ChannelDock | undefined {
return core;
}
const registry = requireActiveExtensionHostRegistry();
const pluginEntry = registry.channels.find((entry) => entry.plugin.id === id);
const pluginEntry = listExtensionHostChannelRegistrations(registry).find(
(entry) => entry.plugin.id === id,
);
if (!pluginEntry) {
return undefined;
}

View File

@ -2,6 +2,7 @@ import {
getActiveExtensionHostRegistryVersion,
requireActiveExtensionHostRegistry,
} from "../../extension-host/active-registry.js";
import { listExtensionHostChannelRegistrations } from "../../extension-host/runtime-registry.js";
import { CHAT_CHANNEL_ORDER, type ChatChannelId, normalizeAnyChannelId } from "../registry.js";
import type { ChannelId, ChannelPlugin } from "./types.js";
@ -47,7 +48,9 @@ function resolveCachedChannelPlugins(): CachedChannelPlugins {
return cached;
}
const sorted = dedupeChannels(registry.channels.map((entry) => entry.plugin)).toSorted((a, b) => {
const sorted = dedupeChannels(
listExtensionHostChannelRegistrations(registry).map((entry) => entry.plugin),
).toSorted((a, b) => {
const indexA = CHAT_CHANNEL_ORDER.indexOf(a.id as ChatChannelId);
const indexB = CHAT_CHANNEL_ORDER.indexOf(b.id as ChatChannelId);
const orderA = a.meta.order ?? (indexA === -1 ? 999 : indexA);

View File

@ -10,6 +10,7 @@ import type { SlackProbe } from "../../../extensions/slack/src/probe.js";
import type { TelegramProbe } from "../../../extensions/telegram/src/probe.js";
import type { TelegramTokenResolution } from "../../../extensions/telegram/src/token.js";
import type { OpenClawConfig } from "../../config/config.js";
import { addExtensionHostChannelRegistration } from "../../extension-host/runtime-registry.js";
import type { LineProbeResult } from "../../line/types.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import {
@ -96,13 +97,12 @@ describe("channel plugin registry", () => {
setActivePluginRegistry(registry, "registry-test");
expect(listChannelPlugins().map((plugin) => plugin.id)).toEqual(["slack"]);
registry.channels = [
{
pluginId: "telegram",
plugin: createPlugin("telegram"),
source: "test",
},
] as typeof registry.channels;
registry.channels = [] as typeof registry.channels;
addExtensionHostChannelRegistration(registry, {
pluginId: "telegram",
plugin: createPlugin("telegram"),
source: "test",
});
setActivePluginRegistry(registry, "registry-test");
expect(listChannelPlugins().map((plugin) => plugin.id)).toEqual(["telegram"]);

View File

@ -1,4 +1,5 @@
import { getActiveExtensionHostRegistry } from "../../extension-host/active-registry.js";
import { listExtensionHostChannelRegistrations } from "../../extension-host/runtime-registry.js";
import type { PluginChannelRegistration, PluginRegistry } from "../../plugins/registry.js";
import type { ChannelId } from "./types.js";
@ -22,7 +23,9 @@ export function createChannelRegistryLoader<TValue>(
if (cached) {
return cached;
}
const pluginEntry = registry?.channels.find((entry) => entry.plugin.id === id);
const pluginEntry = registry
? listExtensionHostChannelRegistrations(registry).find((entry) => entry.plugin.id === id)
: undefined;
if (!pluginEntry) {
return undefined;
}

View File

@ -1,4 +1,5 @@
import { requireActiveExtensionHostRegistry } from "../extension-host/active-registry.js";
import { listExtensionHostChannelRegistrations } from "../extension-host/runtime-registry.js";
import type { ChannelMeta } from "./plugins/types.js";
import type { ChannelId } from "./plugins/types.js";
@ -170,7 +171,7 @@ export function normalizeAnyChannelId(raw?: string | null): ChannelId | null {
}
const registry = requireActiveExtensionHostRegistry();
const hit = registry.channels.find((entry) => {
const hit = listExtensionHostChannelRegistrations(registry).find((entry) => {
const id = String(entry.plugin.id ?? "")
.trim()
.toLowerCase();

View File

@ -44,6 +44,7 @@ import {
resolveExtensionTypedHookRegistration,
} from "./runtime-registrations.js";
import {
listExtensionHostChannelRegistrations,
getExtensionHostGatewayHandlers,
listExtensionHostHttpRoutes,
} from "./runtime-registry.js";
@ -182,7 +183,7 @@ export function createExtensionHostPluginRegistrationActions(params: {
registration: OpenClawPluginChannelRegistration | ChannelPlugin,
) => {
const result = resolveExtensionChannelRegistration({
existing: registry.channels,
existing: [...listExtensionHostChannelRegistrations(registry)],
ownerPluginId: record.id,
ownerSource: record.source,
registration,

View File

@ -25,6 +25,7 @@ import type {
ExtensionHostToolRegistration,
} from "./runtime-registrations.js";
import {
addExtensionHostChannelRegistration,
addExtensionHostCliRegistration,
addExtensionHostHttpRoute,
addExtensionHostProviderRegistration,
@ -78,7 +79,7 @@ export function addExtensionChannelRegistration(params: {
entry: ExtensionHostChannelRegistration;
}): void {
params.record.channelIds.push(params.channelId);
params.registry.channels.push(params.entry as PluginChannelRegistration);
addExtensionHostChannelRegistration(params.registry, params.entry as PluginChannelRegistration);
}
export function addExtensionProviderRegistration(params: {

View File

@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import {
addExtensionHostChannelRegistration,
addExtensionHostCliRegistration,
addExtensionHostHttpRoute,
addExtensionHostProviderRegistration,
@ -8,6 +9,7 @@ import {
addExtensionHostToolRegistration,
getExtensionHostGatewayHandlers,
hasExtensionHostRuntimeEntries,
listExtensionHostChannelRegistrations,
listExtensionHostCliRegistrations,
listExtensionHostHttpRoutes,
listExtensionHostProviderRegistrations,
@ -43,6 +45,28 @@ describe("extension host runtime registry accessors", () => {
});
expect(hasExtensionHostRuntimeEntries(routeRegistry)).toBe(true);
const channelRegistry = createEmptyPluginRegistry();
addExtensionHostChannelRegistration(channelRegistry, {
pluginId: "channel-demo",
source: "test",
plugin: {
id: "channel-demo",
meta: {
id: "channel-demo",
label: "Channel Demo",
selectionLabel: "Channel Demo",
docsPath: "/channels/channel-demo",
blurb: "demo",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
},
});
expect(hasExtensionHostRuntimeEntries(channelRegistry)).toBe(true);
const gatewayRegistry = createEmptyPluginRegistry();
setExtensionHostGatewayHandler({
registry: gatewayRegistry,
@ -75,6 +99,7 @@ describe("extension host runtime registry accessors", () => {
it("returns stable empty views for missing registries", () => {
expect(hasExtensionHostRuntimeEntries(null)).toBe(false);
expect(listExtensionHostProviderRegistrations(null)).toEqual([]);
expect(listExtensionHostChannelRegistrations(null)).toEqual([]);
expect(listExtensionHostToolRegistrations(null)).toEqual([]);
expect(listExtensionHostServiceRegistrations(null)).toEqual([]);
expect(listExtensionHostCliRegistrations(null)).toEqual([]);
@ -136,6 +161,27 @@ describe("extension host runtime registry accessors", () => {
handler,
});
addExtensionHostChannelRegistration(registry, {
pluginId: "channel-demo",
source: "test",
plugin: {
id: "channel-demo",
meta: {
id: "channel-demo",
label: "Channel Demo",
selectionLabel: "Channel Demo",
docsPath: "/channels/channel-demo",
blurb: "demo",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
},
});
expect(listExtensionHostChannelRegistrations(registry)).toEqual(registry.channels);
expect(listExtensionHostToolRegistrations(registry)).toEqual(registry.tools);
expect(listExtensionHostProviderRegistrations(registry)).toEqual(registry.providers);
expect(listExtensionHostServiceRegistrations(registry)).toEqual(registry.services);
@ -229,4 +275,32 @@ describe("extension host runtime registry accessors", () => {
expect(registry.tools[0]?.factory).toBe(factory);
expect(registry.providers[0]?.provider).toBe(provider);
});
it("keeps legacy channel mirrors synchronized with host-owned state", () => {
const registry = createEmptyPluginRegistry();
const plugin = {
id: "channel-demo",
meta: {
id: "channel-demo",
label: "Channel Demo",
selectionLabel: "Channel Demo",
docsPath: "/channels/channel-demo",
blurb: "demo",
},
capabilities: { chatTypes: ["direct"] as const },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
};
addExtensionHostChannelRegistration(registry, {
pluginId: "channel-demo",
source: "test",
plugin,
});
expect(listExtensionHostChannelRegistrations(registry)).toEqual(registry.channels);
expect(registry.channels[0]?.plugin).toBe(plugin);
});
});

View File

@ -1,5 +1,6 @@
import type { GatewayRequestHandlers } from "../gateway/server-methods/types.js";
import type {
PluginChannelRegistration,
PluginCliRegistration,
PluginHttpRouteRegistration,
PluginProviderRegistration,
@ -10,6 +11,7 @@ import type {
const EMPTY_PROVIDERS: readonly PluginProviderRegistration[] = [];
const EMPTY_TOOLS: readonly PluginToolRegistration[] = [];
const EMPTY_CHANNELS: readonly PluginChannelRegistration[] = [];
const EMPTY_SERVICES: readonly PluginServiceRegistration[] = [];
const EMPTY_CLI_REGISTRARS: readonly PluginCliRegistration[] = [];
const EMPTY_HTTP_ROUTES: readonly PluginHttpRouteRegistration[] = [];
@ -17,6 +19,8 @@ const EMPTY_GATEWAY_HANDLERS: Readonly<GatewayRequestHandlers> = Object.freeze({
const EXTENSION_HOST_RUNTIME_REGISTRY_STATE = Symbol.for("openclaw.extensionHostRuntimeRegistry");
type ExtensionHostRuntimeRegistryState = {
channels: PluginChannelRegistration[];
legacyChannels: PluginChannelRegistration[];
tools: PluginToolRegistration[];
legacyTools: PluginToolRegistration[];
providers: PluginProviderRegistration[];
@ -33,7 +37,13 @@ type ExtensionHostRuntimeRegistryState = {
type RuntimeRegistryBackedPluginRegistry = Pick<
PluginRegistry,
"tools" | "providers" | "cliRegistrars" | "services" | "httpRoutes" | "gatewayHandlers"
| "channels"
| "tools"
| "providers"
| "cliRegistrars"
| "services"
| "httpRoutes"
| "gatewayHandlers"
> & {
[EXTENSION_HOST_RUNTIME_REGISTRY_STATE]?: ExtensionHostRuntimeRegistryState;
};
@ -41,8 +51,37 @@ type RuntimeRegistryBackedPluginRegistry = Pick<
function ensureExtensionHostRuntimeRegistryState(
registry: RuntimeRegistryBackedPluginRegistry,
): ExtensionHostRuntimeRegistryState {
if (registry[EXTENSION_HOST_RUNTIME_REGISTRY_STATE]) {
return registry[EXTENSION_HOST_RUNTIME_REGISTRY_STATE];
const existing = registry[EXTENSION_HOST_RUNTIME_REGISTRY_STATE];
if (existing) {
if (registry.channels !== existing.legacyChannels) {
existing.legacyChannels = registry.channels ?? [];
existing.channels = [...existing.legacyChannels];
}
if (registry.tools !== existing.legacyTools) {
existing.legacyTools = registry.tools ?? [];
existing.tools = [...existing.legacyTools];
}
if (registry.providers !== existing.legacyProviders) {
existing.legacyProviders = registry.providers ?? [];
existing.providers = [...existing.legacyProviders];
}
if (registry.cliRegistrars !== existing.legacyCliRegistrars) {
existing.legacyCliRegistrars = registry.cliRegistrars ?? [];
existing.cliRegistrars = [...existing.legacyCliRegistrars];
}
if (registry.services !== existing.legacyServices) {
existing.legacyServices = registry.services ?? [];
existing.services = [...existing.legacyServices];
}
if (registry.httpRoutes !== existing.legacyHttpRoutes) {
existing.legacyHttpRoutes = registry.httpRoutes ?? [];
existing.httpRoutes = [...existing.legacyHttpRoutes];
}
if (registry.gatewayHandlers !== existing.legacyGatewayHandlers) {
existing.legacyGatewayHandlers = registry.gatewayHandlers ?? {};
existing.gatewayHandlers = { ...existing.legacyGatewayHandlers };
}
return existing;
}
const legacyHttpRoutes = registry.httpRoutes ?? [];
@ -53,12 +92,16 @@ function ensureExtensionHostRuntimeRegistryState(
registry.cliRegistrars = legacyCliRegistrars;
const legacyServices = registry.services ?? [];
registry.services = legacyServices;
const legacyChannels = registry.channels ?? [];
registry.channels = legacyChannels;
const legacyTools = registry.tools ?? [];
registry.tools = legacyTools;
const legacyProviders = registry.providers ?? [];
registry.providers = legacyProviders;
const state: ExtensionHostRuntimeRegistryState = {
channels: [...legacyChannels],
legacyChannels,
tools: [...legacyTools],
legacyTools,
providers: [...legacyProviders],
@ -76,6 +119,10 @@ function ensureExtensionHostRuntimeRegistryState(
return state;
}
function syncLegacyChannels(state: ExtensionHostRuntimeRegistryState): void {
state.legacyChannels.splice(0, state.legacyChannels.length, ...state.channels);
}
function syncLegacyTools(state: ExtensionHostRuntimeRegistryState): void {
state.legacyTools.splice(0, state.legacyTools.length, ...state.tools);
}
@ -129,7 +176,7 @@ export function hasExtensionHostRuntimeEntries(
}
return (
registry.plugins.length > 0 ||
registry.channels.length > 0 ||
listExtensionHostChannelRegistrations(registry).length > 0 ||
listExtensionHostToolRegistrations(registry).length > 0 ||
listExtensionHostProviderRegistrations(registry).length > 0 ||
Object.keys(getExtensionHostGatewayHandlers(registry)).length > 0 ||
@ -146,7 +193,13 @@ export function listExtensionHostProviderRegistrations(
registry:
| Pick<
PluginRegistry,
"tools" | "providers" | "cliRegistrars" | "services" | "httpRoutes" | "gatewayHandlers"
| "channels"
| "tools"
| "providers"
| "cliRegistrars"
| "services"
| "httpRoutes"
| "gatewayHandlers"
>
| null
| undefined,
@ -162,7 +215,13 @@ export function listExtensionHostToolRegistrations(
registry:
| Pick<
PluginRegistry,
"tools" | "providers" | "cliRegistrars" | "services" | "httpRoutes" | "gatewayHandlers"
| "channels"
| "tools"
| "providers"
| "cliRegistrars"
| "services"
| "httpRoutes"
| "gatewayHandlers"
>
| null
| undefined,
@ -174,9 +233,40 @@ export function listExtensionHostToolRegistrations(
.tools;
}
export function listExtensionHostChannelRegistrations(
registry:
| Pick<
PluginRegistry,
| "channels"
| "tools"
| "providers"
| "cliRegistrars"
| "services"
| "httpRoutes"
| "gatewayHandlers"
>
| null
| undefined,
): readonly PluginChannelRegistration[] {
if (!registry) {
return EMPTY_CHANNELS;
}
return ensureExtensionHostRuntimeRegistryState(registry as RuntimeRegistryBackedPluginRegistry)
.channels;
}
export function listExtensionHostServiceRegistrations(
registry:
| Pick<PluginRegistry, "cliRegistrars" | "services" | "httpRoutes" | "gatewayHandlers">
| Pick<
PluginRegistry,
| "channels"
| "tools"
| "providers"
| "cliRegistrars"
| "services"
| "httpRoutes"
| "gatewayHandlers"
>
| null
| undefined,
): readonly PluginServiceRegistration[] {
@ -189,7 +279,16 @@ export function listExtensionHostServiceRegistrations(
export function listExtensionHostCliRegistrations(
registry:
| Pick<PluginRegistry, "cliRegistrars" | "services" | "httpRoutes" | "gatewayHandlers">
| Pick<
PluginRegistry,
| "channels"
| "tools"
| "providers"
| "cliRegistrars"
| "services"
| "httpRoutes"
| "gatewayHandlers"
>
| null
| undefined,
): readonly PluginCliRegistration[] {
@ -204,7 +303,13 @@ export function listExtensionHostHttpRoutes(
registry:
| Pick<
PluginRegistry,
"tools" | "providers" | "cliRegistrars" | "services" | "httpRoutes" | "gatewayHandlers"
| "channels"
| "tools"
| "providers"
| "cliRegistrars"
| "services"
| "httpRoutes"
| "gatewayHandlers"
>
| null
| undefined,
@ -220,7 +325,13 @@ export function getExtensionHostGatewayHandlers(
registry:
| Pick<
PluginRegistry,
"tools" | "providers" | "cliRegistrars" | "services" | "httpRoutes" | "gatewayHandlers"
| "channels"
| "tools"
| "providers"
| "cliRegistrars"
| "services"
| "httpRoutes"
| "gatewayHandlers"
>
| null
| undefined,
@ -235,7 +346,13 @@ export function getExtensionHostGatewayHandlers(
export function addExtensionHostHttpRoute(
registry: Pick<
PluginRegistry,
"tools" | "providers" | "cliRegistrars" | "services" | "httpRoutes" | "gatewayHandlers"
| "channels"
| "tools"
| "providers"
| "cliRegistrars"
| "services"
| "httpRoutes"
| "gatewayHandlers"
>,
entry: PluginHttpRouteRegistration,
): void {
@ -249,7 +366,13 @@ export function addExtensionHostHttpRoute(
export function replaceExtensionHostHttpRoute(params: {
registry: Pick<
PluginRegistry,
"tools" | "providers" | "cliRegistrars" | "services" | "httpRoutes" | "gatewayHandlers"
| "channels"
| "tools"
| "providers"
| "cliRegistrars"
| "services"
| "httpRoutes"
| "gatewayHandlers"
>;
index: number;
entry: PluginHttpRouteRegistration;
@ -264,7 +387,13 @@ export function replaceExtensionHostHttpRoute(params: {
export function removeExtensionHostHttpRoute(
registry: Pick<
PluginRegistry,
"tools" | "providers" | "cliRegistrars" | "services" | "httpRoutes" | "gatewayHandlers"
| "channels"
| "tools"
| "providers"
| "cliRegistrars"
| "services"
| "httpRoutes"
| "gatewayHandlers"
>,
entry: PluginHttpRouteRegistration,
): void {
@ -282,7 +411,13 @@ export function removeExtensionHostHttpRoute(
export function setExtensionHostGatewayHandler(params: {
registry: Pick<
PluginRegistry,
"tools" | "providers" | "cliRegistrars" | "services" | "httpRoutes" | "gatewayHandlers"
| "channels"
| "tools"
| "providers"
| "cliRegistrars"
| "services"
| "httpRoutes"
| "gatewayHandlers"
>;
method: string;
handler: GatewayRequestHandlers[string];
@ -297,7 +432,13 @@ export function setExtensionHostGatewayHandler(params: {
export function addExtensionHostCliRegistration(
registry: Pick<
PluginRegistry,
"tools" | "providers" | "cliRegistrars" | "services" | "httpRoutes" | "gatewayHandlers"
| "channels"
| "tools"
| "providers"
| "cliRegistrars"
| "services"
| "httpRoutes"
| "gatewayHandlers"
>,
entry: PluginCliRegistration,
): void {
@ -311,7 +452,13 @@ export function addExtensionHostCliRegistration(
export function addExtensionHostServiceRegistration(
registry: Pick<
PluginRegistry,
"tools" | "providers" | "cliRegistrars" | "services" | "httpRoutes" | "gatewayHandlers"
| "channels"
| "tools"
| "providers"
| "cliRegistrars"
| "services"
| "httpRoutes"
| "gatewayHandlers"
>,
entry: PluginServiceRegistration,
): void {
@ -325,7 +472,13 @@ export function addExtensionHostServiceRegistration(
export function addExtensionHostToolRegistration(
registry: Pick<
PluginRegistry,
"tools" | "providers" | "cliRegistrars" | "services" | "httpRoutes" | "gatewayHandlers"
| "channels"
| "tools"
| "providers"
| "cliRegistrars"
| "services"
| "httpRoutes"
| "gatewayHandlers"
>,
entry: PluginToolRegistration,
): void {
@ -339,7 +492,13 @@ export function addExtensionHostToolRegistration(
export function addExtensionHostProviderRegistration(
registry: Pick<
PluginRegistry,
"tools" | "providers" | "cliRegistrars" | "services" | "httpRoutes" | "gatewayHandlers"
| "channels"
| "tools"
| "providers"
| "cliRegistrars"
| "services"
| "httpRoutes"
| "gatewayHandlers"
>,
entry: PluginProviderRegistration,
): void {
@ -349,3 +508,23 @@ export function addExtensionHostProviderRegistration(
state.providers.push(entry);
syncLegacyProviders(state);
}
export function addExtensionHostChannelRegistration(
registry: Pick<
PluginRegistry,
| "channels"
| "tools"
| "providers"
| "cliRegistrars"
| "services"
| "httpRoutes"
| "gatewayHandlers"
>,
entry: PluginChannelRegistration,
): void {
const state = ensureExtensionHostRuntimeRegistryState(
registry as RuntimeRegistryBackedPluginRegistry,
);
state.channels.push(entry);
syncLegacyChannels(state);
}