Files
smart-org/apps/api/tests/test_engine_tools.py
T
mjalmoudy94 c6488bfadf feat(engine): the turn runner — prompt builder, sandbox tools, agent loop
Phase 2 of TODO-agent-2: one employee, one turn, real tools in the sandbox.

- engine/prompt.py: the stable, cache-friendly prefix in the plan's order
  (rules → role protocol → toolkit → app profile → memory), a task brief
  capped at 1200 chars, role-specific stage context (spec for the developer,
  the diff only for the reviewer, test output for the tester) and a hard
  prompt cap that drops the oldest tool results first.
- engine/tools.py: READ/WRITE/EDIT/LIST_DIR/SEARCH/RUN executed inside the
  sandbox through SandboxProviderPort, rooted at the worktree's sandbox path.
  Path escapes, .. and .git are refused; git push/remote/config are refused
  with "the platform does git"; RUN clamps its timeout (120s default, 600s
  max) and keeps the tail of its output; every execution writes a tool_calls
  row so the approvals UI shows real work.
- engine/runner.py: the loop — model call, parse tags, execute tool tags,
  feed results back — until a terminal tag, the tool cap, the turn timeout or
  a cancellation. The employee's model is checked against ModelGrantPort
  before the first call (ungranted → error_kind=auth, no spend), errors are
  classified transient vs fatal, and every model call writes usage events
  through the one shared record_model_usage path (extracted from
  WorkforceService so manager chat counts too). Writes engine_turns, run_steps
  and tool_calls.
- engine/events.py: RunFeed batches run.log (≤1 event / 500 ms / stream, ≤200
  lines) and emits run.tool and task.gate through the generic envelope.
- tests: engine_fakes.py (ScriptedModel, FakeSandbox, FakeGrants,
  RecordingMemory) plus 54 new tests in test_engine_runner,
  test_engine_prompt and test_engine_tools.

Suite: 648 passed, ruff clean, mypy clean.
2026-10-14 16:32:52 +03:30

185 lines
6.1 KiB
Python

"""The toolkit's guards: path safety, the git refusal and bounded commands.
These are the checks that run *before* anything reaches the sandbox, so they
are tested as pure functions wherever possible — a path bug must be
reproducible without a container.
"""
from __future__ import annotations
import pytest
from app.application.engine.protocol import Tag, TagName, edit_blocks_of
from app.application.engine.tools import (
RUN_DEFAULT_TIMEOUT,
RUN_MAX_TIMEOUT,
ToolExecutor,
UnsafePathError,
safe_join,
tool_key_for,
)
from tests.engine_fakes import FakeSandbox
ROOT = "/workspace/.worktrees/task-1"
# ---------------------------------------------------------------------------
# Path safety
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"relative",
["app/main.py", "./app/main.py", "app/deep/nested/file.py", "a.txt"],
)
def test_a_normal_relative_path_is_allowed(relative):
assert safe_join(ROOT, relative) == f"{ROOT}/{relative.lstrip('./')}".replace(
"/./", "/"
)
@pytest.mark.parametrize(
"relative",
["/etc/passwd", "~/.ssh/id_rsa", "../../etc/passwd", "app/../../secrets", ".git/config"],
)
def test_an_escaping_path_is_refused(relative):
with pytest.raises(UnsafePathError):
safe_join(ROOT, relative)
def test_an_empty_path_is_refused():
with pytest.raises(UnsafePathError):
safe_join(ROOT, " ")
def test_the_git_directory_is_never_readable():
with pytest.raises(UnsafePathError):
safe_join(ROOT, ".git/objects/ab/cdef")
# ---------------------------------------------------------------------------
# The git refusal
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"command",
["git push origin dev", "git remote add origin x", "git config user.email a@b.c"],
)
async def test_git_commands_are_refused_before_the_sandbox(command):
sandbox = FakeSandbox()
executor = ToolExecutor(
sandbox=sandbox, session_external_id="sess-1", root=ROOT
)
outcome = await executor.execute(
Tag(name=TagName.RUN, attrs={"cmd": command}, body="")
)
assert outcome.ok is False
assert outcome.refused is True
assert "پلتفرم" in outcome.output
assert sandbox.executed == []
async def test_other_git_commands_are_allowed_through():
"""Only push/remote/config are refused: the platform owns the rest."""
sandbox = FakeSandbox(commands={"git": []})
executor = ToolExecutor(
sandbox=sandbox, session_external_id="sess-1", root=ROOT
)
outcome = await executor.execute(
Tag(name=TagName.RUN, attrs={"cmd": "git status --porcelain -uall"}, body="")
)
assert outcome.ok is True
assert len(sandbox.executed) == 1
# ---------------------------------------------------------------------------
# Bounds
# ---------------------------------------------------------------------------
async def test_run_clamps_the_timeout_to_the_ceiling():
sandbox = FakeSandbox()
executor = ToolExecutor(sandbox=sandbox, session_external_id="sess-1", root=ROOT)
await executor.execute(
Tag(name=TagName.RUN, attrs={"cmd": "sleep 1", "timeout": "99999"}, body="")
)
assert sandbox.executed[0]["timeout_seconds"] == RUN_MAX_TIMEOUT
async def test_run_defaults_to_a_sane_timeout():
sandbox = FakeSandbox()
executor = ToolExecutor(sandbox=sandbox, session_external_id="sess-1", root=ROOT)
await executor.execute(Tag(name=TagName.RUN, attrs={"cmd": "ls"}, body=""))
assert sandbox.executed[0]["timeout_seconds"] == RUN_DEFAULT_TIMEOUT
async def test_a_huge_output_is_truncated_to_its_tail():
sandbox = FakeSandbox(
commands={"noisy": [type("R", (), {"exit_code": 0, "stdout": "x" * 30000, "stderr": ""})()]}
)
executor = ToolExecutor(
sandbox=sandbox, session_external_id="sess-1", root=ROOT, max_output_chars=1000
)
outcome = await executor.execute(
Tag(name=TagName.RUN, attrs={"cmd": "noisy"}, body="")
)
assert outcome.ok is True
assert len(outcome.output) <= 1100
assert outcome.output.endswith("x" * 100)
# ---------------------------------------------------------------------------
# The tools themselves
# ---------------------------------------------------------------------------
async def test_write_then_read_round_trips_inside_the_root():
sandbox = FakeSandbox()
executor = ToolExecutor(sandbox=sandbox, session_external_id="sess-1", root=ROOT)
written = await executor.execute(
Tag(name=TagName.WRITE_FILE, attrs={"path": "app/a.py"}, body="print(1)")
)
read = await executor.execute(
Tag(name=TagName.READ_FILE, attrs={"path": "app/a.py"}, body="")
)
assert written.ok and read.ok
assert "print(1)" in read.output
assert sandbox.writes[0]["path"] == f"{ROOT}/app/a.py"
async def test_list_dir_and_search_run_quoted_commands():
sandbox = FakeSandbox()
executor = ToolExecutor(sandbox=sandbox, session_external_id="sess-1", root=ROOT)
await executor.execute(Tag(name=TagName.LIST_DIR, attrs={"path": "app"}, body=""))
await executor.execute(
Tag(name=TagName.SEARCH, attrs={"pattern": "TODO", "path": "app"}, body="")
)
assert sandbox.executed[0]["command"].startswith("ls -la -- ")
assert "grep -RIn -e TODO" in sandbox.executed[1]["command"]
async def test_a_missing_file_reports_not_found_without_raising():
sandbox = FakeSandbox()
executor = ToolExecutor(sandbox=sandbox, session_external_id="sess-1", root=ROOT)
outcome = await executor.execute(
Tag(name=TagName.READ_FILE, attrs={"path": "app/none.py"}, body="")
)
assert outcome.ok is False
assert "none.py" in outcome.output
def test_tool_keys_map_onto_the_existing_catalog():
assert tool_key_for(Tag(name=TagName.READ_FILE, attrs={}, body="")) == (
"sandbox.read_file"
)
assert tool_key_for(Tag(name=TagName.RUN, attrs={}, body="")) == (
"sandbox.run_command"
)
def test_edit_blocks_refuse_a_malformed_body():
tag = Tag(name=TagName.EDIT_FILE, attrs={"path": "a"}, body="just text")
with pytest.raises(ValueError):
edit_blocks_of(tag)