From 8552755bb6b5d9a0b59322469be2a09509b5ee23 Mon Sep 17 00:00:00 2001 From: Richard Date: Fri, 2 Oct 2026 13:30:20 +0200 Subject: [PATCH 1/3] 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 --- src/ClawdDotNet.App/AppHost.cs | 5 + .../Services/AgentAdministration.cs | 94 +++ .../Services/SettingsAdministration.cs | 210 +++++++ .../ClawdDotNet.Licensing.csproj | 4 + src/ClawdDotNet.Licensing/ILicensePrompt.cs | 11 +- src/ClawdDotNet.Licensing/LicenseGate.cs | 83 +++ src/ClawdDotNet.Manager.Web/Endpoints.cs | 45 ++ src/ClawdDotNet.Manager.Web/wwwroot/app.css | 8 + .../wwwroot/i18n/de.json | 109 ++-- .../wwwroot/i18n/en.json | 109 ++-- .../wwwroot/views/agents.js | 155 ++++- .../wwwroot/views/settings.js | 544 ++++++++++-------- src/ClawdDotNet.Manager/ManagerCore.cs | 70 +++ src/ClawdDotNet.Web/Contracts/Dtos.cs | 51 +- src/ClawdDotNet.Web/Contracts/Mapping.cs | 3 +- src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs | 34 ++ .../Services/AgentUpdateTests.cs | 101 ++++ .../Services/SettingsAdministrationTests.cs | 116 ++++ .../LicenseKeyEntryTests.cs | 155 +++++ .../LanguagePackTests.cs | 92 +++ 20 files changed, 1677 insertions(+), 322 deletions(-) create mode 100644 src/ClawdDotNet.App/Services/SettingsAdministration.cs create mode 100644 tests/ClawdDotNet.App.Tests/Services/AgentUpdateTests.cs create mode 100644 tests/ClawdDotNet.App.Tests/Services/SettingsAdministrationTests.cs create mode 100644 tests/ClawdDotNet.Manager.Tests/LicenseKeyEntryTests.cs create mode 100644 tests/ClawdDotNet.Web.Tests/LanguagePackTests.cs diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs index 9e18bd0..d24e59b 100644 --- a/src/ClawdDotNet.App/AppHost.cs +++ b/src/ClawdDotNet.App/AppHost.cs @@ -254,6 +254,7 @@ public sealed class AppHost : IAsyncDisposable private readonly Lazy _serviceAdmin; private readonly Lazy _jobAdmin; private readonly Lazy _runs; + private readonly Lazy _settingsAdmin; public AppHost() { @@ -261,6 +262,7 @@ public sealed class AppHost : IAsyncDisposable _serviceAdmin = new Lazy(() => new ServiceAdministration(this)); _jobAdmin = new Lazy(() => new JobAdministration(this)); _runs = new Lazy(() => new RunCoordinator(this)); + _settingsAdmin = new Lazy(() => new SettingsAdministration(this)); } /// Agenten anlegen, entfernen, Werkzeuge zulassen und einstellen. @@ -278,6 +280,9 @@ public sealed class AppHost : IAsyncDisposable /// „Jetzt ausführen" samt Verlauf. public RunCoordinator Runs => _runs.Value; + /// #764: The settings the WebUI reads and writes. + public SettingsAdministration SettingsAdmin => _settingsAdmin.Value; + /// Was der Aufrufer beim Start erfragen muss. public sealed class Callbacks { diff --git a/src/ClawdDotNet.App/Services/AgentAdministration.cs b/src/ClawdDotNet.App/Services/AgentAdministration.cs index b03cc8e..c185339 100644 --- a/src/ClawdDotNet.App/Services/AgentAdministration.cs +++ b/src/ClawdDotNet.App/Services/AgentAdministration.cs @@ -13,6 +13,26 @@ public sealed record NewAgent( string Description = "", string? UnitId = null); +/// +/// #764: What can be changed on an existing agent. Every field is optional — null keeps +/// the current value, so a form that only knows some of the fields cannot blank the others. +/// +public sealed record AgentUpdate( + string? DisplayName = null, + string? Model = null, + string? Description = null, + string? SystemPrompt = null, + string? Identity = null, + string? Soul = null, + string? PromptCaching = null, + int? MaxToolResultChars = null, + int? MaxSteps = null, + int? MaxContextTokens = null, + int? TimeoutSeconds = null, + decimal? DailyCostUsd = null, + long? DailyTokens = null, + string? WhenExhausted = null); + /// /// Agenten anlegen, entfernen, ihre Werkzeuge zulassen und einstellen. /// @@ -191,6 +211,80 @@ public sealed class AgentAdministration(AppHost host) + $"'{Path.GetFileName(archived)}'."); } + /// The values understands. + public static readonly IReadOnlyList PromptCachingModes = ["auto", "on", "off"]; + + /// The values understands. + public static readonly IReadOnlyList WhenExhaustedModes = ["expire", "catchUpOnce"]; + + /// + /// #764: Changes an agent's master data, limits and prompt texts — what the desktop agent page + /// edited directly on the configuration objects. + /// + /// Everything is checked before anything is written. A request with one bad value + /// changes nothing: half an update that says "failed" would leave the agent in a state nobody + /// asked for. + /// + /// The display name changes the name only, not the folder: the agent keeps living in + /// , which is where writes. + /// + public AdminResult Update(string agentId, AgentUpdate update) + { + if (Find(agentId) is not { } agent) + return AdminResult.Failure($"No agent with id '{agentId}'."); + + if (Validate(update) is { } problem) + return AdminResult.Failure(problem); + + if (update.DisplayName is { } name) agent.DisplayName = name.Trim(); + if (update.Model is { } model) agent.Model = model.Trim(); + if (update.Description is { } description) agent.Description = description; + if (update.SystemPrompt is { } systemPrompt) agent.SystemPrompt = systemPrompt; + if (update.Identity is { } identity) agent.Identity = identity; + if (update.Soul is { } soul) agent.Soul = soul; + if (update.PromptCaching is { } caching) agent.PromptCaching = caching.Trim().ToLowerInvariant(); + if (update.MaxToolResultChars is { } chars) agent.MaxToolResultChars = chars; + if (update.MaxSteps is { } steps) agent.LoopGuard.MaxSteps = steps; + if (update.MaxContextTokens is { } context) agent.LoopGuard.MaxContextTokens = context; + if (update.TimeoutSeconds is { } timeout) agent.LoopGuard.TimeoutSeconds = timeout; + if (update.DailyCostUsd is { } cost) agent.Budget.DailyCostUsd = cost; + if (update.DailyTokens is { } tokens) agent.Budget.DailyTokens = tokens; + if (update.WhenExhausted is { } when) + agent.Budget.WhenExhausted = WhenExhaustedModes.First(m => m.Equals(when.Trim(), StringComparison.OrdinalIgnoreCase)); + + PersistAgent(agent); + host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agent.AgentId)); + + return AdminResult.Success($"Agent '{agent.DisplayName}' saved."); + } + + private static string? Validate(AgentUpdate update) + { + if (update.DisplayName is { } name && name.Trim().Length == 0) + return "The display name must not be empty."; + if (update.Model is { } model && model.Trim().Length == 0) + return "The model must not be empty."; + if (update.PromptCaching is { } caching + && !PromptCachingModes.Contains(caching.Trim().ToLowerInvariant())) + return $"Prompt caching is one of {string.Join(", ", PromptCachingModes)}."; + if (update.WhenExhausted is { } when + && !WhenExhaustedModes.Any(m => m.Equals(when.Trim(), StringComparison.OrdinalIgnoreCase))) + return $"'When exhausted' is one of {string.Join(", ", WhenExhaustedModes)}."; + if (update.MaxToolResultChars is < 100) + return "A tool result needs at least 100 characters."; + if (update.MaxSteps is < 1 or > 500) + return "Steps must be between 1 and 500."; + if (update.MaxContextTokens is < 1000) + return "The context needs at least 1000 tokens."; + if (update.TimeoutSeconds is < 10 or > 86_400) + return "The time limit must be between 10 seconds and one day."; + if (update.DailyCostUsd is < 0) + return "A daily budget cannot be negative."; + if (update.DailyTokens is < 0) + return "A daily token budget cannot be negative."; + return null; + } + /// /// Lässt ein Werkzeug zu oder nimmt es weg. /// diff --git a/src/ClawdDotNet.App/Services/SettingsAdministration.cs b/src/ClawdDotNet.App/Services/SettingsAdministration.cs new file mode 100644 index 0000000..6c2afa0 --- /dev/null +++ b/src/ClawdDotNet.App/Services/SettingsAdministration.cs @@ -0,0 +1,210 @@ +namespace ClawdDotNet.App.Services; + +/// +/// #764: The settings of this instance and of the application, as the WebUI sees them — +/// without a single secret. For the OpenRouter key and the Deploymentcenter token there is +/// only whether one is set. +/// +public sealed record SettingsSnapshot( + // Instance — InstanceSettings.json, owned by this process. + string InstanceName, + string LogDirectory, + string EffectiveLogDirectory, + bool AutostartByManager, + bool OpenRouterApiKeySet, + decimal DailyCostUsd, + long DailyTokens, + bool AutoBackupEnabled, + string AutoBackupTime, + int BackupKeepCount, + // Application — settings.json, shared by every instance on this machine. + string MinimumLogLevel, + string OpenRouterBaseUrl, + int StatusCheckIntervalSeconds, + string DeploymentcenterUrl, + bool DeploymentcenterTokenSet, + string DeploymentcenterEnvironment, + bool ErrorReportingEnabled, + bool UpdateCheckEnabled, + string UpdateChannel); + +/// +/// #764: A change to the settings. null keeps a value. For the two secrets an empty +/// value also keeps the one there is: a form never gets them back, so it cannot send them +/// back either. +/// +public sealed record SettingsUpdate( + string? InstanceName = null, + string? LogDirectory = null, + bool? AutostartByManager = null, + string? OpenRouterApiKey = null, + decimal? DailyCostUsd = null, + long? DailyTokens = null, + bool? AutoBackupEnabled = null, + string? AutoBackupTime = null, + int? BackupKeepCount = null, + string? MinimumLogLevel = null, + string? OpenRouterBaseUrl = null, + int? StatusCheckIntervalSeconds = null, + string? DeploymentcenterUrl = null, + string? DeploymentcenterToken = null, + string? DeploymentcenterEnvironment = null, + bool? ErrorReportingEnabled = null, + bool? UpdateCheckEnabled = null, + string? UpdateChannel = null); + +/// +/// #764: Reads and writes what the desktop settings page edited, so the WebUI can do it. +/// +/// Two files, two owners. InstanceSettings.json belongs to this instance +/// process — the manager only reads it (autostart, backup schedule), which is why these +/// fields are written here and not through the manager. settings.json is the +/// application file every instance on the machine reads; a change there reaches the others +/// at their next start. +/// +/// Several values only take effect at the next start: the logging is set up, +/// the agent engine is built (or not, without a key) and the Deploymentcenter connection is +/// made while the instance starts. Pretending otherwise would be a save button that seems to +/// do nothing — so the answer says which ones. +/// +public sealed class SettingsAdministration(AppHost host) +{ + public static readonly IReadOnlyList LogLevels = ["Debug", "Info", "Warn", "Error"]; + public static readonly IReadOnlyList UpdateChannels = ["prod", "beta", "dev"]; + public static readonly IReadOnlyList Environments = ["production", "development", "staging"]; + + public SettingsSnapshot Snapshot() + { + var instance = host.Instance; + var app = host.Settings.AppSettings; + + return new SettingsSnapshot( + instance.InstanceName, + instance.LogDirectory, + host.LogDirectory, + instance.AutostartByManager, + !string.IsNullOrWhiteSpace(instance.OpenRouterApiKey), + instance.Budget.DailyCostUsd, + instance.Budget.DailyTokens, + instance.AutoBackupEnabled, + instance.AutoBackupTime, + instance.BackupKeepCount, + app.MinimumLogLevel, + app.OpenRouterBaseUrl, + app.StatusCheckIntervalSeconds, + app.DeploymentcenterUrl, + !string.IsNullOrWhiteSpace(app.DeploymentcenterToken), + app.DeploymentcenterEnvironment, + app.ErrorReportingEnabled, + app.UpdateCheckEnabled, + app.UpdateChannel); + } + + /// Checks everything first; a request with one bad value changes nothing. + public AdminResult Update(SettingsUpdate update) + { + if (Validate(update) is { } problem) + return AdminResult.Failure(problem); + + var instance = host.Instance; + var app = host.Settings.AppSettings; + var restart = new List(); + + void Note(bool changed, string what) + { + if (changed) restart.Add(what); + } + + // ─── Instance ─── + if (update.InstanceName is { } name) instance.InstanceName = name.Trim(); + if (update.LogDirectory is { } logDir) + { + Note(!string.Equals(instance.LogDirectory, logDir.Trim(), StringComparison.Ordinal), "log directory"); + instance.LogDirectory = logDir.Trim(); + } + if (update.AutostartByManager is { } autostart) instance.AutostartByManager = autostart; + if (!string.IsNullOrWhiteSpace(update.OpenRouterApiKey)) + { + instance.OpenRouterApiKey = update.OpenRouterApiKey.Trim(); + restart.Add("OpenRouter key"); + } + if (update.DailyCostUsd is { } cost) instance.Budget.DailyCostUsd = cost; + if (update.DailyTokens is { } tokens) instance.Budget.DailyTokens = tokens; + if (update.AutoBackupEnabled is { } backup) instance.AutoBackupEnabled = backup; + if (update.AutoBackupTime is { } time) instance.AutoBackupTime = TimeSpan.Parse(time.Trim()).ToString(@"hh\:mm"); + if (update.BackupKeepCount is { } keep) instance.BackupKeepCount = keep; + + // ─── Application ─── + var appChanged = false; + + void ChangeApp(bool differs, Action set, string? restartNote = null) + { + if (differs && restartNote is not null) restart.Add(restartNote); + set(); + appChanged = true; + } + + if (update.MinimumLogLevel is { } level) + { + var known = LogLevels.First(l => l.Equals(level.Trim(), StringComparison.OrdinalIgnoreCase)); + ChangeApp(app.MinimumLogLevel != known, () => app.MinimumLogLevel = known, "log level"); + } + if (update.OpenRouterBaseUrl?.Trim() is { } baseUrl) + ChangeApp(app.OpenRouterBaseUrl != baseUrl, () => app.OpenRouterBaseUrl = baseUrl, "OpenRouter address"); + if (update.StatusCheckIntervalSeconds is { } interval) + ChangeApp(app.StatusCheckIntervalSeconds != interval, () => app.StatusCheckIntervalSeconds = interval, "status check interval"); + if (update.DeploymentcenterUrl?.Trim() is { } dcUrl) + ChangeApp(app.DeploymentcenterUrl != dcUrl, () => app.DeploymentcenterUrl = dcUrl, "Deploymentcenter"); + if (update.DeploymentcenterEnvironment?.Trim() is { } environment) + ChangeApp(app.DeploymentcenterEnvironment != environment, () => app.DeploymentcenterEnvironment = environment, "Deploymentcenter"); + if (update.ErrorReportingEnabled is { } reporting) + ChangeApp(app.ErrorReportingEnabled != reporting, () => app.ErrorReportingEnabled = reporting, "Deploymentcenter"); + if (update.UpdateCheckEnabled is { } updateCheck) + ChangeApp(false, () => app.UpdateCheckEnabled = updateCheck); + if (update.UpdateChannel?.Trim() is { } channel) + ChangeApp(false, () => app.UpdateChannel = channel); + if (!string.IsNullOrWhiteSpace(update.DeploymentcenterToken)) + ChangeApp(true, () => app.DeploymentcenterToken = update.DeploymentcenterToken.Trim(), "Deploymentcenter"); + + if (appChanged) + host.Settings.Save(); + + host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings)); + + var pending = restart.Distinct().ToList(); + return AdminResult.Success(pending.Count == 0 + ? "Settings saved." + : $"Settings saved. Takes effect after the instance restarts: {string.Join(", ", pending)}."); + } + + private static string? Validate(SettingsUpdate update) + { + if (update.InstanceName is { } name && name.Trim().Length == 0) + return "The instance name must not be empty."; + if (update.DailyCostUsd is < 0 || update.DailyTokens is < 0) + return "A daily budget cannot be negative."; + if (update.AutoBackupTime is { } time + && (!TimeSpan.TryParse(time.Trim(), out var at) || at < TimeSpan.Zero || at >= TimeSpan.FromDays(1))) + return "The backup time is a time of day, e.g. 03:00."; + if (update.BackupKeepCount is < 0 or > 365) + return "Keep between 0 (all) and 365 backups."; + if (update.MinimumLogLevel is { } level + && !LogLevels.Any(l => l.Equals(level, StringComparison.OrdinalIgnoreCase))) + return $"The log level is one of {string.Join(", ", LogLevels)}."; + if (update.OpenRouterBaseUrl is { } baseUrl && !IsHttpUrl(baseUrl)) + return "The OpenRouter address must be an http(s) address."; + if (update.DeploymentcenterUrl is { } dcUrl && dcUrl.Trim().Length > 0 && !IsHttpUrl(dcUrl)) + return "The Deploymentcenter address must be an http(s) address."; + if (update.StatusCheckIntervalSeconds is < 10 or > 86_400) + return "The status check interval must be between 10 seconds and one day."; + if (update.DeploymentcenterEnvironment is { } env && !Environments.Contains(env.Trim())) + return $"The environment is one of {string.Join(", ", Environments)}."; + if (update.UpdateChannel is { } channel && !UpdateChannels.Contains(channel.Trim())) + return $"The update channel is one of {string.Join(", ", UpdateChannels)}."; + return null; + } + + private static bool IsHttpUrl(string value) + => Uri.TryCreate(value.Trim(), UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp); +} diff --git a/src/ClawdDotNet.Licensing/ClawdDotNet.Licensing.csproj b/src/ClawdDotNet.Licensing/ClawdDotNet.Licensing.csproj index 458bebc..6ed7615 100644 --- a/src/ClawdDotNet.Licensing/ClawdDotNet.Licensing.csproj +++ b/src/ClawdDotNet.Licensing/ClawdDotNet.Licensing.csproj @@ -22,4 +22,8 @@ + + + + diff --git a/src/ClawdDotNet.Licensing/ILicensePrompt.cs b/src/ClawdDotNet.Licensing/ILicensePrompt.cs index 4ba3b00..ca7236f 100644 --- a/src/ClawdDotNet.Licensing/ILicensePrompt.cs +++ b/src/ClawdDotNet.Licensing/ILicensePrompt.cs @@ -37,8 +37,13 @@ public interface ILicensePrompt /// Für den kopflosen Betrieb: fragt nicht, sondern lehnt ab. /// /// Ein Dienst ohne Sitzung kann keinen Schlüssel entgegennehmen. Statt zu blockieren -/// meldet er, was zu tun ist — der Schlüssel wird vorab über die Kommandozeile -/// hinterlegt (--license-set-key). +/// meldet er, was zu tun ist. +/// +/// #764: Here stood that the key is set beforehand on the command line +/// (--license-set-key). That switch was planned and never built — the log pointed +/// everyone who needed it at something that does not exist. The way in is the web +/// interface () or licenseKey in +/// manager.json, written by the installer. /// public sealed class NonInteractiveLicensePrompt(Action log) : ILicensePrompt { @@ -46,7 +51,7 @@ public sealed class NonInteractiveLicensePrompt(Action log) : ILicensePr { log($"Lizenz erforderlich, aber kein Eingabeweg vorhanden. Hardware-ID: {hardwareId}. " + (problem is null ? "" : $"Grund: {problem}. ") - + "Schlüssel mit '--license-set-key ' hinterlegen."); + + "Enter the key in the web interface (Settings > License) or as licenseKey in manager.json."); return Task.FromResult(null); } diff --git a/src/ClawdDotNet.Licensing/LicenseGate.cs b/src/ClawdDotNet.Licensing/LicenseGate.cs index 5df615a..db7407b 100644 --- a/src/ClawdDotNet.Licensing/LicenseGate.cs +++ b/src/ClawdDotNet.Licensing/LicenseGate.cs @@ -33,8 +33,21 @@ public sealed class LicenseGate private readonly string _serverUrl; private readonly string _appVersion; + /// Test seam: answers in place of the license server. + private readonly Func>? _validator; + private string? _hardwareId; + /// For tests: a gate whose server answers are given, not fetched. + internal LicenseGate( + ILicenseStore store, ILogger logger, ILicensePrompt prompt, string appVersion, + Func> validator) + : this(store, logger, prompt, appVersion) + { + _validator = validator; + _hardwareId = "test-hardware"; + } + /// /// Wo Schlüssel und Serveradresse liegen. Eine Schnittstelle und keine /// Einstellungsklasse, weil zwei Prozesse sie führen: die Anwendung in @@ -179,6 +192,67 @@ public sealed class LicenseGate return true; } + /// + /// #764: Takes a key entered in the WebUI — the way in for a service that never asks + /// (). + /// + /// Only a key the server has confirmed is stored. A refused key leaves the + /// current one in place: otherwise a typo would take a working installation down. A + /// check that could not be made (server unreachable) stores nothing either — the same as + /// the startup check, which also keeps an unconfirmed key only for the running + /// session. + /// + public async Task ApplyKeyAsync(string key, CancellationToken ct = default) + { + key = key.Trim(); + + if (key.Length == 0) + return new LicenseApplyResult(false, false, "Enter a license key."); + + var result = await ValidateAsync(key, ct).ConfigureAwait(false); + + if (!result.IsValid) + { + if (result.IsTransient) + { + _logger.LogWarning( + "License key not checkable ({Status}) - not stored.", result.Status); + return new LicenseApplyResult(false, true, + "The license server could not be reached. The key was not stored; try again later."); + } + + _logger.LogWarning( + "License key refused: {Status} - {Message}", result.Status, result.Message); + + // As in the startup check: no details on a clock rollback, a precise text would + // be a recipe. + return new LicenseApplyResult(false, false, result.Status == "clock_rollback" + ? "The license check could not be completed. Please contact support." + : DescribeProblem(result)); + } + + _store.LicenseKey = key; + + try + { + _store.Save(); + } + catch (Exception ex) + { + // Valid, and in force for this session - but the next start would not know it. + // That has to be said, not logged away. + _logger.LogWarning(ex, "License key could not be saved"); + return new LicenseApplyResult(true, false, + $"The license is valid but could not be saved ({ex.Message}). It applies until the next restart."); + } + + _logger.LogInformation("License key replaced via the web interface."); + + return new LicenseApplyResult(true, false, result.IsCached + ? $"License stored. The server was not reachable; valid offline until {Describe(result.CacheExpiresAt)}." + : "License stored and confirmed."); + } + private async Task ValidateAsync(string key, CancellationToken ct) { if (string.IsNullOrWhiteSpace(key)) @@ -193,6 +267,9 @@ public sealed class LicenseGate try { + if (_validator is not null) + return await _validator(key, ct).ConfigureAwait(false); + return await _client.ValidateAsync( LicenseInfo.ProductSlug, key, _serverUrl, _appVersion, ct); } @@ -246,3 +323,9 @@ public sealed class LicenseGate : result.Message }; } + +/// #764: The outcome of . +/// The key is now the installation's key. +/// Nothing was decided: the server could not be asked. +/// For the person who entered the key. +public sealed record LicenseApplyResult(bool Applied, bool Transient, string Message); diff --git a/src/ClawdDotNet.Manager.Web/Endpoints.cs b/src/ClawdDotNet.Manager.Web/Endpoints.cs index 872b5e7..5951586 100644 --- a/src/ClawdDotNet.Manager.Web/Endpoints.cs +++ b/src/ClawdDotNet.Manager.Web/Endpoints.cs @@ -74,6 +74,20 @@ public sealed record FtpSettingsUpdateDto( bool Enabled, string Host, int Port, string Username, string? Password, string RemoteDirectory, bool RequireTls, int KeepCount); +/// #764: The license as the web interface sees it — never the key, only whether one is set. +public sealed record LicenseStatusDto(bool Enforced, bool Holds, bool KeySet, string? HardwareId); + +/// #764: A license key entered in the web interface. +public sealed record LicenseKeyDto(string Key); + +/// #764: The manager's own settings — without the master token, only whether one is set. +public sealed record ManagerSettingsDto( + string ConfigFile, string InstancesDirectory, int WebUiPort, string WebUiBinding, + string DeploymentcenterUrl, bool MasterTokenSet, string UpdateChannel); + +/// #764: A change to the manager's settings. null keeps a value, an empty token too. +public sealed record ManagerSettingsUpdateDto(string? DeploymentcenterUrl, string? MasterToken, string? UpdateChannel); + /// /// Die HTTP-Fläche des Managers. /// @@ -368,6 +382,37 @@ internal static class ManagerEndpoints } }); + // ─── License and manager settings (#764) ─── + + verwaltung.MapGet("/license", (ManagerCore manager) => + Results.Ok(new LicenseStatusDto( + manager.LicenseEnforced, manager.LicenseHolds, + !string.IsNullOrWhiteSpace(manager.Settings.LicenseKey), manager.LicenseHardwareId))); + + // The key goes to the license server before it is stored; a refused key leaves the + // current one in place (LicenseGate.ApplyKeyAsync). + verwaltung.MapPut("/license", async (LicenseKeyDto input, ManagerCore manager, CancellationToken ct) => + { + var result = await manager.ApplyLicenseKeyAsync(input.Key ?? "", ct); + return result.Applied + ? Results.Ok(new OutcomeDto(true, result.Message)) + : Results.BadRequest(new OutcomeDto(false, result.Message)); + }); + + verwaltung.MapGet("/settings", (ManagerCore manager) => + { + var s = manager.Settings; + return Results.Ok(new ManagerSettingsDto( + manager.SettingsPath, s.InstancesDirectory, s.WebUiPort, s.WebUiBinding, + s.DeploymentcenterUrl, !string.IsNullOrWhiteSpace(s.DeploymentcenterMasterToken), s.UpdateChannel)); + }); + + verwaltung.MapPut("/settings", (ManagerSettingsUpdateDto input, ManagerCore manager) => + { + var (ok, message) = manager.UpdateSettings(input.DeploymentcenterUrl, input.MasterToken, input.UpdateChannel); + return ok ? Results.Ok(new OutcomeDto(true, message)) : Results.BadRequest(new OutcomeDto(false, message)); + }); + // ─── FTP-Upload der Sicherungen ─── verwaltung.MapGet("/sicherung/ftp", (ManagerBackups backups) => diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.css b/src/ClawdDotNet.Manager.Web/wwwroot/app.css index 29a7160..8b68fe8 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/app.css +++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.css @@ -1517,6 +1517,14 @@ button.klein { font-size: 18px; } +/* #764: Settings cards side by side where there is room, stacked on a phone. */ +.settings-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); + gap: 14px; + align-items: start; +} + /* ─── Responsive Optimierung (Handy / 375px) ─── */ @media (max-width: 768px) { diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json index 37c7670..4e03d69 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json @@ -417,6 +417,39 @@ "agents.treatAsForeignReader": "Immer als Leser fremder Inhalte behandeln", "agents.treatAsForeignReaderHint": "Kann nur verschärfen. Die Stufe folgt aus den Werkzeugen. Diese Einstellung setzt sie für einen Agenten, dessen Eingaben auf anderem Weg von außen kommen.", "agents.foreignReaderSaved": "Berichtsweg von {name} gespeichert.", + "agents.edit": "Bearbeiten", + "agents.editTooltip": "Name, Modell, Grenzen, Budget und Prompttexte bearbeiten", + "agents.editTitle": "Agent bearbeiten — {name}", + "agents.saved": "Agent gespeichert.", + "agents.runNow": "Jetzt ausführen", + "agents.runNowTooltip": "Agent von Hand wecken, wie es ein geplanter Lauf täte", + "agents.runStarted": "{name} läuft.", + "agents.runFailed": "Lauf fehlgeschlagen: {error}", + "agents.limits": "Grenzen", + "agents.limitsValue": "{steps} Schritte · {context} Kontext-Tokens · {seconds} s", + "agents.budget": "Tagesbudget", + "agents.budgetValue": "{cost} · {tokens} Tokens", + "agents.unlimited": "unbegrenzt", + "agents.promptTextsHint": "Identity, Soul und Systemprompt liest das Modell vor jedem Lauf. Wer sie ändert, ändert, wie der Agent arbeitet.", + "agents.field.displayName": "Anzeigename", + "agents.field.model": "Modell", + "agents.field.description": "Beschreibung", + "agents.field.identity": "Identity", + "agents.field.soul": "Soul", + "agents.field.systemPrompt": "Systemprompt", + "agents.field.promptCaching": "Prompt-Caching", + "agents.field.maxSteps": "Max. Schritte je Lauf", + "agents.field.maxContextTokens": "Max. Kontext-Tokens", + "agents.field.maxToolResultChars": "Max. Zeichen je Werkzeugergebnis", + "agents.field.timeoutSeconds": "Zeitlimit je Lauf (Sekunden)", + "agents.field.dailyCostUsd": "Tagesbudget (USD, 0 = unbegrenzt)", + "agents.field.dailyTokens": "Tages-Tokens (0 = unbegrenzt)", + "agents.field.whenExhausted": "Wenn das Budget erschöpft ist", + "agents.option.auto": "Automatisch", + "agents.option.on": "An", + "agents.option.off": "Aus", + "agents.option.expire": "Lauf entfällt", + "agents.option.catchUpOnce": "Einmal nachholen", "tasks.title": "Aufgaben", "tasks.newTask": "Neue Aufgabe", @@ -558,34 +591,51 @@ "logs.empty": "Noch keine Protokolleinträge vorhanden.", "settings.title": "Einstellungen & Systemarchitektur", + "settings.license": "Lizenz", + "settings.licenseState": "Zustand", + "settings.licenseValid": "Gültig", + "settings.licenseInvalid": "Nicht gültig", + "settings.licenseMissing": "Kein Schlüssel", + "settings.licenseNoCheck": "Keine Lizenzprüfung", + "settings.licenseUnavailable": "Der Lizenzzustand ließ sich nicht lesen.", + "settings.licenseKeyHint": "Der Schlüssel wird vor dem Speichern beim Lizenzserver geprüft. Ein abgelehnter Schlüssel lässt den bisherigen stehen. Eine Lizenz gilt für alle Instanzen dieser Installation.", + "settings.hardwareId": "Hardware-ID", + "settings.secretSet": "Hinterlegt — leer lassen, um ihn zu behalten", + "settings.secretNotSet": "Nicht gesetzt", + "settings.managerCard": "Instanzmanager", + "settings.instancesDirectory": "Instanzverzeichnis", + "settings.webUi": "Weboberfläche", + "settings.dcUrl": "Deploymentcenter-Adresse", + "settings.dcMasterTokenHint": "Damit stellt der Manager jeder Instanz ein eigenes Melde-Token aus. Nur ein leeres Feld behält das gespeicherte Token.", + "settings.managerRestartHint": "Änderungen hier greifen, wenn der Instanzmanager neu startet. Port, Bindung und Zugangstoken der Weboberfläche stehen in manager.json — ein falscher Wert hier sperrte diese Seite aus.", + "settings.instanceStopped": "Instanz läuft nicht", + "settings.instanceStoppedHint": "Die Instanzdatei gehört dem Instanzprozess und wird nur bearbeitet, solange er läuft. Instanz starten, um ihre Einstellungen zu ändern.", + "settings.instanceCard": "Instanz", + "settings.logDirectory": "Protokollverzeichnis", + "settings.logDirectoryHint": "Leer nimmt die Vorgabe. Derzeit: {path}. Greift nach einem Neustart.", + "settings.instanceDailyCost": "Tagesbudget der Instanz (USD, 0 = unbegrenzt)", + "settings.instanceDailyTokens": "Tages-Tokens der Instanz (0 = unbegrenzt)", + "settings.budgetHint": "Über alle Agenten dieser Instanz. Agenten haben zusätzlich eigene Budgets.", + "settings.apiKeyHint": "Ohne Schlüssel startet die Agent-Engine nicht. Ein neuer Schlüssel greift nach einem Neustart der Instanz.", + "settings.applicationCard": "Anwendung (alle Instanzen)", + "settings.applicationHint": "settings.json auf dieser Maschine, von jeder Instanz beim Start gelesen.", + "settings.minimumLogLevel": "Mindest-Protokollstufe", + "settings.openRouterBaseUrl": "OpenRouter-Adresse", + "settings.statusCheckInterval": "Statusprüfung alle (Sekunden)", + "settings.dcToken": "Deploymentcenter-Token", + "settings.dcEnvironment": "Umgebung", + "settings.errorReporting": "Fehler an das Deploymentcenter melden", + "settings.on": "An", + "settings.off": "Aus", "settings.intro": "Übersicht der Konfiguration nach dem Instanzmanager-Konzept: Strikte Trennung zwischen globaler Manager-Ebene (Installation) und isolierter Instanz-Ebene.", "settings.managerLevel": "Instanzmanager", "settings.managerBadge": "Manager-Ebene · Global", "settings.managerIntro": "Gilt für alle Instanzen und die Weboberfläche. Gespeichert in manager.json im Anwendungs-Hauptverzeichnis.", - "settings.webUiAndNet": "Weboberfläche & Netzwerk", "settings.configFile": "Konfigurationsdatei", - "settings.webUiPort": "WebUI-Port", - "settings.webUiPortDefault": "7000 (Vorgabe, webUiPort)", - "settings.networkBinding": "Netzwerk-Bindung", - "settings.networkBindingValue": "loopback (127.0.0.1, webUiBinding)", - "settings.accessProtection": "Zugangsschutz", - "settings.tokenActive": "Token aktiv (≥ 20 Zeichen)", - "settings.licenseAndUpdates": "Lizenz & Updates", - "settings.licenseScope": "Lizenz-Geltung", - "settings.licenseScopeValue": "Installationsebene (E-M3: Beliebig viele Instanzen)", "settings.licenseKey": "Lizenzschlüssel", - "settings.licenseEncrypted": "●●●●-●●●●-●●●●-●●●● (verschlüsselt)", "settings.updateChannel": "Update-Kanal", - "settings.updateChannelValue": "prod (Offizielle Release-Versionen)", "settings.updateCheck": "Update-Prüfung", - "settings.updateCheckValue": "Zentral auf Manager-Ebene", - "settings.dcAndOversight": "Deploymentcenter & Aufsicht", "settings.dcMasterToken": "Master-Token", - "settings.dcMasterTokenValue": "●●●●●●●● (erzeugt Sub-Token je Instanz)", - "settings.desktopWindows": "Desktop-Fenster", - "settings.desktopWindowsValue": "Aktiv (jede Instanz hat ein eigenes Fenster)", - "settings.crashProtection": "Absturz-Schutz", - "settings.crashProtectionValue": "Max. 5 Neustarts / 10 Minuten", "settings.instanceSectionTitle": "Aktive Instanz: {name}", "settings.instanceSectionTitleNone": "Instanzbezogene Einstellungen", "settings.instanceBadge": "Instanz-Ebene · Isoliert", @@ -594,41 +644,20 @@ "settings.noInstanceSelected": "Keine Instanz ausgewählt", "settings.noInstancePrompt": "Bitte wähle oben im Kopfbereich eine aktive Instanz aus oder wechsle in den Bereich 'Instanzen', um eine Instanz zu starten oder auszuwählen.", "settings.toInstancesOverview": "Zur Instanzen-Übersicht", - "settings.identityAndProcess": "Identität & Prozess", "settings.instanceName": "Instanzname", - "settings.instanceId": "Instanz-ID", "settings.autostartByManager": "Autostart durch Manager", - "settings.autostartActive": "Aktiv (Am Laufen halten)", - "settings.autostartInactive": "Inaktiv (Manueller Start)", - "settings.communication": "Kommunikation", - "settings.communicationValue": "HTTP über Named Pipe (Windows) / Socket (Linux)", - "settings.isolatedPaths": "Isolierte Verzeichnisse", - "settings.logsPath": "Protokolle (Logs)", - "settings.backupsPath": "Datensicherungen", - "settings.stateDbPath": "Zustandsdatenbank", - "settings.stateDbValue": "Instances/{name}/state.db (exklusiv)", - "settings.workspacePath": "Arbeitsbereich (Shared)", "settings.openRouterCard": "Modellanbindung (OpenRouter)", "settings.openRouterStatus": "OpenRouter-Status", "settings.connected": "Verbunden", "settings.offline": "Offline", - "settings.statusMessage": "Statusmeldung", "settings.credits": "Guthaben / Credits", "settings.apiKey": "API-Schlüssel", - "settings.apiKeyMasked": "In InstanceSettings.json dieser Instanz maskiert hinterlegt", - "settings.openRouterNotRunning": "Instanz läuft nicht. Daten werden nach dem Start geladen.", "settings.openRouterNoKey": "Kein Schlüssel hinterlegt oder Dienst nicht erreichbar.", "settings.runtimeAndSubsystems": "Laufzeit & Teilsysteme", "settings.agentEngine": "Agent Engine", "settings.scheduler": "Scanner / Scheduler", "settings.stagingService": "Freigabestelle (Staging)", "settings.dcSubToken": "Deploymentcenter Sub-Token", - "settings.subsystemsLoading": "Laufzeitdaten werden abgefragt…", - "settings.subsystemsStopped": "Instanz ist beendet — Teilsysteme ruhen.", - "settings.save": "Einstellungen speichern", - "settings.discard": "Verwerfen", - "settings.saved": "Einstellungen erfolgreich gespeichert.", - "settings.saveError": "Fehler beim Speichern der Einstellungen: {error}", "info.title": "System & Status", "info.noInstanceSelected": "Keine Instanz ausgewählt", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json index 04ef04e..98dd0d5 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json @@ -417,6 +417,39 @@ "agents.treatAsForeignReader": "Always treat as reading outside content", "agents.treatAsForeignReaderHint": "Can only tighten. The tools decide the tier; this adds it for an agent whose input comes from outside some other way.", "agents.foreignReaderSaved": "Reporting line of {name} saved.", + "agents.edit": "Edit", + "agents.editTooltip": "Edit name, model, limits, budget and prompt texts", + "agents.editTitle": "Edit agent — {name}", + "agents.saved": "Agent saved.", + "agents.runNow": "Run now", + "agents.runNowTooltip": "Wake the agent by hand, as a scheduled run would", + "agents.runStarted": "{name} is running.", + "agents.runFailed": "Run failed: {error}", + "agents.limits": "Limits", + "agents.limitsValue": "{steps} steps · {context} context tokens · {seconds} s", + "agents.budget": "Daily budget", + "agents.budgetValue": "{cost} · {tokens} tokens", + "agents.unlimited": "unlimited", + "agents.promptTextsHint": "Identity, Soul and the system prompt are what the model reads before every run. Changing them changes how the agent works.", + "agents.field.displayName": "Display name", + "agents.field.model": "Model", + "agents.field.description": "Description", + "agents.field.identity": "Identity", + "agents.field.soul": "Soul", + "agents.field.systemPrompt": "System prompt", + "agents.field.promptCaching": "Prompt caching", + "agents.field.maxSteps": "Max. steps per run", + "agents.field.maxContextTokens": "Max. context tokens", + "agents.field.maxToolResultChars": "Max. characters per tool result", + "agents.field.timeoutSeconds": "Time limit per run (seconds)", + "agents.field.dailyCostUsd": "Daily budget (USD, 0 = unlimited)", + "agents.field.dailyTokens": "Daily tokens (0 = unlimited)", + "agents.field.whenExhausted": "When the budget is exhausted", + "agents.option.auto": "Automatic", + "agents.option.on": "On", + "agents.option.off": "Off", + "agents.option.expire": "Skip the run", + "agents.option.catchUpOnce": "Catch up once", "tasks.title": "Tasks", "tasks.newTask": "New Task", @@ -558,34 +591,51 @@ "logs.empty": "No log messages yet.", "settings.title": "Settings & System Architecture", + "settings.license": "License", + "settings.licenseState": "State", + "settings.licenseValid": "Valid", + "settings.licenseInvalid": "Not valid", + "settings.licenseMissing": "No key", + "settings.licenseNoCheck": "No license check", + "settings.licenseUnavailable": "The license state could not be read.", + "settings.licenseKeyHint": "The key is checked with the license server before it is stored. A refused key leaves the current one in place. One license covers every instance of this installation.", + "settings.hardwareId": "Hardware ID", + "settings.secretSet": "Stored — leave empty to keep", + "settings.secretNotSet": "Not set", + "settings.managerCard": "Instance manager", + "settings.instancesDirectory": "Instances directory", + "settings.webUi": "Web interface", + "settings.dcUrl": "Deploymentcenter address", + "settings.dcMasterTokenHint": "Used to issue each instance its own reporting token. Only an empty field keeps the stored token.", + "settings.managerRestartHint": "Changes here take effect when the instance manager restarts. Port, binding and access token of the web interface are changed in manager.json — a wrong value here would lock this page out.", + "settings.instanceStopped": "Instance not running", + "settings.instanceStoppedHint": "The instance file belongs to the instance process and is only edited while it runs. Start the instance to change its settings.", + "settings.instanceCard": "Instance", + "settings.logDirectory": "Log directory", + "settings.logDirectoryHint": "Empty uses the default. Currently: {path}. Takes effect after a restart.", + "settings.instanceDailyCost": "Daily budget of the instance (USD, 0 = unlimited)", + "settings.instanceDailyTokens": "Daily tokens of the instance (0 = unlimited)", + "settings.budgetHint": "Across all agents of this instance. Agents also have their own budgets.", + "settings.apiKeyHint": "Without a key the agent engine does not start. A new key takes effect after the instance restarts.", + "settings.applicationCard": "Application (all instances)", + "settings.applicationHint": "settings.json on this machine, read by every instance when it starts.", + "settings.minimumLogLevel": "Minimum log level", + "settings.openRouterBaseUrl": "OpenRouter address", + "settings.statusCheckInterval": "Status check interval (seconds)", + "settings.dcToken": "Deploymentcenter token", + "settings.dcEnvironment": "Environment", + "settings.errorReporting": "Report errors to the Deploymentcenter", + "settings.on": "On", + "settings.off": "Off", "settings.intro": "Configuration overview following the instance manager concept: Strict separation between global manager level (installation) and isolated instance level.", "settings.managerLevel": "Instance Manager", "settings.managerBadge": "Manager Level · Global", "settings.managerIntro": "Applies to all instances and the WebUI. Stored in manager.json in the application root directory.", - "settings.webUiAndNet": "WebUI & Network", "settings.configFile": "Configuration File", - "settings.webUiPort": "WebUI Port", - "settings.webUiPortDefault": "7000 (default, webUiPort)", - "settings.networkBinding": "Network Binding", - "settings.networkBindingValue": "loopback (127.0.0.1, webUiBinding)", - "settings.accessProtection": "Access Protection", - "settings.tokenActive": "Token active (≥ 20 characters)", - "settings.licenseAndUpdates": "License & Updates", - "settings.licenseScope": "License Scope", - "settings.licenseScopeValue": "Installation level (E-M3: Unlimited instances)", "settings.licenseKey": "License Key", - "settings.licenseEncrypted": "●●●●-●●●●-●●●●-●●●● (encrypted)", "settings.updateChannel": "Update Channel", - "settings.updateChannelValue": "prod (Official release versions)", "settings.updateCheck": "Update Check", - "settings.updateCheckValue": "Central at manager level", - "settings.dcAndOversight": "Deploymentcenter & Oversight", "settings.dcMasterToken": "Master Token", - "settings.dcMasterTokenValue": "●●●●●●●● (generates sub-tokens per instance)", - "settings.desktopWindows": "Desktop Windows", - "settings.desktopWindowsValue": "Active (each instance has its own window)", - "settings.crashProtection": "Crash Protection", - "settings.crashProtectionValue": "Max 5 restarts / 10 minutes", "settings.instanceSectionTitle": "Active Instance: {name}", "settings.instanceSectionTitleNone": "Instance-Related Settings", "settings.instanceBadge": "Instance Level · Isolated", @@ -594,41 +644,20 @@ "settings.noInstanceSelected": "No instance selected", "settings.noInstancePrompt": "Please select an active instance in the header above or switch to 'Instances' to start or select an instance.", "settings.toInstancesOverview": "To Instances Overview", - "settings.identityAndProcess": "Identity & Process", "settings.instanceName": "Instance Name", - "settings.instanceId": "Instance ID", "settings.autostartByManager": "Autostart by Manager", - "settings.autostartActive": "Active (keep running)", - "settings.autostartInactive": "Inactive (manual start)", - "settings.communication": "Communication", - "settings.communicationValue": "HTTP over Named Pipe (Windows) / Socket (Linux)", - "settings.isolatedPaths": "Isolated Directories", - "settings.logsPath": "Logs", - "settings.backupsPath": "Backups", - "settings.stateDbPath": "State Database", - "settings.stateDbValue": "Instances/{name}/state.db (exclusive)", - "settings.workspacePath": "Shared Workspace", "settings.openRouterCard": "Model Integration (OpenRouter)", "settings.openRouterStatus": "OpenRouter Status", "settings.connected": "Connected", "settings.offline": "Offline", - "settings.statusMessage": "Status Message", "settings.credits": "Credits", "settings.apiKey": "API Key", - "settings.apiKeyMasked": "Masked in InstanceSettings.json of this instance", - "settings.openRouterNotRunning": "Instance is not running. Data will be loaded after start.", "settings.openRouterNoKey": "No key configured or service unreachable.", "settings.runtimeAndSubsystems": "Runtime & Subsystems", "settings.agentEngine": "Agent Engine", "settings.scheduler": "Scanner / Scheduler", "settings.stagingService": "Approvals (Staging)", "settings.dcSubToken": "Deploymentcenter Sub-Token", - "settings.subsystemsLoading": "Querying runtime data…", - "settings.subsystemsStopped": "Instance stopped — subsystems idle.", - "settings.save": "Save Settings", - "settings.discard": "Discard Changes", - "settings.saved": "Settings saved successfully.", - "settings.saveError": "Failed to save settings: {error}", "info.title": "System & Status", "info.noInstanceSelected": "No instance selected", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/agents.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/agents.js index ea60ccc..707a0e1 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/views/agents.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/agents.js @@ -186,7 +186,31 @@ async function rendereDetails(agentId) { erstelleElement("span", { text: ` ${t("agents.delete")}` }) ); - aktionen.append(zumChatBtn, loeschenBtn); + // #764: What the desktop agent page could do — edit the agent and wake it by hand. + const editButton = erstelleElement( + "button", + { + klasse: "schaltflaeche flach", + title: t("agents.editTooltip"), + onclick: () => openEditDialog(agent) + }, + erstelleIcon(PFADE.einstellungen, 14), + erstelleElement("span", { text: ` ${t("agents.edit")}` }) + ); + + const runButton = erstelleElement( + "button", + { + klasse: "schaltflaeche flach", + title: t("agents.runNowTooltip"), + disabled: Boolean(agent.isRunning), + onclick: () => runAgentNow(agent, runButton) + }, + erstelleIcon(PFADE.abspielen, 14), + erstelleElement("span", { text: ` ${t("agents.runNow")}` }) + ); + + aktionen.append(editButton, runButton, zumChatBtn, loeschenBtn); // Stammdaten-Karte const stammdatenListe = erstelleElement("dl", { klasse: "datenliste" }); @@ -194,7 +218,13 @@ async function rendereDetails(agentId) { [t("agents.name"), agent.displayName], [t("agents.id"), agent.agentId], [t("agents.model"), agent.model], - [t("agents.description"), agent.description || "—"] + [t("agents.description"), agent.description || "—"], + [t("agents.limits"), t("agents.limitsValue", { + steps: agent.maxSteps, + context: agent.maxContextTokens, + seconds: agent.timeoutSeconds + })], + [t("agents.budget"), formatBudget(agent)] ]; for (const [bez, wert] of felder) { @@ -433,6 +463,127 @@ function oeffneNeuerAgentDialog() { modal.oeffnen(); } +function formatBudget(agent) { + const cost = Number(agent.dailyCostUsd) > 0 ? `$${Number(agent.dailyCostUsd).toFixed(2)}` : t("agents.unlimited"); + const tokens = Number(agent.dailyTokens) > 0 ? Number(agent.dailyTokens).toLocaleString() : t("agents.unlimited"); + return t("agents.budgetValue", { cost, tokens }); +} + +async function runAgentNow(agent, button) { + button.disabled = true; + try { + const result = await api(`/agents/${encodeURIComponent(agent.agentId)}/run`, { method: "POST" }); + zeigeMeldung(result?.message ?? t("agents.runStarted", { name: agent.displayName }), "info"); + } catch (err) { + zeigeMeldung(t("agents.runFailed", { error: err.message }), "gefahr"); + } finally { + button.disabled = false; + } +} + +/** + * #764: Edits an agent — master data, limits, budget and the three prompt texts. The long + * texts are fetched for this agent only; the agent list does not carry them. + */ +async function openEditDialog(agent) { + let texts; + try { + texts = await api(`/agents/${encodeURIComponent(agent.agentId)}/texts`); + } catch (err) { + zeigeMeldung(t("agents.loadFailed", { error: err.message }), "gefahr"); + return; + } + + const form = erstelleElement("form", { klasse: "formular-raster", id: "agent-edit-form" }); + + const textInput = (value) => erstelleElement("input", { type: "text", value: value ?? "" }); + const numberInput = (value, min, step = "1") => + erstelleElement("input", { type: "number", min: String(min), step, value: String(value ?? "") }); + const textArea = (value, rows) => { + const area = erstelleElement("textarea", { rows: String(rows) }); + area.value = value ?? ""; + return area; + }; + const select = (options, value) => { + const el = erstelleElement("select", {}, + ...options.map((o) => erstelleElement("option", { value: o, text: t(`agents.option.${o}`) }))); + el.value = value; + return el; + }; + + const fields = { + displayName: textInput(agent.displayName), + model: textInput(agent.model), + description: textArea(agent.description, 2), + identity: textArea(texts.identity, 8), + soul: textArea(texts.soul, 8), + systemPrompt: textArea(texts.systemPrompt, 6), + promptCaching: select(["auto", "on", "off"], agent.promptCaching || "auto"), + maxSteps: numberInput(agent.maxSteps, 1), + maxContextTokens: numberInput(agent.maxContextTokens, 1000), + maxToolResultChars: numberInput(agent.maxToolResultChars, 100), + timeoutSeconds: numberInput(agent.timeoutSeconds, 10), + dailyCostUsd: numberInput(agent.dailyCostUsd, 0, "0.01"), + dailyTokens: numberInput(agent.dailyTokens, 0), + whenExhausted: select(["expire", "catchUpOnce"], agent.whenExhausted || "expire") + }; + fields.displayName.required = true; + fields.model.required = true; + + for (const [key, input] of Object.entries(fields)) { + form.append(erstelleElement("label", { text: t(`agents.field.${key}`) }), input); + } + form.append(erstelleElement("p", { klasse: "hinweis", text: t("agents.promptTextsHint") })); + + const cancelButton = erstelleElement("button", { + type: "button", + klasse: "schaltflaeche flach", + text: t("common.cancel"), + onclick: () => modal.schliessen() + }); + const saveButton = erstelleElement("button", { + type: "submit", + klasse: "schaltflaeche primaer", + text: t("common.save") + }); + + const modal = erstelleModal(t("agents.editTitle", { name: agent.displayName }), form, [cancelButton, saveButton], { klasse: "modal-breit" }); + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + const body = { + displayName: fields.displayName.value.trim(), + model: fields.model.value.trim(), + description: fields.description.value, + identity: fields.identity.value, + soul: fields.soul.value, + systemPrompt: fields.systemPrompt.value, + promptCaching: fields.promptCaching.value, + maxSteps: Number(fields.maxSteps.value), + maxContextTokens: Number(fields.maxContextTokens.value), + maxToolResultChars: Number(fields.maxToolResultChars.value), + timeoutSeconds: Number(fields.timeoutSeconds.value), + dailyCostUsd: Number(fields.dailyCostUsd.value), + dailyTokens: Number(fields.dailyTokens.value), + whenExhausted: fields.whenExhausted.value + }; + + saveButton.disabled = true; + try { + const result = await api(`/agents/${encodeURIComponent(agent.agentId)}`, { method: "PUT", body }); + modal.schliessen(); + zeigeMeldung(result?.message ?? t("agents.saved"), "erfolg"); + await ladeUndRendereListe(agent.agentId); + } catch (err) { + zeigeMeldung(t("agents.saveFailed", { error: err.message }), "gefahr"); + } finally { + saveButton.disabled = false; + } + }); + + modal.oeffnen(); +} + async function loescheAgent(agentId, name) { if (!confirm(t("agents.deleteConfirm", { name, id: agentId }))) return; diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/settings.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/settings.js index 8c30211..a2e9087 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/views/settings.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/settings.js @@ -1,6 +1,13 @@ -// ClawdDotNet — Einstellungen & Systemarchitektur +// ClawdDotNet — Settings (#764) // -// Strikte Trennung zwischen globaler Manager-Ebene (Installation) und isolierter Instanz-Ebene. +// Two levels, kept apart on purpose: +// - the instance manager (manager.json): license, Deploymentcenter master token, update channel; +// - the active instance (InstanceSettings.json, owned by the instance process) and the +// application file settings.json that every instance on this machine reads. +// +// Until #764 this page only showed fixed text — the values could be edited in the desktop window +// alone. Secrets (license key, tokens, OpenRouter key) are written here and never shown: the API +// returns only whether one is set, and an empty field keeps the stored value. import { api, holeAktiveInstanz } from "../modules/api.js"; import { t } from "../modules/i18n.js"; @@ -10,6 +17,7 @@ import { erstellePlakette, instanzZustandInfo, erstelleIcon, + zeigeMeldung, PFADE } from "../modules/dom.js"; import { erstelleMcpServerKarte } from "./mcp-servers.js"; @@ -21,240 +29,306 @@ export async function rendereEinstellungen() { const layout = erstelleElement("div", { klasse: "einstellungen-ansicht" }); - // Kopfbereich - const kopf = erstelleElement("div", { klasse: "ansicht-kopf" }); - const kopfText = erstelleElement("div"); - kopfText.append( + const head = erstelleElement("div", { klasse: "ansicht-kopf" }); + head.append(erstelleElement("div", {}, erstelleElement("h1", { text: t("settings.title") }), - erstelleElement("p", { - klasse: "hinweis", - text: t("settings.intro") - }) - ); - kopf.append(kopfText); - layout.append(kopf); + erstelleElement("p", { klasse: "hinweis", text: t("settings.intro") }))); + layout.append(head); - // ═══════════════════════════════════════════════════════════════ - // SEKTION 1: Instanzmanager (Globale Ebene / Installation) - // ═══════════════════════════════════════════════════════════════ - - const managerSektion = erstelleElement("section", { klasse: "einstellungen-sektion sektion-manager" }); - - const managerKopf = erstelleElement("div", { klasse: "sektion-titel-zeile" }); - const managerTitelLinks = erstelleElement("div", { klasse: "gruppe-horizontal" }); - managerTitelLinks.append( - erstelleIcon(PFADE.server, 20), - erstelleElement("h2", { text: t("settings.managerLevel") }), - erstellePlakette(t("settings.managerBadge"), "warnung") - ); - managerKopf.append(managerTitelLinks); - - const managerIntro = erstelleElement("p", { - klasse: "gedaempft text-klein", - text: t("settings.managerIntro") - }); - - const managerGitter = erstelleElement("div", { klasse: "info-gitter" }); - - // Karte 1: Weboberfläche & Netzwerk - const dlNetz = erstelleElement("dl", { klasse: "datenliste" }); - dlNetz.append( - erstelleElement("dt", { text: t("settings.configFile") }), - erstelleElement("dd", {}, erstelleElement("code", { text: "manager.json" })), - erstelleElement("dt", { text: t("settings.webUiPort") }), - erstelleElement("dd", { text: t("settings.webUiPortDefault") }), - erstelleElement("dt", { text: t("settings.networkBinding") }), - erstelleElement("dd", { text: t("settings.networkBindingValue") }), - erstelleElement("dt", { text: t("settings.accessProtection") }), - erstelleElement("dd", {}, erstellePlakette(t("settings.tokenActive"), "erfolg")) - ); - managerGitter.append(erstelleKarte(t("settings.webUiAndNet"), [dlNetz])); - - // Karte 2: Lizenz & Programm-Updates - const dlLizenz = erstelleElement("dl", { klasse: "datenliste" }); - dlLizenz.append( - erstelleElement("dt", { text: t("settings.licenseScope") }), - erstelleElement("dd", { text: t("settings.licenseScopeValue") }), - erstelleElement("dt", { text: t("settings.licenseKey") }), - erstelleElement("dd", {}, erstelleElement("code", { text: t("settings.licenseEncrypted") })), - erstelleElement("dt", { text: t("settings.updateChannel") }), - erstelleElement("dd", { text: t("settings.updateChannelValue") }), - erstelleElement("dt", { text: t("settings.updateCheck") }), - erstelleElement("dd", { text: t("settings.updateCheckValue") }) - ); - managerGitter.append(erstelleKarte(t("settings.licenseAndUpdates"), [dlLizenz])); - - // Karte 3: Deploymentcenter & Aufsicht - const dlAufsicht = erstelleElement("dl", { klasse: "datenliste" }); - dlAufsicht.append( - erstelleElement("dt", { text: "Deploymentcenter" }), - erstelleElement("dd", { text: "https://dc.mhdf.de" }), - erstelleElement("dt", { text: t("settings.dcMasterToken") }), - erstelleElement("dd", {}, erstelleElement("code", { text: t("settings.dcMasterTokenValue") })), - erstelleElement("dt", { text: t("settings.desktopWindows") }), - erstelleElement("dd", { text: t("settings.desktopWindowsValue") }), - erstelleElement("dt", { text: t("settings.crashProtection") }), - erstelleElement("dd", { text: t("settings.crashProtectionValue") }) - ); - managerGitter.append(erstelleKarte(t("settings.dcAndOversight"), [dlAufsicht])); - - managerSektion.append(managerKopf, managerIntro, managerGitter); - layout.append(managerSektion); - - // ═══════════════════════════════════════════════════════════════ - // SEKTION 2: Aktive Instanz (Instanz-Ebene) - // ═══════════════════════════════════════════════════════════════ - - const instanzSektion = erstelleElement("section", { klasse: "einstellungen-sektion sektion-instanz" }); - - const aktiveId = holeAktiveInstanz(); - let instanzenListe = []; - try { - instanzenListe = await api("/verwaltung/instanzen"); - } catch { - // Wenn Liste nicht abrufbar, weiter - } - - const aktiveInstanz = instanzenListe.find((i) => i.id === aktiveId); - const zInfo = aktiveInstanz ? instanzZustandInfo(aktiveInstanz.zustand) : { text: t("common.none"), art: "neutral" }; - - const instanzKopf = erstelleElement("div", { klasse: "sektion-titel-zeile" }); - const instanzTitelLinks = erstelleElement("div", { klasse: "gruppe-horizontal" }); - instanzTitelLinks.append( - erstelleIcon(PFADE.fenster, 20), - erstelleElement("h2", { - text: aktiveInstanz - ? t("settings.instanceSectionTitle", { name: aktiveInstanz.name }) - : t("settings.instanceSectionTitleNone") - }), - erstellePlakette(t("settings.instanceBadge"), "erfolg"), - aktiveInstanz ? erstellePlakette(zInfo.text, zInfo.art) : erstellePlakette(t("settings.noInstanceActive"), "neutral") - ); - instanzKopf.append(instanzTitelLinks); - - const instanzIntro = erstelleElement("p", { - klasse: "gedaempft text-klein", - text: t("settings.instanceIntro") - }); - - instanzSektion.append(instanzKopf, instanzIntro); - - if (!aktiveInstanz) { - const leerKarte = erstelleKarte(t("settings.noInstanceSelected"), [ - erstelleElement("p", { - klasse: "hinweis", - text: t("settings.noInstancePrompt") - }), - erstelleElement("a", { - href: "#/instanzen", - klasse: "schaltflaeche primaer klein", - text: t("settings.toInstancesOverview") - }) - ]); - instanzSektion.append(leerKarte); - } else { - // Daten von der laufenden Instanz abfragen (sofern sie läuft) - const [instanzInfo, instanzStatus] = await Promise.all([ - api("/info").catch(() => null), - api("/status").catch(() => null) - ]); - - const instanzGitter = erstelleElement("div", { klasse: "info-gitter" }); - - // Karte 1: Instanz-Identität & Autostart - const dlIdent = erstelleElement("dl", { klasse: "datenliste" }); - dlIdent.append( - erstelleElement("dt", { text: t("settings.instanceName") }), - erstelleElement("dd", {}, erstelleElement("strong", { text: aktiveInstanz.name })), - erstelleElement("dt", { text: t("settings.instanceId") }), - erstelleElement("dd", {}, erstelleElement("code", { text: aktiveInstanz.id })), - erstelleElement("dt", { text: t("settings.configFile") }), - erstelleElement("dd", {}, erstelleElement("code", { text: `Instances/${aktiveInstanz.name}/InstanceSettings.json` })), - erstelleElement("dt", { text: t("settings.autostartByManager") }), - erstelleElement("dd", {}, erstellePlakette( - aktiveInstanz.autostart ? t("settings.autostartActive") : t("settings.autostartInactive"), - aktiveInstanz.autostart ? "erfolg" : "neutral" - )), - erstelleElement("dt", { text: t("settings.communication") }), - erstelleElement("dd", { text: t("settings.communicationValue") }) - ); - instanzGitter.append(erstelleKarte(t("settings.identityAndProcess"), [dlIdent])); - - // Karte 2: Isolierte Verzeichnisse & Speicher - const dlPfade = erstelleElement("dl", { klasse: "datenliste" }); - dlPfade.append( - erstelleElement("dt", { text: t("settings.logsPath") }), - erstelleElement("dd", {}, erstelleElement("code", { text: `Instances/${aktiveInstanz.name}/Logs/` })), - erstelleElement("dt", { text: t("settings.backupsPath") }), - erstelleElement("dd", {}, erstelleElement("code", { text: `Instances/${aktiveInstanz.name}/Backups/` })), - erstelleElement("dt", { text: t("settings.stateDbPath") }), - erstelleElement("dd", {}, erstelleElement("code", { text: `Instances/${aktiveInstanz.name}/state.db (exklusiv)` })), - erstelleElement("dt", { text: t("settings.workspacePath") }), - erstelleElement("dd", {}, erstelleElement("code", { text: `Instances/${aktiveInstanz.name}/Agents/SharedWorkspace/` })) - ); - instanzGitter.append(erstelleKarte(t("settings.isolatedPaths"), [dlPfade])); - - // Karte 3: Modellanbindung & Budget (OpenRouter) - const dlModell = erstelleElement("dl", { klasse: "datenliste" }); - if (instanzStatus) { - dlModell.append( - erstelleElement("dt", { text: t("settings.openRouterStatus") }), - erstelleElement("dd", {}, erstellePlakette( - instanzStatus.isApiReachable ? t("settings.connected") : t("settings.offline"), - instanzStatus.isApiReachable ? "erfolg" : "gefahr" - )), - erstelleElement("dt", { text: t("settings.statusMessage") }), - erstelleElement("dd", { text: instanzStatus.statusText || "—" }), - erstelleElement("dt", { text: t("settings.credits") }), - erstelleElement("dd", {}, erstelleElement("strong", { text: instanzStatus.creditsText || "—" })), - erstelleElement("dt", { text: t("settings.apiKey") }), - erstelleElement("dd", { text: t("settings.apiKeyMasked") }) - ); - } else { - dlModell.append( - erstelleElement("dt", { text: "OpenRouter" }), - erstelleElement("dd", { - klasse: "gedaempft", - text: aktiveInstanz.zustand === "Running" - ? t("settings.openRouterNoKey") - : t("settings.openRouterNotRunning") - }) - ); - } - instanzGitter.append(erstelleKarte(t("settings.openRouterCard"), [dlModell])); - - // Karte 4: Teilsysteme der Instanz - const dlSysteme = erstelleElement("dl", { klasse: "datenliste" }); - if (instanzInfo) { - dlSysteme.append( - erstelleElement("dt", { text: t("settings.agentEngine") }), - erstelleElement("dd", {}, erstellePlakette(instanzInfo.engineActive ? "Aktiv" : "Aus", instanzInfo.engineActive ? "erfolg" : "neutral")), - erstelleElement("dt", { text: t("settings.scheduler") }), - erstelleElement("dd", {}, erstellePlakette(instanzInfo.schedulerActive ? "Aktiv" : "Aus", instanzInfo.schedulerActive ? "erfolg" : "neutral")), - erstelleElement("dt", { text: t("settings.stagingService") }), - erstelleElement("dd", {}, erstellePlakette(instanzInfo.stagingActive ? "Aktiv" : "Aus", instanzInfo.stagingActive ? "erfolg" : "neutral")), - erstelleElement("dt", { text: t("settings.dcSubToken") }), - erstelleElement("dd", {}, erstellePlakette(instanzInfo.deploymentcenterActive ? "Aktiv" : "Aus", instanzInfo.deploymentcenterActive ? "erfolg" : "neutral")) - ); - } else { - dlSysteme.append( - erstelleElement("dt", { text: t("info.subsystems") }), - erstelleElement("dd", { - klasse: "gedaempft", - text: aktiveInstanz.zustand === "Running" - ? t("settings.subsystemsLoading") - : t("settings.subsystemsStopped") - }) - ); - } - instanzGitter.append(erstelleKarte(t("settings.runtimeAndSubsystems"), [dlSysteme])); - - instanzSektion.append(instanzGitter); - - // #197: external MCP servers — only a running instance knows them. - if (instanzInfo) instanzSektion.append(erstelleMcpServerKarte()); - } - - layout.append(instanzSektion); + layout.append(await managerSection(), await instanceSection()); container.append(layout); } + +// ─── Building blocks ─── + +function section(kind, icon, title, badges, intro) { + const el = erstelleElement("section", { klasse: `einstellungen-sektion ${kind}` }); + el.append( + erstelleElement("div", { klasse: "sektion-titel-zeile" }, + erstelleElement("div", { klasse: "gruppe-horizontal" }, + erstelleIcon(icon, 20), erstelleElement("h2", { text: title }), ...badges)), + erstelleElement("p", { klasse: "gedaempft text-klein", text: intro })); + return el; +} + +function textInput(value, type = "text") { + return erstelleElement("input", { type, value: value ?? "" }); +} + +function numberInput(value, min, step = "1") { + return erstelleElement("input", { type: "number", min: String(min), step, value: String(value ?? 0) }); +} + +/** A secret is never shown; the placeholder says whether one is stored. */ +function secretInput(isSet) { + return erstelleElement("input", { + type: "password", + autocomplete: "new-password", + placeholder: isSet ? t("settings.secretSet") : t("settings.secretNotSet") + }); +} + +function selectInput(options, value) { + const el = erstelleElement("select", {}, ...options.map((o) => erstelleElement("option", { value: o, text: o }))); + el.value = value; + return el; +} + +function checkbox(checked) { + return erstelleElement("input", { type: "checkbox", checked: Boolean(checked) }); +} + +/** A form of labelled fields with a save button that sends what `collect` returns. */ +function settingsForm(rows, path, collect, afterSave) { + const form = erstelleElement("form", { klasse: "formular-raster" }); + for (const [label, input, hint] of rows) { + if (input.type === "checkbox") { + form.append(erstelleElement("label", { klasse: "gruppe-horizontal" }, input, erstelleElement("span", { text: label }))); + } else { + form.append(erstelleElement("label", { text: label }), input); + } + if (hint) form.append(erstelleElement("div", { klasse: "gedaempft text-klein", text: hint })); + } + + const save = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer klein", text: t("common.save") }); + form.append(erstelleElement("div", {}, save)); + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + save.disabled = true; + try { + const result = await api(path, { method: "PUT", body: collect() }); + zeigeMeldung(result?.message ?? result?.meldung ?? t("common.saved"), "erfolg", 6000); + await afterSave?.(); + } catch (err) { + zeigeMeldung(err.message, "gefahr", 8000); + } finally { + save.disabled = false; + } + }); + + return form; +} + +function dataList(pairs) { + const dl = erstelleElement("dl", { klasse: "datenliste" }); + for (const [label, value] of pairs) { + dl.append(erstelleElement("dt", { text: label }), + value instanceof Node ? erstelleElement("dd", {}, value) : erstelleElement("dd", { text: value ?? "—" })); + } + return dl; +} + +async function refresh() { + await rendereEinstellungen(); +} + +// ─── Instance manager ─── + +async function managerSection() { + const el = section("sektion-manager", PFADE.server, t("settings.managerLevel"), + [erstellePlakette(t("settings.managerBadge"), "warnung")], t("settings.managerIntro")); + + const [license, manager] = await Promise.all([ + api("/verwaltung/license").catch(() => null), + api("/verwaltung/settings").catch(() => null) + ]); + + const grid = erstelleElement("div", { klasse: "settings-grid" }); + grid.append(licenseCard(license)); + if (manager) grid.append(managerCard(manager)); + el.append(grid); + return el; +} + +function licenseCard(license) { + if (!license) { + return erstelleKarte(t("settings.license"), [erstelleElement("p", { klasse: "gedaempft", text: t("settings.licenseUnavailable") })]); + } + + const state = !license.enforced + ? erstellePlakette(t("settings.licenseNoCheck"), "neutral") + : license.holds + ? erstellePlakette(t("settings.licenseValid"), "erfolg") + : erstellePlakette(license.keySet ? t("settings.licenseInvalid") : t("settings.licenseMissing"), "gefahr"); + + const key = secretInput(license.keySet); + const form = settingsForm( + [[t("settings.licenseKey"), key, t("settings.licenseKeyHint")]], + "/verwaltung/license", + () => ({ key: key.value }), + refresh); + + return erstelleKarte(t("settings.license"), [ + dataList([ + [t("settings.licenseState"), state], + [t("settings.hardwareId"), erstelleElement("code", { text: license.hardwareId ?? "—" })] + ]), + form + ]); +} + +function managerCard(manager) { + const dcUrl = textInput(manager.deploymentcenterUrl, "url"); + const masterToken = secretInput(manager.masterTokenSet); + const channel = selectInput(["prod", "beta", "dev"], manager.updateChannel); + + return erstelleKarte(t("settings.managerCard"), [ + dataList([ + [t("settings.configFile"), erstelleElement("code", { text: manager.configFile })], + [t("settings.instancesDirectory"), erstelleElement("code", { text: manager.instancesDirectory })], + [t("settings.webUi"), `${manager.webUiBinding} · ${manager.webUiPort}`] + ]), + settingsForm( + [ + [t("settings.dcUrl"), dcUrl], + [t("settings.dcMasterToken"), masterToken, t("settings.dcMasterTokenHint")], + [t("settings.updateChannel"), channel] + ], + "/verwaltung/settings", + () => ({ deploymentcenterUrl: dcUrl.value.trim(), masterToken: masterToken.value, updateChannel: channel.value }), + refresh), + erstelleElement("p", { klasse: "gedaempft text-klein", text: t("settings.managerRestartHint") }) + ]); +} + +// ─── Active instance ─── + +async function instanceSection() { + const activeId = holeAktiveInstanz(); + const instances = await api("/verwaltung/instanzen").catch(() => []); + const active = instances.find((i) => i.id === activeId); + const state = active ? instanzZustandInfo(active.zustand) : null; + + const el = section("sektion-instanz", PFADE.fenster, + active ? t("settings.instanceSectionTitle", { name: active.name }) : t("settings.instanceSectionTitleNone"), + [erstellePlakette(t("settings.instanceBadge"), "erfolg"), + state ? erstellePlakette(state.text, state.art) : erstellePlakette(t("settings.noInstanceActive"), "neutral")], + t("settings.instanceIntro")); + + if (!active) { + el.append(erstelleKarte(t("settings.noInstanceSelected"), [ + erstelleElement("p", { klasse: "hinweis", text: t("settings.noInstancePrompt") }), + erstelleElement("a", { href: "#/instanzen", klasse: "schaltflaeche primaer klein", text: t("settings.toInstancesOverview") }) + ])); + return el; + } + + const [settings, status, info] = await Promise.all([ + api("/settings").catch(() => null), + api("/status").catch(() => null), + api("/info").catch(() => null) + ]); + + // The instance file belongs to the instance process: it is only written while it runs. + if (!settings) { + el.append(erstelleKarte(t("settings.instanceStopped"), [ + erstelleElement("p", { klasse: "hinweis", text: t("settings.instanceStoppedHint") }) + ])); + return el; + } + + const grid = erstelleElement("div", { klasse: "settings-grid" }); + grid.append(instanceCard(settings), openRouterCard(settings, status), applicationCard(settings)); + if (info) grid.append(subsystemsCard(info)); + el.append(grid); + + // #197: external MCP servers — only a running instance knows them. + if (info) el.append(erstelleMcpServerKarte()); + return el; +} + +function instanceCard(s) { + const name = textInput(s.instanceName); + const logDirectory = textInput(s.logDirectory); + const autostart = checkbox(s.autostartByManager); + const cost = numberInput(s.dailyCostUsd, 0, "0.01"); + const tokens = numberInput(s.dailyTokens, 0); + + return erstelleKarte(t("settings.instanceCard"), [settingsForm( + [ + [t("settings.instanceName"), name], + [t("settings.logDirectory"), logDirectory, t("settings.logDirectoryHint", { path: s.effectiveLogDirectory })], + [t("settings.autostartByManager"), autostart], + [t("settings.instanceDailyCost"), cost], + [t("settings.instanceDailyTokens"), tokens, t("settings.budgetHint")] + ], + "/settings", + () => ({ + instanceName: name.value.trim(), + logDirectory: logDirectory.value.trim(), + autostartByManager: autostart.checked, + dailyCostUsd: Number(cost.value), + dailyTokens: Number(tokens.value) + }), + refresh)]); +} + +function openRouterCard(s, status) { + const key = secretInput(s.openRouterApiKeySet); + const statusList = status + ? dataList([ + [t("settings.openRouterStatus"), erstellePlakette( + status.apiReachable ? t("settings.connected") : t("settings.offline"), + status.apiReachable ? "erfolg" : "gefahr")], + [t("settings.credits"), status.creditsText || "—"] + ]) + : erstelleElement("p", { klasse: "gedaempft", text: t("settings.openRouterNoKey") }); + + return erstelleKarte(t("settings.openRouterCard"), [ + statusList, + settingsForm([[t("settings.apiKey"), key, t("settings.apiKeyHint")]], "/settings", + () => ({ openRouterApiKey: key.value }), refresh) + ]); +} + +function applicationCard(s) { + const level = selectInput(["Debug", "Info", "Warn", "Error"], s.minimumLogLevel); + const baseUrl = textInput(s.openRouterBaseUrl, "url"); + const interval = numberInput(s.statusCheckIntervalSeconds, 10); + const dcUrl = textInput(s.deploymentcenterUrl, "url"); + const dcToken = secretInput(s.deploymentcenterTokenSet); + const environment = selectInput(["production", "development", "staging"], s.deploymentcenterEnvironment); + const reporting = checkbox(s.errorReportingEnabled); + const updateCheck = checkbox(s.updateCheckEnabled); + const channel = selectInput(["prod", "beta", "dev"], s.updateChannel); + + return erstelleKarte(t("settings.applicationCard"), [ + erstelleElement("p", { klasse: "hinweis", text: t("settings.applicationHint") }), + settingsForm( + [ + [t("settings.minimumLogLevel"), level], + [t("settings.openRouterBaseUrl"), baseUrl], + [t("settings.statusCheckInterval"), interval], + [t("settings.dcUrl"), dcUrl], + [t("settings.dcToken"), dcToken], + [t("settings.dcEnvironment"), environment], + [t("settings.errorReporting"), reporting], + [t("settings.updateCheck"), updateCheck], + [t("settings.updateChannel"), channel] + ], + "/settings", + () => ({ + minimumLogLevel: level.value, + openRouterBaseUrl: baseUrl.value.trim(), + statusCheckIntervalSeconds: Number(interval.value), + deploymentcenterUrl: dcUrl.value.trim(), + deploymentcenterToken: dcToken.value, + deploymentcenterEnvironment: environment.value, + errorReportingEnabled: reporting.checked, + updateCheckEnabled: updateCheck.checked, + updateChannel: channel.value + }), + refresh) + ]); +} + +function subsystemsCard(info) { + const badge = (on) => erstellePlakette(on ? t("settings.on") : t("settings.off"), on ? "erfolg" : "neutral"); + return erstelleKarte(t("settings.runtimeAndSubsystems"), [dataList([ + [t("settings.agentEngine"), badge(info.engineActive)], + [t("settings.scheduler"), badge(info.schedulerActive)], + [t("settings.stagingService"), badge(info.stagingActive)], + [t("settings.dcSubToken"), badge(info.deploymentcenterActive)] + ])]); +} diff --git a/src/ClawdDotNet.Manager/ManagerCore.cs b/src/ClawdDotNet.Manager/ManagerCore.cs index 5c63e48..2ebef3d 100644 --- a/src/ClawdDotNet.Manager/ManagerCore.cs +++ b/src/ClawdDotNet.Manager/ManagerCore.cs @@ -118,6 +118,9 @@ public sealed class ManagerCore : IAsyncDisposable public ManagerSettings Settings => _settings.Settings; + /// Where manager.json lives — shown in the web interface. + public string SettingsPath => _settings.SettingsPath; + // ─── Hochfahren ─── /// @@ -313,6 +316,73 @@ public sealed class ManagerCore : IAsyncDisposable /// betreiben: Der Weg vom Widerruf zum Anhalten ist die Zusage, nicht die Frage, wer /// ihn ausgelöst hat. /// + /// Whether a license check is configured at all — without one nothing is locked. + public bool LicenseEnforced => _license is not null; + + /// The hardware id the license server sees, for support; null without a license check. + public string? LicenseHardwareId => _license?.HardwareId; + + /// + /// #764: A license key from the web interface. The first install of a service has no other + /// way to get one: the manager never asks (NonInteractiveLicensePrompt). + /// + /// A confirmed key lifts the lock at once — the watch starts and the autostart + /// instances come up, as they would have at startup. Without that, a freshly licensed + /// manager would still refuse every start until someone restarted the service. + /// + /// The autostart runs without the request's token: whoever closes the browser right + /// after saving should not leave half the instances started. + /// + public async Task ApplyLicenseKeyAsync(string key, CancellationToken ct = default) + { + if (_license is null) + return new LicenseApplyResult(false, false, "This instance manager has no license check configured."); + + var result = await _license.ApplyKeyAsync(key, ct).ConfigureAwait(false); + if (!result.Applied) + return result; + + var wasLocked = !LicenseHolds; + LicenseHolds = true; + _watch?.Start(); + + if (wasLocked) + { + _logger.LogInformation("License confirmed - instances may start again."); + await StartAutostartInstancesAsync(CancellationToken.None).ConfigureAwait(false); + } + + return result; + } + + /// + /// #764: Changes the manager's own settings in manager.json that the web interface + /// offers. null keeps a value; an empty master token keeps the one there is — it is + /// never sent out, so a form cannot send it back. + /// + /// Everything here is read once, when the manager starts (license server, token + /// source, update check). The answer says so instead of pretending the change is live. + /// + /// Port, binding and the access token of the web interface are deliberately not + /// offered: a wrong value there locks out the very page it was set from. + /// + public (bool Ok, string Message) UpdateSettings(string? deploymentcenterUrl, string? masterToken, string? updateChannel) + { + if (deploymentcenterUrl is { } url && url.Trim().Length > 0 + && !(Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp))) + return (false, "The Deploymentcenter address must be an http(s) address."); + if (updateChannel is { } channel && channel.Trim() is not ("prod" or "beta" or "dev")) + return (false, "The update channel is one of prod, beta, dev."); + + var settings = _settings.Settings; + if (deploymentcenterUrl is not null) settings.DeploymentcenterUrl = deploymentcenterUrl.Trim(); + if (!string.IsNullOrWhiteSpace(masterToken)) settings.DeploymentcenterMasterToken = masterToken.Trim(); + if (updateChannel is not null) settings.UpdateChannel = updateChannel.Trim(); + _settings.Save(); + + return (true, "Saved. Takes effect when the instance manager restarts."); + } + public async Task HandleLicenseRevokedAsync(string grund) { LicenseHolds = false; diff --git a/src/ClawdDotNet.Web/Contracts/Dtos.cs b/src/ClawdDotNet.Web/Contracts/Dtos.cs index 3cc9df1..d578beb 100644 --- a/src/ClawdDotNet.Web/Contracts/Dtos.cs +++ b/src/ClawdDotNet.Web/Contracts/Dtos.cs @@ -57,7 +57,9 @@ public sealed record AgentDto( IReadOnlyList ForeignContentSources, bool TreatAsForeignReader, // #712 - AgentRuntimeDto Runtime); + AgentRuntimeDto Runtime, + // #764 + string WhenExhausted); /// #712: Where an agent's runs happen. public sealed record AgentRuntimeDto( @@ -132,6 +134,29 @@ public sealed record ServiceInputDto(string Name, string Type, int Port, string /// Ein neuer Agent. public sealed record NewAgentDto(string AgentId, string DisplayName, string Model, string Description = "", string? UnitId = null); +/// #764: A change to an agent. Fields left out (null) keep their value. +public sealed record AgentUpdateDto( + string? DisplayName = null, + string? Model = null, + string? Description = null, + string? SystemPrompt = null, + string? Identity = null, + string? Soul = null, + string? PromptCaching = null, + int? MaxToolResultChars = null, + int? MaxSteps = null, + int? MaxContextTokens = null, + int? TimeoutSeconds = null, + decimal? DailyCostUsd = null, + long? DailyTokens = null, + string? WhenExhausted = null); + +/// +/// #764: The long texts of an agent — fetched for one agent at a time, not in the agent list: +/// with fifty agents the list would carry every role description in the instance. +/// +public sealed record AgentTextsDto(string SystemPrompt, string Identity, string Soul); + /// Eine Aufgabe vom Taskboard. public sealed record TaskDto( string Id, @@ -419,3 +444,27 @@ public sealed record UnitCostDto( /// #699: A cost alert as it was raised. public sealed record CostAlertDto(string Period, string Scope, string Kind, string Message, DateTime RaisedAt); + +/// +/// #764: A change to the instance and application settings. null keeps a value; for +/// the OpenRouter key and the Deploymentcenter token an empty value does as well. +/// +public sealed record SettingsUpdateDto( + string? InstanceName = null, + string? LogDirectory = null, + bool? AutostartByManager = null, + string? OpenRouterApiKey = null, + decimal? DailyCostUsd = null, + long? DailyTokens = null, + bool? AutoBackupEnabled = null, + string? AutoBackupTime = null, + int? BackupKeepCount = null, + string? MinimumLogLevel = null, + string? OpenRouterBaseUrl = null, + int? StatusCheckIntervalSeconds = null, + string? DeploymentcenterUrl = null, + string? DeploymentcenterToken = null, + string? DeploymentcenterEnvironment = null, + bool? ErrorReportingEnabled = null, + bool? UpdateCheckEnabled = null, + string? UpdateChannel = null); diff --git a/src/ClawdDotNet.Web/Contracts/Mapping.cs b/src/ClawdDotNet.Web/Contracts/Mapping.cs index 4e8e639..8ecdfa5 100644 --- a/src/ClawdDotNet.Web/Contracts/Mapping.cs +++ b/src/ClawdDotNet.Web/Contracts/Mapping.cs @@ -45,7 +45,8 @@ internal static class Mapping .ToList(), tools is null ? [] : Core.Organisation.ReportingLine.TrustOf(agent, tools).Sources, agent.TreatAsForeignReader, - agent.Runtime.ToDto()); + agent.Runtime.ToDto(), + agent.Budget.MissPolicy == Core.Tasks.BudgetMissPolicy.CatchUpOnce ? "catchUpOnce" : "expire"); public static AgentRuntimeDto ToDto(this Core.Runtimes.AgentRuntimeConfig runtime) => new(Core.Runtimes.RuntimeKinds.Normalize(runtime.Kind), runtime.AllowedTools, runtime.MaxTurns, diff --git a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs index 41c3f5c..497c189 100644 --- a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs +++ b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs @@ -34,6 +34,7 @@ internal static class ApiEndpoints api.MapOrganisation(); api.MapCases(); api.MapFileHistory(); + api.MapSettings(); api.MapGet("/team", async (AppHost host, CancellationToken ct) => (await host.Team.SnapshotAsync(ct)).ToDto()); app.MapEvents(); @@ -62,6 +63,26 @@ internal static class ApiEndpoints public static IResult Unavailable(string reason) => Results.Json(new OutcomeDto(false, reason), statusCode: StatusCodes.Status503ServiceUnavailable); + // ─── Settings (#764) ─── + + /// + /// What the desktop settings page edited. The snapshot carries no secret — only whether + /// the OpenRouter key and the Deploymentcenter token are set (). + /// + private static void MapSettings(this RouteGroupBuilder api) + { + api.MapGet("/settings", (AppHost host) => Results.Ok(host.SettingsAdmin.Snapshot())); + + api.MapPut("/settings", (AppHost host, SettingsUpdateDto input) => + host.SettingsAdmin.Update(new SettingsUpdate( + input.InstanceName, input.LogDirectory, input.AutostartByManager, input.OpenRouterApiKey, + input.DailyCostUsd, input.DailyTokens, input.AutoBackupEnabled, input.AutoBackupTime, + input.BackupKeepCount, input.MinimumLogLevel, input.OpenRouterBaseUrl, + input.StatusCheckIntervalSeconds, input.DeploymentcenterUrl, input.DeploymentcenterToken, + input.DeploymentcenterEnvironment, input.ErrorReportingEnabled, input.UpdateCheckEnabled, + input.UpdateChannel)).ToResponse()); + } + // ─── Info und Zustand ─── private static void MapInfo(this RouteGroupBuilder api) @@ -136,6 +157,19 @@ internal static class ApiEndpoints agents.MapDelete("/{agentId}", (AppHost host, string agentId) => host.AgentAdmin.Remove(agentId).ToResponse()); + // #764: What the desktop agent page edited — master data, limits, budget, prompt texts. + agents.MapPut("/{agentId}", (AppHost host, string agentId, AgentUpdateDto input) => + host.AgentAdmin.Update(agentId, new AgentUpdate( + input.DisplayName, input.Model, input.Description, input.SystemPrompt, + input.Identity, input.Soul, input.PromptCaching, input.MaxToolResultChars, + input.MaxSteps, input.MaxContextTokens, input.TimeoutSeconds, + input.DailyCostUsd, input.DailyTokens, input.WhenExhausted)).ToResponse()); + + agents.MapGet("/{agentId}/texts", (AppHost host, string agentId) => + host.AgentAdmin.Find(agentId) is { } agent + ? Results.Ok(new AgentTextsDto(agent.SystemPrompt, agent.Identity, agent.Soul)) + : Results.NotFound(new OutcomeDto(false, $"No agent with id '{agentId}'."))); + // #712: the runtime — native, or foreign in the sandbox. agents.MapPut("/{agentId}/runtime", (AppHost host, string agentId, AgentRuntimeDto input) => host.AgentAdmin.SetRuntime(agentId, input.ToConfig()).ToResponse()); diff --git a/tests/ClawdDotNet.App.Tests/Services/AgentUpdateTests.cs b/tests/ClawdDotNet.App.Tests/Services/AgentUpdateTests.cs new file mode 100644 index 0000000..b3b1d9c --- /dev/null +++ b/tests/ClawdDotNet.App.Tests/Services/AgentUpdateTests.cs @@ -0,0 +1,101 @@ +using ClawdDotNet.App.Services; +using ClawdDotNet.Core.Config; +using Shouldly; + +namespace ClawdDotNet.App.Tests.Services; + +/// +/// #764: Editing an agent from the WebUI — what the desktop agent page did directly on the +/// configuration objects, now one checked call. +/// +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); + } +} diff --git a/tests/ClawdDotNet.App.Tests/Services/SettingsAdministrationTests.cs b/tests/ClawdDotNet.App.Tests/Services/SettingsAdministrationTests.cs new file mode 100644 index 0000000..363475c --- /dev/null +++ b/tests/ClawdDotNet.App.Tests/Services/SettingsAdministrationTests.cs @@ -0,0 +1,116 @@ +using ClawdDotNet.App.Services; +using ClawdDotNet.App.Settings; +using Shouldly; + +namespace ClawdDotNet.App.Tests.Services; + +/// +/// #764: The settings the WebUI reads and writes — what the desktop settings page edited. +/// +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(); + } +} diff --git a/tests/ClawdDotNet.Manager.Tests/LicenseKeyEntryTests.cs b/tests/ClawdDotNet.Manager.Tests/LicenseKeyEntryTests.cs new file mode 100644 index 0000000..bfb343c --- /dev/null +++ b/tests/ClawdDotNet.Manager.Tests/LicenseKeyEntryTests.cs @@ -0,0 +1,155 @@ +using System.Text.Json; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Instances; +using ClawdDotNet.Licensing; +using Deploymentcenter.Client; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Manager.Tests; + +/// +/// #764: The license key entered in the web interface — the only way in for a manager that +/// runs as a service and never asks (). +/// +/// The license server is replaced by given answers. The promise checked here is not +/// "the server judges correctly" — that is the SDK's job — but "only a confirmed key is +/// stored, and a confirmed key lifts the lock without a restart". +/// +public sealed class LicenseKeyEntryTests : IAsyncDisposable +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), "clawd-tests", "lic-" + Guid.NewGuid().ToString("N")[..8]); + + private readonly FakeLauncher _launcher = new(); + private readonly FakeChannel _channel = new(); + private readonly InstanceDirectoryManager _directories; + + /// What the "server" says, per key. + private readonly Dictionary _answers = new(); + + private static readonly SupervisionOptions Options = new( + ProbeInterval: TimeSpan.FromMilliseconds(50), + StartTimeout: TimeSpan.FromMilliseconds(600), + SilentChecksBeforeAlarm: 3, + StopGrace: TimeSpan.FromMilliseconds(300), + SilentKillGrace: TimeSpan.FromMilliseconds(300), + MaxDrainWait: TimeSpan.FromSeconds(2)); + + public LicenseKeyEntryTests() + { + Directory.CreateDirectory(_root); + _directories = new InstanceDirectoryManager(Path.Combine(_root, "Instances")); + _channel.Launcher = _launcher; + + _answers["GOOD-KEY"] = new LicenseValidationResult { IsValid = true, Status = "valid" }; + _answers["REVOKED-KEY"] = new LicenseValidationResult { IsValid = false, Status = "revoked" }; + _answers["OFFLINE-KEY"] = new LicenseValidationResult { IsValid = false, Status = "server_unavailable", IsTransient = true }; + } + + public async ValueTask DisposeAsync() + { + await Task.CompletedTask; + try { Directory.Delete(_root, recursive: true); } + catch { /* A leftover test folder does not turn a run red. */ } + } + + private ManagerCore Manager() + { + var store = new ManagerSettingsStore(_root); + store.Load(); + + var gate = new LicenseGate( + new ManagerLicenseStore(store), NullLogger.Instance, + new NonInteractiveLicensePrompt(_ => { }), "0.0.0-test", + (key, _) => Task.FromResult(_answers.TryGetValue(key, out var answer) + ? answer + : new LicenseValidationResult { IsValid = false, Status = "not_found" })); + + var registry = new InstanceRegistry( + _directories, _launcher, _channel, NullLoggerFactory.Instance, Options); + + return new ManagerCore(store, registry, new FakeTokenSource(), NullLoggerFactory.Instance, license: gate); + } + + private string StoredKey() + { + var store = new ManagerSettingsStore(_root); + return store.Load().LicenseKey; + } + + private void AutostartInstance(string name) + { + var path = _directories.CreateInstance(name); + var file = Path.Combine(path, "InstanceSettings.json"); + var config = JsonSerializer.Deserialize(File.ReadAllText(file))!; + config.AutostartByManager = true; + File.WriteAllText(file, JsonSerializer.Serialize(config)); + _channel.AnswerFor[Path.GetFullPath(path)] = new InstanceIdentity("i-" + name, name, "0.1.2"); + } + + [Fact] + public async Task A_confirmed_key_is_stored_and_lifts_the_lock_at_once() + { + AutostartInstance("Alpha"); + + await using var manager = Manager(); + (await manager.StartAsync()).ShouldBeFalse(); // no key yet: locked + _launcher.Starts.ShouldBe(0); + + var result = await manager.ApplyLicenseKeyAsync(" GOOD-KEY "); + + result.Applied.ShouldBeTrue(result.Message); + manager.LicenseHolds.ShouldBeTrue(); + StoredKey().ShouldBe("GOOD-KEY"); + _launcher.Starts.ShouldBe(1); // the autostart instance came up + } + + [Fact] + public async Task A_refused_key_leaves_the_current_one_in_place() + { + await using (var first = Manager()) + (await first.ApplyLicenseKeyAsync("GOOD-KEY")).Applied.ShouldBeTrue(); + + await using var manager = Manager(); + var result = await manager.ApplyLicenseKeyAsync("REVOKED-KEY"); + + result.Applied.ShouldBeFalse(); + result.Transient.ShouldBeFalse(); + StoredKey().ShouldBe("GOOD-KEY"); + } + + [Fact] + public async Task An_unreachable_server_stores_nothing() + { + await using var manager = Manager(); + await manager.StartAsync(); + + var result = await manager.ApplyLicenseKeyAsync("OFFLINE-KEY"); + + result.Applied.ShouldBeFalse(); + result.Transient.ShouldBeTrue(); + manager.LicenseHolds.ShouldBeFalse(); + StoredKey().ShouldBeEmpty(); + } + + [Fact] + public async Task An_empty_key_is_not_sent_anywhere() + { + await using var manager = Manager(); + + (await manager.ApplyLicenseKeyAsync(" ")).Applied.ShouldBeFalse(); + } + + [Fact] + public async Task Without_a_license_check_nothing_is_stored() + { + var registry = new InstanceRegistry( + _directories, _launcher, _channel, NullLoggerFactory.Instance, Options); + await using var manager = new ManagerCore( + new ManagerSettingsStore(_root), registry, new FakeTokenSource(), NullLoggerFactory.Instance); + + (await manager.ApplyLicenseKeyAsync("GOOD-KEY")).Applied.ShouldBeFalse(); + StoredKey().ShouldBeEmpty(); + } +} diff --git a/tests/ClawdDotNet.Web.Tests/LanguagePackTests.cs b/tests/ClawdDotNet.Web.Tests/LanguagePackTests.cs new file mode 100644 index 0000000..bfac69c --- /dev/null +++ b/tests/ClawdDotNet.Web.Tests/LanguagePackTests.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Shouldly; + +namespace ClawdDotNet.Web.Tests; + +/// +/// #764 / #661: The WebUI texts come from language packs (wwwroot/i18n/en.json and +/// de.json). A key missing in one pack shows up as the bare key on screen — in the +/// language nobody tested in. So every run checks the packs, not a person after each change. +/// +public sealed partial class LanguagePackTests +{ + private static readonly string WwwRoot = Path.Combine( + FindRepositoryRoot(), "src", "ClawdDotNet.Manager.Web", "wwwroot"); + + private static string FindRepositoryRoot() + { + for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir is not null; dir = dir.Parent) + { + if (File.Exists(Path.Combine(dir.FullName, "ClawdDotNet.slnx"))) + return dir.FullName; + } + + throw new InvalidOperationException("Repository root not found above " + AppContext.BaseDirectory); + } + + private static Dictionary Pack(string language) + => JsonSerializer.Deserialize>( + File.ReadAllText(Path.Combine(WwwRoot, "i18n", language + ".json")))!; + + [GeneratedRegex(@"\{(\w+)\}")] + private static partial Regex Placeholder(); + + // t("key" … — but not erstelleElement("div": the t must stand on its own. + [GeneratedRegex(@"(? k != "trigger.instructionHint") + .Where(k => !Placeholder().Matches(en[k]).Select(m => m.Value).ToHashSet() + .SetEquals(Placeholder().Matches(de[k]).Select(m => m.Value))) + .ToList(); + + mismatches.ShouldBeEmpty(); + } + + [Fact] + public void Every_key_the_code_asks_for_exists() + { + var en = Pack("en"); + + var missing = Directory.EnumerateFiles(WwwRoot, "*.js", SearchOption.AllDirectories) + .Where(f => !f.Contains(Path.DirectorySeparatorChar + "vendor" + Path.DirectorySeparatorChar)) + .SelectMany(f => TranslateCall().Matches(File.ReadAllText(f)) + .Select(m => (File: Path.GetFileName(f), Key: m.Groups[1].Value))) + .Where(x => !en.ContainsKey(x.Key)) + // The example in the doc comment of i18n.js. + .Where(x => x.Key != "runs.count") + .Select(x => $"{x.File}: {x.Key}") + .Distinct() + .ToList(); + + missing.ShouldBeEmpty(); + } + + [Fact] + public void No_text_is_empty() + { + foreach (var language in new[] { "en", "de" }) + Pack(language).Where(p => string.IsNullOrWhiteSpace(p.Value)).Select(p => p.Key).ShouldBeEmpty(language); + } +} From d2559c763acfae7b6fe73a92a12435e871defa45 Mon Sep 17 00:00:00 2001 From: Richard Date: Fri, 2 Oct 2026 14:13:37 +0200 Subject: [PATCH 2/3] Stufe 1 (#764): Werkzeugeinstellungen, Sicherungszeitplan und Import als neue Instanz - Werkzeugformulare aus den typisierten Einstellungsklassen (L8): Geheimnisse nur schreibend, nur geaenderte Schluessel werden ersetzt, Rechte bleiben - Database.allowedTables las ein JSON-Array als rohen Text und haette es beim naechsten Speichern zerlegt - Automatische Sicherung der Instanz in der Sicherungsansicht (L10) - Sicherung hochladen und als neue Instanz einspielen (L11): Pfade der alten Maschine werden zurueckgesetzt, eine Kopie neben dem Original bekommt eine eigene ID, Autostart aus; Upload bis 4 GB - api.js schickte eine Datei als "{}"; Dialoge schliessen beim Seitenwechsel Co-Authored-By: Claude Opus 5.5 --- .../Models/ToolSettingsViewModels.cs | 7 +- .../Services/AgentAdministration.cs | 27 +++ .../Services/ToolSettingsForms.cs | 128 +++++++++++++ src/ClawdDotNet.Manager.Web/Endpoints.cs | 57 ++++++ src/ClawdDotNet.Manager.Web/wwwroot/app.css | 8 + .../wwwroot/i18n/de.json | 82 ++++++++- .../wwwroot/i18n/en.json | 82 ++++++++- .../wwwroot/modules/api.js | 4 +- .../wwwroot/modules/dom.js | 3 + .../wwwroot/views/agents.js | 113 ++++++++++-- .../wwwroot/views/backup.js | 109 ++++++++++- src/ClawdDotNet.Manager/InstanceRegistry.cs | 3 + src/ClawdDotNet.Manager/ManagerBackups.cs | 132 ++++++++++++++ src/ClawdDotNet.Web/Contracts/Dtos.cs | 3 + src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs | 13 ++ .../Services/ToolSettingsFormTests.cs | 115 ++++++++++++ .../BackupImportTests.cs | 169 ++++++++++++++++++ 17 files changed, 1027 insertions(+), 28 deletions(-) create mode 100644 src/ClawdDotNet.App/Services/ToolSettingsForms.cs create mode 100644 tests/ClawdDotNet.App.Tests/Services/ToolSettingsFormTests.cs create mode 100644 tests/ClawdDotNet.Manager.Tests/BackupImportTests.cs diff --git a/src/ClawdDotNet.App/Models/ToolSettingsViewModels.cs b/src/ClawdDotNet.App/Models/ToolSettingsViewModels.cs index 1ebbe01..1386213 100644 --- a/src/ClawdDotNet.App/Models/ToolSettingsViewModels.cs +++ b/src/ClawdDotNet.App/Models/ToolSettingsViewModels.cs @@ -220,9 +220,10 @@ public sealed class DatabaseToolSettings AccessLevel = Enum.TryParse(config.GetValueOrDefault("accessLevel")?.ToString() ?? config.GetValueOrDefault("allowWrite")?.ToString(), true, out var level) ? level : (config.GetValueOrDefault("allowWrite") is true or "True" or "true" ? DatabaseAccessLevel.ReadWrite : DatabaseAccessLevel.ReadOnly), - AllowedTables = config.GetValueOrDefault("allowedTables") is object[] arr - ? string.Join(",", arr) - : config.GetValueOrDefault("allowedTables")?.ToString() ?? "" + // #764: GetStringArray, because after loading the list is a JsonElement array. Its + // ToString() is the raw JSON - ["a","b"] - which the next save split at the commas into + // table names with brackets and quotes in them. + AllowedTables = ConfigHelper.GetStringArray(config, "allowedTables") }; } diff --git a/src/ClawdDotNet.App/Services/AgentAdministration.cs b/src/ClawdDotNet.App/Services/AgentAdministration.cs index c185339..5b65249 100644 --- a/src/ClawdDotNet.App/Services/AgentAdministration.cs +++ b/src/ClawdDotNet.App/Services/AgentAdministration.cs @@ -488,6 +488,33 @@ public sealed class AgentAdministration(AppHost host) $"Prompt history default: {Core.Transcripts.PromptHistorySettings.Describe(retentionDays)}."); } + /// + /// #764: The settings form of a tool for one agent () — + /// also for a tool the agent does not have yet, so one can see what there is to set. + /// + public IReadOnlyList? ToolSettingsForm(string agentId, string toolName) + => Find(agentId) is { } agent + ? ToolSettingsForms.Describe(toolName, agent.Tools.GetValueOrDefault(toolName) ?? new Dictionary()) + : null; + + /// + /// #764: Writes form input from the WebUI into a tool's settings. Secrets left empty keep + /// their value, keys the form does not know are kept, and the rights stay (see + /// ). + /// + public AdminResult ApplyToolSettingsForm(string agentId, string toolName, IReadOnlyDictionary input) + { + if (Find(agentId) is not { } agent) + return AdminResult.Failure($"No agent with id '{agentId}'."); + + var current = agent.Tools.GetValueOrDefault(toolName) ?? new Dictionary(); + var (settings, error) = ToolSettingsForms.Apply(toolName, current, input); + + return settings is null + ? AdminResult.Failure(error ?? "Not saved.") + : SetToolSettings(agentId, toolName, settings); + } + public AdminResult SetToolSettings( string agentId, string toolName, Dictionary settings) { diff --git a/src/ClawdDotNet.App/Services/ToolSettingsForms.cs b/src/ClawdDotNet.App/Services/ToolSettingsForms.cs new file mode 100644 index 0000000..f8f035f --- /dev/null +++ b/src/ClawdDotNet.App/Services/ToolSettingsForms.cs @@ -0,0 +1,128 @@ +using System.ComponentModel; +using System.Reflection; +using System.Text.Json; +using ClawdDotNet.Core.Security; +using ClawdDotNet.Models; + +namespace ClawdDotNet.App.Services; + +/// #764: One field of a tool's settings form. A secret carries no value, only whether one is set. +public sealed record ToolSettingField( + string Name, + string Kind, + string? Value, + bool IsSet, + IReadOnlyList Options); + +/// +/// #764: The tool settings forms of the desktop (ToolSettingsView) for the WebUI — built +/// from the typed settings classes in ToolSettingsViewModels.cs, so a new field there +/// appears in the form without a second list here. +/// +/// Secrets go one way. A field is a secret when its property is marked as a +/// password or its name is one protects (the connection string +/// carries a password but no password mark). Its value is never described, and an empty input +/// keeps the stored one. +/// +/// Only what changed is written. The typed classes do not read every stored form +/// back faithfully — a JSON array, an old key like allowWrite, a key no class knows. Writing +/// the whole round trip would rewrite all of that on every save. So the form is compared with an +/// unedited round trip, and only keys whose value the person actually changed replace the stored +/// ones; everything else stays byte for byte as it was. +/// +public static class ToolSettingsForms +{ + public const string Text = "text", Number = "number", Bool = "bool", Choice = "choice", Secret = "secret", Info = "info"; + + /// The form of a tool, or null if the tool has no settings form. + public static IReadOnlyList? Describe(string toolName, Dictionary config) + { + if (ToolSettingsFactory.CreateViewModel(toolName, config) is not { } settings) + return null; + + return Properties(settings).Select(p => + { + var value = p.GetValue(settings); + var kind = KindOf(p); + return kind == Secret + ? new ToolSettingField(p.Name, kind, null, value is string { Length: > 0 }, []) + : new ToolSettingField(p.Name, kind, Format(value), false, + p.PropertyType.IsEnum ? Enum.GetNames(p.PropertyType) : []); + }).ToList(); + } + + /// + /// Applies form input to a tool's stored settings. Returns the new settings, or an error. + /// Keys the form does not know are kept. + /// + public static (Dictionary? Settings, string? Error) Apply( + string toolName, Dictionary current, IReadOnlyDictionary input) + { + if (ToolSettingsFactory.CreateViewModel(toolName, current) is not { } settings) + return (null, $"'{toolName}' has no settings form."); + + var baseline = ToolSettingsFactory.ToConfig(toolName, ToolSettingsFactory.CreateViewModel(toolName, current)) ?? []; + var properties = Properties(settings).ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase); + + foreach (var (name, raw) in input) + { + if (!properties.TryGetValue(name, out var property)) + return (null, $"'{toolName}' has no setting '{name}'."); + + var kind = KindOf(property); + if (kind == Info) + continue; + if (kind == Secret && string.IsNullOrEmpty(raw)) + continue; // never shown, so an empty field means "keep it" + + if (Convert(property.PropertyType, raw ?? "") is not { } value) + return (null, $"'{raw}' is not a valid value for {name}."); + + property.SetValue(settings, value); + } + + var edited = ToolSettingsFactory.ToConfig(toolName, settings) ?? []; + var result = new Dictionary(current, StringComparer.Ordinal); + + foreach (var (key, value) in edited) + { + if (!baseline.TryGetValue(key, out var before) || Json(before) != Json(value)) + result[key] = value; + } + + return (result, null); + } + + private static IEnumerable Properties(object settings) + => settings.GetType() + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanRead && p.CanWrite); + + private static string KindOf(PropertyInfo p) + { + if (p.GetCustomAttribute()?.IsReadOnly == true) return Info; + if (p.GetCustomAttribute()?.Password == true || ConfigSecrets.IsSecretKey(p.Name)) return Secret; + if (p.PropertyType == typeof(bool)) return Bool; + if (p.PropertyType == typeof(int)) return Number; + if (p.PropertyType.IsEnum) return Choice; + return Text; + } + + private static string? Format(object? value) => value switch + { + null => null, + bool b => b ? "true" : "false", + _ => value.ToString() + }; + + private static object? Convert(Type type, string raw) + { + if (type == typeof(string)) return raw; + if (type == typeof(int)) return int.TryParse(raw.Trim(), out var i) ? i : null; + if (type == typeof(bool)) return bool.TryParse(raw.Trim(), out var b) ? b : null; + if (type.IsEnum) return Enum.TryParse(type, raw.Trim(), ignoreCase: true, out var e) && Enum.IsDefined(type, e!) ? e : null; + return null; + } + + private static string Json(object? value) => JsonSerializer.Serialize(value); +} diff --git a/src/ClawdDotNet.Manager.Web/Endpoints.cs b/src/ClawdDotNet.Manager.Web/Endpoints.cs index 5951586..8230c34 100644 --- a/src/ClawdDotNet.Manager.Web/Endpoints.cs +++ b/src/ClawdDotNet.Manager.Web/Endpoints.cs @@ -102,6 +102,12 @@ public sealed record ManagerSettingsUpdateDto(string? DeploymentcenterUrl, strin /// internal static class ManagerEndpoints { + /// + /// #764: Upper limit for an uploaded backup. Kestrel's default of 30 MB is far below a + /// backup with chat histories and state.db; without a limit, an upload could fill the disk. + /// + private const long MaxImportBytes = 4L * 1024 * 1024 * 1024; + // ─── Was den Manager selbst angeht ─── public static void MapVerwaltung(this WebApplication app) @@ -382,6 +388,57 @@ internal static class ManagerEndpoints } }); + // ─── Import a backup as a new instance (#764) ─── + // + // The file comes as the raw request body, not as a form: the access token is a header, + // not a cookie, so there is no cross-site form to guard against, and a raw body needs + // no antiforgery exception. The source license key travels in a header and never in + // the address, where it would end up in logs. + verwaltung.MapPost("/backups/import", async ( + HttpContext context, ManagerBackups backups, CancellationToken ct) => + { + var name = context.Request.Query["name"].ToString(); + if (string.IsNullOrWhiteSpace(name)) + return Results.BadRequest(new OutcomeDto(false, "The new instance needs a name.")); + + if (context.Features.Get() is { IsReadOnly: false } limit) + limit.MaxRequestBodySize = MaxImportBytes; + + var upload = Path.Combine(Path.GetTempPath(), "clawd-import-" + Guid.NewGuid().ToString("N") + ".bin"); + try + { + await using (var file = File.Create(upload)) + await context.Request.Body.CopyToAsync(file, ct); + + if (new FileInfo(upload).Length == 0) + return Results.BadRequest(new OutcomeDto(false, "The upload was empty.")); + + var result = await backups.ImportAsNewInstanceAsync( + upload, name, + context.Request.Headers["X-Source-License-Key"].ToString(), + context.Request.Headers["X-Backup-Passphrase"].ToString(), + CancellationToken.None); + + if (result.KeyNeeded) + return Results.Json(new KeyNeededDto(false, result.Message, "fremde_lizenz"), + statusCode: StatusCodes.Status422UnprocessableEntity); + + return result.Ok + ? Results.Ok(new OutcomeDto(true, result.Message)) + : Results.BadRequest(new OutcomeDto(false, result.Message)); + } + catch (Microsoft.AspNetCore.Http.BadHttpRequestException ex) when (ex.StatusCode == StatusCodes.Status413PayloadTooLarge) + { + return Results.Json(new OutcomeDto(false, $"The backup is larger than {MaxImportBytes / (1024 * 1024 * 1024)} GB."), + statusCode: StatusCodes.Status413PayloadTooLarge); + } + finally + { + try { File.Delete(upload); } + catch (IOException) { /* temp file; the next cleanup of the temp folder takes it */ } + } + }); + // ─── License and manager settings (#764) ─── verwaltung.MapGet("/license", (ManagerCore manager) => diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/app.css b/src/ClawdDotNet.Manager.Web/wwwroot/app.css index 8b68fe8..4822475 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/app.css +++ b/src/ClawdDotNet.Manager.Web/wwwroot/app.css @@ -1525,6 +1525,14 @@ button.klein { align-items: start; } +.settings-grid .karte { + min-width: 0; +} + +.settings-grid code { + overflow-wrap: anywhere; +} + /* ─── Responsive Optimierung (Handy / 375px) ─── */ @media (max-width: 768px) { diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json index 4e03d69..e392570 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json @@ -389,8 +389,8 @@ "agents.deleteFailed": "Löschen fehlgeschlagen: {error}", "agents.settingsTitle": "Einstellungen: {tool} ({agent})", "agents.loadingSettings": "Lade Konfiguration…", - "agents.secretHint": "Geheime Werte (Passwörter, Schlüssel) werden vom Server maskiert ausgegeben.", - "agents.noSettings": "Keine individuellen Einstellungen hinterlegt.", + "agents.secretHint": "Passwörter und Schlüssel werden nie angezeigt. Ein leeres Feld behält den gespeicherten Wert.", + "agents.noSettings": "Dieses Werkzeug hat keine Einstellungen.", "agents.loadFailed": "Laden fehlgeschlagen: {error}", "agents.rights": "Rechte", "agents.noOperations": "Dieses Werkzeug kennt keine einzelnen Operationen. Es gilt die Standardregel.", @@ -417,6 +417,69 @@ "agents.treatAsForeignReader": "Immer als Leser fremder Inhalte behandeln", "agents.treatAsForeignReaderHint": "Kann nur verschärfen. Die Stufe folgt aus den Werkzeugen. Diese Einstellung setzt sie für einen Agenten, dessen Eingaben auf anderem Weg von außen kommen.", "agents.foreignReaderSaved": "Berichtsweg von {name} gespeichert.", + "toolSettings.FileRW.PersonalAllowedExtensions": "Erlaubte Endungen (eigener Arbeitsbereich)", + "toolSettings.FileRW.PersonalAllowedExtensions.hint": "Dateiendungen für den eigenen Agenten-Workspace (z.B. .txt,.json,.md)", + "toolSettings.FileRW.SharedAccessLevel": "Zugriffslevel (geteilter Arbeitsbereich)", + "toolSettings.FileRW.SharedAccessLevel.hint": "Legt fest, welche Operationen im SharedWorkspace erlaubt sind", + "toolSettings.FileRW.SharedAllowedExtensions": "Erlaubte Endungen (geteilter Arbeitsbereich)", + "toolSettings.FileRW.SharedAllowedExtensions.hint": "Dateiendungen für den geteilten Workspace", + "toolSettings.FileRW.ProtectedPaths": "Geschützte Pfade", + "toolSettings.FileRW.ProtectedPaths.hint": "Komma-getrennte Pfade im SharedWorkspace, die append-only sind (z.B. stocks/,archives/). Dateien dort können nur erstellt, nicht überschrieben oder gelöscht werden. Admin-Level umgeht den Schutz.", + "toolSettings.Mail.Username": "Benutzername", + "toolSettings.Mail.Password": "Passwort", + "toolSettings.Mail.ImapHost": "IMAP-Host", + "toolSettings.Mail.ImapPort": "IMAP-Port", + "toolSettings.Mail.SmtpHost": "SMTP-Host", + "toolSettings.Mail.SmtpPort": "SMTP-Port", + "toolSettings.Mail.AllowedRecipients": "Erlaubte Empfänger", + "toolSettings.Mail.AllowedRecipients.hint": "Komma-getrennte Liste erlaubter E-Mail-Adressen", + "toolSettings.Database.Type": "Typ", + "toolSettings.Database.Type.hint": "Der zu verwendende Datenbanktyp", + "toolSettings.Database.ConnectionString": "Connection-String", + "toolSettings.Database.AccessLevel": "Zugriffsebene", + "toolSettings.Database.AccessLevel.hint": "Legt fest, welche Operationen der Agent ausführen darf", + "toolSettings.Database.AllowedTables": "Erlaubte Tabellen", + "toolSettings.Database.AllowedTables.hint": "Komma-getrennte Liste erlaubter Tabellen/Collections", + "toolSettings.FTP.Host": "Host", + "toolSettings.FTP.Port": "Port", + "toolSettings.FTP.Username": "Benutzername", + "toolSettings.FTP.Password": "Passwort", + "toolSettings.FTP.RootPath": "Root-Pfad", + "toolSettings.FTP.RootPath.hint": "Basisverzeichnis für Dateiübertragungen", + "toolSettings.Telegram.BotToken": "Bot-Token", + "toolSettings.Telegram.DefaultChatId": "Standard Chat-ID", + "toolSettings.Telegram.DefaultChatId.hint": "Die Standard-ID, an die Nachrichten gesendet werden, wenn keine andere ID angegeben ist.", + "toolSettings.Telegram.AllowedChatIds": "Erlaubte Chat-IDs", + "toolSettings.Telegram.AllowedChatIds.hint": "Komma-getrennte Liste erlaubter Chat-IDs", + "toolSettings.DirectAPI.DefaultProvider": "Standard-Provider", + "toolSettings.DirectAPI.CacheTtlSeconds": "Cache TTL (Sekunden)", + "toolSettings.DirectAPI.TwelveDataKey": "Twelve Data Key", + "toolSettings.DirectAPI.AlphaVantageKey": "Alpha Vantage Key", + "toolSettings.WebFetch.AllowedDomains": "Erlaubte Domains", + "toolSettings.WebFetch.AllowedDomains.hint": "Komma-getrennte Liste (z.B. reuters.com,bloomberg.com)", + "toolSettings.WebFetch.MaxResponseKb": "Max. Antwort (KB)", + "toolSettings.WebFetch.UserAgent": "User Agent", + "toolSettings.WebMonitor.MonitorsJson": "Monitore (JSON)", + "toolSettings.WebMonitor.MonitorsJson.hint": "JSON-Konfiguration der Monitore", + "toolSettings.AgentComm.Status.hint": "Dieses Tool benötigt keine Konfiguration. Es ermöglicht Agenten, mit anderen Agenten in der gleichen Instanz zu kommunizieren.", + "toolSettings.SocialMediaManager.XApiKey": "Bearer Token", + "toolSettings.SocialMediaManager.XApiKey.hint": "X API v2 Bearer Token für die Authentifizierung", + "toolSettings.SocialMediaManager.XWatchAccounts": "Überwachte Accounts", + "toolSettings.SocialMediaManager.XWatchAccounts.hint": "Komma-getrennte Liste von X-Accounts, die überwacht werden sollen (ohne @). Beispiel: elonmusk,unusual_whales,DeItaone", + "toolSettings.SocialMediaManager.RedditWatchSubreddits": "Überwachte Subreddits", + "toolSettings.SocialMediaManager.RedditWatchSubreddits.hint": "Komma-getrennte Liste von Subreddits, die überwacht werden sollen (ohne r/). Beispiel: wallstreetbets,stocks,options", + "toolSettings.SocialMediaManager.RedditPostLimit": "Posts pro Subreddit", + "toolSettings.SocialMediaManager.RedditPostLimit.hint": "Maximale Anzahl Posts, die pro Subreddit bei jedem Check abgerufen werden (Standard: 15)", + "toolSettings.SocialMediaManager.OpenRouterApiKey": "OpenRouter API Key", + "toolSettings.SocialMediaManager.OpenRouterApiKey.hint": "API Key für Speech-to-Text-Transkription über OpenRouter", + "toolSettings.SocialMediaManager.STTModel": "STT-Modell", + "toolSettings.SocialMediaManager.STTModel.hint": "OpenRouter Modell-ID für die Transkription", + "toolSettings.SocialMediaManager.YoutubeChannels": "YouTube-Kanäle", + "toolSettings.SocialMediaManager.YoutubeChannels.hint": "Komma-getrennte Liste von YouTube-Kanal-URLs für automatische Überwachung", + "toolSettings.AgentEditor.Status.hint": "Erlaubt dem Agenten, Identity und Soul anderer Agenten zu lesen und Änderungen vorzuschlagen. Änderungen und neue Agenten gehen in die Freigabeliste.", + "toolSettings.AgentEditor.AllowSelfEdit": "Eigene Identity/Soul bearbeiten", + "toolSettings.AgentEditor.AllowSelfEdit.hint": "Darf der Agent Änderungen an seiner eigenen Identity und Soul vorschlagen? Standard: nein — sonst könnte ein Agent sich die Grenzen wegschreiben, an denen er scheitert (#205).", + "toolSettings.AgentSpawn.Status.hint": "Dieses Tool benötigt keine Konfiguration. Es ermöglicht Agenten, andere Agenten zu starten und ihnen Aufgaben zuzuweisen.", "agents.edit": "Bearbeiten", "agents.editTooltip": "Name, Modell, Grenzen, Budget und Prompttexte bearbeiten", "agents.editTitle": "Agent bearbeiten — {name}", @@ -556,6 +619,21 @@ "backup.sourceLicensePlaceholder": "Lizenzschlüssel der Installation, von der die Sicherung stammt…", "backup.restoreWithKey": "Mit diesem Schlüssel wiederherstellen", "backup.keyMismatch": "Dieser Lizenzschlüssel passt nicht zu der Sicherung.", + "backup.scheduleTitle": "Automatische Sicherung", + "backup.scheduleNeedsRunning": "Der Zeitplan steht in der Instanzdatei, die nur die laufende Instanz schreibt. Instanz starten, um ihn zu ändern.", + "backup.scheduleEnabled": "Diese Instanz täglich sichern", + "backup.scheduleTime": "Uhrzeit", + "backup.scheduleKeep": "Sicherungen behalten", + "backup.scheduleKeepHint": "Die neuesten bleiben, ältere werden entfernt. 0 behält alle.", + "backup.importTitle": "Sicherung als neue Instanz einspielen", + "backup.importHint": "Für den Umzug auf diese Maschine: Die Sicherung wird zu einer neuen Instanz. Bestehende Instanzen bleiben unberührt. Die neue Instanz startet nicht von selbst.", + "backup.importFile": "Sicherungsdatei", + "backup.importName": "Name der neuen Instanz", + "backup.importNamePlaceholder": "z. B. Produktion", + "backup.importButton": "Einspielen", + "backup.importing": "Wird eingespielt…", + "backup.imported": "Sicherung eingespielt.", + "backup.importFailed": "Einspielen fehlgeschlagen: {error}", "backup.deleteTitle": "Sicherung löschen", "backup.deleteConfirm": "Soll die Sicherungsdatei '{file}' wirklich unwiderruflich gelöscht werden?", "backup.deleted": "Sicherung gelöscht.", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json index 98dd0d5..1cf69c0 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json @@ -389,8 +389,8 @@ "agents.deleteFailed": "Deletion failed: {error}", "agents.settingsTitle": "Settings: {tool} ({agent})", "agents.loadingSettings": "Loading configuration…", - "agents.secretHint": "Secret values (passwords, keys) are masked by the server.", - "agents.noSettings": "No custom settings configured.", + "agents.secretHint": "Passwords and keys are never shown. Leave a field empty to keep the stored value.", + "agents.noSettings": "This tool has no settings.", "agents.loadFailed": "Loading failed: {error}", "agents.rights": "Permissions", "agents.noOperations": "This tool has no individual operations. Default rule applies.", @@ -417,6 +417,69 @@ "agents.treatAsForeignReader": "Always treat as reading outside content", "agents.treatAsForeignReaderHint": "Can only tighten. The tools decide the tier; this adds it for an agent whose input comes from outside some other way.", "agents.foreignReaderSaved": "Reporting line of {name} saved.", + "toolSettings.FileRW.PersonalAllowedExtensions": "Allowed extensions (own workspace)", + "toolSettings.FileRW.PersonalAllowedExtensions.hint": "File extensions for the agent’s own workspace (e.g. .txt,.json,.md)", + "toolSettings.FileRW.SharedAccessLevel": "Access level (shared workspace)", + "toolSettings.FileRW.SharedAccessLevel.hint": "Which operations are allowed in the SharedWorkspace", + "toolSettings.FileRW.SharedAllowedExtensions": "Allowed extensions (shared workspace)", + "toolSettings.FileRW.SharedAllowedExtensions.hint": "File extensions for the shared workspace", + "toolSettings.FileRW.ProtectedPaths": "Protected paths", + "toolSettings.FileRW.ProtectedPaths.hint": "Comma-separated paths in the SharedWorkspace that are append-only (e.g. stocks/,archives/). Files there can be created, not overwritten or deleted. The Admin level bypasses the protection.", + "toolSettings.Mail.Username": "Username", + "toolSettings.Mail.Password": "Password", + "toolSettings.Mail.ImapHost": "IMAP host", + "toolSettings.Mail.ImapPort": "IMAP port", + "toolSettings.Mail.SmtpHost": "SMTP host", + "toolSettings.Mail.SmtpPort": "SMTP port", + "toolSettings.Mail.AllowedRecipients": "Allowed recipients", + "toolSettings.Mail.AllowedRecipients.hint": "Comma-separated list of allowed e-mail addresses", + "toolSettings.Database.Type": "Type", + "toolSettings.Database.Type.hint": "The database type to use", + "toolSettings.Database.ConnectionString": "Connection string", + "toolSettings.Database.AccessLevel": "Access level", + "toolSettings.Database.AccessLevel.hint": "Which operations the agent may perform", + "toolSettings.Database.AllowedTables": "Allowed tables", + "toolSettings.Database.AllowedTables.hint": "Comma-separated list of allowed tables/collections", + "toolSettings.FTP.Host": "Host", + "toolSettings.FTP.Port": "Port", + "toolSettings.FTP.Username": "Username", + "toolSettings.FTP.Password": "Password", + "toolSettings.FTP.RootPath": "Root path", + "toolSettings.FTP.RootPath.hint": "Base directory for file transfers", + "toolSettings.Telegram.BotToken": "Bot token", + "toolSettings.Telegram.DefaultChatId": "Default chat ID", + "toolSettings.Telegram.DefaultChatId.hint": "The ID messages go to when no other is given.", + "toolSettings.Telegram.AllowedChatIds": "Allowed chat IDs", + "toolSettings.Telegram.AllowedChatIds.hint": "Comma-separated list of allowed chat IDs", + "toolSettings.DirectAPI.DefaultProvider": "Default provider", + "toolSettings.DirectAPI.CacheTtlSeconds": "Cache TTL (seconds)", + "toolSettings.DirectAPI.TwelveDataKey": "Twelve Data key", + "toolSettings.DirectAPI.AlphaVantageKey": "Alpha Vantage key", + "toolSettings.WebFetch.AllowedDomains": "Allowed domains", + "toolSettings.WebFetch.AllowedDomains.hint": "Comma-separated list (e.g. reuters.com,bloomberg.com)", + "toolSettings.WebFetch.MaxResponseKb": "Max. response (KB)", + "toolSettings.WebFetch.UserAgent": "User agent", + "toolSettings.WebMonitor.MonitorsJson": "Monitors (JSON)", + "toolSettings.WebMonitor.MonitorsJson.hint": "JSON configuration of the monitors", + "toolSettings.AgentComm.Status.hint": "This tool needs no configuration. It lets agents talk to other agents in the same instance.", + "toolSettings.SocialMediaManager.XApiKey": "Bearer token", + "toolSettings.SocialMediaManager.XApiKey.hint": "X API v2 bearer token for authentication", + "toolSettings.SocialMediaManager.XWatchAccounts": "Watched accounts", + "toolSettings.SocialMediaManager.XWatchAccounts.hint": "Comma-separated list of X accounts to watch (without @). Example: elonmusk,unusual_whales,DeItaone", + "toolSettings.SocialMediaManager.RedditWatchSubreddits": "Watched subreddits", + "toolSettings.SocialMediaManager.RedditWatchSubreddits.hint": "Comma-separated list of subreddits to watch (without r/). Example: wallstreetbets,stocks,options", + "toolSettings.SocialMediaManager.RedditPostLimit": "Posts per subreddit", + "toolSettings.SocialMediaManager.RedditPostLimit.hint": "Maximum number of posts fetched per subreddit on each check (default: 15)", + "toolSettings.SocialMediaManager.OpenRouterApiKey": "OpenRouter API key", + "toolSettings.SocialMediaManager.OpenRouterApiKey.hint": "API key for speech-to-text transcription via OpenRouter", + "toolSettings.SocialMediaManager.STTModel": "STT model", + "toolSettings.SocialMediaManager.STTModel.hint": "OpenRouter model ID for transcription", + "toolSettings.SocialMediaManager.YoutubeChannels": "YouTube channels", + "toolSettings.SocialMediaManager.YoutubeChannels.hint": "Comma-separated list of YouTube channel URLs to watch automatically", + "toolSettings.AgentEditor.Status.hint": "Lets the agent read the Identity and Soul of other agents and propose changes. Changes and new agents go to the approval list.", + "toolSettings.AgentEditor.AllowSelfEdit": "Edit own Identity/Soul", + "toolSettings.AgentEditor.AllowSelfEdit.hint": "May the agent propose changes to its own Identity and Soul? Default: no — otherwise an agent could write away the limits it fails at (#205).", + "toolSettings.AgentSpawn.Status.hint": "This tool needs no configuration. It lets agents start other agents and give them tasks.", "agents.edit": "Edit", "agents.editTooltip": "Edit name, model, limits, budget and prompt texts", "agents.editTitle": "Edit agent — {name}", @@ -556,6 +619,21 @@ "backup.sourceLicensePlaceholder": "License key of the installation where the backup originated…", "backup.restoreWithKey": "Restore with this Key", "backup.keyMismatch": "This license key does not match the backup.", + "backup.scheduleTitle": "Automatic backup", + "backup.scheduleNeedsRunning": "The schedule is part of the instance file, which only the running instance writes. Start the instance to change it.", + "backup.scheduleEnabled": "Back up this instance every day", + "backup.scheduleTime": "Time of day", + "backup.scheduleKeep": "Keep backups", + "backup.scheduleKeepHint": "The newest ones are kept, older ones removed. 0 keeps all.", + "backup.importTitle": "Import backup as new instance", + "backup.importHint": "For a move to this machine: the backup becomes a new instance. Existing instances are not touched. The new instance does not start by itself.", + "backup.importFile": "Backup file", + "backup.importName": "Name of the new instance", + "backup.importNamePlaceholder": "e.g. Production", + "backup.importButton": "Import", + "backup.importing": "Importing…", + "backup.imported": "Backup imported.", + "backup.importFailed": "Import failed: {error}", "backup.deleteTitle": "Delete Backup", "backup.deleteConfirm": "Really permanently delete backup file '{file}'?", "backup.deleted": "Backup deleted.", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/modules/api.js b/src/ClawdDotNet.Manager.Web/wwwroot/modules/api.js index 306aa80..2e8f0de 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/modules/api.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/modules/api.js @@ -131,7 +131,9 @@ export async function api(pfad, optionen = {}) { kopfzeilen["X-Instanz"] = aktiveInstanz; } - if (optionen.body && typeof optionen.body === "object" && !(optionen.body instanceof FormData)) { + // A file (Blob) goes as it is — #764: backup import. JSON.stringify would turn it into "{}". + if (optionen.body && typeof optionen.body === "object" + && !(optionen.body instanceof FormData) && !(optionen.body instanceof Blob)) { kopfzeilen["Content-Type"] = "application/json"; optionen.body = JSON.stringify(optionen.body); } diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/modules/dom.js b/src/ClawdDotNet.Manager.Web/wwwroot/modules/dom.js index 4b25dca..243c010 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/modules/dom.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/modules/dom.js @@ -223,6 +223,7 @@ export function erstelleModal(titel, inhalt, aktionen = [], optionen = {}) { overlay.hidden = true; overlay.classList.remove("sichtbar"); document.removeEventListener("keydown", tasteHandler); + window.removeEventListener("hashchange", schliessen); if (typeof optionen.onSchliessen === "function") { optionen.onSchliessen(); } @@ -232,6 +233,8 @@ export function erstelleModal(titel, inhalt, aktionen = [], optionen = {}) { overlay.hidden = false; overlay.classList.add("sichtbar"); document.addEventListener("keydown", tasteHandler); + // #764: A dialog belongs to the view it was opened from; navigating away closes it. + window.addEventListener("hashchange", schliessen); const erstesInput = box.querySelector("input, select, textarea, button:not(.schaltflaeche-icon)"); erstesInput?.focus(); }; diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/agents.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/agents.js index 707a0e1..993d19d 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/views/agents.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/agents.js @@ -615,29 +615,18 @@ async function zeigeWerkzeugEinstellungen(agentId, toolName) { modal.oeffnen(); try { - const daten = await api( - `/agents/${encodeURIComponent(agentId)}/tools/${encodeURIComponent(toolName)}/settings` + const fields = await api( + `/agents/${encodeURIComponent(agentId)}/tools/${encodeURIComponent(toolName)}/form` ); box.replaceChildren(); - const hinweis = erstelleElement("p", { - klasse: "hinweis", - text: t("agents.secretHint") - }); - box.append(hinweis); - - if (!daten || Object.keys(daten).length === 0) { - box.append( - erstelleElement("p", { klasse: "gedaempft", text: t("agents.noSettings") }) - ); + if (!fields || fields.length === 0) { + box.append(erstelleElement("p", { klasse: "gedaempft", text: t("agents.noSettings") })); } else { - const dl = erstelleElement("dl", { klasse: "datenliste" }); - for (const [schluessel, wert] of Object.entries(daten)) { - const dt = erstelleElement("dt", { text: schluessel }); - const dd = erstelleElement("dd", { text: typeof wert === "object" ? JSON.stringify(wert) : String(wert) }); - dl.append(dt, dd); - } - box.append(dl); + box.append( + erstelleElement("p", { klasse: "hinweis", text: t("agents.secretHint") }), + toolSettingsForm(agentId, toolName, fields) + ); } } catch (err) { box.replaceChildren( @@ -650,6 +639,92 @@ async function zeigeWerkzeugEinstellungen(agentId, toolName) { await zeigeWerkzeugrechte(agentId, toolName, rechteBox); } +/** Label of a tool setting; the field name if the language pack has none. */ +function fieldLabel(toolName, field) { + const key = `toolSettings.${toolName}.${field.name}`; + const text = t(key); + return text === key ? field.name : text; +} + +/** + * #764: The settings form of a tool — built from the field list the API gives, so a new field + * in a tool's settings class appears without a change here. Secrets are never filled in: an + * empty password field keeps the stored value. + */ +function toolSettingsForm(agentId, toolName, fields) { + const form = erstelleElement("form", { klasse: "formular-raster" }); + const inputs = new Map(); + + for (const field of fields) { + const label = fieldLabel(toolName, field); + const hintKey = `toolSettings.${toolName}.${field.name}.hint`; + const hint = t(hintKey) === hintKey ? "" : t(hintKey); + let input; + + switch (field.kind) { + case "info": + form.append(erstelleElement("p", { klasse: "gedaempft", text: hint || field.value || "" })); + continue; + case "bool": + input = erstelleElement("input", { type: "checkbox", checked: field.value === "true" }); + form.append(erstelleElement("label", { klasse: "gruppe-horizontal" }, input, erstelleElement("span", { text: label }))); + break; + case "choice": + input = erstelleElement("select", {}, ...field.options.map((o) => erstelleElement("option", { value: o, text: o }))); + input.value = field.value ?? ""; + form.append(erstelleElement("label", { text: label }), input); + break; + case "secret": + input = erstelleElement("input", { + type: "password", + autocomplete: "new-password", + placeholder: field.isSet ? t("settings.secretSet") : t("settings.secretNotSet") + }); + form.append(erstelleElement("label", { text: label }), input); + break; + case "number": + input = erstelleElement("input", { type: "number", value: field.value ?? "" }); + form.append(erstelleElement("label", { text: label }), input); + break; + default: + input = field.name.toLowerCase().endsWith("json") + ? erstelleElement("textarea", { rows: "6" }) + : erstelleElement("input", { type: "text" }); + input.value = field.value ?? ""; + form.append(erstelleElement("label", { text: label }), input); + } + + if (hint) form.append(erstelleElement("div", { klasse: "gedaempft text-klein", text: hint })); + inputs.set(field.name, { field, input }); + } + + const save = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer klein", text: t("common.save") }); + form.append(erstelleElement("div", {}, save)); + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + const values = {}; + for (const [name, { field, input }] of inputs) { + values[name] = field.kind === "bool" ? String(input.checked) : input.value; + } + save.disabled = true; + try { + const result = await api( + `/agents/${encodeURIComponent(agentId)}/tools/${encodeURIComponent(toolName)}/form`, + { method: "PUT", body: { values } }); + zeigeMeldung(result?.message ?? t("common.saved"), "erfolg"); + // Saved secrets show as "stored" now; reload the fields rather than trusting the form. + const fresh = await api(`/agents/${encodeURIComponent(agentId)}/tools/${encodeURIComponent(toolName)}/form`); + form.replaceWith(toolSettingsForm(agentId, toolName, fresh)); + } catch (err) { + zeigeMeldung(t("agents.saveFailed", { error: err.message }), "gefahr"); + save.disabled = false; + } + }); + + return form; +} + function getWirkung(effective) { const map = { auto: [t("agents.effectAuto"), "erfolg"], diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/backup.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/backup.js index 1e85424..b3f281b 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/views/backup.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/backup.js @@ -97,11 +97,17 @@ export async function rendereSicherung() { ); } - layout.append(infoBereich, inhaltBereich, ftpBereich); + // #764: the schedule of the active instance, and importing a backup as a new instance. + const scheduleArea = erstelleElement("div", { klasse: "sicherung-ftp-bereich" }); + const importArea = erstelleElement("div", { klasse: "sicherung-ftp-bereich" }); + + layout.append(infoBereich, inhaltBereich, scheduleArea, ftpBereich, importArea); container.append(layout); // Der FTP-Upload gilt für alle Instanzen und ist deshalb auch ohne Auswahl da. rendereFtp(ftpBereich); + renderImport(importArea); + if (instanzId) renderSchedule(scheduleArea); if (!instanzId) return; @@ -505,3 +511,104 @@ async function rendereFtp(bereich) { bereich.replaceChildren(erstelleKarte(t("backup.ftpTitle"), [form])); } + +// ─── Automatic backup of the active instance (#764) ─── +// +// The manager makes the daily backup and reads switch, time and retention from the instance +// file. That file belongs to the instance process, so it is written through the instance +// (/api/settings) — which is why the instance has to be running to change it. + +async function renderSchedule(area) { + let settings; + try { + settings = await api("/settings"); + } catch { + area.replaceChildren(erstelleKarte(t("backup.scheduleTitle"), [ + erstelleElement("p", { klasse: "hinweis", text: t("backup.scheduleNeedsRunning") }) + ])); + return; + } + + const form = erstelleElement("form", { klasse: "formular" }); + const enabled = checkbox("backup-auto", t("backup.scheduleEnabled"), settings.autoBackupEnabled); + const time = erstelleElement("input", { type: "time", value: settings.autoBackupTime || "03:00" }); + const keep = erstelleElement("input", { type: "number", min: "0", max: "365", value: String(settings.backupKeepCount ?? 14) }); + const save = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer", text: t("common.save") }); + + form.append( + enabled.gruppe, + feld(t("backup.scheduleTime"), time), + feld(t("backup.scheduleKeep"), keep, t("backup.scheduleKeepHint")), + erstelleElement("div", { klasse: "gruppe-horizontal" }, save)); + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + save.disabled = true; + try { + const result = await api("/settings", { + method: "PUT", + body: { autoBackupEnabled: enabled.box.checked, autoBackupTime: time.value, backupKeepCount: Number(keep.value) } + }); + zeigeMeldung(result?.message ?? t("common.saved"), "erfolg"); + } catch (err) { + zeigeMeldung(err.message, "gefahr"); + } finally { + save.disabled = false; + } + }); + + area.replaceChildren(erstelleKarte(t("backup.scheduleTitle"), [form])); +} + +// ─── Import a backup as a new instance (#764) ─── +// +// The move to a fresh machine: there is no instance yet, so no backup list to pick from. +// A backup from another installation needs that installation's license key; the server says +// so (code "fremde_lizenz") and the field appears. + +function renderImport(area) { + const form = erstelleElement("form", { klasse: "formular" }); + const file = erstelleElement("input", { type: "file", accept: ".clawdbak,.zip", required: true }); + const name = erstelleElement("input", { type: "text", required: true, placeholder: t("backup.importNamePlaceholder") }); + const key = erstelleElement("input", { type: "password", autocomplete: "off", placeholder: t("backup.sourceLicensePlaceholder") }); + const keyField = feld(t("backup.sourceLicenseLabel"), key, t("backup.sourceLicenseHint")); + keyField.hidden = true; + const submit = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer", text: t("backup.importButton") }); + + form.append( + erstelleElement("p", { klasse: "gedaempft", text: t("backup.importHint") }), + feld(t("backup.importFile"), file), + feld(t("backup.importName"), name), + keyField, + erstelleElement("div", { klasse: "gruppe-horizontal" }, submit)); + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + const chosen = file.files?.[0]; + if (!chosen) return; + + const headers = { "Content-Type": "application/octet-stream" }; + if (key.value.trim()) headers["X-Source-License-Key"] = key.value.trim(); + + submit.disabled = true; + submit.textContent = t("backup.importing"); + try { + const result = await api(`/verwaltung/backups/import?name=${encodeURIComponent(name.value.trim())}`, { + method: "POST", + headers, + body: chosen + }); + zeigeMeldung(result?.meldung ?? result?.message ?? t("backup.imported"), "erfolg", 8000); + form.reset(); + keyField.hidden = true; + } catch (err) { + if (err.code === "fremde_lizenz") keyField.hidden = false; + zeigeMeldung(t("backup.importFailed", { error: err.message }), "gefahr", 8000); + } finally { + submit.disabled = false; + submit.textContent = t("backup.importButton"); + } + }); + + area.replaceChildren(erstelleKarte(t("backup.importTitle"), [form])); +} diff --git a/src/ClawdDotNet.Manager/InstanceRegistry.cs b/src/ClawdDotNet.Manager/InstanceRegistry.cs index 2efef61..8cf33cc 100644 --- a/src/ClawdDotNet.Manager/InstanceRegistry.cs +++ b/src/ClawdDotNet.Manager/InstanceRegistry.cs @@ -121,6 +121,9 @@ public sealed class InstanceRegistry : IAsyncDisposable /// nach einem Neustart der Maschine auch das starten, was jemand mit Absicht /// angehalten hat. /// + /// #764: Where the instances live — for importing a backup as a new one. + public InstanceDirectoryManager Directories => _directories; + public async Task RefreshAsync(CancellationToken ct = default) { // ListInstances liest den Namen aus InstanceSettings.json gleich mit - und tut diff --git a/src/ClawdDotNet.Manager/ManagerBackups.cs b/src/ClawdDotNet.Manager/ManagerBackups.cs index 97b5fdf..a359bf8 100644 --- a/src/ClawdDotNet.Manager/ManagerBackups.cs +++ b/src/ClawdDotNet.Manager/ManagerBackups.cs @@ -12,6 +12,10 @@ namespace ClawdDotNet.Manager; public sealed record BackupOutcome( string Path, string FileName, long SizeBytes, bool Uploaded, string? UploadProblem); +/// #764: What importing a backup as a new instance came to. +/// The backup is from another installation; its license key is needed. +public sealed record ImportOutcome(bool Ok, string Message, bool KeyNeeded = false, string? Directory = null); + /// /// Die Sicherungen aller Instanzen, im Manager. /// @@ -179,6 +183,134 @@ public sealed class ManagerBackups : IAsyncDisposable } } + /// + /// #764: Restores an uploaded backup as a new instance — the move to a fresh machine, + /// which the desktop restore window could do and the WebUI could not: there, no instance and + /// therefore no backup list exists yet. + /// + /// Never into an existing directory. Restoring over an instance is the other + /// endpoint, which stops it first. Here the target must not exist, and if anything fails it + /// is removed again — it did not exist before, so nothing of value goes with it. + /// + /// The restored instance file is from another place. Its working directory, and + /// absolute log and backup directories, point at the old machine — or, for a copy on this + /// machine, into the original instance, which would then get the copy's logs and lose + /// backups to its rotation. They are reset to the new directory. A copy whose id already + /// exists here gets a new id and loses the reporting token, so the two do not report as one. + /// Autostart is switched off: a just-imported instance starts when someone starts it. + /// + public async Task ImportAsNewInstanceAsync( + string archivePath, string name, string? sourceLicenseKey, string? passphrase, CancellationToken ct = default) + { + name = name.Trim(); + if (name.Length == 0) + return new ImportOutcome(false, "The new instance needs a name."); + + var directories = _registry.Directories; + var target = directories.GetInstancePath(name); + if (Directory.Exists(target)) + return new ImportOutcome(false, $"An instance '{name}' already exists."); + + string? key = null; + if (LicenseBackupCipher.IsEncrypted(archivePath)) + { + var given = !string.IsNullOrWhiteSpace(sourceLicenseKey); + key = given ? sourceLicenseKey : LicenseKey; + + if (key is null || !LicenseBackupCipher.MatchesKey(archivePath, key)) + { + return new ImportOutcome(false, given + ? "The license key given does not match this backup." + : "This backup comes from another installation. Its license key is needed to restore it.", + KeyNeeded: true); + } + } + + try + { + await _service.RestoreAsync(archivePath, target, new RestoreOptions + { + Passphrase = string.IsNullOrEmpty(passphrase) ? null : passphrase, + LicenseKey = key, + Overwrite = false + }, ct).ConfigureAwait(false); + + var notes = AdoptImportedConfig(directories, target, name); + await _registry.RefreshAsync(ct).ConfigureAwait(false); + + _logger.LogInformation("Backup imported as new instance {Name}: {Path}", name, target); + + return new ImportOutcome(true, + $"Imported as new instance '{name}'." + (notes.Count == 0 ? "" : " " + string.Join(" ", notes)), + Directory: target); + } + catch (Exception ex) when (ex is BackupException or IOException or InvalidDataException + or UnauthorizedAccessException or InvalidOperationException + or JsonException or FileNotFoundException) + { + TryRemove(target); + _logger.LogWarning(ex, "Import of a backup as instance {Name} failed", name); + return new ImportOutcome(false, $"Import failed: {ex.Message}"); + } + } + + private List AdoptImportedConfig(InstanceDirectoryManager directories, string target, string name) + { + var notes = new List(); + var config = directories.LoadInstanceConfig(target); + + config.InstanceName = name; + config.WorkingDirectory = target; + config.AutostartByManager = false; + + if (Path.IsPathRooted(config.LogDirectory)) + { + config.LogDirectory = ""; + notes.Add("The log directory pointed outside the instance and now uses the default."); + } + + if (Path.IsPathRooted(config.BackupDirectory)) + { + config.BackupDirectory = ""; + notes.Add("The backup directory pointed outside the instance and now uses the default."); + } + + var taken = directories.ListInstances() + .Where(i => !PathBoundary.Comparer.Equals(Path.GetFullPath(i.FolderPath), Path.GetFullPath(target))) + .Select(i => TryReadId(directories, i.FolderPath)) + .Where(id => id is not null) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (string.IsNullOrWhiteSpace(config.InstanceId) || taken.Contains(config.InstanceId)) + { + config.InstanceId = Guid.NewGuid().ToString("N")[..8]; + config.Watchdog.AgentToken = ""; + notes.Add("An instance with the same id exists here, so the copy got a new id and will get its own reporting token."); + } + + directories.SaveInstanceConfig(target, config); + return notes; + } + + private static string? TryReadId(InstanceDirectoryManager directories, string folder) + { + try { return directories.LoadInstanceConfig(folder).InstanceId; } + catch (Exception ex) when (ex is IOException or JsonException or InvalidOperationException) { return null; } + } + + private void TryRemove(string directory) + { + try + { + if (Directory.Exists(directory)) + Directory.Delete(directory, recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _logger.LogWarning(ex, "Half-imported directory {Path} could not be removed", directory); + } + } + /// Behält die neuesten Sicherungen dieser Instanz. public void Rotate(string ordner, string instanceName, int keep) { diff --git a/src/ClawdDotNet.Web/Contracts/Dtos.cs b/src/ClawdDotNet.Web/Contracts/Dtos.cs index d578beb..4873e18 100644 --- a/src/ClawdDotNet.Web/Contracts/Dtos.cs +++ b/src/ClawdDotNet.Web/Contracts/Dtos.cs @@ -468,3 +468,6 @@ public sealed record SettingsUpdateDto( bool? ErrorReportingEnabled = null, bool? UpdateCheckEnabled = null, string? UpdateChannel = null); + +/// #764: Input of a tool settings form, by field name. An empty secret keeps the stored one. +public sealed record ToolSettingsFormInputDto(Dictionary? Values); diff --git a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs index 497c189..7dca5dc 100644 --- a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs +++ b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs @@ -213,6 +213,19 @@ internal static class ApiEndpoints return Results.Ok(Mapping.ToDto(toolName, settings)); }); + // #764: The settings form — what the desktop ToolSettingsView offered. Secrets are + // described only as "set" or not, and written, never read back. + agents.MapGet("/{agentId}/tools/{toolName}/form", (AppHost host, string agentId, string toolName) => + host.AgentAdmin.Find(agentId) is null + ? Results.NotFound(new OutcomeDto(false, $"No agent with id '{agentId}'.")) + : host.AgentAdmin.ToolSettingsForm(agentId, toolName) is { } form + ? Results.Ok(form) + : Results.Ok(Array.Empty())); + + agents.MapPut("/{agentId}/tools/{toolName}/form", ( + AppHost host, string agentId, string toolName, ToolSettingsFormInputDto input) => + host.AgentAdmin.ApplyToolSettingsForm(agentId, toolName, input.Values ?? new Dictionary()).ToResponse()); + // #641: Rechte je Werkzeug - was laeuft, was vorgelegt wird, was verboten ist. agents.MapGet("/{agentId}/tools/{toolName}/rights", async ( AppHost host, string agentId, string toolName, CancellationToken ct) => diff --git a/tests/ClawdDotNet.App.Tests/Services/ToolSettingsFormTests.cs b/tests/ClawdDotNet.App.Tests/Services/ToolSettingsFormTests.cs new file mode 100644 index 0000000..c91be50 --- /dev/null +++ b/tests/ClawdDotNet.App.Tests/Services/ToolSettingsFormTests.cs @@ -0,0 +1,115 @@ +using System.Text.Json; +using ClawdDotNet.App.Services; +using ClawdDotNet.Core.Staging; +using Shouldly; + +namespace ClawdDotNet.App.Tests.Services; + +/// +/// #764: Tool settings written from the WebUI — the desktop ToolSettingsView, without a +/// window. The secrets go one way, and nothing the form does not touch may change. +/// +public sealed class ToolSettingsFormTests +{ + /// Settings as they are after loading: values as JsonElement, as from the file. + private static Dictionary Loaded(string json) + => JsonSerializer.Deserialize>(json)!; + + [Fact] + public void The_form_shows_no_secret() + { + var form = ToolSettingsForms.Describe("FTP", Loaded("""{"host":"ftp.example.org","password":"hunter2"}"""))!; + + var password = form.Single(f => f.Name == "Password"); + password.Kind.ShouldBe(ToolSettingsForms.Secret); + password.Value.ShouldBeNull(); + password.IsSet.ShouldBeTrue(); + JsonSerializer.Serialize(form).ShouldNotContain("hunter2"); + } + + [Fact] + public void A_connection_string_is_a_secret_even_without_a_password_mark() + { + // It carries the database password; ConfigSecrets protects it by name. + var form = ToolSettingsForms.Describe("Database", Loaded("""{"connectionString":"Server=db;Password=x"}"""))!; + + form.Single(f => f.Name == "ConnectionString").Kind.ShouldBe(ToolSettingsForms.Secret); + } + + [Fact] + public void An_empty_secret_keeps_the_stored_one() + { + var (settings, error) = ToolSettingsForms.Apply("FTP", Loaded("""{"host":"old","password":"hunter2"}"""), + new Dictionary { ["Host"] = "new", ["Password"] = "" }); + + error.ShouldBeNull(); + settings!["host"].ShouldBe("new"); + settings["password"]!.ToString().ShouldBe("hunter2"); + } + + [Fact] + public void Untouched_fields_stay_exactly_as_stored() + { + // allowedTables is a JSON array in the file, allowWrite an old key the class only reads, + // "extra" a key no class knows. Saving another field must leave all three alone. + var stored = Loaded("""{"type":"mysql","connectionString":"cs","allowedTables":["orders","stock"],"allowWrite":true,"extra":42}"""); + + var (settings, _) = ToolSettingsForms.Apply("Database", stored, + new Dictionary { ["Type"] = "Postgres" }); + + settings!["type"].ShouldBe("postgres"); + JsonSerializer.Serialize(settings["allowedTables"]).ShouldBe("""["orders","stock"]"""); + JsonSerializer.Serialize(settings["allowWrite"]).ShouldBe("true"); + JsonSerializer.Serialize(settings["extra"]).ShouldBe("42"); + } + + [Fact] + public void The_table_list_reads_back_as_names_not_as_json() + { + var form = ToolSettingsForms.Describe("Database", Loaded("""{"allowedTables":["orders","stock"]}"""))!; + + form.Single(f => f.Name == "AllowedTables").Value.ShouldBe("orders,stock"); + } + + [Theory] + [InlineData("Port", "twenty-one")] + [InlineData("Nonsense", "x")] + public void A_bad_input_is_refused(string field, string value) + { + var (settings, error) = ToolSettingsForms.Apply("FTP", Loaded("{}"), + new Dictionary { [field] = value }); + + settings.ShouldBeNull(); + error.ShouldNotBeNull(); + } + + [Fact] + public void An_enum_outside_its_values_is_refused() + { + var (settings, _) = ToolSettingsForms.Apply("Database", Loaded("{}"), + new Dictionary { ["AccessLevel"] = "99" }); + + settings.ShouldBeNull(); + } + + [Fact] + public void Saving_through_the_agent_keeps_the_rights() + { + using var host = new TestHost(); + host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "model")).Ok.ShouldBeTrue(); + host.Host.AgentAdmin.SetToolEnabled("scout", "FTP", true).Ok.ShouldBeTrue(); + host.Host.AgentAdmin.SetToolRights("scout", "FTP", ["list"], ["delete"]).Ok.ShouldBeTrue(); + + host.Host.AgentAdmin.ApplyToolSettingsForm("scout", "FTP", + new Dictionary { ["Host"] = "ftp.example.org" }).Ok.ShouldBeTrue(); + + var tool = host.Host.AgentAdmin.Find("scout")!.Tools["FTP"]; + tool["host"].ShouldBe("ftp.example.org"); + tool.ShouldContainKey(Werkzeugrechte.ErlaubtSchluessel); + tool.ShouldContainKey(Werkzeugrechte.VerbotenSchluessel); + } + + [Fact] + public void A_tool_without_a_form_has_none() + => ToolSettingsForms.Describe("Memory", []).ShouldBeNull(); +} diff --git a/tests/ClawdDotNet.Manager.Tests/BackupImportTests.cs b/tests/ClawdDotNet.Manager.Tests/BackupImportTests.cs new file mode 100644 index 0000000..9f9a205 --- /dev/null +++ b/tests/ClawdDotNet.Manager.Tests/BackupImportTests.cs @@ -0,0 +1,169 @@ +using System.Text.Json; +using ClawdDotNet.Core.Backup; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Instances; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Manager.Tests; + +/// +/// #764: A backup uploaded in the WebUI becomes a new instance — the move to a fresh machine, +/// which only the desktop restore window could do before. +/// +public sealed class BackupImportTests : IAsyncDisposable +{ + private const string License = "CLAWD-TEST-0000-1111-2222"; + private const string ForeignLicense = "CLAWD-TEST-9999-8888-7777"; + + private readonly string _root = Path.Combine( + Path.GetTempPath(), "clawd-tests", "mimp-" + Guid.NewGuid().ToString("N")[..8]); + + private readonly InstanceDirectoryManager _directories; + private readonly ManagerSettingsStore _settings; + + public BackupImportTests() + { + Directory.CreateDirectory(_root); + _directories = new InstanceDirectoryManager(Path.Combine(_root, "Instances")); + _settings = new ManagerSettingsStore(_root); + _settings.Settings.LicenseKey = License; + } + + public async ValueTask DisposeAsync() + { + await Task.CompletedTask; + ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root); + try { Directory.Delete(_root, recursive: true); } + catch { /* A leftover test folder does not turn a run red. */ } + } + + private async Task<(ManagerBackups Backups, InstanceRegistry Registry)> ServiceAsync() + { + var registry = new InstanceRegistry( + _directories, new FakeLauncher(), new FakeChannel(), NullLoggerFactory.Instance, + SupervisionOptions.Default); + await registry.RefreshAsync(); + return (new ManagerBackups(_settings, registry, null, NullLogger.Instance), registry); + } + + /// An instance with paths of "another machine" and autostart on, backed up and then removed. + private async Task<(string Archive, string InstanceId)> BackupOfAnInstanceAsync(string name, bool removeOriginal = true) + { + var path = _directories.CreateInstance(name); + var file = Path.Combine(path, "InstanceSettings.json"); + var config = JsonSerializer.Deserialize(File.ReadAllText(file))!; + config.AutostartByManager = true; + config.LogDirectory = Path.Combine(_root, "elsewhere", "logs"); + config.BackupDirectory = Path.Combine(_root, "elsewhere", "backups"); + File.WriteAllText(file, JsonSerializer.Serialize(config)); + + var (backups, registry) = await ServiceAsync(); + await using (backups) + await using (registry) + { + var instance = registry.Instances.Single(i => PathEquals(i.Directory, path)); + var outcome = await backups.CreateAsync(instance, false, true, CancellationToken.None); + + var archive = Path.Combine(_root, "upload-" + Guid.NewGuid().ToString("N") + ".bak"); + File.Copy(outcome.Path, archive); + + if (removeOriginal) + { + await registry.DisposeAsync(); + ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root); + Directory.Delete(path, recursive: true); + } + + return (archive, config.InstanceId); + } + } + + private static bool PathEquals(string a, string b) + => ClawdDotNet.Core.Storage.PathBoundary.Comparer.Equals(Path.GetFullPath(a), Path.GetFullPath(b)); + + [Fact] + public async Task A_backup_becomes_a_new_instance_with_its_own_paths() + { + var (archive, id) = await BackupOfAnInstanceAsync("Alpha"); + + var (backups, registry) = await ServiceAsync(); + await using var _ = backups; + await using var __ = registry; + + var result = await backups.ImportAsNewInstanceAsync(archive, "Moved", null, null); + + result.Ok.ShouldBeTrue(result.Message); + var config = _directories.LoadInstanceConfig(result.Directory!); + config.InstanceName.ShouldBe("Moved"); + config.InstanceId.ShouldBe(id); // a move keeps its identity + PathEquals(config.WorkingDirectory, result.Directory!).ShouldBeTrue(); + config.LogDirectory.ShouldBeEmpty(); + config.BackupDirectory.ShouldBeEmpty(); + config.AutostartByManager.ShouldBeFalse(); + registry.Instances.ShouldContain(i => PathEquals(i.Directory, result.Directory!)); + } + + [Fact] + public async Task A_copy_next_to_its_original_gets_a_new_id() + { + // Two instances with one id would report as one and share one reporting token. + var (archive, id) = await BackupOfAnInstanceAsync("Alpha", removeOriginal: false); + + var (backups, registry) = await ServiceAsync(); + await using var _ = backups; + await using var __ = registry; + + var result = await backups.ImportAsNewInstanceAsync(archive, "Alpha-Copy", null, null); + + result.Ok.ShouldBeTrue(result.Message); + var config = _directories.LoadInstanceConfig(result.Directory!); + config.InstanceId.ShouldNotBe(id); + config.Watchdog.AgentToken.ShouldBeEmpty(); + } + + [Fact] + public async Task An_existing_instance_is_never_overwritten() + { + var (archive, _) = await BackupOfAnInstanceAsync("Alpha"); + _directories.CreateInstance("Taken"); + + var (backups, registry) = await ServiceAsync(); + await using var a = backups; + await using var b = registry; + + (await backups.ImportAsNewInstanceAsync(archive, "Taken", null, null)).Ok.ShouldBeFalse(); + } + + [Fact] + public async Task A_foreign_backup_asks_for_its_key_and_leaves_nothing_behind() + { + var (archive, _) = await BackupOfAnInstanceAsync("Alpha"); + _settings.Settings.LicenseKey = ForeignLicense; // now this is "another installation" + + var (backups, registry) = await ServiceAsync(); + await using var a = backups; + await using var b = registry; + + var refused = await backups.ImportAsNewInstanceAsync(archive, "Moved", null, null); + refused.KeyNeeded.ShouldBeTrue(); + Directory.Exists(_directories.GetInstancePath("Moved")).ShouldBeFalse(); + + var accepted = await backups.ImportAsNewInstanceAsync(archive, "Moved", License, null); + accepted.Ok.ShouldBeTrue(accepted.Message); + } + + [Fact] + public async Task A_broken_file_leaves_no_half_instance() + { + var junk = Path.Combine(_root, "junk.bak"); + await File.WriteAllBytesAsync(junk, [1, 2, 3, 4, 5]); + + var (backups, registry) = await ServiceAsync(); + await using var a = backups; + await using var b = registry; + + (await backups.ImportAsNewInstanceAsync(junk, "Broken", null, null)).Ok.ShouldBeFalse(); + Directory.Exists(_directories.GetInstancePath("Broken")).ShouldBeFalse(); + } +} From 6c85c9b9568713e09bcece6a63086f13a677c9c3 Mon Sep 17 00:00:00 2001 From: Richard Date: Fri, 2 Oct 2026 14:36:17 +0200 Subject: [PATCH 3/3] Stufe 1 (#764): Stand und Entscheidungen im Umsetzungsplan Co-Authored-By: Claude Opus 5.5 --- .../WebUI-Only-und-Englisch.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/umsetzungsplaene/WebUI-Only-und-Englisch.md b/docs/umsetzungsplaene/WebUI-Only-und-Englisch.md index 68441ea..54f3d46 100644 --- a/docs/umsetzungsplaene/WebUI-Only-und-Englisch.md +++ b/docs/umsetzungsplaene/WebUI-Only-und-Englisch.md @@ -76,6 +76,25 @@ Für jede Zeile aus Stufe 0 mit „fehlt“: zuerst prüfen, ob die API den Endp (nie im Endpunkt), mit Test in `tests/ClawdDotNet.Web.Tests` oder `App.Tests`. Lizenz und Schlüsselbund sind Sicherheitsstellen → starkes Modell. +**Stand 2026-10-02: erledigt** (Branch `feat/764-stufe1-webui-luecken`, Suiten grün unter +Windows und Linux). L1, L3–L11 gebaut, L2 ist #780. Was dabei entschieden wurde: + +| Lücke | Weg | Bewusst so | +|---|---|---| +| L1 Lizenz | `PUT /api/verwaltung/license` → `LicenseGate.ApplyKeyAsync` | Gespeichert wird nur ein vom Server bestätigter Schlüssel; abgelehnt oder Server nicht erreichbar → der alte bleibt. Ein bestätigter hebt die Sperre ohne Neustart auf und startet die Autostart-Instanzen | +| L3 Manager | `GET/PUT /api/verwaltung/settings` | Nur Deploymentcenter-Adresse, Master-Token, Update-Kanal. Port, Bindung und Web-Token **nicht** — ein falscher Wert sperrte die Seite aus, von der er gesetzt wurde | +| L3/L4/L5 Instanz + Anwendung | `GET/PUT /api/settings` (`SettingsAdministration`) | Nur bei laufender Instanz: Die Instanzdatei gehört dem Instanzprozess. Die Antwort nennt, was erst nach einem Neustart greift | +| L6/L7 Agent | `PUT /api/agents/{id}`, `GET …/texts` | Lange Texte nicht in der Agentenliste (50–100 Agenten) | +| L8 Werkzeuge | `GET/PUT …/tools/{tool}/form` (`ToolSettingsForms`) | Formular aus den typisierten Klassen; ersetzt werden nur geänderte Schlüssel, sonst bleibt die Datei wie sie war | +| L10 Zeitplan | über `/api/settings`, in der Sicherungsansicht | wie L4 | +| L11 Import | `POST /api/verwaltung/backups/import?name=…`, Datei als Rumpf, bis 4 GB | Nie in ein bestehendes Verzeichnis; Pfade der alten Maschine zurückgesetzt; Kopie neben dem Original bekommt neue ID und neues Melde-Token; Autostart aus | + +Nebenbei behoben: `Database.allowedTables` wurde beim Speichern als roher JSON-Text zerlegt; +`api.js` schickte Dateien als `"{}"`; Dialoge blieben beim Seitenwechsel offen; der Log-Hinweis +auf `--license-set-key` zeigte auf einen Schalter, den es nie gab. Die Sprachpakete prüft jetzt +`LanguagePackTests` bei jedem Lauf (gleiche Schlüssel, gleiche Platzhalter, jeder benutzte +Schlüssel vorhanden). + ### Stufe 2 — Die Hülle (Flash: nein) Empfehlung (vom Menschen noch zu bestätigen, bevor du anfängst — frage im Roadmap-Eintrag):