fix(cli): don't crash the run TUI when streamed output contains a literal [...] (#7435)
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / Detect changes (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled

* fix(cli): reject non-serializable literal_eval results in TUI JSON formatting

_try_parse_structured() accepted any dict/list coming out of
ast.literal_eval(), including values json.dumps() cannot encode such as
[Ellipsis] from a literal [...]. _format_json_in_text() then raised
TypeError: Object of type ellipsis is not JSON serializable, which
propagated through _tick and cancelled the whole crew run.

Validate the parsed object with json.dumps() inside
_try_parse_structured() so only JSON-serializable dict/list values are
returned; anything else falls back to the original text. Fixes #7434.

* fix(cli): contain RecursionError in the TUI JSON formatting boundary

A streamed structure nested deeper than the JSON backend can walk could
raise RecursionError out of _try_parse_structured (from json.loads) or
out of the render-path dumps, escaping _tick and losing the TUI update.

Catch RecursionError when loading, and validate literal_eval results
with the exact kwargs the render path uses, so any structure the render
cannot encode is rejected at the boundary and the raw text renders
instead.

---------

Co-authored-by: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com>
This commit is contained in:
theater
2026-09-14 23:11:33 +08:00
committed by GitHub
parent a328710007
commit a225c1b373
2 changed files with 45 additions and 1 deletions

View File

@@ -86,13 +86,19 @@ def _try_parse_structured(text: str) -> Any | None:
"""Try JSON first, then Python repr (single-quoted dicts/lists)."""
try:
return _json.loads(text)
except (ValueError, TypeError):
except (ValueError, TypeError, RecursionError):
pass
try:
import ast
obj = ast.literal_eval(text)
# literal_eval accepts values json.dumps cannot encode (e.g. [Ellipsis]
# from "[...]"), which would crash the renderer later; reject those.
# Validate with the exact kwargs the render path uses so a structure
# the C decoder accepts but the indent encoder cannot walk is also
# rejected here instead of raising inside _tick.
if isinstance(obj, (dict, list)):
_json.dumps(obj, indent=2, ensure_ascii=False)
return obj
except Exception: # noqa: S110
pass

View File

@@ -41,6 +41,8 @@ from crewai_cli.crew_run_tui import (
_LOG_ARGS_TEXT_LIMIT,
_LOG_RESULT_TEXT_LIMIT,
_LOG_TRUNCATION_SUFFIX,
_format_json_in_text,
_try_parse_structured,
)
@@ -1804,3 +1806,39 @@ def test_finished_traces_button_still_records(monkeypatch) -> None:
app.on_button_pressed(SimpleNamespace(button=SimpleNamespace(id="btn-traces-done")))
app._telemetry.feature_usage_span.assert_called_once_with("cli_usage:view_traces")
def test_try_parse_structured_rejects_non_serializable_literals() -> None:
"""ast.literal_eval("[...]") is a valid [Ellipsis] list but cannot be JSON-encoded."""
assert _try_parse_structured("[...]") is None
assert _try_parse_structured("{'a': ...}") is None
assert _try_parse_structured("[1+2j]") is None
def test_try_parse_structured_still_accepts_serializable_values() -> None:
assert _try_parse_structured('{"a": 1}') == {"a": 1}
assert _try_parse_structured("['x', 'y']") == ["x", "y"]
assert _try_parse_structured("{'a': 1}") == {"a": 1}
def test_format_json_in_text_survives_literal_ellipsis() -> None:
"""A streamed [...] must render as-is instead of crashing the TUI (issue #7434)."""
assert _format_json_in_text("pandas,[...]") == "pandas,[...]"
def test_try_parse_structured_rejects_deeply_nested_input() -> None:
"""A nesting depth no JSON backend can walk must parse as nothing, not recurse."""
deep = "[" * 100_000 + "]" * 100_000
assert _try_parse_structured(deep) is None
def test_format_json_in_text_survives_deep_nesting() -> None:
"""The render path must contain the failure instead of losing the TUI update."""
deep = "data: " + "[" * 4000 + "]" * 4000
assert isinstance(_format_json_in_text(deep), str)
def test_format_json_in_text_still_pretty_prints_valid_json() -> None:
assert _format_json_in_text('data: {"a": 1} and [...]') == (
'data: ' + '{\n "a": 1\n}' + ' and [...]'
)