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(); + } +}