Skip to content

feat(app-server): sync managed Codex credentials (Phase 1) - #77

Closed
simonrosenberg wants to merge 18 commits into
mainfrom
phase1-codex-credential-sync
Closed

feat(app-server): sync managed Codex credentials (Phase 1)#77
simonrosenberg wants to merge 18 commits into
mainfrom
phase1-codex-credential-sync

Conversation

@simonrosenberg

@simonrosenberg simonrosenberg commented Jul 27, 2026

Copy link
Copy Markdown
Member

HUMAN:

Re-targeted from OpenHands/OpenHands#15394 after the Agent Canvas repo transition moved this code into OpenHands/enterprise. Identical commits, no code changes — the branch fast-forwards onto main with zero conflicts.

  • A human has tested these changes.

AGENT:

Original PR: OpenHands/OpenHands#15394 (now unmergeable — its base branch became Agent Canvas).
Design: OpenHands/OpenHands#15393. Supersedes OpenHands/OpenHands#15287.


Why

Codex can rotate a conversation-local ChatGPT auth.json from R0 to R1 while the canonical saved CODEX_AUTH_JSON remains R0. A later isolated runtime then starts from an invalidated refresh token.

This is the narrow Phase 1 slice defined in #15393, which replaced #15287 (83 files, >10k added lines — effectively unreviewable). The pinned SDK/Agent Server 1.37.1 owns the private CODEX_HOME, monitoring, masking, CAS flush, final scrub, and cold activation guard; OpenHands supplies only a versioned canonical store, scoped activation, and lifecycle ordering.

Summary

  • Add opaque, ABA-safe compare-and-swap versions for CODEX_AUTH_JSON in SaaS SQL and supported OSS stores; preserve a runtime rotation when an unrelated stale whole-secret document is saved.
  • Reuse the sandbox session key plus one scoped JWE callback for credential load/replace; arm only for saved ChatGPT credentials, authoritative Codex ACP settings, SaaS remote or OSS Docker, and all three exact SDK 1.37.1 capabilities.
  • Remove the managed plaintext credential from the Agent Server start request after readiness succeeds; bind initial activation and failed-create cleanup to the exact internal start-task tag.
  • Persist one non-secret managed marker, add one idempotent conversation-scoped reactivation endpoint, and gate WebSocket reconnect on recovery.
  • Run the SDK drain barrier before clean remote/Docker pause or delete, and propagate managed final-flush failures.

Issue Number

OpenHands/OpenHands#15393

How to Test

Unit tests (both pass on a clean checkout):

# Use a fresh persistence dir — a pre-initialised local sqlite file masks the
# uninitialised-DB path that the pause barrier exercises.
OH_PERSISTENCE_DIR=$(mktemp -d) uv run pytest tests/unit/app_server/ -q
cd frontend && npx vitest run __tests__/conversation-websocket-handler.test.tsx \
  __tests__/hooks/use-websocket.test.ts __tests__/utils/websocket-url.test.ts \
  __tests__/hooks/use-sandbox-recovery.test.tsx

End-to-end validation was run on the original PR against the unmodified pinned SDK/Agent Server 1.37.1 release (99342c4), built into openhands-agent-server-e2e:99342c4-1.37.1-python-source-minimal, using a copied local ChatGPT credential without printing it:

  1. Started managed runtime A and completed a real Codex API request.
  2. Forced a credential refresh and verified the canonical R0 -> R1 update.
  3. Raced an unrelated stale settings edit and verified both R1 and the unrelated edit survived.
  4. Cleanly paused and reactivated A, then completed a second real Codex request.
  5. Started isolated runtime B from the canonical value and completed a real Codex request.
  6. Verified B's private credential matched the canonical store, the legacy auth path was absent, no credential/session value appeared outside the canonical secret store, and the private file was scrubbed before pause.

Type

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

Notes

Complexity boundary. 19 production files and roughly 1.5k added production lines, versus 83 files and >10k in #15287. No reservation/lease rows, migrations, schedulers, sibling scans, resume-task claiming, generic credential broker, pending-message changes, or duplicated SDK monitor/merge logic. OSS process sandboxes remain legacy. Docker retains its existing single-user container session key and receives a fresh scoped JWE on activation; Phase 1 does not claim pre-pause JWE rejection for Docker. SaaS remote rotates the runtime session key before reactivation.

Review history carried over from #15394. Two independent review passes (5 and 15 findings) were triaged against the #15393 design; 2 findings were withdrawn as describing intended behaviour, and a further review pass corrected three of the resulting fixes. The last four commits are that convergence:

Commit What
60afce766 failed-create cleanup disarmed before READY; arming restricted to SaaS remote / OSS Docker; reject pre-existing conversation IDs; local-record cleanup after managed scrub
d24d48b40 stale 1013 close guard scoped to a socket-identity check in useWebSocket
d5e880415 legacy fail-open restored on the pause/delete barrier; key-revocation handler widened; shared-session commit removed from resume_sandbox; Docker APIError handling restored; SaaS Codex row lock order aligned
153494549 pause barrier stubbed per-test in three legacy lifecycle tests (the fixture-wide managed-marker stub was removed because it made the key-revocation tests vacuous)

Two deliberate non-fixes, both design-mandated:

  • _prepare_for_pause is not gated on codex_credential_sync_enabled(). #15393 §Rollout requires reactivation to ignore the flag for already-marked conversations so a rollback preserves availability. The residual cost with the flag off is one indexed conversation scan per pause and no HTTP. Please do not optimise this away with a cache, an index, or a marker column on the sandbox row.
  • The session-key revocation still happens after the drain barrier, per merge gate 9 ("pause drains before remote authorization invalidation"). Only the exception handler was widened.

Known follow-up (not Phase 1). RemoteSandboxService.get_sandbox maps every runtime-API failure to MISSING, so a transient 503 is indistinguishable from a gone runtime. Managed conversation delete therefore fails closed on MISSING, which means a managed conversation whose runtime is genuinely gone cannot be deleted. Teaching get_sandbox to distinguish a definitive 404 from a transient error touches every caller and belongs in its own PR.

CI. The last three commits never got a CI signal on #15394: once its base branch became Agent Canvas the PR went CONFLICTING, so GitHub could not build refs/pull/N/merge and no pull_request workflow could run. This PR is the first real signal for them. The enterprise/storage/saas_secrets_store.py lock-order change is the one edit with no local test coverage — the enterprise suite could not be run locally.


Enterprise server image for this PR:

ghcr.io/openhands/enterprise-server:sha-77a7fd9

simonrosenberg and others added 8 commits July 27, 2026 01:45
The previous guard suppressed onclose for every consumer of useWebSocket
whenever the URL changed, which also swallowed setIsConnected and reconnect
bookkeeping for sockets unrelated to credential binding. Compare the closing
socket's conversation against the selected one at the single call site that
acts on 1013 instead, so the shared hook keeps its existing behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…5394)

Addresses review findings that the Phase 1 barrier changed behaviour for
untagged sandboxes, which #15393 requires to keep their previous pause and
delete semantics.

