From 7be162e84d9f2d365530e90dfe464a392bd64e34 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Thu, 18 Jun 2026 00:21:36 -0700 Subject: [PATCH] feat(#17): create_session Bifrost-bind primitive (slice 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 1 of issue #17 (Bifrost-binding the chat client) — the client-side BIND primitive, TDD'd against docs/contracts/issues/17.contract.md. - BifrostBinding{endpoint_url, scope=None} frozen dataclass (#160 shape) - create_session(..., bifrost=, consumer_key=): carries the bifrost body field and OVERRIDES the bearer to the consumer key per-request (INV-001 — never falls back to the canary key) - BifrostConsumerKeyMissing: raised BEFORE any HTTP when a binding lacks a non-empty key (PRE-001) - BifrostHandshakeFailed: 502 on a BOUND create -> carries detail.bifrost_error (both-shape unwrap per the persona_state wire lesson); gated on bifrost!=None so an unbound 502 stays SessionApiFailed (INV-002) - endpoint_for_plane: memory->:8391 / affect->:8390, invalid->ValueError 7 new tests; full suite 442 green; ruff clean. --- pyproject.toml | 2 +- src/ratatoskr/sessions.py | 117 +++++++++++++++++++++++- tests/test_sessions.py | 184 ++++++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 4 files changed, 301 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fbd6674..76fee41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.17.7" +version = "0.17.8" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/sessions.py b/src/ratatoskr/sessions.py index df6acd6..83c3945 100644 --- a/src/ratatoskr/sessions.py +++ b/src/ratatoskr/sessions.py @@ -59,6 +59,20 @@ class AgentInfo: ui_hints: dict[str, Any] +@dataclass(frozen=True) +class BifrostBinding: + """Session-create Bifrost binding (Worldtree BifrostBindingRequest, #160). + + Issue #17. `endpoint_url` is the WORLDTREE-VISIBLE base URL of ONE provider + plane (memory :8391 / affect :8390). `scope` is an opaque pass-through copied + into the handshake JWT unchanged (≤256 chars); ratatoskr does not interpret + it and v1 sends None. + """ + + endpoint_url: str + scope: str | None = None + + class AgentNotFound(Exception): """Raised on HTTP 404 from POST /sessions — unknown agent_id.""" @@ -89,6 +103,40 @@ class SessionApiFailed(Exception): self.body = body +# Issue #17 — Bifrost-bind failure modes on POST /sessions. +class BifrostConsumerKeyMissing(Exception): + """A bifrost binding was requested without a consumer_key. + + Raised BEFORE any HTTP (INV-001): the bound create must never silently fall + back to the client's default canary key — the consumer key IS the handshake + identity Worldtree signs the Bifrost JWT with. + """ + + def __init__(self) -> None: + super().__init__( + "bifrost binding requires a non-empty consumer_key; refusing to " + "fall back to the canary key (INV-001)" + ) + + +class BifrostHandshakeFailed(Exception): + """Raised on HTTP 502 `bifrost_handshake_failed` from a bound POST /sessions. + + INV-002: the Bifrost handshake runs synchronously at session-create, so a + handshake failure (bad URL / down provider / wrong key / HTTPS rejection) + fails SESSION CREATION — surfaced on the create path, never deferred to the + first turn. `bifrost_error` carries the spec-level code (e.g. + `bifrost.auth_rejected`); `body` is truncated to ≤1024 bytes, consistent with + `SessionApiFailed` (INV-004 precedent). + """ + + def __init__(self, *, bifrost_error: str | None, body: bytes) -> None: + body = body[:1024] + super().__init__(f"bifrost handshake failed: bifrost_error={bifrost_error!r}") + self.bifrost_error = bifrost_error + self.body = body + + # Worldtree #204 / v0.28.0 — persona_state endpoint failure modes. class PersonaNotConfigured(Exception): """Raised on HTTP 404 `persona_not_configured` from GET persona_state. @@ -176,11 +224,51 @@ async def list_sessions( return SessionPage(items=items, next_cursor=body.get("next_cursor")) +def endpoint_for_plane(plane: str, base_host: str) -> str: + """Map a provider plane name to its Worldtree-VISIBLE base URL. + + Issue #17 dev helper: `memory` → :8391, `affect` → :8390. Returns the + Worldtree-visible base (e.g. `http://10.100.10.50:8391`), NOT the client's + loopback — Worldtree must reach the provider over the network. `http://` is + deliberate: the HTTPS relaxation is allowlist-side (Worldtree's + BIFROST_CLIENT_ALLOWED_HOSTS), not a URL concern. A production HTTPS endpoint + is supplied directly, bypassing this helper. + """ + if plane not in ("memory", "affect"): + raise ValueError( + f"unknown plane: {plane!r} (expected 'memory' or 'affect')" + ) + port = 8391 if plane == "memory" else 8390 + return f"http://{base_host}:{port}" + + +def _bifrost_error_from(resp: httpx.Response) -> str | None: + """Pull the spec-level `bifrost_error` from a 502 body. + + Tolerates both the FastAPI-nested `{"detail": {"bifrost_error": …}}` shape + (the spec's documented form, §"Optional Bifrost binding") and a flat + top-level `bifrost_error`, per the both-shape unwrap precedent established for + persona_state errors (the real wire returns the detail-nested form). + """ + try: + err = resp.json() + except ValueError: + return None + if not isinstance(err, dict): + return None + bifrost_error = err.get("bifrost_error") + if bifrost_error is None and isinstance(err.get("detail"), dict): + bifrost_error = err["detail"].get("bifrost_error") + return bifrost_error + + async def create_session( client: httpx.AsyncClient, agent_id: str, *, end_user_id: str | None = None, + bifrost: BifrostBinding | None = None, + consumer_key: str | None = None, ) -> SessionInfo: """POST /sessions to create a new session. See contract FN create_session. @@ -188,17 +276,42 @@ async def create_session( When None (default), the body shape matches the pre-#5 baseline `{"agent_id": agent_id}` so existing callers (mimir smoke) are unaffected. Empty-string `end_user_id` is rejected before HTTP (PRE-003). + + Per issue #17: when `bifrost` is set the request carries the binding and + authenticates with `consumer_key` (NOT the client's default canary bearer); + Worldtree handshakes synchronously to our provider before 201. """ assert client is not None assert agent_id and isinstance(agent_id, str) assert end_user_id is None or (isinstance(end_user_id, str) and end_user_id) - body: dict[str, str] = {"agent_id": agent_id} + # PRE-001 (INV-001): a bifrost binding REQUIRES a non-empty consumer key, + # enforced before any HTTP so a bound create never falls back to the canary. + if bifrost is not None and not (isinstance(consumer_key, str) and consumer_key): + raise BifrostConsumerKeyMissing() + + body: dict[str, Any] = {"agent_id": agent_id} if end_user_id is not None: body["end_user_id"] = end_user_id - resp = await client.post("/sessions", json=body) + headers: dict[str, str] = {} + if bifrost is not None: + body["bifrost"] = { + "endpoint_url": bifrost.endpoint_url, + "scope": bifrost.scope, + } + # INV-001: the bound create authenticates with the consumer key, + # overriding the httpx client's default canary bearer per-request. + headers["Authorization"] = f"Bearer {consumer_key}" + resp = await client.post("/sessions", json=body, headers=headers) if resp.status_code == 404: raise AgentNotFound(agent_id=agent_id) + # POST-002 (INV-002): a 502 on a BOUND create is the synchronous Bifrost + # handshake failing. Gated on `bifrost is not None` — an unbound create's + # 502 is a generic upstream fault and stays SessionApiFailed. + if bifrost is not None and resp.status_code == 502: + raise BifrostHandshakeFailed( + bifrost_error=_bifrost_error_from(resp), body=resp.content + ) if resp.status_code != 201: raise SessionApiFailed(status=resp.status_code, body=resp.content) body = resp.json() diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 081a373..917a677 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -9,11 +9,15 @@ from ratatoskr.sessions import ( AgentNotAvailable, AgentNotFound, AuthScopeDenied, + BifrostBinding, + BifrostConsumerKeyMissing, + BifrostHandshakeFailed, InvalidCursor, PersonaNotConfigured, SessionApiFailed, SessionPage, create_session, + endpoint_for_plane, get_persona_state, list_agents, list_sessions, @@ -206,6 +210,186 @@ class TestCreateSession: assert route.call_count == 0 +class TestCreateSessionBifrostBind: + """Issue #17 slice 1 — the create_session Bifrost-bind primitive.""" + + @respx.mock + async def test_bind_happy_consumer_key_and_body(self) -> None: + """bind_happy [tracer]: a bifrost binding makes the body carry the + `bifrost` field AND overrides the bearer to the consumer key (NOT the + client's canary default), 201 → SessionInfo. Proves the bind path + end-to-end (FN create_session STEPS 1-2, POST-001, INV-001).""" + import json as _json + + route = respx.post("https://w.example/sessions").mock( + return_value=httpx.Response( + 201, + json={ + "session_id": "s-bound", + "agent_id": "ratatoskr:sindra", + "message_count": 0, + "created_at": "2026-06-18T12:00:00+00:00", + "last_active": "2026-06-18T12:00:00+00:00", + "metadata": {}, + }, + ) + ) + binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391") + async with httpx.AsyncClient( + base_url="https://w.example", + headers={"Authorization": "Bearer canary-key"}, + ) as client: + info = await create_session( + client, + "ratatoskr:sindra", + end_user_id="smoke-user", + bifrost=binding, + consumer_key="consumer-key", + ) + req = route.calls[0].request + body = _json.loads(req.content) + # body carries the bifrost field alongside agent_id/end_user_id + assert body == { + "agent_id": "ratatoskr:sindra", + "end_user_id": "smoke-user", + "bifrost": { + "endpoint_url": "http://10.100.10.50:8391", + "scope": None, + }, + } + # bearer overridden to the consumer key (INV-001: never the canary default) + assert req.headers["Authorization"] == "Bearer consumer-key" + assert info.session_id == "s-bound" + assert info.agent_id == "ratatoskr:sindra" + + @respx.mock + async def test_bind_without_consumer_key_raises_before_http(self) -> None: + """missing_key [adversarial]: bifrost set but consumer_key None → + BifrostConsumerKeyMissing BEFORE any HTTP (PRE-001, INV-001: never fall + back to the canary key).""" + route = respx.post("https://w.example/sessions").mock( + return_value=httpx.Response(201, content=b"{}") + ) + binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391") + async with httpx.AsyncClient(base_url="https://w.example") as client: + with pytest.raises(BifrostConsumerKeyMissing): + await create_session(client, "ratatoskr:sindra", bifrost=binding) + assert route.call_count == 0 + + @respx.mock + async def test_bind_with_empty_consumer_key_raises_before_http(self) -> None: + """empty_key [adversarial]: empty-string consumer_key is also rejected + before HTTP (PRE-001 requires a NON-EMPTY str).""" + route = respx.post("https://w.example/sessions").mock( + return_value=httpx.Response(201, content=b"{}") + ) + binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391") + async with httpx.AsyncClient(base_url="https://w.example") as client: + with pytest.raises(BifrostConsumerKeyMissing): + await create_session( + client, "ratatoskr:sindra", bifrost=binding, consumer_key="" + ) + assert route.call_count == 0 + + @respx.mock + async def test_bind_handshake_failure_maps_to_502(self) -> None: + """handshake_502 [adversarial]: a bound create that 502s with + detail.bifrost_error → BifrostHandshakeFailed carrying the bifrost_error + + raw body (POST-002, INV-002 bind-time failure). 'bifrost.auth_rejected' + is the canary-key-instead-of-consumer-key tell.""" + respx.post("https://w.example/sessions").mock( + return_value=httpx.Response( + 502, + json={ + "error_code": "bifrost_handshake_failed", + "detail": {"bifrost_error": "bifrost.auth_rejected"}, + }, + ) + ) + binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391") + async with httpx.AsyncClient(base_url="https://w.example") as client: + with pytest.raises(BifrostHandshakeFailed) as exc_info: + await create_session( + client, "ratatoskr:sindra", bifrost=binding, consumer_key="ck" + ) + assert exc_info.value.bifrost_error == "bifrost.auth_rejected" + # the raw 502 body is carried for debugging + assert exc_info.value.body + + @respx.mock + async def test_bind_ephemeral_rejection_is_session_api_failed(self) -> None: + """ephemeral_422 [boundary]: 422 ephemeral_does_not_accept_bifrost is a + generic create failure → SessionApiFailed, NOT a distinct exception + (POST-003 — deliberate, an operator config error).""" + respx.post("https://w.example/sessions").mock( + return_value=httpx.Response( + 422, json={"error_code": "ephemeral_does_not_accept_bifrost"} + ) + ) + binding = BifrostBinding(endpoint_url="http://10.100.10.50:8391") + async with httpx.AsyncClient(base_url="https://w.example") as client: + with pytest.raises(SessionApiFailed) as exc_info: + await create_session( + client, "echo", bifrost=binding, consumer_key="ck" + ) + assert exc_info.value.status == 422 + + @respx.mock + async def test_unbound_create_unchanged_no_auth_override(self) -> None: + """unbound_unchanged [regression]: with no bifrost, the body is the + pre-#17 shape AND create_session sends NO per-request Authorization + override — the client's default canary bearer governs (INV-001: the two + call sites never cross).""" + import json as _json + + route = respx.post("https://w.example/sessions").mock( + return_value=httpx.Response( + 201, + json={ + "session_id": "s1", + "agent_id": "mimir", + "message_count": 0, + "created_at": "2026-04-15T12:00:00+00:00", + "last_active": "2026-04-15T12:00:00+00:00", + "metadata": {}, + }, + ) + ) + async with httpx.AsyncClient( + base_url="https://w.example", + headers={"Authorization": "Bearer canary-key"}, + ) as client: + await create_session(client, "mimir") + req = route.calls[0].request + body = _json.loads(req.content) + assert body == {"agent_id": "mimir"} + # the client default bearer is used unchanged — no consumer-key override + assert req.headers["Authorization"] == "Bearer canary-key" + + +class TestEndpointForPlane: + """Issue #17 — endpoint_for_plane: plane name → Worldtree-visible base URL.""" + + def test_memory_plane_maps_to_8391(self) -> None: + """memory [tracer]: 'memory' → http://:8391 (POST-001).""" + assert ( + endpoint_for_plane("memory", "10.100.10.50") + == "http://10.100.10.50:8391" + ) + + def test_affect_plane_maps_to_8390(self) -> None: + """affect: 'affect' → http://:8390 (POST-001).""" + assert ( + endpoint_for_plane("affect", "10.100.10.50") + == "http://10.100.10.50:8390" + ) + + def test_unknown_plane_raises_value_error(self) -> None: + """unknown_plane [adversarial]: any other plane → ValueError (PRE-001).""" + with pytest.raises(ValueError): + endpoint_for_plane("persona", "10.100.10.50") + + def _list_item( *, session_id: str = "s1", diff --git a/uv.lock b/uv.lock index 9d07977..3700845 100644 --- a/uv.lock +++ b/uv.lock @@ -1052,7 +1052,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.17.7" +version = "0.17.8" source = { editable = "." } dependencies = [ { name = "httpx" },