fix: preserve custom secrets during legacy secrets_store migration - #121
fix: preserve custom secrets during legacy secrets_store migration#121simonrosenberg wants to merge 2 commits into
Conversation
invalidate_legacy_secrets_store built a Secrets document from only the legacy provider_tokens field and handed it to secrets_store.store(), which both SaasSecretsStore and FileSecretsStore treat as the whole document. Every GET /api/v1/settings for a user with legacy provider_tokens deleted all of that user's custom secrets. Load the existing custom secrets first and carry them into the migrated document. Fixes #102
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||
Review on #121 surfaced two gaps: - The fix only carried existing custom_secrets forward, still letting a stale legacy provider_tokens entry clobber a token already reconnected or rotated in the dedicated store. Merge with the dedicated store taking precedence. - The only test exercised FileSecretsStore; production traffic goes through SaasSecretsStore's delete-then-insert + JWT encryption path, which the fix was never verified against. Add that coverage, including a test documenting a separate, pre-existing limitation: SaasSecretsStore.store() never persists provider_tokens at all, so this migration cannot make them durable there (tracked separately, not fixed here). Also log custom-secret and provider-token counts on every migration so we can tell whether this path still fires in production.
|
Closing. The migration this fixes ( For anyone who's been actively upgrading OpenHands over that window, whatever was going to happen already happened, however long ago — this fix has no retroactive effect on data already lost. The only population it protects going forward is users still running 15-month-old code who eventually jump straight to a fixed version, or a fresh install that happens to reuse a pre-split config directory with stale legacy secrets. Given the project's release cadence, that population isn't worth carrying an open fix for. The bug itself was real and correctly diagnosed (see #102) — this isn't a retraction of the finding, just a call that it's too old to be worth landing a fix for now. Leaving #102 open for the record; not implementing further. |
HUMAN:
AGENT: Implemented the fix proposed in #102's comment; added regression tests against both FileSecretsStore and the production SaasSecretsStore (encrypted Postgres) path; added migration telemetry. See Notes — this is an OSS/self-hosted-only correctness fix; not connected to Codex auth or any enterprise/SaaS issue.
Why
invalidate_legacy_secrets_store(the one-time migration that movesSettings.secrets_store.provider_tokensinto the dedicatedSecretsStore) built aSecretsdocument containing onlyprovider_tokensand passed it tosecrets_store.store(...). BothSaasSecretsStore.store()andFileSecretsStore.store()treat the passedSecretsobject as the entire document —SaasSecretsStoredeletes all existing custom-secret rows for the user before inserting whatever is in the payload, andFileSecretsStoreoverwrites the whole file. Since the migration never populatedcustom_secrets, everyGET /api/v1/settingsfor a user who still had legacyprovider_tokenssilently deleted all of that user's custom secrets.This is fully live today for self-hosted/OSS installs (
FileSettingsStoredoes a full JSON round-trip ofSettings, sosecrets_store.provider_tokensgenuinely persists from before the dedicatedSecretsStoreexisted). On SaaS/enterprise (cloud and replicated alike),SaasSettingsStorehas never persisted or restoredsecrets_storeat all, so the migration guard can never trip there — see Notes.Summary
Secretsdocument before building the migrated one, and carry itscustom_secretsforward instead of dropping them.provider_tokenswith the dedicated store's existing values taking precedence, so a stale legacy token can't clobber one already reconnected/rotated there.FileSecretsStore(tests/unit/app_server/test_invalidate_legacy_secrets_store.py) and the productionSaasSecretsStorepath — real delete-then-insert + JWT encryption (enterprise/tests/unit/test_invalidate_legacy_secrets_store_saas.py).Issue Number
Fixes #102
How to Test
uv run pytest tests/unit/app_server/test_invalidate_legacy_secrets_store.py -q— fails without the fix (confirmed by reverting locally), passes with it.PYTHONPATH=".:$PYTHONPATH" poetry run --project=enterprise pytest ./enterprise/tests/unit/test_invalidate_legacy_secrets_store_saas.py ./enterprise/tests/unit/test_saas_secrets_store.py -q— SaaS path, same result.uv run pytest tests/unit/app_server/test_settings_api.py tests/unit/app_server/test_secrets_api.py -q— no regressions.uv run mypy openhands/app_server/settings/settings_router.pyandruff check/ruff format(both root andenterprise/dev_config/python/ruff.tomlconfigs) — clean.Type
Notes
Scope: OSS/self-hosted only, not Codex/enterprise-relevant. #102 originally speculated this bug could explain part of the "no
CODEX_AUTH_JSONfound" failure bucket from #120. That's now ruled out, not just unlikely — it's timeline-impossible.CODEX_AUTH_JSONand ACP didn't exist until 2026-03/05, five to seven months after thesettingstable (the only thing that could ever populatesecrets_store.provider_tokensand trip this migration's guard) was dropped with no backfill in523b40dbf/ OpenHands/OpenHands#11469 (2025-10-22). Migrations apply strictly in order, so any codebase with Codex ACP support already contains that table drop — the two states can't coexist on any install, cloud or replicated. Full writeup on #102. This PR's actual value is the OSSFileSettingsStorecustom-secret-loss fix, full stop.This lands the credential-agnostic fix recommended in the issue thread, independent of the
CODEX_AUTH_JSON-specific allowlist in #101 (which addresses a different scenario — a stale write-back overwriting a rotated credential — not this migration). The residual design flaw (any caller holding one field can still overwrite the whole document viastore()) is a separate, larger decision tracked elsewhere in that thread.Found, not fixed, and closed as not-planned (was #122):
SaasSecretsStore.store()never persistsprovider_tokens(del kwargs['provider_tokens']), andload()never returns them — a real defect in that store's contract. Initially filed as its own issue, but on closer check there is currently no live path that feeds it non-emptyprovider_tokensfor a SaaS user: (1)SaasSettingsStorehas never persisted/restoredsecrets_store, so this migration's guard can't trip for SaaS; (2) the only other caller,POST /api/v1/secrets/git-providers(manual PAT entry), is hidden entirely for SaaS in the frontend ({!isSaas && ...}around every provider's token input ingit-settings.tsx) — SaaS connects providers via OAuth App/Keycloak instead. So this is dead code today, not an active production bug; closed #122 rather than track a fix against a path that doesn't exist. Testtest_invalidate_legacy_secrets_store_saas_does_not_persist_provider_tokensdocuments the underlying behavior in case that ever changes (e.g. a future migration or a re-enabled PAT UI for SaaS).