- _prepare_for_pause: resolve the agent server URL inside the `required`
  guard so an unmanaged sandbox with no exposed URL still pauses and deletes.
  With this, a flag-off deployment does one conversation scan and no HTTP.
- delete_sandbox: catch Exception, not just httpx.HTTPError, so a SandboxError
  from the barrier still commits the session key revocation. The barrier keeps
  running before the revocation, per merge gate 9.
- resume_sandbox: drop the commit on the shared request session. It flushed
  the caller's staged work, so a failed managed delete durably removed the
  sub-conversations it had staged. The caller owns the transaction, as before.
- Docker get_sandbox: restore APIError handling so a daemon hiccup degrades to
  missing rather than 500ing every conversation view.
- Docker delete_sandbox: skip the barrier for an unresolvable container rather
  than raising past container.stop()/remove() and leaking it with its volume.
- Managed conversation delete: treat MISSING like a missing sandbox, and stop
  re-raising per sub-conversation so one failure cannot orphan its siblings.
- load_credential_binding: only an invalid credential maps to 422. A ValueError
  from an unreadable store surfaced as "please authenticate again".
- SaaS store(): lock the Codex rows in the same order as replace_versioned.

Also drops the fixture stub that made the key revocation tests vacuous and
covers the raising-predicate and barrier-failure paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dropping the fixture-wide managed-marker stub left these three tests calling
the real _has_managed_credential_conversation, which opens an admin injector
against a conversation database CI has not initialised. Stub _prepare_for_pause
per test instead of restoring the broad fixture stub, which would make the key
revocation tests vacuous again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Revert the MISSING shortcut in managed conversation delete. Remote
  get_sandbox() swallows every runtime-API failure and reports MISSING, so a
  transient 503 is indistinguishable from a gone runtime and the shortcut could
  skip the final credential flush before dropping metadata. Absence is not
  definitive here, so fail closed. A managed conversation whose runtime is
  genuinely gone still cannot be deleted; distinguishing a definitive 404 from a
  transient error inside get_sandbox() is left as follow-up.
- Docker pause/delete: losing the image tag hides the sandbox, not its binding,
  so query the marker instead of assuming either way. Managed fails closed;
  legacy keeps its previous fail-open behaviour. This replaces the unconditional
  failure on both paths, which pre-dated the barrier on neither.
- _delete_sub_conversations: remember a managed child's failure, finish the
  sibling loop, then re-raise, so siblings are still deleted but the parent is
  not removed around an unflushed managed child.
- Replace the conversation-URL comparison with a socket identity check in
  useWebSocket: only dispatch onClose when the closing socket is still the
  current one. This drops the URL parser and its tests and covers every stale
  socket callback rather than only the 1013 path. The provider regression test
  is kept and still fails without the guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The 20s deadline fails deterministically on a 2-CPU runner: four spawn
processes each re-import the app tree, then serialise on the cross-process
lock, while `-n auto --forked --cov` competes for the same two cores. Observed
6.3s locally when idle, 11-13s under load, and two consecutive CI failures with
exitcode None (the process had not exited, so the CAS assertions never ran).

Every assertion is unchanged, so the exactly-one-winner guarantee is still
proven; the deadline stays bounded so a real lock bug still fails rather than
hanging.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  enterprise/storage
  saas_secrets_store.py 26-27, 47-65, 72-81, 86-153, 160-164, 171-177, 188-214, 221-233, 236, 247
  openhands/app_server/app_conversation
  app_conversation_models.py
  live_status_app_conversation_service.py 417-437, 456-460, 511-532, 553-562, 575-584, 628-637, 674-710, 722-787, 2396-2397, 2409-2480, 2946-2949, 2979-2984, 2993-3006, 3012-3095
  openhands/app_server/event_callback
  webhook_router.py 125-135, 142-153, 401-409
  openhands/app_server/file_store
  files.py 46, 50, 53
  local.py 18, 37-38, 42, 45-76, 79-80
  memory.py 24, 27-28, 31
  openhands/app_server/sandbox
  docker_sandbox_service.py 521-526, 536-537, 541-548, 552-561, 569-575
  remote_sandbox_service.py 518-519, 532-558, 570-575, 590-603, 620-628, 662-671, 679-714, 854, 869-879
  sandbox_router.py 152, 168-239, 288, 298-365, 369-370, 380-395, 409-432
  sandbox_service.py 288, 291-310, 317-362, 397, 417-422
  openhands/app_server/secrets
  credential_binding.py 50, 58, 62-66, 78, 97, 101, 112-126, 143-195, 199-202
  file_secrets_store.py 37-43, 47-52, 56-64, 68-73, 81-87, 94-101, 105-108, 111, 114-129, 136-211, 222-241, 254-285
  secrets_store.py 40, 49
Project Total  

This report was generated by python-coverage-comment-action

