Befunde aus dem Testbetrieb auf der Linux-VM behoben (#572)
- #578: Abgebrochene Laeufe (Schrittgrenze, Zeitueberschreitung, Abbruch, Ausnahme) tragen ihre bezahlten Token - vorher 0 in RunUsage, fuer das Budget unsichtbar. - #580: WebFetch prueft die aufgeloeste Zieladresse beim Verbinden; ein erlaubter Name, der auf 127.0.0.1 aufloest, wird gesperrt. - #575: Dateien entstehen unter Unix mit 0600 (AtomicFile, Protokolle, FileRW-Anhaengen, state.db), Protokollverzeichnisse mit 0700. - #576: SIGTERM/SIGINT beenden auch mit Fenster geordnet. - #593: OpenRouter-Statusdienst wird gestartet. - #589: Auftragsnachricht nennt aktuelle Zeit, Zeitzone und Termin. - #581: Unvollstaendiges Argument-JSON erreicht das Werkzeug nicht mehr; das Modell bekommt eine verstaendliche Rueckmeldung statt Parsertext. - #591: Weboberflaeche liest die Felder, die API und Ereignisstrom liefern (Freigaben, Protokoll, Taskboard mit Termin, Werkzeug-Einstellungen, Tokennutzung); Ereignisstrom in camelCase. - #592: Chat zeigt Nachrichten nicht mehr doppelt. - #536 (teilweise): A2A-Antworten tragen Absender und Empfaenger, Parser wirft nicht mehr; Datums- und Volltextfilter in Agenten-Chats. - deploy/publish.py: Packager-Pfad net10.0, Schalter --agent-file. - Version 0.1.4. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// Regressionstests fuer die Befunde aus dem Testbetrieb auf der Linux-VM (#572).
|
||||
/// </summary>
|
||||
public sealed class TestbetriebRegressionTests
|
||||
{
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// #578 — Abgebrochene Laeufe tragen ihre bezahlten Token
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_an_der_Schrittgrenze_meldet_die_bis_dahin_bezahlten_Token()
|
||||
{
|
||||
var fixture = new EngineFixture().WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(1)));
|
||||
var agent = fixture.AddAgent("agent-grenze", "TestTool");
|
||||
agent.LoopGuard.MaxSteps = 3;
|
||||
|
||||
// Jede Antwort ist ein Werkzeugaufruf mit 100 Prompt- und 20 Completion-Token.
|
||||
fixture.Client.AlwaysRespondsWithToolCall("TestTool");
|
||||
|
||||
var result = await fixture.Engine.RunAsync(agent, "Arbeite", "test-instance", default);
|
||||
|
||||
result.Status.ShouldBe(AgentRunStatus.LoopLimitExceeded);
|
||||
fixture.Client.CallCount.ShouldBe(3);
|
||||
result.PromptTokens.ShouldBe(300, "drei bezahlte Aufrufe - vorher stand hier 0");
|
||||
result.CompletionTokens.ShouldBe(60);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChatAsync_an_der_Schrittgrenze_meldet_die_bis_dahin_bezahlten_Token()
|
||||
{
|
||||
var fixture = new EngineFixture().WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(1)));
|
||||
var agent = fixture.AddAgent("agent-chat-grenze", "TestTool");
|
||||
agent.LoopGuard.MaxSteps = 2;
|
||||
|
||||
fixture.Client.AlwaysRespondsWithToolCall("TestTool");
|
||||
|
||||
var result = await fixture.Engine.ChatAsync(agent, "Arbeite", "test-instance", default);
|
||||
|
||||
result.Status.ShouldBe(AgentRunStatus.LoopLimitExceeded);
|
||||
result.PromptTokens.ShouldBe(200);
|
||||
result.CompletionTokens.ShouldBe(40);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_mit_Fehler_des_Anbieters_meldet_die_bis_dahin_bezahlten_Token()
|
||||
{
|
||||
var fixture = new EngineFixture().WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(1)));
|
||||
var agent = fixture.AddAgent("agent-fehler", "TestTool");
|
||||
|
||||
fixture.Client
|
||||
.RespondsWithToolCall("TestTool")
|
||||
.Throws(new HttpRequestException("502 vom Anbieter"));
|
||||
|
||||
var result = await fixture.Engine.RunAsync(agent, "Arbeite", "test-instance", default);
|
||||
|
||||
result.Status.ShouldBe(AgentRunStatus.Failed);
|
||||
result.PromptTokens.ShouldBe(100);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// #581 — Unvollstaendiges Argument-JSON
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Theory]
|
||||
[InlineData("{\"action\": \"write\", \"workspace\": \"personal\", \"path\": \"gross.txt\"", false)]
|
||||
[InlineData("{\"action\": \"send_message\", \"targetAgentId\": \"netzpruefer\"", false)]
|
||||
[InlineData("{\"action\": \"list\"}", true)]
|
||||
public void IstVollstaendigesJson_erkennt_abgeschnittene_Argumente(string arguments, bool erwartet)
|
||||
=> AgentEngine.IstVollstaendigesJson(arguments).ShouldBe(erwartet);
|
||||
|
||||
[Fact]
|
||||
public async Task Abgeschnittene_Argumente_erreichen_das_Werkzeug_nicht_und_das_Modell_erfaehrt_warum()
|
||||
{
|
||||
var fixture = new EngineFixture().WithTool(FakeTool.Slow(TimeSpan.FromMilliseconds(1)));
|
||||
var agent = fixture.AddAgent("agent-abgeschnitten", "TestTool");
|
||||
|
||||
// Genau die Form aus dem Pruefstand: Das JSON bricht vor dem grossen Wert ab.
|
||||
fixture.Client
|
||||
.RespondsWithToolCall("TestTool", "{\"action\": \"write\", \"path\": \"gross.txt\"")
|
||||
.RespondsWithText("verstanden");
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "Schreibe viel", "test-instance", default);
|
||||
|
||||
var antwortAnsModell = fixture.Client.ReceivedRequests[1].Messages.Last();
|
||||
antwortAnsModell.Role.ShouldBe("tool");
|
||||
antwortAnsModell.Content.ShouldNotBeNull();
|
||||
antwortAnsModell.Content.ShouldContain("kein vollstaendiges JSON");
|
||||
antwortAnsModell.Content.ShouldContain("mehrere kleinere Aufrufe");
|
||||
antwortAnsModell.Content.ShouldNotContain("Expected depth");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// #589 — Zeit und Termin in der Auftragsnachricht
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public void Zeitangabe_nennt_Ortszeit_der_Aufgabe_UTC_und_Termin()
|
||||
{
|
||||
var task = new TaskItem
|
||||
{
|
||||
Id = "t-1",
|
||||
Title = "Wetter",
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "5 * * * *", TimeZone = "Europe/Berlin" }
|
||||
};
|
||||
|
||||
var text = EngineTaskDispatcher.Zeitangabe(task, new DateTimeOffset(2026, 9, 14, 13, 5, 0, TimeSpan.Zero));
|
||||
|
||||
text.ShouldContain("14.09.2026 15:05 Uhr");
|
||||
text.ShouldContain("Europe/Berlin");
|
||||
text.ShouldContain("2026-09-14T13:05Z");
|
||||
text.ShouldContain("cron \"5 * * * *\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Auftragsnachricht_enthaelt_die_Zeitangabe()
|
||||
{
|
||||
var fixture = new EngineFixture();
|
||||
var dispatcher = new EngineTaskDispatcher(
|
||||
fixture.Engine, () => fixture.Agents, "test-instance", fixture.Registry, fixture.StateStore,
|
||||
NullLoggerFactory.Instance, new FesteZeit(new DateTimeOffset(2026, 1, 5, 7, 15, 0, TimeSpan.Zero)));
|
||||
|
||||
var nachricht = dispatcher.BuildMessage(new TaskItem
|
||||
{
|
||||
Id = "t-2",
|
||||
Title = "Morgenbericht",
|
||||
Body = "Schreibe den Bericht.",
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "15 7 * * *", TimeZone = "Europe/Berlin" }
|
||||
});
|
||||
|
||||
nachricht.ShouldStartWith("[Aufgabe t-2] Morgenbericht");
|
||||
nachricht.ShouldContain("Aktuelle Zeit: 05.01.2026 08:15 Uhr (Europe/Berlin, UTC+01:00)");
|
||||
nachricht.ShouldContain("Schreibe den Bericht.");
|
||||
}
|
||||
|
||||
private sealed class FesteZeit(DateTimeOffset jetzt) : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => jetzt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// #575: Auf der Test-VM (umask 0002) ging InstanceSettings.json beim ersten Speichern von
|
||||
/// 0600 auf 0664, Protokolle entstanden mit 0664. Unter Windows nicht aussagekraeftig.
|
||||
/// </summary>
|
||||
public sealed class OwnerOnlyFileTests : IDisposable
|
||||
{
|
||||
private readonly string _dir = Path.Combine(Path.GetTempPath(), "clawd-owner-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
|
||||
public OwnerOnlyFileTests() => Directory.CreateDirectory(_dir);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { /* Aufraeumen ist best effort */ }
|
||||
}
|
||||
|
||||
private const UnixFileMode NurBesitzer = UnixFileMode.UserRead | UnixFileMode.UserWrite;
|
||||
|
||||
[LinuxFact]
|
||||
[System.Runtime.Versioning.SupportedOSPlatform("linux")]
|
||||
public void AtomicFile_ersetzt_eine_offene_Datei_durch_eine_nur_fuer_den_Besitzer()
|
||||
{
|
||||
var pfad = Path.Combine(_dir, "InstanceSettings.json");
|
||||
File.WriteAllText(pfad, "{}");
|
||||
File.SetUnixFileMode(pfad, NurBesitzer | UnixFileMode.GroupRead | UnixFileMode.OtherRead);
|
||||
|
||||
AtomicFile.WriteAllText(pfad, "{\"a\":1}");
|
||||
|
||||
File.GetUnixFileMode(pfad).ShouldBe(NurBesitzer);
|
||||
}
|
||||
|
||||
[LinuxFact]
|
||||
[System.Runtime.Versioning.SupportedOSPlatform("linux")]
|
||||
public void AtomicFile_legt_neue_Dateien_nur_fuer_den_Besitzer_an()
|
||||
{
|
||||
var pfad = Path.Combine(_dir, "neu.json");
|
||||
|
||||
AtomicFile.WriteAllText(pfad, "{}");
|
||||
|
||||
File.GetUnixFileMode(pfad).ShouldBe(NurBesitzer);
|
||||
}
|
||||
|
||||
[LinuxFact]
|
||||
[System.Runtime.Versioning.SupportedOSPlatform("linux")]
|
||||
public void OpenAppend_legt_neue_Dateien_nur_fuer_den_Besitzer_an()
|
||||
{
|
||||
var pfad = Path.Combine(_dir, "protokoll.log");
|
||||
|
||||
using (var stream = OwnerOnlyFile.OpenAppend(pfad))
|
||||
stream.WriteByte((byte)'x');
|
||||
|
||||
File.GetUnixFileMode(pfad).ShouldBe(NurBesitzer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenAppend_haengt_an_statt_zu_ueberschreiben()
|
||||
{
|
||||
var pfad = Path.Combine(_dir, "anhang.txt");
|
||||
|
||||
foreach (var teil in new[] { "eins", "zwei" })
|
||||
{
|
||||
using var stream = OwnerOnlyFile.OpenAppend(pfad);
|
||||
using var writer = new StreamWriter(stream);
|
||||
writer.Write(teil);
|
||||
}
|
||||
|
||||
File.ReadAllText(pfad).ShouldBe("einszwei");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using ClawdDotNet.Tools.WebFetch;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Tools.Tests.WebFetch;
|
||||
|
||||
/// <summary>
|
||||
/// #580: Die Pruefung der URL sieht nur den Namen. localtest.me steht auf keiner Sperrliste
|
||||
/// und loest auf 127.0.0.1 auf - im Pruefdurchgang oeffnete WebFetch damit eine Verbindung
|
||||
/// auf Loopback. Geprueft wird deshalb die aufgeloeste Adresse, ohne echtes Netz.
|
||||
/// </summary>
|
||||
public sealed class UrlGuardAufloesungTests
|
||||
{
|
||||
private static Func<string, CancellationToken, Task<IPAddress[]>> Aufloesung(params string[] adressen)
|
||||
=> (_, _) => Task.FromResult(adressen.Select(IPAddress.Parse).ToArray());
|
||||
|
||||
[Theory]
|
||||
[InlineData("127.0.0.1")]
|
||||
[InlineData("192.168.40.48")]
|
||||
[InlineData("169.254.169.254")]
|
||||
[InlineData("::1")]
|
||||
[InlineData("::ffff:10.0.0.5")]
|
||||
public async Task Ein_Name_der_ins_lokale_Netz_aufloest_wird_gesperrt(string adresse)
|
||||
{
|
||||
var ex = await Should.ThrowAsync<HttpRequestException>(
|
||||
() => UrlGuard.ResolveAllowedAsync("localtest.me", Aufloesung(adresse), default));
|
||||
|
||||
ex.Message.ShouldContain("localtest.me");
|
||||
ex.Message.ShouldContain("gesperrt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_Name_mit_oeffentlicher_und_interner_Adresse_wird_gesperrt()
|
||||
{
|
||||
// Genau das Muster, mit dem man eine Stichprobenpruefung ueberlistet.
|
||||
await Should.ThrowAsync<HttpRequestException>(
|
||||
() => UrlGuard.ResolveAllowedAsync("gemischt.example", Aufloesung("93.184.215.14", "127.0.0.1"), default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_Name_mit_oeffentlichen_Adressen_wird_zugelassen()
|
||||
{
|
||||
var adressen = await UrlGuard.ResolveAllowedAsync(
|
||||
"example.com", Aufloesung("93.184.215.14", "2606:2800:21f:cb07:6820:80da:af6b:8b2c"), default);
|
||||
|
||||
adressen.Length.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_IP_Literal_wird_ohne_Aufloesung_geprueft()
|
||||
{
|
||||
var aufgerufen = false;
|
||||
|
||||
await Should.ThrowAsync<HttpRequestException>(() => UrlGuard.ResolveAllowedAsync(
|
||||
"[::1]",
|
||||
(_, _) => { aufgerufen = true; return Task.FromResult(Array.Empty<IPAddress>()); },
|
||||
default));
|
||||
|
||||
aufgerufen.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_Name_ohne_Adresse_wird_nicht_verbunden()
|
||||
=> await Should.ThrowAsync<HttpRequestException>(
|
||||
() => UrlGuard.ResolveAllowedAsync("nirgends.example", Aufloesung(), default));
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.Staging;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using ClawdDotNet.Web.Contracts;
|
||||
using ClawdDotNet.Web.Endpoints;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Web.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Befunde aus der Sichtung der Weboberflaeche im Testbetrieb (#572): #591 (Feldnamen) und
|
||||
/// #536 (Richtung der A2A-Antworten).
|
||||
/// </summary>
|
||||
public sealed class TestbetriebWebTests
|
||||
{
|
||||
// ─── #591 Ereignisstrom und Uebertragungstypen ───
|
||||
|
||||
[Fact]
|
||||
public void Protokollzeilen_im_Ereignisstrom_kommen_in_camelCase()
|
||||
{
|
||||
var ereignis = EventEndpoints.Format("log", new LogLineDto("Startup", "INF", "ClawdDotNet startet"));
|
||||
|
||||
ereignis.ShouldStartWith("event: log\n");
|
||||
ereignis.ShouldContain("\"module\":\"Startup\"");
|
||||
ereignis.ShouldContain("\"level\":\"INF\"");
|
||||
ereignis.ShouldContain("\"text\":\"ClawdDotNet startet\"");
|
||||
ereignis.ShouldNotContain("\"Text\"", Case.Sensitive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Freigabe_traegt_ihren_Zeitpunkt()
|
||||
{
|
||||
var angelegt = new DateTime(2026, 9, 14, 10, 18, 53, DateTimeKind.Utc);
|
||||
|
||||
var dto = new StagedCall
|
||||
{
|
||||
Id = 1, AgentId = "hausmeister", Tool = "FileRW", Action = "delete",
|
||||
ArgumentsJson = "{\"path\":\"betrieb/probe-loeschung.txt\"}", CreatedAt = angelegt
|
||||
}.ToDto();
|
||||
|
||||
dto.CreatedAt.ShouldBe(angelegt);
|
||||
dto.Tool.ShouldBe("FileRW");
|
||||
dto.Action.ShouldBe("delete");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Aufgabe_traegt_ihren_Termin()
|
||||
{
|
||||
var dto = new TaskItem
|
||||
{
|
||||
Id = "t-a66932", Title = "Wetter Berlin stuendlich", Assignee = "@new:wetterdienst",
|
||||
When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "5 * * * *", TimeZone = "Europe/Berlin" }
|
||||
}.ToDto();
|
||||
|
||||
dto.When.ShouldNotBeNull();
|
||||
dto.When.Kind.ShouldBe("cron");
|
||||
dto.When.Value.ShouldBe("5 * * * *");
|
||||
dto.When.TimeZone.ShouldBe("Europe/Berlin");
|
||||
dto.Assignee.ShouldBe("@new:wetterdienst");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Einmalige_Aufgabe_hat_keinen_Termin()
|
||||
=> new TaskItem { Id = "t-1", Title = "Einrichtung" }.ToDto().When.ShouldBeNull();
|
||||
|
||||
// ─── #536 Absender und Empfaenger ───
|
||||
|
||||
private static ChatEntry Anfrage(string vonName, string vonId, string text, int minute)
|
||||
=> new("user", $"[Nachricht von Agent \"{vonName}\" ({vonId})]\n\n{text}",
|
||||
new DateTime(2026, 9, 14, 12, minute, 0), ChatSource.AgentComm);
|
||||
|
||||
private static ChatEntry Antwort(string text, int minute)
|
||||
=> new("assistant", text, new DateTime(2026, 9, 14, 12, minute, 30), ChatSource.AgentComm);
|
||||
|
||||
[Fact]
|
||||
public void Antwort_geht_vom_Antwortenden_an_den_Fragenden()
|
||||
{
|
||||
var liste = AgentCommunicationBuilder.Build(
|
||||
[
|
||||
("redaktion", "Redaktion", (IEnumerable<ChatEntry>)
|
||||
[
|
||||
Anfrage("Hausmeister", "hausmeister", "Testnachricht", 48),
|
||||
Antwort("Empfang bestaetigt", 48)
|
||||
])
|
||||
]);
|
||||
|
||||
liste.Count.ShouldBe(2);
|
||||
|
||||
var anfrage = liste[0];
|
||||
anfrage.SourceAgentId.ShouldBe("hausmeister");
|
||||
anfrage.TargetAgentId.ShouldBe("redaktion");
|
||||
anfrage.Content.ShouldBe("Testnachricht");
|
||||
|
||||
var antwort = liste[1];
|
||||
antwort.SourceAgentId.ShouldBe("redaktion");
|
||||
antwort.TargetAgentId.ShouldBe("hausmeister", "vorher stand hier der Antwortende selbst");
|
||||
antwort.TargetAgentName.ShouldBe("Hausmeister");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nur_Agentenkommunikation_wird_aufgenommen()
|
||||
{
|
||||
var liste = AgentCommunicationBuilder.Build(
|
||||
[
|
||||
("hausmeister", "Hausmeister", (IEnumerable<ChatEntry>)
|
||||
[
|
||||
new ChatEntry("user", "Hey, wie gehts?", DateTime.Now, ChatSource.WebUi),
|
||||
new ChatEntry("assistant", "Moin!", DateTime.Now, ChatSource.WebUi)
|
||||
])
|
||||
]);
|
||||
|
||||
liste.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("[Nachricht von Agent \"Hausmeister\" ohne Klammer]\n\nText")]
|
||||
[InlineData("[Nachricht von Agent \"Bo\"b\" (bob)]\n\nText")]
|
||||
[InlineData("[Nachricht von Agent \"")]
|
||||
public void Absender_lesen_wirft_bei_unerwarteter_Form_nicht(string inhalt)
|
||||
=> Should.NotThrow(() => AgentCommunicationBuilder.LeseAbsender(inhalt));
|
||||
|
||||
[Fact]
|
||||
public void Absender_lesen_liefert_Kennung_Name_und_Text()
|
||||
{
|
||||
var (id, name, text) = AgentCommunicationBuilder.LeseAbsender(
|
||||
"[Nachricht von Agent \"Hausmeister\" (hausmeister)]\n\nHallo Redaktion!");
|
||||
|
||||
id.ShouldBe("hausmeister");
|
||||
name.ShouldBe("Hausmeister");
|
||||
text.ShouldBe("Hallo Redaktion!");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user