From d6d7b6c04f0ee2fa543c7b28ec789609875c9af9 Mon Sep 17 00:00:00 2001 From: Meleki Date: Mon, 9 Mar 2026 18:45:26 +0100 Subject: [PATCH] =?UTF-8?q?Windows=20Shell-Ausf=C3=BChrung:=20Temp-Script?= =?UTF-8?q?=20statt=20stdin/ShellArguments,=20background=5Fexecute=20Tool,?= =?UTF-8?q?=20konfigurierbarer=20Timeout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Execute-Tools (execute, background_execute, SessionEndpoint.HandleExec): - Windows: Befehl in temp .cmd-Datei statt cmd.exe stdin (interaktiver Modus, &-Parsing, Mehr?-Prompt) oder ShellArguments (falsches \"-Escaping für cmd.exe) - Linux: weiterhin stdin an /bin/bash (funktioniert zuverlässig) - Script-Cleanup in allen Codepfaden (Success, Timeout, Error) Neues Tool background_execute: - Startet Prozesse losgelöst ohne Warten, gibt PID zurück - Temp-Script-Ansatz: funktioniert auch mit mehrzeiligen Befehlen - Windows: .cmd mit del "%~f0" (Selbstlöschung) - Linux: .sh mit rm -f (Selbstlöschung) Konfigurierbarer Execute-Timeout: - Neues Server-Setting ExecuteTimeout (Default: 180s) - IConfiguration in ToolService injected - Tool-Description zeigt aktuellen Timeout dynamisch an SessionEndpoint.HandleExec (foreground): - Pipe-Deadlock-Fix: ReadToEndAsync vor WaitForExitAsync - Gleicher Temp-Script-Ansatz wie ToolService WorkspaceHelper.ShellArguments() entfernt (keine Aufrufer mehr) --- Endpoints/ServerSettingsEndpoint.cs | 1 + Endpoints/SessionEndpoint.cs | 22 +++++-- Program.cs | 2 +- Services/ToolService.cs | 89 ++++++++++++++++++++++++++--- Services/WorkspaceHelper.cs | 8 --- 5 files changed, 102 insertions(+), 20 deletions(-) diff --git a/Endpoints/ServerSettingsEndpoint.cs b/Endpoints/ServerSettingsEndpoint.cs index 874a203..2a0802f 100644 --- a/Endpoints/ServerSettingsEndpoint.cs +++ b/Endpoints/ServerSettingsEndpoint.cs @@ -27,6 +27,7 @@ public static class ServerSettingsEndpoint new SettingDefinition("MaxIterations", "int", 100, "Max Tool-Loop Iterationen pro Chat", "number"), new SettingDefinition("MaxHistory", "int", 120, "Max History-Messages pro Request", "number"), new SettingDefinition("ReminderInterval", "int", 30, "System-Prompt Reminder alle N Messages (0 = deaktiviert)", "number"), + new SettingDefinition("ExecuteTimeout", "int", 180, "Timeout für Shell-Befehle in Sekunden", "number"), new SettingDefinition("PayloadLogging", "bool", false, "API-Payloads loggen (Debug)", "toggle"), new SettingDefinition("PayloadLoggingGzip", "bool", false, "Payload-Logs als .gz komprimieren", "toggle"), new SettingDefinition("KreaToken", "string", "", "Krea API Token", "password"), diff --git a/Endpoints/SessionEndpoint.cs b/Endpoints/SessionEndpoint.cs index d6a297c..f6de954 100644 --- a/Endpoints/SessionEndpoint.cs +++ b/Endpoints/SessionEndpoint.cs @@ -325,22 +325,31 @@ public static class SessionEndpoint // Synchron ausführen, auf Ergebnis warten try { + string scriptPath = null; var psi = new ProcessStartInfo { FileName = WorkspaceHelper.ShellExecutable, WorkingDirectory = workingDir, - RedirectStandardInput = !WorkspaceHelper.IsWindows, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; if (WorkspaceHelper.IsWindows) - psi.Arguments = WorkspaceHelper.ShellArguments(command); + { + scriptPath = Path.Combine(Path.GetTempPath(), $"gott_exec_{Guid.NewGuid():N}.cmd"); + await System.IO.File.WriteAllTextAsync(scriptPath, $"@echo off\r\n{command}\r\n"); + psi.Arguments = $"/c \"{scriptPath}\""; + } + else + { + psi.RedirectStandardInput = true; + } using var proc = System.Diagnostics.Process.Start(psi); if (proc == null) { + if (scriptPath != null) try { System.IO.File.Delete(scriptPath); } catch { } ctx.Response.StatusCode = 500; await ctx.Response.WriteAsJsonAsync(new { error = "Prozess konnte nicht gestartet werden" }); return; @@ -352,6 +361,9 @@ public static class SessionEndpoint proc.StandardInput.Close(); } + var stdoutTask = proc.StandardOutput.ReadToEndAsync(); + var stderrTask = proc.StandardError.ReadToEndAsync(); + using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(30)); try { @@ -360,12 +372,14 @@ public static class SessionEndpoint catch (OperationCanceledException) { proc.Kill(entireProcessTree: true); + if (scriptPath != null) try { System.IO.File.Delete(scriptPath); } catch { } await ctx.Response.WriteAsJsonAsync(new { status = "timeout", output = "Timeout nach 30 Sekunden", exitCode = -1 }); return; } + if (scriptPath != null) try { System.IO.File.Delete(scriptPath); } catch { } - var stdout = await proc.StandardOutput.ReadToEndAsync(); - var stderr = await proc.StandardError.ReadToEndAsync(); + var stdout = await stdoutTask; + var stderr = await stderrTask; var output = new System.Text.StringBuilder(); if (!string.IsNullOrEmpty(stdout)) output.Append(stdout); diff --git a/Program.cs b/Program.cs index deaf725..d9550c2 100644 --- a/Program.cs +++ b/Program.cs @@ -29,7 +29,7 @@ builder.Services.AddSingleton(memoryService); builder.Services.AddSingleton(skillService); // Shared Services -var toolService = new ToolService(); +var toolService = new ToolService(builder.Configuration); builder.Services.AddSingleton(toolService); var maxHistory = builder.Configuration.GetValue("MaxHistory", 120); var reminderInterval = builder.Configuration.GetValue("ReminderInterval", 30); diff --git a/Services/ToolService.cs b/Services/ToolService.cs index 5e2d02e..87cb068 100644 --- a/Services/ToolService.cs +++ b/Services/ToolService.cs @@ -8,6 +8,7 @@ using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.FileSystemGlobbing; using Microsoft.Extensions.FileSystemGlobbing.Abstractions; @@ -15,6 +16,14 @@ namespace Gott.Services; public class ToolService { + private readonly IConfiguration _config; + + public ToolService(IConfiguration config) + { + _config = config; + } + + private int ExecuteTimeoutSeconds => _config.GetValue("ExecuteTimeout", 180); public const string AskUserChoiceToolName = "ask_user_choice"; /// @@ -163,7 +172,7 @@ public class ToolService new { name = "execute", - description = "Führt einen Shell-Befehl im Workspace-Verzeichnis aus. Gibt stdout und stderr zurück. Timeout: 180 Sekunden.", + description = $"Führt einen Shell-Befehl im Workspace-Verzeichnis aus. Gibt stdout und stderr zurück. Timeout: {ExecuteTimeoutSeconds} Sekunden.", input_schema = new { type = "object", @@ -175,6 +184,20 @@ public class ToolService } }, new + { + name = "background_execute", + description = "Startet einen Shell-Befehl als Hintergrund-Prozess im Workspace-Verzeichnis. Der Prozess läuft unabhängig weiter – es wird NICHT auf dessen Ende gewartet. Gibt die Prozess-ID (PID) zurück. Nutze dies für lang laufende Prozesse wie Server, Watcher oder Build-Prozesse. stdout/stderr werden NICHT erfasst.", + input_schema = new + { + type = "object", + properties = new Dictionary + { + ["command"] = new { type = "string", description = "Shell-Befehl zum Ausführen im Hintergrund" } + }, + required = new[] { "command" } + } + }, + new { name = "glob", description = "Findet Dateien im Workspace anhand eines Glob-Patterns (z.B. '**/*.cs', 'src/*.js'). Gibt eine Liste passender Dateipfade zurück.", @@ -366,28 +389,43 @@ public class ToolService if (string.IsNullOrEmpty(workspace)) return "Fehler: Kein Workspace konfiguriert"; var command = input.GetProperty("command").GetString() ?? ""; + // Unter Windows: Befehl in temp .cmd-Datei schreiben und mit /c ausführen. + // stdin-Ansatz funktioniert nicht zuverlässig (interaktiver Modus, &-Parsing). + string scriptPath = null; var psi = new ProcessStartInfo { FileName = WorkspaceHelper.ShellExecutable, WorkingDirectory = workspace, - RedirectStandardInput = !WorkspaceHelper.IsWindows, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; if (WorkspaceHelper.IsWindows) - psi.Arguments = WorkspaceHelper.ShellArguments(command); + { + scriptPath = Path.Combine(Path.GetTempPath(), $"gott_exec_{Guid.NewGuid():N}.cmd"); + File.WriteAllText(scriptPath, $"@echo off\r\n{command}\r\n"); + psi.Arguments = $"/c \"{scriptPath}\""; + } + else + { + psi.RedirectStandardInput = true; + } using var proc = Process.Start(psi); - if (proc == null) return "Fehler: Prozess konnte nicht gestartet werden"; + if (proc == null) + { + if (scriptPath != null) try { File.Delete(scriptPath); } catch { } + return "Fehler: Prozess konnte nicht gestartet werden"; + } if (!WorkspaceHelper.IsWindows) { await proc.StandardInput.WriteAsync(command); proc.StandardInput.Close(); } - // Timeout (180s) UND externen CancellationToken verknüpfen + // Timeout UND externen CancellationToken verknüpfen + var timeoutSeconds = ExecuteTimeoutSeconds; using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(TimeSpan.FromSeconds(180)); + cts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); // stdout/stderr VOR WaitForExit lesen – verhindert Deadlock // wenn Pipe-Buffer volllaufen (Windows: nur 4KB!) var stdoutTask = proc.StandardOutput.ReadToEndAsync(); @@ -399,10 +437,12 @@ public class ToolService catch (OperationCanceledException) { proc.Kill(entireProcessTree: true); + if (scriptPath != null) try { File.Delete(scriptPath); } catch { } // Wenn der externe Token abgebrochen wurde, weiterwerfen cancellationToken.ThrowIfCancellationRequested(); - return "Fehler: Timeout nach 180 Sekunden - Prozess abgebrochen"; + return $"Fehler: Timeout nach {timeoutSeconds} Sekunden - Prozess abgebrochen"; } + if (scriptPath != null) try { File.Delete(scriptPath); } catch { } var stdout = await stdoutTask; var stderr = await stderrTask; var result = new StringBuilder(); @@ -416,6 +456,41 @@ public class ToolService else if (proc.ExitCode != 0) result.Append($"\n(exit code {proc.ExitCode})"); return result.ToString(); } + case "background_execute": + { + if (string.IsNullOrEmpty(workspace)) + return "Fehler: Kein Workspace konfiguriert"; + var command = input.GetProperty("command").GetString() ?? ""; + // Befehl in temporäre Script-Datei schreiben und losgelöst starten. + // So funktionieren auch mehrzeilige Befehle zuverlässig. + var scriptId = Guid.NewGuid().ToString("N")[..8]; + string scriptPath; + if (WorkspaceHelper.IsWindows) + { + scriptPath = Path.Combine(Path.GetTempPath(), $"gott_bg_{scriptId}.cmd"); + // Script löscht sich nach Ausführung selbst + File.WriteAllText(scriptPath, $"@echo off\r\n{command}\r\ndel \"%~f0\"\r\n"); + } + else + { + scriptPath = Path.Combine(Path.GetTempPath(), $"gott_bg_{scriptId}.sh"); + File.WriteAllText(scriptPath, $"#!/bin/sh\n{command}\nrm -f \"{scriptPath}\"\n"); + // Ausführbar machen + var chmod = Process.Start(new ProcessStartInfo { FileName = "chmod", Arguments = $"+x \"{scriptPath}\"", UseShellExecute = false, CreateNoWindow = true }); + chmod?.WaitForExit(5000); + } + var psi = new ProcessStartInfo + { + FileName = WorkspaceHelper.IsWindows ? "cmd.exe" : scriptPath, + Arguments = WorkspaceHelper.IsWindows ? $"/c \"{scriptPath}\"" : "", + WorkingDirectory = workspace, + UseShellExecute = false, + CreateNoWindow = true + }; + var proc = Process.Start(psi); + if (proc == null) return "Fehler: Prozess konnte nicht gestartet werden"; + return $"Hintergrund-Prozess gestartet (PID: {proc.Id})"; + } case "glob": { if (string.IsNullOrEmpty(workspace)) diff --git a/Services/WorkspaceHelper.cs b/Services/WorkspaceHelper.cs index cd70ec8..7f7ece7 100644 --- a/Services/WorkspaceHelper.cs +++ b/Services/WorkspaceHelper.cs @@ -40,14 +40,6 @@ public static class WorkspaceHelper /// internal static string ShellExecutable => IsWindows ? "cmd.exe" : "/bin/bash"; - /// - /// Gibt die Shell-Argumente zurück um einen Befehl auszuführen. - /// Windows: /c "command", Linux: via stdin. - /// - internal static string ShellArguments(string command) - => IsWindows ? $"/c \"{command.Replace("\"", "\\\"")}\"" : ""; - - /// /// Löst den Workspace-Pfad einer Session auf. /// session.Workspace ist relativ zum user.DefaultPath.