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>
259 lines
11 KiB
C#
259 lines
11 KiB
C#
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);
|
||
}
|
||
}
|
||
}
|