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 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-02 14:13:37 +02:00
co-authored by Claude Opus 5.5
parent 8552755bb6
commit d2559c763a
17 changed files with 1027 additions and 28 deletions
@@ -220,9 +220,10 @@ public sealed class DatabaseToolSettings
AccessLevel = Enum.TryParse<DatabaseAccessLevel>(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")
};
}
@@ -488,6 +488,33 @@ public sealed class AgentAdministration(AppHost host)
$"Prompt history default: {Core.Transcripts.PromptHistorySettings.Describe(retentionDays)}.");
}
/// <summary>
/// #764: The settings form of a tool for one agent (<see cref="ToolSettingsForms"/>) —
/// also for a tool the agent does not have yet, so one can see what there is to set.
/// </summary>
public IReadOnlyList<ToolSettingField>? ToolSettingsForm(string agentId, string toolName)
=> Find(agentId) is { } agent
? ToolSettingsForms.Describe(toolName, agent.Tools.GetValueOrDefault(toolName) ?? new Dictionary<string, object?>())
: null;
/// <summary>
/// #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
/// <see cref="SetToolSettings"/>).
/// </summary>
public AdminResult ApplyToolSettingsForm(string agentId, string toolName, IReadOnlyDictionary<string, string?> input)
{
if (Find(agentId) is not { } agent)
return AdminResult.Failure($"No agent with id '{agentId}'.");
var current = agent.Tools.GetValueOrDefault(toolName) ?? new Dictionary<string, object?>();
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<string, object?> settings)
{
@@ -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;
/// <summary>#764: One field of a tool's settings form. A secret carries no value, only whether one is set.</summary>
public sealed record ToolSettingField(
string Name,
string Kind,
string? Value,
bool IsSet,
IReadOnlyList<string> Options);
/// <summary>
/// #764: The tool settings forms of the desktop (<c>ToolSettingsView</c>) for the WebUI — built
/// from the typed settings classes in <c>ToolSettingsViewModels.cs</c>, so a new field there
/// appears in the form without a second list here.
///
/// <para><b>Secrets go one way.</b> A field is a secret when its property is marked as a
/// password or its name is one <see cref="ConfigSecrets"/> protects (the connection string
/// carries a password but no password mark). Its value is never described, and an empty input
/// keeps the stored one.</para>
///
/// <para><b>Only what changed is written.</b> The typed classes do not read every stored form
/// back faithfully — a JSON array, an old key like <c>allowWrite</c>, 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.</para>
/// </summary>
public static class ToolSettingsForms
{
public const string Text = "text", Number = "number", Bool = "bool", Choice = "choice", Secret = "secret", Info = "info";
/// <summary>The form of a tool, or <c>null</c> if the tool has no settings form.</summary>
public static IReadOnlyList<ToolSettingField>? Describe(string toolName, Dictionary<string, object?> 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();
}
/// <summary>
/// Applies form input to a tool's stored settings. Returns the new settings, or an error.
/// Keys the form does not know are kept.
/// </summary>
public static (Dictionary<string, object?>? Settings, string? Error) Apply(
string toolName, Dictionary<string, object?> current, IReadOnlyDictionary<string, string?> 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<string, object?>(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<PropertyInfo> 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<ReadOnlyAttribute>()?.IsReadOnly == true) return Info;
if (p.GetCustomAttribute<PasswordPropertyTextAttribute>()?.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);
}
+57
View File
@@ -102,6 +102,12 @@ public sealed record ManagerSettingsUpdateDto(string? DeploymentcenterUrl, strin
/// </summary>
internal static class ManagerEndpoints
{
/// <summary>
/// #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.
/// </summary>
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<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>() 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) =>
@@ -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) {
@@ -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.",
@@ -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.",
@@ -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);
}
@@ -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();
};
@@ -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"],
@@ -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]));
}
@@ -121,6 +121,9 @@ public sealed class InstanceRegistry : IAsyncDisposable
/// nach einem Neustart der Maschine auch das starten, was jemand mit Absicht
/// angehalten hat.</para>
/// </summary>
/// <summary>#764: Where the instances live — for importing a backup as a new one.</summary>
public InstanceDirectoryManager Directories => _directories;
public async Task RefreshAsync(CancellationToken ct = default)
{
// ListInstances liest den Namen aus InstanceSettings.json gleich mit - und tut
+132
View File
@@ -12,6 +12,10 @@ namespace ClawdDotNet.Manager;
public sealed record BackupOutcome(
string Path, string FileName, long SizeBytes, bool Uploaded, string? UploadProblem);
/// <summary>#764: What importing a backup as a new instance came to.</summary>
/// <param name="KeyNeeded">The backup is from another installation; its license key is needed.</param>
public sealed record ImportOutcome(bool Ok, string Message, bool KeyNeeded = false, string? Directory = null);
/// <summary>
/// Die Sicherungen aller Instanzen, im Manager.
///
@@ -179,6 +183,134 @@ public sealed class ManagerBackups : IAsyncDisposable
}
}
/// <summary>
/// #764: Restores an uploaded backup as a <b>new</b> 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.
///
/// <para><b>Never into an existing directory.</b> 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.</para>
///
/// <para><b>The restored instance file is from another place.</b> 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.</para>
/// </summary>
public async Task<ImportOutcome> 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<string> AdoptImportedConfig(InstanceDirectoryManager directories, string target, string name)
{
var notes = new List<string>();
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);
}
}
/// <summary>Behält die neuesten <paramref name="keep"/> Sicherungen dieser Instanz.</summary>
public void Rotate(string ordner, string instanceName, int keep)
{
+3
View File
@@ -468,3 +468,6 @@ public sealed record SettingsUpdateDto(
bool? ErrorReportingEnabled = null,
bool? UpdateCheckEnabled = null,
string? UpdateChannel = null);
/// <summary>#764: Input of a tool settings form, by field name. An empty secret keeps the stored one.</summary>
public sealed record ToolSettingsFormInputDto(Dictionary<string, string?>? Values);
@@ -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<ToolSettingField>()));
agents.MapPut("/{agentId}/tools/{toolName}/form", (
AppHost host, string agentId, string toolName, ToolSettingsFormInputDto input) =>
host.AgentAdmin.ApplyToolSettingsForm(agentId, toolName, input.Values ?? new Dictionary<string, string?>()).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) =>