| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| '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}`); |
| }); |
|
|