Files
gott/Endpoints/InternalEndpoint.cs
T
MelekiandClaude Opus 4.8 ace5036f00 Remote-Keep-Alive gegen 180s-Timeout + System-Prompt via Datei
Task 1 – Remote-Befehle laufen nicht mehr in einen starren 180s-Timeout:
- RemoteConnectionManager.ExecuteCommand nutzt jetzt einen INAKTIVITÄTS-
  Timeout (Watchdog auf LastProgressAt) statt hartem Wall-Clock-Limit; ein
  Befehl darf beliebig lange laufen, solange der Client Progress-Frames sendet.
- RemoteEndpoint: neuer "progress"-Case setzt die Frist zurück.
- InternalEndpoint (MCP-Pfad remote_execute): hartes CancellationTokenSource(180s)
  entfernt → nutzt konfigurierbares ExecuteTimeout-Setting + RequestAborted.
- remote.ps1 / remote.sh: senden während laufender Befehle alle 15s ein
  {"type":"progress"}-Keep-Alive; async stdout/stderr-Lesen gegen Pipe-Deadlocks.
- ToolService: Tool-Beschreibungen erklären den Inaktivitäts-Timeout.

Task 2 – System-Prompt über --system-prompt-file statt CLI-Argument:
- ClaudeCliService schreibt den vollen System-Prompt in eine Temp-Datei und
  übergibt --system-prompt-file, um das Windows-Kommandozeilen-Längenlimit zu
  umgehen. Cleanup im finally. Agent-Verhalten unverändert (MEMORY.md bleibt inline).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:21:55 +02:00

259 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// Interne API-Endpoints für MCP-Server und andere lokale Integrationen.
/// Gesichert über einen internen Token (da Reverse-Proxy den Localhost-Check umgeht).
/// </summary>
public static class InternalEndpoint
{
/// <summary>
/// Interner Auth-Token – wird beim Start generiert und an MCP-Server übergeben.
/// </summary>
public static string InternalToken { get; } = Guid.NewGuid().ToString("N");
/// <summary>
/// Beantwortete User-Choices – Key ist sessionId, Value ist die gewählte Option.
/// MCP-Server pollt dieses Dictionary (Short-Poll statt Long-Poll).
/// </summary>
public static readonly ConcurrentDictionary<string, string> ChoiceAnswers = new();
public static void MapInternalEndpoints(this IEndpointRouteBuilder app)
{
app.MapPost("/api/internal/remote-execute", HandleRemoteExecute);
// WICHTIG: Expliziter Rückgabetyp Task<IResult> nötig! Ohne ihn interpretiert ASP.NET den Lambda als
// RequestDelegate (Func<HttpContext,Task>) → IResult-Rückgabe wird ignoriert → 200 empty body.
app.MapPost("/api/internal/ask-user-choice", async Task<IResult> (HttpContext ctx) => await HandleAskUserChoice(ctx));
// choice-respond ist NICHT unter /api/internal/ weil es vom Frontend aufgerufen wird (normale Auth)
app.MapPost("/api/chat/choice-respond", async Task<IResult> (HttpContext ctx) => await HandleChoiceRespond(ctx));
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<IResult> HandleRemoteExecute(HttpContext ctx, RemoteConnectionManager mgr, ServerSettingsService settings)
{
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<JsonElement>(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" });
// Kein hartes Wall-Clock-Limit mehr: ExecuteCommand nutzt einen INAKTIVITÄTS-Timeout
// (Client sendet Progress-Keep-Alives). Als Frist die konfigurierbare ExecuteTimeout-
// Einstellung verwenden; abgebrochen wird nur bei echtem Client-Stillstand oder wenn
// der Request selbst abbricht (RequestAborted).
var inactivityTimeout = settings.GetInt("ExecuteTimeout", 180);
var result = await mgr.ExecuteCommand(conn.Id, command, inactivityTimeout, ctx.RequestAborted);
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);
}
}
/// <summary>
/// MCP-Server pollt diesen Endpoint bis der User eine Auswahl getroffen hat.
/// Gibt 200 mit { choice } zurück wenn beantwortet, 404 wenn noch ausstehend.
/// </summary>
private static async Task<IResult> 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<JsonElement>(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" });
// Prüfen ob bereits eine Antwort vorliegt (Short-Poll)
if (ChoiceAnswers.TryRemove(sessionId, out var choice))
return Results.Ok(new { choice });
// Noch keine Antwort – MCP-Server soll erneut pollen
return Results.Json(new { status = "pending" }, statusCode: 202);
}
catch (Exception ex)
{
return Results.Json(new { error = ex.Message }, statusCode: 500);
}
}
/// <summary>
/// Frontend ruft diesen Endpoint auf wenn der User einen Choice-Button klickt (CLI-Modus).
/// Speichert die Antwort in ChoiceAnswers – MCP-Server holt sie beim nächsten Poll ab.
/// </summary>
private static async Task<IResult> 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<JsonElement>(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" });
// Antwort speichern – MCP-Server holt sie beim nächsten Poll ab
ChoiceAnswers[sessionId] = choice;
return Results.Ok(new { success = true });
}
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<IResult> 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<JsonElement>(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<IResult> 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<JsonElement>(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<IResult> 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<JsonElement>(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);
}
}
}