From 4184c01c6e0764b6c8f1bc8dcada125234aaced7 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 24 Sep 2026 11:03:24 -0700 Subject: [PATCH 01/12] fix(config): mask nostr keys and webhook URLs in the editor Config paths whose segment is nsec, and paths that contain webhookUrl, now use the existing password field that does not preload the stored value. - Add ConfigPathSensitivity and route SchemaConfigEditor.IsSensitive through it - Cover nostr nsec, googlechat webhookUrl, and the existing secret names Signed-off-by: Sebastien Tardif --- .../Controls/SchemaConfigEditor.xaml.cs | 8 +----- .../Helpers/ConfigPathSensitivity.cs | 26 ++++++++++++++++++ .../ConfigPathSensitivityTests.cs | 27 +++++++++++++++++++ .../OpenClaw.Tray.Tests.csproj | 1 + 4 files changed, 55 insertions(+), 7 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Helpers/ConfigPathSensitivity.cs create mode 100644 tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs diff --git a/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs b/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs index 27c4c69db..bfaef5b32 100644 --- a/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs @@ -643,13 +643,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) { diff --git a/src/OpenClaw.Tray.WinUI/Helpers/ConfigPathSensitivity.cs b/src/OpenClaw.Tray.WinUI/Helpers/ConfigPathSensitivity.cs new file mode 100644 index 000000000..0af56bf44 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Helpers/ConfigPathSensitivity.cs @@ -0,0 +1,26 @@ +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) + || normalizedPath.Contains("webhookurl", StringComparison.Ordinal)) + { + return true; + } + + foreach (var segment in path.Split('.')) + { + if (segment.Equals("nsec", StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } +} diff --git a/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs b/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs new file mode 100644 index 000000000..e0cc6528a --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs @@ -0,0 +1,27 @@ +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.googlechat.webhookUrl", 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.discord.applicationId", false)] + [InlineData("channels.googlechat.webhook", false)] + public void IsSensitive_MasksSecretSegmentsAndLegacySecretNames(string path, bool expected) + { + Assert.Equal(expected, ConfigPathSensitivity.IsSensitive(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 @@ + From 7e488a40754685220cdfa5b0b5b7fb2d06fe95ad Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Fri, 25 Sep 2026 08:25:08 -0700 Subject: [PATCH 02/12] fix(config): mask the supported Nostr privateKey field The editor treated an exact nsec segment as secret, but the Gateway property is channels.nostr.privateKey. That stored value still loaded into a plain text box. Match an exact privateKey segment the same way as nsec. A longer name such as privateKeyExtra stays plain. Validation: ./build.ps1 exit 0. Shared 4107 passed, 32 skipped, 0 failed (4139 total). ConfigPathSensitivityTests 19 passed. Tray 3086 passed and 5 failed (3091 total). Those five are the existing LF source checks. Signed-off-by: Sebastien Tardif --- src/OpenClaw.Tray.WinUI/Helpers/ConfigPathSensitivity.cs | 3 ++- tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/OpenClaw.Tray.WinUI/Helpers/ConfigPathSensitivity.cs b/src/OpenClaw.Tray.WinUI/Helpers/ConfigPathSensitivity.cs index 0af56bf44..53eff98eb 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/ConfigPathSensitivity.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/ConfigPathSensitivity.cs @@ -17,7 +17,8 @@ public static bool IsSensitive(string path) foreach (var segment in path.Split('.')) { - if (segment.Equals("nsec", StringComparison.OrdinalIgnoreCase)) + if (segment.Equals("nsec", StringComparison.OrdinalIgnoreCase) + || segment.Equals("privateKey", StringComparison.OrdinalIgnoreCase)) return true; } diff --git a/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs b/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs index e0cc6528a..6b4615536 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs @@ -8,6 +8,9 @@ public class ConfigPathSensitivityTests [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.slack.webhookUrls", true)] [InlineData("channels.discord.token", true)] @@ -18,6 +21,7 @@ public class ConfigPathSensitivityTests [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) From 2bcc09808271b62353a92db58daac7c9c4bf19de Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sat, 26 Sep 2026 16:38:45 -0700 Subject: [PATCH 03/12] fix(config): hide stored webhook URL arrays A sensitive string array, including webhookUrls, uses a password box and does not copy the stored value into it. A blank box keeps the existing item. The schema JSON view and the fallback array view do the same. ./build.ps1 exit 0. Shared tests: 4107 passed, 32 skipped. Tray tests: 3087 passed, 5 failed on the LF source-contract mismatch tracked in #1518. ConfigPathSensitivityTests: 20 passed. Signed-off-by: Sebastien Tardif --- .../Controls/SchemaConfigEditor.xaml.cs | 94 +++++++++++++++---- .../ConfigPathSensitivityTests.cs | 32 +++++++ 2 files changed, 106 insertions(+), 20 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs b/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs index bfaef5b32..302dd4f2b 100644 --- a/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs @@ -458,6 +458,16 @@ private UIElement RenderJsonArrayField(string path, string label, string? descri }); } + if (IsSensitive(path)) + { + panel.Children.Add(new PasswordBox + { + IsEnabled = false, + PlaceholderText = "Leave blank to keep existing value" + }); + return panel; + } + var textBox = new TextBox { Text = config.ValueKind == JsonValueKind.Array @@ -570,24 +580,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 = string.IsNullOrEmpty(value) + ? "Value" + : "Leave blank to keep existing value" + }; + if (!string.IsNullOrEmpty(value)) + passwordBox.Tag = 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 { @@ -604,9 +638,9 @@ private void AddArrayItem(StackPanel itemsPanel, string path, string itemType, s 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 +658,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 (password.Tag is string existing && existing.Length > 0) + values.Add(existing); + continue; } + + if (row.Children[0] is TextBox tb) + values.Add(CoerceArrayItem(tb.Text, itemType)); } onChanged(values.ToArray()); } @@ -940,6 +983,17 @@ private void RenderConfigDirectly(string path, JsonElement config, StackPanel pa break; case JsonValueKind.Array: + if (IsSensitive(childPath)) + { + parent.Children.Add(new PasswordBox + { + Header = GetLabel(childPath, prop.Name), + IsEnabled = false, + PlaceholderText = "Leave blank to keep existing value" + }); + 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/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs b/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs index 6b4615536..574078e97 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs @@ -28,4 +28,36 @@ public void IsSensitive_MasksSecretSegmentsAndLegacySecretNames(string path, boo { 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("passwordBox.Tag = value", source, StringComparison.Ordinal); + Assert.DoesNotContain("Password = value", source, StringComparison.Ordinal); + Assert.Contains("else if (password.Tag is string existing", source, StringComparison.Ordinal); + Assert.Contains("if (IsSensitive(path))", source, StringComparison.Ordinal); + Assert.Contains("if (IsSensitive(childPath))", source, StringComparison.Ordinal); + } + + 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."); + } } From 29faa6b2e25f6d5917c4a48460eb14dc58c88511 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sat, 26 Sep 2026 17:01:19 -0700 Subject: [PATCH 04/12] fix(config): keep array secrets off the control and match webhook names exactly A stored webhook URL array stays in the editor, not on the password box. A blank box still keeps that item. webhookUrl and webhookUrls are exact path segments, so webhookUrlExtra stays a normal field. ./build.ps1 exit 0. Shared tests: 4107 passed, 32 skipped. Tray tests: 3089 passed, 5 failed on the LF source-contract mismatch tracked in #1518. ConfigPathSensitivityTests: 22 passed. Signed-off-by: Sebastien Tardif --- .../Controls/SchemaConfigEditor.xaml.cs | 8 ++++++-- src/OpenClaw.Tray.WinUI/Helpers/ConfigPathSensitivity.cs | 7 ++++--- tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs | 7 +++++-- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs b/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs index 302dd4f2b..9d3f75be1 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 @@ -592,7 +594,7 @@ private void AddArrayItem(StackPanel itemsPanel, string path, string itemType, s : "Leave blank to keep existing value" }; if (!string.IsNullOrEmpty(value)) - passwordBox.Tag = value; + _keptArraySecrets[passwordBox] = value; passwordBox.PasswordChanged += (s, e) => { if (_loading) return; @@ -633,6 +635,8 @@ 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); @@ -665,7 +669,7 @@ private void UpdateArrayChanges(StackPanel itemsPanel, string path, string itemT { if (!string.IsNullOrEmpty(password.Password)) values.Add(password.Password); - else if (password.Tag is string existing && existing.Length > 0) + else if (_keptArraySecrets.TryGetValue(password, out var existing) && existing.Length > 0) values.Add(existing); continue; } diff --git a/src/OpenClaw.Tray.WinUI/Helpers/ConfigPathSensitivity.cs b/src/OpenClaw.Tray.WinUI/Helpers/ConfigPathSensitivity.cs index 53eff98eb..af6996cac 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/ConfigPathSensitivity.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/ConfigPathSensitivity.cs @@ -9,8 +9,7 @@ public static bool IsSensitive(string path) || normalizedPath.Contains("secret", StringComparison.Ordinal) || normalizedPath.Contains("password", StringComparison.Ordinal) || normalizedPath.Contains("apikey", StringComparison.Ordinal) - || normalizedPath.Contains("api_key", StringComparison.Ordinal) - || normalizedPath.Contains("webhookurl", StringComparison.Ordinal)) + || normalizedPath.Contains("api_key", StringComparison.Ordinal)) { return true; } @@ -18,7 +17,9 @@ public static bool IsSensitive(string path) foreach (var segment in path.Split('.')) { if (segment.Equals("nsec", StringComparison.OrdinalIgnoreCase) - || segment.Equals("privateKey", StringComparison.OrdinalIgnoreCase)) + || segment.Equals("privateKey", StringComparison.OrdinalIgnoreCase) + || segment.Equals("webhookUrl", StringComparison.OrdinalIgnoreCase) + || segment.Equals("webhookUrls", StringComparison.OrdinalIgnoreCase)) return true; } diff --git a/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs b/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs index 574078e97..9d8012040 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs @@ -12,7 +12,9 @@ public class ConfigPathSensitivityTests [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)] @@ -37,9 +39,10 @@ public void SchemaEditor_HidesStoredValuesInSensitiveArrays() "src", "OpenClaw.Tray.WinUI", "Controls", "SchemaConfigEditor.xaml.cs")); Assert.Contains("itemType == \"string\" && IsSensitive(path)", source, StringComparison.Ordinal); - Assert.Contains("passwordBox.Tag = value", source, StringComparison.Ordinal); + Assert.Contains("_keptArraySecrets[passwordBox] = value", source, StringComparison.Ordinal); + Assert.DoesNotContain("passwordBox.Tag", source, StringComparison.Ordinal); Assert.DoesNotContain("Password = value", source, StringComparison.Ordinal); - Assert.Contains("else if (password.Tag is string existing", source, StringComparison.Ordinal); + Assert.Contains("_keptArraySecrets.TryGetValue(password, out var existing)", source, StringComparison.Ordinal); Assert.Contains("if (IsSensitive(path))", source, StringComparison.Ordinal); Assert.Contains("if (IsSensitive(childPath))", source, StringComparison.Ordinal); } From 92b1cd635d83f3f038f42f18ceea4d6559b21c5b Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sun, 27 Sep 2026 07:46:06 -0700 Subject: [PATCH 05/12] fix(config): keep empty sensitive array entries A loaded webhook URL row is remembered even when its stored value is empty. Editing another item no longer drops that row from the whole-array patch. A newly added blank row is still omitted until it has a value. ./build.ps1 exit 0. Shared tests: 4107 passed, 32 skipped. Tray tests: 3089 passed, 5 failed on the LF source-contract mismatch tracked in #1518. ConfigPathSensitivityTests: 22 passed. Signed-off-by: Sebastien Tardif --- .../Controls/SchemaConfigEditor.xaml.cs | 14 +++++++------- .../ConfigPathSensitivityTests.cs | 3 +++ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs b/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs index 9d3f75be1..b8ee52be8 100644 --- a/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs @@ -406,7 +406,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); } } @@ -572,7 +572,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 { @@ -589,11 +589,11 @@ private void AddArrayItem(StackPanel itemsPanel, string path, string itemType, s { MinWidth = 250, Height = 34, - PlaceholderText = string.IsNullOrEmpty(value) - ? "Value" - : "Leave blank to keep existing value" + PlaceholderText = existingRow + ? "Leave blank to keep existing value" + : "Value" }; - if (!string.IsNullOrEmpty(value)) + if (existingRow) _keptArraySecrets[passwordBox] = value; passwordBox.PasswordChanged += (s, e) => { @@ -669,7 +669,7 @@ private void UpdateArrayChanges(StackPanel itemsPanel, string path, string itemT { if (!string.IsNullOrEmpty(password.Password)) values.Add(password.Password); - else if (_keptArraySecrets.TryGetValue(password, out var existing) && existing.Length > 0) + else if (_keptArraySecrets.TryGetValue(password, out var existing)) values.Add(existing); continue; } diff --git a/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs b/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs index 9d8012040..2ee14c0c8 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs @@ -39,7 +39,10 @@ public void SchemaEditor_HidesStoredValuesInSensitiveArrays() "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); From 26cf9b9fd8fe22ad05550afe215a7b5d3b2c4999 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sun, 27 Sep 2026 08:18:01 -0700 Subject: [PATCH 06/12] fix(config): say hidden complex arrays cannot be edited here A sensitive array of objects stays masked. The note above it no longer tells the user to edit JSON below a disabled field. It says the stored values stay hidden and cannot be edited on this page. ./build.ps1 exit 0. Shared tests: 4107 passed, 32 skipped. Tray tests: 3089 passed, 5 failed on the LF source-contract mismatch tracked in #1518. ConfigPathSensitivityTests: 22 passed. Signed-off-by: Sebastien Tardif --- .../Controls/SchemaConfigEditor.xaml.cs | 7 +++++-- tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs b/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs index b8ee52be8..41c492215 100644 --- a/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs @@ -440,13 +440,16 @@ private UIElement RenderJsonArrayField(string path, string label, string? descri JsonElement config, TextBlock errorBlock, Action onChanged) { var panel = new StackPanel { Spacing = 6 }; + var hideStoredValues = IsSensitive(path); panel.Children.Add(new InfoBar { IsOpen = true, IsClosable = false, Severity = InfoBarSeverity.Informational, Title = label, - Message = "This array uses complex items. Edit its JSON below; local validation will run before Save is enabled." + Message = hideStoredValues + ? "Stored values in this array stay hidden. They cannot be edited on this page." + : "This array uses complex items. Edit its JSON below; local validation will run before Save is enabled." }); if (!string.IsNullOrEmpty(description)) @@ -460,7 +463,7 @@ private UIElement RenderJsonArrayField(string path, string label, string? descri }); } - if (IsSensitive(path)) + if (hideStoredValues) { panel.Children.Add(new PasswordBox { diff --git a/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs b/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs index 2ee14c0c8..e7bfd8ebd 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs @@ -46,7 +46,8 @@ public void SchemaEditor_HidesStoredValuesInSensitiveArrays() 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.Contains("if (IsSensitive(path))", source, StringComparison.Ordinal); + Assert.Contains("var hideStoredValues = IsSensitive(path)", source, StringComparison.Ordinal); + Assert.Contains("They cannot be edited on this page.", source, StringComparison.Ordinal); Assert.Contains("if (IsSensitive(childPath))", source, StringComparison.Ordinal); } From b3c02a5bc0f9a3e37da143b860a0bae4d08e85ce Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Mon, 28 Sep 2026 15:36:37 -0700 Subject: [PATCH 07/12] fix(config): replace hidden complex arrays instead of leaving them read-only Sensitive complex arrays stay hidden and show only a count. Replace all opens a blank editor and sends only the new JSON. Clear all asks, then sends an empty array. Cancel leaves the stored array out of the patch. The same editor is used when the page has no schema. ./build.ps1 exit 0. Shared 4106 passed, 32 skipped, and the dispose flake passed alone. Tray 3094 passed, 5 failed, the LF source checks tracked in #1518. Signed-off-by: Sebastien Tardif --- .../Controls/SchemaConfigEditor.xaml.cs | 185 ++++++++++++++++-- .../Services/ConfigEditorModel.cs | 99 ++++++++++ .../ConfigEditorModelTests.cs | 70 +++++++ .../ConfigPathSensitivityTests.cs | 24 ++- 4 files changed, 356 insertions(+), 22 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs b/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs index 41c492215..288640439 100644 --- a/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs @@ -439,17 +439,20 @@ private UIElement RenderArrayField(string path, string label, string? descriptio private UIElement RenderJsonArrayField(string path, string label, string? description, JsonElement config, TextBlock errorBlock, Action onChanged) { + if (IsSensitive(path)) + { + var existingCount = config.ValueKind == JsonValueKind.Array ? config.GetArrayLength() : 0; + return BuildSensitiveArrayEditor(path, label, existingCount, description, onChanged); + } + var panel = new StackPanel { Spacing = 6 }; - var hideStoredValues = IsSensitive(path); panel.Children.Add(new InfoBar { IsOpen = true, IsClosable = false, Severity = InfoBarSeverity.Informational, Title = label, - Message = hideStoredValues - ? "Stored values in this array stay hidden. They cannot be edited on this page." - : "This array uses complex items. Edit its JSON below; local validation will run before Save is enabled." + Message = "This array uses complex items. Edit its JSON below; local validation will run before Save is enabled." }); if (!string.IsNullOrEmpty(description)) @@ -463,16 +466,6 @@ private UIElement RenderJsonArrayField(string path, string label, string? descri }); } - if (hideStoredValues) - { - panel.Children.Add(new PasswordBox - { - IsEnabled = false, - PlaceholderText = "Leave blank to keep existing value" - }); - return panel; - } - var textBox = new TextBox { Text = config.ValueKind == JsonValueKind.Array @@ -512,6 +505,150 @@ private UIElement RenderJsonArrayField(string path, string label, string? descri return panel; } + private UIElement BuildSensitiveArrayEditor(string path, string label, int existingCount, + string? description, Action onChanged) + { + var session = new SensitiveArrayEditSession(existingCount); + 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 array. 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.TryApplyReplace() || session.Replacement is not JsonElement replacement) + { + SetValidationError(path, session.Error, errorBlock); + return; + } + + 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); + } + }; + 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 array.", + 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 array will be cleared on save."); + onChanged(SensitiveArrayEditSession.EmptyArray()); + }; + cancelClearButton.Click += (_, _) => + { + if (_loading) return; + session.CancelClear(); + confirmPanel.Visibility = Visibility.Collapsed; + }; + 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) { @@ -992,12 +1129,20 @@ private void RenderConfigDirectly(string path, JsonElement config, StackPanel pa case JsonValueKind.Array: if (IsSensitive(childPath)) { - parent.Children.Add(new PasswordBox - { - Header = GetLabel(childPath, prop.Name), - IsEnabled = false, - PlaceholderText = "Leave blank to keep existing value" - }); + parent.Children.Add(BuildSensitiveArrayEditor( + childPath, + GetLabel(childPath, prop.Name), + value.GetArrayLength(), + null, + edited => + { + if (_loading) return; + if (ReferenceEquals(edited, RemovePendingValue)) + _changes.Remove(childPath); + else + _changes[childPath] = edited; + ConfigChanged?.Invoke(this, new SchemaConfigChangedEventArgs(GetChanges(), GetValidationErrors())); + })); break; } diff --git a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs index e76669643..d1f5c304b 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs @@ -141,3 +141,102 @@ 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 whose current items 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) + { + if (existingCount < 0) + throw new ArgumentOutOfRangeException(nameof(existingCount)); + ExistingCount = existingCount; + } + + public int ExistingCount { 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 void BeginReplace() + { + ReplaceOpen = true; + ClearConfirmOpen = false; + Draft = ""; + Error = null; + } + + public void SetDraft(string? text) => Draft = text ?? ""; + + public bool TryApplyReplace() + { + try + { + using var document = JsonDocument.Parse(Draft); + if (document.RootElement.ValueKind != JsonValueKind.Array) + { + Error = "Must be a JSON array."; + return false; + } + + Replacement = document.RootElement.Clone(); + Decision = SensitiveArrayDecision.Replace; + Error = null; + ReplaceOpen = false; + Draft = ""; + return true; + } + catch (JsonException ex) + { + Error = $"Invalid JSON: {ex.Message}"; + return false; + } + } + + 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; +} diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs index b6a8c7bff..4734ad848 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,73 @@ 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_NearMatchObject_IsNotASecretArrayDecision() + { + Assert.False(ConfigPathSensitivity.IsSensitive("channels.googlechat.webhookUrlExtra")); + Assert.True(ConfigPathSensitivity.IsSensitive("channels.googlechat.webhookUrl")); + } } diff --git a/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs b/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs index e7bfd8ebd..0d8581142 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs @@ -46,11 +46,31 @@ public void SchemaEditor_HidesStoredValuesInSensitiveArrays() 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.Contains("var hideStoredValues = IsSensitive(path)", source, StringComparison.Ordinal); - Assert.Contains("They cannot be edited on this page.", source, StringComparison.Ordinal); + Assert.DoesNotContain("They cannot be edited on this page.", source, StringComparison.Ordinal); + Assert.Contains("return BuildSensitiveArrayEditor(path, label, existingCount, description, onChanged);", 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); } + 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"); From a18cd30510dbb4ff7754c6f84dd822ff6bdc6617 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Mon, 28 Sep 2026 15:56:20 -0700 Subject: [PATCH 08/12] ci: retrigger setup E2E after wizard restart exit -1 Setup and connect E2E failed in the shared wizard before any proof ran. wsl.exe exited -1 after 30 seconds. The recorded category was other_restart_failure, and the owner predicate was not in the CLI output. Network recovery and Revocation recovery passed. This diff does not touch setup. Signed-off-by: Sebastien Tardif From adc98c46cbbfb35f2780f51b53db723788b2315a Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 29 Sep 2026 08:19:11 -0700 Subject: [PATCH 09/12] fix(config): hide sensitive objects and reject wrong array item kinds Sensitive schema objects, and schema-less sensitive objects, use the same blank replace and confirmed clear editor as complex arrays. Stored values stay off the control. Replace all checks the JSON kind and each array item kind before staging. Invalid input keeps the original value and blocks Save. Cancelling Clear all drops the abandoned validation error. ./build.ps1 exit 0. Shared 4107 passed, 32 skipped. Tray 3100 passed, 5 failed on the known LF source contracts (#1518). Focused editor and model tests passed. Signed-off-by: Sebastien Tardif --- .../Controls/SchemaConfigEditor.xaml.cs | 121 ++++++++++++++---- .../Services/ConfigEditorModel.cs | 97 ++++++++++++-- .../ConfigEditorModelTests.cs | 107 ++++++++++++++++ .../ConfigPathSensitivityTests.cs | 10 +- 4 files changed, 302 insertions(+), 33 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs b/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs index 288640439..82c6e1a65 100644 --- a/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs @@ -227,9 +227,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 (type == "object" && isSensitive) + { + 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, @@ -375,12 +386,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 }; @@ -437,12 +448,13 @@ 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); + return BuildSensitiveArrayEditor( + path, label, existingCount, description, onChanged, valueSchema: valueSchema); } var panel = new StackPanel { Spacing = 6 }; @@ -506,9 +518,12 @@ private UIElement RenderJsonArrayField(string path, string label, string? descri } private UIElement BuildSensitiveArrayEditor(string path, string label, int existingCount, - string? description, Action onChanged) + string? description, Action onChanged, + JsonValueKind expectedKind = JsonValueKind.Array, + JsonElement valueSchema = default) { - var session = new SensitiveArrayEditSession(existingCount); + var session = new SensitiveArrayEditSession(existingCount, expectedKind); + var valueNoun = expectedKind == JsonValueKind.Object ? "object" : "array"; var panel = new StackPanel { Spacing = 6 }; var status = new TextBlock { @@ -525,7 +540,7 @@ private UIElement BuildSensitiveArrayEditor(string path, string label, int exist FontFamily = new FontFamily("Consolas"), MinHeight = 120, HorizontalAlignment = HorizontalAlignment.Stretch, - PlaceholderText = "Enter a JSON array. This box starts empty." + 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 }; @@ -574,12 +589,22 @@ private UIElement BuildSensitiveArrayEditor(string path, string label, int exist { if (_loading) return; session.SetDraft(editor.Text); - if (!session.TryApplyReplace() || session.Replacement is not JsonElement replacement) + 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)) { - SetValidationError(path, session.Error, errorBlock); + RejectSensitiveDraft(path, schemaError, errorBlock); return; } + session.CommitReplace(replacement); editor.Text = ""; replacePanel.Visibility = Visibility.Collapsed; SetValidationError(path, null, errorBlock); @@ -598,6 +623,14 @@ private UIElement BuildSensitiveArrayEditor(string path, string label, int exist 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); @@ -609,7 +642,7 @@ private UIElement BuildSensitiveArrayEditor(string path, string label, int exist confirmPanel.Children.Add(new TextBlock { - Text = "Clear all stored entries? This removes every stored value in this array.", + Text = $"Clear all stored entries? This removes every stored value in this {valueNoun}.", TextWrapping = TextWrapping.Wrap }); var confirmClearButton = new Button { Content = "Clear all" }; @@ -622,14 +655,15 @@ private UIElement BuildSensitiveArrayEditor(string path, string label, int exist replacePanel.Visibility = Visibility.Collapsed; editor.Text = ""; SetValidationError(path, null, errorBlock); - ShowStatus("This array will be cleared on save."); - onChanged(SensitiveArrayEditSession.EmptyArray()); + ShowStatus($"This {valueNoun} will be cleared on save."); + onChanged(session.EmptyReplacement()); }; cancelClearButton.Click += (_, _) => { if (_loading) return; session.CancelClear(); confirmPanel.Visibility = Visibility.Collapsed; + AbandonSensitiveDraft(path, errorBlock); }; var confirmActions = new StackPanel { Orientation = Orientation.Horizontal }; confirmActions.Children.Add(confirmClearButton); @@ -850,12 +884,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), @@ -900,6 +963,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) @@ -948,6 +1017,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()) { @@ -1049,6 +1122,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 { @@ -1134,15 +1217,7 @@ private void RenderConfigDirectly(string path, JsonElement config, StackPanel pa GetLabel(childPath, prop.Name), value.GetArrayLength(), null, - edited => - { - if (_loading) return; - if (ReferenceEquals(edited, RemovePendingValue)) - _changes.Remove(childPath); - else - _changes[childPath] = edited; - ConfigChanged?.Invoke(this, new SchemaConfigChangedEventArgs(GetChanges(), GetValidationErrors())); - })); + edited => StageDirectChange(childPath, edited))); break; } diff --git a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs index d1f5c304b..473164a37 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs @@ -115,6 +115,54 @@ 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; + } + + 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); @@ -150,19 +198,23 @@ internal enum SensitiveArrayDecision } /// -/// Edit session for a sensitive array whose current items must stay off the page. +/// 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) + 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; } = ""; @@ -180,6 +232,15 @@ public static JsonElement EmptyArray() 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; @@ -190,22 +251,22 @@ public void BeginReplace() public void SetDraft(string? text) => Draft = text ?? ""; - public bool TryApplyReplace() + public bool TryReadDraft(out JsonElement parsed) { + parsed = default; try { using var document = JsonDocument.Parse(Draft); - if (document.RootElement.ValueKind != JsonValueKind.Array) + if (document.RootElement.ValueKind != ExpectedKind) { - Error = "Must be a JSON array."; + Error = ExpectedKind == JsonValueKind.Object + ? "Must be a JSON object." + : "Must be a JSON array."; return false; } - Replacement = document.RootElement.Clone(); - Decision = SensitiveArrayDecision.Replace; + parsed = document.RootElement.Clone(); Error = null; - ReplaceOpen = false; - Draft = ""; return true; } catch (JsonException ex) @@ -215,6 +276,24 @@ public bool TryApplyReplace() } } + 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; diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs index 4734ad848..9a3c728c5 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs @@ -203,4 +203,111 @@ 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)); + } } diff --git a/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs b/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs index 0d8581142..2ab1d9894 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs @@ -47,7 +47,7 @@ public void SchemaEditor_HidesStoredValuesInSensitiveArrays() 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("return BuildSensitiveArrayEditor(path, label, existingCount, description, onChanged);", 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); @@ -56,6 +56,14 @@ public void SchemaEditor_HidesStoredValuesInSensitiveArrays() 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("type == \"object\" && isSensitive", source, StringComparison.Ordinal); + 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); } private static int CountOf(string source, string text) From 0a3a1c67c876c281efba0739bc088bd9f23e0437 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 29 Sep 2026 09:15:41 -0700 Subject: [PATCH 10/12] fix(config): hide sensitive objects that declare properties A classified object with a properties map was expanded into child controls before the hidden editor ran. Decide that case first, keep the stored children off the page, and cover the properties schema. Signed-off-by: Sebastien Tardif --- .../Controls/SchemaConfigEditor.xaml.cs | 9 +++- .../Services/ConfigEditorModel.cs | 4 ++ .../ConfigEditorModelTests.cs | 43 +++++++++++++++++++ .../ConfigPathSensitivityTests.cs | 6 ++- 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs b/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs index 82c6e1a65..3ad173abf 100644 --- a/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs @@ -121,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); } @@ -230,7 +235,7 @@ private void RenderField(string path, string name, JsonElement schema, control = RenderArrayField(path, headerText, description, schema, itemsSchema, effectiveConfig, errorBlock, value => StageValue(path, value, schema, required, errorBlock)); } - else if (type == "object" && isSensitive) + else if (ConfigEditorModel.UseHiddenObjectEditor(path, schema)) { control = BuildSensitiveArrayEditor( path, diff --git a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs index 473164a37..7b2c87388 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; @@ -142,6 +143,9 @@ public static JsonElement ApplyRelativeChanges( 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)) diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs index 9a3c728c5..6e5075ae9 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs @@ -310,4 +310,47 @@ public void ArrayItemKinds_AcceptsObjectAndStringReplacements() 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 index 2ab1d9894..529aa1b1a 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs @@ -56,7 +56,11 @@ public void SchemaEditor_HidesStoredValuesInSensitiveArrays() 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("type == \"object\" && isSensitive", 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); From b2c836d4b9dae9d24fa430e0c8633cc2ac206ac3 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 29 Sep 2026 09:52:37 -0700 Subject: [PATCH 11/12] ci: retrigger after piper and wsl-create flakes Shared failed only ExtractTarBz2Async_CancellationIsBoundedAndKillsExtractor because the extractor process was still running. That test passed locally. Revocation recovery failed in wsl-create: the Ubuntu-24.04 install exited -1 with no output after 900 seconds. The tree matches 0a3a1c67. Signed-off-by: Sebastien Tardif From 3f81db4d114cba09c3bb2cb66c8fb113bf511152 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 30 Sep 2026 13:43:28 -0700 Subject: [PATCH 12/12] fix(config): restore a committed secret replacement when clear is canceled A valid Replace all stayed on the edit session after a later invalid draft was rejected. Canceling Clear all cleared the error and left the pending change removed, so a later save kept the stored secret. Cancel clear now stages the committed replacement again. SensitiveArray_RejectedRetryThenCanceledClear_RestoresCommittedReplacement passed. ./build.ps1 exit 0. Shared 4107 passed, 32 skipped. Tray 3102 passed and 5 failed, the LF source-contract mismatch in #1518. Signed-off-by: Sebastien Tardif --- .../Controls/SchemaConfigEditor.xaml.cs | 5 ++++- .../Services/ConfigEditorModel.cs | 12 ++++++++++++ .../ConfigEditorModelTests.cs | 19 +++++++++++++++++++ .../ConfigPathSensitivityTests.cs | 1 + 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs b/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs index 3ad173abf..4dc282c75 100644 --- a/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Controls/SchemaConfigEditor.xaml.cs @@ -668,7 +668,10 @@ private UIElement BuildSensitiveArrayEditor(string path, string label, int exist if (_loading) return; session.CancelClear(); confirmPanel.Visibility = Visibility.Collapsed; - AbandonSensitiveDraft(path, errorBlock); + if (session.TryRestoreCommittedReplacement(out var previous)) + onChanged(previous); + else + AbandonSensitiveDraft(path, errorBlock); }; var confirmActions = new StackPanel { Orientation = Orientation.Horizontal }; confirmActions.Children.Add(confirmClearButton); diff --git a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs index 7b2c87388..ca34bb52d 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs @@ -322,4 +322,16 @@ public void ConfirmClear() } 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 6e5075ae9..ff8761969 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs @@ -197,6 +197,25 @@ public void SensitiveArray_CancelReplaceOrClear_KeepsTheStoredArray() 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() { diff --git a/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs b/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs index 529aa1b1a..762c3512c 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigPathSensitivityTests.cs @@ -68,6 +68,7 @@ public void SchemaEditor_HidesStoredValuesInSensitiveArrays() 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)