mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-07 15:21:06 +00:00
300 lines
9.5 KiB
TypeScript
300 lines
9.5 KiB
TypeScript
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
import { runBeforeToolCallHook } from "../agents/pi-tools.before-tool-call.js";
|
|
import { resolveToolLoopDetectionConfig } from "../agents/pi-tools.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 { normalizeMessageChannel } from "../utils/message-channel.js";
|
|
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
|
import type { ResolvedGatewayAuth } from "./auth.js";
|
|
import {
|
|
readJsonBodyOrError,
|
|
sendInvalidRequest,
|
|
sendJson,
|
|
sendMethodNotAllowed,
|
|
} from "./http-common.js";
|
|
import {
|
|
authorizeGatewayHttpRequestOrReply,
|
|
getHeader,
|
|
resolveOpenAiCompatibleHttpOperatorScopes,
|
|
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"]);
|
|
|
|
type ToolsInvokeBody = {
|
|
tool?: unknown;
|
|
action?: unknown;
|
|
args?: unknown;
|
|
sessionKey?: unknown;
|
|
dryRun?: unknown;
|
|
};
|
|
|
|
function resolveSessionKeyFromBody(body: ToolsInvokeBody): string | undefined {
|
|
if (typeof body.sessionKey === "string" && body.sessionKey.trim()) {
|
|
return body.sessionKey.trim();
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function resolveMemoryToolDisableReasons(cfg: ReturnType<typeof loadConfig>): string[] {
|
|
if (!process.env.VITEST) {
|
|
return [];
|
|
}
|
|
const reasons: string[] = [];
|
|
const plugins = cfg.plugins;
|
|
const slotRaw = plugins?.slots?.memory;
|
|
const slotDisabled =
|
|
slotRaw === null || (typeof slotRaw === "string" && slotRaw.trim().toLowerCase() === "none");
|
|
const pluginsDisabled = plugins?.enabled === false;
|
|
const defaultDisabled = isTestDefaultMemorySlotDisabled(cfg);
|
|
|
|
if (pluginsDisabled) {
|
|
reasons.push("plugins.enabled=false");
|
|
}
|
|
if (slotDisabled) {
|
|
reasons.push(slotRaw === null ? "plugins.slots.memory=null" : 'plugins.slots.memory="none"');
|
|
}
|
|
if (!pluginsDisabled && !slotDisabled && defaultDisabled) {
|
|
reasons.push("memory plugin disabled by test default");
|
|
}
|
|
return reasons;
|
|
}
|
|
|
|
function mergeActionIntoArgsIfSupported(params: {
|
|
toolSchema: unknown;
|
|
action: string | undefined;
|
|
args: Record<string, unknown>;
|
|
}): Record<string, unknown> {
|
|
const { toolSchema, action, args } = params;
|
|
if (!action) {
|
|
return args;
|
|
}
|
|
if (args.action !== undefined) {
|
|
return args;
|
|
}
|
|
// TypeBox schemas are plain objects; many tools define an `action` property.
|
|
const schemaObj = toolSchema as { properties?: Record<string, unknown> } | null;
|
|
const hasAction = Boolean(
|
|
schemaObj &&
|
|
typeof schemaObj === "object" &&
|
|
schemaObj.properties &&
|
|
"action" in schemaObj.properties,
|
|
);
|
|
if (!hasAction) {
|
|
return args;
|
|
}
|
|
return { ...args, action };
|
|
}
|
|
|
|
function getErrorMessage(err: unknown): string {
|
|
if (err instanceof Error) {
|
|
return err.message || String(err);
|
|
}
|
|
if (typeof err === "string") {
|
|
return err;
|
|
}
|
|
return String(err);
|
|
}
|
|
|
|
function resolveToolInputErrorStatus(err: unknown): number | null {
|
|
if (err instanceof ToolInputError) {
|
|
const status = (err as { status?: unknown }).status;
|
|
return typeof status === "number" ? status : 400;
|
|
}
|
|
if (typeof err !== "object" || err === null || !("name" in err)) {
|
|
return null;
|
|
}
|
|
const name = (err as { name?: unknown }).name;
|
|
if (name !== "ToolInputError" && name !== "ToolAuthorizationError") {
|
|
return null;
|
|
}
|
|
const status = (err as { status?: unknown }).status;
|
|
if (typeof status === "number") {
|
|
return status;
|
|
}
|
|
return name === "ToolAuthorizationError" ? 403 : 400;
|
|
}
|
|
|
|
export async function handleToolsInvokeHttpRequest(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
opts: {
|
|
auth: ResolvedGatewayAuth;
|
|
maxBodyBytes?: number;
|
|
trustedProxies?: string[];
|
|
allowRealIpFallback?: boolean;
|
|
rateLimiter?: AuthRateLimiter;
|
|
},
|
|
): Promise<boolean> {
|
|
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;
|
|
}
|
|
|
|
if (req.method !== "POST") {
|
|
sendMethodNotAllowed(res, "POST");
|
|
return true;
|
|
}
|
|
|
|
const cfg = loadConfig();
|
|
const requestAuth = await authorizeGatewayHttpRequestOrReply({
|
|
req,
|
|
res,
|
|
auth: opts.auth,
|
|
trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies,
|
|
allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback,
|
|
rateLimiter: opts.rateLimiter,
|
|
});
|
|
if (!requestAuth) {
|
|
return true;
|
|
}
|
|
|
|
// /tools/invoke intentionally uses the same shared-secret HTTP trust model as
|
|
// the OpenAI-compatible APIs: token/password bearer auth is full operator
|
|
// access for the gateway, not a narrower per-request scope boundary.
|
|
const requestedScopes = resolveOpenAiCompatibleHttpOperatorScopes(req, requestAuth);
|
|
const scopeAuth = authorizeOperatorScopesForMethod("agent", requestedScopes);
|
|
if (!scopeAuth.allowed) {
|
|
sendJson(res, 403, {
|
|
ok: false,
|
|
error: {
|
|
type: "forbidden",
|
|
message: `missing scope: ${scopeAuth.missingScope}`,
|
|
},
|
|
});
|
|
return true;
|
|
}
|
|
|
|
const bodyUnknown = await readJsonBodyOrError(req, res, opts.maxBodyBytes ?? DEFAULT_BODY_BYTES);
|
|
if (bodyUnknown === undefined) {
|
|
return true;
|
|
}
|
|
const body = (bodyUnknown ?? {}) as ToolsInvokeBody;
|
|
|
|
const toolName = typeof body.tool === "string" ? body.tool.trim() : "";
|
|
if (!toolName) {
|
|
sendInvalidRequest(res, "tools.invoke requires body.tool");
|
|
return true;
|
|
}
|
|
|
|
if (process.env.VITEST && MEMORY_TOOL_NAMES.has(toolName)) {
|
|
const reasons = resolveMemoryToolDisableReasons(cfg);
|
|
if (reasons.length > 0) {
|
|
const suffix = reasons.length > 0 ? ` (${reasons.join(", ")})` : "";
|
|
sendJson(res, 400, {
|
|
ok: false,
|
|
error: {
|
|
type: "invalid_request",
|
|
message:
|
|
`memory tools are disabled in tests${suffix}. ` +
|
|
'Enable by setting plugins.slots.memory="memory-core" (and ensure plugins.enabled is not false).',
|
|
},
|
|
});
|
|
return true;
|
|
}
|
|
}
|
|
|
|
const action = typeof body.action === "string" ? body.action.trim() : undefined;
|
|
|
|
const argsRaw = body.args;
|
|
const args =
|
|
argsRaw && typeof argsRaw === "object" && !Array.isArray(argsRaw)
|
|
? (argsRaw as Record<string, unknown>)
|
|
: {};
|
|
|
|
const rawSessionKey = resolveSessionKeyFromBody(body);
|
|
const sessionKey =
|
|
!rawSessionKey || rawSessionKey === "main" ? resolveMainSessionKey(cfg) : rawSessionKey;
|
|
|
|
// Resolve message channel/account hints (optional headers) for policy inheritance.
|
|
const messageChannel = normalizeMessageChannel(
|
|
getHeader(req, "x-openclaw-message-channel") ?? "",
|
|
);
|
|
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, tools } = resolveGatewayScopedTools({
|
|
cfg,
|
|
sessionKey,
|
|
messageProvider: messageChannel ?? undefined,
|
|
accountId,
|
|
agentTo,
|
|
agentThreadId,
|
|
allowGatewaySubagentBinding: true,
|
|
allowMediaInvokeCommands: true,
|
|
});
|
|
// 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 gatewayFiltered = applyOwnerOnlyToolPolicy(tools, senderIsOwner);
|
|
|
|
const tool = gatewayFiltered.find((t) => t.name === toolName);
|
|
if (!tool) {
|
|
sendJson(res, 404, {
|
|
ok: false,
|
|
error: { type: "not_found", message: `Tool not available: ${toolName}` },
|
|
});
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
const toolCallId = `http-${Date.now()}`;
|
|
const toolArgs = mergeActionIntoArgsIfSupported({
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
toolSchema: (tool as any).parameters,
|
|
action,
|
|
args,
|
|
});
|
|
const hookResult = await runBeforeToolCallHook({
|
|
toolName,
|
|
params: toolArgs,
|
|
toolCallId,
|
|
ctx: {
|
|
agentId,
|
|
sessionKey,
|
|
loopDetection: resolveToolLoopDetectionConfig({ cfg, agentId }),
|
|
},
|
|
});
|
|
if (hookResult.blocked) {
|
|
sendJson(res, 403, {
|
|
ok: false,
|
|
error: { type: "tool_call_blocked", message: hookResult.reason },
|
|
});
|
|
return true;
|
|
}
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
const result = await (tool as any).execute?.(toolCallId, hookResult.params);
|
|
sendJson(res, 200, { ok: true, result });
|
|
} catch (err) {
|
|
const inputStatus = resolveToolInputErrorStatus(err);
|
|
if (inputStatus !== null) {
|
|
sendJson(res, inputStatus, {
|
|
ok: false,
|
|
error: { type: "tool_error", message: getErrorMessage(err) || "invalid tool arguments" },
|
|
});
|
|
return true;
|
|
}
|
|
logWarn(`tools-invoke: tool execution failed: ${String(err)}`);
|
|
sendJson(res, 500, {
|
|
ok: false,
|
|
error: { type: "tool_error", message: "tool execution failed" },
|
|
});
|
|
}
|
|
|
|
return true;
|
|
}
|