diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index e41ba81dcc4b..ed449bf6da85 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -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 diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts index 4ed318b2cdac..b1e7a72f2e97 100644 --- a/src/agents/cli-runner.spawn.test.ts +++ b/src/agents/cli-runner.spawn.test.ts @@ -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[0]) => + supervisorSpawnMock(params) as ReturnType, + 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> = []; diff --git a/src/agents/cli-runner/claude-live-session.ts b/src/agents/cli-runner/claude-live-session.ts index c3fb561622be..94cac94beb2c 100644 --- a/src/agents/cli-runner/claude-live-session.ts +++ b/src/agents/cli-runner/claude-live-session.ts @@ -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 = ""; diff --git a/src/agents/cli-runner/execute.supervisor-capture.test.ts b/src/agents/cli-runner/execute.supervisor-capture.test.ts index 92c21cd42dd1..c2408f535cfe 100644 --- a/src/agents/cli-runner/execute.supervisor-capture.test.ts +++ b/src/agents/cli-runner/execute.supervisor-capture.test.ts @@ -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, + ); }); }); diff --git a/src/agents/cli-runner/execute.ts b/src/agents/cli-runner/execute.ts index 29dd2b92027c..94c64aa0f364 100644 --- a/src/agents/cli-runner/execute.ts +++ b/src/agents/cli-runner/execute.ts @@ -363,10 +363,7 @@ function formatCliEnvKeyList(keys: readonly string[]): string { function buildCliEnvMcpLog(childEnv: Record): string { return [ `token=${childEnv.OPENCLAW_MCP_TOKEN ? "set" : "missing"}`, - `sessionKey=${childEnv.OPENCLAW_MCP_SESSION_KEY ? "set" : ""}`, - `agentId=${childEnv.OPENCLAW_MCP_AGENT_ID || ""}`, - `accountId=${childEnv.OPENCLAW_MCP_ACCOUNT_ID || ""}`, - `messageChannel=${childEnv.OPENCLAW_MCP_MESSAGE_CHANNEL || ""}`, + `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 { diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index 354fbe310328..7f6086b7fe5b 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -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 }); } diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 330efd639959..bfeb86f9e014 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -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[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 = diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index 7df5cde0b58c..e161d8aa7ed3 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -159,6 +159,11 @@ export type RunCliAgentParams = { export type CliPreparedBackend = { backend: CliBackendConfig; cleanup?: () => Promise; + /** 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; diff --git a/src/gateway/mcp-grant-store.test.ts b/src/gateway/mcp-grant-store.test.ts index 740838fc2f95..8bbffe8480f0 100644 --- a/src/gateway/mcp-grant-store.test.ts +++ b/src/gateway/mcp-grant-store.test.ts @@ -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/); + }); }); diff --git a/src/gateway/mcp-grant-store.ts b/src/gateway/mcp-grant-store.ts index c227b702b4e5..e98b9638355c 100644 --- a/src/gateway/mcp-grant-store.ts +++ b/src/gateway/mcp-grant-store.ts @@ -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 `. */ @@ -31,10 +48,23 @@ export interface McpAttachGrant { readonly issuedAtMs: number; } +export interface McpLoopbackClientGrant { + /** Opaque bearer presented as `Authorization: Bearer `. */ + 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(); +const clientGrantsByToken = new Map(); 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(); +} diff --git a/src/gateway/mcp-http.loopback-runtime.ts b/src/gateway/mcp-http.loopback-runtime.ts index 96fbe38abd75..54ac7653ac93 100644 --- a/src/gateway/mcp-http.loopback-runtime.ts +++ b/src/gateway/mcp-http.loopback-runtime.ts @@ -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 }, }, }, }; diff --git a/src/gateway/mcp-http.request.ts b/src/gateway/mcp-http.request.ts index 81818238b2e4..54e51d15f1c1 100644 --- a/src/gateway/mcp-http.request.ts +++ b/src/gateway/mcp-http.request.ts @@ -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): 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 diff --git a/src/gateway/mcp-http.test.ts b/src/gateway/mcp-http.test.ts index 7229fe607020..35e5dc26d362 100644 --- a/src/gateway/mcp-http.test.ts +++ b/src/gateway/mcp-http.test.ts @@ -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 | undefined; + let resolveSocketReady: () => void = () => {}; + let rejectSocketReady: (error: Error) => void = () => {}; + const socketReady = new Promise((resolve, reject) => { + resolveSocketReady = resolve; + rejectSocketReady = reject; + }); + const responsePromise = new Promise((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 | undefined; + try { + await socketReady; + await new Promise((resolve) => { + setImmediate(resolve); + }); + + let closeSettled = false; + oldClose = oldServer.close().finally(() => { + closeSettled = true; + }); + expect(getActiveMcpLoopbackRuntime()).toBeUndefined(); + await new Promise((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" }); diff --git a/src/gateway/mcp-http.ts b/src/gateway/mcp-http.ts index b6417553741e..e11a681a6a42 100644 --- a/src/gateway/mcp-http.ts +++ b/src/gateway/mcp-http.ts @@ -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((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((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; }