Skip to content

Commit 27a5866

Browse files
bokelleyclaude
andauthored
fix(ci): v3 reference seller storyboard job actually asserts on results (#693)
* fix(ci): v3 reference seller storyboard job actually asserts on results Closes #410. The storyboard-v3-reference-seller job advertised itself as gating "every PR's translator-pattern conformance" but ended its storyboard invocation with `|| true`, so a failing run silently passed CI. The artifact path also pointed at examples/v3_reference_seller/ while the file was written to workspace root, so post-mortem inspection landed on a warning instead of the actual result. Three changes: 1. Drop `|| true` from the storyboard invocation. Move into examples/v3_reference_seller so the result file matches the upload-artifact path. 2. Add an "Assert storyboard passed" step matching the seller_agent job's pattern: overall_status == 'passing' AND controller_detected. On failure, dump the full JSON so the PR diagnostic is the raw storyboard report, not a partial cat | head -50. 3. Add an "Assert upstream traffic" step that GETs /_debug/traffic and fails if all per-method counts are zero. This is the anti-façade gate the issue called for: a seller that returns stub data without translating to upstream calls would have passed the storyboard but emitted no traffic. The gate makes that mode detectable in CI instead of in production. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(v3-ref-seller): admit storyboard credential + emit spec error codes Brings the v3 reference seller from 12 storyboard failures down to (hopefully) 0 + #702 (storyboard YAML bug, upstream). Four changes: 1. CI storyboard step gets `--auth dev-bearer-token-acme-1` so the runner's requests resolve through PgBuyerAgentRegistry to ba_acme_bearer (seeded in seed.py). Without this, 7 storyboard steps fail with PERMISSION_DENIED. 2. update_media_buy validates inputs against the upstream BEFORE bailing with UNSUPPORTED_FEATURE. Unknown media_buy_id resolves to MEDIA_BUY_NOT_FOUND (via SDK 404 projection on get_order). Unknown package_id resolves to PACKAGE_NOT_FOUND. Closes the 2 invalid_transitions storyboard probes that asserted on the specific code. 3. create_media_buy gains _reject_unworkable_terms. When a buyer proposes measurement_terms.billing_measurement.max_variance_percent <= 0 we raise TERMS_REJECTED. Zero variance on third-party measurement is unworkable for any real vendor. Closes the measurement_terms_rejected/aggressive_terms probe that asserted TERMS_REJECTED. 4. Tests grow three new cases covering the three new error paths, and the existing UNSUPPORTED_FEATURE smoke test gains a respx mock for the new upstream-existence check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(v3-ref-seller): wire bearer auth so the registry can resolve credentials Closes the root-cause behind the 12-failure cluster surfaced by the storyboard gate in #410. The v3 reference seller had no bearer auth middleware wired in serve(), so every storyboard request arrived with ``Authorization: Bearer dev-bearer-token-acme-1`` but the framework never extracted it into a credential — BuyerAgentRegistry dispatch saw credential=None and returned PERMISSION_DENIED on every call. Three coordinated changes in app.py: 1. ``_make_validate_token(sessionmaker)`` — async validator that looks up a bearer token against the seeded ``BuyerAgent.api_key_id`` column and returns a Principal with ``caller_identity=agent_url`` and ``metadata={"api_key_id": token}`` so the raw token survives the middleware → dispatch handoff. 2. ``_build_context_factory()`` — replaces the simple tenant-pinning factory with one that wraps ``adcp.server.auth.auth_context_factory`` and upgrades the bearer-flow ``adcp.auth_info`` from ``credential=None`` to ``credential=ApiKeyCredential(key_id=token)``. That's exactly what auth_context_factory's docstring tells adopters to do when they wire BuyerAgentRegistry alongside bearer auth. 3. ``serve()`` gains ``auth=BearerTokenAuth(validate_token=...)`` so the middleware is actually installed. With these wired together the storyboard runner's --auth flag finally resolves through to ``BuyerAgentRegistry.resolve_by_credential`` and the seeded ``ba_acme_bearer`` admits the request. The platform fixes for MEDIA_BUY_NOT_FOUND / PACKAGE_NOT_FOUND / TERMS_REJECTED (already in this branch) then run for the negative-path probes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: pre-load bearer token map at boot + recognize git-merge commits Two CI failures on commit 1ec1f1a: 1. v3 ref seller boot crashed with TypeError because BearerTokenAuth rejects async validators when transport='both' (the A2A leg's middleware cannot await). Fix: load the BuyerAgent.api_key_id rows into a token-to-Principal map at boot, swap the async DB-lookup validator for a sync dict lookup. The bootstrap function does schema-create + token-load in the same event loop, disposes the engine before returning so uvicorn opens a fresh asyncpg pool. 2. conventional-commits gate rejected commit 63453bb ("Merge branch 'main' into ...") because the skip regex only matched the merge-queue API shape ("Merge <sha> into <sha>"), not the shape gh pr update-branch and git merge produce. Widen the regex. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(v3-ref-seller): resolve brand-shaped account references Closes the ACCOUNT_NOT_FOUND cluster surfaced by the storyboard gate after bearer auth started working. The seller previously used ExplicitAccounts which requires account.account_id on every request, but AdCP storyboards send account: {brand: ..., operator: ...} with no account_id during the discover-products phase. Replace ExplicitAccounts with a custom AccountStore that handles both reference shapes: 1. Explicit {account_id} ref - direct lookup against the accounts table's account_id column (existing behavior). 2. Brand-shaped {brand, operator} ref with no account_id - resolve to the first active account associated with the authenticated buyer agent (via auth_info.principal -> agent_url -> buyer_agent row -> first matching account). This is the path AdCP storyboards exercise before the buyer has been issued a per-relationship account_id. The row-to-Account projection is factored out so both paths share the same upstream-routing metadata stamping (network_code, advertiser_id, mock_upstream_url). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(v3-ref-seller): sync-poll upstream approval so create returns media_buy_id After bearer auth + AccountStore brand-fallback landed, the storyboard gate's remaining 5 create_buy failures all shared the same root cause: seller returns Submitted({task_id, status: 'submitted'}) when the upstream order lands in pending_approval, and the storyboard validation expects media_buy_id in the response body. The reference seller runs against a fast mock upstream that auto-approves in milliseconds, so awaiting the polling synchronously is the right behavior for this example. Adopters with slow real-world approvals swap to ctx.handoff_to_task(...) per the documented escape hatch. Tests updated: - test_create_media_buy_returns_task_handoff_on_pending_approval renamed to test_create_media_buy_sync_polls_to_success_on_pending_approval; mocks the task-completion poll and the post-completion order refetch, asserts CreateMediaBuySuccessResponse with media_buy_id. - test_create_media_buy_raises_when_polling_times_out simplified to await directly (no TaskHandoff._fn indirection). - test_create_media_buy_raises_when_task_rejected likewise. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(v3-ref-seller): echo packages with minted ids + pending_creatives status create_media_buy now mints a deterministic package_id per requested package and echoes the spec-marked echo fields (targeting_overlay, measurement_terms, creative_assignments, etc.) so AdCP storyboards can capture packages[0].package_id and verify list-targeting / measurement- terms persistence. When the buyer supplies no creatives anywhere, the response surfaces status='pending_creatives' so the next step is sync_creatives. This unblocks the inventory_list_targeting, invalid_transitions, creative_fate_after_cancellation, and pending_creatives_to_start storyboard create steps. * ci(v3-ref-seller): soft-gate residual storyboard failures (#706) The v3 storyboard runner job now passes 19/25 active steps but reports overall_status='partial' on a handful of scenarios that require feature work tracked under #706 — update_media_buy operations on persisted state, get_media_buys per-package projection, list_accounts/sync_accounts (#377), and an upstream storyboard YAML bug (adcp#702). Split the assert into a hard gate (runner produced output + controller_detected) and a soft gate (overall_status == passing), so the anti-façade work in #410 stays enforced while the residuals are tracked openly. * ci(v3-ref-seller): drop controller_detected gate (translator topology) controller_detected has never been true for the v3 reference seller's translator topology — the runner detects DemoStore overrides only for the examples/seller_agent.py stub-mode shape. The previous "Assert storyboard passed" step never reached the controller_detected check because overall_status != 'passing' tripped first. Splitting the asserts surfaced this latent gate as a hard failure even when the soft gate is suppressed. Anti-façade enforcement is unchanged: the "Assert upstream traffic" step still requires non-zero per-method upstream-call counts. * feat(v3-ref-seller): persist packages upstream + implement update_media_buy create_media_buy now POSTs each requested package as an upstream line item (POST /v1/orders/{id}/lineitems) and returns the line_item_id as the AdCP package_id. update_media_buy is no longer UNSUPPORTED_FEATURE: cancel / pause / creative-assignment patches are applied to a seller-local shadow store (self._buy_state), and creative_assignments flow upstream via POST .../lineitems/{li}/creative-attach. get_media_buys projects each line item plus its shadow-store entry so list-targeting, measurement_terms, and per-package cancel/pause survive create → get. Per-buy double-cancel raises NOT_CANCELLABLE. Unblocks the storyboard residuals tracked in #706: pending_creatives_to_start, inventory_list_targeting, invalid_transitions, and creative_fate_after_cancellation. * fix(v3-ref-seller): filter media_buy_ids + preserve buyer creative_id get_media_buys now narrows to the requested media_buy_ids when the buyer supplies them — without the filter, the response leaks every advertiser-scoped buy and the storyboard's media_buys[0] lookup hits a different scenario's order. Adds a bidirectional buyer-creative-id ↔ upstream-creative-id map so sync_creatives can echo the buyer's id on list_creatives and update_media_buy can translate before attach_creative (the upstream mints cr_<uuid> regardless of client_request_id). * fix(v3-ref-seller): apply package patches + filter list_creatives update_media_buy now applies the buyer's package echo fields (targeting_overlay, measurement_terms, etc.) to the shadow store using replacement semantics, so inventory_list_targeting's swap step reads back the new list_id values. list_creatives now honors the request's filters.creative_ids — without the filter the storyboard's creatives[0] lookup picks up a different scenario's creative. * feat(v3-ref-seller): re-sync no-op + assignments + allowlist CI gate sync_creatives now treats a re-sync of a known buyer creative_id as a no-op upload (action='unchanged') instead of letting the upstream's idempotency-conflict fire. The assignments[] field on the request is applied via attach_creative + shadow store, mirroring the path update_media_buy uses. Replaces the storyboard's continue-on-error soft gate with an explicit allowlist of expected failures — only adcp#702 (refine_products) and #377 (account_discovery) are allowed. Any new failure fails the job; any allowlisted failure that newly passes also fails the job so the list gets pruned as upstream issues close. * feat(v3-ref-seller): sync_accounts/list_accounts via AccountStore (closes #377) Moves the sync_accounts / list_accounts implementations off the platform class (where they were dead code — the framework never dispatched there) onto the AccountStore returned by _make_account_store. The framework's tool-advertising layer probes platform.accounts.upsert and platform.accounts.list as callables; without them every sales-* agent silently dropped both tools and the AdCP 3.0.9 §accounts/overview probe failed. upsert(refs, ctx) persists the buyer's AccountReference (full billing_entity, bank and all) and returns SyncAccountsResultRow per account. The framework projects through to_wire_sync_accounts_row, applying the billing_entity.bank write-only strip on emit. list(filter, ctx) returns Account[TMeta] with the billing_entity populated from Postgres; framework's to_wire_account strips bank on emit. Per-principal scoping by buyer-agent agent_url is enforced — unauthenticated callers see an empty list. Tests cover both projections end-to-end (bank persists on write, never appears on the wire) plus a surface-check that AccountStore exposes upsert/list callables. Drops account_discovery from the storyboard allowlist; allowlist now covers only the refine_products feature gap. * feat(v3-ref-seller): implement refine_get_products (delegates to base fetch) The reference seller has no pricing / forecasting model upstream — the mock-server returns the same product set regardless of refinements. The new refine_get_products re-fetches the base product list via get_products and reports each refine[] entry as RefinementOutcome (status='partial', notes=<no engine>). Adopters with a real forecaster swap the outcomes to 'applied' and project pricing changes onto products. With refine_get_products in place AND sync_accounts/list_accounts on the AccountStore from the previous commit, every in-scope storyboard scenario passes. Drops the allowlist step in favor of a strict overall_status == 'passing' gate. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fddea1a commit 27a5866

5 files changed

Lines changed: 1542 additions & 384 deletions

File tree

.github/workflows/ci.yml

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,12 @@ jobs:
114114
# Check each commit since the base
115115
echo "Validating commits since $BASE_SHA..."
116116
git log --format="%H %s" $BASE_SHA..HEAD | while read sha message; do
117-
# Skip merge commits (GitHub automatically creates these)
118-
if echo "$message" | grep -qE '^Merge [0-9a-f]+ into [0-9a-f]+'; then
117+
# Skip merge commits. Two GitHub-created shapes:
118+
# - "Merge <sha> into <sha>" — the merge-queue API path
119+
# (clicking "Update branch" on a PR)
120+
# - "Merge branch '<name>' [into <name>]" — `gh pr update-branch`
121+
# and `git merge` defaults
122+
if echo "$message" | grep -qE "^Merge ([0-9a-f]+ into [0-9a-f]+|branch '[^']+')"; then
119123
echo "⊙ Skipping merge commit: $sha"
120124
continue
121125
fi
@@ -637,13 +641,79 @@ jobs:
637641
# /etc/hosts override so the buyer can reach acme.localhost
638642
# (the seeded tenant subdomain).
639643
echo "127.0.0.1 acme.localhost" | sudo tee -a /etc/hosts
644+
cd examples/v3_reference_seller
640645
# ``adcp`` was installed once at job start — call the binary
641646
# directly to skip per-invocation ``npx`` extract+link.
647+
# --auth carries the seeded bearer credential (seed.py:74) so the
648+
# storyboard runner's requests resolve through the v3 ref seller's
649+
# PgBuyerAgentRegistry to ba_acme_bearer. Without this, every
650+
# request hits the Tier 2 commercial-allowlist gate and storyboard
651+
# steps fail with PERMISSION_DENIED across the board.
642652
adcp storyboard run \
643653
http://acme.localhost:3001/mcp media_buy_seller \
654+
--auth dev-bearer-token-acme-1 \
644655
--json --allow-http \
645-
> v3-storyboard-result.json || true
646-
cat v3-storyboard-result.json | head -50
656+
> v3-storyboard-result.json
657+
658+
- name: Assert storyboard runner produced output
659+
run: |
660+
# Hard gate: the runner must produce a non-empty result file.
661+
# The anti-façade invariant (the seller actually called upstream)
662+
# is enforced by "Assert upstream traffic" below — controller_detected
663+
# is observational; it has never been true for the v3 reference
664+
# seller's translator topology (the runner detects it for the
665+
# examples/seller_agent.py stub-mode topology only).
666+
python -c "
667+
import json, sys, pathlib
668+
p = pathlib.Path('examples/v3_reference_seller/v3-storyboard-result.json')
669+
if not p.exists() or p.stat().st_size == 0:
670+
print('v3-storyboard-result.json missing or empty — runner produced no output')
671+
sys.exit(1)
672+
with p.open() as f:
673+
d = json.load(f)
674+
print('overall_status:', d.get('overall_status'))
675+
print('controller_detected:', d.get('controller_detected'))
676+
print('summary:', json.dumps(d.get('summary', {}), indent=2))
677+
"
678+
679+
- name: Assert storyboard passed
680+
# Hard gate. The runner's ``overall_status`` MUST be ``passing``;
681+
# any failure fails the job.
682+
run: |
683+
python -c "
684+
import json, sys, pathlib
685+
p = pathlib.Path('examples/v3_reference_seller/v3-storyboard-result.json')
686+
with p.open() as f:
687+
d = json.load(f)
688+
if d.get('overall_status') != 'passing':
689+
print(json.dumps(d, indent=2))
690+
sys.exit(1)
691+
print('Storyboard passing.')
692+
"
693+
694+
- name: Assert upstream traffic (anti-façade gate)
695+
run: |
696+
# The v3 reference seller exposes /_debug/traffic with per-method
697+
# upstream-call counts. If the storyboard passed but the counts
698+
# are empty/zero, the seller served stub data without translating
699+
# to upstream — the façade-mode failure this job exists to catch
700+
# (see #410).
701+
curl -sf http://127.0.0.1:3001/_debug/traffic > traffic.json
702+
python -c "
703+
import json, sys
704+
with open('traffic.json') as f:
705+
counts = json.load(f)
706+
if not counts:
707+
print('Empty traffic snapshot — seller invoked no upstream methods.')
708+
print('This is the façade-mode failure #410 gates against.')
709+
sys.exit(1)
710+
total = sum(counts.values())
711+
if total == 0:
712+
print(f'All upstream-method counts zero: {counts!r}')
713+
print('Seller served stub data without calling upstream — façade failure.')
714+
sys.exit(1)
715+
print(f'Upstream traffic: total={total} per-method={counts!r}')
716+
"
647717
648718
- if: always()
649719
uses: actions/upload-artifact@v4

examples/v3_reference_seller/src/app.py

Lines changed: 118 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,14 @@
5656
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
5757

5858
from adcp.decisioning import AdcpError, InMemoryMockAdServer, serve
59+
from adcp.decisioning.context import AuthInfo
60+
from adcp.decisioning.registry import ApiKeyCredential
5961
from adcp.server import (
6062
SubdomainTenantMiddleware,
6163
ToolContext,
6264
current_tenant,
6365
)
66+
from adcp.server.auth import BearerTokenAuth, Principal, auth_context_factory
6467
from adcp.validation import ValidationHookConfig
6568
from adcp.webhook_sender import WebhookSender
6669
from adcp.webhook_supervisor import InMemoryWebhookDeliverySupervisor
@@ -79,35 +82,126 @@
7982

8083
def _build_context_factory():
8184
"""``context_factory`` that pins :attr:`ToolContext.tenant_id`
82-
from the resolved tenant.
85+
from the resolved tenant AND upgrades the bearer-flow
86+
``adcp.auth_info`` with a typed :class:`ApiKeyCredential`.
87+
88+
The SDK's :func:`adcp.server.auth.auth_context_factory` populates
89+
``metadata["adcp.auth_info"]`` with ``credential=None`` for bearer
90+
flows because raw bearer tokens are server-internal (see
91+
:func:`auth_context_factory`'s docstring). Without a typed
92+
credential the framework's :class:`BuyerAgentRegistry` dispatch
93+
falls into the no-credential branch and returns
94+
``PERMISSION_DENIED`` — so adopters that wire a registry alongside
95+
bearer auth MUST upgrade the ``AuthInfo`` here.
96+
97+
The validator (see :func:`_make_validate_token`) stashes the raw
98+
bearer token in ``Principal.metadata["api_key_id"]``; this factory
99+
reads it back to construct the :class:`ApiKeyCredential` that
100+
:meth:`BuyerAgentRegistry.resolve_by_credential` matches against
101+
the ``api_key_id`` column.
83102
"""
103+
from dataclasses import replace
84104

85105
def build(meta: RequestMetadata) -> ToolContext:
106+
ctx = auth_context_factory(meta)
107+
# Pin tenant from SubdomainTenantMiddleware. Subdomain wins for
108+
# tenant routing; the validator's tenant_id is only the token's
109+
# home tenant and may not match the host the request came in on.
86110
tenant = current_tenant()
87-
return ToolContext(
88-
request_id=meta.request_id,
89-
tenant_id=tenant.id if tenant else None,
90-
)
111+
if tenant is not None:
112+
ctx = replace(ctx, tenant_id=tenant.id)
113+
114+
# Upgrade bearer-flow auth_info with a typed ApiKeyCredential
115+
# when the validator stashed the raw token in principal metadata.
116+
# ctx.metadata is a dict; mutate in place rather than rebuilding.
117+
api_key_id = ctx.metadata.get("api_key_id")
118+
existing = ctx.metadata.get("adcp.auth_info")
119+
if api_key_id and isinstance(existing, AuthInfo):
120+
ctx.metadata["adcp.auth_info"] = AuthInfo(
121+
kind="api_key",
122+
key_id=api_key_id,
123+
principal=existing.principal,
124+
credential=ApiKeyCredential(kind="api_key", key_id=api_key_id),
125+
)
126+
return ctx
91127

92128
return build
93129

94130

95-
async def _bootstrap_schema(engine) -> None:
96-
"""Create all tables. Idempotent (CREATE TABLE IF NOT EXISTS).
131+
async def _load_token_map(sessionmaker) -> dict[str, Principal]:
132+
"""Eagerly load all ``BuyerAgent`` rows with a non-null
133+
``api_key_id`` into a ``token → Principal`` map.
134+
135+
Consumed by the sync validator returned from
136+
:func:`_make_validate_token`. ``BearerTokenAuth.validate_token``
137+
must be sync when ``transport="both"`` (the A2A leg's middleware
138+
cannot await an async validator), so we pay one DB scan at boot
139+
and serve every subsequent request from memory. The seed is small
140+
and stable for the reference seller; adopters with dynamic admin
141+
paths swap in their own validator backed by a cache with TTL-based
142+
reload.
143+
"""
144+
from sqlalchemy import select
145+
146+
from .models import BuyerAgent as BuyerAgentRow
147+
148+
token_map: dict[str, Principal] = {}
149+
async with sessionmaker() as session:
150+
result = await session.execute(
151+
select(BuyerAgentRow).where(BuyerAgentRow.api_key_id.is_not(None))
152+
)
153+
for row in result.scalars():
154+
token_map[row.api_key_id] = Principal(
155+
caller_identity=row.agent_url,
156+
tenant_id=row.tenant_id,
157+
metadata={"api_key_id": row.api_key_id},
158+
)
159+
return token_map
160+
161+
162+
def _make_validate_token(token_map: dict[str, Principal]):
163+
"""Sync validator returning the pre-loaded :class:`Principal` for
164+
a bearer token, or ``None`` for unknown tokens.
165+
166+
The returned Principal carries the raw token in metadata under
167+
``api_key_id`` so :func:`_build_context_factory` can attach a
168+
typed :class:`ApiKeyCredential` to the dispatch context — the
169+
framework's :class:`BuyerAgentRegistry` then resolves
170+
commercially via :meth:`resolve_by_credential`.
171+
"""
172+
173+
def validate_token(token: str) -> Principal | None:
174+
if not token:
175+
return None
176+
return token_map.get(token)
177+
178+
return validate_token
179+
180+
181+
async def _bootstrap_schema_and_load_tokens(engine, sessionmaker) -> dict[str, Principal]:
182+
"""Bootstrap the schema (idempotent ``CREATE TABLE IF NOT EXISTS``)
183+
AND load the bearer-token map in the same event loop, then dispose
184+
the engine before returning.
97185
98186
Production adopters use Alembic — this entrypoint sticks with
99-
``create_all`` for fast iteration.
187+
``create_all`` for fast iteration. Token loading happens here
188+
(rather than separately) because ``BearerTokenAuth.validate_token``
189+
must be sync for ``transport="both"``, so we pay one DB scan at
190+
boot and serve every subsequent request from memory.
191+
192+
asyncpg binds connection-internal Future objects to the loop they
193+
were opened on. Bootstrapping via ``asyncio.run`` runs on a
194+
transient loop that closes when ``asyncio.run`` returns; if those
195+
connections stay in the pool, uvicorn's own loop trips
196+
``RuntimeError: got Future attached to a different loop`` on the
197+
first request. Dispose before returning so uvicorn opens a fresh
198+
pool on its own loop.
100199
"""
101200
async with engine.begin() as conn:
102201
await conn.run_sync(Base.metadata.create_all)
103-
# asyncpg binds connection-internal Future objects to the loop
104-
# they were opened on. Bootstrapping via ``asyncio.run`` runs on
105-
# a transient loop that closes when ``asyncio.run`` returns; if
106-
# those connections stay in the pool, uvicorn's own loop trips
107-
# ``RuntimeError: got Future attached to a different loop`` on
108-
# the first request. Dispose so uvicorn opens a fresh pool on
109-
# its own loop.
202+
token_map = await _load_token_map(sessionmaker)
110203
await engine.dispose()
204+
return token_map
111205

112206

113207
def main() -> None:
@@ -146,7 +240,7 @@ def main() -> None:
146240
engine = create_async_engine(db_url, pool_size=10, max_overflow=20)
147241
sessionmaker = async_sessionmaker(engine, expire_on_commit=False)
148242

149-
asyncio.run(_bootstrap_schema(engine))
243+
token_map = asyncio.run(_bootstrap_schema_and_load_tokens(engine, sessionmaker))
150244

151245
router = SqlSubdomainTenantRouter(sessionmaker=sessionmaker)
152246
audit_sink = make_audit_sink(sessionmaker)
@@ -234,6 +328,14 @@ def main() -> None:
234328
host="0.0.0.0",
235329
transport="both",
236330
buyer_agent_registry=buyer_registry,
331+
# Bearer auth wired so the framework extracts the
332+
# ``Authorization: Bearer <token>`` header, resolves the token
333+
# to a seeded BuyerAgent via api_key_id lookup, and threads the
334+
# raw token into the dispatch context so
335+
# ``BuyerAgentRegistry.resolve_by_credential`` can re-resolve
336+
# commercially. Without this, every dispatched skill hits the
337+
# registry with credential=None and returns PERMISSION_DENIED.
338+
auth=BearerTokenAuth(validate_token=_make_validate_token(token_map)),
237339
context_factory=_build_context_factory(),
238340
asgi_middleware=[
239341
(SubdomainTenantMiddleware, {"router": router}),

0 commit comments

Comments
 (0)