From 6e7d9d73db57015d3cb10d7395ce0243196ac35f Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 23 Sep 2026 18:46:36 -0700 Subject: [PATCH 01/28] fix(config): refuse a save that would write redacted secrets back - Block config.patch when an unedited field still holds a gateway redaction sentinel - Allow a field the user replaced - Do not treat the substring redacted inside ordinary text as a sentinel Signed-off-by: Sebastien Tardif --- .../ChannelConfigPatchBuilder.cs | 6 +- .../Pages/ConfigPage.xaml.cs | 10 +++ .../Services/ConfigEditorModel.cs | 45 ++++++++++++ .../ConfigEditorModelTests.cs | 72 +++++++++++++++++++ 4 files changed, 131 insertions(+), 2 deletions(-) diff --git a/src/OpenClaw.Shared/ChannelConfigPatchBuilder.cs b/src/OpenClaw.Shared/ChannelConfigPatchBuilder.cs index df88bafe6b..953949566b 100644 --- a/src/OpenClaw.Shared/ChannelConfigPatchBuilder.cs +++ b/src/OpenClaw.Shared/ChannelConfigPatchBuilder.cs @@ -59,6 +59,9 @@ public static class ChannelConfigPatchBuilder "********", }; + public static bool IsRedactionSentinel(string? value) => + value != null && RedactionSentinels.Contains(value.Trim()); + /// /// Build a patched full-config from the cached config and a list of /// per-field updates for one channel. @@ -168,8 +171,7 @@ public static ChannelPatchBuildResult BuildPatch( } else if (el.ValueKind == JsonValueKind.String) { - var v = el.GetString(); - if (v != null && RedactionSentinels.Contains(v.Trim())) + if (IsRedactionSentinel(el.GetString())) return path; } return null; diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs index 0a6f4234d8..006b858cc8 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs @@ -550,6 +550,16 @@ private async Task OnSaveAsync() try { var updated = ConfigEditorModel.ApplyChanges(saveBase.Root, _pendingChanges); + var blockedPath = ConfigEditorModel.FindUneditedRedactionSentinel(updated, _pendingChanges.Keys); + if (blockedPath != null) + { + ShowStatus( + L("ConfigPage_StatusSaveFailedTitle"), + L("ConfigPage_StatusSaveRejectedMessage"), + InfoBarSeverity.Error); + return; + } + var result = await client.PatchConfigDetailedAsync(updated, saveBase.BaseHash); if (!result.Ok) { diff --git a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs index e766696433..3ecc1b3f8d 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 OpenClaw.Shared; namespace OpenClawTray.Services; @@ -115,6 +116,50 @@ public static JsonElement ApplyRelativeChanges( return value; } + public static string? FindUneditedRedactionSentinel( + JsonElement document, + IEnumerable editedPaths) + { + var edited = new HashSet(editedPaths, StringComparer.Ordinal); + return FindUneditedRedactionSentinel(document, "", edited); + } + + private static string? FindUneditedRedactionSentinel( + JsonElement element, + string path, + HashSet editedPaths) + { + if (element.ValueKind == JsonValueKind.Object) + { + foreach (var property in element.EnumerateObject()) + { + var childPath = string.IsNullOrEmpty(path) ? property.Name : $"{path}.{property.Name}"; + var hit = FindUneditedRedactionSentinel(property.Value, childPath, editedPaths); + if (hit != null) + return hit; + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + var index = 0; + foreach (var item in element.EnumerateArray()) + { + var childPath = $"{path}[{index++}]"; + var hit = FindUneditedRedactionSentinel(item, childPath, editedPaths); + if (hit != null) + return hit; + } + } + else if (element.ValueKind == JsonValueKind.String && + !editedPaths.Contains(path) && + ChannelConfigPatchBuilder.IsRedactionSentinel(element.GetString())) + { + return path; + } + + return null; + } + private static void SetPath(JsonNode node, string dotPath, JsonNode? value) { var segments = dotPath.Split('.', StringSplitOptions.RemoveEmptyEntries); diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs index b6a8c7bffa..d52db7a5cf 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs @@ -133,4 +133,76 @@ public void ApplyChanges_IgnoresUnsupportedPlainObjectValues() Assert.Equal("existing", updated.GetProperty("secret").GetString()); Assert.Equal("manual", updated.GetProperty("mode").GetString()); } + + [Fact] + public void FindUneditedRedactionSentinel_BlocksUntouchedRedactedSibling() + { + using var document = JsonDocument.Parse(""" + { + "channels": { + "slack": { "signingSecret": "", "enabled": true }, + "telegram": { "botToken": "old", "enabled": true } + } + } + """); + + var updated = ConfigEditorModel.ApplyChanges( + document.RootElement, + new Dictionary + { + ["channels.telegram.botToken"] = "new-token", + }); + + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + updated, + new[] { "channels.telegram.botToken" }); + + Assert.Equal("channels.slack.signingSecret", blocked); + Assert.Equal("", updated.GetProperty("channels").GetProperty("slack").GetProperty("signingSecret").GetString()); + Assert.Equal("new-token", updated.GetProperty("channels").GetProperty("telegram").GetProperty("botToken").GetString()); + } + + [Fact] + public void FindUneditedRedactionSentinel_AllowsUserReplacedToken() + { + using var document = JsonDocument.Parse(""" + { + "channels": { + "telegram": { "botToken": "", "enabled": true } + } + } + """); + + var updated = ConfigEditorModel.ApplyChanges( + document.RootElement, + new Dictionary + { + ["channels.telegram.botToken"] = "real-new-token", + }); + + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + updated, + new[] { "channels.telegram.botToken" }); + + Assert.Null(blocked); + Assert.Equal("real-new-token", updated.GetProperty("channels").GetProperty("telegram").GetProperty("botToken").GetString()); + } + + [Fact] + public void FindUneditedRedactionSentinel_IgnoresRedactedSubstring() + { + using var document = JsonDocument.Parse(""" + { + "channels": { + "slack": { "label": "no redacted secrets here", "enabled": true } + } + } + """); + + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + document.RootElement, + Array.Empty()); + + Assert.Null(blocked); + } } From 1bb4cc578731e00e0f8dddac1d3e6f26aa829ff1 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 23 Sep 2026 19:06:15 -0700 Subject: [PATCH 02/28] ci: retrigger after setup E2E sqlite lock flake Signed-off-by: Sebastien Tardif From a8418f9e7570d095244bd121797c3cb673cec427 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 24 Sep 2026 12:21:26 -0700 Subject: [PATCH 03/28] fix(config): block a redacted placeholder even after the field was edited Signed-off-by: Sebastien Tardif --- .../Pages/ConfigPage.xaml.cs | 2 +- .../Services/ConfigEditorModel.cs | 12 +++--- .../Strings/en-us/Resources.resw | 3 ++ .../Strings/fr-fr/Resources.resw | 3 ++ .../Strings/nl-nl/Resources.resw | 3 ++ .../Strings/pt-br/Resources.resw | 3 ++ .../Strings/zh-cn/Resources.resw | 3 ++ .../Strings/zh-tw/Resources.resw | 3 ++ .../ConfigEditorModelTests.cs | 43 +++++++++++++++++++ 9 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs index 006b858cc8..e7af176b0d 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs @@ -555,7 +555,7 @@ private async Task OnSaveAsync() { ShowStatus( L("ConfigPage_StatusSaveFailedTitle"), - L("ConfigPage_StatusSaveRejectedMessage"), + Lf("ConfigPage_StatusSaveBlockedSentinelFormat", blockedPath), InfoBarSeverity.Error); return; } diff --git a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs index 3ecc1b3f8d..f28bf16dd4 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs @@ -120,21 +120,20 @@ public static JsonElement ApplyRelativeChanges( JsonElement document, IEnumerable editedPaths) { - var edited = new HashSet(editedPaths, StringComparer.Ordinal); - return FindUneditedRedactionSentinel(document, "", edited); + _ = editedPaths; + return FindUneditedRedactionSentinel(document, ""); } private static string? FindUneditedRedactionSentinel( JsonElement element, - string path, - HashSet editedPaths) + string path) { if (element.ValueKind == JsonValueKind.Object) { foreach (var property in element.EnumerateObject()) { var childPath = string.IsNullOrEmpty(path) ? property.Name : $"{path}.{property.Name}"; - var hit = FindUneditedRedactionSentinel(property.Value, childPath, editedPaths); + var hit = FindUneditedRedactionSentinel(property.Value, childPath); if (hit != null) return hit; } @@ -145,13 +144,12 @@ public static JsonElement ApplyRelativeChanges( foreach (var item in element.EnumerateArray()) { var childPath = $"{path}[{index++}]"; - var hit = FindUneditedRedactionSentinel(item, childPath, editedPaths); + var hit = FindUneditedRedactionSentinel(item, childPath); if (hit != null) return hit; } } else if (element.ValueKind == JsonValueKind.String && - !editedPaths.Contains(path) && ChannelConfigPatchBuilder.IsRedactionSentinel(element.GetString())) { return path; diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index e931589d0b..f151724ec3 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -6394,6 +6394,9 @@ Make sure the gateway is running. The gateway rejected the config update. Your changes are preserved. + + Save blocked. A redacted placeholder is still present at {0}. The value was not sent. + {0} Your changes are preserved. diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 549a435ad4..f679427128 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -6387,6 +6387,9 @@ Assurez-vous que la passerelle est en cours d'exécution. Échec de l'enregistrement + + Save blocked. A redacted placeholder is still present at {0}. The value was not sent. + La passerelle a rejeté la mise à jour de configuration. Vos modifications sont conservées. diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index ed2c637be4..67e74728d1 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -6388,6 +6388,9 @@ Controleer of de gateway actief is. Opslaan mislukt + + Save blocked. A redacted placeholder is still present at {0}. The value was not sent. + De gateway heeft de configuratie-update geweigerd. Uw wijzigingen blijven behouden. diff --git a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw index be154fda82..d31b41faa9 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw @@ -6365,6 +6365,9 @@ Certifique-se de que o Gateway está em execução. Falha ao salvar + + Save blocked. A redacted placeholder is still present at {0}. The value was not sent. + O Gateway rejeitou a atualização de configuração. Suas alterações foram preservadas. diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 7a6e3701ea..7341595c79 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -6387,6 +6387,9 @@ 保存失败 + + Save blocked. A redacted placeholder is still present at {0}. The value was not sent. + 网关拒绝了配置更新。你的更改已保留。 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index 298c7ecd7d..3e09a1abaa 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -6387,6 +6387,9 @@ 儲存失敗 + + Save blocked. A redacted placeholder is still present at {0}. The value was not sent. + 閘道拒絕了設定更新。您的變更已保留。 diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs index d52db7a5cf..5b8f8bb472 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs @@ -205,4 +205,47 @@ public void FindUneditedRedactionSentinel_IgnoresRedactedSubstring() Assert.Null(blocked); } + + [Fact] + public void FindUneditedRedactionSentinel_BlocksEditedValueThatIsStillTheSentinel() + { + using var document = JsonDocument.Parse(""" + { + "channels": { + "telegram": { "botToken": "", "enabled": true } + } + } + """); + + var updated = ConfigEditorModel.ApplyChanges( + document.RootElement, + new Dictionary + { + ["channels.telegram.botToken"] = "", + }); + + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + updated, + new[] { "channels.telegram.botToken" }); + + Assert.Equal("channels.telegram.botToken", blocked); + } + + [Fact] + public void FindUneditedRedactionSentinel_BlocksArraySentinelWhenEditUsedTheUnindexedPath() + { + using var document = JsonDocument.Parse(""" + { + "channels": { + "slack": { "accounts": [ { "token": "" } ] } + } + } + """); + + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + document.RootElement, + new[] { "channels.slack.accounts.token" }); + + Assert.Equal("channels.slack.accounts[0].token", blocked); + } } From 01d2f19c6b55bcb3aef7221f8afa3bdb497c2ef7 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Fri, 25 Sep 2026 03:09:11 -0700 Subject: [PATCH 04/28] fix(config): translate the blocked-save sentence Neighboring Config page status strings are translated in the same change. This sentence follows that pattern in French, Dutch, Brazilian Portuguese, and both Chinese locales. The {0} path placeholder stays. The locale parity test passes. Signed-off-by: Sebastien Tardif --- src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw | 2 +- src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw | 2 +- src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw | 2 +- src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw | 2 +- src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index f679427128..bfdd52d604 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -6388,7 +6388,7 @@ Assurez-vous que la passerelle est en cours d'exécution. Échec de l'enregistrement - Save blocked. A redacted placeholder is still present at {0}. The value was not sent. + Enregistrement bloqué. Un espace réservé caviardé est encore présent dans {0}. La valeur n'a pas été envoyée. La passerelle a rejeté la mise à jour de configuration. Vos modifications sont conservées. diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index 67e74728d1..4c3bc035d2 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -6389,7 +6389,7 @@ Controleer of de gateway actief is. Opslaan mislukt - Save blocked. A redacted placeholder is still present at {0}. The value was not sent. + Opslaan geblokkeerd. Een afgeschermde tijdelijke aanduiding staat nog in {0}. De waarde is niet verzonden. De gateway heeft de configuratie-update geweigerd. Uw wijzigingen blijven behouden. diff --git a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw index d31b41faa9..3858e104c9 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw @@ -6366,7 +6366,7 @@ Certifique-se de que o Gateway está em execução. Falha ao salvar - Save blocked. A redacted placeholder is still present at {0}. The value was not sent. + Salvamento bloqueado. Um marcador oculto ainda está presente em {0}. O valor não foi enviado. O Gateway rejeitou a atualização de configuração. Suas alterações foram preservadas. diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 7341595c79..e26e84027c 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -6388,7 +6388,7 @@ 保存失败 - Save blocked. A redacted placeholder is still present at {0}. The value was not sent. + 保存已被阻止。{0} 中仍有脱敏占位符。该值未发送。 网关拒绝了配置更新。你的更改已保留。 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index 3e09a1abaa..cc77d8ccda 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -6388,7 +6388,7 @@ 儲存失敗 - Save blocked. A redacted placeholder is still present at {0}. The value was not sent. + 儲存已遭封鎖。{0} 中仍有遮罩預留文字。該值未傳送。 閘道拒絕了設定更新。您的變更已保留。 From fb1a3442331396325f0bb6c00711fbbb6c7e250d Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sun, 27 Sep 2026 12:25:03 -0700 Subject: [PATCH 05/28] fix(config): let unrelated saves skip untouched redaction masks Config save no longer refuses the whole document because another field still holds a gateway mask. Untouched masks are omitted from the patch so a merge keeps the stored secret. A save is refused only when the edited value is still a loaded placeholder, or when the placeholder sits in an array that cannot be omitted. A literal mask typed into a field that was not redacted is sent. Tests: FindUneditedRedactionSentinel 6 passed. ./build.ps1 exit 0. Shared 4099 passed, 32 skipped. Tray 3070 passed. Five source contract tests still fail on an LF checkout because they expect CRLF snippets. That mismatch is the separate line-ending issue. Signed-off-by: Sebastien Tardif --- .../Pages/ConfigPage.xaml.cs | 7 +- .../Services/ConfigEditorModel.cs | 100 +++++++++++++++--- .../ConfigEditorModelTests.cs | 55 ++++++++-- 3 files changed, 138 insertions(+), 24 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs index e7af176b0d..627a92108f 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs @@ -550,7 +550,10 @@ private async Task OnSaveAsync() try { var updated = ConfigEditorModel.ApplyChanges(saveBase.Root, _pendingChanges); - var blockedPath = ConfigEditorModel.FindUneditedRedactionSentinel(updated, _pendingChanges.Keys); + var blockedPath = ConfigEditorModel.FindUneditedRedactionSentinel( + updated, + _pendingChanges.Keys, + saveBase.Root); if (blockedPath != null) { ShowStatus( @@ -560,6 +563,8 @@ private async Task OnSaveAsync() return; } + updated = ConfigEditorModel.OmitUntouchedRedactionSentinels(updated, _pendingChanges.Keys); + var result = await client.PatchConfigDetailedAsync(updated, saveBase.BaseHash); if (!result.Ok) { diff --git a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs index f28bf16dd4..4c43daf6e1 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs @@ -117,47 +117,117 @@ public static JsonElement ApplyRelativeChanges( } public static string? FindUneditedRedactionSentinel( + JsonElement updated, + IEnumerable editedPaths, + JsonElement baseDocument) + { + var edited = new HashSet(editedPaths, StringComparer.OrdinalIgnoreCase); + return FindBlockedRedactionSentinel(updated, baseDocument, "", edited); + } + + public static JsonElement OmitUntouchedRedactionSentinels( JsonElement document, IEnumerable editedPaths) { - _ = editedPaths; - return FindUneditedRedactionSentinel(document, ""); + var node = JsonNode.Parse(document.GetRawText()); + if (node is null) + return document.Clone(); + + var edited = new HashSet(editedPaths, StringComparer.OrdinalIgnoreCase); + RemoveUntouchedRedactionSentinels(node, "", edited); + using var rewritten = JsonDocument.Parse(node.ToJsonString()); + return rewritten.RootElement.Clone(); } - private static string? FindUneditedRedactionSentinel( - JsonElement element, - string path) + private static string? FindBlockedRedactionSentinel( + JsonElement updated, + JsonElement baseDocument, + string path, + HashSet edited) { - if (element.ValueKind == JsonValueKind.Object) + if (updated.ValueKind == JsonValueKind.Object) { - foreach (var property in element.EnumerateObject()) + var baseObject = baseDocument.ValueKind == JsonValueKind.Object + ? baseDocument + : default; + foreach (var property in updated.EnumerateObject()) { var childPath = string.IsNullOrEmpty(path) ? property.Name : $"{path}.{property.Name}"; - var hit = FindUneditedRedactionSentinel(property.Value, childPath); + var childBase = default(JsonElement); + if (baseObject.ValueKind == JsonValueKind.Object) + baseObject.TryGetProperty(property.Name, out childBase); + var hit = FindBlockedRedactionSentinel(property.Value, childBase, childPath, edited); if (hit != null) return hit; } } - else if (element.ValueKind == JsonValueKind.Array) + else if (updated.ValueKind == JsonValueKind.Array) { var index = 0; - foreach (var item in element.EnumerateArray()) + foreach (var item in updated.EnumerateArray()) { - var childPath = $"{path}[{index++}]"; - var hit = FindUneditedRedactionSentinel(item, childPath); + var childPath = $"{path}[{index}]"; + var childBase = default(JsonElement); + if (baseDocument.ValueKind == JsonValueKind.Array && index < baseDocument.GetArrayLength()) + childBase = baseDocument[index]; + index++; + var hit = FindBlockedRedactionSentinel(item, childBase, childPath, edited); if (hit != null) return hit; } } - else if (element.ValueKind == JsonValueKind.String && - ChannelConfigPatchBuilder.IsRedactionSentinel(element.GetString())) + else if (updated.ValueKind == JsonValueKind.String && + ChannelConfigPatchBuilder.IsRedactionSentinel(updated.GetString())) { - return path; + var loadedSentinel = baseDocument.ValueKind == JsonValueKind.String && + ChannelConfigPatchBuilder.IsRedactionSentinel(baseDocument.GetString()); + if (edited.Contains(path)) + return loadedSentinel ? path : null; + + return path.Contains('[', StringComparison.Ordinal) ? path : null; } return null; } + private static void RemoveUntouchedRedactionSentinels( + JsonNode node, + string path, + HashSet edited) + { + if (node is JsonObject obj) + { + var removals = new List(); + foreach (var property in obj) + { + var childPath = string.IsNullOrEmpty(path) ? property.Key : $"{path}.{property.Key}"; + if (property.Value is JsonValue value && + value.TryGetValue(out var text) && + ChannelConfigPatchBuilder.IsRedactionSentinel(text) && + !edited.Contains(childPath) && + !childPath.Contains('[', StringComparison.Ordinal)) + { + removals.Add(property.Key); + continue; + } + + if (property.Value is not null) + RemoveUntouchedRedactionSentinels(property.Value, childPath, edited); + } + + foreach (var key in removals) + obj.Remove(key); + } + else if (node is JsonArray array) + { + for (var index = 0; index < array.Count; index++) + { + if (array[index] is JsonNode item) + RemoveUntouchedRedactionSentinels(item, $"{path}[{index}]", edited); + } + } + } + private static void SetPath(JsonNode node, string dotPath, JsonNode? value) { var segments = dotPath.Split('.', StringSplitOptions.RemoveEmptyEntries); diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs index 5b8f8bb472..3b59ea28dc 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs @@ -135,7 +135,7 @@ public void ApplyChanges_IgnoresUnsupportedPlainObjectValues() } [Fact] - public void FindUneditedRedactionSentinel_BlocksUntouchedRedactedSibling() + public void FindUneditedRedactionSentinel_OmitsUntouchedRedactedSibling() { using var document = JsonDocument.Parse(""" { @@ -154,12 +154,17 @@ public void FindUneditedRedactionSentinel_BlocksUntouchedRedactedSibling() }); var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + updated, + new[] { "channels.telegram.botToken" }, + document.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels( updated, new[] { "channels.telegram.botToken" }); - Assert.Equal("channels.slack.signingSecret", blocked); - Assert.Equal("", updated.GetProperty("channels").GetProperty("slack").GetProperty("signingSecret").GetString()); - Assert.Equal("new-token", updated.GetProperty("channels").GetProperty("telegram").GetProperty("botToken").GetString()); + Assert.Null(blocked); + Assert.False(sent.GetProperty("channels").GetProperty("slack").TryGetProperty("signingSecret", out _)); + Assert.True(sent.GetProperty("channels").GetProperty("slack").GetProperty("enabled").GetBoolean()); + Assert.Equal("new-token", sent.GetProperty("channels").GetProperty("telegram").GetProperty("botToken").GetString()); } [Fact] @@ -182,7 +187,8 @@ public void FindUneditedRedactionSentinel_AllowsUserReplacedToken() var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( updated, - new[] { "channels.telegram.botToken" }); + new[] { "channels.telegram.botToken" }, + document.RootElement); Assert.Null(blocked); Assert.Equal("real-new-token", updated.GetProperty("channels").GetProperty("telegram").GetProperty("botToken").GetString()); @@ -201,7 +207,8 @@ public void FindUneditedRedactionSentinel_IgnoresRedactedSubstring() var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( document.RootElement, - Array.Empty()); + Array.Empty(), + document.RootElement); Assert.Null(blocked); } @@ -226,7 +233,8 @@ public void FindUneditedRedactionSentinel_BlocksEditedValueThatIsStillTheSentine var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( updated, - new[] { "channels.telegram.botToken" }); + new[] { "channels.telegram.botToken" }, + document.RootElement); Assert.Equal("channels.telegram.botToken", blocked); } @@ -244,8 +252,39 @@ public void FindUneditedRedactionSentinel_BlocksArraySentinelWhenEditUsedTheUnin var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( document.RootElement, - new[] { "channels.slack.accounts.token" }); + new[] { "channels.slack.accounts.token" }, + document.RootElement); Assert.Equal("channels.slack.accounts[0].token", blocked); } + + [Fact] + public void FindUneditedRedactionSentinel_AllowsLiteralMaskInANonSecretField() + { + using var document = JsonDocument.Parse(""" + { + "channels": { + "slack": { "label": "hello", "enabled": true } + } + } + """); + + var updated = ConfigEditorModel.ApplyChanges( + document.RootElement, + new Dictionary + { + ["channels.slack.label"] = "***", + }); + + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + updated, + new[] { "channels.slack.label" }, + document.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + updated, + new[] { "channels.slack.label" }); + + Assert.Null(blocked); + Assert.Equal("***", sent.GetProperty("channels").GetProperty("slack").GetProperty("label").GetString()); + } } From 1624842e52a603c96e13aa256c3505d2c1aefc17 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sun, 27 Sep 2026 15:15:09 -0700 Subject: [PATCH 06/28] fix(config): allow a save when a normal list contains mask text An untouched array entry equal to a mask blocked every other Config edit. A list such as labels keeps that text and no longer blocks the save. A credential field inside an array, such as an account token, still blocks until it is replaced. Tests: FindUneditedRedactionSentinel 7 passed. ./build.ps1 exit 0. Shared 4099 passed, 32 skipped. Tray 3071 passed. Five source contract tests still fail on an LF checkout because they expect CRLF snippets. Signed-off-by: Sebastien Tardif --- .../Services/ConfigEditorModel.cs | 22 +++++++++++++- .../ConfigEditorModelTests.cs | 30 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs index 4c43daf6e1..a0bd9cfdc1 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs @@ -184,12 +184,32 @@ public static JsonElement OmitUntouchedRedactionSentinels( if (edited.Contains(path)) return loadedSentinel ? path : null; - return path.Contains('[', StringComparison.Ordinal) ? path : null; + if (!path.Contains('[', StringComparison.Ordinal) || !loadedSentinel || !IsCredentialPath(path)) + return null; + + return path; } return null; } + private static bool IsCredentialPath(string path) + { + var name = path; + var bracket = name.LastIndexOf('['); + if (bracket >= 0 && name.EndsWith("]", StringComparison.Ordinal)) + name = name[..bracket]; + var dot = name.LastIndexOf('.'); + if (dot >= 0) + name = name[(dot + 1)..]; + + return name.Contains("token", StringComparison.OrdinalIgnoreCase) || + name.Contains("secret", StringComparison.OrdinalIgnoreCase) || + name.Contains("password", StringComparison.OrdinalIgnoreCase) || + name.Contains("webhook", StringComparison.OrdinalIgnoreCase) || + name.Equals("key", StringComparison.OrdinalIgnoreCase); + } + private static void RemoveUntouchedRedactionSentinels( JsonNode node, string path, diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs index 3b59ea28dc..2d34726fae 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs @@ -258,6 +258,36 @@ public void FindUneditedRedactionSentinel_BlocksArraySentinelWhenEditUsedTheUnin Assert.Equal("channels.slack.accounts[0].token", blocked); } + [Fact] + public void FindUneditedRedactionSentinel_AllowsLiteralMaskInAnUntouchedArray() + { + using var document = JsonDocument.Parse(""" + { + "labels": ["***", "public"], + "channels": { "telegram": { "botToken": "old" } } + } + """); + + var updated = ConfigEditorModel.ApplyChanges( + document.RootElement, + new Dictionary + { + ["channels.telegram.botToken"] = "new-token", + }); + + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + updated, + new[] { "channels.telegram.botToken" }, + document.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + updated, + new[] { "channels.telegram.botToken" }); + + Assert.Null(blocked); + Assert.Equal("***", sent.GetProperty("labels")[0].GetString()); + Assert.Equal("new-token", sent.GetProperty("channels").GetProperty("telegram").GetProperty("botToken").GetString()); + } + [Fact] public void FindUneditedRedactionSentinel_AllowsLiteralMaskInANonSecretField() { From 5a3102602ae2b0a0e31180c1342f542ed59c3b32 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Mon, 28 Sep 2026 08:09:35 -0700 Subject: [PATCH 07/28] fix(config): block masked apiKey values inside arrays An untouched accounts[0].apiKey redaction sentinel was not treated as a credential, so a later save could send the placeholder. Signed-off-by: Sebastien Tardif --- .../Services/ConfigEditorModel.cs | 2 + .../ConfigEditorModelTests.cs | 55 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs index a0bd9cfdc1..9714dd69fa 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs @@ -207,6 +207,8 @@ private static bool IsCredentialPath(string path) name.Contains("secret", StringComparison.OrdinalIgnoreCase) || name.Contains("password", StringComparison.OrdinalIgnoreCase) || name.Contains("webhook", StringComparison.OrdinalIgnoreCase) || + name.Contains("apikey", StringComparison.OrdinalIgnoreCase) || + name.Contains("api_key", StringComparison.OrdinalIgnoreCase) || name.Equals("key", StringComparison.OrdinalIgnoreCase); } diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs index 2d34726fae..63808132b4 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs @@ -317,4 +317,59 @@ public void FindUneditedRedactionSentinel_AllowsLiteralMaskInANonSecretField() Assert.Null(blocked); Assert.Equal("***", sent.GetProperty("channels").GetProperty("slack").GetProperty("label").GetString()); } + + [Fact] + public void FindUneditedRedactionSentinel_BlocksUntouchedArrayApiKeyButNotSiblingMask() + { + using var document = JsonDocument.Parse(""" + { + "channels": { + "slack": { + "accounts": [ { "label": "", "apiKey": "", "api_key": "" } ] + } + } + } + """); + + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + document.RootElement, + Array.Empty(), + document.RootElement); + + Assert.Equal("channels.slack.accounts[0].apiKey", blocked); + + using var siblingOnly = JsonDocument.Parse(""" + { + "channels": { + "slack": { + "accounts": [ { "label": "" } ] + } + } + } + """); + + var siblingBlocked = ConfigEditorModel.FindUneditedRedactionSentinel( + siblingOnly.RootElement, + Array.Empty(), + siblingOnly.RootElement); + + Assert.Null(siblingBlocked); + + using var snake = JsonDocument.Parse(""" + { + "channels": { + "slack": { + "accounts": [ { "note": "", "api_key": "" } ] + } + } + } + """); + + var snakeBlocked = ConfigEditorModel.FindUneditedRedactionSentinel( + snake.RootElement, + Array.Empty(), + snake.RootElement); + + Assert.Equal("channels.slack.accounts[0].api_key", snakeBlocked); + } } From 024b62b5761169151d24b311b039c667f0feb9fe Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Mon, 28 Sep 2026 09:17:40 -0700 Subject: [PATCH 08/28] fix(config): omit untouched masked array credentials instead of blocking the save An unrelated Config save stopped when an array still held a redacted token, apiKey, or privateKey. Those untouched values are now left out of the patch. Signed-off-by: Sebastien Tardif --- .../Services/ConfigEditorModel.cs | 21 ++- .../ConfigEditorModelTests.cs | 131 ++++++++++++++++-- 2 files changed, 141 insertions(+), 11 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs index 9714dd69fa..f8ba23f800 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs @@ -184,7 +184,13 @@ public static JsonElement OmitUntouchedRedactionSentinels( if (edited.Contains(path)) return loadedSentinel ? path : null; - if (!path.Contains('[', StringComparison.Ordinal) || !loadedSentinel || !IsCredentialPath(path)) + // Untouched masked credentials nested in arrays are left out of the + // patch below. They must not fail an unrelated save, and the editor + // cannot put the original secret back. + if (ShouldOmitUntouchedSentinel(path)) + return null; + + if (!loadedSentinel || !IsCredentialPath(path)) return null; return path; @@ -209,9 +215,20 @@ private static bool IsCredentialPath(string path) name.Contains("webhook", StringComparison.OrdinalIgnoreCase) || name.Contains("apikey", StringComparison.OrdinalIgnoreCase) || name.Contains("api_key", StringComparison.OrdinalIgnoreCase) || + name.Equals("privateKey", StringComparison.OrdinalIgnoreCase) || name.Equals("key", StringComparison.OrdinalIgnoreCase); } + private static bool ShouldOmitUntouchedSentinel(string path) + { + if (!path.Contains('[', StringComparison.Ordinal)) + return true; + + // A masked array element is not a property we can drop. A credential + // field on an item inside the array is. + return IsCredentialPath(path) && !path.EndsWith(']'); + } + private static void RemoveUntouchedRedactionSentinels( JsonNode node, string path, @@ -227,7 +244,7 @@ private static void RemoveUntouchedRedactionSentinels( value.TryGetValue(out var text) && ChannelConfigPatchBuilder.IsRedactionSentinel(text) && !edited.Contains(childPath) && - !childPath.Contains('[', StringComparison.Ordinal)) + ShouldOmitUntouchedSentinel(childPath)) { removals.Add(property.Key); continue; diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs index 63808132b4..71a08fab86 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs @@ -240,7 +240,7 @@ public void FindUneditedRedactionSentinel_BlocksEditedValueThatIsStillTheSentine } [Fact] - public void FindUneditedRedactionSentinel_BlocksArraySentinelWhenEditUsedTheUnindexedPath() + public void FindUneditedRedactionSentinel_OmitsArrayTokenWhenEditUsedTheUnindexedPath() { using var document = JsonDocument.Parse(""" { @@ -250,12 +250,17 @@ public void FindUneditedRedactionSentinel_BlocksArraySentinelWhenEditUsedTheUnin } """); + var edited = new[] { "channels.slack.accounts.token" }; var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( document.RootElement, - new[] { "channels.slack.accounts.token" }, + edited, document.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + document.RootElement, + edited); - Assert.Equal("channels.slack.accounts[0].token", blocked); + Assert.Null(blocked); + Assert.False(sent.GetProperty("channels").GetProperty("slack").GetProperty("accounts")[0].TryGetProperty("token", out _)); } [Fact] @@ -319,24 +324,40 @@ public void FindUneditedRedactionSentinel_AllowsLiteralMaskInANonSecretField() } [Fact] - public void FindUneditedRedactionSentinel_BlocksUntouchedArrayApiKeyButNotSiblingMask() + public void FindUneditedRedactionSentinel_OmitsUntouchedArrayApiKeyButKeepsSiblingMask() { using var document = JsonDocument.Parse(""" { "channels": { "slack": { - "accounts": [ { "label": "", "apiKey": "", "api_key": "" } ] + "enabled": false, + "accounts": [ { "label": "", "token": "", "apiKey": "", "api_key": "" } ] } } } """); - var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + var updated = ConfigEditorModel.ApplyChanges( document.RootElement, - Array.Empty(), + new Dictionary + { + ["channels.slack.enabled"] = true, + }); + var edited = new[] { "channels.slack.enabled" }; + + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + updated, + edited, document.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels(updated, edited); - Assert.Equal("channels.slack.accounts[0].apiKey", blocked); + Assert.Null(blocked); + Assert.True(sent.GetProperty("channels").GetProperty("slack").GetProperty("enabled").GetBoolean()); + var account = sent.GetProperty("channels").GetProperty("slack").GetProperty("accounts")[0]; + Assert.False(account.TryGetProperty("token", out _)); + Assert.False(account.TryGetProperty("apiKey", out _)); + Assert.False(account.TryGetProperty("api_key", out _)); + Assert.Equal("", account.GetProperty("label").GetString()); using var siblingOnly = JsonDocument.Parse(""" { @@ -352,8 +373,14 @@ public void FindUneditedRedactionSentinel_BlocksUntouchedArrayApiKeyButNotSiblin siblingOnly.RootElement, Array.Empty(), siblingOnly.RootElement); + var siblingSent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + siblingOnly.RootElement, + Array.Empty()); Assert.Null(siblingBlocked); + Assert.Equal( + "", + siblingSent.GetProperty("channels").GetProperty("slack").GetProperty("accounts")[0].GetProperty("label").GetString()); using var snake = JsonDocument.Parse(""" { @@ -369,7 +396,93 @@ public void FindUneditedRedactionSentinel_BlocksUntouchedArrayApiKeyButNotSiblin snake.RootElement, Array.Empty(), snake.RootElement); + var snakeSent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + snake.RootElement, + Array.Empty()); + + Assert.Null(snakeBlocked); + var snakeAccount = snakeSent.GetProperty("channels").GetProperty("slack").GetProperty("accounts")[0]; + Assert.False(snakeAccount.TryGetProperty("api_key", out _)); + Assert.Equal("", snakeAccount.GetProperty("note").GetString()); + } + + [Fact] + public void FindUneditedRedactionSentinel_OmitsUntouchedArrayPrivateKey() + { + using var document = JsonDocument.Parse(""" + { + "channels": { + "slack": { + "enabled": false, + "accounts": [ { "label": "", "privateKey": "" } ] + } + } + } + """); + + var updated = ConfigEditorModel.ApplyChanges( + document.RootElement, + new Dictionary + { + ["channels.slack.enabled"] = true, + }); + var edited = new[] { "channels.slack.enabled" }; + + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + updated, + edited, + document.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels(updated, edited); + + Assert.Null(blocked); + Assert.True(sent.GetProperty("channels").GetProperty("slack").GetProperty("enabled").GetBoolean()); + var account = sent.GetProperty("channels").GetProperty("slack").GetProperty("accounts")[0]; + Assert.False(account.TryGetProperty("privateKey", out _)); + Assert.Equal("", account.GetProperty("label").GetString()); + + using var cased = JsonDocument.Parse(""" + { + "accounts": [ { "PrivateKey": "", "title": "" } ] + } + """); + + var casedBlocked = ConfigEditorModel.FindUneditedRedactionSentinel( + cased.RootElement, + Array.Empty(), + cased.RootElement); + var casedSent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + cased.RootElement, + Array.Empty()); + + Assert.Null(casedBlocked); + Assert.False(casedSent.GetProperty("accounts")[0].TryGetProperty("PrivateKey", out _)); + Assert.Equal("", casedSent.GetProperty("accounts")[0].GetProperty("title").GetString()); + } - Assert.Equal("channels.slack.accounts[0].api_key", snakeBlocked); + [Fact] + public void FindUneditedRedactionSentinel_BlocksEditedArrayCredentialThatIsStillTheSentinel() + { + using var document = JsonDocument.Parse(""" + { + "channels": { + "slack": { + "accounts": [ { "apiKey": "", "privateKey": "", "label": "desk" } ] + } + } + } + """); + + var edited = new[] { "channels.slack.accounts[0].apiKey", "channels.slack.accounts[0].privateKey" }; + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + document.RootElement, + edited, + document.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels(document.RootElement, edited); + + Assert.Equal("channels.slack.accounts[0].apiKey", blocked); + var account = sent.GetProperty("channels").GetProperty("slack").GetProperty("accounts")[0]; + Assert.Equal("", account.GetProperty("apiKey").GetString()); + Assert.Equal("", account.GetProperty("privateKey").GetString()); + Assert.Equal("desk", account.GetProperty("label").GetString()); } } From 5ddef72fa2a6112d3ee682ca2977ca96bc83c600 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 29 Sep 2026 10:41:48 -0700 Subject: [PATCH 09/28] fix(config): keep a safe save when a masked secret sits in an array without ids An untouched masked credential inside an array whose entries have no id cannot be stripped out of config.patch. Gateway replaces that array and rejects a patch that drops a stored entry. An indexed edit inside that array now stays on the machine and names the credential path. An unrelated edit omits the whole array, so the stored entries remain. Arrays whose entries all have an id still omit only the masked field. Signed-off-by: Sebastien Tardif --- .../Pages/ConfigPage.xaml.cs | 5 +- .../Services/ConfigEditorModel.cs | 197 +++++++- .../ConfigEditorModelTests.cs | 429 +++++++++++++++++- 3 files changed, 603 insertions(+), 28 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs index 627a92108f..24959714b7 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs @@ -563,7 +563,10 @@ private async Task OnSaveAsync() return; } - updated = ConfigEditorModel.OmitUntouchedRedactionSentinels(updated, _pendingChanges.Keys); + updated = ConfigEditorModel.OmitUntouchedRedactionSentinels( + updated, + _pendingChanges.Keys, + saveBase.Root); var result = await client.PatchConfigDetailedAsync(updated, saveBase.BaseHash); if (!result.Ok) diff --git a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs index f8ba23f800..d13d89d032 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs @@ -127,14 +127,24 @@ public static JsonElement ApplyRelativeChanges( public static JsonElement OmitUntouchedRedactionSentinels( JsonElement document, - IEnumerable editedPaths) + IEnumerable editedPaths) => + OmitUntouchedRedactionSentinels(document, editedPaths, document); + + public static JsonElement OmitUntouchedRedactionSentinels( + JsonElement document, + IEnumerable editedPaths, + JsonElement baseDocument) { var node = JsonNode.Parse(document.GetRawText()); if (node is null) return document.Clone(); var edited = new HashSet(editedPaths, StringComparer.OrdinalIgnoreCase); - RemoveUntouchedRedactionSentinels(node, "", edited); + var arraysToDrop = new HashSet(StringComparer.Ordinal); + RemoveUntouchedRedactionSentinels(node, "", edited, baseDocument, arraysToDrop); + foreach (var arrayPath in arraysToDrop.OrderBy(static path => path.Length)) + RemovePropertyAtPath(node, arrayPath); + using var rewritten = JsonDocument.Parse(node.ToJsonString()); return rewritten.RootElement.Clone(); } @@ -143,7 +153,15 @@ public static JsonElement OmitUntouchedRedactionSentinels( JsonElement updated, JsonElement baseDocument, string path, - HashSet edited) + HashSet edited) => + FindBlockedRedactionSentinel(updated, baseDocument, path, edited, baseDocument); + + private static string? FindBlockedRedactionSentinel( + JsonElement updated, + JsonElement baseDocument, + string path, + HashSet edited, + JsonElement baseRoot) { if (updated.ValueKind == JsonValueKind.Object) { @@ -156,7 +174,7 @@ public static JsonElement OmitUntouchedRedactionSentinels( var childBase = default(JsonElement); if (baseObject.ValueKind == JsonValueKind.Object) baseObject.TryGetProperty(property.Name, out childBase); - var hit = FindBlockedRedactionSentinel(property.Value, childBase, childPath, edited); + var hit = FindBlockedRedactionSentinel(property.Value, childBase, childPath, edited, baseRoot); if (hit != null) return hit; } @@ -171,7 +189,7 @@ public static JsonElement OmitUntouchedRedactionSentinels( if (baseDocument.ValueKind == JsonValueKind.Array && index < baseDocument.GetArrayLength()) childBase = baseDocument[index]; index++; - var hit = FindBlockedRedactionSentinel(item, childBase, childPath, edited); + var hit = FindBlockedRedactionSentinel(item, childBase, childPath, edited, baseRoot); if (hit != null) return hit; } @@ -184,11 +202,18 @@ public static JsonElement OmitUntouchedRedactionSentinels( if (edited.Contains(path)) return loadedSentinel ? path : null; - // Untouched masked credentials nested in arrays are left out of the - // patch below. They must not fail an unrelated save, and the editor - // cannot put the original secret back. + // Object fields and id-keyed array fields can be left out of the + // patch. Gateway keeps the stored secret for an absent key. An array + // whose entries lack stable ids is replaced wholesale, so an indexed + // edit inside that array cannot drop the masked credential safely. if (ShouldOmitUntouchedSentinel(path)) + { + var arrayPath = OutermostNonIdArrayPath(baseRoot, path); + if (arrayPath != null && EditedPathInsideArray(edited, arrayPath)) + return path; + return null; + } if (!loadedSentinel || !IsCredentialPath(path)) return null; @@ -232,7 +257,9 @@ private static bool ShouldOmitUntouchedSentinel(string path) private static void RemoveUntouchedRedactionSentinels( JsonNode node, string path, - HashSet edited) + HashSet edited, + JsonElement baseRoot, + HashSet arraysToDrop) { if (node is JsonObject obj) { @@ -246,12 +273,17 @@ private static void RemoveUntouchedRedactionSentinels( !edited.Contains(childPath) && ShouldOmitUntouchedSentinel(childPath)) { - removals.Add(property.Key); + var arrayPath = OutermostNonIdArrayPath(baseRoot, childPath); + if (arrayPath == null) + removals.Add(property.Key); + else if (!EditedPathInsideArray(edited, arrayPath)) + arraysToDrop.Add(arrayPath); + continue; } if (property.Value is not null) - RemoveUntouchedRedactionSentinels(property.Value, childPath, edited); + RemoveUntouchedRedactionSentinels(property.Value, childPath, edited, baseRoot, arraysToDrop); } foreach (var key in removals) @@ -262,8 +294,149 @@ private static void RemoveUntouchedRedactionSentinels( for (var index = 0; index < array.Count; index++) { if (array[index] is JsonNode item) - RemoveUntouchedRedactionSentinels(item, $"{path}[{index}]", edited); + RemoveUntouchedRedactionSentinels(item, $"{path}[{index}]", edited, baseRoot, arraysToDrop); + } + } + } + + private readonly record struct ConfigPathSegment(string Name, int? Index); + + private static List ParsePath(string path) + { + var segments = new List(); + var index = 0; + while (index < path.Length) + { + if (path[index] == '.') + { + index++; + continue; + } + + var start = index; + while (index < path.Length && path[index] != '.' && path[index] != '[') + index++; + + var name = path[start..index]; + int? arrayIndex = null; + if (index < path.Length && path[index] == '[') + { + index++; + var numberStart = index; + while (index < path.Length && path[index] != ']') + index++; + if (int.TryParse(path[numberStart..index], NumberStyles.None, CultureInfo.InvariantCulture, out var parsed)) + arrayIndex = parsed; + if (index < path.Length && path[index] == ']') + index++; + } + + if (name.Length > 0) + segments.Add(new ConfigPathSegment(name, arrayIndex)); + } + + return segments; + } + + private static bool ArrayIsIdKeyed(JsonElement array) + { + if (array.ValueKind != JsonValueKind.Array) + return false; + + foreach (var item in array.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object || + !item.TryGetProperty("id", out var id) || + id.ValueKind != JsonValueKind.String || + string.IsNullOrEmpty(id.GetString())) + { + return false; + } + } + + return true; + } + + private static string? OutermostNonIdArrayPath(JsonElement root, string credentialPath) + { + if (!credentialPath.Contains('[', StringComparison.Ordinal) || + root.ValueKind != JsonValueKind.Object) + { + return null; + } + + var current = root; + var walked = ""; + foreach (var segment in ParsePath(credentialPath)) + { + if (current.ValueKind != JsonValueKind.Object || + !current.TryGetProperty(segment.Name, out var child)) + { + return null; + } + + walked = walked.Length == 0 ? segment.Name : $"{walked}.{segment.Name}"; + if (segment.Index is not int arrayIndex) + { + current = child; + continue; + } + + if (child.ValueKind != JsonValueKind.Array) + return null; + if (!ArrayIsIdKeyed(child)) + return walked; + if ((uint)arrayIndex >= (uint)child.GetArrayLength()) + return null; + + walked = $"{walked}[{arrayIndex.ToString(CultureInfo.InvariantCulture)}]"; + current = child[arrayIndex]; + } + + return null; + } + + private static bool EditedPathInsideArray(HashSet edited, string arrayPath) + { + foreach (var path in edited) + { + if (path.Equals(arrayPath, StringComparison.OrdinalIgnoreCase)) + return true; + if (path.StartsWith(arrayPath + "[", StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + + private static void RemovePropertyAtPath(JsonNode root, string propertyPath) + { + JsonNode? current = root; + var segments = ParsePath(propertyPath); + for (var index = 0; index < segments.Count; index++) + { + if (current is not JsonObject obj) + return; + + var segment = segments[index]; + if (obj[segment.Name] is not JsonNode child) + return; + + if (index == segments.Count - 1) + { + obj.Remove(segment.Name); + return; } + + if (segment.Index is int arrayIndex) + { + if (child is not JsonArray array || (uint)arrayIndex >= (uint)array.Count) + return; + current = array[arrayIndex]; + continue; + } + + current = child; } } diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs index 71a08fab86..b75b4ef946 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs @@ -240,7 +240,7 @@ public void FindUneditedRedactionSentinel_BlocksEditedValueThatIsStillTheSentine } [Fact] - public void FindUneditedRedactionSentinel_OmitsArrayTokenWhenEditUsedTheUnindexedPath() + public void FindUneditedRedactionSentinel_OmitsNoIdArrayWhenEditUsedTheUnindexedPath() { using var document = JsonDocument.Parse(""" { @@ -260,7 +260,7 @@ public void FindUneditedRedactionSentinel_OmitsArrayTokenWhenEditUsedTheUnindexe edited); Assert.Null(blocked); - Assert.False(sent.GetProperty("channels").GetProperty("slack").GetProperty("accounts")[0].TryGetProperty("token", out _)); + Assert.False(sent.GetProperty("channels").GetProperty("slack").TryGetProperty("accounts", out _)); } [Fact] @@ -353,11 +353,7 @@ public void FindUneditedRedactionSentinel_OmitsUntouchedArrayApiKeyButKeepsSibli Assert.Null(blocked); Assert.True(sent.GetProperty("channels").GetProperty("slack").GetProperty("enabled").GetBoolean()); - var account = sent.GetProperty("channels").GetProperty("slack").GetProperty("accounts")[0]; - Assert.False(account.TryGetProperty("token", out _)); - Assert.False(account.TryGetProperty("apiKey", out _)); - Assert.False(account.TryGetProperty("api_key", out _)); - Assert.Equal("", account.GetProperty("label").GetString()); + Assert.False(sent.GetProperty("channels").GetProperty("slack").TryGetProperty("accounts", out _)); using var siblingOnly = JsonDocument.Parse(""" { @@ -401,9 +397,7 @@ public void FindUneditedRedactionSentinel_OmitsUntouchedArrayApiKeyButKeepsSibli Array.Empty()); Assert.Null(snakeBlocked); - var snakeAccount = snakeSent.GetProperty("channels").GetProperty("slack").GetProperty("accounts")[0]; - Assert.False(snakeAccount.TryGetProperty("api_key", out _)); - Assert.Equal("", snakeAccount.GetProperty("note").GetString()); + Assert.False(snakeSent.GetProperty("channels").GetProperty("slack").TryGetProperty("accounts", out _)); } [Fact] @@ -436,9 +430,7 @@ public void FindUneditedRedactionSentinel_OmitsUntouchedArrayPrivateKey() Assert.Null(blocked); Assert.True(sent.GetProperty("channels").GetProperty("slack").GetProperty("enabled").GetBoolean()); - var account = sent.GetProperty("channels").GetProperty("slack").GetProperty("accounts")[0]; - Assert.False(account.TryGetProperty("privateKey", out _)); - Assert.Equal("", account.GetProperty("label").GetString()); + Assert.False(sent.GetProperty("channels").GetProperty("slack").TryGetProperty("accounts", out _)); using var cased = JsonDocument.Parse(""" { @@ -455,8 +447,7 @@ public void FindUneditedRedactionSentinel_OmitsUntouchedArrayPrivateKey() Array.Empty()); Assert.Null(casedBlocked); - Assert.False(casedSent.GetProperty("accounts")[0].TryGetProperty("PrivateKey", out _)); - Assert.Equal("", casedSent.GetProperty("accounts")[0].GetProperty("title").GetString()); + Assert.False(casedSent.TryGetProperty("accounts", out _)); } [Fact] @@ -485,4 +476,412 @@ public void FindUneditedRedactionSentinel_BlocksEditedArrayCredentialThatIsStill Assert.Equal("", account.GetProperty("privateKey").GetString()); Assert.Equal("desk", account.GetProperty("label").GetString()); } + + [Fact] + public void FindUneditedRedactionSentinel_BlocksIndexedEditInsideNoIdArray() + { + using var document = JsonDocument.Parse(""" + { + "channels": { + "slack": { + "accounts": [ { "token": "", "label": "renamed" } ] + } + } + } + """); + + var edited = new[] { "channels.slack.accounts[0].label" }; + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + document.RootElement, + edited, + document.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels(document.RootElement, edited); + + Assert.Equal("channels.slack.accounts[0].token", blocked); + var account = sent.GetProperty("channels").GetProperty("slack").GetProperty("accounts")[0]; + Assert.Equal("", account.GetProperty("token").GetString()); + Assert.Equal("renamed", account.GetProperty("label").GetString()); + + var cased = ConfigEditorModel.FindUneditedRedactionSentinel( + document.RootElement, + new[] { "Channels.Slack.Accounts[0].Label" }, + document.RootElement); + Assert.Equal("channels.slack.accounts[0].token", cased); + } + + [Fact] + public void FindUneditedRedactionSentinel_OmitsOutermostNoIdArrayAndKeepsIdKeyedFields() + { + using var outer = JsonDocument.Parse(""" + { + "enabled": true, + "groups": [ { "accounts": [ { "id": "acct-1", "token": "" } ] } ] + } + """); + var outerEdited = new[] { "enabled" }; + var outerBlocked = ConfigEditorModel.FindUneditedRedactionSentinel( + outer.RootElement, + outerEdited, + outer.RootElement); + var outerSent = ConfigEditorModel.OmitUntouchedRedactionSentinels(outer.RootElement, outerEdited); + + Assert.Null(outerBlocked); + Assert.True(outerSent.GetProperty("enabled").GetBoolean()); + Assert.False(outerSent.TryGetProperty("groups", out _)); + + using var inner = JsonDocument.Parse(""" + { + "groups": [ { + "id": "g1", + "name": "new", + "accounts": [ { "token": "", "label": "desk" } ] + } ] + } + """); + var innerEdited = new[] { "groups[0].name" }; + var innerBlocked = ConfigEditorModel.FindUneditedRedactionSentinel( + inner.RootElement, + innerEdited, + inner.RootElement); + var innerSent = ConfigEditorModel.OmitUntouchedRedactionSentinels(inner.RootElement, innerEdited); + + Assert.Null(innerBlocked); + var group = innerSent.GetProperty("groups")[0]; + Assert.Equal("g1", group.GetProperty("id").GetString()); + Assert.Equal("new", group.GetProperty("name").GetString()); + Assert.False(group.TryGetProperty("accounts", out _)); + + using var mixed = JsonDocument.Parse(""" + { + "enabled": true, + "accounts": [ + { "id": "acct-1", "token": "" }, + { "token": "" } + ] + } + """); + var mixedSent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + mixed.RootElement, + new[] { "enabled" }); + Assert.False(mixedSent.TryGetProperty("accounts", out _)); + } + + [Fact] + public void NoIdArray_RefusesIndexedEditAndAcceptsUnrelatedSave() + { + using var stored = JsonDocument.Parse(""" + { + "channels": { + "slack": { + "enabled": false, + "accounts": [ { "token": "stored-secret", "label": "desk" } ] + } + } + } + """); + using var unrelated = JsonDocument.Parse(""" + { + "channels": { + "slack": { + "enabled": true, + "accounts": [ { "token": "", "label": "desk" } ] + } + } + } + """); + var unrelatedEdited = new[] { "channels.slack.enabled" }; + var unrelatedBlocked = ConfigEditorModel.FindUneditedRedactionSentinel( + unrelated.RootElement, + unrelatedEdited, + stored.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + unrelated.RootElement, + unrelatedEdited, + stored.RootElement); + + Assert.Null(unrelatedBlocked); + Assert.False(sent.GetProperty("channels").GetProperty("slack").TryGetProperty("accounts", out _)); + var accepted = GatewayArrayPatch.Apply(stored.RootElement, sent); + Assert.Null(accepted.Error); + Assert.Equal( + "stored-secret", + accepted.Merged["channels"]!["slack"]!["accounts"]![0]!["token"]!.GetValue()); + Assert.True(accepted.Merged["channels"]!["slack"]!["enabled"]!.GetValue()); + + using var indexed = JsonDocument.Parse(""" + { + "channels": { + "slack": { + "enabled": false, + "accounts": [ { "token": "", "label": "renamed" } ] + } + } + } + """); + var indexedEdited = new[] { "channels.slack.accounts[0].label" }; + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + indexed.RootElement, + indexedEdited, + stored.RootElement); + Assert.Equal("channels.slack.accounts[0].token", blocked); + + using var stripped = JsonDocument.Parse(""" + { + "channels": { + "slack": { + "enabled": false, + "accounts": [ { "label": "renamed" } ] + } + } + } + """); + var rejected = GatewayArrayPatch.Apply(stored.RootElement, stripped.RootElement); + Assert.NotNull(rejected.Error); + Assert.Contains( + "config.patch would remove entries from array path(s): channels.slack.accounts", + rejected.Error, + StringComparison.Ordinal); + } + + [Fact] + public void IdKeyedArray_OmitsCredentialFieldAndGatewayKeepsStoredSecret() + { + using var stored = JsonDocument.Parse(""" + { + "enabled": false, + "accounts": [ { "id": "acct-1", "token": "stored-secret", "label": "desk" } ] + } + """); + using var editor = JsonDocument.Parse(""" + { + "enabled": true, + "accounts": [ { "id": "acct-1", "token": "", "label": "desk" } ] + } + """); + var edited = new[] { "enabled" }; + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + editor.RootElement, + edited, + stored.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + editor.RootElement, + edited, + stored.RootElement); + + Assert.Null(blocked); + var sentAccount = sent.GetProperty("accounts")[0]; + Assert.Equal("acct-1", sentAccount.GetProperty("id").GetString()); + Assert.Equal("desk", sentAccount.GetProperty("label").GetString()); + Assert.False(sentAccount.TryGetProperty("token", out _)); + + var accepted = GatewayArrayPatch.Apply(stored.RootElement, sent); + Assert.Null(accepted.Error); + Assert.Equal("stored-secret", accepted.Merged["accounts"]![0]!["token"]!.GetValue()); + Assert.Equal("desk", accepted.Merged["accounts"]![0]!["label"]!.GetValue()); + Assert.True(accepted.Merged["enabled"]!.GetValue()); + } + + /// + /// Models the Gateway config.patch array merge used by these saves: + /// id-keyed arrays keep omitted keys, other arrays replace, and a + /// replacement that drops a stored entry is rejected. + /// + private static class GatewayArrayPatch + { + public readonly record struct Result(JsonNode Merged, string? Error); + + public static Result Apply(JsonElement stored, JsonElement patch) + { + var storedNode = JsonNode.Parse(stored.GetRawText()) ?? new JsonObject(); + var patchNode = JsonNode.Parse(patch.GetRawText()) ?? new JsonObject(); + var merged = MergeObjects(storedNode as JsonObject, patchNode as JsonObject ?? new JsonObject()); + var paths = new List(); + CollectDestructive(storedNode, patchNode, merged, "", paths); + if (paths.Count == 0) + return new Result(merged, null); + + return new Result( + merged, + "config.patch would remove entries from array path(s): " + string.Join(", ", paths) + + ". Pass replacePaths with the exact path(s) when this is intentional, or use config.apply for full-config replacement."); + } + + private static JsonObject MergeObjects(JsonObject? baseObject, JsonObject patch) + { + var result = baseObject?.DeepClone().AsObject() ?? new JsonObject(); + foreach (var property in patch) + { + if (property.Value is null) + { + result.Remove(property.Key); + continue; + } + + if (result[property.Key] is JsonArray baseArray && property.Value is JsonArray patchArray) + { + result[property.Key] = IsIdKeyed(baseArray) + ? MergeIdArray(baseArray, patchArray) + : patchArray.DeepClone(); + continue; + } + + if (property.Value is JsonObject childPatch) + { + result[property.Key] = MergeObjects(result[property.Key] as JsonObject, childPatch); + continue; + } + + result[property.Key] = property.Value.DeepClone(); + } + + return result; + } + + private static JsonArray MergeIdArray(JsonArray baseArray, JsonArray patchArray) + { + var merged = baseArray.DeepClone().AsArray(); + foreach (var patchEntry in patchArray) + { + if (patchEntry is not JsonObject patchObject || !TryGetStringId(patchObject, out var id)) + { + merged.Add(patchEntry?.DeepClone()); + continue; + } + + var index = IndexOfId(merged, id); + if (index < 0) + { + merged.Add(patchObject.DeepClone()); + continue; + } + + merged[index] = MergeObjects(merged[index] as JsonObject, patchObject); + } + + return merged; + } + + private static void CollectDestructive( + JsonNode? baseNode, + JsonNode? patch, + JsonNode? merged, + string path, + List paths) + { + if (patch is not JsonObject patchObject || baseNode is not JsonObject baseObject) + return; + + var mergedObject = merged as JsonObject; + foreach (var property in patchObject) + { + var childPath = path.Length == 0 ? property.Key : $"{path}.{property.Key}"; + var baseValue = baseObject[property.Key]; + var mergedValue = mergedObject?[property.Key]; + if (baseValue is JsonArray baseArray) + { + if (property.Value is not JsonArray) + { + paths.Add(childPath); + continue; + } + + if (mergedValue is JsonArray mergedArray) + { + if (IsIdKeyed(baseArray)) + { + if (!PreservesIds(baseArray, mergedArray)) + { + paths.Add(childPath); + continue; + } + } + else if (!PreservesEntries(baseArray, mergedArray)) + { + paths.Add(childPath); + continue; + } + } + } + else if (property.Value is JsonObject childPatch) + { + CollectDestructive(baseValue, childPatch, mergedValue, childPath, paths); + } + } + } + + private static bool IsIdKeyed(JsonArray array) + { + foreach (var item in array) + { + if (item is not JsonObject obj || !TryGetStringId(obj, out _)) + return false; + } + + return true; + } + + private static bool PreservesIds(JsonArray baseArray, JsonArray merged) + { + var ids = new HashSet(StringComparer.Ordinal); + foreach (var item in merged) + { + if (item is JsonObject obj && TryGetStringId(obj, out var id)) + ids.Add(id); + } + + foreach (var item in baseArray) + { + if (item is not JsonObject obj || !TryGetStringId(obj, out var id) || !ids.Contains(id)) + return false; + } + + return true; + } + + private static bool PreservesEntries(JsonArray baseArray, JsonArray merged) + { + var unused = new List(); + foreach (var item in merged) + unused.Add(item); + + foreach (var baseEntry in baseArray) + { + var match = unused.FindIndex(item => JsonNode.DeepEquals(item, baseEntry)); + if (match < 0) + return false; + unused.RemoveAt(match); + } + + return true; + } + + private static bool TryGetStringId(JsonObject obj, out string id) + { + id = ""; + if (obj["id"] is not JsonValue value || + !value.TryGetValue(out var text) || + string.IsNullOrEmpty(text)) + { + return false; + } + + id = text; + return true; + } + + private static int IndexOfId(JsonArray array, string id) + { + for (var index = 0; index < array.Count; index++) + { + if (array[index] is JsonObject obj && + TryGetStringId(obj, out var existing) && + existing == id) + { + return index; + } + } + + return -1; + } + } } From 46e8d07730e49475fe67f5439eb2f1f09813441d Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 30 Sep 2026 14:05:39 -0700 Subject: [PATCH 10/28] fix(config): keep parent-object edits and literal mask text A JSON object editor stages the parent path. An account label change under that parent was dropped with the non-id array, and a label changed from ordinary text to *** was removed as if it were an untouched secret mask. Ancestor edits now refuse that unsafe array save, and a non-secret literal is kept when the loaded value was not a mask. Validation: ./build.ps1 exit 0. Shared 4099 passed, 32 skipped. ConfigEditorModelTests 21 passed. Tray 3080 passed, 5 failed, the LF source-contract mismatch in #1518. Signed-off-by: Sebastien Tardif --- .../Services/ConfigEditorModel.cs | 56 +++++++++++++++++ .../ConfigEditorModelTests.cs | 62 +++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs index d13d89d032..4e05eafaf2 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs @@ -273,6 +273,12 @@ private static void RemoveUntouchedRedactionSentinels( !edited.Contains(childPath) && ShouldOmitUntouchedSentinel(childPath)) { + if (!IsCredentialPath(childPath) && + !LoadedStringIsRedactionSentinel(baseRoot, childPath)) + { + continue; + } + var arrayPath = OutermostNonIdArrayPath(baseRoot, childPath); if (arrayPath == null) removals.Add(property.Key); @@ -404,11 +410,61 @@ private static bool EditedPathInsideArray(HashSet edited, string arrayPa return true; if (path.StartsWith(arrayPath + "[", StringComparison.OrdinalIgnoreCase)) return true; + if (IsAncestorEdit(path, arrayPath)) + return true; } return false; } + private static bool IsAncestorEdit(string editedPath, string arrayPath) + { + if (editedPath.Length == 0 || editedPath.Length >= arrayPath.Length) + return false; + if (!arrayPath.StartsWith(editedPath, StringComparison.OrdinalIgnoreCase)) + return false; + + var boundary = arrayPath[editedPath.Length]; + return boundary is '.' or '['; + } + + private static bool LoadedStringIsRedactionSentinel(JsonElement root, string path) + { + if (!TryReadPath(root, path, out var element) || + element.ValueKind != JsonValueKind.String) + { + return false; + } + + return ChannelConfigPatchBuilder.IsRedactionSentinel(element.GetString()); + } + + private static bool TryReadPath(JsonElement root, string path, out JsonElement element) + { + element = root; + foreach (var segment in ParsePath(path)) + { + if (element.ValueKind != JsonValueKind.Object || + !element.TryGetProperty(segment.Name, out element)) + { + return false; + } + + if (segment.Index is not int arrayIndex) + continue; + + if (element.ValueKind != JsonValueKind.Array || + (uint)arrayIndex >= (uint)element.GetArrayLength()) + { + return false; + } + + element = element[arrayIndex]; + } + + return true; + } + private static void RemovePropertyAtPath(JsonNode root, string propertyPath) { JsonNode? current = root; diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs index b75b4ef946..9a29cc4e84 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs @@ -293,6 +293,68 @@ public void FindUneditedRedactionSentinel_AllowsLiteralMaskInAnUntouchedArray() Assert.Equal("new-token", sent.GetProperty("channels").GetProperty("telegram").GetProperty("botToken").GetString()); } + [Fact] + public void FindUneditedRedactionSentinel_KeepsLiteralMaskStagedThroughParentObject() + { + using var stored = JsonDocument.Parse(""" + { + "metadata": { "label": "before", "token": "" } + } + """); + using var updated = JsonDocument.Parse(""" + { + "metadata": { "label": "***", "token": "" } + } + """); + var edited = new[] { "metadata" }; + + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + updated.RootElement, + edited, + stored.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + updated.RootElement, + edited, + stored.RootElement); + + Assert.Null(blocked); + Assert.Equal("***", sent.GetProperty("metadata").GetProperty("label").GetString()); + Assert.False(sent.GetProperty("metadata").TryGetProperty("token", out _)); + } + + [Fact] + public void FindUneditedRedactionSentinel_RefusesParentEditInsideNoIdArray() + { + using var stored = JsonDocument.Parse(""" + { + "profiles": { + "accounts": [ { "label": "old", "token": "" } ] + } + } + """); + using var updated = JsonDocument.Parse(""" + { + "profiles": { + "accounts": [ { "label": "new", "token": "" } ] + } + } + """); + var edited = new[] { "profiles" }; + + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + updated.RootElement, + edited, + stored.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + updated.RootElement, + edited, + stored.RootElement); + + Assert.Equal("profiles.accounts[0].token", blocked); + Assert.Equal("new", sent.GetProperty("profiles").GetProperty("accounts")[0].GetProperty("label").GetString()); + Assert.True(sent.GetProperty("profiles").GetProperty("accounts")[0].TryGetProperty("token", out _)); + } + [Fact] public void FindUneditedRedactionSentinel_AllowsLiteralMaskInANonSecretField() { From e1931c4281cf666405bd354379a748054e9574d3 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 30 Sep 2026 14:24:40 -0700 Subject: [PATCH 11/28] ci: retrigger after tray UI host hang UI, functional, and accessibility tests aborted after 5 minutes of inactivity. The host named MarkdownRendererCoalesceTests and ThemeResolveColorFallbackTests. Those tests are not in this change. Signed-off-by: Sebastien Tardif From b6e8060b0da11684d097b6ac6e7d3d4e332e0596 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 30 Sep 2026 14:41:24 -0700 Subject: [PATCH 12/28] ci: retrigger after output-drain timing flake Core and CLI failed only RunAsync_OutputDrainIsBoundedAndPreservesFinalFragments_WhenDescendantRetainsHandles. The drain took 1126 ms against a 1 second bound. That test is not in this change. Signed-off-by: Sebastien Tardif From b8e8208f6a3c755181a338b89c433034a9a8a826 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Wed, 30 Sep 2026 15:00:36 -0700 Subject: [PATCH 13/28] ci: retrigger after startup timeouts Core and CLI failed DisposeDuringStartup_CancelsAndPreventsRestart because the native gateway listener timed out. Network recovery failed because the MCP server did not open within 90 seconds. The same tree passed both jobs on e1931c42. Neither test is in this change. Signed-off-by: Sebastien Tardif From f8ad6a639c0154c83d1669a3a2e15c8a8c7ed362 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 09:13:55 -0700 Subject: [PATCH 14/28] test(config): lock parent-object saves that keep a rename and a new mask ApplyChanges of a profiles object keeps the renamed account and refuses the loaded token sentinel. A metadata label changed to a mask that was not loaded as a secret stays in the outgoing document. Signed-off-by: Sebastien Tardif --- .../ConfigEditorModelTests.cs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs index 9a29cc4e84..0fea4f258d 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs @@ -355,6 +355,44 @@ public void FindUneditedRedactionSentinel_RefusesParentEditInsideNoIdArray() Assert.True(sent.GetProperty("profiles").GetProperty("accounts")[0].TryGetProperty("token", out _)); } + [Fact] + public void ApplyChanges_ParentObjectEdit_KeepsAccountRenameOrRefusesIt() + { + using var stored = JsonDocument.Parse(""" + {"profiles":{"accounts":[{"token":"","label":"desk"}]}} + """); + using var staged = JsonDocument.Parse(""" + {"accounts":[{"token":"","label":"renamed"}]} + """); + var updated = ConfigEditorModel.ApplyChanges( + stored.RootElement, + new Dictionary { ["profiles"] = staged.RootElement.Clone() }); + var edited = new[] { "profiles" }; + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel(updated, edited, stored.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels(updated, edited, stored.RootElement); + + Assert.Equal("profiles.accounts[0].token", blocked); + Assert.True(sent.GetProperty("profiles").TryGetProperty("accounts", out var accounts)); + Assert.Equal("renamed", accounts[0].GetProperty("label").GetString()); + Assert.Equal("", accounts[0].GetProperty("token").GetString()); + } + + [Fact] + public void ApplyChanges_ParentObjectEdit_KeepsNewlyEnteredOrdinaryMask() + { + using var stored = JsonDocument.Parse("""{"metadata":{"label":"before"}}"""); + using var staged = JsonDocument.Parse("""{"label":"***"}"""); + var updated = ConfigEditorModel.ApplyChanges( + stored.RootElement, + new Dictionary { ["metadata"] = staged.RootElement.Clone() }); + var edited = new[] { "metadata" }; + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel(updated, edited, stored.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels(updated, edited, stored.RootElement); + + Assert.Null(blocked); + Assert.Equal("***", sent.GetProperty("metadata").GetProperty("label").GetString()); + } + [Fact] public void FindUneditedRedactionSentinel_AllowsLiteralMaskInANonSecretField() { From 14d286ae02de12273ba5bd73307757daa862240a Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 09:36:01 -0700 Subject: [PATCH 15/28] fix(config): refuse a new credential mask typed into a parent object A parent-object edit that changes a stored token from ordinary text to a mask was omitted. The save looked successful and the Gateway kept the old token. Refuse that path and leave the typed mask in the draft. An unchanged loaded mask under the same parent edit is still omitted. Signed-off-by: Sebastien Tardif --- .../Services/ConfigEditorModel.cs | 20 +++++++++++++++++++ .../ConfigEditorModelTests.cs | 16 +++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs index 4e05eafaf2..c041616bab 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs @@ -202,6 +202,9 @@ public static JsonElement OmitUntouchedRedactionSentinels( if (edited.Contains(path)) return loadedSentinel ? path : null; + if (HasAncestorEdit(edited, path) && !loadedSentinel && IsCredentialPath(path)) + return path; + // Object fields and id-keyed array fields can be left out of the // patch. Gateway keeps the stored secret for an absent key. An array // whose entries lack stable ids is replaced wholesale, so an indexed @@ -279,6 +282,12 @@ private static void RemoveUntouchedRedactionSentinels( continue; } + if (HasAncestorEdit(edited, childPath) && + !LoadedStringIsRedactionSentinel(baseRoot, childPath)) + { + continue; + } + var arrayPath = OutermostNonIdArrayPath(baseRoot, childPath); if (arrayPath == null) removals.Add(property.Key); @@ -417,6 +426,17 @@ private static bool EditedPathInsideArray(HashSet edited, string arrayPa return false; } + private static bool HasAncestorEdit(HashSet edited, string path) + { + foreach (var editedPath in edited) + { + if (IsAncestorEdit(editedPath, path)) + return true; + } + + return false; + } + private static bool IsAncestorEdit(string editedPath, string arrayPath) { if (editedPath.Length == 0 || editedPath.Length >= arrayPath.Length) diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs index 0fea4f258d..67eda73a0d 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs @@ -393,6 +393,22 @@ public void ApplyChanges_ParentObjectEdit_KeepsNewlyEnteredOrdinaryMask() Assert.Equal("***", sent.GetProperty("metadata").GetProperty("label").GetString()); } + [Fact] + public void ApplyChanges_ParentObjectEdit_RefusesNewlyEnteredCredentialMask() + { + using var stored = JsonDocument.Parse("""{"metadata":{"token":"before"}}"""); + using var staged = JsonDocument.Parse("""{"token":"***"}"""); + var updated = ConfigEditorModel.ApplyChanges( + stored.RootElement, + new Dictionary { ["metadata"] = staged.RootElement.Clone() }); + var edited = new[] { "metadata" }; + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel(updated, edited, stored.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels(updated, edited, stored.RootElement); + + Assert.Equal("metadata.token", blocked); + Assert.Equal("***", sent.GetProperty("metadata").GetProperty("token").GetString()); + } + [Fact] public void FindUneditedRedactionSentinel_AllowsLiteralMaskInANonSecretField() { From 234d3fff7e3b6179a1d6e570cca4c7762f710c15 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 10:12:38 -0700 Subject: [PATCH 16/28] fix(config): keep a parent edit that changes one mask literal to another A label loaded as *** and saved as ***** was omitted because both strings are placeholders. Omit a placeholder only when the submitted text matches the loaded text. A credential whose placeholder text changes is refused and stays in the draft. Signed-off-by: Sebastien Tardif --- .../Services/ConfigEditorModel.cs | 22 +++++++++++-- .../ConfigEditorModelTests.cs | 32 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs index c041616bab..fbb5b647e6 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs @@ -202,8 +202,12 @@ public static JsonElement OmitUntouchedRedactionSentinels( if (edited.Contains(path)) return loadedSentinel ? path : null; - if (HasAncestorEdit(edited, path) && !loadedSentinel && IsCredentialPath(path)) + if (HasAncestorEdit(edited, path) && + IsCredentialPath(path) && + !SameSentinel(baseDocument, updated.GetString())) + { return path; + } // Object fields and id-keyed array fields can be left out of the // patch. Gateway keeps the stored secret for an absent key. An array @@ -283,7 +287,7 @@ private static void RemoveUntouchedRedactionSentinels( } if (HasAncestorEdit(edited, childPath) && - !LoadedStringIsRedactionSentinel(baseRoot, childPath)) + !UnchangedLoadedSentinel(baseRoot, childPath, text)) { continue; } @@ -448,6 +452,20 @@ private static bool IsAncestorEdit(string editedPath, string arrayPath) return boundary is '.' or '['; } + private static bool SameSentinel(JsonElement loaded, string? submitted) => + loaded.ValueKind == JsonValueKind.String && + ChannelConfigPatchBuilder.IsRedactionSentinel(loaded.GetString()) && + ChannelConfigPatchBuilder.IsRedactionSentinel(submitted) && + string.Equals(loaded.GetString()?.Trim(), submitted?.Trim(), StringComparison.OrdinalIgnoreCase); + + private static bool UnchangedLoadedSentinel(JsonElement root, string path, string submitted) + { + if (!TryReadPath(root, path, out var element)) + return false; + + return SameSentinel(element, submitted); + } + private static bool LoadedStringIsRedactionSentinel(JsonElement root, string path) { if (!TryReadPath(root, path, out var element) || diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs index 67eda73a0d..6cf4efbb05 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs @@ -409,6 +409,38 @@ public void ApplyChanges_ParentObjectEdit_RefusesNewlyEnteredCredentialMask() Assert.Equal("***", sent.GetProperty("metadata").GetProperty("token").GetString()); } + [Fact] + public void ApplyChanges_ParentObjectEdit_KeepsChangedMaskLiteral() + { + using var stored = JsonDocument.Parse("""{"metadata":{"label":"***"}}"""); + using var staged = JsonDocument.Parse("""{"label":"*****"}"""); + var updated = ConfigEditorModel.ApplyChanges( + stored.RootElement, + new Dictionary { ["metadata"] = staged.RootElement.Clone() }); + var edited = new[] { "metadata" }; + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel(updated, edited, stored.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels(updated, edited, stored.RootElement); + + Assert.Null(blocked); + Assert.Equal("*****", sent.GetProperty("metadata").GetProperty("label").GetString()); + } + + [Fact] + public void ApplyChanges_ParentObjectEdit_RefusesChangedCredentialMask() + { + using var stored = JsonDocument.Parse("""{"metadata":{"token":"***"}}"""); + using var staged = JsonDocument.Parse("""{"token":"*****"}"""); + var updated = ConfigEditorModel.ApplyChanges( + stored.RootElement, + new Dictionary { ["metadata"] = staged.RootElement.Clone() }); + var edited = new[] { "metadata" }; + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel(updated, edited, stored.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels(updated, edited, stored.RootElement); + + Assert.Equal("metadata.token", blocked); + Assert.Equal("*****", sent.GetProperty("metadata").GetProperty("token").GetString()); + } + [Fact] public void FindUneditedRedactionSentinel_AllowsLiteralMaskInANonSecretField() { From 48cf3fb914f429bc5526dba7092b5c9ebad353f5 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 10:33:02 -0700 Subject: [PATCH 17/28] ci: retrigger after UI test host hang Signed-off-by: Sebastien Tardif From 6f85f83cc490a82374d5483febbffb92f8bcb081 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 10:54:29 -0700 Subject: [PATCH 18/28] fix(config): compare loaded and submitted masks without trimming A parent edit that changed *** to a spaced or differently cased placeholder was treated as unchanged and omitted. Compare the original strings exactly. An unchanged *** is still omitted. A credential whose text changes, including added spaces, is refused and stays in the draft. Signed-off-by: Sebastien Tardif --- .../Services/ConfigEditorModel.cs | 4 +- .../ConfigEditorModelTests.cs | 53 +++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs index fbb5b647e6..59673ad340 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs @@ -454,9 +454,7 @@ private static bool IsAncestorEdit(string editedPath, string arrayPath) private static bool SameSentinel(JsonElement loaded, string? submitted) => loaded.ValueKind == JsonValueKind.String && - ChannelConfigPatchBuilder.IsRedactionSentinel(loaded.GetString()) && - ChannelConfigPatchBuilder.IsRedactionSentinel(submitted) && - string.Equals(loaded.GetString()?.Trim(), submitted?.Trim(), StringComparison.OrdinalIgnoreCase); + string.Equals(loaded.GetString(), submitted, StringComparison.Ordinal); private static bool UnchangedLoadedSentinel(JsonElement root, string path, string submitted) { diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs index 6cf4efbb05..6bfc7e5741 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs @@ -425,6 +425,59 @@ public void ApplyChanges_ParentObjectEdit_KeepsChangedMaskLiteral() Assert.Equal("*****", sent.GetProperty("metadata").GetProperty("label").GetString()); } + [Fact] + public void ApplyChanges_ParentObjectEdit_KeepsSpacingAndCaseAroundALoadedMask() + { + using var spacedStored = JsonDocument.Parse("""{"metadata":{"label":"***"}}"""); + using var spacedStaged = JsonDocument.Parse("""{"label":" *** "}"""); + var spacedUpdated = ConfigEditorModel.ApplyChanges( + spacedStored.RootElement, + new Dictionary { ["metadata"] = spacedStaged.RootElement.Clone() }); + var edited = new[] { "metadata" }; + var spacedBlocked = ConfigEditorModel.FindUneditedRedactionSentinel( + spacedUpdated, + edited, + spacedStored.RootElement); + var spacedSent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + spacedUpdated, + edited, + spacedStored.RootElement); + Assert.Null(spacedBlocked); + Assert.Equal(" *** ", spacedSent.GetProperty("metadata").GetProperty("label").GetString()); + + using var caseStored = JsonDocument.Parse("""{"metadata":{"label":"[REDACTED]"}}"""); + using var caseStaged = JsonDocument.Parse("""{"label":"[redacted]"}"""); + var caseUpdated = ConfigEditorModel.ApplyChanges( + caseStored.RootElement, + new Dictionary { ["metadata"] = caseStaged.RootElement.Clone() }); + var caseBlocked = ConfigEditorModel.FindUneditedRedactionSentinel( + caseUpdated, + edited, + caseStored.RootElement); + var caseSent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + caseUpdated, + edited, + caseStored.RootElement); + Assert.Null(caseBlocked); + Assert.Equal("[redacted]", caseSent.GetProperty("metadata").GetProperty("label").GetString()); + + using var tokenStored = JsonDocument.Parse("""{"metadata":{"token":"***"}}"""); + using var tokenStaged = JsonDocument.Parse("""{"token":" *** "}"""); + var tokenUpdated = ConfigEditorModel.ApplyChanges( + tokenStored.RootElement, + new Dictionary { ["metadata"] = tokenStaged.RootElement.Clone() }); + var tokenBlocked = ConfigEditorModel.FindUneditedRedactionSentinel( + tokenUpdated, + edited, + tokenStored.RootElement); + var tokenSent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + tokenUpdated, + edited, + tokenStored.RootElement); + Assert.Equal("metadata.token", tokenBlocked); + Assert.Equal(" *** ", tokenSent.GetProperty("metadata").GetProperty("token").GetString()); + } + [Fact] public void ApplyChanges_ParentObjectEdit_RefusesChangedCredentialMask() { From b810b389041bb0f23ca123306a886a17317dd6f5 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 13:22:22 -0700 Subject: [PATCH 19/28] fix(config): match id-keyed account rows before omitting a mask A parent edit of a reordered account list compared the placeholder with the loaded row at the same index. Match the loaded row by id. An unchanged secret is omitted so the Gateway keeps it. A new id with a mask is refused and stays in the draft. Signed-off-by: Sebastien Tardif --- .../Services/ConfigEditorModel.cs | 156 ++++++++++++++++-- .../ConfigEditorModelTests.cs | 65 ++++++++ 2 files changed, 210 insertions(+), 11 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs index 59673ad340..0006e0f598 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs @@ -141,7 +141,7 @@ public static JsonElement OmitUntouchedRedactionSentinels( var edited = new HashSet(editedPaths, StringComparer.OrdinalIgnoreCase); var arraysToDrop = new HashSet(StringComparer.Ordinal); - RemoveUntouchedRedactionSentinels(node, "", edited, baseDocument, arraysToDrop); + RemoveUntouchedRedactionSentinels(node, "", edited, baseDocument, document, arraysToDrop); foreach (var arrayPath in arraysToDrop.OrderBy(static path => path.Length)) RemovePropertyAtPath(node, arrayPath); @@ -181,13 +181,24 @@ public static JsonElement OmitUntouchedRedactionSentinels( } else if (updated.ValueKind == JsonValueKind.Array) { + var idKeyed = baseDocument.ValueKind == JsonValueKind.Array && ArrayIsIdKeyed(baseDocument); var index = 0; foreach (var item in updated.EnumerateArray()) { var childPath = $"{path}[{index}]"; var childBase = default(JsonElement); - if (baseDocument.ValueKind == JsonValueKind.Array && index < baseDocument.GetArrayLength()) + if (idKeyed && TryGetObjectId(item, out var id) && + TryFindArrayItemById(baseDocument, id, out var matched)) + { + childBase = matched; + } + else if (!idKeyed && + baseDocument.ValueKind == JsonValueKind.Array && + index < baseDocument.GetArrayLength()) + { childBase = baseDocument[index]; + } + index++; var hit = FindBlockedRedactionSentinel(item, childBase, childPath, edited, baseRoot); if (hit != null) @@ -204,7 +215,9 @@ public static JsonElement OmitUntouchedRedactionSentinels( if (HasAncestorEdit(edited, path) && IsCredentialPath(path) && - !SameSentinel(baseDocument, updated.GetString())) + !SameSentinel(baseDocument, updated.GetString()) && + !(LoadedValueIsRealSecret(baseDocument) && + CredentialArrayIsIdKeyed(baseRoot, path))) { return path; } @@ -266,6 +279,7 @@ private static void RemoveUntouchedRedactionSentinels( string path, HashSet edited, JsonElement baseRoot, + JsonElement submittedRoot, HashSet arraysToDrop) { if (node is JsonObject obj) @@ -281,13 +295,15 @@ private static void RemoveUntouchedRedactionSentinels( ShouldOmitUntouchedSentinel(childPath)) { if (!IsCredentialPath(childPath) && - !LoadedStringIsRedactionSentinel(baseRoot, childPath)) + !LoadedStringIsRedactionSentinel(baseRoot, submittedRoot, childPath)) { continue; } if (HasAncestorEdit(edited, childPath) && - !UnchangedLoadedSentinel(baseRoot, childPath, text)) + !UnchangedLoadedSentinel(baseRoot, submittedRoot, childPath, text) && + !(LoadedValueIsRealSecret(baseRoot, submittedRoot, childPath) && + CredentialArrayIsIdKeyed(baseRoot, childPath))) { continue; } @@ -302,7 +318,8 @@ private static void RemoveUntouchedRedactionSentinels( } if (property.Value is not null) - RemoveUntouchedRedactionSentinels(property.Value, childPath, edited, baseRoot, arraysToDrop); + RemoveUntouchedRedactionSentinels( + property.Value, childPath, edited, baseRoot, submittedRoot, arraysToDrop); } foreach (var key in removals) @@ -313,7 +330,8 @@ private static void RemoveUntouchedRedactionSentinels( for (var index = 0; index < array.Count; index++) { if (array[index] is JsonNode item) - RemoveUntouchedRedactionSentinels(item, $"{path}[{index}]", edited, baseRoot, arraysToDrop); + RemoveUntouchedRedactionSentinels( + item, $"{path}[{index}]", edited, baseRoot, submittedRoot, arraysToDrop); } } } @@ -456,17 +474,45 @@ private static bool SameSentinel(JsonElement loaded, string? submitted) => loaded.ValueKind == JsonValueKind.String && string.Equals(loaded.GetString(), submitted, StringComparison.Ordinal); - private static bool UnchangedLoadedSentinel(JsonElement root, string path, string submitted) + private static bool CredentialArrayIsIdKeyed(JsonElement baseRoot, string propertyPath) + { + var bracket = propertyPath.LastIndexOf('['); + if (bracket < 0) + return false; + + var arrayPath = propertyPath[..bracket]; + if (!TryReadPath(baseRoot, arrayPath, out var array)) + return false; + + return ArrayIsIdKeyed(array); + } + + private static bool LoadedValueIsRealSecret(JsonElement loaded) => + loaded.ValueKind == JsonValueKind.String && + !ChannelConfigPatchBuilder.IsRedactionSentinel(loaded.GetString()); + + private static bool LoadedValueIsRealSecret(JsonElement baseRoot, JsonElement submittedRoot, string path) => + TryReadIdAware(baseRoot, submittedRoot, path, out var loaded) && + LoadedValueIsRealSecret(loaded); + + private static bool UnchangedLoadedSentinel( + JsonElement baseRoot, + JsonElement submittedRoot, + string path, + string submitted) { - if (!TryReadPath(root, path, out var element)) + if (!TryReadIdAware(baseRoot, submittedRoot, path, out var element)) return false; return SameSentinel(element, submitted); } - private static bool LoadedStringIsRedactionSentinel(JsonElement root, string path) + private static bool LoadedStringIsRedactionSentinel( + JsonElement baseRoot, + JsonElement submittedRoot, + string path) { - if (!TryReadPath(root, path, out var element) || + if (!TryReadIdAware(baseRoot, submittedRoot, path, out var element) || element.ValueKind != JsonValueKind.String) { return false; @@ -475,6 +521,94 @@ private static bool LoadedStringIsRedactionSentinel(JsonElement root, string pat return ChannelConfigPatchBuilder.IsRedactionSentinel(element.GetString()); } + private static bool TryReadIdAware( + JsonElement baseRoot, + JsonElement submittedRoot, + string path, + out JsonElement value) + { + value = default; + var baseCurrent = baseRoot; + var submittedCurrent = submittedRoot; + foreach (var segment in ParsePath(path)) + { + if (baseCurrent.ValueKind != JsonValueKind.Object || + submittedCurrent.ValueKind != JsonValueKind.Object || + !baseCurrent.TryGetProperty(segment.Name, out var baseChild) || + !submittedCurrent.TryGetProperty(segment.Name, out var submittedChild)) + { + return false; + } + + if (segment.Index is not int index) + { + baseCurrent = baseChild; + submittedCurrent = submittedChild; + continue; + } + + if (submittedChild.ValueKind != JsonValueKind.Array || + (uint)index >= (uint)submittedChild.GetArrayLength()) + { + return false; + } + + var submittedItem = submittedChild[index]; + if (baseChild.ValueKind == JsonValueKind.Array && + ArrayIsIdKeyed(baseChild) && + TryGetObjectId(submittedItem, out var id) && + TryFindArrayItemById(baseChild, id, out var matched)) + { + baseCurrent = matched; + } + else if (baseChild.ValueKind == JsonValueKind.Array && + !ArrayIsIdKeyed(baseChild) && + index < baseChild.GetArrayLength()) + { + baseCurrent = baseChild[index]; + } + else + { + return false; + } + + submittedCurrent = submittedItem; + } + + value = baseCurrent; + return true; + } + + private static bool TryGetObjectId(JsonElement item, out string id) + { + id = ""; + if (item.ValueKind != JsonValueKind.Object || + !item.TryGetProperty("id", out var idElement) || + idElement.ValueKind != JsonValueKind.String) + { + return false; + } + + id = idElement.GetString() ?? ""; + return id.Length > 0; + } + + private static bool TryFindArrayItemById(JsonElement array, string id, out JsonElement item) + { + foreach (var candidate in array.EnumerateArray()) + { + if (TryGetObjectId(candidate, out var candidateId) && + string.Equals(candidateId, id, StringComparison.Ordinal)) + { + item = candidate; + return true; + } + } + + item = default; + return false; + } + private static bool TryReadPath(JsonElement root, string path, out JsonElement element) { element = root; diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs index 6bfc7e5741..37e8ccc025 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs @@ -882,6 +882,71 @@ public void IdKeyedArray_OmitsCredentialFieldAndGatewayKeepsStoredSecret() Assert.True(accepted.Merged["enabled"]!.GetValue()); } + [Fact] + public void IdKeyedArray_ReorderUnderParentEdit_KeepsEachStoredSecret() + { + using var stored = JsonDocument.Parse(""" + { + "accounts": [ + { "id": "acct-a", "token": "secret-a", "label": "one" }, + { "id": "acct-b", "token": "secret-b", "label": "two" } + ] + } + """); + using var editor = JsonDocument.Parse(""" + { + "accounts": [ + { "id": "acct-b", "token": "", "label": "two" }, + { "id": "acct-a", "token": "", "label": "renamed" } + ] + } + """); + var edited = new[] { "accounts" }; + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + editor.RootElement, edited, stored.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + editor.RootElement, edited, stored.RootElement); + Assert.Null(blocked); + Assert.False(sent.GetProperty("accounts")[0].TryGetProperty("token", out _)); + Assert.False(sent.GetProperty("accounts")[1].TryGetProperty("token", out _)); + Assert.Equal("renamed", sent.GetProperty("accounts")[1].GetProperty("label").GetString()); + + var accepted = GatewayArrayPatch.Apply(stored.RootElement, sent); + Assert.Null(accepted.Error); + var mergedAccounts = accepted.Merged["accounts"]!.AsArray(); + var mergedA = mergedAccounts.Single(item => item!["id"]!.GetValue() == "acct-a")!; + var mergedB = mergedAccounts.Single(item => item!["id"]!.GetValue() == "acct-b")!; + Assert.Equal("secret-a", mergedA["token"]!.GetValue()); + Assert.Equal("renamed", mergedA["label"]!.GetValue()); + Assert.Equal("secret-b", mergedB["token"]!.GetValue()); + Assert.Equal("two", mergedB["label"]!.GetValue()); + } + + [Fact] + public void IdKeyedArray_NewRowWithMask_IsRefusedAndKeepsTheDraft() + { + using var stored = JsonDocument.Parse(""" + { "accounts": [ { "id": "acct-1", "token": "secret-a", "label": "desk" } ] } + """); + using var editor = JsonDocument.Parse(""" + { + "accounts": [ + { "id": "acct-1", "token": "", "label": "desk" }, + { "id": "acct-2", "token": "", "label": "new" } + ] + } + """); + var edited = new[] { "accounts" }; + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + editor.RootElement, edited, stored.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + editor.RootElement, edited, stored.RootElement); + + Assert.Equal("accounts[1].token", blocked); + Assert.Equal("", sent.GetProperty("accounts")[1].GetProperty("token").GetString()); + Assert.False(sent.GetProperty("accounts")[0].TryGetProperty("token", out _)); + } + /// /// Models the Gateway config.patch array merge used by these saves: /// id-keyed arrays keep omitted keys, other arrays replace, and a From 6aa7a652df5c871188e696b3d8e8c0a986341962 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 13:42:39 -0700 Subject: [PATCH 20/28] ci: retrigger after UI test host hang Signed-off-by: Sebastien Tardif From 89ea68bfe6328f3018d6240a1f79a429626d83a9 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 14:03:07 -0700 Subject: [PATCH 21/28] fix(config): refuse a new mask and follow literal config keys A real secret changed to a mask under an id-keyed row was omitted, so the Gateway kept the old secret. Refuse that edit and keep the draft. Look up loaded values by the JSON property name, so a provider key such as custom.openai is not split on the dot. When an outer id-keyed list is reordered, classify each row's nested array from that same id. Signed-off-by: Sebastien Tardif --- .../Services/ConfigEditorModel.cs | 111 ++++++++++++------ .../ConfigEditorModelTests.cs | 84 +++++++++++-- 2 files changed, 148 insertions(+), 47 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs index 0006e0f598..f941b7fd54 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs @@ -141,7 +141,8 @@ public static JsonElement OmitUntouchedRedactionSentinels( var edited = new HashSet(editedPaths, StringComparer.OrdinalIgnoreCase); var arraysToDrop = new HashSet(StringComparer.Ordinal); - RemoveUntouchedRedactionSentinels(node, "", edited, baseDocument, document, arraysToDrop); + RemoveUntouchedRedactionSentinels( + node, "", edited, baseDocument, document, baseDocument, arraysToDrop); foreach (var arrayPath in arraysToDrop.OrderBy(static path => path.Length)) RemovePropertyAtPath(node, arrayPath); @@ -154,14 +155,15 @@ public static JsonElement OmitUntouchedRedactionSentinels( JsonElement baseDocument, string path, HashSet edited) => - FindBlockedRedactionSentinel(updated, baseDocument, path, edited, baseDocument); + FindBlockedRedactionSentinel(updated, baseDocument, path, edited, baseDocument, updated); private static string? FindBlockedRedactionSentinel( JsonElement updated, JsonElement baseDocument, string path, HashSet edited, - JsonElement baseRoot) + JsonElement baseRoot, + JsonElement submittedRoot) { if (updated.ValueKind == JsonValueKind.Object) { @@ -174,7 +176,8 @@ public static JsonElement OmitUntouchedRedactionSentinels( var childBase = default(JsonElement); if (baseObject.ValueKind == JsonValueKind.Object) baseObject.TryGetProperty(property.Name, out childBase); - var hit = FindBlockedRedactionSentinel(property.Value, childBase, childPath, edited, baseRoot); + var hit = FindBlockedRedactionSentinel( + property.Value, childBase, childPath, edited, baseRoot, submittedRoot); if (hit != null) return hit; } @@ -200,7 +203,8 @@ public static JsonElement OmitUntouchedRedactionSentinels( } index++; - var hit = FindBlockedRedactionSentinel(item, childBase, childPath, edited, baseRoot); + var hit = FindBlockedRedactionSentinel( + item, childBase, childPath, edited, baseRoot, submittedRoot); if (hit != null) return hit; } @@ -215,9 +219,7 @@ public static JsonElement OmitUntouchedRedactionSentinels( if (HasAncestorEdit(edited, path) && IsCredentialPath(path) && - !SameSentinel(baseDocument, updated.GetString()) && - !(LoadedValueIsRealSecret(baseDocument) && - CredentialArrayIsIdKeyed(baseRoot, path))) + !SameSentinel(baseDocument, updated.GetString())) { return path; } @@ -228,7 +230,7 @@ public static JsonElement OmitUntouchedRedactionSentinels( // edit inside that array cannot drop the masked credential safely. if (ShouldOmitUntouchedSentinel(path)) { - var arrayPath = OutermostNonIdArrayPath(baseRoot, path); + var arrayPath = OutermostNonIdArrayPath(baseRoot, submittedRoot, path); if (arrayPath != null && EditedPathInsideArray(edited, arrayPath)) return path; @@ -280,35 +282,33 @@ private static void RemoveUntouchedRedactionSentinels( HashSet edited, JsonElement baseRoot, JsonElement submittedRoot, + JsonElement loadedNode, HashSet arraysToDrop) { if (node is JsonObject obj) { + var loadedObject = loadedNode.ValueKind == JsonValueKind.Object ? loadedNode : default; var removals = new List(); foreach (var property in obj) { var childPath = string.IsNullOrEmpty(path) ? property.Key : $"{path}.{property.Key}"; + var loadedChild = default(JsonElement); + if (loadedObject.ValueKind == JsonValueKind.Object) + loadedObject.TryGetProperty(property.Key, out loadedChild); + if (property.Value is JsonValue value && value.TryGetValue(out var text) && ChannelConfigPatchBuilder.IsRedactionSentinel(text) && !edited.Contains(childPath) && ShouldOmitUntouchedSentinel(childPath)) { - if (!IsCredentialPath(childPath) && - !LoadedStringIsRedactionSentinel(baseRoot, submittedRoot, childPath)) - { + if (!IsCredentialPath(childPath) && !LoadedValueIsSentinel(loadedChild)) continue; - } - if (HasAncestorEdit(edited, childPath) && - !UnchangedLoadedSentinel(baseRoot, submittedRoot, childPath, text) && - !(LoadedValueIsRealSecret(baseRoot, submittedRoot, childPath) && - CredentialArrayIsIdKeyed(baseRoot, childPath))) - { + if (HasAncestorEdit(edited, childPath) && !SameSentinel(loadedChild, text)) continue; - } - var arrayPath = OutermostNonIdArrayPath(baseRoot, childPath); + var arrayPath = OutermostNonIdArrayPath(baseRoot, submittedRoot, childPath); if (arrayPath == null) removals.Add(property.Key); else if (!EditedPathInsideArray(edited, arrayPath)) @@ -319,7 +319,7 @@ private static void RemoveUntouchedRedactionSentinels( if (property.Value is not null) RemoveUntouchedRedactionSentinels( - property.Value, childPath, edited, baseRoot, submittedRoot, arraysToDrop); + property.Value, childPath, edited, baseRoot, submittedRoot, loadedChild, arraysToDrop); } foreach (var key in removals) @@ -327,11 +327,31 @@ private static void RemoveUntouchedRedactionSentinels( } else if (node is JsonArray array) { + var loadedArray = loadedNode.ValueKind == JsonValueKind.Array ? loadedNode : default; + var idKeyed = loadedArray.ValueKind == JsonValueKind.Array && ArrayIsIdKeyed(loadedArray); for (var index = 0; index < array.Count; index++) { - if (array[index] is JsonNode item) - RemoveUntouchedRedactionSentinels( - item, $"{path}[{index}]", edited, baseRoot, submittedRoot, arraysToDrop); + if (array[index] is not JsonNode item) + continue; + + var loadedItem = default(JsonElement); + if (idKeyed && + item is JsonObject itemObject && + itemObject["id"] is JsonValue idValue && + idValue.TryGetValue(out var id) && + TryFindArrayItemById(loadedArray, id, out var matched)) + { + loadedItem = matched; + } + else if (!idKeyed && + loadedArray.ValueKind == JsonValueKind.Array && + index < loadedArray.GetArrayLength()) + { + loadedItem = loadedArray[index]; + } + + RemoveUntouchedRedactionSentinels( + item, $"{path}[{index}]", edited, baseRoot, submittedRoot, loadedItem, arraysToDrop); } } } @@ -394,20 +414,27 @@ private static bool ArrayIsIdKeyed(JsonElement array) return true; } - private static string? OutermostNonIdArrayPath(JsonElement root, string credentialPath) + private static string? OutermostNonIdArrayPath( + JsonElement baseRoot, + JsonElement submittedRoot, + string credentialPath) { if (!credentialPath.Contains('[', StringComparison.Ordinal) || - root.ValueKind != JsonValueKind.Object) + baseRoot.ValueKind != JsonValueKind.Object || + submittedRoot.ValueKind != JsonValueKind.Object) { return null; } - var current = root; + var baseCurrent = baseRoot; + var submittedCurrent = submittedRoot; var walked = ""; foreach (var segment in ParsePath(credentialPath)) { - if (current.ValueKind != JsonValueKind.Object || - !current.TryGetProperty(segment.Name, out var child)) + if (baseCurrent.ValueKind != JsonValueKind.Object || + submittedCurrent.ValueKind != JsonValueKind.Object || + !baseCurrent.TryGetProperty(segment.Name, out var baseChild) || + !submittedCurrent.TryGetProperty(segment.Name, out var submittedChild)) { return null; } @@ -415,19 +442,31 @@ private static bool ArrayIsIdKeyed(JsonElement array) walked = walked.Length == 0 ? segment.Name : $"{walked}.{segment.Name}"; if (segment.Index is not int arrayIndex) { - current = child; + baseCurrent = baseChild; + submittedCurrent = submittedChild; continue; } - if (child.ValueKind != JsonValueKind.Array) + if (baseChild.ValueKind != JsonValueKind.Array || + submittedChild.ValueKind != JsonValueKind.Array || + (uint)arrayIndex >= (uint)submittedChild.GetArrayLength()) + { return null; - if (!ArrayIsIdKeyed(child)) + } + + if (!ArrayIsIdKeyed(baseChild)) return walked; - if ((uint)arrayIndex >= (uint)child.GetArrayLength()) + + var submittedItem = submittedChild[arrayIndex]; + if (!TryGetObjectId(submittedItem, out var id) || + !TryFindArrayItemById(baseChild, id, out var matched)) + { return null; + } walked = $"{walked}[{arrayIndex.ToString(CultureInfo.InvariantCulture)}]"; - current = child[arrayIndex]; + baseCurrent = matched; + submittedCurrent = submittedItem; } return null; @@ -487,6 +526,10 @@ private static bool CredentialArrayIsIdKeyed(JsonElement baseRoot, string proper return ArrayIsIdKeyed(array); } + private static bool LoadedValueIsSentinel(JsonElement loaded) => + loaded.ValueKind == JsonValueKind.String && + ChannelConfigPatchBuilder.IsRedactionSentinel(loaded.GetString()); + private static bool LoadedValueIsRealSecret(JsonElement loaded) => loaded.ValueKind == JsonValueKind.String && !ChannelConfigPatchBuilder.IsRedactionSentinel(loaded.GetString()); diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs index 37e8ccc025..fd528f755d 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs @@ -906,27 +906,85 @@ public void IdKeyedArray_ReorderUnderParentEdit_KeepsEachStoredSecret() editor.RootElement, edited, stored.RootElement); var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels( editor.RootElement, edited, stored.RootElement); - Assert.Null(blocked); - Assert.False(sent.GetProperty("accounts")[0].TryGetProperty("token", out _)); - Assert.False(sent.GetProperty("accounts")[1].TryGetProperty("token", out _)); + Assert.Equal("accounts[0].token", blocked); + Assert.Equal("", sent.GetProperty("accounts")[0].GetProperty("token").GetString()); Assert.Equal("renamed", sent.GetProperty("accounts")[1].GetProperty("label").GetString()); + } - var accepted = GatewayArrayPatch.Apply(stored.RootElement, sent); - Assert.Null(accepted.Error); - var mergedAccounts = accepted.Merged["accounts"]!.AsArray(); - var mergedA = mergedAccounts.Single(item => item!["id"]!.GetValue() == "acct-a")!; - var mergedB = mergedAccounts.Single(item => item!["id"]!.GetValue() == "acct-b")!; - Assert.Equal("secret-a", mergedA["token"]!.GetValue()); - Assert.Equal("renamed", mergedA["label"]!.GetValue()); - Assert.Equal("secret-b", mergedB["token"]!.GetValue()); - Assert.Equal("two", mergedB["label"]!.GetValue()); + [Fact] + public void IdKeyedArray_ChangedTokenToMask_IsRefusedAndKeepsTheDraft() + { + using var stored = JsonDocument.Parse(""" + { "accounts": [ { "id": "desk", "token": "before" } ] } + """); + using var editor = JsonDocument.Parse(""" + { "accounts": [ { "id": "desk", "token": "***" } ] } + """); + var edited = new[] { "accounts" }; + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + editor.RootElement, edited, stored.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + editor.RootElement, edited, stored.RootElement); + + Assert.Equal("accounts[0].token", blocked); + Assert.Equal("***", sent.GetProperty("accounts")[0].GetProperty("token").GetString()); + } + + [Fact] + public void DottedProviderKey_OmitsUnchangedMaskWithoutSplittingTheKey() + { + using var stored = JsonDocument.Parse(""" + { "models": { "providers": { "custom.openai": { "apiKey": "***", "url": "https://old.example" } } } } + """); + using var editor = JsonDocument.Parse(""" + { "models": { "providers": { "custom.openai": { "apiKey": "***", "url": "https://new.example" } } } } + """); + var edited = new[] { "models.providers" }; + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + editor.RootElement, edited, stored.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + editor.RootElement, edited, stored.RootElement); + + Assert.Null(blocked); + var provider = sent.GetProperty("models").GetProperty("providers").GetProperty("custom.openai"); + Assert.False(provider.TryGetProperty("apiKey", out _)); + Assert.Equal("https://new.example", provider.GetProperty("url").GetString()); + } + + [Fact] + public void NestedArray_ReorderedOuterIds_DoNotTreatTheOtherRowsArrayAsIdKeyed() + { + using var stored = JsonDocument.Parse(""" + { + "groups": [ + { "id": "row-a", "accounts": [ { "id": "acct-1", "token": "secret-a" } ] }, + { "id": "row-b", "accounts": [ { "token": "***" } ] } + ] + } + """); + using var editor = JsonDocument.Parse(""" + { + "groups": [ + { "id": "row-b", "accounts": [ { "token": "***" } ] }, + { "id": "row-a", "accounts": [ { "id": "acct-1", "token": "" } ] } + ] + } + """); + var edited = new[] { "groups" }; + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + editor.RootElement, edited, stored.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + editor.RootElement, edited, stored.RootElement); + + Assert.Equal("groups[0].accounts[0].token", blocked); + Assert.Equal("***", sent.GetProperty("groups")[0].GetProperty("accounts")[0].GetProperty("token").GetString()); } [Fact] public void IdKeyedArray_NewRowWithMask_IsRefusedAndKeepsTheDraft() { using var stored = JsonDocument.Parse(""" - { "accounts": [ { "id": "acct-1", "token": "secret-a", "label": "desk" } ] } + { "accounts": [ { "id": "acct-1", "token": "", "label": "desk" } ] } """); using var editor = JsonDocument.Parse(""" { From da65bba586cc5f4b072ab3c865435d8cccf767b8 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 14:22:31 -0700 Subject: [PATCH 22/28] ci: retrigger after workspace navigation flake Signed-off-by: Sebastien Tardif From ffef70bae5cd76bfe62c39dfbf68650936c9b7ce Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 14:41:55 -0700 Subject: [PATCH 23/28] fix(config): classify non-id arrays without splitting JSON keys A provider key such as custom.openai was split on the dot while deciding whether its accounts array could drop a mask. The array boundary is now recorded while walking the real property, so an unchanged mask in that non-id array is refused and stays in the draft. Signed-off-by: Sebastien Tardif --- .../Services/ConfigEditorModel.cs | 46 +++++++++++++------ .../ConfigEditorModelTests.cs | 21 +++++++++ 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs index f941b7fd54..103643ccaa 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs @@ -142,7 +142,7 @@ public static JsonElement OmitUntouchedRedactionSentinels( var edited = new HashSet(editedPaths, StringComparer.OrdinalIgnoreCase); var arraysToDrop = new HashSet(StringComparer.Ordinal); RemoveUntouchedRedactionSentinels( - node, "", edited, baseDocument, document, baseDocument, arraysToDrop); + node, "", edited, baseDocument, document, baseDocument, null, arraysToDrop); foreach (var arrayPath in arraysToDrop.OrderBy(static path => path.Length)) RemovePropertyAtPath(node, arrayPath); @@ -155,7 +155,7 @@ public static JsonElement OmitUntouchedRedactionSentinels( JsonElement baseDocument, string path, HashSet edited) => - FindBlockedRedactionSentinel(updated, baseDocument, path, edited, baseDocument, updated); + FindBlockedRedactionSentinel(updated, baseDocument, path, edited, baseDocument, updated, null); private static string? FindBlockedRedactionSentinel( JsonElement updated, @@ -163,7 +163,8 @@ public static JsonElement OmitUntouchedRedactionSentinels( string path, HashSet edited, JsonElement baseRoot, - JsonElement submittedRoot) + JsonElement submittedRoot, + string? outermostNonIdArray) { if (updated.ValueKind == JsonValueKind.Object) { @@ -177,7 +178,7 @@ public static JsonElement OmitUntouchedRedactionSentinels( if (baseObject.ValueKind == JsonValueKind.Object) baseObject.TryGetProperty(property.Name, out childBase); var hit = FindBlockedRedactionSentinel( - property.Value, childBase, childPath, edited, baseRoot, submittedRoot); + property.Value, childBase, childPath, edited, baseRoot, submittedRoot, outermostNonIdArray); if (hit != null) return hit; } @@ -185,6 +186,9 @@ public static JsonElement OmitUntouchedRedactionSentinels( else if (updated.ValueKind == JsonValueKind.Array) { var idKeyed = baseDocument.ValueKind == JsonValueKind.Array && ArrayIsIdKeyed(baseDocument); + var nextOuter = outermostNonIdArray; + if (nextOuter is null && baseDocument.ValueKind == JsonValueKind.Array && !idKeyed && path.Length > 0) + nextOuter = path; var index = 0; foreach (var item in updated.EnumerateArray()) { @@ -204,7 +208,7 @@ public static JsonElement OmitUntouchedRedactionSentinels( index++; var hit = FindBlockedRedactionSentinel( - item, childBase, childPath, edited, baseRoot, submittedRoot); + item, childBase, childPath, edited, baseRoot, submittedRoot, nextOuter); if (hit != null) return hit; } @@ -230,8 +234,7 @@ public static JsonElement OmitUntouchedRedactionSentinels( // edit inside that array cannot drop the masked credential safely. if (ShouldOmitUntouchedSentinel(path)) { - var arrayPath = OutermostNonIdArrayPath(baseRoot, submittedRoot, path); - if (arrayPath != null && EditedPathInsideArray(edited, arrayPath)) + if (outermostNonIdArray != null && EditedPathInsideArray(edited, outermostNonIdArray)) return path; return null; @@ -283,6 +286,7 @@ private static void RemoveUntouchedRedactionSentinels( JsonElement baseRoot, JsonElement submittedRoot, JsonElement loadedNode, + string? outermostNonIdArray, HashSet arraysToDrop) { if (node is JsonObject obj) @@ -308,18 +312,24 @@ private static void RemoveUntouchedRedactionSentinels( if (HasAncestorEdit(edited, childPath) && !SameSentinel(loadedChild, text)) continue; - var arrayPath = OutermostNonIdArrayPath(baseRoot, submittedRoot, childPath); - if (arrayPath == null) + if (outermostNonIdArray == null) removals.Add(property.Key); - else if (!EditedPathInsideArray(edited, arrayPath)) - arraysToDrop.Add(arrayPath); + else if (!EditedPathInsideArray(edited, outermostNonIdArray)) + arraysToDrop.Add(outermostNonIdArray); continue; } if (property.Value is not null) RemoveUntouchedRedactionSentinels( - property.Value, childPath, edited, baseRoot, submittedRoot, loadedChild, arraysToDrop); + property.Value, + childPath, + edited, + baseRoot, + submittedRoot, + loadedChild, + outermostNonIdArray, + arraysToDrop); } foreach (var key in removals) @@ -329,6 +339,9 @@ private static void RemoveUntouchedRedactionSentinels( { var loadedArray = loadedNode.ValueKind == JsonValueKind.Array ? loadedNode : default; var idKeyed = loadedArray.ValueKind == JsonValueKind.Array && ArrayIsIdKeyed(loadedArray); + var nextOuter = outermostNonIdArray; + if (nextOuter is null && loadedArray.ValueKind == JsonValueKind.Array && !idKeyed && path.Length > 0) + nextOuter = path; for (var index = 0; index < array.Count; index++) { if (array[index] is not JsonNode item) @@ -351,7 +364,14 @@ item is JsonObject itemObject && } RemoveUntouchedRedactionSentinels( - item, $"{path}[{index}]", edited, baseRoot, submittedRoot, loadedItem, arraysToDrop); + item, + $"{path}[{index}]", + edited, + baseRoot, + submittedRoot, + loadedItem, + nextOuter, + arraysToDrop); } } } diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs index fd528f755d..73dcb65d77 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs @@ -951,6 +951,27 @@ public void DottedProviderKey_OmitsUnchangedMaskWithoutSplittingTheKey() Assert.Equal("https://new.example", provider.GetProperty("url").GetString()); } + [Fact] + public void DottedProviderKey_NonIdAccountsArray_RefusesAndKeepsTheMask() + { + using var stored = JsonDocument.Parse(""" + { "custom.openai": { "accounts": [ { "token": "***", "label": "desk" } ] } } + """); + using var editor = JsonDocument.Parse(""" + { "custom.openai": { "accounts": [ { "token": "***", "label": "renamed" } ] } } + """); + var edited = new[] { "custom.openai" }; + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + editor.RootElement, edited, stored.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + editor.RootElement, edited, stored.RootElement); + + Assert.Equal("custom.openai.accounts[0].token", blocked); + var account = sent.GetProperty("custom.openai").GetProperty("accounts")[0]; + Assert.Equal("***", account.GetProperty("token").GetString()); + Assert.Equal("renamed", account.GetProperty("label").GetString()); + } + [Fact] public void NestedArray_ReorderedOuterIds_DoNotTreatTheOtherRowsArrayAsIdKeyed() { From 15d0ee49bb65d67b679023e1b9fbb3fa2a2a07a7 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 1 Oct 2026 15:02:39 -0700 Subject: [PATCH 24/28] fix(config): drop a non-id array from the object that owns it An unrelated edit queued custom.openai.accounts for removal, then the path splitter looked for a root property named custom and left the mask in the patch. Removal now uses the object and key found while walking. The accounts array is removed and the mask is not sent. Signed-off-by: Sebastien Tardif --- .../Services/ConfigEditorModel.cs | 37 ++++++++++++++----- .../ConfigEditorModelTests.cs | 20 ++++++++++ 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs index 103643ccaa..8276fda5c3 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs @@ -140,11 +140,11 @@ public static JsonElement OmitUntouchedRedactionSentinels( return document.Clone(); var edited = new HashSet(editedPaths, StringComparer.OrdinalIgnoreCase); - var arraysToDrop = new HashSet(StringComparer.Ordinal); + var arraysToDrop = new List(); RemoveUntouchedRedactionSentinels( - node, "", edited, baseDocument, document, baseDocument, null, arraysToDrop); - foreach (var arrayPath in arraysToDrop.OrderBy(static path => path.Length)) - RemovePropertyAtPath(node, arrayPath); + node, "", edited, baseDocument, document, baseDocument, null, null, arraysToDrop); + foreach (var array in arraysToDrop) + array.Parent.Remove(array.Key); using var rewritten = JsonDocument.Parse(node.ToJsonString()); return rewritten.RootElement.Clone(); @@ -287,7 +287,8 @@ private static void RemoveUntouchedRedactionSentinels( JsonElement submittedRoot, JsonElement loadedNode, string? outermostNonIdArray, - HashSet arraysToDrop) + ArrayLocation? outermostArray, + List arraysToDrop) { if (node is JsonObject obj) { @@ -312,14 +313,28 @@ private static void RemoveUntouchedRedactionSentinels( if (HasAncestorEdit(edited, childPath) && !SameSentinel(loadedChild, text)) continue; - if (outermostNonIdArray == null) + if (outermostArray is null) removals.Add(property.Key); - else if (!EditedPathInsideArray(edited, outermostNonIdArray)) - arraysToDrop.Add(outermostNonIdArray); + else if (outermostNonIdArray is null || + !EditedPathInsideArray(edited, outermostNonIdArray)) + { + arraysToDrop.Add(outermostArray.Value); + } continue; } + var childOuter = outermostNonIdArray; + var childArray = outermostArray; + if (childArray is null && + property.Value is JsonArray && + loadedChild.ValueKind == JsonValueKind.Array && + !ArrayIsIdKeyed(loadedChild)) + { + childOuter = childPath; + childArray = new ArrayLocation(obj, property.Key); + } + if (property.Value is not null) RemoveUntouchedRedactionSentinels( property.Value, @@ -328,7 +343,8 @@ private static void RemoveUntouchedRedactionSentinels( baseRoot, submittedRoot, loadedChild, - outermostNonIdArray, + childOuter, + childArray, arraysToDrop); } @@ -371,11 +387,14 @@ item is JsonObject itemObject && submittedRoot, loadedItem, nextOuter, + outermostArray, arraysToDrop); } } } + private readonly record struct ArrayLocation(JsonObject Parent, string Key); + private readonly record struct ConfigPathSegment(string Name, int? Index); private static List ParsePath(string path) diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs index 73dcb65d77..dc25c2f12d 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs @@ -972,6 +972,26 @@ public void DottedProviderKey_NonIdAccountsArray_RefusesAndKeepsTheMask() Assert.Equal("renamed", account.GetProperty("label").GetString()); } + [Fact] + public void DottedProviderKey_UnrelatedEdit_RemovesTheNonIdAccountsArray() + { + using var stored = JsonDocument.Parse(""" + { "enabled": false, "custom.openai": { "accounts": [ { "token": "***" } ] } } + """); + using var editor = JsonDocument.Parse(""" + { "enabled": true, "custom.openai": { "accounts": [ { "token": "***" } ] } } + """); + var edited = new[] { "enabled" }; + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + editor.RootElement, edited, stored.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + editor.RootElement, edited, stored.RootElement); + + Assert.Null(blocked); + Assert.True(sent.GetProperty("enabled").GetBoolean()); + Assert.False(sent.GetProperty("custom.openai").TryGetProperty("accounts", out _)); + } + [Fact] public void NestedArray_ReorderedOuterIds_DoNotTreatTheOtherRowsArrayAsIdKeyed() { From 5fa003d1099ea09ce775f55902e4d763a52e6af3 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 6 Oct 2026 11:43:12 -0700 Subject: [PATCH 25/28] fix(config): omit the gateway redaction mask on an unrelated save config.get replaces a stored secret with __OPENCLAW_REDACTED__. That exact mask was not a local sentinel, so saving another field could write the mask over the stored secret. An unrelated edit now omits it. A parent edit that changes it is refused and the draft stays. Checked with ./build.ps1, Shared tests (4100 passed, 32 skipped), and Tray tests (3095 passed). Five Tray failures are existing source-contract checks that look for CRLF in files this change does not touch. Signed-off-by: Sebastien Tardif --- .../ChannelConfigPatchBuilder.cs | 2 + .../ChannelConfigPatchBuilderTests.cs | 1 + .../ConfigEditorModelTests.cs | 83 +++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/src/OpenClaw.Shared/ChannelConfigPatchBuilder.cs b/src/OpenClaw.Shared/ChannelConfigPatchBuilder.cs index 953949566b..94d8a5732e 100644 --- a/src/OpenClaw.Shared/ChannelConfigPatchBuilder.cs +++ b/src/OpenClaw.Shared/ChannelConfigPatchBuilder.cs @@ -57,6 +57,8 @@ public static class ChannelConfigPatchBuilder "***", "*****", "********", + // config.get replaces a stored secret with this exact mask. + "__OPENCLAW_REDACTED__", }; public static bool IsRedactionSentinel(string? value) => diff --git a/tests/OpenClaw.Shared.Tests/ChannelConfigPatchBuilderTests.cs b/tests/OpenClaw.Shared.Tests/ChannelConfigPatchBuilderTests.cs index c2c7d811a6..b5e0d88d44 100644 --- a/tests/OpenClaw.Shared.Tests/ChannelConfigPatchBuilderTests.cs +++ b/tests/OpenClaw.Shared.Tests/ChannelConfigPatchBuilderTests.cs @@ -195,6 +195,7 @@ public void BuildPatch_DoesNotBlockOnSentinelInsideTargetChannel() [InlineData("")] [InlineData("[redacted]")] [InlineData(" [REDACTED] ")] // trimmed before matching + [InlineData("__OPENCLAW_REDACTED__")] public void BuildPatch_DetectsCommonRedactionSentinels(string sentinel) { var existing = Json($$""" diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs index dc25c2f12d..3347d8a5c8 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs @@ -951,6 +951,89 @@ public void DottedProviderKey_OmitsUnchangedMaskWithoutSplittingTheKey() Assert.Equal("https://new.example", provider.GetProperty("url").GetString()); } + [Fact] + public void GatewayRedactionMask_UnrelatedEdit_OmitsApiKeyAndKeepsModels() + { + using var stored = JsonDocument.Parse(""" + { + "models": { + "providers": { + "custom.openai": { + "apiKey": "__OPENCLAW_REDACTED__", + "baseUrl": "https://old.example", + "models": [ { "id": "m1", "name": "one" } ] + } + } + } + } + """); + using var editor = JsonDocument.Parse(""" + { + "models": { + "providers": { + "custom.openai": { + "apiKey": "__OPENCLAW_REDACTED__", + "baseUrl": "https://new.example", + "models": [ { "id": "m1", "name": "one" } ] + } + } + } + } + """); + var edited = new[] { "models.providers.custom.openai.baseUrl" }; + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + editor.RootElement, edited, stored.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + editor.RootElement, edited, stored.RootElement); + var provider = sent.GetProperty("models").GetProperty("providers").GetProperty("custom.openai"); + + Assert.Null(blocked); + Assert.False(provider.TryGetProperty("apiKey", out _)); + Assert.Equal("https://new.example", provider.GetProperty("baseUrl").GetString()); + Assert.Equal("m1", provider.GetProperty("models")[0].GetProperty("id").GetString()); + } + + [Fact] + public void GatewayRedactionMask_ParentEdit_RefusesAndKeepsTheDraft() + { + using var stored = JsonDocument.Parse(""" + { + "models": { + "providers": { + "custom.openai": { + "apiKey": "__OPENCLAW_REDACTED__", + "baseUrl": "https://old.example", + "models": [ { "id": "m1" } ] + } + } + } + } + """); + using var editor = JsonDocument.Parse(""" + { + "models": { + "providers": { + "custom.openai": { + "apiKey": "***", + "baseUrl": "https://draft.example", + "models": [ { "id": "m1" } ] + } + } + } + } + """); + var edited = new[] { "models.providers" }; + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + editor.RootElement, edited, stored.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + editor.RootElement, edited, stored.RootElement); + var provider = sent.GetProperty("models").GetProperty("providers").GetProperty("custom.openai"); + + Assert.Equal("models.providers.custom.openai.apiKey", blocked); + Assert.Equal("***", provider.GetProperty("apiKey").GetString()); + Assert.Equal("https://draft.example", provider.GetProperty("baseUrl").GetString()); + } + [Fact] public void DottedProviderKey_NonIdAccountsArray_RefusesAndKeepsTheMask() { From fffc8472b6fd71ef56757fe77b1f24f3a1e6f6f8 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 6 Oct 2026 12:16:42 -0700 Subject: [PATCH 26/28] fix(config): keep the gateway mask out of the Channels blocker __OPENCLAW_REDACTED__ is the mask config.get already restores before writing. Treating it as a legacy sentinel made a Channels save refuse when another channel still showed that mask. Channels now sends the mask through. The Config editor still omits that mask on an untouched credential such as apiKey. A SecretRef id keeps the mask in the patch, so a provider change still reaches the gateway's identity check. ./build.ps1 passed. Shared tests: 4100 passed, 32 skipped. Tray tests: 3096 passed. Five failures are the existing CRLF source-contract checks in files this commit does not touch. Signed-off-by: Sebastien Tardif --- .../ChannelConfigPatchBuilder.cs | 12 +++++- .../Services/ConfigEditorModel.cs | 12 +++++- .../ChannelConfigPatchBuilderTests.cs | 26 +++++++++++- .../ConfigEditorModelTests.cs | 41 +++++++++++++++++++ 4 files changed, 87 insertions(+), 4 deletions(-) diff --git a/src/OpenClaw.Shared/ChannelConfigPatchBuilder.cs b/src/OpenClaw.Shared/ChannelConfigPatchBuilder.cs index 94d8a5732e..aab7176e9b 100644 --- a/src/OpenClaw.Shared/ChannelConfigPatchBuilder.cs +++ b/src/OpenClaw.Shared/ChannelConfigPatchBuilder.cs @@ -57,13 +57,21 @@ public static class ChannelConfigPatchBuilder "***", "*****", "********", - // config.get replaces a stored secret with this exact mask. - "__OPENCLAW_REDACTED__", }; + /// + /// Exact mask config.get substitutes for a stored secret. + /// The gateway restores this value before it writes, so a Channels save + /// must still send it. It is not a hit. + /// + public const string NativeGatewayRedactionMask = "__OPENCLAW_REDACTED__"; + public static bool IsRedactionSentinel(string? value) => value != null && RedactionSentinels.Contains(value.Trim()); + public static bool IsNativeGatewayRedactionMask(string? value) => + string.Equals(value, NativeGatewayRedactionMask, StringComparison.Ordinal); + /// /// Build a patched full-config from the cached config and a list of /// per-field updates for one channel. diff --git a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs index 8276fda5c3..e599cf748a 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs @@ -303,10 +303,20 @@ private static void RemoveUntouchedRedactionSentinels( if (property.Value is JsonValue value && value.TryGetValue(out var text) && - ChannelConfigPatchBuilder.IsRedactionSentinel(text) && + (ChannelConfigPatchBuilder.IsRedactionSentinel(text) || + ChannelConfigPatchBuilder.IsNativeGatewayRedactionMask(text)) && !edited.Contains(childPath) && ShouldOmitUntouchedSentinel(childPath)) { + // Leave a native mask on a non-credential leaf, including a + // SecretRef id. The gateway uses that mask to restore the + // stored id or to refuse a provider change. + if (ChannelConfigPatchBuilder.IsNativeGatewayRedactionMask(text) && + !IsCredentialPath(childPath)) + { + continue; + } + if (!IsCredentialPath(childPath) && !LoadedValueIsSentinel(loadedChild)) continue; diff --git a/tests/OpenClaw.Shared.Tests/ChannelConfigPatchBuilderTests.cs b/tests/OpenClaw.Shared.Tests/ChannelConfigPatchBuilderTests.cs index b5e0d88d44..1e8596fa79 100644 --- a/tests/OpenClaw.Shared.Tests/ChannelConfigPatchBuilderTests.cs +++ b/tests/OpenClaw.Shared.Tests/ChannelConfigPatchBuilderTests.cs @@ -168,6 +168,31 @@ public void BuildPatch_BlocksWhenOtherChannelHasRedactionSentinel() Assert.Contains("redacted", result.BlockedReason!, System.StringComparison.OrdinalIgnoreCase); } + [Fact] + public void BuildPatch_AllowsNativeGatewayMaskInAnotherChannel() + { + var existing = Json(""" + { + "channels": { + "slack": { "signingSecret": "__OPENCLAW_REDACTED__", "enabled": true }, + "telegram": { "botToken": "old", "enabled": false } + } + } + """); + + var result = ChannelConfigPatchBuilder.BuildPatch( + existing, "telegram", + Updates(("channels.telegram.botToken", "new-token"))); + + Assert.Null(result.BlockedReason); + Assert.NotNull(result.Patch); + var channels = result.Patch!.Value.GetProperty("channels"); + Assert.Equal( + "__OPENCLAW_REDACTED__", + channels.GetProperty("slack").GetProperty("signingSecret").GetString()); + Assert.Equal("new-token", channels.GetProperty("telegram").GetProperty("botToken").GetString()); + } + [Fact] public void BuildPatch_DoesNotBlockOnSentinelInsideTargetChannel() { @@ -195,7 +220,6 @@ public void BuildPatch_DoesNotBlockOnSentinelInsideTargetChannel() [InlineData("")] [InlineData("[redacted]")] [InlineData(" [REDACTED] ")] // trimmed before matching - [InlineData("__OPENCLAW_REDACTED__")] public void BuildPatch_DetectsCommonRedactionSentinels(string sentinel) { var existing = Json($$""" diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs index 3347d8a5c8..28f93a7faa 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs @@ -1034,6 +1034,47 @@ public void GatewayRedactionMask_ParentEdit_RefusesAndKeepsTheDraft() Assert.Equal("https://draft.example", provider.GetProperty("baseUrl").GetString()); } + [Fact] + public void GatewayRedactionMask_SecretRefProviderEdit_KeepsTheMaskedId() + { + using var stored = JsonDocument.Parse(""" + { + "auth": { + "profiles": { + "one": { + "source": "env", + "provider": "one", + "id": "__OPENCLAW_REDACTED__" + } + } + } + } + """); + using var editor = JsonDocument.Parse(""" + { + "auth": { + "profiles": { + "one": { + "source": "env", + "provider": "two", + "id": "__OPENCLAW_REDACTED__" + } + } + } + } + """); + var edited = new[] { "auth.profiles" }; + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + editor.RootElement, edited, stored.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + editor.RootElement, edited, stored.RootElement); + var profile = sent.GetProperty("auth").GetProperty("profiles").GetProperty("one"); + + Assert.Null(blocked); + Assert.Equal("two", profile.GetProperty("provider").GetString()); + Assert.Equal("__OPENCLAW_REDACTED__", profile.GetProperty("id").GetString()); + } + [Fact] public void DottedProviderKey_NonIdAccountsArray_RefusesAndKeepsTheMask() { From 6ad6ed0f3b253d7577701ac95db040bc4b0acd68 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 6 Oct 2026 14:45:46 -0700 Subject: [PATCH 27/28] fix(config): refuse a direct edit that replaces the gateway mask A credential loaded as __OPENCLAW_REDACTED__ and replaced with a legacy placeholder is refused on the exact edited path, the same as a parent edit. The mask stays out of the Channels sentinel set. A SecretRef id can still be replaced. Disposable OpenClaw 2026.9.6 on port 19242 accepted an unrelated baseUrl save that omitted apiKey. The stored secret still matched. The direct placeholder edit was not sent. Signed-off-by: Sebastien Tardif --- .../Services/ConfigEditorModel.cs | 14 ++++- .../ConfigEditorModelTests.cs | 58 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs index e599cf748a..56afef14cd 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs @@ -218,8 +218,15 @@ public static JsonElement OmitUntouchedRedactionSentinels( { var loadedSentinel = baseDocument.ValueKind == JsonValueKind.String && ChannelConfigPatchBuilder.IsRedactionSentinel(baseDocument.GetString()); + // __OPENCLAW_REDACTED__ stays out of the Channels sentinel set. + // A direct edit of a credential that replaces that mask with a + // legacy placeholder is the same refusal as the parent-object edit. if (edited.Contains(path)) - return loadedSentinel ? path : null; + { + return loadedSentinel || LoadedCredentialIsNativeMask(baseDocument, path) + ? path + : null; + } if (HasAncestorEdit(edited, path) && IsCredentialPath(path) && @@ -575,6 +582,11 @@ private static bool CredentialArrayIsIdKeyed(JsonElement baseRoot, string proper return ArrayIsIdKeyed(array); } + private static bool LoadedCredentialIsNativeMask(JsonElement loaded, string path) => + IsCredentialPath(path) && + loaded.ValueKind == JsonValueKind.String && + ChannelConfigPatchBuilder.IsNativeGatewayRedactionMask(loaded.GetString()); + private static bool LoadedValueIsSentinel(JsonElement loaded) => loaded.ValueKind == JsonValueKind.String && ChannelConfigPatchBuilder.IsRedactionSentinel(loaded.GetString()); diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs index 28f93a7faa..94549b2e66 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs @@ -1034,6 +1034,64 @@ public void GatewayRedactionMask_ParentEdit_RefusesAndKeepsTheDraft() Assert.Equal("https://draft.example", provider.GetProperty("baseUrl").GetString()); } + [Fact] + public void GatewayRedactionMask_DirectEdit_RefusesLegacyPlaceholder() + { + using var stored = JsonDocument.Parse(""" + { + "models": { + "providers": { + "custom": { + "apiKey": "__OPENCLAW_REDACTED__", + "baseUrl": "https://old.example" + } + } + } + } + """); + using var editor = JsonDocument.Parse(""" + { + "models": { + "providers": { + "custom": { + "apiKey": "***", + "baseUrl": "https://old.example" + } + } + } + } + """); + var edited = new[] { "models.providers.custom.apiKey" }; + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + editor.RootElement, edited, stored.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + editor.RootElement, edited, stored.RootElement); + var provider = sent.GetProperty("models").GetProperty("providers").GetProperty("custom"); + + Assert.Equal("models.providers.custom.apiKey", blocked); + Assert.Equal("***", provider.GetProperty("apiKey").GetString()); + Assert.Equal("https://old.example", provider.GetProperty("baseUrl").GetString()); + } + + [Fact] + public void GatewayRedactionMask_DirectSecretRefIdEdit_KeepsTheReplacement() + { + using var stored = JsonDocument.Parse(""" + { "auth": { "profiles": { "one": { "id": "__OPENCLAW_REDACTED__" } } } } + """); + using var editor = JsonDocument.Parse(""" + { "auth": { "profiles": { "one": { "id": "***" } } } } + """); + var edited = new[] { "auth.profiles.one.id" }; + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + editor.RootElement, edited, stored.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + editor.RootElement, edited, stored.RootElement); + + Assert.Null(blocked); + Assert.Equal("***", sent.GetProperty("auth").GetProperty("profiles").GetProperty("one").GetProperty("id").GetString()); + } + [Fact] public void GatewayRedactionMask_SecretRefProviderEdit_KeepsTheMaskedId() { From a8fe27d6016826c6dc3dd293ad608abcae23091a Mon Sep 17 00:00:00 2001 From: "bakudies@microsoft.com" Date: Tue, 20 Oct 2026 15:36:50 -0700 Subject: [PATCH 28/28] fix(config): allow ordinary mask literals in direct edits Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Services/ConfigEditorModel.cs | 2 +- .../ConfigEditorModelTests.cs | 36 +++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs index 56afef14cd..22ceae8549 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs @@ -223,7 +223,7 @@ public static JsonElement OmitUntouchedRedactionSentinels( // legacy placeholder is the same refusal as the parent-object edit. if (edited.Contains(path)) { - return loadedSentinel || LoadedCredentialIsNativeMask(baseDocument, path) + return (loadedSentinel && IsCredentialPath(path)) || LoadedCredentialIsNativeMask(baseDocument, path) ? path : null; } diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs index 94549b2e66..7cde6a8215 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs @@ -213,8 +213,10 @@ public void FindUneditedRedactionSentinel_IgnoresRedactedSubstring() Assert.Null(blocked); } - [Fact] - public void FindUneditedRedactionSentinel_BlocksEditedValueThatIsStillTheSentinel() + [Theory] + [InlineData("")] + [InlineData("*****")] + public void FindUneditedRedactionSentinel_BlocksEditedValueThatIsStillTheSentinel(string replacement) { using var document = JsonDocument.Parse(""" { @@ -228,7 +230,7 @@ public void FindUneditedRedactionSentinel_BlocksEditedValueThatIsStillTheSentine document.RootElement, new Dictionary { - ["channels.telegram.botToken"] = "", + ["channels.telegram.botToken"] = replacement, }); var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( @@ -239,6 +241,34 @@ public void FindUneditedRedactionSentinel_BlocksEditedValueThatIsStillTheSentine Assert.Equal("channels.telegram.botToken", blocked); } + [Theory] + [InlineData("***")] + [InlineData("*****")] + [InlineData("********")] + [InlineData("")] + [InlineData("[REDACTED]")] + [InlineData(" *** ")] + public void FindUneditedRedactionSentinel_AllowsDirectOrdinaryMaskLiteral(string replacement) + { + using var document = JsonDocument.Parse(""" + { "messages": { "responsePrefix": "***" }, "enabled": true } + """); + var changes = new Dictionary + { + ["messages.responsePrefix"] = replacement, + }; + + var updated = ConfigEditorModel.ApplyChanges(document.RootElement, changes); + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + updated, changes.Keys, document.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + updated, changes.Keys, document.RootElement); + + Assert.Null(blocked); + Assert.Equal(replacement, sent.GetProperty("messages").GetProperty("responsePrefix").GetString()); + Assert.True(sent.GetProperty("enabled").GetBoolean()); + } + [Fact] public void FindUneditedRedactionSentinel_OmitsNoIdArrayWhenEditUsedTheUnindexedPath() {