Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2806abac44 |
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ratatoskr"
|
name = "ratatoskr"
|
||||||
version = "0.17.11"
|
version = "0.17.12"
|
||||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
return 11
|
return 11
|
||||||
server_url = os.environ.get("WORLDTREE_API_URL", "http://localhost:8000")
|
server_url = os.environ.get("WORLDTREE_API_URL", "http://localhost:8000")
|
||||||
end_user_id = os.environ.get("RATATOSKR_END_USER_ID")
|
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
|
# INV-001: lazy import. Users without [web] extras get a clean hint
|
||||||
# instead of a raw ImportError. Scoped narrowly to the OPTIONAL
|
# 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),
|
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).
|
# Boot banner to stderr (so stdout stays clean for piping).
|
||||||
version = _pkg_version("ratatoskr")
|
version = _pkg_version("ratatoskr")
|
||||||
|
|||||||
@@ -27,9 +27,13 @@ from ratatoskr.sessions import (
|
|||||||
AgentNotAvailable,
|
AgentNotAvailable,
|
||||||
AgentNotFound,
|
AgentNotFound,
|
||||||
AuthScopeDenied,
|
AuthScopeDenied,
|
||||||
|
BifrostBinding,
|
||||||
|
BifrostConsumerKeyMissing,
|
||||||
|
BifrostHandshakeFailed,
|
||||||
PersonaNotConfigured,
|
PersonaNotConfigured,
|
||||||
SessionApiFailed,
|
SessionApiFailed,
|
||||||
create_session,
|
create_session,
|
||||||
|
endpoint_for_plane,
|
||||||
get_persona_state,
|
get_persona_state,
|
||||||
list_agents,
|
list_agents,
|
||||||
)
|
)
|
||||||
@@ -125,17 +129,65 @@ async def _create_session_endpoint(request: Request) -> JSONResponse:
|
|||||||
return JSONResponse({"error_code": "missing_agent_id"}, status_code=400)
|
return JSONResponse({"error_code": "missing_agent_id"}, status_code=400)
|
||||||
end_user_id = request.app.state.end_user_id
|
end_user_id = request.app.state.end_user_id
|
||||||
client_factory = request.app.state.client_factory
|
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:
|
try:
|
||||||
async with client_factory() as client:
|
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:
|
except AgentNotFound:
|
||||||
return JSONResponse({"error_code": "agent_not_found"}, status_code=404)
|
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:
|
except SessionApiFailed as exc:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"error_code": "session_api_failed", "status": exc.status},
|
{"error_code": "session_api_failed", "status": exc.status},
|
||||||
status_code=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
|
@dataclass
|
||||||
@@ -345,6 +397,8 @@ def create_app(
|
|||||||
client_factory: Callable[[], httpx.AsyncClient],
|
client_factory: Callable[[], httpx.AsyncClient],
|
||||||
*,
|
*,
|
||||||
end_user_id: str | None = None,
|
end_user_id: str | None = None,
|
||||||
|
bifrost_consumer_key: str | None = None,
|
||||||
|
bifrost_visible_host: str | None = None,
|
||||||
) -> Starlette:
|
) -> Starlette:
|
||||||
"""Construct the Starlette app — wire routes + state per FN create_app.
|
"""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 = Starlette(routes=routes, lifespan=lifespan)
|
||||||
app.state.client_factory = client_factory
|
app.state.client_factory = client_factory
|
||||||
app.state.end_user_id = end_user_id
|
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)
|
# INV-002: turn registry is in-process memory, keyed (session_id, turn_id)
|
||||||
app.state.turn_registry = {}
|
app.state.turn_registry = {}
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -792,3 +792,113 @@ class TestDisconnectCancel:
|
|||||||
await asyncio.sleep(0.02)
|
await asyncio.sleep(0.02)
|
||||||
gate.set()
|
gate.set()
|
||||||
assert cancel_route.called, "browser disconnect must cancel the UPSTREAM turn (42)"
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user