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

@ -36,6 +36,10 @@ def test_create_server_registers_vm_tools(tmp_path: Path) -> None:
assert "workspace_stop" in tool_names
assert "workspace_diff" 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
@ -247,6 +251,51 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
},
)
)
listed_files = _extract_structured(
await server.call_tool(
"workspace_file_list",
{
"workspace_id": workspace_id,
"path": "/workspace",
"recursive": True,
},
)
)
file_read = _extract_structured(
await server.call_tool(
"workspace_file_read",
{
"workspace_id": workspace_id,
"path": "note.txt",
"max_bytes": 4096,
},
)
)
file_written = _extract_structured(
await server.call_tool(
"workspace_file_write",
{
"workspace_id": workspace_id,
"path": "src/app.py",
"text": "print('hello from file op')\n",
},
)
)
patched = _extract_structured(
await server.call_tool(
"workspace_patch_apply",
{
"workspace_id": workspace_id,
"patch": (
"--- a/note.txt\n"
"+++ b/note.txt\n"
"@@ -1 +1 @@\n"
"-ok\n"
"+patched\n"
),
},
)
)
diffed = _extract_structured(
await server.call_tool("workspace_diff", {"workspace_id": workspace_id})
)
@ -338,6 +387,10 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
created,
synced,
executed,
listed_files,
file_read,
file_written,
patched,
diffed,
snapshot,
snapshots,
@ -357,6 +410,10 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
created,
synced,
executed,
listed_files,
file_read,
file_written,
patched,
diffed,
snapshot,
snapshots,
@ -379,6 +436,10 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
]
assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
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_written["path"] == "/workspace/src/app.py"
assert patched["changed"] is True
assert diffed["changed"] is True
assert snapshot["snapshot"]["snapshot_name"] == "checkpoint"
assert [entry["snapshot_name"] for entry in snapshots["snapshots"]] == [