Compare commits

..

2 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
vh 016defcc01 feat(#17): TUI Bifrost-bind trigger (slice 3b of the INV-008 lockstep)
Slice 3b of issue #17 — the TUI surface of the bind trigger (web is 3c). The TUI
consumes the same ParsedArgs the cli already parses (--bifrost-plane / --bifrost-url
/ consumer key from RATATOSKR_BIFROST_CONSUMER_KEY), so this wires the bind into
_resolve_then_run's pre-flight create_session:

- bifrost + consumer_key threaded into create_session at the pre-alt-screen
  resolution layer, so bind failures land on the operator's REAL stderr BEFORE
  the Textual alt-screen opens (INV-002, mirrors issue #6's pre-alt-screen
  routing) — never eaten by the alt-screen teardown.
- BifrostConsumerKeyMissing -> exit 22; BifrostHandshakeFailed -> exit 23 with the
  same 401-scoping hint, keyed on bifrost_error == bifrost.auth_rejected. Exit
  codes + label vocabulary match cli._amain exactly (INV-006).
- Bound-state indicator on success (pre-alt-screen): ". bifrost: status=bound
  plane=... endpoint=...".

3 new TUI bind tests (handshake-fail / consumer-key-missing / bound-create carries
binding + indicator, run_async stubbed). Full suite 465 green; added lines ruff +
mypy clean (pre-existing tui.py lint/type backlog untouched per surgical-changes).
2026-06-18 00:49:30 -07:00
7 changed files with 311 additions and 6 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.17.10"
version = "0.17.12"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md"
requires-python = ">=3.12"
+32 -1
View File
@@ -38,6 +38,8 @@ from ratatoskr.sessions import (
AgentNotAvailable,
AgentNotFound,
AuthScopeDenied,
BifrostConsumerKeyMissing,
BifrostHandshakeFailed,
PersonaNotConfigured,
SessionApiFailed,
create_session,
@@ -1504,11 +1506,33 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
assert chosen_agent_id is not None
try:
info = await create_session(
client, chosen_agent_id, end_user_id=args.end_user_id
client,
chosen_agent_id,
end_user_id=args.end_user_id,
bifrost=args.bifrost,
consumer_key=args.consumer_key,
)
except AgentNotFound as exc:
sys.stderr.write(f"[agent_not_found] agent_id={exc.agent_id}\n")
return 12
except BifrostConsumerKeyMissing as exc:
# INV-001/INV-002: bind failures land on real stderr BEFORE the
# alt-screen opens (mirrors cli._amain exit codes / vocab, INV-006).
sys.stderr.write(
f"[bifrost_consumer_key_missing] {exc} "
f"(set RATATOSKR_BIFROST_CONSUMER_KEY)\n"
)
return 22
except BifrostHandshakeFailed as exc:
sys.stderr.write(
f"[bifrost_handshake_failed] bifrost_error={exc.bifrost_error}\n"
)
if exc.bifrost_error == "bifrost.auth_rejected":
sys.stderr.write(
" bound create requires the consumer key "
"(RATATOSKR_BIFROST_CONSUMER_KEY), not WORLDTREE_API_KEY\n"
)
return 23
except SessionApiFailed as exc:
sys.stderr.write(
f"[session_api_failed] status={exc.status} body={exc.body!r}\n"
@@ -1517,6 +1541,13 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
return 21
# Issue #17 bound-state indicator (pre-alt-screen, mirrors cli._amain).
if args.bifrost is not None:
plane = args.bifrost_plane or "direct"
sys.stderr.write(
f". bifrost: status=bound plane={plane} "
f"endpoint={args.bifrost.endpoint_url}\n"
)
session_id = info.session_id
agent_id: str | None = info.agent_id
else:
+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
+96
View File
@@ -9,6 +9,7 @@ import respx
from textual.widgets import RichLog
from ratatoskr.cli import ParsedArgs
from ratatoskr.sessions import BifrostBinding
from ratatoskr.sse_client import (
Cancelled,
Done,
@@ -2849,3 +2850,98 @@ class TestResolveThenRunWithPicker:
err = capsys.readouterr().err
assert "[no_agents]" in err
assert picker_called is False
class TestTuiBifrostBind:
"""Issue #17 slice 3b — TUI bind trigger: bind failures route to the real
stderr BEFORE the alt-screen opens (INV-002, mirrors issue #6; same exit
codes/vocabulary as cli._amain per INV-006)."""
@respx.mock
async def test_handshake_failure_routes_pre_altscreen(
self, capsys: pytest.CaptureFixture[str]
) -> None:
from ratatoskr.tui import _resolve_then_run
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
502,
json={
"error_code": "bifrost_handshake_failed",
"detail": {"bifrost_error": "bifrost.auth_rejected"},
},
)
)
args = _args_new(
agent_id="ratatoskr:sindra",
bifrost=BifrostBinding(endpoint_url="http://10.100.10.50:8391"),
bifrost_plane="memory",
consumer_key="ck",
)
rc = await _resolve_then_run(args)
assert rc == 23
err = capsys.readouterr().err
assert "bifrost.auth_rejected" in err
assert "consumer key" in err # the 401-scoping hint
@respx.mock
async def test_consumer_key_missing_routes_pre_altscreen(
self, capsys: pytest.CaptureFixture[str]
) -> None:
from ratatoskr.tui import _resolve_then_run
args = _args_new(
agent_id="a",
bifrost=BifrostBinding(endpoint_url="http://x:8391"),
consumer_key=None,
)
rc = await _resolve_then_run(args)
assert rc == 22
assert "bifrost_consumer_key_missing" in capsys.readouterr().err
@respx.mock
async def test_bound_create_carries_binding_and_consumer_key(
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""A successful bound create sends the bifrost body + the consumer-key
bearer and prints the bound-state indicator (run_async stubbed so no
alt-screen opens)."""
from ratatoskr import tui as tui_mod
from ratatoskr.tui import _resolve_then_run
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": {},
},
)
)
async def fake_run_async(self) -> int:
return 0
monkeypatch.setattr(tui_mod.RatatoskrApp, "run_async", fake_run_async)
args = _args_new(
agent_id="ratatoskr:sindra",
bifrost=BifrostBinding(endpoint_url="http://10.100.10.50:8391"),
bifrost_plane="memory",
consumer_key="ck",
)
rc = await _resolve_then_run(args)
assert rc == 0
import json as _json
body = _json.loads(route.calls[0].request.content)
assert body["bifrost"] == {
"endpoint_url": "http://10.100.10.50:8391", "scope": None
}
assert route.calls[0].request.headers["Authorization"] == "Bearer ck"
err = capsys.readouterr().err
assert "bifrost: status=bound" in err
assert "plane=memory" in err
+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.10"
version = "0.17.12"
source = { editable = "." }
dependencies = [
{ name = "httpx" },