mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-22 06:08:13 +00:00
761 lines
25 KiB
TypeScript
761 lines
25 KiB
TypeScript
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
|
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
|
import { isBlockedObjectKey } from "../../infra/prototype-keys.js";
|
|
import {
|
|
hasExplicitChannelConfig,
|
|
listConfiguredChannelIdsForReadOnlyScope,
|
|
resolveDiscoverableScopedChannelPluginIds,
|
|
} from "../../plugins/channel-plugin-ids.js";
|
|
import {
|
|
getCachedPluginJitiLoader,
|
|
type PluginJitiLoaderCache,
|
|
} from "../../plugins/jiti-loader-cache.js";
|
|
import type { PluginManifestRecord } from "../../plugins/manifest-registry.js";
|
|
import { loadPluginManifestRegistryForPluginRegistry } from "../../plugins/plugin-registry.js";
|
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
|
import { sanitizeForLog } from "../../terminal/ansi.js";
|
|
import { getBundledChannelSetupPlugin } from "./bundled.js";
|
|
import {
|
|
isSafeManifestChannelId,
|
|
normalizeChannelCommandDefaults,
|
|
readOwnRecordValue,
|
|
resolveReadOnlyChannelCommandDefaults,
|
|
} from "./read-only-command-defaults.js";
|
|
import { listChannelPlugins } from "./registry.js";
|
|
import type { ChannelPlugin } from "./types.plugin.js";
|
|
|
|
const SOURCE_PLUGIN_LOADER_MODULE_CANDIDATES = [
|
|
"../../plugins/loader.js",
|
|
"../../plugins/loader.ts",
|
|
] as const;
|
|
const BUILT_PLUGIN_LOADER_MODULE_CANDIDATES = [
|
|
"plugins/loader.js",
|
|
"plugins/build-smoke-entry.js",
|
|
] as const;
|
|
const jitiLoaders: PluginJitiLoaderCache = new Map();
|
|
|
|
type PluginLoaderModule = {
|
|
loadOpenClawPlugins: (params: {
|
|
config: OpenClawConfig;
|
|
activationSourceConfig?: OpenClawConfig;
|
|
env?: NodeJS.ProcessEnv;
|
|
workspaceDir?: string;
|
|
cache?: boolean;
|
|
activate?: boolean;
|
|
includeSetupOnlyChannelPlugins?: boolean;
|
|
forceSetupOnlyChannelPlugins?: boolean;
|
|
requireSetupEntryForSetupOnlyChannelPlugins?: boolean;
|
|
onlyPluginIds?: readonly string[];
|
|
}) => {
|
|
channelSetups: Iterable<{
|
|
pluginId: string;
|
|
plugin: ChannelPlugin;
|
|
}>;
|
|
};
|
|
};
|
|
|
|
let pluginLoaderModule: PluginLoaderModule | undefined;
|
|
|
|
function listBuiltPluginLoaderModuleCandidateUrls(importerUrl: string): URL[] {
|
|
let importerPath: string;
|
|
try {
|
|
importerPath = fileURLToPath(importerUrl);
|
|
} catch {
|
|
return [];
|
|
}
|
|
const distMarker = `${path.sep}dist${path.sep}`;
|
|
const distMarkerIndex = importerPath.lastIndexOf(distMarker);
|
|
if (distMarkerIndex < 0) {
|
|
return [];
|
|
}
|
|
// Bundled read-only chunks live under dist/ with hashed names. Source-relative
|
|
// ../../plugins candidates would escape the installed openclaw package there.
|
|
const distRoot = importerPath.slice(0, distMarkerIndex + distMarker.length - 1);
|
|
return BUILT_PLUGIN_LOADER_MODULE_CANDIDATES.map((candidate) =>
|
|
pathToFileURL(path.join(distRoot, candidate)),
|
|
);
|
|
}
|
|
|
|
export function listPluginLoaderModuleCandidateUrls(importerUrl = import.meta.url): URL[] {
|
|
const builtCandidates = listBuiltPluginLoaderModuleCandidateUrls(importerUrl);
|
|
if (builtCandidates.length > 0) {
|
|
return builtCandidates;
|
|
}
|
|
return SOURCE_PLUGIN_LOADER_MODULE_CANDIDATES.map((candidate) => new URL(candidate, importerUrl));
|
|
}
|
|
|
|
function loadPluginLoaderModule(): PluginLoaderModule {
|
|
if (pluginLoaderModule) {
|
|
return pluginLoaderModule;
|
|
}
|
|
for (const candidate of listPluginLoaderModuleCandidateUrls()) {
|
|
const modulePath = fileURLToPath(candidate);
|
|
try {
|
|
const jiti = getCachedPluginJitiLoader({
|
|
cache: jitiLoaders,
|
|
modulePath,
|
|
importerUrl: import.meta.url,
|
|
preferBuiltDist: true,
|
|
jitiFilename: import.meta.url,
|
|
tryNative: true,
|
|
});
|
|
pluginLoaderModule = jiti(modulePath) as PluginLoaderModule;
|
|
return pluginLoaderModule;
|
|
} catch {
|
|
// Try built/runtime source candidates in order.
|
|
}
|
|
}
|
|
throw new Error("Could not load plugin runtime loader for channel setup fallback.");
|
|
}
|
|
|
|
type ReadOnlyChannelPluginOptions = {
|
|
env?: NodeJS.ProcessEnv;
|
|
stateDir?: string;
|
|
workspaceDir?: string;
|
|
activationSourceConfig?: OpenClawConfig;
|
|
includePersistedAuthState?: boolean;
|
|
includeSetupFallbackPlugins?: boolean;
|
|
};
|
|
|
|
type ReadOnlyChannelPluginResolution = {
|
|
plugins: ChannelPlugin[];
|
|
configuredChannelIds: string[];
|
|
missingConfiguredChannelIds: string[];
|
|
};
|
|
type ManifestChannelConfigRecord = NonNullable<PluginManifestRecord["channelConfigs"]>[string];
|
|
|
|
function addChannelPlugins(
|
|
byId: Map<string, ChannelPlugin>,
|
|
plugins: Iterable<ChannelPlugin | undefined>,
|
|
options?: {
|
|
onlyIds?: ReadonlySet<string>;
|
|
allowOverwrite?: boolean;
|
|
},
|
|
): void {
|
|
for (const plugin of plugins) {
|
|
if (!plugin) {
|
|
continue;
|
|
}
|
|
if (options?.onlyIds && !options.onlyIds.has(plugin.id)) {
|
|
continue;
|
|
}
|
|
if (options?.allowOverwrite === false && byId.has(plugin.id)) {
|
|
continue;
|
|
}
|
|
byId.set(plugin.id, plugin);
|
|
}
|
|
}
|
|
|
|
function rebindChannelScopedString(
|
|
value: string,
|
|
sourceChannelId: string,
|
|
targetChannelId: string,
|
|
): string {
|
|
const sourcePrefix = `channels.${sourceChannelId}`;
|
|
if (value === sourcePrefix) {
|
|
return `channels.${targetChannelId}`;
|
|
}
|
|
if (value.startsWith(`${sourcePrefix}.`)) {
|
|
return `channels.${targetChannelId}${value.slice(sourcePrefix.length)}`;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function normalizeManifestText(value: string | undefined, fallback: string): string {
|
|
return sanitizeForLog(value?.trim() || fallback).trim();
|
|
}
|
|
|
|
function rebindChannelConfig(
|
|
cfg: OpenClawConfig,
|
|
sourceChannelId: string,
|
|
targetChannelId: string,
|
|
): OpenClawConfig {
|
|
if (sourceChannelId === targetChannelId || !cfg.channels) {
|
|
return cfg;
|
|
}
|
|
return {
|
|
...cfg,
|
|
channels: {
|
|
...cfg.channels,
|
|
[sourceChannelId]: (cfg.channels as Record<string, unknown>)[targetChannelId],
|
|
},
|
|
};
|
|
}
|
|
|
|
function restoreReboundChannelConfig(params: {
|
|
original: OpenClawConfig;
|
|
updated: OpenClawConfig;
|
|
sourceChannelId: string;
|
|
targetChannelId: string;
|
|
}): OpenClawConfig {
|
|
if (params.sourceChannelId === params.targetChannelId || !params.updated.channels) {
|
|
return params.updated;
|
|
}
|
|
const nextChannels = { ...params.updated.channels };
|
|
if (Object.prototype.hasOwnProperty.call(nextChannels, params.sourceChannelId)) {
|
|
nextChannels[params.targetChannelId] = nextChannels[params.sourceChannelId];
|
|
} else {
|
|
delete nextChannels[params.targetChannelId];
|
|
}
|
|
if (
|
|
params.original.channels &&
|
|
Object.prototype.hasOwnProperty.call(params.original.channels, params.sourceChannelId)
|
|
) {
|
|
nextChannels[params.sourceChannelId] = params.original.channels[params.sourceChannelId];
|
|
} else {
|
|
delete nextChannels[params.sourceChannelId];
|
|
}
|
|
return {
|
|
...params.updated,
|
|
channels: nextChannels,
|
|
};
|
|
}
|
|
|
|
function getChannelConfigRecord(cfg: OpenClawConfig, channelId: string): Record<string, unknown> {
|
|
if (!isSafeManifestChannelId(channelId)) {
|
|
return {};
|
|
}
|
|
const channels = cfg.channels;
|
|
if (!channels || typeof channels !== "object" || Array.isArray(channels)) {
|
|
return {};
|
|
}
|
|
const entry = readOwnRecordValue(channels as Record<string, unknown>, channelId);
|
|
return entry && typeof entry === "object" && !Array.isArray(entry)
|
|
? (entry as Record<string, unknown>)
|
|
: {};
|
|
}
|
|
|
|
function listManifestChannelAccountIds(cfg: OpenClawConfig, channelId: string): string[] {
|
|
const channelConfig = getChannelConfigRecord(cfg, channelId);
|
|
const accounts = channelConfig.accounts;
|
|
if (accounts && typeof accounts === "object" && !Array.isArray(accounts)) {
|
|
return [
|
|
...new Set(
|
|
Object.keys(accounts)
|
|
.filter((accountId) => !isBlockedObjectKey(accountId))
|
|
.map((accountId) => normalizeAccountId(accountId))
|
|
.filter((accountId) => !isBlockedObjectKey(accountId)),
|
|
),
|
|
].toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
return hasExplicitChannelConfig({ config: cfg, channelId }) ? [DEFAULT_ACCOUNT_ID] : [];
|
|
}
|
|
|
|
function resolveManifestChannelAccountConfig(params: {
|
|
cfg: OpenClawConfig;
|
|
channelId: string;
|
|
accountId?: string | null;
|
|
}): Record<string, unknown> {
|
|
const channelConfig = getChannelConfigRecord(params.cfg, params.channelId);
|
|
const resolvedAccountId = normalizeAccountId(params.accountId);
|
|
const accounts = channelConfig.accounts;
|
|
if (accounts && typeof accounts === "object" && !Array.isArray(accounts)) {
|
|
const accountConfig = readOwnRecordValue(
|
|
accounts as Record<string, unknown>,
|
|
resolvedAccountId,
|
|
);
|
|
if (accountConfig && typeof accountConfig === "object" && !Array.isArray(accountConfig)) {
|
|
return accountConfig as Record<string, unknown>;
|
|
}
|
|
}
|
|
return channelConfig;
|
|
}
|
|
|
|
function buildManifestChannelPlugin(params: {
|
|
record: PluginManifestRecord;
|
|
channelId: string;
|
|
}): ChannelPlugin | undefined {
|
|
if (!isSafeManifestChannelId(params.channelId)) {
|
|
return undefined;
|
|
}
|
|
const catalogMeta =
|
|
params.record.channelCatalogMeta?.id === params.channelId
|
|
? params.record.channelCatalogMeta
|
|
: undefined;
|
|
const channelConfigValue = params.record.channelConfigs
|
|
? readOwnRecordValue(params.record.channelConfigs as Record<string, unknown>, params.channelId)
|
|
: undefined;
|
|
if (
|
|
!catalogMeta &&
|
|
(!channelConfigValue ||
|
|
typeof channelConfigValue !== "object" ||
|
|
Array.isArray(channelConfigValue))
|
|
) {
|
|
return undefined;
|
|
}
|
|
const channelConfig =
|
|
channelConfigValue &&
|
|
typeof channelConfigValue === "object" &&
|
|
!Array.isArray(channelConfigValue)
|
|
? (channelConfigValue as ManifestChannelConfigRecord)
|
|
: undefined;
|
|
const label =
|
|
normalizeManifestText(
|
|
channelConfig?.label ?? catalogMeta?.label,
|
|
params.record.name || params.channelId,
|
|
) || params.channelId;
|
|
const blurb = normalizeManifestText(
|
|
channelConfig?.description ?? catalogMeta?.blurb,
|
|
params.record.description || "",
|
|
);
|
|
const commands = normalizeChannelCommandDefaults(
|
|
channelConfig?.commands ?? catalogMeta?.commands,
|
|
);
|
|
return {
|
|
id: params.channelId,
|
|
meta: {
|
|
id: params.channelId,
|
|
label,
|
|
selectionLabel: label,
|
|
docsPath: `/channels/${encodeURIComponent(params.channelId)}`,
|
|
blurb,
|
|
...(channelConfig?.preferOver?.length
|
|
? { preferOver: channelConfig.preferOver }
|
|
: catalogMeta?.preferOver?.length
|
|
? { preferOver: catalogMeta.preferOver }
|
|
: {}),
|
|
},
|
|
capabilities: { chatTypes: ["direct"] },
|
|
...(commands ? { commands } : {}),
|
|
...(channelConfig
|
|
? {
|
|
configSchema: {
|
|
schema: channelConfig.schema,
|
|
...(channelConfig.uiHints ? { uiHints: channelConfig.uiHints } : {}),
|
|
...(channelConfig.runtime ? { runtime: channelConfig.runtime } : {}),
|
|
},
|
|
}
|
|
: {}),
|
|
config: {
|
|
listAccountIds: (cfg) => listManifestChannelAccountIds(cfg, params.channelId),
|
|
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
resolveAccount: (cfg, accountId) => ({
|
|
accountId: normalizeAccountId(accountId),
|
|
config: resolveManifestChannelAccountConfig({
|
|
cfg,
|
|
channelId: params.channelId,
|
|
accountId,
|
|
}),
|
|
}),
|
|
isEnabled: (_account, cfg) => getChannelConfigRecord(cfg, params.channelId).enabled !== false,
|
|
isConfigured: (_account, cfg) =>
|
|
hasExplicitChannelConfig({
|
|
config: cfg,
|
|
channelId: params.channelId,
|
|
}),
|
|
hasConfiguredState: ({ cfg }) =>
|
|
hasExplicitChannelConfig({
|
|
config: cfg,
|
|
channelId: params.channelId,
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
function canUseManifestChannelPlugin(record: PluginManifestRecord, channelId: string): boolean {
|
|
const hasChannelConfig = Boolean(
|
|
record.channelConfigs && Object.prototype.hasOwnProperty.call(record.channelConfigs, channelId),
|
|
);
|
|
if (hasChannelConfig) {
|
|
return record.setup?.requiresRuntime === false || !record.setupSource;
|
|
}
|
|
return record.channelCatalogMeta?.id === channelId;
|
|
}
|
|
|
|
export { resolveReadOnlyChannelCommandDefaults };
|
|
|
|
function rebindChannelPluginConfig(
|
|
config: ChannelPlugin["config"],
|
|
sourceChannelId: string,
|
|
targetChannelId: string,
|
|
): ChannelPlugin["config"] {
|
|
const rebind = (cfg: OpenClawConfig) =>
|
|
rebindChannelConfig(cfg, sourceChannelId, targetChannelId);
|
|
return {
|
|
...config,
|
|
listAccountIds: (cfg) => config.listAccountIds(rebind(cfg)),
|
|
resolveAccount: (cfg, accountId) => config.resolveAccount(rebind(cfg), accountId),
|
|
inspectAccount: config.inspectAccount
|
|
? (cfg, accountId) => config.inspectAccount?.(rebind(cfg), accountId)
|
|
: undefined,
|
|
defaultAccountId: config.defaultAccountId
|
|
? (cfg) => config.defaultAccountId?.(rebind(cfg)) ?? ""
|
|
: undefined,
|
|
setAccountEnabled: config.setAccountEnabled
|
|
? (params) =>
|
|
restoreReboundChannelConfig({
|
|
original: params.cfg,
|
|
updated:
|
|
config.setAccountEnabled?.({ ...params, cfg: rebind(params.cfg) }) ?? params.cfg,
|
|
sourceChannelId,
|
|
targetChannelId,
|
|
})
|
|
: undefined,
|
|
deleteAccount: config.deleteAccount
|
|
? (params) =>
|
|
restoreReboundChannelConfig({
|
|
original: params.cfg,
|
|
updated: config.deleteAccount?.({ ...params, cfg: rebind(params.cfg) }) ?? params.cfg,
|
|
sourceChannelId,
|
|
targetChannelId,
|
|
})
|
|
: undefined,
|
|
isEnabled: config.isEnabled
|
|
? (account, cfg) => config.isEnabled?.(account, rebind(cfg)) ?? false
|
|
: undefined,
|
|
disabledReason: config.disabledReason
|
|
? (account, cfg) => config.disabledReason?.(account, rebind(cfg)) ?? ""
|
|
: undefined,
|
|
isConfigured: config.isConfigured
|
|
? (account, cfg) => config.isConfigured?.(account, rebind(cfg)) ?? false
|
|
: undefined,
|
|
unconfiguredReason: config.unconfiguredReason
|
|
? (account, cfg) => config.unconfiguredReason?.(account, rebind(cfg)) ?? ""
|
|
: undefined,
|
|
describeAccount: config.describeAccount
|
|
? (account, cfg) => config.describeAccount!(account, rebind(cfg))
|
|
: undefined,
|
|
resolveAllowFrom: config.resolveAllowFrom
|
|
? (params) => config.resolveAllowFrom?.({ ...params, cfg: rebind(params.cfg) })
|
|
: undefined,
|
|
formatAllowFrom: config.formatAllowFrom
|
|
? (params) => config.formatAllowFrom?.({ ...params, cfg: rebind(params.cfg) }) ?? []
|
|
: undefined,
|
|
hasConfiguredState: config.hasConfiguredState
|
|
? (params) => config.hasConfiguredState?.({ ...params, cfg: rebind(params.cfg) }) ?? false
|
|
: undefined,
|
|
hasPersistedAuthState: config.hasPersistedAuthState
|
|
? (params) => config.hasPersistedAuthState?.({ ...params, cfg: rebind(params.cfg) }) ?? false
|
|
: undefined,
|
|
resolveDefaultTo: config.resolveDefaultTo
|
|
? (params) => config.resolveDefaultTo?.({ ...params, cfg: rebind(params.cfg) })
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
function rebindChannelPluginSecrets(
|
|
secrets: ChannelPlugin["secrets"],
|
|
sourceChannelId: string,
|
|
targetChannelId: string,
|
|
): ChannelPlugin["secrets"] {
|
|
if (!secrets) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
...secrets,
|
|
secretTargetRegistryEntries: secrets.secretTargetRegistryEntries?.map((entry) => ({
|
|
...entry,
|
|
id: rebindChannelScopedString(entry.id, sourceChannelId, targetChannelId),
|
|
pathPattern: rebindChannelScopedString(entry.pathPattern, sourceChannelId, targetChannelId),
|
|
...(entry.refPathPattern
|
|
? {
|
|
refPathPattern: rebindChannelScopedString(
|
|
entry.refPathPattern,
|
|
sourceChannelId,
|
|
targetChannelId,
|
|
),
|
|
}
|
|
: {}),
|
|
})),
|
|
unsupportedSecretRefSurfacePatterns: secrets.unsupportedSecretRefSurfacePatterns?.map(
|
|
(pattern) => rebindChannelScopedString(pattern, sourceChannelId, targetChannelId),
|
|
),
|
|
collectRuntimeConfigAssignments: secrets.collectRuntimeConfigAssignments
|
|
? (params) =>
|
|
secrets.collectRuntimeConfigAssignments?.({
|
|
...params,
|
|
config: rebindChannelConfig(params.config, sourceChannelId, targetChannelId),
|
|
})
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
function cloneChannelPluginForChannelId(plugin: ChannelPlugin, channelId: string): ChannelPlugin {
|
|
if (plugin.id === channelId && plugin.meta.id === channelId) {
|
|
return plugin;
|
|
}
|
|
const sourceChannelId = plugin.id;
|
|
return {
|
|
...plugin,
|
|
id: channelId,
|
|
meta: {
|
|
...plugin.meta,
|
|
id: channelId,
|
|
},
|
|
config: rebindChannelPluginConfig(plugin.config, sourceChannelId, channelId),
|
|
secrets: rebindChannelPluginSecrets(plugin.secrets, sourceChannelId, channelId),
|
|
};
|
|
}
|
|
|
|
function addSetupChannelPlugins(
|
|
byId: Map<string, ChannelPlugin>,
|
|
setups: Iterable<{
|
|
pluginId: string;
|
|
plugin: ChannelPlugin;
|
|
}>,
|
|
options: {
|
|
ownedChannelIdsByPluginId: ReadonlyMap<string, readonly string[]>;
|
|
ownedMissingChannelIdsByPluginId: ReadonlyMap<string, readonly string[]>;
|
|
},
|
|
): void {
|
|
for (const setup of setups) {
|
|
const ownedMissingChannelIds = options.ownedMissingChannelIdsByPluginId
|
|
.get(setup.pluginId)
|
|
?.filter(isSafeManifestChannelId);
|
|
if (!ownedMissingChannelIds || ownedMissingChannelIds.length === 0) {
|
|
continue;
|
|
}
|
|
if (ownedMissingChannelIds.includes(setup.plugin.id)) {
|
|
addChannelPlugins(byId, [setup.plugin], {
|
|
onlyIds: new Set(ownedMissingChannelIds),
|
|
allowOverwrite: false,
|
|
});
|
|
addChannelPlugins(
|
|
byId,
|
|
ownedMissingChannelIds
|
|
.filter((channelId) => channelId !== setup.plugin.id)
|
|
.map((channelId) => cloneChannelPluginForChannelId(setup.plugin, channelId)),
|
|
{
|
|
onlyIds: new Set(ownedMissingChannelIds),
|
|
allowOverwrite: false,
|
|
},
|
|
);
|
|
continue;
|
|
}
|
|
const ownedChannelIds = (options.ownedChannelIdsByPluginId.get(setup.pluginId) ?? []).filter(
|
|
isSafeManifestChannelId,
|
|
);
|
|
if (setup.plugin.id !== setup.pluginId && !ownedChannelIds.includes(setup.plugin.id)) {
|
|
continue;
|
|
}
|
|
addChannelPlugins(
|
|
byId,
|
|
ownedMissingChannelIds.map((channelId) =>
|
|
cloneChannelPluginForChannelId(setup.plugin, channelId),
|
|
),
|
|
{
|
|
onlyIds: new Set(ownedMissingChannelIds),
|
|
allowOverwrite: false,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
function addManifestChannelPlugins(
|
|
byId: Map<string, ChannelPlugin>,
|
|
records: readonly PluginManifestRecord[],
|
|
options: {
|
|
pluginIds: ReadonlySet<string>;
|
|
channelIds: readonly string[];
|
|
},
|
|
): void {
|
|
const channelIds = new Set(options.channelIds);
|
|
for (const record of records) {
|
|
if (!options.pluginIds.has(record.id)) {
|
|
continue;
|
|
}
|
|
for (const channelId of record.channels) {
|
|
if (!isSafeManifestChannelId(channelId)) {
|
|
continue;
|
|
}
|
|
if (!channelIds.has(channelId)) {
|
|
continue;
|
|
}
|
|
if (!canUseManifestChannelPlugin(record, channelId)) {
|
|
continue;
|
|
}
|
|
addChannelPlugins(byId, [buildManifestChannelPlugin({ record, channelId })], {
|
|
onlyIds: channelIds,
|
|
allowOverwrite: false,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function resolveReadOnlyWorkspaceDir(
|
|
cfg: OpenClawConfig,
|
|
options: ReadOnlyChannelPluginOptions,
|
|
): string | undefined {
|
|
return options.workspaceDir ?? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
|
|
}
|
|
|
|
function listExternalChannelManifestRecords(
|
|
records: readonly PluginManifestRecord[],
|
|
): PluginManifestRecord[] {
|
|
return records.filter((plugin) => plugin.origin !== "bundled" && plugin.channels.length > 0);
|
|
}
|
|
|
|
function listBundledChannelManifestRecords(
|
|
records: readonly PluginManifestRecord[],
|
|
): PluginManifestRecord[] {
|
|
return records.filter((plugin) => plugin.origin === "bundled" && plugin.channels.length > 0);
|
|
}
|
|
|
|
function listPluginIdsForChannels(
|
|
records: readonly PluginManifestRecord[],
|
|
channelIds: readonly string[],
|
|
): string[] {
|
|
const requestedChannelIds = new Set(channelIds);
|
|
return records
|
|
.filter((plugin) => plugin.channels.some((channelId) => requestedChannelIds.has(channelId)))
|
|
.map((plugin) => plugin.id)
|
|
.toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
function resolveExternalReadOnlyChannelPluginIds(params: {
|
|
cfg: OpenClawConfig;
|
|
activationSourceConfig?: OpenClawConfig;
|
|
channelIds: readonly string[];
|
|
records: readonly PluginManifestRecord[];
|
|
workspaceDir?: string;
|
|
env: NodeJS.ProcessEnv;
|
|
}): string[] {
|
|
if (params.channelIds.length === 0) {
|
|
return [];
|
|
}
|
|
const candidatePluginIds = resolveDiscoverableScopedChannelPluginIds({
|
|
config: params.cfg,
|
|
activationSourceConfig: params.activationSourceConfig,
|
|
channelIds: params.channelIds,
|
|
workspaceDir: params.workspaceDir,
|
|
env: params.env,
|
|
manifestRecords: params.records,
|
|
});
|
|
if (candidatePluginIds.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const requestedChannelIds = new Set(params.channelIds);
|
|
const candidatePluginIdSet = new Set(candidatePluginIds);
|
|
return params.records
|
|
.filter(
|
|
(plugin) =>
|
|
candidatePluginIdSet.has(plugin.id) &&
|
|
plugin.channels.some((channelId) => requestedChannelIds.has(channelId)),
|
|
)
|
|
.map((plugin) => plugin.id)
|
|
.toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
export function listReadOnlyChannelPluginsForConfig(
|
|
cfg: OpenClawConfig,
|
|
options?: ReadOnlyChannelPluginOptions,
|
|
): ChannelPlugin[] {
|
|
return resolveReadOnlyChannelPluginsForConfig(cfg, options).plugins;
|
|
}
|
|
|
|
export function resolveReadOnlyChannelPluginsForConfig(
|
|
cfg: OpenClawConfig,
|
|
options: ReadOnlyChannelPluginOptions = {},
|
|
): ReadOnlyChannelPluginResolution {
|
|
const env = options.env ?? process.env;
|
|
const workspaceDir = resolveReadOnlyWorkspaceDir(cfg, options);
|
|
const manifestRecords = loadPluginManifestRegistryForPluginRegistry({
|
|
config: cfg,
|
|
stateDir: options.stateDir,
|
|
workspaceDir,
|
|
env,
|
|
includeDisabled: true,
|
|
}).plugins;
|
|
const bundledManifestRecords = listBundledChannelManifestRecords(manifestRecords);
|
|
const externalManifestRecords = listExternalChannelManifestRecords(manifestRecords);
|
|
const configuredChannelIds = [
|
|
...new Set(
|
|
listConfiguredChannelIdsForReadOnlyScope({
|
|
config: cfg,
|
|
activationSourceConfig: options.activationSourceConfig ?? cfg,
|
|
workspaceDir,
|
|
env,
|
|
includePersistedAuthState: options.includePersistedAuthState,
|
|
manifestRecords,
|
|
}),
|
|
),
|
|
].filter(isSafeManifestChannelId);
|
|
const byId = new Map<string, ChannelPlugin>();
|
|
|
|
addChannelPlugins(byId, listChannelPlugins());
|
|
|
|
if (options.includeSetupFallbackPlugins === true) {
|
|
for (const channelId of configuredChannelIds) {
|
|
if (byId.has(channelId)) {
|
|
continue;
|
|
}
|
|
addChannelPlugins(byId, [getBundledChannelSetupPlugin(channelId)]);
|
|
}
|
|
}
|
|
|
|
const bundledManifestMissingChannelIds = configuredChannelIds.filter(
|
|
(channelId) => !byId.has(channelId),
|
|
);
|
|
addManifestChannelPlugins(byId, bundledManifestRecords, {
|
|
pluginIds: new Set(
|
|
listPluginIdsForChannels(bundledManifestRecords, bundledManifestMissingChannelIds),
|
|
),
|
|
channelIds: bundledManifestMissingChannelIds,
|
|
});
|
|
|
|
const missingConfiguredChannelIds = configuredChannelIds.filter(
|
|
(channelId) => !byId.has(channelId),
|
|
);
|
|
const externalPluginIds = resolveExternalReadOnlyChannelPluginIds({
|
|
cfg,
|
|
activationSourceConfig: options.activationSourceConfig ?? cfg,
|
|
channelIds: missingConfiguredChannelIds,
|
|
records: externalManifestRecords,
|
|
workspaceDir,
|
|
env,
|
|
});
|
|
if (externalPluginIds.length > 0) {
|
|
const externalPluginIdSet = new Set(externalPluginIds);
|
|
const ownedChannelIdsByPluginId = new Map(
|
|
externalManifestRecords
|
|
.filter((record) => externalPluginIdSet.has(record.id))
|
|
.map((record) => [record.id, record.channels] as const),
|
|
);
|
|
if (missingConfiguredChannelIds.length > 0 && options.includeSetupFallbackPlugins === true) {
|
|
const missingChannelIdSet = new Set(missingConfiguredChannelIds);
|
|
const ownedMissingChannelIdsByPluginId = new Map(
|
|
[...ownedChannelIdsByPluginId].map(
|
|
([pluginId, channelIds]) =>
|
|
[
|
|
pluginId,
|
|
channelIds.filter((channelId) => missingChannelIdSet.has(channelId)),
|
|
] as const,
|
|
),
|
|
);
|
|
const registry = loadPluginLoaderModule().loadOpenClawPlugins({
|
|
config: cfg,
|
|
activationSourceConfig: options.activationSourceConfig ?? cfg,
|
|
env,
|
|
workspaceDir,
|
|
cache: false,
|
|
activate: false,
|
|
includeSetupOnlyChannelPlugins: true,
|
|
forceSetupOnlyChannelPlugins: true,
|
|
requireSetupEntryForSetupOnlyChannelPlugins: true,
|
|
onlyPluginIds: externalPluginIds,
|
|
});
|
|
addSetupChannelPlugins(byId, registry.channelSetups, {
|
|
ownedChannelIdsByPluginId,
|
|
ownedMissingChannelIdsByPluginId,
|
|
});
|
|
}
|
|
const externalManifestMissingChannelIds = missingConfiguredChannelIds.filter(
|
|
(channelId) => !byId.has(channelId),
|
|
);
|
|
addManifestChannelPlugins(byId, externalManifestRecords, {
|
|
pluginIds: externalPluginIdSet,
|
|
channelIds: externalManifestMissingChannelIds,
|
|
});
|
|
}
|
|
|
|
const plugins = [...byId.values()];
|
|
return {
|
|
plugins,
|
|
configuredChannelIds,
|
|
missingConfiguredChannelIds: configuredChannelIds.filter((channelId) => !byId.has(channelId)),
|
|
};
|
|
}
|