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
|
|
@ -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 == "":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue