fix: scope read-only channel ids

This commit is contained in:
Gustavo Madeira Santana
2026-04-20 22:49:58 -04:00
parent 6ba20a0f7c
commit 89d8986e5d
4 changed files with 125 additions and 12 deletions

View File

@@ -187,6 +187,35 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
expect(fs.existsSync(fullMarker)).toBe(false);
});
it("matches setup-only plugins by manifest-owned channel ids when plugin id differs", () => {
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({
pluginId: "external-chat-plugin",
channelId: "external-chat",
setupChannelId: "external-chat-plugin",
});
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.id).toBe("external-chat");
expect(plugin?.meta.blurb).toBe("setup entry");
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,

View File

@@ -67,6 +67,58 @@ function addChannelPlugins(
}
}
function cloneChannelPluginForChannelId(plugin: ChannelPlugin, channelId: string): ChannelPlugin {
if (plugin.id === channelId && plugin.meta.id === channelId) {
return plugin;
}
return {
...plugin,
id: channelId,
meta: {
...plugin.meta,
id: channelId,
},
};
}
function addSetupChannelPlugins(
byId: Map<string, ChannelPlugin>,
setups: Iterable<{
pluginId: string;
plugin: ChannelPlugin;
}>,
options: {
ownedMissingChannelIdsByPluginId: ReadonlyMap<string, readonly string[]>;
},
): void {
for (const setup of setups) {
const ownedMissingChannelIds = options.ownedMissingChannelIdsByPluginId.get(setup.pluginId);
if (!ownedMissingChannelIds || ownedMissingChannelIds.length === 0) {
continue;
}
if (ownedMissingChannelIds.includes(setup.plugin.id)) {
addChannelPlugins(byId, [setup.plugin], {
onlyIds: new Set(ownedMissingChannelIds),
allowOverwrite: false,
});
continue;
}
if (setup.plugin.id !== setup.pluginId) {
continue;
}
addChannelPlugins(
byId,
ownedMissingChannelIds.map((channelId) =>
cloneChannelPluginForChannelId(setup.plugin, channelId),
),
{
onlyIds: new Set(ownedMissingChannelIds),
allowOverwrite: false,
},
);
}
}
function resolveReadOnlyWorkspaceDir(
cfg: OpenClawConfig,
options: ReadOnlyChannelPluginOptions,
@@ -189,6 +241,18 @@ export function resolveReadOnlyChannelPluginsForConfig(
cache: options.cache,
});
if (externalPluginIds.length > 0) {
const missingChannelIdSet = new Set(missingConfiguredChannelIds);
const ownedMissingChannelIdsByPluginId = new Map(
externalManifestRecords
.filter((record) => externalPluginIds.includes(record.id))
.map(
(record) =>
[
record.id,
record.channels.filter((channelId) => missingChannelIdSet.has(channelId)),
] as const,
),
);
const registry = loadOpenClawPlugins({
config: cfg,
activationSourceConfig: options.activationSourceConfig ?? cfg,
@@ -201,14 +265,9 @@ export function resolveReadOnlyChannelPluginsForConfig(
requireSetupEntryForSetupOnlyChannelPlugins: true,
onlyPluginIds: externalPluginIds,
});
addChannelPlugins(
byId,
registry.channelSetups.map((setup) => setup.plugin),
{
onlyIds: new Set(missingConfiguredChannelIds),
allowOverwrite: false,
},
);
addSetupChannelPlugins(byId, registry.channelSetups, {
ownedMissingChannelIdsByPluginId,
});
}
const plugins = [...byId.values()];

View File

@@ -74,6 +74,24 @@ describe("command secret targets module import", () => {
],
},
},
{
id: "external-chat-plugin",
secrets: {
secretTargetRegistryEntries: [
{
id: "channels.external-chat.token",
targetType: "channels.external-chat.token",
configFile: "openclaw.json",
pathPattern: "channels.external-chat.token",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
],
},
},
]);
vi.doMock("../secrets/target-registry.js", () => ({
@@ -86,9 +104,13 @@ describe("command secret targets module import", () => {
const mod = await import("./command-secret-targets.js");
const targets = mod.getStatusCommandSecretTargetIds({
channels: { telegram: { botToken: "123456:ABCDEF" } },
channels: {
"external-chat": { token: "configured" },
telegram: { botToken: "123456:ABCDEF" },
},
});
expect(targets.has("channels.external-chat.token")).toBe(true);
expect(targets.has("channels.telegram.botToken")).toBe(true);
expect(targets.has("channels.telegram.gatewayToken")).toBe(false);
expect(targets.has("channels.telegram.gatewayTokenRef")).toBe(false);

View File

@@ -75,7 +75,6 @@ function getChannelSecretTargetIds(): string[] {
}
function isScopedChannelSecretTargetEntry(params: {
pluginId: string;
entry: {
id: string;
configFile?: string;
@@ -83,7 +82,11 @@ function isScopedChannelSecretTargetEntry(params: {
refPathPattern?: string;
};
}): boolean {
const allowedPrefix = `channels.${params.pluginId}.`;
const channelId = /^channels\.([^.]+)\./.exec(params.entry.id)?.[1];
if (!channelId) {
return false;
}
const allowedPrefix = `channels.${channelId}.`;
return (
params.entry.id.startsWith(allowedPrefix) &&
params.entry.configFile === "openclaw.json" &&
@@ -104,7 +107,7 @@ function getConfiguredChannelSecretTargetIds(
includePersistedAuthState: false,
})) {
for (const entry of plugin.secrets?.secretTargetRegistryEntries ?? []) {
if (isScopedChannelSecretTargetEntry({ pluginId: plugin.id, entry })) {
if (isScopedChannelSecretTargetEntry({ entry })) {
targetIds.add(entry.id);
}
}