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
@@ -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))}.";
}