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
@@ -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);
}