fix: keep read-only channels off setup runtime

This commit is contained in:
Shakker
2026-04-26 04:36:06 +01:00
parent 2e7635f4f9
commit 34bd66d929
7 changed files with 189 additions and 38 deletions

View File

@@ -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

View File

@@ -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);

View File

@@ -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<string, unknown>, 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()];

View File

@@ -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(),

View File

@@ -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",

View File

@@ -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<Key extends keyof Omit<InstalledPluginInstallRecordInfo, "source">>(
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;
}

View File

@@ -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,
};
}