mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix: prevent CLI MCP children from widening sandbox tools (#103822)
(cherry picked from commit 5c260940bd)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>> = [];
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user