diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index e1d25e8..003d6d6 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -22,7 +22,7 @@ public static class ChatEndpoint app.MapPost("/api/chat/stop", HandleStop); } - private static async Task HandleChat(HttpRequest req, HttpResponse res, IConfiguration config, ChatService chatService, DatabaseService db, StreamingTracker streamingTracker, AnthropicService anthropic, CancellationToken cancellationToken) + private static async Task HandleChat(HttpRequest req, HttpResponse res, IConfiguration config, ChatService chatService, DatabaseService db, StreamingTracker streamingTracker, AnthropicService anthropic, RemoteConnectionManager remoteMgr, CancellationToken cancellationToken) { var body = await JsonSerializer.DeserializeAsync(req.Body); if (body == null) @@ -53,7 +53,8 @@ public static class ChatEndpoint var userId = req.HttpContext.GetUserId(); var maxIterations = config.GetValue("MaxIterations", 100); - var ctx = await chatService.PrepareContext(sessionId, userId, maxIterations, planMode: body.PlanMode); + var hasRemote = remoteMgr.GetBySessionId(sessionId) != null; + var ctx = await chatService.PrepareContext(sessionId, userId, maxIterations, planMode: body.PlanMode, hasRemoteConnection: hasRemote); if (ctx == null) { res.StatusCode = 400; @@ -206,7 +207,7 @@ public static class ChatEndpoint try { - await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, sessionId, agentId: ctx.AgentId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: effectiveToolChoice, review: ctx.Review, finalizer: ctx.Finalizer, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: ctx.PlanMode, workCt: workCt, hardCt: hardCt, sseCt: sseCt); + await anthropic.StreamChat(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, ctx.Workspace, sessionId, agentId: ctx.AgentId, useMemory: ctx.UseMemory, webSearchVersion: ctx.WebSearchVersion, maxIterations: ctx.MaxIterations, toolChoice: effectiveToolChoice, review: ctx.Review, finalizer: ctx.Finalizer, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: ctx.PlanMode, workCt: workCt, hardCt: hardCt, sseCt: sseCt, remoteMgr: remoteMgr); } finally { diff --git a/Endpoints/RemoteEndpoint.cs b/Endpoints/RemoteEndpoint.cs new file mode 100644 index 0000000..960b04f --- /dev/null +++ b/Endpoints/RemoteEndpoint.cs @@ -0,0 +1,193 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Gott.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Gott.Endpoints; + +public static class RemoteEndpoint +{ + public static void MapRemoteEndpoints(this IEndpointRouteBuilder app) + { + // WebSocket-Endpoint für Remote-Clients (kein Auth) + app.Map("/api/remote", HandleWebSocket); + + // REST-APIs für Frontend (Auth required) + app.MapGet("/api/remote/connections", GetConnections); + app.MapPost("/api/remote/assign", AssignConnection); + app.MapPost("/api/remote/unassign", UnassignConnection); + + // PowerShell-Script ausliefern + app.MapGet("/remote.ps1", (Delegate)ServeScript); + } + + private static async Task HandleWebSocket(HttpContext ctx, RemoteConnectionManager mgr) + { + if (!ctx.WebSockets.IsWebSocketRequest) + { + ctx.Response.StatusCode = 400; + await ctx.Response.WriteAsync("WebSocket expected"); + return; + } + + using var ws = await ctx.WebSockets.AcceptWebSocketAsync(); + var conn = mgr.Register(ws); + Console.WriteLine($"[Remote] Client connected: {conn.Id}"); + + try + { + await ReceiveLoop(ws, conn, mgr); + } + catch (WebSocketException) + { + // Client disconnected + } + catch (OperationCanceledException) + { + // Shutdown + } + finally + { + mgr.Unregister(conn.Id); + Console.WriteLine($"[Remote] Client disconnected: {conn.Id} ({conn.Hostname})"); + + if (ws.State == WebSocketState.Open || ws.State == WebSocketState.CloseReceived) + { + try { await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None); } + catch { } + } + } + } + + private static async Task ReceiveLoop(WebSocket ws, RemoteConnection conn, RemoteConnectionManager mgr) + { + var buffer = new byte[64 * 1024]; // 64 KB Buffer + var messageBuffer = new MemoryStream(); + + while (ws.State == WebSocketState.Open) + { + WebSocketReceiveResult result; + messageBuffer.SetLength(0); + + do + { + result = await ws.ReceiveAsync(buffer, CancellationToken.None); + if (result.MessageType == WebSocketMessageType.Close) return; + messageBuffer.Write(buffer, 0, result.Count); + } while (!result.EndOfMessage); + + if (result.MessageType != WebSocketMessageType.Text) continue; + + var json = Encoding.UTF8.GetString(messageBuffer.GetBuffer(), 0, (int)messageBuffer.Length); + JsonElement msg; + try { msg = JsonSerializer.Deserialize(json); } + catch { continue; } + + var type = msg.TryGetProperty("type", out var tp) ? tp.GetString() : ""; + + switch (type) + { + case "hello": + // Client meldet sich mit Hostname + OS-Info + conn.Hostname = msg.TryGetProperty("hostname", out var h) ? h.GetString() ?? "" : ""; + conn.Username = msg.TryGetProperty("username", out var u) ? u.GetString() ?? "" : ""; + conn.OsVersion = msg.TryGetProperty("os", out var o) ? o.GetString() ?? "" : ""; + Console.WriteLine($"[Remote] Client identified: {conn.Id} = {conn.Username}@{conn.Hostname} ({conn.OsVersion})"); + break; + + case "result": + // Ergebnis eines Befehls + var cmdResult = new RemoteCommandResult + { + ExitCode = msg.TryGetProperty("exitCode", out var ec) ? ec.GetInt32() : -1, + Output = msg.TryGetProperty("output", out var ot) ? ot.GetString() ?? "" : "", + Error = msg.TryGetProperty("error", out var er) ? er.GetString() ?? "" : "" + }; + mgr.HandleResult(conn.Id, cmdResult); + break; + + case "ping": + // Heartbeat – antworte mit pong + var pong = Encoding.UTF8.GetBytes("{\"type\":\"pong\"}"); + await ws.SendAsync(pong, WebSocketMessageType.Text, true, CancellationToken.None); + break; + } + } + } + + private static IResult GetConnections(RemoteConnectionManager mgr) + { + var connections = mgr.GetAll().Select(c => new + { + id = c.Id, + hostname = c.Hostname, + username = c.Username, + os = c.OsVersion, + connectedAt = c.ConnectedAt.ToString("o"), + assignedSessionId = c.AssignedSessionId + }); + return Results.Ok(connections); + } + + private static IResult AssignConnection(HttpContext ctx, RemoteConnectionManager mgr) + { + using var reader = new StreamReader(ctx.Request.Body); + var body = reader.ReadToEndAsync().Result; + var json = JsonSerializer.Deserialize(body); + + var connectionId = json.TryGetProperty("connectionId", out var cid) ? cid.GetString() : ""; + var sessionId = json.TryGetProperty("sessionId", out var sid) ? sid.GetString() : ""; + + if (string.IsNullOrEmpty(connectionId) || string.IsNullOrEmpty(sessionId)) + return Results.BadRequest(new { error = "connectionId und sessionId erforderlich" }); + + if (!mgr.Assign(connectionId, sessionId)) + return Results.NotFound(new { error = "Verbindung nicht gefunden" }); + + return Results.Ok(new { ok = true }); + } + + private static IResult UnassignConnection(HttpContext ctx, RemoteConnectionManager mgr) + { + using var reader = new StreamReader(ctx.Request.Body); + var body = reader.ReadToEndAsync().Result; + var json = JsonSerializer.Deserialize(body); + + var connectionId = json.TryGetProperty("connectionId", out var cid) ? cid.GetString() : ""; + + if (string.IsNullOrEmpty(connectionId)) + return Results.BadRequest(new { error = "connectionId erforderlich" }); + + if (!mgr.Unassign(connectionId)) + return Results.NotFound(new { error = "Verbindung nicht gefunden" }); + + return Results.Ok(new { ok = true }); + } + + private static async Task ServeScript(HttpContext ctx) + { + var scriptPath = Path.Combine(AppContext.BaseDirectory, "wwwroot", "remote.ps1"); + if (!File.Exists(scriptPath)) + return Results.NotFound("Script nicht gefunden"); + + // Host aus dem Request ermitteln für die WebSocket-URL + var host = ctx.Request.Host.ToString(); + var scheme = ctx.Request.Scheme == "https" ? "wss" : "ws"; + var wsUrl = $"{scheme}://{host}/api/remote"; + + var script = await File.ReadAllTextAsync(scriptPath); + script = script.Replace("{{WS_URL}}", wsUrl); + + return Results.Content(script, "text/plain; charset=utf-8"); + } +} diff --git a/Middleware/AuthMiddleware.cs b/Middleware/AuthMiddleware.cs index 6579d7d..0716ce0 100644 --- a/Middleware/AuthMiddleware.cs +++ b/Middleware/AuthMiddleware.cs @@ -25,7 +25,9 @@ public class AuthMiddleware var isSharedGet = path.StartsWith("/api/shared/") && ctx.Request.Method == "GET"; var isAutologin = path == "/api/auth/autologin" && ctx.Request.Method == "GET"; var isImageFile = path.StartsWith("/api/images/file/") && ctx.Request.Method == "GET"; - if (!path.StartsWith("/api/") || path == "/api/auth/login" || isTelegramWebhook || isSharedGet || isAutologin || isImageFile) + var isRemoteWs = path == "/api/remote"; + var isRemoteScript = path == "/remote.ps1"; + if (!path.StartsWith("/api/") || path == "/api/auth/login" || isTelegramWebhook || isSharedGet || isAutologin || isImageFile || isRemoteWs || isRemoteScript) { await _next(ctx); return; diff --git a/Program.cs b/Program.cs index ab91d95..3277f11 100644 --- a/Program.cs +++ b/Program.cs @@ -29,6 +29,7 @@ builder.Services.AddSingleton(authService); builder.Services.AddSingleton(dbService); builder.Services.AddSingleton(new ImageService()); builder.Services.AddSingleton(new StreamingTracker()); +builder.Services.AddSingleton(new RemoteConnectionManager()); var memoryService = new MemoryService(dataDir); var skillService = new SkillService(dataDir); builder.Services.AddSingleton(memoryService); @@ -71,6 +72,7 @@ builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); var app = builder.Build(); +app.UseWebSockets(); app.UseDefaultFiles(); app.UseStaticFiles(); app.UseMiddleware(); @@ -93,6 +95,7 @@ app.MapSharedEndpoints(); app.MapAdminEndpoints(); app.MapServerSettingsEndpoints(); app.MapOpenClawEndpoints(); +app.MapRemoteEndpoints(); var port = app.Configuration.GetValue("Port", 19000); app.Run($"http://0.0.0.0:{port}"); diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index a847de9..dcf4a95 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -53,7 +53,8 @@ public class AnthropicService bool planMode = false, CancellationToken workCt = default, CancellationToken hardCt = default, - CancellationToken sseCt = default) + CancellationToken sseCt = default, + RemoteConnectionManager remoteMgr = null) { var sw = System.Diagnostics.Stopwatch.StartNew(); var usage = new TokenUsage(); @@ -90,7 +91,8 @@ public class AnthropicService if (systemPrompt != null && systemPrompt.Count > 0) payload["system"] = systemPrompt; - AddToolsToPayload(payload, workspace, agentId, useMemory, webSearchVersion, toolChoice, iterations, allowUserChoice, planMode); + var hasRemote = remoteMgr?.GetBySessionId(sessionId) != null; + AddToolsToPayload(payload, workspace, agentId, useMemory, webSearchVersion, toolChoice, iterations, allowUserChoice, planMode, includeRemote: hasRemote); var client = CreateClient(); var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); @@ -136,7 +138,7 @@ public class AnthropicService return; } - var (stopReason, assistantContent, executedTools, streamErrorInfo) = await ProcessStream(response, httpResp, usage, workspace, agentId, workCt, sseCt); + var (stopReason, assistantContent, executedTools, streamErrorInfo) = await ProcessStream(response, httpResp, usage, workspace, agentId, workCt, sseCt, remoteMgr, sessionId); lastStopReason = stopReason; promptTrimRetries = 0; // Erfolgreicher API-Call → Retry-Counter resetten @@ -1153,7 +1155,9 @@ public class AnthropicService string workspace, string agentId = null, CancellationToken workCt = default, - CancellationToken sseCt = default) + CancellationToken sseCt = default, + RemoteConnectionManager remoteMgr = null, + string sessionId = null) { var stream = await httpResp.Content.ReadAsStreamAsync(workCt); // Bei Cancellation den Stream disposen, damit ReadLineAsync sofort abbricht. @@ -1335,7 +1339,7 @@ public class AnthropicService string toolResult; bool isError = false; - try { toolResult = await _toolService.ExecuteTool(currentToolName, inputEl, workspace, _memoryService, agentId, workCt); } + try { toolResult = await _toolService.ExecuteTool(currentToolName, inputEl, workspace, _memoryService, agentId, workCt, remoteMgr: remoteMgr, sessionId: sessionId); } catch (OperationCanceledException) { throw; } catch (Exception ex) { toolResult = $"Fehler: {ex.Message}"; isError = true; } @@ -1508,7 +1512,7 @@ public class AnthropicService /// /// Fügt Tool-Definitionen und WebSearch zum Payload hinzu. /// - private void AddToolsToPayload(Dictionary payload, string workspace, string agentId, bool useMemory, string webSearchVersion, string toolChoice, int iteration, bool allowUserChoice = false, bool planMode = false) + private void AddToolsToPayload(Dictionary payload, string workspace, string agentId, bool useMemory, string webSearchVersion, string toolChoice, int iteration, bool allowUserChoice = false, bool planMode = false, bool includeRemote = false) { var hasWorkspace = !string.IsNullOrEmpty(workspace); var includeMemory = useMemory && _memoryService != null && !string.IsNullOrEmpty(agentId); @@ -1520,7 +1524,7 @@ public class AnthropicService var tools = new List(); if (hasTools) { - var allTools = _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: allowUserChoice); + var allTools = _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: allowUserChoice, includeRemote: includeRemote); if (planMode) allTools = _toolService.FilterReadOnlyTools(allTools); tools.AddRange(allTools); diff --git a/Services/ChatService.cs b/Services/ChatService.cs index 82f7786..91ba71e 100644 --- a/Services/ChatService.cs +++ b/Services/ChatService.cs @@ -47,7 +47,7 @@ public class ChatService /// /// Baut den vollständigen ChatContext auf: Agent, Model, SystemPrompt, Chain, Workspace etc. /// - public async Task PrepareContext(string sessionId, string userId, int maxIterations = 100, bool planMode = false) + public async Task PrepareContext(string sessionId, string userId, int maxIterations = 100, bool planMode = false, bool hasRemoteConnection = false) { var session = await _db.GetSession(sessionId); if (session == null || (userId != null && session.UserId != userId) || string.IsNullOrEmpty(session.AgentId)) @@ -80,7 +80,7 @@ public class ChatService var toolChoice = hasTools ? "any" : null; var toolDefs = hasTools - ? _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: agent.AllowUserChoice) + ? _toolService.GetToolDefinitions(includeFileTools: hasWorkspace, includeMemory: includeMemory, includeUserChoice: agent.AllowUserChoice, includeRemote: hasRemoteConnection) : Array.Empty(); var toolNames = toolDefs.Length > 0 ? JsonSerializer.Serialize(toolDefs.Select(td => diff --git a/Services/RemoteConnectionManager.cs b/Services/RemoteConnectionManager.cs new file mode 100644 index 0000000..b86a315 --- /dev/null +++ b/Services/RemoteConnectionManager.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Gott.Services; + +public class RemoteConnection +{ + public string Id { get; set; } + public string Hostname { get; set; } + public string Username { get; set; } + public string OsVersion { get; set; } + public DateTime ConnectedAt { get; set; } + public string AssignedSessionId { get; set; } + public WebSocket Socket { get; set; } + + // Für synchrone Command-Execution: ein Befehl wird gesendet, Ergebnis abgewartet + public SemaphoreSlim CommandLock { get; } = new SemaphoreSlim(1, 1); + public TaskCompletionSource PendingResult { get; set; } +} + +public class RemoteCommandResult +{ + public int ExitCode { get; set; } + public string Output { get; set; } + public string Error { get; set; } +} + +public class RemoteConnectionManager +{ + private readonly ConcurrentDictionary _connections = new(); + + public RemoteConnection Register(WebSocket socket) + { + var conn = new RemoteConnection + { + Id = Guid.NewGuid().ToString("N")[..12], + ConnectedAt = DateTime.UtcNow, + Socket = socket + }; + _connections[conn.Id] = conn; + return conn; + } + + public void Unregister(string connectionId) + { + if (_connections.TryRemove(connectionId, out var conn)) + { + // Falls ein Befehl auf Antwort wartet, abbrechen + conn.PendingResult?.TrySetCanceled(); + conn.AssignedSessionId = null; + } + } + + public List GetAll() + { + return _connections.Values.ToList(); + } + + public RemoteConnection GetById(string connectionId) + { + _connections.TryGetValue(connectionId, out var conn); + return conn; + } + + public RemoteConnection GetBySessionId(string sessionId) + { + return _connections.Values.FirstOrDefault(c => c.AssignedSessionId == sessionId); + } + + public bool Assign(string connectionId, string sessionId) + { + if (!_connections.TryGetValue(connectionId, out var conn)) return false; + conn.AssignedSessionId = sessionId; + return true; + } + + public bool Unassign(string connectionId) + { + if (!_connections.TryGetValue(connectionId, out var conn)) return false; + conn.AssignedSessionId = null; + return true; + } + + /// + /// Sendet einen Befehl an den Remote-Client und wartet auf das Ergebnis. + /// + public async Task ExecuteCommand(string connectionId, string command, int timeoutSeconds = 180, CancellationToken ct = default) + { + if (!_connections.TryGetValue(connectionId, out var conn)) + return new RemoteCommandResult { ExitCode = -1, Error = "Verbindung nicht gefunden" }; + + if (conn.Socket.State != WebSocketState.Open) + return new RemoteCommandResult { ExitCode = -1, Error = "WebSocket nicht verbunden" }; + + await conn.CommandLock.WaitAsync(ct); + try + { + conn.PendingResult = new TaskCompletionSource(); + + // Befehl senden + var msg = JsonSerializer.Serialize(new { type = "execute", command }); + var bytes = Encoding.UTF8.GetBytes(msg); + await conn.Socket.SendAsync(bytes, WebSocketMessageType.Text, true, ct); + + // Auf Ergebnis warten (mit Timeout) + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); + + try + { + var registration = cts.Token.Register(() => conn.PendingResult.TrySetCanceled()); + var result = await conn.PendingResult.Task; + registration.Dispose(); + return result; + } + catch (TaskCanceledException) + { + return new RemoteCommandResult { ExitCode = -1, Error = $"Timeout nach {timeoutSeconds} Sekunden" }; + } + } + finally + { + conn.PendingResult = null; + conn.CommandLock.Release(); + } + } + + /// + /// Wird vom WebSocket-Receiver aufgerufen wenn ein Ergebnis vom Client kommt. + /// + public void HandleResult(string connectionId, RemoteCommandResult result) + { + if (_connections.TryGetValue(connectionId, out var conn)) + { + conn.PendingResult?.TrySetResult(result); + } + } +} diff --git a/Services/ToolService.cs b/Services/ToolService.cs index b5c5714..dadacc5 100644 --- a/Services/ToolService.cs +++ b/Services/ToolService.cs @@ -34,7 +34,7 @@ public class ToolService { "read_file", "list_files", "glob", "grep", "execute", "todo_write", "memory_read", "memory_write", "memory_list", - AskUserChoiceToolName + "remote_execute", AskUserChoiceToolName }; public object[] FilterReadOnlyTools(object[] tools) @@ -54,7 +54,7 @@ public class ToolService return filtered.ToArray(); } - public object[] GetToolDefinitions(bool includeFileTools = false, bool includeMemory = false, bool includeUserChoice = true) + public object[] GetToolDefinitions(bool includeFileTools = false, bool includeMemory = false, bool includeUserChoice = true, bool includeRemote = false) { var allTools = new List(); if (includeUserChoice) @@ -63,6 +63,8 @@ public class ToolService allTools.AddRange(GetFileTools()); if (includeMemory) allTools.AddRange(GetMemoryToolDefinitions()); + if (includeRemote) + allTools.Add(GetRemoteExecuteTool()); return allTools.ToArray(); } @@ -262,6 +264,24 @@ public class ToolService }; } + private object GetRemoteExecuteTool() + { + return new + { + name = "remote_execute", + description = "Führt einen PowerShell-Befehl auf der verbundenen Remote-Windows-Workstation aus. Gibt stdout und stderr zurück. Nutze dies für Diagnose und Administration des Remote-PCs (z.B. Druckerprobleme, Netzwerk, Services, Registry). Timeout: 180 Sekunden.", + input_schema = new + { + type = "object", + properties = new Dictionary + { + ["command"] = new { type = "string", description = "PowerShell-Befehl zum Ausführen auf dem Remote-PC" } + }, + required = new[] { "command" } + } + }; + } + private object[] GetMemoryToolDefinitions() { return new object[] @@ -313,7 +333,7 @@ public class ToolService }; } - public async Task ExecuteTool(string name, JsonElement input, string workspace, MemoryService memoryService = null, string agentId = null, CancellationToken cancellationToken = default) + public async Task ExecuteTool(string name, JsonElement input, string workspace, MemoryService memoryService = null, string agentId = null, CancellationToken cancellationToken = default, RemoteConnectionManager remoteMgr = null, string sessionId = null) { switch (name) { @@ -597,6 +617,30 @@ public class ToolService var files = memoryService.ListMemoryFiles(agentId, subPath); return files.Count == 0 ? "(keine Memory-Dateien vorhanden)" : string.Join("\n", files); } + case "remote_execute": + { + if (remoteMgr == null || string.IsNullOrEmpty(sessionId)) + return "Fehler: Keine Remote-Verbindung verfügbar"; + var conn = remoteMgr.GetBySessionId(sessionId); + if (conn == null) + return "Fehler: Keine Remote-Verbindung für diese Session zugeordnet"; + var command = input.GetProperty("command").GetString() ?? ""; + var result = await remoteMgr.ExecuteCommand(conn.Id, command, ExecuteTimeoutSeconds, cancellationToken); + var sb = new StringBuilder(); + if (!string.IsNullOrEmpty(result.Output)) + sb.Append(result.Output); + if (!string.IsNullOrEmpty(result.Error)) + { + if (sb.Length > 0) sb.AppendLine(); + sb.Append("[stderr] ").Append(result.Error); + } + if (result.ExitCode != 0) + { + if (sb.Length > 0) sb.AppendLine(); + sb.Append($"[Exit Code: {result.ExitCode}]"); + } + return sb.Length > 0 ? sb.ToString() : "(keine Ausgabe)"; + } default: return $"Fehler: Unbekanntes Tool: {name}"; } diff --git a/wwwroot/index.html b/wwwroot/index.html index 09f03e2..d5b63b9 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -358,6 +358,7 @@
diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index c9a9dde..cd96143 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -629,6 +629,31 @@ async function apiImportOpenclawSession(openclawSessionId, sessionId) { return resp.json(); } +// ── Remote API ── + +async function apiGetRemoteConnections() { + const resp = await fetch('/api/remote/connections'); + return resp.json(); +} + +async function apiAssignRemote(connectionId, sessionId) { + const resp = await fetch('/api/remote/assign', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ connectionId, sessionId }) + }); + return resp.json(); +} + +async function apiUnassignRemote(connectionId) { + const resp = await fetch('/api/remote/unassign', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ connectionId }) + }); + return resp.json(); +} + // ── Backup API ── async function apiBackupDb() { diff --git a/wwwroot/js/modals.js b/wwwroot/js/modals.js index 0de415b..248ec39 100644 --- a/wwwroot/js/modals.js +++ b/wwwroot/js/modals.js @@ -292,6 +292,104 @@ async function showOpenClawImportModal(sessionId) { } } +// ── Remote-Verbindungen Modal ── + +async function showRemoteModal(sessionId) { + const { body, close } = _createModal('Remote-Verbindungen'); + + body.innerHTML = '
Laden...
'; + + async function renderConnections() { + try { + const connections = await apiGetRemoteConnections(); + body.innerHTML = ''; + + if (!connections || connections.length === 0) { + var empty = document.createElement('div'); + empty.className = 'sp-loading'; + empty.innerHTML = 'Keine Remote-Verbindungen aktiv.

' + + 'Auf dem Windows-PC ausführen:
' + + 'irm ' + esc(location.origin) + '/remote.ps1 | iex
'; + body.appendChild(empty); + return; + } + + var hint = document.createElement('div'); + hint.style.cssText = 'font-size:var(--font-size);opacity:var(--semi-transparent);margin-bottom:var(--box-spacing)'; + hint.textContent = 'Verbindung auswählen um Remote-Befehle in dieser Session zu ermöglichen:'; + body.appendChild(hint); + + for (var i = 0; i < connections.length; i++) { + var conn = connections[i]; + var card = document.createElement('div'); + card.className = 'sp-block'; + card.style.cursor = 'pointer'; + + var isAssigned = conn.assignedSessionId === sessionId; + var isAssignedOther = conn.assignedSessionId && conn.assignedSessionId !== sessionId; + + var label = document.createElement('div'); + label.className = 'sp-block-label'; + var statusIcon = isAssigned ? 'fa-check-circle' : (isAssignedOther ? 'fa-link' : 'fa-desktop'); + var statusColor = isAssigned ? 'color:var(--primary)' : ''; + label.innerHTML = ' ' + + esc(conn.username || '?') + '@' + esc(conn.hostname || '?'); + card.appendChild(label); + + var content = document.createElement('div'); + content.className = 'sp-block-content'; + content.style.cssText = 'font-size:var(--font-size);display:flex;align-items:center;gap:var(--box-spacing)'; + + var info = document.createElement('span'); + info.style.cssText = 'flex:1;opacity:var(--semi-transparent)'; + var connDate = new Date(conn.connectedAt); + info.textContent = conn.os + ' • Verbunden seit ' + connDate.toLocaleTimeString('de'); + content.appendChild(info); + + var btn = document.createElement('button'); + if (isAssigned) { + btn.textContent = 'Trennen'; + btn.style.cssText = 'color:var(--dangerous)'; + } else if (isAssignedOther) { + btn.textContent = 'Andere Session'; + btn.disabled = true; + btn.style.cssText = 'opacity:var(--semi-transparent)'; + } else { + btn.textContent = 'Verbinden'; + btn.style.cssText = 'color:var(--primary)'; + } + content.appendChild(btn); + + card.appendChild(content); + body.appendChild(card); + + // Event-Handler + (function(c, assigned) { + btn.addEventListener('click', async function(e) { + e.stopPropagation(); + btn.disabled = true; + try { + if (assigned) { + await apiUnassignRemote(c.id); + } else { + await apiAssignRemote(c.id, sessionId); + } + await renderConnections(); + } catch(err) { + showToast('Fehler: ' + err.message, 'error'); + btn.disabled = false; + } + }); + })(conn, isAssigned); + } + } catch (e) { + body.innerHTML = '
Fehler beim Laden: ' + esc(e.message) + '
'; + } + } + + await renderConnections(); +} + // Event-Delegation für Krea-Thumbnails im Chat document.addEventListener('click', function(e) { var ref = e.target.closest('.krea-ref'); diff --git a/wwwroot/js/pages/chat-page.js b/wwwroot/js/pages/chat-page.js index c14e215..c2316a4 100644 --- a/wwwroot/js/pages/chat-page.js +++ b/wwwroot/js/pages/chat-page.js @@ -169,6 +169,13 @@ registerPage('chat', { }); } + // Remote-Verbindungen + const remoteBtn = document.getElementById('remoteBtn'); + if (remoteBtn) remoteBtn.addEventListener('click', () => { + if (chatMenuDropdown) chatMenuDropdown.hidden = true; + showRemoteModal(sessionId); + }); + // System-Prompt anzeigen const systemPromptBtn = document.getElementById('systemPromptBtn'); if (systemPromptBtn) systemPromptBtn.addEventListener('click', () => { diff --git a/wwwroot/js/tool-ui.js b/wwwroot/js/tool-ui.js index c12b945..b832cb1 100644 --- a/wwwroot/js/tool-ui.js +++ b/wwwroot/js/tool-ui.js @@ -1,12 +1,13 @@ // ── Tool Call Visibility ── -const MODIFICATION_TOOLS = ['write_file', 'edit_file', 'delete_file', 'memory_write']; +const MODIFICATION_TOOLS = ['write_file', 'edit_file', 'delete_file', 'memory_write', 'remote_execute']; const TOOL_DISPLAY_NAMES = { write_file: 'Datei schreiben', edit_file: 'Datei bearbeiten', delete_file: 'Datei löschen', memory_write: 'Erinnerungen festhalten', - web_search: 'Web-Suche' + web_search: 'Web-Suche', + remote_execute: 'Remote-Befehl' }; function isToolVisible(toolName) { diff --git a/wwwroot/remote.ps1 b/wwwroot/remote.ps1 new file mode 100644 index 0000000..59bdaff --- /dev/null +++ b/wwwroot/remote.ps1 @@ -0,0 +1,164 @@ +# GOTT Remote Agent - Windows PowerShell Client +# Verbindet sich per WebSocket zum GOTT-Server und fuehrt Befehle aus. +# +# Verwendung: +# irm https://gott.example.com/remote.ps1 | iex +# oder: +# .\remote.ps1 -Url "wss://gott.example.com/api/remote" + +param( + [string]$Url = "{{WS_URL}}" +) + +$ErrorActionPreference = "Stop" + +Write-Host "" +Write-Host " ============================" -ForegroundColor Cyan +Write-Host " GOTT Remote Agent" -ForegroundColor Cyan +Write-Host " ============================" -ForegroundColor Cyan +Write-Host "" +Write-Host " Verbinde zu: $Url" -ForegroundColor Gray +Write-Host "" + +try { + $ws = New-Object System.Net.WebSockets.ClientWebSocket + $ct = New-Object System.Threading.CancellationToken + + # Verbindung aufbauen + $ws.ConnectAsync([Uri]$Url, $ct).GetAwaiter().GetResult() + Write-Host " [OK] Verbunden!" -ForegroundColor Green + + # Hello-Message senden + $hostname = $env:COMPUTERNAME + $username = $env:USERNAME + $osVersion = [System.Environment]::OSVersion.VersionString + $hello = @{ + type = "hello" + hostname = $hostname + username = $username + os = $osVersion + } | ConvertTo-Json -Compress + + $helloBytes = [System.Text.Encoding]::UTF8.GetBytes($hello) + $segment = New-Object System.ArraySegment[byte] -ArgumentList @(,$helloBytes) + $ws.SendAsync($segment, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, $ct).GetAwaiter().GetResult() + + Write-Host " Host: $username@$hostname ($osVersion)" -ForegroundColor Gray + Write-Host "" + Write-Host " Warte auf Befehle... (Strg+C zum Beenden)" -ForegroundColor Yellow + Write-Host "" + + # Empfangs-Loop + $buffer = New-Object byte[] 65536 + $messageBuffer = New-Object System.IO.MemoryStream + + # Heartbeat-Timer (alle 30 Sekunden) + $lastPing = [DateTime]::UtcNow + + while ($ws.State -eq [System.Net.WebSockets.WebSocketState]::Open) { + # Heartbeat senden + if (([DateTime]::UtcNow - $lastPing).TotalSeconds -gt 30) { + $pingBytes = [System.Text.Encoding]::UTF8.GetBytes('{"type":"ping"}') + $pingSegment = New-Object System.ArraySegment[byte] -ArgumentList @(,$pingBytes) + try { + $ws.SendAsync($pingSegment, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, $ct).GetAwaiter().GetResult() + } catch { + break + } + $lastPing = [DateTime]::UtcNow + } + + # Auf Nachricht warten (mit Timeout fuer Heartbeat) + $receiveTask = $ws.ReceiveAsync((New-Object System.ArraySegment[byte] -ArgumentList @(,$buffer)), $ct) + if (-not $receiveTask.Wait(5000)) { + continue + } + + $result = $receiveTask.Result + + if ($result.MessageType -eq [System.Net.WebSockets.WebSocketMessageType]::Close) { + Write-Host " Server hat Verbindung geschlossen." -ForegroundColor Yellow + break + } + + $messageBuffer.SetLength(0) + $messageBuffer.Write($buffer, 0, $result.Count) + + # Mehrteilige Nachrichten zusammenbauen + while (-not $result.EndOfMessage) { + $result = $ws.ReceiveAsync((New-Object System.ArraySegment[byte] -ArgumentList @(,$buffer)), $ct).GetAwaiter().GetResult() + $messageBuffer.Write($buffer, 0, $result.Count) + } + + $json = [System.Text.Encoding]::UTF8.GetString($messageBuffer.ToArray()) + $msg = $json | ConvertFrom-Json + + switch ($msg.type) { + "execute" { + $command = $msg.command + $timestamp = Get-Date -Format "HH:mm:ss" + Write-Host " [$timestamp] Ausfuehren: " -NoNewline -ForegroundColor Cyan + Write-Host $command -ForegroundColor White + + $exitCode = 0 + $stdout = "" + $stderr = "" + + try { + # PowerShell-Befehl ausfuehren + $output = & powershell.exe -NoProfile -NonInteractive -Command $command 2>&1 + $stdout = ($output | Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] }) -join "`n" + $stderrLines = ($output | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }) | ForEach-Object { $_.ToString() } + $stderr = $stderrLines -join "`n" + $exitCode = $LASTEXITCODE + if ($null -eq $exitCode) { $exitCode = 0 } + } catch { + $stderr = $_.Exception.Message + $exitCode = 1 + } + + # Ergebnis senden + $response = @{ + type = "result" + exitCode = $exitCode + output = if ($stdout) { $stdout } else { "" } + error = if ($stderr) { $stderr } else { "" } + } | ConvertTo-Json -Compress + + $responseBytes = [System.Text.Encoding]::UTF8.GetBytes($response) + $responseSegment = New-Object System.ArraySegment[byte] -ArgumentList @(,$responseBytes) + $ws.SendAsync($responseSegment, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, $ct).GetAwaiter().GetResult() + + # Kurzausgabe + if ($stdout) { + $preview = if ($stdout.Length -gt 200) { $stdout.Substring(0, 200) + "..." } else { $stdout } + Write-Host " -> $preview" -ForegroundColor Gray + } + if ($stderr) { + Write-Host " [stderr] $stderr" -ForegroundColor Red + } + Write-Host " [Exit: $exitCode]" -ForegroundColor $(if ($exitCode -eq 0) { "Green" } else { "Red" }) + Write-Host "" + } + "pong" { + # Heartbeat-Antwort – ignorieren + } + default { + Write-Host " Unbekannte Nachricht: $($msg.type)" -ForegroundColor Yellow + } + } + } +} catch [System.Net.WebSockets.WebSocketException] { + Write-Host " Verbindung verloren." -ForegroundColor Red +} catch { + Write-Host " Fehler: $_" -ForegroundColor Red +} finally { + if ($ws -and $ws.State -eq [System.Net.WebSockets.WebSocketState]::Open) { + try { + $ws.CloseAsync([System.Net.WebSockets.WebSocketCloseStatus]::NormalClosure, "bye", $ct).GetAwaiter().GetResult() + } catch { } + } + if ($ws) { $ws.Dispose() } + Write-Host "" + Write-Host " Verbindung beendet." -ForegroundColor Yellow +}