Repository navigation
Conversation
- Block config.patch when an unedited field still holds a gateway redaction sentinel - Allow a field the user replaced - Do not treat the substring redacted inside ordinary text as a sentinel Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
|
🦞👀 Pull request received. I will update this pull request when review starts. ClawSweeper review completeClawSweeper finished reviewing this revision. The review result is being finalized. |
|
Codex review: needs maintainer review before merge. Reviewed October 7, 2026, 4:53 AM ET / 08:53 UTC (Revision 34). ClawSweeper reviewWhat this changesThe Config editor refuses unsafe legacy secret-placeholder edits, preserves the draft, omits untouched credential masks, and adds translated feedback and regression tests. Merge readiness✅ Ready for maintainer review This PR remains useful and is ready for normal landing review. Earlier findings and the latest wording request are resolved; current-head page and Gateway evidence supports the changed behavior, with no remaining actionable blocker identified. Priority: P2 Review scores
Verification
How this fits togetherThe Windows Config page loads configuration from the Gateway and stages local edits. Its save model prepares a patch before the Gateway validates and persists it. flowchart TD
A[Gateway configuration snapshot] --> B[Config page draft]
C[User edits] --> B
B --> D[Placeholder safety check]
D -->|Unsafe edit| E[Show field and retain draft]
D -->|Allowed edit| F[Omit untouched credential masks]
F --> G[Gateway validates and saves]
Before mergeNone. Agent review detailsSecurityNone. Review metrics
Technical reviewBest possible solution: Keep the local defensive guard while preserving Gateway-native restoration, SecretRef validation, safe unrelated saves, and recoverable drafts. Do we have a high-confidence way to reproduce the issue? Yes. Current main sends a directly staged legacy placeholder without this local guard; the branch's exact-head real-page evidence demonstrates refusal before sending and retention of the draft. This review did not execute a current-main reproduction. Is this the best way to solve the issue? Yes. The guard acts at the existing save owner, preserves native Gateway semantics, and addresses the earlier container-edit regressions without adding configuration or a competing save path. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning medium; reviewed against 94006b3672a0. LabelsLabel changes: No label changes. Label justifications:
EvidenceWhat I checked:
Likely related people:
Rating scale
Overall follows the weaker of proof and patch quality. Workflow
HistoryReview history (33 earlier review cycles; latest 8 shown)
|
Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
|
Maintainer disposition at head |
|
Global triage: HOLD_FOR_AUTHOR. Take confidence 10%; recommendation confidence 95%; effort small-to-medium; risk high. Reviewed exact head Owner: |
…ited Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
|
a8418f9 blocks a final Local filter FindUneditedRedactionSentinel: 5 passed. A live WinUI refused save and allowed save were not run. |
Neighboring Config page status strings are translated in the same
change. This sentence follows that pattern in French, Dutch,
Brazilian Portuguese, and both Chinese locales. The {0} path
placeholder stays. The locale parity test passes.
Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
|
The locale test failed because |
Config save no longer refuses the whole document because another field still holds a gateway mask. Untouched masks are omitted from the patch so a merge keeps the stored secret. A save is refused only when the edited value is still a loaded placeholder, or when the placeholder sits in an array that cannot be omitted. A literal mask typed into a field that was not redacted is sent. Tests: FindUneditedRedactionSentinel 6 passed. ./build.ps1 exit 0. Shared 4099 passed, 32 skipped. Tray 3070 passed. Five source contract tests still fail on an LF checkout because they expect CRLF snippets. That mismatch is the separate line-ending issue. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
An untouched array entry equal to a mask blocked every other Config edit. A list such as labels keeps that text and no longer blocks the save. A credential field inside an array, such as an account token, still blocks until it is replaced. Tests: FindUneditedRedactionSentinel 7 passed. ./build.ps1 exit 0. Shared 4099 passed, 32 skipped. Tray 3071 passed. Five source contract tests still fail on an LF checkout because they expect CRLF snippets. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
An untouched accounts[0].apiKey redaction sentinel was not treated as a credential, so a later save could send the placeholder. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
…ing the save An unrelated Config save stopped when an array still held a redacted token, apiKey, or privateKey. Those untouched values are now left out of the patch. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
…ithout ids An untouched masked credential inside an array whose entries have no id cannot be stripped out of config.patch. Gateway replaces that array and rejects a patch that drops a stored entry. An indexed edit inside that array now stays on the machine and names the credential path. An unrelated edit omits the whole array, so the stored entries remain. Arrays whose entries all have an id still omit only the masked field. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
|
@SebTardif Holding head
For these same two inputs, the merge-base save owner ( Please add regressions using the actual parent object/array keys emitted by the editor. Also cover unchanged IDs versus an ID rename/add/remove with retained masks before relying on ID-keyed omission. Then capture an allowed unrelated save/reload and a refused unsafe edit through the isolated Config page and real Gateway. No Config page or Gateway was launched for this review. There was no real user data mutation, credential exposure, or proof-pool pass. No maintainer source patch, commit, push, or merge was made. The PR evidence section records exact-head validation and the source-only proof limits. Both Codex and Claude independently flagged the whole-container literal-omission issue. The accepted source blocker was independently reproduced; their other suggestions were not blindly applied. In particular, current public upstream Final exact-head closeout: full build passed; Shared 4,099 passed / 32 skipped / 0 failed; Tray 3,083 passed / 0 failed; focused Config/localization 23 passed; focused connection owner 199 passed. An intermediate required Tray run had three access-denied reads in untouched source files; those three passed a focused rerun, then the full build and both required suites passed again. No source or ACL workaround was used. |
|
@SebTardif Your head The new loaded-value lookup introduces a separate literal-key regression. For a whole-parent JSON edit, a property such as Three exact production-owner checks were prior I prepared a small local maintainer repair. It carries the corresponding baseline The 16 added regression/control cases were 11 passed / 5 failed before, then 16 passed after. They cover normal/dotted/bracketed keys, parent provider edits, an existing ID-keyed row, ordinary changed literals, explicit credential replacement and missing baseline. The actual same-task Opus 5.5/Astra pair independently reviewed the same scanner-admitted patch: both judged the bounded repair correct with no introduced repair defect. Their inherited ID/protocol concerns are not silently converted into new persisted-data claims. Local working-tree validation, not a published-head claim:
Please apply this patch to your original branch and update its head so we can verify fresh final-head CI. The patch applies cleanly to exact Patch SHA-256: Separate remaining gate: no actual changed Config-page save/reload against a task-owned real Gateway was run. Your current evidence also explicitly records that absence. Source checks, the modelled Gateway test and green CI do not substitute for it. There is no claimed secret exposure or actual user-data mutation. We have not launched another fixture, used a real profile, or bypassed isolation controls. The applyable two-file patch follows. It is limited to the provenance fix and its regression tests: diff --git a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs
index 59673ad3..53d519a0 100644
--- a/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs
+++ b/src/OpenClaw.Tray.WinUI/Services/ConfigEditorModel.cs
@@ -141,7 +141,7 @@ internal static class ConfigEditorModel
var edited = new HashSet<string>(editedPaths, StringComparer.OrdinalIgnoreCase);
var arraysToDrop = new HashSet<string>(StringComparer.Ordinal);
- RemoveUntouchedRedactionSentinels(node, "", edited, baseDocument, arraysToDrop);
+ RemoveUntouchedRedactionSentinels(node, "", edited, baseDocument, baseDocument, arraysToDrop);
foreach (var arrayPath in arraysToDrop.OrderBy(static path => path.Length))
RemovePropertyAtPath(node, arrayPath);
@@ -265,6 +265,7 @@ internal static class ConfigEditorModel
JsonNode node,
string path,
HashSet<string> edited,
+ JsonElement baseDocument,
JsonElement baseRoot,
HashSet<string> arraysToDrop)
{
@@ -274,6 +275,11 @@ internal static class ConfigEditorModel
foreach (var property in obj)
{
var childPath = string.IsNullOrEmpty(path) ? property.Key : $"{path}.{property.Key}";
+ // Keys can contain dots or brackets, so provenance follows the JSON tree, not display paths.
+ var childBase = default(JsonElement);
+ if (baseDocument.ValueKind == JsonValueKind.Object)
+ baseDocument.TryGetProperty(property.Key, out childBase);
+
if (property.Value is JsonValue value &&
value.TryGetValue<string>(out var text) &&
ChannelConfigPatchBuilder.IsRedactionSentinel(text) &&
@@ -281,13 +287,14 @@ internal static class ConfigEditorModel
ShouldOmitUntouchedSentinel(childPath))
{
if (!IsCredentialPath(childPath) &&
- !LoadedStringIsRedactionSentinel(baseRoot, childPath))
+ !(childBase.ValueKind == JsonValueKind.String &&
+ ChannelConfigPatchBuilder.IsRedactionSentinel(childBase.GetString())))
{
continue;
}
if (HasAncestorEdit(edited, childPath) &&
- !UnchangedLoadedSentinel(baseRoot, childPath, text))
+ !SameSentinel(childBase, text))
{
continue;
}
@@ -302,7 +309,7 @@ internal static class ConfigEditorModel
}
if (property.Value is not null)
- RemoveUntouchedRedactionSentinels(property.Value, childPath, edited, baseRoot, arraysToDrop);
+ RemoveUntouchedRedactionSentinels(property.Value, childPath, edited, childBase, baseRoot, arraysToDrop);
}
foreach (var key in removals)
@@ -313,7 +320,12 @@ internal static class ConfigEditorModel
for (var index = 0; index < array.Count; index++)
{
if (array[index] is JsonNode item)
- RemoveUntouchedRedactionSentinels(item, $"{path}[{index}]", edited, baseRoot, arraysToDrop);
+ {
+ var childBase = default(JsonElement);
+ if (baseDocument.ValueKind == JsonValueKind.Array && index < baseDocument.GetArrayLength())
+ childBase = baseDocument[index];
+ RemoveUntouchedRedactionSentinels(item, $"{path}[{index}]", edited, childBase, baseRoot, arraysToDrop);
+ }
}
}
}
@@ -456,51 +468,6 @@ internal static class ConfigEditorModel
loaded.ValueKind == JsonValueKind.String &&
string.Equals(loaded.GetString(), submitted, StringComparison.Ordinal);
- private static bool UnchangedLoadedSentinel(JsonElement root, string path, string submitted)
- {
- if (!TryReadPath(root, path, out var element))
- return false;
-
- return SameSentinel(element, submitted);
- }
-
- private static bool LoadedStringIsRedactionSentinel(JsonElement root, string path)
- {
- if (!TryReadPath(root, path, out var element) ||
- element.ValueKind != JsonValueKind.String)
- {
- return false;
- }
-
- return ChannelConfigPatchBuilder.IsRedactionSentinel(element.GetString());
- }
-
- private static bool TryReadPath(JsonElement root, string path, out JsonElement element)
- {
- element = root;
- foreach (var segment in ParsePath(path))
- {
- if (element.ValueKind != JsonValueKind.Object ||
- !element.TryGetProperty(segment.Name, out element))
- {
- return false;
- }
-
- if (segment.Index is not int arrayIndex)
- continue;
-
- if (element.ValueKind != JsonValueKind.Array ||
- (uint)arrayIndex >= (uint)element.GetArrayLength())
- {
- return false;
- }
-
- element = element[arrayIndex];
- }
-
- return true;
- }
-
private static void RemovePropertyAtPath(JsonNode root, string propertyPath)
{
JsonNode? current = root;
diff --git a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs
index 6bfc7e57..cfa3d93f 100644
--- a/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs
+++ b/tests/OpenClaw.Tray.Tests/ConfigEditorModelTests.cs
@@ -494,6 +494,146 @@ public class ConfigEditorModelTests
Assert.Equal("*****", sent.GetProperty("metadata").GetProperty("token").GetString());
}
+ [Theory]
+ [InlineData("token")]
+ [InlineData("auth.token")]
+ [InlineData("auth[legacy].token")]
+ public void ApplyChanges_ParentObjectEdit_OmitsLoadedMaskByLiteralPropertyKey(string key)
+ {
+ using var stored = JsonDocument.Parse(JsonSerializer.Serialize(new
+ {
+ metadata = new Dictionary<string, string> { [key] = "***", ["label"] = "before" },
+ }));
+ var replacement = JsonSerializer.SerializeToElement(
+ new Dictionary<string, string> { [key] = "***", ["label"] = "after" });
+ var changes = new Dictionary<string, object?> { ["metadata"] = replacement };
+ var updated = ConfigEditorModel.ApplyChanges(stored.RootElement, changes);
+
+ var blocked = ConfigEditorModel.FindUneditedRedactionSentinel(updated, changes.Keys, stored.RootElement);
+ var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels(updated, changes.Keys, stored.RootElement);
+
+ Assert.Null(blocked);
+ Assert.False(sent.GetProperty("metadata").TryGetProperty(key, out _));
+ Assert.Equal("after", sent.GetProperty("metadata").GetProperty("label").GetString());
+ }
+
+ [Theory]
+ [InlineData("custom")]
+ [InlineData("custom.openai")]
+ [InlineData("custom[legacy]")]
+ public void ApplyChanges_ParentProviderEdit_OmitsLoadedMaskByLiteralProviderKey(string key)
+ {
+ using var stored = JsonDocument.Parse(JsonSerializer.Serialize(new
+ {
+ models = new
+ {
+ providers = new Dictionary<string, object>
+ {
+ [key] = new { apiKey = "***", baseUrl = "https://example.invalid/before" },
+ },
+ },
+ }));
+ var replacement = JsonSerializer.SerializeToElement(new Dictionary<string, object>
+ {
+ [key] = new { apiKey = "***", baseUrl = "https://example.invalid/after" },
+ });
+ var changes = new Dictionary<string, object?> { ["models.providers"] = replacement };
+ var updated = ConfigEditorModel.ApplyChanges(stored.RootElement, changes);
+
+ var blocked = ConfigEditorModel.FindUneditedRedactionSentinel(updated, changes.Keys, stored.RootElement);
+ var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels(updated, changes.Keys, stored.RootElement);
+
+ Assert.Null(blocked);
+ var provider = sent.GetProperty("models").GetProperty("providers").GetProperty(key);
+ Assert.False(provider.TryGetProperty("apiKey", out _));
+ Assert.Equal("https://example.invalid/after", provider.GetProperty("baseUrl").GetString());
+ }
+
+ [Theory]
+ [InlineData("label")]
+ [InlineData("display.label")]
+ [InlineData("display[legacy].label")]
+ public void ApplyChanges_ParentObjectEdit_KeepsChangedOrdinaryMaskByLiteralKey(string key)
+ {
+ using var stored = JsonDocument.Parse(JsonSerializer.Serialize(new
+ {
+ metadata = new Dictionary<string, string> { [key] = "***" },
+ }));
+ var replacement = JsonSerializer.SerializeToElement(
+ new Dictionary<string, string> { [key] = "*****" });
+ var changes = new Dictionary<string, object?> { ["metadata"] = replacement };
+ var updated = ConfigEditorModel.ApplyChanges(stored.RootElement, changes);
+
+ var blocked = ConfigEditorModel.FindUneditedRedactionSentinel(updated, changes.Keys, stored.RootElement);
+ var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels(updated, changes.Keys, stored.RootElement);
+
+ Assert.Null(blocked);
+ Assert.Equal("*****", sent.GetProperty("metadata").GetProperty(key).GetString());
+ }
+
+ [Fact]
+ public void ApplyChanges_ArrayEdit_OmitsLoadedMaskByLiteralPropertyKey()
+ {
+ using var stored = JsonDocument.Parse("""
+ {"accounts":[{"id":"desk","auth.token":"***","label":"before"}]}
+ """);
+ using var replacement = JsonDocument.Parse("""
+ [{"id":"desk","auth.token":"***","label":"after"}]
+ """);
+ var changes = new Dictionary<string, object?> { ["accounts"] = replacement.RootElement.Clone() };
+ var updated = ConfigEditorModel.ApplyChanges(stored.RootElement, changes);
+
+ var blocked = ConfigEditorModel.FindUneditedRedactionSentinel(updated, changes.Keys, stored.RootElement);
+ var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels(updated, changes.Keys, stored.RootElement);
+
+ Assert.Null(blocked);
+ Assert.False(sent.GetProperty("accounts")[0].TryGetProperty("auth.token", out _));
+ Assert.Equal("desk", sent.GetProperty("accounts")[0].GetProperty("id").GetString());
+ Assert.Equal("after", sent.GetProperty("accounts")[0].GetProperty("label").GetString());
+ }
+
+ [Theory]
+ [InlineData("auth.token")]
+ [InlineData("auth[legacy].token")]
+ public void ApplyChanges_ParentObjectEdit_KeepsExplicitCredentialReplacementByLiteralKey(string key)
+ {
+ using var stored = JsonDocument.Parse(JsonSerializer.Serialize(new
+ {
+ metadata = new Dictionary<string, string> { [key] = "***" },
+ }));
+ var replacement = JsonSerializer.SerializeToElement(
+ new Dictionary<string, string> { [key] = "test-auth-token" });
+ var changes = new Dictionary<string, object?> { ["metadata"] = replacement };
+ var updated = ConfigEditorModel.ApplyChanges(stored.RootElement, changes);
+
+ var blocked = ConfigEditorModel.FindUneditedRedactionSentinel(updated, changes.Keys, stored.RootElement);
+ var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels(updated, changes.Keys, stored.RootElement);
+
+ Assert.Null(blocked);
+ Assert.Equal("test-auth-token", sent.GetProperty("metadata").GetProperty(key).GetString());
+ }
+
+ [Theory]
+ [InlineData("display.label", false)]
+ [InlineData("display[legacy].label", false)]
+ [InlineData("auth.token", true)]
+ [InlineData("auth[legacy].token", true)]
+ public void ApplyChanges_ParentObjectEdit_DistinguishesMissingBaselineLiteralAndCredential(
+ string key, bool credential)
+ {
+ using var stored = JsonDocument.Parse("""{"metadata":{}}""");
+ var replacement = JsonSerializer.SerializeToElement(
+ new Dictionary<string, string> { [key] = "***" });
+ var changes = new Dictionary<string, object?> { ["metadata"] = replacement };
+ var updated = ConfigEditorModel.ApplyChanges(stored.RootElement, changes);
+
+ var blocked = ConfigEditorModel.FindUneditedRedactionSentinel(updated, changes.Keys, stored.RootElement);
+ var sent = ConfigEditorModel.OmitUntouchedRedactionSentinels(updated, changes.Keys, stored.RootElement);
+
+ Assert.Equal(credential ? $"metadata.{key}" : null, blocked);
+ Assert.Equal("***", sent.GetProperty("metadata").GetProperty(key).GetString());
+ }
+
[Fact]
public void FindUneditedRedactionSentinel_AllowsLiteralMaskInANonSecretField()
{ |
A parent edit of a reordered account list compared the placeholder with the loaded row at the same index. Match the loaded row by id. An unchanged secret is omitted so the Gateway keeps it. A new id with a mask is refused and stays in the draft. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
A real secret changed to a mask under an id-keyed row was omitted, so the Gateway kept the old secret. Refuse that edit and keep the draft. Look up loaded values by the JSON property name, so a provider key such as custom.openai is not split on the dot. When an outer id-keyed list is reordered, classify each row's nested array from that same id. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
A provider key such as custom.openai was split on the dot while deciding whether its accounts array could drop a mask. The array boundary is now recorded while walking the real property, so an unchanged mask in that non-id array is refused and stays in the draft. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
An unrelated edit queued custom.openai.accounts for removal, then the path splitter looked for a root property named custom and left the mask in the patch. Removal now uses the object and key found while walking. The accounts array is removed and the mask is not sent. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
config.get replaces a stored secret with __OPENCLAW_REDACTED__. That exact mask was not a local sentinel, so saving another field could write the mask over the stored secret. An unrelated edit now omits it. A parent edit that changes it is refused and the draft stays. Checked with ./build.ps1, Shared tests (4100 passed, 32 skipped), and Tray tests (3095 passed). Five Tray failures are existing source-contract checks that look for CRLF in files this change does not touch. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
__OPENCLAW_REDACTED__ is the mask config.get already restores before writing. Treating it as a legacy sentinel made a Channels save refuse when another channel still showed that mask. Channels now sends the mask through. The Config editor still omits that mask on an untouched credential such as apiKey. A SecretRef id keeps the mask in the patch, so a provider change still reaches the gateway's identity check. ./build.ps1 passed. Shared tests: 4100 passed, 32 skipped. Tray tests: 3096 passed. Five failures are the existing CRLF source-contract checks in files this commit does not touch. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
A credential loaded as __OPENCLAW_REDACTED__ and replaced with a legacy placeholder is refused on the exact edited path, the same as a parent edit. The mask stays out of the Channels sentinel set. A SecretRef id can still be replaced. Disposable OpenClaw 2026.9.6 on port 19242 accepted an unrelated baseUrl save that omitted apiKey. The stored secret still matched. The direct placeholder edit was not sent. Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
|
Global triage: HOLD_FOR_AUTHOR. Take confidence 20%; recommendation confidence 98%; effort medium; risk medium. Reviewed exact head Author: resolve the current durable review findings, add focused regressions, and request a fresh exact-head review. This is a triage classification, not a merge action. No branch changes were made. |
What Problem This Solves
Fixes: a direct Config edit can put a legacy placeholder where the gateway shows its native secret mask. The gateway already restores
__OPENCLAW_REDACTED__before it writes. This client guard refuses that placeholder before the patch is sent, and an untouched mask is left out of the patch.User Impact
User impact: a direct edit that replaces the native mask with a legacy placeholder such as
***is refused, and the draft stays. An unrelated Config save leaves__OPENCLAW_REDACTED__out of the outgoing patch.Why This Change Was Made
config.getreplaces a stored secret with the exact mask__OPENCLAW_REDACTED__. The gateway restores that mask before it writes. It is not a legacy placeholder, so a Channels save still sends it when another channel shows the mask.The Config editor omits the mask only on an untouched credential such as
apiKey. A direct edit of that credential to a legacy placeholder is refused locally, the same as a parent edit. A SecretRefidis not a credential, so replacing that id still stays in the patch.5fa003d1had put the mask in the shared sentinel set. That blocked a Channels save and dropped a SecretRefid.fffc8472separated those cases.6ad6ed0fteaches the Config loaded-placeholder check to recognize the native mask without putting it back in the Channels set.Evidence
Head
6ad6ed0f3b253d7577701ac95db040bc4b0acd68. A console calledConfigEditorModel.FindUneditedRedactionSentinelandConfigEditorModel.OmitUntouchedRedactionSentinelson the document returned byconfig.get. Those are the two callsConfigPage.OnSaveAsyncmakes afterApplyChanges.The peer was a disposable OpenClaw 2026.9.6 (
eb377ac)gateway runon loopback port 19242, with its own config file. It was not the installed gateway on port 18789. The productwindows-wsl-gateway-e2esuite was not run. That process was stopped after the trace. Ports 18789 and 19241 were still listening.Allowed save, edited path
models.providers.custom.baseUrl. The outgoing document omittedapiKey.config.patchwas sent with{ raw, baseHash }.Direct edit, edited path
models.providers.custom.apiKey, replacement***. Noconfig.patchwas sent. A laterconfig.getstill returned the gateway mask for that key, and the on-disk secret still matched the value seeded before the save. The secret is not printed here.disk_matches_secretcompares the on-disk provider secret with the value seeded before the saves.The same head was then opened in the isolated tray Config page against a new disposable OpenClaw 2026.9.6 gateway on loopback port 19244, auth mode none, paired in this session. The page subtitle named the disposable config file. The loaded provider showed Api Key as the gateway mask and Base Url
https://old.example.Allowed save: Base Url was set to
https://page.exampleand Save Changes was clicked. The page said "Configuration saved and the gateway connection is back. Refreshing the latest config." The Base Url field read backhttps://page.example. The on-disk secret still matched, models length stayed 1, and the model id stayedm1.Refused save: Api Key was set to
***and Save Changes was clicked. The page stayed on 1 unsaved change and showed:The JSON diff kept the draft:
The on-disk secret still matched and the base URL stayed
https://page.example. The window was captured with PrintWindow. GitHub asset upload returned HTTP 400Invalid name for request, so the image is not linked here. The status text above is what that window showed, including Connected, Unsaved changes, and Save Changes still enabled. A SecretRef id was not opened on the page.Change Type
Scope
winnodeRequired proof pools
windows-winui-interactive: Config page save and local refusal. Verified for this head: isolated Config page on6ad6ed0f, Save Changes for Base Url, then Save Changes for an Api Key placeholder. The refusal banner and the unsaved draft are quoted above. Not verified / blocked: the screenshot file was not uploaded (HTTP 400). A SecretRef id was not opened on the page.windows-wsl-gateway-e2e: real gateway preservation of a stored secret. Verified for this head:config.getand oneconfig.patchagainst disposable OpenClaw 2026.9.6 on port 19242, as quoted above. Not verified / blocked: the product WSL setup, pairing, and recovery suite was not run. Port 18789 was left untouched.Validation
Worktree
C:\Users\sebta\wt-wn-1500,OPENCLAW_REPO_ROOTset to that path. Windows x64../build.ps1: passed. Shared, Cli, WinNodeCli, SetupEngine, and WinUI.dotnet test .\tests\OpenClaw.Shared.Tests\OpenClaw.Shared.Tests.csproj --no-restore: passed, 4100 passed / 32 skipped / 0 failed (4132 total). That suite includesBuildPatch_AllowsNativeGatewayMaskInAnotherChannel.dotnet test .\tests\OpenClaw.Tray.Tests\OpenClaw.Tray.Tests.csproj --no-restoreon this tree: 3098 passed / 0 skipped / 5 failed (3103 total). The five failures are existing source-contract checks that search for CRLF inConnectionPage.xaml.cs, diagnostics, local AI setup, and speech input. Those files are not in this commit. The working tree copies are LF. This is fix(tests): tray source checks fail when the checkout is LF #1518 on this host.GatewayRedactionMask_DirectEdit_RefusesLegacyPlaceholder,GatewayRedactionMask_DirectSecretRefIdEdit_KeepsTheReplacement,GatewayRedactionMask_ParentEdit_RefusesAndKeepsTheDraft,GatewayRedactionMask_SecretRefProviderEdit_KeepsTheMaskedId, andGatewayRedactionMask_UnrelatedEdit_OmitsApiKeyAndKeepsModels(5 passed).6ad6ed0fwas still starting when this body was written. Required CI on the parentfffc8472had passed, including Core, Tray, UI, Setup, Network, and Revocation.Real Behavior Proof
eb377ac) onws://127.0.0.1:19242.6ad6ed0f3b253d7577701ac95db040bc4b0acd68.config.getandconfig.patchon port 19242, then the isolated Config page on port 19244. Save Changes after editing Base Url, then Save Changes after editing Api Key to***.https://page.exampleand kept the seeded secret. The refused page save showed the blocked pathmodels.providers.custom.apiKey, kept the unsaved draft, and left the on-disk secret in place.No. The window was captured. Upload returned HTTP 400, so the link is not in this body. The visible status text is quoted above.windows-wsl-gateway-e2esuite. The installed gateway on port 18789 was not the peer. A SecretRef id was not opened on the page. The Channels sentinel set is unchanged, and the shared suite that covers it passed.Security Impact
NoYesNoNoNoYes, explain the risk and mitigation: an outgoing Config patch omits__OPENCLAW_REDACTED__when that credential was not edited. The disposable gateway kept the stored secret after that omission. A direct edit that replaces the mask with a legacy placeholder is refused before a patch is sent.Compatibility and Migration
YesNoNoN/AReview Conversations