diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index 0fefab9892b..889f2b56c3c 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -290,18 +290,10 @@ function resolveReadOnlyWorkspaceDir( return options.workspaceDir ?? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); } -function listExternalChannelManifestRecords(params: { - cfg: OpenClawConfig; - workspaceDir?: string; - env: NodeJS.ProcessEnv; - cache?: boolean; -}): PluginManifestRecord[] { - return loadPluginManifestRegistry({ - config: params.cfg, - workspaceDir: params.workspaceDir, - env: params.env, - cache: params.cache, - }).plugins.filter((plugin) => plugin.origin !== "bundled" && plugin.channels.length > 0); +function listExternalChannelManifestRecords( + records: readonly PluginManifestRecord[], +): PluginManifestRecord[] { + return records.filter((plugin) => plugin.origin !== "bundled" && plugin.channels.length > 0); } function resolveExternalReadOnlyChannelPluginIds(params: { @@ -353,12 +345,13 @@ export function resolveReadOnlyChannelPluginsForConfig( ): ReadOnlyChannelPluginResolution { const env = options.env ?? process.env; const workspaceDir = resolveReadOnlyWorkspaceDir(cfg, options); - const externalManifestRecords = listExternalChannelManifestRecords({ - cfg, + const manifestRecords = loadPluginManifestRegistry({ + config: cfg, workspaceDir, env, cache: options.cache, - }); + }).plugins; + const externalManifestRecords = listExternalChannelManifestRecords(manifestRecords); const configuredChannelIds = [ ...new Set( listConfiguredChannelIdsForReadOnlyScope({ @@ -368,6 +361,7 @@ export function resolveReadOnlyChannelPluginsForConfig( env, cache: options.cache, includePersistedAuthState: options.includePersistedAuthState, + manifestRecords, }), ), ]; diff --git a/src/plugins/channel-plugin-ids.test.ts b/src/plugins/channel-plugin-ids.test.ts index 255dc3af2ae..69b2ca18a45 100644 --- a/src/plugins/channel-plugin-ids.test.ts +++ b/src/plugins/channel-plugin-ids.test.ts @@ -822,6 +822,73 @@ describe("listConfiguredChannelIdsForReadOnlyScope", () => { ).toEqual(["demo-channel"]); }); + it("does not treat disabled stale channel config as explicit read-only intent", () => { + const config = { + channels: { + "demo-channel": { + enabled: false, + token: "stale-token", + }, + }, + } as OpenClawConfig; + + expect(listExplicitConfiguredChannelIdsForConfig(config)).toEqual([]); + expect( + resolveConfiguredChannelPresencePolicy({ + config, + workspaceDir: "/tmp", + env: {}, + includePersistedAuthState: false, + }), + ).toEqual([]); + expect( + listConfiguredChannelIdsForReadOnlyScope({ + config, + workspaceDir: "/tmp", + env: {}, + includePersistedAuthState: false, + }), + ).toEqual([]); + }); + + it("lets explicit bundled channel config bypass restrictive allowlists", () => { + const config = { + channels: { + "demo-channel": { + token: "configured", + }, + }, + plugins: { + allow: ["browser"], + }, + } as OpenClawConfig; + + expect( + resolveConfiguredChannelPresencePolicy({ + config, + workspaceDir: "/tmp", + env: {}, + includePersistedAuthState: false, + }), + ).toEqual([ + { + channelId: "demo-channel", + sources: ["explicit-config"], + effective: true, + pluginIds: ["demo-channel"], + blockedReasons: [], + }, + ]); + expect( + listConfiguredChannelIdsForReadOnlyScope({ + config, + workspaceDir: "/tmp", + env: {}, + includePersistedAuthState: false, + }), + ).toEqual(["demo-channel"]); + }); + it("keeps explicitly configured bundled channels discovered from potential ids", () => { listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel"]); listPotentialConfiguredChannelPresenceSignals.mockReturnValue([ @@ -905,6 +972,37 @@ describe("listConfiguredChannelIdsForReadOnlyScope", () => { ).toEqual(["demo-channel"]); }); + it("does not let disabled mixed-case channel config announce ambient matches", () => { + listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel"]); + listPotentialConfiguredChannelPresenceSignals.mockReturnValue([ + { channelId: "demo-channel", source: "env" }, + ]); + + expect( + listConfiguredAnnounceChannelIdsForConfig({ + config: { + channels: { + "Demo-Channel": { + enabled: false, + token: "stale-token", + }, + }, + plugins: { + entries: { + "demo-channel": { + enabled: true, + }, + }, + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: { + DEMO_CHANNEL_TOKEN: "ambient", + } as NodeJS.ProcessEnv, + }), + ).toEqual([]); + }); + it("uses effective read-only channel policy for announce channels", () => { listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel", "demo-other-channel"]); listPotentialConfiguredChannelPresenceSignals.mockReturnValue([ @@ -932,6 +1030,40 @@ describe("listConfiguredChannelIdsForReadOnlyScope", () => { ).toEqual(["demo-other-channel"]); }); + it("does not treat activation-only declarations as channel ownership", () => { + listPotentialConfiguredChannelIds.mockReturnValue(["activation-only-channel"]); + listPotentialConfiguredChannelPresenceSignals.mockReturnValue([ + { channelId: "activation-only-channel", source: "env" }, + ]); + + expect( + resolveConfiguredChannelPresencePolicy({ + config: { + plugins: { + entries: { + "activation-only-channel-plugin": { + enabled: true, + }, + }, + }, + } as OpenClawConfig, + workspaceDir: "/tmp", + env: { + ACTIVATION_ONLY_CHANNEL_TOKEN: "ambient", + } as NodeJS.ProcessEnv, + includePersistedAuthState: false, + }), + ).toEqual([ + { + channelId: "activation-only-channel", + sources: ["env"], + effective: false, + pluginIds: [], + blockedReasons: ["no-channel-owner"], + }, + ]); + }); + it("uses manifest env vars as read-only configured channel triggers", () => { expect( listConfiguredChannelIdsForReadOnlyScope({ diff --git a/src/plugins/channel-presence-policy.ts b/src/plugins/channel-presence-policy.ts index 6fb0d46bd7a..99df814364b 100644 --- a/src/plugins/channel-presence-policy.ts +++ b/src/plugins/channel-presence-policy.ts @@ -82,7 +82,11 @@ export function hasExplicitChannelConfig(params: { if (!entry || typeof entry !== "object" || Array.isArray(entry)) { return false; } - return (entry as { enabled?: unknown }).enabled === true || hasMeaningfulChannelConfig(entry); + const enabled = (entry as { enabled?: unknown }).enabled; + if (enabled === false) { + return false; + } + return enabled === true || hasMeaningfulChannelConfig(entry); } export function listExplicitConfiguredChannelIdsForConfig(config: OpenClawConfig): string[] { @@ -99,12 +103,12 @@ export function listExplicitConfiguredChannelIdsForConfig(config: OpenClawConfig .toSorted((left, right) => left.localeCompare(right)); } -function recordOwnsChannel(record: PluginManifestRecord, channelId: string): boolean { +function recordDeclaresChannel(record: PluginManifestRecord, channelId: string): boolean { const normalizedChannelId = normalizeOptionalLowercaseString(channelId) ?? ""; if (!normalizedChannelId) { return false; } - return [...record.channels, ...(record.activation?.onChannels ?? [])].some( + return record.channels.some( (ownedChannelId) => (normalizeOptionalLowercaseString(ownedChannelId) ?? "") === normalizedChannelId, ); @@ -167,6 +171,7 @@ function normalizeActivationBlockedReason(reason?: string): ConfiguredChannelBlo function resolveBasePolicyBlockedReason(params: { plugin: Pick; normalizedConfig: ReturnType; + allowRestrictiveAllowlistBypass?: boolean; }): ConfiguredChannelBlockedReason | null { if (!params.normalizedConfig.enabled) { return "plugins-disabled"; @@ -178,6 +183,7 @@ function resolveBasePolicyBlockedReason(params: { return "plugin-disabled"; } if ( + params.allowRestrictiveAllowlistBypass !== true && params.normalizedConfig.allow.length > 0 && !params.normalizedConfig.allow.includes(params.plugin.id) ) { @@ -222,9 +228,16 @@ function evaluateEffectiveChannelPlugin(params: { config: OpenClawConfig; activationSource: ReturnType; }): { effective: boolean; pluginId: string; blockedReason?: ConfiguredChannelBlockedReason } { + const explicitBundledChannelConfig = + isBundledManifestOwner(params.plugin) && + hasExplicitChannelConfig({ + config: params.activationSource.rootConfig ?? params.config, + channelId: params.channelId, + }); const baseBlockedReason = resolveBasePolicyBlockedReason({ plugin: params.plugin, normalizedConfig: params.normalizedConfig, + allowRestrictiveAllowlistBypass: explicitBundledChannelConfig, }); if (baseBlockedReason) { return { @@ -262,12 +275,7 @@ function evaluateEffectiveChannelPlugin(params: { }; } - if ( - hasExplicitChannelConfig({ - config: params.activationSource.rootConfig ?? params.config, - channelId: params.channelId, - }) - ) { + if (explicitBundledChannelConfig) { return { effective: true, pluginId: params.plugin.id }; } @@ -354,7 +362,7 @@ export function resolveConfiguredChannelPresencePolicy(params: { const normalizedConfig = activationSource.plugins; const entries: ConfiguredChannelPresencePolicyEntry[] = []; for (const channelId of normalizeChannelIds(entrySources.keys())) { - const owningRecords = records.filter((record) => recordOwnsChannel(record, channelId)); + const owningRecords = records.filter((record) => recordDeclaresChannel(record, channelId)); const evaluations = owningRecords.map((plugin) => evaluateEffectiveChannelPlugin({ plugin, @@ -423,7 +431,8 @@ export function listConfiguredAnnounceChannelIdsForConfig(params: { (value as { enabled?: unknown }).enabled === false ); }) - .map(([channelId]) => channelId) + .map(([channelId]) => normalizeOptionalLowercaseString(channelId)) + .filter((channelId): channelId is string => Boolean(channelId)) : [], ); return normalizeChannelIds([ @@ -509,12 +518,15 @@ export function resolveConfiguredChannelPluginIds(params: { workspaceDir?: string; env: NodeJS.ProcessEnv; }): string[] { - const configuredChannelIds = listConfiguredChannelIdsForReadOnlyScope({ - config: params.config, - activationSourceConfig: params.activationSourceConfig, - workspaceDir: params.workspaceDir, - env: params.env, - }); + const configuredChannelIds = normalizeChannelIds([ + ...listConfiguredChannelIdsForReadOnlyScope({ + config: params.config, + activationSourceConfig: params.activationSourceConfig, + workspaceDir: params.workspaceDir, + env: params.env, + }), + ...listExplicitConfiguredChannelIdsForConfig(params.activationSourceConfig ?? params.config), + ]); if (configuredChannelIds.length === 0) { return []; }