feat: Remote-Verbindungen – Windows-Workstations per WebSocket steuern

- RemoteConnectionManager: Singleton-Service verwaltet WebSocket-Verbindungen
  mit Command-Execution (Send/Receive mit Timeout + SemaphoreSlim-Locking)
- RemoteEndpoint: WebSocket-Endpoint /api/remote (auth-frei), REST-APIs für
  Connections/Assign/Unassign, PowerShell-Script-Auslieferung mit dynamischer URL
- remote.ps1: PowerShell-Client für Windows – verbindet per WebSocket,
  sendet Hostname/User/OS, empfängt und führt Befehle aus, Heartbeat
- remote_execute Tool: Neues Agent-Tool, nur verfügbar wenn Session eine
  zugeordnete Remote-Verbindung hat, führt PowerShell-Befehle remote aus
- Frontend: Menüpunkt 'Remote-Verbindungen' im Chat-Dropdown, Modal zeigt
  alle aktiven WebSocket-Verbindungen (Hostname, User, OS, Verbindungszeit),
  Verbinden/Trennen-Buttons pro Verbindung und Session
- Auth: /api/remote und /remote.ps1 von Authentifizierung ausgenommen

Architektur: Windows-Client → WebSocket → GOTT-Server → Agent-Tool
Session-gebunden: Eine Verbindung wird einer Session zugeordnet
This commit is contained in:
Meleki committed 2026-03-11 07:04:24 +01:00
1 parent 9f4746c2e7
commit 10da2bd964
14 files changed
+706 -18

No files matched your search

+4 -3
View File
@@ -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<ChatRequest>(req.Body);
if (body == null)
@@ -53,7 +53,8 @@ public static class ChatEndpoint
var userId = req.HttpContext.GetUserId();
var maxIterations = config.GetValue<int>("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
{
+193
View File
@@ -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<JsonElement>(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<JsonElement>(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<JsonElement>(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<IResult> 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");
}
}
+3 -1
View File
@@ -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;
+3
View File
@@ -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<CronjobService>();
builder.Services.AddHostedService<CronjobService>(sp => sp.GetRequiredService<CronjobService>());
var app = builder.Build();
app.UseWebSockets();
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseMiddleware<AuthMiddleware>();
@@ -93,6 +95,7 @@ app.MapSharedEndpoints();
app.MapAdminEndpoints();
app.MapServerSettingsEndpoints();
app.MapOpenClawEndpoints();
app.MapRemoteEndpoints();
var port = app.Configuration.GetValue<int>("Port", 19000);
app.Run($"http://0.0.0.0:{port}");
+11 -7
View File
@@ -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
/// <summary>
/// Fügt Tool-Definitionen und WebSearch zum Payload hinzu.
/// </summary>
private void AddToolsToPayload(Dictionary<string, object> payload, string workspace, string agentId, bool useMemory, string webSearchVersion, string toolChoice, int iteration, bool allowUserChoice = false, bool planMode = false)
private void AddToolsToPayload(Dictionary<string, object> 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<object>();
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);
+2 -2
View File
@@ -47,7 +47,7 @@ public class ChatService
/// <summary>
/// Baut den vollständigen ChatContext auf: Agent, Model, SystemPrompt, Chain, Workspace etc.
/// </summary>
public async Task<ChatContext> PrepareContext(string sessionId, string userId, int maxIterations = 100, bool planMode = false)
public async Task<ChatContext> 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<object>();
var toolNames = toolDefs.Length > 0
? JsonSerializer.Serialize(toolDefs.Select(td =>
+145
View File
@@ -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<RemoteCommandResult> 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<string, RemoteConnection> _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<RemoteConnection> 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;
}
/// <summary>
/// Sendet einen Befehl an den Remote-Client und wartet auf das Ergebnis.
/// </summary>
public async Task<RemoteCommandResult> 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<RemoteCommandResult>();
// 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();
}
}
/// <summary>
/// Wird vom WebSocket-Receiver aufgerufen wenn ein Ergebnis vom Client kommt.
/// </summary>
public void HandleResult(string connectionId, RemoteCommandResult result)
{
if (_connections.TryGetValue(connectionId, out var conn))
{
conn.PendingResult?.TrySetResult(result);
}
}
}
+47 -3
View File
@@ -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<object>();
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<string, object>
{
["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<string> ExecuteTool(string name, JsonElement input, string workspace, MemoryService memoryService = null, string agentId = null, CancellationToken cancellationToken = default)
public async Task<string> 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}";
}
+1
View File
@@ -358,6 +358,7 @@
<div class="header-menu">
<button class="header-menu-trigger icon-btn" id="chatMenuBtn"><i class="fa fa-ellipsis-v"></i></button>
<div class="header-menu-dropdown" id="chatMenuDropdown" hidden>
<button class="header-menu-item" id="remoteBtn"><i class="fa fa-desktop"></i> Remote-Verbindungen</button>
<button class="header-menu-item" id="systemPromptBtn">System-Prompt anzeigen</button>
<button class="header-menu-item" id="archiveBtn">Archivieren</button>
</div>
+25
View File
@@ -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() {
+98
View File
@@ -292,6 +292,104 @@ async function showOpenClawImportModal(sessionId) {
}
}
// ── Remote-Verbindungen Modal ──
async function showRemoteModal(sessionId) {
const { body, close } = _createModal('Remote-Verbindungen');
body.innerHTML = '<div class="sp-loading">Laden...</div>';
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.<br><br>' +
'<span style="font-size:var(--font-size);opacity:var(--semi-transparent)">Auf dem Windows-PC ausführen:<br>' +
'<code>irm ' + esc(location.origin) + '/remote.ps1 | iex</code></span>';
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 = '<i class="fa ' + statusIcon + '" style="' + statusColor + '"></i> ' +
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 = '<div class="sp-loading" style="color:var(--dangerous)">Fehler beim Laden: ' + esc(e.message) + '</div>';
}
}
await renderConnections();
}
// Event-Delegation für Krea-Thumbnails im Chat
document.addEventListener('click', function(e) {
var ref = e.target.closest('.krea-ref');
+7
View File
@@ -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', () => {
+3 -2
View File
@@ -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) {
+164
View File
@@ -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
}