using System; using System.Collections.Concurrent; using System.IO; using System.Linq; 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; /// /// Interne API-Endpoints für MCP-Server und andere lokale Integrationen. /// Gesichert über einen internen Token (da Reverse-Proxy den Localhost-Check umgeht). /// public static class InternalEndpoint { /// /// Interner Auth-Token – wird beim Start generiert und an MCP-Server übergeben. /// public static string InternalToken { get; } = Guid.NewGuid().ToString("N"); /// /// Pending user choices – Key ist sessionId, Value wird completed wenn der User klickt. /// public static readonly ConcurrentDictionary> PendingChoices = new(); public static void MapInternalEndpoints(this IEndpointRouteBuilder app) { app.MapPost("/api/internal/remote-execute", HandleRemoteExecute); app.MapPost("/api/internal/ask-user-choice", HandleAskUserChoice); // choice-respond ist NICHT unter /api/internal/ weil es vom Frontend aufgerufen wird (normale Auth) app.MapPost("/api/chat/choice-respond", HandleChoiceRespond); app.MapPost("/api/internal/memory-read", HandleMemoryRead); app.MapPost("/api/internal/memory-write", HandleMemoryWrite); app.MapPost("/api/internal/memory-list", HandleMemoryList); } private static async Task HandleRemoteExecute(HttpContext ctx, RemoteConnectionManager mgr) { try { // Token-Check: Header "X-Internal-Token" muss den internen Token enthalten var token = ctx.Request.Headers["X-Internal-Token"].FirstOrDefault() ?? ""; if (token != InternalToken) return Results.StatusCode(403); using var reader = new StreamReader(ctx.Request.Body); var body = await reader.ReadToEndAsync(); JsonElement json; try { json = JsonSerializer.Deserialize(body); } catch { return Results.BadRequest(new { error = "Invalid JSON" }); } var command = json.TryGetProperty("command", out var cmd) ? cmd.GetString() ?? "" : ""; var sessionId = json.TryGetProperty("sessionId", out var sid) ? sid.GetString() ?? "" : ""; if (string.IsNullOrEmpty(command)) return Results.BadRequest(new { error = "command erforderlich" }); if (string.IsNullOrEmpty(sessionId)) return Results.BadRequest(new { error = "sessionId erforderlich" }); var conn = mgr.GetBySessionId(sessionId); if (conn == null) return Results.NotFound(new { error = "Keine Remote-Verbindung für diese Session" }); using var cts = new CancellationTokenSource(System.TimeSpan.FromSeconds(180)); var result = await mgr.ExecuteCommand(conn.Id, command, 180, cts.Token); return Results.Ok(new { exitCode = result.ExitCode, stdout = result.Output ?? "", stderr = result.Error ?? "" }); } catch (System.Exception ex) { return Results.Json(new { error = ex.Message, stackTrace = ex.StackTrace }, statusCode: 500); } } /// /// MCP-Server ruft diesen Endpoint auf und blockiert bis der User eine Auswahl trifft. /// Der TCS wird vorher von AnthropicService erstellt (beim tool_use Event). /// private static async Task HandleAskUserChoice(HttpContext ctx) { try { var denied = CheckToken(ctx); if (denied != null) return denied; using var reader = new StreamReader(ctx.Request.Body); var body = await reader.ReadToEndAsync(); JsonElement json; try { json = JsonSerializer.Deserialize(body); } catch { return Results.BadRequest(new { error = "Invalid JSON" }); } var sessionId = json.TryGetProperty("sessionId", out var sid) ? sid.GetString() ?? "" : ""; if (string.IsNullOrEmpty(sessionId)) return Results.BadRequest(new { error = "sessionId erforderlich" }); // Warte auf den TCS der von AnthropicService erstellt wurde if (!PendingChoices.TryGetValue(sessionId, out var tcs)) { // TCS noch nicht erstellt (Race Condition) – kurz warten und nochmal probieren for (int i = 0; i < 10; i++) { await Task.Delay(500, ctx.RequestAborted); if (PendingChoices.TryGetValue(sessionId, out tcs)) break; } if (tcs == null) return Results.Json(new { error = "Keine ausstehende Auswahl für diese Session" }, statusCode: 404); } // Blockieren bis User antwortet (max 5 Minuten) using var cts = CancellationTokenSource.CreateLinkedTokenSource(ctx.RequestAborted); cts.CancelAfter(TimeSpan.FromMinutes(5)); try { var choice = await tcs.Task.WaitAsync(cts.Token); PendingChoices.TryRemove(sessionId, out _); return Results.Ok(new { choice }); } catch (OperationCanceledException) { PendingChoices.TryRemove(sessionId, out _); return Results.Json(new { error = "Timeout – keine Auswahl getroffen" }, statusCode: 408); } } catch (Exception ex) { return Results.Json(new { error = ex.Message }, statusCode: 500); } } /// /// Frontend ruft diesen Endpoint auf wenn der User einen Choice-Button klickt (CLI-Modus). /// private static async Task HandleChoiceRespond(HttpContext ctx) { try { // Kein Token-Check – wird über normale Auth-Middleware geschützt (Cookie-basiert) using var reader = new StreamReader(ctx.Request.Body); var body = await reader.ReadToEndAsync(); JsonElement json; try { json = JsonSerializer.Deserialize(body); } catch { return Results.BadRequest(new { error = "Invalid JSON" }); } var sessionId = json.TryGetProperty("sessionId", out var sid) ? sid.GetString() ?? "" : ""; var choice = json.TryGetProperty("choice", out var c) ? c.GetString() ?? "" : ""; if (string.IsNullOrEmpty(sessionId)) return Results.BadRequest(new { error = "sessionId erforderlich" }); if (string.IsNullOrEmpty(choice)) return Results.BadRequest(new { error = "choice erforderlich" }); if (PendingChoices.TryGetValue(sessionId, out var tcs)) { tcs.TrySetResult(choice); return Results.Ok(new { success = true }); } return Results.NotFound(new { error = "Keine ausstehende Auswahl für diese Session" }); } catch (Exception ex) { return Results.Json(new { error = ex.Message }, statusCode: 500); } } private static IResult CheckToken(HttpContext ctx) { var token = ctx.Request.Headers["X-Internal-Token"].FirstOrDefault() ?? ""; return token != InternalToken ? Results.StatusCode(403) : null; } private static async Task HandleMemoryRead(HttpContext ctx, MemoryService memoryService) { try { var denied = CheckToken(ctx); if (denied != null) return denied; using var reader = new StreamReader(ctx.Request.Body); var body = await reader.ReadToEndAsync(); JsonElement json; try { json = JsonSerializer.Deserialize(body); } catch { return Results.BadRequest(new { error = "Invalid JSON" }); } var agentId = json.TryGetProperty("agentId", out var a) ? a.GetString() ?? "" : ""; var path = json.TryGetProperty("path", out var p) ? p.GetString() ?? "" : ""; if (string.IsNullOrEmpty(agentId)) return Results.BadRequest(new { error = "agentId erforderlich" }); if (string.IsNullOrEmpty(path)) return Results.BadRequest(new { error = "path erforderlich" }); var content = memoryService.ReadMemoryFile(agentId, path); if (content == null) return Results.NotFound(new { error = $"Datei nicht gefunden: {path}" }); return Results.Ok(new { content }); } catch (System.Exception ex) { return Results.Json(new { error = ex.Message }, statusCode: 500); } } private static async Task HandleMemoryWrite(HttpContext ctx, MemoryService memoryService) { try { var denied = CheckToken(ctx); if (denied != null) return denied; using var reader = new StreamReader(ctx.Request.Body); var body = await reader.ReadToEndAsync(); JsonElement json; try { json = JsonSerializer.Deserialize(body); } catch { return Results.BadRequest(new { error = "Invalid JSON" }); } var agentId = json.TryGetProperty("agentId", out var a) ? a.GetString() ?? "" : ""; var path = json.TryGetProperty("path", out var p) ? p.GetString() ?? "" : ""; var content = json.TryGetProperty("content", out var c) ? c.GetString() ?? "" : ""; var append = json.TryGetProperty("append", out var ap) && ap.GetBoolean(); if (string.IsNullOrEmpty(agentId)) return Results.BadRequest(new { error = "agentId erforderlich" }); if (string.IsNullOrEmpty(path)) return Results.BadRequest(new { error = "path erforderlich" }); if (append) memoryService.AppendMemoryFile(agentId, path, content); else memoryService.WriteMemoryFile(agentId, path, content); return Results.Ok(new { success = true, message = $"Memory-Datei {(append ? "erweitert" : "geschrieben")}: {path} ({content.Length} Zeichen)" }); } catch (System.Exception ex) { return Results.Json(new { error = ex.Message }, statusCode: 500); } } private static async Task HandleMemoryList(HttpContext ctx, MemoryService memoryService) { try { var denied = CheckToken(ctx); if (denied != null) return denied; using var reader = new StreamReader(ctx.Request.Body); var body = await reader.ReadToEndAsync(); JsonElement json; try { json = JsonSerializer.Deserialize(body); } catch { return Results.BadRequest(new { error = "Invalid JSON" }); } var agentId = json.TryGetProperty("agentId", out var a) ? a.GetString() ?? "" : ""; var subPath = json.TryGetProperty("path", out var p) ? p.GetString() ?? "" : ""; if (string.IsNullOrEmpty(agentId)) return Results.BadRequest(new { error = "agentId erforderlich" }); var files = memoryService.ListMemoryFiles(agentId, subPath); return Results.Ok(new { files }); } catch (System.Exception ex) { return Results.Json(new { error = ex.Message }, statusCode: 500); } } }