@simonrosenberg
simonrosenberg marked this pull request as ready for review July 27, 2026 17:28
@simonrosenberg
simonrosenberg requested review from a team, lilagrc and neubig and removed request for a team July 27, 2026 17:41
@simonrosenberg simonrosenberg self-assigned this Jul 27, 2026
@lilagrc
lilagrc requested a review from all-hands-bot July 27, 2026 23:16
simonrosenberg and others added 3 commits July 28, 2026 09:42
Both CI failures were `waitFor(() => expect(isConnected).toBe(true))` at
1004ms and 1014ms — the 1000ms asyncUtilTimeout default. The msw WebSocket
handshake does not reliably complete inside 1s on a 2-CPU runner, and this
file has eight such waits, so any of them can fail. The file is already
documented as CI-flaky (#11944) with four tests skipped for the same reason.

Raised in beforeAll and restored in afterAll so the longer deadline cannot
leak into other files sharing the worker. A longer deadline only delays a
failure, so no assertion is weakened.

Not reproducible locally: the file passes standalone, and in the full 2496-test
suite, even under 10x CPU load.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
neubig
neubig previously requested changes Jul 28, 2026

@neubig neubig left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Follow-up with a concrete regression test:

I pushed commit 22bc0f1 to this branch. It adds a file-backed SQLite test with two independent SQLAlchemy sessions, following the async SQLite fixture pattern in enterprise/tests/unit/conftest.py and the real RemoteSandboxService pattern in TestDeleteSandboxKeyHandling.

The test fails both locally and in CI. CI ran 2,351 app-server tests: 2,350 passed, and test_resumed_key_is_visible_to_callback_session_before_request_commit was the only failure because get_sandbox_by_session_api_key returned None for the fresh key:

https://github.com/OpenHands/enterprise/actions/runs/30351645934/job/90250267607

This confirms that resume_sandbox only stages the rotated hash in the outer request transaction, while the credential callback authenticates through a separate request/session before that transaction can commit. Please revise the session-key rotation so the fresh hash is durably visible before the Agent Server activation probe, without committing unrelated work staged in the shared request session, and make the regression test pass.

Regression-test commit: 22bc0f1

@neubig
neubig dismissed their stale review July 28, 2026 11:07

Superseded by a self-contained PR comment addressed directly to the PR author.

@neubig

neubig commented Jul 28, 2026

Copy link
Copy Markdown
Member

@simonrosenberg I found a release-blocking transaction-visibility issue in the managed credential reactivation path.

Problem

When a SaaS remote sandbox resumes, the runtime API returns a fresh session key. RemoteSandboxService.resume_sandbox updates session_api_key_hash on the SQLAlchemy object, but it does not commit that update. The activation endpoint then waits for the sandbox and calls activate_codex_credential_binding in the same outer request.

Agent Server handles that activation by synchronously probing the credential callback with the fresh session key. The callback is a separate HTTP request with an independent database session, and its authentication path calls get_sandbox_by_session_api_key. Since the resume request has not committed yet, the callback still sees the old hash and rejects the fresh key. The outer request cannot commit until activation returns, while activation waits for this callback probe, so managed reactivation after a pause fails deterministically. The paused or error managed-delete path has the same resume-then-activate ordering.

Reproduction

I pushed this regression-test commit directly to the PR branch:

22bc0f1

The test follows the file-backed async SQLite pattern in enterprise/tests/unit/conftest.py and the real RemoteSandboxService setup in TestDeleteSandboxKeyHandling. It:

  1. Creates a file-backed SQLite database and seeds the sandbox with the old committed key hash.
  2. Calls the real RemoteSandboxService.resume_sandbox using one SQLAlchemy session, with only the external runtime behavior and unrelated collaborators mocked.
  3. Before committing that session, creates a second session representing the callback request.
  4. Calls the real get_sandbox_by_session_api_key with the fresh key.

The second session returns None, demonstrating that the callback cannot authenticate the key that activation just supplied. The test fails identically locally and in CI. CI ran 2,351 app-server tests; 2,350 passed, and this regression was the only failure:

https://github.com/OpenHands/enterprise/actions/runs/30351645934/job/90250267607

Requested revision

Please persist the rotated session-key hash in a transaction that is visible to the callback before the Agent Server activation probe begins, and make the regression test pass. Please avoid indiscriminately committing the shared request session, since callers may have unrelated lifecycle mutations staged there.

Managed reactivation failed deterministically. resume_sandbox staged the
rotated session_api_key_hash on the shared request session without committing,
then activation had the Agent Server synchronously probe the credential
callback with the fresh key. The callback is a separate request with its own
session, so it still read the old hash and rejected the key, while the outer
request could not commit until activation returned.

Persist the hash through a sibling session on the same engine and commit it
there, so the callback observes it immediately without committing the caller's
unrelated staged work -- which is what previously caused a failed managed
delete to durably remove its sub-conversations.

The commit runs before the value is staged locally: were it staged first, an
autoflush would leave the outer transaction holding a write lock on that row
and the two transactions would block each other.

A single-writer backend still cannot commit while the request session holds a
write, which pause_old_sandboxes creates whenever the user is over the sandbox
cap. That now raises a SandboxError instead of escaping as a raw
OperationalError, so it is a retryable failure rather than a resume that
reports success while the callback keeps rejecting the key.

Covers the paused/error managed-delete path too, since it resumes through the
same method.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@simonrosenberg

Copy link
Copy Markdown
Member Author

Thanks — diagnosis confirmed, and this was my regression. Fixed in 92040ef3f; your test passes.

Cause

An earlier review pass flagged that resume_sandbox committing the shared request session flushed callers' staged work — a failed managed delete durably removed the sub-conversations it had staged. I removed the commit, reasoning from git history that it was added by this PR so deleting it was a safe revert. That was wrong: the PR added the commit and the callback that depends on it, so reverting one half of a feature is not a safe revert. Both constraints are real, which is why your "don't indiscriminately commit the shared session" wording is the right framing.

Fix

_commit_session_api_key_hash writes the rotated hash through a sibling session on the same engine (AsyncSession.bind) and commits there. The callback sees it immediately; the caller's staged work is never flushed.

One ordering detail worth naming: the commit runs before the value is staged locally. Staged first, an autoflush would leave the outer transaction holding a write lock on that row — and since the outer request is waiting on activation, which waits on the callback probe, the two transactions would block each other. That inversion is the deadlock your report describes, just relocated.

Your second point is covered without a separate change: the paused/error managed-delete path resumes via the same method (live_status_app_conversation_service.py:3030 → activate at :3059).

A limitation I found in my own fix — please sanity-check this call

I wrote an adversarial test and it failed, so flagging it rather than leaving it latent. A single-writer backend cannot commit while the request session already holds a write, and pause_old_sandboxes creates exactly that whenever the user is over the sandbox cap: it stages session_api_key_hash = None per paused sandbox without committing, and the subsequent _get_stored_sandbox autoflushes it. On SQLite that is a whole-database lock, so the isolated commit gets database is locked. On PostgreSQL the locks are row-level and the rows differ (we resume a paused sandbox; pause_old_sandboxes only touches running ones), so production is unaffected.

Rather than paper over it, that path now raises SandboxError instead of letting a raw OperationalError escape resume_sandbox — a retryable failure rather than a resume that reports success while the callback keeps rejecting the key. Test: test_blocked_persist_fails_loudly_instead_of_silently.

The clean structural fix would be to make pause_sandbox's revocation durable through the same helper, which removes the contention source and matches the guarantee delete_sandbox already documents. I left it out because it changes pause semantics — a /pause failure after the clear would leave the key revoked rather than restored by rollback — and that felt like your call, not something to slip into this PR. Happy to do it here or file it as a follow-up.

Verification

  • Your regression test passes.
  • test_session_key_invalidated_and_survives_rollback and the delete-path key tests still pass — they guard the opposite direction.
  • 1585 passing in tests/unit/app_server on a clean database (4 remaining failures are pre-existing macOS-only env assertions).
  • Two tests added: rollback survival, and the loud-failure guarantee above.

Note the e2e I ran yesterday would not have caught this: local Docker does not rotate session keys on resume, so the callback never saw a stale hash. Only SaaS remote exercises it.

@simonrosenberg
simonrosenberg requested a review from neubig July 28, 2026 13:58
@simonrosenberg

Copy link
Copy Markdown
Member Author

@OpenHands /codereview-roasted read latest comments make sure no bugs were introduced when fixing the bugs pointed out by neubig.

@openhands-ai

openhands-ai Bot commented Jul 28, 2026

Copy link
Copy Markdown

I'm on it! simonrosenberg can track my progress at all-hands.dev

@simonrosenberg

Copy link
Copy Markdown
Member Author

@OpenHands /codereview-roasted read latest comments make sure no bugs were introduced when fixing the bugs pointed out by neubig.

@openhands-ai

openhands-ai Bot commented Jul 29, 2026

Copy link
Copy Markdown

I'm on it! simonrosenberg can track my progress at all-hands.dev

@simonrosenberg simonrosenberg left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🔴 Needs improvement

GitHub does not permit the PR author account to submit REQUEST_CHANGES on its own PR. This COMMENT review is intended as blocking feedback.

[CRITICAL ISSUES]

  • Concurrent remote resumes can restore an invalidated session-key hash after the new isolated commit. See the inline finding.

[TESTING GAPS]

  • The new visibility and rollback tests pass, but neither overlaps two request sessions through their final request commits. Add a regression that proves the newest runtime rotation remains canonical after both outer sessions finish.

Focused verification: 4 relevant remote-sandbox persistence tests passed. I also reproduced the race with the real StoredRemoteSandbox, _commit_session_api_key_hash, and two independent AsyncSessions: helper A committed hash-A, helper B committed hash-B, outer B committed, then outer A committed; the final database value was hash-A.

[RISK ASSESSMENT]

  • [Overall PR] ⚠️ Risk Assessment: 🔴 HIGH
    This PR changes authentication, secret synchronization, and sandbox lifecycle ordering. The demonstrated race can make the canonical hash disagree with the only live runtime key, deterministically breaking callback authentication under overlapping resumes. Recommendation: Do not auto-merge. Request a human reviewer to validate the transaction/identity-map fix and its concurrency test.

VERDICT:
Needs rework: The visibility fix solves Neubig’s single-request failure but introduces a stale outer-transaction overwrite under concurrency.

KEY INSIGHT:
An isolated commit is not enough if the original request still marks the same ORM attribute dirty and flushes it later.

This review was created by an AI agent (OpenHands) on behalf of the requester.


Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing. See the customization docs.
  2. Re-request a review; the reviewer reads guidelines from the PR branch.
  3. When merged, the guideline file goes through normal maintainer review.

Resolve with AI? Install the iterate skill and run /iterate to drive CI, review, and QA.

Was this review helpful? React with 👍 or 👎 to give feedback.

Comment thread openhands/app_server/sandbox/remote_sandbox_service.py Outdated
@openhands-ai

openhands-ai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Reviewed PR #77 and posted a blocking review: #77 (review)

Key finding: the fix commits the rotated hash in a sibling transaction, then marks the same ORM attribute dirty in the outer request. Two overlapping resumes can commit out of order and restore an invalidated older hash. I reproduced this with the real model/helper and two AsyncSessions; the final database value was hash-A after the newer hash-B had committed.

Verification:

  • 4 focused remote-sandbox persistence tests passed.
  • All current PR workflow runs succeeded.
  • Worktree remains clean; no code was modified.

The review was submitted as COMMENTED because the available GitHub token belongs to the PR author, and GitHub forbids authors from submitting REQUEST_CHANGES on their own PR. The review explicitly states that the finding is blocking.

simonrosenberg and others added 2 commits July 29, 2026 17:56
…77)

Committing the rotated hash in a sibling transaction was not sufficient: the
value was then assigned back onto the request-scoped ORM row, leaving the
attribute dirty so the outer session re-emitted it at request exit. Two
overlapping resumes could therefore commit out of order -- A rotates, B rotates
and invalidates A's key, then A's request commits and restores A's hash, so the
callback rejects the only live runtime key.

Use set_committed_value so the identity map reflects the persisted value without
marking it dirty. The sibling transaction stays the only writer of this column.

Adds an overlapping-resume regression that drives two request sessions through
their final commits in reverse order and asserts the newer hash survives; it
fails against the previous assignment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@simonrosenberg

Copy link
Copy Markdown
Member Author

@OpenHands /codereview-roasted

@openhands-ai

openhands-ai Bot commented Jul 30, 2026

Copy link
Copy Markdown

I'm on it! simonrosenberg can track my progress at all-hands.dev

@simonrosenberg simonrosenberg left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🟢 Good taste

GitHub does not permit the PR author account to submit APPROVE on its own PR. This COMMENT review records an approval-equivalent, non-blocking verdict.

The latest fix in 184cc3208 correctly closes the prior race: set_committed_value updates the request-scoped identity map without marking session_api_key_hash dirty, so the outer request cannot re-emit an older hash after the sibling transaction commits. The new SQLite-backed overlap regression fails under plain assignment and passes with the committed-value update. I found no bug introduced by this correction.

Focused verification:

  • 5 targeted persistence tests passed: callback visibility, rollback-safe invalidation, blocked isolated persist, persistence across outer rollback, and reversed outer-commit overlap.
  • Exact-head CI is green for Linux Python, enterprise Python, frontend unit/E2E, Docker, and all lint jobs.
  • The upstream runtime API atomically permits only one PAUSED/ERRORSTARTING transition, so normal concurrent resume requests cannot both rotate a key.

VERDICT:
Worth merging: The previously blocking transaction-visibility and stale outer-flush defects are both resolved.

KEY INSIGHT:
The isolated transaction must be the sole writer of the rotated hash; the request session may observe that value but must never flush it.

This review was created by an AI agent (OpenHands) on behalf of the requester.

[RISK ASSESSMENT]

  • [Overall PR] ⚠️ Risk Assessment: 🔴 HIGH
    This remains intrinsically high-risk because it changes authentication, secret synchronization, sandbox lifecycle ordering, and recovery across backend and frontend boundaries. The implementation has focused database-backed regressions, exact-head CI, and documented end-to-end evidence, but it should not be auto-merged. Recommendation: Require human reviewer/architect sign-off on the credential-binding protocol and production SaaS lifecycle behavior.

@openhands-ai

openhands-ai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Reviewed PR #77 and posted an approval-equivalent review: #77 (review)

  • No regression found in 184cc3208; set_committed_value correctly prevents stale outer-session re-flushes.
  • Five focused session-key persistence tests passed.
  • Exact-head Python, enterprise, frontend, Docker, and lint CI jobs are green.
  • Review state is COMMENTED because the authenticated GitHub account is the PR author, so GitHub forbids self-approval.
  • No code was changed; the worktree is clean.

Security note: the repository remote initially contained an embedded GitHub credential, which appeared in command output. I sanitized the remote to https://github.com/OpenHands/enterprise.git. The exposed token should be rotated if it is not ephemeral.

resume_sandbox calls pause_old_sandboxes to make room, but that pass could
select the very sandbox being resumed: the resume endpoint has no status guard,
so a client can resume an already-running sandbox, and if the user is at the cap
and that sandbox is their oldest it gets picked.

pause_sandbox then stages session_api_key_hash = None on the request session.
The following _get_stored_sandbox autoflushes it, so the request holds a row
lock on that row, and the sibling transaction that commits the rotated hash
blocks behind it while the request is awaiting that very commit. PostgreSQL
cannot report this as a deadlock -- one side is an application await, not a lock
cycle -- so the request hangs on the lock instead of failing.

Exclude the target from the pause pass. This also drops a pointless
pause-then-resume of the same sandbox, which would have discarded its key.

Both pause_old_sandboxes implementations needed it; patching only the base class
left RemoteSandboxService's override unchanged, which the new test caught.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@simonrosenberg

Copy link
Copy Markdown
Member Author

Correction: SQLite framing was wrong, and there is a real PostgreSQL hang

Pushed 77a7fd92f.

In my earlier note on 92040ef3f I said a persist failure needed a single-writer backend, and that PostgreSQL was safe because "the rows differ (we resume a paused sandbox; pause_old_sandboxes only touches running ones)". That reasoning was wrong, and SQLite isn't a production path for RemoteSandboxService anyway — enterprise migrations are PostgreSQL-only (postgresql+pg8000), so SQLite is dev/test only. Please disregard that framing; there was a genuine production bug hiding behind it.

The actual bug

