diff --git a/src/OpenClaw.Shared/ChannelConfigPatchBuilder.cs b/src/OpenClaw.Shared/ChannelConfigPatchBuilder.cs index 3dbdbf97b..4a0523815 100644 --- a/src/OpenClaw.Shared/ChannelConfigPatchBuilder.cs +++ b/src/OpenClaw.Shared/ChannelConfigPatchBuilder.cs @@ -60,6 +60,19 @@ public static class ChannelConfigPatchBuilder "********", }; + /// + /// 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. @@ -179,8 +192,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 0a6f4234d..24959714b 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/ConfigPage.xaml.cs @@ -550,6 +550,24 @@ private async Task OnSaveAsync() try { var updated = ConfigEditorModel.ApplyChanges(saveBase.Root, _pendingChanges); + var blockedPath = ConfigEditorModel.FindUneditedRedactionSentinel( + updated, + _pendingChanges.Keys, + saveBase.Root); + if (blockedPath != null) + { + ShowStatus( + L("ConfigPage_StatusSaveFailedTitle"), + Lf("ConfigPage_StatusSaveBlockedSentinelFormat", blockedPath), + InfoBarSeverity.Error); + return; + } + + 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 2d451ab42..4e561be91 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; @@ -191,6 +192,660 @@ public static JsonElement ApplyRelativeChanges( return value; } + 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) => + 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); + var arraysToDrop = new List(); + RemoveUntouchedRedactionSentinels( + 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(); + } + + private static string? FindBlockedRedactionSentinel( + JsonElement updated, + JsonElement baseDocument, + string path, + HashSet edited) => + FindBlockedRedactionSentinel(updated, baseDocument, path, edited, baseDocument, updated, null); + + private static string? FindBlockedRedactionSentinel( + JsonElement updated, + JsonElement baseDocument, + string path, + HashSet edited, + JsonElement baseRoot, + JsonElement submittedRoot, + string? outermostNonIdArray) + { + if (updated.ValueKind == JsonValueKind.Object) + { + 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 childBase = default(JsonElement); + if (baseObject.ValueKind == JsonValueKind.Object) + baseObject.TryGetProperty(property.Name, out childBase); + var hit = FindBlockedRedactionSentinel( + property.Value, childBase, childPath, edited, baseRoot, submittedRoot, outermostNonIdArray); + if (hit != null) + return hit; + } + } + 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()) + { + var childPath = $"{path}[{index}]"; + var childBase = default(JsonElement); + 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, submittedRoot, nextOuter); + if (hit != null) + return hit; + } + } + else if (updated.ValueKind == JsonValueKind.String && + ChannelConfigPatchBuilder.IsRedactionSentinel(updated.GetString())) + { + 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 && IsCredentialPath(path)) || LoadedCredentialIsNativeMask(baseDocument, path) + ? path + : null; + } + + 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 + // whose entries lack stable ids is replaced wholesale, so an indexed + // edit inside that array cannot drop the masked credential safely. + if (ShouldOmitUntouchedSentinel(path)) + { + if (outermostNonIdArray != null && EditedPathInsideArray(edited, outermostNonIdArray)) + return path; + + return null; + } + + if (!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.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, + HashSet edited, + JsonElement baseRoot, + JsonElement submittedRoot, + JsonElement loadedNode, + string? outermostNonIdArray, + ArrayLocation? outermostArray, + List 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) || + 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; + + if (HasAncestorEdit(edited, childPath) && !SameSentinel(loadedChild, text)) + continue; + + if (outermostArray is null) + removals.Add(property.Key); + 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, + childPath, + edited, + baseRoot, + submittedRoot, + loadedChild, + childOuter, + childArray, + arraysToDrop); + } + + foreach (var key in removals) + obj.Remove(key); + } + else if (node is JsonArray array) + { + 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) + 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, + 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) + { + 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 baseRoot, + JsonElement submittedRoot, + string credentialPath) + { + if (!credentialPath.Contains('[', StringComparison.Ordinal) || + baseRoot.ValueKind != JsonValueKind.Object || + submittedRoot.ValueKind != JsonValueKind.Object) + { + return null; + } + + var baseCurrent = baseRoot; + var submittedCurrent = submittedRoot; + var walked = ""; + foreach (var segment in ParsePath(credentialPath)) + { + 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; + } + + walked = walked.Length == 0 ? segment.Name : $"{walked}.{segment.Name}"; + if (segment.Index is not int arrayIndex) + { + baseCurrent = baseChild; + submittedCurrent = submittedChild; + continue; + } + + if (baseChild.ValueKind != JsonValueKind.Array || + submittedChild.ValueKind != JsonValueKind.Array || + (uint)arrayIndex >= (uint)submittedChild.GetArrayLength()) + { + return null; + } + + if (!ArrayIsIdKeyed(baseChild)) + return walked; + + var submittedItem = submittedChild[arrayIndex]; + if (!TryGetObjectId(submittedItem, out var id) || + !TryFindArrayItemById(baseChild, id, out var matched)) + { + return null; + } + + walked = $"{walked}[{arrayIndex.ToString(CultureInfo.InvariantCulture)}]"; + baseCurrent = matched; + submittedCurrent = submittedItem; + } + + 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; + if (IsAncestorEdit(path, arrayPath)) + return true; + } + + 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) + return false; + if (!arrayPath.StartsWith(editedPath, StringComparison.OrdinalIgnoreCase)) + return false; + + var boundary = arrayPath[editedPath.Length]; + return boundary is '.' or '['; + } + + private static bool SameSentinel(JsonElement loaded, string? submitted) => + loaded.ValueKind == JsonValueKind.String && + string.Equals(loaded.GetString(), submitted, StringComparison.Ordinal); + + 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 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()); + + 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 (!TryReadIdAware(baseRoot, submittedRoot, path, out var element)) + return false; + + return SameSentinel(element, submitted); + } + + private static bool LoadedStringIsRedactionSentinel( + JsonElement baseRoot, + JsonElement submittedRoot, + string path) + { + if (!TryReadIdAware(baseRoot, submittedRoot, path, out var element) || + element.ValueKind != JsonValueKind.String) + { + return false; + } + + 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; + 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; + 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; + } + } + private static void SetPath(JsonNode node, string dotPath, JsonNode? value) { var segments = dotPath.Split('.', StringSplitOptions.RemoveEmptyEntries); diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index e5d6511f3..c37982327 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -6441,6 +6441,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 32a877012..bbaf1891c 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -6434,6 +6434,9 @@ Assurez-vous que la passerelle est en cours d'exécution. Échec de l'enregistrement + + 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 b33238cbc..2ae960b10 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -6435,6 +6435,9 @@ Controleer of de gateway actief is. Opslaan mislukt + + 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 903869761..ea1ccd1a8 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw @@ -6410,6 +6410,9 @@ Certifique-se de que o Gateway está em execução. Falha ao salvar + + 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 1f0aa36de..e960a3c80 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -6434,6 +6434,9 @@ 保存失败 + + 保存已被阻止。{0} 中仍有脱敏占位符。该值未发送。 + 网关拒绝了配置更新。你的更改已保留。 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index a4bb1a4db..99570af26 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -6434,6 +6434,9 @@ 儲存失敗 + + 儲存已遭封鎖。{0} 中仍有遮罩預留文字。該值未傳送。 + 閘道拒絕了設定更新。您的變更已保留。 diff --git a/tests/OpenClaw.Shared.Tests/ChannelConfigPatchBuilderTests.cs b/tests/OpenClaw.Shared.Tests/ChannelConfigPatchBuilderTests.cs index 119a73284..976827f84 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() { diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs index 5f2c0c6e5..c03fd38f9 100644 --- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs +++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs @@ -134,6 +134,1334 @@ public void ApplyChanges_IgnoresUnsupportedPlainObjectValues() Assert.Equal("manual", updated.GetProperty("mode").GetString()); } + [Fact] + public void FindUneditedRedactionSentinel_OmitsUntouchedRedactedSibling() + { + 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" }, + document.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + updated, + new[] { "channels.telegram.botToken" }); + + 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] + 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" }, + document.RootElement); + + 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(), + document.RootElement); + + Assert.Null(blocked); + } + + [Theory] + [InlineData("")] + [InlineData("*****")] + public void FindUneditedRedactionSentinel_BlocksEditedValueThatIsStillTheSentinel(string replacement) + { + using var document = JsonDocument.Parse(""" + { + "channels": { + "telegram": { "botToken": "", "enabled": true } + } + } + """); + + var updated = ConfigEditorModel.ApplyChanges( + document.RootElement, + new Dictionary + { + ["channels.telegram.botToken"] = replacement, + }); + + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + updated, + new[] { "channels.telegram.botToken" }, + document.RootElement); + + 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() + { + using var document = JsonDocument.Parse(""" + { + "channels": { + "slack": { "accounts": [ { "token": "" } ] } + } + } + """); + + var edited = new[] { "channels.slack.accounts.token" }; + var blocked = ConfigEditorModel.FindUneditedRedactionSentinel( + document.RootElement, + edited, + document.RootElement); + var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + document.RootElement, + edited); + + Assert.Null(blocked); + Assert.False(sent.GetProperty("channels").GetProperty("slack").TryGetProperty("accounts", out _)); + } + + [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_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 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 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 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_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() + { + 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() + { + 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()); + } + + [Fact] + public void FindUneditedRedactionSentinel_OmitsUntouchedArrayApiKeyButKeepsSiblingMask() + { + using var document = JsonDocument.Parse(""" + { + "channels": { + "slack": { + "enabled": false, + "accounts": [ { "label": "", "token": "", "apiKey": "", "api_key": "" } ] + } + } + } + """); + + 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()); + Assert.False(sent.GetProperty("channels").GetProperty("slack").TryGetProperty("accounts", out _)); + + using var siblingOnly = JsonDocument.Parse(""" + { + "channels": { + "slack": { + "accounts": [ { "label": "" } ] + } + } + } + """); + + var siblingBlocked = ConfigEditorModel.FindUneditedRedactionSentinel( + 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(""" + { + "channels": { + "slack": { + "accounts": [ { "note": "", "api_key": "" } ] + } + } + } + """); + + var snakeBlocked = ConfigEditorModel.FindUneditedRedactionSentinel( + snake.RootElement, + Array.Empty(), + snake.RootElement); + var snakeSent = ConfigEditorModel.OmitUntouchedRedactionSentinels( + snake.RootElement, + Array.Empty()); + + Assert.Null(snakeBlocked); + Assert.False(snakeSent.GetProperty("channels").GetProperty("slack").TryGetProperty("accounts", out _)); + } + + [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()); + Assert.False(sent.GetProperty("channels").GetProperty("slack").TryGetProperty("accounts", out _)); + + 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.TryGetProperty("accounts", out _)); + } + + [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()); + } + + [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()); + } + + [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.Equal("accounts[0].token", blocked); + Assert.Equal("", sent.GetProperty("accounts")[0].GetProperty("token").GetString()); + Assert.Equal("renamed", sent.GetProperty("accounts")[1].GetProperty("label").GetString()); + } + + [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 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 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() + { + 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() + { + 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 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() + { + 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": "", "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 + /// 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; + } + } + [Fact] public void ReadBindingRoutes_UsesTheParsedEnvelope() {