mirror of
https://github.com/pocketpaw/pocketpaw.git
synced 2026-05-21 01:04:57 +00:00
* feat(tools): add run_python, install_package, and edit_file primitives Three new builtin tools that close the gap between "agent that chats" and "agent that builds things": - run_python: sandboxed Python execution via subprocess (trust: elevated). Writes code to a temp file in the file jail, runs with sys.executable, Guardian AI reviews before execution, cleanup in finally block. - install_package: pip install with input validation and Guardian review (trust: elevated). Whitelist regex blocks shell injection. Subprocess runs without shell=True for defense-in-depth. - edit_file: find-and-replace file editing (trust: standard). Closes the gap where policy.py referenced edit_file in group:fs but no tool existed. Supports unique match (default) and replace_all modes. Wiring changes: - __init__.py: lazy imports for all three tools - policy.py: run_python added to group:shell, new group:packages with install_package, coding profile includes group:packages - tool_bridge.py: EditFileTool excluded for claude_agent_sdk (has native Edit) 31 new tests across 3 test files, all passing. Closes #581 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(tools): add deliver_artifact tool for sending files to users Standalone tool that lets the agent explicitly send any file (image, video, audio, PDF) to the user through their current channel. Bridges the gap between _media_result() (tool-internal) and agent-accessible file delivery. Uses the existing media pipeline: _media_result() -> AgentLoop extraction -> OutboundMessage.media -> channel adapter _send_media_file(). 9 new tests, all passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use get_running_loop, remove whitespace from regex, clean up em dashes - Replace deprecated asyncio.get_event_loop() with get_running_loop() in python_exec.py and pip_install.py - Remove \s from package spec regex (single package at a time, no spaces) - Replace em dashes with hyphens/commas per project convention --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Rohit Kushwaha <rohitk290106@gmail.com> Co-authored-by: Rohit Kushwaha <technicalrohit06@gmail.com>
176 lines
5.0 KiB
Python
176 lines
5.0 KiB
Python
# Tests for EditFileTool - find-and-replace file editing.
|
|
# Created: 2026-03-12
|
|
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from pocketpaw.config import Settings
|
|
from pocketpaw.tools.builtin.filesystem import EditFileTool
|
|
|
|
|
|
@pytest.fixture
|
|
def jail(tmp_path):
|
|
"""Temporary directory used as the file jail."""
|
|
return tmp_path
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_settings(jail):
|
|
"""Patch filesystem.get_settings to use the temp jail."""
|
|
settings = Settings(file_jail_path=jail)
|
|
with patch("pocketpaw.tools.builtin.filesystem.get_settings", return_value=settings):
|
|
yield settings
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_basic(jail, mock_settings):
|
|
"""Replace one occurrence, content should change."""
|
|
f = jail / "hello.txt"
|
|
f.write_text("Hello World")
|
|
|
|
tool = EditFileTool()
|
|
result = await tool.execute(path=str(f), old_string="World", new_string="PocketPaw")
|
|
|
|
assert "replacement" in result
|
|
assert f.read_text() == "Hello PocketPaw"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_not_found(jail, mock_settings):
|
|
"""Editing a non-existent file returns a 'not found' error."""
|
|
tool = EditFileTool()
|
|
result = await tool.execute(
|
|
path=str(jail / "missing.txt"),
|
|
old_string="anything",
|
|
new_string="replacement",
|
|
)
|
|
|
|
assert "Error:" in result
|
|
assert "not found" in result.lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_old_string_missing(jail, mock_settings):
|
|
"""old_string not in file returns an error."""
|
|
f = jail / "content.txt"
|
|
f.write_text("The quick brown fox")
|
|
|
|
tool = EditFileTool()
|
|
result = await tool.execute(path=str(f), old_string="lazy dog", new_string="cat")
|
|
|
|
assert "Error:" in result
|
|
assert "not found" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_ambiguous(jail, mock_settings):
|
|
"""old_string appearing 3 times with replace_all=False returns an error mentioning the count."""
|
|
f = jail / "repeat.txt"
|
|
f.write_text("foo bar foo baz foo")
|
|
|
|
tool = EditFileTool()
|
|
result = await tool.execute(path=str(f), old_string="foo", new_string="qux", replace_all=False)
|
|
|
|
assert "Error:" in result
|
|
assert "3" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_replace_all(jail, mock_settings):
|
|
"""replace_all=True replaces every occurrence."""
|
|
f = jail / "repeat.txt"
|
|
f.write_text("foo bar foo baz foo")
|
|
|
|
tool = EditFileTool()
|
|
result = await tool.execute(path=str(f), old_string="foo", new_string="qux", replace_all=True)
|
|
|
|
assert "Error:" not in result
|
|
assert f.read_text() == "qux bar qux baz qux"
|
|
assert "3" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_multiline(jail, mock_settings):
|
|
"""Replace a multi-line block."""
|
|
original = "line one\nline two\nline three\n"
|
|
f = jail / "multi.txt"
|
|
f.write_text(original)
|
|
|
|
tool = EditFileTool()
|
|
result = await tool.execute(
|
|
path=str(f),
|
|
old_string="line one\nline two\n",
|
|
new_string="replaced block\n",
|
|
)
|
|
|
|
assert "Error:" not in result
|
|
assert f.read_text() == "replaced block\nline three\n"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_empty_new_string(jail, mock_settings):
|
|
"""Replace with empty string effectively deletes the matched text."""
|
|
f = jail / "delete.txt"
|
|
f.write_text("keep this DELETE that")
|
|
|
|
tool = EditFileTool()
|
|
result = await tool.execute(path=str(f), old_string=" DELETE", new_string="")
|
|
|
|
assert "Error:" not in result
|
|
assert f.read_text() == "keep this that"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_file_jail(jail, mock_settings):
|
|
"""Paths outside the jail are denied."""
|
|
outside = jail.parent / "outside_secret.txt"
|
|
outside.write_text("sensitive data")
|
|
|
|
tool = EditFileTool()
|
|
result = await tool.execute(
|
|
path=str(outside),
|
|
old_string="sensitive",
|
|
new_string="redacted",
|
|
)
|
|
|
|
assert "Access denied" in result
|
|
# File should be unchanged
|
|
assert outside.read_text() == "sensitive data"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_preserves_rest(jail, mock_settings):
|
|
"""Editing one part of a file leaves the rest of the content intact."""
|
|
f = jail / "partial.txt"
|
|
f.write_text("alpha beta gamma delta")
|
|
|
|
tool = EditFileTool()
|
|
await tool.execute(path=str(f), old_string="beta", new_string="BETA")
|
|
|
|
content = f.read_text()
|
|
assert "alpha" in content
|
|
assert "BETA" in content
|
|
assert "gamma" in content
|
|
assert "delta" in content
|
|
assert "beta" not in content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_file_definition(mock_settings):
|
|
"""Tool definition has correct name, trust level, and required parameters."""
|
|
tool = EditFileTool()
|
|
defn = tool.definition
|
|
|
|
assert defn.name == "edit_file"
|
|
assert defn.trust_level == "standard"
|
|
|
|
required = defn.parameters.get("required", [])
|
|
assert "path" in required
|
|
assert "old_string" in required
|
|
assert "new_string" in required
|
|
|
|
props = defn.parameters.get("properties", {})
|
|
assert "replace_all" in props
|
|
assert props["replace_all"]["type"] == "boolean"
|