Files
ClawdDotNet/src/ClawdDotNet.Core/Runtimes/ForeignWorkspace.cs
T

184 lines
8.2 KiB
C#

using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Runtimes;
/// <summary>#712: One change a foreign run proposes.</summary>
public sealed record ProposedChange(
[property: JsonPropertyName("path")] string Path,
[property: JsonPropertyName("change")] string Change,
[property: JsonPropertyName("bytes")] long Bytes,
[property: JsonPropertyName("sha256")] string? Sha256)
{
public const string Added = "added";
public const string Modified = "modified";
public const string Deleted = "deleted";
}
/// <summary>#712: What a foreign run left behind — never applied, kept for review.</summary>
public sealed record Proposal
{
[JsonPropertyName("runId")] public string RunId { get; init; } = "";
[JsonPropertyName("agentId")] public string AgentId { get; init; } = "";
[JsonPropertyName("taskId")] public string? TaskId { get; init; }
[JsonPropertyName("runtime")] public string Runtime { get; init; } = "";
[JsonPropertyName("createdAtUtc")] public DateTime CreatedAtUtc { get; init; }
[JsonPropertyName("changes")] public IReadOnlyList<ProposedChange> Changes { get; init; } = [];
/// <summary>What the run left out: links, special files — named, never read.</summary>
[JsonPropertyName("ignored")] public IReadOnlyList<string> Ignored { get; init; } = [];
}
/// <summary>
/// #712: The foreign run's place to work — a copy of the agent's workspace, never the
/// workspace itself (rule 1 of the concept). What changed there becomes a proposal
/// (rule 3): the changed files and a list, next to the agent, for review. Whether any of it
/// is used is decided by the delivery chain (#695), not by the run.
///
/// <para><b>Reading what a sandbox left is reading hostile ground.</b> Only regular files
/// are read. A symbolic link could point to a key outside the sandbox — inside it the
/// target did not exist, outside it does; a named pipe would block the reader for good.
/// Both are listed as ignored and never opened. After the run every process of the sandbox
/// is gone, so nothing changes while it is read.</para>
/// </summary>
public static class ForeignWorkspace
{
public const long MaxBytes = 512L * 1024 * 1024;
public const int MaxFiles = 20_000;
private static readonly JsonSerializerOptions Json = new() { WriteIndented = true };
/// <summary>Copies the workspace to <paramref name="target"/> — regular files and folders only.</summary>
public static void Copy(string workspace, string target)
{
Directory.CreateDirectory(target);
if (!Directory.Exists(workspace))
return;
long bytes = 0;
var files = 0;
foreach (var (relative, full) in Walk(workspace, _ => { }))
{
var info = new FileInfo(full);
bytes += info.Length;
if (++files > MaxFiles || bytes > MaxBytes)
throw new ForeignRunException(
$"The workspace is too large for a foreign run (more than {MaxFiles} files or {MaxBytes / (1024 * 1024)} MB).");
var destination = Path.Combine(target, relative);
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
File.Copy(full, destination);
}
}
/// <summary>What differs between the workspace and the run's copy.</summary>
public static (IReadOnlyList<ProposedChange> Changes, IReadOnlyList<string> Ignored) Compare(string workspace, string copy)
{
var before = Directory.Exists(workspace)
? Walk(workspace, _ => { }).ToDictionary(e => e.Relative, e => e.Full, StringComparer.Ordinal)
: new Dictionary<string, string>(StringComparer.Ordinal);
var ignored = new List<string>();
var changes = new List<ProposedChange>();
long bytes = 0;
foreach (var (relative, full) in Walk(copy, ignored.Add))
{
var length = new FileInfo(full).Length;
if ((bytes += length) > MaxBytes || changes.Count >= MaxFiles)
throw new ForeignRunException("The run left more than a proposal can hold.");
var hash = Hash(full);
if (!before.Remove(relative, out var original))
changes.Add(new ProposedChange(relative, ProposedChange.Added, length, hash));
else if (new FileInfo(original).Length != length || Hash(original) != hash)
changes.Add(new ProposedChange(relative, ProposedChange.Modified, length, hash));
}
foreach (var relative in before.Keys)
changes.Add(new ProposedChange(relative, ProposedChange.Deleted, 0, null));
changes.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path));
return (changes, ignored);
}
/// <summary>Writes the proposal to <paramref name="directory"/>: <c>proposal.json</c> and the files under <c>files/</c>.</summary>
public static void Keep(Proposal proposal, string copy, string directory)
{
var files = Path.Combine(directory, "files");
Directory.CreateDirectory(files);
foreach (var change in proposal.Changes.Where(c => c.Change != ProposedChange.Deleted))
{
var destination = Path.Combine(files, change.Path);
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
File.Copy(Path.Combine(copy, change.Path), destination, overwrite: true);
}
File.WriteAllText(Path.Combine(directory, "proposal.json"), JsonSerializer.Serialize(proposal, Json));
}
/// <summary>Regular files below <paramref name="root"/>, relative with '/'; everything else goes to <paramref name="skipped"/>.</summary>
private static IEnumerable<(string Relative, string Full)> Walk(string root, Action<string> skipped)
{
var pending = new Stack<string>([Path.GetFullPath(root)]);
var rootLength = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar).Length + 1;
while (pending.Count > 0)
{
var dir = pending.Pop();
foreach (var entry in new DirectoryInfo(dir).EnumerateFileSystemInfos())
{
var relative = entry.FullName[rootLength..].Replace('\\', '/');
switch (FileKind.Of(entry))
{
case FileKind.Kind.Directory: pending.Push(entry.FullName); break;
case FileKind.Kind.Regular: yield return (relative, entry.FullName); break;
default: skipped(relative); break;
}
}
}
}
private static string Hash(string path)
{
using var stream = File.OpenRead(path);
return Convert.ToHexStringLower(SHA256.HashData(stream));
}
}
/// <summary>
/// #712: What an entry really is, without following it. .NET tells links apart but not a
/// named pipe or a device from a file — under Linux that takes <c>statx</c>, whose layout,
/// unlike <c>stat</c>'s, is the same on every architecture.
/// </summary>
internal static class FileKind
{
public enum Kind { Regular, Directory, Other }
public static Kind Of(FileSystemInfo entry)
{
if (entry.LinkTarget is not null || entry.Attributes.HasFlag(FileAttributes.ReparsePoint))
return Kind.Other;
if (!OperatingSystem.IsLinux())
return entry is DirectoryInfo ? Kind.Directory : Kind.Regular;
var buffer = new byte[256];
if (Statx(AtFdCwd, entry.FullName, AtSymlinkNoFollow, StatxType, buffer) != 0)
return Kind.Other;
var mode = BitConverter.ToUInt16(buffer, 0x1C) & 0xF000;
return mode switch
{
0x8000 => Kind.Regular,
0x4000 => Kind.Directory,
_ => Kind.Other
};
}
private const int AtFdCwd = -100;
private const int AtSymlinkNoFollow = 0x100;
private const uint StatxType = 0x1;
[DllImport("libc", EntryPoint = "statx", SetLastError = true)]
private static extern int Statx(int dirfd, [MarshalAs(UnmanagedType.LPUTF8Str)] string path, int flags, uint mask, byte[] buffer);
}
/// <summary>#712: A foreign run that could not take place or not be read.</summary>
public sealed class ForeignRunException(string message) : Exception(message);