diff --git a/src/ClawdDotNet.Core/Organisation/NamedWorkspaces.cs b/src/ClawdDotNet.Core/Organisation/NamedWorkspaces.cs
new file mode 100644
index 0000000..76687f1
--- /dev/null
+++ b/src/ClawdDotNet.Core/Organisation/NamedWorkspaces.cs
@@ -0,0 +1,80 @@
+using ClawdDotNet.Core.Config;
+
+namespace ClawdDotNet.Core.Organisation;
+
+/// #718: A workspace with a member list, as one call may use it.
+public sealed record NamedWorkspace(WorkspaceConfig Workspace, string Root, WorkspaceAccess Access);
+
+///
+/// #718: The workspaces with member lists (#684) for every tool that moves files — FileRW,
+/// FTP, RocketChat — resolved in one place instead of once per tool.
+///
+/// Unknown and not allowed answer the same. A name that is no workspace and a
+/// workspace the agent is not a member of both give null, so an answer never reveals
+/// that a workspace exists.
+///
+/// personal and shared are not resolved here: they belong to each tool
+/// as they always did (FileRW with its sharedAccessLevel), and running installations
+/// keep what they have.
+///
+public static class NamedWorkspaces
+{
+ ///
+ /// Splits name:path. A prefix counts only if it is personal, shared,
+ /// unit or could be a workspace id — so a colon elsewhere in a path (an NTFS stream,
+ /// a drive letter) is left for the path check to refuse. Without a prefix: personal.
+ ///
+ public static (string Workspace, string Relative) Split(string path)
+ {
+ var colon = path.IndexOf(':');
+ if (colon > 0)
+ {
+ var prefix = path[..colon];
+ var rest = path[(colon + 1)..];
+ var reserved = prefix.ToLowerInvariant();
+ if (WorkspaceLayout.Reserved.Contains(reserved))
+ return (reserved, rest);
+ if (WorkspaceLayout.IsValidId(prefix))
+ return (prefix, rest);
+ }
+ return (WorkspaceLayout.Personal, path);
+ }
+
+ ///
+ /// The workspace behind unit or an id, if the agent may use it; null
+ /// otherwise — also for personal and shared, which the caller handles.
+ ///
+ public static NamedWorkspace? Resolve(string name, string agentId, OrgChart? organisation, string? sharedWorkspacePath)
+ {
+ if (organisation is null
+ || name.Equals(WorkspaceLayout.Personal, StringComparison.OrdinalIgnoreCase)
+ || name.Equals(WorkspaceLayout.Shared, StringComparison.OrdinalIgnoreCase))
+ return null;
+
+ var workspace = name.Equals(WorkspaceLayout.Unit, StringComparison.OrdinalIgnoreCase)
+ ? organisation.UnitWorkspace(organisation.UnitOf(agentId)?.Id)
+ : organisation.Workspace(name);
+ if (workspace is null)
+ return null;
+
+ var access = organisation.AccessTo(agentId, workspace.Id);
+ if (access == WorkspaceAccess.None || WorkspaceLayout.PathOf(sharedWorkspacePath, workspace.Id) is not { } root)
+ return null;
+ return new NamedWorkspace(workspace, root, access);
+ }
+
+ /// The names an agent can use — for the answer to a name that is not one of them.
+ public static IReadOnlyList Available(string agentId, OrgChart? organisation, bool withShared)
+ {
+ var names = new List { WorkspaceLayout.Personal };
+ if (withShared)
+ names.Add(WorkspaceLayout.Shared);
+ if (organisation is not null)
+ names.AddRange(organisation.WorkspacesFor(agentId).Select(g =>
+ g.IsOwnUnit ? $"{WorkspaceLayout.Unit} (= {g.Workspace.Id})" : g.Workspace.Id));
+ return names;
+ }
+
+ public static string Unknown(string requested, string agentId, OrgChart? organisation, bool withShared)
+ => $"Unknown workspace '{requested}'. You can use: {string.Join(", ", Available(agentId, organisation, withShared))}.";
+}
diff --git a/src/ClawdDotNet.Tools.FTP/ClawdDotNet.Tools.FTP.csproj b/src/ClawdDotNet.Tools.FTP/ClawdDotNet.Tools.FTP.csproj
index 4567f32..579d983 100644
--- a/src/ClawdDotNet.Tools.FTP/ClawdDotNet.Tools.FTP.csproj
+++ b/src/ClawdDotNet.Tools.FTP/ClawdDotNet.Tools.FTP.csproj
@@ -13,4 +13,8 @@
+
+
+
+
diff --git a/src/ClawdDotNet.Tools.FTP/FtpTool.cs b/src/ClawdDotNet.Tools.FTP/FtpTool.cs
index 4779df0..f87e0e0 100644
--- a/src/ClawdDotNet.Tools.FTP/FtpTool.cs
+++ b/src/ClawdDotNet.Tools.FTP/FtpTool.cs
@@ -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; ":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 ':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)
///
- private (string fullPath, string workspaceLabel) ResolveLocalPath(string localPath, AgentToolContext context)
+ ///
+ /// #718: The file is written into the workspace (download). A workspace with a member list
+ /// then needs Write; reading from it (upload) needs Read.
+ ///
+ 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);
diff --git a/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs b/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs
index 4ae5a23..2c4803f 100644
--- a/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs
+++ b/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs
@@ -238,35 +238,15 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
if (name.Equals(WorkspaceLayout.Shared, StringComparison.OrdinalIgnoreCase))
return new Area(WorkspaceLayout.Shared, context.SharedWorkspacePath, SharedAccess(context), GetProtectedPaths(context));
- if (context.Organisation is not { } org)
- return null;
-
- var workspace = name.Equals(WorkspaceLayout.Unit, StringComparison.OrdinalIgnoreCase)
- ? org.UnitWorkspace(org.UnitOf(context.AgentId)?.Id)
- : org.Workspace(name);
- if (workspace is null)
- return null;
-
- var access = org.AccessTo(context.AgentId, workspace.Id);
- return access == WorkspaceAccess.None
- ? null
- : new Area(workspace.Id, WorkspaceLayout.PathOf(context.SharedWorkspacePath, workspace.Id), access,
- NormaliseProtectedPaths(workspace.ProtectedPaths));
+ // #718: the same resolution as FTP and RocketChat.
+ return NamedWorkspaces.Resolve(name, context.AgentId, context.Organisation, context.SharedWorkspacePath) is { } named
+ ? new Area(named.Workspace.Id, named.Root, named.Access, NormaliseProtectedPaths(named.Workspace.ProtectedPaths))
+ : null;
}
/// The answer to a name that is not a workspace this agent may use — with the ones it may.
private static string UnknownWorkspace(string requested, AgentToolContext context)
- {
- var names = new List { WorkspaceLayout.Personal };
- if (SharedAccess(context) > WorkspaceAccess.None)
- names.Add(WorkspaceLayout.Shared);
- if (context.Organisation is { } org)
- {
- names.AddRange(org.WorkspacesFor(context.AgentId).Select(g =>
- g.IsOwnUnit ? $"{WorkspaceLayout.Unit} (= {g.Workspace.Id})" : g.Workspace.Id));
- }
- return $"Unknown workspace '{requested}'. You can use: {string.Join(", ", names)}.";
- }
+ => NamedWorkspaces.Unknown(requested, context.AgentId, context.Organisation, SharedAccess(context) > WorkspaceAccess.None);
/// The one shared folder, by the level the agent carries for it — as before #684.
private static WorkspaceAccess SharedAccess(AgentToolContext context)
diff --git a/src/ClawdDotNet.Tools.RocketChat/ClawdDotNet.Tools.RocketChat.csproj b/src/ClawdDotNet.Tools.RocketChat/ClawdDotNet.Tools.RocketChat.csproj
index 58d5dbe..4abf760 100644
--- a/src/ClawdDotNet.Tools.RocketChat/ClawdDotNet.Tools.RocketChat.csproj
+++ b/src/ClawdDotNet.Tools.RocketChat/ClawdDotNet.Tools.RocketChat.csproj
@@ -11,4 +11,8 @@
+
+
+
+
diff --git a/src/ClawdDotNet.Tools.RocketChat/RocketChatTool.cs b/src/ClawdDotNet.Tools.RocketChat/RocketChatTool.cs
index 4a05f6a..4644f3d 100644
--- a/src/ClawdDotNet.Tools.RocketChat/RocketChatTool.cs
+++ b/src/ClawdDotNet.Tools.RocketChat/RocketChatTool.cs
@@ -1,3 +1,4 @@
+using ClawdDotNet.Core.Organisation;
using System.Globalization;
using System.Text;
using System.Text.Json;
@@ -57,7 +58,7 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
},
"localPath": {
"type": "string",
- "description": "Path of the file to send in the workspace (only for send_file): \"report.pdf\" or \"personal:report.pdf\" in personal workspace, \"shared:analysis.csv\" in shared workspace."
+ "description": "Path of the file to send in the workspace (only for send_file): \"report.pdf\" or \"personal:report.pdf\" in personal workspace, \"shared:analysis.csv\" in shared workspace, \"unit:plan.md\" in your unit's workspace, \":file\" in a workspace you are a member of."
},
"filename": {
"type": "string",
@@ -165,8 +166,7 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
"'localPath' is required — e.g. \"report.pdf\" (personal workspace) "
+ "or \"shared:analysis.csv\" (shared workspace).");
- var (file, pathError) = WorkspaceFile.Resolve(
- localPath, context.WorkspacePath, context.SharedWorkspacePath);
+ var (file, pathError) = WorkspaceFile.Resolve(localPath, context, write: false);
if (file is null)
return ToolResult.Fail(pathError!);
@@ -226,23 +226,18 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
// Target name: the model's choice, otherwise the name from the message. Both are
// controlled by someone else, so only the bare file name is taken and any path in
// it is discarded before anything is resolved.
- var wanted = GetString(input, "localPath") ?? GetString(input, "filename") ?? fileId;
+ var chosen = GetString(input, "localPath");
+ var wanted = chosen ?? GetString(input, "filename") ?? fileId;
// Strip the workspace prefix first: on Windows ':' is not a path separator but the
// marker for NTFS alternate data streams. If "shared:" stayed in the name,
// Path.GetFileName would pass it through unchanged — and the file would end up as
// a data stream on the directory instead of a file of its own.
- var prefix = "";
- var rest = wanted;
- if (rest.StartsWith("shared:", StringComparison.OrdinalIgnoreCase))
- {
- prefix = "shared:";
- rest = rest[7..];
- }
- else if (rest.StartsWith("personal:", StringComparison.OrdinalIgnoreCase))
- {
- rest = rest[9..];
- }
+ // #718: only the model's own localPath chooses the workspace. A name from the message
+ // is someone else's — "unit:x.pdf" there is a file name, not a destination.
+ var (workspace, rest) = chosen is not null
+ ? NamedWorkspaces.Split(wanted)
+ : (WorkspaceLayout.Personal, wanted.Replace(':', '_'));
var targetName = Path.GetFileName(rest.Replace('\\', '/'));
if (string.IsNullOrWhiteSpace(targetName))
@@ -254,10 +249,9 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
$"Files of type '{extension}' are not downloaded. "
+ "If needed, set 'allowDangerousDownloads' in the tool configuration.");
- // The model may choose the directory (personal:/shared:), but not compose the file
- // name freely — hence prefix and sanitized name are kept separate.
- var (file, pathError) = WorkspaceFile.Resolve(
- prefix + targetName, context.WorkspacePath, context.SharedWorkspacePath);
+ // The model may choose the directory (personal:/shared:/unit:/a workspace id), but not
+ // compose the file name freely — hence prefix and sanitized name are kept separate.
+ var (file, pathError) = WorkspaceFile.Resolve($"{workspace}:{targetName}", context, write: true);
if (file is null)
return ToolResult.Fail(pathError!);
@@ -269,7 +263,7 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
{
downloaded = true,
fileId,
- savedAs = $"{(file.Label == "shared" ? "shared:" : "")}{targetName}",
+ savedAs = file.Label == WorkspaceLayout.Personal ? targetName : $"{file.Label}:{targetName}",
workspace = file.Label,
bytes
}));
diff --git a/src/ClawdDotNet.Tools.RocketChat/WorkspaceFile.cs b/src/ClawdDotNet.Tools.RocketChat/WorkspaceFile.cs
index 7cd81d0..ca82845 100644
--- a/src/ClawdDotNet.Tools.RocketChat/WorkspaceFile.cs
+++ b/src/ClawdDotNet.Tools.RocketChat/WorkspaceFile.cs
@@ -1,3 +1,7 @@
+using ClawdDotNet.Core.Config;
+using ClawdDotNet.Core.Organisation;
+using ClawdDotNet.Core.Tools;
+
namespace ClawdDotNet.Tools.RocketChat;
///
@@ -13,30 +17,31 @@ internal static class WorkspaceFile
{
public sealed record Resolved(string FullPath, string Label);
- public static (Resolved? File, string? Error) Resolve(
- string path, string? personalRoot, string? sharedRoot)
+ ///
+ /// #718: The file is written into the workspace (download). A workspace with a member list
+ /// then needs Write; sending a file from it needs Read.
+ ///
+ public static (Resolved? File, string? Error) Resolve(string path, AgentToolContext context, bool write)
{
- string relative;
string? root;
- string label;
+ var (label, relative) = NamedWorkspaces.Split(path);
- if (path.StartsWith("shared:", StringComparison.OrdinalIgnoreCase))
- {
- relative = path[7..];
- root = sharedRoot;
- label = "shared";
- }
- else if (path.StartsWith("personal:", StringComparison.OrdinalIgnoreCase))
- {
- relative = path[9..];
- root = personalRoot;
- label = "personal";
- }
+ if (label == WorkspaceLayout.Shared)
+ root = context.SharedWorkspacePath;
+ else if (label == WorkspaceLayout.Personal)
+ root = context.WorkspacePath;
else
{
- relative = path;
- root = personalRoot;
- 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)
+ return (null, NamedWorkspaces.Unknown(label, context.AgentId, context.Organisation, withShared: true));
+ if (named.Access < (write ? WorkspaceAccess.Write : WorkspaceAccess.Read))
+ return (null, $"Access denied: this agent may not {(write ? "write to" : "read from")} workspace '{named.Workspace.Id}'.");
+ root = named.Root;
+ label = named.Workspace.Id;
+ if (write)
+ Directory.CreateDirectory(root);
}
if (string.IsNullOrWhiteSpace(root))
diff --git a/tests/ClawdDotNet.Tools.Tests/ClawdDotNet.Tools.Tests.csproj b/tests/ClawdDotNet.Tools.Tests/ClawdDotNet.Tools.Tests.csproj
index 49be25f..325cb44 100644
--- a/tests/ClawdDotNet.Tools.Tests/ClawdDotNet.Tools.Tests.csproj
+++ b/tests/ClawdDotNet.Tools.Tests/ClawdDotNet.Tools.Tests.csproj
@@ -23,6 +23,8 @@
+
+
diff --git a/tests/ClawdDotNet.Tools.Tests/Workspaces/NamedWorkspaceTransferTests.cs b/tests/ClawdDotNet.Tools.Tests/Workspaces/NamedWorkspaceTransferTests.cs
new file mode 100644
index 0000000..d8abfaf
--- /dev/null
+++ b/tests/ClawdDotNet.Tools.Tests/Workspaces/NamedWorkspaceTransferTests.cs
@@ -0,0 +1,155 @@
+using ClawdDotNet.Core.Config;
+using ClawdDotNet.Core.Organisation;
+using ClawdDotNet.Core.State;
+using ClawdDotNet.Core.Tools;
+using ClawdDotNet.Tools.FTP;
+using ClawdDotNet.Tools.RocketChat;
+using ClawdDotNet.Tools.Tests.FileRW;
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+
+namespace ClawdDotNet.Tools.Tests.Workspaces;
+
+///
+/// #718: FTP and RocketChat know the workspaces with member lists (#684) the way FileRW does.
+/// A buyer uploads from its unit's workspace without first copying the file to shared —
+/// where the separation between units does not hold.
+///
+public sealed class NamedWorkspaceTransferTests : IDisposable
+{
+ private readonly string _agents = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Agents");
+
+ private readonly OrgChart _org = new(
+ [new() { Id = "purchasing", Name = "Purchasing" }, new() { Id = "marketing", Name = "Marketing" }, new() { Id = "accounting", Name = "Accounting" }],
+ [
+ new() { AgentId = "buyer", UnitId = "purchasing" },
+ new() { AgentId = "ads", UnitId = "marketing" },
+ new() { AgentId = "bookkeeper", UnitId = "accounting" }
+ ],
+ [
+ new() { Id = "purchasing", Name = "purchasing", OwnerUnitId = "purchasing", Members = [new() { UnitId = "purchasing", Access = WorkspaceAccess.Write }] },
+ new() { Id = "accounting", Name = "accounting", OwnerUnitId = "accounting", Members = [new() { UnitId = "accounting", Access = WorkspaceAccess.Write }] },
+ new()
+ {
+ Id = "articles", Name = "Article master", OwnerUnitId = "purchasing",
+ Members = [new() { UnitId = "purchasing", Access = WorkspaceAccess.Manage }, new() { UnitId = "marketing", Access = WorkspaceAccess.Read }]
+ }
+ ]);
+
+ public NamedWorkspaceTransferTests() => Directory.CreateDirectory(Path.Combine(_agents, "SharedWorkspace"));
+
+ public void Dispose()
+ {
+ try { Directory.Delete(Path.GetDirectoryName(_agents)!, recursive: true); }
+ catch { /* cleaning up is secondary */ }
+ }
+
+ private string Area(string id) => Path.Combine(_agents, "SharedWorkspaces", id);
+
+ private AgentToolContext Context(string agentId)
+ {
+ var personal = Path.Combine(_agents, agentId, "Workspace");
+ Directory.CreateDirectory(personal);
+ return new AgentToolContext(agentId, "inst", new Dictionary(), new NullStateStore(),
+ NullLogger.Instance, CancellationToken.None, WorkspacePath: personal, SharedWorkspacePath: Path.Combine(_agents, "SharedWorkspace"))
+ {
+ Organisation = _org
+ };
+ }
+
+ // ═══════════════════ the prefix ═══════════════════
+
+ [Theory]
+ [InlineData("report.pdf", "personal", "report.pdf")]
+ [InlineData("personal:a/b.txt", "personal", "a/b.txt")]
+ [InlineData("SHARED:x.csv", "shared", "x.csv")]
+ [InlineData("unit:orders.md", "unit", "orders.md")]
+ [InlineData("articles:list.csv", "articles", "list.csv")]
+ [InlineData("bericht.txt:hidden", "personal", "bericht.txt:hidden")] // an NTFS stream stays for the path check
+ public void The_prefix_names_the_workspace_only_when_it_can_be_one(string path, string workspace, string relative)
+ => NamedWorkspaces.Split(path).ShouldBe((workspace, relative));
+
+ // ═══════════════════ FTP ═══════════════════
+
+ [Fact]
+ public void Ftp_uploads_from_the_own_units_workspace()
+ {
+ Directory.CreateDirectory(Area("purchasing"));
+ File.WriteAllText(Path.Combine(Area("purchasing"), "orders.csv"), "x");
+
+ var (path, label) = FTPTool.ResolveLocalPath("unit:orders.csv", Context("buyer"), write: false);
+
+ path.ShouldBe(Path.Combine(Area("purchasing"), "orders.csv"));
+ label.ShouldBe("purchasing");
+ }
+
+ [Fact]
+ public void Ftp_reads_a_workspace_with_read_access_but_does_not_download_into_it()
+ {
+ FTPTool.ResolveLocalPath("articles:list.csv", Context("ads"), write: false).fullPath
+ .ShouldBe(Path.Combine(Area("articles"), "list.csv"));
+
+ Should.Throw(() => FTPTool.ResolveLocalPath("articles:list.csv", Context("ads"), write: true))
+ .Message.ShouldContain("may not write");
+ }
+
+ [Fact]
+ public void Ftp_answers_a_foreign_workspace_like_an_unknown_one()
+ {
+ var foreign = Should.Throw(() => FTPTool.ResolveLocalPath("accounting:books.csv", Context("buyer"), write: false)).Message;
+ var unknown = Should.Throw(() => FTPTool.ResolveLocalPath("nowhere:books.csv", Context("buyer"), write: false)).Message;
+
+ foreign.ShouldBe(unknown.Replace("nowhere", "accounting"));
+ foreign.ShouldNotContain("accounting (=");
+ }
+
+ [Fact]
+ public void Ftp_still_refuses_a_way_out_of_the_workspace()
+ => Should.Throw(() => FTPTool.ResolveLocalPath("unit:../accounting/books.csv", Context("buyer"), write: false));
+
+ [Fact]
+ public void Ftp_keeps_personal_and_shared_as_they_were()
+ {
+ FTPTool.ResolveLocalPath("notes.txt", Context("buyer"), write: true).workspaceLabel.ShouldBe("personal");
+ FTPTool.ResolveLocalPath("shared:site/index.html", Context("buyer"), write: true).workspaceLabel.ShouldBe("shared");
+ }
+
+ // ═══════════════════ RocketChat ═══════════════════
+
+ [Fact]
+ public void RocketChat_sends_from_and_saves_into_the_own_units_workspace()
+ {
+ var (send, error) = WorkspaceFile.Resolve("unit:plan.md", Context("buyer"), write: false);
+ error.ShouldBeNull();
+ send!.FullPath.ShouldBe(Path.Combine(Area("purchasing"), "plan.md"));
+
+ var (save, _) = WorkspaceFile.Resolve("unit:reply.pdf", Context("buyer"), write: true);
+ save!.Label.ShouldBe("purchasing");
+ Directory.Exists(Area("purchasing")).ShouldBeTrue("a workspace comes into being when it is first written to");
+ }
+
+ [Fact]
+ public void RocketChat_needs_write_access_to_save_into_a_workspace()
+ {
+ var (file, error) = WorkspaceFile.Resolve("articles:offer.pdf", Context("ads"), write: true);
+
+ file.ShouldBeNull();
+ error!.ShouldContain("may not write");
+ }
+
+ [Fact]
+ public void RocketChat_answers_a_foreign_workspace_like_an_unknown_one()
+ {
+ var (_, foreign) = WorkspaceFile.Resolve("accounting:books.csv", Context("buyer"), write: false);
+ var (_, unknown) = WorkspaceFile.Resolve("nowhere:books.csv", Context("buyer"), write: false);
+
+ foreign.ShouldBe(unknown!.Replace("nowhere", "accounting"));
+ }
+
+ [Fact]
+ public void RocketChat_still_refuses_streams_and_ways_out()
+ {
+ WorkspaceFile.Resolve("bericht.txt:hidden", Context("buyer"), write: true).Error!.ShouldContain("Colons");
+ WorkspaceFile.Resolve("unit:../../x", Context("buyer"), write: true).Error!.ShouldContain("outside");
+ }
+}