OSVauco/opax-web/backend/agentClient.js

141 lines
3.3 KiB
JavaScript

import { randomUUID } from 'node:crypto';
const TIMEOUT_MS = 10000;
const MAX_REQUEST_SIZE = 16384;
const MAX_RESPONSE_SIZE = 1024 * 1024;
const GENERIC_ERROR_MESSAGE = 'Agent request failed.';
const READ_ONLY_TOOLS = new Set([
'get_health',
'get_build_status',
'get_state',
'get_telemetry',
'run_emma',
]);
export class AgentNotConfiguredError extends Error {
constructor() {
super('Agent integration is not configured.');
this.name = 'AgentNotConfiguredError';
}
}
function validateMcpUrl(urlString, allowedOrigin) {
const url = new URL(urlString);
if (url.username || url.password || url.hash) {
throw new Error(GENERIC_ERROR_MESSAGE);
}
if (url.protocol === 'http:') {
const isLoopback =
url.hostname === 'localhost' ||
url.hostname === '127.0.0.1' ||
url.hostname === '[::1]';
if (!isLoopback) {
throw new Error(GENERIC_ERROR_MESSAGE);
}
return url;
}
if (url.protocol !== 'https:' || !allowedOrigin) {
throw new Error(GENERIC_ERROR_MESSAGE);
}
const configuredOrigin = new URL(allowedOrigin);
if (url.origin !== configuredOrigin.origin) {
throw new Error(GENERIC_ERROR_MESSAGE);
}
return url;
}
export async function callAgent(tool, args) {
const mcpUrl = process.env.OPAX_MCP_URL?.trim();
const mcpSecret = process.env.MCP_SECRET?.trim();
if (!mcpUrl || !mcpSecret) {
throw new AgentNotConfiguredError();
}
try {
const allowedOrigin = process.env.OPAX_MCP_ALLOWED_ORIGIN?.trim();
if (!READ_ONLY_TOOLS.has(tool)) {
throw new Error(GENERIC_ERROR_MESSAGE);
}
const validatedUrl = validateMcpUrl(mcpUrl, allowedOrigin);
const requestBody = JSON.stringify({
jsonrpc: '2.0',
id: randomUUID(),
method: 'tools/call',
params: {
name: tool,
arguments: args,
},
});
if (requestBody.length > MAX_REQUEST_SIZE) {
throw new Error(GENERIC_ERROR_MESSAGE);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(validatedUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${mcpSecret}`,
},
body: requestBody,
signal: controller.signal,
});
if (!response.ok) {
throw new Error(GENERIC_ERROR_MESSAGE);
}
const responseText = await response.text();
if (responseText.length > MAX_RESPONSE_SIZE) {
throw new Error(GENERIC_ERROR_MESSAGE);
}
const rpcResponse = JSON.parse(responseText);
if (rpcResponse.error) {
throw new Error(GENERIC_ERROR_MESSAGE);
}
const content = rpcResponse.result?.content;
if (!Array.isArray(content) || content.length === 0) {
throw new Error(GENERIC_ERROR_MESSAGE);
}
const firstContent = content[0];
if (
firstContent?.type !== 'text' ||
typeof firstContent.text !== 'string' ||
firstContent.text.length > MAX_RESPONSE_SIZE
) {
throw new Error(GENERIC_ERROR_MESSAGE);
}
return JSON.parse(firstContent.text);
} finally {
clearTimeout(timeoutId);
}
} catch {
throw new Error(GENERIC_ERROR_MESSAGE);
}
}