fix: prevent CLI MCP children from widening sandbox tools (#103822)

(cherry picked from commit 5c260940bd)
This commit is contained in:
Dallin Romney
2026-07-14 04:18:36 -07:00
parent 96c85dfeb3
commit a04bc64ab7
14 changed files with 946 additions and 165 deletions
+2 -2
View File
@@ -423,8 +423,8 @@ Current bundled behavior:
When bundle MCP is enabled, OpenClaw:
- spawns a loopback HTTP MCP server that exposes gateway tools to the CLI process
- authenticates the bridge with a per-session token (`OPENCLAW_MCP_TOKEN`)
- scopes tool access to the current session, account, and channel context
- authenticates the bridge with a per-run context grant (`OPENCLAW_MCP_TOKEN`) active only for the current execution attempt
- binds tool access to the Gateway-selected session, account, and channel context instead of trusting child-process headers
- loads enabled bundle-MCP servers for the current workspace
- merges them with any existing backend MCP config/settings shape
- rewrites the launch config using the backend-owned integration mode from the owning extension
+36
View File
@@ -2774,6 +2774,42 @@ ${JSON.stringify({
expect(requireArgAfter(spawnArg.argv, "--permission-mode")).toBe("bypassPermissions");
});
it("cleans live-turn resources when capture activation fails before spawn", async () => {
const cleanup = vi.fn(async () => undefined);
const context = buildPreparedCliRunContext({
provider: "claude-cli",
model: "sonnet",
runId: "run-live-capture-activation-failure",
mcpDeliveryCapture: true,
});
await expect(
runClaudeLiveSessionTurn({
context,
args: [],
env: {},
prompt: "hi",
useResume: false,
noOutputTimeoutMs: 1_000,
getProcessSupervisor: () => ({
spawn: (params: Parameters<SupervisorSpawnFn>[0]) =>
supervisorSpawnMock(params) as ReturnType<SupervisorSpawnFn>,
cancel: vi.fn(),
cancelScope: vi.fn(),
getRecord: vi.fn(),
}),
onAssistantDelta: () => {},
onMcpCaptureReady: () => {
throw new Error("grant activation failed");
},
cleanup,
}),
).rejects.toThrow("grant activation failed");
expect(cleanup).toHaveBeenCalledOnce();
expect(supervisorSpawnMock).not.toHaveBeenCalled();
});
it("uses a fresh Claude live process and capture key for every captured turn", async () => {
const logWarnSpy = vi.spyOn(cliBackendLog, "warn").mockImplementation(() => undefined);
const cancels: Array<ReturnType<typeof vi.fn>> = [];
+21 -4
View File
@@ -1286,6 +1286,14 @@ export async function runClaudeLiveSessionTurn(params: {
session = null;
}
let cleanupTurnArtifacts = Boolean(session);
let notifiedMcpCaptureKey: string | undefined;
const notifyMcpCaptureReady = (captureKey: string | undefined) => {
if (!captureKey || notifiedMcpCaptureKey === captureKey) {
return;
}
params.onMcpCaptureReady?.(captureKey);
notifiedMcpCaptureKey = captureKey;
};
try {
ensureLiveSessionCapacity(key, params.context);
} catch (error) {
@@ -1312,13 +1320,24 @@ export async function runClaudeLiveSessionTurn(params: {
}
}
if (!session) {
const mcpCaptureKey = params.context.mcpDeliveryCapture ? crypto.randomUUID() : undefined;
if (mcpCaptureKey) {
// Fence the Gateway grant before the capture-bearing child can issue
// its first loopback request during process startup.
try {
notifyMcpCaptureReady(mcpCaptureKey);
} catch (error) {
await cleanup();
throw error;
}
}
const createSession = createClaudeLiveSession({
context: params.context,
argv,
env: params.env,
fingerprint,
key,
mcpCaptureKey: params.context.mcpDeliveryCapture ? crypto.randomUUID() : undefined,
mcpCaptureKey,
noOutputTimeoutMs: params.noOutputTimeoutMs,
supervisor: params.getProcessSupervisor(),
cleanup,
@@ -1354,9 +1373,7 @@ export async function runClaudeLiveSessionTurn(params: {
throw new Error("Claude CLI live session is already handling a turn");
}
const liveSession = session;
if (liveSession.mcpCaptureKey) {
params.onMcpCaptureReady?.(liveSession.mcpCaptureKey);
}
notifyMcpCaptureReady(liveSession.mcpCaptureKey);
liveSession.noOutputTimeoutMs = params.noOutputTimeoutMs;
liveSession.stderr = "";
@@ -1,6 +1,6 @@
// Covers CLI execution paths where the process supervisor keeps stdout capture
// disabled and the runner must parse streamed chunks without relying on tails.
import { beforeEach, describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
markMcpLoopbackRequestFinished,
markMcpLoopbackRequestStarted,
@@ -1210,9 +1210,36 @@ describe("executePreparedCliRun supervisor output capture", () => {
]);
});
it("captures non-Claude JSONL sends and gives every attempt a unique token", async () => {
it("deactivates a Claude live capture when process startup fails", async () => {
const context = buildPreparedCliRunContext({ output: "jsonl", provider: "claude-cli" });
context.mcpDeliveryCapture = true;
context.preparedBackend.backend.liveSession = "claude-stdio";
const activateCapture = vi.fn<(captureKey: string) => void>();
const deactivateCapture = vi.fn<(captureKey: string) => void>();
context.preparedBackend.mcpClientGrantCapture = {
activate: activateCapture,
deactivate: deactivateCapture,
};
supervisorSpawnMock.mockRejectedValueOnce(new Error("spawn failed"));
await expect(executePreparedCliRun(context)).rejects.toThrow("spawn failed");
expect(activateCapture).toHaveBeenCalledOnce();
expect(deactivateCapture).toHaveBeenCalledExactlyOnceWith(activateCapture.mock.calls[0]?.[0]);
expect(activateCapture.mock.invocationCallOrder[0]).toBeLessThan(
supervisorSpawnMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
);
});
it("captures non-Claude JSONL sends and fences every attempt with a unique key", async () => {
const context = buildPreparedCliRunContext({ output: "jsonl", provider: "local-cli" });
context.mcpDeliveryCapture = true;
const activateCapture = vi.fn<(captureKey: string) => void>();
const deactivateCapture = vi.fn<(captureKey: string) => void>();
context.preparedBackend.mcpClientGrantCapture = {
activate: activateCapture,
deactivate: deactivateCapture,
};
const captureKeys: string[] = [];
supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => {
const input = args[0] as SupervisorSpawnInput;
@@ -1250,5 +1277,10 @@ describe("executePreparedCliRun supervisor output capture", () => {
expect(second.didSendViaMessagingTool).toBe(true);
expect(captureKeys).toHaveLength(2);
expect(captureKeys[0]).not.toBe(captureKeys[1]);
expect(activateCapture.mock.calls.map(([captureKey]) => captureKey)).toEqual(captureKeys);
expect(deactivateCapture.mock.calls.map(([captureKey]) => captureKey)).toEqual(captureKeys);
expect(deactivateCapture.mock.invocationCallOrder[0]).toBeLessThan(
activateCapture.mock.invocationCallOrder[1] ?? Number.POSITIVE_INFINITY,
);
});
});
+10 -6
View File
@@ -363,10 +363,7 @@ function formatCliEnvKeyList(keys: readonly string[]): string {
function buildCliEnvMcpLog(childEnv: Record<string, string>): string {
return [
`token=${childEnv.OPENCLAW_MCP_TOKEN ? "set" : "missing"}`,
`sessionKey=${childEnv.OPENCLAW_MCP_SESSION_KEY ? "set" : "<empty>"}`,
`agentId=${childEnv.OPENCLAW_MCP_AGENT_ID || "<empty>"}`,
`accountId=${childEnv.OPENCLAW_MCP_ACCOUNT_ID || "<empty>"}`,
`messageChannel=${childEnv.OPENCLAW_MCP_MESSAGE_CHANNEL || "<empty>"}`,
`capture=${childEnv.OPENCLAW_MCP_CLI_CAPTURE_KEY ? "set" : "missing"}`,
].join(" ");
}
@@ -728,7 +725,7 @@ export async function executePreparedCliRun(
});
cliBackendLog.info(`cli argv: ${backend.command} ${logArgs.join(" ")}`);
cliBackendLog.info(`cli env auth: ${buildCliEnvAuthLog(env)}`);
if (env.OPENCLAW_MCP_TOKEN || env.OPENCLAW_MCP_SESSION_KEY || env.OPENCLAW_MCP_AGENT_ID) {
if (env.OPENCLAW_MCP_TOKEN) {
cliBackendLog.info(`cli env mcp: ${buildCliEnvMcpLog(env)}`);
}
}
@@ -827,6 +824,7 @@ export async function executePreparedCliRun(
if (gatewayCaptureKey) {
throw new Error("CLI MCP capture key changed during an active attempt");
}
context.preparedBackend.mcpClientGrantCapture?.activate(captureKey);
gatewayCaptureKey = captureKey;
const isAdmittedPotentialMessagingDelivery = (toolName: string) => {
return isMessagingTool(normalizeCliMessagingToolName(toolName));
@@ -1446,7 +1444,13 @@ export async function executePreparedCliRun(
recordRunError(error);
} finally {
if (gatewayCaptureKey) {
clearMcpLoopbackToolCallCapture(gatewayCaptureKey);
// Fence this exact grant generation before clearing observers;
// otherwise a late request escapes accounting.
try {
context.preparedBackend.mcpClientGrantCapture?.deactivate(gatewayCaptureKey);
} finally {
clearMcpLoopbackToolCallCapture(gatewayCaptureKey);
}
}
}
try {
+70 -29
View File
@@ -14,6 +14,10 @@ import {
registerContextEngineForOwner,
} from "../../context-engine/registry.js";
import type { ContextEngine } from "../../context-engine/types.js";
import type {
McpLoopbackClientGrant,
McpLoopbackRequestContext,
} from "../../gateway/mcp-grant-store.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { clearMemoryPluginState, registerMemoryPromptSection } from "../../plugins/memory-state.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
@@ -115,19 +119,6 @@ function createTestMcpLoopbackServerConfig(port: number) {
alwaysLoad: true,
headers: {
Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}",
"x-session-key": "${OPENCLAW_MCP_SESSION_KEY}",
"x-openclaw-session-id": "${OPENCLAW_MCP_SESSION_ID}",
"x-openclaw-agent-id": "${OPENCLAW_MCP_AGENT_ID}",
"x-openclaw-account-id": "${OPENCLAW_MCP_ACCOUNT_ID}",
"x-openclaw-message-channel": "${OPENCLAW_MCP_MESSAGE_CHANNEL}",
"x-openclaw-current-channel-id": "${OPENCLAW_MCP_CURRENT_CHANNEL_ID}",
"x-openclaw-current-thread-ts": "${OPENCLAW_MCP_CURRENT_THREAD_TS}",
"x-openclaw-current-message-id": "${OPENCLAW_MCP_CURRENT_MESSAGE_ID}",
"x-openclaw-current-inbound-audio": "${OPENCLAW_MCP_CURRENT_INBOUND_AUDIO}",
"x-openclaw-inbound-event-kind": "${OPENCLAW_MCP_INBOUND_EVENT_KIND}",
"x-openclaw-source-reply-delivery-mode": "${OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE}",
"x-openclaw-require-explicit-message-target":
"${OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET}",
"x-openclaw-cli-capture-key": "${OPENCLAW_MCP_CLI_CAPTURE_KEY}",
},
},
@@ -135,6 +126,15 @@ function createTestMcpLoopbackServerConfig(port: number) {
};
}
function createTestMcpLoopbackClientGrant(params: {
context: McpLoopbackRequestContext;
}): McpLoopbackClientGrant {
return {
token: "loopback-token",
context: structuredClone(params.context),
};
}
async function createTestMcpLoopbackServer(port = 0) {
return {
port,
@@ -257,9 +257,8 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
getActiveMcpLoopbackRuntime: vi.fn(() => undefined),
ensureMcpLoopbackServer: vi.fn(createTestMcpLoopbackServer),
createMcpLoopbackServerConfig: vi.fn(createTestMcpLoopbackServerConfig),
resolveMcpLoopbackBearerToken: vi.fn((runtime, senderIsOwner) =>
senderIsOwner ? runtime.ownerToken : runtime.nonOwnerToken,
),
mintMcpLoopbackClientGrant: vi.fn(createTestMcpLoopbackClientGrant),
revokeMcpLoopbackClientGrant: vi.fn(() => true),
resolveMcpLoopbackScopedTools: vi.fn(() => ({ agentId: "main", tools: [] })),
resolveOpenClawReferencePaths: vi.fn(async () => ({ docsPath: null, sourcePath: null })),
prepareClaudeCliSkillsPlugin: vi.fn(async () => ({
@@ -809,7 +808,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
getActiveMcpLoopbackRuntime,
ensureMcpLoopbackServer: vi.fn(createTestMcpLoopbackServer),
createMcpLoopbackServerConfig: vi.fn(createTestMcpLoopbackServerConfig),
resolveMcpLoopbackBearerToken: vi.fn(() => "loopback-token"),
mintMcpLoopbackClientGrant: vi.fn(createTestMcpLoopbackClientGrant),
resolveMcpLoopbackScopedTools: vi.fn(() => ({ agentId: "main", tools: [] })),
});
@@ -865,6 +864,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
?.GEMINI_CLI_SYSTEM_SETTINGS_PATH;
throw new Error("Gemini auth profile was selected but no credential material was found");
});
const revokeMcpLoopbackClientGrant = vi.fn(() => true);
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
@@ -888,7 +888,8 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
getActiveMcpLoopbackRuntime,
ensureMcpLoopbackServer: vi.fn(createTestMcpLoopbackServer),
createMcpLoopbackServerConfig: vi.fn(createTestMcpLoopbackServerConfig),
resolveMcpLoopbackBearerToken: vi.fn(() => "loopback-token"),
mintMcpLoopbackClientGrant: vi.fn(createTestMcpLoopbackClientGrant),
revokeMcpLoopbackClientGrant,
resolveMcpLoopbackScopedTools: vi.fn(() => ({ agentId: "main", tools: [] })),
});
@@ -910,6 +911,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
expect(generatedSystemSettingsPath).toBeTruthy();
expect(fs.existsSync(generatedSystemSettingsPath ?? "")).toBe(false);
expect(revokeMcpLoopbackClientGrant).toHaveBeenCalledExactlyOnceWith("loopback-token");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
@@ -964,7 +966,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
makeBootstrapWarn: vi.fn(() => () => undefined),
getActiveMcpLoopbackRuntime: vi.fn(() => undefined),
createMcpLoopbackServerConfig: vi.fn(createTestMcpLoopbackServerConfig),
resolveMcpLoopbackBearerToken: vi.fn(() => "token"),
mintMcpLoopbackClientGrant: vi.fn(createTestMcpLoopbackClientGrant),
resolveMcpLoopbackScopedTools: vi.fn(() => ({
agentId: "main",
tools: [
@@ -2034,6 +2036,10 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
}));
const ensureMcpLoopbackServer = vi.fn(createTestMcpLoopbackServer);
const createMcpLoopbackServerConfig = vi.fn(createTestMcpLoopbackServerConfig);
const activateMcpLoopbackClientGrantCapture = vi.fn(() => true);
const deactivateMcpLoopbackClientGrantCapture = vi.fn(() => true);
const mintMcpLoopbackClientGrant = vi.fn(createTestMcpLoopbackClientGrant);
const revokeMcpLoopbackClientGrant = vi.fn(() => true);
const resolveMcpLoopbackScopedTools = vi.fn(() => ({
agentId: "main",
tools: [
@@ -2050,6 +2056,10 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
getActiveMcpLoopbackRuntime,
ensureMcpLoopbackServer,
createMcpLoopbackServerConfig,
activateMcpLoopbackClientGrantCapture,
deactivateMcpLoopbackClientGrantCapture,
mintMcpLoopbackClientGrant,
revokeMcpLoopbackClientGrant,
resolveMcpLoopbackScopedTools,
});
cliBackendsTesting.setDepsForTest({
@@ -2196,7 +2206,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
}
});
it("passes current turn kind into bundle MCP loopback env", async () => {
it("binds current turn context into the bundle MCP client grant", async () => {
const { dir, sessionFile } = createSessionFile();
try {
const getActiveMcpLoopbackRuntime = vi.fn(() => ({
@@ -2206,6 +2216,10 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
}));
const ensureMcpLoopbackServer = vi.fn(createTestMcpLoopbackServer);
const createMcpLoopbackServerConfig = vi.fn(createTestMcpLoopbackServerConfig);
const activateMcpLoopbackClientGrantCapture = vi.fn(() => true);
const deactivateMcpLoopbackClientGrantCapture = vi.fn(() => true);
const mintMcpLoopbackClientGrant = vi.fn(createTestMcpLoopbackClientGrant);
const revokeMcpLoopbackClientGrant = vi.fn(() => true);
const resolveMcpLoopbackScopedTools = vi.fn(() => ({
agentId: "main",
tools: [
@@ -2222,6 +2236,10 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
getActiveMcpLoopbackRuntime,
ensureMcpLoopbackServer,
createMcpLoopbackServerConfig,
activateMcpLoopbackClientGrantCapture,
deactivateMcpLoopbackClientGrantCapture,
mintMcpLoopbackClientGrant,
revokeMcpLoopbackClientGrant,
resolveMcpLoopbackScopedTools,
});
cliBackendsTesting.setDepsForTest({
@@ -2263,17 +2281,38 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
});
expect(context.preparedBackend.env).toMatchObject({
OPENCLAW_MCP_SESSION_ID: "session-test",
OPENCLAW_MCP_MESSAGE_CHANNEL: "telegram",
OPENCLAW_MCP_CURRENT_CHANNEL_ID: "telegram:-100123:topic:42",
OPENCLAW_MCP_CURRENT_THREAD_TS: "42",
OPENCLAW_MCP_CURRENT_MESSAGE_ID: "reply-message-1",
OPENCLAW_MCP_CURRENT_INBOUND_AUDIO: "true",
OPENCLAW_MCP_INBOUND_EVENT_KIND: "room_event",
OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE: "message_tool_only",
OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET: "true",
OPENCLAW_MCP_TOKEN: "loopback-token",
OPENCLAW_MCP_CLI_CAPTURE_KEY: "",
});
expect(mintMcpLoopbackClientGrant).toHaveBeenCalledWith({
context: {
sessionKey: "agent:main:telegram:group:chat123",
sessionId: "session-test",
messageProvider: "telegram",
currentChannelId: "telegram:-100123:topic:42",
currentThreadTs: "42",
currentMessageId: "reply-message-1",
currentInboundAudio: true,
accountId: undefined,
inboundEventKind: "room_event",
sourceReplyDeliveryMode: "message_tool_only",
requireExplicitMessageTarget: true,
senderIsOwner: false,
},
runtimeOwnerToken: "loopback-owner-token",
});
context.preparedBackend.mcpClientGrantCapture?.activate("capture-test");
context.preparedBackend.mcpClientGrantCapture?.deactivate("capture-test");
expect(activateMcpLoopbackClientGrantCapture).toHaveBeenCalledExactlyOnceWith({
token: "loopback-token",
runtimeOwnerToken: "loopback-owner-token",
captureKey: "capture-test",
});
expect(deactivateMcpLoopbackClientGrantCapture).toHaveBeenCalledExactlyOnceWith({
token: "loopback-token",
runtimeOwnerToken: "loopback-owner-token",
captureKey: "capture-test",
});
expect(context.mcpDeliveryCapture).toBe(true);
expect(resolveMcpLoopbackScopedTools).toHaveBeenCalledWith(
expect.objectContaining({
@@ -2286,6 +2325,8 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
expect(context.systemPrompt).not.toContain(
"The target defaults to the current source channel",
);
await context.preparedBackend.cleanup?.();
expect(revokeMcpLoopbackClientGrant).toHaveBeenCalledExactlyOnceWith("loopback-token");
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
+126 -36
View File
@@ -4,17 +4,25 @@
*/
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { getRuntimeConfig } from "../../config/config.js";
import { resolveMainSessionKey } from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
assertContextEngineHostSupport,
buildGenericCliContextEngineHostSupport,
} from "../../context-engine/host-compat.js";
import { ensureContextEnginesInitialized } from "../../context-engine/init.js";
import { resolveContextEngine } from "../../context-engine/registry.js";
import {
activateMcpLoopbackClientGrantCapture,
deactivateMcpLoopbackClientGrantCapture,
mintMcpLoopbackClientGrant,
revokeMcpLoopbackClientGrant,
type McpLoopbackRequestContext,
} from "../../gateway/mcp-grant-store.js";
import { ensureMcpLoopbackServer } from "../../gateway/mcp-http.js";
import {
createMcpLoopbackServerConfig,
getActiveMcpLoopbackRuntime,
resolveMcpLoopbackBearerToken,
} from "../../gateway/mcp-http.loopback-runtime.js";
import { resolveMcpLoopbackScopedTools } from "../../gateway/mcp-http.runtime.js";
import { isClaudeCliProvider } from "../../plugin-sdk/anthropic-cli.js";
@@ -99,7 +107,10 @@ const prepareDeps = {
getActiveMcpLoopbackRuntime,
ensureMcpLoopbackServer,
createMcpLoopbackServerConfig,
resolveMcpLoopbackBearerToken,
activateMcpLoopbackClientGrantCapture,
deactivateMcpLoopbackClientGrantCapture,
mintMcpLoopbackClientGrant,
revokeMcpLoopbackClientGrant,
resolveMcpLoopbackScopedTools,
resolveOpenClawReferencePaths: async (
params: Parameters<typeof import("../docs-path.js").resolveOpenClawReferencePaths>[0],
@@ -110,6 +121,40 @@ const prepareDeps = {
resolveApiKeyForProfile,
};
function normalizeOptionalMcpContextValue(value: string | undefined): string | undefined {
return value?.trim() || undefined;
}
function buildCliMcpGrantContext(params: {
run: RunCliAgentParams;
config: OpenClawConfig;
requireExplicitMessageTarget: boolean;
}): McpLoopbackRequestContext {
const rawSessionKey = params.run.sessionKey?.trim() ?? "";
const sessionKey =
!rawSessionKey || rawSessionKey === "main"
? resolveMainSessionKey(params.config)
: rawSessionKey;
return {
sessionKey,
sessionId: normalizeOptionalMcpContextValue(params.run.sessionId),
messageProvider:
normalizeMessageChannel(params.run.messageChannel ?? params.run.messageProvider) ?? undefined,
currentChannelId: normalizeOptionalMcpContextValue(params.run.currentChannelId),
currentThreadTs: normalizeOptionalMcpContextValue(params.run.currentThreadTs),
currentMessageId:
params.run.currentMessageId == null
? undefined
: normalizeOptionalMcpContextValue(String(params.run.currentMessageId)),
currentInboundAudio: params.run.currentInboundAudio === true ? true : undefined,
accountId: normalizeOptionalMcpContextValue(params.run.agentAccountId),
inboundEventKind: params.run.currentInboundEventKind,
sourceReplyDeliveryMode: params.run.sourceReplyDeliveryMode,
requireExplicitMessageTarget: params.requireExplicitMessageTarget ? true : undefined,
senderIsOwner: params.run.senderIsOwner === true,
};
}
async function resolveCliSkillsPrompt(params: {
agentId: string;
config: RunCliAgentParams["config"];
@@ -437,39 +482,83 @@ export async function prepareCliRunContext(
mcpLoopbackRuntime = prepareDeps.getActiveMcpLoopbackRuntime();
}
const mcpDeliveryCaptureEnabled = bundleMcpEnabled && Boolean(mcpLoopbackRuntime);
const preparedBackend = await prepareCliBundleMcpConfig({
enabled: bundleMcpEnabled,
mode: backendResolved.bundleMcpMode,
backend: backendResolved.config,
workspaceDir,
config: params.config,
additionalConfig: mcpLoopbackRuntime
? prepareDeps.createMcpLoopbackServerConfig(mcpLoopbackRuntime.port)
: undefined,
env: mcpLoopbackRuntime
const mcpClientGrant = mcpLoopbackRuntime
? prepareDeps.mintMcpLoopbackClientGrant({
context: buildCliMcpGrantContext({
run: params,
config: params.config ?? getRuntimeConfig(),
requireExplicitMessageTarget,
}),
runtimeOwnerToken: mcpLoopbackRuntime.ownerToken,
})
: undefined;
const mcpClientGrantCapture =
mcpClientGrant && mcpLoopbackRuntime
? {
OPENCLAW_MCP_TOKEN: prepareDeps.resolveMcpLoopbackBearerToken(
mcpLoopbackRuntime,
params.senderIsOwner === true,
),
OPENCLAW_MCP_AGENT_ID: sessionAgentId ?? "",
OPENCLAW_MCP_ACCOUNT_ID: params.agentAccountId ?? "",
OPENCLAW_MCP_SESSION_KEY: params.sessionKey ?? "",
OPENCLAW_MCP_SESSION_ID: params.sessionId,
OPENCLAW_MCP_MESSAGE_CHANNEL: params.messageChannel ?? params.messageProvider ?? "",
OPENCLAW_MCP_CURRENT_CHANNEL_ID: params.currentChannelId ?? "",
OPENCLAW_MCP_CURRENT_THREAD_TS: params.currentThreadTs ?? "",
OPENCLAW_MCP_CURRENT_MESSAGE_ID:
params.currentMessageId != null ? String(params.currentMessageId) : "",
OPENCLAW_MCP_CURRENT_INBOUND_AUDIO: params.currentInboundAudio === true ? "true" : "",
OPENCLAW_MCP_INBOUND_EVENT_KIND: params.currentInboundEventKind ?? "",
OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE: params.sourceReplyDeliveryMode ?? "",
OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET: requireExplicitMessageTarget ? "true" : "",
OPENCLAW_MCP_CLI_CAPTURE_KEY: "",
activate: (captureKey: string) => {
const activated = prepareDeps.activateMcpLoopbackClientGrantCapture({
token: mcpClientGrant.token,
runtimeOwnerToken: mcpLoopbackRuntime.ownerToken,
captureKey,
});
if (!activated) {
throw new Error("CLI MCP client grant is no longer valid for this Gateway runtime");
}
},
deactivate: (captureKey: string) => {
prepareDeps.deactivateMcpLoopbackClientGrantCapture({
token: mcpClientGrant.token,
runtimeOwnerToken: mcpLoopbackRuntime.ownerToken,
captureKey,
});
},
}
: undefined,
warn: (message) => cliBackendLog.warn(message),
});
: undefined;
let mcpClientGrantRevoked = false;
const cleanupMcpClientGrant = mcpClientGrant
? async () => {
if (mcpClientGrantRevoked) {
return;
}
mcpClientGrantRevoked = true;
prepareDeps.revokeMcpLoopbackClientGrant(mcpClientGrant.token);
}
: undefined;
const preparedBackend = await (async () => {
try {
return await prepareCliBundleMcpConfig({
enabled: bundleMcpEnabled,
mode: backendResolved.bundleMcpMode,
backend: backendResolved.config,
workspaceDir,
config: params.config,
additionalConfig: mcpLoopbackRuntime
? prepareDeps.createMcpLoopbackServerConfig(mcpLoopbackRuntime.port)
: undefined,
env:
mcpLoopbackRuntime && mcpClientGrant
? {
OPENCLAW_MCP_TOKEN: mcpClientGrant.token,
OPENCLAW_MCP_CLI_CAPTURE_KEY: "",
}
: undefined,
warn: (message) => cliBackendLog.warn(message),
});
} catch (error) {
await cleanupMcpClientGrant?.();
throw error;
}
})();
const cleanupPreparedBackend =
preparedBackend.cleanup || cleanupMcpClientGrant
? async () => {
try {
await preparedBackend.cleanup?.();
} finally {
await cleanupMcpClientGrant?.();
}
}
: undefined;
const prepareExecutionContext = {
config: params.config,
workspaceDir,
@@ -498,7 +587,7 @@ export async function prepareCliRunContext(
);
} catch (err) {
try {
await preparedBackend.cleanup?.();
await cleanupPreparedBackend?.();
} catch (cleanupErr) {
cliBackendLog.warn(`cli backend cleanup after prepare failure failed: ${String(cleanupErr)}`);
}
@@ -521,12 +610,12 @@ export async function prepareCliRunContext(
? { ...preparedBackend.env, ...preparedExecution.env }
: preparedBackend.env;
const preparedBackendCleanup =
preparedBackend.cleanup || preparedExecution?.cleanup
cleanupPreparedBackend || preparedExecution?.cleanup
? async () => {
try {
await preparedExecution?.cleanup?.();
} finally {
await preparedBackend.cleanup?.();
await cleanupPreparedBackend?.();
}
}
: undefined;
@@ -566,6 +655,7 @@ export async function prepareCliRunContext(
: {}),
},
...(preparedBackendEnv ? { env: preparedBackendEnv } : {}),
...(mcpClientGrantCapture ? { mcpClientGrantCapture } : {}),
...(preparedCleanup ? { cleanup: preparedCleanup } : {}),
};
const promptTools =
+5
View File
@@ -159,6 +159,11 @@ export type RunCliAgentParams = {
export type CliPreparedBackend = {
backend: CliBackendConfig;
cleanup?: () => Promise<void>;
/** Gateway-owned capture fence for this prepared bundle-MCP client. */
mcpClientGrantCapture?: {
activate: (captureKey: string) => void;
deactivate: (captureKey: string) => void;
};
mcpConfigHash?: string;
mcpResumeHash?: string;
env?: Record<string, string>;
+155 -2
View File
@@ -1,18 +1,29 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
resetAttachGrantsForTest,
activateMcpLoopbackClientGrantCapture,
attachGrantStoreSize,
deactivateMcpLoopbackClientGrantCapture,
mcpLoopbackClientGrantStoreSize,
mintAttachGrant,
mintMcpLoopbackClientGrant,
resolveAttachGrant,
resolveMcpLoopbackClientGrant,
resetAttachGrantsForTest,
resetMcpLoopbackClientGrantsForTest,
revokeAttachGrant,
revokeAttachGrantsForSession,
revokeMcpLoopbackClientGrant,
revokeMcpLoopbackClientGrantsForRuntime,
sweepExpiredAttachGrants,
} from "./mcp-grant-store.js";
const T0 = 1_000_000_000_000; // fixed epoch for deterministic TTL tests
describe("mcp-grant-store", () => {
beforeEach(() => resetAttachGrantsForTest());
beforeEach(() => {
resetAttachGrantsForTest();
resetMcpLoopbackClientGrantsForTest();
});
it("mints a grant bound to the sessionKey with a token and a TTL window", () => {
const g = mintAttachGrant({ sessionKey: "agent:main:main", ttlMs: 60_000, nowMs: T0 });
@@ -88,4 +99,146 @@ describe("mcp-grant-store", () => {
mintAttachGrant({ sessionKey: "s", ttlMs: 1_000, nowMs: T0 + 5_000 });
expect(attachGrantStoreSize()).toBe(1);
});
it("binds an immutable Gateway-selected context to a loopback client grant", () => {
const context = {
sessionKey: " agent:main:telegram:group:1 ",
sessionId: "session-1",
messageProvider: "telegram",
currentChannelId: "telegram:-1001",
currentThreadTs: "42",
currentMessageId: "message-1",
currentInboundAudio: true,
accountId: "account-1",
inboundEventKind: "room_event" as const,
sourceReplyDeliveryMode: "message_tool_only" as const,
requireExplicitMessageTarget: true,
senderIsOwner: false,
};
const grant = mintMcpLoopbackClientGrant({
context,
runtimeOwnerToken: "runtime-one",
});
expect(
activateMcpLoopbackClientGrantCapture({
token: grant.token,
runtimeOwnerToken: "runtime-one",
captureKey: "capture-one",
}),
).toBe(true);
context.currentChannelId = "caller-mutation";
grant.context.currentChannelId = "return-value-mutation";
expect(
resolveMcpLoopbackClientGrant({
token: grant.token,
runtimeOwnerToken: "runtime-one",
captureKey: "capture-one",
})?.context,
).toEqual({
sessionKey: "agent:main:telegram:group:1",
sessionId: "session-1",
messageProvider: "telegram",
currentChannelId: "telegram:-1001",
currentThreadTs: "42",
currentMessageId: "message-1",
currentInboundAudio: true,
accountId: "account-1",
inboundEventKind: "room_event",
sourceReplyDeliveryMode: "message_tool_only",
requireExplicitMessageTarget: true,
senderIsOwner: false,
});
});
it("admits only the active capture on the grant's Gateway runtime", () => {
const grant = mintMcpLoopbackClientGrant({
context: { sessionKey: "agent:main:first", senderIsOwner: false },
runtimeOwnerToken: "runtime-one",
});
const resolve = (runtimeOwnerToken: string, captureKey: string) =>
resolveMcpLoopbackClientGrant({
token: grant.token,
runtimeOwnerToken,
captureKey,
});
expect(resolve("runtime-one", "capture-a")).toBeUndefined();
expect(
activateMcpLoopbackClientGrantCapture({
token: grant.token,
runtimeOwnerToken: "runtime-other",
captureKey: "capture-a",
}),
).toBe(false);
expect(
activateMcpLoopbackClientGrantCapture({
token: grant.token,
runtimeOwnerToken: "runtime-one",
captureKey: "capture-a",
}),
).toBe(true);
expect(resolve("runtime-other", "capture-a")).toBeUndefined();
expect(resolve("runtime-one", "capture-forged")).toBeUndefined();
expect(resolve("runtime-one", "capture-a")?.captureKey).toBe("capture-a");
expect(
activateMcpLoopbackClientGrantCapture({
token: grant.token,
runtimeOwnerToken: "runtime-one",
captureKey: "capture-b",
}),
).toBe(true);
expect(resolve("runtime-one", "capture-a")).toBeUndefined();
expect(
deactivateMcpLoopbackClientGrantCapture({
token: grant.token,
runtimeOwnerToken: "runtime-one",
captureKey: "capture-a",
}),
).toBe(false);
expect(resolve("runtime-one", "capture-b")?.captureKey).toBe("capture-b");
expect(
deactivateMcpLoopbackClientGrantCapture({
token: grant.token,
runtimeOwnerToken: "runtime-one",
captureKey: "capture-b",
}),
).toBe(true);
expect(resolve("runtime-one", "capture-b")).toBeUndefined();
});
it("revokes client grants by token or exact Gateway runtime", () => {
const mintForRuntime = (runtimeOwnerToken: string, sessionKey: string) =>
mintMcpLoopbackClientGrant({
context: { sessionKey, senderIsOwner: false },
runtimeOwnerToken,
});
const first = mintForRuntime("runtime-one", "agent:main:first");
mintForRuntime("runtime-one", "agent:main:second");
const successor = mintForRuntime("runtime-two", "agent:main:successor");
expect(revokeMcpLoopbackClientGrantsForRuntime("runtime-one")).toBe(2);
expect(mcpLoopbackClientGrantStoreSize()).toBe(1);
expect(revokeMcpLoopbackClientGrant(first.token)).toBe(false);
expect(revokeMcpLoopbackClientGrant(successor.token)).toBe(true);
expect(revokeMcpLoopbackClientGrant(successor.token)).toBe(false);
expect(mcpLoopbackClientGrantStoreSize()).toBe(0);
});
it("requires a session key for loopback client grants", () => {
expect(() =>
mintMcpLoopbackClientGrant({
context: { sessionKey: " ", senderIsOwner: false },
runtimeOwnerToken: "runtime-one",
}),
).toThrow(/sessionKey is required/);
expect(() =>
mintMcpLoopbackClientGrant({
context: { sessionKey: "agent:main:main", senderIsOwner: false },
runtimeOwnerToken: " ",
}),
).toThrow(/runtimeOwnerToken is required/);
});
});
+131
View File
@@ -19,6 +19,23 @@
* 127.0.0.1 (gateway host) or tunnelled in over a node/app's existing authenticated channel.
*/
import crypto from "node:crypto";
import type { SourceReplyDeliveryMode } from "../auto-reply/get-reply-options.types.js";
import type { InboundEventKind } from "../channels/inbound-event/kind.js";
export type McpLoopbackRequestContext = {
sessionKey: string;
sessionId?: string;
messageProvider?: string;
currentChannelId?: string;
currentThreadTs?: string;
currentMessageId?: string;
currentInboundAudio?: boolean;
accountId?: string;
inboundEventKind?: InboundEventKind;
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
requireExplicitMessageTarget?: boolean;
senderIsOwner: boolean;
};
export interface McpAttachGrant {
/** Opaque bearer presented as `Authorization: Bearer <token>`. */
@@ -31,10 +48,23 @@ export interface McpAttachGrant {
readonly issuedAtMs: number;
}
export interface McpLoopbackClientGrant {
/** Opaque bearer presented as `Authorization: Bearer <token>`. */
readonly token: string;
/** Gateway-selected request context; child-process headers cannot widen it. */
readonly context: McpLoopbackRequestContext;
}
type StoredMcpLoopbackClientGrant = McpLoopbackClientGrant & {
runtimeOwnerToken: string;
activeCaptureKey?: string;
};
const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1h
const MAX_TTL_MS = 12 * 60 * 60 * 1000; // hard ceiling so a caller can't request a forever-grant
const grantsByToken = new Map<string, McpAttachGrant>();
const clientGrantsByToken = new Map<string, StoredMcpLoopbackClientGrant>();
function clampTtlMs(ttlMs: number | undefined): number {
if (!Number.isFinite(ttlMs) || (ttlMs as number) <= 0) {
@@ -126,3 +156,104 @@ export function attachGrantStoreSize(): number {
export function resetAttachGrantsForTest(): void {
grantsByToken.clear();
}
export function mintMcpLoopbackClientGrant(params: {
context: McpLoopbackRequestContext;
runtimeOwnerToken: string;
}): McpLoopbackClientGrant {
const sessionKey = params.context.sessionKey.trim();
if (!sessionKey) {
throw new Error("mintMcpLoopbackClientGrant: context.sessionKey is required");
}
const runtimeOwnerToken = params.runtimeOwnerToken.trim();
if (!runtimeOwnerToken) {
throw new Error("mintMcpLoopbackClientGrant: runtimeOwnerToken is required");
}
const grant: StoredMcpLoopbackClientGrant = {
token: crypto.randomBytes(32).toString("hex"),
context: structuredClone({ ...params.context, sessionKey }),
runtimeOwnerToken,
};
clientGrantsByToken.set(grant.token, grant);
return structuredClone({
token: grant.token,
context: grant.context,
});
}
/** Bind the active execution attempt's capture before its child process starts. */
export function activateMcpLoopbackClientGrantCapture(params: {
token: string;
runtimeOwnerToken: string;
captureKey: string;
}): boolean {
const captureKey = params.captureKey.trim();
if (!captureKey) {
throw new Error("activateMcpLoopbackClientGrantCapture: captureKey is required");
}
const grant = clientGrantsByToken.get(params.token);
if (!grant || grant.runtimeOwnerToken !== params.runtimeOwnerToken) {
return false;
}
clientGrantsByToken.set(params.token, { ...grant, activeCaptureKey: captureKey });
return true;
}
/** Release only the attempt that still owns this grant's active capture. */
export function deactivateMcpLoopbackClientGrantCapture(params: {
token: string;
runtimeOwnerToken: string;
captureKey: string;
}): boolean {
const grant = clientGrantsByToken.get(params.token);
if (
!grant ||
grant.runtimeOwnerToken !== params.runtimeOwnerToken ||
grant.activeCaptureKey !== params.captureKey
) {
return false;
}
const { activeCaptureKey: _activeCaptureKey, ...inactiveGrant } = grant;
clientGrantsByToken.set(params.token, inactiveGrant);
return true;
}
export function resolveMcpLoopbackClientGrant(params: {
token: string;
runtimeOwnerToken: string;
captureKey: string;
}): { context: McpLoopbackRequestContext; captureKey: string } | undefined {
const grant = clientGrantsByToken.get(params.token);
if (
!grant ||
grant.runtimeOwnerToken !== params.runtimeOwnerToken ||
!grant.activeCaptureKey ||
grant.activeCaptureKey !== params.captureKey
) {
return undefined;
}
return structuredClone({ context: grant.context, captureKey: grant.activeCaptureKey });
}
export function revokeMcpLoopbackClientGrant(token: string): boolean {
return clientGrantsByToken.delete(token);
}
export function revokeMcpLoopbackClientGrantsForRuntime(runtimeOwnerToken: string): number {
let removed = 0;
for (const [token, grant] of clientGrantsByToken) {
if (grant.runtimeOwnerToken === runtimeOwnerToken) {
clientGrantsByToken.delete(token);
removed += 1;
}
}
return removed;
}
export function mcpLoopbackClientGrantStoreSize(): number {
return clientGrantsByToken.size;
}
export function resetMcpLoopbackClientGrantsForTest(): void {
clientGrantsByToken.clear();
}
+9 -25
View File
@@ -351,14 +351,6 @@ export function setActiveMcpLoopbackRuntime(runtime: McpLoopbackRuntime): void {
activeRuntime = { ...runtime };
}
/** Choose the bearer token matching owner/non-owner caller identity. */
export function resolveMcpLoopbackBearerToken(
runtime: McpLoopbackRuntime,
senderIsOwner: boolean,
): string {
return senderIsOwner ? runtime.ownerToken : runtime.nonOwnerToken;
}
/** Clear loopback runtime only when the owning token matches the active runtime. */
export function clearActiveMcpLoopbackRuntimeByOwnerToken(ownerToken: string): void {
if (activeRuntime?.ownerToken === ownerToken) {
@@ -366,6 +358,14 @@ export function clearActiveMcpLoopbackRuntimeByOwnerToken(ownerToken: string): v
}
}
const MCP_AUTH_HEADERS = {
Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}",
} as const;
const MCP_CAPTURE_HEADERS = {
"x-openclaw-cli-capture-key": "${OPENCLAW_MCP_CLI_CAPTURE_KEY}",
} as const;
/** Build the MCP server config injected into agents for loopback tool access. */
export function createMcpLoopbackServerConfig(port: number) {
return {
@@ -374,23 +374,7 @@ export function createMcpLoopbackServerConfig(port: number) {
type: "http",
url: `http://127.0.0.1:${port}/mcp`,
alwaysLoad: true,
headers: {
Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}",
"x-session-key": "${OPENCLAW_MCP_SESSION_KEY}",
"x-openclaw-session-id": "${OPENCLAW_MCP_SESSION_ID}",
"x-openclaw-agent-id": "${OPENCLAW_MCP_AGENT_ID}",
"x-openclaw-account-id": "${OPENCLAW_MCP_ACCOUNT_ID}",
"x-openclaw-message-channel": "${OPENCLAW_MCP_MESSAGE_CHANNEL}",
"x-openclaw-current-channel-id": "${OPENCLAW_MCP_CURRENT_CHANNEL_ID}",
"x-openclaw-current-thread-ts": "${OPENCLAW_MCP_CURRENT_THREAD_TS}",
"x-openclaw-current-message-id": "${OPENCLAW_MCP_CURRENT_MESSAGE_ID}",
"x-openclaw-current-inbound-audio": "${OPENCLAW_MCP_CURRENT_INBOUND_AUDIO}",
"x-openclaw-inbound-event-kind": "${OPENCLAW_MCP_INBOUND_EVENT_KIND}",
"x-openclaw-source-reply-delivery-mode": "${OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE}",
"x-openclaw-require-explicit-message-target":
"${OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET}",
"x-openclaw-cli-capture-key": "${OPENCLAW_MCP_CLI_CAPTURE_KEY}",
},
headers: { ...MCP_AUTH_HEADERS, ...MCP_CAPTURE_HEADERS },
},
},
};
+50 -19
View File
@@ -11,7 +11,11 @@ import { safeEqualSecret } from "../security/secret-equal.js";
import { normalizeMessageChannel } from "../utils/message-channel.js";
import { resolveSafeTimeoutDelayMs } from "../utils/timer-delay.js";
import { getHeader } from "./http-utils.js";
import { resolveAttachGrant } from "./mcp-grant-store.js";
import {
resolveAttachGrant,
resolveMcpLoopbackClientGrant,
type McpLoopbackRequestContext,
} from "./mcp-grant-store.js";
import { isLoopbackAddress } from "./net.js";
import { checkBrowserOrigin } from "./origin-check.js";
@@ -50,19 +54,13 @@ function logMcpLoopbackHttp(step: string, details: Record<string, unknown>): voi
console.error(`[mcp-loopback] ${step} ${JSON.stringify(details)}`);
}
type McpRequestContext = {
sessionKey: string;
sessionId: string | undefined;
messageProvider: string | undefined;
currentChannelId: string | undefined;
currentThreadTs: string | undefined;
currentMessageId: string | undefined;
currentInboundAudio: boolean | undefined;
accountId: string | undefined;
inboundEventKind: InboundEventKind | undefined;
sourceReplyDeliveryMode: SourceReplyDeliveryMode | undefined;
requireExplicitMessageTarget: boolean | undefined;
senderIsOwner: boolean | undefined;
type McpRequestContext = McpLoopbackRequestContext;
type McpLoopbackRequestAuth = {
senderIsOwner: boolean;
boundSessionKey?: string;
boundContext?: McpLoopbackRequestContext;
boundCaptureKey?: string;
};
function resolveScopedSessionKey(cfg: OpenClawConfig, rawSessionKey: string | undefined): string {
@@ -114,7 +112,7 @@ function resolveMcpSender(params: {
req: IncomingMessage;
ownerToken: string;
nonOwnerToken: string;
}): { senderIsOwner: boolean; boundSessionKey?: string } | undefined {
}): McpLoopbackRequestAuth | undefined {
const authHeader = getHeader(params.req, "authorization") ?? "";
const ownerTokenMatched = safeEqualSecret(authHeader, `Bearer ${params.ownerToken}`);
const nonOwnerTokenMatched = safeEqualSecret(authHeader, `Bearer ${params.nonOwnerToken}`);
@@ -125,6 +123,22 @@ function resolveMcpSender(params: {
// Always non-owner, and its scope is bound to the grant's sessionKey so a grant holder cannot widen
// scope via the x-session-key header — resolveMcpRequestContext honors boundSessionKey instead.
const grantToken = authHeader.startsWith("Bearer ") ? authHeader.slice("Bearer ".length) : "";
const captureKey = normalizeOptionalString(getHeader(params.req, "x-openclaw-cli-capture-key"));
const clientGrant =
grantToken && captureKey
? resolveMcpLoopbackClientGrant({
token: grantToken,
runtimeOwnerToken: params.ownerToken,
captureKey,
})
: undefined;
if (clientGrant) {
return {
senderIsOwner: clientGrant.context.senderIsOwner,
boundContext: clientGrant.context,
boundCaptureKey: clientGrant.captureKey,
};
}
const grant = grantToken ? resolveAttachGrant(grantToken) : undefined;
if (grant) {
return { senderIsOwner: false, boundSessionKey: grant.sessionKey };
@@ -138,7 +152,7 @@ export function validateMcpLoopbackRequest(params: {
ownerToken: string;
nonOwnerToken: string;
onSseResponse?: (res: ServerResponse) => void;
}): { senderIsOwner: boolean; boundSessionKey?: string } | null {
}): McpLoopbackRequestAuth | null {
let url: URL;
try {
url = new URL(params.req.url ?? "/", `http://${params.req.headers.host ?? "localhost"}`);
@@ -254,7 +268,12 @@ export function validateMcpLoopbackRequest(params: {
return null;
}
return { senderIsOwner: sender.senderIsOwner, boundSessionKey: sender.boundSessionKey };
return {
senderIsOwner: sender.senderIsOwner,
boundSessionKey: sender.boundSessionKey,
boundContext: sender.boundContext,
boundCaptureKey: sender.boundCaptureKey,
};
}
export async function readMcpHttpBody(
@@ -360,15 +379,27 @@ export function resolveMcpHttpBodyTimeoutMs(): number {
return readPositiveIntEnv("OPENCLAW_MCP_LOOPBACK_BODY_TIMEOUT_MS", DEFAULT_MCP_BODY_TIMEOUT_MS);
}
export function resolveMcpCliCaptureKey(req: IncomingMessage): string | undefined {
export function resolveMcpCliCaptureKey(
req: IncomingMessage,
auth: McpLoopbackRequestAuth,
): string | undefined {
if (auth.boundContext || auth.boundSessionKey) {
return auth.boundCaptureKey;
}
return normalizeOptionalString(getHeader(req, "x-openclaw-cli-capture-key"));
}
export function resolveMcpRequestContext(
req: IncomingMessage,
cfg: OpenClawConfig,
auth: { senderIsOwner: boolean; boundSessionKey?: string },
auth: McpLoopbackRequestAuth,
): McpRequestContext {
if (auth.boundContext) {
// Gateway-launched CLI clients receive an immutable context grant. The
// child process can replay the token, but cannot scope-shop by rewriting
// session, channel, capability, or ownership headers.
return structuredClone(auth.boundContext);
}
// An attach grant is a lower-trust boundary: bind the session server-side AND ignore every
// caller-supplied delivery/action context header (message channel, account, current channel/
// thread/message, inbound-audio, event-kind, source-reply mode, explicit-target). Those headers
+286 -33
View File
@@ -102,7 +102,15 @@ vi.mock("./tool-resolution.js", () => ({
resolveGatewayScopedToolsMock(...args),
}));
import { resetAttachGrantsForTest, mintAttachGrant } from "./mcp-grant-store.js";
import {
activateMcpLoopbackClientGrantCapture,
deactivateMcpLoopbackClientGrantCapture,
mintAttachGrant,
mintMcpLoopbackClientGrant,
resetAttachGrantsForTest,
resetMcpLoopbackClientGrantsForTest,
revokeMcpLoopbackClientGrant,
} from "./mcp-grant-store.js";
import {
createMcpLoopbackServerConfig,
closeMcpLoopbackServer,
@@ -576,6 +584,8 @@ function buildMockMcpToolSchema(tools: MockGatewayTool[]) {
}
beforeEach(() => {
resetAttachGrantsForTest();
resetMcpLoopbackClientGrantsForTest();
clearMcpLoopbackToolCallCapturesForTest();
resolveGatewayScopedToolsMock.mockClear();
runBeforeToolCallHookMock.mockClear();
@@ -735,7 +745,6 @@ describe("mcp loopback server", () => {
});
it("binds an attach grant's session and ignores ALL spoofed context headers (no scope-shop)", async () => {
resetAttachGrantsForTest();
const grant = mintAttachGrant({ sessionKey: "agent:main:attach-host" });
const port = await getFreePortBlockWithPermissionFallback({
offsets: [0],
@@ -775,6 +784,129 @@ describe("mcp loopback server", () => {
expect(call.inboundEventKind).toBeUndefined();
});
it("binds a CLI grant's complete context and ignores spoofed scope headers", async () => {
const { port, runtime } = await startLoopbackServerForTest();
const grant = mintMcpLoopbackClientGrant({
context: {
sessionKey: "agent:main:discord:channel:bound",
sessionId: "session-bound",
messageProvider: "discord",
currentChannelId: "discord:bound",
currentThreadTs: "bound-thread",
currentMessageId: "bound-message",
currentInboundAudio: true,
accountId: "bound-account",
inboundEventKind: "user_request",
sourceReplyDeliveryMode: "message_tool_only",
requireExplicitMessageTarget: true,
senderIsOwner: false,
},
runtimeOwnerToken: runtime.ownerToken,
});
expect(
activateMcpLoopbackClientGrantCapture({
token: grant.token,
runtimeOwnerToken: runtime.ownerToken,
captureKey: "capture-bound",
}),
).toBe(true);
const sendWithCapture = async (captureKey?: string, method: "list" | "call" = "list") =>
await sendRaw({
port,
token: grant.token,
headers: jsonHeaders({
...(captureKey ? { "x-openclaw-cli-capture-key": captureKey } : {}),
"x-session-key": "agent:main:main",
"x-openclaw-session-id": "session-spoofed",
"x-openclaw-message-channel": "telegram",
"x-openclaw-client-caps": "inline-widgets,admin",
"x-openclaw-account-id": "spoofed-account",
"x-openclaw-current-channel-id": "telegram:spoofed",
"x-openclaw-current-thread-ts": "spoofed-thread",
"x-openclaw-current-message-id": "spoofed-message",
"x-openclaw-current-inbound-audio": "false",
"x-openclaw-inbound-event-kind": "room_event",
"x-openclaw-source-reply-delivery-mode": "automatic",
"x-openclaw-task-suggestion-delivery-mode": "direct",
"x-openclaw-require-explicit-message-target": "false",
}),
body: method === "call" ? mcpToolCallBody("message") : mcpToolsListBody(),
});
expect((await sendWithCapture()).status).toBe(401);
expect((await sendWithCapture("capture-forged")).status).toBe(401);
expect(resolveGatewayScopedToolsMock).not.toHaveBeenCalled();
expect((await sendWithCapture("capture-bound")).status).toBe(200);
expect((await sendWithCapture("capture-bound", "call")).status).toBe(200);
const expectedBoundContext = {
sessionKey: "agent:main:discord:channel:bound",
sessionId: "session-bound",
messageProvider: "discord",
currentChannelId: "discord:bound",
currentThreadTs: "bound-thread",
currentMessageId: "bound-message",
currentInboundAudio: true,
accountId: "bound-account",
inboundEventKind: "user_request",
sourceReplyDeliveryMode: "message_tool_only",
requireExplicitMessageTarget: true,
senderIsOwner: false,
surface: "loopback",
};
expect(getScopedToolsCall(0)).toMatchObject(expectedBoundContext);
expect(getScopedToolsCall(1)).toMatchObject(expectedBoundContext);
});
it("rejects revoked and prior-runtime CLI grants", async () => {
const firstServer = await startLoopbackServerForTest();
const staleGrant = mintMcpLoopbackClientGrant({
context: { sessionKey: "agent:main:stale", senderIsOwner: false },
runtimeOwnerToken: firstServer.runtime.ownerToken,
});
activateMcpLoopbackClientGrantCapture({
token: staleGrant.token,
runtimeOwnerToken: firstServer.runtime.ownerToken,
captureKey: "capture-stale",
});
await server?.close();
server = undefined;
const successor = await startLoopbackServerForTest();
expect(
(
await sendRaw({
port: successor.port,
token: staleGrant.token,
headers: jsonHeaders({ "x-openclaw-cli-capture-key": "capture-stale" }),
body: mcpToolsListBody(),
})
).status,
).toBe(401);
const revokedGrant = mintMcpLoopbackClientGrant({
context: { sessionKey: "agent:main:revoked", senderIsOwner: false },
runtimeOwnerToken: successor.runtime.ownerToken,
});
activateMcpLoopbackClientGrantCapture({
token: revokedGrant.token,
runtimeOwnerToken: successor.runtime.ownerToken,
captureKey: "capture-revoked",
});
expect(revokeMcpLoopbackClientGrant(revokedGrant.token)).toBe(true);
expect(
(
await sendRaw({
port: successor.port,
token: revokedGrant.token,
headers: jsonHeaders({ "x-openclaw-cli-capture-key": "capture-revoked" }),
body: mcpToolsListBody(),
})
).status,
).toBe(401);
});
it("routes sessions_yield to the current CLI capture", async () => {
resolveGatewayScopedToolsMock.mockImplementation((input): MockGatewayScopedTools => {
const call = input as ScopedToolsCall;
@@ -1777,37 +1909,10 @@ describe("createMcpLoopbackServerConfig", () => {
};
expect(config.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:23119/mcp");
expect(config.mcpServers?.openclaw?.alwaysLoad).toBe(true);
expect(config.mcpServers?.openclaw?.headers?.Authorization).toBe(
"Bearer ${OPENCLAW_MCP_TOKEN}",
);
expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-session-id"]).toBe(
"${OPENCLAW_MCP_SESSION_ID}",
);
expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-message-channel"]).toBe(
"${OPENCLAW_MCP_MESSAGE_CHANNEL}",
);
expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-current-channel-id"]).toBe(
"${OPENCLAW_MCP_CURRENT_CHANNEL_ID}",
);
expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-current-thread-ts"]).toBe(
"${OPENCLAW_MCP_CURRENT_THREAD_TS}",
);
expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-current-message-id"]).toBe(
"${OPENCLAW_MCP_CURRENT_MESSAGE_ID}",
);
expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-current-inbound-audio"]).toBe(
"${OPENCLAW_MCP_CURRENT_INBOUND_AUDIO}",
);
expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-source-reply-delivery-mode"]).toBe(
"${OPENCLAW_MCP_SOURCE_REPLY_DELIVERY_MODE}",
);
expect(
config.mcpServers?.openclaw?.headers?.["x-openclaw-require-explicit-message-target"],
).toBe("${OPENCLAW_MCP_REQUIRE_EXPLICIT_MESSAGE_TARGET}");
expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-cli-capture-key"]).toBe(
"${OPENCLAW_MCP_CLI_CAPTURE_KEY}",
);
expect(config.mcpServers?.openclaw?.headers).not.toHaveProperty("x-openclaw-sender-is-owner");
expect(config.mcpServers?.openclaw?.headers).toEqual({
Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}",
"x-openclaw-cli-capture-key": "${OPENCLAW_MCP_CLI_CAPTURE_KEY}",
});
});
it("opens an auth-gated SSE stream on GET (Streamable HTTP notification channel)", async () => {
@@ -1822,6 +1927,52 @@ describe("createMcpLoopbackServerConfig", () => {
await expectInitialSseCommentFrame(res);
});
it("requires an active matching CLI capture on GET and DELETE", async () => {
const { port, runtime } = await startLoopbackServerForTest();
const grant = mintMcpLoopbackClientGrant({
context: { sessionKey: "agent:main:transport", senderIsOwner: false },
runtimeOwnerToken: runtime.ownerToken,
});
const captureKey = "capture-transport";
activateMcpLoopbackClientGrantCapture({
token: grant.token,
runtimeOwnerToken: runtime.ownerToken,
captureKey,
});
const send = async (method: "GET" | "DELETE", requestCaptureKey?: string) =>
await fetch(`http://127.0.0.1:${port}/mcp`, {
method,
headers: {
authorization: `Bearer ${grant.token}`,
...(requestCaptureKey ? { "x-openclaw-cli-capture-key": requestCaptureKey } : {}),
},
});
for (const method of ["GET", "DELETE"] as const) {
for (const requestCaptureKey of [undefined, "capture-forged"]) {
const response = await send(method, requestCaptureKey);
expect(response.status).toBe(401);
await response.body?.cancel();
}
}
const getResponse = await send("GET", captureKey);
expect(getResponse.status).toBe(200);
await expectInitialSseCommentFrame(getResponse);
expect((await send("DELETE", captureKey)).status).toBe(200);
deactivateMcpLoopbackClientGrantCapture({
token: grant.token,
runtimeOwnerToken: runtime.ownerToken,
captureKey,
});
for (const method of ["GET", "DELETE"] as const) {
const response = await send(method, captureKey);
expect(response.status).toBe(401);
await response.body?.cancel();
}
});
it("closes active GET notification streams during loopback shutdown", async () => {
server = await startMcpLoopbackServer(0);
const token = getActiveMcpLoopbackRuntime()?.ownerToken;
@@ -1848,6 +1999,108 @@ describe("createMcpLoopbackServerConfig", () => {
}
});
it("withdraws a closing runtime before drain without fencing its successor", async () => {
const oldServer = await startMcpLoopbackServer(0);
const oldRuntime = getActiveMcpLoopbackRuntime();
if (!oldRuntime) {
throw new Error("expected old MCP loopback runtime");
}
let stalledRequest: ReturnType<typeof request> | undefined;
let resolveSocketReady: () => void = () => {};
let rejectSocketReady: (error: Error) => void = () => {};
const socketReady = new Promise<void>((resolve, reject) => {
resolveSocketReady = resolve;
rejectSocketReady = reject;
});
const responsePromise = new Promise<void>((resolve, reject) => {
const req = request(
{
hostname: "127.0.0.1",
port: oldServer.port,
path: "/mcp",
method: "POST",
headers: {
authorization: `Bearer ${oldRuntime.ownerToken}`,
connection: "close",
"content-type": "application/json",
},
},
(res) => {
res.resume();
res.once("end", resolve);
},
);
req.once("socket", (socket) => {
if (!socket.connecting) {
resolveSocketReady();
return;
}
socket.once("connect", resolveSocketReady);
});
req.once("error", (error) => {
rejectSocketReady(error);
reject(error);
});
req.write("{");
stalledRequest = req;
});
let stalledRequestEnded = false;
const finishStalledRequest = () => {
if (stalledRequestEnded) {
return;
}
stalledRequestEnded = true;
stalledRequest?.end("}");
};
let oldClose: Promise<void> | undefined;
try {
await socketReady;
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
let closeSettled = false;
oldClose = oldServer.close().finally(() => {
closeSettled = true;
});
expect(getActiveMcpLoopbackRuntime()).toBeUndefined();
await new Promise<void>((resolve) => {
setTimeout(resolve, 20);
});
expect(closeSettled).toBe(false);
const successor = await startLoopbackServerForTest();
const successorGrant = mintMcpLoopbackClientGrant({
context: { sessionKey: "agent:main:successor", senderIsOwner: false },
runtimeOwnerToken: successor.runtime.ownerToken,
});
activateMcpLoopbackClientGrantCapture({
token: successorGrant.token,
runtimeOwnerToken: successor.runtime.ownerToken,
captureKey: "capture-successor",
});
finishStalledRequest();
await responsePromise;
await oldClose;
expect(getActiveMcpLoopbackRuntime()?.ownerToken).toBe(successor.runtime.ownerToken);
expect(
(
await sendRaw({
port: successor.port,
token: successorGrant.token,
headers: jsonHeaders({ "x-openclaw-cli-capture-key": "capture-successor" }),
body: mcpToolsListBody(),
})
).status,
).toBe(200);
} finally {
finishStalledRequest();
await responsePromise.catch(() => undefined);
await (oldClose ?? oldServer.close()).catch(() => undefined);
}
});
it("rejects a GET notification channel without a bearer token (401)", async () => {
server = await startMcpLoopbackServer(0);
const res = await fetch(`http://127.0.0.1:${server.port}/mcp`, { method: "GET" });
+11 -7
View File
@@ -11,6 +11,7 @@ import { getRuntimeConfig } from "../config/io.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { formatErrorMessage } from "../infra/errors.js";
import { logDebug, logWarn } from "../logger.js";
import { revokeMcpLoopbackClientGrantsForRuntime } from "./mcp-grant-store.js";
import { handleMcpJsonRpc } from "./mcp-http.handlers.js";
import {
clearActiveMcpLoopbackRuntimeByOwnerToken,
@@ -42,7 +43,6 @@ import { McpLoopbackToolCache } from "./mcp-http.runtime.js";
export {
createMcpLoopbackServerConfig,
getActiveMcpLoopbackRuntime,
resolveMcpLoopbackBearerToken,
} from "./mcp-http.loopback-runtime.js";
type McpLoopbackServer = {
@@ -191,7 +191,7 @@ export async function startMcpLoopbackServer(port = 0): Promise<{
// Bind the request before body parsing/tool resolution. A CLI may exit while
// an accepted request is still uploading, and retries must not outrun it.
const cliCaptureKey = resolveMcpCliCaptureKey(req);
const cliCaptureKey = resolveMcpCliCaptureKey(req, auth);
const cliRequestCaptureHandle = markMcpLoopbackRequestStarted(cliCaptureKey);
const requestAbort = createRequestAbortSignal(req, res);
void (async () => {
@@ -252,7 +252,7 @@ export async function startMcpLoopbackServer(port = 0): Promise<{
),
sessionKey: requestContext.sessionKey,
inboundEventKind: requestContext.inboundEventKind,
senderIsOwner: requestContext.senderIsOwner === true,
senderIsOwner: requestContext.senderIsOwner,
toolCount: scopedTools.toolSchema.length,
cronVisible: scopedTools.toolSchema.some((tool) => tool.name === "cron"),
});
@@ -378,11 +378,14 @@ export async function startMcpLoopbackServer(port = 0): Promise<{
const server: McpLoopbackServer = {
port: address.port,
close: () =>
new Promise<void>((resolve, reject) => {
close: () => {
// Stop admitting this runtime's child grants before draining accepted
// requests. A delayed old-server close cannot revoke a successor runtime.
clearActiveMcpLoopbackRuntimeByOwnerToken(ownerToken);
revokeMcpLoopbackClientGrantsForRuntime(ownerToken);
return new Promise<void>((resolve, reject) => {
httpServer.close((error) => {
if (!error) {
clearActiveMcpLoopbackRuntimeByOwnerToken(ownerToken);
if (activeMcpLoopbackServer === server) {
activeMcpLoopbackServer = undefined;
}
@@ -394,7 +397,8 @@ export async function startMcpLoopbackServer(port = 0): Promise<{
resolve();
});
closeActiveSseResponses();
}),
});
},
};
return server;
}