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()
{