ARIA-AGENT/diagnostic/server.js

2163 lines
83 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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;
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 });
}
// Tool-Nutzung erkennen und broadcasten
if (stream === "tool_use" || data.type === "tool_use") {
const toolName = data.name || data.tool || payload.tool || "";
if (toolName) {
broadcast({ type: "agent_activity", activity: "tool", tool: toolName, data });
log("info", "gateway", `Tool: ${toolName}`);
}
}
// Genereller Activity-Heartbeat (ARIA denkt)
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)}"`);
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)}"`);
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}&timestamps=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
const raw = await dockerExec("aria-core", `
cat ${SESSIONS_DIR}/sessions.json 2>/dev/null || echo '{}' &&
echo '===FILE_DETAILS===' &&
for f in ${SESSIONS_DIR}/*.jsonl; do
[ -f "$f" ] || continue
name=$(basename "$f")
lines=$(wc -l < "$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:$lines|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 / Reset-Files)
for (const [filename, details] of Object.entries(fileDetails)) {
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();
});