mirror of https://github.com/openclaw/openclaw.git
fix: restore claude cli loopback mcp bridge (#35676) (thanks @mylukin)
This commit is contained in:
parent
c2435306a7
commit
3de09fbe74
|
|
@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
|
|||
- Agents/system prompts: add an internal cache-prefix boundary across Anthropic-family, OpenAI-family, Google, and CLI transport shaping so stable system-prompt prefixes stay reusable without leaking internal cache markers to provider payloads. (#59054) Thanks @coletebou and @vincentkoc.
|
||||
- Docs/memory: add a dedicated Dreaming concept page, expand Memory overview with the Dreaming model, and link Dreaming from further reading to document the experimental opt-in consolidation workflow. Thanks @vignesh07.
|
||||
- Agents/cache prefixes: route compaction, OpenAI WebSocket HTTP fallback, and later-turn embedded session reuse through the same cache-safe prompt shaping path so Anthropic-family and OpenAI-family requests keep stable prompt bytes across follow-up turns and fallback transport changes. (#60691) Thanks @vincentkoc.
|
||||
- Agents/Claude CLI: expose OpenClaw tools to background Claude CLI runs through a loopback MCP bridge that reuses gateway tool policy, honors session/account/channel scoping, and only advertises the bridge when the local runtime is actually live. (#35676) Thanks @mylukin.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,84 @@ describe("prepareCliBundleMcpConfig", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("merges loopback overlay config with bundle MCP servers", async () => {
|
||||
const env = captureEnv(["HOME"]);
|
||||
try {
|
||||
const homeDir = await tempHarness.createTempDir("openclaw-cli-bundle-mcp-home-");
|
||||
const workspaceDir = await tempHarness.createTempDir("openclaw-cli-bundle-mcp-workspace-");
|
||||
process.env.HOME = homeDir;
|
||||
|
||||
await createBundleProbePlugin(homeDir);
|
||||
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"bundle-probe": { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const prepared = await prepareCliBundleMcpConfig({
|
||||
enabled: true,
|
||||
backend: {
|
||||
command: "node",
|
||||
args: ["./fake-claude.mjs"],
|
||||
},
|
||||
workspaceDir,
|
||||
config,
|
||||
additionalConfig: {
|
||||
mcpServers: {
|
||||
openclaw: {
|
||||
type: "http",
|
||||
url: "http://127.0.0.1:23119/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const configFlagIndex = prepared.backend.args?.indexOf("--mcp-config") ?? -1;
|
||||
const generatedConfigPath = prepared.backend.args?.[configFlagIndex + 1];
|
||||
const raw = JSON.parse(await fs.readFile(generatedConfigPath as string, "utf-8")) as {
|
||||
mcpServers?: Record<string, { url?: string; headers?: Record<string, string> }>;
|
||||
};
|
||||
expect(Object.keys(raw.mcpServers ?? {}).toSorted()).toEqual(["bundleProbe", "openclaw"]);
|
||||
expect(raw.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:23119/mcp");
|
||||
expect(raw.mcpServers?.openclaw?.headers?.Authorization).toBe("Bearer ${OPENCLAW_MCP_TOKEN}");
|
||||
|
||||
await prepared.cleanup?.();
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves extra env values alongside generated MCP config", async () => {
|
||||
const workspaceDir = await tempHarness.createTempDir("openclaw-cli-bundle-mcp-env-");
|
||||
|
||||
const prepared = await prepareCliBundleMcpConfig({
|
||||
enabled: true,
|
||||
backend: {
|
||||
command: "node",
|
||||
args: ["./fake-claude.mjs"],
|
||||
},
|
||||
workspaceDir,
|
||||
config: {},
|
||||
env: {
|
||||
OPENCLAW_MCP_TOKEN: "loopback-token-123",
|
||||
OPENCLAW_MCP_SESSION_KEY: "agent:main:telegram:group:chat123",
|
||||
},
|
||||
});
|
||||
|
||||
expect(prepared.env).toEqual({
|
||||
OPENCLAW_MCP_TOKEN: "loopback-token-123",
|
||||
OPENCLAW_MCP_SESSION_KEY: "agent:main:telegram:group:chat123",
|
||||
});
|
||||
|
||||
await prepared.cleanup?.();
|
||||
});
|
||||
|
||||
it("leaves args untouched when bundle MCP is disabled", async () => {
|
||||
const prepared = await prepareCliBundleMcpConfig({
|
||||
enabled: false,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ type PreparedCliBundleMcpConfig = {
|
|||
backend: CliBackendConfig;
|
||||
cleanup?: () => Promise<void>;
|
||||
mcpConfigHash?: string;
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
async function readExternalMcpConfig(configPath: string): Promise<BundleMcpConfig> {
|
||||
|
|
@ -69,10 +70,12 @@ export async function prepareCliBundleMcpConfig(params: {
|
|||
backend: CliBackendConfig;
|
||||
workspaceDir: string;
|
||||
config?: OpenClawConfig;
|
||||
additionalConfig?: BundleMcpConfig;
|
||||
env?: Record<string, string>;
|
||||
warn?: (message: string) => void;
|
||||
}): Promise<PreparedCliBundleMcpConfig> {
|
||||
if (!params.enabled) {
|
||||
return { backend: params.backend };
|
||||
return { backend: params.backend, env: params.env };
|
||||
}
|
||||
|
||||
const existingMcpConfigPath =
|
||||
|
|
@ -97,6 +100,9 @@ export async function prepareCliBundleMcpConfig(params: {
|
|||
params.warn?.(`bundle MCP skipped for ${diagnostic.pluginId}: ${diagnostic.message}`);
|
||||
}
|
||||
mergedConfig = applyMergePatch(mergedConfig, bundleConfig.config) as BundleMcpConfig;
|
||||
if (params.additionalConfig) {
|
||||
mergedConfig = applyMergePatch(mergedConfig, params.additionalConfig) as BundleMcpConfig;
|
||||
}
|
||||
|
||||
// Always pass an explicit strict MCP config for background claude-cli runs.
|
||||
// Otherwise Claude may inherit ambient user/global MCP servers (for example
|
||||
|
|
@ -116,6 +122,7 @@ export async function prepareCliBundleMcpConfig(params: {
|
|||
),
|
||||
},
|
||||
mcpConfigHash: crypto.createHash("sha256").update(serializedConfig).digest("hex"),
|
||||
env: params.env,
|
||||
cleanup: async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@ export async function executePreparedCliRun(
|
|||
for (const key of backend.clearEnv ?? []) {
|
||||
delete next[key];
|
||||
}
|
||||
Object.assign(next, context.preparedBackend.env);
|
||||
return next;
|
||||
})();
|
||||
const noOutputTimeoutMs = resolveCliNoOutputTimeoutMs({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js";
|
||||
import {
|
||||
createMcpLoopbackServerConfig,
|
||||
getActiveMcpLoopbackRuntime,
|
||||
} from "../../gateway/mcp-http.js";
|
||||
import { resolveSessionAgentIds } from "../agent-scope.js";
|
||||
import {
|
||||
buildBootstrapInjectionStats,
|
||||
|
|
@ -28,6 +32,8 @@ import type { PreparedCliRunContext, RunCliAgentParams } from "./types.js";
|
|||
const prepareDeps = {
|
||||
makeBootstrapWarn: makeBootstrapWarnImpl,
|
||||
resolveBootstrapContextForRun: resolveBootstrapContextForRunImpl,
|
||||
getActiveMcpLoopbackRuntime,
|
||||
createMcpLoopbackServerConfig,
|
||||
};
|
||||
|
||||
export function setCliRunnerPrepareTestDeps(overrides: Partial<typeof prepareDeps>): void {
|
||||
|
|
@ -59,30 +65,10 @@ export async function prepareCliRunContext(
|
|||
if (!backendResolved) {
|
||||
throw new Error(`Unknown CLI backend: ${params.provider}`);
|
||||
}
|
||||
const preparedBackend = await prepareCliBundleMcpConfig({
|
||||
enabled: backendResolved.bundleMcp,
|
||||
backend: backendResolved.config,
|
||||
workspaceDir,
|
||||
config: params.config,
|
||||
warn: (message) => cliBackendLog.warn(message),
|
||||
});
|
||||
const extraSystemPrompt = params.extraSystemPrompt?.trim() ?? "";
|
||||
const extraSystemPromptHash = hashCliSessionText(extraSystemPrompt);
|
||||
const reusableCliSession = resolveCliSessionReuse({
|
||||
binding:
|
||||
params.cliSessionBinding ??
|
||||
(params.cliSessionId ? { sessionId: params.cliSessionId } : undefined),
|
||||
authProfileId: params.authProfileId,
|
||||
extraSystemPromptHash,
|
||||
mcpConfigHash: preparedBackend.mcpConfigHash,
|
||||
});
|
||||
if (reusableCliSession.invalidatedReason) {
|
||||
cliBackendLog.info(
|
||||
`cli session reset: provider=${params.provider} reason=${reusableCliSession.invalidatedReason}`,
|
||||
);
|
||||
}
|
||||
const modelId = (params.model ?? "default").trim() || "default";
|
||||
const normalizedModel = normalizeCliModel(modelId, preparedBackend.backend);
|
||||
const normalizedModel = normalizeCliModel(modelId, backendResolved.config);
|
||||
const modelDisplay = `${params.provider}/${modelId}`;
|
||||
|
||||
const sessionLabel = params.sessionKey ?? params.sessionId;
|
||||
|
|
@ -118,6 +104,40 @@ export async function prepareCliRunContext(
|
|||
config: params.config,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
const mcpLoopbackRuntime =
|
||||
backendResolved.id === "claude-cli" ? prepareDeps.getActiveMcpLoopbackRuntime() : undefined;
|
||||
const preparedBackend = await prepareCliBundleMcpConfig({
|
||||
enabled: backendResolved.bundleMcp,
|
||||
backend: backendResolved.config,
|
||||
workspaceDir,
|
||||
config: params.config,
|
||||
additionalConfig: mcpLoopbackRuntime
|
||||
? prepareDeps.createMcpLoopbackServerConfig(mcpLoopbackRuntime.port)
|
||||
: undefined,
|
||||
env: mcpLoopbackRuntime
|
||||
? {
|
||||
OPENCLAW_MCP_TOKEN: mcpLoopbackRuntime.token,
|
||||
OPENCLAW_MCP_AGENT_ID: sessionAgentId ?? "",
|
||||
OPENCLAW_MCP_ACCOUNT_ID: params.agentAccountId ?? "",
|
||||
OPENCLAW_MCP_SESSION_KEY: params.sessionKey ?? "",
|
||||
OPENCLAW_MCP_MESSAGE_CHANNEL: params.messageProvider ?? "",
|
||||
}
|
||||
: undefined,
|
||||
warn: (message) => cliBackendLog.warn(message),
|
||||
});
|
||||
const reusableCliSession = resolveCliSessionReuse({
|
||||
binding:
|
||||
params.cliSessionBinding ??
|
||||
(params.cliSessionId ? { sessionId: params.cliSessionId } : undefined),
|
||||
authProfileId: params.authProfileId,
|
||||
extraSystemPromptHash,
|
||||
mcpConfigHash: preparedBackend.mcpConfigHash,
|
||||
});
|
||||
if (reusableCliSession.invalidatedReason) {
|
||||
cliBackendLog.info(
|
||||
`cli session reset: provider=${params.provider} reason=${reusableCliSession.invalidatedReason}`,
|
||||
);
|
||||
}
|
||||
const heartbeatPrompt =
|
||||
sessionAgentId === defaultAgentId
|
||||
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
|
||||
|
|
|
|||
|
|
@ -30,12 +30,15 @@ export type RunCliAgentParams = {
|
|||
bootstrapPromptWarningSignature?: string;
|
||||
images?: ImageContent[];
|
||||
imageOrder?: PromptImageOrderEntry[];
|
||||
messageProvider?: string;
|
||||
agentAccountId?: string;
|
||||
};
|
||||
|
||||
export type CliPreparedBackend = {
|
||||
backend: CliBackendConfig;
|
||||
cleanup?: () => Promise<void>;
|
||||
mcpConfigHash?: string;
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type CliReusableSession = {
|
||||
|
|
|
|||
|
|
@ -371,6 +371,8 @@ export function runAgentAttempt(params: {
|
|||
images: params.isFallbackRetry ? undefined : params.opts.images,
|
||||
imageOrder: params.isFallbackRetry ? undefined : params.opts.imageOrder,
|
||||
streamParams: params.opts.streamParams,
|
||||
messageProvider: params.messageChannel,
|
||||
agentAccountId: params.runContext.accountId,
|
||||
});
|
||||
return runCliWithSession(cliSessionBinding?.sessionId).catch(async (err) => {
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -558,6 +558,8 @@ export async function runAgentTurnWithFallback(params: {
|
|||
],
|
||||
images: params.opts?.images,
|
||||
imageOrder: params.opts?.imageOrder,
|
||||
messageProvider: params.followupRun.run.messageProvider,
|
||||
agentAccountId: params.followupRun.run.agentAccountId,
|
||||
});
|
||||
bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen(
|
||||
result.meta?.systemPromptReport,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getFreePortBlockWithPermissionFallback } from "../test-utils/ports.js";
|
||||
|
||||
const resolveGatewayScopedToolsMock = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
agentId: "main",
|
||||
tools: [
|
||||
{
|
||||
name: "message",
|
||||
description: "send a message",
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute: async () => ({
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
}),
|
||||
},
|
||||
],
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: () => ({ session: { mainKey: "main" } }),
|
||||
}));
|
||||
|
||||
vi.mock("../config/sessions.js", () => ({
|
||||
resolveMainSessionKey: () => "agent:main:main",
|
||||
}));
|
||||
|
||||
vi.mock("./tool-resolution.js", () => ({
|
||||
resolveGatewayScopedTools: (...args: Parameters<typeof resolveGatewayScopedToolsMock>) =>
|
||||
resolveGatewayScopedToolsMock(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
createMcpLoopbackServerConfig,
|
||||
getActiveMcpLoopbackRuntime,
|
||||
startMcpLoopbackServer,
|
||||
} from "./mcp-http.js";
|
||||
|
||||
let server: Awaited<ReturnType<typeof startMcpLoopbackServer>> | undefined;
|
||||
|
||||
async function sendRaw(params: {
|
||||
port: number;
|
||||
token?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
}) {
|
||||
return await fetch(`http://127.0.0.1:${params.port}/mcp`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...(params.token ? { authorization: `Bearer ${params.token}` } : {}),
|
||||
...params.headers,
|
||||
},
|
||||
body: params.body,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resolveGatewayScopedToolsMock.mockClear();
|
||||
resolveGatewayScopedToolsMock.mockReturnValue({
|
||||
agentId: "main",
|
||||
tools: [
|
||||
{
|
||||
name: "message",
|
||||
description: "send a message",
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute: async () => ({
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = undefined;
|
||||
});
|
||||
|
||||
describe("mcp loopback server", () => {
|
||||
it("passes session, account, and message channel headers into shared tool resolution", async () => {
|
||||
const port = await getFreePortBlockWithPermissionFallback({
|
||||
offsets: [0],
|
||||
fallbackBase: 53_000,
|
||||
});
|
||||
server = await startMcpLoopbackServer(port);
|
||||
const runtime = getActiveMcpLoopbackRuntime();
|
||||
|
||||
const response = await sendRaw({
|
||||
port: server.port,
|
||||
token: runtime?.token,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-session-key": "agent:main:telegram:group:chat123",
|
||||
"x-openclaw-account-id": "work",
|
||||
"x-openclaw-message-channel": "telegram",
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(resolveGatewayScopedToolsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:main:telegram:group:chat123",
|
||||
accountId: "work",
|
||||
messageProvider: "telegram",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("tracks the active runtime only while the server is running", async () => {
|
||||
server = await startMcpLoopbackServer(0);
|
||||
const active = getActiveMcpLoopbackRuntime();
|
||||
expect(active?.port).toBe(server.port);
|
||||
expect(active?.token).toMatch(/^[0-9a-f]{64}$/);
|
||||
|
||||
await server.close();
|
||||
server = undefined;
|
||||
expect(getActiveMcpLoopbackRuntime()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns 401 when the bearer token is missing", async () => {
|
||||
server = await startMcpLoopbackServer(0);
|
||||
const response = await sendRaw({
|
||||
port: server.port,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 415 when the content type is not JSON", async () => {
|
||||
server = await startMcpLoopbackServer(0);
|
||||
const runtime = getActiveMcpLoopbackRuntime();
|
||||
const response = await sendRaw({
|
||||
port: server.port,
|
||||
token: runtime?.token,
|
||||
headers: { "content-type": "text/plain" },
|
||||
body: "{}",
|
||||
});
|
||||
expect(response.status).toBe(415);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMcpLoopbackServerConfig", () => {
|
||||
it("builds a server entry with env-driven headers", () => {
|
||||
const config = createMcpLoopbackServerConfig(23119) as {
|
||||
mcpServers?: Record<string, { url?: string; headers?: Record<string, string> }>;
|
||||
};
|
||||
expect(config.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:23119/mcp");
|
||||
expect(config.mcpServers?.openclaw?.headers?.Authorization).toBe(
|
||||
"Bearer ${OPENCLAW_MCP_TOKEN}",
|
||||
);
|
||||
expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-message-channel"]).toBe(
|
||||
"${OPENCLAW_MCP_MESSAGE_CHANNEL}",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,391 @@
|
|||
import crypto from "node:crypto";
|
||||
import { createServer as createHttpServer } from "node:http";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveMainSessionKey } from "../config/sessions.js";
|
||||
import { logDebug, logWarn } from "../logger.js";
|
||||
import { normalizeMessageChannel } from "../utils/message-channel.js";
|
||||
import { getHeader } from "./http-utils.js";
|
||||
import { resolveGatewayScopedTools } from "./tool-resolution.js";
|
||||
|
||||
const SERVER_NAME = "openclaw";
|
||||
const SERVER_VERSION = "0.1.0";
|
||||
const MAX_BODY_BYTES = 1_048_576;
|
||||
const TOOL_CACHE_TTL_MS = 30_000;
|
||||
const SUPPORTED_PROTOCOL_VERSIONS = ["2025-03-26", "2024-11-05"] as const;
|
||||
const NATIVE_TOOL_EXCLUDE = new Set(["read", "write", "edit", "apply_patch", "exec", "process"]);
|
||||
|
||||
type JsonRpcId = string | number | null | undefined;
|
||||
type JsonRpcRequest = {
|
||||
jsonrpc: "2.0";
|
||||
id?: JsonRpcId;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type McpLoopbackRuntime = {
|
||||
port: number;
|
||||
token: string;
|
||||
};
|
||||
|
||||
let activeRuntime: McpLoopbackRuntime | undefined;
|
||||
|
||||
function jsonRpcResult(id: JsonRpcId, result: unknown) {
|
||||
return { jsonrpc: "2.0" as const, id: id ?? null, result };
|
||||
}
|
||||
|
||||
function jsonRpcError(id: JsonRpcId, code: number, message: string) {
|
||||
return { jsonrpc: "2.0" as const, id: id ?? null, error: { code, message } };
|
||||
}
|
||||
|
||||
function flattenUnionSchema(raw: Record<string, unknown>): Record<string, unknown> {
|
||||
const variants = (raw.anyOf ?? raw.oneOf) as Record<string, unknown>[] | undefined;
|
||||
if (!Array.isArray(variants) || variants.length === 0) {
|
||||
return raw;
|
||||
}
|
||||
const mergedProps: Record<string, unknown> = {};
|
||||
const requiredSets: Set<string>[] = [];
|
||||
for (const variant of variants) {
|
||||
const props = variant.properties as Record<string, unknown> | undefined;
|
||||
if (props) {
|
||||
for (const [key, schema] of Object.entries(props)) {
|
||||
if (!(key in mergedProps)) {
|
||||
mergedProps[key] = schema;
|
||||
continue;
|
||||
}
|
||||
const existing = mergedProps[key] as Record<string, unknown>;
|
||||
const incoming = schema as Record<string, unknown>;
|
||||
if (Array.isArray(existing.enum) && Array.isArray(incoming.enum)) {
|
||||
mergedProps[key] = {
|
||||
...existing,
|
||||
enum: [...new Set([...(existing.enum as unknown[]), ...(incoming.enum as unknown[])])],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if ("const" in existing && "const" in incoming && existing.const !== incoming.const) {
|
||||
const merged: Record<string, unknown> = {
|
||||
...existing,
|
||||
enum: [existing.const, incoming.const],
|
||||
};
|
||||
delete merged.const;
|
||||
mergedProps[key] = merged;
|
||||
continue;
|
||||
}
|
||||
logWarn(
|
||||
`mcp loopback: conflicting schema definitions for "${key}", keeping the first variant`,
|
||||
);
|
||||
}
|
||||
}
|
||||
requiredSets.push(
|
||||
new Set(Array.isArray(variant.required) ? (variant.required as string[]) : []),
|
||||
);
|
||||
}
|
||||
const required =
|
||||
requiredSets.length > 0
|
||||
? [
|
||||
...requiredSets.reduce(
|
||||
(left, right) => new Set([...left].filter((key) => right.has(key))),
|
||||
),
|
||||
]
|
||||
: [];
|
||||
const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = raw;
|
||||
return { ...rest, type: "object", properties: mergedProps, required };
|
||||
}
|
||||
|
||||
function buildToolSchema(tools: ReturnType<typeof resolveGatewayScopedTools>["tools"]) {
|
||||
return tools.map((tool) => {
|
||||
let raw =
|
||||
tool.parameters && typeof tool.parameters === "object"
|
||||
? { ...(tool.parameters as Record<string, unknown>) }
|
||||
: {};
|
||||
if (raw.anyOf || raw.oneOf) {
|
||||
raw = flattenUnionSchema(raw);
|
||||
}
|
||||
if (raw.type !== "object") {
|
||||
raw.type = "object";
|
||||
if (!raw.properties) {
|
||||
raw.properties = {};
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: raw,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveScopedSessionKey(
|
||||
cfg: ReturnType<typeof loadConfig>,
|
||||
rawSessionKey: string | undefined,
|
||||
): string {
|
||||
const trimmed = rawSessionKey?.trim();
|
||||
return !trimmed || trimmed === "main" ? resolveMainSessionKey(cfg) : trimmed;
|
||||
}
|
||||
|
||||
async function readBody(req: IncomingMessage): Promise<string> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let received = 0;
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
received += chunk.length;
|
||||
if (received > MAX_BODY_BYTES) {
|
||||
req.destroy();
|
||||
reject(new Error(`Request body exceeds ${MAX_BODY_BYTES} bytes`));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
export function getActiveMcpLoopbackRuntime(): McpLoopbackRuntime | undefined {
|
||||
return activeRuntime ? { ...activeRuntime } : undefined;
|
||||
}
|
||||
|
||||
export function createMcpLoopbackServerConfig(port: number) {
|
||||
return {
|
||||
mcpServers: {
|
||||
openclaw: {
|
||||
type: "http",
|
||||
url: `http://127.0.0.1:${port}/mcp`,
|
||||
headers: {
|
||||
Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}",
|
||||
"x-session-key": "${OPENCLAW_MCP_SESSION_KEY}",
|
||||
"x-openclaw-agent-id": "${OPENCLAW_MCP_AGENT_ID}",
|
||||
"x-openclaw-account-id": "${OPENCLAW_MCP_ACCOUNT_ID}",
|
||||
"x-openclaw-message-channel": "${OPENCLAW_MCP_MESSAGE_CHANNEL}",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function handleJsonRpc(params: {
|
||||
message: JsonRpcRequest;
|
||||
tools: ReturnType<typeof resolveGatewayScopedTools>["tools"];
|
||||
toolSchema: ReturnType<typeof buildToolSchema>;
|
||||
}): Promise<object | null> {
|
||||
const { id, method, params: methodParams } = params.message;
|
||||
|
||||
switch (method) {
|
||||
case "initialize": {
|
||||
const clientVersion = (methodParams?.protocolVersion as string) ?? "";
|
||||
const negotiated =
|
||||
SUPPORTED_PROTOCOL_VERSIONS.find((version) => version === clientVersion) ??
|
||||
SUPPORTED_PROTOCOL_VERSIONS[0];
|
||||
return jsonRpcResult(id, {
|
||||
protocolVersion: negotiated,
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
|
||||
});
|
||||
}
|
||||
case "notifications/initialized":
|
||||
case "notifications/cancelled":
|
||||
return null;
|
||||
case "tools/list":
|
||||
return jsonRpcResult(id, { tools: params.toolSchema });
|
||||
case "tools/call": {
|
||||
const toolName = methodParams?.name as string;
|
||||
const toolArgs = (methodParams?.arguments ?? {}) as Record<string, unknown>;
|
||||
const tool = params.tools.find((candidate) => candidate.name === toolName);
|
||||
if (!tool) {
|
||||
return jsonRpcResult(id, {
|
||||
content: [{ type: "text", text: `Tool not available: ${toolName}` }],
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
const toolCallId = `mcp-${crypto.randomUUID()}`;
|
||||
try {
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
const result = await (tool as any).execute(toolCallId, toolArgs);
|
||||
const content =
|
||||
result?.content && Array.isArray(result.content)
|
||||
? result.content.map((block: { type?: string; text?: string }) => ({
|
||||
type: (block.type ?? "text") as "text",
|
||||
text: block.text ?? (typeof block === "string" ? block : JSON.stringify(block)),
|
||||
}))
|
||||
: [
|
||||
{
|
||||
type: "text",
|
||||
text: typeof result === "string" ? result : JSON.stringify(result),
|
||||
},
|
||||
];
|
||||
return jsonRpcResult(id, { content, isError: false });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return jsonRpcResult(id, {
|
||||
content: [{ type: "text", text: message || "tool execution failed" }],
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
default:
|
||||
return jsonRpcError(id, -32601, `Method not found: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startMcpLoopbackServer(port = 0): Promise<{
|
||||
port: number;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const toolCache = new Map<
|
||||
string,
|
||||
{
|
||||
tools: ReturnType<typeof resolveGatewayScopedTools>["tools"];
|
||||
toolSchema: ReturnType<typeof buildToolSchema>;
|
||||
configRef: ReturnType<typeof loadConfig>;
|
||||
time: number;
|
||||
}
|
||||
>();
|
||||
|
||||
const httpServer = createHttpServer((req, res) => {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
||||
} catch {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "bad_request" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname.startsWith("/.well-known/")) {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname !== "/mcp") {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not_found" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
res.writeHead(405, { Allow: "POST" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const authHeader = getHeader(req, "authorization") ?? "";
|
||||
if (authHeader !== `Bearer ${token}`) {
|
||||
res.writeHead(401, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "unauthorized" }));
|
||||
return;
|
||||
}
|
||||
const contentType = getHeader(req, "content-type") ?? "";
|
||||
if (!contentType.startsWith("application/json")) {
|
||||
res.writeHead(415, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "unsupported_media_type" }));
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const body = await readBody(req);
|
||||
const parsed: JsonRpcRequest | JsonRpcRequest[] = JSON.parse(body);
|
||||
const cfg = loadConfig();
|
||||
const sessionKey = resolveScopedSessionKey(cfg, getHeader(req, "x-session-key"));
|
||||
const messageProvider =
|
||||
normalizeMessageChannel(getHeader(req, "x-openclaw-message-channel")) ?? undefined;
|
||||
const accountId = getHeader(req, "x-openclaw-account-id")?.trim() || undefined;
|
||||
const cacheKey = [sessionKey, messageProvider ?? "", accountId ?? ""].join("\u0000");
|
||||
const now = Date.now();
|
||||
const cached = toolCache.get(cacheKey);
|
||||
const scopedTools =
|
||||
cached && cached.configRef === cfg && now - cached.time < TOOL_CACHE_TTL_MS
|
||||
? cached
|
||||
: (() => {
|
||||
const next = resolveGatewayScopedTools({
|
||||
cfg,
|
||||
sessionKey,
|
||||
messageProvider,
|
||||
accountId,
|
||||
excludeToolNames: NATIVE_TOOL_EXCLUDE,
|
||||
});
|
||||
const nextEntry = {
|
||||
tools: next.tools,
|
||||
toolSchema: buildToolSchema(next.tools),
|
||||
configRef: cfg,
|
||||
time: now,
|
||||
};
|
||||
toolCache.set(cacheKey, nextEntry);
|
||||
for (const [key, entry] of toolCache) {
|
||||
if (now - entry.time >= TOOL_CACHE_TTL_MS) {
|
||||
toolCache.delete(key);
|
||||
}
|
||||
}
|
||||
return nextEntry;
|
||||
})();
|
||||
|
||||
const messages = Array.isArray(parsed) ? parsed : [parsed];
|
||||
const responses: object[] = [];
|
||||
for (const message of messages) {
|
||||
const response = await handleJsonRpc({
|
||||
message,
|
||||
tools: scopedTools.tools,
|
||||
toolSchema: scopedTools.toolSchema,
|
||||
});
|
||||
if (response !== null) {
|
||||
responses.push(response);
|
||||
}
|
||||
}
|
||||
|
||||
if (responses.length === 0) {
|
||||
res.writeHead(202);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = Array.isArray(parsed)
|
||||
? JSON.stringify(responses)
|
||||
: JSON.stringify(responses[0]);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(payload);
|
||||
} catch (error) {
|
||||
logWarn(
|
||||
`mcp loopback: request handling failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(jsonRpcError(null, -32700, "Parse error")));
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpServer.once("error", reject);
|
||||
httpServer.listen(port, "127.0.0.1", () => {
|
||||
httpServer.removeListener("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const address = httpServer.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("mcp loopback did not bind to a TCP port");
|
||||
}
|
||||
activeRuntime = { port: address.port, token };
|
||||
logDebug(`mcp loopback listening on 127.0.0.1:${address.port}`);
|
||||
|
||||
return {
|
||||
port: address.port,
|
||||
close: () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
httpServer.close((error) => {
|
||||
if (!error && activeRuntime?.token === token) {
|
||||
activeRuntime = undefined;
|
||||
}
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
@ -89,6 +89,7 @@ import {
|
|||
type GatewayUpdateAvailableEventPayload,
|
||||
} from "./events.js";
|
||||
import { ExecApprovalManager } from "./exec-approval-manager.js";
|
||||
import { startMcpLoopbackServer } from "./mcp-http.js";
|
||||
import { startGatewayModelPricingRefresh } from "./model-pricing-cache.js";
|
||||
import { NodeRegistry } from "./node-registry.js";
|
||||
import { createChannelManager } from "./server-channels.js";
|
||||
|
|
@ -783,6 +784,7 @@ export async function startGatewayServer(
|
|||
let skillsChangeUnsub = () => {};
|
||||
let channelHealthMonitor: ReturnType<typeof startChannelHealthMonitor> | null = null;
|
||||
let stopModelPricingRefresh = () => {};
|
||||
let mcpServer: { port: number; close: () => Promise<void> } | undefined;
|
||||
let configReloader: { stop: () => Promise<void> } = { stop: async () => {} };
|
||||
const closeOnStartupFailure = async () => {
|
||||
if (diagnosticsEnabled) {
|
||||
|
|
@ -798,6 +800,7 @@ export async function startGatewayServer(
|
|||
stopModelPricingRefresh();
|
||||
channelHealthMonitor?.stop();
|
||||
clearSecretsRuntimeSnapshot();
|
||||
await mcpServer?.close().catch(() => {});
|
||||
await createGatewayCloseHandler({
|
||||
bonjourStop,
|
||||
tailscaleCleanup,
|
||||
|
|
@ -864,6 +867,13 @@ export async function startGatewayServer(
|
|||
let transcriptUnsub: (() => void) | null = null;
|
||||
let lifecycleUnsub: (() => void) | null = null;
|
||||
try {
|
||||
try {
|
||||
mcpServer = await startMcpLoopbackServer(0);
|
||||
log.info(`MCP loopback server listening on http://127.0.0.1:${mcpServer.port}/mcp`);
|
||||
} catch (error) {
|
||||
log.warn(`MCP loopback server failed to start: ${String(error)}`);
|
||||
}
|
||||
|
||||
if (!minimalTestGateway) {
|
||||
const machineDisplayName = await getMachineDisplayName();
|
||||
const discovery = await startGatewayDiscovery({
|
||||
|
|
@ -1542,6 +1552,7 @@ export async function startGatewayServer(
|
|||
stopModelPricingRefresh();
|
||||
channelHealthMonitor?.stop();
|
||||
clearSecretsRuntimeSnapshot();
|
||||
await mcpServer?.close().catch(() => {});
|
||||
await close(opts);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { createOpenClawTools } from "../agents/openclaw-tools.js";
|
||||
import {
|
||||
resolveEffectiveToolPolicy,
|
||||
resolveGroupToolPolicy,
|
||||
resolveSubagentToolPolicy,
|
||||
} from "../agents/pi-tools.policy.js";
|
||||
import {
|
||||
applyToolPolicyPipeline,
|
||||
buildDefaultToolPolicyPipelineSteps,
|
||||
} from "../agents/tool-policy-pipeline.js";
|
||||
import {
|
||||
collectExplicitAllowlist,
|
||||
mergeAlsoAllowPolicy,
|
||||
resolveToolProfilePolicy,
|
||||
} from "../agents/tool-policy.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
import { getPluginToolMeta } from "../plugins/tools.js";
|
||||
import { isSubagentSessionKey } from "../routing/session-key.js";
|
||||
import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "../security/dangerous-tools.js";
|
||||
|
||||
export function resolveGatewayScopedTools(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
sessionKey: string;
|
||||
messageProvider?: string;
|
||||
accountId?: string;
|
||||
agentTo?: string;
|
||||
agentThreadId?: string;
|
||||
allowGatewaySubagentBinding?: boolean;
|
||||
allowMediaInvokeCommands?: boolean;
|
||||
excludeToolNames?: Iterable<string>;
|
||||
}) {
|
||||
const {
|
||||
agentId,
|
||||
globalPolicy,
|
||||
globalProviderPolicy,
|
||||
agentPolicy,
|
||||
agentProviderPolicy,
|
||||
profile,
|
||||
providerProfile,
|
||||
profileAlsoAllow,
|
||||
providerProfileAlsoAllow,
|
||||
} = resolveEffectiveToolPolicy({ config: params.cfg, sessionKey: params.sessionKey });
|
||||
const profilePolicy = resolveToolProfilePolicy(profile);
|
||||
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
|
||||
const profilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(profilePolicy, profileAlsoAllow);
|
||||
const providerProfilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(
|
||||
providerProfilePolicy,
|
||||
providerProfileAlsoAllow,
|
||||
);
|
||||
const groupPolicy = resolveGroupToolPolicy({
|
||||
config: params.cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
messageProvider: params.messageProvider,
|
||||
accountId: params.accountId ?? null,
|
||||
});
|
||||
const subagentPolicy = isSubagentSessionKey(params.sessionKey)
|
||||
? resolveSubagentToolPolicy(params.cfg)
|
||||
: undefined;
|
||||
const workspaceDir = resolveAgentWorkspaceDir(
|
||||
params.cfg,
|
||||
agentId ?? resolveDefaultAgentId(params.cfg),
|
||||
);
|
||||
|
||||
const allTools = createOpenClawTools({
|
||||
agentSessionKey: params.sessionKey,
|
||||
agentChannel: params.messageProvider ?? undefined,
|
||||
agentAccountId: params.accountId,
|
||||
agentTo: params.agentTo,
|
||||
agentThreadId: params.agentThreadId,
|
||||
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
|
||||
allowMediaInvokeCommands: params.allowMediaInvokeCommands,
|
||||
config: params.cfg,
|
||||
workspaceDir,
|
||||
pluginToolAllowlist: collectExplicitAllowlist([
|
||||
profilePolicy,
|
||||
providerProfilePolicy,
|
||||
globalPolicy,
|
||||
globalProviderPolicy,
|
||||
agentPolicy,
|
||||
agentProviderPolicy,
|
||||
groupPolicy,
|
||||
subagentPolicy,
|
||||
]),
|
||||
});
|
||||
|
||||
const policyFiltered = applyToolPolicyPipeline({
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
tools: allTools as any,
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
toolMeta: (tool) => getPluginToolMeta(tool as any),
|
||||
warn: logWarn,
|
||||
steps: [
|
||||
...buildDefaultToolPolicyPipelineSteps({
|
||||
profilePolicy: profilePolicyWithAlsoAllow,
|
||||
profile,
|
||||
profileUnavailableCoreWarningAllowlist: profilePolicy?.allow,
|
||||
providerProfilePolicy: providerProfilePolicyWithAlsoAllow,
|
||||
providerProfile,
|
||||
providerProfileUnavailableCoreWarningAllowlist: providerProfilePolicy?.allow,
|
||||
globalPolicy,
|
||||
globalProviderPolicy,
|
||||
agentPolicy,
|
||||
agentProviderPolicy,
|
||||
groupPolicy,
|
||||
agentId,
|
||||
}),
|
||||
{ policy: subagentPolicy, label: "subagent tools.allow" },
|
||||
],
|
||||
});
|
||||
|
||||
const gatewayToolsCfg = params.cfg.gateway?.tools;
|
||||
const defaultGatewayDeny = DEFAULT_GATEWAY_HTTP_TOOL_DENY.filter(
|
||||
(name) => !gatewayToolsCfg?.allow?.includes(name),
|
||||
);
|
||||
const gatewayDenySet = new Set([
|
||||
...defaultGatewayDeny,
|
||||
...(Array.isArray(gatewayToolsCfg?.deny) ? gatewayToolsCfg.deny : []),
|
||||
...(params.excludeToolNames ? Array.from(params.excludeToolNames) : []),
|
||||
]);
|
||||
|
||||
return {
|
||||
agentId,
|
||||
tools: policyFiltered.filter((tool) => !gatewayDenySet.has(tool.name)),
|
||||
};
|
||||
}
|
||||
|
|
@ -736,10 +736,16 @@ describe("POST /tools/invoke", () => {
|
|||
|
||||
it("requires operator.write scope for HTTP tool invocation", async () => {
|
||||
allowAgentsListForMain();
|
||||
vi.mocked(authorizeHttpGatewayConnect).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
method: "trusted-proxy",
|
||||
});
|
||||
|
||||
const res = await invokeTool({
|
||||
port: sharedPort,
|
||||
headers: {},
|
||||
headers: {
|
||||
"x-openclaw-scopes": "",
|
||||
},
|
||||
tool: "agents_list",
|
||||
sessionKey: "main",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,31 +1,12 @@
|
|||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { createOpenClawTools } from "../agents/openclaw-tools.js";
|
||||
import { runBeforeToolCallHook } from "../agents/pi-tools.before-tool-call.js";
|
||||
import { resolveToolLoopDetectionConfig } from "../agents/pi-tools.js";
|
||||
import {
|
||||
resolveEffectiveToolPolicy,
|
||||
resolveGroupToolPolicy,
|
||||
resolveSubagentToolPolicy,
|
||||
} from "../agents/pi-tools.policy.js";
|
||||
import {
|
||||
applyToolPolicyPipeline,
|
||||
buildDefaultToolPolicyPipelineSteps,
|
||||
} from "../agents/tool-policy-pipeline.js";
|
||||
import {
|
||||
applyOwnerOnlyToolPolicy,
|
||||
collectExplicitAllowlist,
|
||||
mergeAlsoAllowPolicy,
|
||||
resolveToolProfilePolicy,
|
||||
} from "../agents/tool-policy.js";
|
||||
import { applyOwnerOnlyToolPolicy } from "../agents/tool-policy.js";
|
||||
import { ToolInputError } from "../agents/tools/common.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveMainSessionKey } from "../config/sessions.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
import { isTestDefaultMemorySlotDisabled } from "../plugins/config-state.js";
|
||||
import { getPluginToolMeta } from "../plugins/tools.js";
|
||||
import { isSubagentSessionKey } from "../routing/session-key.js";
|
||||
import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "../security/dangerous-tools.js";
|
||||
import { normalizeMessageChannel } from "../utils/message-channel.js";
|
||||
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||
|
|
@ -42,6 +23,7 @@ import {
|
|||
resolveOpenAiCompatibleHttpSenderIsOwner,
|
||||
} from "./http-utils.js";
|
||||
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
|
||||
import { resolveGatewayScopedTools } from "./tool-resolution.js";
|
||||
|
||||
const DEFAULT_BODY_BYTES = 2 * 1024 * 1024;
|
||||
const MEMORY_TOOL_NAMES = new Set(["memory_search", "memory_get"]);
|
||||
|
|
@ -151,7 +133,14 @@ export async function handleToolsInvokeHttpRequest(
|
|||
rateLimiter?: AuthRateLimiter;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
||||
} catch {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "bad_request", message: "Invalid request URL" }));
|
||||
return true;
|
||||
}
|
||||
if (url.pathname !== "/tools/invoke") {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -238,100 +227,20 @@ export async function handleToolsInvokeHttpRequest(
|
|||
const accountId = getHeader(req, "x-openclaw-account-id")?.trim() || undefined;
|
||||
const agentTo = getHeader(req, "x-openclaw-message-to")?.trim() || undefined;
|
||||
const agentThreadId = getHeader(req, "x-openclaw-thread-id")?.trim() || undefined;
|
||||
|
||||
const {
|
||||
agentId,
|
||||
globalPolicy,
|
||||
globalProviderPolicy,
|
||||
agentPolicy,
|
||||
agentProviderPolicy,
|
||||
profile,
|
||||
providerProfile,
|
||||
profileAlsoAllow,
|
||||
providerProfileAlsoAllow,
|
||||
} = resolveEffectiveToolPolicy({ config: cfg, sessionKey });
|
||||
const profilePolicy = resolveToolProfilePolicy(profile);
|
||||
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
|
||||
|
||||
const profilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(profilePolicy, profileAlsoAllow);
|
||||
const providerProfilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(
|
||||
providerProfilePolicy,
|
||||
providerProfileAlsoAllow,
|
||||
);
|
||||
const groupPolicy = resolveGroupToolPolicy({
|
||||
config: cfg,
|
||||
const { agentId, tools } = resolveGatewayScopedTools({
|
||||
cfg,
|
||||
sessionKey,
|
||||
messageProvider: messageChannel ?? undefined,
|
||||
accountId: accountId ?? null,
|
||||
});
|
||||
const subagentPolicy = isSubagentSessionKey(sessionKey)
|
||||
? resolveSubagentToolPolicy(cfg)
|
||||
: undefined;
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId ?? resolveDefaultAgentId(cfg));
|
||||
|
||||
// Build tool list (core + plugin tools).
|
||||
const allTools = createOpenClawTools({
|
||||
agentSessionKey: sessionKey,
|
||||
agentChannel: messageChannel ?? undefined,
|
||||
agentAccountId: accountId,
|
||||
accountId,
|
||||
agentTo,
|
||||
agentThreadId,
|
||||
allowGatewaySubagentBinding: true,
|
||||
// HTTP callers consume tool output directly; preserve raw media invoke payloads.
|
||||
allowMediaInvokeCommands: true,
|
||||
config: cfg,
|
||||
workspaceDir,
|
||||
pluginToolAllowlist: collectExplicitAllowlist([
|
||||
profilePolicy,
|
||||
providerProfilePolicy,
|
||||
globalPolicy,
|
||||
globalProviderPolicy,
|
||||
agentPolicy,
|
||||
agentProviderPolicy,
|
||||
groupPolicy,
|
||||
subagentPolicy,
|
||||
]),
|
||||
});
|
||||
|
||||
const subagentFiltered = applyToolPolicyPipeline({
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
tools: allTools as any,
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
toolMeta: (tool) => getPluginToolMeta(tool as any),
|
||||
warn: logWarn,
|
||||
steps: [
|
||||
...buildDefaultToolPolicyPipelineSteps({
|
||||
profilePolicy: profilePolicyWithAlsoAllow,
|
||||
profile,
|
||||
profileUnavailableCoreWarningAllowlist: profilePolicy?.allow,
|
||||
providerProfilePolicy: providerProfilePolicyWithAlsoAllow,
|
||||
providerProfile,
|
||||
providerProfileUnavailableCoreWarningAllowlist: providerProfilePolicy?.allow,
|
||||
globalPolicy,
|
||||
globalProviderPolicy,
|
||||
agentPolicy,
|
||||
agentProviderPolicy,
|
||||
groupPolicy,
|
||||
agentId,
|
||||
}),
|
||||
{ policy: subagentPolicy, label: "subagent tools.allow" },
|
||||
],
|
||||
});
|
||||
|
||||
// Gateway HTTP-specific deny list — applies to ALL sessions via HTTP.
|
||||
const gatewayToolsCfg = cfg.gateway?.tools;
|
||||
const defaultGatewayDeny: string[] = DEFAULT_GATEWAY_HTTP_TOOL_DENY.filter(
|
||||
(name) => !gatewayToolsCfg?.allow?.includes(name),
|
||||
);
|
||||
const gatewayDenyNames = defaultGatewayDeny.concat(
|
||||
Array.isArray(gatewayToolsCfg?.deny) ? gatewayToolsCfg.deny : [],
|
||||
);
|
||||
const gatewayDenySet = new Set(gatewayDenyNames);
|
||||
// Owner semantics intentionally follow the same shared-secret HTTP contract
|
||||
// on this direct tool surface; SECURITY.md documents this as designed-as-is.
|
||||
const senderIsOwner = resolveOpenAiCompatibleHttpSenderIsOwner(req, requestAuth);
|
||||
const ownerFiltered = applyOwnerOnlyToolPolicy(subagentFiltered, senderIsOwner);
|
||||
const gatewayFiltered = ownerFiltered.filter((t) => !gatewayDenySet.has(t.name));
|
||||
const gatewayFiltered = applyOwnerOnlyToolPolicy(tools, senderIsOwner);
|
||||
|
||||
const tool = gatewayFiltered.find((t) => t.name === toolName);
|
||||
if (!tool) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue