feat: Windows-Kompatibilität

- WorkspaceHelper: Zentrale Hilfsmethoden (IsWindows, TempDir, IsSubPathOf,
  GetRelativePath, ShellExecutable, ShellArguments)
- ToolService: execute nutzt cmd.exe auf Windows, GetRelativePath mit
  Forward-Slashes, IsSubPathOf für case-insensitive Pfadprüfung
- SessionEndpoint: /tmp → Path.GetTempPath(), bash → cmd.exe,
  Fire-and-forget via .bat statt systemd-run auf Windows
- ChatService: /tmp → WorkspaceHelper.TempDir
- MemoryService: IsSubPathOf + GetRelativePath
- WorkspaceEndpoint: HandleBrowse zeigt Laufwerke auf Windows,
  IsSubPathOf statt StartsWith
- InfoEndpoint: gott.exe auf Windows, isWindows-Flag in API-Response
- Frontend: File-Explorer mit Laufwerks-Navigation auf Windows
This commit is contained in:
Meleki committed 2026-03-09 11:35:04 +01:00
1 parent 79899a6981
commit 5d090e160b
9 files changed
+155 -61

No files matched your search

+1 -1
View File
@@ -66,7 +66,7 @@ public class ChatService
var workspace = WorkspaceHelper.ResolveWorkspace(session, user);
if (string.IsNullOrEmpty(workspace) && agentSkills.Any())
workspace = "/tmp";
workspace = WorkspaceHelper.TempDir;
var promptBlocks = SystemPromptBuilder.BuildBlocks(
agent, session, markedMessages, _memoryService, agentMemoryDir,
+4 -4
View File
@@ -81,7 +81,7 @@ public class MemoryService
? baseDir
: Path.GetFullPath(Path.Combine(baseDir, subPath));
if (!targetDir.StartsWith(baseDir))
if (!WorkspaceHelper.IsSubPathOf(targetDir, baseDir))
throw new Exception("Zugriff außerhalb des Memory-Verzeichnisses nicht erlaubt");
if (!Directory.Exists(targetDir))
@@ -89,9 +89,9 @@ public class MemoryService
var entries = new List<string>();
foreach (var dir in Directory.GetDirectories(targetDir))
entries.Add(Path.GetRelativePath(baseDir, dir) + "/");
entries.Add(WorkspaceHelper.GetRelativePath(baseDir, dir) + "/");
foreach (var file in Directory.GetFiles(targetDir))
entries.Add(Path.GetRelativePath(baseDir, file));
entries.Add(WorkspaceHelper.GetRelativePath(baseDir, file));
return entries;
}
@@ -148,7 +148,7 @@ public class MemoryService
{
var baseDir = GetAgentMemoryDir(agentId);
var full = Path.GetFullPath(Path.Combine(baseDir, fileName));
if (!full.StartsWith(baseDir))
if (!WorkspaceHelper.IsSubPathOf(full, baseDir))
return null;
return full;
}
+22 -17
View File
@@ -300,7 +300,7 @@ public class ToolService
return "Fehler: Kein Workspace konfiguriert";
var path = ResolvePath(input.GetProperty("path").GetString() ?? "", workspace);
if (!File.Exists(path))
return $"Fehler: Datei nicht gefunden: {Path.GetRelativePath(workspace, path)}";
return $"Fehler: Datei nicht gefunden: {WorkspaceHelper.GetRelativePath(workspace, path)}";
return File.ReadAllText(path);
}
case "write_file":
@@ -312,7 +312,7 @@ public class ToolService
if (dir != null) Directory.CreateDirectory(dir);
var content = input.GetProperty("content").GetString() ?? "";
File.WriteAllText(path, content);
return $"Datei geschrieben: {Path.GetRelativePath(workspace, path)} ({content.Length} Zeichen)";
return $"Datei geschrieben: {WorkspaceHelper.GetRelativePath(workspace, path)} ({content.Length} Zeichen)";
}
case "edit_file":
{
@@ -320,7 +320,7 @@ public class ToolService
return "Fehler: Kein Workspace konfiguriert";
var path = ResolvePath(input.GetProperty("path").GetString() ?? "", workspace);
if (!File.Exists(path))
return $"Fehler: Datei nicht gefunden: {Path.GetRelativePath(workspace, path)}";
return $"Fehler: Datei nicht gefunden: {WorkspaceHelper.GetRelativePath(workspace, path)}";
var fileContent = File.ReadAllText(path);
var oldText = input.GetProperty("old_text").GetString() ?? "";
var newText = input.GetProperty("new_text").GetString() ?? "";
@@ -328,7 +328,7 @@ public class ToolService
return "Fehler: old_text nicht in der Datei gefunden. Exakter Match nötig.";
fileContent = fileContent.Replace(oldText, newText);
File.WriteAllText(path, fileContent);
return $"Datei bearbeitet: {Path.GetRelativePath(workspace, path)}";
return $"Datei bearbeitet: {WorkspaceHelper.GetRelativePath(workspace, path)}";
}
case "list_files":
{
@@ -336,12 +336,12 @@ public class ToolService
return "Fehler: Kein Workspace konfiguriert";
var path = ResolvePath(input.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "", workspace);
if (!Directory.Exists(path))
return $"Fehler: Verzeichnis nicht gefunden: {Path.GetRelativePath(workspace, path)}";
return $"Fehler: Verzeichnis nicht gefunden: {WorkspaceHelper.GetRelativePath(workspace, path)}";
var entries = new List<string>();
foreach (var dir in Directory.GetDirectories(path))
entries.Add(Path.GetRelativePath(workspace, dir) + "/");
entries.Add(WorkspaceHelper.GetRelativePath(workspace, dir) + "/");
foreach (var file in Directory.GetFiles(path))
entries.Add(Path.GetRelativePath(workspace, file));
entries.Add(WorkspaceHelper.GetRelativePath(workspace, file));
return entries.Count == 0 ? "(leer)" : string.Join("\n", entries);
}
case "delete_file":
@@ -352,14 +352,14 @@ public class ToolService
if (File.Exists(path))
{
File.Delete(path);
return $"Datei gelöscht: {Path.GetRelativePath(workspace, path)}";
return $"Datei gelöscht: {WorkspaceHelper.GetRelativePath(workspace, path)}";
}
if (Directory.Exists(path))
{
Directory.Delete(path, recursive: false);
return $"Verzeichnis gelöscht: {Path.GetRelativePath(workspace, path)}";
return $"Verzeichnis gelöscht: {WorkspaceHelper.GetRelativePath(workspace, path)}";
}
return $"Fehler: Nicht gefunden: {Path.GetRelativePath(workspace, path)}";
return $"Fehler: Nicht gefunden: {WorkspaceHelper.GetRelativePath(workspace, path)}";
}
case "execute":
{
@@ -368,18 +368,23 @@ public class ToolService
var command = input.GetProperty("command").GetString() ?? "";
var psi = new ProcessStartInfo
{
FileName = "/bin/bash",
FileName = WorkspaceHelper.ShellExecutable,
WorkingDirectory = workspace,
RedirectStandardInput = true,
RedirectStandardInput = !WorkspaceHelper.IsWindows,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
if (WorkspaceHelper.IsWindows)
psi.Arguments = WorkspaceHelper.ShellArguments(command);
using var proc = Process.Start(psi);
if (proc == null) return "Fehler: Prozess konnte nicht gestartet werden";
await proc.StandardInput.WriteAsync(command);
proc.StandardInput.Close();
if (!WorkspaceHelper.IsWindows)
{
await proc.StandardInput.WriteAsync(command);
proc.StandardInput.Close();
}
// Timeout (180s) UND externen CancellationToken verknüpfen
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(180));
@@ -423,7 +428,7 @@ public class ToolService
var dirInfo = new DirectoryInfoWrapper(new DirectoryInfo(searchRoot));
var result = matcher.Execute(dirInfo);
var matches = result.Files
.Select(f => Path.GetRelativePath(workspace, Path.Combine(searchRoot, f.Path)))
.Select(f => WorkspaceHelper.GetRelativePath(workspace, Path.Combine(searchRoot, f.Path)))
.OrderBy(f => f)
.ToList();
return matches.Count == 0 ? "(keine Treffer)" : string.Join("\n", matches);
@@ -459,7 +464,7 @@ public class ToolService
for (var i = 0; i < lines.Length; i++)
{
if (regex.IsMatch(lines[i]))
results.Add($"{Path.GetRelativePath(workspace, file)}:{i + 1}: {lines[i].Trim()}");
results.Add($"{WorkspaceHelper.GetRelativePath(workspace, file)}:{i + 1}: {lines[i].Trim()}");
}
}
if (results.Count == 0) return "(keine Treffer)";
@@ -521,7 +526,7 @@ public class ToolService
private string ResolvePath(string relativePath, string workspace)
{
var full = Path.GetFullPath(Path.Combine(workspace, relativePath ?? ""));
if (!full.StartsWith(workspace))
if (!WorkspaceHelper.IsSubPathOf(full, workspace))
throw new Exception("Zugriff außerhalb des Workspace nicht erlaubt");
return full;
}
+47 -5
View File
@@ -1,11 +1,53 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using Gott.Models;
namespace Gott.Services;
public static class WorkspaceHelper
{
/// <summary>True wenn die Plattform Windows ist.</summary>
internal static bool IsWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
/// <summary>Plattformunabhängiges Temp-Verzeichnis ohne Trailing-Separator.</summary>
internal static string TempDir => Path.GetTempPath().TrimEnd(Path.DirectorySeparatorChar);
/// <summary>
/// Prüft ob ein Pfad ein Sub-Pfad eines Basis-Pfads ist (oder gleich).
/// Auf Windows case-insensitive, auf Linux case-sensitive.
/// </summary>
internal static bool IsSubPathOf(string path, string basePath)
{
var comparison = IsWindows ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
var normalizedPath = Path.GetFullPath(path);
var normalizedBase = Path.GetFullPath(basePath);
return normalizedPath.Equals(normalizedBase, comparison)
|| normalizedPath.StartsWith(normalizedBase + Path.DirectorySeparatorChar, comparison);
}
/// <summary>
/// Gibt den relativen Pfad zurück – immer mit Forward-Slashes (für Agent-Output/API).
/// </summary>
internal static string GetRelativePath(string relativeTo, string path)
{
var rel = Path.GetRelativePath(relativeTo, path);
return IsWindows ? rel.Replace('\\', '/') : rel;
}
/// <summary>
/// Gibt den Shell-Dateinamen zurück (cmd.exe auf Windows, /bin/bash auf Linux).
/// </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.
@@ -30,8 +72,7 @@ public static class WorkspaceHelper
var resolved = Path.GetFullPath(Path.Combine(basePath, workspace));
// Sicherheit: darf nicht aus dem DefaultPath ausbrechen
var normalizedBase = Path.GetFullPath(basePath);
if (!resolved.StartsWith(normalizedBase + Path.DirectorySeparatorChar) && resolved != normalizedBase)
if (!IsSubPathOf(resolved, Path.GetFullPath(basePath)))
return null;
return resolved;
@@ -47,7 +88,7 @@ public static class WorkspaceHelper
if (string.IsNullOrWhiteSpace(workspace))
return null;
var trimmed = workspace.Trim().TrimStart('/').TrimEnd('/');
var trimmed = workspace.Trim().TrimStart('/', '\\').TrimEnd('/', '\\');
if (string.IsNullOrEmpty(trimmed))
return null;
@@ -76,11 +117,12 @@ public static class WorkspaceHelper
var normalizedBase = Path.GetFullPath(defaultPath.Trim());
var normalizedWorkspace = Path.GetFullPath(absoluteWorkspace.Trim());
var comparison = IsWindows ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
if (normalizedWorkspace.StartsWith(normalizedBase + Path.DirectorySeparatorChar))
if (normalizedWorkspace.StartsWith(normalizedBase + Path.DirectorySeparatorChar, comparison))
return normalizedWorkspace.Substring(normalizedBase.Length + 1);
if (normalizedWorkspace == normalizedBase)
if (normalizedWorkspace.Equals(normalizedBase, comparison))
return "";
return absoluteWorkspace; // Außerhalb des DefaultPath → absoluten Pfad beibehalten (Legacy)