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:
2026-06-18 00:45:48 -07:00
parent 8ebe227ae4
commit 0bebad74ad
4 changed files with 217 additions and 17 deletions
+1 -1
View File
@@ -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"
+81 -2
View File
@@ -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
+134 -13
View File
@@ -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
Generated
+1 -1
View File
@@ -1052,7 +1052,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.17.9"
version = "0.17.10"
source = { editable = "." }
dependencies = [
{ name = "httpx" },