Spaces:
Running
Running
| /** | |
| * a2a-proxy.cjs β Reverse proxy on port 7860 | |
| * | |
| * Routes: | |
| * / β pixel office animation (frontend/index.html) | |
| * /static/* β static assets (frontend/) | |
| * /admin β OpenClaw control UI (port 7861) | |
| * /admin/* β OpenClaw control UI (port 7861) | |
| * /.well-known/* β A2A gateway (port 18800) | |
| * /a2a/* β A2A gateway (port 18800) | |
| * /api/state β local state JSON (for Office frontend polling) | |
| * /agents β merged agent list (OpenClaw + remote agents) | |
| * everything else β OpenClaw (port 7861) | |
| */ | |
| ; | |
| const http = require('http'); | |
| const url = require('url'); | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| // Frontend directory (try /home/node/frontend first, then relative) | |
| 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) { | |
| // Prevent directory traversal | |
| 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'; | |
| // Remote agents to monitor (comma-separated URLs) | |
| // e.g. REMOTE_AGENTS=adam|Adam|https://tao-shen-huggingclaw-adam.hf.space,eve|Eve|https://tao-shen-huggingclaw-eve.hf.space | |
| 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() | |
| }; | |
| // Track A2A activity β when an A2A message is being processed, | |
| // temporarily switch state to 'writing' so frontends can see it | |
| let a2aActiveRequests = 0; | |
| let a2aIdleTimer = null; | |
| const A2A_IDLE_DELAY = 8000; // stay "writing" for 8s after last A2A request ends | |
| 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); | |
| } | |
| } | |
| // Remote agent states (polled periodically) | |
| 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 (_) { | |
| // Keep last known state or mark as offline | |
| 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(', ')}`); | |
| } | |
| // Poll OpenClaw health to track state | |
| async function pollOpenClawHealth() { | |
| // Don't overwrite active A2A state | |
| 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(); | |
| // Fetch agents from OpenClaw and merge with remote agents | |
| 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 (_) {} | |
| // Merge: OpenClaw agents + remote agents (deduplicate by agentId) | |
| 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) => { | |
| // Fix iframe embedding: strip X-Frame-Options so HF Spaces iframe works | |
| 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; | |
| // A2A routes β A2A gateway | |
| if (pathname.startsWith('/.well-known/') || pathname.startsWith('/a2a/')) { | |
| // Track POST requests (message/send) as active communication | |
| if (req.method === 'POST') { | |
| markA2AActive(); | |
| res.on('finish', markA2ADone); | |
| } | |
| return proxyRequest(req, res, A2A_PORT); | |
| } | |
| // State endpoint for Office frontend polling | |
| 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` | |
| })); | |
| } | |
| // Agents endpoint β merge OpenClaw agents with remote agents | |
| 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(() => { | |
| // Fallback: just return remote agents | |
| res.writeHead(200, { | |
| 'Content-Type': 'application/json', | |
| 'Access-Control-Allow-Origin': '*' | |
| }); | |
| res.end(JSON.stringify([...remoteAgentStates.values()])); | |
| }); | |
| return; | |
| } | |
| // Office frontend (only served if OFFICE_MODE=1, e.g. on dedicated office spaces) | |
| if (process.env.OFFICE_MODE === '1') { | |
| // Serve index.html at / | |
| if (pathname === '/' && req.method === 'GET') { | |
| const indexPath = path.join(FRONTEND_DIR, 'index.html'); | |
| return serveStaticFile(res, indexPath); | |
| } | |
| // Serve static assets at /static/* | |
| if (pathname.startsWith('/static/')) { | |
| const assetPath = path.join(FRONTEND_DIR, pathname.slice('/static/'.length).split('?')[0]); | |
| return serveStaticFile(res, assetPath); | |
| } | |
| // Admin panel β proxy to OpenClaw UI | |
| if (pathname === '/admin' || pathname === '/admin/') { | |
| const token = process.env.GATEWAY_TOKEN || ''; | |
| req.url = token ? `/?token=${token}` : '/'; | |
| return proxyRequest(req, res, OPENCLAW_PORT); | |
| } | |
| } else { | |
| // Default mode: redirect / to /?token=xxx so browser URL has the token | |
| 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(); | |
| } | |
| } | |
| } | |
| } | |
| // Everything else β OpenClaw | |
| proxyRequest(req, res, OPENCLAW_PORT); | |
| }); | |
| // Handle WebSocket upgrades | |
| 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}`); | |
| }); | |