| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 'use strict';
|
|
|
| const http = require('http');
|
| const url = require('url');
|
| const fs = require('fs');
|
| const path = require('path');
|
|
|
|
|
| const FRONTEND_DIR = fs.existsSync('/home/node/frontend')
|
| ? '/home/node/frontend'
|
| : path.join(__dirname, '..', 'frontend');
|
|
|
| const MIME_TYPES = {
|
| '.html': 'text/html; charset=utf-8',
|
| '.js': 'application/javascript',
|
| '.css': 'text/css',
|
| '.json': 'application/json',
|
| '.png': 'image/png',
|
| '.jpg': 'image/jpeg',
|
| '.jpeg': 'image/jpeg',
|
| '.webp': 'image/webp',
|
| '.gif': 'image/gif',
|
| '.svg': 'image/svg+xml',
|
| '.woff2': 'font/woff2',
|
| '.woff': 'font/woff',
|
| '.ttf': 'font/ttf',
|
| '.ico': 'image/x-icon',
|
| '.mp3': 'audio/mpeg',
|
| '.ogg': 'audio/ogg',
|
| '.md': 'text/markdown; charset=utf-8',
|
| };
|
|
|
| function serveStaticFile(res, filePath) {
|
|
|
| const resolved = path.resolve(filePath);
|
| if (!resolved.startsWith(path.resolve(FRONTEND_DIR))) {
|
| res.writeHead(403);
|
| return res.end('Forbidden');
|
| }
|
| fs.readFile(resolved, (err, data) => {
|
| if (err) {
|
| res.writeHead(404, { 'Content-Type': 'text/plain' });
|
| return res.end('Not Found');
|
| }
|
| const ext = path.extname(resolved).toLowerCase();
|
| const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
| const cacheControl = (ext === '.html') ? 'no-cache' : 'public, max-age=86400';
|
| res.writeHead(200, {
|
| 'Content-Type': contentType,
|
| 'Cache-Control': cacheControl,
|
| 'Access-Control-Allow-Origin': '*'
|
| });
|
| res.end(data);
|
| });
|
| }
|
|
|
| const LISTEN_PORT = 7860;
|
| const OPENCLAW_PORT = 7861;
|
| const A2A_PORT = 18800;
|
| const AGENT_NAME = process.env.AGENT_NAME || 'Agent';
|
|
|
|
|
|
|
| const REMOTE_AGENTS_RAW = process.env.REMOTE_AGENTS || '';
|
| const remoteAgents = REMOTE_AGENTS_RAW
|
| ? REMOTE_AGENTS_RAW.split(',').map(entry => {
|
| const [id, name, baseUrl] = entry.trim().split('|');
|
| return { id, name, baseUrl };
|
| }).filter(a => a.id && a.name && a.baseUrl)
|
| : [];
|
|
|
| let currentState = {
|
| state: 'syncing',
|
| detail: `${AGENT_NAME} is starting...`,
|
| progress: 0,
|
| updated_at: new Date().toISOString()
|
| };
|
|
|
|
|
|
|
| let a2aActiveRequests = 0;
|
| let a2aIdleTimer = null;
|
| const A2A_IDLE_DELAY = 8000;
|
|
|
| function markA2AActive() {
|
| a2aActiveRequests++;
|
| if (a2aIdleTimer) { clearTimeout(a2aIdleTimer); a2aIdleTimer = null; }
|
| currentState = {
|
| state: 'writing',
|
| detail: `${AGENT_NAME} is communicating...`,
|
| progress: 100,
|
| updated_at: new Date().toISOString()
|
| };
|
| }
|
|
|
| function markA2ADone() {
|
| a2aActiveRequests = Math.max(0, a2aActiveRequests - 1);
|
| if (a2aActiveRequests === 0) {
|
| if (a2aIdleTimer) clearTimeout(a2aIdleTimer);
|
| a2aIdleTimer = setTimeout(() => {
|
| a2aIdleTimer = null;
|
| pollOpenClawHealth();
|
| }, A2A_IDLE_DELAY);
|
| }
|
| }
|
|
|
|
|
| const remoteAgentStates = new Map();
|
|
|
| async function pollRemoteAgent(agent) {
|
| try {
|
| const controller = new AbortController();
|
| const timeout = setTimeout(() => controller.abort(), 5000);
|
| const resp = await fetch(`${agent.baseUrl}/api/state`, {
|
| signal: controller.signal
|
| });
|
| clearTimeout(timeout);
|
| if (resp.ok) {
|
| const data = await resp.json();
|
| remoteAgentStates.set(agent.id, {
|
| agentId: agent.id,
|
| name: agent.name,
|
| state: data.state || 'idle',
|
| detail: data.detail || '',
|
| area: (data.state === 'idle') ? 'breakroom'
|
| : (data.state === 'error') ? 'error'
|
| : 'writing',
|
| authStatus: 'approved',
|
| updated_at: data.updated_at
|
| });
|
| }
|
| } catch (_) {
|
|
|
| if (!remoteAgentStates.has(agent.id)) {
|
| remoteAgentStates.set(agent.id, {
|
| agentId: agent.id,
|
| name: agent.name,
|
| state: 'syncing',
|
| detail: `${agent.name} is starting...`,
|
| area: 'door',
|
| authStatus: 'approved'
|
| });
|
| }
|
| }
|
| }
|
|
|
| function pollAllRemoteAgents() {
|
| for (const agent of remoteAgents) {
|
| pollRemoteAgent(agent);
|
| }
|
| }
|
|
|
| if (remoteAgents.length > 0) {
|
| setInterval(pollAllRemoteAgents, 5000);
|
| pollAllRemoteAgents();
|
| console.log(`[a2a-proxy] Monitoring ${remoteAgents.length} remote agent(s): ${remoteAgents.map(a => a.name).join(', ')}`);
|
| }
|
|
|
|
|
| async function pollOpenClawHealth() {
|
|
|
| if (a2aActiveRequests > 0 || a2aIdleTimer) return;
|
| try {
|
| const controller = new AbortController();
|
| const timeout = setTimeout(() => controller.abort(), 5000);
|
| const resp = await fetch(`http://127.0.0.1:${OPENCLAW_PORT}/`, {
|
| signal: controller.signal,
|
| redirect: 'manual'
|
| });
|
| clearTimeout(timeout);
|
| const isUp = resp.ok || resp.status === 302;
|
| currentState = {
|
| state: isUp ? 'idle' : 'error',
|
| detail: isUp ? `${AGENT_NAME} is running` : `HTTP ${resp.status}`,
|
| progress: isUp ? 100 : 0,
|
| updated_at: new Date().toISOString()
|
| };
|
| } catch (_) {
|
| currentState = {
|
| state: 'syncing',
|
| detail: `${AGENT_NAME} is starting...`,
|
| progress: 0,
|
| updated_at: new Date().toISOString()
|
| };
|
| }
|
| }
|
|
|
| setInterval(pollOpenClawHealth, 5000);
|
| pollOpenClawHealth();
|
|
|
|
|
| async function getMergedAgents() {
|
| let openClawAgents = [];
|
| try {
|
| const controller = new AbortController();
|
| const timeout = setTimeout(() => controller.abort(), 3000);
|
| const resp = await fetch(`http://127.0.0.1:${OPENCLAW_PORT}/agents`, {
|
| signal: controller.signal
|
| });
|
| clearTimeout(timeout);
|
| if (resp.ok) {
|
| openClawAgents = await resp.json();
|
| if (!Array.isArray(openClawAgents)) openClawAgents = [];
|
| }
|
| } catch (_) {}
|
|
|
|
|
| const existingIds = new Set(openClawAgents.map(a => a.agentId));
|
| const merged = [...openClawAgents];
|
| let slotIndex = openClawAgents.length;
|
| for (const [id, agentState] of remoteAgentStates) {
|
| if (!existingIds.has(id)) {
|
| merged.push({ ...agentState, _slotIndex: slotIndex++ });
|
| }
|
| }
|
| return merged;
|
| }
|
|
|
| function proxyRequest(req, res, targetPort) {
|
| const options = {
|
| hostname: '127.0.0.1',
|
| port: targetPort,
|
| path: req.url,
|
| method: req.method,
|
| headers: { ...req.headers, host: `127.0.0.1:${targetPort}` }
|
| };
|
|
|
| const proxy = http.request(options, (proxyRes) => {
|
|
|
| const headers = { ...proxyRes.headers };
|
| delete headers['x-frame-options'];
|
| if (headers['content-security-policy']) {
|
| headers['content-security-policy'] = headers['content-security-policy']
|
| .replace(/frame-ancestors\s+'none'/i, "frame-ancestors 'self' https://huggingface.co https://*.hf.space");
|
| }
|
| res.writeHead(proxyRes.statusCode, headers);
|
| proxyRes.pipe(res, { end: true });
|
| });
|
|
|
| proxy.on('error', (err) => {
|
| if (!res.headersSent) {
|
| res.writeHead(502, { 'Content-Type': 'application/json' });
|
| res.end(JSON.stringify({ error: 'Backend unavailable', target: targetPort }));
|
| }
|
| });
|
|
|
| req.pipe(proxy, { end: true });
|
| }
|
|
|
| const server = http.createServer((req, res) => {
|
| const pathname = url.parse(req.url).pathname;
|
|
|
|
|
| if (pathname.startsWith('/.well-known/') || pathname.startsWith('/a2a/')) {
|
|
|
| if (req.method === 'POST') {
|
| markA2AActive();
|
| res.on('finish', markA2ADone);
|
| }
|
| return proxyRequest(req, res, A2A_PORT);
|
| }
|
|
|
|
|
| if (pathname === '/api/state' || pathname === '/status') {
|
| res.writeHead(200, {
|
| 'Content-Type': 'application/json',
|
| 'Access-Control-Allow-Origin': '*'
|
| });
|
| return res.end(JSON.stringify({
|
| ...currentState,
|
| officeName: `${AGENT_NAME}'s Office`
|
| }));
|
| }
|
|
|
|
|
| if (pathname === '/agents' && req.method === 'GET') {
|
| getMergedAgents().then(agents => {
|
| res.writeHead(200, {
|
| 'Content-Type': 'application/json',
|
| 'Access-Control-Allow-Origin': '*'
|
| });
|
| res.end(JSON.stringify(agents));
|
| }).catch(() => {
|
|
|
| res.writeHead(200, {
|
| 'Content-Type': 'application/json',
|
| 'Access-Control-Allow-Origin': '*'
|
| });
|
| res.end(JSON.stringify([...remoteAgentStates.values()]));
|
| });
|
| return;
|
| }
|
|
|
|
|
| if (process.env.OFFICE_MODE === '1') {
|
|
|
| if (pathname === '/' && req.method === 'GET') {
|
| const indexPath = path.join(FRONTEND_DIR, 'index.html');
|
| return serveStaticFile(res, indexPath);
|
| }
|
|
|
| if (pathname.startsWith('/static/')) {
|
| const assetPath = path.join(FRONTEND_DIR, pathname.slice('/static/'.length).split('?')[0]);
|
| return serveStaticFile(res, assetPath);
|
| }
|
|
|
| if (pathname === '/admin' || pathname === '/admin/') {
|
| const token = process.env.GATEWAY_TOKEN || '';
|
| req.url = token ? `/?token=${token}` : '/';
|
| return proxyRequest(req, res, OPENCLAW_PORT);
|
| }
|
| } else {
|
|
|
| if (pathname === '/' && req.method === 'GET' && !req.headers.upgrade) {
|
| const query = url.parse(req.url, true).query;
|
| if (!query.token) {
|
| const token = process.env.GATEWAY_TOKEN || '';
|
| if (token) {
|
| res.writeHead(302, { Location: `/?token=${token}` });
|
| return res.end();
|
| }
|
| }
|
| }
|
| }
|
|
|
|
|
| proxyRequest(req, res, OPENCLAW_PORT);
|
| });
|
|
|
|
|
| server.on('upgrade', (req, socket, head) => {
|
| const pathname = url.parse(req.url).pathname;
|
| const targetPort = (pathname.startsWith('/.well-known/') || pathname.startsWith('/a2a/'))
|
| ? A2A_PORT
|
| : OPENCLAW_PORT;
|
|
|
| const options = {
|
| hostname: '127.0.0.1',
|
| port: targetPort,
|
| path: req.url,
|
| method: req.method,
|
| headers: { ...req.headers, host: `127.0.0.1:${targetPort}` }
|
| };
|
|
|
| const proxy = http.request(options);
|
| proxy.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
|
| socket.write(
|
| `HTTP/1.1 101 Switching Protocols\r\n` +
|
| Object.entries(proxyRes.headers).map(([k, v]) => `${k}: ${v}`).join('\r\n') +
|
| '\r\n\r\n'
|
| );
|
| proxySocket.write(head);
|
| proxySocket.pipe(socket);
|
| socket.pipe(proxySocket);
|
| });
|
| proxy.on('error', () => socket.end());
|
| proxy.end();
|
| });
|
|
|
| server.listen(LISTEN_PORT, '0.0.0.0', () => {
|
| console.log(`[a2a-proxy] Listening on port ${LISTEN_PORT}`);
|
| console.log(`[a2a-proxy] OpenClaw → :${OPENCLAW_PORT}, A2A → :${A2A_PORT}`);
|
| console.log(`[a2a-proxy] Agent: ${AGENT_NAME}`);
|
| });
|
|
|