Compare commits

..

2 Commits

Author SHA1 Message Date
vh 4e20030229 fix(#20): heid-bug-hunt fixups — CLI open-world container-type hardening (slice-5)
Panel (Gróa + Hulda + Regin, source-verified by Heid): adapter/route-map/
ConnectFailed-at-call-sites sound against the declared invariants; 4 real
robustness findings, all in the CLI open-world presenter/probe paths — the
container-type layer BELOW the null/element holes the code-review already fixed.

Fixed (findings 1-3):
- `_format_whoami` (`cli.py`): a non-iterable `scopes`/`allowed_roles` scalar
  (`{"scopes": 123}`) made `x or []` yield `123` → `for s in 123` TypeError. New
  `_display_seq` helper degrades any non-list (scalar / bare string / null / absent)
  to empty; applied to both `scopes` and `allowed_roles`.
- `_characters_probe` (`cli.py`): same class on the model catalog `items` (`{"items":
  123}`) — now guards `models` is a Mapping and `items` is a list before iterating.
- `_characters_probe`: the top-level open-world reads `created` / `state` are now
  `isinstance(_, Mapping)`-guarded before any `.get` — a non-mapping SDK passthrough
  (`created=[...]`) aborts cleanly (exit 20) / renders `pad=None` instead of an
  AttributeError.

Accepted (finding 4, documented in contract § slice-5 notes): the `--characters`
probe leaks its transient character on a mid-lifecycle failure. PRE-EXISTING (the
retired probe had the identical linear no-`finally` structure — cutover did not
worsen it), TTL-bounded, one-shot diagnostic; a `try/finally` would swallow a
happy-path delete-failure (delete is both teardown and a tested step). Gróa + Heid
concur accept is defensible.

Dismissed (finding 5): Hulda flagged `sessions.py` dropping `get_me`/etc. as a
caller-contract break — it is the intended DEC-3 no-backwards-compat migration (all
in-repo callers rewired same-diff); Heid labels it intended-surface-change.

Added CLI tests for the three hardened paths (scalar scopes/roles; scalar items +
non-mapping state; non-mapping create abort). Suite 488 green; ruff clean; live
smoke re-run clean (identical happy-path output). Patch bump 0.21.17 → 0.21.18.
2026-07-19 11:29:10 -07:00
vh d86d6df147 fix(#20): heid-code-review fixups — CLI presenter degrade-not-crash (slice-5)
Panel: Gróa + Regin returned zero (adapter/route-map/error-map faithful);
Hulda flagged two source-confirmed open-world-presenter crash holes — the same
class the slice-4 bug-hunt found in the agents presenters. Both fixed:

- `_format_whoami` scopes (`cli.py`): `', '.join(me.get('scopes', []))` crashes on
  a present-null `scopes` (`.get(k, [])` returns None, not the default) or a
  non-string element. Now `', '.join(str(s) for s in (me.get('scopes') or []))` —
  matching the `allowed_roles` hardening on the same function. The contract names
  `_format_whoami` as the degrade-not-crash exemplar (contract:144-146); the cited
  exemplar had an un-hardened line.
- `_characters_probe` model items (`cli.py`): the slice-5 `or []` guarded the
  list-level null but not each entry — `[None]` / `["x"]` / `[{"name":123}]` would
  raise. Now guards each item is a dict and str-coerces `name` (element-level
  completion of the list-level guard).

Hulda #3 (live-smoke not in the reviewed file set) → accept: the smoke WAS run and
is recorded in deab762 + coverage-map (artifact-only review couldn't see it).

Added CLI tests for both hardened paths (present-null/non-string scopes; malformed
model items). Suite 485 green; ruff clean; live smoke re-run clean (identical
happy-path output). Patch bump 0.21.16 → 0.21.17.
2026-07-19 11:11:12 -07:00
5 changed files with 176 additions and 15 deletions
@@ -274,6 +274,23 @@ re-anchor its coverage-map rows.
is already hardened (slice-4 heid bug-hunt). The rewired `_characters_probe` extracts
the created id defensively (`created.get("character_id")` + type-guard → clean abort,
never a hard-index KeyError) since the create ACK is now an open-world SDK read.
- **Container-type hardening (heid code-review + bug-hunt slice-5).** The degrade-not-
crash floor is guarded at THREE levels for the CLI presenters, not just one: (a) the
list-typed fields `scopes` / `allowed_roles` / model `items` degrade a non-list scalar
(`123`) or a bare string to empty via `_display_seq` / an `isinstance(_, list)` guard —
the older `or []` idiom only caught null/absent and would `for x in 123` `TypeError`;
(b) each element is type-guarded (`isinstance(m, dict)`); (c) the top-level open-world
reads `created` / `models` / `state` are `isinstance(_, Mapping)`-guarded before any
`.get` (a non-mapping passthrough would otherwise `AttributeError`). All three feed
`--whoami` / `--characters` only.
- **Accepted (not fixed): the `--characters` probe leaks its transient character on a
mid-lifecycle failure.** create → get-state → delete runs linearly with no `finally`,
so a state/delete failure after a successful create orphans the probe character until
its TTL. This is PRE-EXISTING (the retired hand-rolled probe had the identical
structure — the cutover did not worsen it), TTL-bounded, and `--characters` is a
one-shot diagnostic smoke; a `try/finally` cleanup would also swallow a happy-path
delete-failure (delete is both the teardown AND a tested lifecycle step). Accepted as
known-risk per the heid bug-hunt (Gróa + Heid concur accept is defensible).
- **CLI-only rewire.** `me` / `capabilities` / `characters` / `models` have NO
web-server caller — only the `--whoami` and `--characters` CLI one-shot probes. The
web surface is untouched this slice.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.21.16"
version = "0.21.18"
description = "Worldtree Conversation API debug console (web + headless CLI) — multi-pane observability"
readme = "README.md"
requires-python = ">=3.12"
+39 -13
View File
@@ -745,12 +745,29 @@ async def _amain(args: ParsedArgs) -> int:
loop.remove_signal_handler(signal.SIGINT)
def _display_seq(value: Any) -> list[str]:
"""Coerce an open-world wire value to a list of display strings — the degrade-not-
crash floor for a list-typed field (`scopes`, `allowed_roles`, model `items`, ...).
A non-list scalar (absent, null, `123`, or a bare string) → empty rather than a
crash: the older `or []` idiom handles absent/null but NOT a truthy non-iterable
(`123 or [] == 123` → `for x in 123` `TypeError`) and would char-iterate a bare
string. Only a genuine list/tuple is str-mapped (heid bug-hunt slice-5, findings 1-2).
"""
if not isinstance(value, (list, tuple)):
return []
return [str(x) for x in value]
def _format_whoami(me: Mapping[str, Any], caps: Mapping[str, Any]) -> str:
"""Render the --whoami report: identity (GET /me) + server capabilities."""
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, null, a scalar, or carry non-strings —
# `_display_seq` degrades every non-list to empty (the contract names this function
# the degrade-not-crash exemplar; heid code-review + bug-hunt slice-5).
lines.append(f" scopes: {', '.join(_display_seq(me.get('scopes'))) or '(none)'}")
for k in ("display_name", "key_id", "key_label"):
if k in me:
lines.append(f" {k}: {me[k]}")
@@ -759,16 +776,17 @@ def _format_whoami(me: Mapping[str, Any], caps: Mapping[str, Any]) -> str:
if isinstance(templates, dict) and templates:
for name, spec in templates.items():
# A diagnostic renderer must tolerate a malformed / partially-cutover
# server (heid bug-hunt Gróa#1/#2): a non-mapping template value, or an
# explicit-null `allowed_roles` (`.get(k, [])` returns None on null, not
# the default), must degrade — not abort the whole --whoami report.
# server: a non-mapping template value, or an `allowed_roles` that is null
# / a scalar / carries non-strings, must degrade — not abort the whole
# --whoami report (heid bug-hunt slice-5: `_display_seq` guards the
# container type, not just null/element as the prior `or []` did).
if not isinstance(spec, dict):
lines.append(f" ephemeral_template {name}: (malformed)")
continue
# Canonical post-cutover shape (worldtree-dev althing 2026-07-18,
# ADR-0012): roles, not models. `config.role` selects; `config.model`
# is now rejected server-side.
roles = ", ".join(str(r) for r in (spec.get("allowed_roles") or []))
roles = ", ".join(_display_seq(spec.get("allowed_roles")))
lines.append(
f" ephemeral_template {name}: default={spec.get('default_role', '?')} "
f"max_bytes={spec.get('system_prompt_max_bytes', '?')} roles=[{roles}]"
@@ -828,10 +846,15 @@ 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 reads degrade, never crash (heid code-review + bug-hunt slice-5):
# guard the top-level `models` is a mapping AND `items` is a list before
# iterating (a scalar `items: 123` makes `... or []` yield `123` → `for m in
# 123` TypeError), then guard each entry is a dict with a str-coerced `name`.
raw_items = models.get("items") if isinstance(models, Mapping) else None
items = raw_items if isinstance(raw_items, (list, tuple)) else []
names = ", ".join(
str(m.get("name", "?")) for m in items if isinstance(m, dict)
)
sys.stdout.write(f"character models: {names or '(none)'}\n")
created = await wt.create_character(
client,
@@ -848,9 +871,11 @@ async def _characters_probe(args: ParsedArgs) -> int:
},
)
# Open-world create ACK: degrade, don't hard-index (cumulative cutover
# foot-gun). A malformed/absent character_id aborts the probe cleanly rather
# than raising a KeyError — the lifecycle needs the id for state + delete.
cid = created.get("character_id")
# foot-gun). A non-mapping ACK or an absent/blank character_id aborts the
# probe cleanly (exit 20) rather than raising AttributeError/KeyError — the
# lifecycle needs the id for state + delete (heid bug-hunt slice-5). Past the
# guard, `created`/`state` are known mappings.
cid = created.get("character_id") if isinstance(created, Mapping) else None
if not (isinstance(cid, str) and cid):
sys.stderr.write(
f"[session_api_failed] create returned no character_id: {created!r}\n"
@@ -858,7 +883,8 @@ async def _characters_probe(args: ParsedArgs) -> int:
return 20
sys.stdout.write(f"created: {cid} (ttl {created.get('ttl_expires_at')})\n")
state = await wt.get_character_state(client, cid)
sys.stdout.write(f"state: pad={state.get('pad')}\n")
pad = state.get("pad") if isinstance(state, Mapping) else None
sys.stdout.write(f"state: pad={pad}\n")
await wt.delete_character(client, cid)
sys.stdout.write(f"deleted: {cid}\n")
except wt.SessionApiFailed as exc:
+118
View File
@@ -1879,6 +1879,49 @@ 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
@respx.mock
def test_whoami_tolerates_scalar_scopes_and_roles(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""Non-iterable (scalar) `scopes` / `allowed_roles` → degrade to empty, never a
`for x in 123` TypeError (heid bug-hunt slice-5: `_display_seq` guards the
container TYPE, the next layer past the code-review null/element fix)."""
respx.get("https://w.example/me").mock(
return_value=httpx.Response(200, json={"user_id": "u", "scopes": 123, "tier": "user"})
)
respx.get("https://w.example/capabilities").mock(
return_value=httpx.Response(
200,
json={
"ephemeral_templates": {"echo": {"allowed_roles": 7, "default_role": "echo"}}
},
)
)
rc = main(["--whoami", "--api-key", "k", "--server", "https://w.example"])
assert rc == 0
out = capsys.readouterr().out
assert "scopes: (none)" in out
assert "roles=[]" in out
class TestTier2Probes:
"""--characters + --set-persona-pad one-shot probes (Tier-2: #161 + persona_state-write)."""
@@ -1923,6 +1966,60 @@ 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_tolerates_scalar_items_and_nonmapping_state(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""Scalar `items` (`123`) → '(none)' not a `for m in 123` TypeError; a non-mapping
`state` → 'pad=None' not an AttributeError. Lifecycle still completes (heid
bug-hunt slice-5: container-type + top-level-mapping guards)."""
respx.get("https://w.example/models/available-for-characters").mock(
return_value=httpx.Response(200, json={"items": 123})
)
respx.post("https://w.example/characters").mock(
return_value=httpx.Response(201, json={"character_id": "c1", "ttl_expires_at": "t"})
)
# non-mapping state body (open-world passthrough of a JSON array).
respx.get("https://w.example/characters/c1/state").mock(
return_value=httpx.Response(200, json=["not", "a", "mapping"])
)
del_route = 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
assert "character models: (none)" in out
assert "state: pad=None" in out
assert "deleted: c1" in out
assert del_route.call_count == 1
@respx.mock
def test_characters_probe_create_missing_id_aborts(
self, capsys: pytest.CaptureFixture[str]
@@ -1944,6 +2041,27 @@ class TestTier2Probes:
assert "no character_id" in capsys.readouterr().err
assert del_route.call_count == 0 # aborted before state/delete — nothing to clean
@respx.mock
def test_characters_probe_non_mapping_create_aborts(
self, capsys: pytest.CaptureFixture[str]
) -> None:
"""A non-mapping create ACK (open-world passthrough of a JSON array/scalar) →
clean exit-20 abort, never an AttributeError on `created.get(...)` (heid
bug-hunt slice-5, finding #3)."""
respx.get("https://w.example/models/available-for-characters").mock(
return_value=httpx.Response(200, json={"items": []})
)
respx.post("https://w.example/characters").mock(
return_value=httpx.Response(201, json=["not", "a", "mapping"])
)
del_route = respx.delete(url__regex=r"https://w\.example/characters/.+").mock(
return_value=httpx.Response(204)
)
rc = main(["--characters", "--api-key", "k", "--server", "https://w.example"])
assert rc == 20
assert "no character_id" in capsys.readouterr().err
assert del_route.call_count == 0
@respx.mock
def test_set_persona_probe(self, capsys: pytest.CaptureFixture[str]) -> None:
"""set_persona_probe [happy,tracer]: POST pad to /sessions/{id}/persona_state; 204."""
Generated
+1 -1
View File
@@ -472,7 +472,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.21.16"
version = "0.21.18"
source = { editable = "." }
dependencies = [
{ name = "httpx" },