2001 lines
76 KiB
JavaScript
2001 lines
76 KiB
JavaScript
"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 {}
|
||
let activeSessionKey = (() => {
|
||
try {
|
||
const saved = fs.readFileSync(SESSION_KEY_FILE, "utf-8").trim();
|
||
if (saved) { console.log(`[startup] Gespeicherte Session geladen: '${saved}'`); return saved; }
|
||
} catch {}
|
||
console.log("[startup] Keine gespeicherte Session — Fallback 'main'");
|
||
return "main";
|
||
})();
|
||
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;
|
||
}
|
||
|
||
// ── 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();
|
||
checkGatewayHealth();
|
||
// Auto-Reconnect nach 5s
|
||
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);
|
||
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)}"`);
|
||
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);
|
||
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_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.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 === "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 === "get_voice_config") {
|
||
handleGetVoiceConfig(ws);
|
||
} else if (msg.action === "send_voice_config") {
|
||
// Stimmen-Config persistent speichern + an Bridge via RVS senden
|
||
const voiceConfig = {
|
||
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,
|
||
};
|
||
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}, highlight=${voiceConfig.highlightVoice}, tts=${voiceConfig.ttsEnabled}`);
|
||
} 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 === "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 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 ────
|
||
async function resolveActiveSession() {
|
||
// Nur bei Fallback-Key "main" automatisch aufloesen — gespeicherte Wahl respektieren
|
||
const hasSavedSession = (() => {
|
||
try { return !!fs.readFileSync(SESSION_KEY_FILE, "utf-8").trim(); } catch { return false; }
|
||
})();
|
||
if (hasSavedSession && activeSessionKey !== "main") {
|
||
log("info", "server", `Gespeicherte Session '${activeSessionKey}' wird beibehalten`);
|
||
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
|
||
let newest = null;
|
||
let newestTime = 0;
|
||
for (const entry of entries) {
|
||
const t = entry.updatedAt || entry.createdAt || 0;
|
||
if (t >= newestTime) {
|
||
newestTime = t;
|
||
newest = entry;
|
||
}
|
||
}
|
||
|
||
if (newest) {
|
||
const rawKey = newest.key || newest.sessionKey || newest.name || "";
|
||
const key = rawKey.replace(/^agent:main:/, "");
|
||
if (key) {
|
||
activeSessionKey = key;
|
||
try { fs.writeFileSync(SESSION_KEY_FILE, activeSessionKey); } catch {}
|
||
log("info", "server", `Aktive Session auf neueste gewechselt: '${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;
|
||
try { fs.writeFileSync(SESSION_KEY_FILE, activeSessionKey); } catch {}
|
||
log("info", "server", `Aktive Session: ${activeSessionKey}`);
|
||
// 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;
|
||
try { fs.writeFileSync(SESSION_KEY_FILE, activeSessionKey); } catch {}
|
||
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();
|
||
});
|