Ablage je Einheit, Stufe 1: benannte Arbeitsbereiche mit Mitgliedern (#684)
- WorkspaceConfig in der Instanzkonfiguration: Id, Name, Zweck, Eigentuemer-Einheit, Mitglieder (Einheit oder Agent, Stufe Read/Write/ Manage, Untereinheiten ein/aus), geschuetzte Pfade. Das Recht steht am Bereich, nicht am Agenten. - OrgChart: AccessTo, WorkspacesFor, UnitWorkspace; Problems nennt ungueltige Ids und tote Verweise. WorkspaceLayout: Bereiche liegen unter Agents/SharedWorkspaces/<id>/, neben dem unveraenderten SharedWorkspace. - FileRW: workspace ist ein Name - personal, shared (wie bisher ueber sharedAccessLevel), unit (eigene Einheit) oder eine Bereichs-Id. Ein unbekannter und ein nicht erlaubter Bereich bekommen dieselbe Antwort. Loeschen braucht Manage wie frueher Admin. Der #662-Hinweis sucht in allen erlaubten Bereichen. - Mit Einheiten ist shared:/tasks/ fuer FileRW gesperrt: Aufgaben laufen ueber das Brett, sonst umginge eine direkt geschriebene Datei Brett- und Adressbuchpruefung. Ohne Einheiten unveraendert. - Organisationsabschnitt im Prompt nennt die Bereiche mit Stufe und Zweck. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ba5fe4cdf7
commit
ff20ce2563
@@ -0,0 +1,133 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Organisation;
|
||||
|
||||
/// <summary>
|
||||
/// #684: Who may use which workspace. The right stands at the workspace — a member list of
|
||||
/// units and agents with a level — and the tree decides what a unit entry covers.
|
||||
/// </summary>
|
||||
public sealed class WorkspaceAccessTests
|
||||
{
|
||||
private static readonly UnitConfig[] Units =
|
||||
[
|
||||
new() { Id = "purchasing", Name = "Purchasing", LeaderAgentId = "head" },
|
||||
new() { Id = "suppliers", Name = "Suppliers", ParentId = "purchasing" },
|
||||
new() { Id = "marketing", Name = "Marketing" }
|
||||
];
|
||||
|
||||
private static readonly AgentConfig[] Agents =
|
||||
[
|
||||
new() { AgentId = "head", UnitId = "purchasing" },
|
||||
new() { AgentId = "scout", UnitId = "suppliers" },
|
||||
new() { AgentId = "ads", UnitId = "marketing" },
|
||||
new() { AgentId = "ceo" }
|
||||
];
|
||||
|
||||
private static OrgChart Chart(params WorkspaceConfig[] workspaces) => new(Units, Agents, workspaces);
|
||||
|
||||
private static WorkspaceConfig Workspace(string id, string? owner, params WorkspaceMember[] members)
|
||||
=> new() { Id = id, Name = id, OwnerUnitId = owner, Members = [.. members] };
|
||||
|
||||
[Fact]
|
||||
public void A_unit_entry_covers_the_units_below()
|
||||
{
|
||||
var org = Chart(Workspace("purchasing", "purchasing", new WorkspaceMember { UnitId = "purchasing", Access = WorkspaceAccess.Write }));
|
||||
|
||||
org.AccessTo("head", "purchasing").ShouldBe(WorkspaceAccess.Write);
|
||||
org.AccessTo("scout", "purchasing").ShouldBe(WorkspaceAccess.Write);
|
||||
org.AccessTo("ads", "purchasing").ShouldBe(WorkspaceAccess.None);
|
||||
org.AccessTo("ceo", "purchasing").ShouldBe(WorkspaceAccess.None, "the top level is not a member of every unit");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_sub_units_the_entry_covers_the_unit_alone()
|
||||
{
|
||||
var org = Chart(Workspace("round", null,
|
||||
new WorkspaceMember { UnitId = "purchasing", Access = WorkspaceAccess.Read, IncludeSubUnits = false }));
|
||||
|
||||
org.AccessTo("head", "round").ShouldBe(WorkspaceAccess.Read);
|
||||
org.AccessTo("scout", "round").ShouldBe(WorkspaceAccess.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_highest_level_of_all_matching_entries_counts()
|
||||
{
|
||||
var org = Chart(Workspace("articles", "purchasing",
|
||||
new WorkspaceMember { UnitId = "purchasing", Access = WorkspaceAccess.Read },
|
||||
new WorkspaceMember { AgentId = "scout", Access = WorkspaceAccess.Manage }));
|
||||
|
||||
org.AccessTo("scout", "articles").ShouldBe(WorkspaceAccess.Manage);
|
||||
org.AccessTo("head", "articles").ShouldBe(WorkspaceAccess.Read);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_agent_on_the_top_level_can_be_named_directly()
|
||||
=> Chart(Workspace("board", null, new WorkspaceMember { AgentId = "ceo", Access = WorkspaceAccess.Write }))
|
||||
.AccessTo("ceo", "board").ShouldBe(WorkspaceAccess.Write);
|
||||
|
||||
[Fact]
|
||||
public void The_own_unit_s_workspace_comes_first_and_only_visible_ones_are_listed()
|
||||
{
|
||||
var org = Chart(
|
||||
Workspace("articles", "purchasing", new WorkspaceMember { UnitId = "marketing", Access = WorkspaceAccess.Read }),
|
||||
Workspace("marketing", "marketing", new WorkspaceMember { UnitId = "marketing", Access = WorkspaceAccess.Write }),
|
||||
Workspace("purchasing", "purchasing", new WorkspaceMember { UnitId = "purchasing", Access = WorkspaceAccess.Write }));
|
||||
|
||||
var grants = org.WorkspacesFor("ads");
|
||||
|
||||
grants.Select(g => g.Workspace.Id).ShouldBe(["marketing", "articles"]);
|
||||
grants[0].IsOwnUnit.ShouldBeTrue();
|
||||
org.UnitWorkspace("marketing")!.Id.ShouldBe("marketing");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_workspace_carrying_a_unit_s_id_but_owned_by_another_is_not_that_unit_s()
|
||||
=> Chart(Workspace("marketing", "purchasing")).UnitWorkspace("marketing").ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
public void Broken_references_and_reserved_names_are_named()
|
||||
{
|
||||
var problems = Chart(
|
||||
Workspace("shared", null),
|
||||
Workspace("orphan", "gone", new WorkspaceMember { UnitId = "nowhere" }, new WorkspaceMember { AgentId = "ghost" })).Problems();
|
||||
|
||||
problems.ShouldContain(p => p.Contains("'shared' has an invalid id"));
|
||||
problems.ShouldContain(p => p.Contains("'gone'"));
|
||||
problems.ShouldContain(p => p.Contains("'nowhere'"));
|
||||
problems.ShouldContain(p => p.Contains("'ghost'"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Workspaces_live_next_to_the_one_shared_folder()
|
||||
{
|
||||
var shared = Path.Combine(Path.GetTempPath(), "inst", "Agents", "SharedWorkspace");
|
||||
|
||||
WorkspaceLayout.PathOf(shared, "purchasing")
|
||||
.ShouldBe(Path.Combine(Path.GetTempPath(), "inst", "Agents", "SharedWorkspaces", "purchasing"));
|
||||
WorkspaceLayout.PathOf(shared + Path.DirectorySeparatorChar, "purchasing")
|
||||
.ShouldBe(Path.Combine(Path.GetTempPath(), "inst", "Agents", "SharedWorkspaces", "purchasing"), "a trailing separator changes nothing");
|
||||
WorkspaceLayout.PathOf(shared, "../escape").ShouldBeNull();
|
||||
WorkspaceLayout.PathOf(null, "purchasing").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_briefing_names_the_workspaces_and_what_the_agent_may_do_there()
|
||||
{
|
||||
var org = Chart(
|
||||
Workspace("marketing", "marketing", new WorkspaceMember { UnitId = "marketing", Access = WorkspaceAccess.Write }),
|
||||
new WorkspaceConfig
|
||||
{
|
||||
Id = "articles", Name = "Article master", OwnerUnitId = "purchasing", Purpose = "One file per article.",
|
||||
Members = [new WorkspaceMember { UnitId = "marketing", Access = WorkspaceAccess.Read }]
|
||||
});
|
||||
|
||||
var section = OrgBriefing.For(org, "ads", DateTime.UtcNow)!;
|
||||
|
||||
section.ShouldContain("\"unit\" — your unit's workspace (also \"marketing\"), read and write");
|
||||
section.ShouldContain("\"articles\" (Article master), read only — One file per article.");
|
||||
OrgBriefing.For(org, "scout", DateTime.UtcNow)!.ShouldNotContain("workspaces", Case.Insensitive,
|
||||
"the scout is in none of them — and must not learn that they exist");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
using ClawdDotNet.Core.State;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using ClawdDotNet.Tools.FileRW;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Tools.Tests.FileRW;
|
||||
|
||||
/// <summary>
|
||||
/// #684: Named workspaces with member lists. Purchasing files its things in its own
|
||||
/// workspace, marketing reads the article master that purchasing owns, and accounting's
|
||||
/// workspace does not exist for anyone outside accounting — not even as a name.
|
||||
/// </summary>
|
||||
public sealed class WorkspaceAreasTests : IDisposable
|
||||
{
|
||||
private readonly string _agents;
|
||||
private readonly FileRWTool _tool = new();
|
||||
|
||||
private readonly List<UnitConfig> _units =
|
||||
[
|
||||
new() { Id = "purchasing", Name = "Purchasing" },
|
||||
new() { Id = "suppliers", Name = "Suppliers", ParentId = "purchasing" },
|
||||
new() { Id = "marketing", Name = "Marketing" },
|
||||
new() { Id = "accounting", Name = "Accounting" }
|
||||
];
|
||||
|
||||
private readonly List<AgentConfig> _people =
|
||||
[
|
||||
new() { AgentId = "buyer", UnitId = "purchasing" },
|
||||
new() { AgentId = "scout", UnitId = "suppliers" },
|
||||
new() { AgentId = "ads", UnitId = "marketing" },
|
||||
new() { AgentId = "copywriter", UnitId = "marketing" },
|
||||
new() { AgentId = "bookkeeper", UnitId = "accounting" },
|
||||
new() { AgentId = "auditor" }
|
||||
];
|
||||
|
||||
private readonly List<WorkspaceConfig> _workspaces =
|
||||
[
|
||||
Own("purchasing"),
|
||||
Own("suppliers"),
|
||||
Own("marketing"),
|
||||
Own("accounting"),
|
||||
new()
|
||||
{
|
||||
Id = "articles", Name = "Article master", OwnerUnitId = "purchasing",
|
||||
Members =
|
||||
[
|
||||
new() { UnitId = "purchasing", Access = WorkspaceAccess.Manage },
|
||||
new() { UnitId = "marketing", Access = WorkspaceAccess.Read },
|
||||
new() { AgentId = "auditor", Access = WorkspaceAccess.Read },
|
||||
new() { AgentId = "copywriter", Access = WorkspaceAccess.Write }
|
||||
],
|
||||
ProtectedPaths = ["prices"]
|
||||
}
|
||||
];
|
||||
|
||||
private static WorkspaceConfig Own(string unit) => new()
|
||||
{
|
||||
Id = unit, Name = unit, OwnerUnitId = unit,
|
||||
Members = [new() { UnitId = unit, Access = WorkspaceAccess.Write }]
|
||||
};
|
||||
|
||||
public WorkspaceAreasTests()
|
||||
{
|
||||
_agents = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Agents");
|
||||
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, string sharedLevel = "Denied", bool withUnits = true)
|
||||
{
|
||||
var personal = Path.Combine(_agents, agentId, "Workspace");
|
||||
Directory.CreateDirectory(personal);
|
||||
return new AgentToolContext(
|
||||
agentId, "inst",
|
||||
new Dictionary<string, object?> { ["sharedAccessLevel"] = sharedLevel },
|
||||
new NullStateStore(), NullLogger.Instance, CancellationToken.None,
|
||||
WorkspacePath: personal,
|
||||
SharedWorkspacePath: Path.Combine(_agents, "SharedWorkspace"))
|
||||
{
|
||||
Organisation = withUnits ? new OrgChart(_units, _people, _workspaces) : new OrgChart([], _people)
|
||||
};
|
||||
}
|
||||
|
||||
private Task<ToolResult> Run(string agentId, object input, string sharedLevel = "Denied", bool withUnits = true)
|
||||
=> _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), Context(agentId, sharedLevel, withUnits), CancellationToken.None);
|
||||
|
||||
// ═══════════════════ the own unit ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task An_agent_writes_to_its_unit_without_knowing_the_unit_id()
|
||||
{
|
||||
var result = await Run("buyer", new { action = "write", workspace = "unit", path = "orders.md", content = "3 pallets" });
|
||||
|
||||
result.Success.ShouldBeTrue(result.ErrorMessage);
|
||||
File.ReadAllText(Path.Combine(Area("purchasing"), "orders.md")).ShouldBe("3 pallets");
|
||||
result.Content.ShouldContain("purchasing:/orders.md", Case.Sensitive, "the answer names the workspace by its id");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_colleague_finds_it_under_the_same_name()
|
||||
{
|
||||
await Run("buyer", new { action = "write", workspace = "purchasing", path = "orders.md", content = "3 pallets" });
|
||||
|
||||
// The scout sits in suppliers, below purchasing — the purchasing entry includes sub-units.
|
||||
var read = await Run("scout", new { action = "read", workspace = "purchasing", path = "orders.md" });
|
||||
|
||||
read.Success.ShouldBeTrue(read.ErrorMessage);
|
||||
read.Content.ShouldContain("3 pallets");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_unit_s_workspace_does_not_exist_for_other_units()
|
||||
{
|
||||
await Run("bookkeeper", new { action = "write", workspace = "unit", path = "salaries.md", content = "secret" });
|
||||
|
||||
var probe = await Run("ads", new { action = "read", workspace = "accounting", path = "salaries.md" });
|
||||
var unknown = await Run("ads", new { action = "read", workspace = "does-not-exist", path = "x.md" });
|
||||
|
||||
probe.Success.ShouldBeFalse();
|
||||
probe.ErrorMessage!.ShouldStartWith("Unknown workspace 'accounting'");
|
||||
// The same answer as for a name that does not exist — nothing tells marketing it is there.
|
||||
probe.ErrorMessage.Replace("accounting", "X").ShouldBe(unknown.ErrorMessage!.Replace("does-not-exist", "X"));
|
||||
probe.ErrorMessage.ShouldNotContain("accounting,");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_answer_to_an_unknown_name_lists_what_the_agent_can_use()
|
||||
{
|
||||
var result = await Run("ads", new { action = "list", workspace = "team" }, sharedLevel: "Read");
|
||||
|
||||
result.ErrorMessage!.ShouldContain("personal, shared, unit (= marketing), articles");
|
||||
}
|
||||
|
||||
// ═══════════════════ shared across units ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Marketing_reads_the_article_master_but_cannot_write_it()
|
||||
{
|
||||
await Run("buyer", new { action = "write", workspace = "articles", path = "a-100.md", content = "Chair" });
|
||||
|
||||
(await Run("ads", new { action = "read", workspace = "articles", path = "a-100.md" })).Content.ShouldContain("Chair");
|
||||
|
||||
var write = await Run("ads", new { action = "write", workspace = "articles", path = "a-100.md", content = "Sofa" });
|
||||
write.Success.ShouldBeFalse();
|
||||
write.ErrorMessage!.ShouldContain("not allowed in workspace 'articles'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_single_agent_can_be_a_member_without_its_unit()
|
||||
{
|
||||
var result = await Run("auditor", new { action = "list", workspace = "articles" });
|
||||
|
||||
result.Success.ShouldBeTrue(result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Protected_paths_hold_for_writers_but_not_for_managers()
|
||||
{
|
||||
(await Run("buyer", new { action = "write", workspace = "articles", path = "prices/a-100.md", content = "10" })).Success.ShouldBeTrue();
|
||||
|
||||
// The copywriter may write here, but prices are append-only for writers…
|
||||
var overwrite = await Run("copywriter", new { action = "write", workspace = "articles", path = "prices/a-100.md", content = "1" });
|
||||
overwrite.Success.ShouldBeFalse();
|
||||
overwrite.ErrorMessage!.ShouldContain("Protected area");
|
||||
(await Run("copywriter", new { action = "write", workspace = "articles", path = "prices/a-101.md", content = "5" })).Success
|
||||
.ShouldBeTrue("adding a new file is what protected paths allow");
|
||||
|
||||
// …while purchasing manages the workspace, which includes changing them, as Admin did.
|
||||
(await Run("buyer", new { action = "write", workspace = "articles", path = "prices/a-100.md", content = "12" })).Success.ShouldBeTrue();
|
||||
File.ReadAllText(Path.Combine(Area("articles"), "prices", "a-100.md")).ShouldBe("12");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Writing_is_not_deleting()
|
||||
{
|
||||
await Run("buyer", new { action = "write", workspace = "unit", path = "draft.md", content = "x" });
|
||||
|
||||
var delete = await Run("buyer", new { action = "delete", workspace = "unit", path = "draft.md" });
|
||||
|
||||
delete.Success.ShouldBeFalse("deleting needs Manage, as it needed Admin before");
|
||||
File.Exists(Path.Combine(Area("purchasing"), "draft.md")).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Copy_goes_from_the_own_unit_to_a_shared_workspace()
|
||||
{
|
||||
await Run("buyer", new { action = "write", workspace = "unit", path = "a-200.md", content = "Table" });
|
||||
|
||||
var copy = await Run("buyer", new { action = "copy", workspace = "unit", path = "a-200.md", destinationWorkspace = "articles", destinationPath = "a-200.md" });
|
||||
|
||||
copy.Success.ShouldBeTrue(copy.ErrorMessage);
|
||||
File.ReadAllText(Path.Combine(Area("articles"), "a-200.md")).ShouldBe("Table");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_hint_points_to_the_workspace_where_the_file_is()
|
||||
{
|
||||
await Run("buyer", new { action = "write", workspace = "unit", path = "orders.md", content = "3 pallets" });
|
||||
|
||||
var result = await Run("buyer", new { action = "read", path = "orders.md" });
|
||||
|
||||
result.ErrorMessage!.ShouldContain("exists in the purchasing workspace");
|
||||
}
|
||||
|
||||
// ═══════════════════ the one shared folder ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Shared_works_as_before_by_the_agent_s_own_level()
|
||||
{
|
||||
(await Run("ads", new { action = "write", workspace = "shared", path = "note.md", content = "x" }, sharedLevel: "ReadWrite"))
|
||||
.Success.ShouldBeTrue();
|
||||
|
||||
var denied = await Run("ads", new { action = "write", workspace = "shared", path = "note.md", content = "y" }, sharedLevel: "Read");
|
||||
denied.ErrorMessage!.ShouldContain("not allowed in workspace 'shared'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task With_units_the_task_files_are_closed_to_FileRW()
|
||||
{
|
||||
var tasks = Path.Combine(_agents, "SharedWorkspace", "tasks");
|
||||
Directory.CreateDirectory(tasks);
|
||||
File.WriteAllText(Path.Combine(tasks, "t-1.md"), "---\nassignee: bookkeeper\n---");
|
||||
|
||||
var read = await Run("ads", new { action = "read", workspace = "shared", path = "tasks/t-1.md" }, sharedLevel: "Admin");
|
||||
var write = await Run("ads", new { action = "write", workspace = "shared", path = "tasks/t-2.md", content = "sneaky" }, sharedLevel: "Admin");
|
||||
var list = await Run("ads", new { action = "list", workspace = "shared" }, sharedLevel: "Admin");
|
||||
|
||||
read.Success.ShouldBeFalse();
|
||||
read.ErrorMessage!.ShouldContain("Taskboard tool");
|
||||
write.Success.ShouldBeFalse();
|
||||
File.Exists(Path.Combine(tasks, "t-2.md")).ShouldBeFalse();
|
||||
list.Content.ShouldNotContain("tasks/");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_units_the_task_files_stay_as_they_were()
|
||||
{
|
||||
var tasks = Path.Combine(_agents, "SharedWorkspace", "tasks");
|
||||
Directory.CreateDirectory(tasks);
|
||||
File.WriteAllText(Path.Combine(tasks, "t-1.md"), "task");
|
||||
|
||||
var read = await Run("ads", new { action = "read", workspace = "shared", path = "tasks/t-1.md" }, sharedLevel: "Read", withUnits: false);
|
||||
|
||||
read.Success.ShouldBeTrue(read.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Without_an_organisation_only_personal_and_shared_exist()
|
||||
{
|
||||
var context = new AgentToolContext(
|
||||
"solo", "inst", new Dictionary<string, object?>(), new NullStateStore(), NullLogger.Instance, CancellationToken.None,
|
||||
WorkspacePath: Path.Combine(_agents, "solo"), SharedWorkspacePath: Path.Combine(_agents, "SharedWorkspace"));
|
||||
Directory.CreateDirectory(context.WorkspacePath!);
|
||||
|
||||
var result = await _tool.ExecuteAsync(
|
||||
JsonSerializer.SerializeToElement(new { action = "list", workspace = "unit" }), context, CancellationToken.None);
|
||||
|
||||
result.ErrorMessage!.ShouldBe("Unknown workspace 'unit'. You can use: personal.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user