Skip to content

fix(agent-server): support cipher key rotation and refuse writes under a lost key - #5758

Merged
VascoSch92 merged 9 commits into
mainfrom
claude/umbrella-bug-fixes-tgz9ai-cipher-key-change-409
Oct 21, 2026
Merged

VascoSch92 merged 9 commits into
mainfrom
claude/umbrella-bug-fixes-tgz9ai-cipher-key-change-409

Conversation

@enyst

@enyst enyst commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

HUMAN:
This PR proposes a fix for a bug uncovered during the first full agentic run of verify-agent-server.


AGENT:

Why

Changing the encryption key could turn unreadable saved secrets into null during a successful write, destroying values that the old key could have recovered.

Summary

 current key + OH_PREVIOUS_SECRET_KEYS
   read  → try current, then previous keys
   write → encrypt with current key

 startup / POST /api/init
   settings.json + secrets.json → re-encrypt readable stale tokens atomically
 other encrypted stores        → rotate when next saved

 no configured key can decrypt
   read → existing fail-soft behavior
   settings write → 409; secrets write → 500; preserve files

Add previous-key decryption and rotation to Cipher, propagate it through server configuration and deferred initialization, and use strict decryption for destructive read-modify-write paths. Rebuild the cached cipher when initialized keys change.

Source: cipher.py · store.py · init_router.py.

REST API contract changes

Compared with base OpenAPI 3e208f61aaca for public /api/** paths.

--- base public OpenAPI
+++ head public OpenAPI
@@ -1881,0 +1882 @@
+schema InitRequest property previous_secret_keys optional schema=anyOf=[type="array" items=type="string" format="password",type="null"]

Issue Number

Fixes #5622

How to Test

Previously recorded verification
  • Unit tests: uv run pytest tests/sdk/utils tests/agent_server/test_store_cipher_mismatch.py tests/agent_server/test_config.py tests/agent_server/test_init_router.py gives 339 passed. New coverage:
    • Cipher: decrypts with a previous key and encrypts with the current one; rotate returns a new-key token, after which the previous key is not needed; without the old key, decryption still fails; previous keys are normalised.
    • pydantic_secrets: strict decrypt accepts a previous key; the error names OH_PREVIOUS_SECRET_KEYS; serialize_secret rotates a stale token.
    • Stores and REST routes: with the old key listed, every value reads, PATCH/PUT/DELETE answer 200 and re-encrypt, and a server with only the new key then reads everything. reencrypt() rewrites only stale files, and leaves a file untouched when the listed key is wrong. Startup reencrypt_stale_stores. Without the old key, the refusal still holds (409, with OH_PREVIOUS_SECRET_KEYS in the detail).
    • Config: OH_PREVIOUS_SECRET_KEYS as a JSON list and indexed; a rotated session key with the session-key fallback; model_copy rebuilds the cipher; /api/init passes the keys through.
  • Wider suites: tests/agent_server tests/sdk/{utils,llm,conversation,profiles} gives 5230 passed. One canvas-extension test, test_exited_leader_descendant_does_not_block_recovery[status], also fails on unmodified main in this environment, so it is unrelated.
  • Live map runs (a real server restarted with a rotated key):
    • map run --file F18 --only F18.secret-key-rotation,F18.secret-key-previous-keys --fresh on this branch: both pass. The server log shows Re-encrypted the settings store under the current key and the same line for the secrets store.
    • F18.secret-key-previous-keys against main (--checkout <main>): fail, because main ignores the previous key and the stored secret reads as 404.
    • Full families on this branch: map run --file F18 --fresh gives 29 pass and 5 xfail (the other known bugs), and F22 gives 20 pass and 5 xfail. F20 gives 30 pass, 5 xfail and 1 xpass: F20.secret-key-rotation is the expected side effect noted below, fixed for real by the stacked fix(agent-server): refuse activating or re-saving a profile under a changed cipher key #5775. The corrupt-file bullets in F18, F20 and F22 now match the settings refusal detail with contains, because the detail carries the new guidance.
    • map check: ok.
    • Before the rotation commit: F18.secret-key-rotation was xfail twice on main and xpass twice on the first commit of this branch.

Video/Screenshots

n/a

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

previous_secret_keys accepts the server’s JSON-list or indexed environment format; /api/init accepts the optional list too. Only settings and secrets are eagerly rewritten, and only if all their values decrypt. Keep old keys configured while conversations, profiles, provider connections or MCP OAuth data still need them. Explicit OH_SECRET_KEY avoids coupling encryption rotation to the first session API key. Lost-key secrets errors still use 500 with masked detail; settings use 409 with guidance. The lost-key named-profile case remains tracked in #5317. Named-profile write protection is stacked in #5775; reference-only credentials remain the longer-term direction in #4288.

Visual explanation by Codex using HumanLayer show-me.

REST API contract changes

Compared with the merge base on main for public /api/** paths. (The generated summary compared against a stale base, dfd8fe8, so it also listed changes from #5981 and #5983 that came in from main. They are trimmed here.)

--- base public OpenAPI
+++ head public OpenAPI
+schema InitRequest property previous_secret_keys optional schema=anyOf=[type="array" items=type="string" format="password",type="null"]

🐳 Agent Server images for this PR — GHCR package, pull/run commands, and all pushed tags (click to expand)

• GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python-slim amd64, arm64 python-node-runtime Link
python-minimal amd64, arm64 python-node-runtime Link
python amd64, arm64 python-node-runtime Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:bd9d30d-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-bd9d30d-python \
  ghcr.io/openhands/agent-server:bd9d30d-python

All tags pushed for this build

ghcr.io/openhands/agent-server:bd9d30d-golang-amd64
ghcr.io/openhands/agent-server:bd9d30d063f4410e881f5e669be911faa0d1d7cb-golang-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-key-change-409-golang-amd64
ghcr.io/openhands/agent-server:bd9d30d-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:bd9d30d-golang-arm64
ghcr.io/openhands/agent-server:bd9d30d063f4410e881f5e669be911faa0d1d7cb-golang-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-key-change-409-golang-arm64
ghcr.io/openhands/agent-server:bd9d30d-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:bd9d30d-java-amd64
ghcr.io/openhands/agent-server:bd9d30d063f4410e881f5e669be911faa0d1d7cb-java-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-key-change-409-java-amd64
ghcr.io/openhands/agent-server:bd9d30d-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:bd9d30d-java-arm64
ghcr.io/openhands/agent-server:bd9d30d063f4410e881f5e669be911faa0d1d7cb-java-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-key-change-409-java-arm64
ghcr.io/openhands/agent-server:bd9d30d-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:bd9d30d-python-amd64
ghcr.io/openhands/agent-server:bd9d30d063f4410e881f5e669be911faa0d1d7cb-python-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-key-change-409-python-amd64
ghcr.io/openhands/agent-server:bd9d30d-python-node-runtime-amd64
ghcr.io/openhands/agent-server:bd9d30d-python-arm64
ghcr.io/openhands/agent-server:bd9d30d063f4410e881f5e669be911faa0d1d7cb-python-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-key-change-409-python-arm64
ghcr.io/openhands/agent-server:bd9d30d-python-node-runtime-arm64
ghcr.io/openhands/agent-server:bd9d30d-python-minimal-amd64
ghcr.io/openhands/agent-server:bd9d30d063f4410e881f5e669be911faa0d1d7cb-python-minimal-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-key-change-409-python-minimal-amd64
ghcr.io/openhands/agent-server:bd9d30d-python-node-runtime-minimal-amd64
ghcr.io/openhands/agent-server:bd9d30d-python-minimal-arm64
ghcr.io/openhands/agent-server:bd9d30d063f4410e881f5e669be911faa0d1d7cb-python-minimal-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-key-change-409-python-minimal-arm64
ghcr.io/openhands/agent-server:bd9d30d-python-node-runtime-minimal-arm64
ghcr.io/openhands/agent-server:bd9d30d-python-slim-amd64
ghcr.io/openhands/agent-server:bd9d30d063f4410e881f5e669be911faa0d1d7cb-python-slim-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-key-change-409-python-slim-amd64
ghcr.io/openhands/agent-server:bd9d30d-python-node-runtime-slim-amd64
ghcr.io/openhands/agent-server:bd9d30d-python-slim-arm64
ghcr.io/openhands/agent-server:bd9d30d063f4410e881f5e669be911faa0d1d7cb-python-slim-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-key-change-409-python-slim-arm64
ghcr.io/openhands/agent-server:bd9d30d-python-node-runtime-slim-arm64
ghcr.io/openhands/agent-server:bd9d30d-golang
ghcr.io/openhands/agent-server:bd9d30d063f4410e881f5e669be911faa0d1d7cb-golang
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-key-change-409-golang
ghcr.io/openhands/agent-server:bd9d30d-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:bd9d30d-java
ghcr.io/openhands/agent-server:bd9d30d063f4410e881f5e669be911faa0d1d7cb-java
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-key-change-409-java
ghcr.io/openhands/agent-server:bd9d30d-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:bd9d30d-python-minimal
ghcr.io/openhands/agent-server:bd9d30d063f4410e881f5e669be911faa0d1d7cb-python-minimal
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-key-change-409-python-minimal
ghcr.io/openhands/agent-server:bd9d30d-python-node-runtime-minimal
ghcr.io/openhands/agent-server:bd9d30d-python-slim
ghcr.io/openhands/agent-server:bd9d30d063f4410e881f5e669be911faa0d1d7cb-python-slim
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-key-change-409-python-slim
ghcr.io/openhands/agent-server:bd9d30d-python-node-runtime-slim
ghcr.io/openhands/agent-server:bd9d30d-python
ghcr.io/openhands/agent-server:bd9d30d063f4410e881f5e669be911faa0d1d7cb-python
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-key-change-409-python
ghcr.io/openhands/agent-server:bd9d30d-python-node-runtime

About Multi-Architecture Support

  • Each variant tag (e.g., bd9d30d-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., bd9d30d-python-amd64) are also available if needed

… cipher key

After a cipher key change, undecryptable secrets loaded as None, so the
stores' "different key" guards never fired and any settings PATCH or
secrets PUT/DELETE answered 200 and saved null over the stored ciphertext.

Read-modify-write paths in FileSettingsStore and FileSecretsStore now load
with a strict_decrypt validation-context flag: validate_secret and
validate_secret_dict raise SecretDecryptionError for a token the cipher
cannot decrypt, the load fails, and the existing guards refuse the write
(409 for settings, 500 for secrets), leaving both files untouched. Reads
stay lenient. The F18.secret-key-rotation map bullet now passes, so its
known-bug marker is removed.

Fixes #5622

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MKndVj7XWvRQmvW9w2bDg
@enyst enyst self-assigned this Oct 13, 2026
@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result: ✅ PASSED

Action log

@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-agent-server/openhands/agent_server
   api.py4093492%140, 194–195, 204–205, 207, 210–211, 218–219, 233–234, 251, 257, 260–261, 321, 335, 341, 404, 409, 422–424, 449, 478, 484, 488, 509–510, 714, 796, 799, 805
   config.py171199%599
   init_router.py177597%207, 209, 213, 215, 217
   profiles_router.py212697%540–545
   settings_router.py198697%224, 571, 573–574, 616, 621
openhands-agent-server/openhands/agent_server/persistence
   store.py4856786%49–50, 100, 104, 108, 112, 116, 122, 145–146, 168, 171–174, 176–177, 182, 219, 221–225, 227–231, 236–241, 268, 271–272, 387–388, 393, 395–396, 523, 556–557, 562, 564–565, 573, 650–651, 730, 732, 751, 755, 773–774, 780, 848–849, 853–855, 874, 1010–1011
openhands-sdk/openhands/sdk/utils
   pydantic_secrets.py82199%60
TOTAL51327854983% 

@enyst
enyst marked this pull request as ready for review October 13, 2026 21:24
@enyst
enyst requested a review from all-hands-bot October 13, 2026 21:24
Comment thread openhands-sdk/openhands/sdk/utils/pydantic_secrets.py Outdated

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This review was posted by an AI agent (OpenHands).

Verified

  • Scope: this belongs here. The agent-server settings/secrets stores and openhands.sdk.utils.pydantic_secrets are owned by this repository, and the change is a bug fix matching #5622 (currently ready-for-dev).
  • Head 56f4777 is unchanged and CI for this head is green (23 success, 7 skipped, none failing).
  • The F18.secret-key-rotation marker removal is justified: the bullet loses its known bug label and now asserts a 500 (secrets PUT), a 409 (settings PATCH), byte-untouched files, and the original key reading both values back — which the code delivers.

Checked against the code

  • Exception safety: through real Pydantic model validation I confirmed SecretDecryptionError (deliberately not a ValueError) propagates unwrapped, so Pydantic does not wrap it in a ValidationError that would echo the Fernet token. The messages from both the plain and dict validators, and the router 409/500 detail bodies, contain no token.
  • Nested dict secret path: a settings write under a wrong key with an encrypted MCP env value fails the strict load (returns None, so update raises and the route answers 409). The dict validator is strict-aware, so this path is not silently left lenient.
  • Lenient reads preserved: load(), get_secret, and the GET routes still degrade to None instead of raising.
  • No missed read-modify-write path: every in-repo writer of these stores (settings PATCH / meta-profiles / profiles / agent-profiles, and secrets PUT / DELETE / versioned credential binding) goes through update/save/set_secret/delete_secret/load_versioned_secret/replace_versioned_secret, all now strict=True.

Test evidence

  • tests/agent_server/test_store_cipher_mismatch.py + tests/sdk/utils/test_pydantic_secrets.py: 52 passed.
  • Wider settings/secrets/profile suites pass. The 12 tests/agent_server/test_credential_binding.py failures reproduce identically on unmodified main (confirmed in a clean worktree), so they are pre-existing and unrelated to this change.

Not blocking

  • The secrets-500 vs settings-409 asymmetry raised in the description is a deliberate maintainer call; the linked acceptance criterion asks only for a non-2xx refusal, so it is not a defect here.
  • The open thread from VascoSch92 (use Final[str] on line 28) is a style nit without correctness impact and does not hold up approval.

✅ APPROVED

…keys

Rotating OH_SECRET_KEY (or the session API key it falls back to) used to
make every stored secret unreadable; #5622's guard only stopped writes from
destroying them until the old key came back.

Config gains previous_secret_keys (OH_PREVIOUS_SECRET_KEYS as a JSON list,
or OH_PREVIOUS_SECRET_KEYS_0, _1, ...; also accepted by POST /api/init).
Cipher encrypts with the current key and decrypts with the current key
first, then each previous key (MultiFernet, same sha256 derivation per
key), so values saved under an old key stay readable, and any save writes
them back under the current key; a stale token carried through as is is
rotated too. At startup the settings and secrets stores are re-encrypted
under the current key when they hold a stale token, so the previous key can
be dropped afterwards. Config.cipher is rebuilt when a model_copy changes
the keys.

The write refusal now applies only when no configured key can decrypt a
value, and its message says to restore the key or add it to
OH_PREVIOUS_SECRET_KEYS. The verify-agent-server map gains
F18.secret-key-previous-keys and restart --keep-previous-secret-key.

Fixes #5622

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MKndVj7XWvRQmvW9w2bDg
@enyst enyst changed the title fix(agent-server): refuse settings and secrets writes under a changed cipher key fix(agent-server): support cipher key rotation and refuse writes under a lost key Oct 14, 2026
…sal details by prefix

The settings refusal detail now carries the OH_PREVIOUS_SECRET_KEYS
guidance, so the F18, F20 and F22 corrupt-file bullets check it with
contains instead of eq.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MKndVj7XWvRQmvW9w2bDg
@enyst
enyst requested a review from all-hands-bot October 14, 2026 05:31

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This review was posted by an AI agent (OpenHands).

Context

Head 8621cf33 is a superset of the earlier commit (56f4777): it adds previous-key rotation (Cipher with MultiFernet, OH_PREVIOUS_SECRET_KEYS, startup re-encrypt, /api/init field) on top of the strict-decrypt refusal. The linked issue #5622 is still open and ready-for-dev; CI for this head is green (all check-runs success/skipped, none failing).

Verified

  • The refusal path is unchanged and correct: read-modify-write loads use strict_decrypt, the stores stay byte-for-byte unchanged, and the new hint (OH_PREVIOUS_SECRET_KEYS) is added without leaking tokens. SecretDecryptionError remains a non-ValueError so Pydantic does not wrap it.
  • Rotation mechanics are sound: Cipher.encrypt uses the current key, decrypt/try_decrypt_str use MultiFernet (current first, then previous), is_current/rotate behave as documented, and serialize_secret re-encrypts a stale token on save. Config.cipher correctly rebuilds on model_copy with changed keys.
  • FileSettingsStore.reencrypt/FileSecretsStore.reencrypt rewrite only when the file holds a stale token and every value decrypts, otherwise leave it untouched; reencrypt_stale_stores swallows failures.
  • Focused tests pass locally: tests/sdk/utils/test_cipher.py tests/sdk/utils/test_pydantic_secrets.py tests/agent_server/test_store_cipher_mismatch.py tests/agent_server/test_config.py tests/agent_server/test_init_router.py = 137 passed.

Material finding: previous_secret_keys delivered via /api/init do not reach an already-cached settings store

The /api/init rotation feature only works when the settings-store singleton has not been built yet. On a deferred-init (warm-pool) pod whose boot path reads persisted settings, _settings_store is constructed with the dormant boot cipher before /api/init runs. get_settings_store caches the first instance regardless of the config passed later (the docstring says so explicitly: "The config parameter is only used on first initialization. Subsequent calls return the existing instance regardless of config"), and InitService.initialize never resets the singletons. reencrypt_stale_stores(new_config) therefore calls get_settings_store(new_config), gets the stale-cipher instance, sees cipher.previous_keys == (), and does nothing.

Reproduction (deferred boot with a telemetry exporter configured, which triggers the boot-time consent read _read_misc_settings_sync -> get_settings_store(config).load() in telemetry/service.py), with settings.json holding an LLM key saved under the old key key-a:

  • POST /api/init {"secret_key": "key-b", "previous_secret_keys": ["key-a"]} returns 200.
  • _settings_store.cipher is still dormant-key with previous_keys == () (not key-b).
  • GET /api/settings reports llm_api_key_is_set = false (the old LLM key is unreadable).
  • PATCH /api/settings returns 409 Settings file is corrupted or encrypted with a different key: restore that key as OH_SECRET_KEY or add it to OH_PREVIOUS_SECRET_KEYS.

So the warm-pool rotation path advertised in the PR description ("a warm-pool pod that receives a rotated per-user key ... can still read that user's old values") fails for settings: the user's stored settings become unreadable and any settings write is refused, which is the same symptom as the bug this PR fixes, reached through /api/init. _secrets_store happens to be built lazily at init time (it is not read at boot), so secrets are unaffected, which makes the inconsistency easy to miss. Note that test_previous_secret_keys_reach_the_cipher only asserts on _build_initialized_config(...).cipher and never exercises the store, so the suite does not catch this.

Suggested direction: in InitService.initialize, reset the persistence singletons (e.g. reset_stores()) after _build_initialized_config and before reencrypt_stale_stores(new_config), so the stores are rebuilt from the merged config; add an end-to-end /api/init test that seeds settings under the old key, boots a deferred pod with a settings-reading boot path, and asserts the old LLM key reads back after init.

Not blocking

  • The secrets-500 vs settings-409 asymmetry remains a deliberate maintainer call.

🔄 CHANGES REQUESTED

Comment thread openhands-agent-server/openhands/agent_server/init_router.py Outdated
enyst and others added 2 commits October 15, 2026 23:54
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

🧹 PR Artifact Cleanup Queued

The .pr/ directory reached main after merge. A cleanup PR has been opened or updated: #6122

Co-authored-by: openhands <openhands@all-hands.dev>
@enyst
enyst requested a review from all-hands-bot October 15, 2026 23:25

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This review was posted by an AI agent (OpenHands).

Context

Head moved to bdea6cd0 (commits f4dfad4 "refresh persistence stores during deferred init" + evidence/test follow-ups) since the last review at 8621cf33. Linked issue #5622 is open and ready-for-dev. CI for this head is fully green (30/30 check-runs success). My prior blocking finding on 8621cf33 is addressed.

Prior finding resolved and verified

The earlier defect — previous_secret_keys delivered via /api/init never reaching an already-cached settings store on a warm-pool pod — is fixed. InitService.initialize now calls reset_stores() immediately before reencrypt_stale_stores(new_config) (init_router.py), so the stores are rebuilt from the merged config. I reproduced the original failure on 8621cf33 and re-ran the same scenario on this head: after POST /api/init {secret_key: key-b, previous_secret_keys: [key-a]} against a deferred boot with a settings-reading boot path, _settings_store.cipher is now key-b with previous_keys == ('key-a',), GET /api/settings reports llm_api_key_is_set=true, PATCH /api/settings returns 200, and the old secret still reads. The new regression tests/cross/test_remote_conversation_live_server.py::test_deferred_init_refreshes_persistence_ciphers (both rotated-key and current-key cases) passes locally.

Verified on this head

  • Refusal path unchanged and correct: read-modify-write loads use strict_decrypt, files stay byte-for-byte unchanged, and the new OH_PREVIOUS_SECRET_KEYS hint leaks no token. SecretDecryptionError remains a non-ValueError, so Pydantic does not wrap it into a token-echoing ValidationError.
  • Rotation mechanics sound: Cipher encrypts with the current key and decrypts current-first then previous (MultiFernet); is_current/rotate behave as documented; serialize_secret re-encrypts a stale token on save; Config.cipher rebuilds on model_copy with changed keys.
  • reencrypt() rewrites only when the file holds a stale token and every value decrypts; reencrypt_stale_stores swallows failures.
  • Focused suites pass locally: tests/sdk/utils/test_cipher.py tests/sdk/utils/test_pydantic_secrets.py tests/agent_server/test_store_cipher_mismatch.py tests/agent_server/test_config.py tests/agent_server/test_init_router.py = 137 passed.

Notes (not blocking)

  • The .pr/live-verification.md artifact is per the repository's documented .pr/ policy (temporary, removed on approval), so it is expected here.
  • One local run of the new cross test failed with IndexError in env_parser.merge, but that is a pre-existing bug in a file this PR does not touch, triggered only by ambient OH_* variables in this review sandbox; with a clean environment the test passes, and CI's cross-tests is green.
  • The secrets-500 vs settings-409 asymmetry remains a deliberate maintainer call, unchanged from the description.

No material findings remain.

✅ APPROVED

@enyst
enyst requested a review from VascoSch92 October 16, 2026 00:16

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This review was posted by an AI agent (OpenHands).

Review of head 1c42543d (fresh review: the head moved from bdea6cd to the merge commit 1c42543d; the linked issue #5622 is still open with bug / priority:low / ready-for-dev).

Scope — This change belongs here: this repo owns the Agent Server settings/secrets persistence and the cipher. The two decision points the author flagged (500 for refused secrets writes, strict refusal of every write to a store holding an undecryptable value) are reasonable defaults with clear rationale; neither requires a product/architecture call to proceed.

What I verified on the current head

  • The core change files (cipher.py, pydantic_secrets.py, persistence/store.py, config.py, init_router.py, settings_router.py) are byte-identical to bdea6cd, the commit this account approved on 2026-10-15. The only new work since then is the merge of main (plus an unrelated main-side edit merged into profiles_router.py).
  • All 53 non-skipped CI checks pass on this head, including cross-tests, integration-test, sdk-tests, agent-server-tests, and Check OpenAPI Schema.
  • Focused suites: tests/sdk/utils, tests/agent_server/test_store_cipher_mismatch.py, test_config.py, test_init_router.py → 339 passed, 2 xfailed.
  • The refusal path is sound: SecretDecryptionError is deliberately not a ValueError, so Pydantic does not wrap it and echo the Fernet token; read-modify-write paths (update, save, set_secret, delete_secret, load_versioned_secret, replace_versioned_secret) load with strict=True and leave both files byte-for-byte unchanged, while plain reads (load(), get_secret, GET routes) stay lenient. Rotation via MultiFernet + previous_keys + startup reencrypt is correct and covered by the real-HTTP cross test.
  • The earlier init_router.py concern (re-encrypting through a stale boot cipher) is addressed: reset_stores() now runs before reencrypt_stale_stores(new_config) in the deferred-init path.

Non-blocking operational note — The PR is currently mergeable_state: dirty; I reproduced conflicts against the current main in openhands-agent-server/.../api.py (both sides edited the non-deferred lifespan region), tests/agent_server/test_config.py, and .agents/.../F18-settings-and-secrets.md. The branch needs a rebase/conflict resolution before it can merge. This is a merge-state matter, not a defect in the change itself, so it does not change the code verdict.

The one tests/agent_server/test_agent_launch_additions.py::test_launch_additions_apply_after_agent_resolution[False] failure I saw is not in this PR's diff and also fails on the base commit c73ec79 in this environment; it is pre-existing/environmental and unrelated.

No material correctness, security, or compatibility finding on the current head.

✅ APPROVED

…ange-409

Resolve conflicts: run reencrypt_stale_stores before main's export_session_api_key and ConversationService.get_instance(config) in the lifespan, keep both new test groups in test_config.py, and take main's fixed 422 secret-name contract alongside this PR's key-rotation entries in the F18 feature map. Narrow agent_settings to OpenHandsAgentSettings in test_store_cipher_mismatch.py now that ACPAgentSettings.llm is deprecated on main.
@VascoSch92
VascoSch92 enabled auto-merge (squash) October 21, 2026 14:09
@VascoSch92
VascoSch92 disabled auto-merge October 21, 2026 14:09
@VascoSch92
VascoSch92 merged commit 53757a3 into main Oct 21, 2026
51 checks passed
@VascoSch92
VascoSch92 deleted the claude/umbrella-bug-fixes-tgz9ai-cipher-key-change-409 branch October 21, 2026 14:10
@github-actions github-actions Bot mentioned this pull request Oct 21, 2026
1 of 7 tasks
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.

[Bug]: After a cipher key change, settings and secret writes answer 200 and null stored keys, not 409

5 participants