Spaces:
Sleeping
Sleeping
feat: add A2A gateway support (activated by A2A_PEERS env var)
Browse files- Add openclaw-a2a-gateway extension to Dockerfile
- Add a2a-proxy.cjs for port routing (7860 proxy β 7861 OpenClaw, 18800 A2A)
- Modify sync_hf.py: port 7861, A2A config only when A2A_PEERS is set
- No impact when A2A_PEERS is not set (proxy transparently forwards)
- Dockerfile +8 -0
- scripts/a2a-proxy.cjs +134 -0
- scripts/entrypoint.sh +6 -0
- scripts/sync_hf.py +42 -5
Dockerfile
CHANGED
|
@@ -52,6 +52,14 @@ RUN echo "[build][layer2] Clone + install + build..." && START=$(date +%s) \
|
|
| 52 |
&& echo "[build] version: $(cat /app/openclaw/.version)" \
|
| 53 |
&& echo "[build][layer2] Total clone+install+build: $(($(date +%s) - START))s"
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
# ββ Layer 3 (node): Scripts + Config ββββββββββββββββββββββββββββββββββββββββββ
|
| 56 |
COPY --chown=node:node scripts /home/node/scripts
|
| 57 |
COPY --chown=node:node openclaw.json /home/node/scripts/openclaw.json.default
|
|
|
|
| 52 |
&& echo "[build] version: $(cat /app/openclaw/.version)" \
|
| 53 |
&& echo "[build][layer2] Total clone+install+build: $(($(date +%s) - START))s"
|
| 54 |
|
| 55 |
+
# ββ Layer 2.5: A2A Gateway Extension (optional, activated by A2A_PEERS env) ββ
|
| 56 |
+
RUN echo "[build][layer2.5] Cloning A2A gateway extension..." && START=$(date +%s) \
|
| 57 |
+
&& git clone --depth 1 https://github.com/win4r/openclaw-a2a-gateway.git /app/openclaw/extensions/a2a-gateway \
|
| 58 |
+
&& cd /app/openclaw/extensions/a2a-gateway \
|
| 59 |
+
&& npm install --production \
|
| 60 |
+
&& echo "[build] A2A gateway installed: $(ls node_modules | wc -l) packages" \
|
| 61 |
+
&& echo "[build][layer2.5] A2A gateway: $(($(date +%s) - START))s"
|
| 62 |
+
|
| 63 |
# ββ Layer 3 (node): Scripts + Config ββββββββββββββββββββββββββββββββββββββββββ
|
| 64 |
COPY --chown=node:node scripts /home/node/scripts
|
| 65 |
COPY --chown=node:node openclaw.json /home/node/scripts/openclaw.json.default
|
scripts/a2a-proxy.cjs
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* a2a-proxy.cjs β Reverse proxy on port 7860
|
| 3 |
+
*
|
| 4 |
+
* Routes:
|
| 5 |
+
* /.well-known/* β A2A gateway (port 18800)
|
| 6 |
+
* /a2a/* β A2A gateway (port 18800)
|
| 7 |
+
* /api/state β local state JSON (for Office frontend polling)
|
| 8 |
+
* everything else β OpenClaw (port 7861)
|
| 9 |
+
*/
|
| 10 |
+
'use strict';
|
| 11 |
+
|
| 12 |
+
const http = require('http');
|
| 13 |
+
const url = require('url');
|
| 14 |
+
|
| 15 |
+
const LISTEN_PORT = 7860;
|
| 16 |
+
const OPENCLAW_PORT = 7861;
|
| 17 |
+
const A2A_PORT = 18800;
|
| 18 |
+
const AGENT_NAME = process.env.AGENT_NAME || 'Agent';
|
| 19 |
+
|
| 20 |
+
let currentState = {
|
| 21 |
+
state: 'syncing',
|
| 22 |
+
detail: `${AGENT_NAME} is starting...`,
|
| 23 |
+
progress: 0,
|
| 24 |
+
updated_at: new Date().toISOString()
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
// Poll OpenClaw health to track state
|
| 28 |
+
async function pollOpenClawHealth() {
|
| 29 |
+
try {
|
| 30 |
+
const controller = new AbortController();
|
| 31 |
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
| 32 |
+
const resp = await fetch(`http://127.0.0.1:${OPENCLAW_PORT}/`, {
|
| 33 |
+
signal: controller.signal
|
| 34 |
+
});
|
| 35 |
+
clearTimeout(timeout);
|
| 36 |
+
currentState = {
|
| 37 |
+
state: resp.ok ? 'idle' : 'error',
|
| 38 |
+
detail: resp.ok ? `${AGENT_NAME} is running` : `HTTP ${resp.status}`,
|
| 39 |
+
progress: resp.ok ? 100 : 0,
|
| 40 |
+
updated_at: new Date().toISOString()
|
| 41 |
+
};
|
| 42 |
+
} catch (_) {
|
| 43 |
+
currentState = {
|
| 44 |
+
state: 'syncing',
|
| 45 |
+
detail: `${AGENT_NAME} is starting...`,
|
| 46 |
+
progress: 0,
|
| 47 |
+
updated_at: new Date().toISOString()
|
| 48 |
+
};
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
setInterval(pollOpenClawHealth, 5000);
|
| 53 |
+
pollOpenClawHealth();
|
| 54 |
+
|
| 55 |
+
function proxyRequest(req, res, targetPort) {
|
| 56 |
+
const options = {
|
| 57 |
+
hostname: '127.0.0.1',
|
| 58 |
+
port: targetPort,
|
| 59 |
+
path: req.url,
|
| 60 |
+
method: req.method,
|
| 61 |
+
headers: { ...req.headers, host: `127.0.0.1:${targetPort}` }
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
const proxy = http.request(options, (proxyRes) => {
|
| 65 |
+
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
| 66 |
+
proxyRes.pipe(res, { end: true });
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
proxy.on('error', (err) => {
|
| 70 |
+
if (!res.headersSent) {
|
| 71 |
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
| 72 |
+
res.end(JSON.stringify({ error: 'Backend unavailable', target: targetPort }));
|
| 73 |
+
}
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
req.pipe(proxy, { end: true });
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
const server = http.createServer((req, res) => {
|
| 80 |
+
const pathname = url.parse(req.url).pathname;
|
| 81 |
+
|
| 82 |
+
// A2A routes β A2A gateway
|
| 83 |
+
if (pathname.startsWith('/.well-known/') || pathname.startsWith('/a2a/')) {
|
| 84 |
+
return proxyRequest(req, res, A2A_PORT);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
// State endpoint for Office frontend polling
|
| 88 |
+
if (pathname === '/api/state' || pathname === '/status') {
|
| 89 |
+
res.writeHead(200, {
|
| 90 |
+
'Content-Type': 'application/json',
|
| 91 |
+
'Access-Control-Allow-Origin': '*'
|
| 92 |
+
});
|
| 93 |
+
return res.end(JSON.stringify(currentState));
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// Everything else β OpenClaw
|
| 97 |
+
proxyRequest(req, res, OPENCLAW_PORT);
|
| 98 |
+
});
|
| 99 |
+
|
| 100 |
+
// Handle WebSocket upgrades
|
| 101 |
+
server.on('upgrade', (req, socket, head) => {
|
| 102 |
+
const pathname = url.parse(req.url).pathname;
|
| 103 |
+
const targetPort = (pathname.startsWith('/.well-known/') || pathname.startsWith('/a2a/'))
|
| 104 |
+
? A2A_PORT
|
| 105 |
+
: OPENCLAW_PORT;
|
| 106 |
+
|
| 107 |
+
const options = {
|
| 108 |
+
hostname: '127.0.0.1',
|
| 109 |
+
port: targetPort,
|
| 110 |
+
path: req.url,
|
| 111 |
+
method: req.method,
|
| 112 |
+
headers: { ...req.headers, host: `127.0.0.1:${targetPort}` }
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
const proxy = http.request(options);
|
| 116 |
+
proxy.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
|
| 117 |
+
socket.write(
|
| 118 |
+
`HTTP/1.1 101 Switching Protocols\r\n` +
|
| 119 |
+
Object.entries(proxyRes.headers).map(([k, v]) => `${k}: ${v}`).join('\r\n') +
|
| 120 |
+
'\r\n\r\n'
|
| 121 |
+
);
|
| 122 |
+
proxySocket.write(head);
|
| 123 |
+
proxySocket.pipe(socket);
|
| 124 |
+
socket.pipe(proxySocket);
|
| 125 |
+
});
|
| 126 |
+
proxy.on('error', () => socket.end());
|
| 127 |
+
proxy.end();
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
server.listen(LISTEN_PORT, '0.0.0.0', () => {
|
| 131 |
+
console.log(`[a2a-proxy] Listening on port ${LISTEN_PORT}`);
|
| 132 |
+
console.log(`[a2a-proxy] OpenClaw β :${OPENCLAW_PORT}, A2A β :${A2A_PORT}`);
|
| 133 |
+
console.log(`[a2a-proxy] Agent: ${AGENT_NAME}`);
|
| 134 |
+
});
|
scripts/entrypoint.sh
CHANGED
|
@@ -64,6 +64,12 @@ if [ -f /app/openclaw/.version ]; then
|
|
| 64 |
echo "[entrypoint] OpenClaw version: $OPENCLAW_VERSION"
|
| 65 |
fi
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
# ββ Start OpenClaw via sync_hf.py βββββββββββββββββββββββββββββββββββββββββ
|
| 68 |
echo "[entrypoint] Starting OpenClaw via sync_hf.py..."
|
| 69 |
exec python3 -u /home/node/scripts/sync_hf.py
|
|
|
|
| 64 |
echo "[entrypoint] OpenClaw version: $OPENCLAW_VERSION"
|
| 65 |
fi
|
| 66 |
|
| 67 |
+
# ββ Start A2A proxy on port 7860 (OpenClaw moves to 7861) βββββββββββββββββ
|
| 68 |
+
echo "[entrypoint] Starting A2A proxy on port 7860..."
|
| 69 |
+
node /home/node/scripts/a2a-proxy.cjs &
|
| 70 |
+
A2A_PROXY_PID=$!
|
| 71 |
+
echo "[entrypoint] A2A proxy PID: $A2A_PROXY_PID"
|
| 72 |
+
|
| 73 |
# ββ Start OpenClaw via sync_hf.py βββββββββββββββββββββββββββββββββββββββββ
|
| 74 |
echo "[entrypoint] Starting OpenClaw via sync_hf.py..."
|
| 75 |
exec python3 -u /home/node/scripts/sync_hf.py
|
scripts/sync_hf.py
CHANGED
|
@@ -71,6 +71,10 @@ ZHIPU_API_KEY = os.environ.get("ZHIPU_API_KEY", "")
|
|
| 71 |
# Gateway token (default: huggingclaw; override via GATEWAY_TOKEN env var)
|
| 72 |
GATEWAY_TOKEN = os.environ.get("GATEWAY_TOKEN", "huggingclaw")
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
# Default model for new conversations (infer from provider if not set)
|
| 75 |
OPENCLAW_DEFAULT_MODEL = os.environ.get("OPENCLAW_DEFAULT_MODEL") or (
|
| 76 |
"openai/gpt-5-nano" if OPENAI_API_KEY
|
|
@@ -378,7 +382,7 @@ class OpenClawFullSync:
|
|
| 378 |
with open(config_path, "w") as f:
|
| 379 |
json.dump({
|
| 380 |
"gateway": {
|
| 381 |
-
"mode": "local", "bind": "lan", "port":
|
| 382 |
"trustedProxies": ["0.0.0.0/0"],
|
| 383 |
"controlUi": {
|
| 384 |
"allowInsecureAuth": True,
|
|
@@ -436,7 +440,7 @@ class OpenClawFullSync:
|
|
| 436 |
data["gateway"] = {
|
| 437 |
"mode": "local",
|
| 438 |
"bind": "lan",
|
| 439 |
-
"port":
|
| 440 |
"auth": {"token": GATEWAY_TOKEN},
|
| 441 |
"trustedProxies": ["0.0.0.0/0"],
|
| 442 |
"controlUi": {
|
|
@@ -445,7 +449,7 @@ class OpenClawFullSync:
|
|
| 445 |
"allowedOrigins": allowed_origins
|
| 446 |
}
|
| 447 |
}
|
| 448 |
-
print(f"[SYNC] Set gateway config (auth=token, origins={len(allowed_origins)})")
|
| 449 |
|
| 450 |
# Ensure agents defaults
|
| 451 |
data.setdefault("agents", {}).setdefault("defaults", {}).setdefault("model", {})
|
|
@@ -490,14 +494,47 @@ class OpenClawFullSync:
|
|
| 490 |
data.setdefault("models", {})["providers"] = providers
|
| 491 |
data["agents"]["defaults"]["model"]["primary"] = OPENCLAW_DEFAULT_MODEL
|
| 492 |
|
| 493 |
-
# Plugin whitelist
|
| 494 |
data.setdefault("plugins", {}).setdefault("entries", {})
|
| 495 |
-
|
|
|
|
|
|
|
|
|
|
| 496 |
if "telegram" not in data["plugins"]["entries"]:
|
| 497 |
data["plugins"]["entries"]["telegram"] = {"enabled": True}
|
| 498 |
elif isinstance(data["plugins"]["entries"]["telegram"], dict):
|
| 499 |
data["plugins"]["entries"]["telegram"]["enabled"] = True
|
| 500 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 501 |
# ββ Telegram channel defaults (open DM policy for HF Spaces) ββ
|
| 502 |
# Personal bot on HF Spaces β no need for strict pairing.
|
| 503 |
tg_ch = data.setdefault("channels", {}).setdefault("telegram", {})
|
|
|
|
| 71 |
# Gateway token (default: huggingclaw; override via GATEWAY_TOKEN env var)
|
| 72 |
GATEWAY_TOKEN = os.environ.get("GATEWAY_TOKEN", "huggingclaw")
|
| 73 |
|
| 74 |
+
# A2A configuration (optional; only activated when A2A_PEERS is set)
|
| 75 |
+
AGENT_NAME = os.environ.get("AGENT_NAME", "HuggingClaw")
|
| 76 |
+
A2A_PEERS = os.environ.get("A2A_PEERS", "") # comma-separated peer URLs
|
| 77 |
+
|
| 78 |
# Default model for new conversations (infer from provider if not set)
|
| 79 |
OPENCLAW_DEFAULT_MODEL = os.environ.get("OPENCLAW_DEFAULT_MODEL") or (
|
| 80 |
"openai/gpt-5-nano" if OPENAI_API_KEY
|
|
|
|
| 382 |
with open(config_path, "w") as f:
|
| 383 |
json.dump({
|
| 384 |
"gateway": {
|
| 385 |
+
"mode": "local", "bind": "lan", "port": 7861,
|
| 386 |
"trustedProxies": ["0.0.0.0/0"],
|
| 387 |
"controlUi": {
|
| 388 |
"allowInsecureAuth": True,
|
|
|
|
| 440 |
data["gateway"] = {
|
| 441 |
"mode": "local",
|
| 442 |
"bind": "lan",
|
| 443 |
+
"port": 7861,
|
| 444 |
"auth": {"token": GATEWAY_TOKEN},
|
| 445 |
"trustedProxies": ["0.0.0.0/0"],
|
| 446 |
"controlUi": {
|
|
|
|
| 449 |
"allowedOrigins": allowed_origins
|
| 450 |
}
|
| 451 |
}
|
| 452 |
+
print(f"[SYNC] Set gateway config (port=7861, auth=token, origins={len(allowed_origins)})")
|
| 453 |
|
| 454 |
# Ensure agents defaults
|
| 455 |
data.setdefault("agents", {}).setdefault("defaults", {}).setdefault("model", {})
|
|
|
|
| 494 |
data.setdefault("models", {})["providers"] = providers
|
| 495 |
data["agents"]["defaults"]["model"]["primary"] = OPENCLAW_DEFAULT_MODEL
|
| 496 |
|
| 497 |
+
# Plugin whitelist
|
| 498 |
data.setdefault("plugins", {}).setdefault("entries", {})
|
| 499 |
+
plugin_allow = ["telegram", "whatsapp"]
|
| 500 |
+
if A2A_PEERS:
|
| 501 |
+
plugin_allow.append("a2a-gateway")
|
| 502 |
+
data["plugins"]["allow"] = plugin_allow
|
| 503 |
if "telegram" not in data["plugins"]["entries"]:
|
| 504 |
data["plugins"]["entries"]["telegram"] = {"enabled": True}
|
| 505 |
elif isinstance(data["plugins"]["entries"]["telegram"], dict):
|
| 506 |
data["plugins"]["entries"]["telegram"]["enabled"] = True
|
| 507 |
|
| 508 |
+
# ββ A2A Gateway Plugin Configuration (only if A2A_PEERS is set) ββ
|
| 509 |
+
if A2A_PEERS:
|
| 510 |
+
peers = []
|
| 511 |
+
for peer_url in A2A_PEERS.split(","):
|
| 512 |
+
peer_url = peer_url.strip()
|
| 513 |
+
if not peer_url:
|
| 514 |
+
continue
|
| 515 |
+
name = peer_url.split("//")[-1].split(".")[0].split("-")[-1].capitalize()
|
| 516 |
+
peers.append({
|
| 517 |
+
"name": name,
|
| 518 |
+
"agentCardUrl": f"{peer_url}/.well-known/agent-card.json"
|
| 519 |
+
})
|
| 520 |
+
print(f"[SYNC] A2A peer: {name} β {peer_url}")
|
| 521 |
+
|
| 522 |
+
data["plugins"]["entries"]["a2a-gateway"] = {
|
| 523 |
+
"enabled": True,
|
| 524 |
+
"config": {
|
| 525 |
+
"agentCard": {
|
| 526 |
+
"name": AGENT_NAME,
|
| 527 |
+
"description": f"{AGENT_NAME} - HuggingClaw A2A Agent",
|
| 528 |
+
"skills": [{"id": "chat", "name": "chat", "description": "Chat bridge"}]
|
| 529 |
+
},
|
| 530 |
+
"server": {"host": "0.0.0.0", "port": 18800},
|
| 531 |
+
"security": {"inboundAuth": "none"},
|
| 532 |
+
"routing": {"defaultAgentId": "main"},
|
| 533 |
+
"peers": peers
|
| 534 |
+
}
|
| 535 |
+
}
|
| 536 |
+
print(f"[SYNC] A2A gateway configured: name={AGENT_NAME}, port=18800, peers={len(peers)}")
|
| 537 |
+
|
| 538 |
# ββ Telegram channel defaults (open DM policy for HF Spaces) ββ
|
| 539 |
# Personal bot on HF Spaces β no need for strict pairing.
|
| 540 |
tg_ch = data.setdefault("channels", {}).setdefault("telegram", {})
|