From 1d6b2ff8b2d6240cbe9cb38d6a7bda85834a2055 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 20 Apr 2026 22:27:35 -0400 Subject: [PATCH] fix: keep read-only channel scope complete --- src/channels/plugins/read-only.ts | 21 ++++++++++++- src/commands/status-runtime-shared.test.ts | 35 ++++++++++++++++++++++ src/commands/status-runtime-shared.ts | 9 ++++-- src/plugins/channel-plugin-ids.test.ts | 24 +++++++++++++++ src/plugins/channel-plugin-ids.ts | 20 +++++++++++++ 5 files changed, 106 insertions(+), 3 deletions(-) diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index 338a63f9e06..2d5b3956c84 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -21,6 +21,12 @@ type ReadOnlyChannelPluginOptions = { cache?: boolean; }; +export type ReadOnlyChannelPluginResolution = { + plugins: ChannelPlugin[]; + configuredChannelIds: string[]; + missingConfiguredChannelIds: string[]; +}; + function resolveReadOnlyChannelPluginOptions( envOrOptions?: NodeJS.ProcessEnv | ReadOnlyChannelPluginOptions, ): ReadOnlyChannelPluginOptions { @@ -130,6 +136,13 @@ export function listReadOnlyChannelPluginsForConfig( cfg: OpenClawConfig, envOrOptions?: NodeJS.ProcessEnv | ReadOnlyChannelPluginOptions, ): ChannelPlugin[] { + return resolveReadOnlyChannelPluginsForConfig(cfg, envOrOptions).plugins; +} + +export function resolveReadOnlyChannelPluginsForConfig( + cfg: OpenClawConfig, + envOrOptions?: NodeJS.ProcessEnv | ReadOnlyChannelPluginOptions, +): ReadOnlyChannelPluginResolution { const options = resolveReadOnlyChannelPluginOptions(envOrOptions); const env = options.env ?? process.env; const workspaceDir = resolveReadOnlyWorkspaceDir(cfg, options); @@ -143,6 +156,7 @@ export function listReadOnlyChannelPluginsForConfig( ...new Set( listConfiguredChannelIdsForReadOnlyScope({ config: cfg, + activationSourceConfig: options.activationSourceConfig ?? cfg, workspaceDir, env, cache: options.cache, @@ -197,5 +211,10 @@ export function listReadOnlyChannelPluginsForConfig( ); } - return [...byId.values()]; + const plugins = [...byId.values()]; + return { + plugins, + configuredChannelIds, + missingConfiguredChannelIds: configuredChannelIds.filter((channelId) => !byId.has(channelId)), + }; } diff --git a/src/commands/status-runtime-shared.test.ts b/src/commands/status-runtime-shared.test.ts index 19aab5165f5..5415b2b72ab 100644 --- a/src/commands/status-runtime-shared.test.ts +++ b/src/commands/status-runtime-shared.test.ts @@ -16,6 +16,11 @@ const mocks = vi.hoisted(() => ({ callGateway: vi.fn(), getDaemonStatusSummary: vi.fn(), getNodeDaemonStatusSummary: vi.fn(), + resolveReadOnlyChannelPluginsForConfig: vi.fn(), +})); + +vi.mock("../channels/plugins/read-only.js", () => ({ + resolveReadOnlyChannelPluginsForConfig: mocks.resolveReadOnlyChannelPluginsForConfig, })); vi.mock("../infra/provider-usage.js", () => ({ @@ -43,6 +48,11 @@ describe("status-runtime-shared", () => { mocks.callGateway.mockResolvedValue({ ok: true }); mocks.getDaemonStatusSummary.mockResolvedValue({ label: "LaunchAgent" }); mocks.getNodeDaemonStatusSummary.mockResolvedValue({ label: "node" }); + mocks.resolveReadOnlyChannelPluginsForConfig.mockReturnValue({ + plugins: [{ id: "telegram" }], + configuredChannelIds: ["telegram"], + missingConfiguredChannelIds: [], + }); }); it("resolves the shared security audit payload", async () => { @@ -59,6 +69,31 @@ describe("status-runtime-shared", () => { includeChannelSecurity: true, plugins: expect.any(Array), }); + expect(mocks.resolveReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith( + { gateway: {} }, + { activationSourceConfig: { gateway: {} } }, + ); + }); + + it("lets the security audit load configured channel plugins when read-only discovery is incomplete", async () => { + mocks.resolveReadOnlyChannelPluginsForConfig.mockReturnValue({ + plugins: [], + configuredChannelIds: ["external"], + missingConfiguredChannelIds: ["external"], + }); + + await resolveStatusSecurityAudit({ + config: { gateway: {} }, + sourceConfig: { gateway: {} }, + }); + + expect(mocks.runSecurityAudit).toHaveBeenCalledWith({ + config: { gateway: {} }, + sourceConfig: { gateway: {} }, + deep: false, + includeFilesystem: true, + includeChannelSecurity: true, + }); }); it("resolves usage summaries with the provided timeout", async () => { diff --git a/src/commands/status-runtime-shared.ts b/src/commands/status-runtime-shared.ts index fabd50cea27..742260b0985 100644 --- a/src/commands/status-runtime-shared.ts +++ b/src/commands/status-runtime-shared.ts @@ -1,4 +1,4 @@ -import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js"; +import { resolveReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js"; import type { OpenClawConfig } from "../config/types.js"; import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js"; import type { HealthSummary } from "./health.js"; @@ -28,13 +28,18 @@ export async function resolveStatusSecurityAudit(params: { sourceConfig: OpenClawConfig; }) { const { runSecurityAudit } = await loadSecurityAuditModule(); + const readOnlyPlugins = resolveReadOnlyChannelPluginsForConfig(params.config, { + activationSourceConfig: params.sourceConfig, + }); return await runSecurityAudit({ config: params.config, sourceConfig: params.sourceConfig, deep: false, includeFilesystem: true, includeChannelSecurity: true, - plugins: listReadOnlyChannelPluginsForConfig(params.config), + ...(readOnlyPlugins.missingConfiguredChannelIds.length === 0 + ? { plugins: readOnlyPlugins.plugins } + : {}), }); } diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index 0f101b80941..5ed68b23edb 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -672,6 +672,30 @@ describe("listConfiguredChannelIdsForReadOnlyScope", () => { ).toEqual(["external-env-channel"]); }); + it("ignores manifest env vars from untrusted external plugins", () => { + expect( + listConfiguredChannelIdsForReadOnlyScope({ + config: {} as OpenClawConfig, + workspaceDir: "/tmp", + env: { + EXTERNAL_ENV_CHANNEL_TOKEN: "token", + } as NodeJS.ProcessEnv, + includePersistedAuthState: false, + }), + ).toEqual([]); + + expect( + hasConfiguredChannelsForReadOnlyScope({ + config: {} as OpenClawConfig, + workspaceDir: "/tmp", + env: { + EXTERNAL_ENV_CHANNEL_TOKEN: "token", + } as NodeJS.ProcessEnv, + includePersistedAuthState: false, + }), + ).toBe(false); + }); + it("ignores ambient or malformed manifest env vars as read-only configured channel triggers", () => { expect( listConfiguredChannelIdsForReadOnlyScope({ diff --git a/src/plugins/channel-plugin-ids.ts b/src/plugins/channel-plugin-ids.ts index d4ee7e5ebbd..0cd437e96bb 100644 --- a/src/plugins/channel-plugin-ids.ts +++ b/src/plugins/channel-plugin-ids.ts @@ -76,10 +76,23 @@ function hasNonEmptyEnvValue(env: NodeJS.ProcessEnv, key: string): boolean { function listEnvConfiguredManifestChannelIds(params: { records: readonly PluginManifestRecord[]; + config: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; env: NodeJS.ProcessEnv; }): string[] { const channelIds = new Set(); + const trustConfig = params.activationSourceConfig ?? params.config; + const normalizedConfig = normalizePluginsConfig(trustConfig.plugins); for (const record of params.records) { + if ( + !isChannelPluginEligibleForScopedOwnership({ + plugin: record, + normalizedConfig, + rootConfig: trustConfig, + }) + ) { + continue; + } for (const channelId of record.channels) { const envVars = record.channelEnvVars?.[channelId] ?? []; if (envVars.some((envVar) => hasNonEmptyEnvValue(params.env, envVar))) { @@ -92,6 +105,7 @@ function listEnvConfiguredManifestChannelIds(params: { export function listConfiguredChannelIdsForPluginScope(params: { config: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; workspaceDir?: string; env: NodeJS.ProcessEnv; cache?: boolean; @@ -113,6 +127,8 @@ export function listConfiguredChannelIdsForPluginScope(params: { }), ...listEnvConfiguredManifestChannelIds({ records, + config: params.config, + activationSourceConfig: params.activationSourceConfig, env: params.env, }), ]), @@ -121,6 +137,7 @@ export function listConfiguredChannelIdsForPluginScope(params: { export function listConfiguredChannelIdsForReadOnlyScope(params: { config: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; cache?: boolean; @@ -133,6 +150,7 @@ export function listConfiguredChannelIdsForReadOnlyScope(params: { resolveAgentWorkspaceDir(params.config, resolveDefaultAgentId(params.config)); return listConfiguredChannelIdsForPluginScope({ config: params.config, + activationSourceConfig: params.activationSourceConfig, workspaceDir, env, cache: params.cache, @@ -143,6 +161,7 @@ export function listConfiguredChannelIdsForReadOnlyScope(params: { export function hasConfiguredChannelsForReadOnlyScope(params: { config: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; cache?: boolean; @@ -328,6 +347,7 @@ export function resolveConfiguredChannelPluginIds(params: { const configuredChannelIds = new Set( listConfiguredChannelIdsForPluginScope({ config: params.config, + activationSourceConfig: params.activationSourceConfig, workspaceDir: params.workspaceDir, env: params.env, }).map((id) => id.trim()),