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:
parent
dbb71a3174
commit
ab02ae46c7
27 changed files with 3068 additions and 17 deletions
|
|
@ -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]]] = []
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue