Skip to content

fix(sdk): keep the source's cipher and secrets on a forked conversation - #5765

Open
enyst wants to merge 4 commits into
mainfrom
claude/umbrella-bug-fixes-tgz9ai-fork-keeps-credentials
Open

enyst wants to merge 4 commits into
mainfrom
claude/umbrella-bug-fixes-tgz9ai-fork-keeps-credentials

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

A fork could lose its source’s encryption context and runtime secrets, leaving persisted credentials redacted and the fork unable to resume model calls.

Summary

 source conversation
   cipher ───────────────────────→ fork uses same cipher
   runtime secret registry ─copy→ fork's own registry
                                      ↓ assign state / autosave
                                  encrypted base_state.json
                                      ↓ resume
                                  usable credentials

Carry the source cipher into the fork and deep-copy its secret sources into the fork’s registry. The registry assignment participates in normal state persistence.

Source: local_conversation.py.

Issue Number

Fixes #5465

How to Test

Added kyletser's test_cipher_backed_fork_resumes_with_credentials from #5473, preserving Xiaoping Liao as commit author in 0fb50d610. It exercises a real ConversationService fork, service restart, and persisted resume, asserting that both the LLM key and a runtime-added secret survive. The existing registry merge and deep-copy implementation is retained.

Validation after incorporation: uv run pytest tests/agent_server/test_conversation_service.py tests/sdk/conversation/local/test_fork.py — 152 passed, with an isolated OH_PERSISTENCE_DIR. Per-file pre-commit passed.

Previously recorded verification
  • uv run pytest tests/sdk/conversation/local/test_fork.py: 20 passed. The new test_fork_keeps_source_cipher_for_persisted_secrets fails on main (fork key redacted, secret missing) and passes here; it checks the fork's base_state.json key and secret decrypt to the source's values and that a resume from that file (ConversationState.model_validate(..., context={"cipher": ...})) gets the key back. test_fork_without_cipher_still_redacts_secrets covers the cipher-less path.
  • uv run pytest tests/sdk/conversation tests/agent_server -k "fork or secret or cipher": 283 passed.
  • Map proof on a live agent-server with DeepSeek:
    • main (dfd8fe8): control-agent-server map run --file F10 --only F10.fork-keeps-credentials,F10.fork-run-memory --fresh --repeat 2 --checkout <main checkout> reports xfail for both bullets in both runs.
    • this branch, same command from the worktree: xpass for both bullets in both runs; the fork's run finishes and recalls the codename only the source was told.
    • after removing the markers: map check ok; map run --file F10 --fresh 22 pass, 0 fail, 4 xfail (other, unrelated known bugs).
  • uv run pre-commit run --files <changed files>: all passed.

Video/Screenshots

n/a

Type

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

Notes

Cipherless conversations still serialize secrets as redacted. Copying the registry is deliberate, beyond a cipher-only fix; server creation secrets can still be reapplied through metadata. Docker fork remains unsupported (501). The useful server-restart regression from #5473 is incorporated; its overlapping production change and duplicate SDK assertions are not needed; verification umbrella #5653.

Visual explanation by Codex using HumanLayer show-me.


🐳 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:0308e03-python

Run

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

All tags pushed for this build

