refactor(auth): dedupe the last target auth-header selection onto the shared helper (BE-3275) - #630
Conversation
…d helper (BE-3275) The api_key -> X-API-Key / auth_token -> Bearer selection was hand-rolled in comfy_client._request, the last target-credential site outside http.py after BE-3274 moved transfer and the CQL engine onto target_auth_headers. Delegate to that helper so the selection (and its is_cloud leak gate) lives in exactly one place. Behavior-preserving under resolve_target's at-most-one-credential invariant: resolve_target is the only Target constructor in comfy_cli and derives both fields from a single resolved credential, so api_key-first vs the old Bearer-first ordering can never disagree in practice.
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 25 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
Comment |
There was a problem hiding this comment.
🔍 Cursor Review — Consolidated panel
Triggered by @mattmillerai.
Found 5 finding(s).
| Severity | Count |
|---|---|
| 🟡 Medium | 2 |
| 🟢 Low | 2 |
| ⚪ Nit | 1 |
Panel: 8/8 reviewers contributed findings.
…uth-header-selection
Routing comfy_client through target_auth_headers inverted that call site's credential precedence: the hand-rolled code was OAuth-first, the helper is api_key-first. Unreachable today — resolve_target derives both fields from a single resolved credential, so at most one is ever set — but it left the helper as the one place in the codebase that disagrees with every other credential decision, which review flagged (6 of 8 reviewers). Given both credentials, api_key-first would authenticate at the gateway as the API key while submit_prompt hands partner-API nodes the OAuth identity via extra_data.auth_token_comfy_org, and the resulting 401 could never self-heal because _try_refresh_token early-returns when api_key is set. Order the helper OAuth-first instead, matching resolve_target's own precedence (a live session beats API keys, which are on a deprecation path), the extra_data credential, and the refresh path. The tie-break tests are re-pinned to the new ordering rather than dropped, and the comments no longer claim a precedence the code doesn't have. Also from review: the leak-guard assertions used `"X-api-key" not in req.headers`, which only worked because Request.headers is keyed by the capitalize()d form — one casing change from silently testing nothing. They go through a case-insensitive _header() helper now. And the local stray-credential test asserts the request body too, since submit_prompt's extra_data injection carries its own is_cloud gate that the header assertions can't cover. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
bigcat88
left a comment
There was a problem hiding this comment.
Approving. The precedence flip is the only thing in this diff that could change a request, and you flagged it — so I went and tried to break the invariant it rests on, then checked what actually goes on the wire.
Wire-level: only the unreachable state differs
Recording server, Client._request driven directly against four Target shapes:
| Target | main |
this branch |
|---|---|---|
| cloud, api_key only | X-Api-Key: K123 |
X-Api-Key: K123 |
| cloud, auth_token only | Authorization: Bearer T456 |
Authorization: Bearer T456 |
| cloud, both (synthetic) | Authorization: Bearer T456 |
X-Api-Key: K123 |
| local, stray both | {} |
{} |
Every reachable state is byte-identical; the leak gate still drops both credentials on a local target.
The invariant holds — and it's enforced more strongly than your description claims
An AST sweep of comfy_cli/ for Target(...) constructions, object.__setattr__ calls, and direct .api_key / .auth_token assignments:
- Two
Target(...)constructions, not one —target.py:99(cloud) andtarget.py:118(local). The cloud one derives both fields from a singlecred, so exactly one is non-Noneby construction; the local one passesauth_token=None, noapi_key, and isis_cloud-gated anyway. Minor correction to "the onlyTarget(...)construction", with no impact on the conclusion. - One
object.__setattr__, atcomfy_client.py:183, exactly as you said. - The only direct assignment hit is
execution.py:116 self.api_key = api_key, which is a run-execution object, not aTarget.
Your "Note for the reviewer" is more pessimistic than reality. You describe a both-credentials Target where a 401 would refresh OAuth and then retry with X-API-Key — wasted work. That can't happen: _try_refresh_token bails twice before reaching the __setattr__:
if not self.target.is_cloud or not self.target.auth_token:
return False
...
if self.target.api_key:
return False # <- an api_key Target never refreshes at allBoth guards are pre-existing on main and untouched here, so the refresh path can neither create nor operate on a both-credentials Target. The invariant is enforced at construction and at the only mutation site.
Judgment calls
Agreed on keeping target_auth_headers over the ticket's auth_headers(target, *, cloud_only=False) — adding a parameter whose default is the credential-leaking mode, with no caller wanting it, would be strictly worse than the always-gated helper. That's the right reading of "keep whichever churns less".
Leaving the extra_data body path alone is also right for scope, and I like that the test docstring records why rather than leaving the asymmetry to be misread as intent.
test_local_target_with_stray_credentials_sends_no_auth_header is the test I'd have asked for: _assert_safe_url only covers cloud targets, so the is_cloud gate is the entire defense on the local path, and pinning it at the call site rather than only in the helper is what makes the delegation safe to repeat.
One correction to the description
You note "one pre-existing reformat in tests/comfy_cli/command/github/test_pr.py … present on main — likely local-vs-CI ruff version drift." It isn't pre-existing — main is clean under CI's pinned 0.15.15 (270 files already formatted). I tracked it down: ruff 0.14.x rewrites that docstring into a form 0.15.15 rejects, while 0.15.x leaves it alone. Your local ruff is 0.14.x and is generating that diff itself. It already turned #596's CI red the same way (details in a comment there). Pinning locally to ruff==0.15.15 will stop it recurring.
Full suite green on this branch merged with current main; ruff check + ruff format --diff clean under 0.15.15.
ELI-5
Two ways to prove who you are to Comfy Cloud: an API key (sent as
X-API-Key) or an OAuth token (sent asAuthorization: Bearer …). The code that decides which one to send used to be copy-pasted in several places. BE-3274 moved most of them onto one shared helper; this PR moves the last one — the unifiedClientincomfy_client.py— so that decision now lives in exactly one function. Nothing about what gets sent over the wire changes.What
Phase 2 of the BE-3264 dedup (the security fix itself landed in phase 1, #530).
Client._requestwas the last site still hand-rolling theapi_key → X-API-Key/auth_token → Bearerselection; it now delegates tohttp.target_auth_headers, which already carries theis_cloudgate and is whatcommand/transfer.pyandcql/engine.pyuse.Comfy-Usage-Source,Content-Type,Acceptand_OPENERare untouched.Behavior-preservation argument (the one real risk)
The old block checked
auth_tokenfirst (Bearer-first); the shared helper is api_key-first. Flagging this explicitly because it is the only line in the diff that could change a request.It cannot, because a Target never carries both credentials:
resolve_target(comfy_cli/target.py) is the onlyTarget(...)construction incomfy_cli/(git grep 'Target('— the other hits are unrelated enums), and it derives both fields from a single resolved credential:cred = resolve_cloud_credential(...), thentoken = cred.value if cred.kind == "oauth" else None/api_key = cred.value if cred.kind == "api_key" else None. Exactly one is non-Noneby construction.comfy_cli/is the OAuth-refreshobject.__setattr__(self.target, "auth_token", …)incomfy_client.py, which is gated onauth_tokenalready being set — so it cannot introduce an api_key alongside it.That is the same invariant the replaced block's own comment asserted, and the local-target leak gate is unchanged (it moved into the helper, which is where the other call sites already get it).
Tests
tests/comfy_cli/test_http.py— added the missingtarget_auth_headerscase: a cloud Target with no credential returns{}. (api_key-precedence, Bearer-only and the local-with-stray-creds cases were already covered by fix: route authed urllib paths through a shared NoRedirectHandler opener (BE-3274) #530.)tests/comfy_cli/cloud/test_client.pytest_cloud_oauth_wins_over_api_key_when_both_set→ renamedtest_cloud_both_credentials_uses_shared_header_selectionand updated to the unified api_key-first header. This is the one test flipped by the precedence change and the state it exercises is unreachable in production per the argument above. Itsextra_dataassertions are unchanged (see judgment call 2).test_local_target_with_stray_credentials_sends_no_auth_header— pins the leak guard at the call site, not just in the helper: a local Target carrying stray credentials sends neither header._assert_safe_urlonly covers cloud targets, so thisis_cloudgate is the whole defense there.Full suite:
uv run pytest→ 3501 passed, 37 skipped.ruff check/ruff format --checkclean on all four touched files. (Repo-wideruff checkreports 17 pre-existingUP038findings and one pre-existing reformat intests/comfy_cli/command/github/test_pr.py, all untouched by this PR and present onmain— likely local-vs-CI ruff version drift.)Judgment calls
auth_headers(target, *, cloud_only: bool = False); phase 1 (fix: route authed urllib paths through a shared NoRedirectHandler opener (BE-3274) #530) had already landed the equivalent astarget_auth_headers(target), unconditionally cloud-gated. All call sites want thecloud_only=Truebehavior, so adding acloud_onlyparameter would mean introducing an unused mode whose default is the credential-leaking one. Kept the existing always-gated helper instead — the ticket's "keep whichever churns less" applied to the whole item.extra_databody path alone.submit_promptinjects the partner-API credential into the request body with its ownauth_token-first ordering. The ticket scoped this change to header selection, so it is unchanged — meaning for a hypothetical both-credentials Target the header would beX-API-Keywhile the body carriedauth_token_comfy_org. That state is unreachable (same argument as above), and the test docstring says so explicitly rather than leaving it to be read as an intended contract. Unifying the body path would be a separate, larger change touching partner-API behavior.git grep -n 'X-API-Key' comfy_cli/still returns matches beyondhttp.py+ transfer's_AUTH_HEADERS_TO_STRIP, all pre-existing and none of them target-credential header selection: three user-facingrprintstrings incloud/command.py, doc comments incredentials.pyandtarget.py, andcommand/generate/client.py, which has a different credential model (a bare API-key string that is routed toX-API-KeyorBearerby inspecting the key's shape — Firebase JWT vscomfyui-key — with noTargetinvolved). That file was not one of the ticket's three sites and folding it into the target helper would be a behavior change, not a dedup. What the criterion was protecting is satisfied: after this PR, noTarget-credential header selection exists outsidecomfy_cli/http.py.cql/loader.pyandcloud/oauth.pyconfirmed to have none and are untouched."Authorization" not in req.headers, so: the OAuth/Bearer capability is intact and still covered bytest_posts_with_bearer_to_prefixed_urland the 401-refresh test, both passing. The only state where Bearer is no longer chosen is the both-credentials one, which I went looking for in production code and could not construct (thegit grep 'Target('/__setattr__evidence above). No path is denied to any user.Note for the reviewer
One unreachable-state wrinkle worth knowing about: the 401 auto-refresh in
_requestis keyed onself.target.auth_token. If a both-credentials Target could exist, a 401 would now refresh the OAuth token and retry withX-API-Key— i.e. the refresh would be wasted work before the error surfaced, rather than incorrect. Harmless, and unreachable, but it is the one downstream interaction with the precedence change.