diff --git a/pyproject.toml b/pyproject.toml index d80a885..940034c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.21.16" +version = "0.21.17" description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/cli.py b/src/ratatoskr/cli.py index 1f5e35a..0c38fb3 100644 --- a/src/ratatoskr/cli.py +++ b/src/ratatoskr/cli.py @@ -750,7 +750,11 @@ def _format_whoami(me: Mapping[str, Any], caps: Mapping[str, Any]) -> str: lines = ["identity:"] lines.append(f" user_id: {me.get('user_id', '?')}") lines.append(f" tier: {me.get('tier', '?')}") - lines.append(f" scopes: {', '.join(me.get('scopes', [])) or '(none)'}") + # Open-world read: `scopes` may be absent, present-null, or carry non-strings — + # `or []` + str() degrades all three (a present-null `.get('scopes', [])` returns + # None, not the default), matching the `allowed_roles` hardening below (heid-code- + # review slice-5: the contract names this function the degrade-not-crash exemplar). + lines.append(f" scopes: {', '.join(str(s) for s in (me.get('scopes') or [])) or '(none)'}") for k in ("display_name", "key_id", "key_label"): if k in me: lines.append(f" {k}: {me[k]}") @@ -828,10 +832,16 @@ async def _characters_probe(args: ParsedArgs) -> int: client = wt.build_client(args.server_url, api_key=args.api_key, transport=transport) try: models = await wt.list_character_models(client) - # Open-world read: `items` may be absent OR explicit-null — `or []` degrades - # both to empty (mirrors the slice-4 `_format_whoami` hardening), never a - # `join(None)` crash. - names = ", ".join(m.get("name", "?") for m in (models.get("items") or [])) + # Open-world read: `items` may be absent/null (`or []`) AND each entry may be + # a non-mapping (`[None]` / `["x"]`) or carry a non-string `name` — guard the + # item is a dict and coerce `name` to str so a malformed catalog degrades + # rather than crashing (heid-code-review slice-5; element-level completion of + # the list-level `or []` guard). + names = ", ".join( + str(m.get("name", "?")) + for m in (models.get("items") or []) + if isinstance(m, dict) + ) sys.stdout.write(f"character models: {names or '(none)'}\n") created = await wt.create_character( client, diff --git a/tests/test_cli.py b/tests/test_cli.py index 8c4668d..075a06b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1879,6 +1879,25 @@ class TestWhoami: assert rc == 20 assert "[session_api_failed]" in capsys.readouterr().err + @respx.mock + def test_whoami_tolerates_null_and_nonstring_scopes( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """scopes present-null / non-string → renders '(none)' or str-coerced, never a + `join(None)` TypeError (heid-code-review slice-5: `_format_whoami` is the + contract's degrade-not-crash exemplar; `allowed_roles` was hardened, `scopes` + was not).""" + # scopes: null (present, not absent) → `.get('scopes', [])` would return None. + respx.get("https://w.example/me").mock( + return_value=httpx.Response(200, json={"user_id": "u", "scopes": None, "tier": "user"}) + ) + respx.get("https://w.example/capabilities").mock( + return_value=httpx.Response(200, json={"ephemeral_templates": {}}) + ) + rc = main(["--whoami", "--api-key", "k", "--server", "https://w.example"]) + assert rc == 0 + assert "scopes: (none)" in capsys.readouterr().out + class TestTier2Probes: """--characters + --set-persona-pad one-shot probes (Tier-2: #161 + persona_state-write).""" @@ -1923,6 +1942,32 @@ class TestTier2Probes: assert "deleted: char_z" in out assert del_route.call_count == 1 # lifecycle cleaned up + @respx.mock + def test_characters_probe_tolerates_malformed_models( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """models catalog with non-mapping / non-string-name items → degrades (no + AttributeError/TypeError), lifecycle still proceeds (heid-code-review slice-5: + element-level completion of the list-level `or []` guard).""" + respx.get("https://w.example/models/available-for-characters").mock( + return_value=httpx.Response( + 200, json={"items": [None, "x", {"name": 123}, {"name": "ok"}]} + ) + ) + respx.post("https://w.example/characters").mock( + return_value=httpx.Response(201, json={"character_id": "c1", "ttl_expires_at": "t"}) + ) + respx.get("https://w.example/characters/c1/state").mock( + return_value=httpx.Response(200, json={"pad": [0.0, 0.0, 0.0]}) + ) + respx.delete("https://w.example/characters/c1").mock(return_value=httpx.Response(204)) + rc = main(["--characters", "--api-key", "k", "--server", "https://w.example"]) + assert rc == 0 + out = capsys.readouterr().out + # non-mappings dropped; {"name":123}→"123", {"name":"ok"}→"ok" — no crash. + assert "character models: 123, ok" in out + assert "created: c1" in out + @respx.mock def test_characters_probe_create_missing_id_aborts( self, capsys: pytest.CaptureFixture[str] diff --git a/uv.lock b/uv.lock index 3ae38c6..68ac1f4 100644 --- a/uv.lock +++ b/uv.lock @@ -472,7 +472,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.21.16" +version = "0.21.17" source = { editable = "." } dependencies = [ { name = "httpx" },