pyro-mcp/tests/test_workspace_files.py
Thales Maciel ab02ae46c7 Add model-native workspace file operations
Remove shell-escaped file mutation from the stable workspace flow by adding explicit file and patch tools across the CLI, SDK, and MCP surfaces.

This adds workspace file list/read/write plus unified text patch application, backed by new guest and manager file primitives that stay scoped to started workspaces and /workspace only. Patch application is preflighted on the host, file writes stay text-only and bounded, and the existing diff/export/reset semantics remain intact.

The milestone also updates the 3.2.0 roadmap, public contract, docs, examples, and versioning, and includes focused coverage for the new helper module and dispatch paths.

Validation:
- uv lock
- UV_CACHE_DIR=.uv-cache make check
- UV_CACHE_DIR=.uv-cache make dist-check
- real guest-backed smoke for workspace file read, patch apply, exec, export, and delete
2026-03-12 22:03:25 -03:00

427 lines
13 KiB
Python

from __future__ import annotations
import os
from pathlib import Path
import pytest
from pyro_mcp.workspace_files import (
WORKSPACE_FILE_MAX_BYTES,
WORKSPACE_PATCH_MAX_BYTES,
WorkspacePatchHunk,
WorkspaceTextPatch,
apply_unified_text_patch,
delete_workspace_path,
list_workspace_files,
normalize_workspace_path,
parse_unified_text_patch,
read_workspace_file,
workspace_host_path,
write_workspace_file,
)
def test_workspace_files_list_read_write_and_delete(tmp_path: Path) -> None:
workspace_dir = tmp_path / "workspace"
workspace_dir.mkdir()
(workspace_dir / "src").mkdir()
(workspace_dir / "src" / "note.txt").write_text("hello\n", encoding="utf-8")
os.symlink("note.txt", workspace_dir / "src" / "note-link")
listing = list_workspace_files(
workspace_dir,
workspace_path="/workspace/src",
recursive=True,
)
assert listing.path == "/workspace/src"
assert listing.artifact_type == "directory"
assert [entry.to_payload() for entry in listing.entries] == [
{
"path": "/workspace/src/note-link",
"artifact_type": "symlink",
"size_bytes": 8,
"link_target": "note.txt",
},
{
"path": "/workspace/src/note.txt",
"artifact_type": "file",
"size_bytes": 6,
"link_target": None,
},
]
read_payload = read_workspace_file(
workspace_dir,
workspace_path="/workspace/src/note.txt",
)
assert read_payload.content_bytes == b"hello\n"
written = write_workspace_file(
workspace_dir,
workspace_path="/workspace/generated/out.txt",
text="generated\n",
)
assert written.bytes_written == 10
assert (workspace_dir / "generated" / "out.txt").read_text(encoding="utf-8") == "generated\n"
deleted = delete_workspace_path(
workspace_dir,
workspace_path="/workspace/generated/out.txt",
)
assert deleted.deleted is True
assert not (workspace_dir / "generated" / "out.txt").exists()
def test_workspace_file_read_and_delete_reject_unsupported_paths(tmp_path: Path) -> None:
workspace_dir = tmp_path / "workspace"
workspace_dir.mkdir()
(workspace_dir / "dir").mkdir()
(workspace_dir / "file.txt").write_text("ok\n", encoding="utf-8")
os.symlink("file.txt", workspace_dir / "link.txt")
with pytest.raises(RuntimeError, match="regular files"):
read_workspace_file(workspace_dir, workspace_path="/workspace/dir")
with pytest.raises(RuntimeError, match="regular files"):
read_workspace_file(workspace_dir, workspace_path="/workspace/link.txt")
with pytest.raises(RuntimeError, match="does not support directories"):
delete_workspace_path(workspace_dir, workspace_path="/workspace/dir")
def test_workspace_file_helpers_cover_single_paths_and_path_validation(tmp_path: Path) -> None:
workspace_dir = tmp_path / "workspace"
workspace_dir.mkdir()
(workspace_dir / "note.txt").write_text("hello\n", encoding="utf-8")
listing = list_workspace_files(
workspace_dir,
workspace_path="/workspace/note.txt",
recursive=False,
)
assert listing.path == "/workspace/note.txt"
assert listing.artifact_type == "file"
assert [entry.path for entry in listing.entries] == ["/workspace/note.txt"]
assert normalize_workspace_path("src/app.py") == "/workspace/src/app.py"
assert workspace_host_path(workspace_dir, "src/app.py") == workspace_dir / "src" / "app.py"
with pytest.raises(ValueError, match="must not be empty"):
normalize_workspace_path(" ")
with pytest.raises(ValueError, match="must stay inside /workspace"):
normalize_workspace_path("..")
with pytest.raises(ValueError, match="must stay inside /workspace"):
normalize_workspace_path("/tmp/outside")
with pytest.raises(ValueError, match="must stay inside /workspace"):
normalize_workspace_path("/")
def test_workspace_file_read_limits_and_write_validation(tmp_path: Path) -> None:
workspace_dir = tmp_path / "workspace"
workspace_dir.mkdir()
(workspace_dir / "big.txt").write_text("hello\n", encoding="utf-8")
(workspace_dir / "dir").mkdir()
real_dir = workspace_dir / "real"
real_dir.mkdir()
os.symlink("real", workspace_dir / "linked")
with pytest.raises(ValueError, match="max_bytes must be positive"):
read_workspace_file(workspace_dir, workspace_path="/workspace/big.txt", max_bytes=0)
with pytest.raises(ValueError, match="at most"):
read_workspace_file(
workspace_dir,
workspace_path="/workspace/big.txt",
max_bytes=WORKSPACE_FILE_MAX_BYTES + 1,
)
with pytest.raises(RuntimeError, match="exceeds the maximum supported size"):
read_workspace_file(workspace_dir, workspace_path="/workspace/big.txt", max_bytes=4)
with pytest.raises(RuntimeError, match="regular file targets"):
write_workspace_file(workspace_dir, workspace_path="/workspace/dir", text="nope\n")
with pytest.raises(RuntimeError, match="symlinked parent"):
write_workspace_file(
workspace_dir,
workspace_path="/workspace/linked/out.txt",
text="nope\n",
)
with pytest.raises(ValueError, match="at most"):
write_workspace_file(
workspace_dir,
workspace_path="/workspace/huge.txt",
text="x" * (WORKSPACE_FILE_MAX_BYTES + 1),
)
def test_workspace_file_list_rejects_unsupported_filesystem_types(tmp_path: Path) -> None:
workspace_dir = tmp_path / "workspace"
workspace_dir.mkdir()
fifo_path = workspace_dir / "pipe"
os.mkfifo(fifo_path)
with pytest.raises(RuntimeError, match="unsupported workspace path type"):
list_workspace_files(workspace_dir, workspace_path="/workspace", recursive=True)
def test_parse_and_apply_unified_text_patch_round_trip() -> None:
patch_text = """--- a/src/app.py
+++ b/src/app.py
@@ -1,2 +1,3 @@
print("old")
-print("bug")
+print("fixed")
+print("done")
--- /dev/null
+++ b/src/new.py
@@ -0,0 +1 @@
+print("new")
--- a/src/remove.py
+++ /dev/null
@@ -1 +0,0 @@
-print("remove")
"""
patches = parse_unified_text_patch(patch_text)
assert [(item.path, item.status) for item in patches] == [
("/workspace/src/app.py", "modified"),
("/workspace/src/new.py", "added"),
("/workspace/src/remove.py", "deleted"),
]
modified = apply_unified_text_patch(
path="/workspace/src/app.py",
patch=patches[0],
before_text='print("old")\nprint("bug")\n',
)
added = apply_unified_text_patch(
path="/workspace/src/new.py",
patch=patches[1],
before_text=None,
)
deleted = apply_unified_text_patch(
path="/workspace/src/remove.py",
patch=patches[2],
before_text='print("remove")\n',
)
assert modified == 'print("old")\nprint("fixed")\nprint("done")\n'
assert added == 'print("new")\n'
assert deleted is None
def test_parse_unified_text_patch_rejects_unsupported_features() -> None:
with pytest.raises(ValueError, match="unsupported patch feature"):
parse_unified_text_patch(
"""diff --git a/file.txt b/file.txt
old mode 100644
--- a/file.txt
+++ b/file.txt
@@ -1 +1 @@
-old
+new
"""
)
with pytest.raises(ValueError, match="rename and copy patches are not supported"):
parse_unified_text_patch(
"""--- a/old.txt
+++ b/new.txt
@@ -1 +1 @@
-old
+new
"""
)
def test_parse_unified_text_patch_handles_git_headers_and_validation_errors() -> None:
parsed = parse_unified_text_patch(
"""
diff --git a/file.txt b/file.txt
index 1234567..89abcde 100644
--- /workspace/file.txt
+++ /workspace/file.txt
@@ -1 +1 @@
-old
+new
\\ No newline at end of file
"""
)
assert parsed[0].path == "/workspace/file.txt"
with pytest.raises(ValueError, match="must not be empty"):
parse_unified_text_patch("")
with pytest.raises(ValueError, match="invalid patch header"):
parse_unified_text_patch("oops\n")
with pytest.raises(ValueError, match="missing '\\+\\+\\+' header"):
parse_unified_text_patch("--- a/file.txt\n")
with pytest.raises(ValueError, match="has no hunks"):
parse_unified_text_patch("--- a/file.txt\n+++ b/file.txt\n")
with pytest.raises(ValueError, match="line counts do not match"):
parse_unified_text_patch(
"""--- a/file.txt
+++ b/file.txt
@@ -1,2 +1,1 @@
-old
+new
"""
)
with pytest.raises(ValueError, match="invalid patch hunk line"):
parse_unified_text_patch(
"""--- a/file.txt
+++ b/file.txt
@@ -1 +1 @@
?bad
"""
)
with pytest.raises(ValueError, match="at most"):
parse_unified_text_patch("x" * (WORKSPACE_PATCH_MAX_BYTES + 1))
with pytest.raises(ValueError, match="patch must target a workspace path"):
parse_unified_text_patch("--- /dev/null\n+++ /dev/null\n")
with pytest.raises(ValueError, match="patch must contain at least one file change"):
parse_unified_text_patch(
"""diff --git a/file.txt b/file.txt
index 1234567..89abcde 100644
"""
)
with pytest.raises(ValueError, match="unsupported patch feature"):
parse_unified_text_patch(
"""--- a/file.txt
+++ b/file.txt
new mode 100644
"""
)
with pytest.raises(ValueError, match="invalid patch hunk header"):
parse_unified_text_patch(
"""--- a/file.txt
+++ b/file.txt
@@ -1 +1 @@
old
@@bogus
"""
)
parsed = parse_unified_text_patch(
"""--- a/file.txt
+++ b/file.txt
index 1234567..89abcde 100644
@@ -1 +1 @@
-old
+new
@@ -3 +3 @@
-before
+after
"""
)
assert len(parsed[0].hunks) == 2
def test_apply_unified_text_patch_rejects_context_mismatches() -> None:
patch = parse_unified_text_patch(
"""--- a/file.txt
+++ b/file.txt
@@ -1 +1 @@
-before
+after
"""
)[0]
with pytest.raises(RuntimeError, match="patch context does not match"):
apply_unified_text_patch(
path="/workspace/file.txt",
patch=patch,
before_text="different\n",
)
with pytest.raises(RuntimeError, match="patch context does not match"):
apply_unified_text_patch(
path="/workspace/file.txt",
patch=WorkspaceTextPatch(
path="/workspace/file.txt",
status="modified",
hunks=[
WorkspacePatchHunk(
old_start=1,
old_count=1,
new_start=1,
new_count=1,
lines=[" same\n"],
)
],
),
before_text="",
)
def test_apply_unified_text_patch_rejects_range_prefix_delete_and_size_errors() -> None:
with pytest.raises(RuntimeError, match="out of range"):
apply_unified_text_patch(
path="/workspace/file.txt",
patch=WorkspaceTextPatch(
path="/workspace/file.txt",
status="modified",
hunks=[
WorkspacePatchHunk(
old_start=3,
old_count=1,
new_start=3,
new_count=1,
lines=["-old\n", "+new\n"],
)
],
),
before_text="old\n",
)
with pytest.raises(RuntimeError, match="invalid patch line prefix"):
apply_unified_text_patch(
path="/workspace/file.txt",
patch=WorkspaceTextPatch(
path="/workspace/file.txt",
status="modified",
hunks=[
WorkspacePatchHunk(
old_start=1,
old_count=0,
new_start=1,
new_count=0,
lines=["?bad\n"],
)
],
),
before_text="",
)
with pytest.raises(RuntimeError, match="delete patch did not remove all content"):
apply_unified_text_patch(
path="/workspace/file.txt",
patch=WorkspaceTextPatch(
path="/workspace/file.txt",
status="deleted",
hunks=[
WorkspacePatchHunk(
old_start=1,
old_count=1,
new_start=1,
new_count=0,
lines=["-first\n"],
)
],
),
before_text="first\nsecond\n",
)
huge_payload = "x" * (WORKSPACE_FILE_MAX_BYTES + 1)
with pytest.raises(RuntimeError, match="exceeds the maximum supported size"):
apply_unified_text_patch(
path="/workspace/file.txt",
patch=WorkspaceTextPatch(
path="/workspace/file.txt",
status="added",
hunks=[
WorkspacePatchHunk(
old_start=0,
old_count=0,
new_start=1,
new_count=1,
lines=[f"+{huge_payload}"],
)
],
),
before_text=None,
)