Weboberflaeche hinzugefuegt und Fachlogik nach App herausgeloest (#493, #494)

- ClawdDotNet.Web: Kestrel im Prozess der Anwendung, Token-Absicherung (TokenGate mit FixedTimeEquals), Endpunkte unter /api, Ereignisstrom unter /events (Server-Sent Events) und fortlaufendes Log-Streaming.

- Weboberflaeche (wwwroot/): Vollstaendiges Single-Page-Frontend mit nativem HTML5, CSS3 und ES-Modulen ohne Buildschritt (strikt textContent, #-Routing, sofortiger Login-Sprung bei fehlendem Token, Light/Dark-Theme, lokale Barlow-Schriften).

- ClawdDotNet.App: Fachlogik aus den Avalonia-ViewModels in Dienste herausgeloest (AgentAdministration, JobAdministration, ServiceAdministration, RunCoordinator) und ueber InstanceChange synchronisiert.

- Tests: ClawdDotNet.Web.Tests und ClawdDotNet.App.Tests hinzugefuegt, alle 684 Tests erfolgreich.
This commit is contained in:
Richard
2026-09-07 21:56:02 +02:00
parent cffd6f4b77
commit 1e9cb789c8
64 changed files with 10543 additions and 554 deletions
@@ -159,6 +159,97 @@ public sealed class BackupServiceTests : IDisposable
SecretProtector.Unprotect(stored).ShouldBe(ApiKeyPlain);
}
/// <summary>
/// Der Umzug auf einen anderen Rechner — der Migrationspfad aus #190.
///
/// <para>Die Tests darüber sichern und stellen im selben Lauf wieder her und
/// benutzen dabei denselben Schlüssel. Sie können damit gar nicht zeigen, worauf es
/// beim Umzug ankommt: dass die Werte auch dann lesbar sind, wenn der Schlüssel der
/// Quellmaschine <b>nicht</b> mitkommt. Genau das ist der Fall Windows → Linux —
/// die bestehenden Werte sind DPAPI-verschlüsselt, und
/// <c>SecretProtector.UnprotectV1</c> wirft dafür auf Linux bewusst.</para>
///
/// <para>Hier wechselt der Schlüssel deshalb mitten im Test. Ohne den Wechsel wäre
/// der Test grün, ohne etwas zu belegen.</para>
/// </summary>
[Fact]
public async Task Der_Umzug_auf_einen_anderen_Rechner_traegt()
{
var zielMaschine = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
try
{
// Maschine 1: Werte liegen unter dem hiesigen Schlüssel.
await BuildInstanceAsync();
var schluesselDerQuelle = File.ReadAllBytes(SecretKeyStore.KeyFilePath);
await _service.CreateAsync(_instanceDir, ZipPath,
new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "umzug" });
// Maschine 2: anderer Rechner, anderer Schlüssel. Der Schlüssel reist nicht
// mit — er liegt bewusst außerhalb des Instanzverzeichnisses.
SecretKeyStore.UseDirectory(zielMaschine);
await _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions { Passphrase = "umzug" });
File.ReadAllBytes(SecretKeyStore.KeyFilePath)
.ShouldNotBe(schluesselDerQuelle, "sonst wäre es derselbe Rechner");
var settings = JsonDocument.Parse(
File.ReadAllText(Path.Combine(RestoreDir, "InstanceSettings.json")));
var stored = settings.RootElement.GetProperty("openRouterApiKey").GetString();
SecretProtector.Unprotect(stored).ShouldBe(ApiKeyPlain);
// Auch die verschachtelten Werte, nicht nur der offensichtliche oben.
var agent = JsonDocument.Parse(File.ReadAllText(
Path.Combine(RestoreDir, "Agents", "Agent-Analyst", "AgentSettings.json")));
var password = agent.RootElement
.GetProperty("tools").GetProperty("Mail").GetProperty("password").GetString();
SecretProtector.Unprotect(password).ShouldBe(MailPasswordPlain);
}
finally
{
// Ein eigenes Verzeichnis für den nächsten Test der Sammlung: Das der
// Quellmaschine steht hier nicht mehr zur Verfügung, und der echte
// Benutzerschlüssel darf es erst recht nicht sein.
SecretKeyStore.UseDirectory(
Path.Combine(Path.GetTempPath(), "clawd-tests", "keys", Guid.NewGuid().ToString("N")));
try { Directory.Delete(zielMaschine, recursive: true); } catch { /* Nebensache */ }
}
}
[Fact]
public async Task Ohne_die_Passphrase_ist_der_Umzug_verloren()
{
// Die Kehrseite, und der Grund, warum die Passphrase beim Sichern angeboten
// werden muss: Ohne sie ist ein Backup auf der Zielmaschine wertlos, was die
// Zugangsdaten angeht. Das ist kein Fehler, sondern der Preis dafür, dass der
// Schlüssel nicht mitreist — aber es muss auffallen, statt still zu geschehen.
var zielMaschine = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
try
{
await BuildInstanceAsync();
await _service.CreateAsync(_instanceDir, ZipPath,
new BackupOptions { Secrets = SecretMode.Passphrase, Passphrase = "umzug" });
SecretKeyStore.UseDirectory(zielMaschine);
await Should.ThrowAsync<Exception>(
() => _service.RestoreAsync(ZipPath, RestoreDir, new RestoreOptions { Passphrase = "falsch" }));
}
finally
{
SecretKeyStore.UseDirectory(
Path.Combine(Path.GetTempPath(), "clawd-tests", "keys", Guid.NewGuid().ToString("N")));
try { Directory.Delete(zielMaschine, recursive: true); } catch { /* Nebensache */ }
}
}
[Fact]
public async Task Auch_verschachtelte_Zugangsdaten_in_Tool_Konfigurationen_kommen_zurueck()
{