mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:50:43 +00:00
Fix channel presence gating for disabled plugins (#69862)
Merged via squash.
Prepared head SHA: f76f6212b2
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
committed by
GitHub
parent
eb6006730d
commit
f4478a142a
@@ -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: resolve channel presence through a shared policy that keeps ambient 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,7 +361,7 @@ export function resolveReadOnlyChannelPluginsForConfig(
|
||||
env,
|
||||
cache: options.cache,
|
||||
includePersistedAuthState: options.includePersistedAuthState,
|
||||
manifestRecords: externalManifestRecords,
|
||||
manifestRecords,
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -211,6 +211,7 @@ export async function channelsStatusCommand(
|
||||
},
|
||||
configuredChannels: listConfiguredChannelIdsForReadOnlyScope({
|
||||
config: resolvedConfig,
|
||||
activationSourceConfig: cfg,
|
||||
env: process.env,
|
||||
includePersistedAuthState: false,
|
||||
}),
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock("../channels/plugins/bundled-ids.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/persisted-auth-state.js", () => ({
|
||||
listBundledChannelIdsWithPersistedAuthState: () => ["matrix", "whatsapp"],
|
||||
hasBundledChannelPersistedAuthState: () => false,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -2,12 +2,25 @@ 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) => {
|
||||
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,
|
||||
listPotentialConfiguredChannelPresenceSignals,
|
||||
hasPotentialConfiguredChannels,
|
||||
hasMeaningfulChannelConfig,
|
||||
}));
|
||||
|
||||
vi.mock("./manifest-registry.js", async (importOriginal) => {
|
||||
@@ -20,7 +33,11 @@ vi.mock("./manifest-registry.js", async (importOriginal) => {
|
||||
|
||||
import {
|
||||
hasConfiguredChannelsForReadOnlyScope,
|
||||
listConfiguredAnnounceChannelIdsForConfig,
|
||||
listConfiguredChannelIdsForReadOnlyScope,
|
||||
listExplicitConfiguredChannelIdsForConfig,
|
||||
resolveConfiguredChannelPresencePolicy,
|
||||
resolveConfiguredDeferredChannelPluginIds,
|
||||
resolveConfiguredChannelPluginIds,
|
||||
resolveGatewayStartupPluginIds,
|
||||
} from "./channel-plugin-ids.js";
|
||||
@@ -165,6 +182,25 @@ function createManifestRegistryFixture() {
|
||||
};
|
||||
}
|
||||
|
||||
function createManifestRegistryFixtureWithWorkspaceDemoChannel() {
|
||||
const fixture = createManifestRegistryFixture();
|
||||
return {
|
||||
...fixture,
|
||||
plugins: [
|
||||
...fixture.plugins,
|
||||
{
|
||||
id: "workspace-demo-channel-plugin",
|
||||
channels: ["demo-channel"],
|
||||
startupDeferConfiguredChannelFullLoadUntilAfterListen: true,
|
||||
origin: "workspace",
|
||||
enabledByDefault: undefined,
|
||||
providers: [],
|
||||
cliBackends: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function expectStartupPluginIds(params: {
|
||||
config: OpenClawConfig;
|
||||
activationSourceConfig?: OpenClawConfig;
|
||||
@@ -316,6 +352,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;
|
||||
@@ -394,6 +438,74 @@ describe("resolveGatewayStartupPluginIds", () => {
|
||||
expectStartupPluginIdsCase({
|
||||
config: effectiveConfig,
|
||||
activationSourceConfig: rawConfig,
|
||||
expected: ["browser"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let weak channel presence start untrusted workspace channel owners", () => {
|
||||
loadPluginManifestRegistry
|
||||
.mockReset()
|
||||
.mockReturnValue(createManifestRegistryFixtureWithWorkspaceDemoChannel());
|
||||
listPotentialConfiguredChannelIds.mockReturnValue(["demo-channel"]);
|
||||
listPotentialConfiguredChannelPresenceSignals.mockReturnValue([
|
||||
{ channelId: "demo-channel", source: "env" },
|
||||
]);
|
||||
|
||||
const config = {} as OpenClawConfig;
|
||||
|
||||
expectStartupPluginIdsCase({
|
||||
config,
|
||||
env: {
|
||||
DEMO_CHANNEL_ANYTHING: "1",
|
||||
} as NodeJS.ProcessEnv,
|
||||
expected: ["demo-channel", "browser"],
|
||||
});
|
||||
expect(
|
||||
resolveConfiguredDeferredChannelPluginIds({
|
||||
config,
|
||||
workspaceDir: "/tmp",
|
||||
env: {
|
||||
DEMO_CHANNEL_ANYTHING: "1",
|
||||
} as NodeJS.ProcessEnv,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps explicitly trusted deferred channel owners eligible at startup", () => {
|
||||
loadPluginManifestRegistry
|
||||
.mockReset()
|
||||
.mockReturnValue(createManifestRegistryFixtureWithWorkspaceDemoChannel());
|
||||
expect(
|
||||
resolveConfiguredDeferredChannelPluginIds({
|
||||
config: {
|
||||
channels: {
|
||||
"demo-channel": {
|
||||
token: "configured",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
allow: ["workspace-demo-channel-plugin"],
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
workspaceDir: "/tmp",
|
||||
env: {},
|
||||
}),
|
||||
).toEqual(["workspace-demo-channel-plugin"]);
|
||||
});
|
||||
|
||||
it("preserves explicit bundled channel config under restrictive allowlists", () => {
|
||||
expectStartupPluginIdsCase({
|
||||
config: {
|
||||
channels: {
|
||||
"demo-channel": {
|
||||
token: "configured",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
allow: ["browser"],
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
env: {},
|
||||
expected: ["demo-channel", "browser"],
|
||||
});
|
||||
});
|
||||
@@ -489,6 +601,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;
|
||||
@@ -523,6 +643,25 @@ describe("resolveConfiguredChannelPluginIds", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps explicitly configured bundled channel owners under restrictive allowlists", () => {
|
||||
expect(
|
||||
resolveConfiguredChannelPluginIds({
|
||||
config: {
|
||||
channels: {
|
||||
"demo-channel": {
|
||||
token: "configured",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
allow: ["browser"],
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
workspaceDir: "/tmp",
|
||||
env: {},
|
||||
}),
|
||||
).toEqual(["demo-channel"]);
|
||||
});
|
||||
|
||||
it("blocks bundled activation owners when explicitly denied", () => {
|
||||
expect(
|
||||
resolveConfiguredChannelPluginIds({
|
||||
@@ -651,10 +790,387 @@ describe("resolveConfiguredChannelPluginIds", () => {
|
||||
describe("listConfiguredChannelIdsForReadOnlyScope", () => {
|
||||
beforeEach(() => {
|
||||
listPotentialConfiguredChannelIds.mockReset().mockReturnValue([]);
|
||||
listPotentialConfiguredChannelPresenceSignals.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"]);
|
||||
listPotentialConfiguredChannelPresenceSignals.mockReturnValue([
|
||||
{ channelId: "demo-channel", source: "env" },
|
||||
]);
|
||||
|
||||
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("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({
|
||||
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("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("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([
|
||||
{ channelId: "demo-channel", source: "config" },
|
||||
]);
|
||||
|
||||
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"]);
|
||||
listPotentialConfiguredChannelPresenceSignals.mockReturnValue([
|
||||
{ channelId: "demo-channel", source: "config" },
|
||||
]);
|
||||
|
||||
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("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([
|
||||
{ channelId: "demo-channel", source: "env" },
|
||||
{ channelId: "demo-other-channel", source: "config" },
|
||||
]);
|
||||
|
||||
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("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({
|
||||
@@ -777,6 +1293,7 @@ describe("listConfiguredChannelIdsForReadOnlyScope", () => {
|
||||
|
||||
it("uses manifest env vars for read-only channel presence checks", () => {
|
||||
listPotentialConfiguredChannelIds.mockReturnValue([]);
|
||||
listPotentialConfiguredChannelPresenceSignals.mockReturnValue([]);
|
||||
hasPotentialConfiguredChannels.mockReturnValue(false);
|
||||
|
||||
expect(
|
||||
|
||||
@@ -1,472 +1,19 @@
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { collectConfiguredAgentHarnessRuntimes } from "../agents/harness-runtimes.js";
|
||||
import {
|
||||
hasPotentialConfiguredChannels,
|
||||
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));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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;
|
||||
return [
|
||||
...new Set([
|
||||
...listPotentialConfiguredChannelIds(params.config, params.env, {
|
||||
includePersistedAuthState: params.includePersistedAuthState,
|
||||
}),
|
||||
...listEnvConfiguredManifestChannelIds({
|
||||
records,
|
||||
config: params.config,
|
||||
activationSourceConfig: params.activationSourceConfig,
|
||||
env: params.env,
|
||||
}),
|
||||
]),
|
||||
].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
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 {
|
||||
const env = params.env ?? process.env;
|
||||
if (
|
||||
hasPotentialConfiguredChannels(params.config, env, {
|
||||
includePersistedAuthState: params.includePersistedAuthState,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
listConfiguredChannelIdsForReadOnlyScope({
|
||||
...params,
|
||||
env,
|
||||
}).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
547
src/plugins/channel-presence-policy.ts
Normal file
547
src/plugins/channel-presence-policy.ts
Normal file
@@ -0,0 +1,547 @@
|
||||
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;
|
||||
}
|
||||
const enabled = (entry as { enabled?: unknown }).enabled;
|
||||
if (enabled === false) {
|
||||
return false;
|
||||
}
|
||||
return 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 recordDeclaresChannel(record: PluginManifestRecord, channelId: string): boolean {
|
||||
const normalizedChannelId = normalizeOptionalLowercaseString(channelId) ?? "";
|
||||
if (!normalizedChannelId) {
|
||||
return false;
|
||||
}
|
||||
return record.channels.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>;
|
||||
allowRestrictiveAllowlistBypass?: boolean;
|
||||
}): 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.allowRestrictiveAllowlistBypass !== true &&
|
||||
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;
|
||||
channelId?: string;
|
||||
}): boolean {
|
||||
const allowRestrictiveAllowlistBypass =
|
||||
params.channelId !== undefined &&
|
||||
isBundledManifestOwner(params.plugin) &&
|
||||
hasExplicitChannelConfig({
|
||||
config: params.rootConfig,
|
||||
channelId: params.channelId,
|
||||
});
|
||||
if (
|
||||
!passesManifestOwnerBasePolicy({
|
||||
plugin: params.plugin,
|
||||
normalizedConfig: params.normalizedConfig,
|
||||
allowRestrictiveAllowlistBypass,
|
||||
})
|
||||
) {
|
||||
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 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 {
|
||||
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 (explicitBundledChannelConfig) {
|
||||
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) => recordDeclaresChannel(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]) => normalizeOptionalLowercaseString(channelId))
|
||||
.filter((channelId): channelId is string => Boolean(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,
|
||||
channelId: channelIds.find((channelId) => recordDeclaresChannel(plugin, channelId)),
|
||||
});
|
||||
})
|
||||
.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 = normalizeChannelIds([
|
||||
...listConfiguredChannelIdsForReadOnlyScope({
|
||||
config: params.config,
|
||||
activationSourceConfig: params.activationSourceConfig,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
}),
|
||||
...listExplicitConfiguredChannelIdsForConfig(params.activationSourceConfig ?? params.config),
|
||||
]);
|
||||
if (configuredChannelIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return resolveScopedChannelOwnerPluginIds({
|
||||
...params,
|
||||
channelIds: configuredChannelIds,
|
||||
});
|
||||
}
|
||||
267
src/plugins/gateway-startup-plugin-ids.ts
Normal file
267
src/plugins/gateway-startup-plugin-ids.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
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 { hasExplicitChannelConfig } from "./channel-presence-policy.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;
|
||||
}
|
||||
|
||||
function hasConfiguredStartupChannel(params: {
|
||||
plugin: PluginManifestRecord;
|
||||
configuredChannelIds: ReadonlySet<string>;
|
||||
}): boolean {
|
||||
return params.plugin.channels.some((channelId) => params.configuredChannelIds.has(channelId));
|
||||
}
|
||||
|
||||
function canStartConfiguredChannelPlugin(params: {
|
||||
plugin: PluginManifestRecord;
|
||||
config: OpenClawConfig;
|
||||
pluginsConfig: ReturnType<typeof normalizePluginsConfig>;
|
||||
activationSource: ReturnType<typeof createPluginActivationSource>;
|
||||
}): boolean {
|
||||
if (!params.pluginsConfig.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (params.pluginsConfig.deny.includes(params.plugin.id)) {
|
||||
return false;
|
||||
}
|
||||
if (params.pluginsConfig.entries[params.plugin.id]?.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
const explicitBundledChannelConfig =
|
||||
params.plugin.origin === "bundled" &&
|
||||
params.plugin.channels.some((channelId) =>
|
||||
hasExplicitChannelConfig({
|
||||
config: params.activationSource.rootConfig ?? params.config,
|
||||
channelId,
|
||||
}),
|
||||
);
|
||||
if (
|
||||
params.pluginsConfig.allow.length > 0 &&
|
||||
!params.pluginsConfig.allow.includes(params.plugin.id) &&
|
||||
!explicitBundledChannelConfig
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (params.plugin.origin === "bundled") {
|
||||
return true;
|
||||
}
|
||||
const activationState = resolveEffectivePluginActivationState({
|
||||
id: params.plugin.id,
|
||||
origin: params.plugin.origin,
|
||||
config: params.pluginsConfig,
|
||||
rootConfig: params.config,
|
||||
enabledByDefault: params.plugin.enabledByDefault,
|
||||
activationSource: params.activationSource,
|
||||
});
|
||||
return activationState.enabled && activationState.explicitlyEnabled;
|
||||
}
|
||||
|
||||
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 [];
|
||||
}
|
||||
const pluginsConfig = normalizePluginsConfig(params.config.plugins);
|
||||
const activationSource = createPluginActivationSource({
|
||||
config: params.config,
|
||||
});
|
||||
return loadPluginManifestRegistry({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
})
|
||||
.plugins.filter(
|
||||
(plugin) =>
|
||||
hasConfiguredStartupChannel({ plugin, configuredChannelIds }) &&
|
||||
plugin.startupDeferConfiguredChannelFullLoadUntilAfterListen === true &&
|
||||
canStartConfiguredChannelPlugin({
|
||||
plugin,
|
||||
config: params.config,
|
||||
pluginsConfig,
|
||||
activationSource,
|
||||
}),
|
||||
)
|
||||
.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 (hasConfiguredStartupChannel({ plugin, configuredChannelIds })) {
|
||||
return canStartConfiguredChannelPlugin({
|
||||
plugin,
|
||||
config: params.config,
|
||||
pluginsConfig,
|
||||
activationSource,
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export function passesManifestOwnerBasePolicy(params: {
|
||||
plugin: Pick<PluginManifestRecord, "id">;
|
||||
normalizedConfig: NormalizedPluginsConfig;
|
||||
allowExplicitlyDisabled?: boolean;
|
||||
allowRestrictiveAllowlistBypass?: boolean;
|
||||
}): boolean {
|
||||
if (!params.normalizedConfig.enabled) {
|
||||
return false;
|
||||
@@ -38,6 +39,7 @@ export function passesManifestOwnerBasePolicy(params: {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
params.allowRestrictiveAllowlistBypass !== true &&
|
||||
params.normalizedConfig.allow.length > 0 &&
|
||||
!params.normalizedConfig.allow.includes(params.plugin.id)
|
||||
) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user