Windows Shell-Ausführung: Temp-Script statt stdin/ShellArguments, background_execute Tool, konfigurierbarer Timeout

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)
This commit is contained in:
Meleki committed 2026-03-09 18:45:26 +01:00
1 parent 3e590a6d03
commit d6d7b6c04f
5 files changed
+102 -20

No files matched your search

+82 -7
View File
@@ -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<int>("ExecuteTimeout", 180);
public const string AskUserChoiceToolName = "ask_user_choice";
/// <summary>
@@ -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<string, object>
{
["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))
-8
View File
@@ -40,14 +40,6 @@ public static class WorkspaceHelper
/// </summary>
internal static string ShellExecutable => IsWindows ? "cmd.exe" : "/bin/bash";
/// <summary>
/// Gibt die Shell-Argumente zurück um einen Befehl auszuführen.
/// Windows: /c "command", Linux: via stdin.
/// </summary>
internal static string ShellArguments(string command)
=> IsWindows ? $"/c \"{command.Replace("\"", "\\\"")}\"" : "";
/// <summary>
/// Löst den Workspace-Pfad einer Session auf.
/// session.Workspace ist relativ zum user.DefaultPath.