mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(cron): align CLI scheduled tool projections
This commit is contained in:
@@ -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?.();
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
Reference in New Issue
Block a user