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; /// /// #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. /// 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 { ["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 Run(object input, AgentToolContext? context = null) => _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), context ?? Context(), CancellationToken.None); private string? KeyOf(object input, AgentToolContext? context = null) => _tool.StagingAktion(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(); } }