mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 01:50:43 +00:00
refactor: split channel presence policy
This commit is contained in:
@@ -6,6 +6,7 @@ import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
hasMeaningfulChannelConfig,
|
||||
hasPotentialConfiguredChannels,
|
||||
listPotentialConfiguredChannelPresenceSignals,
|
||||
listPotentialConfiguredChannelIds,
|
||||
} from "./config-presence.js";
|
||||
|
||||
@@ -90,6 +91,11 @@ describe("config presence", () => {
|
||||
expectedConfigured: true,
|
||||
options: { includePersistedAuthState: false },
|
||||
});
|
||||
expect(
|
||||
listPotentialConfiguredChannelPresenceSignals({}, env, {
|
||||
includePersistedAuthState: false,
|
||||
}),
|
||||
).toEqual([{ channelId: "matrix", source: "env" }]);
|
||||
});
|
||||
|
||||
it("detects persisted Matrix credentials without config or env", () => {
|
||||
|
||||
@@ -24,6 +24,13 @@ type ChannelPresenceOptions = {
|
||||
};
|
||||
};
|
||||
|
||||
export type ChannelPresenceSignalSource = "config" | "env" | "persisted-auth";
|
||||
|
||||
export type ChannelPresenceSignal = {
|
||||
channelId: string;
|
||||
source: ChannelPresenceSignalSource;
|
||||
};
|
||||
|
||||
export function hasMeaningfulChannelConfig(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
@@ -76,6 +83,30 @@ export function listPotentialConfiguredChannelIds(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
options: ChannelPresenceOptions = {},
|
||||
): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
listPotentialConfiguredChannelPresenceSignals(cfg, env, options).map(
|
||||
(signal) => signal.channelId,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function listPotentialConfiguredChannelPresenceSignals(
|
||||
cfg: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
options: ChannelPresenceOptions = {},
|
||||
): ChannelPresenceSignal[] {
|
||||
const signals: ChannelPresenceSignal[] = [];
|
||||
const seenSignals = new Set<string>();
|
||||
const addSignal = (channelId: string, source: ChannelPresenceSignalSource) => {
|
||||
const key = `${source}:${channelId}`;
|
||||
if (seenSignals.has(key)) {
|
||||
return;
|
||||
}
|
||||
seenSignals.add(key);
|
||||
signals.push({ channelId, source });
|
||||
};
|
||||
const configuredChannelIds = new Set<string>();
|
||||
const channelIds = listBundledChannelPluginIds();
|
||||
const channelEnvPrefixes = listChannelEnvPrefixes(channelIds);
|
||||
@@ -87,6 +118,7 @@ export function listPotentialConfiguredChannelIds(
|
||||
}
|
||||
if (hasMeaningfulChannelConfig(value)) {
|
||||
configuredChannelIds.add(key);
|
||||
addSignal(key, "config");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,6 +130,7 @@ export function listPotentialConfiguredChannelIds(
|
||||
for (const [prefix, channelId] of channelEnvPrefixes) {
|
||||
if (key.startsWith(prefix)) {
|
||||
configuredChannelIds.add(channelId);
|
||||
addSignal(channelId, "env");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,11 +139,12 @@ export function listPotentialConfiguredChannelIds(
|
||||
for (const channelId of listPersistedAuthStateChannelIds(options)) {
|
||||
if (hasPersistedAuthState({ channelId, cfg, env, options })) {
|
||||
configuredChannelIds.add(channelId);
|
||||
addSignal(channelId, "persisted-auth");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...configuredChannelIds];
|
||||
return signals.filter((signal) => configuredChannelIds.has(signal.channelId));
|
||||
}
|
||||
|
||||
function hasEnvConfiguredChannel(
|
||||
|
||||
@@ -482,6 +482,10 @@ vi.mock("../channels/config-presence.js", () => ({
|
||||
),
|
||||
listPotentialConfiguredChannelIds: (cfg: { channels?: Record<string, unknown> }) =>
|
||||
Object.keys(cfg.channels ?? {}).filter((key) => key !== "defaults" && key !== "modelByChannel"),
|
||||
listPotentialConfiguredChannelPresenceSignals: (cfg: { channels?: Record<string, unknown> }) =>
|
||||
Object.keys(cfg.channels ?? {})
|
||||
.filter((key) => key !== "defaults" && key !== "modelByChannel")
|
||||
.map((channelId) => ({ channelId, source: "config" })),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/memory-runtime.js", () => ({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
|
||||
const listPotentialConfiguredChannelIds = vi.hoisted(() => vi.fn());
|
||||
const listPotentialConfiguredChannelPresenceSignals = vi.hoisted(() => vi.fn());
|
||||
const hasPotentialConfiguredChannels = vi.hoisted(() => vi.fn());
|
||||
const hasMeaningfulChannelConfig = vi.hoisted(() =>
|
||||
vi.fn((value: unknown) => {
|
||||
@@ -17,6 +18,7 @@ const loadPluginManifestRegistry = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../channels/config-presence.js", () => ({
|
||||
listPotentialConfiguredChannelIds,
|
||||
listPotentialConfiguredChannelPresenceSignals,
|
||||
hasPotentialConfiguredChannels,
|
||||
hasMeaningfulChannelConfig,
|
||||
}));
|
||||
@@ -34,6 +36,7 @@ import {
|
||||
listConfiguredAnnounceChannelIdsForConfig,
|
||||
listConfiguredChannelIdsForReadOnlyScope,
|
||||
listExplicitConfiguredChannelIdsForConfig,
|
||||
resolveConfiguredChannelPresencePolicy,
|
||||
resolveConfiguredChannelPluginIds,
|
||||
resolveGatewayStartupPluginIds,
|
||||
} from "./channel-plugin-ids.js";
|
||||
@@ -329,6 +332,14 @@ describe("resolveGatewayStartupPluginIds", () => {
|
||||
}
|
||||
return ["demo-channel"];
|
||||
});
|
||||
listPotentialConfiguredChannelPresenceSignals
|
||||
.mockReset()
|
||||
.mockImplementation((config: OpenClawConfig) => {
|
||||
return listPotentialConfiguredChannelIds(config).map((channelId: string) => ({
|
||||
channelId,
|
||||
source: "config",
|
||||
}));
|
||||
});
|
||||
hasPotentialConfiguredChannels.mockReset().mockImplementation((config: OpenClawConfig) => {
|
||||
if (Object.prototype.hasOwnProperty.call(config, "channels")) {
|
||||
return Object.keys(config.channels ?? {}).length > 0;
|
||||
@@ -502,6 +513,14 @@ describe("resolveConfiguredChannelPluginIds", () => {
|
||||
}
|
||||
return [];
|
||||
});
|
||||
listPotentialConfiguredChannelPresenceSignals
|
||||
.mockReset()
|
||||
.mockImplementation((config: OpenClawConfig) => {
|
||||
return listPotentialConfiguredChannelIds(config).map((channelId: string) => ({
|
||||
channelId,
|
||||
source: "config",
|
||||
}));
|
||||
});
|
||||
hasPotentialConfiguredChannels.mockReset().mockImplementation((config: OpenClawConfig) => {
|
||||
if (Object.prototype.hasOwnProperty.call(config, "channels")) {
|
||||
return Object.keys(config.channels ?? {}).length > 0;
|
||||
@@ -664,6 +683,7 @@ describe("resolveConfiguredChannelPluginIds", () => {
|
||||
describe("listConfiguredChannelIdsForReadOnlyScope", () => {
|
||||
beforeEach(() => {
|
||||
listPotentialConfiguredChannelIds.mockReset().mockReturnValue([]);
|
||||
listPotentialConfiguredChannelPresenceSignals.mockReset().mockReturnValue([]);
|
||||
hasPotentialConfiguredChannels.mockReset().mockReturnValue(false);
|
||||
hasMeaningfulChannelConfig.mockClear();
|
||||
loadPluginManifestRegistry.mockReset().mockReturnValue(createManifestRegistryFixture());
|
||||
@@ -671,6 +691,9 @@ describe("listConfiguredChannelIdsForReadOnlyScope", () => {
|
||||
|
||||
it("filters bundled ambient channel triggers through effective activation", () => {
|
||||
listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel"]);
|
||||
listPotentialConfiguredChannelPresenceSignals.mockReturnValue([
|
||||
{ channelId: "demo-channel", source: "env" },
|
||||
]);
|
||||
|
||||
expect(
|
||||
listConfiguredChannelIdsForReadOnlyScope({
|
||||
@@ -703,8 +726,41 @@ describe("listConfiguredChannelIdsForReadOnlyScope", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns reason-rich policy entries for blocked ambient channel triggers", () => {
|
||||
listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel"]);
|
||||
listPotentialConfiguredChannelPresenceSignals.mockReturnValue([
|
||||
{ channelId: "demo-channel", source: "env" },
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveConfiguredChannelPresencePolicy({
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["memory-core"],
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
workspaceDir: "/tmp",
|
||||
env: {
|
||||
DEMO_CHANNEL_TOKEN: "token",
|
||||
} as NodeJS.ProcessEnv,
|
||||
includePersistedAuthState: false,
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
channelId: "demo-channel",
|
||||
sources: ["env"],
|
||||
effective: false,
|
||||
pluginIds: [],
|
||||
blockedReasons: ["not-in-allowlist"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps explicitly enabled bundled ambient channel triggers", () => {
|
||||
listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel"]);
|
||||
listPotentialConfiguredChannelPresenceSignals.mockReturnValue([
|
||||
{ channelId: "demo-channel", source: "env" },
|
||||
]);
|
||||
|
||||
expect(
|
||||
listConfiguredChannelIdsForReadOnlyScope({
|
||||
@@ -726,8 +782,51 @@ describe("listConfiguredChannelIdsForReadOnlyScope", () => {
|
||||
).toEqual(["demo-channel"]);
|
||||
});
|
||||
|
||||
it("treats enabled-only channel config as explicit read-only intent", () => {
|
||||
expect(
|
||||
resolveConfiguredChannelPresencePolicy({
|
||||
config: {
|
||||
channels: {
|
||||
"demo-channel": {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
workspaceDir: "/tmp",
|
||||
env: {},
|
||||
includePersistedAuthState: false,
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
channelId: "demo-channel",
|
||||
sources: ["explicit-config"],
|
||||
effective: true,
|
||||
pluginIds: ["demo-channel"],
|
||||
blockedReasons: [],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
listConfiguredChannelIdsForReadOnlyScope({
|
||||
config: {
|
||||
channels: {
|
||||
"demo-channel": {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
workspaceDir: "/tmp",
|
||||
env: {},
|
||||
includePersistedAuthState: false,
|
||||
}),
|
||||
).toEqual(["demo-channel"]);
|
||||
});
|
||||
|
||||
it("keeps explicitly configured bundled channels discovered from potential ids", () => {
|
||||
listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel"]);
|
||||
listPotentialConfiguredChannelPresenceSignals.mockReturnValue([
|
||||
{ channelId: "demo-channel", source: "config" },
|
||||
]);
|
||||
|
||||
expect(
|
||||
listConfiguredChannelIdsForReadOnlyScope({
|
||||
@@ -747,6 +846,9 @@ describe("listConfiguredChannelIdsForReadOnlyScope", () => {
|
||||
|
||||
it("blocks explicitly configured bundled channels when plugins are disabled or denied", () => {
|
||||
listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel"]);
|
||||
listPotentialConfiguredChannelPresenceSignals.mockReturnValue([
|
||||
{ channelId: "demo-channel", source: "config" },
|
||||
]);
|
||||
|
||||
expect(
|
||||
listConfiguredChannelIdsForReadOnlyScope({
|
||||
@@ -805,6 +907,10 @@ describe("listConfiguredChannelIdsForReadOnlyScope", () => {
|
||||
|
||||
it("uses effective read-only channel policy for announce channels", () => {
|
||||
listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel", "demo-other-channel"]);
|
||||
listPotentialConfiguredChannelPresenceSignals.mockReturnValue([
|
||||
{ channelId: "demo-channel", source: "env" },
|
||||
{ channelId: "demo-other-channel", source: "config" },
|
||||
]);
|
||||
|
||||
expect(
|
||||
listConfiguredAnnounceChannelIdsForConfig({
|
||||
@@ -948,6 +1054,7 @@ describe("listConfiguredChannelIdsForReadOnlyScope", () => {
|
||||
|
||||
it("uses manifest env vars for read-only channel presence checks", () => {
|
||||
listPotentialConfiguredChannelIds.mockReturnValue([]);
|
||||
listPotentialConfiguredChannelPresenceSignals.mockReturnValue([]);
|
||||
hasPotentialConfiguredChannels.mockReturnValue(false);
|
||||
|
||||
expect(
|
||||
|
||||
@@ -1,627 +1,19 @@
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { collectConfiguredAgentHarnessRuntimes } from "../agents/harness-runtimes.js";
|
||||
import {
|
||||
hasMeaningfulChannelConfig,
|
||||
listPotentialConfiguredChannelIds,
|
||||
} from "../channels/config-presence.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
resolveMemoryDreamingConfig,
|
||||
resolveMemoryDreamingPluginConfig,
|
||||
resolveMemoryDreamingPluginId,
|
||||
} from "../memory-host-sdk/dreaming.js";
|
||||
import { isSafeChannelEnvVarTriggerName } from "../secrets/channel-env-var-names.js";
|
||||
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
|
||||
import { resolveManifestActivationPluginIds } from "./activation-planner.js";
|
||||
import {
|
||||
createPluginActivationSource,
|
||||
normalizePluginId,
|
||||
normalizePluginsConfig,
|
||||
resolveEffectivePluginActivationState,
|
||||
} from "./config-state.js";
|
||||
import {
|
||||
hasExplicitManifestOwnerTrust,
|
||||
isActivatedManifestOwner,
|
||||
isBundledManifestOwner,
|
||||
passesManifestOwnerBasePolicy,
|
||||
} from "./manifest-owner-policy.js";
|
||||
import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js";
|
||||
import { hasKind } from "./slots.js";
|
||||
export {
|
||||
hasConfiguredChannelsForReadOnlyScope,
|
||||
hasExplicitChannelConfig,
|
||||
listConfiguredAnnounceChannelIdsForConfig,
|
||||
listConfiguredChannelIdsForReadOnlyScope,
|
||||
listExplicitConfiguredChannelIdsForConfig,
|
||||
resolveConfiguredChannelPluginIds,
|
||||
resolveConfiguredChannelPresencePolicy,
|
||||
resolveDiscoverableScopedChannelPluginIds,
|
||||
type ConfiguredChannelBlockedReason,
|
||||
type ConfiguredChannelPresencePolicyEntry,
|
||||
type ConfiguredChannelPresenceSource,
|
||||
} from "./channel-presence-policy.js";
|
||||
|
||||
function hasRuntimeContractSurface(plugin: PluginManifestRecord): boolean {
|
||||
return Boolean(
|
||||
plugin.providers.length > 0 ||
|
||||
plugin.cliBackends.length > 0 ||
|
||||
plugin.contracts?.speechProviders?.length ||
|
||||
plugin.contracts?.mediaUnderstandingProviders?.length ||
|
||||
plugin.contracts?.imageGenerationProviders?.length ||
|
||||
plugin.contracts?.videoGenerationProviders?.length ||
|
||||
plugin.contracts?.musicGenerationProviders?.length ||
|
||||
plugin.contracts?.webFetchProviders?.length ||
|
||||
plugin.contracts?.webSearchProviders?.length ||
|
||||
plugin.contracts?.memoryEmbeddingProviders?.length ||
|
||||
hasKind(plugin.kind, "memory"),
|
||||
);
|
||||
}
|
||||
|
||||
function isGatewayStartupMemoryPlugin(plugin: PluginManifestRecord): boolean {
|
||||
return hasKind(plugin.kind, "memory");
|
||||
}
|
||||
|
||||
function isGatewayStartupSidecar(plugin: PluginManifestRecord): boolean {
|
||||
return plugin.channels.length === 0 && !hasRuntimeContractSurface(plugin);
|
||||
}
|
||||
|
||||
function dedupeSortedPluginIds(values: Iterable<string>): string[] {
|
||||
return [...new Set(values)].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function normalizeChannelIds(channelIds: Iterable<string>): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
[...channelIds]
|
||||
.map((channelId) => normalizeOptionalLowercaseString(channelId))
|
||||
.filter((channelId): channelId is string => Boolean(channelId)),
|
||||
),
|
||||
).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;
|
||||
}
|
||||
const trimmed = key.trim();
|
||||
const value = env[trimmed] ?? env[trimmed.toUpperCase()];
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
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))) {
|
||||
channelIds.add(channelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
workspaceDir?: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
cache?: boolean;
|
||||
includePersistedAuthState?: boolean;
|
||||
manifestRecords?: readonly PluginManifestRecord[];
|
||||
}): string[] {
|
||||
const records =
|
||||
params.manifestRecords ??
|
||||
loadPluginManifestRegistry({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
cache: params.cache,
|
||||
}).plugins;
|
||||
const channelIds = [
|
||||
...new Set([
|
||||
...listPotentialConfiguredChannelIds(params.config, params.env, {
|
||||
includePersistedAuthState: params.includePersistedAuthState,
|
||||
}),
|
||||
...listEnvConfiguredManifestChannelIds({
|
||||
records,
|
||||
config: params.config,
|
||||
activationSourceConfig: params.activationSourceConfig,
|
||||
env: params.env,
|
||||
}),
|
||||
]),
|
||||
];
|
||||
return filterEffectiveConfiguredChannelIds({
|
||||
channelIds,
|
||||
records,
|
||||
config: params.config,
|
||||
activationSourceConfig: params.activationSourceConfig,
|
||||
});
|
||||
}
|
||||
|
||||
export function listConfiguredChannelIdsForReadOnlyScope(params: {
|
||||
config: OpenClawConfig;
|
||||
activationSourceConfig?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
cache?: boolean;
|
||||
includePersistedAuthState?: boolean;
|
||||
manifestRecords?: readonly PluginManifestRecord[];
|
||||
}): string[] {
|
||||
const env = params.env ?? process.env;
|
||||
const workspaceDir =
|
||||
params.workspaceDir ??
|
||||
resolveAgentWorkspaceDir(params.config, resolveDefaultAgentId(params.config));
|
||||
return listConfiguredChannelIdsForPluginScope({
|
||||
config: params.config,
|
||||
activationSourceConfig: params.activationSourceConfig,
|
||||
workspaceDir,
|
||||
env,
|
||||
cache: params.cache,
|
||||
includePersistedAuthState: params.includePersistedAuthState,
|
||||
manifestRecords: params.manifestRecords,
|
||||
});
|
||||
}
|
||||
|
||||
export function hasConfiguredChannelsForReadOnlyScope(params: {
|
||||
config: OpenClawConfig;
|
||||
activationSourceConfig?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
cache?: boolean;
|
||||
includePersistedAuthState?: boolean;
|
||||
manifestRecords?: readonly PluginManifestRecord[];
|
||||
}): boolean {
|
||||
return (
|
||||
listConfiguredChannelIdsForReadOnlyScope({
|
||||
...params,
|
||||
}).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>;
|
||||
rootConfig: OpenClawConfig;
|
||||
}): boolean {
|
||||
if (
|
||||
!passesManifestOwnerBasePolicy({
|
||||
plugin: params.plugin,
|
||||
normalizedConfig: params.normalizedConfig,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (isBundledManifestOwner(params.plugin)) {
|
||||
return true;
|
||||
}
|
||||
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.rootConfig,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveScopedChannelOwnerPluginIds(params: {
|
||||
config: OpenClawConfig;
|
||||
activationSourceConfig?: OpenClawConfig;
|
||||
channelIds: readonly string[];
|
||||
workspaceDir?: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
cache?: boolean;
|
||||
}): string[] {
|
||||
const channelIds = normalizeChannelIds(params.channelIds);
|
||||
if (channelIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const registry = loadPluginManifestRegistry({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
cache: params.cache,
|
||||
});
|
||||
const trustConfig = params.activationSourceConfig ?? params.config;
|
||||
const normalizedConfig = normalizePluginsConfig(trustConfig.plugins);
|
||||
const candidateIds = dedupeSortedPluginIds(
|
||||
channelIds.flatMap((channelId) => {
|
||||
return resolveManifestActivationPluginIds({
|
||||
trigger: {
|
||||
kind: "channel",
|
||||
channel: channelId,
|
||||
},
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
cache: params.cache,
|
||||
});
|
||||
}),
|
||||
);
|
||||
if (candidateIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const candidateIdSet = new Set(candidateIds);
|
||||
return registry.plugins
|
||||
.filter((plugin) => {
|
||||
if (!candidateIdSet.has(plugin.id)) {
|
||||
return false;
|
||||
}
|
||||
return isChannelPluginEligibleForScopedOwnership({
|
||||
plugin,
|
||||
normalizedConfig,
|
||||
rootConfig: trustConfig,
|
||||
});
|
||||
})
|
||||
.map((plugin) => plugin.id)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function resolveScopedChannelPluginIds(params: {
|
||||
config: OpenClawConfig;
|
||||
activationSourceConfig?: OpenClawConfig;
|
||||
channelIds: readonly string[];
|
||||
workspaceDir?: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
cache?: boolean;
|
||||
}): string[] {
|
||||
return resolveScopedChannelOwnerPluginIds(params);
|
||||
}
|
||||
|
||||
export function resolveDiscoverableScopedChannelPluginIds(params: {
|
||||
config: OpenClawConfig;
|
||||
activationSourceConfig?: OpenClawConfig;
|
||||
channelIds: readonly string[];
|
||||
workspaceDir?: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
cache?: boolean;
|
||||
}): string[] {
|
||||
return resolveScopedChannelOwnerPluginIds(params);
|
||||
}
|
||||
|
||||
function resolveGatewayStartupDreamingPluginIds(config: OpenClawConfig): Set<string> {
|
||||
const dreamingConfig = resolveMemoryDreamingConfig({
|
||||
pluginConfig: resolveMemoryDreamingPluginConfig(config),
|
||||
cfg: config,
|
||||
});
|
||||
if (!dreamingConfig.enabled) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(["memory-core", resolveMemoryDreamingPluginId(config)]);
|
||||
}
|
||||
|
||||
function resolveExplicitMemorySlotStartupPluginId(config: OpenClawConfig): string | undefined {
|
||||
const configuredSlot = config.plugins?.slots?.memory?.trim();
|
||||
if (!configuredSlot || configuredSlot.toLowerCase() === "none") {
|
||||
return undefined;
|
||||
}
|
||||
return normalizePluginId(configuredSlot);
|
||||
}
|
||||
|
||||
function shouldConsiderForGatewayStartup(params: {
|
||||
plugin: PluginManifestRecord;
|
||||
startupDreamingPluginIds: ReadonlySet<string>;
|
||||
explicitMemorySlotStartupPluginId?: string;
|
||||
}): boolean {
|
||||
if (isGatewayStartupSidecar(params.plugin)) {
|
||||
return true;
|
||||
}
|
||||
if (!isGatewayStartupMemoryPlugin(params.plugin)) {
|
||||
return false;
|
||||
}
|
||||
if (params.startupDreamingPluginIds.has(params.plugin.id)) {
|
||||
return true;
|
||||
}
|
||||
return params.explicitMemorySlotStartupPluginId === params.plugin.id;
|
||||
}
|
||||
|
||||
export function resolveChannelPluginIds(params: {
|
||||
config: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): string[] {
|
||||
return loadPluginManifestRegistry({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
})
|
||||
.plugins.filter((plugin) => plugin.channels.length > 0)
|
||||
.map((plugin) => plugin.id);
|
||||
}
|
||||
|
||||
export function resolveConfiguredChannelPluginIds(params: {
|
||||
config: OpenClawConfig;
|
||||
activationSourceConfig?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): string[] {
|
||||
const configuredChannelIds = new Set(
|
||||
listConfiguredChannelIdsForPluginScope({
|
||||
config: params.config,
|
||||
activationSourceConfig: params.activationSourceConfig,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
}).map((id) => id.trim()),
|
||||
);
|
||||
if (configuredChannelIds.size === 0) {
|
||||
return [];
|
||||
}
|
||||
return resolveScopedChannelPluginIds({
|
||||
...params,
|
||||
channelIds: [...configuredChannelIds],
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveConfiguredDeferredChannelPluginIds(params: {
|
||||
config: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): string[] {
|
||||
const configuredChannelIds = new Set(
|
||||
listPotentialConfiguredChannelIds(params.config, params.env).map((id) => id.trim()),
|
||||
);
|
||||
if (configuredChannelIds.size === 0) {
|
||||
return [];
|
||||
}
|
||||
return loadPluginManifestRegistry({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
})
|
||||
.plugins.filter(
|
||||
(plugin) =>
|
||||
plugin.channels.some((channelId) => configuredChannelIds.has(channelId)) &&
|
||||
plugin.startupDeferConfiguredChannelFullLoadUntilAfterListen === true,
|
||||
)
|
||||
.map((plugin) => plugin.id);
|
||||
}
|
||||
|
||||
export function resolveGatewayStartupPluginIds(params: {
|
||||
config: OpenClawConfig;
|
||||
activationSourceConfig?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): string[] {
|
||||
const configuredChannelIds = new Set(
|
||||
listPotentialConfiguredChannelIds(params.config, params.env).map((id) => id.trim()),
|
||||
);
|
||||
const pluginsConfig = normalizePluginsConfig(params.config.plugins);
|
||||
// Startup must classify allowlist exceptions against the raw config snapshot,
|
||||
// not the auto-enabled effective snapshot, or configured-only channels can be
|
||||
// misclassified as explicit enablement.
|
||||
const activationSource = createPluginActivationSource({
|
||||
config: params.activationSourceConfig ?? params.config,
|
||||
});
|
||||
const requiredAgentHarnessPluginIds = new Set(
|
||||
collectConfiguredAgentHarnessRuntimes(
|
||||
params.activationSourceConfig ?? params.config,
|
||||
params.env,
|
||||
).flatMap((runtime) =>
|
||||
resolveManifestActivationPluginIds({
|
||||
trigger: {
|
||||
kind: "agentHarness",
|
||||
runtime,
|
||||
},
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
cache: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const startupDreamingPluginIds = resolveGatewayStartupDreamingPluginIds(params.config);
|
||||
const explicitMemorySlotStartupPluginId = resolveExplicitMemorySlotStartupPluginId(
|
||||
params.activationSourceConfig ?? params.config,
|
||||
);
|
||||
return loadPluginManifestRegistry({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
})
|
||||
.plugins.filter((plugin) => {
|
||||
if (plugin.channels.some((channelId) => configuredChannelIds.has(channelId))) {
|
||||
return true;
|
||||
}
|
||||
if (requiredAgentHarnessPluginIds.has(plugin.id)) {
|
||||
const activationState = resolveEffectivePluginActivationState({
|
||||
id: plugin.id,
|
||||
origin: plugin.origin,
|
||||
config: pluginsConfig,
|
||||
rootConfig: params.config,
|
||||
enabledByDefault: plugin.enabledByDefault,
|
||||
activationSource,
|
||||
});
|
||||
return activationState.enabled;
|
||||
}
|
||||
if (
|
||||
!shouldConsiderForGatewayStartup({
|
||||
plugin,
|
||||
startupDreamingPluginIds,
|
||||
explicitMemorySlotStartupPluginId,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const activationState = resolveEffectivePluginActivationState({
|
||||
id: plugin.id,
|
||||
origin: plugin.origin,
|
||||
config: pluginsConfig,
|
||||
rootConfig: params.config,
|
||||
enabledByDefault: plugin.enabledByDefault,
|
||||
activationSource,
|
||||
});
|
||||
if (!activationState.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (plugin.origin !== "bundled") {
|
||||
return activationState.explicitlyEnabled;
|
||||
}
|
||||
return activationState.source === "explicit" || activationState.source === "default";
|
||||
})
|
||||
.map((plugin) => plugin.id);
|
||||
}
|
||||
export {
|
||||
resolveChannelPluginIds,
|
||||
resolveConfiguredDeferredChannelPluginIds,
|
||||
resolveGatewayStartupPluginIds,
|
||||
} from "./gateway-startup-plugin-ids.js";
|
||||
|
||||
525
src/plugins/channel-presence-policy.ts
Normal file
525
src/plugins/channel-presence-policy.ts
Normal file
@@ -0,0 +1,525 @@
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import {
|
||||
hasMeaningfulChannelConfig,
|
||||
listPotentialConfiguredChannelPresenceSignals,
|
||||
type ChannelPresenceSignalSource,
|
||||
} from "../channels/config-presence.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { isSafeChannelEnvVarTriggerName } from "../secrets/channel-env-var-names.js";
|
||||
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
|
||||
import { resolveManifestActivationPluginIds } from "./activation-planner.js";
|
||||
import {
|
||||
createPluginActivationSource,
|
||||
normalizePluginsConfig,
|
||||
resolveEffectivePluginActivationState,
|
||||
} from "./config-state.js";
|
||||
import {
|
||||
hasExplicitManifestOwnerTrust,
|
||||
isActivatedManifestOwner,
|
||||
isBundledManifestOwner,
|
||||
passesManifestOwnerBasePolicy,
|
||||
} from "./manifest-owner-policy.js";
|
||||
import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js";
|
||||
|
||||
const IGNORED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]);
|
||||
|
||||
export type ConfiguredChannelPresenceSource =
|
||||
| "explicit-config"
|
||||
| Exclude<ChannelPresenceSignalSource, "config">
|
||||
| "manifest-env";
|
||||
|
||||
export type ConfiguredChannelBlockedReason =
|
||||
| "plugins-disabled"
|
||||
| "blocked-by-denylist"
|
||||
| "plugin-disabled"
|
||||
| "not-in-allowlist"
|
||||
| "workspace-disabled-by-default"
|
||||
| "bundled-disabled-by-default"
|
||||
| "untrusted-plugin"
|
||||
| "no-channel-owner"
|
||||
| "not-activated";
|
||||
|
||||
export type ConfiguredChannelPresencePolicyEntry = {
|
||||
channelId: string;
|
||||
sources: ConfiguredChannelPresenceSource[];
|
||||
effective: boolean;
|
||||
pluginIds: string[];
|
||||
blockedReasons: ConfiguredChannelBlockedReason[];
|
||||
};
|
||||
|
||||
function dedupeSortedPluginIds(values: Iterable<string>): string[] {
|
||||
return [...new Set(values)].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function normalizeChannelIds(channelIds: Iterable<string>): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
[...channelIds]
|
||||
.map((channelId) => normalizeOptionalLowercaseString(channelId))
|
||||
.filter((channelId): channelId is string => Boolean(channelId)),
|
||||
),
|
||||
).toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function hasNonEmptyEnvValue(env: NodeJS.ProcessEnv, key: string): boolean {
|
||||
if (!isSafeChannelEnvVarTriggerName(key)) {
|
||||
return false;
|
||||
}
|
||||
const trimmed = key.trim();
|
||||
const value = env[trimmed] ?? env[trimmed.toUpperCase()];
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
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 listManifestEnvConfiguredChannelSignals(params: {
|
||||
records: readonly PluginManifestRecord[];
|
||||
activationSourceConfig?: OpenClawConfig;
|
||||
config: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Array<{ channelId: string; source: "manifest-env" }> {
|
||||
const signals: Array<{ channelId: string; source: "manifest-env" }> = [];
|
||||
const seen = 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))) {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(channelId)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(channelId);
|
||||
signals.push({ channelId, source: "manifest-env" });
|
||||
}
|
||||
}
|
||||
return signals.toSorted((left, right) => left.channelId.localeCompare(right.channelId));
|
||||
}
|
||||
|
||||
function normalizeActivationBlockedReason(reason?: string): ConfiguredChannelBlockedReason {
|
||||
switch (reason) {
|
||||
case "plugins disabled":
|
||||
return "plugins-disabled";
|
||||
case "blocked by denylist":
|
||||
return "blocked-by-denylist";
|
||||
case "disabled in config":
|
||||
return "plugin-disabled";
|
||||
case "not in allowlist":
|
||||
return "not-in-allowlist";
|
||||
case "workspace plugin (disabled by default)":
|
||||
return "workspace-disabled-by-default";
|
||||
case "bundled (disabled by default)":
|
||||
return "bundled-disabled-by-default";
|
||||
default:
|
||||
return "not-activated";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBasePolicyBlockedReason(params: {
|
||||
plugin: Pick<PluginManifestRecord, "id">;
|
||||
normalizedConfig: ReturnType<typeof normalizePluginsConfig>;
|
||||
}): ConfiguredChannelBlockedReason | null {
|
||||
if (!params.normalizedConfig.enabled) {
|
||||
return "plugins-disabled";
|
||||
}
|
||||
if (params.normalizedConfig.deny.includes(params.plugin.id)) {
|
||||
return "blocked-by-denylist";
|
||||
}
|
||||
if (params.normalizedConfig.entries[params.plugin.id]?.enabled === false) {
|
||||
return "plugin-disabled";
|
||||
}
|
||||
if (
|
||||
params.normalizedConfig.allow.length > 0 &&
|
||||
!params.normalizedConfig.allow.includes(params.plugin.id)
|
||||
) {
|
||||
return "not-in-allowlist";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isChannelPluginEligibleForScopedOwnership(params: {
|
||||
plugin: PluginManifestRecord;
|
||||
normalizedConfig: ReturnType<typeof normalizePluginsConfig>;
|
||||
rootConfig: OpenClawConfig;
|
||||
}): boolean {
|
||||
if (
|
||||
!passesManifestOwnerBasePolicy({
|
||||
plugin: params.plugin,
|
||||
normalizedConfig: params.normalizedConfig,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (isBundledManifestOwner(params.plugin)) {
|
||||
return true;
|
||||
}
|
||||
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.rootConfig,
|
||||
});
|
||||
}
|
||||
|
||||
function evaluateEffectiveChannelPlugin(params: {
|
||||
plugin: PluginManifestRecord;
|
||||
channelId: string;
|
||||
normalizedConfig: ReturnType<typeof normalizePluginsConfig>;
|
||||
config: OpenClawConfig;
|
||||
activationSource: ReturnType<typeof createPluginActivationSource>;
|
||||
}): { effective: boolean; pluginId: string; blockedReason?: ConfiguredChannelBlockedReason } {
|
||||
const baseBlockedReason = resolveBasePolicyBlockedReason({
|
||||
plugin: params.plugin,
|
||||
normalizedConfig: params.normalizedConfig,
|
||||
});
|
||||
if (baseBlockedReason) {
|
||||
return {
|
||||
effective: false,
|
||||
pluginId: params.plugin.id,
|
||||
blockedReason: baseBlockedReason,
|
||||
};
|
||||
}
|
||||
|
||||
if (!isBundledManifestOwner(params.plugin)) {
|
||||
if (params.plugin.origin === "global" || params.plugin.origin === "config") {
|
||||
const trusted = hasExplicitManifestOwnerTrust({
|
||||
plugin: params.plugin,
|
||||
normalizedConfig: params.normalizedConfig,
|
||||
});
|
||||
return trusted
|
||||
? { effective: true, pluginId: params.plugin.id }
|
||||
: {
|
||||
effective: false,
|
||||
pluginId: params.plugin.id,
|
||||
blockedReason: "untrusted-plugin",
|
||||
};
|
||||
}
|
||||
const activated = isActivatedManifestOwner({
|
||||
plugin: params.plugin,
|
||||
normalizedConfig: params.normalizedConfig,
|
||||
rootConfig: params.activationSource.rootConfig,
|
||||
});
|
||||
return activated
|
||||
? { effective: true, pluginId: params.plugin.id }
|
||||
: {
|
||||
effective: false,
|
||||
pluginId: params.plugin.id,
|
||||
blockedReason: "untrusted-plugin",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
hasExplicitChannelConfig({
|
||||
config: params.activationSource.rootConfig ?? params.config,
|
||||
channelId: params.channelId,
|
||||
})
|
||||
) {
|
||||
return { effective: true, pluginId: params.plugin.id };
|
||||
}
|
||||
|
||||
const activationState = resolveEffectivePluginActivationState({
|
||||
id: params.plugin.id,
|
||||
origin: params.plugin.origin,
|
||||
config: params.normalizedConfig,
|
||||
rootConfig: params.config,
|
||||
enabledByDefault: params.plugin.enabledByDefault,
|
||||
activationSource: params.activationSource,
|
||||
});
|
||||
return activationState.enabled
|
||||
? { effective: true, pluginId: params.plugin.id }
|
||||
: {
|
||||
effective: false,
|
||||
pluginId: params.plugin.id,
|
||||
blockedReason: normalizeActivationBlockedReason(activationState.reason),
|
||||
};
|
||||
}
|
||||
|
||||
function addPolicySignal(
|
||||
entries: Map<string, Set<ConfiguredChannelPresenceSource>>,
|
||||
channelId: string,
|
||||
source: ConfiguredChannelPresenceSource,
|
||||
) {
|
||||
const normalized = normalizeOptionalLowercaseString(channelId);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
let sources = entries.get(normalized);
|
||||
if (!sources) {
|
||||
sources = new Set();
|
||||
entries.set(normalized, sources);
|
||||
}
|
||||
sources.add(source);
|
||||
}
|
||||
|
||||
export function resolveConfiguredChannelPresencePolicy(params: {
|
||||
config: OpenClawConfig;
|
||||
activationSourceConfig?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
cache?: boolean;
|
||||
includePersistedAuthState?: boolean;
|
||||
manifestRecords?: readonly PluginManifestRecord[];
|
||||
}): ConfiguredChannelPresencePolicyEntry[] {
|
||||
const env = params.env ?? process.env;
|
||||
const workspaceDir =
|
||||
params.workspaceDir ??
|
||||
resolveAgentWorkspaceDir(params.config, resolveDefaultAgentId(params.config));
|
||||
const records =
|
||||
params.manifestRecords ??
|
||||
loadPluginManifestRegistry({
|
||||
config: params.config,
|
||||
workspaceDir,
|
||||
env,
|
||||
cache: params.cache,
|
||||
}).plugins;
|
||||
|
||||
const entrySources = new Map<string, Set<ConfiguredChannelPresenceSource>>();
|
||||
for (const channelId of listExplicitConfiguredChannelIdsForConfig(params.config)) {
|
||||
addPolicySignal(entrySources, channelId, "explicit-config");
|
||||
}
|
||||
for (const signal of listPotentialConfiguredChannelPresenceSignals(params.config, env, {
|
||||
includePersistedAuthState: params.includePersistedAuthState,
|
||||
})) {
|
||||
if (signal.source === "config") {
|
||||
continue;
|
||||
}
|
||||
addPolicySignal(entrySources, signal.channelId, signal.source);
|
||||
}
|
||||
for (const signal of listManifestEnvConfiguredChannelSignals({
|
||||
records,
|
||||
config: params.config,
|
||||
activationSourceConfig: params.activationSourceConfig,
|
||||
env,
|
||||
})) {
|
||||
addPolicySignal(entrySources, signal.channelId, signal.source);
|
||||
}
|
||||
|
||||
const activationSource = createPluginActivationSource({
|
||||
config: params.activationSourceConfig ?? params.config,
|
||||
});
|
||||
const normalizedConfig = activationSource.plugins;
|
||||
const entries: ConfiguredChannelPresencePolicyEntry[] = [];
|
||||
for (const channelId of normalizeChannelIds(entrySources.keys())) {
|
||||
const owningRecords = records.filter((record) => recordOwnsChannel(record, channelId));
|
||||
const evaluations = owningRecords.map((plugin) =>
|
||||
evaluateEffectiveChannelPlugin({
|
||||
plugin,
|
||||
channelId,
|
||||
normalizedConfig,
|
||||
config: params.config,
|
||||
activationSource,
|
||||
}),
|
||||
);
|
||||
const effectivePluginIds = evaluations
|
||||
.filter((entry) => entry.effective)
|
||||
.map((entry) => entry.pluginId);
|
||||
const blockedReasons =
|
||||
owningRecords.length === 0
|
||||
? ["no-channel-owner" as const]
|
||||
: [
|
||||
...new Set(
|
||||
evaluations
|
||||
.map((entry) => entry.blockedReason)
|
||||
.filter((reason): reason is ConfiguredChannelBlockedReason => Boolean(reason)),
|
||||
),
|
||||
].toSorted((left, right) => left.localeCompare(right));
|
||||
entries.push({
|
||||
channelId,
|
||||
sources: [...(entrySources.get(channelId) ?? [])].toSorted((left, right) =>
|
||||
left.localeCompare(right),
|
||||
),
|
||||
effective: effectivePluginIds.length > 0,
|
||||
pluginIds: dedupeSortedPluginIds(effectivePluginIds),
|
||||
blockedReasons,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function listConfiguredChannelIdsForReadOnlyScope(
|
||||
params: Parameters<typeof resolveConfiguredChannelPresencePolicy>[0],
|
||||
): string[] {
|
||||
return resolveConfiguredChannelPresencePolicy(params)
|
||||
.filter((entry) => entry.effective)
|
||||
.map((entry) => entry.channelId);
|
||||
}
|
||||
|
||||
export function hasConfiguredChannelsForReadOnlyScope(
|
||||
params: Parameters<typeof resolveConfiguredChannelPresencePolicy>[0],
|
||||
): boolean {
|
||||
return listConfiguredChannelIdsForReadOnlyScope(params).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 resolveScopedChannelOwnerPluginIds(params: {
|
||||
config: OpenClawConfig;
|
||||
activationSourceConfig?: OpenClawConfig;
|
||||
channelIds: readonly string[];
|
||||
workspaceDir?: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
cache?: boolean;
|
||||
}): string[] {
|
||||
const channelIds = normalizeChannelIds(params.channelIds);
|
||||
if (channelIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const registry = loadPluginManifestRegistry({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
cache: params.cache,
|
||||
});
|
||||
const trustConfig = params.activationSourceConfig ?? params.config;
|
||||
const normalizedConfig = normalizePluginsConfig(trustConfig.plugins);
|
||||
const candidateIds = dedupeSortedPluginIds(
|
||||
channelIds.flatMap((channelId) => {
|
||||
return resolveManifestActivationPluginIds({
|
||||
trigger: {
|
||||
kind: "channel",
|
||||
channel: channelId,
|
||||
},
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
cache: params.cache,
|
||||
});
|
||||
}),
|
||||
);
|
||||
if (candidateIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const candidateIdSet = new Set(candidateIds);
|
||||
return registry.plugins
|
||||
.filter((plugin) => {
|
||||
if (!candidateIdSet.has(plugin.id)) {
|
||||
return false;
|
||||
}
|
||||
return isChannelPluginEligibleForScopedOwnership({
|
||||
plugin,
|
||||
normalizedConfig,
|
||||
rootConfig: trustConfig,
|
||||
});
|
||||
})
|
||||
.map((plugin) => plugin.id)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function resolveDiscoverableScopedChannelPluginIds(params: {
|
||||
config: OpenClawConfig;
|
||||
activationSourceConfig?: OpenClawConfig;
|
||||
channelIds: readonly string[];
|
||||
workspaceDir?: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
cache?: boolean;
|
||||
}): string[] {
|
||||
return resolveScopedChannelOwnerPluginIds(params);
|
||||
}
|
||||
|
||||
export function resolveConfiguredChannelPluginIds(params: {
|
||||
config: OpenClawConfig;
|
||||
activationSourceConfig?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): string[] {
|
||||
const configuredChannelIds = listConfiguredChannelIdsForReadOnlyScope({
|
||||
config: params.config,
|
||||
activationSourceConfig: params.activationSourceConfig,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
});
|
||||
if (configuredChannelIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return resolveScopedChannelOwnerPluginIds({
|
||||
...params,
|
||||
channelIds: configuredChannelIds,
|
||||
});
|
||||
}
|
||||
200
src/plugins/gateway-startup-plugin-ids.ts
Normal file
200
src/plugins/gateway-startup-plugin-ids.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { collectConfiguredAgentHarnessRuntimes } from "../agents/harness-runtimes.js";
|
||||
import { listPotentialConfiguredChannelIds } from "../channels/config-presence.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
resolveMemoryDreamingConfig,
|
||||
resolveMemoryDreamingPluginConfig,
|
||||
resolveMemoryDreamingPluginId,
|
||||
} from "../memory-host-sdk/dreaming.js";
|
||||
import { resolveManifestActivationPluginIds } from "./activation-planner.js";
|
||||
import {
|
||||
createPluginActivationSource,
|
||||
normalizePluginId,
|
||||
normalizePluginsConfig,
|
||||
resolveEffectivePluginActivationState,
|
||||
} from "./config-state.js";
|
||||
import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js";
|
||||
import { hasKind } from "./slots.js";
|
||||
|
||||
function hasRuntimeContractSurface(plugin: PluginManifestRecord): boolean {
|
||||
return Boolean(
|
||||
plugin.providers.length > 0 ||
|
||||
plugin.cliBackends.length > 0 ||
|
||||
plugin.contracts?.speechProviders?.length ||
|
||||
plugin.contracts?.mediaUnderstandingProviders?.length ||
|
||||
plugin.contracts?.imageGenerationProviders?.length ||
|
||||
plugin.contracts?.videoGenerationProviders?.length ||
|
||||
plugin.contracts?.musicGenerationProviders?.length ||
|
||||
plugin.contracts?.webFetchProviders?.length ||
|
||||
plugin.contracts?.webSearchProviders?.length ||
|
||||
plugin.contracts?.memoryEmbeddingProviders?.length ||
|
||||
hasKind(plugin.kind, "memory"),
|
||||
);
|
||||
}
|
||||
|
||||
function isGatewayStartupMemoryPlugin(plugin: PluginManifestRecord): boolean {
|
||||
return hasKind(plugin.kind, "memory");
|
||||
}
|
||||
|
||||
function isGatewayStartupSidecar(plugin: PluginManifestRecord): boolean {
|
||||
return plugin.channels.length === 0 && !hasRuntimeContractSurface(plugin);
|
||||
}
|
||||
|
||||
function resolveGatewayStartupDreamingPluginIds(config: OpenClawConfig): Set<string> {
|
||||
const dreamingConfig = resolveMemoryDreamingConfig({
|
||||
pluginConfig: resolveMemoryDreamingPluginConfig(config),
|
||||
cfg: config,
|
||||
});
|
||||
if (!dreamingConfig.enabled) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(["memory-core", resolveMemoryDreamingPluginId(config)]);
|
||||
}
|
||||
|
||||
function resolveExplicitMemorySlotStartupPluginId(config: OpenClawConfig): string | undefined {
|
||||
const configuredSlot = config.plugins?.slots?.memory?.trim();
|
||||
if (!configuredSlot || configuredSlot.toLowerCase() === "none") {
|
||||
return undefined;
|
||||
}
|
||||
return normalizePluginId(configuredSlot);
|
||||
}
|
||||
|
||||
function shouldConsiderForGatewayStartup(params: {
|
||||
plugin: PluginManifestRecord;
|
||||
startupDreamingPluginIds: ReadonlySet<string>;
|
||||
explicitMemorySlotStartupPluginId?: string;
|
||||
}): boolean {
|
||||
if (isGatewayStartupSidecar(params.plugin)) {
|
||||
return true;
|
||||
}
|
||||
if (!isGatewayStartupMemoryPlugin(params.plugin)) {
|
||||
return false;
|
||||
}
|
||||
if (params.startupDreamingPluginIds.has(params.plugin.id)) {
|
||||
return true;
|
||||
}
|
||||
return params.explicitMemorySlotStartupPluginId === params.plugin.id;
|
||||
}
|
||||
|
||||
export function resolveChannelPluginIds(params: {
|
||||
config: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): string[] {
|
||||
return loadPluginManifestRegistry({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
})
|
||||
.plugins.filter((plugin) => plugin.channels.length > 0)
|
||||
.map((plugin) => plugin.id);
|
||||
}
|
||||
|
||||
export function resolveConfiguredDeferredChannelPluginIds(params: {
|
||||
config: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): string[] {
|
||||
const configuredChannelIds = new Set(
|
||||
listPotentialConfiguredChannelIds(params.config, params.env).map((id) => id.trim()),
|
||||
);
|
||||
if (configuredChannelIds.size === 0) {
|
||||
return [];
|
||||
}
|
||||
return loadPluginManifestRegistry({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
})
|
||||
.plugins.filter(
|
||||
(plugin) =>
|
||||
plugin.channels.some((channelId) => configuredChannelIds.has(channelId)) &&
|
||||
plugin.startupDeferConfiguredChannelFullLoadUntilAfterListen === true,
|
||||
)
|
||||
.map((plugin) => plugin.id);
|
||||
}
|
||||
|
||||
export function resolveGatewayStartupPluginIds(params: {
|
||||
config: OpenClawConfig;
|
||||
activationSourceConfig?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): string[] {
|
||||
const configuredChannelIds = new Set(
|
||||
listPotentialConfiguredChannelIds(params.config, params.env).map((id) => id.trim()),
|
||||
);
|
||||
const pluginsConfig = normalizePluginsConfig(params.config.plugins);
|
||||
// Startup must classify allowlist exceptions against the raw config snapshot,
|
||||
// not the auto-enabled effective snapshot, or configured-only channels can be
|
||||
// misclassified as explicit enablement.
|
||||
const activationSource = createPluginActivationSource({
|
||||
config: params.activationSourceConfig ?? params.config,
|
||||
});
|
||||
const requiredAgentHarnessPluginIds = new Set(
|
||||
collectConfiguredAgentHarnessRuntimes(
|
||||
params.activationSourceConfig ?? params.config,
|
||||
params.env,
|
||||
).flatMap((runtime) =>
|
||||
resolveManifestActivationPluginIds({
|
||||
trigger: {
|
||||
kind: "agentHarness",
|
||||
runtime,
|
||||
},
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
cache: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const startupDreamingPluginIds = resolveGatewayStartupDreamingPluginIds(params.config);
|
||||
const explicitMemorySlotStartupPluginId = resolveExplicitMemorySlotStartupPluginId(
|
||||
params.activationSourceConfig ?? params.config,
|
||||
);
|
||||
return loadPluginManifestRegistry({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
})
|
||||
.plugins.filter((plugin) => {
|
||||
if (plugin.channels.some((channelId) => configuredChannelIds.has(channelId))) {
|
||||
return true;
|
||||
}
|
||||
if (requiredAgentHarnessPluginIds.has(plugin.id)) {
|
||||
const activationState = resolveEffectivePluginActivationState({
|
||||
id: plugin.id,
|
||||
origin: plugin.origin,
|
||||
config: pluginsConfig,
|
||||
rootConfig: params.config,
|
||||
enabledByDefault: plugin.enabledByDefault,
|
||||
activationSource,
|
||||
});
|
||||
return activationState.enabled;
|
||||
}
|
||||
if (
|
||||
!shouldConsiderForGatewayStartup({
|
||||
plugin,
|
||||
startupDreamingPluginIds,
|
||||
explicitMemorySlotStartupPluginId,
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const activationState = resolveEffectivePluginActivationState({
|
||||
id: plugin.id,
|
||||
origin: plugin.origin,
|
||||
config: pluginsConfig,
|
||||
rootConfig: params.config,
|
||||
enabledByDefault: plugin.enabledByDefault,
|
||||
activationSource,
|
||||
});
|
||||
if (!activationState.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (plugin.origin !== "bundled") {
|
||||
return activationState.explicitlyEnabled;
|
||||
}
|
||||
return activationState.source === "explicit" || activationState.source === "default";
|
||||
})
|
||||
.map((plugin) => plugin.id);
|
||||
}
|
||||
Reference in New Issue
Block a user