resume_sandbox can be called on an already-running sandbox — the resume endpoint (sandbox_router.py:148) has no status guard. So if the user is at max_num_sandboxes and the target is their oldest running sandbox, pause_old_sandboxes selects the sandbox being resumed. Then:

  1. pause_sandbox(target) stages session_api_key_hash = None on the request session.
  2. _get_stored_sandbox(target) autoflushes it (autoflush is on — the injector doesn't disable it), so the request now holds a row lock on that row.
  3. _commit_session_api_key_hash(target, …) opens the sibling transaction and blocks on that lock.
  4. The request is awaiting step 3.

PostgreSQL can't report this as a deadlock — one side is an application await, not a lock cycle — so it hangs on the lock rather than failing, holding two pool connections. Strictly worse than the error I'd guarded for.

Fix

Exclude the target from the pause pass. It also removes a pointless pause-then-resume of the same sandbox, which would have thrown away the key we're about to rotate.

Worth noting: RemoteSandboxService overrides pause_old_sandboxes, so patching only the base class in sandbox_service.py changed nothing on the remote path. The new test caught that.

Verification

  • test_resume_does_not_pause_its_own_sandbox — target is the oldest running sandbox and over the cap. Confirmed a real regression test: without the exclusion it fails with resume paused the sandbox it was resuming: ['sb-self'].
  • Updated 5 existing tests that assert the pause_old_sandboxes call signature.
  • 1587 passing in tests/unit/app_server (4 remaining failures are pre-existing macOS-only env assertions).

The SandboxError guard from 92040ef3f stays, but on its correct merits: any persist failure (lock timeout, statement timeout, dropped connection, pool exhaustion) must be loud and retryable rather than a resume that returns True while the callback rejects the only live key.

@simonrosenberg

Copy link
Copy Markdown
Member Author

Code review (high effort, workflow-backed)

10 verified findings, most severe first.

Correctness / security

1. Session-key hash invalidated after fallible network calls, not beforeopenhands/app_server/sandbox/remote_sandbox_service.py:611
pause_sandbox/delete_sandbox now clear stored_sandbox.session_api_key_hash only after the new credential-drain round trip (_get_runtime + _prepare_for_pause), instead of before any further work. A leaked session_api_key used to pause/delete a sandbox specifically to cut off access remains valid for the whole drain window (up to the pause-prepare timeout plus support-check/runtime-lookup calls), even though the drain itself doesn't depend on the DB hash being live.

2. required=False fail-open contract broken on transient network errorsopenhands/app_server/secrets/credential_binding.py:153
activate_codex_credential_binding's bare except Exception always raises SandboxError, regardless of required. _prepare_codex_credential_binding calls this with required=False as a best-effort probe during conversation start; a transient httpx.ConnectError/ReadTimeout (not an HTTPStatusError) or malformed capability payload turns an optional probe failure into a hard abort of the whole conversation-start flow.

3. Partial sub-conversation deletion surfaces as a misleading 404openhands/app_server/app_conversation/live_status_app_conversation_service.py:3006
_delete_sub_conversations re-raises a failed managed (Codex-bound) sub-conversation's error after the loop, but delete_app_conversation's blanket except Exception: return False swallows it into an API-level 404 — even though sibling sub-conversations were already deleted from both the agent server and the DB before the failure. The client sees "not found" for what was actually a partial destructive operation.

4. New activate endpoint 404s if AppConversationInfo row is missing/staleopenhands/app_server/sandbox/sandbox_router.py:156
The frontend's resumeV1ConversationSandbox now calls the new POST /{sandbox_id}/credential-bindings/{conversation_id}/activate instead of the old /resume. This endpoint 404s if get_app_conversation_info(conversation_id) is None or sandbox_id doesn't match — before ever calling resume_sandbox. A precondition the old plain /resume never had. In multi-tab/replication-lag scenarios this can fail a resume that would have succeeded via the old path, surfacing as "Failed to start conversation" in useSandboxRecovery's tab-focus/initial-load recovery.

5. ws.onerror missing the stale-socket identity guard added to ws.onclosefrontend/src/hooks/use-websocket.ts:120
This PR adds if (ws === wsRef.current) before optionsRef.current?.onClose?.(event) because a superseded socket could otherwise deliver its event to whichever conversation is currently selected — but the same guard wasn't added to ws.onerror. A stale socket's genuine error event can still misattribute a connection error to the conversation the user has already navigated away from.

6. Router duplicates resume/status logic, diverges from the service on MISSINGopenhands/app_server/sandbox/sandbox_router.py:194
activate_conversation_credential_binding reimplements the "resume before managed op" dance already in LiveStatusAppConversationService._delete_from_agent_server, but with different status handling: the router resumes for anything outside (RUNNING, STARTING) — including MISSING — while the service only resumes for (STARTING, PAUSED, ERROR) and raises otherwise. Same conceptual operation, different behavior depending on entry point.

7. (Plausible, not fully confirmed) Deleted invariant against auto-resume on WS disconnectfrontend/src/hooks/use-sandbox-recovery.ts:20
The docstring "WebSocket disconnect does NOT automatically resume the sandbox" and its negative test were removed; the hook now exposes recoverCredentialBinding, called by a ws.onClose(1013, credential_binding_activation_required) handler to force a resume regardless of sandboxStatus. If that close code/reason is ever set by something other than the intended handshake (proxy/LB reuse, an unrelated bug), this now silently triggers a real resume — the exact behavior the deleted test asserted must never happen — with no remaining regression test.

Cleanup

8. Fail-open/closed branch duplicated 6x in one methodopenhands/app_server/sandbox/sandbox_service.py:312
_prepare_for_pause repeats the identical if not required: return / raise SandboxError(...) idiom at 5-6 separate failure points. A future policy change (new exception type, required/optional semantics) has to touch all of them; missing one silently breaks the policy for just that failure mode. Worth extracting into a single _fail_or_skip(required, message, cause=None) helper.

9. Unconditional full DB scan on every pause/delete for a yes/no checkopenhands/app_server/sandbox/sandbox_service.py:290
_has_managed_credential_conversation does a full paginated search_app_conversation_info scan on every pause_sandbox/delete_sandbox call, for every sandbox — not just managed ones. Adds latency/DB load to a hot lifecycle path, worse for sandboxes with many sub-conversations.

10. Multi-paragraph narrative docstringsopenhands/app_server/sandbox/remote_sandbox_service.py:524 (also 550, 594)
_commit_session_api_key_hash and neighboring code carry multi-line docstrings/comments narrating cross-module reasoning (how the credential callback authenticates, how the Agent Server probes it) — this drifts out of sync as those other components change and belongs in the PR description, not the source.


One duplicated-CAS-preserve-logic finding across file_secrets_store.py/saas_secrets_store.py was found but dropped to respect a 10-item cap as the least severe surviving item.

@simonrosenberg

Copy link
Copy Markdown
Member Author

Triage of the 10 findings, plus why this PR keeps not converging

I verified all 10 findings from the review above against the code at head (77a7fd92f), then went looking for what four review passes have missed. Summary: 4 of the 10 are real defects, 3 are the design asking for two contradictory things, 2 are cosmetic. And there are 4 unreported findings, two of which brick a conversation permanently.

The 10 findings

# Verdict Note
1 Design-mandated This is merge gate 9 ("pause drains before remote authorization invalidation"). The window is real; the PR description already declares it a deliberate non-fix.
2 Real credential_binding.py:153except ExceptionSandboxError regardless of required. _prepare_codex_credential_binding (:2464) passes required=False and doesn't catch, so a transient ConnectError aborts conversation start.
3 Real, and worse than described Confirmed at live_status:3006:2955app_conversation_router.py:989. But the DB deletes roll back while the agent-server DELETEs already happened, so the real outcome is DB rows orphaned against deleted agent-server conversations — reported to the client as 404.
4 + 6 Real, and they are one bug conversation-mutation-utils.ts:75 routes every v1 resume through the managed activate endpoint, which adds three preconditions /resume never had. And sandbox_router.py:197 resumes on MISSING while live_status:3022 raises on it — two copies of the same 20-line dance with different semantics.
5 Real, low use-websocket.ts:119 onerror has no identity guard; neither does setIsConnected(false) in either handler. Pre-existing, but this PR created the asymmetry.
7 Cosmetic The negative test was replaced by an intentional feature, not deleted.
8 Design-mandated Six fail-open branches exist because the barrier runs for every sandbox and then has to un-run itself for legacy ones. Symptom of #1/#9, not an independent issue.
9 Explicitly forbidden The PR description says "please do not optimise this away with a cache, an index, or a marker column."
10 Cosmetic Valid style point.

Four findings no review pass has reported

The first two are conversation-bricking, and I only realised how severe after reading the SDK's latching behaviour in 1.37.1:

  • acp_file_credentials.py:176,196CredentialNeedsReauthentication and CredentialSyncError are both latched via _set_error and re-raised by _raise_sticky_error on every subsequent load and flush.
  • :435_refresh_authorization_state clears the latch only for CredentialAuthorizationRejected.
  • credential.py:76reauthorize() mutates the binding in place, so the lifecycle object (and its latch) survives reactivation.

So the SDK contract is: 403 is clearable by reactivation; 404, 422 and 5xx are permanent. Given that:

  1. saas_secrets_store.py::_require_organization_id raises KeyError for three distinct conditions — unresolvable org id, non-UUID user_id, and OrgMemberStore.get_org_member(...) is None. The router maps KeyError → 404, the SDK maps 404 → CredentialNeedsReauthentication, and that latch is permanent. A membership-lookup hiccup permanently bricks the conversation, and reactivation cannot clear it. Fix: return 403 for authz failure and reserve 404 for a genuinely absent row. The SDK already has the right bucket (CredentialAuthorizationRejected) — we're picking the wrong door.

  2. file_secrets_store.py:233load_versioned writes the file during a GET to mint a missing generation. On a read-only volume that's an uncaught OSError → 500 → CredentialSyncError → also permanently latched. (I flagged this in the pass-3 review of #15287; it survived the rewrite.)

  3. The pause barrier runs for legacy sandboxes. In _prepare_for_pause, both agent_server_supports_credential_binding (a GET) and prepare-for-sandbox-pause (a POST) execute regardless of required — only the error handling branches on it. On a 1.37.1 image every untagged sandbox now pays a DB scan plus two round trips on pause and delete. #15393 says untagged sandboxes "keep their previous pause/delete behavior"; they keep the previous outcome on failure, not the behavior.

  4. invalidate_legacy_secrets_store (settings_router.py:390) destroys the canonical credential on a GET. GET /settings resolves provider_tokens (establishing a baseline via load()), then calls store(Secrets(provider_tokens=...)) with custom_secrets={}. Because a baseline exists and submitted_codex=None ≠ R1, preserve_codex is False, the delete is unfiltered, and every custom-secret row is deleted with nothing re-inserted. This is pre-existing, not introduced here — but it means merge gate 2 holds only for the case the test writes, while the invariant the feature needs is false in production today. Note the perversity: having a baseline is what makes it not preserve.

Why this keeps not converging

Five mechanisms, in increasing order of importance.

"Clean" has never meant what it sounds like. The 07-30 07:41 review says "No regression found in 184cc3208" and "Five focused session-key persistence tests passed." That is a verdict on the last fix. 79 minutes later a whole-PR review found 10 items. Those results don't contradict each other — they answer different questions. Every round here has verified the delta and declared the feature clean.

There is no executable definition of correct. #15393 has 12 numbered merge gates. None exists as a named test. So every reviewer re-derives the spec from the code, and the code's local reasoning always admits a different reading than the prose. Findings 1, 8 and 9 are the same reviewer-vs-spec collision three times.

The design mandates contradictory invariants and the code sits on the seam. "Revoke the session key up front" (the comment this PR deleted from delete_sandbox) vs. "drain before invalidating authorization" (gate 9). "One barrier before every pause" vs. "untagged sandboxes keep previous behavior." These cannot both hold, so the implementation encodes the tension as branches and each reviewer finds a different branch. Findings 1, 2, 8 and my #3 are all that seam. They are not fixable; they are re-findable.

Every bug that actually shipped lives in the one layer with no real tests. The four regressions found by humans and bots — transaction visibility, identity-map re-flush, pause_old_sandboxes self-selection, set_committed_value — are all cross-transaction ordering bugs. Now count mocks: test_file_secrets_store_versioning.py has 2 mocks for 10 tests and a real cross-process CAS test, and that layer has produced zero escaped bugs. test_sandbox_router.py has 83 mocks for 16 tests, and that layer has produced all of them. CI is 18/18 green right now and proves nothing here. The manual e2e can't help either — as I noted on 92040ef3f, local Docker doesn't rotate session keys on resume, so the production path has never been exercised end to end.

The deepest one. preserve_codex / _loaded_codex_auth bolts a CAS onto a store whose contract is "delete every row, insert what I sent," using per-request in-memory state. That is not a correctness mechanism, it's a probability reduction — and unreported finding 4 above is the proof that its floor is "destroys the credential on a GET." As long as CODEX_AUTH_JSON lives inside the whole-document Secrets blob, every new write path is a new way to lose it, and no amount of review converges on that.

Plan

Three subtractive design changes. Each deletes code and turns a behaviour into a structural invariant. All three are app-server-side — no SDK change, 1.37.1 stays pinned, and the #15393 cut ("no runtime-api or Agent Server changes") holds.

A. Take CODEX_AUTH_JSON out of the Secrets document. Its own record with its own version, which store() structurally cannot reach. Then delete _loaded_codex_auth, preserve_codex, the _merge_entries special-casing, the codex_rows locking, the description carry-forward, and the delete-query filter — roughly 150 lines across both stores, plus the 11th finding the reviewer dropped for the cap. Merge gate 2 stops being a tested behaviour and becomes a type invariant: nothing can submit a stale whole document over it because the document no longer contains it. This is invisible to the SDK, which only cares that a GET returns an opaque version string.

B. One reactivation call site. Extract ensure_managed_binding_active(sandbox_id, conversation_id); call it from start, router and delete. Revert conversation-mutation-utils.ts to SandboxService.resumeSandbox — unmanaged conversations should not transit a credential endpoint at all. Closes 4 and 6 and the divergence class.

C. Make the barrier conditional at the top, not fail-open six times inside. if not await self._is_managed(sandbox): return as line one of _prepare_for_pause. One predicate, one policy. Legacy sandboxes then literally keep their previous behaviour (my #3 goes away), 8's duplication goes away because there is one branch, and 9's cost applies only to managed sandboxes.

Then decide finding 1 once, explicitly. Gate 9 versus prompt revocation is a security trade-off with a real number attached — barrier timeout plus two round trips. Pick it, bound the timeout, write test_merge_gate_9_..., and put the number in the description. It stops being re-findable.

One rule the design never stated, and should: the callback may only emit a non-403 failure when the condition is definitive and permanent. Anything transient must succeed, retry internally, or return 403. That rule is testable, which is more than gate 9 currently is.

Process, and this matters more than the code:

  1. Write the 12 merge gates as 12 named tests before touching anything else. A gate that can't be a test isn't real — renegotiate it in #15393 rather than leaving it as prose the next reviewer re-derives.
  2. Stop the review loop until those exist. Four passes have each verified a delta; a fifth will find a fifth set.
  3. Split the PR. Doing this now — see below.
  4. Replace mocked router tests with app-level tests against a real DB and a real agent-server stub. This is the single highest-value change on the list: the escaping bug class is transaction ordering, and mocks cannot represent it.
  5. Run the e2e against SaaS remote, not Docker.

Two things I'd want from the SDK eventually, neither blocking: CredentialSyncError from a transient 5xx is permanently latched and nothing clears it (widening _refresh_authorization_state would be defence in depth); and _credential_bindings at conversation_service.py:585 is an in-memory dict, so any agent-server restart loses bindings — which makes the 1013 recovery path load-bearing rather than an edge case, worth knowing when weighing finding 7. Both belong in Phase 2 (OpenHands/software-agent-sdk#4171).

Splitting out the store/CAS layer

Per point 3, I'm opening a separate PR for the store and CAS layer — secrets_store.py, file_secrets_store.py, saas_secrets_store.py, file_store/{files,local,memory}.py and their tests. That slice has real non-mocked concurrency tests including a cross-process CAS test, has produced zero escaped bugs across four review passes, and has no callers until this PR's router lands — so it is inert and safe to land first. It leaves this PR as the lifecycle and frontend change, which is the part that actually needs the transaction-level test treatment.

That PR is a straight extraction: it carries preserve_codex and the two bricking bugs above forward unchanged rather than smuggling design change A into a "split" PR, since moving the credential out of the document is a schema decision for this repo's maintainers and not mine to make quietly. Both are listed in its description.

@simonrosenberg

Copy link
Copy Markdown
Member Author

Store/CAS layer split out as #100 — eight files, byte-identical to this PR's head (77a7fd92f), verified file by file. It's inert on merge (load_versioned/replace_versioned have no callers until this PR's callback router lands), so it can land first and shrink this PR to the lifecycle and frontend change.

Its description lists the three known issues it carries forward unchanged rather than fixing — the two sticky-404 bricking bugs and preserve_codex — since each needs either a version-derivation decision or a SaaS schema change, and neither belongs in a PR labelled as a split.

@simonrosenberg

Copy link
Copy Markdown
Member Author

splitting this PR into this smaller one: #100

@simonrosenberg

Copy link
Copy Markdown
Member Author

Closing, evidence-based — not a judgment on the code quality here, which per the review history is solid.

This is the enterprise-side wiring for the write-back design in #70 (now closed). #120's production telemetry (Status & Closing Plan) found the mechanism this addresses — cross-conversation Codex credential rotation — affects at most 1 of 78 observed sandboxes, and that the credential-binding machinery this depends on already auto-activates and works for the case that actually occurs (sandbox reuse with an already-persisted local credential) via software-agent-sdk#4124/#4198, both already merged and confirmed deployed to SaaS, with no enterprise-side wiring required.

Building the external HTTP activation endpoint / CAS-backed write-back path this PR adds isn't justified by what production actually shows. Re-open if new evidence changes that — the design itself isn't wrong, it's just not what the data says to build right now.

@linear

linear Bot commented Aug 7, 2026

Copy link
Copy Markdown
OHE-3025 Codex auth.json: production evidence says re-scope OHE-2794 (config + data loss, not credential sync)

Telemetry-first investigation of the Codex auth.json problem. OHE-2794's description is Local Agent Server / Local Docker / Saas — no symptom, repro, error, frequency, or customer need — so the symptom record was built from production evidence rather than from the design chain.

Full writeup: enterprise#120. This issue is the OHE-side summary of what the logs actually show and what to do next.

Headline

Of 73 sandboxes that started codex-acp in a 15-day window, only ~15 (21%) reached a working authenticated session. The dominant failure is no credential present at all, not credential staleness. The write-back design in #70 targets a mechanism observed in at most 1 of 11 relevant failures, and the machinery it builds on has no caller in ent/main.

Limits on all numbers below

  • Datadog log retention is ~15 days, not 30–90. Oldest log 2026-07-19T14:25:16Z; window is 2026-07-19 → 2026-08-03. The single incident named in sdk#4170 (SaaS, 2026-07-15) falls outside it and can be neither verified nor refuted.
  • All evidence is SaaS remote runtimes (110/110 Codex lines cluster_name:prod-runtime). OHE-2794's Local Agent Server and Local Docker rows are unobserved, not disproven.
  • No user-level telemetry. Runtime pod logs carry no user/org id. Per-user counts are inferred from per-user MCP config fingerprints.

New information from the logs

Population — small feature

  • 73 sandboxes started codex-acp (108 init events); 43 distinct conversations materialised CODEX_AUTH_JSON; ~3/day.
  • Scanned all 111 live runtime pods for /workspace/conversations/*/acp/*/zero live ACP credential dirs.

Two distinct failure modes

A. Failed to start ACP server: Authentication required — 48 events / 27 pods. Always preceded by ACP server offers auth methods ['api-key','chat-gpt'] but no matching env var is set, i.e. _select_auth_method returned None and authenticate() was never called.

  • 21 pods (78%): no CODEX_AUTH_JSON at all — no materialisation line of any kind. User picked the Codex agent with neither a ChatGPT blob nor OPENAI_API_KEY/CODEX_API_KEY. We provisioned a sandbox and failed inside the runtime.
  • 6 pods (22%): blob present but rejected by the SDK's own is_valid_codex_auth. Fingerprints suggest ≤4 users; three of six share a custom MCP server sorensadrgit-art (almost certainly one user). Deterministic — one pod retried 5× over 4 min, all failing.

B. Failed to start ACP server: Invalid params — 13 events / 11 pods. This is the signature sdk#4170 describes. All 11 materialised the blob and authenticated chat-gpt successfully. Then:

  • Failure lands exactly 600s after Authenticating with ACP method: chat-gpt, ±0.4s across all 11 → a fixed deadline, not a variable upstream condition.
  • 10 of 11 are the first conversation on that sandbox — no sibling conversation existed to rotate R0→R1.
  • 1 of 11 shows the cross-conversation pattern (runtime-linexdkmfsjqkxwc: conv 1836480… keeps authenticating at 09:14/11:35/12:05 while newly-seeded ef9aa16b… fails at 11:48).

Zero-hit signatures, with a control that makes the zeros meaningful

All of: credential_binding_materialized, _rotation_detected, _replace, _final_flush, _monitor_failed; CredentialNeedsReauthentication, CredentialSyncError, CredentialAuthorizationRejected, CredentialConflict, CredentialBindingUnsupported; ACPAuthRequired; refresh_token_reused; "credential-bindings"; "ACP startup timed out"all 0.

Control: "Materialised ACP file-secret" = 46, same logger, same INFO level, same pods. INFO reaches Datadog, so these are absence of the event, not missing plumbing.

False leads ruled out

  • invalid_grant (8892 hits) is entirely data-platform Keycloak/HubSpot. Unrelated.
  • model_not_found (130), gpt-5.2-codex (198), gpt-5.1-codex-max (90) are conversation-title-generation LLM errors, a separate defect.
  • OSS-5415 is a different bug — non-ACP LLM profile, Missing scopes: api.responses.write.
  • Most codex+error hits are git branch-name false positives (origin/codex/*).

Live cluster findings (read-only kubectl)

  • Zero restarts / zero prior terminations across runtime-pods → Failure B is a hang, not a crash or OOM.
  • The 600s exists in no k8s config: OH_RUNTIME_IDLE_TIMEOUT_SECONDS=1200, activeDeadlineSeconds unset, SANDBOX_CLOSE_DELAY=1800, SANDBOX_REMOTE_RUNTIME_API_TIMEOUT=60, no ingress/traefik timeouts, no acp_startup_timeout override anywhere → internal to codex-acp 1.1.2. Which means the SDK's own 90s acp_startup_timeout should have fired at 90s and did not ("ACP startup timed out" = 0 hits).
  • Active agent-server version split: 89 pods on 1.39.1-python, 7 on 1.36.0-python — several created the same day (14:08/14:10/14:49 UTC 2026-08-03), so live, not drift. 1.36.0 has no acp_file_credentials.py at all and uses the looser OSS-1742 check ("tokens" in json) vs 1.39.1's stricter is_valid_codex_auth (auth_mode + non-empty refresh_token). Two running versions disagree on which credentials are valid.
  • 152 FailedScheduling: persistentvolumeclaim "runtime-…" not found — every sandbox start races its own PVC.
  • 105 of 107 runtime pods have no memory or CPU limit (limits=[ephemeral-storage] only); 2 have all three. Inconsistent sandbox spec.
  • No CODEX_HOME in the pod env → set per-conversation by the SDK, ruling out a pod-spec collision.

Code findings

  • ent/main @ 7cca8f8d7 has zero credential_binding / CredentialBinding references → no caller for the agent-server's PUT /{conversation_id}/credential-bindings/{secret_name}. The 1.39.1 VersionedCredentialBinding path never runs in prod. acp_file_credentials.py is byte-identical v1.37.1→v1.39.1, so the version gap doesn't rescue it.
  • Latent defect in that machinery: _monitor_loop (acp_file_credentials.py:240-256) returns on both exception arms, permanently killing the monitor thread; _raise_sticky_error then latches and is cleared only for CredentialAuthorizationRejected. One transient blip would brick credential sync for a conversation. No prod impact today; guaranteed impact the moment a binding activates.
  • No shape validation at entry. CODEX_AUTH_JSON appears in ent/main only as a frontend constant. The UI hint tells users to paste ~/.codex/auth.json, which is {"auth_mode":"apikey",...}-shaped if their local Codex used an API key — silently accepted, stored, materialised, then rejected in a runtime pod.
  • #102 is live on the prod path. load_settings (the GET /api/v1/settings handler, settings_router.py:180) → invalidate_legacy_secrets_store:383 builds Secrets(provider_tokens=...) with custom_secrets={}; SaasSecretsStore.store() deletes every StoredCustomSecrets row for (user, org) then inserts nothing. Total, one-shot, unrecoverable. Opening the app is sufficient. This is the only mechanism reproduced against real code that produces the 21-pod shape.
  • #101 does not fix #102. Its guard is PROTECTED_CREDENTIAL_NAMES = frozenset({'CODEX_AUTH_JSON'}), applied by narrowing the delete; settings_router.py is untouched (empty diff vs ent/main). After fix(secrets): make the managed-credential write guard structural #101, GET /settings still deletes every custom secret except the Codex one — including invalidate_legacy_secrets_store deletes all custom secrets on GET /settings #102's own repro secrets MY_API_KEY and DEPLOY_TOKEN.

Recommendations

  1. Preserve custom_secrets in invalidate_legacy_secrets_store — ~3 lines, credential-agnostic, fixes every secret name on both stores, targets main with no dependency on feat(secrets): add versioned compare-and-swap to the secrets stores #100/fix(secrets): make the managed-credential write guard structural #101. Ship first. See the #102 comment.
  2. Pre-flight credential check before launching a Codex ACP conversation (the 21-of-27 bucket).
  3. Validate CODEX_AUTH_JSON shape at entry with the is_valid_codex_auth predicate, and fix the misleading UI hint (the 6-of-27 bucket).
  4. Make acp_startup_timeout actually bound startup so a rejected credential surfaces a re-auth CTA in seconds instead of a 10-minute silent hang.
  5. Capture the codex-acp subprocess logrefresh_token_reused is the diagnostic sdk#4170 relies on and we don't collect it. Without it, sibling rotation vs external rotation vs plain expiry is unresolvable.
  6. Re-scope #70 / #100 / #101 — see the correction below.
  7. Fix the sticky-error latch before the binding path ships.
  8. Infra: converge the agent-server version split; fix the PVC race; reconcile missing resource limits.

Correction issued after further checking

Posted at enterprise#120 comment. Two claims revised, one in each direction:

  • I overstated the residual store hazard. Of 12 store() call sites, 11 already load-then-merge correctly; invalidate_legacy_secrets_store is the sole violator. So recommendation 1 is a genuine fix, not a workaround — and hardening the store contract is lower priority than I implied.
  • I was wrong that feat(secrets): add versioned compare-and-swap to the secrets stores #100's CAS is justified only by the write-back design. openhands/app_server/secrets/ has no locking of any kind, load_versioned/replace_versioned don't exist on main, the codebase already admits the multi-worker gap for profiles (settings_router.py:417), and prod runs 16 enterprise-server replicas. Every secrets write is an unguarded read-modify-write across 16 workers — a real, Codex-independent correctness gap for which CAS is the standard remedy. #100 should be evaluated on that basis, not as scaffolding for Phase 1: minimally synchronize managed Codex credentials across runtimes #70. Note recommendation 1's own implementation is a read-modify-write and inherits this exposure; still strictly better than today's unconditional delete.

Unchanged: #101's Codex allowlist and #70's rotation premise. sdk#4171 states its own status — "No production incident has been attributed to this race yet" — which the window confirms.

Ask

Re-scope OHE-2794 from a credential-synchronisation problem to a configuration + data-loss problem, and sequence recommendation 1 ahead of the #70 design work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: feat A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants