Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a7212c240 | ||
|
|
88c42b244d |
@@ -0,0 +1,80 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
|
||||
namespace ClawdDotNet.Core.Organisation;
|
||||
|
||||
/// <summary>#718: A workspace with a member list, as one call may use it.</summary>
|
||||
public sealed record NamedWorkspace(WorkspaceConfig Workspace, string Root, WorkspaceAccess Access);
|
||||
|
||||
/// <summary>
|
||||
/// #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.
|
||||
///
|
||||
/// <para><b>Unknown and not allowed answer the same.</b> A name that is no workspace and a
|
||||
/// workspace the agent is not a member of both give <c>null</c>, so an answer never reveals
|
||||
/// that a workspace exists.</para>
|
||||
///
|
||||
/// <para><c>personal</c> and <c>shared</c> are not resolved here: they belong to each tool
|
||||
/// as they always did (FileRW with its <c>sharedAccessLevel</c>), and running installations
|
||||
/// keep what they have.</para>
|
||||
/// </summary>
|
||||
public static class NamedWorkspaces
|
||||
{
|
||||
/// <summary>
|
||||
/// Splits <c>name:path</c>. A prefix counts only if it is <c>personal</c>, <c>shared</c>,
|
||||
/// <c>unit</c> 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: <c>personal</c>.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The workspace behind <c>unit</c> or an id, if the agent may use it; <c>null</c>
|
||||
/// otherwise — also for <c>personal</c> and <c>shared</c>, which the caller handles.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>The names an agent can use — for the answer to a name that is not one of them.</summary>
|
||||
public static IReadOnlyList<string> Available(string agentId, OrgChart? organisation, bool withShared)
|
||||
{
|
||||
var names = new List<string> { 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))}.";
|
||||
}
|
||||
@@ -13,4 +13,8 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\ClawdDotNet.Core\\ClawdDotNet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ClawdDotNet.Tools.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>The answer to a name that is not a workspace this agent may use — with the ones it may.</summary>
|
||||
private static string UnknownWorkspace(string requested, AgentToolContext context)
|
||||
{
|
||||
var names = new List<string> { 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);
|
||||
|
||||
/// <summary>The one shared folder, by the level the agent carries for it — as before #684.</summary>
|
||||
private static WorkspaceAccess SharedAccess(AgentToolContext context)
|
||||
|
||||
@@ -11,4 +11,8 @@
|
||||
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ClawdDotNet.Tools.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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, \"<workspace id>: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
|
||||
}));
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
|
||||
namespace ClawdDotNet.Tools.RocketChat;
|
||||
|
||||
/// <summary>
|
||||
@@ -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)
|
||||
/// <param name="write">
|
||||
/// #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.
|
||||
/// </param>
|
||||
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))
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.Database\ClawdDotNet.Tools.Database.csproj" />
|
||||
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.DirectAPI\ClawdDotNet.Tools.DirectAPI.csproj" />
|
||||
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.FileRW\ClawdDotNet.Tools.FileRW.csproj" />
|
||||
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.FTP\ClawdDotNet.Tools.FTP.csproj" />
|
||||
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.RocketChat\ClawdDotNet.Tools.RocketChat.csproj" />
|
||||
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.Memory\ClawdDotNet.Tools.Memory.csproj" />
|
||||
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.SocialMediaManager\ClawdDotNet.Tools.SocialMediaManager.csproj" />
|
||||
<ProjectReference Include="..\..\src\ClawdDotNet.Tools.Taskboard\ClawdDotNet.Tools.Taskboard.csproj" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// #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.
|
||||
/// </summary>
|
||||
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<string, object?>(), 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<ArgumentException>(() => 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<ArgumentException>(() => FTPTool.ResolveLocalPath("accounting:books.csv", Context("buyer"), write: false)).Message;
|
||||
var unknown = Should.Throw<ArgumentException>(() => 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<UnauthorizedAccessException>(() => 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user