Skip to content

fix(secrets): make the managed-credential write guard structural - #101

Closed
simonrosenberg wants to merge 3 commits into
versioned-credential-storefrom
protected-credential-writes
Closed

fix(secrets): make the managed-credential write guard structural#101
simonrosenberg wants to merge 3 commits into
versioned-credential-storefrom
protected-credential-writes

Conversation

@simonrosenberg

@simonrosenberg simonrosenberg commented Jul 30, 2026

Copy link
Copy Markdown
Member

HUMAN:

Stacked on #100. Implements the follow-up that PR's notes describe as issue 3 — turning the managed-credential write guard from a remembered baseline into a structural one.

  • A human has tested these changes.

AGENT:

Base is versioned-credential-store (#100), not main — it deletes machinery that only exists there. Merge #100 first and this retargets to main cleanly.

Design rationale: #77 (comment) (§ "Plan", change A). Parent design: OpenHands/OpenHands#15393 §1.


Why

#100 protects CODEX_AUTH_JSON from a stale whole-document save using a baseline remembered in per-request memory: store preserves the credential only when the same store instance called load first and the submitted value equals what it read. That makes durability depend on a caller's in-process history, and two live paths defeat it. Both are now regression tests, and I verified both fail against #100's head:

(a) blind stale save      -> {"tokens":{"refresh_token":"r0"}}     # rotation undone
(b) legacy-migration save -> <<GONE>>                              # credential deleted
  • (a) A whole-document save on an instance that never called load has no baseline, so the value it carries wins and a runtime rotation is undone. FileSecretsStore: preserve is False whenever baseline is None and the payload names the key. SaasSecretsStore: same via has_baseline.
  • (b) invalidate_legacy_secrets_store (settings_router.py:390) runs inside GET /settings. It resolves provider_tokens — which calls load, establishing a baseline — then saves Secrets(provider_tokens=...) with empty custom_secrets. Baseline R1 ≠ submitted None, so preserve is False, the delete is unfiltered, and the credential is removed with nothing re-inserted. On the SaaS path load_versioned then raises KeyError → 404 → which SDK 1.37.1 latches as a permanent CredentialNeedsReauthentication.

Note the inversion in (b): having a baseline is what makes it not preserve. That is the tell that the mechanism is the wrong shape.

Summary

store now drops protected names from the submitted document and excludes their rows from its delete — unconditionally, on both stores and on both the locked and unlocked file-store paths. Whether the caller loaded first stops mattering, which lets the following go away entirely:

  • _loaded_codex_auth and _LoadedCredential (file store)
  • _loaded_codex_auth (SaaS store)
  • preserve / preserve_codex in both store implementations
  • the with_for_update() Codex row lock in SaaS store and its description carry-forward — including the lock-ordering edit from d5e880415 that feat(app-server): sync managed Codex credentials (Phase 1) #77 flagged as its one change with no local test coverage

Writing a protected credential is now possible only through replace_protected_credential / delete_protected_credential. The three /secrets endpoints dispatch to them so a user can still manage their own credential; renaming into or out of a protected name is refused (400) rather than silently breaking an armed binding.

What deliberately does not change. The credential stays a CustomSecret in the same table, with the same JwtService row encryption and the same SecretStr model, and load still returns it. So the arming predicate at live_status_app_conversation_service.py:2430 (which compares the request value against user_context.get_secrets()), OSS process sandboxes on the legacy path, and flag-off rollback all keep working. No schema change, no migration.

One intentional behaviour change beyond the fix: a whole-document save no longer updates a protected entry's description either. It has no authority over that entry at all. The user-facing description edit still works through the per-key writer.

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

17 passed. Four are new: test_store_cannot_create_a_protected_credential, test_store_cannot_overwrite_a_rotation_without_a_prior_load, test_whole_document_save_without_custom_secrets_keeps_the_credential, test_protected_delete_removes_value_and_generation. Existing tests that seeded the credential through store now seed through the per-key writer, which is the contract change.

The enterprise suite does run locally#77 and #100 both say it can't, but the only blockers are missing test-only deps in the root uv venv:

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/test_saas_secrets_store.py -q \
  -p no:ddtrace -p no:ddtrace.pytest_bdd

15 passed, including two new SaaS regressions mirroring (a) and (b).

Full suites, both compared against #100 as the baseline:

Suite This branch Pre-existing failures
tests/unit/app_server/ 1508 passed, 4 failed Same 4 on #100 (macOS TMPDIR, missing server module)
enterprise/tests/unit/ 2633 passed, 17 failed Same 17 on #100 (slack-sdk, posthog, saas_server route order)

Seven enterprise/tests/unit files still can't be collected locally — all need google-cloud-recaptcha-enterprise and none touch secrets.

Lint: both pre-commit configs pass — root and enterprise (run from enterprise/).

Type

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

Notes

Still not fixed here, and still listed on #100 — the other two carried-forward issues, because each needs a decision I shouldn't make unilaterally:

  1. file_secrets_store.pyload_versioned still writes during a GET to mint a generation for a credential that predates this feature. Removing the write means either deriving the bootstrap version from the value (weakens merge gate 3's ABA guarantee for exactly the first rotation, and #15393 explicitly rejected a plaintext digest) or minting the generation at arming time in feat(app-server): sync managed Codex credentials (Phase 1) #77's layer. Worth deciding in the issue.
  2. saas_secrets_store.py::_require_organization_id still raises KeyError for a revoked org membership → 404 → permanent SDK latch. The correct status is 403, which the SDK can clear on reactivation, but the mapping lives in sandbox_router.py in feat(app-server): sync managed Codex credentials (Phase 1) #77 — so it needs a distinct exception type here plus a router change there.

Scope note. The unconditional delete in SaaS store still wipes non-protected custom secrets for the shape in (b), and FileSecretsStore._merge_entries drops keys absent from the incoming document. That's pre-existing on main, independent of the credential work, and I left it alone — but invalidate_legacy_secrets_store destroying a user's custom secrets on a GET deserves its own issue.


Enterprise server image for this PR:

ghcr.io/openhands/enterprise-server:sha-ebc6b74

The guard this replaces was a baseline remembered in per-request memory:
``store`` preserved CODEX_AUTH_JSON only when the same store instance had
called ``load`` first and the submitted value matched what it read. Two live
paths defeat that, both proven as regression tests here:

- a whole-document save on an instance that never loaded restores the value it
  carries, undoing a runtime rotation;
- ``invalidate_legacy_secrets_store`` runs inside GET /settings, loads, then
  saves with empty custom_secrets, which deletes the credential outright.

``store`` now drops protected names from the submitted document and excludes
their rows from its delete, unconditionally. Whether the caller loaded first
no longer matters, so ``_loaded_codex_auth``, ``preserve``/``preserve_codex``,
the Codex row lock and its description carry-forward all go away.

Writing one is now only possible through ``replace_protected_credential`` /
``delete_protected_credential``, which the three /secrets endpoints dispatch to
so a user can still manage their own credential. Renaming into or out of a
protected name is refused rather than silently breaking an armed binding.

The credential stays a CustomSecret in the same table with the same row
encryption and the same SecretStr model, and ``load`` still returns it, so the
arming predicate, OSS process sandboxes and flag-off rollback are unaffected.
No schema change.

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

Copy link
Copy Markdown
Member Author

Filed the scope note as #102 — the general form of this bug. invalidate_legacy_secrets_store destroys all custom secrets on GET /settings, not just the managed credential; this PR only immunises CODEX_AUTH_JSON.

Reproduced against main (7cca8f8d7) with the real function, and pinned the blast radius: OSS only, because SaasSettingsStore has no secrets_store handling so the trigger field is always empty in SaaS. Not a production SaaS exposure — which is also why it doesn't belong in this stack.

@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 25, 42, 45, 57-62, 69-71, 79-113, 121-148, 155-163, 166-175, 183-188
  openhands/app_server/secrets
  file_secrets_store.py 32-34, 41, 115, 119, 125-126, 129-163, 171-192, 199-213, 216-233
  secrets_router.py 56, 283-298, 353-368, 412-419
  secrets_store.py 20, 50, 66, 79, 82
Project Total  

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

Self-review of the previous commit found it reintroduced the exact failure it
set out to remove. The PUT /secrets/{name} branch for a protected name called
replace_protected_credential with the value it had loaded, so a metadata edit
was a read-modify-write over a rotating credential:

  value after description-only edit : {"tokens":{"refresh_token":"r0"}}
  rotation preserved?               : False
  generation changed by metadata edit?: True

Two defects in one: it restored a value the runtime had already rotated away,
and it minted a new generation, invalidating the runtime's compare-and-swap
token so the next flush would 409 on a description change.

Adds set_protected_credential_description, which touches neither the value nor
the generation, and points the endpoint at it.

The router branches had no test coverage, which is why this got through. Adds
six tests over the real FastAPI app and a real store covering create, delete,
description edit, both rename refusals, and an unrelated create leaving a
rotated credential intact; test_managed_description_edit_keeps_value_and_generation
fails against the previous commit.

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

Copy link
Copy Markdown
Member Author

Self-review found a bug I introduced here — fixed in 979b8d50c

Asked myself whether green CI meant this was actually correct. It didn't.

The PUT /secrets/{name} branch for a protected name called replace_protected_credential with the value it had just loaded — making a description edit a read-modify-write over a rotating credential, which is precisely the failure class this PR exists to eliminate, reintroduced through a new door:

value after description-only edit   : {"tokens":{"refresh_token":"r0"}}
rotation preserved?                 : False
generation changed by metadata edit?: True

Two defects in one:

  1. It restored a value the runtime had already rotated away.
  2. It minted a new generation, invalidating the runtime's CAS token — so a description change would make the next flush 409.

Fix: set_protected_credential_description, which touches neither the value nor the generation, on both stores; the endpoint now uses it.

Why it got through: the three router dispatch branches had zero test coverage. That's the same diagnosis as #77 — the bug lived in the layer without real tests. Added six tests over the real FastAPI app and a real store (create, delete, description edit, both rename refusals, and an unrelated create leaving a rotated credential intact), plus store-level regressions on both backends. test_managed_description_edit_keeps_value_and_generation fails against f706c54e7, verified.

Re-verified: 1516 passed / 4 pre-existing (app_server), 2634 passed / 17 pre-existing (enterprise), both lint configs clean.

@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 built-in structural guard and /secrets dispatch are well-shaped and the new real-path tests catch the prior clobber, but the public store extension contract is broken.

[CRITICAL ISSUES]

  • One blocking compatibility regression for existing custom SecretsStore implementations is documented inline.

[RISK ASSESSMENT]

  • [Overall PR] ⚠️ Risk Assessment: 🔴 HIGH
    This changes user-facing secret writes and a documented secrets-storage extension point. The built-in stores are covered, but existing external implementations now fail at runtime for the protected name. Recommendation: Do not auto-merge; have a human reviewer validate the backward-compatibility/fallback contract for custom stores.

VERDICT:
Needs rework: Preserve the legacy custom-store path or introduce explicit capability handling instead of a runtime 500.

KEY INSIGHT:
A structural guard is the right design for supporting stores, but unsupported extension implementations need an explicit legacy boundary rather than inherited traps.

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.

description: str | None = None,
) -> None:
"""Write a protected credential, the only user-facing way to change one."""
raise NotImplementedError

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: SecretsStore is documented above as an application extension point, but existing implementations only had to implement load(), store(), and get_instance(). They inherit this NotImplementedError, while the new /secrets dispatch unconditionally calls these per-key methods for CODEX_AUTH_JSON; I reproduced an old-style custom store returning 201 for a normal POST and 500 for the protected POST. Preserve a legacy fallback or explicit capability dispatch for stores that do not implement protected per-key operations (the built-in stores can retain the structural path). Otherwise this needs to be declared as a breaking extension API and rejected clearly at configuration/startup rather than failing user requests at runtime.

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

Both review findings confirmed and fixed in ebc6b748e. I reproduced each before changing anything.

1. load() required write access — real, and worse than reported

plain read works        : True
load() on read-only dir : FAILS -> PermissionError ... secrets.json.lock

The review missed a second consequence: locked_update takes flock(LOCK_EX), so every read serialised. Four concurrent load()s over a 0.4s critical section took 1.62s — that's every GET /settings and GET /secrets contending in all deployments, not only read-only ones.

Two corrections that make the fix simpler than suggested: this came from #100, not this PR, and #100 needed the lock only to snapshot the preserve baseline. This branch deleted that baseline, so the lock was dead weight. load() is a plain read again, exactly as before #100 — a subtraction with no trade-off, so the "the baseline can still be recorded from that snapshot" hedge no longer applies.

2. Extension-point break — real

My first repro was wrong (patched the wrong symbol, got 201/201). Patching the actual extension point, shared.SecretsStoreImpl:

POST ordinary secret : 201
POST CODEX_AUTH_JSON : 500

The defect is sharper than a missing method: a legacy store's own store() has no protected-name filtering, so the protection never applied there — but the dispatch did. I'd decoupled two halves of one feature.

Fixed with a supports_protected_credentials capability mirroring the existing FileStore.supports_locked_update idiom: False on the base class, True on both first-party stores. A store without it keeps the whole-document path, which matches the 501 the callback already returns for such stores.

Verification

Both new tests fail against 979b8d50c. 1518 passed / 4 pre-existing (app_server), 2634 passed / 17 pre-existing (enterprise), both lint configs clean.

Neither finding duplicated a declared-deferred item, and the read-only one was something I'd listed as a residual risk and under-rated — good catch.

@simonrosenberg

Copy link
Copy Markdown
Member Author

Closing, evidence-based, same reasoning as #77 (which this stacks on) — not a judgment on the code, which is sound.

Two things this PR did were worth doing, and both are now handled elsewhere:

Turning the write guard from a remembered baseline into a structural one (this PR's actual contribution) is good hardening if the write-back design ships — it isn't shipping on current evidence, so there's nothing for it to harden yet. Re-open alongside #77 if that changes.

@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: fix A bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant