fix(cron): align CLI scheduled tool projections

This commit is contained in:
joshavant
2026-07-23 03:42:39 -05:00
committed by Josh Avant
parent 84f87c63db
commit 24088edcb5
4 changed files with 317 additions and 196 deletions
+82 -43
View File
@@ -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<string, unknown>) => ({
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<string, unknown>) => ({
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<string, unknown>) => ({
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<void>) | 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?.();
}
+90 -93
View File
@@ -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<typeof import("../docs-path.js").resolveOpenClawReferencePaths>[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<void>) | 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()))
+67 -2
View File
@@ -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<string, unknown> = {}) {
accountId: undefined,
inboundEventKind: undefined,
sourceReplyDeliveryMode: undefined,
senderIsOwner: undefined,
senderIsOwner: false,
...overrides,
} as Parameters<typeof resolveMcpLoopbackScopedTools>[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", () => {
+78 -58
View File
@@ -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<McpLoopbackRequestContext, "senderIsOwner"> & {
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> | 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<string> {
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<string, CachedScopedTools>();
@@ -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 ?? "",