From 0bebad74ad8adf97ab412805bd246dabc37cc5ae Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Thu, 18 Jun 2026 00:45:48 -0700 Subject: [PATCH] feat(#17): CLI Bifrost-bind trigger (slice 3a of the INV-008 lockstep) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 3a of issue #17 — the CLI surface of the bind trigger (TUI + web follow, INV-008 lockstep). ratatoskr can now self-drive a bound session from the CLI: - New flags: --bifrost-plane {memory,affect} (dev shortcut -> endpoint_for_plane over --bifrost-host / RATATOSKR_PROVIDER_VISIBLE_HOST) and --bifrost-url (the direct HTTPS/prod endpoint, bypassing the plane shortcut). Mutually exclusive; a binding is a session-CREATE concern (forbidden with --session). - Consumer key resolved from RATATOSKR_BIFROST_CONSUMER_KEY only (the privileged handshake identity — never a CLI flag, distinct from the canary WORLDTREE_API_KEY). - _amain threads bifrost + consumer_key into create_session and routes the bind failures: BifrostConsumerKeyMissing -> exit 22; BifrostHandshakeFailed -> exit 23 with the 401-scoping hint ("use the consumer key, not WORLDTREE_API_KEY") keyed on bifrost_error == bifrost.auth_rejected. - Bound-state indicator on success: ". bifrost: status=bound plane=... endpoint=..." — shows WHICH identity/endpoint bound, not a bare boolean. Also fixes a pre-existing test-isolation bug: test_no_textual_import did a live importlib.reload(ratatoskr.cli) that mutated the shared module in place, breaking class identity (isinstance / pytest.raises) for every test after it. The real check is the static source grep; the reload was vestigial and is removed. 9 new CLI bind tests; full suite 462 green; ruff clean (no new mypy errors). --- pyproject.toml | 2 +- src/ratatoskr/cli.py | 83 +++++++++++++++++++++++- tests/test_cli.py | 147 +++++++++++++++++++++++++++++++++++++++---- uv.lock | 2 +- 4 files changed, 217 insertions(+), 17 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4435adb..0502641 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.17.9" +version = "0.17.10" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/cli.py b/src/ratatoskr/cli.py index e0b6bee..a8e4127 100644 --- a/src/ratatoskr/cli.py +++ b/src/ratatoskr/cli.py @@ -16,7 +16,15 @@ from typing import TextIO import httpx -from ratatoskr.sessions import AgentNotFound, SessionApiFailed, create_session +from ratatoskr.sessions import ( + AgentNotFound, + BifrostBinding, + BifrostConsumerKeyMissing, + BifrostHandshakeFailed, + SessionApiFailed, + create_session, + endpoint_for_plane, +) from ratatoskr.sse_client import ( AffectUpdate, AwaitingLlmFirstToken, @@ -83,6 +91,12 @@ class ParsedArgs: # Per issue #5: optional `--end-user-id` for per-end-user agents (lofn etc.). # Default None preserves the pre-#5 baseline for agents that don't require it (mimir). end_user_id: str | None = None + # Issue #17: optional Bifrost binding (one plane) + its consumer key. None on + # the unbound pre-#17 path. `bifrost_plane` is the human label for the + # bound-state indicator (None when --bifrost-url supplies the endpoint directly). + bifrost: BifrostBinding | None = None + bifrost_plane: str | None = None + consumer_key: str | None = None class _ArgparseError(Exception): @@ -109,6 +123,13 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs: parser.add_argument("--raw", action="store_true") # Issue #5: required for per-end-user agents (lofn etc.); optional otherwise (mimir). parser.add_argument("--end-user-id", dest="end_user_id", default=None) + # Issue #17: bind the created session to our own Bifrost provider plane. + parser.add_argument( + "--bifrost-plane", dest="bifrost_plane", choices=("memory", "affect"), + default=None, + ) + parser.add_argument("--bifrost-host", dest="bifrost_host", default=None) + parser.add_argument("--bifrost-url", dest="bifrost_url", default=None) try: ns = parser.parse_args(argv) except _ArgparseError as exc: @@ -143,6 +164,30 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs: # gets a stable partition without papering over the explicit-flag override. end_user_id = ns.end_user_id or os.environ.get("RATATOSKR_END_USER_ID") or None + # Issue #17: resolve the optional Bifrost binding. --bifrost-url (direct, + # HTTPS/prod) and --bifrost-plane (dev shortcut → endpoint_for_plane) are + # mutually exclusive; a binding is a session-CREATE concern (forbidden with + # --session). The consumer key — the privileged handshake identity, distinct + # from the canary key — comes from the env (never a CLI flag). + bifrost: BifrostBinding | None = None + bifrost_plane: str | None = None + if ns.bifrost_url and ns.bifrost_plane: + raise UsageError("--bifrost-url and --bifrost-plane are mutually exclusive") + if (ns.bifrost_url or ns.bifrost_plane) and not ns.new: + raise UsageError("a bifrost binding requires --new (it binds at session create)") + if ns.bifrost_url: + bifrost = BifrostBinding(endpoint_url=ns.bifrost_url) + elif ns.bifrost_plane: + host = ns.bifrost_host or os.environ.get("RATATOSKR_PROVIDER_VISIBLE_HOST") + if not host: + raise UsageError( + "--bifrost-plane requires --bifrost-host " + "(or RATATOSKR_PROVIDER_VISIBLE_HOST) — the Worldtree-visible provider host" + ) + bifrost = BifrostBinding(endpoint_url=endpoint_for_plane(ns.bifrost_plane, host)) + bifrost_plane = ns.bifrost_plane + consumer_key = os.environ.get("RATATOSKR_BIFROST_CONSUMER_KEY") or None + return ParsedArgs( send_content=ns.send, session_id=ns.session, @@ -152,6 +197,9 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs: server_url=server_url, raw=ns.raw, end_user_id=end_user_id, + bifrost=bifrost, + bifrost_plane=bifrost_plane, + consumer_key=consumer_key, ) @@ -432,11 +480,34 @@ async def _amain(args: ParsedArgs) -> int: assert args.agent_id is not None try: info = await create_session( - client, args.agent_id, end_user_id=args.end_user_id + client, + args.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: never fall back to the canary key — fail loud. + sys.stderr.write( + f"[bifrost_consumer_key_missing] {exc} " + f"(set RATATOSKR_BIFROST_CONSUMER_KEY)\n" + ) + return 22 + except BifrostHandshakeFailed as exc: + # INV-002: bind-time handshake failure fails session creation. + sys.stderr.write( + f"[bifrost_handshake_failed] bifrost_error={exc.bifrost_error}\n" + ) + # 401-message scoping: keyed on auth_rejected, name the key mismatch. + 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") return 20 @@ -448,6 +519,14 @@ async def _amain(args: ParsedArgs) -> int: sys.stderr.write( f". create_session: session_id={info.session_id} agent_id={info.agent_id}\n" ) + # Issue #17 bound-state indicator: plane + endpoint + status, so the + # operator sees WHICH identity/endpoint bound (not a bare boolean). + 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 else: assert args.session_id is not None diff --git a/tests/test_cli.py b/tests/test_cli.py index 6c0d6c5..cdef431 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -19,6 +19,7 @@ from ratatoskr.cli import ( _run_turn, main, ) +from ratatoskr.sessions import BifrostBinding from ratatoskr.sse_client import ( Cancelled, Done, @@ -86,6 +87,8 @@ def _clear_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("WORLDTREE_API_KEY", raising=False) monkeypatch.delenv("WORLDTREE_API_URL", raising=False) monkeypatch.delenv("RATATOSKR_END_USER_ID", raising=False) + monkeypatch.delenv("RATATOSKR_BIFROST_CONSUMER_KEY", raising=False) + monkeypatch.delenv("RATATOSKR_PROVIDER_VISIBLE_HOST", raising=False) class TestParseArgs: @@ -1290,25 +1293,16 @@ class TestAmain: def test_no_textual_import(self) -> None: """no_textual_import [scenario]: …""" - import importlib - import sys - - # Clear any prior textual import to make this test honest in isolation - textual_was_imported = "textual" in sys.modules - # We cannot reliably remove textual mid-suite (other tests might rely on it via dev deps), - # so the assertion is: importing ratatoskr.cli does not REQUIRE textual. - importlib.reload(__import__("ratatoskr.cli", fromlist=["_amain"])) - # The boundary is the INV-001 import-only rule. If ratatoskr/cli.py grew an - # `import textual` directly, the import would still succeed (textual is installed) - # but the source-level boundary is the load-bearing check — covered by a static-grep - # smoke test pattern. Do that here: + # INV-001 import-only boundary: cli.py must not import textual/rich at the + # source level. The load-bearing check is a static source grep (NOT a live + # `importlib.reload`, which would mutate the shared module in place and break + # class identity — isinstance / pytest.raises — for every later test). import pathlib src = pathlib.Path(__file__).parent.parent / "src" / "ratatoskr" / "cli.py" text = src.read_text() for forbidden in ("import textual", "from textual", "import rich", "from rich"): assert forbidden not in text, f"INV-001 violation: cli.py contains '{forbidden}'" - _ = textual_was_imported # avoid unused warning class TestMain: @@ -1419,3 +1413,130 @@ class TestMain: assert tui_calls[0].send_content is None assert tui_calls[0].session_id == "s-1" assert amain_calls == [] + + +class TestBifrostBindCli: + """Issue #17 slice 3a — the CLI Bifrost-bind trigger (INV-008, one of three).""" + + def test_plane_and_host_build_binding( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """tracer: --bifrost-plane + --bifrost-host resolve a BifrostBinding via + endpoint_for_plane; the consumer key comes from the env.""" + monkeypatch.setenv("RATATOSKR_BIFROST_CONSUMER_KEY", "ck") + args = _parse_args( + [ + "--send", "hi", "--new", "--agent", "ratatoskr:sindra", "--api-key", "k", + "--bifrost-plane", "memory", "--bifrost-host", "10.100.10.50", + ] + ) + assert args.bifrost == BifrostBinding(endpoint_url="http://10.100.10.50:8391") + assert args.bifrost_plane == "memory" + assert args.consumer_key == "ck" + + def test_host_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + """--bifrost-host falls back to RATATOSKR_PROVIDER_VISIBLE_HOST.""" + monkeypatch.setenv("RATATOSKR_BIFROST_CONSUMER_KEY", "ck") + monkeypatch.setenv("RATATOSKR_PROVIDER_VISIBLE_HOST", "10.0.0.9") + args = _parse_args( + ["--send", "hi", "--new", "--agent", "a", "--api-key", "k", + "--bifrost-plane", "affect"] + ) + assert args.bifrost == BifrostBinding(endpoint_url="http://10.0.0.9:8390") + + def test_direct_url_bypasses_plane(self, monkeypatch: pytest.MonkeyPatch) -> None: + """--bifrost-url is the direct (HTTPS/prod) endpoint, bypassing the plane + shortcut; no plane label.""" + monkeypatch.setenv("RATATOSKR_BIFROST_CONSUMER_KEY", "ck") + args = _parse_args( + ["--send", "hi", "--new", "--agent", "a", "--api-key", "k", + "--bifrost-url", "https://prov.example:8391"] + ) + assert args.bifrost == BifrostBinding(endpoint_url="https://prov.example:8391") + assert args.bifrost_plane is None + + def test_no_bifrost_flags_leaves_binding_none(self) -> None: + """regression: no bifrost flags → bifrost/consumer_key None (pre-#17 path).""" + args = _parse_args( + ["--send", "hi", "--new", "--agent", "mimir", "--api-key", "k"] + ) + assert args.bifrost is None + assert args.consumer_key is None + + def test_plane_and_url_mutually_exclusive( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("RATATOSKR_BIFROST_CONSUMER_KEY", "ck") + with pytest.raises(UsageError): + _parse_args( + ["--send", "hi", "--new", "--agent", "a", "--api-key", "k", + "--bifrost-plane", "memory", "--bifrost-host", "h", + "--bifrost-url", "https://x:8391"] + ) + + def test_plane_without_host_is_usage_error( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("RATATOSKR_BIFROST_CONSUMER_KEY", "ck") + with pytest.raises(UsageError): + _parse_args( + ["--send", "hi", "--new", "--agent", "a", "--api-key", "k", + "--bifrost-plane", "memory"] + ) + + def test_bind_with_existing_session_is_usage_error( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A binding is a session-CREATE concern; --session (existing) + bind is + a usage error.""" + monkeypatch.setenv("RATATOSKR_BIFROST_CONSUMER_KEY", "ck") + with pytest.raises(UsageError): + _parse_args( + ["--send", "hi", "--session", "s-1", "--api-key", "k", + "--bifrost-plane", "memory", "--bifrost-host", "h"] + ) + + @respx.mock + async def test_amain_bound_create_carries_binding_and_routes_502( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """_amain on a bound create sends the bifrost body + the consumer-key + bearer; a 502 auth_rejected routes to BifrostHandshakeFailed with the + consumer-key-mismatch hint (INV-001/002, 401-message scoping).""" + route = respx.post("http://w/sessions").mock( + return_value=httpx.Response( + 502, + json={ + "error_code": "bifrost_handshake_failed", + "detail": {"bifrost_error": "bifrost.auth_rejected"}, + }, + ) + ) + args = ParsedArgs( + send_content="hi", session_id=None, new=True, agent_id="ratatoskr:sindra", + api_key="canary", server_url="http://w", raw=False, end_user_id="smoke-user", + bifrost=BifrostBinding(endpoint_url="http://10.100.10.50:8391"), + bifrost_plane="memory", consumer_key="ck", + ) + rc = await _amain(args) + assert rc == 23 + 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.auth_rejected" in err + assert "consumer key" in err # the 401-scoping hint + + async def test_amain_bind_without_consumer_key_exits(self) -> None: + """_amain on a bind with no consumer key raises BifrostConsumerKeyMissing + (before HTTP) → a clean exit code, never a canary fallback.""" + args = ParsedArgs( + send_content="hi", session_id=None, new=True, agent_id="a", + api_key="canary", server_url="http://w", raw=False, end_user_id=None, + bifrost=BifrostBinding(endpoint_url="http://x:8391"), + bifrost_plane="memory", consumer_key=None, + ) + rc = await _amain(args) + assert rc == 22 diff --git a/uv.lock b/uv.lock index bb30919..8abe54b 100644 --- a/uv.lock +++ b/uv.lock @@ -1052,7 +1052,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.17.9" +version = "0.17.10" source = { editable = "." } dependencies = [ { name = "httpx" },