ghcr.io/openhands/agent-server:0308e03-golang-amd64
ghcr.io/openhands/agent-server:0308e031d1c3286d1d2368d2762127c7036fe2d7-golang-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-fork-keeps-credentials-golang-amd64
ghcr.io/openhands/agent-server:0308e03-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:0308e03-golang-arm64
ghcr.io/openhands/agent-server:0308e031d1c3286d1d2368d2762127c7036fe2d7-golang-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-fork-keeps-credentials-golang-arm64
ghcr.io/openhands/agent-server:0308e03-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:0308e03-java-amd64
ghcr.io/openhands/agent-server:0308e031d1c3286d1d2368d2762127c7036fe2d7-java-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-fork-keeps-credentials-java-amd64
ghcr.io/openhands/agent-server:0308e03-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:0308e03-java-arm64
ghcr.io/openhands/agent-server:0308e031d1c3286d1d2368d2762127c7036fe2d7-java-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-fork-keeps-credentials-java-arm64
ghcr.io/openhands/agent-server:0308e03-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:0308e03-python-amd64
ghcr.io/openhands/agent-server:0308e031d1c3286d1d2368d2762127c7036fe2d7-python-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-fork-keeps-credentials-python-amd64
ghcr.io/openhands/agent-server:0308e03-python-node-runtime-amd64
ghcr.io/openhands/agent-server:0308e03-python-arm64
ghcr.io/openhands/agent-server:0308e031d1c3286d1d2368d2762127c7036fe2d7-python-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-fork-keeps-credentials-python-arm64
ghcr.io/openhands/agent-server:0308e03-python-node-runtime-arm64
ghcr.io/openhands/agent-server:0308e03-python-minimal-amd64
ghcr.io/openhands/agent-server:0308e031d1c3286d1d2368d2762127c7036fe2d7-python-minimal-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-fork-keeps-credentials-python-minimal-amd64
ghcr.io/openhands/agent-server:0308e03-python-node-runtime-minimal-amd64
ghcr.io/openhands/agent-server:0308e03-python-minimal-arm64
ghcr.io/openhands/agent-server:0308e031d1c3286d1d2368d2762127c7036fe2d7-python-minimal-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-fork-keeps-credentials-python-minimal-arm64
ghcr.io/openhands/agent-server:0308e03-python-node-runtime-minimal-arm64
ghcr.io/openhands/agent-server:0308e03-python-slim-amd64
ghcr.io/openhands/agent-server:0308e031d1c3286d1d2368d2762127c7036fe2d7-python-slim-amd64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-fork-keeps-credentials-python-slim-amd64
ghcr.io/openhands/agent-server:0308e03-python-node-runtime-slim-amd64
ghcr.io/openhands/agent-server:0308e03-python-slim-arm64
ghcr.io/openhands/agent-server:0308e031d1c3286d1d2368d2762127c7036fe2d7-python-slim-arm64
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-fork-keeps-credentials-python-slim-arm64
ghcr.io/openhands/agent-server:0308e03-python-node-runtime-slim-arm64
ghcr.io/openhands/agent-server:0308e03-golang
ghcr.io/openhands/agent-server:0308e031d1c3286d1d2368d2762127c7036fe2d7-golang
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-fork-keeps-credentials-golang
ghcr.io/openhands/agent-server:0308e03-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:0308e03-java
ghcr.io/openhands/agent-server:0308e031d1c3286d1d2368d2762127c7036fe2d7-java
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-fork-keeps-credentials-java
ghcr.io/openhands/agent-server:0308e03-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:0308e03-python-minimal
ghcr.io/openhands/agent-server:0308e031d1c3286d1d2368d2762127c7036fe2d7-python-minimal
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-fork-keeps-credentials-python-minimal
ghcr.io/openhands/agent-server:0308e03-python-node-runtime-minimal
ghcr.io/openhands/agent-server:0308e03-python-slim
ghcr.io/openhands/agent-server:0308e031d1c3286d1d2368d2762127c7036fe2d7-python-slim
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-fork-keeps-credentials-python-slim
ghcr.io/openhands/agent-server:0308e03-python-node-runtime-slim
ghcr.io/openhands/agent-server:0308e03-python
ghcr.io/openhands/agent-server:0308e031d1c3286d1d2368d2762127c7036fe2d7-python
ghcr.io/openhands/agent-server:claude-umbrella-bug-fixes-tgz9ai-fork-keeps-credentials-python
ghcr.io/openhands/agent-server:0308e03-python-node-runtime

About Multi-Architecture Support

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

LocalConversation.fork() built the fork without the source's cipher, so the
fork's base_state.json stored the LLM key redacted. The agent-server resumes
every fork from that file, so the fork's first turn failed with
LLMAuthenticationError. Forward the cipher, and copy the source's
conversation secrets into the fork's registry so they persist too.

The verify-agent-server map bullets F10.fork-keeps-credentials and
F10.fork-run-memory now pass; their known-bug markers are removed.

Fixes #5465

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-sdk/openhands/sdk/conversation/impl
   local_conversation.py11387094%168, 372, 438, 710–711, 753, 795, 1171–1172, 1177, 1192, 1194, 1265, 1362, 1388, 1477, 1482–1487, 1540, 1560–1562, 1611, 1946–1947, 2210, 2213–2214, 2239, 2270, 2276, 2415, 2418, 2421, 2425–2426, 2430–2431, 2434, 2441, 2461, 2465, 2468, 2487, 2539, 2542, 2574, 2581–2582, 2590, 2594–2596, 2603, 2775, 2780, 2900–2901, 2919–2920, 2953, 3165, 3169, 3239, 3246–3247
TOTAL52000857684% 

@enyst
enyst marked this pull request as ready for review October 13, 2026 21:44
@enyst
enyst requested a review from all-hands-bot October 13, 2026 21:45

@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 #5765

Scope: In-repo and in-scope. Fork behavior for LocalConversation is owned by this repository (Python SDK / Agent Server), per AGENTS.md cross-repository boundaries and the review guide's ownership section. No product/architecture relocation is needed.

Freshness: Head is 5383a6f38494598db44d3d4f798c9dc3d19d6192 (matches), PR open, no labels, no prior reviews, no review threads. Linked issue #5465 is open with bug, priority:low, ready-for-dev. Head CI is green (sdk-tests, build, build-binary-and-test on ubuntu/macos/windows, Check OpenAPI Schema, image builds all success).

