Skip to content

fix(agent-server): refuse activating or re-saving a profile under a changed cipher key - #5775

Open
enyst wants to merge 12 commits into
mainfrom
claude/umbrella-bug-fixes-tgz9ai-cipher-profile-connections
Open

enyst wants to merge 12 commits into
mainfrom
claude/umbrella-bug-fixes-tgz9ai-cipher-profile-connections

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

An unreadable encrypted profile key could become null, then activation or re-save could overwrite recoverable credentials with that empty value.

Summary

 profile encrypted with old key
   old key in OH_PREVIOUS_SECRET_KEYS
     → read / activate normally → save under current key
   no configured key can decrypt
     → strict activation: 409; settings untouched
     → overwrite:         409; profile ciphertext untouched
     → ordinary GET: existing lenient read behavior

Use strict profile loads for activation, guard overwrites, and share the profile-key-mismatch response. This branch includes the key-rotation foundation from #5758; profiles rotate when saved, not at startup.

Source: llm_profile_store.py · profiles_router.py · settings_router.py.

Issue Number

Part of #5317

How to Test

Previously recorded verification
  • New tests (fail on the base, pass here): tests/agent_server/test_profile_cipher_mismatch.py (activate 409, PATCH active_profile 409, re-save 409 with the file byte-identical and the original key still reading sk-a, then delete and re-save under the new key works) and three tests in tests/sdk/llm/test_llm_profile_store.py (lenient default load, strict load raises, save over an undecryptable profile raises).

  • Related suites, run one file at a time (this machine was short on memory): test_profiles_router.py 109 passed, test_settings_router.py 84, test_agent_profiles_router.py 80, test_store_cipher_mismatch.py 6, test_profile_cipher_mismatch.py 3, tests/sdk/llm/test_llm_profile_store.py 67, tests/sdk/utils/test_pydantic_secrets.py 46. pre-commit is clean on the changed files.

  • Live map proof (a real server restarted with a rotated secret key):

    • main a430c77 (map run --file F20 --only F20.secret-key-rotation --fresh --repeat 2 --checkout <main>): xfail in both attempts. Activation answers 200 activated and applied.
    • this branch (same command, no --checkout, marker still in place): xpass in both attempts. Activation answers 409 Profile 'qa-rotate' is encrypted with a different key. The server log shows Refusing to use profile 'qa-rotate': A stored secret cannot be decrypted ... and contains no gAAAAA.
    • with the marker removed: map check ok, and map run --file F20 --only F20.secret-key-rotation --fresh gives pass.
  • After merging the rotation work: test_profile_cipher_mismatch.py gains a test where, with the old key listed, the profile reads its key, activates (and the settings get the key), and is re-saved under the new key; a server with only the new key then reads and activates it. The suites tests/agent_server tests/sdk/{llm,utils,profiles} give 4372 passed. Two tests failed under parallel load: the canvas-extension recovery test, which also fails on main here, and a websocket test that passes when re-run. Live map run --file F20 --fresh gives 32 pass and 5 xfail (the other known bugs), including F20.secret-key-previous-keys and F20.secret-key-rotation. F18.secret-key-rotation and F18.secret-key-previous-keys pass, and map check is ok.

Video/Screenshots

n/a

Type

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

Notes

Stacked on #5758. The refusal covers any overwrite, even one supplying a replacement key; alternatives and remaining choices are recorded on the issue. Recovery is to restore/list the old key, or explicitly delete and recreate the profile. Provider-connection failures and their 400/409 consistency are covered separately by #5338. GET adds no new broken-key indicator, and agent-profile launch / LocalConversation profile switching still load leniently. The issue is only partially addressed.

Visual explanation by Codex using HumanLayer show-me.

REST API contract changes

