feat(channels): use manifest configs for read-only discovery

This commit is contained in:
Vincent Koc
2026-04-24 15:18:45 -07:00
committed by GitHub
parent d4a8fdb6ce
commit 5394efe71f
4 changed files with 295 additions and 18 deletions

View File

@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
- Plugins/setup: include `setup.providers[].envVars` in generic provider auth/env lookups and warn non-bundled plugins that still rely on deprecated `providerAuthEnvVars` compatibility metadata. Thanks @vincentkoc.
- Plugins/setup: surface manifest provider auth choices directly in provider setup flow before falling back to setup runtime or install-catalog choices. Thanks @vincentkoc.
- Plugins/setup: warn when descriptor-only setup plugins still ship ignored setup runtime entries, keeping `setup.requiresRuntime: false` semantics explicit without breaking existing metadata. Thanks @vincentkoc.
- Plugins/channels: use manifest `channelConfigs` for read-only external channel discovery when no setup entry is available or setup descriptors declare runtime unnecessary. Thanks @vincentkoc.
- TUI/dependencies: remove direct `cli-highlight` usage from the OpenClaw TUI code-block renderer, keeping themed code coloring without the extra root dependency. Thanks @vincentkoc.
- Diagnostics/OTEL: export run, model-call, and tool-execution diagnostic lifecycle events as OTEL spans without retaining live span state. Thanks @vincentkoc.
- Providers/Anthropic Vertex: move the Vertex SDK runtime behind the bundled provider plugin so core no longer owns that provider-specific dependency. Thanks @vincentkoc.

View File

@@ -498,7 +498,9 @@ Each provider entry can include:
## channelConfigs reference
Use `channelConfigs` when a channel plugin needs cheap config metadata before
runtime loads.
runtime loads. Read-only channel setup/status discovery can use this metadata
directly for configured external channels when no setup entry is available, or
when `setup.requiresRuntime: false` declares setup runtime unnecessary.
```json
{

View File

@@ -17,6 +17,8 @@ function writeExternalSetupChannelPlugin(
pluginId?: string;
channelId?: string;
manifestChannelIds?: string[];
manifestChannelConfig?: boolean;
setupRequiresRuntime?: boolean;
setupChannelId?: string;
} = {},
) {
@@ -56,6 +58,36 @@ function writeExternalSetupChannelPlugin(
channelEnvVars: {
[channelId]: ["EXTERNAL_CHAT_TOKEN"],
},
...(typeof options.setupRequiresRuntime === "boolean"
? { setup: { requiresRuntime: options.setupRequiresRuntime } }
: {}),
...(options.manifestChannelConfig
? {
channelConfigs: Object.fromEntries(
manifestChannelIds.map((id) => [
id,
{
schema: {
type: "object",
additionalProperties: false,
properties: {
token: { type: "string" },
},
},
uiHints: {
token: {
label: "Token",
sensitive: true,
},
},
label: "External Chat Manifest",
description: "manifest config",
preferOver: ["legacy-external-chat"],
},
]),
),
}
: {}),
},
null,
2,
@@ -456,6 +488,115 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
expect(fs.existsSync(fullMarker)).toBe(false);
});
it("uses manifest channel configs when no setup entry exists", () => {
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({
setupEntry: false,
pluginId: "external-chat-plugin",
channelId: "external-chat",
manifestChannelConfig: true,
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
channels: {
"external-chat": { token: "configured" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never,
{
env: { ...process.env },
includePersistedAuthState: false,
},
);
expect(plugins.find((entry) => entry.id === "external-chat")?.meta.blurb).toBe(
"manifest config",
);
expect(fs.existsSync(setupMarker)).toBe(false);
expect(fs.existsSync(fullMarker)).toBe(false);
});
it("uses manifest channel configs before setup-only plugin loading", () => {
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({
pluginId: "external-chat-plugin",
channelId: "external-chat",
manifestChannelConfig: true,
setupRequiresRuntime: false,
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
channels: {
"external-chat": { token: "configured" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never,
{
env: { ...process.env },
includePersistedAuthState: false,
},
);
const plugin = plugins.find((entry) => entry.id === "external-chat");
expect(plugin?.meta.label).toBe("External Chat Manifest");
expect(plugin?.meta.blurb).toBe("manifest config");
expect(plugin?.meta.preferOver).toEqual(["legacy-external-chat"]);
expect(plugin?.configSchema?.schema).toMatchObject({
properties: {
token: { type: "string" },
},
});
expect(plugin?.configSchema?.uiHints?.token).toMatchObject({
label: "Token",
sensitive: true,
});
expect(
plugin?.config.listAccountIds({ channels: { "external-chat": { token: "t" } } } as never),
).toEqual(["default"]);
expect(
plugin?.config.resolveAccount({
channels: { "external-chat": { token: "configured" } },
} as never),
).toMatchObject({
accountId: "default",
config: { token: "configured" },
});
expect(fs.existsSync(setupMarker)).toBe(false);
expect(fs.existsSync(fullMarker)).toBe(false);
});
it("keeps setup-entry precedence when channel config descriptors are not runtime cutoffs", () => {
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({
pluginId: "external-chat-plugin",
channelId: "external-chat",
manifestChannelConfig: true,
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
channels: {
"external-chat": { token: "configured" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat-plugin"],
},
} as never,
{
env: { ...process.env },
includePersistedAuthState: false,
},
);
const plugin = plugins.find((entry) => entry.id === "external-chat");
expect(plugin?.meta.blurb).toBe("setup entry");
expect(fs.existsSync(setupMarker)).toBe(true);
expect(fs.existsSync(fullMarker)).toBe(false);
});
it("uses external channel env vars as read-only configuration triggers", () => {
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({
pluginId: "external-chat-plugin",

View File

@@ -1,6 +1,7 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
hasExplicitChannelConfig,
listConfiguredChannelIdsForReadOnlyScope,
resolveDiscoverableScopedChannelPluginIds,
} from "../../plugins/channel-plugin-ids.js";
@@ -9,6 +10,7 @@ import {
loadPluginManifestRegistry,
type PluginManifestRecord,
} from "../../plugins/manifest-registry.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import { getBundledChannelSetupPlugin } from "./bundled.js";
import { listChannelPlugins } from "./registry.js";
import type { ChannelPlugin } from "./types.plugin.js";
@@ -110,6 +112,99 @@ function restoreReboundChannelConfig(params: {
};
}
function getChannelConfigRecord(cfg: OpenClawConfig, channelId: string): Record<string, unknown> {
const channels = cfg.channels;
if (!channels || typeof channels !== "object" || Array.isArray(channels)) {
return {};
}
const entry = (channels as Record<string, unknown>)[channelId];
return entry && typeof entry === "object" && !Array.isArray(entry)
? (entry as Record<string, unknown>)
: {};
}
function listManifestChannelAccountIds(cfg: OpenClawConfig, channelId: string): string[] {
const channelConfig = getChannelConfigRecord(cfg, channelId);
const accounts = channelConfig.accounts;
if (accounts && typeof accounts === "object" && !Array.isArray(accounts)) {
return Object.keys(accounts).toSorted((left, right) => left.localeCompare(right));
}
return hasExplicitChannelConfig({ config: cfg, channelId }) ? [DEFAULT_ACCOUNT_ID] : [];
}
function resolveManifestChannelAccountConfig(params: {
cfg: OpenClawConfig;
channelId: string;
accountId?: string | null;
}): Record<string, unknown> {
const channelConfig = getChannelConfigRecord(params.cfg, params.channelId);
const resolvedAccountId = params.accountId?.trim() || DEFAULT_ACCOUNT_ID;
const accounts = channelConfig.accounts;
if (accounts && typeof accounts === "object" && !Array.isArray(accounts)) {
const accountConfig = (accounts as Record<string, unknown>)[resolvedAccountId];
if (accountConfig && typeof accountConfig === "object" && !Array.isArray(accountConfig)) {
return accountConfig as Record<string, unknown>;
}
}
return channelConfig;
}
function buildManifestChannelPlugin(params: {
record: PluginManifestRecord;
channelId: string;
}): ChannelPlugin | undefined {
const channelConfig = params.record.channelConfigs?.[params.channelId];
if (!channelConfig) {
return undefined;
}
const label = channelConfig.label?.trim() || params.record.name || params.channelId;
const blurb = channelConfig.description?.trim() || params.record.description || "";
return {
id: params.channelId,
meta: {
id: params.channelId,
label,
selectionLabel: label,
docsPath: `/channels/${params.channelId}`,
blurb,
...(channelConfig.preferOver?.length ? { preferOver: channelConfig.preferOver } : {}),
},
capabilities: { chatTypes: ["direct"] },
configSchema: {
schema: channelConfig.schema,
...(channelConfig.uiHints ? { uiHints: channelConfig.uiHints } : {}),
...(channelConfig.runtime ? { runtime: channelConfig.runtime } : {}),
},
config: {
listAccountIds: (cfg) => listManifestChannelAccountIds(cfg, params.channelId),
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
resolveAccount: (cfg, accountId) => ({
accountId: accountId?.trim() || DEFAULT_ACCOUNT_ID,
config: resolveManifestChannelAccountConfig({
cfg,
channelId: params.channelId,
accountId,
}),
}),
isEnabled: (_account, cfg) => getChannelConfigRecord(cfg, params.channelId).enabled !== false,
isConfigured: (_account, cfg) =>
hasExplicitChannelConfig({
config: cfg,
channelId: params.channelId,
}),
hasConfiguredState: ({ cfg }) =>
hasExplicitChannelConfig({
config: cfg,
channelId: params.channelId,
}),
},
};
}
function canUseManifestChannelPlugin(record: PluginManifestRecord): boolean {
return record.setup?.requiresRuntime === false || !record.setupSource;
}
function rebindChannelPluginConfig(
config: ChannelPlugin["config"],
sourceChannelId: string,
@@ -283,6 +378,34 @@ function addSetupChannelPlugins(
}
}
function addManifestChannelPlugins(
byId: Map<string, ChannelPlugin>,
records: readonly PluginManifestRecord[],
options: {
pluginIds: ReadonlySet<string>;
channelIds: readonly string[];
},
): void {
const channelIds = new Set(options.channelIds);
for (const record of records) {
if (!options.pluginIds.has(record.id)) {
continue;
}
if (!canUseManifestChannelPlugin(record)) {
continue;
}
for (const channelId of record.channels) {
if (!channelIds.has(channelId)) {
continue;
}
addChannelPlugins(byId, [buildManifestChannelPlugin({ record, channelId })], {
onlyIds: channelIds,
allowOverwrite: false,
});
}
}
}
function resolveReadOnlyWorkspaceDir(
cfg: OpenClawConfig,
options: ReadOnlyChannelPluginOptions,
@@ -389,8 +512,16 @@ export function resolveReadOnlyChannelPluginsForConfig(
cache: options.cache,
});
if (externalPluginIds.length > 0) {
const missingChannelIdSet = new Set(missingConfiguredChannelIds);
const externalPluginIdSet = new Set(externalPluginIds);
addManifestChannelPlugins(byId, externalManifestRecords, {
pluginIds: externalPluginIdSet,
channelIds: missingConfiguredChannelIds,
});
const setupMissingChannelIds = missingConfiguredChannelIds.filter(
(channelId) => !byId.has(channelId),
);
const missingChannelIdSet = new Set(setupMissingChannelIds);
const ownedChannelIdsByPluginId = new Map(
externalManifestRecords
.filter((record) => externalPluginIdSet.has(record.id))
@@ -402,22 +533,24 @@ export function resolveReadOnlyChannelPluginsForConfig(
[pluginId, channelIds.filter((channelId) => missingChannelIdSet.has(channelId))] as const,
),
);
const registry = loadOpenClawPlugins({
config: cfg,
activationSourceConfig: options.activationSourceConfig ?? cfg,
env,
workspaceDir,
cache: false,
activate: false,
includeSetupOnlyChannelPlugins: true,
forceSetupOnlyChannelPlugins: true,
requireSetupEntryForSetupOnlyChannelPlugins: true,
onlyPluginIds: externalPluginIds,
});
addSetupChannelPlugins(byId, registry.channelSetups, {
ownedChannelIdsByPluginId,
ownedMissingChannelIdsByPluginId,
});
if (setupMissingChannelIds.length > 0) {
const registry = loadOpenClawPlugins({
config: cfg,
activationSourceConfig: options.activationSourceConfig ?? cfg,
env,
workspaceDir,
cache: false,
activate: false,
includeSetupOnlyChannelPlugins: true,
forceSetupOnlyChannelPlugins: true,
requireSetupEntryForSetupOnlyChannelPlugins: true,
onlyPluginIds: externalPluginIds,
});
addSetupChannelPlugins(byId, registry.channelSetups, {
ownedChannelIdsByPluginId,
ownedMissingChannelIdsByPluginId,
});
}
}
const plugins = [...byId.values()];