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

@@ -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 * automaticamente; en modo stdio no se propaga y la logica original se
* mantiene. * 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 params = typeof projectName === "string" ? { project: projectName } : (projectName || {});
const headers = getLocalServerHeaders(); const headers = getLocalServerHeaders();
if (acaiUser) headers["X-Acai-User"] = acaiUser; 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`, { const response = await axios.get(`${LOCAL_SERVER_URL}/api/project-info`, {
params, params,
headers, headers,

View File

@@ -76,7 +76,12 @@ const verifyJwt = (token) => {
const resolveProjectCredentials = async (projectName, acaiUser = null) => { const resolveProjectCredentials = async (projectName, acaiUser = null) => {
try { 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) { if (!info.success) {
throw new Error(info.error || "Failed to resolve project info"); throw new Error(info.error || "Failed to resolve project info");
} }

View File

@@ -3,7 +3,7 @@ import path from "path";
import { LOCAL_SERVER_URL, getLocalServerHeaders } from "../../config/index.js"; import { LOCAL_SERVER_URL, getLocalServerHeaders } from "../../config/index.js";
import { getCurrentSessionId } from "../../utils/sessionContext.js"; import { getCurrentSessionId } from "../../utils/sessionContext.js";
import { getMcpSessionCredentials } from "../../auth/credentials.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. * 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) { export async function callLocalFileEndpoint(method, endpoint, payload = null, query = null) {
const headers = getLocalServerHeaders(); const headers = getLocalServerHeaders();
const authHeader = process.env.ACAI_AUTH_HEADER || ""; 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 role = process.env.ACAI_ROLE_OVERRIDE || "";
if (authHeader) headers["Authorization"] = authHeader; if (authHeader) headers["Authorization"] = authHeader;

View File

@@ -1,5 +1,5 @@
import axios from "axios"; 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}`; 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 = {}) { function buildPythonHeaders(extra = {}) {
const authHeader = process.env.ACAI_AUTH_HEADER || ""; 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 role = process.env.ACAI_ROLE_OVERRIDE || "";
const acaiUser = resolveCurrentAcaiUser(); const acaiUser = resolveCurrentAcaiUser();
@@ -43,3 +43,20 @@ export async function pythonGet(path, params = null, timeout = 30000) {
}); });
return response.data; 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 };
}

View File

@@ -25,3 +25,23 @@ export function resolveCurrentAcaiUser() {
const creds = getMcpSessionCredentials(sessionId); const creds = getMcpSessionCredentials(sessionId);
return creds?.acai_user || null; 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 || "";
}

View File

@@ -1,13 +1,38 @@
import { z } from "zod"; import { z } from "zod";
import axios from "axios"; import axios from "axios";
import fs from "fs";
import path from "path"; import path from "path";
import { withAuth } from "../../auth/index.js"; import { withAuth } from "../../auth/index.js";
import { handleToolError } from "../helpers/errorHandler.js"; import { handleToolError } from "../helpers/errorHandler.js";
import { withAuthParams } from "../helpers/authSchema.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 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."; 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. * Carga la imagen como { mimeType, base64 }.
* Acepta `/api/chat-preview?file=xxx` o variantes con host. * - URL remota real (host público) → fetch directo por HTTP.
*/ * - Adjunto de chat, ruta del proyecto, o URL con host local → los bytes los
function resolveChatPreviewPath(imageUrl) { * resuelve el server Python (/api/image-bytes): disco para standalone, fetch
let qs; * de producción para imágenes Acai cuyo fichero local es un stub.
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.
*/ */
async function loadImage(imageUrl) { async function loadImage(imageUrl) {
// Caso 1: chat-preview local let parsed = null;
const localPath = resolveChatPreviewPath(imageUrl); try { parsed = new URL(imageUrl); } catch { parsed = null; }
if (localPath) { const isRemote = parsed
if (!fs.existsSync(localPath)) { && (parsed.protocol === "http:" || parsed.protocol === "https:")
throw new Error(`Local chat upload not found: ${path.basename(localPath)}`); && parsed.hostname && !isLocalResolvableHost(parsed.hostname);
}
const buffer = fs.readFileSync(localPath);
return {
mimeType: detectMimeType(localPath, buffer),
base64: buffer.toString("base64"),
};
}
// Caso 2: URL publica http(s) if (isRemote) {
if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
const response = await axios.get(imageUrl, { const response = await axios.get(imageUrl, {
responseType: "arraybuffer", responseType: "arraybuffer",
timeout: 30000, timeout: 30000,
@@ -98,19 +87,21 @@ async function loadImage(imageUrl) {
const mimeType = headerMime && headerMime.startsWith("image/") const mimeType = headerMime && headerMime.startsWith("image/")
? headerMime ? headerMime
: detectMimeType(imageUrl.split("?")[0], buffer); : detectMimeType(imageUrl.split("?")[0], buffer);
return { return { mimeType, base64: buffer.toString("base64") };
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) { export function registerAnalyzeImageTool(server) {
server.tool( server.tool(
"analyze_image", "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({ withAuthParams({
image_url: z.string().describe("URL de la imagen. Acepta URL publica http(s):// o ruta relativa /api/chat-preview?file=..."), 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."), 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 { handleToolError, validateRequired, handleApiResponse } from "../helpers/errorHandler.js";
import { withAuthParams } from "../helpers/authSchema.js"; import { withAuthParams } from "../helpers/authSchema.js";
import { AcaiHttpClient } from "../helpers/acaiHttpClient.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"; import { resolveCurrentProjectDir } from "../files/helpers.js";
/** /**
@@ -34,69 +34,33 @@ async function mcpPost(target, actionWs, payload, token, tokenHash) {
* null si la URL no es local (usar imageUrl directamente) * null si la URL no es local (usar imageUrl directamente)
*/ */
async function resolveLocalImageAsBase64(imageUrl) { 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) // URL http(s) con host NO local → es una URL pública real: usar tal cual (null).
if (typeof imageUrl === "string" && imageUrl.startsWith("/") && !imageUrl.startsWith("//")) { if (typeof imageUrl === "string" && /^https?:\/\//i.test(imageUrl)) {
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
let parsed; let parsed;
try { try { parsed = new URL(imageUrl); } catch { return null; }
parsed = new URL(imageUrl); if (!LOCAL_HOSTS.includes(parsed.hostname)) return null;
} 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 { try {
const axios = (await import("axios")).default; const { buffer } = await pythonGetBinary("/api/image-bytes", { project, ref: imageUrl });
const response = await axios.get(imageUrl, { let fileName = "image.jpg";
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) {
try { try {
const localPath = path.join(projectDir, parsed.pathname); const p = imageUrl.startsWith("/") ? imageUrl : new URL(imageUrl).pathname;
if (fs.existsSync(localPath) && fs.statSync(localPath).isFile()) { fileName = (p.split("?")[0].split("/").pop()) || "image.jpg";
const buffer = fs.readFileSync(localPath); } catch { /* keep default */ }
return { return { fileBase64: buffer.toString("base64"), fileName };
fileBase64: buffer.toString("base64"),
fileName: path.basename(localPath),
};
}
} catch (error) { } 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; return null;
} }
}
export function registerUploadRecordImageTool(server) { export function registerUploadRecordImageTool(server) {
server.tool( server.tool(