Compared with base OpenAPI a430c778bd8a for public /api/** paths.

--- base public OpenAPI
+++ head public OpenAPI
@@ -1841,0 +1842 @@
+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:b4452c7-python

Run

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

All tags pushed for this build

ghcr.io/openhands/agent-server:b4452c7-golang-amd64
ghcr.io/openhands/agent-server:b4452c7a1a7f12d4b42de4af99f4044cd6b956a3-golang-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-profile-connections-golang-amd64
ghcr.io/openhands/agent-server:b4452c7-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:b4452c7-golang-arm64
ghcr.io/openhands/agent-server:b4452c7a1a7f12d4b42de4af99f4044cd6b956a3-golang-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-profile-connections-golang-arm64
ghcr.io/openhands/agent-server:b4452c7-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:b4452c7-java-amd64
ghcr.io/openhands/agent-server:b4452c7a1a7f12d4b42de4af99f4044cd6b956a3-java-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-profile-connections-java-amd64
ghcr.io/openhands/agent-server:b4452c7-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:b4452c7-java-arm64
ghcr.io/openhands/agent-server:b4452c7a1a7f12d4b42de4af99f4044cd6b956a3-java-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-profile-connections-java-arm64
ghcr.io/openhands/agent-server:b4452c7-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:b4452c7-python-amd64
ghcr.io/openhands/agent-server:b4452c7a1a7f12d4b42de4af99f4044cd6b956a3-python-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-profile-connections-python-amd64
ghcr.io/openhands/agent-server:b4452c7-python-node-runtime-amd64
ghcr.io/openhands/agent-server:b4452c7-python-arm64
ghcr.io/openhands/agent-server:b4452c7a1a7f12d4b42de4af99f4044cd6b956a3-python-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-profile-connections-python-arm64
ghcr.io/openhands/agent-server:b4452c7-python-node-runtime-arm64
ghcr.io/openhands/agent-server:b4452c7-python-minimal-amd64
ghcr.io/openhands/agent-server:b4452c7a1a7f12d4b42de4af99f4044cd6b956a3-python-minimal-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-profile-connections-python-minimal-amd64
ghcr.io/openhands/agent-server:b4452c7-python-node-runtime-minimal-amd64
ghcr.io/openhands/agent-server:b4452c7-python-minimal-arm64
ghcr.io/openhands/agent-server:b4452c7a1a7f12d4b42de4af99f4044cd6b956a3-python-minimal-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-profile-connections-python-minimal-arm64
ghcr.io/openhands/agent-server:b4452c7-python-node-runtime-minimal-arm64
ghcr.io/openhands/agent-server:b4452c7-python-slim-amd64
ghcr.io/openhands/agent-server:b4452c7a1a7f12d4b42de4af99f4044cd6b956a3-python-slim-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-profile-connections-python-slim-amd64
ghcr.io/openhands/agent-server:b4452c7-python-node-runtime-slim-amd64
ghcr.io/openhands/agent-server:b4452c7-python-slim-arm64
ghcr.io/openhands/agent-server:b4452c7a1a7f12d4b42de4af99f4044cd6b956a3-python-slim-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-profile-connections-python-slim-arm64
ghcr.io/openhands/agent-server:b4452c7-python-node-runtime-slim-arm64
ghcr.io/openhands/agent-server:b4452c7-golang
ghcr.io/openhands/agent-server:b4452c7a1a7f12d4b42de4af99f4044cd6b956a3-golang
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-profile-connections-golang
ghcr.io/openhands/agent-server:b4452c7-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:b4452c7-java
ghcr.io/openhands/agent-server:b4452c7a1a7f12d4b42de4af99f4044cd6b956a3-java
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-profile-connections-java
ghcr.io/openhands/agent-server:b4452c7-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:b4452c7-python-minimal
ghcr.io/openhands/agent-server:b4452c7a1a7f12d4b42de4af99f4044cd6b956a3-python-minimal
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-profile-connections-python-minimal
ghcr.io/openhands/agent-server:b4452c7-python-node-runtime-minimal
ghcr.io/openhands/agent-server:b4452c7-python-slim
ghcr.io/openhands/agent-server:b4452c7a1a7f12d4b42de4af99f4044cd6b956a3-python-slim
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-profile-connections-python-slim
ghcr.io/openhands/agent-server:b4452c7-python-node-runtime-slim
ghcr.io/openhands/agent-server:b4452c7-python
ghcr.io/openhands/agent-server:b4452c7a1a7f12d4b42de4af99f4044cd6b956a3-python
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-cipher-profile-connections-python
ghcr.io/openhands/agent-server:b4452c7-python-node-runtime

About Multi-Architecture Support

  • Each variant tag (e.g., b4452c7-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., b4452c7-python-amd64) are also available if needed

Jev-Fast-Audit

⚡ Jev fast audit · estimates · 0.36s · commit 6d228a0
Strongest signal: No primary concern selected.
Evidence: No primary concern to locate.
Coverage: ⚠️ reduced context — partial coverage; 26/28 hunks, 9/9 files (context budget: 26, hunk budget: 2).

All estimates and evidence
Estimate Likelihood / value Direct evidence
SQL injection 3.0% No direct hunk selected
Command injection 3.0% No direct hunk selected
Weakened authentication 7.0% No direct hunk selected
Weakened authorization 9.0% No direct hunk selected
Contract regression 34.0% F005H005 · openhands-agent-server/openhands/agent_server/profiles_router.py:504–512
Data loss 10.0% No direct hunk selected
Sensitive data disclosure 11.0% No direct hunk selected
Unexpected data transfer 4.0% No direct hunk selected
Credential misuse 7.0% No direct hunk selected
Untrusted instruction authority 3.0% No direct hunk selected
Package source redirection 3.0% No direct hunk selected
Unverified remote execution 2.0% No direct hunk selected
Privileged environment access 9.0% F007H002 · openhands-sdk/openhands/sdk/llm/llm_profile_store.py:63–95
Security assessment bypass 6.0% No direct hunk selected
Prohibited workload 2.0% No direct hunk selected
Primary concern None selected; confidence 59.0% No primary concern to locate

… 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
…hanged cipher key

After the cipher key changed (a new OH_SECRET_KEY, or a new first session
key when it is unset), a named LLM profile's stored key loaded as null.
POST /api/profiles/{name}/activate and PATCH /api/settings with
active_profile then answered 200 and applied an LLM with api_key: null over
the settings' key, and re-saving the keyless config read back wrote null
over the profile's recoverable ciphertext.

LLMProfileStore.load gains strict=True (the strict_decrypt context from the
settings/secrets fix), used by both activation paths, and save refuses to
overwrite a profile holding a token the cipher cannot decrypt. Both answer
409 "Profile '<name>' is encrypted with a different key" and write nothing.
Plain reads stay lenient. F20.secret-key-rotation loses its known-bug
marker and asserts the 409s.

Part of #5317

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MKndVj7XWvRQmvW9w2bDg
@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
   _secrets_exposure.py77396%32, 89, 130
   profiles_router.py219697%555–560
   settings_router.py216598%617, 619–620, 662, 667
openhands-sdk/openhands/sdk/llm
   llm_profile_store.py1841095%62–63, 75–76, 78, 181–182, 381, 413, 419
TOTAL52025857984% 

@enyst
enyst marked this pull request as ready for review October 13, 2026 22:52
@enyst
enyst requested a review from all-hands-bot October 13, 2026 22:55

@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 PR #5775

Scope check: this change belongs in this repository. It touches the SDK profile store (LLMProfileStore.load/save) and the agent-server REST paths that consume it (POST /api/profiles/{name}/activate, PATCH /api/settings with active_profile), which this repo owns. No cross-repository move is warranted. The author's three open questions (provider-connection half in #5338, strictness of the save refusal, no new read hint) are documented design decisions, not blockers.

What I verified on head f1572ab:

  • The fix is wired through every affected path. load(strict=True) is used by both activation entry points (profiles_router.activate_profile, settings_router._load_active_profile_llm), and save refuses an overwrite via _raise_if_undecryptable. Both raise the new SecretDecryptionError, which is deliberately not a ValueError, so store_errors() does not swallow it into a 400; the explicit except SecretDecryptionError arms map it to the shared 409 profile_key_mismatch. load re-raises it before the generic except Exception wrap that would have turned it into a ValueError.
  • The guard is fail-closed. _raise_if_undecryptable runs inside the same file lock as the write, and both refusals return before anything is written (activation raises before settings_store.update; save raises before the temp-file replace). The tests assert the profile file is byte-identical and that the original key still reads sk-a.
  • The save guard covers every encrypted secret field. I confirmed empirically that LLM.to_persisted emits all four LLM_SECRET_FIELDS (api_key, aws_access_key_id, aws_secret_access_key, aws_session_token) as top-level Fernet-token strings, which is exactly what the guard walks. No nested secret-bearing dict exists in the serialized profile.
  • No secret leakage. The 409 detail and the logged message are generic; the stored token is never echoed, and the test asserts gAAAAA is absent from the response.
  • No compatibility break: strict is keyword-only with a False default, so existing callers are unchanged, and the profile-store protocols are untouched.
  • Evidence quality: the new tests are real integration tests through create_app with a real LLMProfileStore and real Cipher (not mocked behavior), and they pass (70 passed). Related suites pass (325 passed: profiles/settings/agent-profiles/store-cipher/pydantic-secrets). ruff check/ruff format --check are clean on the changed files, and all check runs on f1572ab are green. The PR also supplies the runtime before/after evidence the guide asks for on a restart-with-rotated-key path: the F20 map bullet xfails on main and xpasses on this branch with the profile's own 409 detail.

Notes (not findings): The provider-connection activation path can still answer 400 for an undecryptable connection key rather than 409; that half is explicitly deferred to #5338 and remains fail-closed (nothing written), so it does not affect this merge. The GET-by-name vs list api_key_set divergence after rotation is the author's deliberate, documented decision to avoid an API addition.

No material correctness, security, compatibility, or acceptance-criterion defect found on the current head.

✅ 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
…-cipher-key-change-409' into claude/umbrella-bug-fixes-tgz9ai-cipher-profile-connections
…keys

With the base branch's OH_PREVIOUS_SECRET_KEYS, profile reads, activation
and re-saves go through Config.cipher, so a profile saved under a listed
previous key reads, activates, and is re-encrypted under the current key
when saved. The 409 for a profile no configured key can decrypt now says to
restore the key or add it to OH_PREVIOUS_SECRET_KEYS. The map gains
F20.secret-key-previous-keys; F20.secret-key-rotation covers the lost key.

Part of #5317

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MKndVj7XWvRQmvW9w2bDg
…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
…-cipher-key-change-409' into claude/umbrella-bug-fixes-tgz9ai-cipher-profile-connections
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014MKndVj7XWvRQmvW9w2bDg

@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 PR #5775 (head a22ce73)

Scope check: this change belongs in this repository. It touches the SDK cipher/profile store (openhands-sdk/openhands/sdk/utils/cipher.py, pydantic_secrets.py, llm/llm_profile_store.py) and the agent-server REST paths that consume them (profiles_router, settings_router, persistence/store.py, config.py, init_router), all owned here. No cross-repository move is warranted.

What I verified on this head:

  • The named-profile fix is wired through both activation entry points (profiles_router.activate_profile, settings_router._load_active_profile_llm) via load(strict=True), and save refuses an overwrite through _raise_if_undecryptable. Both raise SecretDecryptionError, which is deliberately not a ValueError, so store_errors() cannot swallow it into a 400; explicit except arms map it to the shared 409 profile_key_mismatch, and load re-raises it before the generic except Exception wrap.
  • The refusals are fail-closed: the save guard runs inside the same file lock and both refusals return before any write. Tests assert byte-identical files and that the original key still reads sk-a.
  • Key rotation (OH_PREVIOUS_SECRET_KEYS / OH_PREVIOUS_SECRET_KEYS_n, also previous_secret_keys on POST /api/init) is additive and backward compatible: Cipher still encrypts with the current key only and decrypts current-then-previous via MultiFernet; blank/duplicate/current-key entries are dropped; Config.cipher rebuilds when model_copy changes the keys. I confirmed empirically that serializing a token only a previous key can read re-encrypts it under the current key, so the settings/secrets stores (and re-saved profiles) move to the new key on the next write.
  • The startup re-encryption (reencrypt_stale_stores) only rewrites when the file actually holds a stale token, leaves undecryptable files untouched, and logs rather than raises on failure. It runs inside asyncio.to_thread in both the legacy lifespan and the deferred-init path.
  • No secret leakage: refusal details and logged messages are generic and never echo the token; tests assert gAAAAA is absent from responses.
  • Evidence: the new tests are real integration tests through create_app with a real LLMProfileStore/Cipher (not mocked behavior). Focused runs pass here — test_profile_cipher_mismatch.py, test_cipher.py, test_config.py, test_init_router.py (81), test_store_cipher_mismatch.py, test_pydantic_secrets.py, test_llm_profile_store.py (127), and test_profiles_router.py/test_settings_router.py (193). ruff check is clean on the changed files, and all 53 check runs on a22ce73 are green. The PR also supplies the restart-with-rotated-key before/after map evidence (F18/F20 secret-key-rotation and secret-key-previous-keys).

Notes (not findings): the provider-connection half of #5317 is explicitly deferred to #5338 and stays fail-closed (nothing written); the GET-by-name vs list api_key_set divergence after a lost-key rotation is the author's documented decision to avoid an API addition. The only OpenAPI delta is the additive optional previous_secret_keys on InitRequest.

No material correctness, security, compatibility, or acceptance-criterion defect found on the current head.

✅ APPROVED

Co-authored-by: openhands <openhands@all-hands.dev>

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

Scope: The change belongs here — it is agent-server/SDK profile persistence behavior, owned by this repository per AGENTS.md.

Review findings — no material issues on head 6d228a0c:

  • Refusals write nothing. activate_profile and _load_active_profile_llm load with strict=True and map SecretDecryptionError to 409 before settings_store.update(...) runs, so no keyless LLM is applied over the settings stored key. LLMProfileStore.save runs _raise_if_undecryptable inside the file lock and before the tempfile write, leaving the profile byte-identical (covered by tests asserting file bytes are unchanged).
  • The save-side guard is complete for this store. Profile JSON is flat: LLM_SECRET_FIELDS (api_key, aws_*) are top-level SecretStr fields serialized flat via serialize_secret, so scanning all top-level string values for FERNET_TOKEN_PREFIX covers every encrypted secret a profile can hold. Corrupt-JSON and non-dict files intentionally pass through and remain left to the existing load/save error handling.
  • Secrets handling follows the established machinery. The strict flag rides the existing pydantic_secrets context mechanism — no parallel redaction sentinels. Error messages and logs (profile_key_mismatch) contain profile names and recovery hints only, never ciphertext or key material (tests assert no gAAAAA in responses).
  • Lenient/strict split is coherent. Ordinary GET keeps the lenient read (api_key_set: false), matching the agreed fail-closed-on-writes degrade-gracefully-on-reads behavior; strictness applies only to the two write-adjacent paths.
  • Scope matches the maintainer discussion on #5317. The strict refusal covers any overwrite, even one supplying a replacement key; deletes stay allowed, so the way forward under a new key is delete-and-recreate. That tradeoff, the missing GET broken-key indicator, lenient runtime-use loads, and the provider-connection half (#5338) are explicitly recorded as open decisions on the issue — consistent with Part-of-#5317, not Fixes.
  • Compatibility. This PR own diff adds no REST schema change (the optional InitRequest.previous_secret_keys in the contract summary comes from the stacked #5758 work already on main). The 200→409 change on the corrupt-key paths is the intended fix and is documented in route docstrings and the F20 feature map.
  • Evidence. The PR includes live before/after map runs on real restarted servers (base answered 200 activated-and-applied; head answers 409 with no ciphertext in logs), satisfying the live-evidence requirement for a persisted-state recovery fix. GitHub Actions is green on this head. I additionally ran the focused suites in a workspace checkout of this exact head: test_profile_cipher_mismatch.py (4), tests/sdk/llm/test_llm_profile_store.py (67, incl. the 3 new tests), test_profiles_router.py (116), test_settings_router.py (89), test_store_cipher_mismatch.py (11), tests/sdk/utils/test_pydantic_secrets.py (49), test_agent_profiles_router.py (85) — all passing.

✅ APPROVED

enyst and others added 2 commits October 26, 2026 07:30
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions

Copy link
Copy Markdown
Contributor

📁 PR Artifacts Notice

This PR contains a .pr/ directory with temporary PR-specific documents. The directory will be automatically removed after this PR merges to main through a cleanup PR.

Co-authored-by: openhands <openhands@all-hands.dev>
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.

3 participants