tao-shen commited on
Commit
8ee58f9
Β·
1 Parent(s): 86b44f8

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)

Files changed (4) hide show
  1. Dockerfile +8 -0
  2. scripts/a2a-proxy.cjs +134 -0
  3. scripts/entrypoint.sh +6 -0
  4. 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": 7860,
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": 7860,
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 (only load telegram + whatsapp to speed up startup)
494
  data.setdefault("plugins", {}).setdefault("entries", {})
495
- data["plugins"]["allow"] = ["telegram", "whatsapp"]
 
 
 
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", {})