Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt). Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e): Properties, Record-Parameter, Enum-Member sowie die Parameter der Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
145 lines
7.2 KiB
C#
145 lines
7.2 KiB
C#
using System.Text.Json;
|
|
using ClawdDotNet.Core.Files;
|
|
using ClawdDotNet.Core.Staging;
|
|
using ClawdDotNet.Core.Storage;
|
|
using ClawdDotNet.Core.Tools;
|
|
using ClawdDotNet.Tools.FileRW;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.Tools.Tests.FileRW;
|
|
|
|
/// <summary>
|
|
/// #195: The file history keeps what a delete removes, so deleting a file is no irreversible
|
|
/// act any more and needs no approval. A folder at once, or a file the history cannot keep,
|
|
/// still goes to a human; the workspace itself is never deleted; and an agent can see and undo
|
|
/// what was deleted. Test instance 2026-10-01: eleven cleanup deletes waited for the human.
|
|
/// </summary>
|
|
public sealed class ReversibleDeleteTests : IDisposable
|
|
{
|
|
private readonly string _instance = Path.Combine(Path.GetTempPath(), "clawd-tests", "rd-" + Guid.NewGuid().ToString("N")[..8]);
|
|
private readonly string _agents;
|
|
private readonly string _shared;
|
|
private readonly FileHistorySettings _settings = new();
|
|
private readonly FileHistory _history;
|
|
private readonly FileRWTool _tool = new();
|
|
|
|
public ReversibleDeleteTests()
|
|
{
|
|
_agents = Path.Combine(_instance, "Agents");
|
|
_shared = Path.Combine(_agents, "SharedWorkspace");
|
|
Directory.CreateDirectory(_shared);
|
|
_history = new FileHistory(_agents, new SqliteStorage(Path.Combine(_instance, "state.db")), () => _settings);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
SqliteStorage.ReleasePooledConnections(_instance);
|
|
try { Directory.Delete(_instance, recursive: true); }
|
|
catch { /* cleanup is secondary */ }
|
|
}
|
|
|
|
private AgentToolContext Context(string agentId = "keeper", bool history = true)
|
|
{
|
|
var personal = Path.Combine(_agents, "Agent-" + agentId, "Workspace");
|
|
Directory.CreateDirectory(personal);
|
|
return new AgentToolContext(agentId, "inst",
|
|
new Dictionary<string, object?> { ["sharedAccessLevel"] = "Admin", ["sharedAllowedExtensions"] = JsonSerializer.SerializeToElement(new[] { ".txt", ".md" }) },
|
|
new NullStateStore(), NullLogger.Instance, CancellationToken.None,
|
|
WorkspacePath: personal, SharedWorkspacePath: _shared)
|
|
{
|
|
History = history ? _history : null,
|
|
RunId = "run-" + agentId
|
|
};
|
|
}
|
|
|
|
private Task<ToolResult> Run(object input, AgentToolContext? context = null)
|
|
=> _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), context ?? Context(), CancellationToken.None);
|
|
|
|
private string? KeyOf(object input, AgentToolContext? context = null)
|
|
=> _tool.StagingAction(JsonSerializer.SerializeToElement(input), context ?? Context());
|
|
|
|
private void Put(string relative, string content = "x")
|
|
{
|
|
var full = Path.Combine(_shared, relative);
|
|
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
|
|
File.WriteAllText(full, content);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_kept_file_is_deleted_without_approval_and_put_back_by_the_agent()
|
|
{
|
|
Put("wetter/stunden/berlin_00.txt", "13.4 °C");
|
|
var delete = new { action = "delete", workspace = "shared", path = "wetter/stunden/berlin_00.txt" };
|
|
|
|
KeyOf(delete).ShouldBe("delete");
|
|
_tool.Vorgabe("delete", Context()).ShouldBe(StagingDecision.Auto);
|
|
|
|
var deleted = await Run(delete);
|
|
deleted.Success.ShouldBeTrue(deleted.ErrorMessage);
|
|
deleted.Content.ShouldContain("can be restored for 90 days");
|
|
|
|
var list = await Run(new { action = "deleted", workspace = "shared" });
|
|
list.Content.ShouldContain("wetter/stunden/berlin_00.txt");
|
|
var entry = (await _history.ListAsync("shared", null, null, null, 10, default)).First(e => e.Action == FileAction.Delete).Id;
|
|
|
|
var restored = await Run(new { action = "restore", workspace = "shared", entry });
|
|
restored.Success.ShouldBeTrue(restored.ErrorMessage);
|
|
File.ReadAllText(Path.Combine(_shared, "wetter/stunden/berlin_00.txt")).ShouldBe("13.4 °C");
|
|
|
|
(await Run(new { action = "restore", workspace = "shared", entry })).ErrorMessage.ShouldContain("exists again");
|
|
(await Run(new { action = "deleted", workspace = "shared" })).Content.ShouldContain("Nothing deleted");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_folder_needs_recursive_and_a_human_and_the_workspace_is_never_deleted()
|
|
{
|
|
Put("archiv/a.txt");
|
|
Put("archiv/b.txt");
|
|
|
|
KeyOf(new { action = "delete", workspace = "shared", path = "archiv" }).ShouldBe("delete_folder");
|
|
KeyOf(new { action = "delete", workspace = "shared", path = "archiv", recursive = true }).ShouldBe("delete_folder");
|
|
new StagingPolicy().Decide("FileRW", "delete_folder").ShouldBe(StagingDecision.Approve);
|
|
|
|
(await Run(new { action = "delete", workspace = "shared", path = "archiv" })).ErrorMessage.ShouldContain("recursive");
|
|
(await Run(new { action = "delete", workspace = "shared", path = "." })).ErrorMessage.ShouldContain("workspace itself cannot be deleted");
|
|
Directory.Exists(Path.Combine(_shared, "archiv")).ShouldBeTrue();
|
|
|
|
(await Run(new { action = "delete", workspace = "shared", path = "archiv", recursive = true })).Success.ShouldBeTrue();
|
|
Directory.Exists(Path.Combine(_shared, "archiv")).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void A_file_the_history_cannot_keep_stays_with_a_human()
|
|
{
|
|
Put("big.txt", new string('x', 2048));
|
|
|
|
KeyOf(new { action = "delete", workspace = "shared", path = "big.txt" }, Context(history: false)).ShouldBe("delete_unkept");
|
|
_tool.Vorgabe("delete", Context(history: false)).ShouldBeNull("without a history, the standard rule asks a human");
|
|
|
|
_settings.MaxObjectMegabytes = 0;
|
|
KeyOf(new { action = "delete", workspace = "shared", path = "big.txt" }).ShouldBe("delete_unkept");
|
|
new StagingPolicy().Decide("FileRW", "delete_unkept").ShouldBe(StagingDecision.Approve);
|
|
new StagingPolicy().Decide("FileRW", "delete").ShouldBe(StagingDecision.Approve, "without the history's word, delete stays with a human");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_deletion_from_another_workspace_or_with_a_forbidden_extension_is_not_restored()
|
|
{
|
|
Put("note.txt", "shared note");
|
|
await Run(new { action = "delete", workspace = "shared", path = "note.txt" });
|
|
var entry = (await _history.ListAsync("shared", null, null, null, 10, default)).First(e => e.Action == FileAction.Delete).Id;
|
|
|
|
(await Run(new { action = "restore", workspace = "personal", entry })).ErrorMessage.ShouldContain("no deletion in the personal workspace");
|
|
(await Run(new { action = "restore", workspace = "shared" })).ErrorMessage.ShouldContain("'entry' is required");
|
|
|
|
// A file whose extension the workspace no longer allows does not come back through restore.
|
|
Put("tool.exe", "MZ");
|
|
var exe = await _history.BeforeAsync(Path.Combine(_shared, "tool.exe"), "shared", default);
|
|
File.Delete(Path.Combine(_shared, "tool.exe"));
|
|
var recorded = await _history.RecordAsync(exe, FileAction.Delete, "someone", null, default);
|
|
(await Run(new { action = "restore", workspace = "shared", entry = recorded!.Id })).Success.ShouldBeFalse();
|
|
File.Exists(Path.Combine(_shared, "tool.exe")).ShouldBeFalse();
|
|
}
|
|
}
|