fix(tui,cli,tests): harden persona/input modes and stabilize parallel test execution

- Add shell safety controls for REPL/TUI (`looks_dangerous`, confirmation gate, timeout handling, and env-based shell disable guard).
- Secure persona workflows with strict name/path validation, safe import/export resolution, atomic+locked registry writes, and malformed YAML resilience.
- Unify persona models and wiring by reusing canonical TUI schema/registry, adding DI providers, and lazy-loading TUI exports to avoid circular imports.
- Improve reference discovery with ignored-directory filtering, symlink-safe walking, and TTL caching for CLI/TUI reference catalogs.
- Expand Behave/Robot coverage for safety/error paths and parallel-isolation behavior; add shared `features/mocks/fake_repl_input.py` helper.
- Fix parallel-run flakiness via deterministic cleanup/reload patterns and watchdog polling fallback when inotify limits are reached.

ISSUES CLOSED: #695
This commit is contained in:
2026-03-17 13:34:51 +00:00
parent c02f3842ae
commit d9e51d98f8
26 changed files with 927 additions and 354 deletions
+7 -4
View File
@@ -10,14 +10,17 @@ from cleveragents.tui.search.fuzzy import rank_candidates
class TuiReferenceFuzzyBench:
"""Bench weighted fuzzy ranking on a medium candidate set."""
params: ClassVar[list[int]] = [1_000, 10_000]
param_names: ClassVar[list[str]] = ["candidate_count"]
params: ClassVar[list[list[object]]] = [
[1_000, 10_000],
["module_42/file_42", "zzzzzz-no-match-query"],
]
param_names: ClassVar[list[str]] = ["candidate_count", "query"]
def setup(self, candidate_count: int) -> None:
self.values = [
f"src/module_{idx}/file_{idx}.py" for idx in range(candidate_count)
]
def time_rank_candidates(self, candidate_count: int) -> None:
def time_rank_candidates(self, candidate_count: int, query: str) -> None:
del candidate_count
rank_candidates("module_42/file_42", self.values, limit=20)
rank_candidates(query, self.values, limit=20)
+22
View File
@@ -0,0 +1,22 @@
"""Shared fake input helper for REPL Behave scenarios."""
from __future__ import annotations
class FakeReplInput:
"""Feed predefined lines and then terminate input with EOFError."""
def __init__(self, lines: list[str], *, interrupt_token: str | None = None) -> None:
self._lines = list(lines)
self._index = 0
self._interrupt_token = interrupt_token
def __call__(self, prompt: str = "") -> str:
del prompt
if self._index >= len(self._lines):
raise EOFError
line = self._lines[self._index]
self._index += 1
if self._interrupt_token is not None and line == self._interrupt_token:
raise KeyboardInterrupt
return line
+80 -2
View File
@@ -17,11 +17,20 @@ Feature: REPL input modes and persona controls
Scenario: Shell mode runs host shell commands
Given a temporary REPL config directory
When I run the REPL with input lines
| line |
| !printf hello |
| line |
| !echo hello |
Then the REPL mode output should contain "hello"
Scenario: Shell mode blocks dangerous commands without confirmation
Given a temporary REPL config directory
When I run the REPL with input lines
| line |
| !rm -rf / |
| n |
Then the REPL mode output should contain "Blocked dangerous shell command."
Scenario: @ references are expanded with canonical category prefixes
Given a deterministic REPL reference catalog fixture
When I expand REPL references for "info @README.md"
Then the expanded REPL line should contain "@file:README.md"
@@ -33,6 +42,14 @@ Feature: REPL input modes and persona controls
| /persona reviewer |
Then the REPL mode output should contain "Active persona: reviewer"
Scenario: CLI persona model rejects ANSI/control characters in names
When I validate CLI persona name "bad\\x1bname"
Then CLI persona validation should fail with "path/control"
Scenario: CLI persona model preserves color field
When I validate CLI persona payload with color "red"
Then CLI persona color should be "red"
Scenario: Persona export import and delete workflow
Given a temporary REPL config directory
And a temporary persona export path
@@ -48,6 +65,38 @@ Feature: REPL input modes and persona controls
And the REPL mode output should contain "Imported persona: dev"
And the REPL mode output should contain "Active persona: dev"
Scenario: Persona create rejects path traversal names
Given a temporary REPL config directory
When I run the REPL with input lines
| line |
| /persona create ../../etc/cron.d/evil --actor local/mock-default |
Then the REPL mode output should contain "name must not contain path/control separators"
Scenario: Persona export rejects absolute path targets
Given a temporary REPL config directory
And an absolute persona export path
When I run the REPL with input lines
| line |
| /persona create dev --actor local/mock-default |
| /persona export dev {export_path} |
Then the REPL mode output should contain "Export path must be relative to current working directory"
Scenario: Persona export rejects parent directory escape
Given a temporary REPL config directory
When I run the REPL with input lines
| line |
| /persona create dev --actor local/mock-default |
| /persona export dev ../outside.yaml |
Then the REPL mode output should contain "Export path must stay within working directory"
Scenario: Persona import rejects absolute path targets
Given a temporary REPL config directory
And an absolute persona import file path
When I run the REPL with input lines
| line |
| /persona import {absolute_import_path} |
Then the REPL mode output should contain "Import path must be relative to current working directory"
Scenario: Persona binding is independent per REPL session
Given a temporary REPL config directory
When I run the REPL with input lines
@@ -62,3 +111,32 @@ Feature: REPL input modes and persona controls
| /session list |
Then the REPL mode output should contain "work (persona=dev)"
And the REPL mode output should contain "default (persona=reviewer)"
Scenario: Deleting persona resets all sessions using it
Given a temporary REPL config directory
When I run the REPL with input lines
| line |
| /persona create dev --actor local/mock-default |
| /session new work |
| /session switch work |
| /persona set dev |
| /session switch default |
| /persona set dev |
| /persona delete dev |
| /session list |
Then the REPL mode output should contain "work (persona=default)"
And the REPL mode output should contain "default (persona=default)"
Scenario: Slash persona commands surface validation and usage errors
Given a temporary REPL config directory
And an invalid persona import file path
When I run the REPL with input lines
| line |
| /persona set missing |
| /persona create dev |
| /persona delete default |
| /persona import {invalid_import_path} |
Then the REPL mode output should contain "Persona not found: missing"
And the REPL mode output should contain "Usage: /persona create <name> --actor <namespace/name>"
And the REPL mode output should contain "Cannot delete default persona."
And the REPL mode output should contain "Invalid persona YAML."
+16 -25
View File
@@ -10,6 +10,7 @@ import contextlib
import json
from datetime import datetime
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
import yaml
@@ -35,24 +36,6 @@ _FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "m1"
_PLAN_ULID = "01M1SM0KE00000000000000001"
def _register_patcher_cleanup(context: Context, patcher: object) -> None:
"""Register a patcher's stop() with Behave scenario cleanup."""
stop = getattr(patcher, "stop", None)
if not callable(stop):
return
def _safe_stop() -> None:
with contextlib.suppress(RuntimeError):
stop()
if hasattr(context, "add_cleanup"):
context.add_cleanup(_safe_stop)
return
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(_safe_stop)
def _make_m1_plan(
*,
name: str = "local/m1-smoke-plan",
@@ -105,6 +88,18 @@ def _make_m1_action(
)
def _register_patcher_cleanup(context: Context, patcher: Any) -> None:
"""Start a patcher and ensure it is always stopped."""
patcher.start()
context.add_cleanup(_safe_stop_patcher, patcher)
def _safe_stop_patcher(patcher: Any) -> None:
"""Best-effort patcher stop for repeated/failed scenarios."""
with contextlib.suppress(RuntimeError):
patcher.stop()
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@@ -153,16 +148,14 @@ def step_m1_smoke_mock_service(context: Context) -> None:
context.link_patcher = patch(
"cleveragents.cli.commands.project._get_resource_link_repo",
)
context.plan_patcher.start()
_register_patcher_cleanup(context, context.plan_patcher)
context.action_patcher.start()
_register_patcher_cleanup(context, context.action_patcher)
context.mock_project_repo = context.project_patcher.start()
_register_patcher_cleanup(context, context.project_patcher)
context.add_cleanup(_safe_stop_patcher, context.project_patcher)
context.mock_resource_svc = context.resource_patcher.start()
_register_patcher_cleanup(context, context.resource_patcher)
context.add_cleanup(_safe_stop_patcher, context.resource_patcher)
context.mock_link_repo = context.link_patcher.start()
_register_patcher_cleanup(context, context.link_patcher)
context.add_cleanup(_safe_stop_patcher, context.link_patcher)
context.last_result = None
context.captured_plan_id = None
@@ -476,7 +469,6 @@ def step_m1_plan_in_strategize(context: Context) -> None:
"cleveragents.cli.commands.plan._get_plan_executor",
return_value=MagicMock(),
)
context._m1_executor_patcher.start()
_register_patcher_cleanup(context, context._m1_executor_patcher)
@@ -528,7 +520,6 @@ def step_m1_plan_with_changeset(context: Context) -> None:
"cleveragents.cli.commands.plan._get_apply_service",
return_value=mock_apply_svc,
)
context.apply_patcher.start()
_register_patcher_cleanup(context, context.apply_patcher)
@@ -41,24 +41,6 @@ _PLAN_ULID = "01M4SM0KE00000000000000001"
_DECISION_ULID = "01M4DEC00000000000000000001"
def _register_patcher_cleanup(context: Context, patcher: object) -> None:
"""Register a patcher's stop() with Behave scenario cleanup."""
stop = getattr(patcher, "stop", None)
if not callable(stop):
return
def _safe_stop() -> None:
with contextlib.suppress(RuntimeError):
stop()
if hasattr(context, "add_cleanup"):
context.add_cleanup(_safe_stop)
return
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(_safe_stop)
def _make_m4_plan(
*,
phase: PlanPhase = PlanPhase.EXECUTE,
@@ -96,6 +78,18 @@ def _make_m4_plan(
)
def _register_patcher_cleanup(context: Context, patcher: object) -> None:
"""Start a patcher and always stop it via Behave cleanup."""
patcher.start()
context.add_cleanup(_safe_stop_patcher, patcher)
def _safe_stop_patcher(patcher: object) -> None:
"""Stop patcher safely even if it was already stopped."""
with contextlib.suppress(RuntimeError):
patcher.stop()
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@@ -117,7 +111,6 @@ def step_m4_smoke_mock_service(context: Context) -> None:
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=context.m4_mock_service,
)
context.m4_plan_patcher.start()
_register_patcher_cleanup(context, context.m4_plan_patcher)
context.m4_last_result = None
@@ -249,7 +242,6 @@ def step_m4_plan_with_decision_tree(context: Context) -> None:
"cleveragents.application.services.correction_service.CorrectionService",
return_value=mock_correction_svc,
)
context.m4_correction_patcher.start()
_register_patcher_cleanup(context, context.m4_correction_patcher)
context.m4_mock_correction_service = mock_correction_svc
@@ -263,7 +255,6 @@ def step_m4_plan_with_decision_tree(context: Context) -> None:
"cleveragents.application.container.get_container",
return_value=mock_container,
)
context.m4_container_patcher.start()
_register_patcher_cleanup(context, context.m4_container_patcher)
+3 -1
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import importlib
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@@ -113,11 +114,12 @@ def step_call_lifecycle_service_helper(context) -> None:
settings = MagicMock()
service = PlanLifecycleService(settings=settings)
container = SimpleNamespace(plan_lifecycle_service=MagicMock(return_value=service))
reloaded_plan_module = importlib.reload(plan_module)
with patch(
"cleveragents.application.container.get_container", return_value=container
):
context.helper_settings = settings
context.helper_service = plan_module._get_lifecycle_service()
context.helper_service = reloaded_plan_module._get_lifecycle_service()
@then("the lifecycle service helper should return a plan lifecycle service")
+81 -19
View File
@@ -10,23 +10,11 @@ from unittest.mock import patch
from behave import given, then, when
from behave.runner import Context
from pydantic import ValidationError
from cleveragents.cli.commands.repl import _expand_references, run_repl
class _FakeInput:
"""Feed predefined lines and then terminate REPL with EOF."""
def __init__(self, lines: list[str]) -> None:
self._lines = list(lines)
self._idx = 0
def __call__(self, prompt: str = "") -> str:
if self._idx >= len(self._lines):
raise EOFError
line = self._lines[self._idx]
self._idx += 1
return line
from cleveragents.cli.persona import PersonaConfig
from features.mocks.fake_repl_input import FakeReplInput
def _run_repl_capture(lines: list[str], config_dir: Path) -> tuple[int, str]:
@@ -36,7 +24,7 @@ def _run_repl_capture(lines: list[str], config_dir: Path) -> tuple[int, str]:
output = StringIO()
console = Console(file=output, no_color=True, width=180)
fake_input = _FakeInput(lines)
fake_input = FakeReplInput(lines)
with (
patch("cleveragents.cli.commands.repl._console", console),
patch("builtins.input", fake_input),
@@ -53,8 +41,23 @@ def step_temp_repl_config(context: Context) -> None:
context.add_cleanup(lambda: shutil.rmtree(str(config_dir), ignore_errors=True))
@given("a deterministic REPL reference catalog fixture")
def step_repl_reference_fixture(context: Context) -> None:
fixture_dir = Path(tempfile.mkdtemp())
(fixture_dir / "README.md").write_text("# fixture\n", encoding="utf-8")
context.repl_reference_fixture_dir = fixture_dir
context.add_cleanup(lambda: shutil.rmtree(str(fixture_dir), ignore_errors=True))
@given("a temporary persona export path")
def step_temp_persona_export_path(context: Context) -> None:
export_path = Path("tmp-persona-export.yaml")
context.repl_mode_export_path = export_path
context.add_cleanup(lambda: export_path.unlink(missing_ok=True))
@given("an absolute persona export path")
def step_absolute_persona_export_path(context: Context) -> None:
export_path = Path(tempfile.mkdtemp()) / "persona-export.yaml"
context.repl_mode_export_path = export_path
context.add_cleanup(
@@ -62,14 +65,40 @@ def step_temp_persona_export_path(context: Context) -> None:
)
@given("an invalid persona import file path")
def step_invalid_persona_import_file(context: Context) -> None:
invalid_path = Path("tmp-invalid-persona.yaml")
invalid_path.write_text("- bad\n- yaml\n", encoding="utf-8")
context.repl_mode_invalid_import_path = invalid_path
context.add_cleanup(lambda: invalid_path.unlink(missing_ok=True))
@given("an absolute persona import file path")
def step_absolute_persona_import_path(context: Context) -> None:
temp_dir = Path(tempfile.mkdtemp())
input_path = temp_dir / "persona-import.yaml"
input_path.write_text("name: x\nactor: local/mock-default\n", encoding="utf-8")
context.repl_mode_absolute_import_path = input_path
context.add_cleanup(lambda: shutil.rmtree(str(temp_dir), ignore_errors=True))
@when("I run the REPL with input lines")
def step_run_repl_lines(context: Context) -> None:
replacements: dict[str, str] = {}
export_path = getattr(context, "repl_mode_export_path", None)
if export_path is not None:
replacements["export_path"] = str(export_path)
invalid_import_path = getattr(context, "repl_mode_invalid_import_path", None)
if invalid_import_path is not None:
replacements["invalid_import_path"] = str(invalid_import_path)
absolute_import_path = getattr(context, "repl_mode_absolute_import_path", None)
if absolute_import_path is not None:
replacements["absolute_import_path"] = str(absolute_import_path)
lines = []
for row in context.table:
line = row["line"]
if export_path is not None:
line = line.format(export_path=str(export_path))
if replacements:
line = line.format(**replacements)
lines.append(line)
code, output = _run_repl_capture(lines, context.repl_mode_config_dir)
context.repl_mode_code = code
@@ -85,7 +114,12 @@ def step_output_contains(context: Context, text: str) -> None:
@when('I expand REPL references for "{line}"')
def step_expand_references(context: Context, line: str) -> None:
context.expanded_repl_line = _expand_references(line)
fixture_dir = getattr(context, "repl_reference_fixture_dir", None)
if fixture_dir is None:
context.expanded_repl_line = _expand_references(line)
return
with patch("cleveragents.cli.commands.repl.Path.cwd", return_value=fixture_dir):
context.expanded_repl_line = _expand_references(line)
@then('the expanded REPL line should contain "{text}"')
@@ -93,3 +127,31 @@ def step_expanded_contains(context: Context, text: str) -> None:
assert text in context.expanded_repl_line, (
f"Expected {text!r} in expanded line: {context.expanded_repl_line}"
)
@when('I validate CLI persona name "{name}"')
def step_validate_cli_persona_name(context: Context, name: str) -> None:
decoded = name.encode("utf-8").decode("unicode_escape")
context.repl_cli_persona_error = None
try:
PersonaConfig(name=decoded, actor="local/mock-default")
except ValidationError as exc:
context.repl_cli_persona_error = exc
@then('CLI persona validation should fail with "{message}"')
def step_cli_persona_validation_fails(context: Context, message: str) -> None:
assert context.repl_cli_persona_error is not None
assert message in str(context.repl_cli_persona_error)
@when('I validate CLI persona payload with color "{color}"')
def step_validate_cli_persona_payload_with_color(context: Context, color: str) -> None:
context.repl_cli_persona_payload = PersonaConfig.model_validate(
{"name": "colorful", "actor": "local/mock-default", "color": color}
)
@then('CLI persona color should be "{color}"')
def step_cli_persona_color(context: Context, color: str) -> None:
assert context.repl_cli_persona_payload.color == color
+2 -18
View File
@@ -14,29 +14,13 @@ from cleveragents.cli.commands.repl import (
_get_prompt_context,
run_repl,
)
from features.mocks.fake_repl_input import FakeReplInput
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _FakeInput:
"""Simulate user input for the REPL by feeding lines then raising EOFError."""
def __init__(self, lines: list[str]) -> None:
self._lines = list(lines)
self._index = 0
def __call__(self, prompt: str = "") -> str:
if self._index >= len(self._lines):
raise EOFError
line = self._lines[self._index]
self._index += 1
if line == "__KEYBOARD_INTERRUPT__":
raise KeyboardInterrupt
return line
def _run_repl_with_input(
lines: list[str],
*,
@@ -55,7 +39,7 @@ def _run_repl_with_input(
if history_path is not None:
kw["history_path"] = history_path
fake = _FakeInput(lines)
fake = FakeReplInput(lines, interrupt_token="__KEYBOARD_INTERRUPT__")
with (
patch("cleveragents.cli.commands.repl._console", console),
patch("builtins.input", fake),
+76 -2
View File
@@ -2,11 +2,19 @@
from __future__ import annotations
from behave import then, when
import os
import shutil
import tempfile
from pathlib import Path
from unittest.mock import patch
from behave import given, then, when
from behave.runner import Context
from cleveragents.tui.input import reference_parser
from cleveragents.tui.input.modes import InputModeRouter
from cleveragents.tui.input.reference_parser import parse_references
from cleveragents.tui.input.reference_parser import parse_references, suggestions
from cleveragents.tui.input.shell_exec import looks_dangerous
@when('I parse TUI references for "{line}"')
@@ -40,3 +48,69 @@ def step_shell_stdout(context: Context, value: str) -> None:
shell = context.tui_mode_result.shell_result
assert shell is not None
assert value in shell.stdout
@then('the TUI shell stderr should contain "{value}"')
def step_shell_stderr(context: Context, value: str) -> None:
shell = context.tui_mode_result.shell_result
assert shell is not None
assert value in shell.stderr
@when('I check shell danger detection for "{command}"')
def step_check_shell_danger(context: Context, command: str) -> None:
context.shell_danger_result = looks_dangerous(command)
@then('shell danger detection result should be "{value}"')
def step_shell_danger_result(context: Context, value: str) -> None:
expected = value.strip().lower() == "true"
assert context.shell_danger_result is expected
@given("a deterministic TUI reference fixture")
def step_deterministic_tui_reference_fixture(context: Context) -> None:
fixture_dir = Path(tempfile.mkdtemp())
(fixture_dir / "README.md").write_text("# fixture\n", encoding="utf-8")
(fixture_dir / ".git").mkdir(exist_ok=True)
(fixture_dir / ".git" / "secret.txt").write_text("secret\n", encoding="utf-8")
context.tui_reference_fixture_dir = fixture_dir
context.add_cleanup(lambda: shutil.rmtree(str(fixture_dir), ignore_errors=True))
@when("I parse and suggest TUI references using fixture")
def step_parse_and_suggest_with_fixture(context: Context) -> None:
context.tui_walk_count = 0
context.tui_fixture_suggestions = []
reference_parser._catalog_cache["cwd"] = None
reference_parser._catalog_cache["created_at"] = 0.0
reference_parser._catalog_cache["catalog"] = None
real_walk = os.walk
def counting_walk(*args: object, **kwargs: object):
context.tui_walk_count += 1
return real_walk(*args, **kwargs)
with (
patch(
"cleveragents.tui.input.reference_parser.Path.cwd",
return_value=context.tui_reference_fixture_dir,
),
patch(
"cleveragents.tui.input.reference_parser.os.walk", side_effect=counting_walk
),
):
parse_references("inspect @README.md")
context.tui_fixture_suggestions = suggestions("secret", category="resource")
@then('TUI walk count should be "{count}"')
def step_tui_walk_count(context: Context, count: str) -> None:
assert context.tui_walk_count == int(count)
@then('TUI suggestions should not include "{value}"')
def step_tui_suggestions_not_include(context: Context, value: str) -> None:
assert all(
value not in suggestion for suggestion in context.tui_fixture_suggestions
)
+111 -1
View File
@@ -4,13 +4,14 @@ from __future__ import annotations
import shutil
import tempfile
import threading
from pathlib import Path
from behave import given, then, when
from behave.runner import Context
from cleveragents.tui.persona.registry import PersonaRegistry
from cleveragents.tui.persona.schema import Persona
from cleveragents.tui.persona.schema import Persona, PersonaPreset
from cleveragents.tui.persona.state import PersonaState
@@ -50,6 +51,25 @@ def step_given_save_persona(context: Context, name: str, actor: str) -> None:
step_save_persona(context, name, actor)
@given(
'I save TUI persona "{name}" with actor "{actor}" and extra preset "{preset_name}"'
)
def step_save_persona_with_extra_preset(
context: Context, name: str, actor: str, preset_name: str
) -> None:
persona = Persona(
name=name,
actor=actor,
argument_presets=[
PersonaPreset(name="default", display="default", overrides={}),
PersonaPreset(
name=preset_name, display=preset_name, overrides={"mode": "x"}
),
],
)
context.tui_registry.save(persona)
@given(
'I save a TUI persona named "{name}" with actor "{actor}" and cycle order {cycle:d}'
)
@@ -103,3 +123,93 @@ def step_set_active_persona(
def step_active_persona(context: Context, session_id: str, persona_name: str) -> None:
persona = context.tui_state.active_persona(session_id)
assert persona.name == persona_name
@when('I cycle persona preset for session "{session_id}"')
def step_cycle_preset(context: Context, session_id: str) -> None:
if not hasattr(context, "tui_state"):
context.tui_state = PersonaState(registry=context.tui_registry)
context.tui_state.cycle_preset(session_id)
@then('current preset for session "{session_id}" should be "{preset_name}"')
def step_current_preset(context: Context, session_id: str, preset_name: str) -> None:
assert context.tui_state.current_preset(session_id) == preset_name
@given('I create malformed TUI persona file "{name}"')
def step_create_malformed_persona_file(context: Context, name: str) -> None:
malformed = context.tui_registry.personas_dir / f"{name}.yaml"
context.tui_registry.ensure_dirs()
malformed.write_text("name: broken\nactor:\n- not-a-string\n", encoding="utf-8")
@when("I list TUI personas")
def step_list_tui_personas(context: Context) -> None:
context.tui_list_error = None
context.tui_persona_names = []
try:
personas = context.tui_registry.list_personas()
context.tui_persona_names = [persona.name for persona in personas]
except Exception as exc:
context.tui_list_error = exc
@then('TUI persona list should include "{name}"')
def step_tui_persona_list_includes(context: Context, name: str) -> None:
assert context.tui_list_error is None
assert name in context.tui_persona_names
@when('I export TUI persona "{name}" to "{output_path}"')
def step_export_tui_persona(context: Context, name: str, output_path: str) -> None:
context.tui_export_error = None
try:
context.tui_registry.export_persona(name, Path(output_path))
except Exception as exc:
context.tui_export_error = exc
@then('TUI export should fail with "{message}"')
def step_tui_export_fails(context: Context, message: str) -> None:
assert context.tui_export_error is not None
assert message in str(context.tui_export_error)
@when('I import TUI persona from "{input_path}"')
def step_import_tui_persona(context: Context, input_path: str) -> None:
context.tui_import_error = None
try:
context.tui_registry.import_persona(Path(input_path))
except Exception as exc:
context.tui_import_error = exc
@then('TUI import should fail with "{message}"')
def step_tui_import_fails(context: Context, message: str) -> None:
assert context.tui_import_error is not None
assert message in str(context.tui_import_error)
@when("I concurrently set TUI last persona names")
def step_concurrent_set_last_persona_names(context: Context) -> None:
context.tui_concurrency_errors = []
def _worker(persona_name: str) -> None:
try:
context.tui_registry.set_last_persona(persona_name)
except Exception as exc: # pragma: no cover - assertion handled after join
context.tui_concurrency_errors.append(exc)
threads: list[threading.Thread] = []
for row in context.table:
thread = threading.Thread(target=_worker, args=(row["name"],))
threads.append(thread)
thread.start()
for thread in threads:
thread.join()
@then("TUI concurrent update should complete without errors")
def step_tui_concurrent_update_no_errors(context: Context) -> None:
assert context.tui_concurrency_errors == []
+18 -1
View File
@@ -11,6 +11,23 @@ Feature: TUI input modes
And the TUI command result should be "ok:/help"
Scenario: Shell mode runs host command
When I route TUI input "!printf hello"
When I route TUI input "!echo hello"
Then the TUI mode should be "shell"
And the TUI shell stdout should contain "hello"
Scenario: Shell mode blocks dangerous command by default
When I route TUI input "!rm -rf /"
Then the TUI mode should be "shell"
And the TUI shell stderr should contain "blocked dangerous shell command"
Scenario: Dangerous shell pattern detection helper
When I check shell danger detection for "rm -rf /tmp"
Then shell danger detection result should be "true"
When I check shell danger detection for "echo hello"
Then shell danger detection result should be "false"
Scenario: Reference catalog ignores .git and caches scans
Given a deterministic TUI reference fixture
When I parse and suggest TUI references using fixture
Then TUI walk count should be "1"
And TUI suggestions should not include ".git/secret.txt"
+36
View File
@@ -25,3 +25,39 @@ Feature: TUI persona system
And I set active persona to "reviewer" for session "s2"
Then active persona for session "s1" should be "dev"
And active persona for session "s2" should be "reviewer"
Scenario: Persona state cycles through presets per session
Given a temporary TUI persona registry
And I save TUI persona "cycler" with actor "local/mock-default" and extra preset "focused"
When I set active persona to "cycler" for session "s1"
And I cycle persona preset for session "s1"
Then current preset for session "s1" should be "focused"
When I cycle persona preset for session "s1"
Then current preset for session "s1" should be "default"
Scenario: Malformed persona YAML does not break registry operations
Given a temporary TUI persona registry
And I save a TUI persona named "good" with actor "local/mock-default"
And I create malformed TUI persona file "broken"
When I list TUI personas
Then TUI persona list should include "good"
When I save a TUI persona named "stillworks" with actor "local/mock-default"
Then loading persona "stillworks" should succeed
Scenario: TUI registry rejects unsafe export and import paths
Given a temporary TUI persona registry
And I save a TUI persona named "safe" with actor "local/mock-default"
When I export TUI persona "safe" to "../escape.yaml"
Then TUI export should fail with "stay within working directory"
When I import TUI persona from "../escape.yaml"
Then TUI import should fail with "stay within working directory"
Scenario: TUI registry state writes tolerate concurrent updates
Given a temporary TUI persona registry
When I concurrently set TUI last persona names
| name |
| a |
| b |
| c |
| d |
Then TUI concurrent update should complete without errors
+32
View File
@@ -8,3 +8,35 @@ TUI Headless Startup Check Returns JSON
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} textual_available
Should Contain ${result.stdout} default_persona
TUI Headless Works When Shell Disabled
${result}= Run Process ${PYTHON} -m cleveragents tui --headless shell=False stderr=STDOUT env:CLEVERAGENTS_DATABASE_URL=sqlite:///:memory: env:CLEVERAGENTS_DISABLE_SHELL_MODE=1
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} textual_available
Should Contain ${result.stdout} default_persona
TUI Input Mode Router And Prompt Widget Behavior
${script}= Catenate SEPARATOR=\n
... from cleveragents.tui.input.modes import InputModeRouter
... from cleveragents.tui.widgets.prompt import PromptInput
... prompt = PromptInput()
... prompt.value = "hello"
... payload = prompt.consume_text()
... assert payload.text == "hello"
... assert prompt.value == ""
... router = InputModeRouter(command_handler=lambda raw: f"ok:{raw}")
... command_result = router.process("/help")
... assert command_result.mode.value == "command"
... assert command_result.command_result == "ok:help"
... normal_result = router.process("inspect @README.md")
... assert normal_result.mode.value == "normal"
... print("router-widget-ok")
${result}= Run Process ${PYTHON} -c ${script} shell=False stderr=STDOUT
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} router-widget-ok
TUI Headless Includes Router Help Payload
${result}= Run Process ${PYTHON} -m cleveragents tui --headless shell=False stderr=STDOUT env:CLEVERAGENTS_DATABASE_URL=sqlite:///:memory:
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} "help"
Should Contain ${result.stdout} /persona
@@ -96,6 +96,8 @@ from cleveragents.providers.registry import ProviderRegistry, get_provider_regis
from cleveragents.reactive.route_bridge import RouteBridge
from cleveragents.reactive.stream_router import ReactiveStreamRouter
from cleveragents.shared.redaction import redact_value
from cleveragents.tui.persona.registry import PersonaRegistry
from cleveragents.tui.persona.state import PersonaState
_logger = structlog.get_logger(__name__)
@@ -539,6 +541,10 @@ class Container(containers.DeclarativeContainer):
plan_service=plan_service,
)
# TUI persona wiring through DI container
persona_registry = providers.Factory(PersonaRegistry)
persona_state = providers.Factory(PersonaState, registry=persona_registry)
# Metrics Emitter - structured metric emission (Forgejo #579)
metrics_emitter = providers.Singleton(
MetricsEmitter.from_settings,
@@ -29,6 +29,7 @@ from watchdog.events import (
FileSystemEventHandler,
)
from watchdog.observers import Observer # type: ignore[import-untyped]
from watchdog.observers.polling import PollingObserver # type: ignore[import-untyped]
from cleveragents.infrastructure.events.models import DomainEvent
from cleveragents.infrastructure.events.protocol import EventBus
@@ -100,6 +101,7 @@ class ResourceFileWatcher:
# resolved path (str) -> pending debounce Timer
self._pending_timers: dict[str, threading.Timer] = {}
self._running = False
self._using_polling = False
_logger.debug(
"file_watcher.initialized",
@@ -157,11 +159,28 @@ class ResourceFileWatcher:
and parent_str not in self._dir_watches
):
handler = _ResourceChangeHandler(self)
watch_handle = self._observer.schedule(
handler,
parent_str,
recursive=False,
)
try:
watch_handle = self._observer.schedule(
handler,
parent_str,
recursive=False,
)
except OSError as exc:
if not self._using_polling:
_logger.warning(
"file_watcher.watch_schedule_fallback",
error_type=type(exc).__name__,
error_message=str(exc),
fallback="polling_observer",
)
self._switch_to_polling_observer_locked()
watch_handle = self._observer.schedule( # type: ignore[union-attr]
handler,
parent_str,
recursive=False,
)
else:
raise
self._dir_watches[parent_str] = watch_handle
_logger.debug(
@@ -229,23 +248,20 @@ class ResourceFileWatcher:
if self._running:
msg = "File watcher is already running"
raise RuntimeError(msg)
observer = Observer()
observer.daemon = True
handler = _ResourceChangeHandler(self)
seen_dirs: set[str] = set()
for path_str in self._watched_paths:
parent_str = str(Path(path_str).parent)
if parent_str not in seen_dirs:
watch_handle = observer.schedule(
handler,
parent_str,
recursive=False,
)
self._dir_watches[parent_str] = watch_handle
seen_dirs.add(parent_str)
observer.start()
observer = self._build_observer(use_polling=False)
try:
observer.start()
self._using_polling = False
except OSError as exc:
_logger.warning(
"file_watcher.inotify_unavailable",
error_type=type(exc).__name__,
error_message=str(exc),
fallback="polling_observer",
)
observer = self._build_observer(use_polling=True)
observer.start()
self._using_polling = True
self._observer = observer
self._running = True
@@ -255,6 +271,38 @@ class ResourceFileWatcher:
watched_dirs=len(self._dir_watches),
)
def _build_observer(self, *, use_polling: bool) -> Observer: # type: ignore[valid-type]
observer: Observer = ( # type: ignore[valid-type]
PollingObserver() if use_polling else Observer()
)
observer.daemon = True
self._dir_watches.clear()
handler = _ResourceChangeHandler(self)
seen_dirs: set[str] = set()
for path_str in self._watched_paths:
parent_str = str(Path(path_str).parent)
if parent_str in seen_dirs:
continue
watch_handle = observer.schedule(
handler,
parent_str,
recursive=False,
)
self._dir_watches[parent_str] = watch_handle
seen_dirs.add(parent_str)
return observer
def _switch_to_polling_observer_locked(self) -> None:
"""Swap current observer to polling backend while lock is held."""
old_observer = self._observer
if old_observer is not None:
old_observer.stop()
old_observer.join(timeout=5.0)
polling_observer = self._build_observer(use_polling=True)
polling_observer.start()
self._observer = polling_observer
self._using_polling = True
def stop(self) -> None:
"""Stop the watchdog observer and cancel pending timers."""
observer: Observer | None = None # type: ignore[valid-type]
@@ -268,6 +316,7 @@ class ResourceFileWatcher:
self._observer = None
self._dir_watches.clear()
self._running = False
self._using_polling = False
# Join outside lock to avoid deadlock with timer callbacks.
if observer is not None:
+100 -29
View File
@@ -13,6 +13,7 @@ import readline
import shlex
import subprocess
import sys
import time
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
@@ -23,6 +24,7 @@ import yaml
from rich.console import Console
from cleveragents.cli.persona import PersonaConfig, PersonaRegistry
from cleveragents.tui.input.shell_exec import looks_dangerous
# ---------------------------------------------------------------------------
# Constants
@@ -33,6 +35,10 @@ _DEFAULT_HISTORY_PATH = _DEFAULT_HISTORY_DIR / "history"
_MULTILINE_CONTINUATION = "\\"
_LAST_COMMAND_TOKEN = "!!"
_MAX_REFERENCE_FILES = 300
_SHELL_TIMEOUT_SECONDS = 30
_IGNORED_DIRS = {".git", ".venv", "node_modules", "__pycache__", ".mypy_cache"}
_REFERENCE_CACHE_TTL_SECONDS = 5.0
_reference_cache: dict[str, object] = {"cwd": None, "created_at": 0.0, "catalog": None}
# The subset of first-position CLI commands exposed inside the REPL.
# Mirrors the ``valid_cmds`` list in ``main.py`` minus the flags.
@@ -194,15 +200,33 @@ def _print_repl_help() -> None:
def _build_reference_catalog() -> dict[str, list[str]]:
"""Build lightweight catalog for @ reference expansion."""
cwd = Path.cwd()
now = time.time()
cached_cwd = _reference_cache.get("cwd")
cached_time = _reference_cache.get("created_at")
cached_catalog = _reference_cache.get("catalog")
if (
isinstance(cached_cwd, Path)
and cached_cwd == cwd
and isinstance(cached_time, float)
and now - cached_time < _REFERENCE_CACHE_TTL_SECONDS
and isinstance(cached_catalog, dict)
):
return cached_catalog
files: list[str] = []
for path in cwd.rglob("*"):
if len(files) >= _MAX_REFERENCE_FILES:
break
if path.is_file():
for root, dirs, filenames in os.walk(cwd, followlinks=False):
dirs[:] = [name for name in dirs if name not in _IGNORED_DIRS]
root_path = Path(root)
for filename in filenames:
if len(files) >= _MAX_REFERENCE_FILES:
break
path = root_path / filename
try:
files.append(str(path.relative_to(cwd).as_posix()))
except ValueError:
files.append(path.as_posix())
if len(files) >= _MAX_REFERENCE_FILES:
break
actors: set[str] = set()
actor_examples = cwd / "examples" / "actors"
@@ -228,7 +252,7 @@ def _build_reference_catalog() -> dict[str, list[str]]:
project_env = os.environ.get("CLEVERAGENTS_PROJECT", "")
plan_env = os.environ.get("CLEVERAGENTS_PLAN", "")
return {
catalog = {
"file": sorted(files),
"actor": sorted(actors),
"tool": sorted(tools),
@@ -236,6 +260,10 @@ def _build_reference_catalog() -> dict[str, list[str]]:
"project": [project_env] if project_env else [],
"plan": [plan_env] if plan_env else [],
}
_reference_cache["cwd"] = cwd
_reference_cache["created_at"] = now
_reference_cache["catalog"] = catalog
return catalog
def _best_fuzzy_match(query: str, options: list[str]) -> str | None:
@@ -270,9 +298,11 @@ def _find_reference_candidates(
return (prefix + contains + fuzzy)[:limit]
def _expand_references(line: str) -> str:
def _expand_references(
line: str, *, catalog: dict[str, list[str]] | None = None
) -> str:
"""Expand inline @ tokens to canonical references when possible."""
catalog = _build_reference_catalog()
active_catalog = catalog or _build_reference_catalog()
tokens = line.split()
expanded_tokens: list[str] = []
@@ -289,12 +319,12 @@ def _expand_references(line: str) -> str:
query = raw
if ":" in raw:
maybe_category, rest = raw.split(":", 1)
if maybe_category in catalog:
if maybe_category in active_catalog:
category = maybe_category
query = rest
if category:
candidate = _best_fuzzy_match(query, catalog.get(category, []))
candidate = _best_fuzzy_match(query, active_catalog.get(category, []))
if candidate:
expanded_tokens.append(f"@{category}:{candidate}")
continue
@@ -303,7 +333,7 @@ def _expand_references(line: str) -> str:
best_category = ""
best_value = ""
for candidate_category, values in catalog.items():
for candidate_category, values in active_catalog.items():
candidate = _best_fuzzy_match(query, values)
if candidate:
best_category = candidate_category
@@ -317,9 +347,11 @@ def _expand_references(line: str) -> str:
return " ".join(expanded_tokens)
def _reference_suggestions(line: str) -> list[str]:
def _reference_suggestions(
line: str, *, catalog: dict[str, list[str]] | None = None
) -> list[str]:
"""Return lightweight suggestion hints for unresolved @ tokens."""
catalog = _build_reference_catalog()
active_catalog = catalog or _build_reference_catalog()
for token in line.split():
if not token.startswith("@") or token.startswith("\\@"):
continue
@@ -328,14 +360,14 @@ def _reference_suggestions(line: str) -> list[str]:
continue
if ":" in raw:
category, query = raw.split(":", 1)
if category in catalog:
if category in active_catalog:
candidates = _find_reference_candidates(
query, catalog[category], limit=5
query, active_catalog[category], limit=5
)
return [f"@{category}:{value}" for value in candidates]
else:
merged: list[str] = []
for category, values in catalog.items():
for category, values in active_catalog.items():
for value in _find_reference_candidates(raw, values, limit=2):
merged.append(f"@{category}:{value}")
return merged[:5]
@@ -343,11 +375,37 @@ def _reference_suggestions(line: str) -> list[str]:
def _run_shell_command(command_text: str) -> int:
"""Execute a shell command and print output."""
"""Execute a shell command with minimal safeguards.
Threat model:
- Shell mode is intended for trusted local development workflows.
- It is not a sandbox and must never be exposed as a remote execution surface.
- Dangerous patterns require explicit user confirmation before execution.
"""
if not command_text.strip():
_console.print("[yellow]Shell mode requires a command after '!'.[/yellow]")
return 2
proc = subprocess.run(command_text, shell=True, text=True, capture_output=True) # nosec B602
if os.environ.get("CLEVERAGENTS_DISABLE_SHELL_MODE", "").strip() in {"1", "true"}:
_console.print("[yellow]Shell mode is disabled by configuration.[/yellow]")
return 2
if looks_dangerous(command_text):
answer = input("Dangerous command detected. Continue? [y/N]: ").strip().lower()
if answer not in {"y", "yes"}:
_console.print("[yellow]Blocked dangerous shell command.[/yellow]")
return 1
try:
proc = subprocess.run(
command_text,
shell=True,
text=True,
capture_output=True,
timeout=_SHELL_TIMEOUT_SECONDS,
) # nosec B602
except subprocess.TimeoutExpired:
_console.print(
f"[red]Shell command timed out after {_SHELL_TIMEOUT_SECONDS}s.[/red]"
)
return 124
if proc.stdout:
_console.print(proc.stdout.rstrip("\n"))
if proc.stderr:
@@ -507,8 +565,12 @@ def _handle_slash_command(
if not deleted:
_console.print(f"[red]Persona not found:[/red] {target}")
return 1, current_session
if sessions[current_session].active_persona == target:
sessions[current_session].active_persona = "default"
reset_any = False
for state in sessions.values():
if state.active_persona == target:
state.active_persona = "default"
reset_any = True
if reset_any:
registry.set_active_persona_name("default")
_console.print(f"[green]Deleted persona:[/green] {target}")
return 0, current_session
@@ -521,26 +583,34 @@ def _handle_slash_command(
if persona is None:
_console.print(f"[red]Persona not found:[/red] {target}")
return 1, current_session
output_path = (
Path(tokens[3]) if len(tokens) > 3 else Path.cwd() / f"{target}.yaml"
)
output_path = Path(tokens[3]) if len(tokens) > 3 else Path(f"{target}.yaml")
try:
safe_output_path = registry.resolve_export_path(output_path)
except ValueError as exc:
_console.print(f"[red]{exc}[/red]")
return 1, current_session
payload = persona.model_dump(mode="json", exclude_none=True)
output_path.write_text(
safe_output_path.write_text(
yaml.safe_dump(payload, sort_keys=False, default_flow_style=False),
encoding="utf-8",
)
_console.print(f"[green]Exported persona:[/green] {output_path}")
_console.print(f"[green]Exported persona:[/green] {safe_output_path}")
return 0, current_session
if tokens[1] == "import":
if len(tokens) < 3:
_console.print("[red]Usage:[/red] /persona import <path>")
return 2, current_session
input_path = Path(tokens[2])
if not input_path.exists():
_console.print(f"[red]File not found:[/red] {input_path}")
try:
safe_input_path = registry.resolve_import_path(input_path)
except ValueError as exc:
_console.print(f"[red]{exc}[/red]")
return 1, current_session
if not safe_input_path.exists():
_console.print(f"[red]File not found:[/red] {safe_input_path}")
return 1, current_session
raw = yaml.safe_load(input_path.read_text(encoding="utf-8")) or {}
raw = yaml.safe_load(safe_input_path.read_text(encoding="utf-8")) or {}
if not isinstance(raw, dict):
_console.print("[red]Invalid persona YAML.[/red]")
return 1, current_session
@@ -714,11 +784,12 @@ def run_repl(
continue
# Normal mode @ references
expanded_line = _expand_references(line)
reference_catalog = _build_reference_catalog() if "@" in line else None
expanded_line = _expand_references(line, catalog=reference_catalog)
if expanded_line != line:
_console.print(f"[dim]{expanded_line}[/dim]")
elif "@" in line:
hints = _reference_suggestions(line)
hints = _reference_suggestions(line, catalog=reference_catalog)
if hints:
_console.print("[dim]Reference suggestions:[/dim] " + ", ".join(hints))
line = expanded_line
+3 -2
View File
@@ -6,8 +6,6 @@ from typing import Annotated
import typer
from cleveragents.tui.commands import run_tui
app = typer.Typer(help="Launch the Textual TUI.")
@@ -22,4 +20,7 @@ def tui_callback(
] = False,
) -> None:
"""Launch the CleverAgents TUI."""
# Import lazily so non-TUI commands avoid Textual startup cost.
from cleveragents.tui.commands import run_tui
raise typer.Exit(run_tui(headless=headless))
+22 -148
View File
@@ -1,177 +1,51 @@
"""Persona schema and local YAML storage for interactive CLI/TUI usage.
Personas are intentionally a presentation-layer concept. They are stored as
YAML files under the user config directory and never persisted in domain
models or database tables.
"""
"""CLI compatibility layer for canonical TUI persona modules."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, ConfigDict, Field, field_validator
from cleveragents.tui.persona.registry import PersonaRegistry as _TuiPersonaRegistry
from cleveragents.tui.persona.schema import Persona as PersonaConfig
def _default_config_dir() -> Path:
from_env = os.environ.get("CLEVERAGENTS_CONFIG_DIR", "").strip()
if from_env:
return Path(from_env)
return Path.home() / ".config" / "cleveragents"
class PersonaRegistry(_TuiPersonaRegistry):
"""Backwards-compatible API for CLI callers.
class PersonaPreset(BaseModel):
"""Named argument overlay for a persona."""
name: str = Field(..., min_length=1)
display: str = Field(..., min_length=1)
overrides: dict[str, Any] = Field(default_factory=dict)
model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
class PersonaConfig(BaseModel):
"""Persona definition loaded from YAML."""
name: str = Field(..., min_length=1)
description: str = Field(default="")
icon: str = Field(default="")
actor: str = Field(..., min_length=1)
base_arguments: dict[str, Any] = Field(default_factory=dict)
scoped_projects: list[str] = Field(default_factory=list)
scoped_plans: list[str] = Field(default_factory=list)
argument_presets: list[PersonaPreset] = Field(default_factory=list)
cycle_order: int = Field(default=0, ge=0)
greeting: str = Field(default="")
@field_validator("actor")
@classmethod
def _validate_actor(cls, value: str) -> str:
if "/" not in value:
raise ValueError("actor must be namespaced (namespace/name)")
return value
@field_validator("argument_presets")
@classmethod
def _validate_default_preset(
cls, value: list[PersonaPreset]
) -> list[PersonaPreset]:
if not value:
return [PersonaPreset(name="default", display="default", overrides={})]
defaults = [preset for preset in value if preset.name == "default"]
if len(defaults) != 1:
raise ValueError("persona must contain exactly one 'default' preset")
if defaults[0].overrides:
raise ValueError("'default' preset must have empty overrides")
return value
model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
@dataclass(slots=True)
class PersonaRegistry:
"""Loads and persists persona YAML files from user config space."""
config_dir: Path = field(default_factory=_default_config_dir)
@property
def personas_dir(self) -> Path:
return self.config_dir / "personas"
Canonical persona schema/registry live in `cleveragents.tui.persona.*`.
This class preserves old method names used by REPL and CLI command code.
"""
@property
def state_file(self) -> Path:
return self.config_dir / "tui-state.yaml"
return self.state_path
def ensure_dirs(self) -> None:
self.personas_dir.mkdir(parents=True, exist_ok=True)
@property
def personas_lock_file(self) -> Path:
return self.personas_lock_path
def persona_path(self, name: str) -> Path:
return self.personas_dir / f"{name}.yaml"
@property
def state_lock_file(self) -> Path:
return self.state_lock_path
def list_personas(self) -> list[PersonaConfig]:
self.ensure_dirs()
personas: list[PersonaConfig] = []
for path in sorted(self.personas_dir.glob("*.y*ml")):
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(raw, dict):
continue
personas.append(PersonaConfig.model_validate(raw))
return personas
return super().list_personas()
def get_persona(self, name: str) -> PersonaConfig | None:
path = self.persona_path(name)
if not path.exists():
return None
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
if not isinstance(raw, dict):
return None
return PersonaConfig.model_validate(raw)
return super().get(name)
def save_persona(self, persona: PersonaConfig) -> Path:
self.ensure_dirs()
if persona.cycle_order > 0:
for existing in self.list_personas():
if (
existing.name != persona.name
and existing.cycle_order == persona.cycle_order
):
raise ValueError(
"cycle_order must be unique across personas "
"with cycle_order > 0"
)
path = self.persona_path(persona.name)
payload = persona.model_dump(mode="json", exclude_none=True)
path.write_text(
yaml.safe_dump(payload, sort_keys=False, default_flow_style=False),
encoding="utf-8",
)
return path
return super().save(persona)
def delete_persona(self, name: str) -> bool:
path = self.persona_path(name)
if not path.exists():
return False
path.unlink()
return True
return super().delete(name)
def get_active_persona_name(self) -> str | None:
if not self.state_file.exists():
return None
raw = yaml.safe_load(self.state_file.read_text(encoding="utf-8")) or {}
if not isinstance(raw, dict):
return None
active = raw.get("last_persona")
return active if isinstance(active, str) and active else None
return super().get_last_persona()
def set_active_persona_name(self, name: str) -> None:
self.ensure_dirs()
state: dict[str, Any] = {}
if self.state_file.exists():
loaded = yaml.safe_load(self.state_file.read_text(encoding="utf-8")) or {}
if isinstance(loaded, dict):
state = dict(loaded)
state["last_persona"] = name
self.state_file.write_text(
yaml.safe_dump(state, sort_keys=False, default_flow_style=False),
encoding="utf-8",
)
super().set_last_persona(name)
def ensure_default_persona(
self, actor: str = "local/mock-default"
) -> PersonaConfig:
self.ensure_dirs()
existing = self.get_persona("default")
if existing is not None:
return existing
default_persona = PersonaConfig(
name="default",
description="Default persona",
actor=actor,
argument_presets=[PersonaPreset(name="default", display="default")],
)
self.save_persona(default_persona)
self.set_active_persona_name(default_persona.name)
return default_persona
return super().ensure_default(actor=actor)
+8 -1
View File
@@ -3,7 +3,14 @@
from __future__ import annotations
from cleveragents.tui.app import CleverAgentsTuiApp
from cleveragents.tui.commands import run_tui
def run_tui(*, headless: bool = False) -> int:
"""Run TUI command entrypoint lazily to avoid import cycles."""
from cleveragents.tui.commands import run_tui as _run_tui
return _run_tui(headless=headless)
__all__ = [
"CleverAgentsTuiApp",
+13 -3
View File
@@ -3,8 +3,9 @@
from __future__ import annotations
import importlib
import os
from dataclasses import dataclass
from typing import Any, ClassVar, Protocol
from typing import TYPE_CHECKING, Any, ClassVar, Protocol
from cleveragents.tui.input.modes import InputMode, InputModeRouter
from cleveragents.tui.input.reference_parser import suggestions
@@ -14,6 +15,11 @@ from cleveragents.tui.widgets.prompt import PromptInput
from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay
from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay
if TYPE_CHECKING:
InputSubmittedEvent = Any
else:
InputSubmittedEvent = Any
_TEXTUAL_AVAILABLE = False
_TextualApp: type[Any] = object
_Vertical: type[Any] = object
@@ -135,7 +141,7 @@ if _TEXTUAL_AVAILABLE:
scope_text=scope_text,
)
def on_input_submitted(self, event: object) -> None:
def on_input_submitted(self, event: InputSubmittedEvent) -> None:
del event
prompt = self.query_one("#prompt", PromptInput)
payload = prompt.consume_text()
@@ -146,7 +152,11 @@ if _TEXTUAL_AVAILABLE:
mode_router = InputModeRouter(
command_handler=lambda raw: self._command_router.handle(
raw, session_id=self._session.session_id
)
),
shell_confirm=lambda _cmd: (
os.environ.get("CLEVERAGENTS_ALLOW_DANGEROUS_SHELL", "").strip()
in {"1", "true"}
),
)
result = mode_router.process(text)
conversation = self.query_one("#conversation", _Static)
+2 -3
View File
@@ -51,9 +51,8 @@ class TuiCommandRouter:
def run_tui(*, headless: bool = False) -> int:
"""Run the Textual TUI app or a headless startup check."""
container = get_container()
_ = container.tui_adapters()
registry = PersonaRegistry()
state = PersonaState(registry=registry)
registry = container.persona_registry()
state = container.persona_state(registry=registry)
router = TuiCommandRouter(persona_registry=registry, persona_state=state)
if headless:
+14 -2
View File
@@ -35,8 +35,16 @@ class ModeResult:
class InputModeRouter:
"""Dispatch user input to normal/command/shell handlers."""
def __init__(self, command_handler: Callable[[str], str]) -> None:
def __init__(
self,
command_handler: Callable[[str], str],
*,
shell_confirm: Callable[[str], bool] | None = None,
shell_timeout_seconds: int = 30,
) -> None:
self._command_handler = command_handler
self._shell_confirm = shell_confirm
self._shell_timeout_seconds = shell_timeout_seconds
@staticmethod
def detect_mode(text: str) -> InputMode:
@@ -61,7 +69,11 @@ class InputModeRouter:
)
if mode == InputMode.SHELL:
command = text.lstrip()[1:].strip()
shell_result = run_shell_command(command)
shell_result = run_shell_command(
command,
confirm_dangerous=self._shell_confirm,
timeout_seconds=self._shell_timeout_seconds,
)
return ModeResult(
mode=mode,
expanded_text=text,
+37 -10
View File
@@ -2,12 +2,18 @@
from __future__ import annotations
import os
import time
from dataclasses import dataclass
from pathlib import Path
from cleveragents.tui.search.fuzzy import rank_candidates
_REFERENCE_TYPES = ("project", "plan", "resource", "actor", "tool", "skill")
_IGNORED_DIRS = {".git", ".venv", "node_modules", "__pycache__", ".mypy_cache"}
_CATALOG_LIMIT = 400
_CATALOG_CACHE_TTL_SECONDS = 5.0
_catalog_cache: dict[str, object] = {"cwd": None, "created_at": 0.0, "catalog": None}
@dataclass(slots=True, frozen=True)
@@ -30,17 +36,34 @@ class ReferenceParseResult:
def _catalog() -> dict[str, list[str]]:
cwd = Path.cwd()
files = []
for path in cwd.rglob("*"):
if path.is_file():
try:
files.append(path.relative_to(cwd).as_posix())
except ValueError:
files.append(path.as_posix())
if len(files) >= 400:
break
now = time.time()
cached_cwd = _catalog_cache.get("cwd")
cached_time = _catalog_cache.get("created_at")
cached_catalog = _catalog_cache.get("catalog")
if (
isinstance(cached_cwd, Path)
and cached_cwd == cwd
and isinstance(cached_time, float)
and now - cached_time < _CATALOG_CACHE_TTL_SECONDS
and isinstance(cached_catalog, dict)
):
return cached_catalog
return {
files = []
for root, dirs, filenames in os.walk(cwd, followlinks=False):
dirs[:] = [name for name in dirs if name not in _IGNORED_DIRS]
root_path = Path(root)
for filename in filenames:
try:
files.append((root_path / filename).relative_to(cwd).as_posix())
except ValueError:
files.append((root_path / filename).as_posix())
if len(files) >= _CATALOG_LIMIT:
break
if len(files) >= _CATALOG_LIMIT:
break
catalog = {
"resource": sorted(files),
"project": [value for value in [cwd.name] if value],
"plan": [],
@@ -63,6 +86,10 @@ def _catalog() -> dict[str, list[str]]:
if (cwd / "examples" / "skills").is_dir()
else [],
}
_catalog_cache["cwd"] = cwd
_catalog_cache["created_at"] = now
_catalog_cache["catalog"] = catalog
return catalog
def _resolve(category: str, query: str, items: dict[str, list[str]]) -> str | None:
+48 -3
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
import os
import subprocess
from collections.abc import Callable
from dataclasses import dataclass
@@ -29,13 +31,56 @@ def looks_dangerous(command: str) -> bool:
return any(pattern in lowered for pattern in patterns)
def run_shell_command(command: str) -> ShellResult:
"""Execute shell command and capture outputs."""
def run_shell_command(
command: str,
*,
confirm_dangerous: Callable[[str], bool] | None = None,
timeout_seconds: int = 30,
) -> ShellResult:
"""Execute shell command with basic safeguards.
Threat model:
- Shell mode is a convenience feature for local development, not a sandbox.
- We block obviously dangerous command patterns unless explicitly confirmed.
- We apply a timeout to avoid hanging the UI event loop indefinitely.
"""
if not command.strip():
return ShellResult(
command=command, exit_code=2, stdout="", stderr="empty command"
)
proc = subprocess.run(command, shell=True, text=True, capture_output=True) # nosec B602
if os.environ.get("CLEVERAGENTS_DISABLE_SHELL_MODE", "").strip() in {"1", "true"}:
return ShellResult(
command=command,
exit_code=2,
stdout="",
stderr="shell mode is disabled",
)
if looks_dangerous(command):
confirmed = False
if confirm_dangerous is not None:
confirmed = confirm_dangerous(command)
if not confirmed:
return ShellResult(
command=command,
exit_code=1,
stdout="",
stderr="blocked dangerous shell command",
)
try:
proc = subprocess.run(
command,
shell=True,
text=True,
capture_output=True,
timeout=timeout_seconds,
) # nosec B602
except subprocess.TimeoutExpired:
return ShellResult(
command=command,
exit_code=124,
stdout="",
stderr=f"command timed out after {timeout_seconds}s",
)
return ShellResult(
command=command,
exit_code=proc.returncode,
+96 -25
View File
@@ -2,15 +2,21 @@
from __future__ import annotations
import fcntl
import logging
import os
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
from pydantic import ValidationError
from cleveragents.tui.persona.schema import Persona
_logger = logging.getLogger(__name__)
def _default_config_dir() -> Path:
override = os.environ.get("CLEVERAGENTS_CONFIG_DIR", "").strip()
@@ -33,20 +39,82 @@ class PersonaRegistry:
def state_path(self) -> Path:
return self.config_dir / "tui-state.yaml"
@property
def personas_lock_path(self) -> Path:
return self.config_dir / ".personas.lock"
@property
def state_lock_path(self) -> Path:
return self.config_dir / ".state.lock"
def ensure_dirs(self) -> None:
self.personas_dir.mkdir(parents=True, exist_ok=True)
def _lock_file(self, lock_path: Path) -> Any:
lock_path.parent.mkdir(parents=True, exist_ok=True)
handle = lock_path.open("a+", encoding="utf-8")
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
return handle
@staticmethod
def _atomic_write_yaml(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=str(path.parent),
prefix=f".{path.name}.",
suffix=".tmp",
delete=False,
) as handle:
yaml.safe_dump(payload, handle, sort_keys=False, default_flow_style=False)
temp_name = handle.name
Path(temp_name).replace(path)
def persona_path(self, name: str) -> Path:
Persona.validate_name_safe(name)
result = (self.personas_dir / f"{name}.yaml").resolve()
if not result.is_relative_to(self.personas_dir.resolve()):
raise ValueError(f"Invalid persona name: {name}")
return result
def resolve_export_path(self, output_path: Path) -> Path:
if output_path.is_absolute():
raise ValueError(
"Export path must be relative to current working directory"
)
base = Path.cwd().resolve()
resolved = (base / output_path).resolve()
if not resolved.is_relative_to(base):
raise ValueError("Export path must stay within working directory")
return resolved
def resolve_import_path(self, input_path: Path) -> Path:
if input_path.is_absolute():
raise ValueError(
"Import path must be relative to current working directory"
)
base = Path.cwd().resolve()
resolved = (base / input_path).resolve()
if not resolved.is_relative_to(base):
raise ValueError("Import path must stay within working directory")
return resolved
def list_personas(self) -> list[Persona]:
self.ensure_dirs()
personas: list[Persona] = []
for file in sorted(self.personas_dir.glob("*.y*ml")):
raw = yaml.safe_load(file.read_text(encoding="utf-8")) or {}
if isinstance(raw, dict):
try:
raw = yaml.safe_load(file.read_text(encoding="utf-8")) or {}
if not isinstance(raw, dict):
continue
personas.append(Persona.model_validate(raw))
except (yaml.YAMLError, ValidationError) as exc:
_logger.warning("Skipping invalid persona file %s: %s", file, exc)
return personas
def get(self, name: str) -> Persona | None:
file = self.personas_dir / f"{name}.yaml"
file = self.persona_path(name)
if not file.exists():
return None
raw = yaml.safe_load(file.read_text(encoding="utf-8")) or {}
@@ -65,17 +133,19 @@ class PersonaRegistry:
def save(self, persona: Persona) -> Path:
self.ensure_dirs()
self._validate_cycle_order(persona)
file = self.personas_dir / f"{persona.name}.yaml"
payload = persona.model_dump(mode="json", exclude_none=True)
file.write_text(
yaml.safe_dump(payload, sort_keys=False, default_flow_style=False),
encoding="utf-8",
)
return file
lock = self._lock_file(self.personas_lock_path)
try:
self._validate_cycle_order(persona)
file = self.persona_path(persona.name)
payload = persona.model_dump(mode="json", exclude_none=True)
self._atomic_write_yaml(file, payload)
return file
finally:
fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
lock.close()
def delete(self, name: str) -> bool:
file = self.personas_dir / f"{name}.yaml"
file = self.persona_path(name)
if not file.exists():
return False
file.unlink()
@@ -86,14 +156,13 @@ class PersonaRegistry:
if persona is None:
raise ValueError(f"Persona not found: {name}")
payload = persona.model_dump(mode="json", exclude_none=True)
output_path.write_text(
yaml.safe_dump(payload, sort_keys=False, default_flow_style=False),
encoding="utf-8",
)
return output_path
safe_output = self.resolve_export_path(output_path)
self._atomic_write_yaml(safe_output, payload)
return safe_output
def import_persona(self, input_path: Path) -> Persona:
raw = yaml.safe_load(input_path.read_text(encoding="utf-8")) or {}
safe_input = self.resolve_import_path(input_path)
raw = yaml.safe_load(safe_input.read_text(encoding="utf-8")) or {}
if not isinstance(raw, dict):
raise ValueError("Invalid persona file")
persona = Persona.model_validate(raw)
@@ -110,10 +179,7 @@ class PersonaRegistry:
def save_state(self, state: dict[str, Any]) -> None:
self.ensure_dirs()
self.state_path.write_text(
yaml.safe_dump(state, sort_keys=False, default_flow_style=False),
encoding="utf-8",
)
self._atomic_write_yaml(self.state_path, state)
def get_last_persona(self) -> str | None:
state = self.load_state()
@@ -121,9 +187,14 @@ class PersonaRegistry:
return value if isinstance(value, str) and value else None
def set_last_persona(self, name: str) -> None:
state = self.load_state()
state["last_persona"] = name
self.save_state(state)
lock = self._lock_file(self.state_lock_path)
try:
state = self.load_state()
state["last_persona"] = name
self.save_state(state)
finally:
fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
lock.close()
def ensure_default(self, actor: str = "local/mock-default") -> Persona:
existing = self.get("default")
+11 -12
View File
@@ -39,19 +39,18 @@ class Persona(BaseModel):
raise ValueError("actor must be namespaced (namespace/name)")
return value
@field_validator("argument_presets")
@field_validator("name")
@classmethod
def validate_default_preset(
cls, presets: list[PersonaPreset]
) -> list[PersonaPreset]:
if not presets:
return presets
defaults = [preset for preset in presets if preset.name == "default"]
if len(defaults) != 1:
raise ValueError("must contain exactly one default preset")
if defaults[0].overrides:
raise ValueError("default preset overrides must be empty")
return presets
def validate_name_safe(cls, value: str) -> str:
if (
"/" in value
or "\\" in value
or ".." in value
or "\x00" in value
or any(ord(char) < 32 or ord(char) == 127 for char in value)
):
raise ValueError("name must not contain path/control separators")
return value
@model_validator(mode="after")
def ensure_default_preset(self) -> Persona: