Files
ClawdDotNet/src/ClawdDotNet.Tools.WebFetch/WebFetchTool.cs
T
RichardandClaude Opus 5 cffd6f4b77 Bugtracker-Durchgang: 13 Bugs behoben, Build ohne Warnungen
Alle Behebungen auf der Linux-Test-VM gegengeprueft (Ubuntu, .NET 10.0.111):
Build 0 Warnungen / 0 Fehler, 637 Tests gruen, 1 uebersprungen (WindowsFact).

Sicherheit und Zielplattform
  #373 PathBoundary.Canonicalize fiel offen aus. File.ResolveLinkTarget wirft
       fuer einen fehlenden Abschnitt FileNotFoundException; der catch(IOException)
       verliess daraufhin die GANZE Aufloesung mit dem unaufgeloesten Pfad - und
       der besteht jede Praefixpruefung. Der Symlink-Schutz war damit bei jedem
       Schreibvorgang wirkungslos. Jetzt ueber FileSystemInfo.LinkTarget, und
       TryCanonicalize meldet "nicht bestimmbar", statt fail-open durchzureichen.
  #374 WorkspacePath weist den Backslash rundheraus ab, wie den Doppelpunkt.
       Eine Schreibweise statt einer Sonderregel je Windows-Form.
  #375 Log-Verzeichnis: leer heisst "Anwendungseinstellung"; ein relativer Wert
       zaehlt gegen das Instanzverzeichnis, nicht gegen das Arbeitsverzeichnis
       des Prozesses. Ein systemd-Dienst landet nicht mehr auf /Logs.

Agentenkommunikation
  #206 Delegationskette gegen A->B->A (Test A13). Zyklusfrei per Bauart, dazu
       MaxDelegationDepth gegen die Staffel, die niemand beauftragt hat.
  #461 Zwei unabhaengige Ketten ueber Kreuz - beim Beheben von #206 gefunden und
       reproduziert. Ein delegierter Lauf wartet nur noch begrenzt auf ein fremdes
       Gate; ein Chat vom Menschen reiht sich weiter unbegrenzt ein.
  #234 Instanz-Id wird durchgereicht statt aus einem nur beilaeufig gesetzten Feld
       gelesen.

Betrieb und Oberflaeche
  #376 Log-Ansicht chronologisch statt gruppiert je Moduldatei, mit Stacktraces
       an ihrer Meldung - auch ueber Taktgrenzen hinweg.
  #377 Info-Tabelle: feste Spaltenbreiten, damit ein langer Aenderungstext den
       anderen Spalten nicht den Platz nimmt.
  #378 Sicherung: Mindestabstand zwischen Ueberschrift und Schaltflaechenleiste.
  #379 Hellmodus: drei von vier gedaempften Textpinseln lagen unter 4,5:1. Die
       helle Palette ist als Ganzes verschoben, damit die Staffelung sichtbar
       bleibt statt auf einen Wert zusammenzufallen.

Hygiene
  #233 stock_add: der Indexstand wird innerhalb der Sperre festgehalten.
  #235 Zeitumstellung: eine nicht existierende Ortszeit wird auf den ersten
       Zeitpunkt nach der Luecke verschoben statt uebersprungen (R4, R5).
  #236 45 Nullable-Warnungen und eine CA1416 - nicht unterdrueckt, sondern die
       Stellen so umgebaut, dass die Zusage nachvollziehbar wird. Mail meldet
       jetzt, WELCHER Zugangsschluessel fehlt, statt an einer
       KeyNotFoundException zu scheitern.
  #203 Kompaktiert wird vor dem API-Aufruf statt danach; die gemessenen
       Prompt-Groessen kalibrieren die Schaetzung je Modell.

Neu: tests/ClawdDotNet.App.Tests - fuer #375 und #376 gab es keinen Ort fuer
Tests. Dazu Regressionstests fuer jede Behebung; jeder wurde gegen den alten
Stand geprueft, damit belegt ist, dass er den Fehler faengt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:04:48 +02:00

305 lines
12 KiB
C#

