- "shared:betrieb/protokoll.md" wird zu workspace + Pfad (wie FTP/RocketChat seit #718); muss zu einem angegebenen workspace passen, eine Pfadliste nennt nur einen Bereich - read_many mit Muster: Hinweis auf einen anderen Bereich nur, wenn dort Dateien passen; ohne Pfad und Muster kein Hinweis (die Wurzel gibt es ueberall) - verweigerter Pfad: kein doppeltes "Access denied", Warnung statt ERR mit Stacktrace - Tests: WorkspaceInPathTests (6) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1336 lines
63 KiB
C#
1336 lines
63 KiB
C#
using ClawdDotNet.Core.Files;
|
|
using ClawdDotNet.Core.Config;
|
|
using ClawdDotNet.Core.Organisation;
|
|
using System.Collections.Concurrent;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using ClawdDotNet.Core.Storage;
|
|
using ClawdDotNet.Core.Tools;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace ClawdDotNet.Tools.FileRW;
|
|
|
|
public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|
{
|
|
/// <summary>
|
|
/// Per-Datei Locking: Verhindert Race Conditions wenn mehrere Agenten
|
|
/// gleichzeitig auf dieselbe Datei zugreifen (read/write/append/delete/copy).
|
|
/// Key = normalisierter absoluter Pfad (lowercase), Value = SemaphoreSlim(1,1).
|
|
/// </summary>
|
|
private static readonly ConcurrentDictionary<string, SemaphoreSlim> FileLocks = new();
|
|
|
|
private static SemaphoreSlim GetFileLock(string fullPath)
|
|
=> FileLocks.GetOrAdd(NormalizePath(fullPath), _ => new SemaphoreSlim(1, 1));
|
|
|
|
private static string NormalizePath(string path)
|
|
=> Path.GetFullPath(path).ToLowerInvariant();
|
|
|
|
public string Name => "FileRW";
|
|
|
|
public string Description => "File management in workspaces:\n" +
|
|
"- workspace='personal': Your PRIVATE folder — only you have access.\n" +
|
|
"- workspace='shared': the instance's SHARED folder, if you have access to it.\n" +
|
|
"- workspace='unit': your unit's workspace; other named workspaces you are a member of " +
|
|
"are listed in the Organisation section of your instructions.\n" +
|
|
"Actions: read, read_many, write, append, list, delete, copy, stock_add.\n" +
|
|
"'read_many' reads multiple files in ONE call - either 'paths' (list) or 'path' " +
|
|
"(directory) with 'pattern' (e.g. \"berlin_2026-09-20_*.txt\"). Use this instead of reading " +
|
|
"many files individually: each individual call costs a step.\n" +
|
|
"'list' by default shows the 50 most recently modified entries, newest first; " +
|
|
"'pattern', 'sort', 'order' and 'limit' control this. It is better to query a specific file " +
|
|
"using 'pattern' rather than searching through a long list.\n" +
|
|
"For copy, files can be copied between workspaces.\n" +
|
|
"'stock_add' adds a new data point to the stock knowledge base (shared:stocks/{ticker}/). " +
|
|
"Protected paths (protectedPaths) only allow creating new files — no overwriting or deleting.";
|
|
|
|
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"action": {
|
|
"type": "string",
|
|
"enum": ["read", "read_many", "write", "append", "list", "delete", "copy", "stock_add"],
|
|
"description": "The action to perform. 'read_many' reads multiple files at once. 'stock_add' adds a structured data point to the stock knowledge base."
|
|
},
|
|
"workspace": {
|
|
"type": "string",
|
|
"description": "The workspace in which to execute the action (source workspace for copy): 'personal', 'shared', 'unit' or the name of a workspace you are a member of. Default is 'personal'."
|
|
},
|
|
"path": {
|
|
"type": "string",
|
|
"description": "Relative path to the file or directory"
|
|
},
|
|
"content": {
|
|
"type": "string",
|
|
"description": "Content for write or append actions"
|
|
},
|
|
"paths": {
|
|
"type": "array",
|
|
"items": { "type": "string" },
|
|
"description": "Only for read_many: files to read. Alternatively 'path' (directory) with 'pattern'."
|
|
},
|
|
"pattern": {
|
|
"type": "string",
|
|
"description": "For read_many and list: file pattern within directory 'path', e.g. 'berlin_2026-09-20_*.txt'. Without pattern, the entire directory is used."
|
|
},
|
|
"sort": {
|
|
"type": "string",
|
|
"enum": ["modified", "name"],
|
|
"description": "Only for list: sorting. Default 'modified' (newest first)."
|
|
},
|
|
"order": {
|
|
"type": "string",
|
|
"enum": ["desc", "asc"],
|
|
"description": "Only for list: sort direction. Default descending for 'modified', ascending for 'name'."
|
|
},
|
|
"limit": {
|
|
"type": "integer",
|
|
"description": "Only for list: maximum number of entries. Default 50, maximum 500. The response header always mentions total count."
|
|
},
|
|
"destinationWorkspace": {
|
|
"type": "string",
|
|
"description": "Destination workspace for copy, named like 'workspace'. Default is the same workspace as 'workspace'."
|
|
},
|
|
"destinationPath": {
|
|
"type": "string",
|
|
"description": "Relative destination path for copy. Required for copy."
|
|
},
|
|
"ticker": {
|
|
"type": "string",
|
|
"description": "Stock ticker for stock_add (e.g. 'NVDA', 'TSLA'). Automatically converted to uppercase."
|
|
},
|
|
"category": {
|
|
"type": "string",
|
|
"enum": ["news", "social", "analysis", "capitol", "price", "earnings", "filing", "sentiment", "other"],
|
|
"description": "Category of data point for stock_add."
|
|
},
|
|
"source": {
|
|
"type": "string",
|
|
"description": "Source of data point for stock_add (e.g. 'reuters', 'x_unusual_whales', 'youtube', 'reddit')."
|
|
},
|
|
"title": {
|
|
"type": "string",
|
|
"description": "Short headline/summary of data point for stock_add."
|
|
},
|
|
"data": {
|
|
"type": "object",
|
|
"description": "Structured data of data point for stock_add. Can contain arbitrary fields."
|
|
}
|
|
},
|
|
"required": ["action"]
|
|
}
|
|
""").RootElement.Clone();
|
|
|
|
public async Task<ToolResult> ExecuteAsync(
|
|
JsonElement input,
|
|
AgentToolContext context,
|
|
CancellationToken ct)
|
|
{
|
|
var action = input.GetProperty("action").GetString()
|
|
?? throw new ArgumentException("'action' is required");
|
|
|
|
// #718: "shared:betrieb/protokoll.md" names the workspace in the path — as in FTP and RocketChat.
|
|
if (WorkspaceFromPath(ref input) is { } prefixProblem)
|
|
return ToolResult.Fail(prefixProblem);
|
|
|
|
var requested = input.TryGetProperty("workspace", out var w) && w.ValueKind == JsonValueKind.String ? w.GetString() : null;
|
|
if (ResolveArea(requested, context) is not { } area)
|
|
return ToolResult.Fail(UnknownWorkspace(requested!, context));
|
|
|
|
if (string.IsNullOrWhiteSpace(area.Root))
|
|
return ToolResult.Fail($"Configuration error: the folder of workspace '{area.Name}' is not defined.");
|
|
|
|
// Sicherstellen, dass rootPath absolut ist
|
|
var rootPath = Path.GetFullPath(area.Root);
|
|
|
|
// Zugriffsprüfung
|
|
if (!IsActionAllowed(action, area))
|
|
{
|
|
return ToolResult.Fail($"Access denied: Action '{action}' is not allowed in workspace '{area.Name}' for this agent.");
|
|
}
|
|
|
|
// A workspace with a member list comes into being when it is first used.
|
|
if (!area.IsPersonal && area.Name != WorkspaceLayout.Shared)
|
|
Directory.CreateDirectory(rootPath);
|
|
|
|
try
|
|
{
|
|
return action switch
|
|
{
|
|
"read" => await HandleReadAsync(input, rootPath, area, context, ct),
|
|
"read_many" => await HandleReadManyAsync(input, rootPath, area, context, ct),
|
|
"write" => await HandleWriteAsync(input, rootPath, area, context, ct),
|
|
"append" => await HandleAppendAsync(input, rootPath, area, context, ct),
|
|
"list" => await HandleListAsync(input, rootPath, area, context, ct),
|
|
"delete" => await HandleDeleteAsync(input, rootPath, area, context, ct),
|
|
"copy" => await HandleCopyAsync(input, rootPath, area, context, ct),
|
|
"stock_add" => await HandleStockAddAsync(input, context, ct),
|
|
_ => ToolResult.Fail($"Unknown action: {action}")
|
|
};
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
// A refused path is the agent's mistake, not the tool's — no stack trace in the error log.
|
|
context.Logger.LogWarning("FileRW {Action} refused: {Reason}", action, ex.Message);
|
|
return ToolResult.Fail(Denied(ex.Message));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
context.Logger.LogError(ex, "Error during FileRW action {Action}", action);
|
|
return ToolResult.Fail($"Error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// #579: Rechte und Pfad pruefen, bevor ein Aufruf in die Freigabeliste kommt. Dieselben
|
|
/// Pruefungen wie in <see cref="ExecuteAsync"/>, ohne etwas anzufassen.
|
|
/// </summary>
|
|
public string? Preflight(JsonElement input, AgentToolContext context)
|
|
{
|
|
if (input.ValueKind != JsonValueKind.Object
|
|
|| !input.TryGetProperty("action", out var a) || a.ValueKind != JsonValueKind.String)
|
|
return null;
|
|
|
|
var action = a.GetString() ?? "";
|
|
if (WorkspaceFromPath(ref input) is { } prefixProblem)
|
|
return prefixProblem;
|
|
var requested = input.TryGetProperty("workspace", out var w) && w.ValueKind == JsonValueKind.String
|
|
? w.GetString() : null;
|
|
|
|
if (ResolveArea(requested, context) is not { } area)
|
|
return UnknownWorkspace(requested!, context);
|
|
|
|
if (!IsActionAllowed(action, area))
|
|
return $"Access denied: Action '{action}' is not allowed in workspace '{area.Name}' for this agent.";
|
|
|
|
if (action != "delete" || string.IsNullOrWhiteSpace(area.Root))
|
|
return null;
|
|
|
|
try
|
|
{
|
|
var rootPath = Path.GetFullPath(area.Root);
|
|
var path = GetAndValidatePath(input, rootPath, area, false, context);
|
|
|
|
if (!CanOverrideProtection(area) && IsPathProtected(path, rootPath, area))
|
|
return $"🛡️ Protected area: '{Path.GetRelativePath(rootPath, path)}' cannot be deleted.";
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
// Dieselbe Pfadpruefung wirft bei der Ausfuehrung - dort wird daraus ein Fehler.
|
|
return Denied(ex.Message);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string Denied(string message)
|
|
=> message.StartsWith("Access denied", StringComparison.Ordinal) ? message : $"Access denied: {message}";
|
|
|
|
/// <summary>
|
|
/// #718: A path may name its workspace — <c>shared:betrieb/protokoll.md</c>, the form FTP and
|
|
/// RocketChat take. Agents carry it over to FileRW, and every refusal cost them a step. The
|
|
/// prefix becomes the <c>workspace</c> argument; it must agree with one given, and a list of
|
|
/// paths names one workspace. Returns the refusal, or <c>null</c>.
|
|
/// </summary>
|
|
internal static string? WorkspaceFromPath(ref JsonElement input)
|
|
{
|
|
if (input.ValueKind != JsonValueKind.Object)
|
|
return null;
|
|
|
|
// The relative path, or null without a prefix; "\0" when it names a second workspace.
|
|
string? named = null;
|
|
string? Strip(string path)
|
|
{
|
|
if (path.IndexOf(':') <= 0)
|
|
return null;
|
|
var (workspace, relative) = NamedWorkspaces.Split(path);
|
|
if (relative.Length == path.Length)
|
|
return null;
|
|
if (named is not null && !Same(named, workspace))
|
|
return "\0";
|
|
named = workspace;
|
|
return relative;
|
|
}
|
|
|
|
var node = System.Text.Json.Nodes.JsonNode.Parse(input.GetRawText())!.AsObject();
|
|
var changed = false;
|
|
|
|
if (node["path"] is System.Text.Json.Nodes.JsonValue pv && pv.TryGetValue<string>(out var single) && Strip(single) is { } rel)
|
|
{
|
|
node["path"] = rel;
|
|
changed = true;
|
|
}
|
|
if (node["paths"] is System.Text.Json.Nodes.JsonArray list)
|
|
{
|
|
for (var i = 0; i < list.Count; i++)
|
|
{
|
|
if (list[i] is not System.Text.Json.Nodes.JsonValue v || !v.TryGetValue<string>(out var entry))
|
|
continue;
|
|
var stripped = Strip(entry);
|
|
if (stripped == "\0")
|
|
return "'paths' names more than one workspace. Read from one workspace per call (parameter 'workspace').";
|
|
if (stripped is not null)
|
|
{
|
|
list[i] = stripped;
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
if (!changed || named is null)
|
|
return null;
|
|
|
|
if (node["workspace"] is System.Text.Json.Nodes.JsonValue wv && wv.TryGetValue<string>(out var given)
|
|
&& !string.IsNullOrWhiteSpace(given) && !Same(given, named))
|
|
return $"The path names the workspace '{named}', but 'workspace' says '{given}'. Give one of them.";
|
|
|
|
node["workspace"] = named;
|
|
input = JsonSerializer.SerializeToElement(node);
|
|
return null;
|
|
}
|
|
|
|
private static bool Same(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
|
|
|
|
// ─── Workspaces (#684) ───
|
|
|
|
/// <summary>The folder in the shared workspace that holds the task files.</summary>
|
|
private const string TasksFolder = "tasks";
|
|
|
|
/// <summary>A workspace as one call sees it: where it lies and what the agent may do there.</summary>
|
|
private sealed record Area(string Name, string? Root, WorkspaceAccess Access, IReadOnlyList<string> ProtectedPaths)
|
|
{
|
|
public bool IsPersonal => Name == WorkspaceLayout.Personal;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves the name an agent gave. "personal" and "shared" work as they always did —
|
|
/// "shared" still by the agent's own <c>sharedAccessLevel</c>. "unit" is the own unit's
|
|
/// workspace; any other name must be a workspace the agent is a member of. Everything
|
|
/// else is <c>null</c>: an unknown name and a workspace the agent may not see get the
|
|
/// same answer, so the answer does not reveal that it exists.
|
|
/// </summary>
|
|
private static Area? ResolveArea(string? requested, AgentToolContext context)
|
|
{
|
|
var name = string.IsNullOrWhiteSpace(requested) ? WorkspaceLayout.Personal : requested.Trim();
|
|
|
|
if (name.Equals(WorkspaceLayout.Personal, StringComparison.OrdinalIgnoreCase))
|
|
return new Area(WorkspaceLayout.Personal, context.WorkspacePath, WorkspaceAccess.Manage, []);
|
|
|
|
if (name.Equals(WorkspaceLayout.Shared, StringComparison.OrdinalIgnoreCase))
|
|
return new Area(WorkspaceLayout.Shared, context.SharedWorkspacePath, SharedAccess(context), GetProtectedPaths(context));
|
|
|
|
// #718: the same resolution as FTP and RocketChat.
|
|
return NamedWorkspaces.Resolve(name, context.AgentId, context.Organisation, context.SharedWorkspacePath) is { } named
|
|
? new Area(named.Workspace.Id, named.Root, named.Access, NormaliseProtectedPaths(named.Workspace.ProtectedPaths))
|
|
: null;
|
|
}
|
|
|
|
/// <summary>The answer to a name that is not a workspace this agent may use — with the ones it may.</summary>
|
|
private static string UnknownWorkspace(string requested, AgentToolContext context)
|
|
=> NamedWorkspaces.Unknown(requested, context.AgentId, context.Organisation, SharedAccess(context) > WorkspaceAccess.None);
|
|
|
|
/// <summary>The one shared folder, by the level the agent carries for it — as before #684.</summary>
|
|
private static WorkspaceAccess SharedAccess(AgentToolContext context)
|
|
=> (context.ToolConfig.TryGetValue("sharedAccessLevel", out var lv) ? lv?.ToString() : "Denied") switch
|
|
{
|
|
"Read" => WorkspaceAccess.Read,
|
|
"ReadWrite" => WorkspaceAccess.Write,
|
|
"Admin" => WorkspaceAccess.Manage,
|
|
_ => WorkspaceAccess.None
|
|
};
|
|
|
|
private static bool IsActionAllowed(string action, Area area)
|
|
{
|
|
if (area.IsPersonal) return true; // Alles erlaubt
|
|
|
|
return action switch
|
|
{
|
|
"read" or "read_many" or "list" or "copy" => area.Access >= WorkspaceAccess.Read,
|
|
"write" or "append" or "stock_add" => area.Access >= WorkspaceAccess.Write,
|
|
"delete" => area.Access >= WorkspaceAccess.Manage,
|
|
_ => false
|
|
};
|
|
}
|
|
|
|
/// <summary>Managing a workspace includes changing and deleting protected files — as the old Admin level did.</summary>
|
|
private static bool CanOverrideProtection(Area area) => area.Access >= WorkspaceAccess.Manage;
|
|
|
|
/// <summary>
|
|
/// Prüft ob ein Pfad innerhalb eines geschützten Bereichs liegt.
|
|
/// Geschützte Pfade erlauben nur: read, list, append, write (nur neue Dateien), stock_add.
|
|
/// Kein Überschreiben existierender Dateien, kein Löschen.
|
|
/// Wer den Bereich verwaltet, umgeht den Schutz.
|
|
/// </summary>
|
|
private static bool IsPathProtected(string fullPath, string rootPath, Area area)
|
|
{
|
|
if (area.ProtectedPaths.Count == 0) return false;
|
|
|
|
var relativePath = Path.GetRelativePath(rootPath, fullPath)
|
|
.Replace('\\', '/').TrimStart('/');
|
|
|
|
// Jeder Eintrag endet auf "/", deshalb greift StartsWith hier auf einer echten
|
|
// Verzeichnisgrenze: "stocks/" trifft "stocks/x.json", aber nicht "stocks-alt/x.json".
|
|
return area.ProtectedPaths.Any(pp =>
|
|
relativePath.StartsWith(pp, StringComparison.OrdinalIgnoreCase) ||
|
|
relativePath.Equals(pp.TrimEnd('/'), StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
/// <summary>
|
|
/// #684: With units, the task files in the shared folder are closed to FileRW. Written
|
|
/// directly, a task file would pass every check the task board makes — the board of the
|
|
/// unit, the address book of the assignment. Tasks go through the Taskboard tool. Without
|
|
/// units nothing changes, so running installations keep what they have.
|
|
/// </summary>
|
|
private static bool IsBlocked(string fullPath, Area area, AgentToolContext context)
|
|
{
|
|
if (area.Name != WorkspaceLayout.Shared || context.Organisation?.HasUnits != true || string.IsNullOrWhiteSpace(area.Root))
|
|
return false;
|
|
|
|
var relative = Path.GetRelativePath(Path.GetFullPath(area.Root), fullPath).Replace('\\', '/');
|
|
return relative.Equals(TasksFolder, StringComparison.OrdinalIgnoreCase)
|
|
|| relative.StartsWith(TasksFolder + "/", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
// ─── History (#719) ───
|
|
|
|
/// <summary>The workspace as the history names it — the personal one with its owner, since every agent has one.</summary>
|
|
private static string HistoryWorkspace(Area area, AgentToolContext context)
|
|
=> area.IsPersonal ? $"{WorkspaceLayout.Personal}:{context.AgentId}" : area.Name;
|
|
|
|
/// <summary>
|
|
/// Keeps the version before the change. Best effort: a history that cannot be written
|
|
/// must not stop the work — it is logged, and the change happens without its entry.
|
|
/// </summary>
|
|
private static async Task<FileChange?> BeforeChangeAsync(string path, Area area, AgentToolContext context, CancellationToken ct)
|
|
{
|
|
if (context.History is not { } history)
|
|
return null;
|
|
try
|
|
{
|
|
return await history.BeforeAsync(path, HistoryWorkspace(area, context), ct);
|
|
}
|
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or Microsoft.Data.Sqlite.SqliteException)
|
|
{
|
|
context.Logger.LogWarning(ex, "File history: the version before could not be kept for {Path}", path);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static async Task AfterChangeAsync(FileChange? change, string action, AgentToolContext context, CancellationToken ct)
|
|
{
|
|
if (change is null || context.History is not { } history)
|
|
return;
|
|
try
|
|
{
|
|
await history.RecordAsync(change, action, context.AgentId, context.RunId, ct);
|
|
}
|
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or Microsoft.Data.Sqlite.SqliteException)
|
|
{
|
|
context.Logger.LogWarning(ex, "File history: the change could not be recorded for {Path}", change.FullPath);
|
|
}
|
|
}
|
|
|
|
// ─── Lost updates (#720) ───
|
|
|
|
/// <summary>Only workspaces more than one agent can write to — never the personal one.</summary>
|
|
private static bool Guarded(Area area, AgentToolContext context) => !area.IsPersonal && context.Files is not null;
|
|
|
|
private static void NoteRead(string path, Area area, AgentToolContext context)
|
|
{
|
|
if (Guarded(area, context))
|
|
context.Files!.NoteRead(context.AgentId, path, FileLedger.HashOf(path));
|
|
}
|
|
|
|
private static void NoteReplace(string path, Area area, AgentToolContext context)
|
|
{
|
|
if (Guarded(area, context))
|
|
context.Files!.NoteReplace(context.AgentId, context.RunId, path, FileLedger.HashOf(path));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The refusal when replacing the file would lose a change this agent has not seen —
|
|
/// kept for the analysis, audited as a conflict. <c>null</c>: go ahead. Called under the
|
|
/// file's lock, so nothing can change between the check and the write.
|
|
/// </summary>
|
|
private static async Task<ToolResult?> RefuseLostUpdateAsync(
|
|
string path, string displayPath, Area area, AgentToolContext context, CancellationToken ct)
|
|
{
|
|
if (!Guarded(area, context))
|
|
return null;
|
|
if (context.Files!.CheckReplace(context.AgentId, context.RunId, path, area.Name, FileLedger.HashOf(path)) is not { } conflict)
|
|
return null;
|
|
|
|
context.Logger.LogInformation("Write refused, lost update: {Path} by {Agent} ({Kind})", displayPath, context.AgentId, conflict.Kind);
|
|
await context.Files.RecordAsync(conflict, ct);
|
|
return ToolResult.Conflict(conflict.Message(displayPath));
|
|
}
|
|
|
|
private static List<string> NormaliseProtectedPaths(IEnumerable<string> paths)
|
|
=> paths
|
|
.Select(p => p.Replace('\\', '/').Trim().Trim('/'))
|
|
.Where(p => p.Length > 0)
|
|
.Select(p => p + "/")
|
|
.ToList();
|
|
|
|
private static List<string> GetProtectedPaths(AgentToolContext context)
|
|
{
|
|
if (!context.ToolConfig.TryGetValue("protectedPaths", out var pp) || pp is null)
|
|
return [];
|
|
|
|
if (pp is JsonElement je && je.ValueKind == JsonValueKind.Array)
|
|
return je.EnumerateArray()
|
|
.Select(e => e.GetString()?.Replace('\\', '/').Trim().TrimEnd('/') + "/")
|
|
.Where(s => !string.IsNullOrEmpty(s))
|
|
.ToList()!;
|
|
|
|
if (pp is string s && !string.IsNullOrWhiteSpace(s))
|
|
return s.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.Select(p => p.Replace('\\', '/').TrimEnd('/') + "/")
|
|
.ToList();
|
|
|
|
return [];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Nachschau nach einem Absturz (#555). Sieht nach, was von einem unterbrochenen
|
|
/// Aufruf übrig ist — verändert nichts.
|
|
///
|
|
/// <para>Wie weit die Auskunft trägt, hängt an der Aktion:</para>
|
|
/// <list type="bullet">
|
|
/// <item><b>write</b> — seit #553 atomar. Entweder die alte oder die neue Fassung,
|
|
/// nie eine halbe. Ein Vergleich mit dem beabsichtigten Inhalt sagt deshalb
|
|
/// zuverlässig, ob der Aufruf durchkam.</item>
|
|
/// <item><b>append</b> — nicht atomar und nicht atomar zu machen. Hier kann wirklich
|
|
/// ein Riss entstehen, und hier lohnt die Nachschau: Steht der beabsichtigte Text
|
|
/// vollständig am Ende, kam der Aufruf durch; steht ein Anfang davon da, riss es
|
|
/// mittendrin.</item>
|
|
/// <item>alles andere — <see cref="ProbeOutcome.Unknown"/>. Lieber schweigen als
|
|
/// raten.</item>
|
|
/// </list>
|
|
/// </summary>
|
|
public async Task<ProbeFinding> ProbeAsync(JsonElement arguments, AgentToolContext context, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
var action = arguments.TryGetProperty("action", out var a) ? a.GetString() : null;
|
|
|
|
if (action is not ("write" or "append"))
|
|
return ProbeFinding.Unknown($"Nothing to probe for action '{action ?? "?"}'.");
|
|
if (WorkspaceFromPath(ref arguments) is { } prefixProblem)
|
|
return ProbeFinding.Unknown(prefixProblem);
|
|
|
|
var requested = arguments.TryGetProperty("workspace", out var w) && w.ValueKind == JsonValueKind.String ? w.GetString() : null;
|
|
if (ResolveArea(requested, context) is not { Root: { Length: > 0 } root } area)
|
|
return ProbeFinding.Unknown("Workspace path is unknown.");
|
|
|
|
var path = GetAndValidatePath(arguments, Path.GetFullPath(root), area, true, context);
|
|
var erwartet = arguments.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
|
|
|
if (!File.Exists(path))
|
|
{
|
|
return new ProbeFinding(ProbeOutcome.NotStarted,
|
|
"File does not exist — nothing was written.");
|
|
}
|
|
|
|
var vorhanden = await File.ReadAllTextAsync(path, ct);
|
|
|
|
if (action == "write")
|
|
{
|
|
return vorhanden == erwartet
|
|
? new ProbeFinding(ProbeOutcome.Complete,
|
|
$"Content matches intended content exactly ({erwartet.Length} characters).")
|
|
: new ProbeFinding(ProbeOutcome.NotStarted,
|
|
"File still has old content. Writes are atomic — partial versions cannot exist.");
|
|
}
|
|
|
|
// append
|
|
if (vorhanden.EndsWith(erwartet, StringComparison.Ordinal))
|
|
{
|
|
return new ProbeFinding(ProbeOutcome.Complete,
|
|
$"Intended text is completely at the end ({erwartet.Length} characters).");
|
|
}
|
|
|
|
// Wie viel vom Erwarteten steht am Ende? Das ist die Rissstelle.
|
|
for (var laenge = Math.Min(erwartet.Length - 1, vorhanden.Length); laenge > 0; laenge--)
|
|
{
|
|
if (vorhanden.EndsWith(erwartet[..laenge], StringComparison.Ordinal))
|
|
{
|
|
return new ProbeFinding(ProbeOutcome.Partial,
|
|
$"Appended {laenge} of {erwartet.Length} characters — call was interrupted.");
|
|
}
|
|
}
|
|
|
|
return new ProbeFinding(ProbeOutcome.NotStarted,
|
|
"None of the intended text is at the end of the file.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Nie werfen: Eine gescheiterte Nachschau ist keine Aussage über die Welt,
|
|
// sondern eine über die Nachschau.
|
|
return ProbeFinding.Unknown($"Probe not possible: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private string GetAndValidatePath(JsonElement input, string rootPath, Area area, bool checkExtension, AgentToolContext context)
|
|
{
|
|
var relativePath = input.TryGetProperty("path", out var p) ? p.GetString() : "";
|
|
|
|
// Prüft Ausbruch aus dem Workspace, absolute Pfade und Alternate Data Streams.
|
|
var fullPath = WorkspacePath.Resolve(rootPath, relativePath, area.Name);
|
|
|
|
if (IsBlocked(fullPath, area, context))
|
|
throw new UnauthorizedAccessException(
|
|
"Tasks are kept on the task board — use the Taskboard tool (task_list, task_find, task_create, task_update), not their files.");
|
|
|
|
// Extension Check
|
|
if (checkExtension && !Directory.Exists(fullPath))
|
|
{
|
|
var extension = Path.GetExtension(fullPath).ToLowerInvariant();
|
|
|
|
var configKey = area.IsPersonal ? "personalAllowedExtensions" : "sharedAllowedExtensions";
|
|
|
|
var allowedExtensions = context.ToolConfig.TryGetValue(configKey, out var ae) && ae is JsonElement je
|
|
? je.EnumerateArray().Select(x => x.GetString()?.ToLowerInvariant()).ToList()
|
|
: new List<string?> { ".txt", ".json", ".md", ".html", ".js", ".css" }; // Default
|
|
|
|
if (!allowedExtensions.Contains(extension))
|
|
{
|
|
throw new UnauthorizedAccessException($"File extension '{extension}' is not allowed in workspace '{area.Name}'.");
|
|
}
|
|
}
|
|
|
|
return fullPath;
|
|
}
|
|
|
|
/// <summary>
|
|
/// #662: Gibt es den Pfad in einem anderen Workspace, sagt die Fehlermeldung das.
|
|
///
|
|
/// <para>Anlass aus dem Testbetrieb: Ein guenstiges Modell vergisst regelmaessig
|
|
/// <c>workspace: "shared"</c> und bekommt nur "nicht gefunden" zurueck. Es rät dann
|
|
/// weiter, und jeder Versuch kostet einen Schritt. Der Hinweis nennt nur Workspaces,
|
|
/// die der Agent fuer diese Aktion auch benutzen darf - sonst waere er eine Einladung
|
|
/// zu etwas, das ohnehin abgewiesen wird, und verriete obendrein, dass es sie gibt.</para>
|
|
///
|
|
/// <para>Seit #684 gibt es mehr als zwei: gesucht wird in allen, die der Agent kennt.</para>
|
|
/// </summary>
|
|
private string AndererWorkspaceHinweis(
|
|
string action, Area area, string? relativePath, AgentToolContext context, bool verzeichnis, string? muster = null)
|
|
{
|
|
// The root of a workspace exists everywhere — pointing there would send the agent in circles.
|
|
if (string.IsNullOrWhiteSpace(relativePath) && string.IsNullOrWhiteSpace(muster))
|
|
return "";
|
|
|
|
var candidates = new List<string> { WorkspaceLayout.Personal, WorkspaceLayout.Shared };
|
|
if (context.Organisation is { } org)
|
|
candidates.AddRange(org.WorkspacesFor(context.AgentId).Select(g => g.Workspace.Id));
|
|
|
|
foreach (var name in candidates.Where(n => !string.Equals(n, area.Name, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
if (ResolveArea(name, context) is not { Root: { Length: > 0 } root } other || !IsActionAllowed(action, other))
|
|
continue;
|
|
|
|
try
|
|
{
|
|
var pfad = WorkspacePath.Resolve(Path.GetFullPath(root), relativePath, other.Name);
|
|
if (IsBlocked(pfad, other, context))
|
|
continue;
|
|
var dort = verzeichnis
|
|
? Directory.Exists(pfad) && (string.IsNullOrWhiteSpace(muster) || Directory.EnumerateFiles(pfad, muster).Any())
|
|
: File.Exists(pfad);
|
|
if (dort)
|
|
return string.IsNullOrWhiteSpace(muster)
|
|
? $" The path exists in the {other.Name} workspace — call with workspace: \"{other.Name}\"."
|
|
: $" Matching files exist in the {other.Name} workspace — call with workspace: \"{other.Name}\".";
|
|
}
|
|
catch
|
|
{
|
|
// Ein Pfad, der dort gar nicht auflösbar ist, hilft niemandem.
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
private static string? RelativerPfad(JsonElement input)
|
|
=> input.ValueKind == JsonValueKind.Object && input.TryGetProperty("path", out var p) && p.ValueKind == JsonValueKind.String
|
|
? p.GetString()
|
|
: null;
|
|
|
|
private async Task<ToolResult> HandleReadAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct)
|
|
{
|
|
var path = GetAndValidatePath(input, rootPath, area, true, context);
|
|
if (!File.Exists(path))
|
|
{
|
|
return ToolResult.Fail(
|
|
$"File not found in {area.Name} workspace: {Path.GetRelativePath(rootPath, path)}."
|
|
+ AndererWorkspaceHinweis("read", area, RelativerPfad(input), context, verzeichnis: false));
|
|
}
|
|
|
|
var fileLock = GetFileLock(path);
|
|
await fileLock.WaitAsync(ct);
|
|
try
|
|
{
|
|
var content = await File.ReadAllTextAsync(path, ct);
|
|
NoteRead(path, area, context);
|
|
var relativePath = Path.GetRelativePath(rootPath, path);
|
|
return ToolResult.Ok($"[{area.Name}:/{relativePath}]\n{content}");
|
|
}
|
|
finally
|
|
{
|
|
fileLock.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>Hoechstzahl Dateien je <c>read_many</c>.</summary>
|
|
public const int MaxDateienJeAufruf = 50;
|
|
|
|
/// <summary>Hoechstzahl Zeichen je <c>read_many</c> - danach werden nur noch Namen genannt.</summary>
|
|
public const int MaxZeichenJeAufruf = 200_000;
|
|
|
|
/// <summary>
|
|
/// #663: Mehrere Dateien in einem Aufruf lesen.
|
|
///
|
|
/// <para>Anlass: Der Tagesabschluss im Testbetrieb muss 24 Stundendateien zu einem
|
|
/// Tagesarchiv zusammenfassen. Einzeln gelesen sind das 24 Schritte, und der Lauf
|
|
/// stirbt an der Schrittgrenze, bevor er fertig ist. Die Grenze soll Endlosschleifen
|
|
/// abfangen, nicht ehrliche Arbeit - also liest das Werkzeug jetzt auf einmal.</para>
|
|
///
|
|
/// <para>Entweder <c>paths</c> (ausdrueckliche Liste) oder <c>path</c> als Verzeichnis
|
|
/// mit optionalem <c>pattern</c>. Gedeckelt auf <see cref="MaxDateienJeAufruf"/> Dateien
|
|
/// und <see cref="MaxZeichenJeAufruf"/> Zeichen; was nicht mehr hineinpasst, wird am
|
|
/// Ende namentlich genannt, damit der Agent den Rest gezielt nachholen kann.</para>
|
|
/// </summary>
|
|
private async Task<ToolResult> HandleReadManyAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct)
|
|
{
|
|
var (dateien, fehler) = SammleDateien(input, rootPath, area, context);
|
|
if (fehler is not null)
|
|
return ToolResult.Fail(fehler);
|
|
|
|
if (dateien.Count == 0)
|
|
{
|
|
return ToolResult.Fail(
|
|
$"No matching file found in {area.Name} workspace."
|
|
+ AndererWorkspaceHinweis("read_many", area, RelativerPfad(input), context, verzeichnis: true,
|
|
muster: input.TryGetProperty("pattern", out var m) && m.ValueKind == JsonValueKind.String ? m.GetString() : null));
|
|
}
|
|
|
|
var uebersprungen = new List<string>();
|
|
var fehlend = new List<string>();
|
|
var text = new StringBuilder();
|
|
var gelesen = 0;
|
|
|
|
foreach (var pfad in dateien.Take(MaxDateienJeAufruf))
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
var relativ = Path.GetRelativePath(rootPath, pfad).Replace('\\', '/');
|
|
|
|
if (!File.Exists(pfad)) { fehlend.Add(relativ); continue; }
|
|
if (text.Length >= MaxZeichenJeAufruf) { uebersprungen.Add(relativ); continue; }
|
|
|
|
var sperre = GetFileLock(pfad);
|
|
await sperre.WaitAsync(ct);
|
|
string inhalt;
|
|
try
|
|
{
|
|
inhalt = await File.ReadAllTextAsync(pfad, ct);
|
|
NoteRead(pfad, area, context);
|
|
}
|
|
finally { sperre.Release(); }
|
|
|
|
text.Append($"\n--- {area.Name}:/{relativ} ---\n").Append(inhalt.TrimEnd()).Append('\n');
|
|
gelesen++;
|
|
}
|
|
|
|
uebersprungen.AddRange(dateien.Skip(MaxDateienJeAufruf).Select(d => Path.GetRelativePath(rootPath, d).Replace('\\', '/')));
|
|
|
|
var kopf = new StringBuilder($"[{area.Name} Workspace] {gelesen} of {dateien.Count} file(s) read.");
|
|
if (fehlend.Count > 0)
|
|
kopf.Append($"\nNot found: {string.Join(", ", fehlend.Take(10))}{(fehlend.Count > 10 ? " …" : "")}");
|
|
if (uebersprungen.Count > 0)
|
|
kopf.Append($"\nSkipped reading (limit {MaxDateienJeAufruf} files / {MaxZeichenJeAufruf} chars), "
|
|
+ $"fetch with an additional call: {string.Join(", ", uebersprungen.Take(10))}{(uebersprungen.Count > 10 ? " …" : "")}");
|
|
|
|
return ToolResult.Ok(kopf.Append('\n').Append(text).ToString());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Die zu lesenden Dateien: entweder aus <c>paths</c> oder aus Verzeichnis und Muster.
|
|
/// Jeder Pfad geht durch dieselbe Pruefung wie ein einzelnes <c>read</c>.
|
|
/// </summary>
|
|
private (List<string> Dateien, string? Fehler) SammleDateien(
|
|
JsonElement input, string rootPath, Area area, AgentToolContext context)
|
|
{
|
|
try
|
|
{
|
|
if (input.TryGetProperty("paths", out var liste) && liste.ValueKind == JsonValueKind.Array)
|
|
{
|
|
var dateien = new List<string>();
|
|
foreach (var eintrag in liste.EnumerateArray())
|
|
{
|
|
if (eintrag.ValueKind != JsonValueKind.String) continue;
|
|
var relativ = eintrag.GetString();
|
|
if (string.IsNullOrWhiteSpace(relativ)) continue;
|
|
dateien.Add(PruefeLesepfad(relativ, rootPath, area, context));
|
|
}
|
|
return (dateien, dateien.Count == 0 ? "'paths' contains no valid path." : null);
|
|
}
|
|
|
|
var verzeichnis = WorkspacePath.Resolve(rootPath, RelativerPfad(input), area.Name);
|
|
if (!Directory.Exists(verzeichnis))
|
|
return ([], null); // Die Meldung samt Workspace-Hinweis macht der Aufrufer.
|
|
|
|
var muster = input.TryGetProperty("pattern", out var m) && m.ValueKind == JsonValueKind.String
|
|
? m.GetString()
|
|
: null;
|
|
if (MusterFehler(muster) is { } musterFehler)
|
|
return ([], musterFehler);
|
|
|
|
var gefunden = Directory.GetFiles(verzeichnis, string.IsNullOrWhiteSpace(muster) ? "*" : muster)
|
|
.OrderBy(f => f, StringComparer.Ordinal)
|
|
.ToList();
|
|
|
|
// Dieselbe Endungspruefung wie beim einzelnen Lesen - was dort verboten ist,
|
|
// kommt auch hier nicht durch, nur ohne den Lauf abzubrechen.
|
|
return (gefunden.Where(f => IstLesbar(f, area, context)).ToList(), null);
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
return ([], Denied(ex.Message));
|
|
}
|
|
}
|
|
|
|
private string PruefeLesepfad(string relativePath, string rootPath, Area area, AgentToolContext context)
|
|
{
|
|
var json = JsonSerializer.SerializeToElement(new { action = "read", path = relativePath });
|
|
return GetAndValidatePath(json, rootPath, area, true, context);
|
|
}
|
|
|
|
private bool IstLesbar(string vollerPfad, Area area, AgentToolContext context)
|
|
{
|
|
try
|
|
{
|
|
var json = JsonSerializer.SerializeToElement(new { action = "read", path = Path.GetFileName(vollerPfad) });
|
|
GetAndValidatePath(json, Path.GetDirectoryName(vollerPfad)!, area, true, context);
|
|
return true;
|
|
}
|
|
catch (UnauthorizedAccessException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private async Task<ToolResult> HandleWriteAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct)
|
|
{
|
|
var path = GetAndValidatePath(input, rootPath, area, true, context);
|
|
var content = input.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
|
|
|
var fileLock = GetFileLock(path);
|
|
await fileLock.WaitAsync(ct);
|
|
try
|
|
{
|
|
// Schutz: In geschützten Bereichen darf nur geschrieben werden wenn die Datei NICHT existiert
|
|
if (!CanOverrideProtection(area) && File.Exists(path) && IsPathProtected(path, rootPath, area))
|
|
{
|
|
var relPath = Path.GetRelativePath(rootPath, path);
|
|
return ToolResult.Fail(
|
|
$"🛡️ Protected area: File '{relPath}' already exists and cannot be overwritten. " +
|
|
$"Use 'append' to add data, or create a new file with a different name. " +
|
|
$"Tip: Use 'stock_add' for structured data points.");
|
|
}
|
|
|
|
// #720: not over a change this agent has not seen.
|
|
if (await RefuseLostUpdateAsync(path, $"{area.Name}:/{Path.GetRelativePath(rootPath, path).Replace('\\', '/')}", area, context, ct) is { } conflict)
|
|
return conflict;
|
|
|
|
var dir = Path.GetDirectoryName(path);
|
|
if (dir != null && !Directory.Exists(dir)) Directory.CreateDirectory(dir);
|
|
var change = await BeforeChangeAsync(path, area, context, ct);
|
|
|
|
// Atomar schreiben (#553): erst in eine Nebendatei, dann ersetzen. Ein Absturz
|
|
// mitten im Vorgang hinterlässt damit entweder die alte oder die neue Fassung,
|
|
// nie eine halbe. Zuvor stand hier File.WriteAllTextAsync — die Anwendung
|
|
// schützte also ihre eigenen Konfigurationsdateien, die Arbeitsergebnisse der
|
|
// Agenten aber nicht.
|
|
await AtomicFile.WriteAllTextAsync(path, content, new UTF8Encoding(false), ct);
|
|
NoteReplace(path, area, context);
|
|
await AfterChangeAsync(change, FileAction.Replace, context, ct);
|
|
return ToolResult.Ok($"[{area.Name} Workspace] File successfully written: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
|
|
}
|
|
finally
|
|
{
|
|
fileLock.Release();
|
|
}
|
|
}
|
|
|
|
private async Task<ToolResult> HandleAppendAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct)
|
|
{
|
|
var path = GetAndValidatePath(input, rootPath, area, true, context);
|
|
var content = input.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
|
|
|
var fileLock = GetFileLock(path);
|
|
await fileLock.WaitAsync(ct);
|
|
try
|
|
{
|
|
// Bewusst NICHT atomar (#553): Anhängen ließe sich nur atomar machen, indem
|
|
// die ganze Datei neu geschrieben wird — bei einer großen Protokolldatei wäre
|
|
// das der falsche Handel. Hier bleibt ein Riss möglich; genau dafür gibt es
|
|
// die Zustandsprüfung in #555.
|
|
// #720: appending loses nothing and is not checked — but noted, so whoever read
|
|
// the file before does not replace it without the appended part.
|
|
var before = Guarded(area, context) ? FileLedger.HashOf(path) : null;
|
|
var change = await BeforeChangeAsync(path, area, context, ct);
|
|
|
|
// #575: Neu entstehende Dateien gehoeren nur dem Besitzer (0600).
|
|
await using (var stream = ClawdDotNet.Core.Storage.OwnerOnlyFile.OpenAppend(path))
|
|
await using (var writer = new StreamWriter(stream, new UTF8Encoding(false)))
|
|
await writer.WriteAsync(content.AsMemory(), ct);
|
|
|
|
if (Guarded(area, context))
|
|
context.Files!.NoteAppend(context.AgentId, context.RunId, path, before, FileLedger.HashOf(path));
|
|
await AfterChangeAsync(change, FileAction.Append, context, ct);
|
|
return ToolResult.Ok($"[{area.Name} Workspace] Content successfully appended to: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
|
|
}
|
|
finally
|
|
{
|
|
fileLock.Release();
|
|
}
|
|
}
|
|
|
|
/// <summary>Wie viele Eintraege ein <c>list</c> ohne ausdrueckliches <c>limit</c> zeigt.</summary>
|
|
public const int StandardEintraege = 50;
|
|
|
|
/// <summary>Obergrenze fuer <c>limit</c> - darueber wird die Antwort zur Heuhaufensuche.</summary>
|
|
public const int MaxEintraege = 500;
|
|
|
|
/// <summary>
|
|
/// #673: Verzeichnis auflisten - sortiert, gedeckelt, kompakt.
|
|
///
|
|
/// <para>Anlass: Die Betriebskontrolle im Testbetrieb meldete Stundendateien als fehlend,
|
|
/// die es gab. Das Verzeichnis lieferte 105 Eintraege in der Reihenfolge des Dateisystems,
|
|
/// vierzeilig als eingeruecktes JSON. Darin zwei bestimmte Namen zu finden ist Sucharbeit,
|
|
/// und ein guenstiges Modell erledigt sie falsch.</para>
|
|
///
|
|
/// <para>Also: neueste zuerst (umstellbar ueber <c>sort</c> und <c>order</c>), <c>pattern</c>
|
|
/// zum Eingrenzen, eine Zeile je Eintrag - und der Kopf sagt, wie viele Eintraege es
|
|
/// insgesamt gibt. Eine gedeckelte Liste, die ihre Deckelung verschweigt, waere die
|
|
/// schlimmere Falle.</para>
|
|
/// </summary>
|
|
private async Task<ToolResult> HandleListAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct)
|
|
{
|
|
var path = GetAndValidatePath(input, rootPath, area, false, context);
|
|
if (!Directory.Exists(path))
|
|
{
|
|
return ToolResult.Fail(
|
|
$"Directory not found in {area.Name} workspace: {Path.GetRelativePath(rootPath, path)}."
|
|
+ AndererWorkspaceHinweis("list", area, RelativerPfad(input), context, verzeichnis: true));
|
|
}
|
|
|
|
var muster = GetOptionalString(input, "pattern");
|
|
if (MusterFehler(muster) is { } musterFehler)
|
|
return ToolResult.Fail(musterFehler);
|
|
|
|
List<string> pfade;
|
|
try
|
|
{
|
|
pfade = Directory.GetFileSystemEntries(path, string.IsNullOrWhiteSpace(muster) ? "*" : muster)
|
|
.Where(e => !IsBlocked(e, area, context))
|
|
.ToList();
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
return ToolResult.Fail($"Invalid pattern '{muster}': {ex.Message}");
|
|
}
|
|
|
|
var eintraege = pfade
|
|
.Select(e => new Verzeichniseintrag(
|
|
Path.GetFileName(e),
|
|
Directory.Exists(e),
|
|
Directory.Exists(e) ? 0 : new FileInfo(e).Length,
|
|
File.GetLastWriteTime(e)))
|
|
.ToList();
|
|
|
|
var nachName = string.Equals(GetOptionalString(input, "sort"), "name", StringComparison.OrdinalIgnoreCase);
|
|
var absteigend = GetOptionalString(input, "order") is { } o
|
|
? string.Equals(o, "desc", StringComparison.OrdinalIgnoreCase)
|
|
: !nachName; // newest first, names ascending
|
|
|
|
IEnumerable<Verzeichniseintrag> sortiert = nachName
|
|
? (absteigend
|
|
? eintraege.OrderByDescending(e => e.Name, StringComparer.OrdinalIgnoreCase)
|
|
: eintraege.OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase))
|
|
: (absteigend
|
|
? eintraege.OrderByDescending(e => e.Geaendert)
|
|
: eintraege.OrderBy(e => e.Geaendert));
|
|
|
|
var grenze = Math.Clamp(GetInt(input, "limit") ?? StandardEintraege, 1, MaxEintraege);
|
|
var gezeigt = sortiert.Take(grenze).ToList();
|
|
|
|
var relativeDirPath = Path.GetRelativePath(rootPath, path);
|
|
var ordnung = nachName
|
|
? (absteigend ? "name descending" : "name ascending")
|
|
: (absteigend ? "newest first" : "oldest first");
|
|
|
|
var text = new StringBuilder();
|
|
text.Append($"[{area.Name} Workspace] Directory: {area.Name}:/{(relativeDirPath == "." ? "" : relativeDirPath)}");
|
|
text.Append($" - {gezeigt.Count} of {eintraege.Count} entries, {ordnung}.");
|
|
if (!string.IsNullOrWhiteSpace(muster))
|
|
text.Append($" Pattern: {muster}.");
|
|
if (eintraege.Count > gezeigt.Count)
|
|
text.Append($"\n{eintraege.Count - gezeigt.Count} more not shown - narrow down with 'pattern' or set 'limit' (max {MaxEintraege}).");
|
|
text.Append("\nModified Size Name\n");
|
|
|
|
foreach (var e in gezeigt)
|
|
{
|
|
var groesse = e.IstVerzeichnis ? "<DIR>" : e.Groesse.ToString();
|
|
text.Append($"{e.Geaendert:yyyy-MM-dd HH:mm} {groesse,9} {e.Name}{(e.IstVerzeichnis ? "/" : "")}\n");
|
|
}
|
|
|
|
return await Task.FromResult(ToolResult.Ok(text.ToString().TrimEnd()));
|
|
}
|
|
|
|
private readonly record struct Verzeichniseintrag(string Name, bool IstVerzeichnis, long Groesse, DateTime Geaendert);
|
|
|
|
/// <summary>
|
|
/// Ein Muster ist ein Dateiname mit Platzhaltern, kein Pfad. <c>Directory.GetFiles</c>
|
|
/// wertet Trennzeichen im Muster aus — <c>pattern: "../*"</c> listet damit das
|
|
/// Elternverzeichnis, an der Pfadpruefung vorbei. Deshalb hier und nicht erst dort.
|
|
/// </summary>
|
|
private static string? MusterFehler(string? muster)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(muster)) return null;
|
|
|
|
if (muster.Contains('/') || muster.Contains('\\') || muster.Contains(".."))
|
|
return $"Invalid pattern '{muster}': a pattern is a file name with wildcards "
|
|
+ "(e.g. 'berlin_2026-09-21_*.txt'), not a path. The directory belongs in 'path'.";
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string? GetOptionalString(JsonElement input, string name)
|
|
=> input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
|
|
|
private static int? GetInt(JsonElement input, string name)
|
|
=> input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i) ? i : null;
|
|
|
|
private async Task<ToolResult> HandleDeleteAsync(JsonElement input, string rootPath, Area area, AgentToolContext context, CancellationToken ct)
|
|
{
|
|
var path = GetAndValidatePath(input, rootPath, area, false, context);
|
|
|
|
// Schutz: In geschützten Bereichen ist Löschen komplett verboten (außer Admin)
|
|
if (!CanOverrideProtection(area) && IsPathProtected(path, rootPath, area))
|
|
{
|
|
var relPath = Path.GetRelativePath(rootPath, path);
|
|
return ToolResult.Fail(
|
|
$"🛡️ Protected area: '{relPath}' is in a protected directory and cannot be deleted. " +
|
|
$"Data in protected areas is append-only — it can only be added, not removed.");
|
|
}
|
|
|
|
var fileLock = GetFileLock(path);
|
|
await fileLock.WaitAsync(ct);
|
|
try
|
|
{
|
|
if (File.Exists(path))
|
|
{
|
|
var change = await BeforeChangeAsync(path, area, context, ct);
|
|
File.Delete(path);
|
|
await AfterChangeAsync(change, FileAction.Delete, context, ct);
|
|
return ToolResult.Ok($"[{area.Name} Workspace] File deleted: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
|
|
}
|
|
else if (Directory.Exists(path))
|
|
{
|
|
// #719: every file in it is a deletion of its own in the history.
|
|
var changes = new List<FileChange?>();
|
|
foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
|
|
changes.Add(await BeforeChangeAsync(file, area, context, ct));
|
|
Directory.Delete(path, true);
|
|
foreach (var change in changes)
|
|
await AfterChangeAsync(change, FileAction.Delete, context, ct);
|
|
return ToolResult.Ok($"[{area.Name} Workspace] Directory deleted: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
|
|
}
|
|
|
|
return ToolResult.Fail($"File or directory not found in {area.Name} workspace.");
|
|
}
|
|
finally
|
|
{
|
|
fileLock.Release();
|
|
}
|
|
}
|
|
|
|
private async Task<ToolResult> HandleCopyAsync(JsonElement input, string sourceRootPath, Area sourceArea, AgentToolContext context, CancellationToken ct)
|
|
{
|
|
var sourcePath = GetAndValidatePath(input, sourceRootPath, sourceArea, false, context);
|
|
|
|
var requestedDestination = input.TryGetProperty("destinationWorkspace", out var dw) && dw.ValueKind == JsonValueKind.String
|
|
? dw.GetString()
|
|
: null;
|
|
var destRelPath = input.TryGetProperty("destinationPath", out var dp) ? dp.GetString() : null;
|
|
|
|
if (string.IsNullOrWhiteSpace(destRelPath))
|
|
return ToolResult.Fail("'destinationPath' is required for copy action.");
|
|
|
|
var destArea = requestedDestination is null ? sourceArea : ResolveArea(requestedDestination, context);
|
|
if (destArea is null)
|
|
return ToolResult.Fail(UnknownWorkspace(requestedDestination!, context));
|
|
if (string.IsNullOrWhiteSpace(destArea.Root))
|
|
return ToolResult.Fail($"Configuration error: the folder of workspace '{destArea.Name}' is not defined.");
|
|
|
|
var destRootPath = Path.GetFullPath(destArea.Root);
|
|
|
|
if (!IsActionAllowed("write", destArea))
|
|
return ToolResult.Fail($"Access denied: Writing to workspace '{destArea.Name}' is not allowed for this agent.");
|
|
|
|
var destInput = JsonDocument.Parse(JsonSerializer.Serialize(new { path = destRelPath })).RootElement;
|
|
var destPath = GetAndValidatePath(destInput, destRootPath, destArea, false, context);
|
|
|
|
if (File.Exists(sourcePath))
|
|
{
|
|
// Schutz: Kein Überschreiben in geschützten Bereichen per Copy
|
|
if (!CanOverrideProtection(destArea) && File.Exists(destPath) && IsPathProtected(destPath, destRootPath, destArea))
|
|
{
|
|
var relPath = Path.GetRelativePath(destRootPath, destPath);
|
|
return ToolResult.Fail(
|
|
$"🛡️ Protected area: Destination file '{relPath}' already exists and cannot be overwritten.");
|
|
}
|
|
|
|
// Deadlock-sicheres Locking: Immer in alphabetischer Reihenfolge locken
|
|
var srcNorm = NormalizePath(sourcePath);
|
|
var dstNorm = NormalizePath(destPath);
|
|
var first = string.Compare(srcNorm, dstNorm, StringComparison.Ordinal) <= 0
|
|
? GetFileLock(sourcePath) : GetFileLock(destPath);
|
|
var second = string.Compare(srcNorm, dstNorm, StringComparison.Ordinal) <= 0
|
|
? GetFileLock(destPath) : GetFileLock(sourcePath);
|
|
|
|
await first.WaitAsync(ct);
|
|
try
|
|
{
|
|
await second.WaitAsync(ct);
|
|
try
|
|
{
|
|
// #720: copying over a file replaces it — the same check as a write.
|
|
if (await RefuseLostUpdateAsync(destPath, $"{destArea.Name}:/{Path.GetRelativePath(destRootPath, destPath).Replace('\\', '/')}", destArea, context, ct) is { } conflict)
|
|
return conflict;
|
|
|
|
var destDir = Path.GetDirectoryName(destPath);
|
|
if (destDir != null && !Directory.Exists(destDir)) Directory.CreateDirectory(destDir);
|
|
var change = await BeforeChangeAsync(destPath, destArea, context, ct);
|
|
|
|
await Task.Run(() => File.Copy(sourcePath, destPath, overwrite: !IsPathProtected(destPath, destRootPath, destArea)), ct);
|
|
NoteReplace(destPath, destArea, context);
|
|
await AfterChangeAsync(change, FileAction.Copy, context, ct);
|
|
|
|
var srcLabel = $"{sourceArea.Name}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}";
|
|
var dstLabel = $"{destArea.Name}:/{Path.GetRelativePath(destRootPath, destPath)}";
|
|
return ToolResult.Ok($"File copied: {srcLabel} → {dstLabel}");
|
|
}
|
|
finally
|
|
{
|
|
second.Release();
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
first.Release();
|
|
}
|
|
}
|
|
else if (Directory.Exists(sourcePath))
|
|
{
|
|
// #720/#719: each file the copy would replace is checked and recorded like a single copy.
|
|
var targets = Directory.EnumerateFiles(sourcePath, "*", SearchOption.AllDirectories)
|
|
.Select(f => Path.Combine(destPath, Path.GetRelativePath(sourcePath, f)))
|
|
.ToList();
|
|
foreach (var target in targets)
|
|
{
|
|
var shown = $"{destArea.Name}:/{Path.GetRelativePath(destRootPath, target).Replace('\\', '/')}";
|
|
if (await RefuseLostUpdateAsync(target, shown, destArea, context, ct) is { } conflict)
|
|
return conflict;
|
|
}
|
|
var changes = new List<FileChange?>();
|
|
foreach (var target in targets)
|
|
changes.Add(await BeforeChangeAsync(target, destArea, context, ct));
|
|
|
|
await Task.Run(() => CopyDirectory(sourcePath, destPath), ct);
|
|
|
|
foreach (var (target, change) in targets.Zip(changes))
|
|
{
|
|
NoteReplace(target, destArea, context);
|
|
await AfterChangeAsync(change, FileAction.Copy, context, ct);
|
|
}
|
|
|
|
var srcLabel = $"{sourceArea.Name}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}";
|
|
var dstLabel = $"{destArea.Name}:/{Path.GetRelativePath(destRootPath, destPath)}";
|
|
return ToolResult.Ok($"Directory copied: {srcLabel} → {dstLabel}");
|
|
}
|
|
|
|
return ToolResult.Fail("Source file or directory not found.");
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════
|
|
// STOCK DATABASE (Append-Only Wissensdatenbank)
|
|
// ═══════════════════════════════════════════════════
|
|
|
|
private static readonly JsonSerializerOptions StockJsonOpts = new()
|
|
{
|
|
WriteIndented = true,
|
|
PropertyNameCaseInsensitive = true,
|
|
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
|
};
|
|
|
|
private async Task<ToolResult> HandleStockAddAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
|
|
{
|
|
// Pflichtfelder
|
|
var ticker = (input.TryGetProperty("ticker", out var t) ? t.GetString() : null)?.Trim().ToUpperInvariant();
|
|
if (string.IsNullOrWhiteSpace(ticker))
|
|
return ToolResult.Fail("'ticker' is required for stock_add (e.g. 'NVDA', 'TSLA').");
|
|
|
|
var category = (input.TryGetProperty("category", out var cat) ? cat.GetString() : null) ?? "other";
|
|
var source = (input.TryGetProperty("source", out var src) ? src.GetString() : null) ?? "unknown";
|
|
var title = (input.TryGetProperty("title", out var ttl) ? ttl.GetString() : null) ?? "";
|
|
var content = input.TryGetProperty("content", out var cnt) ? cnt.GetString() : null;
|
|
var data = input.TryGetProperty("data", out var d) ? d : (JsonElement?)null;
|
|
|
|
if (string.IsNullOrWhiteSpace(title) && string.IsNullOrWhiteSpace(content) && data is null)
|
|
return ToolResult.Fail("At least 'title', 'content', or 'data' must be specified.");
|
|
|
|
// SharedWorkspace ist Pflicht für stocks
|
|
var sharedRoot = context.SharedWorkspacePath;
|
|
if (string.IsNullOrWhiteSpace(sharedRoot))
|
|
return ToolResult.Fail("SharedWorkspace is not configured.");
|
|
|
|
sharedRoot = Path.GetFullPath(sharedRoot);
|
|
|
|
// Zugriffsprüfung
|
|
if (ResolveArea(WorkspaceLayout.Shared, context) is not { } shared || !IsActionAllowed("stock_add", shared))
|
|
return ToolResult.Fail("Access denied: Writing to SharedWorkspace is not allowed.");
|
|
|
|
// Sanitize Ticker für Verzeichnisname
|
|
var safeTicker = SanitizeFileName(ticker);
|
|
var stockDir = Path.Combine(sharedRoot, "stocks", safeTicker);
|
|
Directory.CreateDirectory(stockDir);
|
|
|
|
// Timestamp-basierten Dateinamen generieren
|
|
var now = DateTime.UtcNow;
|
|
var timestamp = now.ToString("yyyyMMdd_HHmmss");
|
|
var safeSource = SanitizeFileName(source);
|
|
var fileName = $"{timestamp}_{category}_{safeSource}.json";
|
|
var filePath = Path.Combine(stockDir, fileName);
|
|
|
|
// Kollisionsvermeidung
|
|
var counter = 1;
|
|
while (File.Exists(filePath))
|
|
{
|
|
fileName = $"{timestamp}_{category}_{safeSource}_{counter}.json";
|
|
filePath = Path.Combine(stockDir, fileName);
|
|
counter++;
|
|
}
|
|
|
|
// Datenpunkt erstellen
|
|
var entry = new Dictionary<string, object?>
|
|
{
|
|
["id"] = $"{safeTicker}_{timestamp}_{category}_{safeSource}",
|
|
["ticker"] = ticker,
|
|
["category"] = category,
|
|
["source"] = source,
|
|
["title"] = title,
|
|
["timestamp"] = now.ToString("o"),
|
|
["agent"] = context.AgentId
|
|
};
|
|
|
|
if (!string.IsNullOrWhiteSpace(content))
|
|
entry["content"] = content;
|
|
|
|
if (data.HasValue)
|
|
entry["data"] = data.Value;
|
|
|
|
// Datenpunkt speichern
|
|
var json = JsonSerializer.Serialize(entry, StockJsonOpts);
|
|
var stockArea = new Area(WorkspaceLayout.Shared, sharedRoot, WorkspaceAccess.Write, []);
|
|
var dataChange = await BeforeChangeAsync(filePath, stockArea, context, ct);
|
|
await ClawdDotNet.Core.Storage.AtomicFile.WriteAllTextAsync(filePath, json, new UTF8Encoding(false), ct);
|
|
await AfterChangeAsync(dataChange, FileAction.Create, context, ct);
|
|
|
|
// _index.json aktualisieren (thread-safe via per-file SemaphoreSlim, append-only)
|
|
var indexPath = Path.Combine(stockDir, "_index.json");
|
|
var indexEntry = new Dictionary<string, object?>
|
|
{
|
|
["file"] = fileName,
|
|
["category"] = category,
|
|
["source"] = source,
|
|
["title"] = title,
|
|
["timestamp"] = now.ToString("o"),
|
|
["agent"] = context.AgentId
|
|
};
|
|
|
|
int indexCount;
|
|
|
|
var indexLock = GetFileLock(indexPath);
|
|
await indexLock.WaitAsync(ct);
|
|
try
|
|
{
|
|
List<Dictionary<string, object?>> index;
|
|
if (File.Exists(indexPath))
|
|
{
|
|
try
|
|
{
|
|
var existing = await File.ReadAllTextAsync(indexPath, ct);
|
|
index = JsonSerializer.Deserialize<List<Dictionary<string, object?>>>(existing, StockJsonOpts)
|
|
?? [];
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
// Korrupter Index: Backup + Neustart
|
|
var backup = indexPath + $".bak_{now:yyyyMMdd_HHmmss}";
|
|
try { File.Copy(indexPath, backup, overwrite: true); } catch { /* best effort */ }
|
|
index = [];
|
|
}
|
|
}
|
|
else
|
|
{
|
|
index = [];
|
|
}
|
|
|
|
index.Add(indexEntry);
|
|
var indexChange = await BeforeChangeAsync(indexPath, stockArea, context, ct);
|
|
// Atomar: Der Index ist die einzige Übersicht über die Datenpunkte —
|
|
// eine halb geschriebene Datei wäre nicht wiederherstellbar.
|
|
await ClawdDotNet.Core.Storage.AtomicFile.WriteAllTextAsync(
|
|
indexPath, JsonSerializer.Serialize(index, StockJsonOpts), ct: ct);
|
|
await AfterChangeAsync(indexChange, FileAction.Append, context, ct);
|
|
|
|
// Den Stand hier festhalten, solange die Sperre noch gilt (B9). Vorher
|
|
// wurde die Datei nach dem Freigeben ein zweites Mal gelesen und geparst:
|
|
// ein paralleler stock_add konnte dazwischenschreiben, und die gemeldete
|
|
// Zahl gehörte dann zu einem anderen Aufruf als die gemeldete Datei.
|
|
// Innerhalb der Sperre ist der Zähler ohnehin bekannt.
|
|
indexCount = index.Count;
|
|
}
|
|
finally
|
|
{
|
|
indexLock.Release();
|
|
}
|
|
|
|
var relPath = $"shared:/stocks/{safeTicker}/{fileName}";
|
|
return ToolResult.Ok(
|
|
$"✅ Data point added: {relPath}\n" +
|
|
$"Ticker: {ticker} | Category: {category} | Source: {source}\n" +
|
|
$"Title: {title}\n" +
|
|
$"Index: {indexCount} entries for {ticker}");
|
|
}
|
|
|
|
private static string SanitizeFileName(string name)
|
|
{
|
|
var sanitized = name.Trim();
|
|
foreach (var c in Path.GetInvalidFileNameChars())
|
|
sanitized = sanitized.Replace(c, '_');
|
|
return sanitized.Replace(' ', '_');
|
|
}
|
|
|
|
private static void CopyDirectory(string sourceDir, string destinationDir)
|
|
{
|
|
Directory.CreateDirectory(destinationDir);
|
|
|
|
foreach (var file in Directory.GetFiles(sourceDir))
|
|
File.Copy(file, Path.Combine(destinationDir, Path.GetFileName(file)), overwrite: true);
|
|
|
|
foreach (var dir in Directory.GetDirectories(sourceDir))
|
|
CopyDirectory(dir, Path.Combine(destinationDir, Path.GetFileName(dir)));
|
|
}
|
|
}
|