feat(#17): CLI Bifrost-bind trigger (slice 3a of the INV-008 lockstep)
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).
This commit is contained in:
+134
-13
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user