using System.Net;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using ClawdDotNet.Core.Tools;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Tools.WebFetch;
public sealed class WebFetchTool : IAgentTool
{
public string Name => "WebFetch";
public string Description => """
Ruft statische Webseiten oder RSS-Feeds ab und extrahiert Text + Timestamps.
Nur Domains aus der Whitelist erlaubt. Kein JavaScript-Rendering.
Aktionen: fetch, rss
""";
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
{
"type": "object",
"required": ["action", "url"],
"properties": {
"action": {
"type": "string",
"enum": ["fetch", "rss"],
"description": "fetch=HTML-Seite abrufen und zu Text konvertieren, rss=RSS/Atom-Feed parsen"
},
"url": { "type": "string" },
"selector":{ "type": "string",
"description": "optional: CSS-ähnlicher Hint welcher Teil relevant ist, z.B. 'table', 'article'" }
}
}
""").RootElement.Clone();
public async Task<ToolResult> ExecuteAsync(
JsonElement input,
AgentToolContext context,
CancellationToken ct)
{
try
{
var url = input.GetProperty("url").GetString()!;
var action = input.GetProperty("action").GetString()!;
IReadOnlyDictionary<string, object?>? config = context.ToolConfig.TryGetValue("WebFetch", out var c) && c is JsonElement je
? JsonSerializer.Deserialize<Dictionary<string, object?>>(je.GetRawText())
: context.ToolConfig;
if (config == null) return ToolResult.Fail("WebFetch Konfiguration fehlt.");
var allowedDomains = config.GetValueOrDefault("allowedDomains") is JsonElement ad
? ad.EnumerateArray().Select(x => x.GetString()!).ToList()
: (config.GetValueOrDefault("allowedDomains") as IEnumerable<string>)?.ToList() ?? new List<string>();
// Adresse prüfen: Schema, privates Netz, Whitelist
if (!UrlGuard.TryValidate(url, allowedDomains, out _, out var urlError))
return ToolResult.Fail(urlError!);
return action switch
{
"fetch" => await FetchPageAsync(url, allowedDomains, config, ct),
"rss" => await FetchRssAsync(url, allowedDomains, ct),
_ => ToolResult.Fail($"Unbekannte Aktion: {action}")
};
}
catch (Exception ex)
{
context.Logger.LogError(ex, "Fehler in WebFetch");
return ToolResult.Fail($"Fehler: {ex.Message}");
}
}
private async Task<ToolResult> FetchPageAsync(
string url, IReadOnlyCollection<string> allowedDomains,
IReadOnlyDictionary<string, object?> config, CancellationToken ct)
{
using var http = CreateHttpClient(config);
var fetchedAt = DateTime.UtcNow;
var (response, redirectError) = await GetFollowingRedirectsAsync(http, url, allowedDomains, ct);
if (redirectError is not null)
return redirectError;
// response ist genau dann gesetzt, wenn keine Absage kam - dem Tupel sieht der
// Compiler das nicht an. Die Pruefung sagt dasselbe nachvollziehbar und ersetzt
// das Ausrufezeichen, das hier stand.
if (response is null)
return ToolResult.Fail($"Keine Antwort von {url}.");
using var _ = response;
if (!response.IsSuccessStatusCode)
return ToolResult.Fail($"Seite nicht erreichbar: {url} → HTTP {(int)response.StatusCode} {response.ReasonPhrase}");
DateTime? dataAsOf = response.Content.Headers.LastModified?.UtcDateTime
?? response.Headers.Date?.UtcDateTime;
var html = await response.Content.ReadAsStringAsync(ct);
var maxKb = GetConfigInt(config, "maxResponseKb", 512);
if (html.Length > maxKb * 1024) html = html[..(maxKb * 1024)];
var text = StripHtml(html);
if (dataAsOf == null) dataAsOf = ExtractDateFromHtml(html);
var result = new
{
fetchedAt = fetchedAt,
dataAsOf = dataAsOf,
source = url,
data = new { text }
};
return ToolResult.Ok(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
}
private async Task<ToolResult> FetchRssAsync(
string url, IReadOnlyCollection<string> allowedDomains, CancellationToken ct)
{
using var http = CreateHttpClient(new Dictionary<string, object?>());
var fetchedAt = DateTime.UtcNow;
var (response, redirectError) = await GetFollowingRedirectsAsync(http, url, allowedDomains, ct);
if (redirectError is not null)
return redirectError;
// response ist genau dann gesetzt, wenn keine Absage kam - dem Tupel sieht der
// Compiler das nicht an. Die Pruefung sagt dasselbe nachvollziehbar und ersetzt
// das Ausrufezeichen, das hier stand.
if (response is null)
return ToolResult.Fail($"Keine Antwort von {url}.");
using var _ = response;
if (!response.IsSuccessStatusCode)
return ToolResult.Fail($"RSS-Feed nicht erreichbar: {url} → HTTP {(int)response.StatusCode} {response.ReasonPhrase}");
var xml = await response.Content.ReadAsStringAsync(ct);
var doc = XDocument.Parse(xml);
var items = new List<object>();
// Support RSS and Atom.
// Ohne Wurzelelement gibt es keinen Namensraum — dann gilt der leere. Vorher
// stand das Ausweichen nur an der ersten von fünf Stellen, die übrigen vier
// rechneten mit einem möglicherweise fehlenden Namensraum weiter.
var ns = doc.Root?.Name.Namespace ?? XNamespace.None;
if (doc.Root?.Name.LocalName == "rss")
{
foreach (var item in doc.Descendants("item").Take(20))
{
items.Add(new
{
title = item.Element("title")?.Value,
link = item.Element("link")?.Value,
pubDate = item.Element("pubDate")?.Value,
description = StripHtml(item.Element("description")?.Value ?? "")
});
}
}
else // Atom
{
foreach (var entry in doc.Descendants(ns + "entry").Take(20))
{
items.Add(new
{
title = entry.Element(ns + "title")?.Value,
link = entry.Element(ns + "link")?.Attribute("href")?.Value,
pubDate = entry.Element(ns + "updated")?.Value ?? entry.Element(ns + "published")?.Value,
description = StripHtml(entry.Element(ns + "summary")?.Value ?? entry.Element(ns + "content")?.Value ?? "")
});
}
}
var result = new
{
fetchedAt = fetchedAt,
dataAsOf = fetchedAt, // RSS current state
source = url,
data = new { items }
};
return ToolResult.Ok(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
}
private static string StripHtml(string html)
{
if (string.IsNullOrWhiteSpace(html)) return "";
// Script und Style entfernen
html = Regex.Replace(html, "<script.*?>.*?</script>", "", RegexOptions.Singleline | RegexOptions.IgnoreCase);
html = Regex.Replace(html, "<style.*?>.*?</style>", "", RegexOptions.Singleline | RegexOptions.IgnoreCase);
// Alle anderen Tags entfernen
html = Regex.Replace(html, "<.*?>", " ", RegexOptions.Singleline);
// Entities dekodieren
html = WebUtility.HtmlDecode(html);
// Whitespace säubern
html = Regex.Replace(html, @"\s+", " ");
html = html.Replace(" \n ", "\n").Replace(" \r ", "\r").Trim();
return html;
}
private static DateTime? ExtractDateFromHtml(string html)
{
// Suche nach meta-Tags
var patterns = new[]
{
"<meta.*?property=\"article:published_time\".*?content=\"(.*?)\"",
"<meta.*?name=\"date\".*?content=\"(.*?)\"",
"<time.*?datetime=\"(.*?)\""
};
foreach (var pattern in patterns)
{
var match = Regex.Match(html, pattern, RegexOptions.IgnoreCase);
if (match.Success && DateTime.TryParse(match.Groups[1].Value, out var dt))
return dt.ToUniversalTime();
}
return null;
}
private const int MaxRedirects = 5;
/// <summary>
/// Folgt Weiterleitungen selbst und prüft jedes Ziel erneut gegen Whitelist und
/// Netzsperren. HttpClient würde sonst ungefragt bis ins lokale Netz folgen.
/// </summary>
private static async Task<(HttpResponseMessage? Response, ToolResult? Error)> GetFollowingRedirectsAsync(
HttpClient http, string url, IReadOnlyCollection<string> allowedDomains, CancellationToken ct)
{
var current = url;
for (var hop = 0; hop <= MaxRedirects; hop++)
{
if (!UrlGuard.TryValidate(current, allowedDomains, out var uri, out var error))
{
var context = hop == 0 ? "" : $" (Weiterleitung {hop} von {url})";
return (null, ToolResult.Fail($"{error}{context}"));
}
var response = await http.GetAsync(uri, ct);
if (!IsRedirect(response.StatusCode))
return (response, null);
var location = response.Headers.Location;
response.Dispose();
if (location is null)
return (null, ToolResult.Fail($"Weiterleitung ohne Zieladresse bei {current}."));
// Relative Weiterleitungen gegen die aktuelle Adresse auflösen.
current = location.IsAbsoluteUri
? location.ToString()
: new Uri(uri!, location).ToString();
}
return (null, ToolResult.Fail(
$"Mehr als {MaxRedirects} Weiterleitungen ausgehend von {url} — abgebrochen."));
}
private static bool IsRedirect(HttpStatusCode status) => status is
HttpStatusCode.MovedPermanently or
HttpStatusCode.Found or
HttpStatusCode.SeeOther or
HttpStatusCode.TemporaryRedirect or
HttpStatusCode.PermanentRedirect;
private static HttpClient CreateHttpClient(IReadOnlyDictionary<string, object?> config)
{
// Weiterleitungen bewusst NICHT automatisch verfolgen — sie werden einzeln
// aufgelöst und geprüft.
var handler = new HttpClientHandler { AllowAutoRedirect = false };
var client = new HttpClient(handler, disposeHandler: true);
var timeout = GetConfigInt(config, "timeoutSeconds", 15);
client.Timeout = TimeSpan.FromSeconds(timeout);
client.DefaultRequestHeaders.UserAgent.ParseAdd(
config.GetValueOrDefault("userAgent")?.ToString() ?? "ClawdDotNet-Agent/1.0");
client.DefaultRequestHeaders.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue { NoCache = true, NoStore = true };
return client;
}
/// <summary>
/// Sicheres Lesen eines int-Werts aus der Config (funktioniert mit JsonElement und primitiven Typen).
/// </summary>
private static int GetConfigInt(IReadOnlyDictionary<string, object?> config, string key, int defaultValue)
{
var val = config.GetValueOrDefault(key);
return val switch
{
null => defaultValue,
int i => i,
long l => (int)l,
System.Text.Json.JsonElement je when je.ValueKind == System.Text.Json.JsonValueKind.Number => je.GetInt32(),
_ => int.TryParse(val.ToString(), out var parsed) ? parsed : defaultValue
};
}
}