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:
Jordan
2026-06-19 19:11:50 +01:00
parent 5883473e92
commit 5dc2dbcf4a
7 changed files with 120 additions and 118 deletions

View File

@@ -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."),

View File

@@ -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;
// 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;
}
// Caso 2: URL HTTP — verificar si es local
let parsed;
// 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 {
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)
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),
};
}
} catch (error) {
console.error(`[upload] Filesystem fallback failed for ${imageUrl}: ${error.message}`);
}
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] /api/image-bytes falló para ${imageUrl}: ${error.message}`);
return null;
}
return null;
}
export function registerUploadRecordImageTool(server) {