Task 1 – Remote-Befehle laufen nicht mehr in einen starren 180s-Timeout:
- RemoteConnectionManager.ExecuteCommand nutzt jetzt einen INAKTIVITÄTS-
Timeout (Watchdog auf LastProgressAt) statt hartem Wall-Clock-Limit; ein
Befehl darf beliebig lange laufen, solange der Client Progress-Frames sendet.
- RemoteEndpoint: neuer "progress"-Case setzt die Frist zurück.
- InternalEndpoint (MCP-Pfad remote_execute): hartes CancellationTokenSource(180s)
entfernt → nutzt konfigurierbares ExecuteTimeout-Setting + RequestAborted.
- remote.ps1 / remote.sh: senden während laufender Befehle alle 15s ein
{"type":"progress"}-Keep-Alive; async stdout/stderr-Lesen gegen Pipe-Deadlocks.
- ToolService: Tool-Beschreibungen erklären den Inaktivitäts-Timeout.
Task 2 – System-Prompt über --system-prompt-file statt CLI-Argument:
- ClaudeCliService schreibt den vollen System-Prompt in eine Temp-Datei und
übergibt --system-prompt-file, um das Windows-Kommandozeilen-Längenlimit zu
umgehen. Cleanup im finally. Agent-Verhalten unverändert (MEMORY.md bleibt inline).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
333 lines
9.4 KiB
Bash
333 lines
9.4 KiB
Bash
#!/usr/bin/env bash
|
|
# GOTT Remote Agent - Linux Bash Client
|
|
# Verbindet sich per WebSocket zum GOTT-Server und fuehrt Befehle aus.
|
|
#
|
|
# Verwendung:
|
|
# curl -s https://gott.example.com/remote.sh | bash
|
|
# oder:
|
|
# chmod +x remote.sh && ./remote.sh
|
|
# oder mit eigener URL:
|
|
# WS_URL="wss://example.com/api/remote" ./remote.sh
|
|
|
|
WS_URL="${WS_URL:-{{WS_URL}}}"
|
|
|
|
# Python finden
|
|
PYTHON=""
|
|
for cmd in python3 python py; do
|
|
if command -v "$cmd" &>/dev/null; then
|
|
PYTHON="$cmd"
|
|
break
|
|
fi
|
|
done
|
|
|
|
if [ -z "$PYTHON" ]; then
|
|
echo ""
|
|
echo " [FEHLER] Python nicht gefunden!"
|
|
echo " Bitte installiere Python 3: sudo apt install python3"
|
|
echo ""
|
|
exit 1
|
|
fi
|
|
|
|
# Python-Script inline ausfuehren
|
|
exec "$PYTHON" - "$WS_URL" << 'PYTHON_SCRIPT'
|
|
import sys
|
|
import os
|
|
import json
|
|
import subprocess
|
|
import signal
|
|
import socket
|
|
import ssl
|
|
import hashlib
|
|
import base64
|
|
import struct
|
|
import time
|
|
from urllib.parse import urlparse
|
|
|
|
WS_URL = sys.argv[1] if len(sys.argv) > 1 else "ws://localhost/api/remote"
|
|
|
|
CYAN = "\033[96m"
|
|
GREEN = "\033[92m"
|
|
YELLOW = "\033[93m"
|
|
RED = "\033[91m"
|
|
GRAY = "\033[90m"
|
|
WHITE = "\033[97m"
|
|
RESET = "\033[0m"
|
|
|
|
def cprint(msg, color=""):
|
|
print(f"{color}{msg}{RESET}", flush=True)
|
|
|
|
class WebSocketClient:
|
|
"""Minimaler WebSocket-Client (RFC 6455) ohne externe Abhaengigkeiten."""
|
|
|
|
def __init__(self, url):
|
|
self.url = url
|
|
self.sock = None
|
|
self.closed = False
|
|
|
|
def connect(self):
|
|
parsed = urlparse(self.url)
|
|
use_ssl = parsed.scheme in ("wss", "https")
|
|
host = parsed.hostname
|
|
port = parsed.port or (443 if use_ssl else 80)
|
|
path = parsed.path or "/"
|
|
if parsed.query:
|
|
path += "?" + parsed.query
|
|
|
|
self.sock = socket.create_connection((host, port), timeout=10)
|
|
if use_ssl:
|
|
ctx = ssl.create_default_context()
|
|
self.sock = ctx.wrap_socket(self.sock, server_hostname=host)
|
|
|
|
# WebSocket-Handshake
|
|
key = base64.b64encode(os.urandom(16)).decode()
|
|
handshake = (
|
|
f"GET {path} HTTP/1.1\r\n"
|
|
f"Host: {host}\r\n"
|
|
f"Upgrade: websocket\r\n"
|
|
f"Connection: Upgrade\r\n"
|
|
f"Sec-WebSocket-Key: {key}\r\n"
|
|
f"Sec-WebSocket-Version: 13\r\n"
|
|
f"\r\n"
|
|
)
|
|
self.sock.sendall(handshake.encode())
|
|
|
|
# Response lesen
|
|
response = b""
|
|
while b"\r\n\r\n" not in response:
|
|
chunk = self.sock.recv(4096)
|
|
if not chunk:
|
|
raise ConnectionError("Handshake fehlgeschlagen")
|
|
response += chunk
|
|
|
|
status_line = response.split(b"\r\n")[0].decode()
|
|
if "101" not in status_line:
|
|
raise ConnectionError(f"WebSocket-Handshake fehlgeschlagen: {status_line}")
|
|
|
|
self.sock.settimeout(None)
|
|
|
|
def send(self, data):
|
|
if self.closed:
|
|
return
|
|
payload = data.encode("utf-8")
|
|
frame = bytearray()
|
|
frame.append(0x81) # FIN + Text
|
|
|
|
length = len(payload)
|
|
if length < 126:
|
|
frame.append(0x80 | length) # Mask-Bit gesetzt
|
|
elif length < 65536:
|
|
frame.append(0x80 | 126)
|
|
frame.extend(struct.pack(">H", length))
|
|
else:
|
|
frame.append(0x80 | 127)
|
|
frame.extend(struct.pack(">Q", length))
|
|
|
|
# Masking Key (Client muss maskieren)
|
|
mask = os.urandom(4)
|
|
frame.extend(mask)
|
|
masked = bytearray(b ^ mask[i % 4] for i, b in enumerate(payload))
|
|
frame.extend(masked)
|
|
|
|
self.sock.sendall(frame)
|
|
|
|
def recv(self):
|
|
if self.closed:
|
|
return None
|
|
|
|
def read_exact(n):
|
|
data = b""
|
|
while len(data) < n:
|
|
chunk = self.sock.recv(n - len(data))
|
|
if not chunk:
|
|
raise ConnectionError("Verbindung geschlossen")
|
|
data += chunk
|
|
return data
|
|
|
|
header = read_exact(2)
|
|
opcode = header[0] & 0x0F
|
|
is_masked = header[1] & 0x80
|
|
length = header[1] & 0x7F
|
|
|
|
if opcode == 0x8: # Close
|
|
self.closed = True
|
|
return None
|
|
if opcode == 0x9: # Ping
|
|
self.send_pong()
|
|
return self.recv()
|
|
|
|
if length == 126:
|
|
length = struct.unpack(">H", read_exact(2))[0]
|
|
elif length == 127:
|
|
length = struct.unpack(">Q", read_exact(8))[0]
|
|
|
|
if is_masked:
|
|
mask = read_exact(4)
|
|
data = read_exact(length)
|
|
data = bytearray(b ^ mask[i % 4] for i, b in enumerate(data))
|
|
else:
|
|
data = read_exact(length)
|
|
|
|
return data.decode("utf-8")
|
|
|
|
def send_pong(self):
|
|
if self.closed:
|
|
return
|
|
frame = bytearray([0x8A, 0x80]) # FIN + Pong, Mask-Bit, Length 0
|
|
frame.extend(os.urandom(4)) # Mask key
|
|
self.sock.sendall(frame)
|
|
|
|
def close(self):
|
|
if self.closed:
|
|
return
|
|
self.closed = True
|
|
try:
|
|
# Close-Frame senden
|
|
frame = bytearray([0x88, 0x82]) # FIN + Close, Mask-Bit, Length 2
|
|
mask = os.urandom(4)
|
|
frame.extend(mask)
|
|
# Status 1000 (Normal Closure) maskiert
|
|
code = struct.pack(">H", 1000)
|
|
frame.extend(bytearray(b ^ mask[i % 4] for i, b in enumerate(code)))
|
|
self.sock.sendall(frame)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
self.sock.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def main():
|
|
print()
|
|
cprint(" ============================", CYAN)
|
|
cprint(" GOTT Remote Agent", CYAN)
|
|
cprint(" ============================", CYAN)
|
|
print()
|
|
cprint(f" Verbinde zu: {WS_URL}", GRAY)
|
|
print()
|
|
|
|
ws = WebSocketClient(WS_URL)
|
|
running = True
|
|
|
|
def on_signal(sig, frame):
|
|
nonlocal running
|
|
running = False
|
|
|
|
signal.signal(signal.SIGINT, on_signal)
|
|
signal.signal(signal.SIGTERM, on_signal)
|
|
|
|
try:
|
|
ws.connect()
|
|
except Exception as e:
|
|
cprint(f" [FEHLER] Verbindung fehlgeschlagen: {e}", RED)
|
|
cprint(f" URL: {WS_URL}", GRAY)
|
|
return
|
|
|
|
cprint(" [OK] Verbunden!", GREEN)
|
|
|
|
# Hello-Message senden
|
|
hostname = os.uname().nodename if hasattr(os, "uname") else socket.gethostname()
|
|
username = os.environ.get("USER", os.environ.get("LOGNAME", "unknown"))
|
|
import platform
|
|
os_version = f"{platform.system()} {platform.release()}"
|
|
|
|
hello = json.dumps({
|
|
"type": "hello",
|
|
"hostname": hostname,
|
|
"username": username,
|
|
"os": os_version
|
|
})
|
|
ws.send(hello)
|
|
|
|
cprint(f" Host: {username}@{hostname} ({os_version})", GRAY)
|
|
print()
|
|
cprint(" Warte auf Befehle... (Strg+C zum Beenden)", YELLOW)
|
|
print()
|
|
|
|
while running and not ws.closed:
|
|
try:
|
|
raw = ws.recv()
|
|
except (ConnectionError, OSError):
|
|
cprint(" Verbindung verloren.", RED)
|
|
break
|
|
|
|
if raw is None:
|
|
cprint(" Server hat Verbindung geschlossen.", YELLOW)
|
|
break
|
|
|
|
try:
|
|
msg = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
msg_type = msg.get("type", "")
|
|
|
|
if msg_type == "execute":
|
|
command = msg.get("command", "")
|
|
timestamp = time.strftime("%H:%M:%S")
|
|
print(f" {CYAN}[{timestamp}] Ausfuehren: {WHITE}{command}{RESET}", flush=True)
|
|
|
|
exit_code = 0
|
|
stdout = ""
|
|
stderr = ""
|
|
|
|
try:
|
|
proc = subprocess.Popen(
|
|
["bash", "-c", command],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True
|
|
)
|
|
# Warten und dabei periodisch ein Keep-Alive senden, damit lange Befehle
|
|
# nicht in den Server-Inaktivitaets-Timeout laufen. communicate(timeout)
|
|
# liest die Pipes leer (kein Deadlock) und laesst den Prozess bei einem
|
|
# Timeout weiterlaufen -> beliebig lange Laufzeit moeglich.
|
|
while True:
|
|
try:
|
|
stdout, stderr = proc.communicate(timeout=15)
|
|
break
|
|
except subprocess.TimeoutExpired:
|
|
try:
|
|
ws.send('{"type":"progress"}')
|
|
except Exception:
|
|
pass
|
|
exit_code = proc.returncode
|
|
except Exception as e:
|
|
stderr = str(e)
|
|
exit_code = 1
|
|
|
|
response = json.dumps({
|
|
"type": "result",
|
|
"exitCode": exit_code,
|
|
"output": stdout or "",
|
|
"error": stderr or ""
|
|
})
|
|
ws.send(response)
|
|
|
|
# Kurzausgabe lokal
|
|
if stdout:
|
|
preview = stdout[:200] + "..." if len(stdout) > 200 else stdout.rstrip()
|
|
cprint(f" -> {preview}", GRAY)
|
|
if stderr:
|
|
cprint(f" [stderr] {stderr.rstrip()}", RED)
|
|
color = GREEN if exit_code == 0 else RED
|
|
cprint(f" [Exit: {exit_code}]", color)
|
|
print()
|
|
|
|
elif msg_type == "ping":
|
|
ws.send('{"type":"pong"}')
|
|
|
|
elif msg_type == "pong":
|
|
pass
|
|
|
|
else:
|
|
cprint(f" Unbekannte Nachricht: {msg_type}", YELLOW)
|
|
|
|
ws.close()
|
|
print()
|
|
cprint(" Verbindung beendet.", YELLOW)
|
|
|
|
|
|
main()
|
|
PYTHON_SCRIPT
|