"use strict"; /** * ARIA Diagnostic Server * * Leichtgewichtiges Diagnose-Tool: * - Verbindet sich direkt mit OpenClaw Gateway (ws://127.0.0.1:18789) * - Fuehrt den vollstaendigen Handshake durch * - Sendet Testnachrichten und zeigt Antworten * - Zeigt Verbindungsstatus aller Komponenten * * Laeuft im selben Netzwerk wie aria-core (network_mode: service:aria) */ const http = require("http"); const { WebSocket, WebSocketServer } = require("ws"); const crypto = require("crypto"); const fs = require("fs"); const path = require("path"); // ── Konfiguration ─────────────────────────────────────── const HTTP_PORT = parseInt(process.env.DIAG_PORT || "3001", 10); const GATEWAY_URL = process.env.ARIA_CORE_WS || "ws://127.0.0.1:18789"; const GATEWAY_TOKEN = process.env.ARIA_AUTH_TOKEN || ""; const RVS_HOST = process.env.RVS_HOST || ""; const RVS_PORT = process.env.RVS_PORT || "443"; const RVS_TLS = process.env.RVS_TLS || "true"; const RVS_TLS_FALLBACK = process.env.RVS_TLS_FALLBACK || "true"; const RVS_TOKEN = process.env.RVS_TOKEN || ""; const PROXY_URL = process.env.PROXY_URL || "http://proxy:3456"; // ── State ─────────────────────────────────────────────── const state = { gateway: { status: "disconnected", lastError: null, handshakeOk: false }, rvs: { status: "disconnected", lastError: null }, proxy: { status: "unknown", lastError: null }, }; const SESSION_KEY_FILE = "/data/active-session"; // /data Verzeichnis sicherstellen (Volume Mount) try { fs.mkdirSync("/data", { recursive: true }); } catch (e) { console.error(`[startup] /data mkdir fehlgeschlagen: ${e.message}`); } // sessionFromFile zeigt an, ob der aktive Key aus der Datei kam. // Wenn true, darf resolveActiveSession NICHT mehr auto-picken (Wahl respektieren). let sessionFromFile = false; let activeSessionKey = (() => { try { const saved = fs.readFileSync(SESSION_KEY_FILE, "utf-8").trim(); if (saved) { console.log(`[startup] Gespeicherte Session geladen: '${saved}'`); sessionFromFile = true; return saved; } } catch (e) { console.error(`[startup] SESSION_KEY_FILE read: ${e.code || e.message}`); } console.log("[startup] Keine gespeicherte Session — Fallback 'main'"); return "main"; })(); // Atomic write: temp-file + rename, laute Logs bei Fehler. function persistActiveSession(key) { try { const tmp = SESSION_KEY_FILE + ".tmp"; fs.writeFileSync(tmp, key); fs.renameSync(tmp, SESSION_KEY_FILE); sessionFromFile = true; console.log(`[session] Aktive Session persistiert: '${key}'`); return true; } catch (e) { console.error(`[session] FEHLER beim Persistieren von '${key}': ${e.message}`); return false; } } const logs = []; let gatewayWs = null; let rvsWs = null; let reqIdCounter = 0; const browserClients = new Set(); // ── Pipeline Tracking ────────────────────────────────── let pipelineActive = false; let pipelineStartTime = 0; // Nach chat:final kommen oft noch Trailing Agent-Events. Waehrend dieses // Fensters unterdruecken wir agent_activity-Broadcasts, damit der // Thinking-Indicator nicht wieder anspringt. let lastChatFinalAt = 0; const SETTLED_WINDOW_MS = 3000; function plog(message, level) { const elapsed = pipelineActive ? `+${Date.now() - pipelineStartTime}ms` : ""; const entry = { ts: new Date().toISOString(), level: level || "info", source: "pipeline", message: `${elapsed ? `[${elapsed}] ` : ""}${message}` }; logs.push(entry); if (logs.length > 500) logs.shift(); console.log(`[PIPELINE] ${entry.message}`); broadcast({ type: "log", entry }); } let pipelineTimeout = null; function pipelineStart(method, text) { // Falls noch eine Pipeline laeuft, beenden if (pipelineActive) pipelineEnd(false, "Abgebrochen (neue Nachricht)"); pipelineActive = true; pipelineStartTime = Date.now(); if (pipelineTimeout) clearTimeout(pipelineTimeout); pipelineTimeout = setTimeout(() => { if (pipelineActive) pipelineEnd(false, "Timeout — keine Antwort nach 10min"); }, 600000); plog(`━━━ Pipeline Start: ${method} ━━━`); plog(`Nachricht: "${text}"`); } function pipelineEnd(ok, detail) { if (!pipelineActive) return; if (pipelineTimeout) { clearTimeout(pipelineTimeout); pipelineTimeout = null; } const elapsed = Date.now() - pipelineStartTime; if (ok) { plog(`>>> Fertig (${elapsed}ms): ${detail}`); } else { plog(`>>> FEHLER (${elapsed}ms): ${detail}`, "error"); } plog(`━━━ Pipeline Ende ━━━`); pipelineActive = false; // Thinking-Indikator IMMER zuruecksetzen — auch bei Timeout/Fehler/Abbruch broadcast({ type: "agent_activity", activity: "idle" }); pendingMessageTime = 0; } // ── Auto-Restart bei Netzwerk-Namespace-Verlust ────── // Bei network_mode: "service:aria" verliert dieser Container // den Netzwerkzugriff wenn aria-core neustartet. // Nach MAX_GATEWAY_FAILURES aufeinanderfolgenden Fehlern → process.exit // Docker restart: unless-stopped startet uns mit neuem Namespace neu. const MAX_GATEWAY_FAILURES = 6; // 6 × 5s = 30s let gatewayFailCount = 0; function checkGatewayHealth() { if (state.gateway.status === "connected") { gatewayFailCount = 0; return; } gatewayFailCount++; if (gatewayFailCount >= MAX_GATEWAY_FAILURES) { log("error", "server", `Gateway ${MAX_GATEWAY_FAILURES}x nicht erreichbar — Neustart (Netzwerk-Namespace veraltet?)`); // Kurze Verzoegerung damit die Log-Nachricht noch gesendet wird setTimeout(() => process.exit(1), 500); } } function nextReqId() { return `diag-${++reqIdCounter}`; } function log(level, source, message) { const entry = { ts: new Date().toISOString(), level, source, message }; logs.push(entry); if (logs.length > 500) logs.shift(); console.log(`[${entry.ts}] [${level}] [${source}] ${message}`); // An alle Browser-Clients senden broadcast({ type: "log", entry }); } function broadcast(msg) { const data = JSON.stringify(msg); for (const client of browserClients) { if (client.readyState === WebSocket.OPEN) { client.send(data); } } } function broadcastState() { broadcast({ type: "state", state }); } // ── OpenClaw Gateway Verbindung ───────────────────────── async function connectGateway() { if (gatewayWs) { try { gatewayWs.close(); } catch (_) {} gatewayWs = null; } state.gateway.status = "connecting"; state.gateway.handshakeOk = false; broadcastState(); log("info", "gateway", `Verbinde: ${GATEWAY_URL}`); try { const ws = new WebSocket(GATEWAY_URL); await new Promise((resolve, reject) => { const timeout = setTimeout(() => { ws.close(); reject(new Error("Verbindungs-Timeout (10s)")); }, 10000); ws.on("open", () => { clearTimeout(timeout); resolve(); }); ws.on("error", (err) => { clearTimeout(timeout); reject(err); }); }); log("info", "gateway", "TCP-Verbindung hergestellt — warte auf Challenge"); // Schritt 1: Auf Challenge warten const challengeRaw = await waitForMessage(ws, 10000); const challenge = JSON.parse(challengeRaw); if (challenge.type !== "event" || challenge.event !== "connect.challenge") { throw new Error(`Unerwartete erste Nachricht: ${challengeRaw.slice(0, 200)}`); } const nonce = challenge.payload?.nonce || ""; log("info", "gateway", `Challenge empfangen (nonce: ${nonce.slice(0, 8)}...)`); // Schritt 2: Connect Request senden const connectReq = { type: "req", id: nextReqId(), method: "connect", params: { minProtocol: 3, maxProtocol: 3, client: { id: "cli", version: "0.0.1", platform: "linux", mode: "cli", }, role: "operator", scopes: ["operator.read", "operator.write"], caps: [], commands: [], permissions: {}, auth: GATEWAY_TOKEN ? { token: GATEWAY_TOKEN } : {}, locale: "de-DE", userAgent: "aria-diagnostic/0.0.1", }, }; ws.send(JSON.stringify(connectReq)); log("info", "gateway", "Connect-Request gesendet"); // Schritt 3: hello-ok warten const responseRaw = await waitForMessage(ws, 10000); const response = JSON.parse(responseRaw); if (response.type === "res" && response.ok) { log("info", "gateway", "Handshake erfolgreich — hello-ok!"); state.gateway.status = "connected"; state.gateway.handshakeOk = true; state.gateway.lastError = null; gatewayFailCount = 0; } else { const error = typeof response.error === "string" ? response.error : JSON.stringify(response.error || response).slice(0, 300); throw new Error(`Handshake fehlgeschlagen: ${error}`); } gatewayWs = ws; broadcastState(); // Nach (Re-)Connect: letzte aktive Session aus OpenClaw wiederherstellen resolveActiveSession().catch(err => { log("warn", "server", `Session-Aufloesung fehlgeschlagen: ${err.message}`); }); // Nachrichten-Loop — RAW logging fuer Debugging ws.on("message", (raw) => { try { const rawStr = raw.toString(); log("debug", "gateway", `RAW <<< ${rawStr.slice(0, 300)}`); const msg = JSON.parse(rawStr); handleGatewayMessage(msg); } catch (err) { log("error", "gateway", `Parse-Fehler: ${err.message}`); } }); ws.on("close", (code, reason) => { log("warn", "gateway", `Verbindung geschlossen (Code: ${code}, Reason: ${reason || "-"})`); state.gateway.status = "disconnected"; state.gateway.handshakeOk = false; gatewayWs = null; broadcastState(); // Stuck "ARIA denkt..." vermeiden, falls Gateway waehrend Pipeline abkackt if (pipelineActive) pipelineEnd(false, `Gateway-Verbindung verloren (${code})`); else broadcast({ type: "agent_activity", activity: "idle" }); checkGatewayHealth(); setTimeout(connectGateway, 5000); }); ws.on("error", (err) => { log("error", "gateway", `WebSocket-Fehler: ${err.message}`); state.gateway.lastError = err.message; broadcastState(); }); } catch (err) { log("error", "gateway", `Fehler: ${err.message}`); state.gateway.status = "error"; state.gateway.lastError = err.message; state.gateway.handshakeOk = false; gatewayWs = null; broadcastState(); checkGatewayHealth(); // Retry nach 5s setTimeout(connectGateway, 5000); } } // Extrahiert Text aus OpenClaw chat-Event message.content Array function extractChatText(payload) { try { const content = payload.message?.content; if (Array.isArray(content)) { return content .filter(c => c.type === "text") .map(c => c.text || "") .join(""); } if (typeof payload.message === "string") return payload.message; return payload.text || ""; } catch { return ""; } } // Deduplizierung: nur ein chat_final pro runId broadcasten const seenFinalRuns = new Set(); function handleGatewayMessage(msg) { if (msg.type === "res") { const status = msg.ok ? "OK" : `FEHLER: ${JSON.stringify(msg.error).slice(0, 100)}`; log("info", "gateway", `Response [${msg.id}]: ${status}`); if (pipelineActive) { if (msg.ok) plog(`Gateway ACK [${msg.id}] — Nachricht angenommen`); else plog(`Gateway NACK [${msg.id}] — ${JSON.stringify(msg.error).slice(0, 100)}`, "error"); } broadcast({ type: "response", msg }); return; } if (msg.type === "event") { const event = msg.event || "?"; const payload = msg.payload || {}; // ── agent Events: Streaming-Deltas vom LLM ── if (event === "agent") { const data = payload.data || {}; const delta = data.delta || ""; const stream = payload.stream || ""; if (delta && stream === "assistant") { broadcast({ type: "chat_delta", delta, payload }); } // Nach chat:final trickeln noch Aufraeum-Events rein — unterdruecken, // damit der Thinking-Indicator nicht wieder anspringt. const settled = lastChatFinalAt && (Date.now() - lastChatFinalAt) < SETTLED_WINDOW_MS; // Tool-Nutzung erkennen und broadcasten if (stream === "tool_use" || data.type === "tool_use") { const toolName = data.name || data.tool || payload.tool || ""; if (toolName && !settled) { broadcast({ type: "agent_activity", activity: "tool", tool: toolName, data }); log("info", "gateway", `Tool: ${toolName}`); } } if (!settled) { broadcast({ type: "agent_activity", activity: stream || "thinking" }); } updateAgentActivity(); return; } // ── chat Events: Snapshots mit state=delta|final ── if (event === "chat") { const state = payload.state || ""; const text = extractChatText(payload); if (state === "final") { const runId = payload.runId || ""; if (runId && seenFinalRuns.has(runId)) return; // Duplikat if (runId) { seenFinalRuns.add(runId); setTimeout(() => seenFinalRuns.delete(runId), 60000); } log("info", "gateway", `ANTWORT: "${text.slice(0, 200)}"`); lastChatFinalAt = Date.now(); if (pipelineActive) pipelineEnd(true, `"${text.slice(0, 120)}"`); broadcast({ type: "chat_final", text, payload }); broadcast({ type: "agent_activity", activity: "idle" }); pendingMessageTime = 0; // Watchdog: Antwort erhalten updateAgentActivity(); // Antwort in Backup-Log schreiben try { const entry = JSON.stringify({ ts: Date.now(), role: "assistant", text: text.slice(0, 2000), session: activeSessionKey }) + "\n"; fs.appendFileSync("/shared/config/chat_backup.jsonl", entry); } catch {} return; } if (state === "delta") { // Periodischer Snapshot — nicht einzeln loggen return; } if (state === "error") { const error = payload.error || text || "Unbekannt"; log("error", "gateway", `Chat-Fehler: ${error}`); if (pipelineActive) pipelineEnd(false, error); else broadcast({ type: "agent_activity", activity: "idle" }); broadcast({ type: "chat_error", error, payload }); return; } log("debug", "gateway", `chat state=${state}`); return; } // ── Legacy event names (chat:delta, chat:final, chat:error) ── if (event === "chat:delta") { const delta = payload.delta || payload.text || ""; if (delta) broadcast({ type: "chat_delta", delta, payload }); return; } if (event === "chat:final") { const runId = payload.runId || ""; if (runId && seenFinalRuns.has(runId)) return; // Duplikat if (runId) { seenFinalRuns.add(runId); setTimeout(() => seenFinalRuns.delete(runId), 60000); } const text = extractChatText(payload) || payload.text || ""; log("info", "gateway", `ANTWORT: "${text.slice(0, 200)}"`); lastChatFinalAt = Date.now(); if (pipelineActive) pipelineEnd(true, `"${text.slice(0, 120)}"`); else broadcast({ type: "agent_activity", activity: "idle" }); broadcast({ type: "chat_final", text, payload }); return; } if (event === "chat:error") { const error = payload.error || payload.message || "Unbekannt"; log("error", "gateway", `Chat-Fehler: ${error}`); if (pipelineActive) pipelineEnd(false, error); else broadcast({ type: "agent_activity", activity: "idle" }); broadcast({ type: "chat_error", error, payload }); return; } // ── Andere Events (tick, health, presence) ── if (event === "tick" || event === "health") return; // Noise log("debug", "gateway", `Event: ${event}`); } } function sendToGateway(text, isPipeline) { if (!gatewayWs || gatewayWs.readyState !== WebSocket.OPEN) { log("error", "gateway", "Nicht verbunden — kann nicht senden"); if (isPipeline) pipelineEnd(false, "Gateway nicht verbunden"); return false; } const reqId = nextReqId(); const msg = { type: "req", id: reqId, method: "chat.send", params: { sessionKey: activeSessionKey, message: text, idempotencyKey: crypto.randomUUID(), }, }; const payload = JSON.stringify(msg); log("debug", "gateway", `RAW >>> ${payload}`); gatewayWs.send(payload); pendingMessageTime = Date.now(); // Watchdog: Nachricht gesendet // Nachricht sofort in Backup-Log schreiben (OpenClaw speichert erst nach Run-Ende) try { fs.mkdirSync("/shared/config", { recursive: true }); const entry = JSON.stringify({ ts: Date.now(), role: "user", text, session: activeSessionKey }) + "\n"; fs.appendFileSync("/shared/config/chat_backup.jsonl", entry); } catch {} log("info", "gateway", `chat.send [${reqId}]: "${text}"`); if (isPipeline) plog(`chat.send [${reqId}] an Gateway gesendet — warte auf ACK...`); // Gateway-Nachrichten NICHT an RVS senden (sonst doppelter ARIA-Request via Bridge) return true; } // ── RVS Verbindung (optional) ─────────────────────────── function connectRVS(forcePlain) { if (!RVS_HOST || !RVS_TOKEN) { log("info", "rvs", "Nicht konfiguriert — ueberspringe"); state.rvs.status = "not_configured"; broadcastState(); return; } // Alte Verbindung sauber schliessen if (rvsWs) { try { rvsWs.removeAllListeners(); rvsWs.close(); } catch (_) {} rvsWs = null; } // TLS-Logik: wss zuerst, bei Fehler Fallback auf ws const useTls = RVS_TLS === "true" && !forcePlain; const proto = useTls ? "wss" : "ws"; const url = `${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`; state.rvs.status = "connecting"; broadcastState(); log("info", "rvs", `Verbinde: ${proto}://${RVS_HOST}:${RVS_PORT}`); let ws; try { ws = new WebSocket(url); } catch (err) { log("error", "rvs", `WebSocket erstellen fehlgeschlagen: ${err.message}`); if (useTls && RVS_TLS_FALLBACK === "true") { connectRVS(true); } return; } let fallbackTriggered = false; ws.on("open", () => { log("info", "rvs", `Verbunden (${proto})`); state.rvs.status = "connected"; state.rvs.lastError = null; rvsWs = ws; broadcastState(); // Keepalive: alle 25s ein Ping senden damit die Verbindung nicht stirbt const keepalive = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { try { ws.ping(); } catch (_) {} } else { clearInterval(keepalive); } }, 25000); ws._keepalive = keepalive; }); ws.on("message", (raw) => { try { const msg = JSON.parse(raw.toString()); if (msg.type === "chat" && msg.payload) { const sender = msg.payload.sender || "?"; // Eigene Nachrichten ignorieren (Echo) if (sender === "diagnostic") return; log("info", "rvs", `Chat von ${sender}: "${(msg.payload.text || "").slice(0, 100)}"`); if (pipelineActive) { pipelineEnd(true, `Antwort via RVS von ${sender}: "${(msg.payload.text || "").slice(0, 120)}"`); } broadcast({ type: "rvs_chat", msg }); } else if (msg.type === "file_saved" && msg.payload) { // Bild/Datei-Upload von der App — im Chat anzeigen const name = msg.payload.name || "?"; const serverPath = msg.payload.serverPath || ""; const mimeType = msg.payload.mimeType || ""; log("info", "rvs", `Datei empfangen: ${name} (${serverPath})`); // Als User-Nachricht mit Pfad broadcasten (Diagnostic zeigt Bilder inline) broadcast({ type: "rvs_chat", msg: { type: "chat", payload: { text: `Anhang: ${name}\n${serverPath}`, sender: "user" } }}); } else if (msg.type === "heartbeat") { // ignorieren } else { log("debug", "rvs", `Nachricht: ${JSON.stringify(msg).slice(0, 150)}`); } } catch {} }); ws.on("close", () => { log("warn", "rvs", "Verbindung geschlossen"); if (ws._keepalive) clearInterval(ws._keepalive); state.rvs.status = "disconnected"; if (rvsWs === ws) rvsWs = null; broadcastState(); if (!fallbackTriggered) { setTimeout(() => connectRVS(), 5000); } }); ws.on("error", (err) => { log("error", "rvs", `Fehler (${proto}): ${err.message}`); state.rvs.lastError = err.message; broadcastState(); // TLS Fallback if (useTls && RVS_TLS_FALLBACK === "true" && !fallbackTriggered) { fallbackTriggered = true; log("warn", "rvs", "TLS fehlgeschlagen — Fallback auf ws://"); try { ws.removeAllListeners(); ws.close(); } catch (_) {} if (rvsWs === ws) rvsWs = null; connectRVS(true); } }); } function sendToRVS_withResponse(sendType, sendPayload, expectType, clientWs) { if (!RVS_HOST || !RVS_TOKEN) return; const proto = RVS_TLS === "true" ? "wss" : "ws"; const url = `${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`; const freshWs = new WebSocket(url); const timeout = setTimeout(() => { try { freshWs.close(); } catch (_) {} clientWs.send(JSON.stringify({ type: expectType, payload: { voices: [], error: "Timeout" }, timestamp: Date.now() })); }, 15000); freshWs.on("open", () => { freshWs.send(JSON.stringify({ type: sendType, payload: sendPayload, timestamp: Date.now() })); }); freshWs.on("message", (raw) => { try { const resp = JSON.parse(raw.toString()); if (resp.type === expectType) { clearTimeout(timeout); clientWs.send(JSON.stringify(resp)); setTimeout(() => { try { freshWs.close(); } catch (_) {} }, 1000); } } catch {} }); freshWs.on("error", () => {}); } function sendToRVS_raw(msgObj) { if (!RVS_HOST || !RVS_TOKEN) return; const proto = RVS_TLS === "true" ? "wss" : "ws"; const url = `${proto}://${RVS_HOST}:${RVS_PORT}?token=${RVS_TOKEN}`; const freshWs = new WebSocket(url); freshWs.on("open", () => { freshWs.send(JSON.stringify(msgObj)); setTimeout(() => { try { freshWs.close(); } catch (_) {} }, 5000); }); freshWs.on("error", () => {}); } function sendToRVS(text, isPipeline) { // Ueber Gateway senden (zuverlaessig) UND an RVS fuer App-Sichtbarkeit // Die Bridge empfaengt RVS-Nachrichten von der App zuverlaessig, // aber die Diagnostic→RVS→Bridge Route hat Zombie-Probleme. // Deshalb: Gateway fuer ARIA, RVS nur fuer App-Anzeige. // 1. An Gateway senden (damit ARIA antwortet) const gatewayOk = sendToGateway(text, isPipeline); // 2. An RVS senden (damit die App die Nachricht sieht) sendToRVS_raw({ type: "chat", payload: { text, sender: "diagnostic" }, timestamp: Date.now(), }); return gatewayOk; } // ── Claude Proxy Test ──────────────────────────────────── async function testProxy(prompt) { state.proxy.status = "testing"; state.proxy.lastError = null; broadcastState(); log("info", "proxy", `Teste Proxy: ${PROXY_URL}`); try { // Schritt 1: Erreichbarkeit pruefen const healthUrl = `${PROXY_URL}/v1/models`; log("info", "proxy", `Rufe ab: ${healthUrl}`); const modelsRes = await fetch(healthUrl, { headers: { "Authorization": "Bearer not-needed" }, signal: AbortSignal.timeout(30000), }); if (!modelsRes.ok) { throw new Error(`Models-Endpoint: HTTP ${modelsRes.status} ${modelsRes.statusText}`); } const modelsData = await modelsRes.json(); const models = (modelsData.data || []).map(m => m.id).filter(Boolean); log("info", "proxy", `Proxy erreichbar — ${models.length} Model(s) verfuegbar`); // Modellnamen loggen + OpenClaw-Config Hinweis if (models.length > 0) { log("info", "proxy", `Modelle: ${models.join(", ")}`); log("info", "proxy", `Fuer docker-compose.yml (DEFAULT_MODEL): ${models.map(m => m.replace("openai/", "")).join(" | ")}`); } // Schritt 1b: Auth-Dateien im Proxy-Container pruefen try { const authInfo = await dockerExec("aria-proxy", "echo '--- /root/.config/claude/ ---' && ls -la /root/.config/claude/ 2>&1 && echo '--- /root/.claude/ ---' && ls -la /root/.claude/ 2>&1 && echo '--- Credential-Dateien ---' && find /root/.config/claude /root/.claude -name '*.json' -o -name '*credential*' -o -name '*auth*' -o -name '*token*' 2>/dev/null | head -20"); log("info", "proxy", `Auth-Dateien im Container:\n${authInfo}`); broadcast({ type: "proxy_auth", info: authInfo }); } catch (authErr) { log("warn", "proxy", `Auth-Check fehlgeschlagen: ${authErr.message}`); } // Schritt 2: Chat Completion testen (kurzer Prompt) const testPrompt = prompt || "Antworte in einem Satz: Wer bist du und funktionierst du?"; log("info", "proxy", `Sende Test-Prompt: "${testPrompt}"`); const chatRes = await fetch(`${PROXY_URL}/v1/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": "Bearer not-needed", }, body: JSON.stringify({ model: "claude-sonnet-4-6", messages: [{ role: "user", content: testPrompt }], max_tokens: 200, }), signal: AbortSignal.timeout(120000), // 2min — Cold Start braucht Zeit }); if (!chatRes.ok) { const errBody = await chatRes.text().catch(() => ""); throw new Error(`Chat-Completion: HTTP ${chatRes.status} — ${errBody.slice(0, 300)}`); } const chatData = await chatRes.json(); const reply = chatData.choices?.[0]?.message?.content || "(leer)"; log("info", "proxy", `Antwort: "${reply.slice(0, 200)}"`); state.proxy.status = "connected"; state.proxy.lastError = null; state.proxy.models = models; broadcastState(); broadcast({ type: "proxy_result", ok: true, reply, models }); } catch (err) { log("error", "proxy", `Fehler: ${err.message}`); state.proxy.status = "error"; state.proxy.lastError = err.message; broadcastState(); broadcast({ type: "proxy_result", ok: false, error: err.message }); } } // ── Claude Login im Proxy-Container ───────────────────── // ── Interaktives Terminal (xterm.js ↔ Docker Exec) ────── const net = require("net"); function attachTerminal(clientWs, containerName, cmd) { const createBody = JSON.stringify({ AttachStdin: true, AttachStdout: true, AttachStderr: true, Tty: true, Cmd: Array.isArray(cmd) ? cmd : ["sh", "-c", cmd], }); const createReq = http.request({ socketPath: "/var/run/docker.sock", path: `/containers/${containerName}/exec`, method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(createBody) }, }, (res) => { let data = ""; res.on("data", (c) => data += c); res.on("end", () => { if (res.statusCode !== 201) { clientWs.send(JSON.stringify({ type: "term_error", error: `Exec create failed: HTTP ${res.statusCode}` })); return; } const execId = JSON.parse(data).Id; // Exec starten — raw TCP socket fuer bidirektionalen Stream // Docker Exec mit Tty macht ein HTTP Upgrade auf raw stream const startBody = JSON.stringify({ Detach: false, Tty: true }); const sock = net.connect({ path: "/var/run/docker.sock" }, () => { // Raw HTTP Request senden (Upgrade-artig) const req = `POST /exec/${execId}/start HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nConnection: Upgrade\r\nUpgrade: tcp\r\nContent-Length: ${Buffer.byteLength(startBody)}\r\n\r\n${startBody}`; sock.write(req); }); let headersParsed = false; let headerBuf = ""; sock.on("data", (chunk) => { if (!headersParsed) { // HTTP Response Header parsen headerBuf += chunk.toString("utf-8"); const headerEnd = headerBuf.indexOf("\r\n\r\n"); if (headerEnd === -1) return; // Noch nicht komplett const headers = headerBuf.slice(0, headerEnd); const remaining = chunk.slice(chunk.length - (headerBuf.length - headerEnd - 4)); headersParsed = true; if (!headers.includes("200") && !headers.includes("101")) { clientWs.send(JSON.stringify({ type: "term_error", error: `Exec start failed: ${headers.split("\r\n")[0]}` })); sock.end(); return; } log("info", "proxy", "Terminal-Session gestartet"); clientWs.send(JSON.stringify({ type: "term_ready" })); clientWs._termSock = sock; // Verbleibende Daten nach dem Header if (remaining.length > 0) { clientWs.send(JSON.stringify({ type: "term_data", data: remaining.toString("base64") })); } return; } // Tty=true: Rohdaten direkt durchreichen if (clientWs.readyState === WebSocket.OPEN) { clientWs.send(JSON.stringify({ type: "term_data", data: chunk.toString("base64") })); } }); sock.on("end", () => { log("info", "proxy", "Terminal-Session beendet"); if (clientWs.readyState === WebSocket.OPEN) { clientWs.send(JSON.stringify({ type: "term_exit" })); } clientWs._termSock = null; setTimeout(() => checkProxyAuth(), 1000); }); sock.on("error", (err) => { log("error", "proxy", `Terminal-Socket-Fehler: ${err.message}`); if (clientWs.readyState === WebSocket.OPEN) { clientWs.send(JSON.stringify({ type: "term_error", error: err.message })); } }); // Wenn Browser-Client disconnected, Socket schliessen clientWs.on("close", () => { if (sock && !sock.destroyed) sock.end(); }); }); }); createReq.on("error", (err) => { clientWs.send(JSON.stringify({ type: "term_error", error: err.message })); }); createReq.write(createBody); createReq.end(); } function handleTermInput(clientWs, data) { if (clientWs._termSock && !clientWs._termSock.destroyed) { clientWs._termSock.write(Buffer.from(data, "base64")); } } // Credentials manuell einfuegen (von einem Rechner wo Claude eingeloggt ist) async function writeProxyCredentials(credentialsJson) { try { // Validieren const parsed = JSON.parse(credentialsJson); if (!parsed.claudeAiOauth && !parsed.oauth) { throw new Error("Ungueltig: Kein OAuth-Objekt gefunden. Erwartet 'claudeAiOauth' oder 'oauth' Key."); } log("info", "proxy", "Schreibe Credentials in Proxy-Container..."); // Escaped fuer Shell — Einfache Anfuehrungszeichen im JSON escapen const escaped = credentialsJson.replace(/'/g, "'\\''"); // In beide moegliche Speicherorte schreiben await dockerExec("aria-proxy", `mkdir -p /root/.config/claude && echo '${escaped}' > /root/.config/claude/.credentials.json && mkdir -p /root/.claude && echo '${escaped}' > /root/.claude/credentials.json`); log("info", "proxy", "Credentials geschrieben!"); broadcast({ type: "login_status", status: "done" }); broadcast({ type: "login_output", text: "Credentials erfolgreich geschrieben! Proxy muss neu gestartet werden." }); // Auth pruefen setTimeout(() => checkProxyAuth(), 500); } catch (err) { log("error", "proxy", `Credentials schreiben fehlgeschlagen: ${err.message}`); broadcast({ type: "login_status", status: "error", error: err.message }); } } async function checkProxyAuth() { try { log("info", "proxy", "Pruefe Auth-Dateien im Proxy-Container..."); // Breit suchen: Claude Code speichert Credentials je nach Version an verschiedenen Orten const authInfo = await dockerExec("aria-proxy", ` echo '=== /root/.config/claude/ ===' && ls -la /root/.config/claude/ 2>&1 && echo '' && echo '=== /root/.claude/ ===' && ls -la /root/.claude/ 2>&1 && echo '' && echo '=== /root/.claude/auth/ ===' && ls -la /root/.claude/auth/ 2>&1 && echo '' && echo '=== Credentials-Dateien (rekursiv) ===' && find /root/.config/claude /root/.claude -name '*.json' -o -name '*credential*' -o -name '*auth*' -o -name '*token*' -o -name '*oauth*' -o -name '*session*' 2>/dev/null | head -20 && echo '' && echo '=== .credentials.json ===' && cat /root/.config/claude/.credentials.json 2>/dev/null || echo '(nicht in .config/claude/)' && echo '' && echo '=== /root/.claude/credentials.json ===' && cat /root/.claude/credentials.json 2>/dev/null || echo '(nicht in .claude/)' && echo '' && echo '=== /root/.claude/auth/*.json ===' && cat /root/.claude/auth/*.json 2>/dev/null || echo '(keine auth/*.json)' `.trim()); log("info", "proxy", `Auth-Dateien:\n${authInfo}`); broadcast({ type: "proxy_auth", info: authInfo }); } catch (err) { log("error", "proxy", `Auth-Check fehlgeschlagen: ${err.message}`); broadcast({ type: "proxy_auth", info: null, error: err.message }); } } // ── OpenClaw Agent-Auth pruefen ────────────────────────── async function checkCoreAuth() { try { log("info", "gateway", "Pruefe OpenClaw Agent-Konfiguration..."); const info = await dockerExec("aria-core", ` echo '=== Agent-Verzeichnis ===' && ls -la /home/node/.openclaw/agents/main/agent/ 2>&1 && echo '' && echo '=== auth-profiles.json ===' && cat /home/node/.openclaw/agents/main/agent/auth-profiles.json 2>/dev/null || echo '(nicht vorhanden)' && echo '' && echo '=== Umgebungsvariablen ===' && echo "OPENAI_BASE_URL=$OPENAI_BASE_URL" && echo "OPENAI_API_KEY=$(echo $OPENAI_API_KEY | head -c 15)..." && echo "DEFAULT_MODEL=$DEFAULT_MODEL" && echo '' && echo '=== OpenClaw Version ===' && openclaw --version 2>/dev/null || echo '(openclaw CLI nicht gefunden)' && echo '' && echo '=== Agents Liste ===' && openclaw agents list 2>/dev/null || echo '(Befehl fehlgeschlagen)' `.trim()); log("info", "gateway", `OpenClaw Config:\n${info}`); broadcast({ type: "core_auth", info }); } catch (err) { log("error", "gateway", `Core-Auth-Check fehlgeschlagen: ${err.message}`); broadcast({ type: "core_auth", info: null, error: err.message }); } } // ── Docker Container Logs ──────────────────────────────── const CONTAINER_MAP = { gateway: "aria-core", proxy: "aria-proxy", bridge: "aria-bridge", }; function fetchDockerLogs(tab, tail) { const containerName = CONTAINER_MAP[tab]; if (!containerName) return; const lines = parseInt(tail, 10) || 100; const dockerPath = `/containers/${containerName}/logs?stdout=true&stderr=true&tail=${lines}×tamps=true`; return new Promise((resolve, reject) => { const req = http.request( { socketPath: "/var/run/docker.sock", path: dockerPath, method: "GET" }, (res) => { const chunks = []; res.on("data", (chunk) => chunks.push(chunk)); res.on("end", () => { // Docker log stream hat 8-byte Header pro Frame (stream type + size) const raw = Buffer.concat(chunks); const logLines = []; let offset = 0; while (offset < raw.length) { if (offset + 8 > raw.length) break; const size = raw.readUInt32BE(offset + 4); if (offset + 8 + size > raw.length) break; const line = raw.slice(offset + 8, offset + 8 + size).toString("utf-8").trimEnd(); if (line) logLines.push(line); offset += 8 + size; } resolve(logLines); }); } ); req.on("error", (err) => reject(err)); req.end(); }); } async function handleDockerLogs(ws, tab, tail) { const containerName = CONTAINER_MAP[tab]; if (!containerName) { ws.send(JSON.stringify({ type: "docker_logs", tab, error: `Unbekannter Tab: ${tab}` })); return; } try { log("info", "server", `Lade Docker-Logs: ${containerName} (tail ${tail || 100})`); const lines = await fetchDockerLogs(tab, tail); ws.send(JSON.stringify({ type: "docker_logs", tab, container: containerName, lines })); } catch (err) { log("error", "server", `Docker-Logs Fehler (${containerName}): ${err.message}`); ws.send(JSON.stringify({ type: "docker_logs", tab, error: err.message })); } } // ── Docker Exec (Befehl in Container ausfuehren) ──────── function dockerExec(containerName, cmd) { return new Promise((resolve, reject) => { const createBody = JSON.stringify({ AttachStdout: true, AttachStderr: true, Cmd: Array.isArray(cmd) ? cmd : ["sh", "-c", cmd], }); const createReq = http.request({ socketPath: "/var/run/docker.sock", path: `/containers/${containerName}/exec`, method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(createBody) }, }, (res) => { let data = ""; res.on("data", (c) => data += c); res.on("end", () => { if (res.statusCode !== 201) return reject(new Error(`Exec create: HTTP ${res.statusCode} — ${data.slice(0, 200)}`)); const execId = JSON.parse(data).Id; // Exec starten const startBody = JSON.stringify({ Detach: false }); const startReq = http.request({ socketPath: "/var/run/docker.sock", path: `/exec/${execId}/start`, method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(startBody) }, }, (sRes) => { const chunks = []; sRes.on("data", (c) => chunks.push(c)); sRes.on("end", () => { // Docker multiplexed stream: 8-byte header pro Frame const raw = Buffer.concat(chunks); const lines = []; let offset = 0; while (offset < raw.length) { if (offset + 8 > raw.length) { lines.push(raw.slice(offset).toString("utf-8").trim()); break; } const size = raw.readUInt32BE(offset + 4); if (size === 0 || offset + 8 + size > raw.length) { lines.push(raw.slice(offset).toString("utf-8").trim()); break; } const line = raw.slice(offset + 8, offset + 8 + size).toString("utf-8").trimEnd(); if (line) lines.push(line); offset += 8 + size; } resolve(lines.join("\n")); }); }); startReq.on("error", reject); startReq.write(startBody); startReq.end(); }); }); createReq.on("error", reject); createReq.write(createBody); createReq.end(); }); } // ── Hilfsfunktionen ───────────────────────────────────── function waitForMessage(ws, timeoutMs) { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error(`Timeout (${timeoutMs}ms)`)); }, timeoutMs); ws.once("message", (data) => { clearTimeout(timeout); resolve(data.toString()); }); }); } // ── Watchdog: Stuck Run Erkennung ──────────────────────── let lastAgentActivity = Date.now(); let watchdogWarned = false; let watchdogFixAttempted = false; let pendingMessageTime = 0; // Wann wurde die letzte Nachricht gesendet function updateAgentActivity() { lastAgentActivity = Date.now(); watchdogWarned = false; } // Watchdog prüft alle 30s ob ARIA nach einer gesendeten Nachricht reagiert setInterval(async () => { if (pendingMessageTime === 0) return; // Keine Nachricht gesendet const waitingMs = Date.now() - pendingMessageTime; // Nach 2min ohne Agent-Activity: Warnung if (waitingMs > 120000 && !watchdogWarned) { watchdogWarned = true; log("warn", "server", `Watchdog: Keine ARIA-Aktivitaet seit ${Math.round(waitingMs / 1000)}s — moeglicherweise stuck`); broadcast({ type: "watchdog", status: "warning", waitingMs, message: "ARIA reagiert nicht — moeglicherweise stuck Run" }); } // Nach 5min: doctor --fix if (waitingMs > 300000 && watchdogWarned && !watchdogFixAttempted) { watchdogFixAttempted = true; log("error", "server", "Watchdog: 5min ohne Antwort — fuehre openclaw doctor --fix aus"); broadcast({ type: "watchdog", status: "fixing", message: "Auto-Fix: openclaw doctor --fix" }); try { await dockerExec("aria-core", "openclaw doctor --fix 2>/dev/null || true"); log("info", "server", "Watchdog: doctor --fix ausgefuehrt"); broadcast({ type: "watchdog", status: "fixed", message: "doctor --fix ausgefuehrt — warte auf Antwort..." }); } catch (err) { log("error", "server", `Watchdog: doctor --fix fehlgeschlagen: ${err.message}`); } } // Nach 8min: Container neustarten if (waitingMs > 480000 && watchdogFixAttempted) { log("error", "server", "Watchdog: 8min ohne Antwort — starte aria-core + aria-proxy neu"); broadcast({ type: "watchdog", status: "restarting", message: "Container-Restart: aria-core + aria-proxy" }); try { const { execSync } = require("child_process"); execSync("docker restart aria-core aria-proxy", { timeout: 60000 }); log("info", "server", "Watchdog: Container neugestartet"); broadcast({ type: "watchdog", status: "restarted", message: "Container neugestartet — warte auf Gateway-Reconnect..." }); // Gateway wird sich automatisch neu verbinden } catch (err) { log("error", "server", `Watchdog: Container-Restart fehlgeschlagen: ${err.message}`); broadcast({ type: "watchdog", status: "error", message: `Restart fehlgeschlagen: ${err.message}` }); } pendingMessageTime = 0; watchdogWarned = false; watchdogFixAttempted = false; } }, 30000); // ── HTTP Server + WebSocket fuer Browser ──────────────── const htmlPath = path.join(__dirname, "index.html"); const server = http.createServer((req, res) => { if (req.url === "/" || req.url === "/index.html") { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(fs.readFileSync(htmlPath, "utf-8")); } else if (req.url === "/api/state") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ state, logs: logs.slice(-100) })); } else if (req.url === "/api/session") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ sessionKey: activeSessionKey })); } else if (req.url === "/api/cancel" && req.method === "POST") { log("warn", "server", "HTTP /api/cancel — Cancel-Request (von Bridge)"); pendingMessageTime = 0; watchdogWarned = false; watchdogFixAttempted = false; if (pipelineActive) pipelineEnd(false, "Vom Benutzer abgebrochen (App)"); else broadcast({ type: "agent_activity", activity: "idle" }); dockerExec("aria-core", "openclaw doctor --fix 2>/dev/null || true").catch(() => {}); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true })); } else if (req.url.startsWith("/shared/")) { // Dateien aus Shared Volume ausliefern (Bilder, Uploads) const filePath = decodeURIComponent(req.url); const safePath = path.resolve(filePath); if (!safePath.startsWith("/shared/")) { res.writeHead(403); res.end("Forbidden"); return; } try { if (!fs.existsSync(safePath)) { res.writeHead(404); res.end("Not Found"); return; } const ext = path.extname(safePath).toLowerCase(); const mimeTypes = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif", ".pdf": "application/pdf", ".txt": "text/plain", ".json": "application/json" }; const contentType = mimeTypes[ext] || "application/octet-stream"; const data = fs.readFileSync(safePath); res.writeHead(200, { "Content-Type": contentType, "Content-Length": data.length }); res.end(data); } catch (err) { res.writeHead(500); res.end("Error"); } } else { res.writeHead(404); res.end("Not Found"); } }); const wss = new WebSocketServer({ server }); wss.on("connection", (ws) => { browserClients.add(ws); // Initialen State + letzte Logs senden ws.send(JSON.stringify({ type: "init", state, logs: logs.slice(-100) })); ws.on("message", (raw) => { try { const msg = JSON.parse(raw.toString()); if (msg.action === "test_gateway") { pipelineStart("Gateway", msg.text || "aria lebst du noch?"); sendToGateway(msg.text || "aria lebst du noch?", true); } else if (msg.action === "test_rvs") { pipelineStart("RVS", msg.text || "aria lebst du noch?"); sendToRVS(msg.text || "aria lebst du noch?", true); } else if (msg.action === "reconnect_gateway") { connectGateway(); } else if (msg.action === "reconnect_rvs") { connectRVS(); } else if (msg.action === "test_proxy") { testProxy(msg.text); } else if (msg.action === "check_proxy_auth") { checkProxyAuth(); } else if (msg.action === "proxy_login") { attachTerminal(ws, "aria-proxy", "claude login"); } else if (msg.action === "core_terminal") { // Interaktive Shell in aria-core (fuer openclaw agents, etc.) attachTerminal(ws, "aria-core", msg.cmd || "sh"); } else if (msg.action === "check_core_auth") { checkCoreAuth(); } else if (msg.action === "term_input") { handleTermInput(ws, msg.data); } else if (msg.action === "write_credentials") { writeProxyCredentials(msg.credentials); } else if (msg.action === "docker_logs") { handleDockerLogs(ws, msg.tab, msg.tail); } else if (msg.action === "live_ssh_start") { startLiveSSH(ws); } else if (msg.action === "live_ssh_input") { if (ws._sshSock) ws._sshSock.write(msg.data); } else if (msg.action === "live_ssh_close") { if (ws._sshSock) { ws._sshSock.end(); ws._sshSock = null; } } else if (msg.action === "send_file") { // Datei von Diagnostic an Bridge via RVS senden sendToRVS_raw({ type: "file", payload: { name: msg.name, type: msg.type, size: msg.size, base64: msg.base64 }, timestamp: Date.now(), }); log("info", "server", `Datei gesendet: ${msg.name} (${msg.type})`); } else if (msg.action === "cancel_request") { // Laufende Anfrage abbrechen — doctor --fix beendet stuck runs log("warn", "server", "Anfrage abgebrochen — fuehre doctor --fix aus"); pendingMessageTime = 0; watchdogWarned = false; watchdogFixAttempted = false; if (pipelineActive) pipelineEnd(false, "Vom Benutzer abgebrochen"); broadcast({ type: "agent_activity", activity: "idle" }); dockerExec("aria-core", "openclaw doctor --fix 2>/dev/null || true").catch(() => {}); } else if (msg.action === "voice_upload") { // Voice-Samples an XTTS-Bridge via RVS weiterleiten, auf Bestätigung warten log("info", "server", `Voice-Upload '${msg.name}' (${(msg.samples || []).length} Samples) sende an RVS...`); sendToRVS_withResponse("voice_upload", { name: msg.name, samples: msg.samples }, "xtts_voice_saved", ws); } else if (msg.action === "xtts_list_voices") { // Frische Verbindung die auf Antwort wartet sendToRVS_withResponse("xtts_list_voices", {}, "xtts_voices_list", ws); } else if (msg.action === "get_voice_config") { handleGetVoiceConfig(ws); } else if (msg.action === "send_voice_config") { // Stimmen-Config persistent speichern + an Bridge via RVS senden // Bestehende Config lesen um Felder zu mergen die dieser Call nicht setzt let existing = {}; try { existing = JSON.parse(fs.readFileSync("/shared/config/voice_config.json", "utf-8")); } catch {} const voiceConfig = { ...existing, defaultVoice: msg.defaultVoice || "ramona", highlightVoice: msg.highlightVoice || "thorsten", ttsEnabled: msg.ttsEnabled !== false, ttsEngine: msg.ttsEngine || "piper", xttsVoice: msg.xttsVoice || "", speedRamona: msg.speedRamona || 1.0, speedThorsten: msg.speedThorsten || 1.0, }; if (msg.whisperModel !== undefined) voiceConfig.whisperModel = msg.whisperModel; try { fs.mkdirSync("/shared/config", { recursive: true }); fs.writeFileSync("/shared/config/voice_config.json", JSON.stringify(voiceConfig, null, 2)); } catch {} sendToRVS_raw({ type: "config", payload: voiceConfig, timestamp: Date.now() }); log("info", "server", `Voice-Config gespeichert+gesendet: default=${voiceConfig.defaultVoice}, whisper=${voiceConfig.whisperModel || "-"}`); } else if (msg.action === "get_triggers") { handleGetTriggers(ws); } else if (msg.action === "save_triggers") { handleSaveTriggers(ws, msg.triggers || []); } else if (msg.action === "test_tts") { handleTestTTS(ws, msg.voice || "ramona", msg.text || "Test"); } else if (msg.action === "check_tts") { handleCheckTTS(ws); } else if (msg.action === "check_desktop") { checkDesktopAvailable(ws); } else if (msg.action === "load_chat_history") { handleLoadChatHistory(ws); } else if (msg.action === "list_sessions") { handleListSessions(ws); } else if (msg.action === "read_session") { handleReadSession(ws, msg.sessionPath); } else if (msg.action === "export_session") { handleExportSession(ws, msg.sessionPath, msg.sessionKey); } else if (msg.action === "delete_session") { handleDeleteSession(ws, msg.sessionPath); } else if (msg.action === "set_active_session") { handleSetActiveSession(ws, msg.sessionKey); } else if (msg.action === "get_active_session") { ws.send(JSON.stringify({ type: "active_session", sessionKey: activeSessionKey })); } else if (msg.action === "create_session") { handleCreateSession(ws, msg.sessionName); } else if (msg.action === "restart_session") { handleRestartSession(ws); } else if (msg.action === "list_brain") { handleListBrain(ws); } else if (msg.action === "read_brain_file") { handleReadBrainFile(ws, msg.filename); // ── Einstellungen ── // list_permissions / save_permissions entfernt — Alles-oder-Nichts via --dangerously-skip-permissions } else if (msg.action === "get_model") { handleGetModel(ws); } else if (msg.action === "set_model") { handleSetModel(ws, msg.model); } else if (msg.action === "get_openclaw_config") { handleGetOpenClawConfig(ws); } } catch {} }); ws.on("close", () => { browserClients.delete(ws); }); }); // ── Live SSH ───────────────────────────────────────────── function startLiveSSH(clientWs) { // Bestehende Session schliessen if (clientWs._sshSock) { try { clientWs._sshSock.end(); } catch (_) {} clientWs._sshSock = null; } const createBody = JSON.stringify({ AttachStdin: true, AttachStdout: true, AttachStderr: true, Tty: true, Cmd: ["ssh", "-tt", "aria-wohnung"], }); const createReq = http.request({ socketPath: "/var/run/docker.sock", path: "/containers/aria-core/exec", method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(createBody) }, }, (res) => { let data = ""; res.on("data", (c) => data += c); res.on("end", () => { if (res.statusCode !== 201) { clientWs.send(JSON.stringify({ type: "live_ssh_error", error: `Exec create failed: HTTP ${res.statusCode}` })); return; } const execId = JSON.parse(data).Id; const startBody = JSON.stringify({ Detach: false, Tty: true }); const sock = net.connect({ path: "/var/run/docker.sock" }, () => { const req = `POST /exec/${execId}/start HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nConnection: Upgrade\r\nUpgrade: tcp\r\nContent-Length: ${Buffer.byteLength(startBody)}\r\n\r\n${startBody}`; sock.write(req); }); let headersParsed = false; let headerBuf = ""; sock.on("data", (chunk) => { if (!headersParsed) { headerBuf += chunk.toString("utf-8"); const headerEnd = headerBuf.indexOf("\r\n\r\n"); if (headerEnd === -1) return; const headers = headerBuf.slice(0, headerEnd); const remaining = chunk.slice(chunk.length - (headerBuf.length - headerEnd - 4)); headersParsed = true; if (!headers.includes("200") && !headers.includes("101")) { clientWs.send(JSON.stringify({ type: "live_ssh_error", error: `SSH start failed` })); sock.end(); return; } log("info", "server", "Live SSH-Session gestartet"); clientWs.send(JSON.stringify({ type: "live_ssh_connected" })); clientWs._sshSock = sock; if (remaining.length > 0) { clientWs.send(JSON.stringify({ type: "live_ssh_data", data: remaining.toString("base64") })); } return; } // Stream-Daten ans Frontend try { clientWs.send(JSON.stringify({ type: "live_ssh_data", data: chunk.toString("base64") })); } catch (_) { sock.end(); } }); sock.on("close", () => { clientWs._sshSock = null; try { clientWs.send(JSON.stringify({ type: "live_ssh_closed" })); } catch (_) {} log("info", "server", "Live SSH-Session beendet"); }); sock.on("error", (err) => { clientWs._sshSock = null; try { clientWs.send(JSON.stringify({ type: "live_ssh_error", error: err.message })); } catch (_) {} }); }); }); createReq.on("error", (err) => { clientWs.send(JSON.stringify({ type: "live_ssh_error", error: `Docker: ${err.message}` })); }); createReq.end(createBody); } // ── Voice-Config laden ──────────────────────────────── function handleGetVoiceConfig(clientWs) { try { const configPath = "/shared/config/voice_config.json"; if (fs.existsSync(configPath)) { const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); clientWs.send(JSON.stringify({ type: "voice_config", ...config })); } else { clientWs.send(JSON.stringify({ type: "voice_config", defaultVoice: "ramona", highlightVoice: "thorsten", ttsEnabled: true })); } } catch (err) { clientWs.send(JSON.stringify({ type: "voice_config", defaultVoice: "ramona", highlightVoice: "thorsten", ttsEnabled: true })); } } // ── Highlight-Trigger ───────────────────────────────── const TRIGGERS_FILE = "/shared/config/highlight_triggers.json"; async function handleGetTriggers(clientWs) { try { // Zuerst aus Shared Volume lesen, dann Fallback auf Bridge-Defaults let triggers; if (fs.existsSync(TRIGGERS_FILE)) { triggers = JSON.parse(fs.readFileSync(TRIGGERS_FILE, "utf-8")); } else { // Defaults aus der Bridge lesen const result = await dockerExec("aria-bridge", `python3 -c " import sys; sys.path.insert(0,'/app') from aria_bridge import EPIC_TRIGGERS print('\\n'.join(EPIC_TRIGGERS)) "`); triggers = result.trim().split("\n").filter(t => t); } clientWs.send(JSON.stringify({ type: "trigger_list", triggers })); } catch (err) { clientWs.send(JSON.stringify({ type: "trigger_list", triggers: [], error: err.message })); } } async function handleSaveTriggers(clientWs, triggers) { try { // In Shared Volume speichern (fuer Bridge lesbar) fs.mkdirSync("/shared/config", { recursive: true }); fs.writeFileSync(TRIGGERS_FILE, JSON.stringify(triggers, null, 2)); log("info", "server", `${triggers.length} Highlight-Trigger gespeichert`); // Bridge informieren (wird beim naechsten Start geladen) clientWs.send(JSON.stringify({ type: "trigger_list", triggers })); } catch (err) { log("error", "server", `Trigger speichern fehlgeschlagen: ${err.message}`); } } // ── TTS Diagnose ────────────────────────────────────── async function handleTestTTS(clientWs, voice, text) { try { log("info", "server", `TTS-Test: ${voice} — "${text}"`); const result = await dockerExec("aria-bridge", `python3 -c " import time, sys sys.path.insert(0, '/app') from piper import PiperVoice import wave, tempfile, os voices = {'ramona': '/voices/de_DE-ramona-low.onnx', 'thorsten': '/voices/de_DE-thorsten-high.onnx'} path = voices.get('${voice}') if not path or not os.path.exists(path): print('FEHLER: Stimme nicht gefunden') sys.exit(1) v = PiperVoice.load(path) start = time.time() tmp = tempfile.NamedTemporaryFile(suffix='.wav', delete=False) with wave.open(tmp.name, 'wb') as wf: wf.setnchannels(1) wf.setsampwidth(2) wf.setframerate(v.config.sample_rate) v.synthesize('${text.replace(/'/g, "\\'")}', wf) size = os.path.getsize(tmp.name) dur = int((time.time() - start) * 1000) os.unlink(tmp.name) print(f'OK:{dur}:{size}') "`); const parts = result.trim().split(":"); if (parts[0] === "OK") { clientWs.send(JSON.stringify({ type: "tts_result", ok: true, voice, duration: parts[1], size: parts[2] })); } else { clientWs.send(JSON.stringify({ type: "tts_result", ok: false, voice, error: result.trim() })); } } catch (err) { clientWs.send(JSON.stringify({ type: "tts_result", ok: false, voice, error: err.message })); } } async function handleCheckTTS(clientWs) { try { const result = await dockerExec("aria-bridge", `python3 -c " import os, json voices = {} for name, path in [('ramona', '/voices/de_DE-ramona-low.onnx'), ('thorsten', '/voices/de_DE-thorsten-high.onnx')]: voices[name] = os.path.exists(path) print(json.dumps(voices)) "`); const voices = JSON.parse(result.trim()); const available = Object.entries(voices).filter(([,v]) => v).map(([k]) => k); const missing = Object.entries(voices).filter(([,v]) => !v).map(([k]) => k); clientWs.send(JSON.stringify({ type: "tts_status", ok: missing.length === 0, voices: available, defaultVoice: "ramona", highlightVoice: "thorsten", error: missing.length > 0 ? `Fehlend: ${missing.join(", ")}` : null, })); } catch (err) { clientWs.send(JSON.stringify({ type: "tts_status", ok: false, error: err.message })); } } function checkDesktopAvailable(clientWs) { // Pruefen ob VNC auf der VM laeuft (Port 5900/5901) const checkSock = net.connect({ host: "host.docker.internal", port: 5901 }, () => { checkSock.end(); clientWs.send(JSON.stringify({ type: "desktop_status", available: true, url: `http://${clientWs._socket?.remoteAddress || "localhost"}:6080/vnc.html?autoconnect=true`, message: "VNC Desktop verfuegbar", })); }); checkSock.on("error", () => { clientWs.send(JSON.stringify({ type: "desktop_status", available: false, message: "Kein VNC-Server auf aria-wohnung gefunden (Port 5901)", })); }); checkSock.setTimeout(3000, () => { checkSock.destroy(); clientWs.send(JSON.stringify({ type: "desktop_status", available: false, message: "VNC-Server nicht erreichbar (Timeout)", })); }); } // ── Session Viewer ────────────────────────────────────── const SESSIONS_DIR = "/home/node/.openclaw/agents/main/sessions"; async function handleListSessions(clientWs) { try { log("info", "server", "Lade Sessions aus aria-core..."); // sessions.json als Index lesen + Datei-Details holen (inkl. .reset.* Archive) const raw = await dockerExec("aria-core", ` cat ${SESSIONS_DIR}/sessions.json 2>/dev/null || echo '{}' && echo '===FILE_DETAILS===' && for f in ${SESSIONS_DIR}/*.jsonl ${SESSIONS_DIR}/*.jsonl.reset.*; do [ -f "$f" ] || continue name=$(basename "$f") msgs=$(grep -cE '"role":"(user|assistant)"' "$f" 2>/dev/null || echo 0) size=$(du -h "$f" 2>/dev/null | cut -f1) modified=$(stat -c '%Y' "$f" 2>/dev/null || echo 0) echo "FILE:$name|LINES:$msgs|SIZE:$size|MODIFIED:$modified" done `.trim()); const parts = raw.split("===FILE_DETAILS==="); let sessionsIndex = {}; try { sessionsIndex = JSON.parse(parts[0].trim()); } catch {} // Datei-Details parsen const fileDetails = {}; if (parts[1]) { for (const line of parts[1].trim().split("\n")) { if (!line.startsWith("FILE:")) continue; const segs = {}; for (const seg of line.split("|")) { const idx = seg.indexOf(":"); if (idx > 0) segs[seg.slice(0, idx)] = seg.slice(idx + 1); } if (segs.FILE) fileDetails[segs.FILE] = segs; } } // Sessions zusammenbauen: Index + Datei-Info kombinieren const sessions = []; // Aus sessions.json die Session-Keys und IDs const indexEntries = Array.isArray(sessionsIndex) ? sessionsIndex : Array.isArray(sessionsIndex.sessions) ? sessionsIndex.sessions : Object.entries(sessionsIndex).map(([k, v]) => ({ key: k, ...(typeof v === "object" ? v : { id: v }) })); for (const entry of indexEntries) { const id = entry.id || entry.sessionId || ""; const rawKey = entry.key || entry.sessionKey || entry.name || id; // "agent:main:aria-diagnostic" → "aria-diagnostic" const key = rawKey.replace(/^agent:main:/, ""); const filename = `${id}.jsonl`; const details = fileDetails[filename] || {}; // updatedAt aus sessions.json (ms) ist genauer als stat const updatedAt = entry.updatedAt || 0; const model = entry.model || ""; sessions.push({ path: `${SESSIONS_DIR}/${filename}`, sessionKey: key, sessionId: id, lines: parseInt(details.LINES) || 0, size: details.SIZE || "?", modified: updatedAt ? Math.floor(updatedAt / 1000) : (parseInt(details.MODIFIED) || 0), model, }); // Aus fileDetails entfernen (fuer Waisen-Check) delete fileDetails[filename]; } // Dateien die nicht im Index stehen (Waisen ODER Reset-Archive) for (const [filename, details] of Object.entries(fileDetails)) { // .jsonl.reset..Z → archivierte Session (OpenClaw-Reset) const resetMatch = filename.match(/^([a-f0-9-]+)\.jsonl\.reset\.(.+)\.Z$/); if (resetMatch) { const id = resetMatch[1]; // Timestamp ISO-8601 parsen (in Dateinamen: : durch - ersetzt) // z.B. 2026-04-18T09-49-44.814 → 2026-04-18T09:49:44.814Z const tsStr = resetMatch[2].replace(/T(\d{2})-(\d{2})-(\d{2})/, "T$1:$2:$3"); const resetAt = Math.floor(new Date(tsStr + "Z").getTime() / 1000) || parseInt(details.MODIFIED) || 0; sessions.push({ path: `${SESSIONS_DIR}/${filename}`, sessionKey: id.slice(0, 8) + "… (archiv)", sessionId: id, lines: parseInt(details.LINES) || 0, size: details.SIZE || "?", modified: resetAt, archived: true, resetAt, }); continue; } // Echte Waisen (UUID.jsonl ohne Eintrag in sessions.json) const id = filename.replace(".jsonl", ""); sessions.push({ path: `${SESSIONS_DIR}/${filename}`, sessionKey: id.slice(0, 12) + "...", sessionId: id, lines: parseInt(details.LINES) || 0, size: details.SIZE || "?", modified: parseInt(details.MODIFIED) || 0, orphan: true, }); } sessions.sort((a, b) => b.modified - a.modified); clientWs.send(JSON.stringify({ type: "sessions_list", sessions })); log("info", "server", `${sessions.length} Session(s) gefunden`); } catch (err) { log("error", "server", `Sessions laden fehlgeschlagen: ${err.message}`); clientWs.send(JSON.stringify({ type: "sessions_list", sessions: [], error: err.message })); } } async function handleReadSession(clientWs, sessionPath) { if (!sessionPath || sessionPath.includes("..")) { clientWs.send(JSON.stringify({ type: "session_detail", error: "Ungueltiger Pfad" })); return; } try { // Letzte 100 Zeilen der Session (JSONL) const raw = await dockerExec("aria-core", `tail -100 '${sessionPath.replace(/'/g, "")}'`); const messages = []; for (const line of raw.split("\n")) { if (!line.trim()) continue; try { const obj = JSON.parse(line); messages.push(obj); } catch {} } clientWs.send(JSON.stringify({ type: "session_detail", path: sessionPath, messages, raw: messages.length === 0 ? raw : undefined })); } catch (err) { clientWs.send(JSON.stringify({ type: "session_detail", error: err.message })); } } async function handleExportSession(clientWs, sessionPath, sessionKey) { if (!sessionPath || sessionPath.includes("..") || !sessionPath.startsWith(SESSIONS_DIR)) { clientWs.send(JSON.stringify({ type: "session_export", ok: false, error: "Ungueltiger Pfad" })); return; } try { const safePath = sessionPath.replace(/'/g, ""); const raw = await dockerExec("aria-core", `cat '${safePath}'`); const lines = raw.split("\n").filter(l => l.trim()); const blocks = []; for (const line of lines) { let obj; try { obj = JSON.parse(line); } catch { continue; } if (obj.type !== "message" || !obj.message) continue; const role = obj.message.role; if (role !== "user" && role !== "assistant") continue; let text = ""; const content = obj.message.content; if (typeof content === "string") text = content; else if (Array.isArray(content)) text = content.filter(c => c.type === "text").map(c => c.text || "").join("\n"); if (!text) continue; if (role === "user") { text = text.replace(/^Sender \(untrusted metadata\):[\s\S]*?```[\s\S]*?```\s*\n*/m, "").trim(); text = text.replace(/^\[.*?\]\s*/, "").trim(); } else { text = text.replace(/^\[\[reply_to_\w+\]\]\s*/g, "").trim(); } if (!text) continue; const ts = obj.message.timestamp || obj.timestamp || 0; const when = ts ? new Date(ts).toISOString().replace("T", " ").slice(0, 19) : ""; const heading = role === "user" ? "## 🧑 User" : "## 🤖 ARIA"; blocks.push(`${heading}${when ? ` — ${when}` : ""}\n\n${text}`); } const exportedAt = new Date().toISOString().replace("T", " ").slice(0, 19); const title = sessionKey || sessionPath.split("/").pop().replace(".jsonl", ""); const markdown = [ `# Session: ${title}`, ``, `Exportiert: ${exportedAt} `, `Quelle: ${sessionPath}`, ``, `---`, ``, blocks.join("\n\n---\n\n"), ``, ].join("\n"); const safeKey = (sessionKey || "session").replace(/[^a-zA-Z0-9_-]/g, "_"); const filename = `${exportedAt.slice(0, 10)}_${safeKey}.md`; clientWs.send(JSON.stringify({ type: "session_export", ok: true, filename, markdown })); log("info", "server", `Session exportiert: ${filename} (${blocks.length} Nachrichten)`); } catch (err) { log("error", "server", `Session-Export fehlgeschlagen: ${err.message}`); clientWs.send(JSON.stringify({ type: "session_export", ok: false, error: err.message })); } } async function handleDeleteSession(clientWs, sessionPath) { if (!sessionPath || sessionPath.includes("..") || !sessionPath.startsWith(SESSIONS_DIR)) { clientWs.send(JSON.stringify({ type: "session_deleted", ok: false, error: "Ungueltiger Pfad" })); return; } try { log("warn", "server", `Loesche Session: ${sessionPath}`); const safePath = sessionPath.replace(/'/g, ""); // Session-ID aus Pfad extrahieren (UUID.jsonl) const filename = safePath.split("/").pop(); const sessionId = filename.replace(".jsonl", ""); // 1. JSONL-Datei loeschen await dockerExec("aria-core", `rm -f '${safePath}'`); // 2. Eintrag aus sessions.json entfernen try { const sFile = `${SESSIONS_DIR}/sessions.json`; const script = [ 'const fs=require("fs");', `const f="${sFile}",sid="${sessionId}";`, 'try{const d=JSON.parse(fs.readFileSync(f,"utf8"));', 'for(const k of Object.keys(d)){const v=d[k];', 'if(v&&(v.sessionId===sid||v.id===sid))delete d[k];}', 'fs.writeFileSync(f,JSON.stringify(d,null,2));}catch(e){}', ].join(""); const b64 = Buffer.from(script).toString("base64"); await dockerExec("aria-core", `echo ${b64} | base64 -d | node`); } catch (e) { log("warn", "server", `sessions.json Update fehlgeschlagen: ${e.message}`); } clientWs.send(JSON.stringify({ type: "session_deleted", ok: true, path: sessionPath })); log("info", "server", "Session geloescht"); } catch (err) { clientWs.send(JSON.stringify({ type: "session_deleted", ok: false, error: err.message })); } } // ── Session-Aufloesung: letzte aktive Session finden ──── // Wird nach Gateway-(Re-)Connect aufgerufen. Darf die explizit gewaehlte // Session NIE ueberschreiben — nur beim absoluten Erststart auto-picken. async function resolveActiveSession() { if (sessionFromFile) { log("info", "server", `Session '${activeSessionKey}' aus /data — keine Auto-Wahl`); return; } const indexRaw = await dockerExec("aria-core", `cat ${SESSIONS_DIR}/sessions.json 2>/dev/null || echo '{}'`); log("debug", "server", `sessions.json: ${indexRaw.slice(0, 500)}`); let sessionsIndex = {}; try { sessionsIndex = JSON.parse(indexRaw.trim()); } catch { return; } const entries = Array.isArray(sessionsIndex) ? sessionsIndex : Array.isArray(sessionsIndex.sessions) ? sessionsIndex.sessions : Object.entries(sessionsIndex).map(([k, v]) => ({ key: k, ...(typeof v === "object" ? v : { id: v }) })); if (entries.length === 0) return; // Vorhandene Keys loggen const keys = entries.map(e => (e.key || e.sessionKey || e.name || "?").replace(/^agent:main:/, "")); log("info", "server", `Verfuegbare Sessions: [${keys.join(", ")}]`); // Neueste Session nehmen — aber user-definierte bevorzugen. // aria-bridge / aria-diagnostic werden von den Services auto-erstellt; // bei erstem Start soll lieber eine "echte" Session gewaehlt werden, // falls vorhanden. const AUTO_KEYS = new Set(["aria-bridge", "aria-diagnostic"]); const normalise = (e) => (e.key || e.sessionKey || e.name || "").replace(/^agent:main:/, ""); const userEntries = entries.filter(e => !AUTO_KEYS.has(normalise(e))); const pool = userEntries.length > 0 ? userEntries : entries; let newest = null; let newestTime = 0; for (const entry of pool) { const t = entry.updatedAt || entry.createdAt || 0; if (t >= newestTime) { newestTime = t; newest = entry; } } if (newest) { const key = normalise(newest); if (key) { activeSessionKey = key; persistActiveSession(activeSessionKey); log("info", "server", `Auto-Wahl Erststart: '${activeSessionKey}'`); for (const c of browserClients) { c.send(JSON.stringify({ type: "active_session", sessionKey: activeSessionKey })); } } } } // ── Chat-History aus aktiver Session laden (Display-Only) ── async function handleLoadChatHistory(clientWs) { try { // Session-ID fuer den aktiven sessionKey finden const indexRaw = await dockerExec("aria-core", `cat ${SESSIONS_DIR}/sessions.json 2>/dev/null || echo '{}'`); let sessionsIndex = {}; try { sessionsIndex = JSON.parse(indexRaw.trim()); } catch {} const entries = Array.isArray(sessionsIndex) ? sessionsIndex : Array.isArray(sessionsIndex.sessions) ? sessionsIndex.sessions : Object.entries(sessionsIndex).map(([k, v]) => ({ key: k, ...(typeof v === "object" ? v : { id: v }) })); let sessionId = null; const availableKeys = []; for (const entry of entries) { const rawKey = entry.key || entry.sessionKey || entry.name || ""; const key = rawKey.replace(/^agent:main:/, ""); availableKeys.push(key); if (key === activeSessionKey) { sessionId = entry.id || entry.sessionId || ""; break; } } if (!sessionId) { log("warn", "server", `Chat-History: Session '${activeSessionKey}' nicht in sessions.json gefunden. Verfuegbar: [${availableKeys.join(", ")}]`); clientWs.send(JSON.stringify({ type: "chat_history", messages: [] })); return; } log("info", "server", `Chat-History: Session '${activeSessionKey}' -> ID '${sessionId}'`); const sessionFile = `${SESSIONS_DIR}/${sessionId}.jsonl`; const raw = await dockerExec("aria-core", `cat '${sessionFile}' 2>/dev/null || echo ''`); const chatMessages = []; for (const line of raw.split("\n")) { if (!line.trim()) continue; try { const obj = JSON.parse(line); // OpenClaw Format: {"type":"message","message":{"role":"user|assistant","content":[...]}} if (obj.type !== "message" || !obj.message) continue; const msg = obj.message; const role = msg.role; if (!role) continue; // Text aus content-Array extrahieren let text = ""; if (typeof msg.content === "string") text = msg.content; else if (Array.isArray(msg.content)) text = msg.content.filter(c => c.type === "text").map(c => c.text || "").join("\n"); if (!text) continue; if (role === "user") { // Metadata-Prefix entfernen: "Sender (untrusted metadata):\n```json\n{...}\n```\n\n[timestamp] Text" text = text.replace(/^Sender \(untrusted metadata\):[\s\S]*?```[\s\S]*?```\s*\n*/m, "").trim(); // Timestamp-Prefix entfernen: "[Sat 2026-03-28 14:51 UTC] " text = text.replace(/^\[.*?\]\s*/, "").trim(); chatMessages.push({ type: "sent", text, meta: "Gateway direkt", ts: msg.timestamp || obj.timestamp || 0 }); } else if (role === "assistant") { // Reply-Prefix entfernen: "[[reply_to_current]] " text = text.replace(/^\[\[reply_to_\w+\]\]\s*/g, "").trim(); if (text) chatMessages.push({ type: "received", text, meta: "chat:final", ts: msg.timestamp || obj.timestamp || 0 }); } } catch {} } clientWs.send(JSON.stringify({ type: "chat_history", messages: chatMessages })); log("info", "server", `Chat-History geladen: ${chatMessages.length} Nachrichten`); } catch (err) { log("error", "server", `Chat-History laden fehlgeschlagen: ${err.message}`); clientWs.send(JSON.stringify({ type: "chat_history", messages: [], error: err.message })); } } // ── Session aktivieren ───────────────────────────────── function handleSetActiveSession(clientWs, sessionKey) { if (!sessionKey || typeof sessionKey !== "string") { clientWs.send(JSON.stringify({ type: "active_session", ok: false, error: "Kein sessionKey" })); return; } activeSessionKey = sessionKey; const ok = persistActiveSession(activeSessionKey); log("info", "server", `Aktive Session: ${activeSessionKey}${ok ? "" : " (WARN: nicht persistiert!)"}`); if (!ok) { clientWs.send(JSON.stringify({ type: "active_session", ok: false, sessionKey: activeSessionKey, error: "Persistierung fehlgeschlagen — /data Volume pruefen" })); } // Allen Clients mitteilen for (const c of browserClients) { c.send(JSON.stringify({ type: "active_session", sessionKey: activeSessionKey })); } } // ── Session erstellen ────────────────────────────────── async function handleCreateSession(clientWs, sessionName) { if (!sessionName || typeof sessionName !== "string" || !/^[a-zA-Z0-9_-]+$/.test(sessionName)) { clientWs.send(JSON.stringify({ type: "session_created", ok: false, error: "Ungueltiger Name (nur a-z, 0-9, -, _)" })); return; } try { // Session wird automatisch erstellt wenn man die erste Nachricht sendet activeSessionKey = sessionName; persistActiveSession(activeSessionKey); log("info", "server", `Neue Session erstellt und aktiviert: ${sessionName}`); // Allen Clients mitteilen for (const c of browserClients) { c.send(JSON.stringify({ type: "active_session", sessionKey: activeSessionKey })); } clientWs.send(JSON.stringify({ type: "session_created", ok: true, sessionKey: sessionName })); } catch (err) { clientWs.send(JSON.stringify({ type: "session_created", ok: false, error: err.message })); } } // ── Session neu starten (Container Restart) ──────────── async function handleRestartSession(clientWs) { try { log("info", "server", "Starte aria-core Container neu (Session Restart)..."); clientWs.send(JSON.stringify({ type: "session_restarted", status: "restarting" })); // Container neu starten via Docker API const http = require("http"); await new Promise((resolve, reject) => { const req = http.request({ socketPath: "/var/run/docker.sock", path: "/containers/aria-core/restart?t=5", method: "POST", }, (res) => { let body = ""; res.on("data", d => body += d); res.on("end", () => { if (res.statusCode === 204 || res.statusCode === 200) resolve(); else reject(new Error(`Restart fehlgeschlagen: HTTP ${res.statusCode} ${body}`)); }); }); req.on("error", reject); req.end(); }); log("info", "server", "aria-core Container neu gestartet"); // Warten bis Gateway wieder erreichbar (max 30s) for (let i = 0; i < 15; i++) { await new Promise(r => setTimeout(r, 2000)); try { await dockerExec("aria-core", "echo ok"); log("info", "server", "aria-core ist wieder erreichbar"); clientWs.send(JSON.stringify({ type: "session_restarted", status: "ok", info: "aria-core neu gestartet — Permissions werden bei der naechsten Nachricht geladen", })); // Aktive Session beibehalten for (const c of browserClients) { c.send(JSON.stringify({ type: "active_session", sessionKey: activeSessionKey })); } return; } catch {} } clientWs.send(JSON.stringify({ type: "session_restarted", status: "timeout", error: "aria-core antwortet nicht nach 30s" })); } catch (err) { log("error", "server", `Session Restart fehlgeschlagen: ${err.message}`); clientWs.send(JSON.stringify({ type: "session_restarted", status: "error", error: err.message })); } } // ── Brain Viewer ──────────────────────────────────────── async function handleListBrain(clientWs) { try { log("info", "server", "Lade Brain-Dateien..."); const raw = await dockerExec("aria-core", ` for f in /home/node/.openclaw/workspace/memory/*; do [ -f "$f" ] || continue name=$(basename "$f") size=$(du -h "$f" 2>/dev/null | cut -f1) lines=$(wc -l < "$f" 2>/dev/null || echo 0) modified=$(stat -c '%Y' "$f" 2>/dev/null || echo 0) # Frontmatter extrahieren (erste 10 Zeilen) head10=$(head -10 "$f" 2>/dev/null | tr '\\n' '|') echo "FILE:$name|SIZE:$size|LINES:$lines|MODIFIED:$modified|HEAD:$head10" done `.trim()); const files = []; for (const line of raw.split("\n")) { if (!line.startsWith("FILE:")) continue; const parts = {}; for (const seg of line.split("|")) { const idx = seg.indexOf(":"); if (idx > 0) { const key = seg.slice(0, idx); const val = seg.slice(idx + 1); // HEAD hat mehrere |, also nur die bekannten Keys parsen if (["FILE", "SIZE", "LINES", "MODIFIED"].includes(key)) { parts[key] = val; } } } if (!parts.FILE || parts.FILE === "*") continue; // Frontmatter-Info aus HEAD extrahieren let description = ""; let memType = ""; const headPart = line.slice(line.indexOf("|HEAD:") + 6); if (headPart) { const headLines = headPart.split("|"); for (const hl of headLines) { if (hl.startsWith("description:")) description = hl.replace("description:", "").trim(); if (hl.startsWith("type:")) memType = hl.replace("type:", "").trim(); } } files.push({ name: parts.FILE, size: parts.SIZE || "?", lines: parseInt(parts.LINES) || 0, modified: parseInt(parts.MODIFIED) || 0, description, memType, }); } files.sort((a, b) => b.modified - a.modified); clientWs.send(JSON.stringify({ type: "brain_list", files })); log("info", "server", `${files.length} Brain-Datei(en) gefunden`); } catch (err) { log("error", "server", `Brain laden fehlgeschlagen: ${err.message}`); clientWs.send(JSON.stringify({ type: "brain_list", files: [], error: err.message })); } } async function handleReadBrainFile(clientWs, filename) { // Path Traversal verhindern if (!filename || filename.includes("..") || filename.includes("/")) { clientWs.send(JSON.stringify({ type: "brain_content", error: "Ungueltiger Dateiname" })); return; } try { const content = await dockerExec("aria-core", `cat '/home/node/.openclaw/workspace/memory/${filename.replace(/'/g, "")}'`); clientWs.send(JSON.stringify({ type: "brain_content", filename, content })); } catch (err) { clientWs.send(JSON.stringify({ type: "brain_content", filename, error: err.message })); } } // ── Einstellungen: Tool-Berechtigungen ────────────────── // ENTFERNT: Granulare Permissions haben nie funktioniert. // Claude Code laeuft mit --dangerously-skip-permissions (Alles oder Nichts). // Root-Check wird via CLAUDE_CODE_BUBBLEWRAP=1 in docker-compose.yml umgangen. // ── Einstellungen: Model ──────────────────────────────── async function handleGetModel(clientWs) { try { const raw = await dockerExec("aria-core", `echo $DEFAULT_MODEL`); clientWs.send(JSON.stringify({ type: "model_info", model: raw.trim(), info: "Aktuelles Model (ENV)" })); } catch (err) { clientWs.send(JSON.stringify({ type: "model_info", error: err.message })); } } async function handleSetModel(clientWs, model) { if (!model || typeof model !== "string") { clientWs.send(JSON.stringify({ type: "model_info", error: "Kein Model angegeben" })); return; } try { // Model in Settings speichern (OpenClaw liest das) const settingsPath = await findSettingsFile(); const script = [ 'const fs=require("fs");', `const f="${settingsPath}";`, 'let s={};try{s=JSON.parse(fs.readFileSync(f,"utf8"));}catch(e){}', `s.model=${JSON.stringify(model)};`, `const dir=f.substring(0,f.lastIndexOf("/"));`, 'try{fs.mkdirSync(dir,{recursive:true});}catch(e){}', 'fs.writeFileSync(f,JSON.stringify(s,null,2));', ].join(""); const b64 = Buffer.from(script).toString("base64"); await dockerExec("aria-core", `echo ${b64} | base64 -d | node`); clientWs.send(JSON.stringify({ type: "model_info", model, info: `Model auf "${model}" gesetzt (Neustart noetig)` })); log("info", "server", `Model gesetzt: ${model}`); } catch (err) { clientWs.send(JSON.stringify({ type: "model_info", error: err.message })); } } // ── Einstellungen: OpenClaw Config ────────────────────── async function handleGetOpenClawConfig(clientWs) { try { const raw = await dockerExec("aria-core", ` echo '=== Umgebungsvariablen ===' echo "DEFAULT_MODEL=$DEFAULT_MODEL" echo "RATE_LIMIT_PER_USER=$RATE_LIMIT_PER_USER" echo "OPENCLAW_GATEWAY_TOKEN=$(echo $OPENCLAW_GATEWAY_TOKEN | head -c 8)..." echo "OPENCLAW_GATEWAY_BIND=$OPENCLAW_GATEWAY_BIND" echo "" echo '=== openclaw.json ===' cat /home/node/.openclaw/openclaw.json 2>/dev/null || echo "(nicht vorhanden)" echo "" echo '=== exec-approvals.json ===' cat /home/node/.openclaw/exec-approvals.json 2>/dev/null || echo "(nicht vorhanden)" echo "" echo '=== Agent-Verzeichnis ===' ls -la /home/node/.openclaw/agents/main/agent/ 2>&1 echo "" echo '=== Workspace ===' ls -la /home/node/.openclaw/workspace/ 2>&1 `.trim()); clientWs.send(JSON.stringify({ type: "openclaw_config", config: raw })); } catch (err) { clientWs.send(JSON.stringify({ type: "openclaw_config", error: err.message })); } } // ── Start ─────────────────────────────────────────────── server.listen(HTTP_PORT, "0.0.0.0", () => { log("info", "server", `Diagnostic Server laeuft auf http://0.0.0.0:${HTTP_PORT}`); log("info", "server", `Gateway: ${GATEWAY_URL}`); log("info", "server", `Token: ${GATEWAY_TOKEN ? GATEWAY_TOKEN.slice(0, 8) + "..." : "(keiner)"}`); log("info", "server", `RVS: ${RVS_HOST ? `${RVS_HOST}:${RVS_PORT}` : "(nicht konfiguriert)"}`); log("info", "server", `Proxy: ${PROXY_URL}`); // Verbindungen aufbauen connectGateway(); connectRVS(); });