What the change does

  • LocalConversation.fork() now passes cipher=self._cipher into the fork's constructor. This is the actual root cause of #5465: without the source cipher, the fork's base_state.json serialized the LLM api_key as the redacted sentinel, and the agent-server resumes every fork from that file (EventService.start with agent=None), so the first turn failed with LLMAuthenticationError. The fix is minimal and directly traceable to the reported symptom; the cipher-less path still redacts (unchanged behavior, covered by test_fork_without_cipher_still_redacts_secrets).
  • The fork also copies the source's secret_registry.secret_sources by assigning the field (which triggers ConversationState.__setattr__ autosave), so conversation-level secrets (secrets= / update_secrets) persist encrypted under the same cipher. On the agent-server this is consistent with the existing resume path (EventService re-applies meta.json secrets) and with fork_conversation, which copies the source's stored metadata onto the fork. I verified the server's _cipher_for() resolves to the same configured cipher that fork() now forwards, and that fork is 501 in Docker runtime mode (where a per-conversation cipher resolver exists), so the forwarded cipher matches wherever a fork can be created.

Verification performed

  • uv run pytest tests/sdk/conversation/local/test_fork.py -> 20 passed, including the new cipher and secret assertions (base_state.json key and secret decrypt to the source values; a ConversationState.model_validate(..., context={"cipher": cipher}) resume returns the key).
  • uv run pytest tests/sdk/conversation -k "fork or secret or cipher" -> 151 passed.
  • uv run pytest tests/agent_server -k "fork or secret or cipher" -> 130 passed, 2 failed. Both failures (test_local_storage.py::test_a_fork_keeps_the_shared_worktree_until_it_is_deleted, test_credential_binding.py::test_direct_start_strips_reserved_conversation_secret) reproduce identically on the base commit dfd8fe8 in a separate worktree, i.e. they are pre-existing environment failures (test-server auth / git-fixture setup), not regressions from this diff. Neither touches the fork cipher/secret path under review.

Acceptance

The linked issue's acceptance criterion is met: a cipher-backed fork persists its credentials encrypted and can resume from base_state.json. The PR additionally supplies authentic before/after live evidence on the agent-server (map bullets F10.fork-keeps-credentials and F10.fork-run-memory xfail on main, xpass on this head), which satisfies the review guide's live-evidence requirement for a production-facing bug fix. No material correctness, security, compatibility, or acceptance defect found.

Non-blocking note (not a finding)

This PR overlaps the independently written open PR #5473, which targets the same issue. As the author flags in the description, a maintainer must pick one and close the other. That is a merge-selection decision between two valid implementations, not a defect in this head, so it does not change the verdict.

✅ APPROVED

kyletser and others added 2 commits October 22, 2026 08:16
Adapt the server restart regression test from #5473, source commit 583fb5e. Keep the existing registry merge and deep-copy implementation in #5765.

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>

@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 #5765 (head 0308e031)

Scope: In-repo and in-scope. LocalConversation.fork() is SDK conversation behavior this repository owns, and it directly addresses #5465 (bug, ready-for-dev). The agent-server regression test and the F10 verify-agent-server feature-map update belong here as well.

Current state: Head is unchanged at 0308e031d1c3286d1d2368d2762127c7036fe2d7; PR is open and non-draft; all GitHub Actions checks pass on this head; no unresolved review threads.

Analysis:

  • Passing the source's cipher into the fork constructor makes the fork's base_state.json encrypt secrets with the same key as the source instead of redacting them, and the cipherless path still redacts with a warning (covered by test_fork_without_cipher_still_redacts_secrets).
  • Deep-copying source secret sources into the fork's registry via field assignment (rather than mutating the registry) routes persistence through the normal autosave, now encrypted with the inherited cipher. The merge gives source secrets precedence and the resume-time fill-if-absent seeding cannot downgrade them. The fork is not yet visible to other threads when this runs, so the registry swap is race-free, and the server's fork_conversation cleans up the fork directory if service start fails.
  • Tests exercise real paths, no mocks: persisted-state decrypt assertions, ConversationState.model_validate(..., context={"cipher": ...}) resume, and a full ConversationService fork → server restart → resume cycle asserting both the LLM key and a runtime-added secret survive.
  • Independently verified in the workspace: tests/sdk/conversation/local/test_fork.py (22 passed) and the fork tests in tests/agent_server/test_conversation_service.py (11 passed) on this head, and the new regression test fails on the merge-base (24a3a511) with the fork's persisted key redacted — confirming both the bug and the fix.
  • The PR's live agent-server map evidence (fork of a live DeepSeek conversation: xfail on main → xpass on this branch, plus a passing map check after marker removal) satisfies the repository's live-evidence requirement for a production-facing lifecycle fix.

No material correctness, security, compatibility, or acceptance-criterion defects found. The linked issue's acceptance criteria are met on the current head.

✅ APPROVED

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]: A forked conversation loses its LLM API key and fails its first turn with LLMAuthenticationError

4 participants