diff --git a/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs b/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs index 27c4c69db..4dc282c75 100644 --- a/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs @@ -29,6 +29,7 @@ public sealed partial class SchemaConfigEditor : UserControl private bool _loading; private readonly Dictionary _changes = new(StringComparer.Ordinal); private readonly Dictionary _validationErrors = new(StringComparer.Ordinal); + private readonly Dictionary _keptArraySecrets = new(); private static readonly TimeSpan PatternValidationTimeout = TimeSpan.FromMilliseconds(200); private static readonly Regex CamelCaseSplitPattern = new( @@ -53,6 +54,7 @@ public void LoadSchema(JsonElement schema, JsonElement config) _config = config; _changes.Clear(); _validationErrors.Clear(); + _keptArraySecrets.Clear(); FieldsPanel.Children.Clear(); try @@ -119,7 +121,12 @@ private void RenderSchemaNode(string path, JsonElement schema, JsonElement confi var childType = ExtractSchemaType(childSchema); - if (childType == "object" && childSchema.TryGetProperty("properties", out _)) + if (ConfigEditorModel.UseHiddenObjectEditor(childPath, childSchema)) + { + var required = IsRequired(schema, prop.Name); + RenderField(childPath, prop.Name, childSchema, childConfig, parent, required); + } + else if (childType == "object" && childSchema.TryGetProperty("properties", out _)) { RenderObjectSection(childPath, prop.Name, childSchema, childConfig, parent, depth); } @@ -225,9 +232,20 @@ private void RenderField(string path, string name, JsonElement schema, } else if (type == "array" && schema.TryGetProperty("items", out var itemsSchema)) { - control = RenderArrayField(path, headerText, description, itemsSchema, effectiveConfig, errorBlock, + control = RenderArrayField(path, headerText, description, schema, itemsSchema, effectiveConfig, errorBlock, value => StageValue(path, value, schema, required, errorBlock)); } + else if (ConfigEditorModel.UseHiddenObjectEditor(path, schema)) + { + control = BuildSensitiveArrayEditor( + path, + headerText, + CountObjectProperties(effectiveConfig), + description, + value => StageValue(path, value, schema, required, errorBlock), + expectedKind: JsonValueKind.Object, + valueSchema: schema); + } else if (type == "object") { control = RenderJsonObjectField(path, headerText, description, effectiveConfig, errorBlock, @@ -373,12 +391,12 @@ private UIElement RenderSensitiveField(string path, string label, } private UIElement RenderArrayField(string path, string label, string? description, - JsonElement itemsSchema, JsonElement config, TextBlock errorBlock, Action onChanged) + JsonElement schema, JsonElement itemsSchema, JsonElement config, TextBlock errorBlock, Action onChanged) { var itemType = ExtractSchemaType(itemsSchema) ?? "string"; if (itemType is not ("string" or "integer" or "number" or "boolean")) { - return RenderJsonArrayField(path, label, description, config, errorBlock, onChanged); + return RenderJsonArrayField(path, label, description, config, errorBlock, onChanged, schema); } var panel = new StackPanel { Spacing = 6 }; @@ -404,7 +422,7 @@ private UIElement RenderArrayField(string path, string label, string? descriptio { foreach (var item in config.EnumerateArray()) { - AddArrayItem(itemsPanel, path, itemType, FormatScalar(item), onChanged); + AddArrayItem(itemsPanel, path, itemType, FormatScalar(item), onChanged, existingRow: true); } } @@ -435,8 +453,15 @@ private UIElement RenderArrayField(string path, string label, string? descriptio } private UIElement RenderJsonArrayField(string path, string label, string? description, - JsonElement config, TextBlock errorBlock, Action onChanged) + JsonElement config, TextBlock errorBlock, Action onChanged, JsonElement valueSchema) { + if (IsSensitive(path)) + { + var existingCount = config.ValueKind == JsonValueKind.Array ? config.GetArrayLength() : 0; + return BuildSensitiveArrayEditor( + path, label, existingCount, description, onChanged, valueSchema: valueSchema); + } + var panel = new StackPanel { Spacing = 6 }; panel.Children.Add(new InfoBar { @@ -497,6 +522,175 @@ private UIElement RenderJsonArrayField(string path, string label, string? descri return panel; } + private UIElement BuildSensitiveArrayEditor(string path, string label, int existingCount, + string? description, Action onChanged, + JsonValueKind expectedKind = JsonValueKind.Array, + JsonElement valueSchema = default) + { + var session = new SensitiveArrayEditSession(existingCount, expectedKind); + var valueNoun = expectedKind == JsonValueKind.Object ? "object" : "array"; + var panel = new StackPanel { Spacing = 6 }; + var status = new TextBlock + { + FontSize = 12, + Foreground = SecondaryBrush, + TextWrapping = TextWrapping.Wrap + }; + var errorBlock = CreateErrorBlock(); + var editor = new TextBox + { + Text = "", + AcceptsReturn = true, + TextWrapping = TextWrapping.NoWrap, + FontFamily = new FontFamily("Consolas"), + MinHeight = 120, + HorizontalAlignment = HorizontalAlignment.Stretch, + PlaceholderText = $"Enter a JSON {valueNoun}. This box starts empty." + }; + var replacePanel = new StackPanel { Spacing = 6, Visibility = Visibility.Collapsed }; + var confirmPanel = new StackPanel { Spacing = 6, Visibility = Visibility.Collapsed }; + + void ShowStatus(string text) => status.Text = text; + + panel.Children.Add(new InfoBar + { + IsOpen = true, + IsClosable = false, + Severity = InfoBarSeverity.Informational, + Title = label, + Message = session.CountText + }); + if (!string.IsNullOrEmpty(description)) + { + panel.Children.Add(new TextBlock + { + Text = description, + FontSize = 11, + Foreground = SecondaryBrush, + TextWrapping = TextWrapping.Wrap + }); + } + + var replaceButton = new Button { Content = "Replace all", Margin = new Thickness(0, 4, 8, 0) }; + var clearButton = new Button { Content = "Clear all" }; + var actions = new StackPanel { Orientation = Orientation.Horizontal }; + actions.Children.Add(replaceButton); + actions.Children.Add(clearButton); + panel.Children.Add(actions); + + replaceButton.Click += (_, _) => + { + if (_loading) return; + session.BeginReplace(); + editor.Text = ""; + replacePanel.Visibility = Visibility.Visible; + confirmPanel.Visibility = Visibility.Collapsed; + errorBlock.Visibility = Visibility.Collapsed; + }; + + var applyButton = new Button { Content = "Apply" }; + var cancelReplaceButton = new Button { Content = "Cancel", Margin = new Thickness(8, 0, 0, 0) }; + applyButton.Click += (_, _) => + { + if (_loading) return; + session.SetDraft(editor.Text); + if (!session.TryReadDraft(out var replacement)) + { + RejectSensitiveDraft(path, session.Error, errorBlock); + return; + } + + var schemaError = valueSchema.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null + ? null + : ValidateValue(replacement, valueSchema, false); + if (!string.IsNullOrWhiteSpace(schemaError)) + { + RejectSensitiveDraft(path, schemaError, errorBlock); + return; + } + + session.CommitReplace(replacement); + editor.Text = ""; + replacePanel.Visibility = Visibility.Collapsed; + SetValidationError(path, null, errorBlock); + ShowStatus("Replacement is ready to save."); + onChanged(replacement); + }; + cancelReplaceButton.Click += (_, _) => + { + if (_loading) return; + session.CancelReplace(); + editor.Text = ""; + replacePanel.Visibility = Visibility.Collapsed; + SetValidationError(path, null, errorBlock); + if (session.Decision == SensitiveArrayDecision.Preserve) + { + status.Text = ""; + onChanged(RemovePendingValue); + } + else if (session.Replacement is JsonElement previous) + { + onChanged(previous); + } + else + { + AbandonSensitiveDraft(path, errorBlock); + } + }; + var replaceActions = new StackPanel { Orientation = Orientation.Horizontal }; + replaceActions.Children.Add(applyButton); + replaceActions.Children.Add(cancelReplaceButton); + replacePanel.Children.Add(editor); + replacePanel.Children.Add(errorBlock); + replacePanel.Children.Add(replaceActions); + panel.Children.Add(replacePanel); + + confirmPanel.Children.Add(new TextBlock + { + Text = $"Clear all stored entries? This removes every stored value in this {valueNoun}.", + TextWrapping = TextWrapping.Wrap + }); + var confirmClearButton = new Button { Content = "Clear all" }; + var cancelClearButton = new Button { Content = "Cancel", Margin = new Thickness(8, 0, 0, 0) }; + confirmClearButton.Click += (_, _) => + { + if (_loading) return; + session.ConfirmClear(); + confirmPanel.Visibility = Visibility.Collapsed; + replacePanel.Visibility = Visibility.Collapsed; + editor.Text = ""; + SetValidationError(path, null, errorBlock); + ShowStatus($"This {valueNoun} will be cleared on save."); + onChanged(session.EmptyReplacement()); + }; + cancelClearButton.Click += (_, _) => + { + if (_loading) return; + session.CancelClear(); + confirmPanel.Visibility = Visibility.Collapsed; + if (session.TryRestoreCommittedReplacement(out var previous)) + onChanged(previous); + else + AbandonSensitiveDraft(path, errorBlock); + }; + var confirmActions = new StackPanel { Orientation = Orientation.Horizontal }; + confirmActions.Children.Add(confirmClearButton); + confirmActions.Children.Add(cancelClearButton); + confirmPanel.Children.Add(confirmActions); + panel.Children.Add(confirmPanel); + panel.Children.Add(status); + + clearButton.Click += (_, _) => + { + if (_loading) return; + session.BeginClear(); + replacePanel.Visibility = Visibility.Collapsed; + confirmPanel.Visibility = Visibility.Visible; + }; + + return panel; + } + private UIElement RenderJsonObjectField(string path, string label, string? description, JsonElement config, TextBlock errorBlock, Action onChanged) { @@ -560,7 +754,7 @@ private UIElement RenderJsonObjectField(string path, string label, string? descr return panel; } - private void AddArrayItem(StackPanel itemsPanel, string path, string itemType, string value, Action onChanged) + private void AddArrayItem(StackPanel itemsPanel, string path, string itemType, string value, Action onChanged, bool existingRow = false) { var row = new Grid { @@ -570,24 +764,48 @@ private void AddArrayItem(StackPanel itemsPanel, string path, string itemType, s row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - var textBox = new TextBox + FrameworkElement editor; + if (itemType == "string" && IsSensitive(path)) { - Text = value, - MinWidth = 250, - Height = 34, - PlaceholderText = itemType switch + var passwordBox = new PasswordBox { - "boolean" => "true or false", - "integer" => "Integer value", - "number" => "Number value", - _ => "Value" - } - }; - textBox.TextChanged += (s, e) => + MinWidth = 250, + Height = 34, + PlaceholderText = existingRow + ? "Leave blank to keep existing value" + : "Value" + }; + if (existingRow) + _keptArraySecrets[passwordBox] = value; + passwordBox.PasswordChanged += (s, e) => + { + if (_loading) return; + UpdateArrayChanges(itemsPanel, path, itemType, onChanged); + }; + editor = passwordBox; + } + else { - if (_loading) return; - UpdateArrayChanges(itemsPanel, path, itemType, onChanged); - }; + var textBox = new TextBox + { + Text = value, + MinWidth = 250, + Height = 34, + PlaceholderText = itemType switch + { + "boolean" => "true or false", + "integer" => "Integer value", + "number" => "Number value", + _ => "Value" + } + }; + textBox.TextChanged += (s, e) => + { + if (_loading) return; + UpdateArrayChanges(itemsPanel, path, itemType, onChanged); + }; + editor = textBox; + } var removeBtn = new Button { @@ -599,14 +817,16 @@ private void AddArrayItem(StackPanel itemsPanel, string path, string itemType, s ToolTipService.SetToolTip(removeBtn, "Remove item"); removeBtn.Click += (s, e) => { + if (row.Children[0] is PasswordBox removed) + _keptArraySecrets.Remove(removed); if (row.Parent is Border border) itemsPanel.Children.Remove(border); UpdateArrayChanges(itemsPanel, path, itemType, onChanged); }; - Grid.SetColumn(textBox, 0); + Grid.SetColumn(editor, 0); Grid.SetColumn(removeBtn, 1); - row.Children.Add(textBox); + row.Children.Add(editor); row.Children.Add(removeBtn); itemsPanel.Children.Add(new Border { @@ -624,11 +844,20 @@ private void UpdateArrayChanges(StackPanel itemsPanel, string path, string itemT var values = new List(); foreach (var child in itemsPanel.Children) { - if (child is Border { Child: Grid row } && row.Children.Count > 0 - && row.Children[0] is TextBox tb) + if (child is not Border { Child: Grid row } || row.Children.Count == 0) + continue; + + if (row.Children[0] is PasswordBox password) { - values.Add(CoerceArrayItem(tb.Text, itemType)); + if (!string.IsNullOrEmpty(password.Password)) + values.Add(password.Password); + else if (_keptArraySecrets.TryGetValue(password, out var existing)) + values.Add(existing); + continue; } + + if (row.Children[0] is TextBox tb) + values.Add(CoerceArrayItem(tb.Text, itemType)); } onChanged(values.ToArray()); } @@ -643,13 +872,7 @@ private static string GetLabel(string path, string name) return result; } - private static bool IsSensitive(string path) - { - var normalizedPath = path.ToLowerInvariant(); - return normalizedPath.Contains("token") || normalizedPath.Contains("secret") - || normalizedPath.Contains("password") || normalizedPath.Contains("apikey") - || normalizedPath.Contains("api_key"); - } + private static bool IsSensitive(string path) => ConfigPathSensitivity.IsSensitive(path); private static bool IsRequired(JsonElement parentSchema, string propName) { @@ -669,12 +892,41 @@ private static bool IsRequired(JsonElement parentSchema, string propName) private void StageValue(string path, object? value, JsonElement schema, bool required, TextBlock errorBlock) { - _changes[path] = value; var error = ValidateValue(value, schema, required); + _changes[path] = !string.IsNullOrWhiteSpace(error) && value is JsonElement + ? RemovePendingValue + : value; + SetValidationError(path, error, errorBlock); + ConfigChanged?.Invoke(this, new SchemaConfigChangedEventArgs(GetChanges(), GetValidationErrors())); + } + + private void RejectSensitiveDraft(string path, string? error, TextBlock errorBlock) + { + _changes[path] = RemovePendingValue; SetValidationError(path, error, errorBlock); ConfigChanged?.Invoke(this, new SchemaConfigChangedEventArgs(GetChanges(), GetValidationErrors())); } + private void AbandonSensitiveDraft(string path, TextBlock errorBlock) + { + SetValidationError(path, null, errorBlock); + ConfigChanged?.Invoke(this, new SchemaConfigChangedEventArgs(GetChanges(), GetValidationErrors())); + } + + private void StageDirectChange(string path, object? edited) + { + if (_loading) + return; + if (ReferenceEquals(edited, RemovePendingValue)) + _changes.Remove(path); + else + _changes[path] = edited; + ConfigChanged?.Invoke(this, new SchemaConfigChangedEventArgs(GetChanges(), GetValidationErrors())); + } + + private static int CountObjectProperties(JsonElement value) => + value.ValueKind == JsonValueKind.Object ? value.EnumerateObject().Count() : 0; + private static TextBlock CreateErrorBlock() => new() { Foreground = new SolidColorBrush(Colors.Firebrick), @@ -719,6 +971,12 @@ private void SetValidationError(string path, string? error, TextBlock errorBlock value is not Array && value is not JsonElement { ValueKind: JsonValueKind.Array }) return "Must be a list."; + if (value is JsonElement kindElement) + { + var kindError = ConfigEditorModel.JsonKindMismatch(kindElement, expectedType); + if (kindError != null) + return kindError; + } } if (value is string text) @@ -767,6 +1025,10 @@ value is not Array && if (schema.TryGetProperty("items", out var itemSchema)) { + var kindError = ConfigEditorModel.FirstArrayItemKindError(jsonArray, itemSchema); + if (kindError != null) + return kindError; + var index = 0; foreach (var item in jsonArray.EnumerateArray()) { @@ -868,6 +1130,16 @@ private void RenderConfigDirectly(string path, JsonElement config, StackPanel pa switch (value.ValueKind) { + case JsonValueKind.Object when IsSensitive(childPath): + parent.Children.Add(BuildSensitiveArrayEditor( + childPath, + GetLabel(childPath, prop.Name), + CountObjectProperties(value), + null, + edited => StageDirectChange(childPath, edited), + expectedKind: JsonValueKind.Object)); + break; + case JsonValueKind.Object: var expander = new Expander { @@ -946,6 +1218,17 @@ private void RenderConfigDirectly(string path, JsonElement config, StackPanel pa break; case JsonValueKind.Array: + if (IsSensitive(childPath)) + { + parent.Children.Add(BuildSensitiveArrayEditor( + childPath, + GetLabel(childPath, prop.Name), + value.GetArrayLength(), + null, + edited => StageDirectChange(childPath, edited))); + break; + } + var arrayLabel = new TextBlock { Text = GetLabel(childPath, prop.Name), FontWeight = FontWeights.SemiBold, Margin = new Thickness(0, 8, 0, 4) }; parent.Children.Add(arrayLabel); var arrayText = new TextBox diff --git a/src/OpenClaw.Tray.WinUI/Helpers/ConfigPathSensitivity.cs b/src/OpenClaw.Tray.WinUI/Helpers/ConfigPathSensitivity.cs new file mode 100644 index 000000000..af6996cac --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Helpers/ConfigPathSensitivity.cs @@ -0,0 +1,28 @@ +namespace OpenClawTray.Helpers; + +internal static class ConfigPathSensitivity +{ + public static bool IsSensitive(string path) + { + var normalizedPath = path.ToLowerInvariant(); + if (normalizedPath.Contains("token", StringComparison.Ordinal) + || normalizedPath.Contains("secret", StringComparison.Ordinal) + || normalizedPath.Contains("password", StringComparison.Ordinal) + || normalizedPath.Contains("apikey", StringComparison.Ordinal) + || normalizedPath.Contains("api_key", StringComparison.Ordinal)) + { + return true; + } + + foreach (var segment in path.Split('.')) + { + if (segment.Equals("nsec", StringComparison.OrdinalIgnoreCase) + || segment.Equals("privateKey", StringComparison.OrdinalIgnoreCase) + || segment.Equals("webhookUrl", StringComparison.OrdinalIgnoreCase) + || segment.Equals("webhookUrls", StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs index e76669643..ca34bb52d 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Text.Json; using System.Text.Json.Nodes; +using OpenClawTray.Helpers; namespace OpenClawTray.Services; @@ -115,6 +116,57 @@ public static JsonElement ApplyRelativeChanges( return value; } + public static string? JsonKindMismatch(JsonElement value, string? expectedType) + { + if (expectedType == "object" && value.ValueKind != JsonValueKind.Object) + return "Must be a JSON object."; + if (expectedType == "string" && value.ValueKind != JsonValueKind.String) + return "Must be a JSON string."; + return null; + } + + public static string? FirstArrayItemKindError(JsonElement array, JsonElement itemsSchema) + { + if (array.ValueKind != JsonValueKind.Array) + return "Must be a list."; + + var expectedType = ReadSchemaType(itemsSchema); + var index = 0; + foreach (var item in array.EnumerateArray()) + { + var error = JsonKindMismatch(item, expectedType); + if (error != null) + return $"Item {index + 1}: {error}"; + index++; + } + + return null; + } + + public static bool UseHiddenObjectEditor(string path, JsonElement schema) => + ConfigPathSensitivity.IsSensitive(path) && ReadSchemaType(schema) == "object"; + + private static string? ReadSchemaType(JsonElement schemaNode) + { + if (!schemaNode.TryGetProperty("type", out var typeEl)) + return null; + if (typeEl.ValueKind == JsonValueKind.String) + return typeEl.GetString(); + if (typeEl.ValueKind != JsonValueKind.Array) + return null; + + foreach (var item in typeEl.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.String) + continue; + var schemaType = item.GetString(); + if (!string.IsNullOrEmpty(schemaType) && schemaType != "null") + return schemaType; + } + + return null; + } + private static void SetPath(JsonNode node, string dotPath, JsonNode? value) { var segments = dotPath.Split('.', StringSplitOptions.RemoveEmptyEntries); @@ -141,3 +193,145 @@ private static void SetPath(JsonNode node, string dotPath, JsonNode? value) target[segments[^1]] = value; } } + +internal enum SensitiveArrayDecision +{ + Preserve, + Replace, + Clear +} + +/// +/// Edit session for a sensitive array or object whose current value must stay off the page. +/// The session stores a count and the newly typed JSON. It never receives the stored secrets. +/// +internal sealed class SensitiveArrayEditSession +{ + public SensitiveArrayEditSession(int existingCount, JsonValueKind expectedKind = JsonValueKind.Array) + { + if (existingCount < 0) + throw new ArgumentOutOfRangeException(nameof(existingCount)); + if (expectedKind is not JsonValueKind.Array and not JsonValueKind.Object) + throw new ArgumentOutOfRangeException(nameof(expectedKind)); + ExistingCount = existingCount; + ExpectedKind = expectedKind; + } + + public int ExistingCount { get; } + public JsonValueKind ExpectedKind { get; } + public bool ReplaceOpen { get; private set; } + public bool ClearConfirmOpen { get; private set; } + public string Draft { get; private set; } = ""; + public string? Error { get; private set; } + public SensitiveArrayDecision Decision { get; private set; } + public JsonElement? Replacement { get; private set; } + + public string CountText => ExistingCount == 1 + ? "1 entry is configured. Stored values stay hidden." + : $"{ExistingCount} entries are configured. Stored values stay hidden."; + + public static JsonElement EmptyArray() + { + using var document = JsonDocument.Parse("[]"); + return document.RootElement.Clone(); + } + + public static JsonElement EmptyObject() + { + using var document = JsonDocument.Parse("{}"); + return document.RootElement.Clone(); + } + + public JsonElement EmptyReplacement() => + ExpectedKind == JsonValueKind.Object ? EmptyObject() : EmptyArray(); + + public void BeginReplace() + { + ReplaceOpen = true; + ClearConfirmOpen = false; + Draft = ""; + Error = null; + } + + public void SetDraft(string? text) => Draft = text ?? ""; + + public bool TryReadDraft(out JsonElement parsed) + { + parsed = default; + try + { + using var document = JsonDocument.Parse(Draft); + if (document.RootElement.ValueKind != ExpectedKind) + { + Error = ExpectedKind == JsonValueKind.Object + ? "Must be a JSON object." + : "Must be a JSON array."; + return false; + } + + parsed = document.RootElement.Clone(); + Error = null; + return true; + } + catch (JsonException ex) + { + Error = $"Invalid JSON: {ex.Message}"; + return false; + } + } + + public void CommitReplace(JsonElement replacement) + { + Replacement = replacement; + Decision = SensitiveArrayDecision.Replace; + Error = null; + ReplaceOpen = false; + Draft = ""; + } + + public bool TryApplyReplace() + { + if (!TryReadDraft(out var parsed)) + return false; + + CommitReplace(parsed); + return true; + } + + public void CancelReplace() + { + ReplaceOpen = false; + Draft = ""; + Error = null; + } + + public void BeginClear() + { + ClearConfirmOpen = true; + ReplaceOpen = false; + } + + public void ConfirmClear() + { + ClearConfirmOpen = false; + ReplaceOpen = false; + Draft = ""; + Error = null; + Replacement = null; + Decision = SensitiveArrayDecision.Clear; + } + + public void CancelClear() => ClearConfirmOpen = false; + + public bool TryRestoreCommittedReplacement(out JsonElement value) + { + if (Replacement is JsonElement previous) + { + value = previous; + return true; + } + + value = default; + return false; + } +} diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs index b6a8c7bff..ff8761969 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using OpenClawTray.Helpers; using OpenClawTray.Services; namespace OpenClaw.Tray.Tests; @@ -133,4 +134,242 @@ public void ApplyChanges_IgnoresUnsupportedPlainObjectValues() Assert.Equal("existing", updated.GetProperty("secret").GetString()); Assert.Equal("manual", updated.GetProperty("mode").GetString()); } + + [Fact] + public void SensitiveArray_UnrelatedEdit_PreservesStoredEntries() + { + var session = new SensitiveArrayEditSession(2); + + Assert.Equal(SensitiveArrayDecision.Preserve, session.Decision); + Assert.Null(session.Replacement); + Assert.Equal("", session.Draft); + Assert.Equal("2 entries are configured. Stored values stay hidden.", session.CountText); + Assert.DoesNotContain("stored-secret", session.CountText, StringComparison.Ordinal); + } + + [Fact] + public void SensitiveArray_ReplaceAll_SendsOnlyTheNewArray() + { + var session = new SensitiveArrayEditSession(2); + session.BeginReplace(); + Assert.Equal("", session.Draft); + + session.SetDraft("""[{"url":"https://new.example/hook"}]"""); + Assert.True(session.TryApplyReplace()); + + Assert.Equal(SensitiveArrayDecision.Replace, session.Decision); + var raw = session.Replacement!.Value.GetRawText(); + Assert.Contains("https://new.example/hook", raw, StringComparison.Ordinal); + Assert.DoesNotContain("stored-secret", raw, StringComparison.Ordinal); + Assert.Equal("", session.Draft); + } + + [Fact] + public void SensitiveArray_ClearAll_SendsAnEmptyArray() + { + var session = new SensitiveArrayEditSession(2); + session.BeginClear(); + Assert.True(session.ClearConfirmOpen); + Assert.Equal(SensitiveArrayDecision.Preserve, session.Decision); + + session.ConfirmClear(); + + Assert.Equal(SensitiveArrayDecision.Clear, session.Decision); + Assert.Null(session.Replacement); + Assert.Equal(0, SensitiveArrayEditSession.EmptyArray().GetArrayLength()); + } + + [Fact] + public void SensitiveArray_CancelReplaceOrClear_KeepsTheStoredArray() + { + var session = new SensitiveArrayEditSession(1); + session.BeginReplace(); + session.SetDraft("""[{"token":"typed-then-cancelled"}]"""); + session.CancelReplace(); + + Assert.Equal(SensitiveArrayDecision.Preserve, session.Decision); + Assert.Equal("", session.Draft); + Assert.Null(session.Replacement); + + session.BeginClear(); + session.CancelClear(); + Assert.False(session.ClearConfirmOpen); + Assert.Equal(SensitiveArrayDecision.Preserve, session.Decision); + } + + [Fact] + public void SensitiveArray_RejectedRetryThenCanceledClear_RestoresCommittedReplacement() + { + var session = new SensitiveArrayEditSession(1); + using var committed = JsonDocument.Parse("""[{"url":"https://example.invalid/hook-dummy"}]"""); + session.CommitReplace(committed.RootElement.Clone()); + + session.BeginReplace(); + session.SetDraft("not-json"); + Assert.False(session.TryReadDraft(out _)); + + session.BeginClear(); + session.CancelClear(); + + Assert.True(session.TryRestoreCommittedReplacement(out var restored)); + Assert.Equal(SensitiveArrayDecision.Replace, session.Decision); + Assert.Contains("hook-dummy", restored.GetRawText(), StringComparison.Ordinal); + } + + [Fact] + public void SensitiveArray_NearMatchObject_IsNotASecretArrayDecision() + { + Assert.False(ConfigPathSensitivity.IsSensitive("channels.googlechat.webhookUrlExtra")); + Assert.True(ConfigPathSensitivity.IsSensitive("channels.googlechat.webhookUrl")); + } + + [Fact] + public void SensitiveObject_UnrelatedEditAndCancel_PreserveStoredObject() + { + using var document = JsonDocument.Parse(""" + { + "channels": { + "webhookUrl": { + "id": "stored-dummy", + "material": "stored-secret" + } + }, + "mode": "hybrid" + } + """); + + var session = new SensitiveArrayEditSession(2, JsonValueKind.Object); + Assert.Equal(SensitiveArrayDecision.Preserve, session.Decision); + Assert.Equal("", session.Draft); + Assert.DoesNotContain("stored-dummy", session.CountText, StringComparison.Ordinal); + Assert.DoesNotContain("stored-secret", session.CountText, StringComparison.Ordinal); + + session.BeginReplace(); + session.SetDraft("""{"id":"typed-then-cancelled"}"""); + session.CancelReplace(); + Assert.Equal(SensitiveArrayDecision.Preserve, session.Decision); + Assert.Null(session.Replacement); + + var updated = ConfigEditorModel.ApplyChanges( + document.RootElement, + new Dictionary + { + ["channels.webhookUrl"] = new object(), + ["mode"] = "manual", + }); + + Assert.Equal("stored-dummy", updated.GetProperty("channels").GetProperty("webhookUrl").GetProperty("id").GetString()); + Assert.Equal("stored-secret", updated.GetProperty("channels").GetProperty("webhookUrl").GetProperty("material").GetString()); + Assert.Equal("manual", updated.GetProperty("mode").GetString()); + } + + [Fact] + public void SensitiveObject_ReplaceSendsOnlyTheNewObject_ClearSendsEmptyObject() + { + using var document = JsonDocument.Parse(""" + { + "channels": { + "webhookUrl": { + "id": "stored-dummy" + } + } + } + """); + + var session = new SensitiveArrayEditSession(1, JsonValueKind.Object); + session.BeginReplace(); + session.SetDraft("""["not-an-object"]"""); + Assert.False(session.TryApplyReplace()); + Assert.Equal(SensitiveArrayDecision.Preserve, session.Decision); + + session.SetDraft("""{"id":"replacement-dummy"}"""); + Assert.True(session.TryApplyReplace()); + Assert.Equal(JsonValueKind.Object, session.Replacement!.Value.ValueKind); + Assert.Contains("replacement-dummy", session.Replacement.Value.GetRawText(), StringComparison.Ordinal); + Assert.DoesNotContain("stored-dummy", session.Replacement.Value.GetRawText(), StringComparison.Ordinal); + + var replaced = ConfigEditorModel.ApplyChanges( + document.RootElement, + new Dictionary + { + ["channels.webhookUrl"] = session.Replacement, + }); + Assert.Equal("replacement-dummy", replaced.GetProperty("channels").GetProperty("webhookUrl").GetProperty("id").GetString()); + Assert.False(replaced.GetProperty("channels").GetProperty("webhookUrl").TryGetProperty("material", out _)); + + var cleared = ConfigEditorModel.ApplyChanges( + document.RootElement, + new Dictionary + { + ["channels.webhookUrl"] = SensitiveArrayEditSession.EmptyObject(), + }); + Assert.Equal(JsonValueKind.Object, cleared.GetProperty("channels").GetProperty("webhookUrl").ValueKind); + Assert.Empty(cleared.GetProperty("channels").GetProperty("webhookUrl").EnumerateObject()); + } + + [Theory] + [InlineData("""{"type":"object"}""", """["not-an-object"]""", "Item 1: Must be a JSON object.")] + [InlineData("""{"type":"object"}""", """[{}, "not-an-object"]""", "Item 2: Must be a JSON object.")] + [InlineData("""{"type":"string"}""", """[{"url":"https://example.invalid/hook"}]""", "Item 1: Must be a JSON string.")] + public void ArrayItemKinds_RejectsTheWrongJsonKind(string itemSchema, string arrayJson, string expected) + { + using var schema = JsonDocument.Parse(itemSchema); + using var value = JsonDocument.Parse(arrayJson); + Assert.Equal(expected, ConfigEditorModel.FirstArrayItemKindError(value.RootElement, schema.RootElement)); + } + + [Fact] + public void ArrayItemKinds_AcceptsObjectAndStringReplacements() + { + using var objects = JsonDocument.Parse("""{"type":"object"}"""); + using var strings = JsonDocument.Parse("""{"type":"string"}"""); + using var objectValue = JsonDocument.Parse("""[{"url":"https://example.invalid/hook"}]"""); + using var stringValue = JsonDocument.Parse("""["https://example.invalid/hook"]"""); + + Assert.Null(ConfigEditorModel.FirstArrayItemKindError(objectValue.RootElement, objects.RootElement)); + Assert.Null(ConfigEditorModel.FirstArrayItemKindError(stringValue.RootElement, strings.RootElement)); + } + + [Fact] + public void UseHiddenObjectEditor_SensitiveObjectWithProperties_StaysHidden() + { + using var document = JsonDocument.Parse(""" + { + "type": "object", + "properties": { + "signingSecret": { + "type": "object", + "properties": { + "value": { "type": "string" } + } + }, + "token": { "type": "string" }, + "displayName": { "type": "string" }, + "webhookUrl": { + "type": ["object", "null"], + "properties": { + "id": { "type": "string" } + } + } + } + } + """); + + var properties = document.RootElement.GetProperty("properties"); + Assert.True(ConfigEditorModel.UseHiddenObjectEditor( + "channels.slack.signingSecret", + properties.GetProperty("signingSecret"))); + Assert.True(ConfigEditorModel.UseHiddenObjectEditor( + "channels.googlechat.webhookUrl", + properties.GetProperty("webhookUrl"))); + Assert.False(ConfigEditorModel.UseHiddenObjectEditor( + "channels.discord.token", + properties.GetProperty("token"))); + Assert.False(ConfigEditorModel.UseHiddenObjectEditor( + "channels.slack.displayName", + properties.GetProperty("displayName"))); + Assert.False(ConfigEditorModel.UseHiddenObjectEditor( + "channels.slack", + document.RootElement)); + } } diff --git a/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs b/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs new file mode 100644 index 000000000..762c3512c --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs @@ -0,0 +1,103 @@ +using OpenClawTray.Helpers; + +namespace OpenClaw.Tray.Tests; + +public class ConfigPathSensitivityTests +{ + [Theory] + [InlineData("channels.nostr.nsec", true)] + [InlineData("channels.nostr.NSEC", true)] + [InlineData("nsec", true)] + [InlineData("channels.nostr.privateKey", true)] + [InlineData("channels.nostr.PrivateKey", true)] + [InlineData("privateKey", true)] + [InlineData("channels.googlechat.webhookUrl", true)] + [InlineData("channels.googlechat.webhookUrlExtra", false)] + [InlineData("channels.slack.webhookUrls", true)] + [InlineData("channels.slack.WebhookUrls", true)] + [InlineData("channels.discord.token", true)] + [InlineData("channels.slack.signingSecret", true)] + [InlineData("channels.telegram.botToken", true)] + [InlineData("auth.password", true)] + [InlineData("providers.apiKey", true)] + [InlineData("providers.api_key", true)] + [InlineData("channels.nostr.relays", false)] + [InlineData("channels.nostr.nsecExtra", false)] + [InlineData("channels.nostr.privateKeyExtra", false)] + [InlineData("channels.discord.applicationId", false)] + [InlineData("channels.googlechat.webhook", false)] + public void IsSensitive_MasksSecretSegmentsAndLegacySecretNames(string path, bool expected) + { + Assert.Equal(expected, ConfigPathSensitivity.IsSensitive(path)); + } + + [Fact] + public void SchemaEditor_HidesStoredValuesInSensitiveArrays() + { + var source = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", "OpenClaw.Tray.WinUI", "Controls", "SchemaConfigEditor.xaml.cs")); + + Assert.Contains("itemType == \"string\" && IsSensitive(path)", source, StringComparison.Ordinal); + Assert.Contains("existingRow: true", source, StringComparison.Ordinal); + Assert.Contains("if (existingRow)", source, StringComparison.Ordinal); + Assert.Contains("_keptArraySecrets[passwordBox] = value", source, StringComparison.Ordinal); + Assert.DoesNotContain("existing.Length > 0", source, StringComparison.Ordinal); + Assert.DoesNotContain("passwordBox.Tag", source, StringComparison.Ordinal); + Assert.DoesNotContain("Password = value", source, StringComparison.Ordinal); + Assert.Contains("_keptArraySecrets.TryGetValue(password, out var existing)", source, StringComparison.Ordinal); + Assert.DoesNotContain("They cannot be edited on this page.", source, StringComparison.Ordinal); + Assert.Contains("valueSchema: valueSchema", source, StringComparison.Ordinal); + Assert.Contains("GetLabel(childPath, prop.Name)", source, StringComparison.Ordinal); + Assert.Contains("value.GetArrayLength()", source, StringComparison.Ordinal); + Assert.Contains("editor.Text = \"\"", source, StringComparison.Ordinal); + Assert.Contains("Content = \"Replace all\"", source, StringComparison.Ordinal); + Assert.Contains("Content = \"Clear all\"", source, StringComparison.Ordinal); + Assert.Contains("Clear all stored entries?", source, StringComparison.Ordinal); + Assert.Equal(2, CountOf(source, "JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true })")); + Assert.Contains("if (IsSensitive(childPath))", source, StringComparison.Ordinal); + Assert.Contains("ConfigEditorModel.UseHiddenObjectEditor(childPath, childSchema)", source, StringComparison.Ordinal); + Assert.Contains("ConfigEditorModel.UseHiddenObjectEditor(path, schema)", source, StringComparison.Ordinal); + var hiddenObject = source.IndexOf("UseHiddenObjectEditor(childPath, childSchema)", StringComparison.Ordinal); + var expandObject = source.IndexOf("RenderObjectSection(childPath,", StringComparison.Ordinal); + Assert.True(hiddenObject >= 0 && hiddenObject < expandObject); + Assert.Contains("case JsonValueKind.Object when IsSensitive(childPath):", source, StringComparison.Ordinal); + Assert.Equal(2, CountOf(source, "expectedKind: JsonValueKind.Object")); + Assert.Contains("ConfigEditorModel.JsonKindMismatch(kindElement, expectedType)", source, StringComparison.Ordinal); + Assert.Contains("ConfigEditorModel.FirstArrayItemKindError(jsonArray, itemSchema)", source, StringComparison.Ordinal); + Assert.Contains("RejectSensitiveDraft(path, session.Error, errorBlock);", source, StringComparison.Ordinal); + Assert.Contains("RejectSensitiveDraft(path, schemaError, errorBlock);", source, StringComparison.Ordinal); + Assert.Contains("AbandonSensitiveDraft(path, errorBlock);", source, StringComparison.Ordinal); + Assert.Contains("TryRestoreCommittedReplacement(out var previous)", source, StringComparison.Ordinal); + } + + private static int CountOf(string source, string text) + { + var count = 0; + var index = 0; + while ((index = source.IndexOf(text, index, StringComparison.Ordinal)) >= 0) + { + count++; + index += text.Length; + } + + return count; + } + + private static string FindRepoRoot() + { + var env = Environment.GetEnvironmentVariable("OPENCLAW_REPO_ROOT"); + if (!string.IsNullOrWhiteSpace(env) && Directory.Exists(env)) + return env; + + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + if (File.Exists(Path.Combine(directory.FullName, "openclaw-windows-node.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new InvalidOperationException("Could not find repository root. Set OPENCLAW_REPO_ROOT to the repo path."); + } +} diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 3812cd5a2..4ad1384b6 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -156,6 +156,7 @@ +