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:
1 parent
3e590a6d03
commit
d6d7b6c04f
5 files changed
+102
-20
No files matched your search
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
|
||||
+1
-1
@@ -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<int>("MaxHistory", 120);
|
||||
var reminderInterval = builder.Configuration.GetValue<int>("ReminderInterval", 30);
|
||||
|
||||
+82
-7
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user