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
This commit is contained in:
Thales Maciel 2026-03-12 22:03:25 -03:00
parent dbb71a3174
commit ab02ae46c7
27 changed files with 3068 additions and 17 deletions

View file

@ -55,6 +55,10 @@ def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None:
assert "workspace_diff" in tool_names
assert "workspace_sync_push" in tool_names
assert "workspace_export" in tool_names
assert "workspace_file_list" in tool_names
assert "workspace_file_read" in tool_names
assert "workspace_file_write" in tool_names
assert "workspace_patch_apply" in tool_names
assert "workspace_disk_export" in tool_names
assert "workspace_disk_list" in tool_names
assert "workspace_disk_read" in tool_names
@ -230,6 +234,23 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
command='sh -lc \'printf "%s\\n" "$API_TOKEN"\'',
secret_env={"API_TOKEN": "API_TOKEN"},
)
listed_files = pyro.list_workspace_files(workspace_id, path="/workspace", recursive=True)
file_read = pyro.read_workspace_file(workspace_id, "note.txt")
file_write = pyro.write_workspace_file(
workspace_id,
"src/app.py",
text="print('hello from file op')\n",
)
patch_result = pyro.apply_workspace_patch(
workspace_id,
patch=(
"--- a/note.txt\n"
"+++ b/note.txt\n"
"@@ -1 +1 @@\n"
"-ok\n"
"+patched\n"
),
)
diff_payload = pyro.diff_workspace(workspace_id)
snapshot = pyro.create_snapshot(workspace_id, "checkpoint")
snapshots = pyro.list_snapshots(workspace_id)
@ -273,13 +294,19 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
{"name": "FILE_TOKEN", "source_kind": "file"},
]
assert executed["stdout"] == "[REDACTED]\n"
assert any(entry["path"] == "/workspace/note.txt" for entry in listed_files["entries"])
assert file_read["content"] == "ok\n"
assert file_write["path"] == "/workspace/src/app.py"
assert file_write["bytes_written"] == len("print('hello from file op')\n".encode("utf-8"))
assert patch_result["changed"] is True
assert patch_result["entries"] == [{"path": "/workspace/note.txt", "status": "modified"}]
assert created["workspace_seed"]["mode"] == "directory"
assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
assert diff_payload["changed"] is True
assert snapshot["snapshot"]["snapshot_name"] == "checkpoint"
assert snapshots["count"] == 2
assert exported["output_path"] == str(export_path)
assert export_path.read_text(encoding="utf-8") == "ok\n"
assert export_path.read_text(encoding="utf-8") == "patched\n"
assert shell_output["output"].count("[REDACTED]") >= 1
assert shell_closed["closed"] is True
assert service["state"] == "running"
@ -540,6 +567,304 @@ def test_pyro_create_server_workspace_disk_tools_delegate() -> None:
]
def test_pyro_workspace_file_methods_delegate_to_manager() -> None:
calls: list[tuple[str, dict[str, Any]]] = []
class StubManager:
def list_workspace_files(
self,
workspace_id: str,
*,
path: str = "/workspace",
recursive: bool = False,
) -> dict[str, Any]:
calls.append(
(
"list_workspace_files",
{
"workspace_id": workspace_id,
"path": path,
"recursive": recursive,
},
)
)
return {"workspace_id": workspace_id, "entries": []}
def read_workspace_file(
self,
workspace_id: str,
path: str,
*,
max_bytes: int = 65536,
) -> dict[str, Any]:
calls.append(
(
"read_workspace_file",
{
"workspace_id": workspace_id,
"path": path,
"max_bytes": max_bytes,
},
)
)
return {"workspace_id": workspace_id, "content": "hello\n"}
def write_workspace_file(
self,
workspace_id: str,
path: str,
*,
text: str,
) -> dict[str, Any]:
calls.append(
(
"write_workspace_file",
{
"workspace_id": workspace_id,
"path": path,
"text": text,
},
)
)
return {"workspace_id": workspace_id, "bytes_written": len(text.encode("utf-8"))}
def apply_workspace_patch(
self,
workspace_id: str,
*,
patch: str,
) -> dict[str, Any]:
calls.append(
(
"apply_workspace_patch",
{
"workspace_id": workspace_id,
"patch": patch,
},
)
)
return {"workspace_id": workspace_id, "changed": True}
pyro = Pyro(manager=cast(Any, StubManager()))
listed = pyro.list_workspace_files("workspace-123", path="/workspace/src", recursive=True)
read = pyro.read_workspace_file("workspace-123", "note.txt", max_bytes=4096)
written = pyro.write_workspace_file("workspace-123", "src/app.py", text="print('hi')\n")
patched = pyro.apply_workspace_patch(
"workspace-123",
patch="--- a/note.txt\n+++ b/note.txt\n@@ -1 +1 @@\n-old\n+new\n",
)
assert listed["entries"] == []
assert read["content"] == "hello\n"
assert written["bytes_written"] == len("print('hi')\n".encode("utf-8"))
assert patched["changed"] is True
assert calls == [
(
"list_workspace_files",
{
"workspace_id": "workspace-123",
"path": "/workspace/src",
"recursive": True,
},
),
(
"read_workspace_file",
{
"workspace_id": "workspace-123",
"path": "note.txt",
"max_bytes": 4096,
},
),
(
"write_workspace_file",
{
"workspace_id": "workspace-123",
"path": "src/app.py",
"text": "print('hi')\n",
},
),
(
"apply_workspace_patch",
{
"workspace_id": "workspace-123",
"patch": "--- a/note.txt\n+++ b/note.txt\n@@ -1 +1 @@\n-old\n+new\n",
},
),
]
def test_pyro_create_server_workspace_file_tools_delegate() -> None:
calls: list[tuple[str, dict[str, Any]]] = []
class StubManager:
def list_workspace_files(
self,
workspace_id: str,
*,
path: str = "/workspace",
recursive: bool = False,
) -> dict[str, Any]:
calls.append(
(
"list_workspace_files",
{
"workspace_id": workspace_id,
"path": path,
"recursive": recursive,
},
)
)
return {"workspace_id": workspace_id, "entries": []}
def read_workspace_file(
self,
workspace_id: str,
path: str,
*,
max_bytes: int = 65536,
) -> dict[str, Any]:
calls.append(
(
"read_workspace_file",
{
"workspace_id": workspace_id,
"path": path,
"max_bytes": max_bytes,
},
)
)
return {"workspace_id": workspace_id, "content": "hello\n"}
def write_workspace_file(
self,
workspace_id: str,
path: str,
*,
text: str,
) -> dict[str, Any]:
calls.append(
(
"write_workspace_file",
{
"workspace_id": workspace_id,
"path": path,
"text": text,
},
)
)
return {"workspace_id": workspace_id, "bytes_written": len(text.encode("utf-8"))}
def apply_workspace_patch(
self,
workspace_id: str,
*,
patch: str,
) -> dict[str, Any]:
calls.append(
(
"apply_workspace_patch",
{
"workspace_id": workspace_id,
"patch": patch,
},
)
)
return {"workspace_id": workspace_id, "changed": True}
pyro = Pyro(manager=cast(Any, StubManager()))
def _extract_structured(raw_result: object) -> dict[str, Any]:
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
raise TypeError("unexpected call_tool result shape")
_, structured = raw_result
if not isinstance(structured, dict):
raise TypeError("expected structured dictionary result")
return cast(dict[str, Any], structured)
async def _run() -> tuple[dict[str, Any], ...]:
server = pyro.create_server()
listed = _extract_structured(
await server.call_tool(
"workspace_file_list",
{
"workspace_id": "workspace-123",
"path": "/workspace/src",
"recursive": True,
},
)
)
read = _extract_structured(
await server.call_tool(
"workspace_file_read",
{
"workspace_id": "workspace-123",
"path": "note.txt",
"max_bytes": 4096,
},
)
)
written = _extract_structured(
await server.call_tool(
"workspace_file_write",
{
"workspace_id": "workspace-123",
"path": "src/app.py",
"text": "print('hi')\n",
},
)
)
patched = _extract_structured(
await server.call_tool(
"workspace_patch_apply",
{
"workspace_id": "workspace-123",
"patch": "--- a/note.txt\n+++ b/note.txt\n@@ -1 +1 @@\n-old\n+new\n",
},
)
)
return listed, read, written, patched
listed, read, written, patched = asyncio.run(_run())
assert listed["entries"] == []
assert read["content"] == "hello\n"
assert written["bytes_written"] == len("print('hi')\n".encode("utf-8"))
assert patched["changed"] is True
assert calls == [
(
"list_workspace_files",
{
"workspace_id": "workspace-123",
"path": "/workspace/src",
"recursive": True,
},
),
(
"read_workspace_file",
{
"workspace_id": "workspace-123",
"path": "note.txt",
"max_bytes": 4096,
},
),
(
"write_workspace_file",
{
"workspace_id": "workspace-123",
"path": "src/app.py",
"text": "print('hi')\n",
},
),
(
"apply_workspace_patch",
{
"workspace_id": "workspace-123",
"patch": "--- a/note.txt\n+++ b/note.txt\n@@ -1 +1 @@\n-old\n+new\n",
},
),
]
def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> None:
calls: list[tuple[str, dict[str, Any]]] = []