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.