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; /// /// #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. /// public sealed class WorkspaceAreasTests : IDisposable { private readonly string _agents; private readonly FileRWTool _tool = new(); private readonly List _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 _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 _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 { ["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 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(), 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."); } }