fix(settings): keep member rows a true delta over org defaults - #243
Conversation
OHE-3138 Changing your own agent settings changes them for everyone in the org
What happensIf you change your own agent settings - switching your agent from OpenHands to Codex, say, or picking a different model - that change is applied to every other member of your organization, and to the organization's defaults. There's no warning that this will happen, and you don't need to be an admin or owner to do it. Any member can. For everyone else, their agent silently becomes whatever you picked. If your choice needs credentials they don't have, such as a connected Codex account or an API key, then every new conversation they try to start fails with an error like:
They can't fix it for themselves either. If they change their own settings back, that just pushes their choice onto everyone else in turn. What we sawOn our shared beta instance, one person switched their agent to Codex to try it out. That immediately blocked all 17 members of the organization from starting any new conversation, and it stayed broken until someone changed the setting back. Switching the agent also wipes the organization's configured model and connection URL, and switching back doesn't restore them. So recovering isn't just a matter of undoing the change - someone has to know the original values and re-enter them by hand. Expected behavior
ImpactAny organization with more than one member. A single member with no special permissions can stop everyone else from starting conversations by changing a setting on their own account, and the people affected have no way to recover on their own. FixThe first three points are addressed in OpenHands/enterprise#241. |
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||
HUMAN:
AGENT:
Why
Changing one setting detaches a member from every org-wide default, silently and permanently.
An admin switches the org onto a different model. It reaches the members who have never saved their agent settings. Everyone else stays on the old model with nothing telling them so, and the admin sees the change apply and assumes the rollout landed.
/settings/agentis the page that does it. Members can reach it (view_llm_settingsis a member permission) and it has no read-only mode. Its save handler sendsenable_sub_agentsunconditionally, so hitting Save is enough on its own; there's no particular toggle a member has to find. The other two branches of that same handler sendagent_kind, so that one page is the entry point for this and for #241.The cause is that
store()receives the member's composed settings: the org defaults with their own edits already merged in. Writing that whole view back turns every inherited value into an explicit override. I measured it on a member with an empty row who toggledenable_sub_agentsand nothing else: their row went from 0 explicit settings to 10, includingllmpinned to whatever the org default was at that moment.Second thing in here:
agent_context.current_datetimeis adefault_factoryofdatetime.now(), so it's regenerated on every construction. It was being written into the row on every save, andload()returns the stored value, so the agent gets handed a stale "now" on later conversations. Seeding a member row with2019-01-01and loading it returns2019-01-01.Approach
Before writing the row, drop anything that already matches the org default. What's left is a genuine override. What's dropped resolves through the org on load, so it keeps following that default when an admin changes it.
The comparison recurses, so changing one field stores that field alone rather than the whole block it lives in. A member who picks a model gets
{'llm': {'model': ...}}and still tracks org changes to every other LLM field.Both sides of the comparison go through one
_agent_settings_dumphelper. That matters more than it looks:api_keyis excluded from the member's dump andmcp_configlives in its own column, so building the two sides separately made the blocks compare unequal and defeated the whole thing. Runtime values are stripped in the same place, which is what fixes the timestamp.A consequence worth being explicit about: a member can't pin a value that currently equals the org default. Setting your model to the same model the org uses stores nothing, so you'll move when the org moves. Expressing "hold this even if the default changes" needs a separate flag, and nothing in the UI offers that today.
Issue Number
OHE-3138 - https://linear.app/all-hands-ai/issue/OHE-3138
How to Test
Manual, one admin and one member:
/settings/org-defaults.Video/Screenshots
Type
Notes
This is based on
jl/scope-member-settings-save(#241) so the diff here is just the delta change. Retarget it tomainif that one merges first.It stacks on #241 rather than replacing it. The two fixes catch different things and neither covers the other:
agent_kindflip. Those fabricated values genuinely differ from the org default, so comparing against the org can't spot them.Two tests from #241 changed here, which is the clearest way to see the difference:
test_non_flip_save_still_persists_full_agent_settingsbecametest_non_flip_save_persists_only_the_changed_field. It pinned the full-dump write as deliberate; that's the behaviour this PR removes.agent_kindin the row. Flipping back toopenhandsmatches the org default, so it resolves by absence.No migration. An existing row full of redundant overrides thins out on that member's next save.
Enterprise server image for this PR: