analyze/upload vía /api/image-bytes + MCP HTTP (vscode) forzado a test
Imágenes: - analyze_image y upload resuelven los bytes por el endpoint Python /api/image-bytes (pythonGetBinary). analyze_image enruta los dominios forge (env ACAI_FORGE_DOMAIN) al endpoint en vez de fetch directo (que daba ECONNREFUSED 127.0.0.1 dentro del container). Aislamiento de entorno (vscode = solo test): - resolveCurrentModeOverride(): sesión MCP HTTP (mcpSessionId presente) → "local"; stdio (chat/cron) → ACAI_MODE_OVERRIDE de entorno. Lo usan los builders de headers (pythonServerClient, files/helpers) → toda tool del MCP HTTP manda X-Acai-Mode: local. - httpServer.resolveProjectCredentials fuerza forceMode:"local" al resolver project-info → la sesión obtiene web_url/api_web_url forge-local y opera siempre contra test, nunca producción. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,10 +11,15 @@ import { LOCAL_SERVER_URL, getLocalServerHeaders } from "../config/index.js";
|
||||
* automaticamente; en modo stdio no se propaga y la logica original se
|
||||
* mantiene.
|
||||
*/
|
||||
export async function fetchProjectInfo(projectName, acaiUser = null) {
|
||||
export async function fetchProjectInfo(projectName, acaiUser = null, opts = {}) {
|
||||
const params = typeof projectName === "string" ? { project: projectName } : (projectName || {});
|
||||
const headers = getLocalServerHeaders();
|
||||
if (acaiUser) headers["X-Acai-User"] = acaiUser;
|
||||
// forceMode: fuerza el modo efectivo con el que el server Python resuelve el
|
||||
// web_url/api_web_url del proyecto. Lo usa el transporte MCP HTTP (plugin VS
|
||||
// Code) para fijar "local" → la sesion entera apunta al web forge-local
|
||||
// (test), nunca a produccion, sea cual sea el mode del .acai.
|
||||
if (opts.forceMode) headers["X-Acai-Mode"] = opts.forceMode;
|
||||
const response = await axios.get(`${LOCAL_SERVER_URL}/api/project-info`, {
|
||||
params,
|
||||
headers,
|
||||
|
||||
@@ -76,7 +76,12 @@ const verifyJwt = (token) => {
|
||||
|
||||
const resolveProjectCredentials = async (projectName, acaiUser = null) => {
|
||||
try {
|
||||
const info = await fetchProjectInfo(projectName, acaiUser);
|
||||
// El transporte MCP HTTP es exclusivo de clientes externos (plugin VS
|
||||
// Code Acai Forge). Por politica solo pueden operar sobre TEST: forzamos
|
||||
// mode=local al resolver el proyecto, de modo que web_url/api_web_url
|
||||
// apunten al web forge-local y TODA la sesion (records, modules, git,
|
||||
// media...) use el destino de test, nunca produccion.
|
||||
const info = await fetchProjectInfo(projectName, acaiUser, { forceMode: "local" });
|
||||
if (!info.success) {
|
||||
throw new Error(info.error || "Failed to resolve project info");
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "path";
|
||||
import { LOCAL_SERVER_URL, getLocalServerHeaders } from "../../config/index.js";
|
||||
import { getCurrentSessionId } from "../../utils/sessionContext.js";
|
||||
import { getMcpSessionCredentials } from "../../auth/credentials.js";
|
||||
import { resolveCurrentAcaiUser } from "../helpers/sessionHelpers.js";
|
||||
import { resolveCurrentAcaiUser, resolveCurrentModeOverride } from "../helpers/sessionHelpers.js";
|
||||
|
||||
/**
|
||||
* Resuelve `project_dir` para la tool en curso.
|
||||
@@ -38,7 +38,7 @@ export function getCurrentProjectInfo() {
|
||||
export async function callLocalFileEndpoint(method, endpoint, payload = null, query = null) {
|
||||
const headers = getLocalServerHeaders();
|
||||
const authHeader = process.env.ACAI_AUTH_HEADER || "";
|
||||
const mode = process.env.ACAI_MODE_OVERRIDE || process.env.ACAI_MODE || "";
|
||||
const mode = resolveCurrentModeOverride();
|
||||
const role = process.env.ACAI_ROLE_OVERRIDE || "";
|
||||
|
||||
if (authHeader) headers["Authorization"] = authHeader;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from "axios";
|
||||
import { resolveCurrentAcaiUser } from "./sessionHelpers.js";
|
||||
import { resolveCurrentAcaiUser, resolveCurrentModeOverride } from "./sessionHelpers.js";
|
||||
|
||||
const PYTHON_BASE = `http://app:${process.env.ACAI_PORT || 9091}`;
|
||||
|
||||
@@ -11,7 +11,7 @@ const PYTHON_BASE = `http://app:${process.env.ACAI_PORT || 9091}`;
|
||||
*/
|
||||
function buildPythonHeaders(extra = {}) {
|
||||
const authHeader = process.env.ACAI_AUTH_HEADER || "";
|
||||
const mode = process.env.ACAI_MODE_OVERRIDE || process.env.ACAI_MODE || "";
|
||||
const mode = resolveCurrentModeOverride();
|
||||
const role = process.env.ACAI_ROLE_OVERRIDE || "";
|
||||
const acaiUser = resolveCurrentAcaiUser();
|
||||
|
||||
@@ -43,3 +43,20 @@ export async function pythonGet(path, params = null, timeout = 30000) {
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET binario al server Python (p.ej. /api/image-bytes). Devuelve
|
||||
* { buffer: Buffer, mimeType: string }. Lanza si el status no es 2xx.
|
||||
*/
|
||||
export async function pythonGetBinary(path, params = null, timeout = 30000) {
|
||||
const response = await axios.get(`${PYTHON_BASE}${path}`, {
|
||||
params: params || undefined,
|
||||
headers: buildPythonHeaders(),
|
||||
responseType: "arraybuffer",
|
||||
timeout,
|
||||
maxContentLength: Infinity,
|
||||
});
|
||||
const mimeType = (response.headers?.["content-type"] || "").split(";")[0].trim()
|
||||
|| "application/octet-stream";
|
||||
return { buffer: Buffer.from(response.data), mimeType };
|
||||
}
|
||||
|
||||
@@ -25,3 +25,23 @@ export function resolveCurrentAcaiUser() {
|
||||
const creds = getMcpSessionCredentials(sessionId);
|
||||
return creds?.acai_user || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modo efectivo (X-Acai-Mode) para las llamadas al server Python.
|
||||
*
|
||||
* Regla de seguridad: una sesion MCP HTTP (mcpSessionId presente) es SIEMPRE un
|
||||
* cliente externo — en la practica el plugin VS Code Acai Forge — y solo puede
|
||||
* operar sobre TEST. Por eso forzamos "local" pase lo que pase el .acai del
|
||||
* proyecto. El server Python honra este header para decidir el destino real
|
||||
* (BD y ficheros), de modo que vscode nunca toca produccion.
|
||||
*
|
||||
* Las sesiones stdio (chat del dashboard / cronjobs) NO tienen mcpSessionId:
|
||||
* mantienen el override de entorno (ACAI_MODE_OVERRIDE), que puede ser
|
||||
* "production" cuando corresponde (chat en modo produccion, cron de prod).
|
||||
*
|
||||
* @returns {string} "local" | "production" | "" (vacio = usar .acai)
|
||||
*/
|
||||
export function resolveCurrentModeOverride() {
|
||||
if (getCurrentSessionId()) return "local";
|
||||
return process.env.ACAI_MODE_OVERRIDE || process.env.ACAI_MODE || "";
|
||||
}
|
||||
|
||||
@@ -1,13 +1,38 @@
|
||||
import { z } from "zod";
|
||||
import axios from "axios";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { withAuth } from "../../auth/index.js";
|
||||
import { handleToolError } from "../helpers/errorHandler.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
import { resolveCurrentProjectDir } from "../files/helpers.js";
|
||||
import { pythonGetBinary } from "../helpers/pythonServerClient.js";
|
||||
|
||||
const GEMINI_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent";
|
||||
const CHAT_UPLOADS_DIR = "/opt/acai/chat-uploads";
|
||||
// Hosts locales NO alcanzables por HTTP desde este container (localhost = el
|
||||
// propio agentic). Para esas refs y rutas del proyecto, los bytes los resuelve
|
||||
// el server Python (/api/image-bytes), que decide disco (standalone) vs fetch de
|
||||
// producción (Acai / stub local).
|
||||
const LOCAL_HOSTS = ["localhost", "127.0.0.1", "acai-app", "acai-web", "web", "host.docker.internal"];
|
||||
// Dominio forge del entorno (forge.acai.test en local, forge.acaisuite.com en
|
||||
// prod). Los hosts forge resuelven (dnsmasq) a 127.0.0.1, que dentro de este
|
||||
// container es él mismo → un fetch directo da ECONNREFUSED, sea http o https.
|
||||
// Por eso NO son "remotos": sus bytes los resuelve el server Python
|
||||
// (/api/image-bytes), que para un proyecto Acai trae el stub desde producción.
|
||||
// Env-driven, igual que is_local_project_host en Python.
|
||||
const FORGE_DOMAIN = (process.env.ACAI_FORGE_DOMAIN || "forge.acaisuite.com").toLowerCase();
|
||||
|
||||
/**
|
||||
* ¿Es un host que debe resolverse vía el server Python (no alcanzable / no
|
||||
* conviene un fetch directo desde este container)? Cubre loopback/hosts Docker
|
||||
* internos y el dominio forge del entorno.
|
||||
*/
|
||||
function isLocalResolvableHost(hostname) {
|
||||
if (!hostname) return false;
|
||||
const h = hostname.toLowerCase();
|
||||
if (LOCAL_HOSTS.includes(h)) return true;
|
||||
if (FORGE_DOMAIN && (h === FORGE_DOMAIN || h.endsWith("." + FORGE_DOMAIN))) return true;
|
||||
return false;
|
||||
}
|
||||
const DEFAULT_PROMPT = "Describe esta imagen detalladamente, mencionando elementos visuales, texto, layout y proposito aparente.";
|
||||
|
||||
/**
|
||||
@@ -38,56 +63,20 @@ function detectMimeType(filename, buffer) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resuelve una URL de chat-preview a una ruta local segura dentro de CHAT_UPLOADS_DIR.
|
||||
* Acepta `/api/chat-preview?file=xxx` o variantes con host.
|
||||
*/
|
||||
function resolveChatPreviewPath(imageUrl) {
|
||||
let qs;
|
||||
try {
|
||||
// Permite tanto absolutas como relativas
|
||||
const u = imageUrl.startsWith("http")
|
||||
? new URL(imageUrl)
|
||||
: new URL(imageUrl, "http://placeholder.local");
|
||||
if (!u.pathname.startsWith("/api/chat-preview")) return null;
|
||||
qs = u.searchParams;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileParam = qs.get("file");
|
||||
if (!fileParam) return null;
|
||||
|
||||
// Sanitizar contra traversal PRESERVANDO el subdirectorio de usuario
|
||||
// (el file= es "<username>/<archivo>"; basename lo perdía → not found).
|
||||
if (fileParam.includes("..") || fileParam.startsWith("/") || fileParam.includes("\\")) return null;
|
||||
|
||||
const fullPath = path.join(CHAT_UPLOADS_DIR, fileParam);
|
||||
// Asegurar que queda dentro de CHAT_UPLOADS_DIR.
|
||||
const base = path.resolve(CHAT_UPLOADS_DIR);
|
||||
if (!path.resolve(fullPath).startsWith(base + path.sep)) return null;
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carga la imagen como { mimeType, base64 } desde URL publica o chat-preview local.
|
||||
* Carga la imagen como { mimeType, base64 }.
|
||||
* - URL remota real (host público) → fetch directo por HTTP.
|
||||
* - Adjunto de chat, ruta del proyecto, o URL con host local → los bytes los
|
||||
* resuelve el server Python (/api/image-bytes): disco para standalone, fetch
|
||||
* de producción para imágenes Acai cuyo fichero local es un stub.
|
||||
*/
|
||||
async function loadImage(imageUrl) {
|
||||
// Caso 1: chat-preview local
|
||||
const localPath = resolveChatPreviewPath(imageUrl);
|
||||
if (localPath) {
|
||||
if (!fs.existsSync(localPath)) {
|
||||
throw new Error(`Local chat upload not found: ${path.basename(localPath)}`);
|
||||
}
|
||||
const buffer = fs.readFileSync(localPath);
|
||||
return {
|
||||
mimeType: detectMimeType(localPath, buffer),
|
||||
base64: buffer.toString("base64"),
|
||||
};
|
||||
}
|
||||
let parsed = null;
|
||||
try { parsed = new URL(imageUrl); } catch { parsed = null; }
|
||||
const isRemote = parsed
|
||||
&& (parsed.protocol === "http:" || parsed.protocol === "https:")
|
||||
&& parsed.hostname && !isLocalResolvableHost(parsed.hostname);
|
||||
|
||||
// Caso 2: URL publica http(s)
|
||||
if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
|
||||
if (isRemote) {
|
||||
const response = await axios.get(imageUrl, {
|
||||
responseType: "arraybuffer",
|
||||
timeout: 30000,
|
||||
@@ -98,19 +87,21 @@ async function loadImage(imageUrl) {
|
||||
const mimeType = headerMime && headerMime.startsWith("image/")
|
||||
? headerMime
|
||||
: detectMimeType(imageUrl.split("?")[0], buffer);
|
||||
return {
|
||||
mimeType,
|
||||
base64: buffer.toString("base64"),
|
||||
};
|
||||
return { mimeType, base64: buffer.toString("base64") };
|
||||
}
|
||||
|
||||
throw new Error("Unsupported image_url. Use http(s):// or /api/chat-preview?file=...");
|
||||
const project = path.basename(resolveCurrentProjectDir() || "");
|
||||
if (!project) {
|
||||
throw new Error("No hay proyecto activo para resolver la imagen.");
|
||||
}
|
||||
const { buffer, mimeType } = await pythonGetBinary("/api/image-bytes", { project, ref: imageUrl });
|
||||
return { mimeType, base64: buffer.toString("base64") };
|
||||
}
|
||||
|
||||
export function registerAnalyzeImageTool(server) {
|
||||
server.tool(
|
||||
"analyze_image",
|
||||
"Analiza una imagen usando Gemini Vision. Util cuando el usuario adjunta una imagen, despues de un screenshot de Playwright, o para describir cualquier imagen accesible via URL. Devuelve descripcion text del contenido visual.",
|
||||
"Analiza una imagen usando Gemini Vision. Usala SOLO para imagenes que NO puedes ver directamente (p.ej. una URL/imagen del CMS que no esta adjunta a la conversacion, o un screenshot de Playwright). Si la imagen ya esta adjunta y visible en el mensaje del usuario, descríbela tú mismo SIN llamar a esta tool. Devuelve descripcion text del contenido visual.",
|
||||
withAuthParams({
|
||||
image_url: z.string().describe("URL de la imagen. Acepta URL publica http(s):// o ruta relativa /api/chat-preview?file=..."),
|
||||
prompt: z.string().optional().describe("Que quieres saber de la imagen. Default: descripcion detallada."),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
||||
import { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
|
||||
import { withAuthParams } from "../helpers/authSchema.js";
|
||||
import { AcaiHttpClient } from "../helpers/acaiHttpClient.js";
|
||||
import { pythonPost } from "../helpers/pythonServerClient.js";
|
||||
import { pythonPost, pythonGetBinary } from "../helpers/pythonServerClient.js";
|
||||
import { resolveCurrentProjectDir } from "../files/helpers.js";
|
||||
|
||||
/**
|
||||
@@ -34,68 +34,32 @@ async function mcpPost(target, actionWs, payload, token, tokenHash) {
|
||||
* null si la URL no es local (usar imageUrl directamente)
|
||||
*/
|
||||
async function resolveLocalImageAsBase64(imageUrl) {
|
||||
const LOCAL_HOSTS = ["localhost", "127.0.0.1", "acai-app", "host.docker.internal"];
|
||||
const LOCAL_HOSTS = ["localhost", "127.0.0.1", "acai-app", "acai-web", "web", "host.docker.internal"];
|
||||
|
||||
// Caso 1: Path absoluto del filesystem (e.g. /opt/acai/webs/.../cms/uploads/x.jpg)
|
||||
if (typeof imageUrl === "string" && imageUrl.startsWith("/") && !imageUrl.startsWith("//")) {
|
||||
try {
|
||||
if (fs.existsSync(imageUrl) && fs.statSync(imageUrl).isFile()) {
|
||||
const buffer = fs.readFileSync(imageUrl);
|
||||
return {
|
||||
fileBase64: buffer.toString("base64"),
|
||||
fileName: path.basename(imageUrl),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[upload] Failed to read filesystem path ${imageUrl}: ${error.message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Caso 2: URL HTTP — verificar si es local
|
||||
// URL http(s) con host NO local → es una URL pública real: usar tal cual (null).
|
||||
if (typeof imageUrl === "string" && /^https?:\/\//i.test(imageUrl)) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(imageUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!LOCAL_HOSTS.includes(parsed.hostname)) {
|
||||
return null;
|
||||
try { parsed = new URL(imageUrl); } catch { return null; }
|
||||
if (!LOCAL_HOSTS.includes(parsed.hostname)) return null;
|
||||
}
|
||||
|
||||
// Intento A: descargar via HTTP (funciona cuando el host local es alcanzable)
|
||||
// Ruta del proyecto, host local o chat-preview → los bytes los resuelve el
|
||||
// server Python (/api/image-bytes): disco para standalone, fetch de producción
|
||||
// para imágenes Acai cuyo fichero local es un stub.
|
||||
const project = path.basename(resolveCurrentProjectDir() || "");
|
||||
if (!project) return null;
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
const response = await axios.get(imageUrl, {
|
||||
responseType: "arraybuffer",
|
||||
timeout: 30000,
|
||||
});
|
||||
const fileBase64 = Buffer.from(response.data).toString("base64");
|
||||
const pathname = parsed.pathname || "/image.jpg";
|
||||
const fileName = pathname.split("/").pop() || "image.jpg";
|
||||
return { fileBase64, fileName };
|
||||
} catch (httpError) {
|
||||
console.error(`[upload] HTTP fetch failed for ${imageUrl}: ${httpError.message}. Trying filesystem fallback.`);
|
||||
}
|
||||
|
||||
// Intento B: resolver el pathname contra ACAI_PROJECT_DIR y leer del disco
|
||||
const projectDir = resolveCurrentProjectDir();
|
||||
if (projectDir && parsed.pathname) {
|
||||
const { buffer } = await pythonGetBinary("/api/image-bytes", { project, ref: imageUrl });
|
||||
let fileName = "image.jpg";
|
||||
try {
|
||||
const localPath = path.join(projectDir, parsed.pathname);
|
||||
if (fs.existsSync(localPath) && fs.statSync(localPath).isFile()) {
|
||||
const buffer = fs.readFileSync(localPath);
|
||||
return {
|
||||
fileBase64: buffer.toString("base64"),
|
||||
fileName: path.basename(localPath),
|
||||
};
|
||||
}
|
||||
const p = imageUrl.startsWith("/") ? imageUrl : new URL(imageUrl).pathname;
|
||||
fileName = (p.split("?")[0].split("/").pop()) || "image.jpg";
|
||||
} catch { /* keep default */ }
|
||||
return { fileBase64: buffer.toString("base64"), fileName };
|
||||
} catch (error) {
|
||||
console.error(`[upload] Filesystem fallback failed for ${imageUrl}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[upload] /api/image-bytes falló para ${imageUrl}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerUploadRecordImageTool(server) {
|
||||
|
||||
Reference in New Issue
Block a user