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:
1 parent
9f4746c2e7
commit
10da2bd964
14 files changed
+706
-18
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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
@@ -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}";
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user