- 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>
272 lines
12 KiB
C#
272 lines
12 KiB
C#
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.");
|
|
}
|
|
}
|