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:
co-authored by
Claude Opus 5.5
parent
8552755bb6
commit
d2559c763a
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user