mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-19 19:21:37 +00:00
* fix(channels): enforce configured read targets * test(channels): align policy checks with boundaries * fix: bind channel reads to trusted turn context * test: satisfy gateway lint * fix: narrow message action channel imports * fix(feishu): authorize message reads before provider access * fix(slack): await reaction clear authorization * fix(channels): align provider action contracts * fix(matrix): read direct-room account data before sync * fix(channels): reject unsupported attachment actions early * fix: restore trusted operator conversation reads * fix(matrix): authorize pin actions before provider reads * fix: preserve trusted channel read workflows * fix(discord): resolve current channel ids consistently * fix(agents): preserve message action turn capability * fix(plugins): enforce host-owned read provenance * fix(channels): harden Teams and Discord read policy * fix(channels): preserve exact-current action compatibility * fix(imessage): authorize trusted current chat aliases * fix(channels): preserve normalized current aliases * fix(channels): preserve external current target aliases * fix: reconcile channel policy with current main * fix(discord): isolate DM read policy * fix(channels): enforce provider read gates * fix(gateway): await serialized message action identity tokens * fix(ci): refresh channel protocol contracts
188 lines
6.8 KiB
TypeScript
188 lines
6.8 KiB
TypeScript
// MCP loopback runtime scope cache.
|
|
// Resolves Gateway-visible tools for MCP clients with short-lived schema caching.
|
|
import type { ExecElevatedDefaults } from "../agents/bash-tools.exec-types.js";
|
|
import type { ExecPolicyOverrides, ExecSessionDefaults } from "../agents/exec-defaults.js";
|
|
import type {
|
|
SourceReplyDeliveryMode,
|
|
TaskSuggestionDeliveryMode,
|
|
} from "../auto-reply/get-reply-options.types.js";
|
|
import type { InboundEventKind } from "../channels/inbound-event/kind.js";
|
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
import type { PluginHookChannelContext } from "../plugins/hook-types.js";
|
|
import {
|
|
buildMcpToolSchema,
|
|
type McpLoopbackTool,
|
|
type McpToolSchemaEntry,
|
|
} from "./mcp-http.schema.js";
|
|
import { resolveGatewayScopedTools } from "./tool-resolution.js";
|
|
|
|
// MCP loopback runtime scopes gateway tools to the current session/channel
|
|
// context and caches the expensive schema projection for short bursts of tool
|
|
// list/call traffic from the same MCP client.
|
|
const TOOL_CACHE_TTL_MS = 30_000;
|
|
const TOOL_CACHE_MAX_ENTRIES = 256;
|
|
const NATIVE_TOOL_EXCLUDE = new Set(["read", "write", "edit", "apply_patch", "exec", "process"]);
|
|
|
|
type CachedScopedTools = {
|
|
agentId: string | undefined;
|
|
tools: McpLoopbackTool[];
|
|
toolSchema: McpToolSchemaEntry[];
|
|
configRef: OpenClawConfig;
|
|
time: number;
|
|
};
|
|
|
|
type McpLoopbackScopeParams = {
|
|
cfg: OpenClawConfig;
|
|
sessionKey: string;
|
|
runtimePolicySessionKey?: string;
|
|
agentId?: string;
|
|
sessionId?: string;
|
|
modelProvider?: string;
|
|
modelId?: string;
|
|
yieldContextCacheKey?: string;
|
|
onYield?: (message: string) => Promise<void> | void;
|
|
messageProvider: string | undefined;
|
|
clientCaps?: string[];
|
|
currentChannelId: string | undefined;
|
|
currentThreadTs: string | undefined;
|
|
currentMessageId: string | number | undefined;
|
|
currentInboundAudio: boolean | undefined;
|
|
accountId: string | undefined;
|
|
inboundEventKind: InboundEventKind | undefined;
|
|
sourceReplyDeliveryMode: SourceReplyDeliveryMode | undefined;
|
|
taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode;
|
|
requireExplicitMessageTarget?: boolean;
|
|
senderIsOwner: boolean | undefined;
|
|
nodeExecAllowed?: boolean;
|
|
execSession?: ExecSessionDefaults;
|
|
execOverrides?: ExecPolicyOverrides;
|
|
bashElevated?: ExecElevatedDefaults;
|
|
trigger?: string;
|
|
approvalReviewerDeviceId?: string;
|
|
channelContext?: PluginHookChannelContext;
|
|
senderName?: string;
|
|
senderUsername?: string;
|
|
senderE164?: string;
|
|
groupId?: string;
|
|
groupChannel?: string;
|
|
groupSpace?: string;
|
|
spawnedBy?: string;
|
|
};
|
|
|
|
/** Resolves loopback-visible tools after applying gateway scope and native-tool exclusions. */
|
|
export function resolveMcpLoopbackScopedTools(params: McpLoopbackScopeParams): {
|
|
agentId: string | undefined;
|
|
tools: McpLoopbackTool[];
|
|
} {
|
|
const excludeToolNames = new Set(NATIVE_TOOL_EXCLUDE);
|
|
if (params.nodeExecAllowed === true) {
|
|
excludeToolNames.delete("exec");
|
|
}
|
|
const scoped = resolveGatewayScopedTools({
|
|
...params,
|
|
conversationReadOrigin: "delegated",
|
|
surface: "loopback",
|
|
excludeToolNames,
|
|
includeNodeExecTool: params.nodeExecAllowed === true,
|
|
});
|
|
return {
|
|
agentId: scoped.agentId,
|
|
tools: scoped.tools,
|
|
};
|
|
}
|
|
|
|
/** Short-lived cache for loopback tool lists keyed by session/channel context. */
|
|
export class McpLoopbackToolCache {
|
|
#entries = new Map<string, CachedScopedTools>();
|
|
|
|
resolve(params: McpLoopbackScopeParams): CachedScopedTools {
|
|
// Callers differing only in capabilities must not share cached tool lists.
|
|
const clientCapsCacheKey = [...new Set(params.clientCaps ?? [])].toSorted().join(",");
|
|
const cacheKey = [
|
|
params.sessionKey,
|
|
params.runtimePolicySessionKey ?? "",
|
|
params.agentId ?? "",
|
|
params.sessionId ?? "",
|
|
params.modelProvider ?? "",
|
|
params.modelId ?? "",
|
|
params.yieldContextCacheKey ?? "",
|
|
params.messageProvider ?? "",
|
|
clientCapsCacheKey,
|
|
params.currentChannelId ?? "",
|
|
params.currentThreadTs ?? "",
|
|
params.currentMessageId != null ? String(params.currentMessageId) : "",
|
|
params.currentInboundAudio === true ? "audio" : "no-audio",
|
|
params.accountId ?? "",
|
|
params.inboundEventKind ?? "",
|
|
params.sourceReplyDeliveryMode ?? "",
|
|
params.taskSuggestionDeliveryMode ?? "",
|
|
params.requireExplicitMessageTarget === true ? "explicit-message-target" : "",
|
|
params.nodeExecAllowed === true ? "node-exec" : "",
|
|
params.execSession?.execHost ?? "",
|
|
params.execSession?.execSecurity ?? "",
|
|
params.execSession?.execAsk ?? "",
|
|
params.execSession?.execNode ?? "",
|
|
params.execOverrides?.host ?? "",
|
|
params.execOverrides?.security ?? "",
|
|
params.execOverrides?.ask ?? "",
|
|
params.execOverrides?.node ?? "",
|
|
params.bashElevated ? "elevated-present" : "elevated-absent",
|
|
params.bashElevated?.enabled === true ? "elevated-enabled" : "elevated-disabled",
|
|
params.bashElevated?.allowed === true ? "elevated-allowed" : "elevated-blocked",
|
|
params.bashElevated?.defaultLevel ?? "",
|
|
params.bashElevated?.fullAccessAvailable === true
|
|
? "full-access-available"
|
|
: params.bashElevated?.fullAccessAvailable === false
|
|
? "full-access-unavailable"
|
|
: "",
|
|
params.bashElevated?.fullAccessBlockedReason ?? "",
|
|
params.trigger ?? "",
|
|
params.approvalReviewerDeviceId ?? "",
|
|
params.channelContext?.sender?.id ?? "",
|
|
params.channelContext?.chat?.id ?? "",
|
|
params.senderName ?? "",
|
|
params.senderUsername ?? "",
|
|
params.senderE164 ?? "",
|
|
params.groupId ?? "",
|
|
params.groupChannel ?? "",
|
|
params.groupSpace ?? "",
|
|
params.spawnedBy ?? "",
|
|
params.senderIsOwner === true
|
|
? "owner"
|
|
: params.senderIsOwner === false
|
|
? "non-owner"
|
|
: "unknown-owner",
|
|
].join("\u0000");
|
|
const now = Date.now();
|
|
for (const [key, entry] of this.#entries) {
|
|
if (now - entry.time >= TOOL_CACHE_TTL_MS) {
|
|
this.#entries.delete(key);
|
|
}
|
|
}
|
|
const cached = this.#entries.get(cacheKey);
|
|
// Config object identity is part of the cache contract so explicit gateway
|
|
// reloads invalidate tool scope and schema without filesystem polling.
|
|
if (cached && cached.configRef === params.cfg && now - cached.time < TOOL_CACHE_TTL_MS) {
|
|
return cached;
|
|
}
|
|
|
|
const next = resolveMcpLoopbackScopedTools(params);
|
|
const nextEntry: CachedScopedTools = {
|
|
agentId: next.agentId,
|
|
tools: next.tools,
|
|
toolSchema: buildMcpToolSchema(next.tools),
|
|
configRef: params.cfg,
|
|
time: now,
|
|
};
|
|
this.#entries.set(cacheKey, nextEntry);
|
|
while (this.#entries.size > TOOL_CACHE_MAX_ENTRIES) {
|
|
const oldestKey = this.#entries.keys().next().value;
|
|
if (oldestKey === undefined) {
|
|
break;
|
|
}
|
|
this.#entries.delete(oldestKey);
|
|
}
|
|
return nextEntry;
|
|
}
|
|
}
|