Stufe 1 (#764): Agent bearbeiten, Lizenz und Einstellungen in der WebUI

- PUT /api/agents/{id} und /texts: Stammdaten, Grenzen, Budget, Identity/Soul/
  Systemprompt (L6/L7); "Jetzt ausfuehren" in der Agentenansicht (L9)
- Lizenzschluessel ueber die Weboberflaeche (L1): erst vom Lizenzserver
  bestaetigt, dann gespeichert; hebt die Sperre ohne Neustart auf
- GET/PUT /api/settings: Instanz- und Anwendungseinstellungen inkl.
  OpenRouter-Key (L3/L4/L5), Geheimnisse nur schreibend
- GET/PUT /api/verwaltung/settings: Deploymentcenter und Update-Kanal des Managers
- Einstellungsseite zeigt echte Werte statt fester Texte
- NonInteractiveLicensePrompt verwies auf --license-set-key, den es nie gab
- Sprachpakete werden bei jedem Lauf geprueft (LanguagePackTests)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-02 13:30:20 +02:00
co-authored by Claude Opus 5.5
parent a9ce72abe2
commit 8552755bb6
20 changed files with 1677 additions and 322 deletions
@@ -0,0 +1,101 @@
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Config;
using Shouldly;
namespace ClawdDotNet.App.Tests.Services;
/// <summary>
/// #764: Editing an agent from the WebUI — what the desktop agent page did directly on the
/// configuration objects, now one checked call.
/// </summary>
public sealed class AgentUpdateTests
{
[Fact]
public void Changes_survive_a_restart_including_identity_and_soul()
{
// Identity and Soul live in their own files; written only into AgentSettings.json they
// would be overwritten by the next load.
using var host = new TestHost();
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "old-model")).Ok.ShouldBeTrue();
var result = host.Host.AgentAdmin.Update("scout", new AgentUpdate(
DisplayName: "Scout II", Model: "new-model", SystemPrompt: "Be brief.",
Identity: "# Identity: changed", Soul: "# Soul: changed", PromptCaching: "OFF",
MaxSteps: 42, DailyTokens: 12_345, DailyCostUsd: 1.5m, WhenExhausted: "catchupOnce"));
result.Ok.ShouldBeTrue(result.Message);
var reloaded = host.Host.Directories
.LoadInstanceConfig(host.Host.InstancePath).Agents
.Single(a => a.AgentId == "scout");
reloaded.DisplayName.ShouldBe("Scout II");
reloaded.Model.ShouldBe("new-model");
reloaded.SystemPrompt.ShouldBe("Be brief.");
reloaded.Identity.ShouldBe("# Identity: changed");
reloaded.Soul.ShouldBe("# Soul: changed");
reloaded.PromptCaching.ShouldBe("off");
reloaded.LoopGuard.MaxSteps.ShouldBe(42);
reloaded.Budget.DailyTokens.ShouldBe(12_345);
reloaded.Budget.DailyCostUsd.ShouldBe(1.5m);
reloaded.Budget.WhenExhausted.ShouldBe("catchUpOnce");
}
[Fact]
public void Fields_left_out_keep_their_value()
{
using var host = new TestHost();
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "model", "Watches the market")).Ok.ShouldBeTrue();
host.Host.AgentAdmin.Update("scout", new AgentUpdate(Identity: "keep me")).Ok.ShouldBeTrue();
host.Host.AgentAdmin.Update("scout", new AgentUpdate(Model: "other")).Ok.ShouldBeTrue();
var agent = host.Host.AgentAdmin.Find("scout")!;
agent.Model.ShouldBe("other");
agent.Description.ShouldBe("Watches the market");
agent.Identity.ShouldBe("keep me");
}
[Theory]
[InlineData("display")]
[InlineData("caching")]
[InlineData("exhausted")]
[InlineData("steps")]
[InlineData("budget")]
public void One_bad_value_changes_nothing(string bad)
{
using var host = new TestHost(TestHost.Agent("scout", "Scout"));
var update = bad switch
{
"display" => new AgentUpdate(Model: "new-model", DisplayName: " "),
"caching" => new AgentUpdate(Model: "new-model", PromptCaching: "sometimes"),
"exhausted" => new AgentUpdate(Model: "new-model", WhenExhausted: "panic"),
"steps" => new AgentUpdate(Model: "new-model", MaxSteps: 0),
_ => new AgentUpdate(Model: "new-model", DailyCostUsd: -1m)
};
host.Host.AgentAdmin.Update("scout", update).Ok.ShouldBeFalse();
host.Host.AgentAdmin.Find("scout")!.Model.ShouldBe("anthropic/claude-sonnet-4-5");
host.Changes.ShouldBeEmpty();
}
[Fact]
public void An_unknown_agent_is_refused()
{
using var host = new TestHost();
host.Host.AgentAdmin.Update("ghost", new AgentUpdate(Model: "x")).Ok.ShouldBeFalse();
}
[Fact]
public void A_change_is_announced()
{
// Without the event the other views (and a second browser) would keep the old values.
using var host = new TestHost(TestHost.Agent("scout"));
host.Host.AgentAdmin.Update("scout", new AgentUpdate(Model: "x")).Ok.ShouldBeTrue();
host.Changes.ShouldHaveSingleItem().Kind.ShouldBe(InstanceChangeKind.Agents);
}
}
@@ -0,0 +1,116 @@
using ClawdDotNet.App.Services;
using ClawdDotNet.App.Settings;
using Shouldly;
namespace ClawdDotNet.App.Tests.Services;
/// <summary>
/// #764: The settings the WebUI reads and writes — what the desktop settings page edited.
/// </summary>
public sealed class SettingsAdministrationTests
{
[Fact]
public void The_snapshot_never_carries_a_secret()
{
using var host = new TestHost();
host.Host.Instance.OpenRouterApiKey = "sk-or-secret";
host.Host.Settings.AppSettings.DeploymentcenterToken = "dc-secret";
var snapshot = host.Host.SettingsAdmin.Snapshot();
snapshot.OpenRouterApiKeySet.ShouldBeTrue();
snapshot.DeploymentcenterTokenSet.ShouldBeTrue();
System.Text.Json.JsonSerializer.Serialize(snapshot).ShouldNotContain("secret");
}
[Fact]
public void Instance_settings_survive_a_restart()
{
using var host = new TestHost();
host.Host.Directories.SaveInstanceConfig(host.Host.InstancePath, host.Host.Instance);
var result = host.Host.SettingsAdmin.Update(new SettingsUpdate(
InstanceName: "Renamed", AutostartByManager: true, DailyCostUsd: 2.5m, DailyTokens: 900,
AutoBackupEnabled: true, AutoBackupTime: "4:30", BackupKeepCount: 7));
result.Ok.ShouldBeTrue(result.Message);
var reloaded = host.Host.Directories.LoadInstanceConfig(host.Host.InstancePath);
reloaded.InstanceName.ShouldBe("Renamed");
reloaded.AutostartByManager.ShouldBeTrue();
reloaded.Budget.DailyCostUsd.ShouldBe(2.5m);
reloaded.Budget.DailyTokens.ShouldBe(900);
reloaded.AutoBackupEnabled.ShouldBeTrue();
reloaded.AutoBackupTime.ShouldBe("04:30");
reloaded.BackupKeepCount.ShouldBe(7);
}
[Fact]
public void An_empty_secret_keeps_the_one_there_is()
{
// The form never gets the key back, so it sends an empty field. That must not wipe it.
using var host = new TestHost();
host.Host.Instance.OpenRouterApiKey = "sk-or-keep";
host.Host.Settings.AppSettings.DeploymentcenterToken = "dc-keep";
host.Host.SettingsAdmin.Update(new SettingsUpdate(OpenRouterApiKey: "", DeploymentcenterToken: " ")).Ok.ShouldBeTrue();
host.Host.Instance.OpenRouterApiKey.ShouldBe("sk-or-keep");
host.Host.Settings.AppSettings.DeploymentcenterToken.ShouldBe("dc-keep");
}
[Fact]
public void A_new_key_says_it_needs_a_restart()
{
// The engine is built at start; without saying so the save button would seem to do nothing.
using var host = new TestHost();
var result = host.Host.SettingsAdmin.Update(new SettingsUpdate(OpenRouterApiKey: "sk-or-new"));
result.Ok.ShouldBeTrue();
result.Message.ShouldContain("restart");
host.Host.Instance.OpenRouterApiKey.ShouldBe("sk-or-new");
}
[Fact]
public void Application_settings_are_written_to_settings_json()
{
using var host = new TestHost();
host.Host.SettingsAdmin.Update(new SettingsUpdate(
MinimumLogLevel: "warn", UpdateChannel: "beta", ErrorReportingEnabled: false)).Ok.ShouldBeTrue();
var root = Path.GetDirectoryName(host.Host.Settings.SettingsPath)!;
var reloaded = new SettingsManager(root);
reloaded.Load();
reloaded.AppSettings.MinimumLogLevel.ShouldBe("Warn");
reloaded.AppSettings.UpdateChannel.ShouldBe("beta");
reloaded.AppSettings.ErrorReportingEnabled.ShouldBeFalse();
}
[Theory]
[InlineData("time")]
[InlineData("level")]
[InlineData("url")]
[InlineData("channel")]
[InlineData("name")]
[InlineData("keep")]
public void One_bad_value_changes_nothing(string bad)
{
using var host = new TestHost();
var update = bad switch
{
"time" => new SettingsUpdate(DailyTokens: 5, AutoBackupTime: "25:00"),
"level" => new SettingsUpdate(DailyTokens: 5, MinimumLogLevel: "Chatty"),
"url" => new SettingsUpdate(DailyTokens: 5, OpenRouterBaseUrl: "file:///etc/passwd"),
"channel" => new SettingsUpdate(DailyTokens: 5, UpdateChannel: "nightly"),
"name" => new SettingsUpdate(DailyTokens: 5, InstanceName: " "),
_ => new SettingsUpdate(DailyTokens: 5, BackupKeepCount: -1)
};
host.Host.SettingsAdmin.Update(update).Ok.ShouldBeFalse();
host.Host.Instance.Budget.DailyTokens.ShouldBe(0);
host.Changes.ShouldBeEmpty();
}
}