Skip to content

fix(config): restore SecretRef id field when redacted (#44357)#44455

Closed
claw-explorer wants to merge 1 commit into
openclaw:mainfrom
claw-explorer:fix/secretref-redacted-validation
Closed

fix(config): restore SecretRef id field when redacted (#44357)#44455
claw-explorer wants to merge 1 commit into
openclaw:mainfrom
claw-explorer:fix/secretref-redacted-validation

Conversation

@claw-explorer

Copy link
Copy Markdown

Summary

Fixes Control UI config.set failures when SecretRef fields are present (#44357). Previously, the gateway would redact SecretRef id values to __OPENCLAW_REDACTED__ during config.get, but the restore logic didn't handle SecretRef objects specifically, causing validation to reject the sentinel value.

Root Cause

When a SecretRef object is redacted:

{ source: "exec", provider: "default", id: "path/to/secret" }
   redact
{ source: "exec", provider: "default", id: "__OPENCLAW_REDACTED__" }

The restore logic checked for value === REDACTED_SENTINEL, but for SecretRef objects, the value is the entire object, not the sentinel. The sentinel is inside the id field, so it was never restored before validation.

Fix

Added logic to both restoreRedactedValuesWithLookup and restoreRedactedValuesGuessing to:

  1. Detect SecretRef shapes (isSecretRefShape)
  2. Check if the id field contains __OPENCLAW_REDACTED__
  3. Restore the id from the original config

This happens before Zod validation, so the restored SecretRef passes validation.

Testing

Added two test cases:

  1. Restoration test: Verifies SecretRef id restoration preserves structure during redact → restore round-trip
  2. User modification test: Verifies users can still explicitly change SecretRef id values (not using the sentinel)

Impact

  • Before: Users with SecretRef-based credentials couldn't save ANY config changes through Control UI (UI was read-only)
  • After: SecretRef fields are transparently preserved during config.set, Control UI works normally

Workaround (No Longer Needed)

Users previously had to edit ~/.openclaw/openclaw.json directly instead of using the Control UI.

Closes #44357

## Summary

Fixes Control UI config.set failures when SecretRef fields are present.
Previously, the gateway would redact SecretRef id values to
__OPENCLAW_REDACTED__ during config.get, but the restore logic didn't
handle SecretRef objects specifically, causing validation to reject the
sentinel value.

## Root Cause

When a SecretRef object is redacted:

The restore logic checked for `value === REDACTED_SENTINEL`, but for
SecretRef objects, the `value` is the entire object, not the sentinel.
The sentinel is inside the `id` field, so it was never restored.

## Fix

Added logic to both `restoreRedactedValuesWithLookup` and
`restoreRedactedValuesGuessing` to detect SecretRef shapes and restore
the `id` field specifically when it contains the sentinel.

## Testing

Added two test cases:
1. Verifies SecretRef id restoration preserves structure
2. Verifies users can still explicitly change SecretRef id values

Closes openclaw#44357
@greptile-apps

greptile-apps Bot commented Mar 12, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a real bug where SecretRef objects ({ source, provider, id }) had their id field redacted to __OPENCLAW_REDACTED__ during config.get, but the restoration logic in restoreRedactedValues only handled top-level string sentinels — not the sentinel embedded inside a SecretRef — causing Zod validation to reject the round-tripped value and rendering Control UI read-only for any user with SecretRef-based credentials.

The fix is implemented correctly for the primary code path (restoreRedactedValuesWithLookup with a populated lookup). Two areas worth tidying up:

  • Consistency: The new SecretRef block in restoreRedactedValuesGuessing (and the !matched fallback in restoreRedactedValuesWithLookup) does not mirror the isSensitivePath / !isExplicitlyNonSensitivePath guards that the adjacent string-sentinel restoration carries. The practical risk is low (non-sensitive paths are never redacted), but the inconsistency could confuse future maintainers or create subtle behaviour if the sentinel value is user-supplied.
  • Code duplication: The detection-and-restore pattern is copy-pasted verbatim in three separate locations; a small helper function would make future changes (e.g., adding the guard above) easier to apply uniformly.

Confidence Score: 4/5

  • The fix correctly resolves the bug for the common case; two minor style/consistency issues exist but do not affect correctness or security for normal usage.
  • The core logic is sound: SecretRef objects are identified by shape, the sentinel in id is detected, and the original id is spliced back in before validation. The existing test suite covers the primary lookup-based restore path and the explicit-user-change case. The small inconsistency in sensitivity guards within restoreRedactedValuesGuessing and the !matched fallback is low-risk (sentinel values at non-sensitive paths are never produced by the redaction code), and the code duplication is a maintainability concern rather than a correctness one.
  • The guessing path in src/config/redact-snapshot.ts (lines 703–715) deserves a second look to confirm the missing sensitivity guards are acceptable or to add them for consistency.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/config/redact-snapshot.ts
Line: 703-715

Comment:
**Missing sensitivity guard on SecretRef restoration in guessing path**

In `restoreRedactedValuesGuessing`, the string-sentinel restoration (lines 697–702) is guarded by `!isExplicitlyNonSensitivePath(hints, [...]) && isSensitivePath(path)`, but the new SecretRef restoration block immediately below has no equivalent guard. This means any incoming object that happens to match the `isSecretRefShape` predicate (i.e. has `source: string` and `id: "__OPENCLAW_REDACTED__"`) at *any* path — including non-sensitive ones — will have its `id` silently replaced from the original config, even if the path is explicitly marked non-sensitive in `hints`.

The same inconsistency exists in the `!matched` branch of `restoreRedactedValuesWithLookup` (lines 651–662), where the string check includes `!markedNonSensitive && isSensitivePath(path)` but the new SecretRef block does not.

In practice the impact is low (a non-sensitive path would never have `id` set to `REDACTED_SENTINEL` by the redaction logic itself), but for consistency with the surrounding code the guard should be mirrored:

```
    } else if (typeof value === "object" && value !== null) {
      // Check if this is a SecretRef with redacted id field (#44357)
      const incomingObj = value as Record<string, unknown>;
      if (
        !isExplicitlyNonSensitivePath(hints, [path, wildcardPath]) &&
        isSensitivePath(path) &&
        isSecretRefShape(incomingObj) &&
        incomingObj.id === REDACTED_SENTINEL
      ) {
        const originalObj = toObjectRecord(orig[key]);
        if (isSecretRefShape(originalObj)) {
          result[key] = { ...incomingObj, id: originalObj.id };
        } else {
          result[key] = restoreRedactedValuesGuessing(value, orig[key], path, hints);
        }
      } else {
        result[key] = restoreRedactedValuesGuessing(value, orig[key], path, hints);
      }
    }
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/config/redact-snapshot.ts
Line: 630-641

Comment:
**Triplicated SecretRef restoration logic**

The same SecretRef detection + restoration block appears verbatim in three separate places:
1. `restoreRedactedValuesWithLookup` — matched branch (here, lines 630–641)
2. `restoreRedactedValuesWithLookup``!matched` / guessing fallback branch (lines 651–662)
3. `restoreRedactedValuesGuessing` (lines 704–715)

Extracting it into a small helper (e.g. `restoreSecretRefId`) would reduce the surface area for future divergence and make each call site easier to read:

```typescript
function restoreSecretRefId(
  incomingObj: Record<string, unknown>,
  originalValue: unknown,
): Record<string, unknown> | null {
  if (!isSecretRefShape(incomingObj) || incomingObj.id !== REDACTED_SENTINEL) {
    return null;
  }
  const originalObj = toObjectRecord(originalValue);
  if (!isSecretRefShape(originalObj)) {
    return null;
  }
  return { ...incomingObj, id: originalObj.id };
}
```

Each call site then becomes a simple `restoreSecretRefId(incomingObj, orig[key]) ?? <fallback>` check, making it easy to apply (or audit) the sensitivity guard mentioned in the adjacent comment uniformly.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: c9c73ec

Comment on lines 703 to +715
} else if (typeof value === "object" && value !== null) {
result[key] = restoreRedactedValuesGuessing(value, orig[key], path, hints);
// Check if this is a SecretRef with redacted id field (#44357)
const incomingObj = value as Record<string, unknown>;
if (isSecretRefShape(incomingObj) && incomingObj.id === REDACTED_SENTINEL) {
const originalObj = toObjectRecord(orig[key]);
if (isSecretRefShape(originalObj)) {
result[key] = { ...incomingObj, id: originalObj.id };
} else {
result[key] = restoreRedactedValuesGuessing(value, orig[key], path, hints);
}
} else {
result[key] = restoreRedactedValuesGuessing(value, orig[key], path, hints);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing sensitivity guard on SecretRef restoration in guessing path

In restoreRedactedValuesGuessing, the string-sentinel restoration (lines 697–702) is guarded by !isExplicitlyNonSensitivePath(hints, [...]) && isSensitivePath(path), but the new SecretRef restoration block immediately below has no equivalent guard. This means any incoming object that happens to match the isSecretRefShape predicate (i.e. has source: string and id: "__OPENCLAW_REDACTED__") at any path — including non-sensitive ones — will have its id silently replaced from the original config, even if the path is explicitly marked non-sensitive in hints.

The same inconsistency exists in the !matched branch of restoreRedactedValuesWithLookup (lines 651–662), where the string check includes !markedNonSensitive && isSensitivePath(path) but the new SecretRef block does not.

In practice the impact is low (a non-sensitive path would never have id set to REDACTED_SENTINEL by the redaction logic itself), but for consistency with the surrounding code the guard should be mirrored:

    } else if (typeof value === "object" && value !== null) {
      // Check if this is a SecretRef with redacted id field (#44357)
      const incomingObj = value as Record<string, unknown>;
      if (
        !isExplicitlyNonSensitivePath(hints, [path, wildcardPath]) &&
        isSensitivePath(path) &&
        isSecretRefShape(incomingObj) &&
        incomingObj.id === REDACTED_SENTINEL
      ) {
        const originalObj = toObjectRecord(orig[key]);
        if (isSecretRefShape(originalObj)) {
          result[key] = { ...incomingObj, id: originalObj.id };
        } else {
          result[key] = restoreRedactedValuesGuessing(value, orig[key], path, hints);
        }
      } else {
        result[key] = restoreRedactedValuesGuessing(value, orig[key], path, hints);
      }
    }
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/config/redact-snapshot.ts
Line: 703-715

Comment:
**Missing sensitivity guard on SecretRef restoration in guessing path**

In `restoreRedactedValuesGuessing`, the string-sentinel restoration (lines 697–702) is guarded by `!isExplicitlyNonSensitivePath(hints, [...]) && isSensitivePath(path)`, but the new SecretRef restoration block immediately below has no equivalent guard. This means any incoming object that happens to match the `isSecretRefShape` predicate (i.e. has `source: string` and `id: "__OPENCLAW_REDACTED__"`) at *any* path — including non-sensitive ones — will have its `id` silently replaced from the original config, even if the path is explicitly marked non-sensitive in `hints`.

The same inconsistency exists in the `!matched` branch of `restoreRedactedValuesWithLookup` (lines 651–662), where the string check includes `!markedNonSensitive && isSensitivePath(path)` but the new SecretRef block does not.

In practice the impact is low (a non-sensitive path would never have `id` set to `REDACTED_SENTINEL` by the redaction logic itself), but for consistency with the surrounding code the guard should be mirrored:

```
    } else if (typeof value === "object" && value !== null) {
      // Check if this is a SecretRef with redacted id field (#44357)
      const incomingObj = value as Record<string, unknown>;
      if (
        !isExplicitlyNonSensitivePath(hints, [path, wildcardPath]) &&
        isSensitivePath(path) &&
        isSecretRefShape(incomingObj) &&
        incomingObj.id === REDACTED_SENTINEL
      ) {
        const originalObj = toObjectRecord(orig[key]);
        if (isSecretRefShape(originalObj)) {
          result[key] = { ...incomingObj, id: originalObj.id };
        } else {
          result[key] = restoreRedactedValuesGuessing(value, orig[key], path, hints);
        }
      } else {
        result[key] = restoreRedactedValuesGuessing(value, orig[key], path, hints);
      }
    }
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +630 to +641
// Check if this is a SecretRef with redacted id field (#44357)
const incomingObj = value as Record<string, unknown>;
if (isSecretRefShape(incomingObj) && incomingObj.id === REDACTED_SENTINEL) {
const originalObj = toObjectRecord(orig[key]);
if (isSecretRefShape(originalObj)) {
result[key] = { ...incomingObj, id: originalObj.id };
} else {
result[key] = restoreRedactedValuesWithLookup(value, orig[key], lookup, candidate, hints);
}
} else {
result[key] = restoreRedactedValuesWithLookup(value, orig[key], lookup, candidate, hints);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Triplicated SecretRef restoration logic

The same SecretRef detection + restoration block appears verbatim in three separate places:

  1. restoreRedactedValuesWithLookup — matched branch (here, lines 630–641)
  2. restoreRedactedValuesWithLookup!matched / guessing fallback branch (lines 651–662)
  3. restoreRedactedValuesGuessing (lines 704–715)

Extracting it into a small helper (e.g. restoreSecretRefId) would reduce the surface area for future divergence and make each call site easier to read:

function restoreSecretRefId(
  incomingObj: Record<string, unknown>,
  originalValue: unknown,
): Record<string, unknown> | null {
  if (!isSecretRefShape(incomingObj) || incomingObj.id !== REDACTED_SENTINEL) {
    return null;
  }
  const originalObj = toObjectRecord(originalValue);
  if (!isSecretRefShape(originalObj)) {
    return null;
  }
  return { ...incomingObj, id: originalObj.id };
}

Each call site then becomes a simple restoreSecretRefId(incomingObj, orig[key]) ?? <fallback> check, making it easy to apply (or audit) the sensitivity guard mentioned in the adjacent comment uniformly.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/config/redact-snapshot.ts
Line: 630-641

Comment:
**Triplicated SecretRef restoration logic**

The same SecretRef detection + restoration block appears verbatim in three separate places:
1. `restoreRedactedValuesWithLookup` — matched branch (here, lines 630–641)
2. `restoreRedactedValuesWithLookup``!matched` / guessing fallback branch (lines 651–662)
3. `restoreRedactedValuesGuessing` (lines 704–715)

Extracting it into a small helper (e.g. `restoreSecretRefId`) would reduce the surface area for future divergence and make each call site easier to read:

```typescript
function restoreSecretRefId(
  incomingObj: Record<string, unknown>,
  originalValue: unknown,
): Record<string, unknown> | null {
  if (!isSecretRefShape(incomingObj) || incomingObj.id !== REDACTED_SENTINEL) {
    return null;
  }
  const originalObj = toObjectRecord(originalValue);
  if (!isSecretRefShape(originalObj)) {
    return null;
  }
  return { ...incomingObj, id: originalObj.id };
}
```

Each call site then becomes a simple `restoreSecretRefId(incomingObj, orig[key]) ?? <fallback>` check, making it easy to apply (or audit) the sensitivity guard mentioned in the adjacent comment uniformly.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c9c73ecef2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +653 to +656
if (isSecretRefShape(incomingObj) && incomingObj.id === REDACTED_SENTINEL) {
const originalObj = toObjectRecord(orig[key]);
if (isSecretRefShape(originalObj)) {
result[key] = { ...incomingObj, id: originalObj.id };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Respect sensitive:false before restoring SecretRef IDs

This branch restores incomingObj.id from the original config whenever it sees a SecretRef sentinel, but it runs even in the !matched path where markedNonSensitive was already computed. That means paths explicitly marked sensitive:false can no longer keep a literal __OPENCLAW_REDACTED__ value for SecretRef id, unlike other fields (the existing non-sensitive override behavior), so user input is silently overwritten during config.set instead of preserved.

Useful? React with 👍 / 👎.

@joshavant

Copy link
Copy Markdown
Contributor

Thanks @claw-explorer for the focused fix direction on this path.

This is now fully covered by #58044 (merged), which landed the restore-path hardening in the final code path with additional guardrails and regression coverage.

Closing this PR as superseded by #58044.

@joshavant joshavant closed this Mar 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BUG: Control UI config.set fails on SecretRef fields — gateway rejects its own redacted placeholder

2 participants