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>
This commit is contained in:
Richard
2026-09-28 10:11:02 +02:00
co-authored by Claude Opus 5.5
parent 1bf26e9ff3
commit 88c42b244d
9 changed files with 313 additions and 81 deletions
@@ -13,4 +13,8 @@
<ItemGroup>
<ProjectReference Include="..\\ClawdDotNet.Core\\ClawdDotNet.Core.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ClawdDotNet.Tools.Tests" />
</ItemGroup>
</Project>
+25 -17
View File
@@ -1,5 +1,7 @@
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;
@@ -20,7 +22,9 @@ public sealed class FTPTool : IAgentTool
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" }
""";
@@ -36,7 +40,7 @@ public sealed class FTPTool : IAgentTool
},
"localPath": {
"type": "string",
"description": "Path to local file with optional workspace prefix: 'personal:file.txt' (default) or 'shared:file.txt' for the SharedWorkspace. Without prefix, personal workspace is used."
"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",
@@ -114,33 +118,37 @@ public sealed class FTPTool : IAgentTool
/// "personal:datei.txt" → persönlicher Workspace + "datei.txt"
/// "datei.txt" → persönlicher Workspace + "datei.txt" (Standard)
/// </summary>
private (string fullPath, string workspaceLabel) ResolveLocalPath(string localPath, AgentToolContext context)
/// <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;
string relativePath;
string label;
var (label, relativePath) = NamedWorkspaces.Split(localPath);
if (localPath.StartsWith("shared:", StringComparison.OrdinalIgnoreCase))
if (label == WorkspaceLayout.Shared)
{
relativePath = localPath[7..]; // after "shared:"
basePath = context.SharedWorkspacePath
?? throw new ArgumentException("No SharedWorkspace configured.");
label = "shared";
}
else if (localPath.StartsWith("personal:", StringComparison.OrdinalIgnoreCase))
else if (label == WorkspaceLayout.Personal)
{
relativePath = localPath[9..]; // after "personal:"
basePath = context.WorkspacePath
?? throw new ArgumentException("No personal workspace configured.");
label = "personal";
}
else
{
// No prefix → personal workspace as default
relativePath = localPath;
basePath = context.WorkspacePath
?? throw new ArgumentException("No personal workspace configured.");
label = "personal";
// #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)
@@ -182,7 +190,7 @@ public sealed class FTPTool : IAgentTool
try
{
var (localFull, wsLabel) = ResolveLocalPath(localRel, context);
var (localFull, wsLabel) = ResolveLocalPath(localRel, context, write: false);
if (!File.Exists(localFull))
return ToolResult.Fail($"Local file not found: {localRel} (resolved: {localFull})");
@@ -307,7 +315,7 @@ public sealed class FTPTool : IAgentTool
try
{
var (localFull, wsLabel) = ResolveLocalPath(localRel, context);
var (localFull, wsLabel) = ResolveLocalPath(localRel, context, write: true);
var localDir = Path.GetDirectoryName(localFull);
if (localDir != null && !Directory.Exists(localDir)) Directory.CreateDirectory(localDir);