335 lines
10 KiB
TypeScript
335 lines
10 KiB
TypeScript
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
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 { 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 { authorizeHttpGatewayConnect, type ResolvedGatewayAuth } from "./auth.js";
|
|
import {
|
|
readJsonBodyOrError,
|
|
sendGatewayAuthFailure,
|
|
sendInvalidRequest,
|
|
sendJson,
|
|
sendMethodNotAllowed,
|
|
} from "./http-common.js";
|
|
import { getBearerToken, getHeader } from "./http-utils.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> {
|
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
if (url.pathname !== "/tools/invoke") {
|
|
return false;
|
|
}
|
|
|
|
if (req.method !== "POST") {
|
|
sendMethodNotAllowed(res, "POST");
|
|
return true;
|
|
}
|
|
|
|
const cfg = loadConfig();
|
|
const token = getBearerToken(req);
|
|
const authResult = await authorizeHttpGatewayConnect({
|
|
auth: opts.auth,
|
|
connectAuth: token ? { token, password: token } : null,
|
|
req,
|
|
trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies,
|
|
allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback,
|
|
rateLimiter: opts.rateLimiter,
|
|
});
|
|
if (!authResult.ok) {
|
|
sendGatewayAuthFailure(res, authResult);
|
|
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 {
|
|
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,
|
|
sessionKey,
|
|
messageProvider: messageChannel ?? undefined,
|
|
accountId: accountId ?? null,
|
|
});
|
|
const subagentPolicy = isSubagentSessionKey(sessionKey)
|
|
? resolveSubagentToolPolicy(cfg)
|
|
: undefined;
|
|
|
|
// Build tool list (core + plugin tools).
|
|
const allTools = createOpenClawTools({
|
|
agentSessionKey: sessionKey,
|
|
agentChannel: messageChannel ?? undefined,
|
|
agentAccountId: accountId,
|
|
config: cfg,
|
|
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,
|
|
providerProfilePolicy: providerProfilePolicyWithAlsoAllow,
|
|
providerProfile,
|
|
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);
|
|
const gatewayFiltered = subagentFiltered.filter((t) => !gatewayDenySet.has(t.name));
|
|
|
|
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 toolArgs = mergeActionIntoArgsIfSupported({
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
toolSchema: (tool as any).parameters,
|
|
action,
|
|
args,
|
|
});
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
const result = await (tool as any).execute?.(`http-${Date.now()}`, toolArgs);
|
|
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;
|
|
}
|