From 24088edcb51491e766487715d18d18569190f2f4 Mon Sep 17 00:00:00 2001 From: joshavant <830519+joshavant@users.noreply.github.com> Date: Thu, 23 Jul 2026 03:42:39 -0500 Subject: [PATCH] fix(cron): align CLI scheduled tool projections --- src/agents/cli-runner/prepare.test.ts | 125 ++++++++++++------ src/agents/cli-runner/prepare.ts | 183 +++++++++++++------------- src/gateway/mcp-http.runtime.test.ts | 69 +++++++++- src/gateway/mcp-http.runtime.ts | 136 +++++++++++-------- 4 files changed, 317 insertions(+), 196 deletions(-) diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index d6f4a26c676e..593be75e4188 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -328,6 +328,7 @@ describe("prepareCliRunContext", () => { createMcpLoopbackServerConfig: vi.fn(createTestMcpLoopbackServerConfig), mintMcpLoopbackClientGrant: vi.fn(createTestMcpLoopbackClientGrant), revokeMcpLoopbackClientGrant: vi.fn(() => true), + resolveMcpLoopbackPolicyTools: vi.fn(() => ({ agentId: "main", tools: [] })), resolveMcpLoopbackScopedTools: vi.fn(() => ({ agentId: "main", tools: [] })), resolveOpenClawReferencePaths: vi.fn(async () => ({ docsPath: null, sourcePath: null })), prepareClaudeCliSkillsPlugin: vi.fn(async () => ({ @@ -2231,16 +2232,16 @@ describe("prepareCliRunContext", () => { 1, expect.objectContaining({ senderIsOwner: true, - currentMessageId: undefined, - sourceReplyDeliveryMode: "message_tool_only", + currentMessageId: "owner-message", + sourceReplyDeliveryMode: undefined, }), ); expect(resolveMcpLoopbackScopedTools).toHaveBeenNthCalledWith( 2, expect.objectContaining({ senderIsOwner: false, - currentMessageId: undefined, - sourceReplyDeliveryMode: "message_tool_only", + currentMessageId: "non-owner-message", + sourceReplyDeliveryMode: undefined, }), ); expect(second.promptToolNamesHash).not.toBe(first.promptToolNamesHash); @@ -2384,7 +2385,7 @@ describe("prepareCliRunContext", () => { const deactivateMcpLoopbackClientGrantCapture = vi.fn(() => true); const mintMcpLoopbackClientGrant = vi.fn(createTestMcpLoopbackClientGrant); const revokeMcpLoopbackClientGrant = vi.fn(() => true); - const resolveMcpLoopbackScopedTools = vi.fn(() => ({ + const resolveMcpLoopbackScopedTools = vi.fn((_scope: Record) => ({ agentId: "main", tools: [ { @@ -2433,6 +2434,7 @@ describe("prepareCliRunContext", () => { provider: "native-cli", runId: "run-test-loopback-prompt-tools", config: createCliBackendConfig({ bundleMcp: true }), + scheduledToolPolicy: { ownerSessionKey: "agent:worker:discord:group:ops" }, cliSessionBinding: { sessionId: "cli-session", promptToolNamesHash: "old-tool-surface", @@ -2445,39 +2447,21 @@ describe("prepareCliRunContext", () => { }, }); - expect(resolveMcpLoopbackScopedTools).toHaveBeenCalledWith({ - cfg: expect.any(Object), + const projected = resolveMcpLoopbackScopedTools.mock.calls.at(-1)?.[0]; + const grantContext = mintMcpLoopbackClientGrant.mock.calls.at(-1)?.[0]?.context; + expect(projected).toBeDefined(); + expect(grantContext).toBeDefined(); + const { cfg: projectedConfig, ...projectedContext } = projected ?? {}; + expect(projectedConfig).toEqual(expect.any(Object)); + expect(projectedContext).toEqual(grantContext); + expect(projectedContext).toMatchObject({ sessionKey: "agent:worker:main", - runtimePolicySessionKey: undefined, - agentId: "worker", - messageProvider: undefined, - clientCaps: undefined, - currentChannelId: undefined, - currentThreadTs: undefined, - currentMessageId: undefined, - currentInboundAudio: undefined, - accountId: undefined, - inboundEventKind: undefined, - sourceReplyDeliveryMode: undefined, - taskSuggestionDeliveryMode: undefined, - requireExplicitMessageTarget: false, - senderIsOwner: false, - nodeExecAllowed: true, + sessionId: expect.any(String), + runId: "run-test-loopback-prompt-tools", + workspaceDir: expect.any(String), modelProvider: "native-cli", modelId: "test-model", - execSession: undefined, - execOverrides: undefined, - bashElevated: undefined, - trigger: undefined, - approvalReviewerDeviceId: undefined, - channelContext: undefined, - senderName: undefined, - senderUsername: undefined, - senderE164: undefined, - groupId: undefined, - groupChannel: undefined, - groupSpace: undefined, - spawnedBy: undefined, + scheduledToolPolicy: { ownerSessionKey: "agent:worker:discord:group:ops" }, }); expect(context.systemPrompt).toContain("## Memory Recall"); expect(context.systemPrompt).toContain("tools=memory_search"); @@ -2823,6 +2807,45 @@ describe("prepareCliRunContext", () => { expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled(); }); + it("materializes runtime toolsAllow for selectable backends without bundle MCP", async () => { + const resolveExecutionArgs = vi.fn((context: { baseArgs: readonly string[] }) => [ + ...context.baseArgs, + ]); + const resolveMcpLoopbackPolicyTools = vi.fn((_scope: Record) => ({ + agentId: "main", + tools: ["write", "apply_patch"].map((name) => ({ name })), + })); + setRawCliBackendForPrepareTest({ + id: "selectable-cli", + pluginId: "selectable-plugin", + bundleMcp: false, + nativeToolMode: "selectable", + toolAvailabilityEnforcement: "execution-args", + resolveExecutionArgs, + config: { + command: "selectable-cli", + args: ["--print"], + output: "jsonl", + input: "stdin", + sessionMode: "existing", + }, + }); + setCliRunnerPrepareTestDeps({ resolveMcpLoopbackPolicyTools }); + + const context = await fixture.prepare({ + provider: "selectable-cli", + toolsAllow: ["write"], + }); + + expect(context.params.cliToolAvailability).toEqual({ + native: [], + openClaw: ["write", "apply_patch"], + }); + expect(resolveMcpLoopbackPolicyTools).toHaveBeenCalledWith( + expect.objectContaining({ toolsAllow: ["write"] }), + ); + }); + it("requires prepared-execution backends to acknowledge exact enforcement and cleans up", async () => { const cleanup = vi.fn(async () => {}); const prepareExecution = vi.fn(async () => ({ cleanup })); @@ -2920,6 +2943,10 @@ describe("prepareCliRunContext", () => { ...context.baseArgs, ]); const mintMcpLoopbackClientGrant = vi.fn(createTestMcpLoopbackClientGrant); + const resolveMcpLoopbackPolicyTools = vi.fn((_scope: Record) => ({ + agentId: "main", + tools: ["write", "apply_patch"].map((name) => ({ name })), + })); setRawCliBackendForPrepareTest({ id: "claude-cli", pluginId: "anthropic", @@ -2946,7 +2973,7 @@ describe("prepareCliRunContext", () => { ensureMcpLoopbackServer: vi.fn(createTestMcpLoopbackServer), createMcpLoopbackServerConfig: vi.fn(createTestMcpLoopbackServerConfig), mintMcpLoopbackClientGrant, - resolveMcpLoopbackScopedTools: vi.fn(() => ({ agentId: "main", tools: [] })), + resolveMcpLoopbackPolicyTools, }); let cleanup: (() => Promise) | undefined; @@ -2954,7 +2981,7 @@ describe("prepareCliRunContext", () => { const context = await fixture.prepare({ sessionKey: "agent:main:main", provider: "claude-cli", - toolsAllow: ["group:fs", "exec", "browser", "image"], + toolsAllow: ["write"], scheduledToolPolicy: { ownerSessionKey: "agent:main:discord:group:ops" }, }); cleanup = context.preparedBackend.cleanup; @@ -2962,20 +2989,32 @@ describe("prepareCliRunContext", () => { expect(context.params.toolsAllow).toBeUndefined(); expect(context.params.cliToolAvailability).toEqual({ native: [], - openClaw: ["read", "write", "edit", "apply_patch", "exec", "browser", "image"], + openClaw: ["write", "apply_patch"], }); expect(mintMcpLoopbackClientGrant.mock.calls[0]?.[0]?.context.toolsAllow).toEqual([ - "read", "write", - "edit", "apply_patch", - "exec", - "browser", - "image", ]); expect(mintMcpLoopbackClientGrant.mock.calls[0]?.[0]?.context.scheduledToolPolicy).toEqual({ ownerSessionKey: "agent:main:discord:group:ops", }); + expect(resolveMcpLoopbackPolicyTools).toHaveBeenCalledWith( + expect.objectContaining({ + toolsAllow: ["write"], + scheduledToolPolicy: { ownerSessionKey: "agent:main:discord:group:ops" }, + }), + ); + const projected = resolveMcpLoopbackPolicyTools.mock.calls[0]?.[0]; + const grantContext = mintMcpLoopbackClientGrant.mock.calls[0]?.[0]?.context; + const { + cfg: _projectedConfig, + toolsAllow: projectedPolicy, + ...projectedTrustedContext + } = projected ?? {}; + const { toolsAllow: grantedTools, ...grantTrustedContext } = grantContext ?? {}; + expect(projectedPolicy).toEqual(["write"]); + expect(grantedTools).toEqual(["write", "apply_patch"]); + expect(projectedTrustedContext).toEqual(grantTrustedContext); } finally { await cleanup?.(); } diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index e2fbea811a8e..dc1a08febc01 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -22,7 +22,10 @@ import { createMcpLoopbackServerConfig, getActiveMcpLoopbackRuntime, } from "../../gateway/mcp-http.loopback-runtime.js"; -import { resolveMcpLoopbackScopedTools } from "../../gateway/mcp-http.runtime.js"; +import { + resolveMcpLoopbackPolicyTools, + resolveMcpLoopbackScopedTools, +} from "../../gateway/mcp-http.runtime.js"; import { buildSystemAgentToolsMcpServerConfig } from "../../mcp/openclaw-tools-serve-config.js"; import type { CliBackendConfig } from "../../plugins/cli-backend.types.js"; import type { @@ -103,16 +106,7 @@ import { getClaudeLiveSessionGenerationForOwner } from "./claude-live-session.js import { prepareClaudeCliSkillsPlugin } from "./claude-skills-plugin.js"; import { buildCliAgentSystemPrompt, isClaudeCliProvider, normalizeCliModel } from "./helpers.js"; import { cliBackendLog } from "./log.js"; -import { - buildCliMcpBashElevated, - buildCliMcpChannelContext, - buildCliMcpExecOverrides, - buildCliMcpExecSession, - buildCliMcpGrantContext, - normalizeOptionalMcpContextValue, - resolveCliMcpMessageProvider, - resolveCliMcpSessionKey, -} from "./mcp-grant-context.js"; +import { buildCliMcpGrantContext, normalizeOptionalMcpContextValue } from "./mcp-grant-context.js"; import { CLAUDE_CLI_CONTEXT_MODEL_ALIASES, resolveNodeClaudePlacement } from "./prepare-claude.js"; import { buildCliSessionHistoryPrompt, @@ -153,6 +147,7 @@ const prepareDeps = { deactivateMcpLoopbackClientGrantCapture, mintMcpLoopbackClientGrant, revokeMcpLoopbackClientGrant, + resolveMcpLoopbackPolicyTools, resolveMcpLoopbackScopedTools, resolveOpenClawReferencePaths: async ( params: Parameters[0], @@ -358,25 +353,36 @@ export async function prepareCliRunContext( if (!backendResolved) { throw new Error(`Unknown CLI backend: ${params.provider}`); } + let runtimeToolsAllowPolicy: string[] | undefined; if (params.toolsAllow !== undefined) { if (params.cliToolAvailability !== undefined) { throw new Error( `CLI backend ${backendResolved.id} received conflicting runtime tool policies`, ); } - const normalizedToolsAllow = expandToolGroups(params.toolsAllow); - if (normalizedToolsAllow.includes("*")) { + if (params.toolsAllow.some((toolName) => normalizeToolName(toolName) === "*")) { params = { ...params, toolsAllow: undefined }; } else { - const canonicalToolsAllow = uniqueStrings( - normalizedToolsAllow.map((toolName) => normalizeToolName(toolName)).filter(Boolean), + runtimeToolsAllowPolicy = [...params.toolsAllow]; + const fallbackOpenClawTools = uniqueStrings( + expandToolGroups(params.toolsAllow) + .map((toolName) => normalizeToolName(toolName)) + .filter(Boolean), ); + if ( + fallbackOpenClawTools.includes("write") && + !fallbackOpenClawTools.includes("apply_patch") + ) { + fallbackOpenClawTools.push("apply_patch"); + } params = { ...params, toolsAllow: undefined, cliToolAvailability: { native: [], - openClaw: canonicalToolsAllow, + // Preserve the prior normalized fallback for modes without a catalog; + // catalog-backed paths replace it with exact names below. + openClaw: fallbackOpenClawTools, }, }; } @@ -509,15 +515,6 @@ export async function prepareCliRunContext( bindingExtraSystemPromptStatic !== undefined ? hashCliSessionText(bindingExtraSystemPromptStatic.trim() || undefined) : hashCliSessionText(extraSystemPrompt); - const toolBoundExtraSystemPromptHash = params.cliToolAvailability - ? hashCliSessionText( - JSON.stringify([ - baseExtraSystemPromptHash ?? null, - params.cliToolAvailability.native.toSorted(), - params.cliToolAvailability.openClaw.toSorted(), - ]), - ) - : baseExtraSystemPromptHash; const requireExplicitMessageTarget = params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey); const hasCliSessionBindingFacts = bindingFacts !== undefined; @@ -678,12 +675,6 @@ export async function prepareCliRunContext( seenSignatures: params.bootstrapPromptWarningSignaturesSeen, previousSignature: params.bootstrapPromptWarningSignature, }); - // Bootstrap guidance changes resumable system context. Hash the pending mode - // so entering or leaving bootstrap refreshes first-only CLI system prompts. - const extraSystemPromptHash = - bootstrapMode === "none" - ? toolBoundExtraSystemPromptHash - : hashCliSessionText(JSON.stringify([toolBoundExtraSystemPromptHash ?? null, bootstrapMode])); // Ring-zero OpenClaw runs replace the bundle MCP surface entirely: no // loopback server, no plugin/user servers. A selectable backend also removes // its native tools, leaving only this openclaw stdio server. @@ -714,29 +705,83 @@ export async function prepareCliRunContext( ); } const mcpDeliveryCaptureEnabled = bundleMcpEnabled && Boolean(mcpLoopbackRuntime); + const runtimeConfig = params.config ?? getRuntimeConfig(); + const shouldMaterializeRuntimePolicy = + runtimeToolsAllowPolicy !== undefined && + !nodeClaudePlacement && + !isSideQuestion && + !systemAgentMcpConfig && + params.disableTools !== true; + const mcpContextBase = + mcpLoopbackRuntime || shouldMaterializeRuntimePolicy + ? buildCliMcpGrantContext({ + run: params, + config: runtimeConfig, + requireExplicitMessageTarget, + agentId: sessionAgentId, + modelProvider, + modelId, + }) + : undefined; + const requestedLoopbackToolsAllow = + runtimeToolsAllowPolicy ?? params.cliToolAvailability?.openClaw; + const mcpProjectionContext = + mcpContextBase && requestedLoopbackToolsAllow !== undefined + ? { ...mcpContextBase, toolsAllow: [...requestedLoopbackToolsAllow] } + : mcpContextBase; + const resolveProjectedTools = + runtimeToolsAllowPolicy !== undefined + ? prepareDeps.resolveMcpLoopbackPolicyTools + : prepareDeps.resolveMcpLoopbackScopedTools; + const projectedTools = + (bundleMcpEnabled || shouldMaterializeRuntimePolicy) && mcpProjectionContext + ? resolveProjectedTools({ cfg: runtimeConfig, ...mcpProjectionContext }).tools + : []; + if (runtimeToolsAllowPolicy !== undefined && shouldMaterializeRuntimePolicy) { + params = { + ...params, + cliToolAvailability: { + native: [], + openClaw: projectedTools.map((tool) => tool.name), + }, + }; + } + const promptTools = bundleMcpEnabled ? projectedTools : []; // A restricted selectable tool surface must also bound the MCP bundle: // CLI-side --allowedTools is advisory under bypass permission modes, so // user/plugin MCP servers must not be merged into the run's config at all. // The loopback server (scoped by the grant allowlist) becomes the complete // tool universe for the run. const restrictedLoopbackToolsAllow = params.cliToolAvailability?.openClaw; + const mcpGrantContext = + mcpContextBase && restrictedLoopbackToolsAllow !== undefined + ? { ...mcpContextBase, toolsAllow: [...restrictedLoopbackToolsAllow] } + : mcpContextBase; + const toolBoundExtraSystemPromptHash = params.cliToolAvailability + ? hashCliSessionText( + JSON.stringify([ + baseExtraSystemPromptHash ?? null, + params.cliToolAvailability.native.toSorted(), + params.cliToolAvailability.openClaw.toSorted(), + ]), + ) + : baseExtraSystemPromptHash; + // Bootstrap guidance changes resumable system context. Hash the pending mode + // so entering or leaving bootstrap refreshes first-only CLI system prompts. + const extraSystemPromptHash = + bootstrapMode === "none" + ? toolBoundExtraSystemPromptHash + : hashCliSessionText(JSON.stringify([toolBoundExtraSystemPromptHash ?? null, bootstrapMode])); let cleanupPreparedResources: (() => Promise) | undefined; let preparedExecution: PrivateCliBackendPreparedExecution | undefined; try { - const mcpClientGrant = mcpLoopbackRuntime - ? prepareDeps.mintMcpLoopbackClientGrant({ - context: buildCliMcpGrantContext({ - run: params, - config: params.config ?? getRuntimeConfig(), - requireExplicitMessageTarget, - agentId: sessionAgentId, - modelProvider, - modelId, - toolsAllow: restrictedLoopbackToolsAllow, - }), - runtimeOwnerToken: mcpLoopbackRuntime.ownerToken, - }) - : undefined; + const mcpClientGrant = + mcpLoopbackRuntime && mcpGrantContext + ? prepareDeps.mintMcpLoopbackClientGrant({ + context: mcpGrantContext, + runtimeOwnerToken: mcpLoopbackRuntime.ownerToken, + }) + : undefined; const mcpClientGrantCapture = mcpClientGrant && mcpLoopbackRuntime ? { @@ -940,54 +985,6 @@ export async function prepareCliRunContext( ...(mcpClientGrantCapture ? { mcpClientGrantCapture } : {}), ...(preparedCleanup ? { cleanup: preparedCleanup } : {}), }; - const promptTools = - bundleMcpEnabled && mcpLoopbackRuntime - ? prepareDeps.resolveMcpLoopbackScopedTools({ - cfg: params.config ?? getRuntimeConfig(), - sessionKey: resolveCliMcpSessionKey( - params, - params.config ?? getRuntimeConfig(), - sessionAgentId, - ), - runtimePolicySessionKey: normalizeOptionalMcpContextValue( - params.runtimePolicySessionKey, - ), - agentId: sessionAgentId, - messageProvider: resolveCliMcpMessageProvider(params), - clientCaps: params.clientCaps, - currentChannelId: params.currentChannelId, - // CLI binding hashes omit per-message facts, but identity, owner, and - // model policy must match the runtime MCP grant's advertised tools. - currentThreadTs: undefined, - currentMessageId: undefined, - currentInboundAudio: undefined, - accountId: params.agentAccountId, - inboundEventKind: undefined, - sourceReplyDeliveryMode: bindingSourceReplyDeliveryMode, - taskSuggestionDeliveryMode: params.taskSuggestionDeliveryMode, - requireExplicitMessageTarget: bindingRequireExplicitMessageTarget, - senderIsOwner: params.senderIsOwner === true, - nodeExecAllowed: true, - modelProvider, - modelId, - execSession: buildCliMcpExecSession(params.sessionEntry), - execOverrides: buildCliMcpExecOverrides(params.execOverrides), - bashElevated: buildCliMcpBashElevated(params.bashElevated), - trigger: params.trigger, - approvalReviewerDeviceId: normalizeOptionalMcpContextValue( - params.approvalReviewerDeviceId, - ), - channelContext: buildCliMcpChannelContext(params.channelContext, params.senderId), - senderName: normalizeOptionalMcpContextValue(params.senderName ?? undefined), - senderUsername: normalizeOptionalMcpContextValue(params.senderUsername ?? undefined), - senderE164: normalizeOptionalMcpContextValue(params.senderE164 ?? undefined), - groupId: normalizeOptionalMcpContextValue(params.groupId ?? undefined), - groupChannel: normalizeOptionalMcpContextValue(params.groupChannel ?? undefined), - groupSpace: normalizeOptionalMcpContextValue(params.groupSpace ?? undefined), - spawnedBy: normalizeOptionalMcpContextValue(params.spawnedBy ?? undefined), - toolsAllow: restrictedLoopbackToolsAllow, - }).tools - : []; const promptToolNamesHash = bundleMcpEnabled && mcpLoopbackRuntime ? hashCliSessionText(JSON.stringify(promptTools.map((tool) => tool.name).toSorted())) diff --git a/src/gateway/mcp-http.runtime.test.ts b/src/gateway/mcp-http.runtime.test.ts index eee3e438f520..cfbfa66a0c31 100644 --- a/src/gateway/mcp-http.runtime.test.ts +++ b/src/gateway/mcp-http.runtime.test.ts @@ -1,6 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { McpLoopbackToolCache, resolveMcpLoopbackScopedTools } from "./mcp-http.runtime.js"; +import { setPluginToolMeta } from "../plugins/tools.js"; +import { + McpLoopbackToolCache, + resolveMcpLoopbackPolicyTools, + resolveMcpLoopbackScopedTools, +} from "./mcp-http.runtime.js"; const resolveGatewayScopedTools = vi.hoisted(() => vi.fn()); @@ -27,7 +32,7 @@ function scopeParams(overrides: Record = {}) { accountId: undefined, inboundEventKind: undefined, sourceReplyDeliveryMode: undefined, - senderIsOwner: undefined, + senderIsOwner: false, ...overrides, } as Parameters[0]; } @@ -60,6 +65,17 @@ describe("resolveMcpLoopbackScopedTools", () => { ]); }); + it("keeps exact grant names exact instead of reinterpreting policy shorthand", () => { + resolveGatewayScopedTools.mockReturnValue(scopedToolFixture(["write", "apply_patch"])); + + const scoped = resolveMcpLoopbackScopedTools(scopeParams({ toolsAllow: ["write"] })); + + expect(scoped.tools.map((tool) => (tool as { name: string }).name)).toEqual(["write"]); + expect(resolveGatewayScopedTools.mock.calls[0]?.[0]).toMatchObject({ + mediatedToolNames: new Set(["write"]), + }); + }); + it("fails closed on an empty grant allowlist", () => { const scoped = resolveMcpLoopbackScopedTools(scopeParams({ toolsAllow: [] })); expect(scoped.tools).toEqual([]); @@ -91,6 +107,55 @@ describe("resolveMcpLoopbackScopedTools", () => { expect(call.excludeToolNames?.has("write")).toBe(true); expect(call.mediatedToolNames).toEqual(new Set(["read", "exec"])); }); + + it.each([ + { allow: ["write"], expected: ["write", "apply_patch"] }, + { allow: ["apply-patch"], expected: ["apply_patch"] }, + { allow: ["web_*"], expected: ["web_search", "web_fetch"] }, + { allow: ["group:fs"], expected: ["read", "write", "edit", "apply_patch"] }, + { allow: [] as string[], expected: [] }, + { allow: ["unknown"], expected: [] }, + ])( + "materializes policy expressions into concrete loopback tools: $allow", + ({ allow, expected }) => { + resolveGatewayScopedTools.mockReturnValue( + scopedToolFixture([ + "read", + "write", + "edit", + "apply_patch", + "web_search", + "web_fetch", + "message", + ]), + ); + + const scoped = resolveMcpLoopbackPolicyTools(scopeParams({ toolsAllow: allow })); + + expect(scoped.tools.map((tool) => (tool as { name: string }).name)).toEqual(expected); + }, + ); + + it.each([ + { allow: ["group:plugins"], expected: ["memory_search", "memory_get"] }, + { allow: ["active-memory"], expected: ["memory_search", "memory_get"] }, + ])("materializes plugin policy selectors: $allow", ({ allow, expected }) => { + const pluginTools = ["memory_search", "memory_get"].map((name) => ({ + name, + description: `${name} tool`, + })); + for (const tool of pluginTools) { + setPluginToolMeta(tool as never, { pluginId: "active-memory", optional: false }); + } + resolveGatewayScopedTools.mockReturnValue({ + agentId: "main", + tools: [...pluginTools, { name: "message", description: "message tool" }], + }); + + const scoped = resolveMcpLoopbackPolicyTools(scopeParams({ toolsAllow: allow })); + + expect(scoped.tools.map((tool) => (tool as { name: string }).name)).toEqual(expected); + }); }); describe("McpLoopbackToolCache", () => { diff --git a/src/gateway/mcp-http.runtime.ts b/src/gateway/mcp-http.runtime.ts index 7f6cc7e6c500..e39bdcd443b2 100644 --- a/src/gateway/mcp-http.runtime.ts +++ b/src/gateway/mcp-http.runtime.ts @@ -1,16 +1,10 @@ // 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 { ScheduledToolPolicyContext } from "../agents/scheduled-tool-policy.js"; +import { applyEmbeddedAttemptToolsAllow } from "../agents/embedded-agent-runner/run/attempt-tool-construction-plan.js"; import { normalizeToolName } from "../agents/tool-policy.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 { getPluginToolMeta } from "../plugins/tools.js"; +import type { McpLoopbackRequestContext } from "./mcp-grant-store.js"; import { buildMcpToolSchema, readMcpLoopbackToolName, @@ -34,63 +28,51 @@ type CachedScopedTools = { time: number; }; -type McpLoopbackScopeParams = { +type McpLoopbackScopeParams = Omit & { cfg: OpenClawConfig; - sessionKey: string; - runtimePolicySessionKey?: string; - agentId?: string; - sessionId?: string; - runId?: string; - workspaceDir?: string; - cwd?: string; - modelProvider?: string; - modelId?: string; + senderIsOwner: boolean | undefined; yieldContextCacheKey?: string; onYield?: (message: string) => Promise | 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; - /** Per-run grant allowlist of gateway tool names; unset keeps full scope. */ - toolsAllow?: string[]; - scheduledToolPolicy?: ScheduledToolPolicyContext; - 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): { +type LoopbackToolsAllowMode = "exact" | "policy"; + +function resolveMediatedNativeTools( + toolsAllow: string[] | undefined, + mode: LoopbackToolsAllowMode, +): Set { + if (mode === "exact") { + return new Set( + (toolsAllow ?? []) + .map((name) => normalizeToolName(name)) + .filter((name) => NATIVE_TOOL_EXCLUDE.has(name)), + ); + } + if ( + toolsAllow === undefined || + toolsAllow.some((toolName) => normalizeToolName(toolName) === "*") + ) { + return new Set(); + } + return new Set( + applyEmbeddedAttemptToolsAllow( + Array.from(NATIVE_TOOL_EXCLUDE, (name) => ({ name })), + toolsAllow, + ).map((tool) => tool.name), + ); +} + +function resolveMcpLoopbackTools( + params: McpLoopbackScopeParams, + mode: LoopbackToolsAllowMode, +): { agentId: string | undefined; tools: McpLoopbackTool[]; } { const excludeToolNames = new Set(NATIVE_TOOL_EXCLUDE); // Restricted CLI grants use OpenClaw's implementations for coding tools; // native CLI tools bypass path, approval, sandbox, and exec policy. - const mediatedNativeTools = new Set( - (params.toolsAllow ?? []) - .map((name) => normalizeToolName(name)) - .filter((name) => NATIVE_TOOL_EXCLUDE.has(name)), - ); + const mediatedNativeTools = resolveMediatedNativeTools(params.toolsAllow, mode); for (const toolName of mediatedNativeTools) { excludeToolNames.delete(toolName); } @@ -98,8 +80,9 @@ export function resolveMcpLoopbackScopedTools(params: McpLoopbackScopeParams): { if (includeNodeExecTool) { excludeToolNames.delete("exec"); } + const { toolsAllow: _toolsAllow, ...scopeParams } = params; const scoped = resolveGatewayScopedTools({ - ...params, + ...scopeParams, conversationReadOrigin: "delegated", surface: "loopback", excludeToolNames, @@ -108,10 +91,29 @@ export function resolveMcpLoopbackScopedTools(params: McpLoopbackScopeParams): { }); return { agentId: scoped.agentId, - tools: applyGrantToolsAllow(scoped.tools, params.toolsAllow), + tools: + mode === "exact" + ? applyGrantToolsAllow(scoped.tools, params.toolsAllow) + : applyPolicyToolsAllow(scoped.tools, params.toolsAllow), }; } +/** Resolves loopback-visible tools from the exact names carried by a minted grant. */ +export function resolveMcpLoopbackScopedTools(params: McpLoopbackScopeParams): { + agentId: string | undefined; + tools: McpLoopbackTool[]; +} { + return resolveMcpLoopbackTools(params, "exact"); +} + +/** Materializes runtime policy expressions against the concrete loopback catalog. */ +export function resolveMcpLoopbackPolicyTools(params: McpLoopbackScopeParams): { + agentId: string | undefined; + tools: McpLoopbackTool[]; +} { + return resolveMcpLoopbackTools(params, "policy"); +} + /** * Hard-enforces a per-run grant allowlist on the loopback surface. Both * tools/list and tools/call consume this list, so a tool outside the @@ -132,6 +134,24 @@ function applyGrantToolsAllow( }); } +function applyPolicyToolsAllow( + tools: McpLoopbackTool[], + toolsAllow: string[] | undefined, +): McpLoopbackTool[] { + if (!toolsAllow) { + return tools; + } + // Grant lists remain exact; only this pre-mint path may expand groups, + // globs, plugin ids, and write-to-apply_patch policy semantics. + const candidates = tools.flatMap((tool) => { + const name = readMcpLoopbackToolName(tool); + return name ? [{ name, tool }] : []; + }); + return applyEmbeddedAttemptToolsAllow(candidates, toolsAllow, { + toolMeta: (candidate) => getPluginToolMeta(candidate.tool), + }).map((candidate) => candidate.tool); +} + /** Short-lived cache for loopback tool lists keyed by session/channel context. */ export class McpLoopbackToolCache { #entries = new Map(); @@ -154,7 +174,7 @@ export class McpLoopbackToolCache { clientCapsCacheKey, params.currentChannelId ?? "", params.currentThreadTs ?? "", - params.currentMessageId != null ? String(params.currentMessageId) : "", + params.currentMessageId ?? "", params.currentInboundAudio === true ? "audio" : "no-audio", params.accountId ?? "", params.inboundEventKind ?? "",