feat: the orchestrating agent
Entry point for the estate: understands a request, consults the specialists that can settle it, composes one answer. LangGraph 1.2.11, a2a-sdk 1.1.2, both current as of August 2026. It holds no data, and the realm is what enforces that rather than the prompt. classic-models-agent-assistant is granted two optional scopes — one per specialist, plus the inference endpoint — and every domain audience it could request comes back Invalid scopes. An injection that succeeds here finds no path to a domain because there is none. "No scopes at all" was too absolute: the agent has to reason, and inference is not a domain. The two-hop token chain was the heaviest unproven thing in the design and it holds: sub stays the end user through all four hops, so their own roles apply at the far end and the MCP server filters tool discovery for the right human. azp names the last agent, not the first — Keycloak accepts actor_token and emits no act claim, so an orchestrator's identity survives only in the traces. Verified end to end: a routed question answered from the specialist's data; a question outside every remit refused rather than answered from memory; and a write proposed through the orchestrator, held by the specialist in input-required, relayed here unchanged, approved by the user through the same conversation, and written. The orchestrator never owns that decision. The graph is plan → delegate → plan, tools described by what a specialist decides rather than what it holds, delegations in one step run concurrently and capped at eight counted in calls. Rebuilt per turn, because its tools carry the user's token. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
"""The token exchange, which is where this agent's security actually lives."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from assistant_agent.identity import TokenExchanger, TokenExchangeError
|
||||
|
||||
|
||||
class _Response:
|
||||
def __init__(self, status_code: int, payload: dict | None = None, text: str = "") -> None:
|
||||
self.status_code = status_code
|
||||
self._payload = payload or {}
|
||||
self.text = text
|
||||
|
||||
def json(self) -> dict:
|
||||
return self._payload
|
||||
|
||||
|
||||
class _Client:
|
||||
"""Records what was posted, so the assertions are about the request shape."""
|
||||
|
||||
def __init__(self, response: _Response) -> None:
|
||||
self.response = response
|
||||
self.calls: list[dict] = []
|
||||
|
||||
async def post(self, url: str, data: dict) -> _Response:
|
||||
self.calls.append({"url": url, "data": data})
|
||||
return self.response
|
||||
|
||||
|
||||
def _exchanger(client: _Client) -> TokenExchanger:
|
||||
return TokenExchanger(
|
||||
client, # type: ignore[arg-type]
|
||||
token_endpoint="https://kc/realms/r/protocol/openid-connect/token",
|
||||
client_id="classic-models-agent-catalogue",
|
||||
client_secret="s3cret",
|
||||
)
|
||||
|
||||
|
||||
async def test_audience_is_requested_as_a_scope() -> None:
|
||||
"""Keycloak answers 'Requested audience not available' to `audience=...`.
|
||||
|
||||
The audience has to be asked for as a scope instead. This is the single
|
||||
detail that decides whether the exchange works at all, so it is asserted
|
||||
rather than left to a comment.
|
||||
"""
|
||||
client = _Client(_Response(200, {"access_token": "t", "expires_in": 300}))
|
||||
await _exchanger(client).exchange("user-token", "mcp-catalog-audience")
|
||||
|
||||
data = client.calls[0]["data"]
|
||||
assert data["scope"] == "openid mcp-catalog-audience"
|
||||
assert "audience" not in data
|
||||
assert data["grant_type"] == "urn:ietf:params:oauth:grant-type:token-exchange"
|
||||
assert data["subject_token"] == "user-token"
|
||||
|
||||
|
||||
async def test_tokens_are_cached_per_audience_not_globally() -> None:
|
||||
"""Two audiences must produce two exchanges.
|
||||
|
||||
Reusing one token across audiences would hand the agent a broader
|
||||
credential than any single MCP server needs, which is the thing the
|
||||
per-audience design exists to prevent.
|
||||
"""
|
||||
client = _Client(_Response(200, {"access_token": "t", "expires_in": 300}))
|
||||
exchanger = _exchanger(client)
|
||||
|
||||
await exchanger.exchange("user-token", "mcp-catalog-audience")
|
||||
await exchanger.exchange("user-token", "mcp-catalog-audience")
|
||||
await exchanger.exchange("user-token", "mcp-sales-audience")
|
||||
|
||||
scopes = [c["data"]["scope"] for c in client.calls]
|
||||
assert scopes == ["openid mcp-catalog-audience", "openid mcp-sales-audience"]
|
||||
|
||||
|
||||
async def test_refusal_is_raised_with_keycloak_s_own_message() -> None:
|
||||
"""A failed exchange must not degrade into an unauthenticated call."""
|
||||
client = _Client(_Response(400, text='{"error":"invalid_client"}'))
|
||||
with pytest.raises(TokenExchangeError, match="invalid_client"):
|
||||
await _exchanger(client).exchange("user-token", "mcp-catalog-audience")
|
||||
Reference in New Issue
Block a user