Skip to content

🤫 feat: Better Secret Redaction - #99

Merged
danny-avila merged 24 commits into
mainfrom
feat-masked-secret-fields-ui
Jul 29, 2026
Merged

🤫 feat: Better Secret Redaction#99
danny-avila merged 24 commits into
mainfrom
feat-masked-secret-fields-ui

Conversation

@dustinhealy

@dustinhealy dustinhealy commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

packages/api/src/admin/secrets.ts was introduced by the already-merged Encrypted Langfuse Fanout Config feature (github.com/danny-avila/pull/14107) and was hardcoded to exactly one field, langfuse.secretKey: encrypt-at-rest on write, redact on read, preserve-on-omit.

Every other credential-shaped field in the admin-editable config schema had no such protection. Because BASE_ONLY_CONFIG_SECTIONS is empty, the whole TCustomConfig document is admin-writable and readable through the admin config API, so any operator who stored a literal (non-${ENV_VAR}) key on the base config document got that plaintext value back verbatim in the API response to any admin holding read:configs:<section>.

This PR replaces the single hardcoded field with a registry (CONFIG_SECRET_FIELDS) and generalizes every function in the module (encryptConfigSecretFields, encryptConfigSecrets, getConfigSecretMutationPaths, getConfigSecretInputError, isConfigSecretAncestorPath, isConfigSecretDescendantPath, preserveConfigSecrets, redactConfigSecrets, plus a new getConfigSecretSections and a runtime resolveConfigSecret) to operate over arbitrary dot-paths at any depth. langfuse.secretKey semantics are preserved byte-for-byte (display companion, always-encrypt, array-section stripping); its existing tests stay green.

Two behaviors distinguish the new fields from langfuse:

  • Env-placeholder exemption. Fields conventionally set to ${ENV_VAR} references keep those references stored and returned as plain, visible values (an env-var name is not a secret, and admins need to see which var is wired). A literal value in the same field is always encrypted. langfuse.secretKey keeps its original no-exemption behavior.
  • Runtime decryption at the consumer. Stored values become v3: ciphertext, so the consumers that read them from the merged app config must decrypt. resolveConfigSecret (decrypt, else resolve ${ENV_VAR}, else pass through) is wired into the speech STT/TTS services; the Mistral OCR auth loader uses decryptConfigSecret. webSearch and the assistants endpoints need no runtime change (see scout table).

Change Type

  • Bug fix (non-breaking change which fixes an issue) — closes a plaintext-secret exposure on admin config reads
  • New feature (non-breaking change which adds functionality) — encryption-at-rest for the newly-registered fields

Scout list — every credential-shaped field in the config schema

Paths are in packages/data-provider/src/config.ts / mcp.ts. "Registered" = added to CONFIG_SECRET_FIELDS.

Registered (encrypt-at-rest + redact-on-read)

Dot-path Schema Env-placeholder exempt Runtime decrypt wired Why sensitive
langfuse.secretKey z.string().optional() no (unchanged) already existed (langfuse/utils.ts) Langfuse private key; the pre-existing registered field
ocr.apiKey z.string().optional().default('${OCR_API_KEY}') yes files/mistral/crud.ts loadAuthConfig Mistral OCR key
speech.tts.openai.apiKey z.string() yes STTService/TTSService via resolveConfigSecret OpenAI TTS key
speech.tts.azureOpenAI.apiKey z.string() yes TTSService Azure OpenAI TTS key
speech.tts.elevenlabs.apiKey z.string() yes TTSService ElevenLabs key
speech.tts.localai.apiKey z.string().optional() yes TTSService LocalAI key
speech.stt.openai.apiKey z.string() yes STTService OpenAI STT key
speech.stt.azureOpenAI.apiKey z.string() yes STTService Azure OpenAI STT key
webSearch.serperApiKey z.string().optional().default('${SERPER_API_KEY}') yes none needed¹ Serper key
webSearch.searxngApiKey ...default('${SEARXNG_API_KEY}') yes none needed¹ SearXNG key
webSearch.firecrawlApiKey ...default('${FIRECRAWL_API_KEY}') yes none needed¹ Firecrawl key
webSearch.tavilyApiKey ...default('${TAVILY_API_KEY}') yes none needed¹ Tavily key
webSearch.jinaApiKey ...default('${JINA_API_KEY}') yes none needed¹ Jina key
webSearch.cohereApiKey ...default('${COHERE_API_KEY}') yes none needed¹ Cohere reranker key
endpoints.assistants.apiKey z.string().optional() yes none needed² Assistants provider key
endpoints.azureAssistants.apiKey z.string().optional() yes none needed² Azure Assistants provider key

¹ webSearch credentials are resolved by packages/api/src/web/web.ts loadWebSearchAuth, which only ever collects ${VAR} references (extractWebSearchEnvVars skips any non-placeholder string). A literal webSearch key never reached the runtime auth path pre-PR, so encrypting the literal at rest changes nothing at runtime — the protection is purely the at-rest/redaction fix. Documented so a reviewer knows this is deliberate, not a forgotten decrypt.

² endpoints.assistants.apiKey / endpoints.azureAssistants.apiKey are never read from the merged app config at runtime — api/server/services/Endpoints/{assistants,azureAssistants}/initialize.js source the key from process.env.ASSISTANTS_API_KEY / AZURE_ASSISTANTS_API_KEY via EndpointService.js. The config-schema field is a config-document field only, so registering it gives at-rest + redaction protection with no runtime decrypt.

Considered sensitive-looking but deliberately NOT registered

Path Reason excluded
mcpServers.<name>.apiKey.key, .oauth.client_secret The primary admin write path is the MCP registry (ServerConfigsDB), which already encrypts these with encryptV2. A v3: value written through the generic config API could not be decrypted by MCP runtime, so registering here would break MCP. Residual: the generic config API still round-trips these two in plaintext (secondary write path) — a known scoped gap, not closed here to avoid the v2/v3 scheme conflict.
mcpServers.<name>.headers, .oauth_headers, .env z.record(z.string(), z.string()) maps of mixed-sensitivity values (some entries are auth tokens, some are not). Redacting the whole record would blank non-secret entries; per-entry classification is out of scope for a dot-path registry.
endpoints.custom[].apiKey, endpoints.azureOpenAI.groups[].apiKey Array-of-objects. Preserve-on-omit has no stable identity for a dot-path registry, and azure group keys are resolved in packages/data-provider/src/azure.ts, shared with the frontend bundle, which cannot import the backend decrypt.
endpoints.*.headers / additionalHeaders Same mixed-sensitivity record rationale as MCP headers.
turnstile.siteKey Public Cloudflare site key, designed for browser exposure. The Turnstile secret is env-only (TURNSTILE_SECRET), not in this schema.
langfuse.publicKey, langfuse.displaySecretKey Public identifier and the deliberately-non-secret masked display value.
skillSync.github.sources[].token Schema refinement forces a ${VAR} reference and rejects literals, so a literal secret cannot be stored.
agents.remoteApi.auth.apiKey Not a key — an { enabled: boolean } toggle.
anthropic.vertex.serviceKeyFile A filesystem path, not the secret value.

Testing

Automated

  • packages/api/src/admin/secrets.spec.ts — unit coverage for every registry function over the new fields; the original langfuse cases are unchanged and green.
  • packages/api/src/admin/secrets.integration.spec.ts (new) — drives the real createAdminConfigHandlers against a real mongodb-memory-server Config collection with real createModels/createMethods (hydrated Mongoose documents, not mocks). For all 16 registered fields it asserts, per field: dotted-patch write is encrypted at rest and redacted from the response; object-valued upsert is encrypted at rest and redacted from getConfig/listConfigs; an unrelated write preserves the stored ciphertext; a redacted read round-tripped back through a full upsert does not clobber the secret; explicit-empty clears it; v3: submissions are rejected. Plus env-placeholder pass-through, legacy-plaintext redaction, and getBaseConfig YAML-literal redaction.
  • Every regression test was proven to catch the regression: reverting secrets.ts to the origin/main single-field version makes the new-field cases fail (e.g. the endpoints.assistants.apiKey cases: 5 fail), restoring makes them pass.
  • packages/api: admin + langfuse + files/mistral suites — 670/670 pass. Speech services (api workspace, STTService.spec.js) — 29/29 pass. tsc --noEmit clean; eslint clean; prettier --check clean. Full packages/api suite: 7929 pass; the 202 failures are entirely pre-existing Redis/stream *_integration.spec.ts suites (no local Redis), none in changed areas.

