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

@ -3,6 +3,7 @@
from __future__ import annotations
import base64
import codecs
import fcntl
import io
@ -31,6 +32,7 @@ WORKSPACE_ROOT = PurePosixPath("/workspace")
SHELL_ROOT = Path("/run/pyro-shells")
SERVICE_ROOT = Path("/run/pyro-services")
SECRET_ROOT = Path("/run/pyro-secrets")
WORKSPACE_FILE_MAX_BYTES = 1024 * 1024
SERVICE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
SHELL_SIGNAL_MAP = {
"HUP": signal.SIGHUP,
@ -328,6 +330,153 @@ def _prepare_export_archive(path: str) -> dict[str, Any]:
raise
def _workspace_entry(path_text: str, host_path: Path) -> dict[str, Any]:
try:
stat_result = os.lstat(host_path)
except FileNotFoundError as exc:
raise RuntimeError(f"workspace path does not exist: {path_text}") from exc
if host_path.is_symlink():
return {
"path": path_text,
"artifact_type": "symlink",
"size_bytes": stat_result.st_size,
"link_target": os.readlink(host_path),
}
if host_path.is_dir():
return {
"path": path_text,
"artifact_type": "directory",
"size_bytes": 0,
"link_target": None,
}
if host_path.is_file():
return {
"path": path_text,
"artifact_type": "file",
"size_bytes": stat_result.st_size,
"link_target": None,
}
raise RuntimeError(f"unsupported workspace path type: {path_text}")
def _join_workspace_path(base: str, child_name: str) -> str:
base_path = PurePosixPath(base)
return str(base_path / child_name) if str(base_path) != "/" else f"/{child_name}"
def _list_workspace(path: str, *, recursive: bool) -> dict[str, Any]:
normalized_path, host_path = _normalize_destination(path)
entry = _workspace_entry(str(normalized_path), host_path)
if entry["artifact_type"] != "directory":
return {
"path": str(normalized_path),
"artifact_type": entry["artifact_type"],
"entries": [entry],
}
entries: list[dict[str, Any]] = []
def walk(current_path: str, current_host_path: Path) -> None:
children: list[tuple[dict[str, Any], Path]] = []
with os.scandir(current_host_path) as iterator:
for child in iterator:
child_host_path = Path(child.path)
children.append(
(
_workspace_entry(
_join_workspace_path(current_path, child.name),
child_host_path,
),
child_host_path,
)
)
children.sort(key=lambda item: str(item[0]["path"]))
for child_entry, child_host_path in children:
entries.append(child_entry)
if recursive and child_entry["artifact_type"] == "directory":
walk(str(child_entry["path"]), child_host_path)
walk(str(normalized_path), host_path)
return {
"path": str(normalized_path),
"artifact_type": "directory",
"entries": entries,
}
def _read_workspace_file(path: str, *, max_bytes: int) -> dict[str, Any]:
if max_bytes <= 0:
raise RuntimeError("max_bytes must be positive")
if max_bytes > WORKSPACE_FILE_MAX_BYTES:
raise RuntimeError(
f"max_bytes must be at most {WORKSPACE_FILE_MAX_BYTES} bytes"
)
normalized_path, host_path = _normalize_destination(path)
entry = _workspace_entry(str(normalized_path), host_path)
if entry["artifact_type"] != "file":
raise RuntimeError("workspace file read only supports regular files")
raw_bytes = host_path.read_bytes()
if len(raw_bytes) > max_bytes:
raise RuntimeError(
f"workspace file exceeds the maximum supported size of {max_bytes} bytes"
)
return {
"path": str(normalized_path),
"size_bytes": len(raw_bytes),
"content_b64": base64.b64encode(raw_bytes).decode("ascii"),
}
def _ensure_no_symlink_parents_for_write(root: Path, target_path: Path, path_text: str) -> None:
relative_path = target_path.relative_to(root)
current = root
for part in relative_path.parts[:-1]:
current = current / part
if current.is_symlink():
raise RuntimeError(
f"workspace path would traverse through a symlinked parent: {path_text}"
)
def _write_workspace_file(path: str, *, text: str) -> dict[str, Any]:
raw_bytes = text.encode("utf-8")
if len(raw_bytes) > WORKSPACE_FILE_MAX_BYTES:
raise RuntimeError(
f"text must be at most {WORKSPACE_FILE_MAX_BYTES} bytes when encoded as UTF-8"
)
normalized_path, host_path = _normalize_destination(path)
_ensure_no_symlink_parents_for_write(Path("/workspace"), host_path, str(normalized_path))
if host_path.exists() or host_path.is_symlink():
entry = _workspace_entry(str(normalized_path), host_path)
if entry["artifact_type"] != "file":
raise RuntimeError("workspace file write only supports regular file targets")
host_path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
prefix=".pyro-workspace-write-",
dir=host_path.parent,
delete=False,
) as handle:
temp_path = Path(handle.name)
handle.write(raw_bytes)
os.replace(temp_path, host_path)
return {
"path": str(normalized_path),
"size_bytes": len(raw_bytes),
"bytes_written": len(raw_bytes),
}
def _delete_workspace_path(path: str) -> dict[str, Any]:
normalized_path, host_path = _normalize_destination(path)
entry = _workspace_entry(str(normalized_path), host_path)
if entry["artifact_type"] == "directory":
raise RuntimeError("workspace file delete does not support directories")
host_path.unlink(missing_ok=False)
return {
"path": str(normalized_path),
"deleted": True,
}
def _run_command(
command: str,
timeout_seconds: int,
@ -931,6 +1080,23 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
raise RuntimeError("archive_size must not be negative")
payload = _read_exact(conn, archive_size)
return _install_secrets_archive(payload)
if action == "list_workspace":
return _list_workspace(
str(request.get("path", "/workspace")),
recursive=bool(request.get("recursive", False)),
)
if action == "read_workspace_file":
return _read_workspace_file(
str(request.get("path", "/workspace")),
max_bytes=int(request.get("max_bytes", WORKSPACE_FILE_MAX_BYTES)),
)
if action == "write_workspace_file":
return _write_workspace_file(
str(request.get("path", "/workspace")),
text=str(request.get("text", "")),
)
if action == "delete_workspace_path":
return _delete_workspace_path(str(request.get("path", "/workspace")))
if action == "open_shell":
shell_id = str(request.get("shell_id", "")).strip()
if shell_id == "":