Añadido el modo producción / test
This commit is contained in:
@@ -11,7 +11,12 @@
|
|||||||
* (the SimpleAuth header that Claude sends with each request).
|
* (the SimpleAuth header that Claude sends with each request).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fetchProjectInfo } from "./localClient.js";
|
||||||
|
|
||||||
const DEFAULT_ROLE = 'developer';
|
const DEFAULT_ROLE = 'developer';
|
||||||
|
const FORGE_INTERNAL_URL = process.env.ACAI_FORGE_WEB_URL || "http://web:80";
|
||||||
|
|
||||||
|
|
||||||
// Session-based credentials storage (ephemeral, per-session)
|
// Session-based credentials storage (ephemeral, per-session)
|
||||||
@@ -46,6 +51,102 @@ const cleanupExpiredMcpSessions = () => {
|
|||||||
// Run cleanup every 5 minutes
|
// Run cleanup every 5 minutes
|
||||||
setInterval(cleanupExpiredMcpSessions, 5 * 60 * 1000);
|
setInterval(cleanupExpiredMcpSessions, 5 * 60 * 1000);
|
||||||
|
|
||||||
|
const buildApiUrlFromPublicUrl = (webUrl, explicitForgeHost = "") => {
|
||||||
|
if (!webUrl) return null;
|
||||||
|
if (explicitForgeHost) return FORGE_INTERNAL_URL;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(webUrl);
|
||||||
|
if (parsed.hostname.includes(".forge.")) {
|
||||||
|
return FORGE_INTERNAL_URL;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return webUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readProjectAcaiFallback = () => {
|
||||||
|
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
||||||
|
if (!projectDir) return null;
|
||||||
|
|
||||||
|
const acaiFilePath = path.join(projectDir, ".acai");
|
||||||
|
if (!fs.existsSync(acaiFilePath)) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(fs.readFileSync(acaiFilePath, "utf-8"));
|
||||||
|
const website = data.domain || process.env.ACAI_WEBSITE || null;
|
||||||
|
let webUrl = data.local_web_url || process.env.ACAI_WEB_URL || (website ? `https://${website}` : null);
|
||||||
|
let forgeHost = data.local_forge_host || process.env.ACAI_FORGE_HOST || null;
|
||||||
|
// Respeta local_api_web_url del .acai si esta presente (override del usuario);
|
||||||
|
// si no, fallback al calculo automatico (web:80 para forge, web_url para Docker).
|
||||||
|
let apiWebUrl = data.local_api_web_url || process.env.ACAI_API_WEB_URL || buildApiUrlFromPublicUrl(webUrl, forgeHost);
|
||||||
|
let mode = data.mode || process.env.ACAI_MODE || "local";
|
||||||
|
|
||||||
|
// Override de entorno (inyectado por cronjobs via mcp_env)
|
||||||
|
if (process.env.ACAI_MODE_OVERRIDE) {
|
||||||
|
mode = process.env.ACAI_MODE_OVERRIDE;
|
||||||
|
if (process.env.ACAI_WEB_URL_OVERRIDE) webUrl = process.env.ACAI_WEB_URL_OVERRIDE;
|
||||||
|
if (process.env.ACAI_API_WEB_URL_OVERRIDE) apiWebUrl = process.env.ACAI_API_WEB_URL_OVERRIDE;
|
||||||
|
if (process.env.ACAI_FORGE_HOST_OVERRIDE !== undefined) forgeHost = process.env.ACAI_FORGE_HOST_OVERRIDE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: data.token || process.env.ACAI_TOKEN || null,
|
||||||
|
website,
|
||||||
|
web_url: webUrl,
|
||||||
|
api_web_url: apiWebUrl,
|
||||||
|
forge_host: forgeHost,
|
||||||
|
tokenHash: data.tokenHash || process.env.ACAI_TOKEN_HASH || null,
|
||||||
|
mode,
|
||||||
|
profileName: "acai-file",
|
||||||
|
role: DEFAULT_ROLE,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Credentials] Failed to read .acai fallback: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveLocalProjectFallback = async () => {
|
||||||
|
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
||||||
|
|
||||||
|
if (projectDir) {
|
||||||
|
try {
|
||||||
|
const info = await fetchProjectInfo({ project_dir: projectDir });
|
||||||
|
if (info?.success) {
|
||||||
|
let webUrl = info.web_url || process.env.ACAI_WEB_URL || null;
|
||||||
|
let apiWebUrl = info.api_web_url || buildApiUrlFromPublicUrl(info.web_url, info.forge_host) || null;
|
||||||
|
let forgeHost = info.forge_host || process.env.ACAI_FORGE_HOST || null;
|
||||||
|
let mode = info.mode || process.env.ACAI_MODE || "local";
|
||||||
|
|
||||||
|
// Override de entorno (inyectado por cronjobs via mcp_env)
|
||||||
|
if (process.env.ACAI_MODE_OVERRIDE) {
|
||||||
|
mode = process.env.ACAI_MODE_OVERRIDE;
|
||||||
|
if (process.env.ACAI_WEB_URL_OVERRIDE) webUrl = process.env.ACAI_WEB_URL_OVERRIDE;
|
||||||
|
if (process.env.ACAI_API_WEB_URL_OVERRIDE) apiWebUrl = process.env.ACAI_API_WEB_URL_OVERRIDE;
|
||||||
|
if (process.env.ACAI_FORGE_HOST_OVERRIDE !== undefined) forgeHost = process.env.ACAI_FORGE_HOST_OVERRIDE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: info.token || process.env.ACAI_TOKEN || null,
|
||||||
|
website: info.domain || process.env.ACAI_WEBSITE || null,
|
||||||
|
web_url: webUrl,
|
||||||
|
api_web_url: apiWebUrl,
|
||||||
|
forge_host: forgeHost,
|
||||||
|
tokenHash: info.tokenHash || process.env.ACAI_TOKEN_HASH || null,
|
||||||
|
mode,
|
||||||
|
profileName: "project-info",
|
||||||
|
role: DEFAULT_ROLE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Credentials] project-info fallback failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return readProjectAcaiFallback();
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get credentials by MCP-Session-Id
|
* Get credentials by MCP-Session-Id
|
||||||
*/
|
*/
|
||||||
@@ -132,10 +233,38 @@ export const getSessionCredentials = async (sessionId, inlineCredentials = null)
|
|||||||
// Priority 2: Session credentials
|
// Priority 2: Session credentials
|
||||||
const sessionCreds = sessionCredentials.get(sessionId);
|
const sessionCreds = sessionCredentials.get(sessionId);
|
||||||
if (sessionCreds) {
|
if (sessionCreds) {
|
||||||
|
if (sessionCreds.token && sessionCreds.web_url && sessionCreds.api_web_url) {
|
||||||
|
console.error(`[Credentials] getSessionCredentials(${sessionId}) - FOUND: website=${sessionCreds.website}, hasToken=${!!sessionCreds.token}`);
|
||||||
|
return sessionCreds;
|
||||||
|
}
|
||||||
|
const hydrated = await resolveLocalProjectFallback();
|
||||||
|
if (hydrated) {
|
||||||
|
const merged = {
|
||||||
|
...sessionCreds,
|
||||||
|
token: sessionCreds.token || hydrated.token,
|
||||||
|
website: sessionCreds.website || hydrated.website,
|
||||||
|
web_url: sessionCreds.web_url || hydrated.web_url,
|
||||||
|
api_web_url: sessionCreds.api_web_url || hydrated.api_web_url,
|
||||||
|
forge_host: sessionCreds.forge_host || hydrated.forge_host,
|
||||||
|
tokenHash: sessionCreds.tokenHash || hydrated.tokenHash,
|
||||||
|
profileName: sessionCreds.profileName || hydrated.profileName,
|
||||||
|
role: sessionCreds.role || hydrated.role,
|
||||||
|
};
|
||||||
|
sessionCredentials.set(sessionId, merged);
|
||||||
|
console.error(`[Credentials] getSessionCredentials(${sessionId}) - HYDRATED from local fallback`);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
console.error(`[Credentials] getSessionCredentials(${sessionId}) - FOUND: website=${sessionCreds.website}, hasToken=${!!sessionCreds.token}`);
|
console.error(`[Credentials] getSessionCredentials(${sessionId}) - FOUND: website=${sessionCreds.website}, hasToken=${!!sessionCreds.token}`);
|
||||||
return sessionCreds;
|
return sessionCreds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const localFallback = await resolveLocalProjectFallback();
|
||||||
|
if (localFallback) {
|
||||||
|
sessionCredentials.set(sessionId, localFallback);
|
||||||
|
console.error(`[Credentials] getSessionCredentials(${sessionId}) - USING local project fallback`);
|
||||||
|
return localFallback;
|
||||||
|
}
|
||||||
|
|
||||||
// Priority 3: Fallback to environment variables (for backwards compatibility)
|
// Priority 3: Fallback to environment variables (for backwards compatibility)
|
||||||
console.error(`[Credentials] getSessionCredentials(${sessionId}) - NOT FOUND, using env fallback`);
|
console.error(`[Credentials] getSessionCredentials(${sessionId}) - NOT FOUND, using env fallback`);
|
||||||
console.error(`[Credentials] Active sessions: [${Array.from(sessionCredentials.keys()).join(', ')}]`);
|
console.error(`[Credentials] Active sessions: [${Array.from(sessionCredentials.keys()).join(', ')}]`);
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { LOCAL_SERVER_URL } from "../config/index.js";
|
import { LOCAL_SERVER_URL, getLocalServerHeaders } from "../config/index.js";
|
||||||
|
|
||||||
export async function fetchProjectInfo(projectName) {
|
export async function fetchProjectInfo(projectName) {
|
||||||
const response = await axios.get(`${LOCAL_SERVER_URL}/api/mcp/project-info`, {
|
const params = typeof projectName === "string" ? { project: projectName } : (projectName || {});
|
||||||
params: { project: projectName }
|
const response = await axios.get(`${LOCAL_SERVER_URL}/api/project-info`, {
|
||||||
|
params,
|
||||||
|
headers: getLocalServerHeaders(),
|
||||||
});
|
});
|
||||||
return response.data; // { success, web_url, token, tokenHash, domain, project_dir }
|
return response.data; // { success, web_url, token, tokenHash, domain, project_dir }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchProjectsList() {
|
export async function fetchProjectsList() {
|
||||||
const response = await axios.get(`${LOCAL_SERVER_URL}/api/mcp/projects`);
|
const response = await axios.get(`${LOCAL_SERVER_URL}/api/mcp/projects`, {
|
||||||
|
headers: getLocalServerHeaders(),
|
||||||
|
});
|
||||||
return response.data; // { success, projects: [...] }
|
return response.data; // { success, projects: [...] }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ const resolveProjectCredentials = async (projectName) => {
|
|||||||
web_url: info.web_url,
|
web_url: info.web_url,
|
||||||
api_web_url: info.api_web_url || info.web_url,
|
api_web_url: info.api_web_url || info.web_url,
|
||||||
forge_host: info.forge_host || null,
|
forge_host: info.forge_host || null,
|
||||||
|
mode: info.mode || "local",
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Failed to resolve project '${projectName}': ${error.message}`);
|
throw new Error(`Failed to resolve project '${projectName}': ${error.message}`);
|
||||||
@@ -106,6 +107,7 @@ const configureSessionCredentials = async (sessionId, { token, tokenHash, websit
|
|||||||
web_url: projectCreds.web_url,
|
web_url: projectCreds.web_url,
|
||||||
api_web_url: projectCreds.api_web_url || projectCreds.web_url,
|
api_web_url: projectCreds.api_web_url || projectCreds.web_url,
|
||||||
forge_host: projectCreds.forge_host || null,
|
forge_host: projectCreds.forge_host || null,
|
||||||
|
mode: projectCreds.mode || "local",
|
||||||
profileName: 'project-' + projectName,
|
profileName: 'project-' + projectName,
|
||||||
role: 'developer',
|
role: 'developer',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,17 +2,16 @@
|
|||||||
* Acai Code MCP Server - Stdio Entry Point
|
* Acai Code MCP Server - Stdio Entry Point
|
||||||
*
|
*
|
||||||
* Used when Claude Code launches the MCP server directly via .mcp.json.
|
* Used when Claude Code launches the MCP server directly via .mcp.json.
|
||||||
* Reads credentials from .acai file on each tool call (auto-refresh on token renewal).
|
* Reads credentials from the local Python server on each tool call.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
import { createMcpServer } from "./server.js";
|
import { createMcpServer } from "./server.js";
|
||||||
import { registerPrompts } from "./prompts/index.js";
|
import { registerPrompts } from "./prompts/index.js";
|
||||||
import { registerTools } from "./tools/index.js";
|
import { registerTools } from "./tools/index.js";
|
||||||
import { registerResources } from "./resources/index.js";
|
import { registerResources } from "./resources/index.js";
|
||||||
import { sessionCredentials } from "./auth/credentials.js";
|
import { sessionCredentials } from "./auth/credentials.js";
|
||||||
|
import { fetchProjectInfo } from "./auth/localClient.js";
|
||||||
|
|
||||||
// Create server instance
|
// Create server instance
|
||||||
const server = createMcpServer();
|
const server = createMcpServer();
|
||||||
@@ -20,75 +19,71 @@ registerPrompts(server);
|
|||||||
registerTools(server);
|
registerTools(server);
|
||||||
registerResources(server);
|
registerResources(server);
|
||||||
|
|
||||||
// Static env vars (web_url and website don't change, token does)
|
|
||||||
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
||||||
const acaiFilePath = projectDir ? path.join(projectDir, ".acai") : "";
|
|
||||||
|
|
||||||
// Read .acai once at startup for URL fallbacks
|
// Aplica vars de override de entorno (usado por cronjobs para forzar el
|
||||||
let acaiFileData = {};
|
// entorno objetivo sin tocar el .acai del proyecto). Modifica creds in-place.
|
||||||
if (acaiFilePath) {
|
function applyEnvironmentOverride(creds) {
|
||||||
try {
|
const modeOverride = process.env.ACAI_MODE_OVERRIDE;
|
||||||
acaiFileData = JSON.parse(fs.readFileSync(acaiFilePath, "utf-8"));
|
if (!modeOverride) return creds;
|
||||||
} catch { /* ignore - fall back to env vars */ }
|
creds.mode = modeOverride;
|
||||||
|
if (process.env.ACAI_WEB_URL_OVERRIDE) creds.web_url = process.env.ACAI_WEB_URL_OVERRIDE;
|
||||||
|
if (process.env.ACAI_API_WEB_URL_OVERRIDE) creds.api_web_url = process.env.ACAI_API_WEB_URL_OVERRIDE;
|
||||||
|
if (process.env.ACAI_FORGE_HOST_OVERRIDE !== undefined) {
|
||||||
|
creds.forge_host = process.env.ACAI_FORGE_HOST_OVERRIDE;
|
||||||
|
}
|
||||||
|
return creds;
|
||||||
}
|
}
|
||||||
|
|
||||||
const website = process.env.ACAI_WEBSITE || acaiFileData.domain || "";
|
async function readFreshCredentials() {
|
||||||
const webUrl = process.env.ACAI_WEB_URL || acaiFileData.local_web_url || "";
|
if (projectDir) {
|
||||||
const derivedForgeHost = (() => {
|
|
||||||
// First check .acai for explicit forge host
|
|
||||||
if (acaiFileData.local_forge_host) return acaiFileData.local_forge_host;
|
|
||||||
if (!webUrl) return "";
|
|
||||||
try {
|
|
||||||
const parsed = new URL(webUrl);
|
|
||||||
return parsed.hostname.includes("forge.acaisuite.com") ? parsed.host : "";
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
const apiWebUrl = process.env.ACAI_API_WEB_URL || (derivedForgeHost ? "http://web:80/" : webUrl);
|
|
||||||
const forgeHost = process.env.ACAI_FORGE_HOST || derivedForgeHost;
|
|
||||||
|
|
||||||
// Read fresh credentials from .acai file
|
|
||||||
function readFreshCredentials() {
|
|
||||||
let token = process.env.ACAI_TOKEN || "";
|
|
||||||
let tokenHash = process.env.ACAI_TOKEN_HASH || "";
|
|
||||||
|
|
||||||
// If .acai file exists, read fresh token from disk (renewed by Python server)
|
|
||||||
if (acaiFilePath) {
|
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(fs.readFileSync(acaiFilePath, "utf-8"));
|
const data = await fetchProjectInfo({ project_dir: projectDir });
|
||||||
if (data.token) token = data.token;
|
if (data?.success) {
|
||||||
if (data.tokenHash) tokenHash = data.tokenHash;
|
return applyEnvironmentOverride({
|
||||||
} catch {
|
token: data.token || "",
|
||||||
// Fall back to env vars if .acai can't be read
|
tokenHash: data.tokenHash || "",
|
||||||
|
website: data.domain || "",
|
||||||
|
web_url: data.web_url || "",
|
||||||
|
api_web_url: data.api_web_url || data.web_url || "",
|
||||||
|
forge_host: data.forge_host || "",
|
||||||
|
mode: data.mode || "local",
|
||||||
|
profileName: "stdio",
|
||||||
|
role: "developer",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[MCP stdio] Failed to resolve project-info: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return applyEnvironmentOverride({
|
||||||
token,
|
token: process.env.ACAI_TOKEN || "",
|
||||||
tokenHash,
|
tokenHash: process.env.ACAI_TOKEN_HASH || "",
|
||||||
website,
|
website: process.env.ACAI_WEBSITE || "",
|
||||||
web_url: webUrl,
|
web_url: process.env.ACAI_WEB_URL || "",
|
||||||
api_web_url: apiWebUrl,
|
api_web_url: process.env.ACAI_API_WEB_URL || "",
|
||||||
forge_host: forgeHost,
|
forge_host: process.env.ACAI_FORGE_HOST || "",
|
||||||
profileName: "stdio",
|
mode: process.env.ACAI_MODE || "local",
|
||||||
|
profileName: "stdio-fallback",
|
||||||
role: "developer",
|
role: "developer",
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!webUrl) {
|
const initialCreds = await readFreshCredentials();
|
||||||
|
|
||||||
|
if (!initialCreds.web_url) {
|
||||||
console.error("[MCP stdio] WARNING: No ACAI_WEB_URL in environment. Tools will fail.");
|
console.error("[MCP stdio] WARNING: No ACAI_WEB_URL in environment. Tools will fail.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set initial credentials
|
// Set initial credentials
|
||||||
sessionCredentials.set("_default", readFreshCredentials());
|
sessionCredentials.set("_default", initialCreds);
|
||||||
|
|
||||||
// Intercept tool calls to refresh credentials from .acai before each call
|
// Intercept tool calls to refresh credentials from the Python server before each call
|
||||||
const _origSetHandler = server.server.setRequestHandler;
|
const _origSetHandler = server.server.setRequestHandler;
|
||||||
server.server.setRequestHandler = (schema, handler) => {
|
server.server.setRequestHandler = (schema, handler) => {
|
||||||
return _origSetHandler.call(server.server, schema, async (request, extra) => {
|
return _origSetHandler.call(server.server, schema, async (request, extra) => {
|
||||||
// Re-read .acai on every tool call to pick up renewed tokens
|
const freshCreds = await readFreshCredentials();
|
||||||
const freshCreds = readFreshCredentials();
|
|
||||||
sessionCredentials.set("_default", freshCreds);
|
sessionCredentials.set("_default", freshCreds);
|
||||||
if (extra?.sessionId) {
|
if (extra?.sessionId) {
|
||||||
sessionCredentials.set(extra.sessionId, freshCreds);
|
sessionCredentials.set(extra.sessionId, freshCreds);
|
||||||
@@ -100,4 +95,4 @@ server.server.setRequestHandler = (schema, handler) => {
|
|||||||
// Connect via stdio transport
|
// Connect via stdio transport
|
||||||
const transport = new StdioServerTransport();
|
const transport = new StdioServerTransport();
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
console.error(`[MCP stdio] Connected — ${website} → ${webUrl} (project: ${projectDir})`);
|
console.error(`[MCP stdio] Connected — ${initialCreds.website} → ${initialCreds.web_url} (project: ${projectDir})`);
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import axios from "axios";
|
|
||||||
import { sessionCredentials } from "../../auth/credentials.js";
|
import { sessionCredentials } from "../../auth/credentials.js";
|
||||||
import { withAuthParams } from "../helpers/authSchema.js";
|
import { withAuthParams } from "../helpers/authSchema.js";
|
||||||
|
import { fetchProjectInfo } from "../../auth/localClient.js";
|
||||||
const LOCAL_SERVER_URL = `http://localhost:${process.env.ACAI_HOST_PORT || 29871}`;
|
|
||||||
|
|
||||||
export function registerAuthTools(server) {
|
export function registerAuthTools(server) {
|
||||||
server.tool(
|
server.tool(
|
||||||
@@ -54,14 +52,10 @@ export function registerAuthTools(server) {
|
|||||||
// Step 3: If expired, ask Python server to refresh it
|
// Step 3: If expired, ask Python server to refresh it
|
||||||
if (isExpired) {
|
if (isExpired) {
|
||||||
try {
|
try {
|
||||||
// Call the compile-module endpoint pattern — but we need a refresh endpoint
|
const info = await fetchProjectInfo({ project_dir: projectDir });
|
||||||
// Use the server's existing auto-refresh: just call any endpoint that triggers refresh
|
token = info?.token || token;
|
||||||
// The simplest: GET /api/projects which auto-refreshes expired tokens
|
tokenHash = info?.tokenHash || tokenHash;
|
||||||
const res = await axios.get(`${LOCAL_SERVER_URL}/api/projects`, { timeout: 15000 });
|
domain = info?.domain || domain;
|
||||||
// Re-read .acai after server refreshed it
|
|
||||||
const data = JSON.parse(fs.readFileSync(acaiFilePath, "utf-8"));
|
|
||||||
token = data.token || "";
|
|
||||||
tokenHash = data.tokenHash || "";
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: JSON.stringify({ success: false, error: `Token refresh failed: ${e.message}` }) }],
|
content: [{ type: "text", text: JSON.stringify({ success: false, error: `Token refresh failed: ${e.message}` }) }],
|
||||||
@@ -70,14 +64,18 @@ export function registerAuthTools(server) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Update credentials in memory
|
// Step 4: Update credentials in memory from the canonical server resolver
|
||||||
const webUrl = process.env.ACAI_WEB_URL || "";
|
const info = await fetchProjectInfo({ project_dir: projectDir });
|
||||||
const website = domain || process.env.ACAI_WEBSITE || "";
|
const webUrl = info?.web_url || process.env.ACAI_WEB_URL || "";
|
||||||
|
const apiWebUrl = info?.api_web_url || process.env.ACAI_API_WEB_URL || webUrl;
|
||||||
|
const website = info?.domain || domain || process.env.ACAI_WEBSITE || "";
|
||||||
const freshCreds = {
|
const freshCreds = {
|
||||||
token,
|
token,
|
||||||
tokenHash,
|
tokenHash,
|
||||||
website,
|
website,
|
||||||
web_url: webUrl,
|
web_url: webUrl,
|
||||||
|
api_web_url: apiWebUrl,
|
||||||
|
forge_host: info?.forge_host || null,
|
||||||
profileName: "stdio",
|
profileName: "stdio",
|
||||||
role: "developer",
|
role: "developer",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import path from 'path';
|
|||||||
* Check if the current user has write access to a table.
|
* Check if the current user has write access to a table.
|
||||||
* Reads .acai file from ACAI_PROJECT_DIR.
|
* Reads .acai file from ACAI_PROJECT_DIR.
|
||||||
* Returns { allowed: true } or { allowed: false, error: "..." }
|
* Returns { allowed: true } or { allowed: false, error: "..." }
|
||||||
|
*
|
||||||
|
* NOTA: Esta funcion NO depende del campo `mode`. En modo produccion los
|
||||||
|
* registros de tablas (contenido CMS) se siguen pudiendo editar — son los
|
||||||
|
* datos reales del usuario, no codigo. El bloqueo de produccion solo aplica
|
||||||
|
* a escritura de archivos de codigo (gestionado por is_project_admin en el
|
||||||
|
* server Python al recibir POST /api/files/*).
|
||||||
|
*
|
||||||
|
* Si existe ACAI_MODE_OVERRIDE en el entorno (cronjob con override), se usa
|
||||||
|
* en lugar del .acai para determinar el modo.
|
||||||
*/
|
*/
|
||||||
export function canAccessTable(tableName) {
|
export function canAccessTable(tableName) {
|
||||||
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
const projectDir = process.env.ACAI_PROJECT_DIR || "";
|
||||||
@@ -14,6 +23,10 @@ export function canAccessTable(tableName) {
|
|||||||
try {
|
try {
|
||||||
if (!fs.existsSync(acaiFile)) return { allowed: true };
|
if (!fs.existsSync(acaiFile)) return { allowed: true };
|
||||||
const data = JSON.parse(fs.readFileSync(acaiFile, "utf-8"));
|
const data = JSON.parse(fs.readFileSync(acaiFile, "utf-8"));
|
||||||
|
// Override de modo (cronjobs lo inyectan via env var)
|
||||||
|
if (process.env.ACAI_MODE_OVERRIDE) {
|
||||||
|
data.mode = process.env.ACAI_MODE_OVERRIDE;
|
||||||
|
}
|
||||||
const user = data.user || {};
|
const user = data.user || {};
|
||||||
|
|
||||||
// Admin has full access
|
// Admin has full access
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export function registerUploadImageToAssetsTool(server) {
|
|||||||
|
|
||||||
// Upload using saveFileBuilder
|
// Upload using saveFileBuilder
|
||||||
const uploadResult = await saveFileBuilder({
|
const uploadResult = await saveFileBuilder({
|
||||||
web_url: credentials.web_url,
|
web_url: credentials.api_web_url || credentials.web_url,
|
||||||
token: credentials.token,
|
token: credentials.token,
|
||||||
tokenHash: credentials.tokenHash,
|
tokenHash: credentials.tokenHash,
|
||||||
path: assetsPath,
|
path: assetsPath,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { withAuth, getSessionCredentials } from "../../auth/index.js";
|
import { getSessionCredentials } 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";
|
||||||
|
|
||||||
@@ -9,9 +9,10 @@ export function registerGetWebUrlTool(server) {
|
|||||||
`Get the correct URL for the project's development website. Always use this URL for fetch, Playwright, or any HTTP request to the site. Never guess or use production domains.`,
|
`Get the correct URL for the project's development website. Always use this URL for fetch, Playwright, or any HTTP request to the site. Never guess or use production domains.`,
|
||||||
withAuthParams({}),
|
withAuthParams({}),
|
||||||
{ readOnlyHint: true, destructiveHint: false },
|
{ readOnlyHint: true, destructiveHint: false },
|
||||||
withAuth(async (_params, extra) => {
|
async (_params, extra) => {
|
||||||
try {
|
try {
|
||||||
const credentials = await getSessionCredentials(extra.sessionId);
|
const sessionId = extra?.sessionId || "_default";
|
||||||
|
const credentials = await getSessionCredentials(sessionId);
|
||||||
|
|
||||||
if (!credentials || !credentials.web_url) {
|
if (!credentials || !credentials.web_url) {
|
||||||
return {
|
return {
|
||||||
@@ -20,17 +21,11 @@ export function registerGetWebUrlTool(server) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inside Docker, HTTPS is not available — force HTTP for internal requests
|
|
||||||
let webUrl = credentials.web_url;
|
|
||||||
if (webUrl && webUrl.startsWith("https://") && webUrl.includes(".forge.")) {
|
|
||||||
webUrl = webUrl.replace("https://", "http://");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{
|
content: [{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify({
|
text: JSON.stringify({
|
||||||
web_url: webUrl,
|
web_url: credentials.web_url,
|
||||||
api_web_url: credentials.api_web_url || null,
|
api_web_url: credentials.api_web_url || null,
|
||||||
website: credentials.website || null,
|
website: credentials.website || null,
|
||||||
note: "Always use web_url for Playwright/fetch. IMPORTANT: Always append ?pruebas=1 to any URL you visit (e.g. web_url + '/?pruebas=1' or web_url + '/servicios/?pruebas=1'). Never use the production domain directly.",
|
note: "Always use web_url for Playwright/fetch. IMPORTANT: Always append ?pruebas=1 to any URL you visit (e.g. web_url + '/?pruebas=1' or web_url + '/servicios/?pruebas=1'). Never use the production domain directly.",
|
||||||
@@ -40,6 +35,6 @@ export function registerGetWebUrlTool(server) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleToolError(error, "get_web_url", {});
|
return handleToolError(error, "get_web_url", {});
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ function parseUrl(url, fieldName, context) {
|
|||||||
export function assertSafeCmsTarget(target, context = "cms") {
|
export function assertSafeCmsTarget(target, context = "cms") {
|
||||||
const publicUrl = typeof target === "string" ? target : (target?.web_url || "");
|
const publicUrl = typeof target === "string" ? target : (target?.web_url || "");
|
||||||
const apiUrl = typeof target === "string" ? target : (target?.api_web_url || "");
|
const apiUrl = typeof target === "string" ? target : (target?.api_web_url || "");
|
||||||
|
const mode = typeof target === "string" ? "local" : (target?.mode || "local");
|
||||||
|
|
||||||
if (!apiUrl) {
|
if (!apiUrl) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -19,12 +20,6 @@ export function assertSafeCmsTarget(target, context = "cms") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parsedApiUrl = parseUrl(apiUrl, "ACAI_API_WEB_URL", context);
|
const parsedApiUrl = parseUrl(apiUrl, "ACAI_API_WEB_URL", context);
|
||||||
if (!SAFE_INTERNAL_HOSTS.has(parsedApiUrl.hostname)) {
|
|
||||||
throw new Error(
|
|
||||||
`[${context}] Unsafe ACAI_API_WEB_URL host "${parsedApiUrl.hostname}". ` +
|
|
||||||
`Only approved local hosts are allowed: ${Array.from(SAFE_INTERNAL_HOSTS).join(", ")}.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!["http:", "https:"].includes(parsedApiUrl.protocol)) {
|
if (!["http:", "https:"].includes(parsedApiUrl.protocol)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -32,6 +27,25 @@ export function assertSafeCmsTarget(target, context = "cms") {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Modo "production": el .acai del proyecto autoriza explicitamente apuntar
|
||||||
|
// al sitio real. Saltamos el whitelist de hosts internos. Usar SOLO para
|
||||||
|
// testing/debug controlado — el agente IA puede modificar produccion.
|
||||||
|
if (mode === "production") {
|
||||||
|
return {
|
||||||
|
publicUrl,
|
||||||
|
apiUrl,
|
||||||
|
forgeHost: typeof target === "string" ? null : (target?.forge_host || null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SAFE_INTERNAL_HOSTS.has(parsedApiUrl.hostname)) {
|
||||||
|
throw new Error(
|
||||||
|
`[${context}] Unsafe ACAI_API_WEB_URL host "${parsedApiUrl.hostname}". ` +
|
||||||
|
`Only approved local hosts are allowed: ${Array.from(SAFE_INTERNAL_HOSTS).join(", ")}. ` +
|
||||||
|
`Set "mode": "production" in .acai to bypass this check (intended for testing only).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (publicUrl) {
|
if (publicUrl) {
|
||||||
const parsedPublicUrl = parseUrl(publicUrl, "ACAI_WEB_URL", context);
|
const parsedPublicUrl = parseUrl(publicUrl, "ACAI_WEB_URL", context);
|
||||||
const publicIsSafeInternal = SAFE_INTERNAL_HOSTS.has(parsedPublicUrl.hostname);
|
const publicIsSafeInternal = SAFE_INTERNAL_HOSTS.has(parsedPublicUrl.hostname);
|
||||||
|
|||||||
Reference in New Issue
Block a user