Compare commits

..

1 Commits

Author SHA1 Message Date
vh 2806abac44 feat(#17): web Bifrost-bind — server side (slice 3c, INV-008 lockstep complete)
Slice 3c of issue #17 — the web surface of the bind trigger, server side. Closes
the INV-008 lockstep (CLI + TUI + web all carry the bind now). Implements the
contract's "web bind split": the browser selects only the PLANE; the consumer key
and the Worldtree-visible host are SERVER-HELD config and never reach the browser.

- create_app gains bifrost_consumer_key + bifrost_visible_host (server-held,
  from env via the entrypoint: RATATOSKR_BIFROST_CONSUMER_KEY /
  RATATOSKR_PROVIDER_VISIBLE_HOST).
- _create_session_endpoint reads an optional `bifrost_plane` from the browser
  body, builds the BifrostBinding SERVER-SIDE via endpoint_for_plane(plane,
  visible_host), and calls create_session(bifrost=, consumer_key=). The 201
  response echoes bound-state {plane, endpoint, status: bound} for the UI
  indicator — never the key (INV-008/INV-009).
- Error routing: invalid plane / unconfigured server -> 400; BifrostHandshakeFailed
  -> 502 {bifrost_error}; BifrostConsumerKeyMissing (server misconfig) -> 400.

5 new web bind tests (server constructs binding + key-never-leaks + upstream
carries bifrost body + consumer-key bearer; unconfigured -> 400; invalid plane;
handshake 502; no-plane unbound regression). Full suite 470 green; added lines
ruff + mypy clean (pre-existing web-file backlog untouched).

Follow-on: the index.html plane selector (UI trigger) — the server capability is
complete and TDD'd; the browser-side dropdown is a thin separate change.

LIVE-SMOKE PROVEN (this session): the CLI bind drove a bound sindra session
against personal Worldtree :8081 -> handshake 200 -> the op-feed captured 2
recall searches correlated to the EXACT bound session_id (2c0c7482), with the
real #297/#298 union-recall scopes. Bind + observe proven end-to-end live.
2026-06-18 01:02:14 -07:00
5 changed files with 183 additions and 5 deletions
+1 -1
View File
@@ -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"
+10 -1
View File
@@ -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")
+61 -2
View File
@@ -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
+110
View File
@@ -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()
Generated
+1 -1
View File
@@ -1052,7 +1052,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.17.11"
version = "0.17.12"
source = { editable = "." }
dependencies = [
{ name = "httpx" },