fix: centralize channel presence gating

This commit is contained in:
Gustavo Madeira Santana
2026-04-21 19:03:45 -04:00
parent eb6006730d
commit b8787904ab
23 changed files with 673 additions and 56 deletions

View File

@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
- Discord: keep slash command follow-up chunks ephemeral when the command is configured for ephemeral replies, so long `/status` output no longer leaks fallback model or runtime details into the public channel. (#69869) thanks @gumadeiras.
- Plugins/discovery: reject package plugin source entries that escape the package directory before explicit runtime entries or inferred built JavaScript peers can be used. (#69868) thanks @gumadeiras.
- CLI/channels: keep ambient channel env vars and stale persisted auth from surfacing disabled bundled plugins in status, doctor, security audit, and cron delivery validation unless the channel or plugin is effectively enabled or explicitly configured. (#69862) Thanks @gumadeiras.
## 2026.4.21

View File

@@ -651,7 +651,10 @@ See [Configuration reference](/gateway/configuration) for the full `plugins.*` s
hardcoding the owning provider.
- `channelEnvVars` is the cheap metadata path for shell-env fallback, setup
prompts, and similar channel surfaces that should not boot plugin runtime
just to inspect env names.
just to inspect env names. Env names are metadata, not activation by
themselves: status, audit, cron delivery validation, and other read-only
surfaces still apply plugin trust and effective activation policy before they
treat an env var as a configured channel.
- `providerAuthChoices` is the cheap metadata path for auth-choice pickers,
`--auth-choice` resolution, preferred-provider mapping, and simple onboarding
CLI flag registration before provider runtime loads. For runtime wizard

View File

@@ -155,6 +155,117 @@ module.exports = {
return { pluginDir, fullMarker, setupMarker };
}
function writeBundledSetupChannelPlugin(
options: {
pluginId?: string;
channelId?: string;
envVar?: string;
} = {},
) {
const bundledRoot = makeTempDir();
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledRoot;
const pluginId = options.pluginId ?? "bundled-chat";
const channelId = options.channelId ?? pluginId;
const envVar = options.envVar ?? "BUNDLED_CHAT_TOKEN";
const pluginDir = path.join(bundledRoot, pluginId);
fs.mkdirSync(pluginDir, { recursive: true });
const fullMarker = path.join(pluginDir, "full-loaded.txt");
const setupMarker = path.join(pluginDir, "setup-loaded.txt");
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify(
{
name: `@openclaw/${pluginId}`,
version: "1.0.0",
type: "commonjs",
openclaw: {
extensions: ["./index.cjs"],
setupEntry: "./setup-entry.cjs",
channel: {
id: channelId,
label: "Bundled Chat",
selectionLabel: "Bundled Chat",
docsPath: `/channels/${channelId}`,
blurb: "bundled setup entry",
},
},
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify(
{
id: pluginId,
configSchema: EMPTY_PLUGIN_SCHEMA,
channels: [channelId],
channelEnvVars: {
[channelId]: [envVar],
},
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "index.cjs"),
`require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8");
module.exports = {
kind: "bundled-channel-entry",
id: ${JSON.stringify(pluginId)},
name: "Bundled Chat",
description: "full entry",
register() {},
loadChannelPlugin() {
return {
id: ${JSON.stringify(channelId)},
meta: { id: ${JSON.stringify(channelId)}, label: "Bundled Chat" },
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({ accountId: "default", token: "configured" }),
},
outbound: { deliveryMode: "direct" },
};
},
};`,
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "setup-entry.cjs"),
`module.exports = {
kind: "bundled-channel-setup-entry",
loadSetupPlugin() {
require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8");
return {
id: ${JSON.stringify(channelId)},
meta: {
id: ${JSON.stringify(channelId)},
label: "Bundled Chat",
selectionLabel: "Bundled Chat",
docsPath: ${JSON.stringify(`/channels/${channelId}`)},
blurb: "bundled setup entry",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({ accountId: "default", token: "configured" }),
},
outbound: { deliveryMode: "direct" },
};
},
};`,
"utf-8",
);
return { bundledRoot, pluginDir, fullMarker, setupMarker, pluginId, channelId, envVar };
}
afterEach(() => {
resetPluginLoaderTestStateForTest();
});
@@ -366,6 +477,49 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
expect(fs.existsSync(fullMarker)).toBe(false);
});
it("does not promote disabled bundled channels from ambient env", () => {
const { channelId, envVar, fullMarker, setupMarker } = writeBundledSetupChannelPlugin();
const plugins = listReadOnlyChannelPluginsForConfig(
{
plugins: {
allow: ["memory-core"],
},
} as never,
{
env: { ...process.env, [envVar]: "configured" },
includePersistedAuthState: false,
},
);
expect(plugins.some((entry) => entry.id === channelId)).toBe(false);
expect(fs.existsSync(setupMarker)).toBe(false);
expect(fs.existsSync(fullMarker)).toBe(false);
});
it("keeps explicitly enabled bundled channels visible from env configuration", () => {
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,
},
);
const plugin = plugins.find((entry) => entry.id === channelId);
expect(plugin?.meta.blurb).toBe("bundled setup entry");
expect(fs.existsSync(setupMarker)).toBe(true);
expect(fs.existsSync(fullMarker)).toBe(false);
});
it("accepts option-like env keys through the explicit env option", () => {
const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({
pluginId: "external-chat-plugin",

View File

@@ -368,7 +368,6 @@ export function resolveReadOnlyChannelPluginsForConfig(
env,
cache: options.cache,
includePersistedAuthState: options.includePersistedAuthState,
manifestRecords: externalManifestRecords,
}),
),
];

View File

@@ -47,8 +47,10 @@ export async function formatConfigChannelsStatusLines(
return buildChannelAccountLine(provider, account, bits);
});
const plugins = listReadOnlyChannelPluginsForConfig(cfg);
const sourceConfig = opts?.sourceConfig ?? cfg;
const plugins = listReadOnlyChannelPluginsForConfig(cfg, {
activationSourceConfig: sourceConfig,
});
for (const plugin of plugins) {
const accountIds = plugin.config.listAccountIds(cfg);
if (!accountIds.length) {

View File

@@ -211,6 +211,7 @@ export async function channelsStatusCommand(
},
configuredChannels: listConfiguredChannelIdsForReadOnlyScope({
config: resolvedConfig,
activationSourceConfig: cfg,
env: process.env,
includePersistedAuthState: false,
}),

View File

@@ -14,6 +14,7 @@ vi.mock("../channels/plugins/bundled-ids.js", () => ({
}));
vi.mock("../channels/plugins/persisted-auth-state.js", () => ({
listBundledChannelIdsWithPersistedAuthState: () => ["matrix", "whatsapp"],
hasBundledChannelPersistedAuthState: () => false,
}));

View File

@@ -2,8 +2,6 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { listAgentEntries, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { listBundledChannelPluginIds } from "../channels/plugins/bundled-ids.js";
import { hasBundledChannelPersistedAuthState } from "../channels/plugins/persisted-auth-state.js";
import { formatCliCommand } from "../cli/command-format.js";
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
import {
@@ -21,6 +19,7 @@ import { loadSessionStore } from "../config/sessions/store-load.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
import { resolveMemoryBackendConfig } from "../memory-host-sdk/engine-storage.js";
import { listConfiguredChannelIdsForReadOnlyScope } from "../plugins/channel-plugin-ids.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { parseAgentSessionKey } from "../sessions/session-key-utils.js";
import { asNullableObjectRecord } from "../shared/record-coerce.js";
@@ -549,10 +548,23 @@ function shouldRequireOAuthDir(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boo
if (!channels) {
return false;
}
for (const channelId of listBundledChannelPluginIds()) {
if (hasBundledChannelPersistedAuthState({ channelId, cfg, env })) {
return true;
}
const withPersistedAuth = new Set(
listConfiguredChannelIdsForReadOnlyScope({
config: cfg,
env,
cache: true,
}),
);
const withoutPersistedAuth = new Set(
listConfiguredChannelIdsForReadOnlyScope({
config: cfg,
env,
cache: true,
includePersistedAuthState: false,
}),
);
if ([...withPersistedAuth].some((channelId) => !withoutPersistedAuth.has(channelId))) {
return true;
}
// Pairing allowlists are persisted under credentials/<channel>-allowFrom.json.
for (const [channelId, channelCfg] of Object.entries(channels)) {

View File

@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as configPresence from "../../../channels/config-presence.js";
import * as manifestRegistry from "../../../plugins/manifest-registry.js";
import { scanConfiguredChannelPluginBlockers } from "./channel-plugin-blockers.js";
@@ -9,7 +8,6 @@ describe("channel plugin blockers", () => {
});
it("skips plugin registry work when config has no plugin blocker surfaces", () => {
const presenceSpy = vi.spyOn(configPresence, "listPotentialConfiguredChannelIds");
const registrySpy = vi.spyOn(manifestRegistry, "loadPluginManifestRegistry");
const hits = scanConfiguredChannelPluginBlockers({
@@ -25,12 +23,10 @@ describe("channel plugin blockers", () => {
});
expect(hits).toEqual([]);
expect(presenceSpy).not.toHaveBeenCalled();
expect(registrySpy).not.toHaveBeenCalled();
});
it("still evaluates configured channels when plugins are disabled globally", () => {
vi.spyOn(configPresence, "listPotentialConfiguredChannelIds").mockReturnValue(["slack"]);
vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({
plugins: [
{
@@ -66,4 +62,48 @@ describe("channel plugin blockers", () => {
},
]);
});
it("ignores ambient channel env when reporting plugin blockers", () => {
vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({
plugins: [
{
id: "slack",
origin: "bundled",
channels: ["slack"],
enabledByDefault: true,
},
{
id: "telegram",
origin: "bundled",
channels: ["telegram"],
enabledByDefault: true,
},
],
diagnostics: [],
} as unknown as ReturnType<typeof manifestRegistry.loadPluginManifestRegistry>);
const hits = scanConfiguredChannelPluginBlockers(
{
plugins: {
enabled: false,
},
channels: {
telegram: {
botToken: "configured",
},
},
},
{
SLACK_BOT_TOKEN: "ambient",
} as NodeJS.ProcessEnv,
);
expect(hits).toEqual([
{
channelId: "telegram",
pluginId: "telegram",
reason: "plugins disabled",
},
]);
});
});

View File

@@ -1,5 +1,5 @@
import { listPotentialConfiguredChannelIds } from "../../../channels/config-presence.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { listExplicitConfiguredChannelIdsForConfig } from "../../../plugins/channel-plugin-ids.js";
import {
normalizePluginsConfig,
resolveEffectivePluginActivationState,
@@ -39,9 +39,7 @@ export function scanConfiguredChannelPluginBlockers(
if (!hasExplicitChannelPluginBlockerConfig(cfg)) {
return [];
}
const configuredChannelIds = new Set(
listPotentialConfiguredChannelIds(cfg, env).map((id) => id.trim()),
);
const configuredChannelIds = new Set(listExplicitConfiguredChannelIdsForConfig(cfg));
if (configuredChannelIds.size === 0) {
return [];
}

View File

@@ -205,7 +205,10 @@ export async function buildChannelsTable(
rows: Array<Record<string, string>>;
}> = [];
for (const plugin of listReadOnlyChannelPluginsForConfig(cfg)) {
const sourceConfig = opts?.sourceConfig ?? cfg;
for (const plugin of listReadOnlyChannelPluginsForConfig(cfg, {
activationSourceConfig: sourceConfig,
})) {
const accountIds = plugin.config.listAccountIds(cfg);
const defaultAccountId = resolveChannelDefaultAccountId({
plugin,
@@ -215,7 +218,6 @@ export async function buildChannelsTable(
const resolvedAccountIds = accountIds.length > 0 ? accountIds : [defaultAccountId];
const accounts: ChannelAccountRow[] = [];
const sourceConfig = opts?.sourceConfig ?? cfg;
for (const accountId of resolvedAccountIds) {
accounts.push(
await resolveChannelAccountRow({

View File

@@ -14,8 +14,12 @@ export type LinkChannelContext = {
export async function resolveLinkChannelContext(
cfg: OpenClawConfig,
options: { sourceConfig?: OpenClawConfig } = {},
): Promise<LinkChannelContext | null> {
for (const plugin of listReadOnlyChannelPluginsForConfig(cfg)) {
const sourceConfig = options.sourceConfig ?? cfg;
for (const plugin of listReadOnlyChannelPluginsForConfig(cfg, {
activationSourceConfig: sourceConfig,
})) {
const { defaultAccountId, account, enabled, configured } =
await resolveDefaultChannelAccountContext(plugin, cfg, {
mode: "read_only",

View File

@@ -13,8 +13,8 @@ const mocks = vi.hoisted(() => ({
buildChannelsTable: vi.fn(),
}));
vi.mock("../channels/config-presence.js", () => ({
hasPotentialConfiguredChannels: mocks.hasPotentialConfiguredChannels,
vi.mock("../plugins/channel-plugin-ids.js", () => ({
hasConfiguredChannelsForReadOnlyScope: mocks.hasPotentialConfiguredChannels,
}));
vi.mock("../cli/command-config-resolution.js", () => ({

View File

@@ -133,7 +133,7 @@ export async function collectStatusScanOverview(params: {
showSecrets: boolean;
runtime?: RuntimeEnv;
allowMissingConfigFastPath?: boolean;
resolveHasConfiguredChannels?: (cfg: OpenClawConfig) => boolean;
resolveHasConfiguredChannels?: (cfg: OpenClawConfig, sourceConfig: OpenClawConfig) => boolean;
includeChannelsData?: boolean;
useGatewayCallOverridesForChannelsStatus?: boolean;
progress?: {
@@ -177,8 +177,8 @@ export async function collectStatusScanOverview(params: {
});
params.progress?.tick();
const hasConfiguredChannels = params.resolveHasConfiguredChannels
? params.resolveHasConfiguredChannels(cfg)
: hasConfiguredChannelsForReadOnlyScope({ config: cfg });
? params.resolveHasConfiguredChannels(cfg, sourceConfig)
: hasConfiguredChannelsForReadOnlyScope({ config: cfg, activationSourceConfig: sourceConfig });
const osSummary = resolveOsSummary();
const bootstrap = await createStatusScanCoreBootstrap<
Awaited<ReturnType<typeof getAgentLocalStatusesFn>>

View File

@@ -13,7 +13,7 @@ type StatusJsonScanPolicy = {
commandName: string;
allowMissingConfigFastPath?: boolean;
includeChannelSummary?: boolean;
resolveHasConfiguredChannels: (cfg: OpenClawConfig) => boolean;
resolveHasConfiguredChannels: (cfg: OpenClawConfig, sourceConfig: OpenClawConfig) => boolean;
resolveMemory: Parameters<typeof executeStatusScanFromOverview>[0]["resolveMemory"];
};
@@ -58,9 +58,10 @@ export async function scanStatusJsonFast(
commandName: "status --json",
allowMissingConfigFastPath: true,
includeChannelSummary: false,
resolveHasConfiguredChannels: (cfg) =>
resolveHasConfiguredChannels: (cfg, sourceConfig) =>
hasConfiguredChannelsForReadOnlyScope({
config: cfg,
activationSourceConfig: sourceConfig,
env: process.env,
includePersistedAuthState: false,
}),

View File

@@ -25,8 +25,11 @@ export async function scanStatus(
_runtime,
{
commandName: "status --json",
resolveHasConfiguredChannels: (cfg) =>
hasConfiguredChannelsForReadOnlyScope({ config: cfg }),
resolveHasConfiguredChannels: (cfg, sourceConfig) =>
hasConfiguredChannelsForReadOnlyScope({
config: cfg,
activationSourceConfig: sourceConfig,
}),
resolveMemory: async ({ cfg, agentStatus, memoryPlugin }) =>
await resolveStatusMemoryStatusSnapshot({
cfg,

View File

@@ -118,14 +118,15 @@ export async function getStatusSummary(
resolveSessionModelRef,
} = await loadStatusSummaryRuntimeModule();
const cfg = options.config ?? (await loadConfigIoModule()).loadConfig();
const channelScopeConfig =
options.sourceConfig === undefined
? { config: cfg }
: { config: cfg, activationSourceConfig: options.sourceConfig };
const needsChannelPlugins =
includeChannelSummary &&
hasConfiguredChannelsForReadOnlyScope({
config: cfg,
});
includeChannelSummary && hasConfiguredChannelsForReadOnlyScope(channelScopeConfig);
const linkContext = needsChannelPlugins
? await loadLinkChannelModule().then(({ resolveLinkChannelContext }) =>
resolveLinkChannelContext(cfg),
resolveLinkChannelContext(cfg, { sourceConfig: options.sourceConfig }),
)
: null;
const agentList = listGatewayAgentsBasic(cfg);

View File

@@ -1,4 +1,3 @@
import { listPotentialConfiguredChannelIds } from "../../channels/config-presence.js";
import { loadConfig } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resolveCronDeliveryPreviews } from "../../cron/delivery-preview.js";
@@ -13,6 +12,7 @@ import { isInvalidCronSessionTargetIdError } from "../../cron/session-target.js"
import type { CronDelivery, CronJob, CronJobCreate, CronJobPatch } from "../../cron/types.js";
import { validateScheduleTimestamp } from "../../cron/validate-timestamp.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { listConfiguredAnnounceChannelIdsForConfig } from "../../plugins/channel-plugin-ids.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js";
import {
ErrorCodes,
@@ -30,9 +30,11 @@ import {
import type { GatewayRequestHandlers } from "./types.js";
function listConfiguredAnnounceChannelIds(cfg: OpenClawConfig): string[] {
return listPotentialConfiguredChannelIds(cfg, process.env, {
includePersistedAuthState: false,
}).filter((channelId) => cfg.channels?.[channelId]?.enabled !== false);
return listConfiguredAnnounceChannelIdsForConfig({
config: cfg,
env: process.env,
cache: true,
});
}
function assertConfiguredAnnounceChannel(params: {

View File

@@ -740,6 +740,12 @@ describe("gateway server cron", () => {
appToken: "xapp-slack-token",
},
},
plugins: {
entries: {
telegram: { enabled: true },
slack: { enabled: true },
},
},
});
const { server, ws } = await startServerWithClient();
@@ -763,6 +769,43 @@ describe("gateway server cron", () => {
}
});
test("ignores ambient disabled channel env when validating announce delivery", async () => {
vi.stubEnv("SLACK_BOT_TOKEN", "xoxb-ambient");
vi.stubEnv("TELEGRAM_BOT_TOKEN", "ambient-telegram");
const { prevSkipCron } = await setupCronTestRun({
tempPrefix: "openclaw-gw-cron-ambient-disabled-delivery-",
cronEnabled: false,
});
await writeCronConfig({
session: {
mainKey: "main",
},
plugins: {
allow: ["memory-core"],
},
});
const { server, ws } = await startServerWithClient();
await connectOk(ws);
try {
const addRes = await rpcReq(ws, "cron.add", {
name: "ambient disabled announce",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "hello" },
delivery: { mode: "announce" },
});
expect(addRes.ok).toBe(true);
} finally {
await cleanupCronTestRun({ ws, server, prevSkipCron });
}
});
test("rejects ambiguous announce delivery on update when multiple channels are configured", async () => {
const { prevSkipCron } = await setupCronTestRun({
tempPrefix: "openclaw-gw-cron-ambiguous-delivery-update-",
@@ -782,6 +825,12 @@ describe("gateway server cron", () => {
appToken: "xapp-slack-token",
},
},
plugins: {
entries: {
telegram: { enabled: true },
slack: { enabled: true },
},
},
});
const { server, ws } = await startServerWithClient();
@@ -832,6 +881,11 @@ describe("gateway server cron", () => {
appToken: "xapp-slack-token",
},
},
plugins: {
entries: {
slack: { enabled: true },
},
},
});
const { server, ws } = await startServerWithClient();

View File

@@ -48,9 +48,14 @@ async function loadChannelSummaryConfig(): Promise<OpenClawConfig> {
return loadConfig();
}
async function listChannelSummaryPlugins(cfg: OpenClawConfig): Promise<ChannelPlugin[]> {
async function listChannelSummaryPlugins(params: {
cfg: OpenClawConfig;
sourceConfig: OpenClawConfig;
}): Promise<ChannelPlugin[]> {
const { listReadOnlyChannelPluginsForConfig } = await import("../channels/plugins/read-only.js");
return listReadOnlyChannelPluginsForConfig(cfg);
return listReadOnlyChannelPluginsForConfig(params.cfg, {
activationSourceConfig: params.sourceConfig,
});
}
const buildAccountDetails = (params: {
@@ -123,7 +128,8 @@ export async function buildChannelSummary(
resolved.colorize && color ? color(value) : value;
const sourceConfig = options?.sourceConfig ?? effective;
const plugins = options?.plugins ?? (await listChannelSummaryPlugins(effective));
const plugins =
options?.plugins ?? (await listChannelSummaryPlugins({ cfg: effective, sourceConfig }));
for (const plugin of plugins) {
const accountIds = plugin.config.listAccountIds(effective);
const defaultAccountId =

View File

@@ -3,11 +3,22 @@ import type { OpenClawConfig } from "../config/config.js";
const listPotentialConfiguredChannelIds = vi.hoisted(() => vi.fn());
const hasPotentialConfiguredChannels = vi.hoisted(() => vi.fn());
const hasMeaningfulChannelConfig = vi.hoisted(() =>
vi.fn((value: unknown) => {
return (
!!value &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.keys(value).some((key) => key !== "enabled")
);
}),
);
const loadPluginManifestRegistry = vi.hoisted(() => vi.fn());
vi.mock("../channels/config-presence.js", () => ({
listPotentialConfiguredChannelIds,
hasPotentialConfiguredChannels,
hasMeaningfulChannelConfig,
}));
vi.mock("./manifest-registry.js", async (importOriginal) => {
@@ -20,7 +31,9 @@ vi.mock("./manifest-registry.js", async (importOriginal) => {
import {
hasConfiguredChannelsForReadOnlyScope,
listConfiguredAnnounceChannelIdsForConfig,
listConfiguredChannelIdsForReadOnlyScope,
listExplicitConfiguredChannelIdsForConfig,
resolveConfiguredChannelPluginIds,
resolveGatewayStartupPluginIds,
} from "./channel-plugin-ids.js";
@@ -652,9 +665,167 @@ describe("listConfiguredChannelIdsForReadOnlyScope", () => {
beforeEach(() => {
listPotentialConfiguredChannelIds.mockReset().mockReturnValue([]);
hasPotentialConfiguredChannels.mockReset().mockReturnValue(false);
hasMeaningfulChannelConfig.mockClear();
loadPluginManifestRegistry.mockReset().mockReturnValue(createManifestRegistryFixture());
});
it("filters bundled ambient channel triggers through effective activation", () => {
listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel"]);
expect(
listConfiguredChannelIdsForReadOnlyScope({
config: {
plugins: {
allow: ["memory-core"],
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: {
DEMO_CHANNEL_TOKEN: "token",
} as NodeJS.ProcessEnv,
includePersistedAuthState: false,
}),
).toEqual([]);
expect(
hasConfiguredChannelsForReadOnlyScope({
config: {
plugins: {
allow: ["memory-core"],
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: {
DEMO_CHANNEL_TOKEN: "token",
} as NodeJS.ProcessEnv,
includePersistedAuthState: false,
}),
).toBe(false);
});
it("keeps explicitly enabled bundled ambient channel triggers", () => {
listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel"]);
expect(
listConfiguredChannelIdsForReadOnlyScope({
config: {
plugins: {
entries: {
"demo-channel": {
enabled: true,
},
},
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: {
DEMO_CHANNEL_TOKEN: "token",
} as NodeJS.ProcessEnv,
includePersistedAuthState: false,
}),
).toEqual(["demo-channel"]);
});
it("keeps explicitly configured bundled channels discovered from potential ids", () => {
listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel"]);
expect(
listConfiguredChannelIdsForReadOnlyScope({
config: {
channels: {
"demo-channel": {
token: "configured",
},
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: {},
includePersistedAuthState: false,
}),
).toEqual(["demo-channel"]);
});
it("blocks explicitly configured bundled channels when plugins are disabled or denied", () => {
listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel"]);
expect(
listConfiguredChannelIdsForReadOnlyScope({
config: {
channels: {
"demo-channel": {
token: "configured",
},
},
plugins: {
enabled: false,
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: {},
includePersistedAuthState: false,
}),
).toEqual([]);
expect(
listConfiguredChannelIdsForReadOnlyScope({
config: {
channels: {
"demo-channel": {
token: "configured",
},
},
plugins: {
deny: ["demo-channel"],
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: {},
includePersistedAuthState: false,
}),
).toEqual([]);
});
it("lists explicit configured channels without ambient env triggers", () => {
expect(
listExplicitConfiguredChannelIdsForConfig({
channels: {
defaults: {
model: "sonnet-4.6",
},
"demo-channel": {
token: "configured",
},
"demo-other-channel": {
enabled: false,
},
},
} as OpenClawConfig),
).toEqual(["demo-channel"]);
});
it("uses effective read-only channel policy for announce channels", () => {
listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel", "demo-other-channel"]);
expect(
listConfiguredAnnounceChannelIdsForConfig({
config: {
channels: {
"demo-other-channel": {
token: "configured",
},
},
plugins: {
allow: ["demo-other-channel"],
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: {
DEMO_CHANNEL_TOKEN: "ambient",
} as NodeJS.ProcessEnv,
}),
).toEqual(["demo-other-channel"]);
});
it("uses manifest env vars as read-only configured channel triggers", () => {
expect(
listConfiguredChannelIdsForReadOnlyScope({

View File

@@ -1,7 +1,7 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { collectConfiguredAgentHarnessRuntimes } from "../agents/harness-runtimes.js";
import {
hasPotentialConfiguredChannels,
hasMeaningfulChannelConfig,
listPotentialConfiguredChannelIds,
} from "../channels/config-presence.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -66,6 +66,8 @@ function normalizeChannelIds(channelIds: Iterable<string>): string[] {
).toSorted((left, right) => left.localeCompare(right));
}
const IGNORED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]);
function hasNonEmptyEnvValue(env: NodeJS.ProcessEnv, key: string): boolean {
if (!isSafeChannelEnvVarTriggerName(key)) {
return false;
@@ -104,6 +106,127 @@ function listEnvConfiguredManifestChannelIds(params: {
return [...channelIds].toSorted((left, right) => left.localeCompare(right));
}
export function hasExplicitChannelConfig(params: {
config: OpenClawConfig;
channelId: string;
}): boolean {
const channels = params.config.channels;
if (!channels || typeof channels !== "object" || Array.isArray(channels)) {
return false;
}
const entry = (channels as Record<string, unknown>)[params.channelId];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return false;
}
return (entry as { enabled?: unknown }).enabled === true || hasMeaningfulChannelConfig(entry);
}
export function listExplicitConfiguredChannelIdsForConfig(config: OpenClawConfig): string[] {
const channels = config.channels;
if (!channels || typeof channels !== "object" || Array.isArray(channels)) {
return [];
}
return Object.keys(channels)
.filter(
(channelId) =>
!IGNORED_CHANNEL_CONFIG_KEYS.has(channelId) &&
hasExplicitChannelConfig({ config, channelId }),
)
.toSorted((left, right) => left.localeCompare(right));
}
function recordOwnsChannel(record: PluginManifestRecord, channelId: string): boolean {
const normalizedChannelId = normalizeOptionalLowercaseString(channelId) ?? "";
if (!normalizedChannelId) {
return false;
}
return [...record.channels, ...(record.activation?.onChannels ?? [])].some(
(ownedChannelId) =>
(normalizeOptionalLowercaseString(ownedChannelId) ?? "") === normalizedChannelId,
);
}
function isChannelPluginEligibleForEffectiveConfiguredChannel(params: {
plugin: PluginManifestRecord;
channelId: string;
normalizedConfig: ReturnType<typeof normalizePluginsConfig>;
config: OpenClawConfig;
activationSource: ReturnType<typeof createPluginActivationSource>;
}): boolean {
if (
!passesManifestOwnerBasePolicy({
plugin: params.plugin,
normalizedConfig: params.normalizedConfig,
})
) {
return false;
}
if (!isBundledManifestOwner(params.plugin)) {
if (params.plugin.origin === "global" || params.plugin.origin === "config") {
return hasExplicitManifestOwnerTrust({
plugin: params.plugin,
normalizedConfig: params.normalizedConfig,
});
}
return isActivatedManifestOwner({
plugin: params.plugin,
normalizedConfig: params.normalizedConfig,
rootConfig: params.activationSource.rootConfig,
});
}
if (
hasExplicitChannelConfig({
config: params.activationSource.rootConfig ?? params.config,
channelId: params.channelId,
})
) {
return true;
}
return resolveEffectivePluginActivationState({
id: params.plugin.id,
origin: params.plugin.origin,
config: params.normalizedConfig,
rootConfig: params.config,
enabledByDefault: params.plugin.enabledByDefault,
activationSource: params.activationSource,
}).enabled;
}
function filterEffectiveConfiguredChannelIds(params: {
channelIds: Iterable<string>;
records: readonly PluginManifestRecord[];
config: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
}): string[] {
const channelIds = normalizeChannelIds(params.channelIds);
if (channelIds.length === 0) {
return [];
}
const activationSource = createPluginActivationSource({
config: params.activationSourceConfig ?? params.config,
});
const normalizedConfig = activationSource.plugins;
const effective = new Set<string>();
for (const channelId of channelIds) {
if (
params.records.some(
(record) =>
recordOwnsChannel(record, channelId) &&
isChannelPluginEligibleForEffectiveConfiguredChannel({
plugin: record,
channelId,
normalizedConfig,
config: params.config,
activationSource,
}),
)
) {
effective.add(channelId);
}
}
return [...effective].toSorted((left, right) => left.localeCompare(right));
}
function listConfiguredChannelIdsForPluginScope(params: {
config: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
@@ -121,7 +244,7 @@ function listConfiguredChannelIdsForPluginScope(params: {
env: params.env,
cache: params.cache,
}).plugins;
return [
const channelIds = [
...new Set([
...listPotentialConfiguredChannelIds(params.config, params.env, {
includePersistedAuthState: params.includePersistedAuthState,
@@ -133,7 +256,13 @@ function listConfiguredChannelIdsForPluginScope(params: {
env: params.env,
}),
]),
].toSorted((left, right) => left.localeCompare(right));
];
return filterEffectiveConfiguredChannelIds({
channelIds,
records,
config: params.config,
activationSourceConfig: params.activationSourceConfig,
});
}
export function listConfiguredChannelIdsForReadOnlyScope(params: {
@@ -169,22 +298,48 @@ export function hasConfiguredChannelsForReadOnlyScope(params: {
includePersistedAuthState?: boolean;
manifestRecords?: readonly PluginManifestRecord[];
}): boolean {
const env = params.env ?? process.env;
if (
hasPotentialConfiguredChannels(params.config, env, {
includePersistedAuthState: params.includePersistedAuthState,
})
) {
return true;
}
return (
listConfiguredChannelIdsForReadOnlyScope({
...params,
env,
}).length > 0
);
}
export function listConfiguredAnnounceChannelIdsForConfig(params: {
config: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
cache?: boolean;
}): string[] {
const channels = params.config.channels;
const disabledChannelIds = new Set(
channels && typeof channels === "object" && !Array.isArray(channels)
? Object.entries(channels)
.filter(([, value]) => {
return (
value &&
typeof value === "object" &&
!Array.isArray(value) &&
(value as { enabled?: unknown }).enabled === false
);
})
.map(([channelId]) => channelId)
: [],
);
return normalizeChannelIds([
...listExplicitConfiguredChannelIdsForConfig(params.config),
...listConfiguredChannelIdsForReadOnlyScope({
config: params.config,
activationSourceConfig: params.activationSourceConfig,
workspaceDir: params.workspaceDir,
env: params.env,
cache: params.cache,
includePersistedAuthState: false,
}),
]).filter((channelId) => !disabledChannelIds.has(channelId));
}
function isChannelPluginEligibleForScopedOwnership(params: {
plugin: PluginManifestRecord;
normalizedConfig: ReturnType<typeof normalizePluginsConfig>;

View File

@@ -1,7 +1,6 @@
import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js";
import { hasPotentialConfiguredChannels } from "../channels/config-presence.js";
import type { listChannelPlugins } from "../channels/plugins/index.js";
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/config.js";
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
@@ -14,7 +13,10 @@ import {
} from "../infra/exec-safe-bin-runtime-policy.js";
import { listRiskyConfiguredSafeBins } from "../infra/exec-safe-bin-semantics.js";
import { normalizeTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js";
import { resolveConfiguredChannelPluginIds } from "../plugins/channel-plugin-ids.js";
import {
hasConfiguredChannelsForReadOnlyScope,
resolveConfiguredChannelPluginIds,
} from "../plugins/channel-plugin-ids.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
import { asNullableRecord } from "../shared/record-coerce.js";
@@ -1009,7 +1011,12 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
const shouldAuditChannelSecurity =
context.includeChannelSecurity &&
(context.plugins !== undefined ||
hasPotentialConfiguredChannels(cfg, env) ||
hasConfiguredChannelsForReadOnlyScope({
config: cfg,
activationSourceConfig: context.sourceConfig,
workspaceDir: context.workspaceDir,
env,
}) ||
resolveConfiguredChannelPluginIds({
config: cfg,
activationSourceConfig: context.sourceConfig,