fix: preserve external read-only channel metadata

This commit is contained in:
Gustavo Madeira Santana
2026-04-20 19:04:32 -04:00
parent c7365fd583
commit fd2d20a8ee
5 changed files with 404 additions and 76 deletions

View File

@@ -0,0 +1,204 @@
import fs from "node:fs";
import path from "node:path";
import { afterAll, afterEach, describe, expect, it } from "vitest";
import {
cleanupPluginLoaderFixturesForTest,
EMPTY_PLUGIN_SCHEMA,
makeTempDir,
resetPluginLoaderTestStateForTest,
useNoBundledPlugins,
} from "../../plugins/loader.test-fixtures.js";
import { listReadOnlyChannelPluginsForConfig } from "./read-only.js";
function writeExternalSetupChannelPlugin(options: { setupEntry?: boolean } = {}) {
useNoBundledPlugins();
const pluginDir = makeTempDir();
const fullMarker = path.join(pluginDir, "full-loaded.txt");
const setupMarker = path.join(pluginDir, "setup-loaded.txt");
const setupEntry = options.setupEntry !== false;
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify(
{
name: "@example/openclaw-external-chat",
version: "1.0.0",
openclaw: {
extensions: ["./index.cjs"],
...(setupEntry ? { setupEntry: "./setup-entry.cjs" } : {}),
},
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "external-chat",
configSchema: EMPTY_PLUGIN_SCHEMA,
channels: ["external-chat"],
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "index.cjs"),
`require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8");
module.exports = {
id: "external-chat",
register(api) {
api.registerChannel({
plugin: {
id: "external-chat",
meta: {
id: "external-chat",
label: "External Chat",
selectionLabel: "External Chat",
docsPath: "/channels/external-chat",
blurb: "full entry",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({ accountId: "default", token: "configured" }),
},
outbound: { deliveryMode: "direct" },
secrets: {
secretTargetRegistryEntries: [
{
id: "channels.external-chat.token",
targetType: "channel",
configFile: "openclaw.json",
pathPattern: "channels.external-chat.token",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
],
},
},
});
},
};`,
"utf-8",
);
if (setupEntry) {
fs.writeFileSync(
path.join(pluginDir, "setup-entry.cjs"),
`require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8");
module.exports = {
plugin: {
id: "external-chat",
meta: {
id: "external-chat",
label: "External Chat",
selectionLabel: "External Chat",
docsPath: "/channels/external-chat",
blurb: "setup entry",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({ accountId: "default", token: "configured" }),
},
outbound: { deliveryMode: "direct" },
secrets: {
secretTargetRegistryEntries: [
{
id: "channels.external-chat.token",
targetType: "channel",
configFile: "openclaw.json",
pathPattern: "channels.external-chat.token",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
],
},
},
};`,
"utf-8",
);
}
return { pluginDir, fullMarker, setupMarker };
}
afterEach(() => {
resetPluginLoaderTestStateForTest();
});
afterAll(() => {
cleanupPluginLoaderFixturesForTest();
});
describe("listReadOnlyChannelPluginsForConfig", () => {
it("loads configured external channel setup metadata without importing full runtime", () => {
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin();
const plugins = listReadOnlyChannelPluginsForConfig(
{
channels: {
"external-chat": { token: "configured" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat"],
},
} as never,
{
env: { ...process.env },
includePersistedAuthState: false,
},
);
const plugin = plugins.find((entry) => entry.id === "external-chat");
expect(plugin?.meta.blurb).toBe("setup entry");
expect(
plugin?.secrets?.secretTargetRegistryEntries?.some(
(entry) => entry.id === "channels.external-chat.token",
),
).toBe(true);
expect(fs.existsSync(setupMarker)).toBe(true);
expect(fs.existsSync(fullMarker)).toBe(false);
});
it("keeps configured external channels visible when no setup entry exists", () => {
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({
setupEntry: false,
});
const plugins = listReadOnlyChannelPluginsForConfig(
{
channels: {
"external-chat": { token: "configured" },
},
plugins: {
load: { paths: [pluginDir] },
allow: ["external-chat"],
},
} as never,
{
env: { ...process.env },
includePersistedAuthState: false,
},
);
const plugin = plugins.find((entry) => entry.id === "external-chat");
expect(plugin?.meta.blurb).toBe("full entry");
expect(
plugin?.secrets?.secretTargetRegistryEntries?.some(
(entry) => entry.id === "channels.external-chat.token",
),
).toBe(true);
expect(fs.existsSync(setupMarker)).toBe(false);
expect(fs.existsSync(fullMarker)).toBe(true);
});
});

View File

@@ -1,27 +1,145 @@
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resolveDiscoverableScopedChannelPluginIds } from "../../plugins/channel-plugin-ids.js";
import { loadOpenClawPlugins } from "../../plugins/loader.js";
import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js";
import { listPotentialConfiguredChannelIds } from "../config-presence.js";
import { getBundledChannelSetupPlugin } from "./bundled.js";
import { listChannelPlugins } from "./registry.js";
import type { ChannelPlugin } from "./types.plugin.js";
export function listReadOnlyChannelPluginsForConfig(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): ChannelPlugin[] {
const byId = new Map<string, ChannelPlugin>();
type ReadOnlyChannelPluginOptions = {
env?: NodeJS.ProcessEnv;
workspaceDir?: string;
activationSourceConfig?: OpenClawConfig;
includePersistedAuthState?: boolean;
cache?: boolean;
};
for (const plugin of listChannelPlugins()) {
byId.set(plugin.id, plugin);
function resolveReadOnlyChannelPluginOptions(
envOrOptions?: NodeJS.ProcessEnv | ReadOnlyChannelPluginOptions,
): ReadOnlyChannelPluginOptions {
if (!envOrOptions) {
return {};
}
if (
"env" in envOrOptions ||
"workspaceDir" in envOrOptions ||
"activationSourceConfig" in envOrOptions ||
"includePersistedAuthState" in envOrOptions ||
"cache" in envOrOptions
) {
return envOrOptions as ReadOnlyChannelPluginOptions;
}
return { env: envOrOptions as NodeJS.ProcessEnv };
}
function addChannelPlugins(
byId: Map<string, ChannelPlugin>,
plugins: Iterable<ChannelPlugin | undefined>,
): void {
for (const plugin of plugins) {
if (plugin) {
byId.set(plugin.id, plugin);
}
}
}
function resolveExternalReadOnlyChannelPluginIds(params: {
cfg: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
channelIds: readonly string[];
workspaceDir?: string;
env: NodeJS.ProcessEnv;
cache?: boolean;
}): string[] {
if (params.channelIds.length === 0) {
return [];
}
const candidatePluginIds = resolveDiscoverableScopedChannelPluginIds({
config: params.cfg,
activationSourceConfig: params.activationSourceConfig,
channelIds: params.channelIds,
workspaceDir: params.workspaceDir,
env: params.env,
cache: params.cache,
});
if (candidatePluginIds.length === 0) {
return [];
}
for (const channelId of listPotentialConfiguredChannelIds(cfg, env)) {
const requestedChannelIds = new Set(params.channelIds);
const candidatePluginIdSet = new Set(candidatePluginIds);
return loadPluginManifestRegistry({
config: params.cfg,
workspaceDir: params.workspaceDir,
env: params.env,
cache: params.cache,
})
.plugins.filter(
(plugin) =>
candidatePluginIdSet.has(plugin.id) &&
plugin.origin !== "bundled" &&
plugin.channels.some((channelId) => requestedChannelIds.has(channelId)),
)
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right));
}
export function listReadOnlyChannelPluginsForConfig(
cfg: OpenClawConfig,
env?: NodeJS.ProcessEnv,
): ChannelPlugin[];
export function listReadOnlyChannelPluginsForConfig(
cfg: OpenClawConfig,
options?: ReadOnlyChannelPluginOptions,
): ChannelPlugin[];
export function listReadOnlyChannelPluginsForConfig(
cfg: OpenClawConfig,
envOrOptions?: NodeJS.ProcessEnv | ReadOnlyChannelPluginOptions,
): ChannelPlugin[] {
const options = resolveReadOnlyChannelPluginOptions(envOrOptions);
const env = options.env ?? process.env;
const configuredChannelIds = listPotentialConfiguredChannelIds(cfg, env, {
includePersistedAuthState: options.includePersistedAuthState,
});
const byId = new Map<string, ChannelPlugin>();
addChannelPlugins(byId, listChannelPlugins());
for (const channelId of configuredChannelIds) {
if (byId.has(channelId)) {
continue;
}
const setupPlugin = getBundledChannelSetupPlugin(channelId);
if (setupPlugin) {
byId.set(setupPlugin.id, setupPlugin);
}
addChannelPlugins(byId, [getBundledChannelSetupPlugin(channelId)]);
}
const missingConfiguredChannelIds = configuredChannelIds.filter(
(channelId) => !byId.has(channelId),
);
const externalPluginIds = resolveExternalReadOnlyChannelPluginIds({
cfg,
activationSourceConfig: options.activationSourceConfig ?? cfg,
channelIds: missingConfiguredChannelIds,
workspaceDir: options.workspaceDir,
env,
cache: options.cache,
});
if (externalPluginIds.length > 0) {
const registry = loadOpenClawPlugins({
config: cfg,
activationSourceConfig: options.activationSourceConfig ?? cfg,
env,
workspaceDir: options.workspaceDir,
cache: false,
activate: false,
includeSetupOnlyChannelPlugins: true,
forceSetupOnlyChannelPlugins: true,
onlyPluginIds: externalPluginIds,
});
addChannelPlugins(
byId,
registry.channelSetups.map((setup) => setup.plugin),
);
}
return [...byId.values()];

View File

@@ -32,32 +32,33 @@ describe("command secret targets module import", () => {
const listSecretTargetRegistryEntries = vi.fn(() => {
throw new Error("registry touched too early");
});
const loadBundledChannelSecretContractApi = vi.fn((channelId: string) =>
channelId === "telegram"
? {
secretTargetRegistryEntries: [
{
id: "channels.telegram.botToken",
targetType: "channels.telegram.botToken",
configFile: "openclaw.json",
pathPattern: "channels.telegram.botToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
],
}
: undefined,
);
const listReadOnlyChannelPluginsForConfig = vi.fn(() => [
{
id: "telegram",
secrets: {
secretTargetRegistryEntries: [
{
id: "channels.telegram.botToken",
targetType: "channels.telegram.botToken",
configFile: "openclaw.json",
pathPattern: "channels.telegram.botToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
],
},
},
]);
vi.doMock("../secrets/target-registry.js", () => ({
discoverConfigSecretTargetsByIds: vi.fn(() => []),
listSecretTargetRegistryEntries,
}));
vi.doMock("../secrets/channel-contract-api.js", () => ({
loadBundledChannelSecretContractApi,
vi.doMock("../channels/plugins/read-only.js", () => ({
listReadOnlyChannelPluginsForConfig,
}));
const mod = await import("./command-secret-targets.js");
@@ -67,7 +68,10 @@ describe("command secret targets module import", () => {
expect(targets.has("channels.telegram.botToken")).toBe(true);
expect(targets.has("agents.defaults.memorySearch.remote.apiKey")).toBe(true);
expect(loadBundledChannelSecretContractApi).toHaveBeenCalledWith("telegram");
expect(listReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ includePersistedAuthState: false }),
);
expect(listSecretTargetRegistryEntries).not.toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,6 @@
import { listPotentialConfiguredChannelIds } from "../channels/config-presence.js";
import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeOptionalAccountId } from "../routing/session-key.js";
import { loadBundledChannelSecretContractApi } from "../secrets/channel-contract-api.js";
import {
discoverConfigSecretTargetsByIds,
listSecretTargetRegistryEntries,
@@ -69,37 +68,28 @@ type CommandSecretTargets = {
let cachedCommandSecretTargets: CommandSecretTargets | undefined;
let cachedChannelSecretTargetIds: string[] | undefined;
const cachedBundledChannelSecretTargetIds = new Map<string, string[] | null>();
function getChannelSecretTargetIds(): string[] {
cachedChannelSecretTargetIds ??= idsByPrefix(["channels."]);
return cachedChannelSecretTargetIds;
}
function getBundledChannelSecretTargetIds(channelId: string): string[] {
const normalizedChannelId = channelId.trim();
if (!normalizedChannelId) {
return [];
}
if (cachedBundledChannelSecretTargetIds.has(normalizedChannelId)) {
return cachedBundledChannelSecretTargetIds.get(normalizedChannelId) ?? [];
}
const targetIds =
loadBundledChannelSecretContractApi(normalizedChannelId)
?.secretTargetRegistryEntries?.map((entry) => entry.id)
.filter((id) => id.startsWith(`channels.${normalizedChannelId}.`))
.toSorted() ?? null;
cachedBundledChannelSecretTargetIds.set(normalizedChannelId, targetIds);
return targetIds ?? [];
}
function getConfiguredChannelSecretTargetIds(
config: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): string[] {
return listPotentialConfiguredChannelIds(config, env, { includePersistedAuthState: false })
.toSorted()
.flatMap((channelId) => getBundledChannelSecretTargetIds(channelId));
const targetIds = new Set<string>();
for (const plugin of listReadOnlyChannelPluginsForConfig(config, {
env,
includePersistedAuthState: false,
})) {
for (const entry of plugin.secrets?.secretTargetRegistryEntries ?? []) {
if (entry.id.startsWith(`channels.${plugin.id}.`)) {
targetIds.add(entry.id);
}
}
}
return [...targetIds].toSorted((left, right) => left.localeCompare(right));
}
function buildCommandSecretTargets(): CommandSecretTargets {

View File

@@ -134,6 +134,7 @@ export type PluginLoadOptions = {
mode?: "full" | "validate";
onlyPluginIds?: string[];
includeSetupOnlyChannelPlugins?: boolean;
forceSetupOnlyChannelPlugins?: boolean;
/**
* Prefer `setupEntry` for configured channel plugins that explicitly opt in
* via package metadata because their setup entry covers the pre-listen startup surface.
@@ -505,6 +506,7 @@ function buildCacheKey(params: {
env: NodeJS.ProcessEnv;
onlyPluginIds?: string[];
includeSetupOnlyChannelPlugins?: boolean;
forceSetupOnlyChannelPlugins?: boolean;
preferSetupRuntimeForChannelPlugins?: boolean;
loadModules?: boolean;
runtimeSubagentMode?: "default" | "explicit" | "gateway-bindable";
@@ -534,6 +536,8 @@ function buildCacheKey(params: {
);
const scopeKey = serializePluginIdScope(params.onlyPluginIds);
const setupOnlyKey = params.includeSetupOnlyChannelPlugins === true ? "setup-only" : "runtime";
const setupOnlyModeKey =
params.forceSetupOnlyChannelPlugins === true ? "force-setup" : "normal-setup";
const startupChannelMode =
params.preferSetupRuntimeForChannelPlugins === true ? "prefer-setup" : "full";
const moduleLoadMode = params.loadModules === false ? "manifest-only" : "load-modules";
@@ -544,7 +548,7 @@ function buildCacheKey(params: {
installs,
loadPaths,
activationMetadataKey: params.activationMetadataKey ?? "",
})}::${scopeKey}::${setupOnlyKey}::${startupChannelMode}::${moduleLoadMode}::${runtimeSubagentMode}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`;
})}::${scopeKey}::${setupOnlyKey}::${setupOnlyModeKey}::${startupChannelMode}::${moduleLoadMode}::${runtimeSubagentMode}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`;
}
function matchesScopedPluginRequest(params: {
@@ -619,6 +623,7 @@ function hasExplicitCompatibilityInputs(options: PluginLoadOptions): boolean {
options.pluginSdkResolution !== undefined ||
options.coreGatewayHandlers !== undefined ||
options.includeSetupOnlyChannelPlugins === true ||
options.forceSetupOnlyChannelPlugins === true ||
options.preferSetupRuntimeForChannelPlugins === true ||
options.loadModules === false
);
@@ -634,6 +639,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
});
const onlyPluginIds = normalizePluginIdScope(options.onlyPluginIds);
const includeSetupOnlyChannelPlugins = options.includeSetupOnlyChannelPlugins === true;
const forceSetupOnlyChannelPlugins = options.forceSetupOnlyChannelPlugins === true;
const preferSetupRuntimeForChannelPlugins = options.preferSetupRuntimeForChannelPlugins === true;
const runtimeSubagentMode = resolveRuntimeSubagentMode(options.runtimeOptions);
const coreGatewayMethodNames = Object.keys(options.coreGatewayHandlers ?? {}).toSorted();
@@ -648,6 +654,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
env,
onlyPluginIds,
includeSetupOnlyChannelPlugins,
forceSetupOnlyChannelPlugins,
preferSetupRuntimeForChannelPlugins,
loadModules: options.loadModules,
runtimeSubagentMode,
@@ -663,6 +670,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
autoEnabledReasons: options.autoEnabledReasons ?? {},
onlyPluginIds,
includeSetupOnlyChannelPlugins,
forceSetupOnlyChannelPlugins,
preferSetupRuntimeForChannelPlugins,
shouldActivate: options.activate !== false,
shouldLoadModules: options.loadModules !== false,
@@ -1410,6 +1418,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
autoEnabledReasons,
onlyPluginIds,
includeSetupOnlyChannelPlugins,
forceSetupOnlyChannelPlugins,
preferSetupRuntimeForChannelPlugins,
shouldActivate,
shouldLoadModules,
@@ -1740,25 +1749,28 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
}
}
const registrationMode = enableState.enabled
? shouldLoadModules &&
!validateOnly &&
shouldLoadChannelPluginInSetupRuntime({
manifestChannels: manifestRecord.channels,
setupSource: manifestRecord.setupSource,
startupDeferConfiguredChannelFullLoadUntilAfterListen:
manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen,
cfg,
env,
preferSetupRuntimeForChannelPlugins,
})
? "setup-runtime"
: "full"
: includeSetupOnlyChannelPlugins &&
const canLoadScopedSetupOnlyChannelPlugin =
includeSetupOnlyChannelPlugins &&
!validateOnly &&
onlyPluginIdSet &&
manifestRecord.channels.length > 0 &&
(!enableState.enabled || forceSetupOnlyChannelPlugins);
const registrationMode = canLoadScopedSetupOnlyChannelPlugin
? "setup-only"
: enableState.enabled
? shouldLoadModules &&
!validateOnly &&
onlyPluginIdSet &&
manifestRecord.channels.length > 0
? "setup-only"
shouldLoadChannelPluginInSetupRuntime({
manifestChannels: manifestRecord.channels,
setupSource: manifestRecord.setupSource,
startupDeferConfiguredChannelFullLoadUntilAfterListen:
manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen,
cfg,
env,
preferSetupRuntimeForChannelPlugins,
})
? "setup-runtime"
: "full"
: null;
if (!registrationMode) {