diff --git a/pyproject.toml b/pyproject.toml index 347f329..9c7aeca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.17.11" +version = "0.17.12" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/web/entrypoint.py b/src/ratatoskr/web/entrypoint.py index 9868014..f69daea 100644 --- a/src/ratatoskr/web/entrypoint.py +++ b/src/ratatoskr/web/entrypoint.py @@ -59,6 +59,10 @@ def main(argv: list[str] | None = None) -> int: return 11 server_url = os.environ.get("WORLDTREE_API_URL", "http://localhost:8000") end_user_id = os.environ.get("RATATOSKR_END_USER_ID") + # Issue #17 (web bind split): server-held Bifrost binding config. The browser + # selects the plane; the consumer key + visible host live server-side only. + bifrost_consumer_key = os.environ.get("RATATOSKR_BIFROST_CONSUMER_KEY") + bifrost_visible_host = os.environ.get("RATATOSKR_PROVIDER_VISIBLE_HOST") # INV-001: lazy import. Users without [web] extras get a clean hint # instead of a raw ImportError. Scoped narrowly to the OPTIONAL @@ -93,7 +97,12 @@ def main(argv: list[str] | None = None) -> int: timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0), ) - app = create_app(client_factory, end_user_id=end_user_id) + app = create_app( + client_factory, + end_user_id=end_user_id, + bifrost_consumer_key=bifrost_consumer_key, + bifrost_visible_host=bifrost_visible_host, + ) # Boot banner to stderr (so stdout stays clean for piping). version = _pkg_version("ratatoskr") diff --git a/src/ratatoskr/web/server.py b/src/ratatoskr/web/server.py index ddee1fd..f545a4f 100644 --- a/src/ratatoskr/web/server.py +++ b/src/ratatoskr/web/server.py @@ -27,9 +27,13 @@ from ratatoskr.sessions import ( AgentNotAvailable, AgentNotFound, AuthScopeDenied, + BifrostBinding, + BifrostConsumerKeyMissing, + BifrostHandshakeFailed, PersonaNotConfigured, SessionApiFailed, create_session, + endpoint_for_plane, get_persona_state, list_agents, ) @@ -125,17 +129,65 @@ async def _create_session_endpoint(request: Request) -> JSONResponse: return JSONResponse({"error_code": "missing_agent_id"}, status_code=400) end_user_id = request.app.state.end_user_id client_factory = request.app.state.client_factory + + # Issue #17 (web bind split): the browser may select a PLANE; the server holds + # the consumer key + visible host and constructs the binding. The consumer key + # NEVER reaches the browser (INV-008/INV-009). + bifrost: BifrostBinding | None = None + bifrost_plane = body.get("bifrost_plane") if isinstance(body, dict) else None + consumer_key = request.app.state.bifrost_consumer_key + visible_host = request.app.state.bifrost_visible_host + if bifrost_plane: + if bifrost_plane not in ("memory", "affect"): + return JSONResponse( + {"error_code": "invalid_bifrost_plane"}, status_code=400 + ) + if not (consumer_key and visible_host): + return JSONResponse( + {"error_code": "bifrost_not_configured"}, status_code=400 + ) + bifrost = BifrostBinding( + endpoint_url=endpoint_for_plane(bifrost_plane, visible_host) + ) + try: async with client_factory() as client: - info = await create_session(client, agent_id, end_user_id=end_user_id) + info = await create_session( + client, + agent_id, + end_user_id=end_user_id, + bifrost=bifrost, + consumer_key=consumer_key if bifrost else None, + ) except AgentNotFound: return JSONResponse({"error_code": "agent_not_found"}, status_code=404) + except BifrostConsumerKeyMissing: + # Server misconfiguration: a plane was requested but no consumer key. + return JSONResponse( + {"error_code": "bifrost_not_configured"}, status_code=400 + ) + except BifrostHandshakeFailed as exc: + return JSONResponse( + { + "error_code": "bifrost_handshake_failed", + "bifrost_error": exc.bifrost_error, + }, + status_code=502, + ) except SessionApiFailed as exc: return JSONResponse( {"error_code": "session_api_failed", "status": exc.status}, status_code=exc.status, ) - return JSONResponse(_as_dict(info), status_code=201) + payload = _as_dict(info) + if bifrost is not None: + # Bound-state for the UI indicator — plane + endpoint only, never the key. + payload["bifrost"] = { + "plane": bifrost_plane, + "endpoint": bifrost.endpoint_url, + "status": "bound", + } + return JSONResponse(payload, status_code=201) @dataclass @@ -345,6 +397,8 @@ def create_app( client_factory: Callable[[], httpx.AsyncClient], *, end_user_id: str | None = None, + bifrost_consumer_key: str | None = None, + bifrost_visible_host: str | None = None, ) -> Starlette: """Construct the Starlette app — wire routes + state per FN create_app. @@ -411,6 +465,11 @@ def create_app( app = Starlette(routes=routes, lifespan=lifespan) app.state.client_factory = client_factory app.state.end_user_id = end_user_id + # Issue #17 (web bind split): the consumer key + Worldtree-visible provider + # host are SERVER-HELD config (env), never sent from the browser. The browser + # selects only the PLANE; the server constructs the bound session (INV-008). + app.state.bifrost_consumer_key = bifrost_consumer_key + app.state.bifrost_visible_host = bifrost_visible_host # INV-002: turn registry is in-process memory, keyed (session_id, turn_id) app.state.turn_registry = {} return app diff --git a/tests/test_web_server.py b/tests/test_web_server.py index 90e390f..c973020 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -792,3 +792,113 @@ class TestDisconnectCancel: await asyncio.sleep(0.02) gate.set() assert cancel_route.called, "browser disconnect must cancel the UPSTREAM turn (42)" + + +class TestWebBifrostBind: + """Issue #17 slice 3c — web bind split: the browser selects the PLANE; the + consumer key + visible host are SERVER-HELD and never reach the browser + (INV-008/INV-009).""" + + @respx.mock + def test_bound_create_server_constructs_binding_key_never_leaks(self) -> None: + """tracer: a plane from the browser → the server builds the binding with + its OWN consumer key + host, sends the bifrost body + consumer-key bearer + upstream, and returns bound-state WITHOUT the key.""" + import json as _json + + from ratatoskr.web.server import create_app + + route = respx.post("https://w.example/sessions").mock( + return_value=httpx.Response(201, json=_CREATE_OK) + ) + app = create_app( + _mock_client_factory(), + bifrost_consumer_key="server-ck", + bifrost_visible_host="10.100.10.50", + ) + resp = TestClient(app).post( + "/api/sessions", json={"agent_id": "ratatoskr:sindra", "bifrost_plane": "memory"} + ) + assert resp.status_code == 201 + # bound-state echoed for the UI indicator — plane + endpoint, NO key + assert resp.json()["bifrost"] == { + "plane": "memory", + "endpoint": "http://10.100.10.50:8391", + "status": "bound", + } + assert "server-ck" not in resp.text # the key never reaches the browser + # upstream got the bifrost body + the consumer-key bearer override + upstream = route.calls[0].request + body = _json.loads(upstream.content) + assert body["bifrost"] == { + "endpoint_url": "http://10.100.10.50:8391", "scope": None + } + assert upstream.headers["Authorization"] == "Bearer server-ck" + + @respx.mock + def test_plane_without_server_config_is_400(self) -> None: + """A plane requested but no server-held key/host → bifrost_not_configured.""" + from ratatoskr.web.server import create_app + + app = create_app(_mock_client_factory()) # no bifrost config + resp = TestClient(app).post( + "/api/sessions", json={"agent_id": "a", "bifrost_plane": "memory"} + ) + assert resp.status_code == 400 + assert resp.json()["error_code"] == "bifrost_not_configured" + + def test_invalid_plane_is_400(self) -> None: + from ratatoskr.web.server import create_app + + app = create_app( + _mock_client_factory(), + bifrost_consumer_key="ck", + bifrost_visible_host="h", + ) + resp = TestClient(app).post( + "/api/sessions", json={"agent_id": "a", "bifrost_plane": "persona"} + ) + assert resp.status_code == 400 + assert resp.json()["error_code"] == "invalid_bifrost_plane" + + @respx.mock + def test_handshake_failure_is_502(self) -> None: + from ratatoskr.web.server import create_app + + respx.post("https://w.example/sessions").mock( + return_value=httpx.Response( + 502, + json={ + "error_code": "bifrost_handshake_failed", + "detail": {"bifrost_error": "bifrost.auth_rejected"}, + }, + ) + ) + app = create_app( + _mock_client_factory(), + bifrost_consumer_key="ck", + bifrost_visible_host="h", + ) + resp = TestClient(app).post( + "/api/sessions", json={"agent_id": "a", "bifrost_plane": "memory"} + ) + assert resp.status_code == 502 + assert resp.json()["error_code"] == "bifrost_handshake_failed" + assert resp.json()["bifrost_error"] == "bifrost.auth_rejected" + + @respx.mock + def test_no_plane_is_unbound_no_bifrost_in_response(self) -> None: + """regression: no bifrost_plane → pre-#17 unbound create, no bifrost key.""" + from ratatoskr.web.server import create_app + + respx.post("https://w.example/sessions").mock( + return_value=httpx.Response(201, json=_CREATE_OK) + ) + app = create_app( + _mock_client_factory(), + bifrost_consumer_key="ck", + bifrost_visible_host="h", + ) + resp = TestClient(app).post("/api/sessions", json={"agent_id": "mimir"}) + assert resp.status_code == 201 + assert "bifrost" not in resp.json() diff --git a/uv.lock b/uv.lock index 3d5c56e..907c63a 100644 --- a/uv.lock +++ b/uv.lock @@ -1052,7 +1052,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.17.11" +version = "0.17.12" source = { editable = "." } dependencies = [ { name = "httpx" },