fix: attach per-seller credentials on booking calls - #132
Merged
Merged
Conversation
Seller-agent PR #77 (merged to seller main today) now requires a verified buyer API key on POST /api/v1/deals and returns 401 authentication_required otherwise. The buyer never attached any credential to that request: MultiSellerOrchestrator's three call sites that construct a DealsClient for quoting, negotiation, and booking (orchestration/multi_seller.py) invoked the deals_client_factory with zero kwargs, so DealsClient.__init__ always took its api_key=None, bearer_token=None 401 defaults, even though ApiKeyStore already had a per-seller key on disk. This was caught by a real-mode integration gate against a live seller fleet, not by CI, since each repo's test suite exercises only its own side of the contract. Fix: give MultiSellerOrchestrator an optional key_store (ApiKeyStore), defaulting to ApiKeyStore() (~/.ad_buyer/seller_keys.json) when not injected -- the same default construction interfaces/mcp_server.py already uses for its key-management tools. Route all three call sites through a new _client_for_booking(seller_url) helper that looks up self._key_store.get_key(seller_url) and forwards it as api_key to the existing factory, which DealsClient already knows how to turn into an X-Api-Key header. A seller with no stored key still gets a client with no credential, unchanged from today, but that path now logs so a resulting 401 is locally diagnosable instead of a bare surprise. Both production orchestrator-construction sites (flows/deal_booking_flow.py::build_default_orchestrator and interfaces/chat/main.py) pick this up for free via the constructor default; neither needed a direct edit.
The tests added with the credential wiring exercised _client_for_booking directly: build an orchestrator with a key store, call the helper, assert the client carries x-api-key. Those tests prove the helper attaches the credential. They do not prove that anything calls it, and that gap is exactly the shape of the original defect. _client_for_booking did not exist before this branch; the quote, negotiation, and booking paths each built a client straight from self._deals_client_factory(seller_url) with no kwargs, so DealsClient took its api_key=None default and the seller returned 401. Reverting any one call site back to that call reintroduces the identical 401 while the helper and every one of its tests stay green, which means the suite could not have caught the bug it was written for. Two tests close that gap, from opposite directions. test_quote_path_sends_stored_key_to_the_factory drives the public request_quotes_parallel with a spy factory instead of touching the helper, and asserts the stored key arrived at the factory as api_key. It fails if the quote call site stops routing through the helper. test_deals_client_factory_has_exactly_one_caller is a structural guard: it parses orchestration/multi_seller.py and asserts that self._deals_client_factory is only ever called from _client_for_booking. This covers the negotiation and booking call sites without standing up their full orchestration state, and it is the half that covers call sites nobody has written yet -- a fourth path added later that builds its own uncredentialed client fails this test on the spot. Both were verified against deliberate reverts. Reverting the quote call site fails both tests. Reverting the booking call site, which the end-to-end test does not reach, fails the structural guard alone. The temporary source edits were reverted; no production code changes here.
atc964
marked this pull request as ready for review
September 16, 2026 12:17
Sirajmx
approved these changes
Sep 16, 2026
Sirajmx
left a comment
Contributor
There was a problem hiding this comment.
Verified live: real seller (current
main, #77's auth gate live) +
real buyerDealsClientover a real network — pre-fix genuinely 401s, post-fix genuinely passes
auth and reaches real business logic. Also tried to break the
new structural guard test by reverting a call site back to the vulnerable pattern — it caught it
immediately, naming the offender. Full suite: 3615 passed, 83 skipped, 0 failed. No conflicts.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What breaks today
Seller-agent PR #77 (merged to seller
maintoday ascf44c27, "fix: require verified buyer on deal booking; operator-gate the MCP deal tool") added an enforcement gate toPOST /api/v1/deals:The buyer never attaches any credential to that request, so every real booking against a seller running #77 now fails with
401 authentication_required. This was proven on a live rig fleet, not caught by either repo's own CI (see below).Root cause (file/line citations)
The outbound POST is made by
DealsClient.book_deal(src/ad_buyer/clients/deals_client.py:265), sent through anhttpx.AsyncClientbuilt once inDealsClient.__init__(deals_client.py:122-153). That constructor already knows how to attach a credential:But nothing ever supplies
api_key/bearer_token.MultiSellerOrchestrator's three call sites that construct aDealsClientfor quoting, negotiation, and booking called the factory with zero kwargs:src/ad_buyer/orchestration/multi_seller.py:1104(pre-fix) — quote stagesrc/ad_buyer/orchestration/multi_seller.py:1524(pre-fix) — negotiation re-quotesrc/ad_buyer/orchestration/multi_seller.py:1733(pre-fix) — bookingBoth production factories forward
**kwargsfaithfully (flows/deal_booking_flow.py:122,interfaces/chat/main.py:413-418), so the plumbing to pass a credential through already existed end-to-end — it just was never fed one. MeanwhileApiKeyStore(src/ad_buyer/auth/key_store.py) already had per-seller keys on disk (~/.ad_buyer/seller_keys.json), andAuthMiddleware(src/ad_buyer/auth/middleware.py) already knew how to pull a key from that store and attach it — butgrep -rn "AuthMiddleware(" src/returns zero matches. It was fully implemented, dead code.Why neither repo's CI catches this
Each repo tests only its own side of the contract: the buyer's unit/integration suite mocks or stubs the seller's HTTP surface and never asserts on outbound auth headers reaching a real seller enforcing #77; the seller's suite tests its own auth gate in isolation and has no visibility into what the buyer actually sends. The gap is only visible when both real services talk to each other, which is what the rig's real-mode integration gate does and CI does not.
What changed
MultiSellerOrchestrator.__init__gained an optionalkey_store: ApiKeyStore | Noneparameter, defaulting toApiKeyStore()(~/.ad_buyer/seller_keys.json) when not injected — the same default constructioninterfaces/mcp_server.py::_get_api_key_store()already uses for its key-management tools._client_for_booking(seller_url)helper looks upself._key_store.get_key(seller_url)and forwards it asapi_key=to the existingdeals_client_factory, whichDealsClientalready turns into anX-Api-Keyheader.orchestration/multi_seller.pynow go through_client_for_bookinginstead of calling the factory directly.flows/deal_booking_flow.py::build_default_orchestratorandinterfaces/chat/main.py's orchestrator construction needed no direct edit — both constructMultiSellerOrchestratorwithout akey_storekwarg, so both pick up the constructor'sApiKeyStore()default automatically. Kept the diff to a single file for the logic change.Why the header, not a guess
Confirmed by reading the seller's actual dependency (
ad_seller/interfaces/api/deps.py::_get_optional_api_key_record, read-only, not modified in this PR): it accepts eitherAuthorization: Bearer <key>(aliased) orX-Api-Key: <key>.DealsClientalready sendsX-Api-Keywhen givenapi_key=, which is exactly whatApiKeyStore/AuthMiddlewarewere built around (header_type: Literal["api_key", "bearer"] = "api_key"), so this fix usesapi_key=, notbearer_token=.Judgment calls
Why not instantiate
AuthMiddleware.AuthMiddlewareis the more "designed-for-this" mechanism — it wraps anhttpx.Request/Responsepair and would also give 401-triggered reauth detection for free viahandle_response. I did not wire it in because doing so means changing howDealsClient'shttpx.AsyncClientis built (anevent_hooks["request"]callback or a custom transport), whichDealsClient.__init__does not currently accept as a parameter — that's a second, larger surface change for the same immediate outcome, andDealsClientalready has a working, documented mechanism for exactly this (api_key/bearer_tokenconstructor kwargs, see its docstring). Using the constructor kwarg the class was already built to accept is the smaller, more faithful fix.AuthMiddleware's response-inspection half (401-triggered reauth) is not invoked by anything either and is a reasonable follow-up if that behavior is wanted, but it's out of scope for closing the immediate wiring gap.URL normalization.
ApiKeyStore.get_key()(key_store.py:50-53) internally calls_normalize_url(rstrips trailing/) on every lookup, andadd_keydoes the same on every write — so both directions are normalized identically regardless of what the caller passes in. I resolve the credential exclusively throughApiKeyStore.get_key(seller_url)(never by indexing_keysdirectly or a copy of it), so the call site does not need to duplicate that normalization or guess at URL shape.DealsClient.seller_urlis separatelyrstrip("/")'d (deals_client.py:132), so the URL shape used as the dict key at construction time and the shape looked up against the store are consistent either way. Verified this round-trips with a unit test that stores a key under a trailing-slash URL and looks it up via the no-trailing-slash form the orchestrator actually uses.Assurance carried by this PR
This has not had external code review. It was validated by: (1) the buyer repo's full existing unit suite (unchanged, still green), (2) three new unit tests that fail without the fix and pass with it (proof below), and (3) the diagnosis this fix is based on, itself checked against the real rig's live 401 failure. It is being opened directly rather than held in draft because it and seller-agent #77 need to ship together and #77 is already merged — every real-mode booking is broken until this lands. Whoever reads this history later should weigh it accordingly: suite-and-integration-gate validated, not reviewer validated.
Tests
Added to
tests/unit/test_auth.py(TestBookingCredentialWiring), exercising the same factory shape production uses (lambda seller_url, **kwargs: DealsClient(seller_url, **kwargs), i.e. a realDealsClient, not a mock):test_seller_with_stored_key_sends_x_api_key_header— a seller WITH a stored key gets a client whosehttpx.AsyncClientcarriesX-Api-Key.test_seller_with_no_stored_key_still_constructs_uncredentialed— a seller with NO stored key still constructs a working client with noX-Api-Key/Authorizationheader (today's behavior, unchanged).test_url_normalization_round_trips_through_booking_lookup— a key stored under a trailing-slash seller URL is found by the no-trailing-slash lookup the orchestrator actually performs.Failing without the fix (
git stashofmulti_seller.pyonly, tests left in place):Passing with the fix restored:
Full unit suite, with the fix:
Ships with
This is required for compatibility with seller-agent #77, which is already merged on seller
main. Every real booking against a seller running #77 fails with 401 until this lands — the two changes need to ship together.