Files
ClawdDotNet/src/ClawdDotNet.Tools.FTP/FtpTool.cs
T
RichardandClaude Opus 5.5 88c42b244d FTP und RocketChat kennen die benannten Arbeitsbereiche (#718)
Gemeinsamer Helfer NamedWorkspaces in Core statt dreier Kopien: unit: und
Bereichs-Id als Praefix, Lesen braucht Read, Herunterladen braucht Write,
Unbekanntes und Verbotenes antworten gleich. Ein Dateiname aus einer
RocketChat-Nachricht waehlt keinen Bereich.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:11:02 +02:00

364 lines
16 KiB
C#

using System.Text;
using System.Text.Json;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tools;
using FluentFTP;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Tools.FTP;
public sealed class FTPTool : IAgentTool
{
public string Name => "FTP";
/// <summary>#715: Brings text from outside into the context — files from outside servers.</summary>
public bool ReadsForeignContent => true;
public string Description => """
Allows file transfer between the local workspace and a remote FTP/FTPS server.
For localPath, the same workspace prefixes as in the FileRW tool are supported:
- "personal:website/index.html" → file in the agent's personal workspace
- "shared:website/index.html" → file in the shared SharedWorkspace
- "unit:report.pdf" → your unit's workspace; "<workspace id>:file" → a workspace you are a member of
- Without prefix, the personal workspace is used.
Uploading from a workspace needs read access to it, downloading into one needs write access.
Example upload: { "action": "upload", "localPath": "shared:website/index.html", "remotePath": "/index.html" }
""";
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
{
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["upload", "download", "list", "delete"],
"description": "The action to perform"
},
"localPath": {
"type": "string",
"description": "Path to local file with optional workspace prefix: 'personal:file.txt' (default), 'shared:file.txt' for the SharedWorkspace, 'unit:file.txt' for your unit's workspace or '<workspace id>:file.txt'. Without prefix, personal workspace is used."
},
"remotePath": {
"type": "string",
"description": "Path on the FTP server"
}
},
"required": ["action"]
}
""").RootElement.Clone();
public async Task<ToolResult> ExecuteAsync(
JsonElement input,
AgentToolContext context,
CancellationToken ct)
{
var action = input.GetProperty("action").GetString()
?? throw new ArgumentException("'action' is required");
var config = context.ToolConfig;
var host = config.TryGetValue("host", out var h) ? h?.ToString() : null;
var username = config.TryGetValue("username", out var u) ? u?.ToString() : null;
var password = config.TryGetValue("password", out var p) ? p?.ToString() : null;
var port = 21;
if (config.TryGetValue("port", out var po) && po is JsonElement portElement)
port = portElement.TryGetInt32(out var p32) ? p32 : 21;
if (string.IsNullOrWhiteSpace(host))
{
return ToolResult.Fail("Configuration error: 'host' is required.");
}
// SSL/TLS-Modus aus Config lesen (Standard: true)
var useSsl = true;
if (config.TryGetValue("useSsl", out var sslVal))
{
if (sslVal is JsonElement sslElem) useSsl = sslElem.GetBoolean();
else if (sslVal is bool sslBool) useSsl = sslBool;
}
using var ftpClient = new AsyncFtpClient(host, username, password, port);
if (useSsl)
{
ftpClient.Config.EncryptionMode = FtpEncryptionMode.Explicit;
ftpClient.Config.ValidateAnyCertificate = true;
}
try
{
await ftpClient.Connect(ct);
return action switch
{
"upload" => await HandleUploadAsync(ftpClient, input, context, ct),
"download" => await HandleDownloadAsync(ftpClient, input, context, ct),
"list" => await HandleListAsync(ftpClient, input, context, ct),
"delete" => await HandleDeleteAsync(ftpClient, input, context, ct),
_ => ToolResult.Fail($"Unknown action: {action}")
};
}
catch (Exception ex)
{
context.Logger.LogError(ex, "Error during FTP action {Action} on {Host}", action, host);
return ToolResult.Fail($"Error: {ex.Message}");
}
finally
{
if (ftpClient.IsConnected) await ftpClient.Disconnect(ct);
}
}
/// <summary>
/// Löst einen localPath mit optionalem Workspace-Prefix auf.
/// "shared:website/index.html" → SharedWorkspace + "website/index.html"
/// "personal:datei.txt" → persönlicher Workspace + "datei.txt"
/// "datei.txt" → persönlicher Workspace + "datei.txt" (Standard)
/// </summary>
/// <param name="write">
/// #718: The file is written into the workspace (download). A workspace with a member list
/// then needs Write; reading from it (upload) needs Read.
/// </param>
internal static (string fullPath, string workspaceLabel) ResolveLocalPath(string localPath, AgentToolContext context, bool write)
{
string basePath;
var (label, relativePath) = NamedWorkspaces.Split(localPath);
if (label == WorkspaceLayout.Shared)
{
basePath = context.SharedWorkspacePath
?? throw new ArgumentException("No SharedWorkspace configured.");
}
else if (label == WorkspaceLayout.Personal)
{
basePath = context.WorkspacePath
?? throw new ArgumentException("No personal workspace configured.");
}
else
{
// #718: "unit:" or a workspace id. Unknown and not allowed answer the same.
var named = NamedWorkspaces.Resolve(label, context.AgentId, context.Organisation, context.SharedWorkspacePath);
if (named is null || named.Access < (write ? WorkspaceAccess.Write : WorkspaceAccess.Read))
throw new ArgumentException(named is null
? NamedWorkspaces.Unknown(label, context.AgentId, context.Organisation, withShared: true)
: $"Access denied: this agent may not {(write ? "write to" : "read from")} workspace '{named.Workspace.Id}'.");
basePath = named.Root;
label = named.Workspace.Id;
if (write)
Directory.CreateDirectory(basePath);
}
// Config-rootPath als Override (nur wenn kein Prefix verwendet)
if (label == "personal" && !localPath.Contains(':'))
{
if (context.ToolConfig.TryGetValue("rootPath", out var rp) && !string.IsNullOrWhiteSpace(rp?.ToString()))
{
basePath = rp.ToString()!;
}
}
basePath = Path.GetFullPath(basePath);
var fullPath = Path.GetFullPath(Path.Combine(basePath, relativePath));
// Sicherheitsprüfung: Kein Ausbruch aus dem Workspace.
//
// PathBoundary statt eines Zeichenketten-Präfixes: Es vergleicht auf
// Verzeichnisgrenzen (sonst gälte "…/Workspace-Backup" als Teil von
// "…/Workspace"), berücksichtigt die Groß-/Kleinschreibung des Dateisystems
// und löst symbolische Verknüpfungen auf.
if (!PathBoundary.IsInside(fullPath, basePath))
{
throw new UnauthorizedAccessException(
$"Access outside the {label} workspace directory denied.");
}
return (fullPath, label);
}
private async Task<ToolResult> HandleUploadAsync(IAsyncFtpClient client, JsonElement input, AgentToolContext context, CancellationToken ct)
{
var localRel = input.TryGetProperty("localPath", out var lp) ? lp.GetString() : null;
var remote = input.TryGetProperty("remotePath", out var rp) ? rp.GetString() : null;
if (string.IsNullOrWhiteSpace(localRel) || string.IsNullOrWhiteSpace(remote))
{
return ToolResult.Fail("'localPath' and 'remotePath' are required for upload.");
}
try
{
var (localFull, wsLabel) = ResolveLocalPath(localRel, context, write: false);
if (!File.Exists(localFull))
return ToolResult.Fail($"Local file not found: {localRel} (resolved: {localFull})");
var status = await client.UploadFile(localFull, remote, FtpRemoteExists.Overwrite, true, FtpVerify.None, null, ct);
if (status != FtpStatus.Success)
return ToolResult.Fail($"Upload failed: {status}");
// Bei HTML/CSS/JS-Uploads: .htaccess für UTF-8 sicherstellen
var extra = "";
if (IsWebFile(remote))
{
var htaccessDeployed = await EnsureHtaccessAsync(client, remote, context.Logger, ct);
if (htaccessDeployed)
extra = " (.htaccess for UTF-8 encoding was automatically created)";
}
return ToolResult.Ok($"[{wsLabel} Workspace] File successfully uploaded: {localRel} → {remote}{extra}");
}
catch (ArgumentException ex)
{
return ToolResult.Fail(ex.Message);
}
catch (UnauthorizedAccessException ex)
{
return ToolResult.Fail(ex.Message);
}
}
private static bool IsWebFile(string remotePath)
{
var ext = Path.GetExtension(remotePath).ToLowerInvariant();
return ext is ".html" or ".htm" or ".css" or ".js" or ".json" or ".xml" or ".svg";
}
private const string HtaccessCharsetMarker = "# ClawdDotNet-UTF8-Charset";
private const string HtaccessCharsetBlock = $"""
{HtaccessCharsetMarker}
AddDefaultCharset UTF-8
<IfModule mod_mime.c>
AddCharset UTF-8 .html .htm .css .js .json .xml .svg .txt
</IfModule>
""";
/// <summary>
/// Stellt sicher, dass im Zielverzeichnis eine .htaccess mit UTF-8 Charset existiert.
/// - Existiert keine .htaccess: Neue mit nur den Charset-Regeln erstellen.
/// - Existiert eine .htaccess: Charset-Block anhängen, falls der Marker noch nicht drin ist.
/// Der bestehende Inhalt (z.B. Zugriffsschutz, Rewrite-Regeln) bleibt IMMER unangetastet.
/// </summary>
private async Task<bool> EnsureHtaccessAsync(IAsyncFtpClient client, string remotePath, ILogger logger, CancellationToken ct)
{
try
{
var remoteDir = Path.GetDirectoryName(remotePath)?.Replace('\\', '/') ?? "/";
if (string.IsNullOrWhiteSpace(remoteDir)) remoteDir = "/";
var htaccessPath = remoteDir.TrimEnd('/') + "/.htaccess";
if (await client.FileExists(htaccessPath, ct))
{
// .htaccess existiert — lesen und prüfen ob UTF-8-Block schon drin ist
using var downloadStream = new MemoryStream();
if (!await client.DownloadStream(downloadStream, htaccessPath, token: ct))
return false; // Download fehlgeschlagen → nichts tun
downloadStream.Position = 0;
var existingContent = Encoding.UTF8.GetString(downloadStream.ToArray());
// Wenn unser Marker bereits enthalten ist → nichts tun
if (existingContent.Contains(HtaccessCharsetMarker, StringComparison.Ordinal))
return false;
// Wenn bereits AddDefaultCharset gesetzt ist → nicht einmischen
if (existingContent.Contains("AddDefaultCharset", StringComparison.OrdinalIgnoreCase))
return false;
// Charset-Block an bestehenden Inhalt anhängen
var newContent = existingContent.TrimEnd() + "\n" + HtaccessCharsetBlock;
var bytes = Encoding.UTF8.GetBytes(newContent);
using var uploadStream = new MemoryStream(bytes);
var status = await client.UploadStream(uploadStream, htaccessPath, FtpRemoteExists.Overwrite, true, null, ct);
if (status == FtpStatus.Success)
{
logger.LogInformation("UTF-8 charset block appended to existing .htaccess: {Path}", htaccessPath);
return true;
}
}
else
{
// Keine .htaccess vorhanden → neue erstellen (nur Charset-Regeln)
var content = HtaccessCharsetMarker + "\nAddDefaultCharset UTF-8\n\n<IfModule mod_mime.c>\n AddCharset UTF-8 .html .htm .css .js .json .xml .svg .txt\n</IfModule>\n";
var bytes = Encoding.UTF8.GetBytes(content);
using var stream = new MemoryStream(bytes);
var status = await client.UploadStream(stream, htaccessPath, FtpRemoteExists.Skip, true, null, ct);
if (status == FtpStatus.Success)
{
logger.LogInformation("New UTF-8 .htaccess created: {Path}", htaccessPath);
return true;
}
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not automatically adjust .htaccess (non-critical)");
}
return false;
}
private async Task<ToolResult> HandleDownloadAsync(IAsyncFtpClient client, JsonElement input, AgentToolContext context, CancellationToken ct)
{
var localRel = input.TryGetProperty("localPath", out var lp) ? lp.GetString() : null;
var remote = input.TryGetProperty("remotePath", out var rp) ? rp.GetString() : null;
if (string.IsNullOrWhiteSpace(localRel) || string.IsNullOrWhiteSpace(remote))
{
return ToolResult.Fail("'localPath' and 'remotePath' are required for download.");
}
try
{
var (localFull, wsLabel) = ResolveLocalPath(localRel, context, write: true);
var localDir = Path.GetDirectoryName(localFull);
if (localDir != null && !Directory.Exists(localDir)) Directory.CreateDirectory(localDir);
var status = await client.DownloadFile(localFull, remote, FtpLocalExists.Overwrite, FtpVerify.None, null, ct);
return status == FtpStatus.Success
? ToolResult.Ok($"[{wsLabel} Workspace] File successfully downloaded: {remote} → {localRel}")
: ToolResult.Fail($"Download failed: {status}");
}
catch (ArgumentException ex)
{
return ToolResult.Fail(ex.Message);
}
catch (UnauthorizedAccessException ex)
{
return ToolResult.Fail(ex.Message);
}
}
private async Task<ToolResult> HandleListAsync(IAsyncFtpClient client, JsonElement input, AgentToolContext context, CancellationToken ct)
{
var remote = input.TryGetProperty("remotePath", out var rp) ? rp.GetString() : "/";
var items = await client.GetListing(remote, FtpListOption.Recursive, ct);
var result = items.Select(i => new
{
i.Name,
i.FullName,
Type = i.Type.ToString(),
Size = i.Size,
Modified = i.Modified
}).ToList();
return ToolResult.Ok(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
}
private async Task<ToolResult> HandleDeleteAsync(IAsyncFtpClient client, JsonElement input, AgentToolContext context, CancellationToken ct)
{
var remote = input.TryGetProperty("remotePath", out var rp) ? rp.GetString() : null;
if (string.IsNullOrWhiteSpace(remote)) return ToolResult.Fail("'remotePath' is required for delete.");
await client.DeleteFile(remote, ct);
return ToolResult.Ok($"File/directory deleted: {remote}");
}
}