Skip to content

feat(secrets): add versioned compare-and-swap to the secrets stores - #100

Closed
simonrosenberg wants to merge 2 commits into
mainfrom
versioned-credential-store
Closed

feat(secrets): add versioned compare-and-swap to the secrets stores#100
simonrosenberg wants to merge 2 commits into
mainfrom
versioned-credential-store

Conversation

@simonrosenberg

@simonrosenberg simonrosenberg commented Jul 30, 2026

Copy link
Copy Markdown
Member

HUMAN:

Split out of #77 so the store layer can be reviewed on its own. See the triage comment on that PR for why: #77 (comment)

  • A human has tested these changes.

AGENT:

Parent PR: #77. Design: OpenHands/OpenHands#15393 §1 ("Add only a versioned canonical-store contract").


Why

#77 has been through four review passes without converging. The triage linked above breaks down where its bugs actually live, and the split is clean:

  • This layer (secrets_store, file_secrets_store, saas_secrets_store, file_store/*) has real non-mocked concurrency tests — including a cross-process CAS test that spawns four processes and asserts exactly one winner — and has produced zero escaped bugs across all four passes.
  • The lifecycle/frontend layer has 83 mocks across 16 router tests and has produced every escaped bug: transaction visibility, identity-map re-flush, pause_old_sandboxes self-selection. All cross-transaction ordering bugs that mocks cannot represent.

Landing this separately shrinks #77 to the part that actually needs transaction-level test treatment, and gets the well-tested half out of a review loop it isn't causing.

This is safe to land first because it is inert. load_versioned and replace_versioned have no callers until #77's callback router lands. Nothing in this diff is reachable from a request path.

Summary

  • SecretsStore gains load_versioned(name, organization_id) -> (value, opaque_version) and replace_versioned(name, expected_version, value, organization_id) -> successor_version, plus a CredentialVersionConflict exception. Both default to NotImplementedError so unsupported stores keep the legacy path (the callback turns that into a 501).
  • FileStore gains supports_locked_update / locked_update. LocalFileStore implements it with flock (msvcrt on Windows), InMemoryFileStore with an RLock. Other stores report unsupported.
  • FileSecretsStore persists an opaque random generation alongside the secret document and does read/compare/write inside that lock.
  • SaasSecretsStore locks all matching Codex rows, treats the newest duplicate as canonical, checks its opaque row generation, and rewrites duplicates together.
  • Both stores narrowly remember the Codex value loaded by an ordinary read-modify-write request, so a stale whole-document save applies its unrelated edits without restoring the older credential.

The eight files are byte-identical to #77's head (77a7fd92f). No behaviour is added, removed, or changed in the split — verified file by file. Review this as the extraction it is.

Issue Number

OpenHands/OpenHands#15393

How to Test

OH_PERSISTENCE_DIR=$(mktemp -d) uv run pytest tests/unit/app_server/test_file_secrets_store_versioning.py -q

11 passed. Full suite: 1502 passed, 4 failed in tests/unit/app_server/; the 4 are pre-existing macOS-only environment assertions (TMPDIR vs /tmp, and a missing server module) — confirmed by running them on a clean main checkout with these changes stashed, where they fail identically.

Lint: both pre-commit configs pass on all eight files — root (./dev_config/python/.pre-commit-config.yaml) and enterprise (enterprise/dev_config/python/.pre-commit-config.yaml, run from enterprise/). The enterprise files are formatted to the enterprise config, so a bare root ruff format will want to reformat them; that is expected and matches 4d391c2d7 on #77.

The enterprise suite does run locally — #77 says it can't, but the only blockers are test-only deps missing from the root uv venv (the enterprise poetry venv is the dead end, since it resolves openhands-sdk 1.29.0 against a tree needing newer):

uv pip install python-keycloak freezegun gspread limits resend slack_sdk stripe
OH_PERSISTENCE_DIR=$(mktemp -d) PYTHONPATH="enterprise:." \
  uv run --no-sync pytest enterprise/tests/unit -q -p no:ddtrace -p no:ddtrace.pytest_bdd

enterprise/tests/unit/test_saas_secrets_store.py: 15 passed. Whole suite: 2633 passed, 17 failed — the 17 are pre-existing (slack-sdk, posthog, saas_server route order), confirmed identical on main. Seven files still need google-cloud-recaptcha-enterprise to collect; none touch secrets. CI covers those.

Type

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

Notes

Three known issues are carried forward unchanged rather than fixed here, because fixing them means changing the version-derivation contract or reaching into #77's layer, and those are maintainer calls rather than something to slip into a PR labelled as a split. All three are in the triage comment with locations. Issue 3 now has a fix up as #101, stacked on this branch.

  1. file_secrets_store.py:233load_versioned writes the file during a GET to mint a missing generation. On a read-only volume that is an uncaught OSError → 500, which SDK 1.37.1 latches as a permanent CredentialSyncError (acp_file_credentials.py:196, cleared only for CredentialAuthorizationRejected at :435). Conversation-bricking once the callback is wired. Fixing it properly needs a decision on how to version a pre-existing secret that has no stored generation — #15393 explicitly rejected a plaintext digest, so there is no free answer.

  2. saas_secrets_store.py::_require_organization_id raises KeyError for three distinct conditions, including OrgMemberStore.get_org_member(...) is None. The callback maps KeyError → 404 → the same permanent latch. An authz revocation should be a 403, which the SDK can clear on reactivation. The status mapping lives in sandbox_router.py (in feat(app-server): sync managed Codex credentials (Phase 1) #77, not here), so the store needs a distinct exception type and the router needs to map it — a two-PR change.

  3. preserve_codex / _loaded_codex_auth is a CAS emulated on top of a store whose contract is "delete every row, insert what I sent," using per-request in-memory state. Two paths defeat it, and I verified both against this branch's head:

    (a) blind stale save      -> {"tokens":{"refresh_token":"r0"}}     # rotation undone
    (b) legacy-migration save -> <<GONE>>                              # credential deleted
    

    (b) is settings_router.py:390invalidate_legacy_secrets_store runs inside GET /settings, establishes a baseline, then saves custom_secrets={}, so preserve_codex is False and the row is deleted with nothing re-inserted. Pre-existing, not introduced by feat(app-server): sync managed Codex credentials (Phase 1) #77, but it means the invariant the feature needs is false today. Fixed in fix(secrets): make the managed-credential write guard structural #101: store drops protected names unconditionally, which deletes the baseline machinery and the Codex row lock. Worth correcting one thing I wrote earlier — that fix needs no schema change and no migration; the credential stays a CustomSecret in the same table with the same encryption and the same SecretStr model, and only who may write it changes.

None of the three is reachable while this PR is inert, which is the argument for landing it and fixing them against a smaller surface.


Enterprise server image for this PR:

ghcr.io/openhands/enterprise-server:sha-4c900e2

Split out of #77 so the store layer can be reviewed and landed on its own.
The files are byte-identical to that PR's head (77a7fd9); no behaviour is
added or changed here.

- SecretsStore gains load_versioned/replace_versioned and
  CredentialVersionConflict, both defaulting to NotImplementedError so
  unsupported stores keep the legacy path.
- FileStore gains supports_locked_update/locked_update; LocalFileStore
  implements it with flock (msvcrt on Windows), InMemoryFileStore with an
  RLock.
- FileSecretsStore stores an opaque random generation alongside the secret
  document and does read/compare/write under that lock.
- SaasSecretsStore locks all matching Codex rows, treats the newest
  duplicate as canonical, checks its row generation, and rewrites
  duplicates together.

The two new endpoints have no callers until #77's callback router lands, so
this is inert on merge.

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

github-actions Bot commented Jul 30, 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/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/secrets
  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

Copy link
Copy Markdown
Member Author

Follow-up for note 3 is up as #101, stacked on this branch: it replaces the remembered-baseline guard with a structural one, which deletes _loaded_codex_auth / preserve_codex and the Codex row lock.

Worth knowing while reviewing this PR: I proved both failure modes against this branch's head, as regression tests on #101

(a) blind stale save      -> {"tokens":{"refresh_token":"r0"}}     # rotation undone
(b) legacy-migration save -> <<GONE>>                              # credential deleted

(b) is invalidate_legacy_secrets_store inside GET /settings, and on the SaaS path it leaves load_versioned raising KeyError → 404 → a permanent SDK re-auth latch. So the preserve mechanism here holds only for the case its own test writes. Still fine to land this first — it's inert until #77's router exists — but #101 shouldn't lag far behind it.

Also: the enterprise suite does run locally, contrary to what this PR's description says. Only test-only deps were missing from the root uv venv — uv pip install python-keycloak freezegun gspread limits resend slack_sdk stripe, then PYTHONPATH="enterprise:." uv run --no-sync pytest enterprise/tests/unit. 2633 pass, 17 pre-existing failures. I'll correct that section.

@simonrosenberg

Copy link
Copy Markdown
Member Author

@OpenHands /codereview-roasted
Please review these two stacked PRs:

Review #100 first, then #101 as a delta. #101's base is versioned-credential-store,
not main.

Grounding:

Merged SDK PRs these pin against (Agent Server 1.37.1 — do not assume it can change):

Predecessor: #77 — these two were split
out of it. Read the triage comment first; it explains the split and lists what is
deliberately deferred:
#77 (comment)

Two known-deferred items are documented in #101's description — load_versioned
writing during a GET, and _require_organization_id returning 404 for an authz
failure. Confirming or sharpening these is useful; re-reporting them as new is not.

Highest-value focus: the /secrets router dispatch in #101. A clobber bug lived
there and its tests are new.

@openhands-ai

openhands-ai Bot commented Aug 3, 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.

🟡 Acceptable — The CAS implementation and real concurrency coverage are solid, but an existing read path now requires write access.

[CRITICAL ISSUES]

  • One blocking read-only regression is documented inline. This is distinct from the known-deferred load_versioned bootstrap write: ordinary load() now creates a lock sidecar.

[RISK ASSESSMENT]

  • [Overall PR] ⚠️ Risk Assessment: 🔴 HIGH
    This touches canonical secret persistence and breaks an existing read-only deployment mode on a request-reachable path, despite the PR's inertness claim. Recommendation: Do not auto-merge; have a human reviewer validate the read-only persistence behavior after the fix.

VERDICT:
Needs rework: Restore read-only ordinary loads before merging.

KEY INSIGHT:
Serialize read/compare/write mutations, but do not make an ordinary atomic read depend on creating a writable sidecar.

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


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 (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

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

self._loaded_codex_auth = _LoadedCredential(value)
return loaded

return await call_sync_from_async(

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.

🔴 Critical: load() was previously read-only, but this call enters LocalFileStore.locked_update(), which opens or creates <path>.lock. A readable secrets.json on a read-only persistence volume now raises PermissionError before the file is read; I reproduced this with the file at 0444 and its directory at 0555. This affects ordinary settings/secrets GETs immediately, not only the known-deferred load_versioned() bootstrap. Keep ordinary load() on _read_data() without requiring the sidecar lock (the baseline can still be recorded from that snapshot), or otherwise use a read strategy that does not require write permission, and add a read-only regression test.

@openhands-ai

openhands-ai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Reviewed both stacked PRs and posted AI-disclosed inline feedback:

  • PR #100 review: blocking regression at file_secrets_store.py:129. Ordinary load() now creates a lock sidecar, so a readable secrets file on a read-only volume fails with PermissionError. This is separate from the already-deferred load_versioned() GET write.
  • PR #101 review: blocking compatibility regression at secrets_store.py:52. Existing custom SecretsStore implementations inherit NotImplementedError; the new router dispatch makes a normal secret POST return 201 but CODEX_AUTH_JSON POST return 500.

Validation completed:

The reviews appear as COMMENTED because the authenticated GitHub account is the PR author and cannot request changes on its own PR. The review bodies clearly mark both findings as blocking.

simonrosenberg added a commit that referenced this pull request Aug 3, 2026
Addresses both review findings, each reproduced first.

load() took the update lock, which opens <path>.lock with O_CREAT. A readable
secrets.json on a read-only volume raised PermissionError where a plain read
succeeded, and the exclusive lock serialised every read: four concurrent loads
over a 0.4s critical section took 1.62s. #100 needed that lock to snapshot the
preserve baseline; this branch deleted the baseline, so it was dead weight.
load() is a plain read again, as it was before #100.

The /secrets dispatch called the per-key writers for CODEX_AUTH_JSON on any
store, but SecretsStore is a documented extension point and third-party
implementations only had to provide load/store/get_instance. They inherit
NotImplementedError, so a legacy custom store returned 500 for that one name
while ordinary secrets returned 201.

Gate the dispatch on a supports_protected_credentials capability, mirroring
FileStore.supports_locked_update: False on the base class, True on both
first-party stores. Withholding protected names from store() and writing them
per-key are two halves of one feature, so a store that lacks the second does
not get the first and keeps the whole-document path.

Both regressions fail against 979b8d5.

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

Copy link
Copy Markdown
Member Author

Not closing this one — decoupling it from the Codex write-back track instead, per the correction in #120's thread.

This PR was split out of #77 so the store layer could be reviewed independently, and #77 is now closed on Codex-specific evidence (see its closing comment). That evidence doesn't apply here: this PR's actual subject — every secrets write in production being an unguarded read-modify-write across 16 enterprise-server replicas, with no locking of any kind in openhands/app_server/secrets/ — is a real correctness gap independent of Codex, ACP, or the write-back design. It's also, per its own description, the half of the original #77 split with non-mocked concurrency tests (including a cross-process CAS test) and zero escaped bugs across four review passes.

Recommend evaluating this purely on those merits — general secrets-store concurrency safety — rather than as scaffolding for #70/#101 (both closed). #101 stacked on this one specifically to add Codex-scoped hardening on top; with that closed, this PR's own value stands or falls independent of it.

@simonrosenberg

Copy link
Copy Markdown
Member Author

Closing this as superseded after rechecking the underlying race, rather than applying #121's one-shot-migration reasoning.

The lost-update bug is real, ongoing, and worth fixing. It has now been fixed directly by #133: that PR reproduced lost writes through the live secrets router, serialized all five ongoing /api/v1/secrets read-modify-write endpoints per (user, org) (Redis across SaaS replicas and an in-process lock for OSS), merged on 2026-08-06, and shipped in enterprise-server 1.50.0.

Why #100 should not land on top of that fix:

I reran the implementation's claims on the current head (4c900e2):

  • File-store versioning suite: 11 passed, including the real four-process CAS test with exactly one winner.
  • SaaS store suite: 13 passed.
  • Full app-server unit suite: 1,530 passed / 4 environment-specific failures matching the PR's documented classes.
  • Full enterprise collection stopped on the same seven missing google.cloud.recaptchaenterprise_v1 dependency errors documented in the PR.

Operational evidence supports the risk but cannot prove a historical collision. Over the last 15 days, Datadog showed 432 deduplicated successful direct secrets writes (321 POST, 45 PUT, 66 DELETE). I found 39 sub-second, cross-pod pairs sharing an internal source IP among successful versioned-image requests, but those addresses are proxy/internal addresses and the endpoint logs contain no user, org, session, trace, or request identifier. Application-error and store-name searches found no consistency signal; a silent lost update is not recoverable from these logs after the fact.

The current production deployment has 15 ready replicas (not 16) and is still on enterprise-server 1.49.1. Multiple replicas increase collision probability, but two are already sufficient; the remaining operational action is rolling out the released 1.50.0 fix, not merging this unused CAS layer.

Tracking/index context: #120.

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

1 participant