mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:31:06 +00:00
feat(channels): use manifest configs for read-only discovery
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()];
|
||||
|
||||
Reference in New Issue
Block a user