diff --git a/CHANGELOG.md b/CHANGELOG.md index 66f32901f36..a34e5ff14bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Providers/Z.AI: map OpenClaw thinking controls to Z.AI's `thinking` payload and add opt-in preserved thinking replay via `params.preserveThinking`, so GLM 5.x can keep prior `reasoning_content` when requested. Fixes #58680. Thanks @xuanmingguo. +- Channels/status: keep read-only channel lists on manifest and package metadata by default, loading setup runtime only for explicit fallback callers. Thanks @shakkernerd. - Plugins/registry: resolve web provider ownership from the installed plugin index instead of broad manifest scans on secret, tool, and pricing paths. Thanks @shakkernerd. - TTS: strip model-emitted TTS directives from streamed block text before channel delivery, including directives split across adjacent blocks, while preserving diff --git a/src/channels/plugins/read-only.test.ts b/src/channels/plugins/read-only.test.ts index 0a1cf2131ce..f1404fe8a2f 100644 --- a/src/channels/plugins/read-only.test.ts +++ b/src/channels/plugins/read-only.test.ts @@ -838,6 +838,31 @@ describe("listReadOnlyChannelPluginsForConfig", () => { }, ); + const plugin = plugins.find((entry) => entry.id === channelId); + expect(plugin?.meta.blurb).toBe("bundled setup entry"); + expect(fs.existsSync(setupMarker)).toBe(false); + expect(fs.existsSync(fullMarker)).toBe(false); + }); + + it("loads bundled setup runtime only when explicitly requested", () => { + const { channelId, envVar, fullMarker, pluginId, setupMarker } = + writeBundledSetupChannelPlugin(); + const plugins = listReadOnlyChannelPluginsForConfig( + { + plugins: { + allow: [pluginId], + entries: { + [pluginId]: { enabled: true }, + }, + }, + } as never, + { + env: { ...process.env, [envVar]: "configured" }, + includePersistedAuthState: false, + includeSetupRuntimeFallback: true, + }, + ); + const plugin = plugins.find((entry) => entry.id === channelId); expect(plugin?.meta.blurb).toBe("bundled setup entry"); expect(fs.existsSync(setupMarker)).toBe(true); diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index 4cfc5e47c37..eedfce28a74 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -188,21 +188,36 @@ function buildManifestChannelPlugin(params: { if (!isSafeManifestChannelId(params.channelId)) { return undefined; } + const catalogMeta = + params.record.channelCatalogMeta?.id === params.channelId + ? params.record.channelCatalogMeta + : undefined; const channelConfigValue = params.record.channelConfigs ? readOwnRecordValue(params.record.channelConfigs as Record, params.channelId) : undefined; if ( - !channelConfigValue || - typeof channelConfigValue !== "object" || - Array.isArray(channelConfigValue) + !catalogMeta && + (!channelConfigValue || + typeof channelConfigValue !== "object" || + Array.isArray(channelConfigValue)) ) { return undefined; } - const channelConfig = channelConfigValue as ManifestChannelConfigRecord; + const channelConfig = + channelConfigValue && + typeof channelConfigValue === "object" && + !Array.isArray(channelConfigValue) + ? (channelConfigValue as ManifestChannelConfigRecord) + : undefined; const label = - normalizeManifestText(channelConfig.label, params.record.name || params.channelId) || - params.channelId; - const blurb = normalizeManifestText(channelConfig.description, params.record.description || ""); + normalizeManifestText( + channelConfig?.label ?? catalogMeta?.label, + params.record.name || params.channelId, + ) || params.channelId; + const blurb = normalizeManifestText( + channelConfig?.description ?? catalogMeta?.blurb, + params.record.description || "", + ); return { id: params.channelId, meta: { @@ -211,14 +226,22 @@ function buildManifestChannelPlugin(params: { selectionLabel: label, docsPath: `/channels/${encodeURIComponent(params.channelId)}`, blurb, - ...(channelConfig.preferOver?.length ? { preferOver: channelConfig.preferOver } : {}), + ...(channelConfig?.preferOver?.length + ? { preferOver: channelConfig.preferOver } + : catalogMeta?.preferOver?.length + ? { preferOver: catalogMeta.preferOver } + : {}), }, capabilities: { chatTypes: ["direct"] }, - configSchema: { - schema: channelConfig.schema, - ...(channelConfig.uiHints ? { uiHints: channelConfig.uiHints } : {}), - ...(channelConfig.runtime ? { runtime: channelConfig.runtime } : {}), - }, + ...(channelConfig + ? { + 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, @@ -245,8 +268,14 @@ function buildManifestChannelPlugin(params: { }; } -function canUseManifestChannelPlugin(record: PluginManifestRecord): boolean { - return record.setup?.requiresRuntime === false || !record.setupSource; +function canUseManifestChannelPlugin(record: PluginManifestRecord, channelId: string): boolean { + const hasChannelConfig = Boolean( + record.channelConfigs && Object.prototype.hasOwnProperty.call(record.channelConfigs, channelId), + ); + if (hasChannelConfig) { + return record.setup?.requiresRuntime === false || !record.setupSource; + } + return record.channelCatalogMeta?.id === channelId; } function rebindChannelPluginConfig( @@ -439,9 +468,6 @@ function addManifestChannelPlugins( if (!options.pluginIds.has(record.id)) { continue; } - if (!canUseManifestChannelPlugin(record)) { - continue; - } for (const channelId of record.channels) { if (!isSafeManifestChannelId(channelId)) { continue; @@ -449,6 +475,9 @@ function addManifestChannelPlugins( if (!channelIds.has(channelId)) { continue; } + if (!canUseManifestChannelPlugin(record, channelId)) { + continue; + } addChannelPlugins(byId, [buildManifestChannelPlugin({ record, channelId })], { onlyIds: channelIds, allowOverwrite: false, @@ -470,6 +499,23 @@ function listExternalChannelManifestRecords( return records.filter((plugin) => plugin.origin !== "bundled" && plugin.channels.length > 0); } +function listBundledChannelManifestRecords( + records: readonly PluginManifestRecord[], +): PluginManifestRecord[] { + return records.filter((plugin) => plugin.origin === "bundled" && plugin.channels.length > 0); +} + +function listPluginIdsForChannels( + records: readonly PluginManifestRecord[], + channelIds: readonly string[], +): string[] { + const requestedChannelIds = new Set(channelIds); + return records + .filter((plugin) => plugin.channels.some((channelId) => requestedChannelIds.has(channelId))) + .map((plugin) => plugin.id) + .toSorted((left, right) => left.localeCompare(right)); +} + function resolveExternalReadOnlyChannelPluginIds(params: { cfg: OpenClawConfig; activationSourceConfig?: OpenClawConfig; @@ -527,6 +573,7 @@ export function resolveReadOnlyChannelPluginsForConfig( cache: options.cache, includeDisabled: true, }).plugins; + const bundledManifestRecords = listBundledChannelManifestRecords(manifestRecords); const externalManifestRecords = listExternalChannelManifestRecords(manifestRecords); const configuredChannelIds = [ ...new Set( @@ -545,13 +592,25 @@ export function resolveReadOnlyChannelPluginsForConfig( addChannelPlugins(byId, listChannelPlugins()); - for (const channelId of configuredChannelIds) { - if (byId.has(channelId)) { - continue; + if (options.includeSetupRuntimeFallback === true) { + for (const channelId of configuredChannelIds) { + if (byId.has(channelId)) { + continue; + } + addChannelPlugins(byId, [getBundledChannelSetupPlugin(channelId)]); } - addChannelPlugins(byId, [getBundledChannelSetupPlugin(channelId)]); } + const bundledManifestMissingChannelIds = configuredChannelIds.filter( + (channelId) => !byId.has(channelId), + ); + addManifestChannelPlugins(byId, bundledManifestRecords, { + pluginIds: new Set( + listPluginIdsForChannels(bundledManifestRecords, bundledManifestMissingChannelIds), + ), + channelIds: bundledManifestMissingChannelIds, + }); + const missingConfiguredChannelIds = configuredChannelIds.filter( (channelId) => !byId.has(channelId), ); @@ -566,27 +625,22 @@ export function resolveReadOnlyChannelPluginsForConfig( }); if (externalPluginIds.length > 0) { 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)) .map((record) => [record.id, record.channels] as const), ); - const ownedMissingChannelIdsByPluginId = new Map( - [...ownedChannelIdsByPluginId].map( - ([pluginId, channelIds]) => - [pluginId, channelIds.filter((channelId) => missingChannelIdSet.has(channelId))] as const, - ), - ); - if (setupMissingChannelIds.length > 0 && options.includeSetupRuntimeFallback === true) { + if (missingConfiguredChannelIds.length > 0 && options.includeSetupRuntimeFallback === true) { + const missingChannelIdSet = new Set(missingConfiguredChannelIds); + const ownedMissingChannelIdsByPluginId = new Map( + [...ownedChannelIdsByPluginId].map( + ([pluginId, channelIds]) => + [ + pluginId, + channelIds.filter((channelId) => missingChannelIdSet.has(channelId)), + ] as const, + ), + ); const registry = loadOpenClawPlugins({ config: cfg, activationSourceConfig: options.activationSourceConfig ?? cfg, @@ -604,6 +658,13 @@ export function resolveReadOnlyChannelPluginsForConfig( ownedMissingChannelIdsByPluginId, }); } + const externalManifestMissingChannelIds = missingConfiguredChannelIds.filter( + (channelId) => !byId.has(channelId), + ); + addManifestChannelPlugins(byId, externalManifestRecords, { + pluginIds: externalPluginIdSet, + channelIds: externalManifestMissingChannelIds, + }); } const plugins = [...byId.values()]; diff --git a/src/plugins/installed-plugin-index-store.ts b/src/plugins/installed-plugin-index-store.ts index 1fc6814a4b8..a5d23b3467f 100644 --- a/src/plugins/installed-plugin-index-store.ts +++ b/src/plugins/installed-plugin-index-store.ts @@ -53,6 +53,7 @@ const InstalledPluginIndexRecordSchema = z installRecord: z.record(z.string(), z.unknown()).optional(), installRecordHash: z.string().optional(), packageInstall: z.unknown().optional(), + packageChannel: z.unknown().optional(), manifestPath: z.string(), manifestHash: z.string(), format: z.string().optional(), diff --git a/src/plugins/installed-plugin-index.test.ts b/src/plugins/installed-plugin-index.test.ts index f7ae5c0ea8a..d8a13557979 100644 --- a/src/plugins/installed-plugin-index.test.ts +++ b/src/plugins/installed-plugin-index.test.ts @@ -138,6 +138,12 @@ function createRichPluginFixture(params: { packageVersion?: string } = {}) { packageName: "@vendor/demo-plugin", packageVersion: params.packageVersion ?? "1.2.3", packageManifest: { + channel: { + id: "demo", + label: "Demo", + blurb: "Demo channel", + preferOver: ["legacy-demo"], + }, install: { npmSpec: "@vendor/demo-plugin@1.2.3", expectedIntegrity: "sha512-demo", @@ -184,6 +190,12 @@ describe("installed plugin index", () => { }, warnings: [], }, + packageChannel: { + id: "demo", + label: "Demo", + blurb: "Demo channel", + preferOver: ["legacy-demo"], + }, compat: [ "activation-channel-hint", "activation-provider-hint", diff --git a/src/plugins/installed-plugin-index.ts b/src/plugins/installed-plugin-index.ts index 5784d7cb673..5106b30a2e4 100644 --- a/src/plugins/installed-plugin-index.ts +++ b/src/plugins/installed-plugin-index.ts @@ -17,6 +17,7 @@ import { type PluginManifestRegistry, } from "./manifest-registry.js"; import type { PluginDiagnostic } from "./manifest-types.js"; +import type { PluginPackageChannel } from "./manifest.js"; import { safeRealpathSync } from "./path-safety.js"; import { hasKind } from "./slots.js"; @@ -66,6 +67,11 @@ export type InstalledPluginInstallRecordInfo = Pick< | "marketplacePlugin" >; +export type InstalledPluginPackageChannelInfo = Pick< + PluginPackageChannel, + "id" | "label" | "blurb" | "preferOver" +>; + export type InstalledPluginIndexRecord = { pluginId: string; packageName?: string; @@ -82,6 +88,7 @@ export type InstalledPluginIndexRecord = { * install intent and must not be treated as the durable install record. */ packageInstall?: PluginInstallSourceInfo; + packageChannel?: InstalledPluginPackageChannelInfo; manifestPath: string; manifestHash: string; format?: PluginManifestRecord["format"]; @@ -279,6 +286,45 @@ function describePackageInstallSource( }); } +function normalizeStringField(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim(); + return normalized ? normalized : undefined; +} + +function normalizeStringListField(value: unknown): readonly string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const normalized = value + .flatMap((entry) => { + const normalizedEntry = normalizeStringField(entry); + return normalizedEntry ? [normalizedEntry] : []; + }) + .filter((entry, index, all) => all.indexOf(entry) === index); + return normalized.length > 0 ? normalized : undefined; +} + +function normalizePackageChannel( + channel: PluginPackageChannel | undefined, +): InstalledPluginPackageChannelInfo | undefined { + const id = normalizeStringField(channel?.id); + if (!id) { + return undefined; + } + const label = normalizeStringField(channel?.label); + const blurb = normalizeStringField(channel?.blurb); + const preferOver = normalizeStringListField(channel?.preferOver); + return { + id, + ...(label ? { label } : {}), + ...(blurb ? { blurb } : {}), + ...(preferOver ? { preferOver } : {}), + }; +} + function setInstallStringField>( target: InstalledPluginInstallRecordInfo, key: Key, @@ -491,6 +537,7 @@ function buildInstalledPluginIndex( const packageJsonPath = resolvePackageJsonPath(candidate); const installRecord = installRecords[record.id]; const packageInstall = describePackageInstallSource(candidate); + const packageChannel = normalizePackageChannel(candidate?.packageManifest?.channel); const manifestHash = safeHashFile({ filePath: record.manifestPath, @@ -546,6 +593,9 @@ function buildInstalledPluginIndex( if (packageInstall) { indexRecord.packageInstall = packageInstall; } + if (packageChannel) { + indexRecord.packageChannel = packageChannel; + } if (packageJson) { indexRecord.packageJson = packageJson; } diff --git a/src/plugins/manifest-registry-installed.ts b/src/plugins/manifest-registry-installed.ts index 90df5f8afc6..ba825739609 100644 --- a/src/plugins/manifest-registry-installed.ts +++ b/src/plugins/manifest-registry-installed.ts @@ -34,6 +34,7 @@ function toPluginCandidate(record: InstalledPluginIndexRecord): PluginCandidate ...(record.bundleFormat ? { bundleFormat: record.bundleFormat } : {}), ...(record.packageName ? { packageName: record.packageName } : {}), ...(record.packageVersion ? { packageVersion: record.packageVersion } : {}), + ...(record.packageChannel ? { packageManifest: { channel: record.packageChannel } } : {}), packageDir: rootDir, }; }