Live verification (real backend + real MongoDB + real admin-panel frontend)

Isolated instance: ephemeral MongoDB on :27057, backend on :3092, admin panel on :3006, so it doesn't collide with other local dev.

  • Before (origin/main): admin saves a literal ocr.apiKey and webSearch.serperApiKey through the Configuration UI, reloads, and the plaintext values come straight back into the form fields. Confirmed at rest in Mongo (overrides.ocr.apiKey: "sk-mistral-LEAKED-...") and in the reloaded UI. The vulnerability is real, not theoretical.
  • Real save (patched): admin enters new secrets and saves; the diff-confirmation dialog and the save succeed with no error.
  • After (patched): the same fields at rest in Mongo are now v3:... ciphertext, and after reload the UI form fields are empty (redacted) — no plaintext, no ciphertext, zero leaks anywhere in the rendered form.
  • Feature still usable: the runtime resolveConfigSecret (the exact helper wired into speech; decryptConfigSecret for OCR) was run against the actual ciphertext stored in Mongo and recovered the exact secret the admin typed (sk-mistral-PATCHED-..., sk-serper-PATCHED-...); ${ENV_VAR} references still resolve. The encryption round-trip is genuinely usable by the backend, not just accepted by the UI.

Before — plaintext leak on origin/main:

ocr.apiKey
01-before-ocr-apikey-plaintext-leak

After — redacted on the patched branch:

ocr.apiKey
Screenshot 2026-07-29 at 9 43 52 AM

Test Configuration

  • Local MongoDB, packages/api + packages/data-schemas rebuilt (npm run build) so the api/ workspace picks up the compiled dist/.

Checklist

  • My code adheres to this project's style guidelines
  • I have performed a self-review of my own code (plus an adversarial subagent review pass)
  • My changes do not introduce new warnings
  • I have written tests demonstrating that my changes are effective
  • Local unit tests pass with my changes

The backend redacts registered secret fields (ocr.apiKey, webSearch API keys, speech tts/stt apiKeys, endpoints.assistants.apiKey) from admin config reads and returns a display<Key> sibling (first6...last4) in their place. The Configuration UI previously rendered these as empty inputs indistinguishable from never-configured fields.

String fields whose parent value carries a non-empty display companion now render the masked value read-only with an explicit Replace flow. Display companion paths map back to their real secret paths so the configured indicator and Reset affordance survive redaction. Save payloads exclude display companion leaf paths and strip display companion strings nested inside object values, so a masked display value can never be submitted as a real secret. Schema extraction also filters display companion fields so they never render as editable inputs once the shared config schema ships them.
Scope-mode configured paths drive the override indicator and reset affordance while editing a profile; without the mapping a redacted scope secret lost both, same as the base-config case.
@dustinhealy dustinhealy changed the title feat: Better Secret Redaction 🤫 feat: Better Secret Redaction Jul 29, 2026
@dustinhealy

Copy link
Copy Markdown
Contributor Author

@codex review

@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: a656e24201

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/server/config.ts Outdated
Comment thread src/components/configuration/utils.ts Outdated
Comment thread src/components/configuration/fields/SecretField.tsx Outdated
Comment thread src/components/configuration/FieldRenderer.tsx Outdated
Comment thread src/components/configuration/ConfigPage.tsx
filterSecretDisplayFields was imported from the @/utils/secrets subpath
instead of the @/utils barrel. SavePayload was declared locally in
utils.ts instead of src/types, where all local interfaces belong per
this repo's import conventions.
A masked secret field's Replace state was local useState, disconnected
from the parent's editedValues/touchedPaths clears. Discarding, saving,
confirming a scope change, or resetting base config left an in-progress
(possibly untyped) replacement visible instead of reverting to the
masked display.

editSessionId is bumped on each of those clears and threaded down to
key SecretField's remount, so a stale replacement session never
survives past the edit session that started it.
…erers

ProvidersRenderer and ProviderSection destructured and forwarded a
fixed prop list that predated pendingResets and editSessionId, so
fields rendered under a named endpoint provider (e.g.
endpoints.assistants.apiKey, a registered secret) never received
either. A pending reset on such a field kept showing the masked value
instead of reverting to an editable input, and the SecretField
remount fix from the previous commit didn't reach this subtree either.
…review

