openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
518 lines (517 loc) • 18.3 kB
JavaScript
import { t as isPlainObject } from "./plain-object-BFvGATnd.mjs";
import "./utils-DgZn2dLu.mjs";
import "./account-id-DbRsdayc.mjs";
import { d as getActivePluginRegistryVersion, l as getActivePluginRegistry } from "./runtime-NsVZTERq.mjs";
import { o as resolveConfiguredTalkRealtimeProviderId, s as resolveConfiguredTalkSpeechProviderId } from "./talk-tTHQncKV.mjs";
import { t as haveSameOperatorRoleSourcePolicies } from "./operator-role-source-policy-DD1lr4qS.mjs";
import { r as listChannelPlugins } from "./registry-bbtfCBEf.mjs";
import "./plugins-9R-VsYQh.mjs";
import { t as canHotReloadGatewayAuthCredentials } from "./auth-resolve-BmUDP1m_.mjs";
import { isDeepStrictEqual } from "node:util";
//#region src/gateway/config-diff.ts
function collectConfigDiffPaths(prev, next, prefix, refinementPrefixes, paths) {
if (prev === next) return;
const prevIsPlainObject = isPlainObject(prev);
const nextIsPlainObject = isPlainObject(next);
if (prevIsPlainObject && nextIsPlainObject || (prevIsPlainObject || nextIsPlainObject) && refinementPrefixes.some((entry) => prefix ? entry.startsWith(`${prefix}.`) : true)) {
const prevRecord = prevIsPlainObject ? prev : {};
const nextRecord = nextIsPlainObject ? next : {};
const keys = /* @__PURE__ */ new Set([...Object.keys(prevRecord), ...Object.keys(nextRecord)]);
for (const key of keys) {
const prevValue = prevRecord[key];
const nextValue = nextRecord[key];
if (prevValue === void 0 && nextValue === void 0) continue;
collectConfigDiffPaths(prevValue, nextValue, prefix ? `${prefix}.${key}` : key, refinementPrefixes, paths);
}
return;
}
if (Array.isArray(prev) && Array.isArray(next)) {
if (isDeepStrictEqual(prev, next)) return;
}
paths.push(prefix || "<root>");
}
/** Return dotted config paths whose values differ between two config snapshots. */
function diffConfigPaths(prev, next, prefix = "", refinementPrefixes = []) {
const paths = [];
collectConfigDiffPaths(prev, next, prefix, refinementPrefixes, paths);
return paths;
}
function projectGatewayReloadBoundaries(config) {
return { talk: {
provider: resolveConfiguredTalkSpeechProviderId(config),
realtime: { provider: resolveConfiguredTalkRealtimeProviderId(config) }
} };
}
/** Preserve declared reload boundaries and derived capability-owner changes. */
function diffGatewayReloadPaths(prevConfig, nextConfig, reloadPrefixes) {
const refinementPrefixes = new Set(reloadPrefixes);
for (const config of [prevConfig, nextConfig]) for (const pluginId of Object.keys(config.plugins?.entries ?? {})) refinementPrefixes.add(`plugins.entries.${pluginId}`);
const rosterPaths = [];
if (prevConfig.agents?.entries === void 0 !== (nextConfig.agents?.entries === void 0)) rosterPaths.push("agents.entries");
const refineDecisionModel = refinementPrefixes.delete("agents.entries.*.decisionModel");
for (const [config, other] of [[prevConfig, nextConfig], [nextConfig, prevConfig]]) for (const [agentId, agent] of Object.entries(config.agents?.entries ?? {})) {
if (!Object.hasOwn(other.agents?.entries ?? {}, agentId)) rosterPaths.push(`agents.entries.${agentId}`);
if (refineDecisionModel && agent.decisionModel !== void 0) refinementPrefixes.add(`agents.entries.${agentId}.decisionModel`);
}
const changedPaths = diffConfigPaths(prevConfig, nextConfig, "", [...refinementPrefixes]);
const boundaryPaths = diffConfigPaths(projectGatewayReloadBoundaries(prevConfig), projectGatewayReloadBoundaries(nextConfig));
return [...changedPaths, ...[...rosterPaths, ...boundaryPaths].filter((path) => !changedPaths.includes(path))];
}
//#endregion
//#region src/gateway/config-reload-plan.ts
const RELOAD_ACTIONS = [
"reloadHooks",
"reloadInternalHooks",
"refreshHooksPolicy",
"restartGmailWatcher",
"restartCron",
"restartHeartbeat",
"reconcileSystemJobs",
"reloadPlugins",
"disposeMcpRuntimes"
];
function isNoopGatewayReloadPlan(plan) {
return !plan.restartGateway && plan.hotReasons.length === 0 && RELOAD_ACTIONS.every((action) => !plan[action]) && plan.restartChannels.size === 0 && (plan.restartServices?.size ?? 0) === 0 && (plan.restartChannelAccounts?.size ?? 0) === 0;
}
const PLUGIN_INSTALL_TIMESTAMP_KEYS = ["installedAt", "resolvedAt"];
const AUTH_CREDENTIAL_PATHS = ["gateway.auth.token", "gateway.auth.password"];
const SHARED_CHANNEL_PREFIXES = [
"agents.defaults.mediaMaxMb",
"channels.defaults",
"channels.modelByChannel",
"messages.inbound",
"messages.ackReactionScope",
"commands",
"accessGroups",
"tts",
"surfaces",
"acp.stream",
"diagnostics.flags"
];
function matchesReloadPrefix(path, prefix, includeAncestors = false) {
if (prefix.includes("*")) {
const segments = path.split(".");
return prefix.split(".").every((segment, index) => includeAncestors && index >= segments.length || (segment === "*" ? Boolean(segments[index]) : segment === segments[index]));
}
return path === prefix || path.startsWith(`${prefix}.`) || includeAncestors && prefix.startsWith(`${path}.`);
}
function compareReloadRules(left, right) {
const leftSegments = left.prefix.split(".");
const rightSegments = right.prefix.split(".");
return rightSegments.length - leftSegments.length || leftSegments.filter((segment) => segment === "*").length - rightSegments.filter((segment) => segment === "*").length;
}
function expandReloadPolicies(policies) {
return policies.flatMap(({ prefixes, ...policy }) => prefixes.map((prefix) => ({
...policy,
prefix
}))).toSorted(compareReloadRules);
}
const AGENT_ROSTER_RELOAD_ACTIONS = [
"restartHeartbeat",
"reconcileSystemJobs",
"refreshHooksPolicy",
"reloadInternalHooks"
];
const CORE_RELOAD_POLICIES = [
{
prefixes: ["gateway.remote", "gateway.reload"],
kind: "none"
},
{
prefixes: [
...AUTH_CREDENTIAL_PATHS,
"mcp.apps",
"secrets.egressProxy",
"gateway.portals"
],
kind: "restart"
},
{
prefixes: [
"gateway.http.endpoints",
"gateway.http.securityHeaders.strictTransportSecurity",
"gateway.tools",
"gateway.uploads",
"gateway.cliAgents",
"gateway.controlUi.enabled",
"gateway.controlUi.environment",
"gateway.controlUi.communityInvite",
"gateway.controlUi.newSessionModelDefaults",
"gateway.controlUi.github",
"gateway.controlUi.sessionObserver",
"gateway.controlUi.embedSandbox",
"gateway.controlUi.allowExternalEmbedUrls",
"gateway.controlUi.automaticallyFetchFavicons",
"gateway.controlUi.experimental.customPlugins",
"gateway.controlUi.allowedOrigins",
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback",
"gateway.nodes.browser",
"gateway.nodes.pairing",
"gateway.nodes.commands",
"gateway.nodes.pluginTools.enabled",
"gateway.nodes.allowSkills",
"gateway.push.apns.relay",
"gateway.terminal",
"gateway.auth.rateLimit",
"gateway.roles",
"gateway.trustedProxies",
"gateway.allowRealIpFallback",
"gateway.auth.allowTailscale",
"gateway.auth.identityScopes",
"gateway.auth.trustedProxy",
"diagnostics.enabled",
"discovery.mdns.mode",
"mcp.apps.sandboxOrigin",
"agents.defaults",
"desktop.host",
"cloudWorkers"
],
kind: "hot"
},
{
prefixes: ["hooks.gmail"],
kind: "hot",
actions: ["restartGmailWatcher", "reloadHooks"]
},
{
prefixes: ["hooks.internal", "agents.defaults.workspace"],
kind: "hot",
actions: ["reloadInternalHooks"]
},
{
prefixes: ["hooks"],
kind: "hot",
actions: ["reloadHooks"]
},
{
prefixes: [
"agents.defaults.heartbeat",
"agents.defaults.models",
"agents.defaults.modelPolicy",
"agents.defaults.model",
"models",
"agent.heartbeat"
],
kind: "hot",
actions: ["restartHeartbeat", "reconcileSystemJobs"]
},
{
prefixes: ["agents.entries"],
kind: "hot",
actions: AGENT_ROSTER_RELOAD_ACTIONS
},
{
prefixes: ["agents.entries.*.decisionModel"],
kind: "hot",
actions: [...AGENT_ROSTER_RELOAD_ACTIONS, "reloadPlugins"]
},
{
prefixes: ["agents.defaults.sessionStore", "agents.ownership"],
kind: "hot",
actions: ["refreshHooksPolicy"]
},
{
prefixes: ["skills.workshop.autonomous.mode"],
kind: "hot",
actions: ["reconcileSystemJobs"]
},
{
prefixes: ["agents.defaults.decisionModel"],
kind: "hot",
actions: ["reloadPlugins"]
},
{
prefixes: ["plugins.load", "plugins.installs"],
kind: "hot",
actions: ["reloadPlugins"]
},
{
prefixes: ["cron"],
kind: "hot",
actions: ["restartCron"]
},
{
prefixes: ["transcripts", "cloudWorkers.profiles"],
kind: "hot",
actions: ["reloadPlugins"]
},
{
prefixes: ["mcp", "gateway.publicOrigin"],
kind: "hot",
actions: ["disposeMcpRuntimes"]
},
{
prefixes: ["talk.provider", "talk.realtime.provider"],
kind: "hot",
actions: ["reloadPlugins"]
}
];
const DEFAULT_RELOAD_POLICIES = [
{
prefixes: [
"meta",
"identity",
"wizard",
"logging",
"agents",
"bindings",
"audio",
"agent",
"routing",
"messages",
"session",
"talk",
"skills",
"secrets",
"tui",
"ui"
],
kind: "none"
},
{
prefixes: [
"tools",
"approvals.exec",
"approvals.plugin",
"auth.order",
"auth.profiles",
"broadcast",
"memory.citations",
"worktreeRoot",
"worktreeAcceleration",
"security.audit.suppressions",
"security.installPolicy",
"diagnostics.cacheTrace.enabled",
"acp",
"attachments.ttlHours",
"update.checkOnStart",
"update.channel",
"update.auto.enabled",
"telemetry.enabled",
"telemetry.consentedAt"
],
kind: "hot"
},
{
prefixes: ["plugins"],
kind: "hot",
actions: ["reloadPlugins", "disposeMcpRuntimes"]
},
{
prefixes: ["channels"],
kind: "hot",
actions: ["reloadPlugins"],
replaceChannelPlugins: true
},
{
prefixes: ["gateway", "discovery"],
kind: "restart"
}
];
let cachedCatalog;
function getReloadPolicyCatalog() {
const registry = getActivePluginRegistry();
const version = getActivePluginRegistryVersion();
if (cachedCatalog?.registry === registry && cachedCatalog.version === version) return cachedCatalog;
const channelPlugins = listChannelPlugins();
const servicePolicies = (registry?.services ?? []).map(({ id, service }) => ({
id,
prefixes: service.reload?.configPrefixes ?? []
}));
const channelPolicies = channelPlugins.flatMap((plugin) => [{
prefixes: plugin.reload?.configPrefixes ?? [],
kind: "hot",
channels: [plugin],
accountScoped: plugin.reload?.accountScopedRestart
}, {
prefixes: plugin.reload?.noopPrefixes ?? [],
kind: "none",
channels: [plugin]
}]);
const channelRules = expandReloadPolicies(channelPolicies);
const sharedPrefixes = /* @__PURE__ */ new Set([...SHARED_CHANNEL_PREFIXES, ...channelRules.filter(({ prefix }) => SHARED_CHANNEL_PREFIXES.some((root) => matchesReloadPrefix(prefix, root))).map(({ prefix }) => prefix)]);
const ownedRules = expandReloadPolicies([
...CORE_RELOAD_POLICIES,
...(registry?.reloads ?? []).flatMap(({ registration }) => [
["restart", registration.restartPrefixes],
["hot", registration.hotPrefixes],
["none", registration.noopPrefixes]
].map(([kind, prefixes]) => ({
kind,
prefixes: prefixes ?? []
}))),
...Array.from(sharedPrefixes, (prefix) => {
const channels = channelPlugins.filter((plugin) => channelRules.find((rule) => rule.channels?.includes(plugin) && matchesReloadPrefix(prefix, rule.prefix))?.kind !== "none");
const hasService = servicePolicies.some(({ prefixes }) => prefixes.some((owner) => matchesReloadPrefix(prefix, owner)));
return {
prefixes: [prefix],
kind: channels.length || hasService ? "hot" : "none",
channels
};
}),
...channelPolicies,
...channelPlugins.map((plugin) => ({
prefixes: [`plugins.entries.${plugin.id}`],
kind: "hot",
actions: ["reloadPlugins", "disposeMcpRuntimes"],
channels: [plugin]
})),
{
prefixes: ["session.scope", "session.store"],
kind: "hot",
actions: ["refreshHooksPolicy"]
},
...DEFAULT_RELOAD_POLICIES
]);
const rules = [...ownedRules, ...servicePolicies.flatMap(({ prefixes }) => prefixes.map((prefix) => ({
...ownedRules.find((owner) => matchesReloadPrefix(prefix, owner.prefix)),
kind: "hot",
prefix
})))];
rules.sort(compareReloadRules);
cachedCatalog = {
registry,
version,
rules,
servicePolicies,
refinementPrefixes: rules.map((rule) => rule.prefix)
};
return cachedCatalog;
}
function listConfigReloadRefinementPrefixes() {
return getReloadPolicyCatalog().refinementPrefixes;
}
function matchRule(path) {
return getReloadPolicyCatalog().rules.find(({ prefix }) => matchesReloadPrefix(path, prefix));
}
function resolveConfigReloadMetadata(path) {
if (isPluginInstallTimestampPath(path)) return { kind: "none" };
return { kind: matchRule(path)?.kind ?? "restart" };
}
function isPluginInstallTimestampPath(path) {
return /^plugins\.installs\..+\.(installedAt|resolvedAt)$/.test(path);
}
function getPluginInstallRecords(config) {
if (!isPlainObject(config)) return {};
const plugins = config.plugins;
if (!isPlainObject(plugins)) return {};
const installs = plugins.installs;
return isPlainObject(installs) ? installs : {};
}
function resolvePluginInstallReloadMetadata(prevConfig, nextConfig) {
const prevInstalls = getPluginInstallRecords(prevConfig);
const nextInstalls = getPluginInstallRecords(nextConfig);
const ids = /* @__PURE__ */ new Set([...Object.keys(prevInstalls), ...Object.keys(nextInstalls)]);
const noopPaths = [];
const forceChangedPaths = [];
for (const id of ids) {
const prevRecord = prevInstalls[id];
const nextRecord = nextInstalls[id];
if (!isPlainObject(prevRecord) || !isPlainObject(nextRecord)) {
forceChangedPaths.push(`plugins.installs.${id}`);
continue;
}
for (const key of PLUGIN_INSTALL_TIMESTAMP_KEYS) if (prevRecord[key] !== nextRecord[key]) noopPaths.push(`plugins.installs.${id}.${key}`);
}
return {
noopPaths,
forceChangedPaths
};
}
function extractAccountIdFromPath(channel, path) {
const prefix = `channels.${channel}.accounts.`;
const id = path.startsWith(prefix) ? path.slice(prefix.length).split(".", 1)[0] : void 0;
return id && id !== "default" ? id : null;
}
function isInspectableChannelAccount(params) {
try {
if (!params.plugin.config.listAccountIds(params.config).includes(params.accountId)) return false;
if (!params.plugin.config.inspectAccount && params.plugin.config.resolveAccountAsync) return false;
(params.plugin.config.inspectAccount ?? params.plugin.config.resolveAccount)(params.config, params.accountId);
return true;
} catch {
return false;
}
}
function buildGatewayReloadPlan(changedPaths, options = {}) {
const noopPaths = new Set(options.noopPaths);
const forceChangedPaths = new Set(options.forceChangedPaths);
const roleModelPolicyOnly = haveSameOperatorRoleSourcePolicies(options.previousConfig?.gateway?.roles, options.candidateConfig?.gateway?.roles) && (!options.previousCompareConfig && !options.candidateCompareConfig || haveSameOperatorRoleSourcePolicies(options.previousCompareConfig?.gateway?.roles, options.candidateCompareConfig?.gateway?.roles));
const restartChannelAccounts = /* @__PURE__ */ new Map();
const plan = {
changedPaths,
restartGateway: false,
restartReasons: [],
hotReasons: [],
reloadHooks: false,
reloadInternalHooks: false,
restartGmailWatcher: false,
restartCron: false,
restartHeartbeat: false,
reconcileSystemJobs: false,
reloadPlugins: false,
restartChannels: /* @__PURE__ */ new Set(),
restartServices: /* @__PURE__ */ new Set(),
disposeMcpRuntimes: false,
restartChannelAccounts,
noopPaths: []
};
for (const path of changedPaths) {
if (roleModelPolicyOnly && matchesReloadPrefix(path, "gateway.roles") && !forceChangedPaths.has(path)) {
plan.noopPaths.push(path);
continue;
}
if (!forceChangedPaths.has(path) && (noopPaths.size > 0 ? noopPaths.has(path) : isPluginInstallTimestampPath(path))) {
plan.noopPaths.push(path);
continue;
}
const rule = matchRule(path);
const kind = rule?.kind ?? "restart";
const isCredentialRotation = rule && AUTH_CREDENTIAL_PATHS.includes(rule.prefix) && canHotReloadGatewayAuthCredentials(options.previousConfig, options.candidateConfig);
if (kind === "restart" && !isCredentialRotation) {
plan.restartGateway = true;
plan.restartReasons.push(path);
continue;
}
if (kind === "none") {
plan.noopPaths.push(path);
continue;
}
plan.hotReasons.push(path);
for (const action of rule?.actions ?? []) {
plan[action] = true;
if (action === "reloadPlugins") (plan.reloadPluginPaths ??= []).push(path);
}
if (rule?.replaceChannelPlugins) {
for (const record of getReloadPolicyCatalog().registry?.plugins ?? []) if (record.channelIds.some((id) => path === "channels" || matchesReloadPrefix(path, `channels.${id}`))) (plan.reloadPluginIds ??= /* @__PURE__ */ new Set()).add(record.id);
}
for (const service of getReloadPolicyCatalog().servicePolicies) if (service.prefixes.some((prefix) => matchesReloadPrefix(path, prefix, true))) plan.restartServices?.add(service.id);
for (const plugin of rule?.channels ?? []) {
const accountId = rule?.accountScoped ? extractAccountIdFromPath(plugin.id, path) : null;
if (accountId === null || options.candidateConfig && !isInspectableChannelAccount({
plugin,
accountId,
config: options.candidateConfig
})) {
plan.restartChannels.add(plugin.id);
continue;
}
const accounts = restartChannelAccounts.get(plugin.id) ?? /* @__PURE__ */ new Set();
accounts.add(accountId);
restartChannelAccounts.set(plugin.id, accounts);
}
}
for (const channel of plan.restartChannels) restartChannelAccounts.delete(channel);
const { pluginLifecycle } = options;
if (pluginLifecycle) {
plan.pluginLifecycle = pluginLifecycle;
plan.reloadPlugins = true;
const unrelatedRestart = plan.restartReasons.find((path) => path !== "plugins" && !path.startsWith("plugins."));
if (unrelatedRestart) throw new Error(`Cannot apply plugin change while ${unrelatedRestart} requires a Gateway restart.`);
plan.restartGateway = false;
plan.restartReasons = [];
}
return plan;
}
//#endregion
export { resolvePluginInstallReloadMetadata as a, resolveConfigReloadMetadata as i, isNoopGatewayReloadPlan as n, diffConfigPaths as o, listConfigReloadRefinementPrefixes as r, diffGatewayReloadPaths as s, buildGatewayReloadPlan as t };