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

@ -22,6 +22,7 @@ from pyro_mcp.vm_manager import (
DEFAULT_SERVICE_READY_TIMEOUT_SECONDS,
DEFAULT_VCPU_COUNT,
DEFAULT_WORKSPACE_DISK_READ_MAX_BYTES,
DEFAULT_WORKSPACE_FILE_READ_MAX_BYTES,
WORKSPACE_GUEST_PATH,
WORKSPACE_SHELL_SIGNAL_NAMES,
)
@ -322,6 +323,72 @@ def _print_workspace_diff_human(payload: dict[str, Any]) -> None:
print(patch, end="" if patch.endswith("\n") else "\n")
def _print_workspace_file_list_human(payload: dict[str, Any]) -> None:
print(
f"Workspace path: {str(payload.get('path', WORKSPACE_GUEST_PATH))} "
f"(recursive={'yes' if bool(payload.get('recursive')) else 'no'})"
)
entries = payload.get("entries")
if not isinstance(entries, list) or not entries:
print("No workspace entries found.")
return
for entry in entries:
if not isinstance(entry, dict):
continue
line = (
f"{str(entry.get('path', 'unknown'))} "
f"[{str(entry.get('artifact_type', 'unknown'))}] "
f"size={int(entry.get('size_bytes', 0))}"
)
link_target = entry.get("link_target")
if isinstance(link_target, str) and link_target != "":
line += f" -> {link_target}"
print(line)
def _print_workspace_file_read_human(payload: dict[str, Any]) -> None:
_write_stream(str(payload.get("content", "")), stream=sys.stdout)
print(
"[workspace-file-read] "
f"workspace_id={str(payload.get('workspace_id', 'unknown'))} "
f"path={str(payload.get('path', 'unknown'))} "
f"size_bytes={int(payload.get('size_bytes', 0))} "
f"truncated={'yes' if bool(payload.get('truncated', False)) else 'no'}",
file=sys.stderr,
flush=True,
)
def _print_workspace_file_write_human(payload: dict[str, Any]) -> None:
print(
"[workspace-file-write] "
f"workspace_id={str(payload.get('workspace_id', 'unknown'))} "
f"path={str(payload.get('path', 'unknown'))} "
f"bytes_written={int(payload.get('bytes_written', 0))} "
f"execution_mode={str(payload.get('execution_mode', 'unknown'))}"
)
def _print_workspace_patch_human(payload: dict[str, Any]) -> None:
summary = payload.get("summary")
if isinstance(summary, dict):
print(
"[workspace-patch] "
f"workspace_id={str(payload.get('workspace_id', 'unknown'))} "
f"total={int(summary.get('total', 0))} "
f"added={int(summary.get('added', 0))} "
f"modified={int(summary.get('modified', 0))} "
f"deleted={int(summary.get('deleted', 0))} "
f"execution_mode={str(payload.get('execution_mode', 'unknown'))}"
)
return
print(
"[workspace-patch] "
f"workspace_id={str(payload.get('workspace_id', 'unknown'))} "
f"execution_mode={str(payload.get('execution_mode', 'unknown'))}"
)
def _print_workspace_logs_human(payload: dict[str, Any]) -> None:
entries = payload.get("entries")
if not isinstance(entries, list) or not entries:
@ -733,6 +800,8 @@ def _build_parser() -> argparse.ArgumentParser:
Examples:
pyro workspace create debian:12 --seed-path ./repo
pyro workspace sync push WORKSPACE_ID ./repo --dest src
pyro workspace file read WORKSPACE_ID src/app.py
pyro workspace patch apply WORKSPACE_ID --patch "$(cat fix.patch)"
pyro workspace exec WORKSPACE_ID -- sh -lc 'printf "hello\\n" > note.txt'
pyro workspace stop WORKSPACE_ID
pyro workspace disk list WORKSPACE_ID
@ -996,6 +1065,145 @@ def _build_parser() -> argparse.ArgumentParser:
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_file_parser = workspace_subparsers.add_parser(
"file",
help="List, read, and write workspace files without shell quoting.",
description=(
"Use workspace file operations for model-native tree inspection and text edits "
"inside one started workspace."
),
epilog=dedent(
"""
Examples:
pyro workspace file list WORKSPACE_ID
pyro workspace file read WORKSPACE_ID src/app.py
pyro workspace file write WORKSPACE_ID src/app.py --text 'print("hi")'
"""
),
formatter_class=_HelpFormatter,
)
workspace_file_subparsers = workspace_file_parser.add_subparsers(
dest="workspace_file_command",
required=True,
metavar="FILE",
)
workspace_file_list_parser = workspace_file_subparsers.add_parser(
"list",
help="List metadata for one live workspace path.",
description="List files, directories, and symlinks under one started workspace path.",
epilog="Example:\n pyro workspace file list WORKSPACE_ID src --recursive",
formatter_class=_HelpFormatter,
)
workspace_file_list_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
workspace_file_list_parser.add_argument(
"path",
nargs="?",
default=WORKSPACE_GUEST_PATH,
metavar="PATH",
help="Workspace path to inspect. Relative values resolve inside `/workspace`.",
)
workspace_file_list_parser.add_argument(
"--recursive",
action="store_true",
help="Walk directories recursively.",
)
workspace_file_list_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_file_read_parser = workspace_file_subparsers.add_parser(
"read",
help="Read one regular text file from a started workspace.",
description=(
"Read one regular text file under `/workspace`. This is bounded and does not "
"follow symlinks."
),
epilog="Example:\n pyro workspace file read WORKSPACE_ID src/app.py",
formatter_class=_HelpFormatter,
)
workspace_file_read_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
workspace_file_read_parser.add_argument("path", metavar="PATH")
workspace_file_read_parser.add_argument(
"--max-bytes",
type=int,
default=DEFAULT_WORKSPACE_FILE_READ_MAX_BYTES,
help="Maximum number of bytes to return in the decoded text response.",
)
workspace_file_read_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_file_write_parser = workspace_file_subparsers.add_parser(
"write",
help="Create or replace one regular text file in a started workspace.",
description=(
"Write one UTF-8 text file under `/workspace`. Missing parent directories are "
"created automatically."
),
epilog=(
"Example:\n"
" pyro workspace file write WORKSPACE_ID src/app.py --text 'print(\"hi\")'"
),
formatter_class=_HelpFormatter,
)
workspace_file_write_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
workspace_file_write_parser.add_argument("path", metavar="PATH")
workspace_file_write_parser.add_argument(
"--text",
required=True,
help="UTF-8 text content to write into the target file.",
)
workspace_file_write_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_patch_parser = workspace_subparsers.add_parser(
"patch",
help="Apply unified text patches inside a started workspace.",
description=(
"Apply add/modify/delete unified text patches under `/workspace` without shell "
"editing tricks."
),
epilog=dedent(
"""
Example:
pyro workspace patch apply WORKSPACE_ID --patch "$(cat fix.patch)"
Patch application is preflighted but not fully transactional. If an apply fails
partway through, prefer `pyro workspace reset WORKSPACE_ID`.
"""
),
formatter_class=_HelpFormatter,
)
workspace_patch_subparsers = workspace_patch_parser.add_subparsers(
dest="workspace_patch_command",
required=True,
metavar="PATCH",
)
workspace_patch_apply_parser = workspace_patch_subparsers.add_parser(
"apply",
help="Apply one unified text patch to a started workspace.",
description=(
"Apply one unified text patch for add, modify, and delete operations under "
"`/workspace`."
),
epilog="Example:\n pyro workspace patch apply WORKSPACE_ID --patch \"$(cat fix.patch)\"",
formatter_class=_HelpFormatter,
)
workspace_patch_apply_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
workspace_patch_apply_parser.add_argument(
"--patch",
required=True,
help="Unified text patch to apply under `/workspace`.",
)
workspace_patch_apply_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_snapshot_parser = workspace_subparsers.add_parser(
"snapshot",
help="Create, list, and delete workspace snapshots.",
@ -2005,6 +2213,78 @@ def main() -> None:
raise SystemExit(1) from exc
_print_workspace_diff_human(payload)
return
if args.workspace_command == "file":
if args.workspace_file_command == "list":
try:
payload = pyro.list_workspace_files(
args.workspace_id,
path=args.path,
recursive=bool(args.recursive),
)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
_print_workspace_file_list_human(payload)
return
if args.workspace_file_command == "read":
try:
payload = pyro.read_workspace_file(
args.workspace_id,
args.path,
max_bytes=args.max_bytes,
)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
_print_workspace_file_read_human(payload)
return
if args.workspace_file_command == "write":
try:
payload = pyro.write_workspace_file(
args.workspace_id,
args.path,
text=args.text,
)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
_print_workspace_file_write_human(payload)
return
if args.workspace_command == "patch" and args.workspace_patch_command == "apply":
try:
payload = pyro.apply_workspace_patch(
args.workspace_id,
patch=args.patch,
)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
_print_workspace_patch_human(payload)
return
if args.workspace_command == "snapshot":
if args.workspace_snapshot_command == "create":
try: