feat(host-agent): Backend-Anbindung — RVS + Bridge-Registry + Brain-Tools (Phase B-D)

Damit kann ARIA Host-Agenten tatsaechlich nutzen (Ende-zu-Ende):

- RVS: host_hello/host_ping/host_command/host_result in ALLOWED_TYPES.
- Bridge: _hosts-Registry (host_hello/host_ping/host_result), _host_list,
  _host_request (requestId->Future wie Satelliten), HTTP-Endpoints
  /internal/host-list und /internal/host (op via action).
- Brain: Tools host_list/host_exec/host_read/host_write/host_info/
  host_screenshot + _dispatch_host (ruft /internal/host*). host_screenshot
  speichert das PNG unter /shared/host-screenshots. host_exec kann sudo=true.

Naechste (optionale) Phase E: Host-Agenten in der Diagnostic-Flotte anzeigen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 16:11:06 +02:00
co-authored by Claude Opus 4.8
parent 223692d4c7
commit c7761284c7
3 changed files with 309 additions and 0 deletions
+210
View File
@@ -20,6 +20,7 @@ import json
import logging
import os
import re
import time
import urllib.error
import urllib.parse
import urllib.request
@@ -1262,6 +1263,104 @@ META_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "host_list",
"description": (
"Zeigt die ARIA-Host-Agenten (Rechner, auf denen ein Agent DIREKT "
"laeuft und sich per RVS meldet) die ONLINE sind + was sie koennen "
"(exec/read/write/info/screenshot). Ein Host-Agent gibt Dir vollen "
"Zugriff auf GENAU DIESEN Rechner — auch wenn er hinter NAT/Firewall "
"sitzt. Nutze das, wenn Stefan etwas 'auf meinem Laptop/PC/Server X' "
"machen will, das kein Geraet im Netz ist."
),
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "host_exec",
"description": (
"Fuehrt EIN Shell-Kommando auf einem Host-Agenten aus (bash -lc). "
"Gibt exit_code + stdout (gefenstert: contains/offset/max_chars) + "
"stderr. Fuer Root-Rechte sudo=true setzen (Agent nutzt root/"
"SUDO_PASSWORD/NOPASSWD automatisch). Sei vorsichtig — das ist "
"vollwertiger Rechnerzugriff."
),
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Host-ID/Name aus host_list."},
"cmd": {"type": "string", "description": "Shell-Kommando."},
"sudo": {"type": "boolean", "description": "Mit Root-Rechten ausfuehren."},
"timeout": {"type": "number", "description": "max. Laufzeit in Sekunden (Default 60)."},
},
"required": ["host", "cmd"],
},
},
},
{
"type": "function",
"function": {
"name": "host_read",
"description": "Liest eine Datei von einem Host-Agenten (Text). Fuer Binaerdateien liefert der Agent Base64.",
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Host-ID/Name."},
"path": {"type": "string", "description": "Absoluter Pfad auf dem Rechner."},
},
"required": ["host", "path"],
},
},
},
{
"type": "function",
"function": {
"name": "host_write",
"description": "Schreibt Text in eine Datei auf einem Host-Agenten (ueberschreibt; append=true haengt an).",
"parameters": {
"type": "object",
"properties": {
"host": {"type": "string", "description": "Host-ID/Name."},
"path": {"type": "string", "description": "Zielpfad."},
"text": {"type": "string", "description": "Inhalt."},
"append": {"type": "boolean", "description": "Anhaengen statt ueberschreiben."},
},
"required": ["host", "path", "text"],
},
},
},
{
"type": "function",
"function": {
"name": "host_info",
"description": "System-Info eines Host-Agenten: OS, CPU/RAM/Disk-Auslastung, Uptime, IP.",
"parameters": {
"type": "object",
"properties": {"host": {"type": "string", "description": "Host-ID/Name."}},
"required": ["host"],
},
},
},
{
"type": "function",
"function": {
"name": "host_screenshot",
"description": (
"Macht ein Bildschirmfoto auf einem Host-Agenten (nur wenn dort eine "
"grafische Session laeuft). Gibt an, dass ein Bild erstellt wurde; das "
"Bild wird der Konversation angehaengt."
),
"parameters": {
"type": "object",
"properties": {"host": {"type": "string", "description": "Host-ID/Name."}},
"required": ["host"],
},
},
},
{
"type": "function",
"function": {
@@ -2428,6 +2527,114 @@ class Agent:
except Exception as exc:
return f"FEHLER: Satellit/Bridge nicht erreichbar: {exc}"
def _dispatch_host(self, name: str, arguments: dict) -> str:
"""host_list / host_exec / host_read / host_write / host_info /
host_screenshot — via Bridge (/internal/host*) → RVS → Host-Agent."""
import base64 as _b64
def _post(path: str, body: dict, timeout: float) -> dict:
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(f"{BRIDGE_URL}{path}", data=data, method="POST",
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8", "ignore"))
try:
if name == "host_list":
result = _post("/internal/host-list", {}, 10)
hosts = result.get("hosts") or []
if not hosts:
return ("Gerade ist kein Host-Agent online. (Ein Host-Agent laeuft "
"direkt auf einem Rechner und erlaubt Dir, ihn zu steuern — "
"auch hinter NAT/Firewall.)")
lines = []
for h in hosts:
status = "online" if h.get("online") else "offline"
caps = ", ".join(h.get("caps") or [])
lines.append(f"- {h.get('name')} (id={h.get('hostId')}, {status}, "
f"{h.get('os', '')}; kann: {caps})")
return "Host-Agenten (Rechner):\n" + "\n".join(lines)
host = (arguments.get("host") or "").strip()
if not host:
return "FEHLER: host ist Pflicht (siehe host_list)."
if name == "host_exec":
cmd = (arguments.get("cmd") or "").strip()
if not cmd:
return "FEHLER: cmd ist Pflicht."
params = {"cmd": cmd}
if arguments.get("sudo"):
params["sudo"] = True
timeout = float(arguments.get("timeout") or 60)
result = _post("/internal/host", {"host": host, "action": "exec",
"params": params, "timeout": timeout + 10},
timeout + 20)
if not result.get("ok"):
return f"FEHLER: {result.get('error')}"
r = result.get("result") or {}
note = f"exit={r.get('exit_code')}"
if r.get("truncated"):
note += f" (stdout gekuerzt, {r.get('total_chars')} Zeichen gesamt)"
out = f"[{note}]\n{r.get('body', '')}"
stderr = (r.get("stderr") or "").strip()
if stderr:
out += f"\n--- stderr ---\n{stderr[:1500]}"
return out
if name == "host_read":
path = (arguments.get("path") or "").strip()
result = _post("/internal/host", {"host": host, "action": "read",
"params": {"path": path}}, 30)
if not result.get("ok"):
return f"FEHLER: {result.get('error')}"
r = result.get("result") or {}
try:
text = _b64.b64decode(r.get("base64", "")).decode("utf-8", "replace")
except Exception:
text = "(Binaerdatei — nicht als Text darstellbar)"
trunc = " (gekuerzt)" if r.get("truncated") else ""
return f"{r.get('path')} ({r.get('size')} Bytes){trunc}:\n{text[:8000]}"
if name == "host_write":
text = arguments.get("text") or ""
params = {"path": (arguments.get("path") or "").strip(),
"base64": _b64.b64encode(text.encode("utf-8")).decode("ascii"),
"append": bool(arguments.get("append"))}
result = _post("/internal/host", {"host": host, "action": "write",
"params": params}, 30)
if not result.get("ok"):
return f"FEHLER: {result.get('error')}"
r = result.get("result") or {}
return f"OK — {r.get('bytes')} Bytes nach {r.get('path')} geschrieben."
if name == "host_info":
result = _post("/internal/host", {"host": host, "action": "info",
"params": {}}, 20)
if not result.get("ok"):
return f"FEHLER: {result.get('error')}"
return f"System-Info {host}:\n" + json.dumps(result.get("result") or {},
ensure_ascii=False, indent=2)
if name == "host_screenshot":
result = _post("/internal/host", {"host": host, "action": "screenshot",
"params": {}}, 30)
if not result.get("ok"):
return f"FEHLER: {result.get('error')}"
r = result.get("result") or {}
try:
d = "/shared/host-screenshots"
os.makedirs(d, exist_ok=True)
fp = os.path.join(d, f"{host}-{int(time.time())}.png")
with open(fp, "wb") as f:
f.write(_b64.b64decode(r.get("base64", "")))
return f"Screenshot von {host} gespeichert: {fp} ({r.get('bytes')} Bytes)."
except Exception as exc:
return f"Screenshot erstellt ({r.get('bytes')} Bytes), Speichern fehlgeschlagen: {exc}"
return f"FEHLER: unbekanntes Host-Tool {name}"
except Exception as exc:
return f"FEHLER: Host/Bridge nicht erreichbar: {exc}"
def _dispatch_tool(self, name: str, arguments: dict, project_id: str = "") -> str:
"""Fuehrt einen Tool-Call aus und gibt ein kurzes Text-Resultat zurueck.
Niemals werfen — Fehler werden als Text-Resultat reportet damit Claude
@@ -3220,6 +3427,9 @@ class Agent:
return f"OK — Projekt '{updated['name']}' ist wieder ein normaler Chat."
if name in ("satellite_list", "satellite_devices", "satellite_command"):
return self._dispatch_satellite(name, arguments)
if name in ("host_list", "host_exec", "host_read", "host_write",
"host_info", "host_screenshot"):
return self._dispatch_host(name, arguments)
if name == "vm_register":
pid = (project_id or "").strip()
if not pid: