fix(desktop): stop Settings autosave from clobbering out-of-band config edits - #89645
fix(desktop): stop Settings autosave from clobbering out-of-band config edits#89645chelsealong wants to merge 3 commits into
Conversation
…ig edits ConfigSettingsInner seeds its local draft once from the config record and never re-seeds it while the page stays open, but every autosave PUT still sent the entire draft. Since PUT /api/config deep-merges onto disk, that degenerates into a full overwrite for every field the UI's schema knows about: if `hermes config set` (or another profile/session) changes a schema-known key like fallback_providers while Settings is open, the next autosave — triggered by editing any unrelated field — writes the stale seed-time value back over it. Diff the draft against the seed-time baseline and send only the changed branches, so an untouched key is never resent and the backend's deep-merge actually protects it.
…iped _denormalize_config_from_web only wrote model_context_length into the on-disk model dict inside the branch gated on `model` also being present in the payload. That was harmless when the frontend always sent the full config, but the prior commit switched Settings autosave to send only the diff (diffConfig), so editing the Context Window control alone omits `model` from the payload and the context-length edit is silently thrown away. The mirror case regressed too: editing `model` alone now omits model_context_length from the diff, and the old code treated that missing key the same as an explicit 0, wiping an existing context_length override that the user never touched. Track whether model_context_length was actually present in the payload and only mutate context_length when it was, independent of whether `model` also changed.
|
suggesting changes The configuration write-path security invariants remain intact, but one autosave correctness blocker remains: after a successful save, the autosave baseline is not advanced. A later edit that restores a field to its pre-save value is compared with the original page-load snapshot, so the revert can produce an empty patch and leave the saved value unchanged. Advance the baseline only after each accepted save, preserve request order for overlapping saves, and add regression coverage for edit, save, then revert. Security evidence:
Review setup: I reviewed a run-owned local rebase or patch replay against current GitHub Not checked:
Signed: GPT-5.6-luna-max in Codex |
Without this, diffConfig kept comparing against the page-load snapshot forever, so reverting a field to its original value produced an empty patch and left the earlier (now-stale) save on disk. Saves are now queued so an older in-flight request can't resolve after a newer one and re-advance the baseline with stale data.
|
Addressed: the autosave baseline now advances to the saved snapshot after each accepted save, so a later edit that reverts a field to its pre-save value is diffed against the actual on-disk state instead of the original page-load snapshot (previously that produced an empty patch and left the stale saved value on disk). Autosave requests are now queued so an older in-flight save can't resolve after a newer one and re-advance the baseline with stale data. Added a regression test ( |
Fixes #89597.
Problem
The issue reports that on Windows, the desktop Settings UI has priority over
config.yaml: an edit an agent makes viahermes config setgets silentlyreverted the next time Settings is touched, leaving no reliable
programmatic config path.
Two of the issue's three sub-problems are already resolved on
main:hermes config setparsing composite (list/mapping) values was fixed byfix(config): parse structured list/dict values in hermes config set (consolidates 8-PR cluster) #88163 (tracked separately in bug:
hermes config setstores composite values (lists/mappings) as strings #89561, closed as a duplicate of that merge).config.yamldirectly(
tools/file_tools.py::_check_sensitive_path) for a real security reason(it guards
approvals.modeand other exec-approval settings fromprompt-injected writes) —
hermes config setis the documented guardedpath, and it already works.
What's left, and what this PR fixes, is the actual clobbering mechanism —
and it isn't Windows-specific; the code path is shared across every
platform, so the issue title just reflects the reporter's environment.
Root cause
ConfigSettingsInner(apps/desktop/src/app/settings/config-settings.tsx)seeds its local editable draft once from the config record when Settings
opens, and deliberately does not re-seed it from background refetches while
the page stays open (
configSeededref — this is intentional, so in-progressedits aren't clobbered by a stale refetch).
The debounced autosave, however, always sent the entire draft object on
every save:
The backend (
hermes_cli/web_server.py::update_config) deep-merges theincoming body onto a fresh read of disk specifically so a field the frontend
doesn't know about (e.g.
custom_providers) survives an untouched roundtrip. But every schema-known field — including
fallback_providers, theexact field the issue names — is always present in the draft, so it's always
"explicitly sent," and the deep-merge degenerates into a full overwrite for
it.
Concretely: open Settings, have an agent run
hermes config set fallback_providers '[...]'in the meantime, then edit anyunrelated field in the still-open Settings page (e.g. toggle a switch on
another section). The debounced autosave fires 550ms later and writes the
seed-time value of
fallback_providersback to disk, discarding theagent's change — with no error, no conflict, nothing to signal it happened.
Fix
Snapshot the record at seed time (
configBaselineRef) and, on autosave,diff the current draft against that baseline (
diffConfig, new pure helperin
helpers.ts). Only the branches that actually changed are sent. Plainobjects are diffed recursively (so a sibling under the same top-level key
that the user never touched stays out of the patch too); arrays and scalars
are compared as whole values. A field nobody edited locally is now never
resent, so the backend's existing deep-merge genuinely protects it instead
of being handed a full copy of its own stale value.
configBaselineRefresets alongsideconfigSeededon a profile switch, soit always tracks "the record as of this page's most recent seed."
Follow-up: a hidden
model/model_context_lengthcouplingReview caught a real regression in the initial version of this fix.
modelandmodel_context_lengthare two virtual top-level fields that_normalize_config_for_websynthesizes from a single on-diskmodeldict(
model.defaultandmodel.context_length)._denormalize_config_from_webreverses that, but it only ever wrote
model_context_lengthback into themodel dict inside the branch gated on
modelalso being present in thepayload:
Under the old full-draft autosave this was never a problem —
modelwasalways present, so the branch always ran. Under
diffConfig, editing theContext Window control alone (a real field, one of only two in the Model
section) produces a patch containing only
model_context_length;modelis absent, the branch is skipped, and the edit is silently discarded before
it ever reaches disk.
The mirror case regressed too, more subtly: editing
modelalone nowomits
model_context_lengthfrom the diff. The old code treated a missingkey the same as an explicit
0and popped any existingcontext_lengthoff the disk model — so saving an unrelated model change would silently
wipe a context-length override the user never touched.
Fixed by tracking whether
model_context_lengthwas actually present inthe payload (
ctx_sent) and only mutatingcontext_lengthwhen it was,independent of whether
modelalso changed in the same payload. Added tworegression tests in
tests/hermes_cli/test_web_server.py(
TestModelContextLength) exercising the real (mocked-disk)_denormalize_config_from_webpath directly — one for each direction —and confirmed both fail on the pre-fix code (
KeyError) and pass after.Test plan
Added
describe('diffConfig', ...)toapps/desktop/src/app/settings/helpers.test.ts(5 cases): omits an untouchedtop-level key, includes a nested key only when it changed while leaving
siblings out, includes a brand-new key, returns
{}for an unchanged draft,and treats arrays as whole values.
Verified the tests fail without the fix:
Full suite green with the fix applied:
Backend regression tests (
scripts/run_tests.sh tests/hermes_cli/test_web_server.py):Confirmed the two new tests fail on the pre-fix code:
ruff checkclean on both changed files;ty check hermes_cli/web_server.pyshows only pre-existing, unrelated diagnostics (Windows-only stubs and an
optional
uvicornimport), none near the changed lines.Manual testing on Windows itself wasn't possible from this environment; the
affected code path (
config-settings.tsx,use-config-record.ts,hermes.ts,web_server.py) has no OS branching, so the fix and its testcoverage apply identically on macOS/Linux/Windows.
Disclosure
This PR was prepared with AI assistance (Claude Code), with the root cause,
fix, and tests verified by running the actual test suite, typecheck, and
lint locally as shown above.