previewChangedPaths was passed the raw scopeChangedPaths from the
backend, which reports a changed secret at its display path (e.g.
endpoints.openAI.displayApiKey), not the real field path FieldRenderer
renders at. A secret changed at scope level never got a "changed"
indicator in scope-preview mode and was dropped entirely by the
changed-only filter.

scopeConfiguredPaths already ran scopeChangedPaths through
mapSecretDisplayPaths for the configured/db-override indicators;
scopeChangedPathsMapped applies the same mapping for the preview-diff
path, preserving null when there are no scope changes.
@dustinhealy

Copy link
Copy Markdown
Contributor Author

@codex re-review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: 9310323b7c

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@dustinhealy
dustinhealy marked this pull request as ready for review July 29, 2026 04:29
Comment thread src/components/configuration/ConfigPage.tsx
Comment thread src/components/configuration/ConfigPage.tsx
…ention

The backend (danny-avila/LibreChat#14509) renamed its masked-companion
fields from a display<Field> prefix to a <field>Preview suffix, since
the prefix form could collide with real display-label config fields
like modelDisplayLabel and required a hand-declared displayPath per
registry entry instead of a mechanical derivation.

Renamed every consumer (secrets.ts, ConfigPage, FieldRenderer, the
save-payload stripping in utils.ts, and extractSchemaTree's field
filter) to match, plus all test fixtures. Two tests that exercised the
old prefix-shaped false-positive case (a real field that merely looked
display-shaped) were updated to exercise the equivalent suffix-shaped
case instead, since the collision risk moved with the convention.
Replace and Cancel were bare colored text with no icon, no resting
border, and no focus-visible indicator, and unlike every other
per-field action in this UI (e.g. ConfigRow's Reset) they carried no
accessible name distinguishing which field's secret they act on.

Added a pencil/cross icon matching the Reset convention, a resting
border plus hover fill so the control reads as a button rather than a
link at every state (WCAG 1.4.11 non-text contrast for UI component
boundaries), an explicit focus-visible outline, and a field-name-aware
aria-label via two new locale keys so multiple secret fields on one
page remain distinguishable to screen readers.
Comment thread src/components/configuration/FieldRenderer.tsx Outdated
Comment thread src/components/configuration/fields/SecretField.tsx
Comment thread src/components/configuration/ConfigPage.tsx Outdated
editSessionId only incremented inside the "discard unsaved edits"
branch, so opening a SecretField's Replace input without typing
anything and then switching scope left editedValues empty and skipped
the bump entirely. React kept the same SecretField instance across the
scope change, leaving the untyped Replace input open against the newly
selected scope's data instead of reverting to the masked view.
Abandoning an in-progress Replace called onChange(path, undefined),
reusing the same signal handleResetField uses for a real reset —
but unlike handleResetField, this went through applyConfigEdit's
baseline-match diffing instead of writing the reset sentinel directly.
applyConfigEdit only treats undefined as a no-op when it matches the
baseline exactly; the base config baseline for a redacted secret is
always absent (undefined), but a scope-resolved baseline for a path
with no override at that scope isn't guaranteed to be. If it ever
reads back as '' instead, cancelling a replacement would register a
real pending reset, and a later save could delete the stored
credential without the admin choosing reset.

onDiscardField removes the path from editedValues/touchedPaths
directly, the same way the "Discard all" button already does, so
Cancel can never be misread as an intentional reset regardless of what
the baseline happens to contain. Threaded through ConfigTabContent,
FieldRenderer, and the provider-section renderers alongside the
existing onResetField prop.
handleImportAsProfile already strips preview values from imported
config before submitting, since a re-imported YAML export can carry
stale display*/*Preview companions from a previous read (or, from an
untrusted YAML source, spoofed ones). The base-config import path
skipped this and sent the parsed YAML straight through.

The backend's own registry-driven encrypt/preserve/redact protection
already covers this (upsertConfigOverrides runs unconditionally for
every principal, base included, so a spoofed or stale preview value
can't actually corrupt a real secret) — this closes the gap between
the two import paths for consistency and defense-in-depth rather than
a live vulnerability.
@dustinhealy

Copy link
Copy Markdown
Contributor Author

@codex re-review

@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: 14e793f73a

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/components/configuration/FieldRenderer.tsx
SecretField decided whether to show its input using only its own local
replacing state plus the current value. That state resets on remount
(e.g. switching config tabs and back), so an admin who clears a
replacement to an empty string and then remounts the field would see
the stale masked secret again, even though the empty string is still
queued and will clear the stored credential on save.

Thread this through the existing touchedPaths tracking instead of
relying on local component state alone, so the input stays visible for
any path with a queued edit regardless of remounts.
@dustinhealy

Copy link
Copy Markdown
Contributor Author

@codex re-review

Comment thread src/utils/secrets.ts

@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: af051ab854

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/utils/secrets.ts
Comment thread src/server/config.ts
Comment thread src/components/configuration/FieldRenderer.tsx Outdated
touchedPaths only ever grows within an edit session, but
applyConfigEdit removes a path from editedValues whenever a typed
value matches the active baseline. For scope-mode edits where a
redacted secret's baseline reads as an empty string, clearing a
replacement back to empty hits exactly that match and gets removed
from editedValues while staying in touchedPaths, and it can outlive a
clean scope change too since touchedPaths is only cleared when
editedValues is nonempty at the time.

Using touchedPaths for hasPendingEdit kept the field open and empty
with nothing queued to save. Deriving it from editedValues membership
instead reflects only what is actually pending.
…entries

renderInlineField, used for fields nested inside custom endpoint and
similar collection entries, always rendered a plain empty TextField
regardless of whether the field had a preview companion sibling. An
already-configured custom endpoint secret showed as unset with no
Replace flow, since the top-level masked-secret handling in
SingleFieldRenderer never applied to this rendering path.

Mirrors the existing top-level pattern: check for a sibling <field>Preview
value and render SecretField when the entry's real value is empty.
Editing an existing custom-endpoint entry saves a path like
endpoints.custom.0, so secretPathForPreviewPath constructed
endpoints.custom.0.apiKeyPreview against a schema path set that only
knows the index-free endpoints.custom.apiKey, and never recognized it
as a preview companion. The stripped save then reached
mergeIndexedArrayEntriesIntoBase, which spliced that one clean entry
into the full array fetched from /api/admin/config/base but sent the
untouched sibling entries' preview companions right back with it,
since that merge never re-stripped the array it built.

Fixes both ends: secretPathForPreviewPath now strips numeric segments
before matching against schema paths, and the indexed-array merge
strips preview companions from the array it constructs before sending
it as a save entry.
@dustinhealy

Copy link
Copy Markdown
Contributor Author

@codex re-review

Comment thread src/components/configuration/FieldRenderer.tsx Outdated

@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: 5cc8acba5a

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/components/configuration/sections/EndpointsRenderer.tsx
Comment thread src/components/configuration/FieldRenderer.tsx Outdated
…ed value

Cancel in renderInlineField called onChange(field.key, '') for a
masked secret inside an array/record entry, which queues an explicit
apiKey: '' onto the containing entry even if nothing was typed. That
entry no longer matches its baseline, so save would submit it and the
backend's empty-value handling clears the real stored secret.

Pass undefined instead: spread-merging it into the entry keeps the
key present with an undefined value, which JSON serialization drops
entirely, so a save either resolves to a true no-op (matches baseline)
or reaches the backend without an apiKey key at all, leaving the
existing secret untouched either way.
ProviderSection and ProvidersRenderer forwarded onDiscardField/reset/
session props to SingleFieldRenderer but not editedValues, so
hasPendingEdit always computed false for provider secret fields. A
cleared-but-queued provider secret would revert to the stale masked
display after remounting (e.g. a tab switch), the same gap already
fixed for the top-level renderer.
Comment thread src/components/configuration/FieldRenderer.tsx
@dustinhealy

Copy link
Copy Markdown
Contributor Author

@codex re-review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

Reviewed commit: 76b455ccbc

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

renderInlineField's masked-secret branch treated any empty stringValue
as untouched, matching on stringValue alone the same way
SingleFieldRenderer did before its own hasPendingEdit fix. A queued
empty replacement (typed then cleared, or an in-progress remount)
could show the stale read-only mask again with the clear still queued
for save.

Derives hasPendingEdit from whether fieldValue is a defined string at
all, including '', versus absent/undefined for an untouched baseline,
and passes it into SecretField the same way the top-level renderer
does.
Comment thread src/components/configuration/FieldRenderer.tsx
Comment thread src/components/configuration/ConfigPage.tsx
renderInlineField's masked-secret SecretField instances omitted the
editSessionId-based key SingleFieldRenderer uses, so local Replace
mode could survive a global discard, a successful save, or a scope
change even though editedValues had already reverted for that path.

Threads editSessionId through the render-fields chain (ArrayObjectField,
RecordObjectField, and ObjectEntryCard's CollectionRenderFields call,
plus the custom-endpoint grouped-fields renderer) down to
renderInlineField, and uses it in SecretField's key the same way
SingleFieldRenderer already does.
serializedEditedValues and originalValuesForDialog serialized raw
editedValues/baseline entries for the confirm-save diff view without
the stripSecretPreviewValues pass buildSavePayload already applies.
An edited array or nested-object entry could show apiKeyPreview (and
similar companions) in the before/after YAML even though those keys
never reach the actual PATCH payload.
Comment thread src/components/configuration/FieldRenderer.tsx
Comment thread src/components/configuration/FieldRenderer.tsx
@danny-avila

Copy link
Copy Markdown
Contributor

Verified this branch against the merged backend (danny-avila/LibreChat#14509, squashed as f4e0888f1) and the rebased #14510 — naming and behavior line up, no changes needed here:

  • Naming matches: toSecretPreviewKey (${key}Preview) is exactly the convention the backend registry derives (previewPath = ${field.path}Preview), and no display* secret references remain on this branch.
  • Array secrets work: #14510 registers endpoints.custom[*].apiKey with an apiKeyPreview companion, which stripArrayIndices already handles (it uses that exact path as its worked example).
  • No librechat-data-provider bump required for correctness: the published 0.8.509 this branch pins carries none of the *Preview schema fields yet, but nothing here depends on that — preview values are read from API response data, and secretPathForPreviewPath anchors on the real secret paths, which do exist in the schema. filterSecretPreviewFields is a harmless no-op today and becomes load-bearing after a future bump (keeping preview fields from rendering as editable inputs), so the branch is correct both before and after.
  • Backend legacy handling: stored langfuse.displaySecretKey values are translated to secretKeyPreview on read and migrated on write, so this UI sees the new name even for not-yet-migrated documents.

Test run on 7efb148: 21 files, 796 tests passing (with NODE_ENV=development SESSION_SECRET=... per CI).

Cancel in renderInlineField always called onChange(field.key, undefined),
which updates the containing entry and gets recorded in editedValues
under the entry's flat path regardless of content, since flattenObject
never produces a flat key for a whole array entry to compare against.
Opening Replace and cancelling without ever typing anything marked the
page dirty even though the mask correctly reappeared, unlike the
top-level onDiscardField path which stays clean in the same scenario.

Only calls onChange when hasPendingEdit is already true, i.e. there is
a real queued value to discard.
Comment thread src/server/config.ts
… merges

mergeIndexedArrayEntriesForScope rebuilds a full array from base,
scope-override, and pending sources when a scope-profile save touches
an indexed array entry, but never re-stripped preview companions from
the result the way mergeIndexedArrayEntriesIntoBase already does for
base-config saves. Editing one scope-override array entry could PATCH
sibling entries still carrying masked apiKeyPreview strings from base
or scope overrides.

Exports getSchemaPathSet from config.ts and reuses it here, matching
the base-config fix.
Comment thread src/components/configuration/ConfigPage.tsx
…e entries

originalValuesForDialog only stripped a reconstructed value when the
edited path was absent from the flat baseline, but flattenObject never
descends into arrays, so a whole-array edit path like endpoints.custom
is itself a flat baseline key. The before side of the confirm-save
diff could still show sibling entries' apiKeyPreview companions for
that case, even though the after side was already stripped.

Strips the baseline value too when the edited path already exists in
the flat baseline.

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9592f12. Configure here.

Comment thread src/components/configuration/FieldRenderer.tsx
renderInlineField threads editSessionId to its own SecretField, but
nested object fields render through NestedGroupWithAddField's inner
InlineFieldRenderer, which never received it. A secret inside a nested
inline group (a field with children, rendered inside a collection
entry) could stay in an open Replace state across a discard, scope
change, or save.
@danny-avila
danny-avila merged commit 5abf308 into main Jul 29, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants