fix: keep read-only channel scope complete

This commit is contained in:
Gustavo Madeira Santana
2026-04-20 22:27:35 -04:00
parent c857e2dc12
commit 1d6b2ff8b2
5 changed files with 106 additions and 3 deletions

View File

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

View File

@@ -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 () => {

View File

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

View File

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

View File

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