Add workspace export and baseline diff

Complete the 2.6.0 workspace milestone by adding explicit host-out export and immutable-baseline diff across the CLI, Python SDK, and MCP server.

Capture a baseline archive at workspace creation, export live /workspace paths through the guest agent, and compute structured whole-workspace diffs on the host without affecting command logs or shell state. The docs, roadmap, bundled guest agent, and workspace example now reflect the new create -> sync -> diff -> export workflow.

Validation: uv lock, UV_CACHE_DIR=.uv-cache make check, UV_CACHE_DIR=.uv-cache make dist-check, and a real guest-backed Firecracker smoke covering workspace create, sync push, diff, export, and delete.
This commit is contained in:
Thales Maciel 2026-03-12 03:15:45 -03:00
parent 3f8293ad24
commit 84a7e18d4d
26 changed files with 1492 additions and 43 deletions

View file

@ -2,6 +2,16 @@
All notable user-visible changes to `pyro-mcp` are documented here.
## 2.6.0
- Added explicit host-out workspace operations across the CLI, Python SDK, and MCP server with
`pyro workspace export`, `Pyro.export_workspace()`, `pyro workspace diff`,
`Pyro.diff_workspace()`, and the matching `workspace_export` / `workspace_diff` MCP tools.
- Captured an immutable create-time baseline for every new workspace so later `workspace diff`
compares the live `/workspace` tree against that original seed state.
- Kept export and diff separate from command execution and shell state so workspaces can mutate,
be inspected, and copy results back to the host without affecting command logs or shell sessions.
## 2.5.0
- Added persistent PTY shell sessions across the CLI, Python SDK, and MCP server with

View file

@ -20,7 +20,7 @@ It exposes the same runtime in three public forms:
- First run transcript: [docs/first-run.md](docs/first-run.md)
- Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif)
- PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/)
- What's new in 2.5.0: [CHANGELOG.md#250](CHANGELOG.md#250)
- What's new in 2.6.0: [CHANGELOG.md#260](CHANGELOG.md#260)
- Host requirements: [docs/host-requirements.md](docs/host-requirements.md)
- Integration targets: [docs/integrations.md](docs/integrations.md)
- Public contract: [docs/public-contract.md](docs/public-contract.md)
@ -57,7 +57,7 @@ What success looks like:
```bash
Platform: linux-x86_64
Runtime: PASS
Catalog version: 2.5.0
Catalog version: 2.6.0
...
[pull] phase=install environment=debian:12
[pull] phase=ready environment=debian:12
@ -78,6 +78,8 @@ After the quickstart works:
- prove the full one-shot lifecycle with `uvx --from pyro-mcp pyro demo`
- create a persistent workspace with `uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo`
- update a live workspace from the host with `uvx --from pyro-mcp pyro workspace sync push WORKSPACE_ID ./changes`
- diff the live workspace against its create-time baseline with `uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID`
- export a changed file or directory with `uvx --from pyro-mcp pyro workspace export WORKSPACE_ID note.txt --output ./note.txt`
- open a persistent interactive shell with `uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID`
- move to Python or MCP via [docs/integrations.md](docs/integrations.md)
@ -132,7 +134,7 @@ uvx --from pyro-mcp pyro env list
Expected output:
```bash
Catalog version: 2.5.0
Catalog version: 2.6.0
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
@ -210,6 +212,8 @@ longer-term interaction model.
pyro workspace create debian:12 --seed-path ./repo
pyro workspace sync push WORKSPACE_ID ./changes --dest src
pyro workspace exec WORKSPACE_ID -- cat src/note.txt
pyro workspace diff WORKSPACE_ID
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
pyro workspace shell open WORKSPACE_ID
pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'
pyro workspace shell read WORKSPACE_ID SHELL_ID
@ -222,10 +226,12 @@ Persistent workspaces start in `/workspace` and keep command history until you d
machine consumption, add `--json` and read the returned `workspace_id`. Use `--seed-path` when
you want the workspace to start from a host directory or a local `.tar` / `.tar.gz` / `.tgz`
archive instead of an empty workspace. Use `pyro workspace sync push` when you want to import
later host-side changes into a started workspace. Sync is non-atomic in `2.5.0`; if it fails
partway through, delete and recreate the workspace from its seed. Use `pyro workspace exec` for
one-shot non-interactive commands inside a live workspace, and `pyro workspace shell *` when you
need a persistent PTY session that keeps interactive shell state between calls.
later host-side changes into a started workspace. Sync is non-atomic in `2.6.0`; if it fails
partway through, delete and recreate the workspace from its seed. Use `pyro workspace diff` to
compare the live `/workspace` tree to its immutable create-time baseline, and `pyro workspace export`
to copy one changed file or directory back to the host. Use `pyro workspace exec` for one-shot
non-interactive commands inside a live workspace, and `pyro workspace shell *` when you need a
persistent PTY session that keeps interactive shell state between calls.
## Public Interfaces

View file

@ -22,7 +22,7 @@ Networking: tun=yes ip_forward=yes
```bash
$ uvx --from pyro-mcp pyro env list
Catalog version: 2.5.0
Catalog version: 2.6.0
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
@ -72,6 +72,8 @@ deterministic structured result.
$ uvx --from pyro-mcp pyro demo
$ uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo
$ uvx --from pyro-mcp pyro workspace sync push WORKSPACE_ID ./changes
$ uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID
$ uvx --from pyro-mcp pyro workspace export WORKSPACE_ID note.txt --output ./note.txt
$ uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID
$ uvx --from pyro-mcp pyro mcp serve
```
@ -98,6 +100,15 @@ $ uvx --from pyro-mcp pyro workspace exec WORKSPACE_ID -- cat src/note.txt
hello from synced workspace
[workspace-exec] workspace_id=... sequence=1 cwd=/workspace execution_mode=guest_vsock exit_code=0 duration_ms=...
$ uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID
[workspace-diff] workspace_id=... total=... added=... modified=... deleted=... type_changed=... text_patched=... non_text=...
--- a/src/note.txt
+++ b/src/note.txt
@@ ...
$ uvx --from pyro-mcp pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
[workspace-export] workspace_id=... workspace_path=/workspace/src/note.txt output_path=... artifact_type=file entry_count=... bytes_written=... execution_mode=guest_vsock
$ uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID
[workspace-shell-open] workspace_id=... shell_id=... state=running cwd=/workspace cols=120 rows=30 execution_mode=guest_vsock
@ -112,8 +123,10 @@ $ uvx --from pyro-mcp pyro workspace shell read WORKSPACE_ID SHELL_ID
Use `--seed-path` when the workspace should start from a host directory or a local
`.tar` / `.tar.gz` / `.tgz` archive instead of an empty `/workspace`. Use
`pyro workspace sync push` when you need to import later host-side changes into a started
workspace. Sync is non-atomic in `2.5.0`; if it fails partway through, delete and recreate the
workspace. Use `pyro workspace exec` for one-shot commands and `pyro workspace shell *` when you
workspace. Sync is non-atomic in `2.6.0`; if it fails partway through, delete and recreate the
workspace. Use `pyro workspace diff` to compare the current `/workspace` tree to its immutable
create-time baseline, and `pyro workspace export` to copy one changed file or directory back to
the host. Use `pyro workspace exec` for one-shot commands and `pyro workspace shell *` when you
need a persistent interactive PTY session in that same workspace.
Example output:

View file

@ -83,7 +83,7 @@ uvx --from pyro-mcp pyro env list
Expected output:
```bash
Catalog version: 2.5.0
Catalog version: 2.6.0
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
@ -176,6 +176,8 @@ After the CLI path works, you can move on to:
- persistent workspaces: `pyro workspace create debian:12 --seed-path ./repo`
- live workspace updates: `pyro workspace sync push WORKSPACE_ID ./changes`
- baseline diff: `pyro workspace diff WORKSPACE_ID`
- host export: `pyro workspace export WORKSPACE_ID note.txt --output ./note.txt`
- interactive shells: `pyro workspace shell open WORKSPACE_ID`
- MCP: `pyro mcp serve`
- Python SDK: `from pyro_mcp import Pyro`
@ -189,6 +191,8 @@ Use `pyro workspace ...` when you need repeated commands in one sandbox instead
pyro workspace create debian:12 --seed-path ./repo
pyro workspace sync push WORKSPACE_ID ./changes --dest src
pyro workspace exec WORKSPACE_ID -- cat src/note.txt
pyro workspace diff WORKSPACE_ID
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
pyro workspace shell open WORKSPACE_ID
pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'
pyro workspace shell read WORKSPACE_ID SHELL_ID
@ -201,9 +205,11 @@ Workspace commands default to the persistent `/workspace` directory inside the g
the identifier programmatically, use `--json` and read the `workspace_id` field. Use `--seed-path`
when the workspace should start from a host directory or a local `.tar` / `.tar.gz` / `.tgz`
archive. Use `pyro workspace sync push` for later host-side changes to a started workspace. Sync
is non-atomic in `2.5.0`; if it fails partway through, delete and recreate the workspace from its
seed. Use `pyro workspace exec` for one-shot commands and `pyro workspace shell *` when you need
an interactive PTY that survives across separate calls.
is non-atomic in `2.6.0`; if it fails partway through, delete and recreate the workspace from its
seed. Use `pyro workspace diff` to compare the current workspace tree to its immutable create-time
baseline, and `pyro workspace export` to copy one changed file or directory back to the host. Use
`pyro workspace exec` for one-shot commands and `pyro workspace shell *` when you need an
interactive PTY that survives across separate calls.
## Contributor Clone

View file

@ -31,6 +31,7 @@ Recommended surface:
- `vm_run`
- `workspace_create(seed_path=...)` + `workspace_sync_push` + `workspace_exec` when the agent needs persistent workspace state
- `workspace_diff` + `workspace_export` when the agent needs explicit baseline comparison or host-out file transfer
- `open_shell` / `read_shell` / `write_shell` when the agent needs an interactive PTY inside that workspace
Canonical example:
@ -67,6 +68,7 @@ Recommended default:
- `Pyro.run_in_vm(...)`
- `Pyro.create_workspace(seed_path=...)` + `Pyro.push_workspace_sync(...)` + `Pyro.exec_workspace(...)` when repeated workspace commands are required
- `Pyro.diff_workspace(...)` + `Pyro.export_workspace(...)` when the agent needs baseline comparison or host-out file transfer
- `Pyro.open_shell(...)` + `Pyro.write_shell(...)` + `Pyro.read_shell(...)` when the agent needs an interactive PTY inside the workspace
Lifecycle note:
@ -78,6 +80,9 @@ Lifecycle note:
`/workspace` that starts from host content
- use `push_workspace_sync(...)` when later host-side changes need to be imported into that
running workspace without recreating it
- use `diff_workspace(...)` when the agent needs a structured comparison against the immutable
create-time baseline
- use `export_workspace(...)` when the agent needs one file or directory copied back to the host
- use `open_shell(...)` when the agent needs interactive shell state instead of one-shot execs
Examples:

View file

@ -22,6 +22,8 @@ Top-level commands:
- `pyro workspace create`
- `pyro workspace sync push`
- `pyro workspace exec`
- `pyro workspace export`
- `pyro workspace diff`
- `pyro workspace shell open`
- `pyro workspace shell read`
- `pyro workspace shell write`
@ -54,6 +56,8 @@ Behavioral guarantees:
- `pyro workspace create` auto-starts a persistent workspace.
- `pyro workspace create --seed-path PATH` seeds `/workspace` from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive before the workspace is returned.
- `pyro workspace sync push WORKSPACE_ID SOURCE_PATH [--dest WORKSPACE_PATH]` imports later host-side directory or archive content into a started workspace.
- `pyro workspace export WORKSPACE_ID PATH --output HOST_PATH` exports one file or directory from `/workspace` back to the host.
- `pyro workspace diff WORKSPACE_ID` compares the current `/workspace` tree to the immutable create-time baseline.
- `pyro workspace exec` runs in the persistent `/workspace` for that workspace and does not auto-clean.
- `pyro workspace shell *` manages persistent PTY sessions inside a started workspace.
- `pyro workspace logs` returns persisted command history for that workspace until `pyro workspace delete`.
@ -76,6 +80,8 @@ Supported public entrypoints:
- `Pyro.create_vm(...)`
- `Pyro.create_workspace(...)`
- `Pyro.push_workspace_sync(workspace_id, source_path, *, dest="/workspace")`
- `Pyro.export_workspace(workspace_id, path, *, output_path)`
- `Pyro.diff_workspace(workspace_id)`
- `Pyro.open_shell(workspace_id, *, cwd="/workspace", cols=120, rows=30)`
- `Pyro.read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536)`
- `Pyro.write_shell(workspace_id, shell_id, *, input, append_newline=True)`
@ -104,6 +110,8 @@ Stable public method names:
- `create_vm(...)`
- `create_workspace(...)`
- `push_workspace_sync(workspace_id, source_path, *, dest="/workspace")`
- `export_workspace(workspace_id, path, *, output_path)`
- `diff_workspace(workspace_id)`
- `open_shell(workspace_id, *, cwd="/workspace", cols=120, rows=30)`
- `read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536)`
- `write_shell(workspace_id, shell_id, *, input, append_newline=True)`
@ -130,6 +138,8 @@ Behavioral defaults:
- `allow_host_compat` defaults to `False` on `create_workspace(...)`.
- `Pyro.create_workspace(..., seed_path=...)` seeds `/workspace` from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive before the workspace is returned.
- `Pyro.push_workspace_sync(...)` imports later host-side directory or archive content into a started workspace.
- `Pyro.export_workspace(...)` exports one file or directory from `/workspace` to an explicit host path.
- `Pyro.diff_workspace(...)` compares the current `/workspace` tree to the immutable create-time baseline.
- `Pyro.exec_vm(...)` runs one command and auto-cleans that VM after the exec completes.
- `Pyro.exec_workspace(...)` runs one command in the persistent workspace and leaves it alive.
- `Pyro.open_shell(...)` opens a persistent PTY shell attached to one started workspace.
@ -159,6 +169,8 @@ Persistent workspace tools:
- `workspace_create`
- `workspace_sync_push`
- `workspace_exec`
- `workspace_export`
- `workspace_diff`
- `shell_open`
- `shell_read`
- `shell_write`
@ -176,6 +188,8 @@ Behavioral defaults:
- `workspace_create` exposes `allow_host_compat`, which defaults to `false`.
- `workspace_create` accepts optional `seed_path` and seeds `/workspace` from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive before the workspace is returned.
- `workspace_sync_push` imports later host-side directory or archive content into a started workspace, with an optional `dest` under `/workspace`.
- `workspace_export` exports one file or directory from `/workspace` to an explicit host path.
- `workspace_diff` compares the current `/workspace` tree to the immutable create-time baseline.
- `vm_exec` runs one command and auto-cleans that VM after the exec completes.
- `workspace_exec` runs one command in a persistent `/workspace` and leaves the workspace alive.
- `shell_open`, `shell_read`, `shell_write`, `shell_signal`, and `shell_close` manage persistent PTY shells inside a started workspace.

View file

@ -2,12 +2,13 @@
This roadmap turns the agent-workspace vision into release-sized milestones.
Current baseline is `2.5.0`:
Current baseline is `2.6.0`:
- workspace persistence exists and the public surface is now workspace-first
- host crossing currently covers create-time seeding and later sync push
- host crossing currently covers create-time seeding, later sync push, and explicit export
- persistent PTY shell sessions exist alongside one-shot `workspace exec`
- no export, diff, service, snapshot, reset, or secrets contract exists yet
- immutable create-time baselines now power whole-workspace diff
- no service, snapshot, reset, or secrets contract exists yet
Locked roadmap decisions:
@ -28,7 +29,7 @@ also expected to update:
1. [`2.4.0` Workspace Contract Pivot](task-workspace-ga/2.4.0-workspace-contract-pivot.md) - Done
2. [`2.5.0` PTY Shell Sessions](task-workspace-ga/2.5.0-pty-shell-sessions.md) - Done
3. [`2.6.0` Structured Export And Baseline Diff](task-workspace-ga/2.6.0-structured-export-and-baseline-diff.md)
3. [`2.6.0` Structured Export And Baseline Diff](task-workspace-ga/2.6.0-structured-export-and-baseline-diff.md) - Done
4. [`2.7.0` Service Lifecycle And Typed Readiness](task-workspace-ga/2.7.0-service-lifecycle-and-typed-readiness.md)
5. [`2.8.0` Named Snapshots And Reset](task-workspace-ga/2.8.0-named-snapshots-and-reset.md)
6. [`2.9.0` Secrets](task-workspace-ga/2.9.0-secrets.md)

View file

@ -1,5 +1,7 @@
# `2.6.0` Structured Export And Baseline Diff
Status: Done
## Goal
Complete the next explicit host-crossing step by letting a workspace export

View file

@ -11,6 +11,7 @@ def main() -> None:
with (
tempfile.TemporaryDirectory(prefix="pyro-workspace-seed-") as seed_dir,
tempfile.TemporaryDirectory(prefix="pyro-workspace-sync-") as sync_dir,
tempfile.TemporaryDirectory(prefix="pyro-workspace-export-") as export_dir,
):
Path(seed_dir, "note.txt").write_text("hello from seed\n", encoding="utf-8")
Path(sync_dir, "note.txt").write_text("hello from sync\n", encoding="utf-8")
@ -20,6 +21,11 @@ def main() -> None:
pyro.push_workspace_sync(workspace_id, sync_dir)
result = pyro.exec_workspace(workspace_id, command="cat note.txt")
print(result["stdout"], end="")
diff_result = pyro.diff_workspace(workspace_id)
print(f"changed={diff_result['changed']} total={diff_result['summary']['total']}")
exported_path = Path(export_dir, "note.txt")
pyro.export_workspace(workspace_id, "note.txt", output_path=exported_path)
print(exported_path.read_text(encoding="utf-8"), end="")
logs = pyro.logs_workspace(workspace_id)
print(f"workspace_id={workspace_id} command_count={logs['count']}")
finally:

View file

@ -1,6 +1,6 @@
[project]
name = "pyro-mcp"
version = "2.5.0"
version = "2.6.0"
description = "Ephemeral Firecracker sandboxes with curated environments, persistent workspaces, and MCP tools."
readme = "README.md"
license = { file = "LICENSE" }

View file

@ -14,6 +14,7 @@ import socket
import struct
import subprocess
import tarfile
import tempfile
import termios
import threading
import time
@ -180,6 +181,54 @@ def _extract_archive(payload: bytes, destination: str) -> dict[str, Any]:
}
def _inspect_archive(archive_path: Path) -> tuple[int, int]:
entry_count = 0
bytes_written = 0
with tarfile.open(archive_path, "r:*") as archive:
for member in archive.getmembers():
entry_count += 1
if member.isfile():
bytes_written += member.size
return entry_count, bytes_written
def _prepare_export_archive(path: str) -> dict[str, Any]:
normalized_path, source_path = _normalize_destination(path)
if not source_path.exists() and not source_path.is_symlink():
raise RuntimeError(f"workspace path does not exist: {normalized_path}")
if source_path.is_symlink():
artifact_type = "symlink"
elif source_path.is_file():
artifact_type = "file"
elif source_path.is_dir():
artifact_type = "directory"
else:
raise RuntimeError(f"unsupported workspace path type: {normalized_path}")
with tempfile.NamedTemporaryFile(prefix="pyro-export-", suffix=".tar", delete=False) as handle:
archive_path = Path(handle.name)
try:
with tarfile.open(archive_path, "w") as archive:
archive.dereference = False
if artifact_type == "directory":
for child in sorted(source_path.iterdir(), key=lambda item: item.name):
archive.add(child, arcname=child.name, recursive=True)
else:
archive.add(source_path, arcname=source_path.name, recursive=False)
entry_count, bytes_written = _inspect_archive(archive_path)
return {
"workspace_path": str(normalized_path),
"artifact_type": artifact_type,
"archive_path": archive_path,
"archive_size": archive_path.stat().st_size,
"entry_count": entry_count,
"bytes_written": bytes_written,
}
except Exception:
archive_path.unlink(missing_ok=True)
raise
def _run_command(command: str, timeout_seconds: int) -> dict[str, Any]:
started = time.monotonic()
try:
@ -533,6 +582,26 @@ def main() -> None:
with conn:
try:
request = _read_request(conn)
if str(request.get("action", "")) == "export_archive":
export = _prepare_export_archive(str(request.get("path", "/workspace")))
try:
header = {
"workspace_path": export["workspace_path"],
"artifact_type": export["artifact_type"],
"archive_size": export["archive_size"],
"entry_count": export["entry_count"],
"bytes_written": export["bytes_written"],
}
conn.sendall((json.dumps(header) + "\n").encode("utf-8"))
with Path(str(export["archive_path"])).open("rb") as handle:
while True:
chunk = handle.read(BUFFER_SIZE)
if chunk == b"":
break
conn.sendall(chunk)
finally:
Path(str(export["archive_path"])).unlink(missing_ok=True)
continue
response = _dispatch(request, conn)
except Exception as exc: # noqa: BLE001
response = {"error": str(exc)}

View file

@ -130,6 +130,22 @@ class Pyro:
def logs_workspace(self, workspace_id: str) -> dict[str, Any]:
return self._manager.logs_workspace(workspace_id)
def export_workspace(
self,
workspace_id: str,
path: str,
*,
output_path: str | Path,
) -> dict[str, Any]:
return self._manager.export_workspace(
workspace_id,
path=path,
output_path=output_path,
)
def diff_workspace(self, workspace_id: str) -> dict[str, Any]:
return self._manager.diff_workspace(workspace_id)
def open_shell(
self,
workspace_id: str,
@ -370,6 +386,20 @@ class Pyro:
"""Return persisted command history for one workspace."""
return self.logs_workspace(workspace_id)
@server.tool()
async def workspace_export(
workspace_id: str,
path: str,
output_path: str,
) -> dict[str, Any]:
"""Export one file or directory from `/workspace` back to the host."""
return self.export_workspace(workspace_id, path, output_path=output_path)
@server.tool()
async def workspace_diff(workspace_id: str) -> dict[str, Any]:
"""Compare `/workspace` to the immutable create-time baseline."""
return self.diff_workspace(workspace_id)
@server.tool()
async def shell_open(
workspace_id: str,

View file

@ -215,6 +215,41 @@ def _print_workspace_sync_human(payload: dict[str, Any]) -> None:
)
def _print_workspace_export_human(payload: dict[str, Any]) -> None:
print(
"[workspace-export] "
f"workspace_id={str(payload.get('workspace_id', 'unknown'))} "
f"workspace_path={str(payload.get('workspace_path', WORKSPACE_GUEST_PATH))} "
f"output_path={str(payload.get('output_path', 'unknown'))} "
f"artifact_type={str(payload.get('artifact_type', 'unknown'))} "
f"entry_count={int(payload.get('entry_count', 0))} "
f"bytes_written={int(payload.get('bytes_written', 0))} "
f"execution_mode={str(payload.get('execution_mode', 'unknown'))}"
)
def _print_workspace_diff_human(payload: dict[str, Any]) -> None:
if not bool(payload.get("changed")):
print("No workspace changes.")
return
summary = payload.get("summary")
if isinstance(summary, dict):
print(
"[workspace-diff] "
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"type_changed={int(summary.get('type_changed', 0))} "
f"text_patched={int(summary.get('text_patched', 0))} "
f"non_text={int(summary.get('non_text', 0))}"
)
patch = str(payload.get("patch", ""))
if patch != "":
print(patch, end="" if patch.endswith("\n") else "\n")
def _print_workspace_logs_human(payload: dict[str, Any]) -> None:
entries = payload.get("entries")
if not isinstance(entries, list) or not entries:
@ -301,6 +336,8 @@ def _build_parser() -> argparse.ArgumentParser:
Need repeated commands in one workspace after that?
pyro workspace create debian:12 --seed-path ./repo
pyro workspace sync push WORKSPACE_ID ./changes
pyro workspace diff WORKSPACE_ID
pyro workspace export WORKSPACE_ID note.txt --output ./note.txt
pyro workspace shell open WORKSPACE_ID
Use `pyro mcp serve` only after the CLI validation path works.
@ -509,6 +546,8 @@ def _build_parser() -> argparse.ArgumentParser:
pyro workspace create debian:12 --seed-path ./repo
pyro workspace sync push WORKSPACE_ID ./repo --dest src
pyro workspace exec WORKSPACE_ID -- sh -lc 'printf "hello\\n" > note.txt'
pyro workspace diff WORKSPACE_ID
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
pyro workspace shell open WORKSPACE_ID
pyro workspace logs WORKSPACE_ID
"""
@ -530,6 +569,7 @@ def _build_parser() -> argparse.ArgumentParser:
pyro workspace create debian:12
pyro workspace create debian:12 --seed-path ./repo
pyro workspace sync push WORKSPACE_ID ./changes
pyro workspace diff WORKSPACE_ID
"""
),
formatter_class=_HelpFormatter,
@ -667,6 +707,57 @@ def _build_parser() -> argparse.ArgumentParser:
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_export_parser = workspace_subparsers.add_parser(
"export",
help="Export one workspace path to the host.",
description="Export one file or directory from `/workspace` to an explicit host path.",
epilog="Example:\n pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt",
formatter_class=_HelpFormatter,
)
workspace_export_parser.add_argument(
"workspace_id",
metavar="WORKSPACE_ID",
help="Persistent workspace identifier.",
)
workspace_export_parser.add_argument(
"path",
metavar="PATH",
help="Workspace path to export. Relative values resolve inside `/workspace`.",
)
workspace_export_parser.add_argument(
"--output",
required=True,
help="Exact host path to create for the exported file or directory.",
)
workspace_export_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_diff_parser = workspace_subparsers.add_parser(
"diff",
help="Diff `/workspace` against the create-time baseline.",
description="Compare the current `/workspace` tree to the immutable workspace baseline.",
epilog=dedent(
"""
Example:
pyro workspace diff WORKSPACE_ID
Use `workspace export` to copy a changed file or directory back to the host.
"""
),
formatter_class=_HelpFormatter,
)
workspace_diff_parser.add_argument(
"workspace_id",
metavar="WORKSPACE_ID",
help="Persistent workspace identifier.",
)
workspace_diff_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_shell_parser = workspace_subparsers.add_parser(
"shell",
help="Open and manage persistent interactive shells.",
@ -1148,6 +1239,46 @@ def main() -> None:
raise SystemExit(1) from exc
_print_workspace_sync_human(payload)
return
if args.workspace_command == "export":
if bool(args.json):
try:
payload = pyro.export_workspace(
args.workspace_id,
args.path,
output_path=args.output,
)
except Exception as exc: # noqa: BLE001
_print_json({"ok": False, "error": str(exc)})
raise SystemExit(1) from exc
_print_json(payload)
else:
try:
payload = pyro.export_workspace(
args.workspace_id,
args.path,
output_path=args.output,
)
except Exception as exc: # noqa: BLE001
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
_print_workspace_export_human(payload)
return
if args.workspace_command == "diff":
if bool(args.json):
try:
payload = pyro.diff_workspace(args.workspace_id)
except Exception as exc: # noqa: BLE001
_print_json({"ok": False, "error": str(exc)})
raise SystemExit(1) from exc
_print_json(payload)
else:
try:
payload = pyro.diff_workspace(args.workspace_id)
except Exception as exc: # noqa: BLE001
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
_print_workspace_diff_human(payload)
return
if args.workspace_command == "shell":
if args.workspace_shell_command == "open":
try:

View file

@ -8,7 +8,9 @@ PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune")
PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = (
"create",
"delete",
"diff",
"exec",
"export",
"logs",
"shell",
"status",
@ -25,6 +27,8 @@ PUBLIC_CLI_WORKSPACE_CREATE_FLAGS = (
"--seed-path",
"--json",
)
PUBLIC_CLI_WORKSPACE_DIFF_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS = ("--output", "--json")
PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS = ("--cwd", "--cols", "--rows", "--json")
PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS = ("--cursor", "--max-chars", "--json")
PUBLIC_CLI_WORKSPACE_SHELL_WRITE_FLAGS = ("--input", "--no-newline", "--json")
@ -48,8 +52,10 @@ PUBLIC_SDK_METHODS = (
"create_workspace",
"delete_vm",
"delete_workspace",
"diff_workspace",
"exec_vm",
"exec_workspace",
"export_workspace",
"inspect_environment",
"list_environments",
"logs_workspace",
@ -87,7 +93,9 @@ PUBLIC_MCP_TOOLS = (
"vm_stop",
"workspace_create",
"workspace_delete",
"workspace_diff",
"workspace_exec",
"workspace_export",
"workspace_logs",
"workspace_status",
"workspace_sync_push",

View file

@ -14,6 +14,7 @@ import socket
import struct
import subprocess
import tarfile
import tempfile
import termios
import threading
import time
@ -180,6 +181,54 @@ def _extract_archive(payload: bytes, destination: str) -> dict[str, Any]:
}
def _inspect_archive(archive_path: Path) -> tuple[int, int]:
entry_count = 0
bytes_written = 0
with tarfile.open(archive_path, "r:*") as archive:
for member in archive.getmembers():
entry_count += 1
if member.isfile():
bytes_written += member.size
return entry_count, bytes_written
def _prepare_export_archive(path: str) -> dict[str, Any]:
normalized_path, source_path = _normalize_destination(path)
if not source_path.exists() and not source_path.is_symlink():
raise RuntimeError(f"workspace path does not exist: {normalized_path}")
if source_path.is_symlink():
artifact_type = "symlink"
elif source_path.is_file():
artifact_type = "file"
elif source_path.is_dir():
artifact_type = "directory"
else:
raise RuntimeError(f"unsupported workspace path type: {normalized_path}")
with tempfile.NamedTemporaryFile(prefix="pyro-export-", suffix=".tar", delete=False) as handle:
archive_path = Path(handle.name)
try:
with tarfile.open(archive_path, "w") as archive:
archive.dereference = False
if artifact_type == "directory":
for child in sorted(source_path.iterdir(), key=lambda item: item.name):
archive.add(child, arcname=child.name, recursive=True)
else:
archive.add(source_path, arcname=source_path.name, recursive=False)
entry_count, bytes_written = _inspect_archive(archive_path)
return {
"workspace_path": str(normalized_path),
"artifact_type": artifact_type,
"archive_path": archive_path,
"archive_size": archive_path.stat().st_size,
"entry_count": entry_count,
"bytes_written": bytes_written,
}
except Exception:
archive_path.unlink(missing_ok=True)
raise
def _run_command(command: str, timeout_seconds: int) -> dict[str, Any]:
started = time.monotonic()
try:
@ -533,6 +582,26 @@ def main() -> None:
with conn:
try:
request = _read_request(conn)
if str(request.get("action", "")) == "export_archive":
export = _prepare_export_archive(str(request.get("path", "/workspace")))
try:
header = {
"workspace_path": export["workspace_path"],
"artifact_type": export["artifact_type"],
"archive_size": export["archive_size"],
"entry_count": export["entry_count"],
"bytes_written": export["bytes_written"],
}
conn.sendall((json.dumps(header) + "\n").encode("utf-8"))
with Path(str(export["archive_path"])).open("rb") as handle:
while True:
chunk = handle.read(BUFFER_SIZE)
if chunk == b"":
break
conn.sendall(chunk)
finally:
Path(str(export["archive_path"])).unlink(missing_ok=True)
continue
response = _dispatch(request, conn)
except Exception as exc: # noqa: BLE001
response = {"error": str(exc)}

View file

@ -25,7 +25,7 @@
"guest": {
"agent": {
"path": "guest/pyro_guest_agent.py",
"sha256": "07adf6269551447dbea8c236f91499ea1479212a3f084c5402a656f5f5cc5892"
"sha256": "4118589ccd8f4ac8200d9cedf25d13ff515d77c28094bbbdb208310247688b40"
}
},
"platform": "linux-x86_64",

View file

@ -19,7 +19,7 @@ from typing import Any
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
DEFAULT_CATALOG_VERSION = "2.5.0"
DEFAULT_CATALOG_VERSION = "2.6.0"
OCI_MANIFEST_ACCEPT = ", ".join(
(
"application/vnd.oci.image.index.v1+json",

View file

@ -39,6 +39,14 @@ class GuestArchiveResponse:
bytes_written: int
@dataclass(frozen=True)
class GuestArchiveExportResponse:
workspace_path: str
artifact_type: str
entry_count: int
bytes_written: int
@dataclass(frozen=True)
class GuestShellSummary:
shell_id: str
@ -128,6 +136,52 @@ class VsockExecClient:
bytes_written=int(payload.get("bytes_written", 0)),
)
def export_archive(
self,
guest_cid: int,
port: int,
*,
workspace_path: str,
archive_path: Path,
timeout_seconds: int = 60,
uds_path: str | None = None,
) -> GuestArchiveExportResponse:
request = {
"action": "export_archive",
"path": workspace_path,
}
sock = self._connect(guest_cid, port, timeout_seconds, uds_path=uds_path)
try:
sock.sendall((json.dumps(request) + "\n").encode("utf-8"))
header = self._recv_line(sock)
if header.strip() == "":
raise RuntimeError("guest export response header is empty")
payload = json.loads(header)
if not isinstance(payload, dict):
raise RuntimeError("guest export response header must be a JSON object")
error = payload.get("error")
if error is not None:
raise RuntimeError(str(error))
archive_size = int(payload.get("archive_size", 0))
if archive_size < 0:
raise RuntimeError("guest export archive_size must not be negative")
with archive_path.open("wb") as handle:
remaining = archive_size
while remaining > 0:
chunk = sock.recv(min(65536, remaining))
if chunk == b"":
raise RuntimeError("unexpected EOF while receiving export archive")
handle.write(chunk)
remaining -= len(chunk)
finally:
sock.close()
return GuestArchiveExportResponse(
workspace_path=str(payload.get("workspace_path", workspace_path)),
artifact_type=str(payload.get("artifact_type", "file")),
entry_count=int(payload.get("entry_count", 0)),
bytes_written=int(payload.get("bytes_written", 0)),
)
def open_shell(
self,
guest_cid: int,

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import difflib
import json
import os
import shlex
@ -43,7 +44,9 @@ DEFAULT_TIMEOUT_SECONDS = 30
DEFAULT_TTL_SECONDS = 600
DEFAULT_ALLOW_HOST_COMPAT = False
WORKSPACE_LAYOUT_VERSION = 3
WORKSPACE_LAYOUT_VERSION = 4
WORKSPACE_BASELINE_DIRNAME = "baseline"
WORKSPACE_BASELINE_ARCHIVE_NAME = "workspace.tar"
WORKSPACE_DIRNAME = "workspace"
WORKSPACE_COMMANDS_DIRNAME = "commands"
WORKSPACE_SHELLS_DIRNAME = "shells"
@ -57,6 +60,7 @@ DEFAULT_SHELL_MAX_CHARS = 65536
WORKSPACE_SHELL_SIGNAL_NAMES = shell_signal_names()
WorkspaceSeedMode = Literal["empty", "directory", "tar_archive"]
WorkspaceArtifactType = Literal["file", "directory", "symlink"]
@dataclass
@ -287,6 +291,24 @@ class VmExecResult:
duration_ms: int
@dataclass(frozen=True)
class ExportedWorkspaceArchive:
workspace_path: str
artifact_type: WorkspaceArtifactType
archive_path: Path
entry_count: int
bytes_written: int
@dataclass(frozen=True)
class WorkspaceTreeEntry:
path: str
artifact_type: WorkspaceArtifactType
disk_path: Path
size_bytes: int = 0
link_target: str | None = None
def _optional_int(value: object) -> int | None:
if value is None:
return None
@ -522,6 +544,66 @@ def _write_directory_seed_archive(source_dir: Path, archive_path: Path) -> None:
archive.add(child, arcname=child.name, recursive=True)
def _write_empty_seed_archive(archive_path: Path) -> None:
archive_path.parent.mkdir(parents=True, exist_ok=True)
with tarfile.open(archive_path, "w"):
pass
def _persist_workspace_baseline(
prepared_seed: PreparedWorkspaceSeed,
*,
baseline_archive_path: Path,
) -> None:
baseline_archive_path.parent.mkdir(parents=True, exist_ok=True)
if prepared_seed.archive_path is None:
_write_empty_seed_archive(baseline_archive_path)
return
shutil.copy2(prepared_seed.archive_path, baseline_archive_path)
def _write_workspace_export_archive(
source_path: Path,
*,
archive_path: Path,
) -> WorkspaceArtifactType:
archive_path.parent.mkdir(parents=True, exist_ok=True)
if source_path.is_symlink():
artifact_type: WorkspaceArtifactType = "symlink"
elif source_path.is_file():
artifact_type = "file"
elif source_path.is_dir():
artifact_type = "directory"
else:
raise RuntimeError(f"unsupported workspace path type: {source_path}")
def validate_source(current_path: Path, relative_path: PurePosixPath) -> None:
if current_path.is_symlink():
_validate_archive_symlink_target(relative_path, os.readlink(current_path))
return
if current_path.is_file():
return
if current_path.is_dir():
for child in sorted(current_path.iterdir(), key=lambda item: item.name):
validate_source(child, relative_path / child.name)
return
raise RuntimeError(f"unsupported workspace path type: {current_path}")
if artifact_type == "directory":
for child in sorted(source_path.iterdir(), key=lambda item: item.name):
validate_source(child, PurePosixPath(child.name))
else:
validate_source(source_path, PurePosixPath(source_path.name))
with tarfile.open(archive_path, "w") as archive:
archive.dereference = False
if artifact_type == "directory":
for child in sorted(source_path.iterdir(), key=lambda item: item.name):
archive.add(child, arcname=child.name, recursive=True)
else:
archive.add(source_path, arcname=source_path.name, recursive=False)
return artifact_type
def _extract_seed_archive_to_host_workspace(
archive_path: Path,
*,
@ -576,6 +658,120 @@ def _extract_seed_archive_to_host_workspace(
}
def _prepare_workspace_export_archive(
*,
workspace_dir: Path,
workspace_path: str,
archive_path: Path,
) -> ExportedWorkspaceArchive:
normalized_workspace_path, _ = _normalize_workspace_destination(workspace_path)
source_path = _workspace_host_destination(workspace_dir, normalized_workspace_path)
if not source_path.exists() and not source_path.is_symlink():
raise RuntimeError(f"workspace path does not exist: {normalized_workspace_path}")
artifact_type = _write_workspace_export_archive(source_path, archive_path=archive_path)
entry_count, bytes_written = _inspect_seed_archive(archive_path)
return ExportedWorkspaceArchive(
workspace_path=normalized_workspace_path,
artifact_type=artifact_type,
archive_path=archive_path,
entry_count=entry_count,
bytes_written=bytes_written,
)
def _extract_workspace_export_archive(
archive_path: Path,
*,
output_path: Path,
artifact_type: WorkspaceArtifactType,
) -> dict[str, Any]:
output_path.parent.mkdir(parents=True, exist_ok=True)
if output_path.exists() or output_path.is_symlink():
raise RuntimeError(f"output_path already exists: {output_path}")
entry_count = 0
bytes_written = 0
if artifact_type == "directory":
output_path.mkdir(parents=True, exist_ok=False)
with tarfile.open(archive_path, "r:*") as archive:
for member in archive.getmembers():
member_name = _normalize_archive_member_name(member.name)
target_path = output_path.joinpath(*member_name.parts)
entry_count += 1
_ensure_no_symlink_parents(output_path, target_path, member.name)
if member.isdir():
if target_path.is_symlink() or (
target_path.exists() and not target_path.is_dir()
):
raise RuntimeError(f"directory conflicts with existing path: {member.name}")
target_path.mkdir(parents=True, exist_ok=True)
continue
if member.isfile():
target_path.parent.mkdir(parents=True, exist_ok=True)
if target_path.is_symlink() or target_path.is_dir():
raise RuntimeError(f"file conflicts with existing path: {member.name}")
source = archive.extractfile(member)
if source is None:
raise RuntimeError(f"failed to read archive member: {member.name}")
with target_path.open("wb") as handle:
shutil.copyfileobj(source, handle)
bytes_written += member.size
continue
if member.issym():
_validate_archive_symlink_target(member_name, member.linkname)
target_path.parent.mkdir(parents=True, exist_ok=True)
if target_path.exists() and not target_path.is_symlink():
raise RuntimeError(f"symlink conflicts with existing path: {member.name}")
if target_path.is_symlink():
target_path.unlink()
os.symlink(member.linkname, target_path)
continue
if member.islnk():
raise RuntimeError(
f"hard links are not allowed in workspace archives: {member.name}"
)
raise RuntimeError(f"unsupported archive member type: {member.name}")
return {
"output_path": str(output_path),
"artifact_type": artifact_type,
"entry_count": entry_count,
"bytes_written": bytes_written,
}
with tarfile.open(archive_path, "r:*") as archive:
members = archive.getmembers()
if len(members) != 1:
raise RuntimeError(
"expected exactly one archive member for "
f"{artifact_type} export, got {len(members)}"
)
member = members[0]
_normalize_archive_member_name(member.name)
entry_count = 1
if artifact_type == "file":
if not member.isfile():
raise RuntimeError("exported archive did not contain a regular file")
source = archive.extractfile(member)
if source is None:
raise RuntimeError(f"failed to read archive member: {member.name}")
with output_path.open("wb") as handle:
shutil.copyfileobj(source, handle)
bytes_written = member.size
elif artifact_type == "symlink":
if not member.issym():
raise RuntimeError("exported archive did not contain a symlink")
_validate_archive_symlink_target(PurePosixPath(member.name), member.linkname)
os.symlink(member.linkname, output_path)
else:
raise RuntimeError(f"unsupported artifact type: {artifact_type}")
return {
"output_path": str(output_path),
"artifact_type": artifact_type,
"entry_count": entry_count,
"bytes_written": bytes_written,
}
def _instance_workspace_host_dir(instance: VmInstance) -> Path:
raw_value = instance.metadata.get("workspace_host_dir")
if raw_value is None or raw_value == "":
@ -640,6 +836,205 @@ def _pid_is_running(pid: int | None) -> bool:
return True
def _collect_workspace_tree(root: Path) -> dict[str, WorkspaceTreeEntry]:
entries: dict[str, WorkspaceTreeEntry] = {}
def walk(current: Path, relative_parts: tuple[str, ...] = ()) -> bool:
has_entries = False
for child in sorted(current.iterdir(), key=lambda item: item.name):
child_relative_parts = relative_parts + (child.name,)
relative_path = "/".join(child_relative_parts)
if child.is_symlink():
entries[relative_path] = WorkspaceTreeEntry(
path=relative_path,
artifact_type="symlink",
disk_path=child,
link_target=os.readlink(child),
)
has_entries = True
continue
if child.is_file():
entries[relative_path] = WorkspaceTreeEntry(
path=relative_path,
artifact_type="file",
disk_path=child,
size_bytes=child.stat().st_size,
)
has_entries = True
continue
if child.is_dir():
child_has_entries = walk(child, child_relative_parts)
if not child_has_entries:
entries[relative_path] = WorkspaceTreeEntry(
path=relative_path,
artifact_type="directory",
disk_path=child,
)
has_entries = True
else:
has_entries = True
continue
raise RuntimeError(f"unsupported workspace artifact type: {child}")
return has_entries
walk(root)
return entries
def _is_probably_text(data: bytes) -> bool:
if b"\x00" in data:
return False
try:
data.decode("utf-8")
except UnicodeDecodeError:
return False
return True
def _build_text_patch(
*,
path: str,
before_text: str,
after_text: str,
status: str,
) -> str:
if status == "added":
fromfile = "/dev/null"
tofile = f"b/{path}"
elif status == "deleted":
fromfile = f"a/{path}"
tofile = "/dev/null"
else:
fromfile = f"a/{path}"
tofile = f"b/{path}"
lines = list(
difflib.unified_diff(
before_text.splitlines(keepends=True),
after_text.splitlines(keepends=True),
fromfile=fromfile,
tofile=tofile,
n=3,
)
)
if not lines:
return ""
return "".join(lines)
def _diff_workspace_trees(
baseline_root: Path,
current_root: Path,
) -> dict[str, Any]:
baseline_entries = _collect_workspace_tree(baseline_root)
current_entries = _collect_workspace_tree(current_root)
changed_entries: list[dict[str, Any]] = []
patch_parts: list[str] = []
summary = {
"total": 0,
"added": 0,
"modified": 0,
"deleted": 0,
"type_changed": 0,
"text_patched": 0,
"non_text": 0,
}
for path in sorted(set(baseline_entries) | set(current_entries)):
baseline_entry = baseline_entries.get(path)
current_entry = current_entries.get(path)
entry_payload: dict[str, Any] | None = None
text_patch = ""
if baseline_entry is None and current_entry is not None:
entry_payload = {
"path": path,
"status": "added",
"artifact_type": current_entry.artifact_type,
"text_patch": None,
}
if current_entry.artifact_type == "file":
current_bytes = current_entry.disk_path.read_bytes()
if _is_probably_text(current_bytes):
text_patch = _build_text_patch(
path=path,
before_text="",
after_text=current_bytes.decode("utf-8"),
status="added",
)
elif current_entry is None and baseline_entry is not None:
entry_payload = {
"path": path,
"status": "deleted",
"artifact_type": baseline_entry.artifact_type,
"text_patch": None,
}
if baseline_entry.artifact_type == "file":
baseline_bytes = baseline_entry.disk_path.read_bytes()
if _is_probably_text(baseline_bytes):
text_patch = _build_text_patch(
path=path,
before_text=baseline_bytes.decode("utf-8"),
after_text="",
status="deleted",
)
elif baseline_entry is not None and current_entry is not None:
if baseline_entry.artifact_type != current_entry.artifact_type:
entry_payload = {
"path": path,
"status": "type_changed",
"artifact_type": current_entry.artifact_type,
"text_patch": None,
}
elif current_entry.artifact_type == "directory":
continue
elif current_entry.artifact_type == "symlink":
if baseline_entry.link_target != current_entry.link_target:
entry_payload = {
"path": path,
"status": "modified",
"artifact_type": current_entry.artifact_type,
"text_patch": None,
}
else:
baseline_bytes = baseline_entry.disk_path.read_bytes()
current_bytes = current_entry.disk_path.read_bytes()
if baseline_bytes == current_bytes:
continue
entry_payload = {
"path": path,
"status": "modified",
"artifact_type": current_entry.artifact_type,
"text_patch": None,
}
if _is_probably_text(baseline_bytes) and _is_probably_text(current_bytes):
text_patch = _build_text_patch(
path=path,
before_text=baseline_bytes.decode("utf-8"),
after_text=current_bytes.decode("utf-8"),
status="modified",
)
if entry_payload is None:
continue
summary["total"] += 1
summary[str(entry_payload["status"])] += 1
if text_patch != "":
entry_payload["text_patch"] = text_patch
patch_parts.append(text_patch)
summary["text_patched"] += 1
else:
summary["non_text"] += 1
changed_entries.append(entry_payload)
return {
"changed": bool(changed_entries),
"summary": summary,
"entries": changed_entries,
"patch": "".join(patch_parts),
}
class VmBackend:
"""Backend interface for lifecycle operations."""
@ -674,6 +1069,15 @@ class VmBackend:
) -> dict[str, Any]:
raise NotImplementedError
def export_archive( # pragma: no cover
self,
instance: VmInstance,
*,
workspace_path: str,
archive_path: Path,
) -> dict[str, Any]:
raise NotImplementedError
def open_shell( # pragma: no cover
self,
instance: VmInstance,
@ -768,6 +1172,26 @@ class MockBackend(VmBackend):
destination=destination,
)
def export_archive(
self,
instance: VmInstance,
*,
workspace_path: str,
archive_path: Path,
) -> dict[str, Any]:
exported = _prepare_workspace_export_archive(
workspace_dir=_instance_workspace_host_dir(instance),
workspace_path=workspace_path,
archive_path=archive_path,
)
return {
"workspace_path": exported.workspace_path,
"artifact_type": exported.artifact_type,
"entry_count": exported.entry_count,
"bytes_written": exported.bytes_written,
"execution_mode": "host_compat",
}
def open_shell(
self,
instance: VmInstance,
@ -1086,6 +1510,55 @@ class FirecrackerBackend(VmBackend): # pragma: no cover
destination=destination,
)
def export_archive(
self,
instance: VmInstance,
*,
workspace_path: str,
archive_path: Path,
) -> dict[str, Any]:
if self._runtime_capabilities.supports_guest_exec:
guest_cid = int(instance.metadata["guest_cid"])
port = int(instance.metadata["guest_exec_port"])
uds_path = instance.metadata.get("guest_exec_uds_path")
deadline = time.monotonic() + 10
while True:
try:
response = self._guest_exec_client.export_archive(
guest_cid,
port,
workspace_path=workspace_path,
archive_path=archive_path,
timeout_seconds=WORKSPACE_ARCHIVE_UPLOAD_TIMEOUT_SECONDS,
uds_path=uds_path,
)
return {
"workspace_path": response.workspace_path,
"artifact_type": response.artifact_type,
"entry_count": response.entry_count,
"bytes_written": response.bytes_written,
"execution_mode": instance.metadata.get("execution_mode", "pending"),
}
except (OSError, RuntimeError) as exc:
if time.monotonic() >= deadline:
raise RuntimeError(
f"guest export transport did not become ready: {exc}"
) from exc
time.sleep(0.2)
instance.metadata["execution_mode"] = "host_compat"
exported = _prepare_workspace_export_archive(
workspace_dir=_instance_workspace_host_dir(instance),
workspace_path=workspace_path,
archive_path=archive_path,
)
return {
"workspace_path": exported.workspace_path,
"artifact_type": exported.artifact_type,
"entry_count": exported.entry_count,
"bytes_written": exported.bytes_written,
"execution_mode": "host_compat",
}
def open_shell(
self,
instance: VmInstance,
@ -1488,10 +1961,15 @@ class VmManager:
host_workspace_dir = self._workspace_host_dir(workspace_id)
commands_dir = self._workspace_commands_dir(workspace_id)
shells_dir = self._workspace_shells_dir(workspace_id)
baseline_archive_path = self._workspace_baseline_archive_path(workspace_id)
workspace_dir.mkdir(parents=True, exist_ok=False)
host_workspace_dir.mkdir(parents=True, exist_ok=True)
commands_dir.mkdir(parents=True, exist_ok=True)
shells_dir.mkdir(parents=True, exist_ok=True)
_persist_workspace_baseline(
prepared_seed,
baseline_archive_path=baseline_archive_path,
)
instance = VmInstance(
vm_id=workspace_id,
environment=environment,
@ -1523,23 +2001,14 @@ class VmManager:
self._start_instance_locked(instance)
self._require_guest_exec_or_opt_in(instance)
workspace_seed = prepared_seed.to_payload()
if prepared_seed.archive_path is not None:
import_summary = self._backend.import_archive(
instance,
archive_path=prepared_seed.archive_path,
destination=WORKSPACE_GUEST_PATH,
)
workspace_seed["entry_count"] = int(import_summary["entry_count"])
workspace_seed["bytes_written"] = int(import_summary["bytes_written"])
workspace_seed["destination"] = str(import_summary["destination"])
elif self._runtime_capabilities.supports_guest_exec:
self._backend.exec(
instance,
f"mkdir -p {shlex.quote(WORKSPACE_GUEST_PATH)}",
10,
)
else:
instance.metadata["execution_mode"] = "host_compat"
import_summary = self._backend.import_archive(
instance,
archive_path=baseline_archive_path,
destination=WORKSPACE_GUEST_PATH,
)
workspace_seed["entry_count"] = int(import_summary["entry_count"])
workspace_seed["bytes_written"] = int(import_summary["bytes_written"])
workspace_seed["destination"] = str(import_summary["destination"])
workspace = WorkspaceRecord.from_instance(instance, workspace_seed=workspace_seed)
self._save_workspace_locked(workspace)
return self._serialize_workspace(workspace)
@ -1612,6 +2081,98 @@ class VmManager:
"workspace_sync": workspace_sync,
}
def export_workspace(
self,
workspace_id: str,
*,
path: str,
output_path: str | Path,
) -> dict[str, Any]:
normalized_path, _ = _normalize_workspace_destination(path)
raw_output_path = str(output_path).strip()
if raw_output_path == "":
raise ValueError("output_path must not be empty")
resolved_output_path = Path(output_path).expanduser().resolve()
with self._lock:
workspace = self._load_workspace_locked(workspace_id)
instance = self._workspace_instance_for_live_operation_locked(
workspace,
operation_name="workspace_export",
)
with tempfile.TemporaryDirectory(prefix="pyro-workspace-export-") as temp_dir:
archive_path = Path(temp_dir) / "workspace-export.tar"
exported = self._backend.export_archive(
instance,
workspace_path=normalized_path,
archive_path=archive_path,
)
extracted = _extract_workspace_export_archive(
archive_path,
output_path=resolved_output_path,
artifact_type=cast(WorkspaceArtifactType, str(exported["artifact_type"])),
)
with self._lock:
workspace = self._load_workspace_locked(workspace_id)
workspace.state = instance.state
workspace.firecracker_pid = instance.firecracker_pid
workspace.last_error = instance.last_error
workspace.metadata = dict(instance.metadata)
self._save_workspace_locked(workspace)
return {
"workspace_id": workspace_id,
"workspace_path": normalized_path,
"output_path": str(Path(str(extracted["output_path"]))),
"artifact_type": extracted["artifact_type"],
"entry_count": int(extracted["entry_count"]),
"bytes_written": int(extracted["bytes_written"]),
"execution_mode": str(
exported.get("execution_mode", instance.metadata.get("execution_mode", "pending"))
),
}
def diff_workspace(self, workspace_id: str) -> dict[str, Any]:
with self._lock:
workspace = self._load_workspace_locked(workspace_id)
instance = self._workspace_instance_for_live_operation_locked(
workspace,
operation_name="workspace_diff",
)
baseline_archive_path = self._workspace_baseline_archive_path(workspace_id)
if not baseline_archive_path.exists():
raise RuntimeError(
"workspace diff requires a baseline snapshot. Recreate the workspace to use diff."
)
with tempfile.TemporaryDirectory(prefix="pyro-workspace-diff-") as temp_dir:
temp_root = Path(temp_dir)
current_archive_path = temp_root / "current.tar"
baseline_root = temp_root / "baseline"
current_root = temp_root / "current"
self._backend.export_archive(
instance,
workspace_path=WORKSPACE_GUEST_PATH,
archive_path=current_archive_path,
)
_extract_seed_archive_to_host_workspace(
baseline_archive_path,
workspace_dir=baseline_root,
destination=WORKSPACE_GUEST_PATH,
)
_extract_seed_archive_to_host_workspace(
current_archive_path,
workspace_dir=current_root,
destination=WORKSPACE_GUEST_PATH,
)
diff_payload = _diff_workspace_trees(baseline_root, current_root)
with self._lock:
workspace = self._load_workspace_locked(workspace_id)
workspace.state = instance.state
workspace.firecracker_pid = instance.firecracker_pid
workspace.last_error = instance.last_error
workspace.metadata = dict(instance.metadata)
self._save_workspace_locked(workspace)
diff_payload["workspace_id"] = workspace_id
return diff_payload
def exec_workspace(
self,
workspace_id: str,
@ -2136,6 +2697,12 @@ class VmManager:
def _workspace_host_dir(self, workspace_id: str) -> Path:
return self._workspace_dir(workspace_id) / WORKSPACE_DIRNAME
def _workspace_baseline_dir(self, workspace_id: str) -> Path:
return self._workspace_dir(workspace_id) / WORKSPACE_BASELINE_DIRNAME
def _workspace_baseline_archive_path(self, workspace_id: str) -> Path:
return self._workspace_baseline_dir(workspace_id) / WORKSPACE_BASELINE_ARCHIVE_NAME
def _workspace_commands_dir(self, workspace_id: str) -> Path:
return self._workspace_dir(workspace_id) / WORKSPACE_COMMANDS_DIRNAME
@ -2278,17 +2845,29 @@ class VmManager:
return entries
def _workspace_instance_for_live_shell_locked(self, workspace: WorkspaceRecord) -> VmInstance:
instance = self._workspace_instance_for_live_operation_locked(
workspace,
operation_name="shell operations",
)
self._require_workspace_shell_support(instance)
return instance
def _workspace_instance_for_live_operation_locked(
self,
workspace: WorkspaceRecord,
*,
operation_name: str,
) -> VmInstance:
self._ensure_workspace_not_expired_locked(workspace, time.time())
self._refresh_workspace_liveness_locked(workspace)
if workspace.state != "started":
raise RuntimeError(
"workspace "
f"{workspace.workspace_id} must be in 'started' state before shell operations"
f"{workspace.workspace_id} must be in 'started' state before {operation_name}"
)
instance = workspace.to_instance(
workdir=self._workspace_runtime_dir(workspace.workspace_id)
)
self._require_workspace_shell_support(instance)
return instance
def _workspace_shell_record_from_payload(

View file

@ -50,7 +50,9 @@ def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None:
assert "vm_run" in tool_names
assert "vm_create" in tool_names
assert "workspace_create" in tool_names
assert "workspace_diff" in tool_names
assert "workspace_sync_push" in tool_names
assert "workspace_export" in tool_names
assert "shell_open" in tool_names
assert "shell_read" in tool_names
assert "shell_write" in tool_names
@ -136,6 +138,9 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
(updated_dir / "more.txt").write_text("more\n", encoding="utf-8")
synced = pyro.push_workspace_sync(workspace_id, updated_dir, dest="subdir")
executed = pyro.exec_workspace(workspace_id, command="cat note.txt")
diff_payload = pyro.diff_workspace(workspace_id)
export_path = tmp_path / "exported-note.txt"
exported = pyro.export_workspace(workspace_id, "note.txt", output_path=export_path)
opened = pyro.open_shell(workspace_id)
shell_id = str(opened["shell_id"])
written = pyro.write_shell(workspace_id, shell_id, input="pwd")
@ -154,6 +159,9 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
assert created["workspace_seed"]["mode"] == "directory"
assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
assert written["input_length"] == 3
assert diff_payload["changed"] is True
assert exported["output_path"] == str(export_path)
assert export_path.read_text(encoding="utf-8") == "ok\n"
assert "/workspace" in read["output"]
assert signaled["signal"] == "INT"
assert closed["closed"] is True

View file

@ -64,6 +64,8 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
assert "pyro workspace create debian:12 --seed-path ./repo" in workspace_help
assert "pyro workspace sync push WORKSPACE_ID ./repo --dest src" in workspace_help
assert "pyro workspace exec WORKSPACE_ID" in workspace_help
assert "pyro workspace diff WORKSPACE_ID" in workspace_help
assert "pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt" in workspace_help
assert "pyro workspace shell open WORKSPACE_ID" in workspace_help
workspace_create_help = _subparser_choice(
@ -93,6 +95,18 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
assert "--dest" in workspace_sync_push_help
assert "Import host content into `/workspace`" in workspace_sync_push_help
workspace_export_help = _subparser_choice(
_subparser_choice(parser, "workspace"), "export"
).format_help()
assert "--output" in workspace_export_help
assert "Export one file or directory from `/workspace`" in workspace_export_help
workspace_diff_help = _subparser_choice(
_subparser_choice(parser, "workspace"), "diff"
).format_help()
assert "immutable workspace baseline" in workspace_diff_help
assert "workspace export" in workspace_diff_help
workspace_shell_help = _subparser_choice(
_subparser_choice(parser, "workspace"),
"shell",
@ -522,6 +536,100 @@ def test_cli_workspace_exec_prints_human_output(
)
def test_cli_workspace_export_prints_human_output(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def export_workspace(
self,
workspace_id: str,
path: str,
*,
output_path: str,
) -> dict[str, Any]:
assert workspace_id == "workspace-123"
assert path == "note.txt"
assert output_path == "./note.txt"
return {
"workspace_id": workspace_id,
"workspace_path": "/workspace/note.txt",
"output_path": "/tmp/note.txt",
"artifact_type": "file",
"entry_count": 1,
"bytes_written": 6,
"execution_mode": "guest_vsock",
}
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="workspace",
workspace_command="export",
workspace_id="workspace-123",
path="note.txt",
output="./note.txt",
json=False,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
output = capsys.readouterr().out
assert "[workspace-export] workspace_id=workspace-123" in output
assert "artifact_type=file" in output
def test_cli_workspace_diff_prints_human_output(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def diff_workspace(self, workspace_id: str) -> dict[str, Any]:
assert workspace_id == "workspace-123"
return {
"workspace_id": workspace_id,
"changed": True,
"summary": {
"total": 1,
"added": 0,
"modified": 1,
"deleted": 0,
"type_changed": 0,
"text_patched": 1,
"non_text": 0,
},
"entries": [
{
"path": "note.txt",
"status": "modified",
"artifact_type": "file",
"text_patch": "--- a/note.txt\n+++ b/note.txt\n",
}
],
"patch": "--- a/note.txt\n+++ b/note.txt\n",
}
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="workspace",
workspace_command="diff",
workspace_id="workspace-123",
json=False,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
output = capsys.readouterr().out
assert (
"[workspace-diff] workspace_id=workspace-123 total=1 added=0 modified=1"
in output
)
assert "--- a/note.txt" in output
def test_cli_workspace_sync_push_prints_json(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:

View file

@ -18,6 +18,8 @@ from pyro_mcp.contract import (
PUBLIC_CLI_ENV_SUBCOMMANDS,
PUBLIC_CLI_RUN_FLAGS,
PUBLIC_CLI_WORKSPACE_CREATE_FLAGS,
PUBLIC_CLI_WORKSPACE_DIFF_FLAGS,
PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS,
PUBLIC_CLI_WORKSPACE_SHELL_CLOSE_FLAGS,
PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS,
PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS,
@ -92,6 +94,16 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
).format_help()
for flag in PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS:
assert flag in workspace_sync_push_help_text
workspace_export_help_text = _subparser_choice(
_subparser_choice(parser, "workspace"), "export"
).format_help()
for flag in PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS:
assert flag in workspace_export_help_text
workspace_diff_help_text = _subparser_choice(
_subparser_choice(parser, "workspace"), "diff"
).format_help()
for flag in PUBLIC_CLI_WORKSPACE_DIFF_FLAGS:
assert flag in workspace_diff_help_text
workspace_shell_help_text = _subparser_choice(
_subparser_choice(parser, "workspace"),
"shell",

View file

@ -33,6 +33,8 @@ def test_create_server_registers_vm_tools(tmp_path: Path) -> None:
assert "vm_run" in tool_names
assert "vm_status" in tool_names
assert "workspace_create" in tool_names
assert "workspace_diff" in tool_names
assert "workspace_export" in tool_names
assert "workspace_logs" in tool_names
assert "workspace_sync_push" in tool_names
assert "shell_open" in tool_names
@ -201,6 +203,8 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
dict[str, Any],
dict[str, Any],
dict[str, Any],
dict[str, Any],
dict[str, Any],
]:
server = create_server(manager=manager)
created = _extract_structured(
@ -236,6 +240,20 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
},
)
)
diffed = _extract_structured(
await server.call_tool("workspace_diff", {"workspace_id": workspace_id})
)
export_path = tmp_path / "exported-more.txt"
exported = _extract_structured(
await server.call_tool(
"workspace_export",
{
"workspace_id": workspace_id,
"path": "subdir/more.txt",
"output_path": str(export_path),
},
)
)
opened = _extract_structured(
await server.call_tool("shell_open", {"workspace_id": workspace_id})
)
@ -296,12 +314,27 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
deleted = _extract_structured(
await server.call_tool("workspace_delete", {"workspace_id": workspace_id})
)
return created, synced, executed, opened, written, read, signaled, closed, logs, deleted
return (
created,
synced,
executed,
diffed,
exported,
opened,
written,
read,
signaled,
closed,
logs,
deleted,
)
(
created,
synced,
executed,
diffed,
exported,
opened,
written,
read,
@ -314,6 +347,9 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
assert created["workspace_seed"]["mode"] == "directory"
assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
assert executed["stdout"] == "more\n"
assert diffed["changed"] is True
assert exported["artifact_type"] == "file"
assert Path(str(exported["output_path"])).read_text(encoding="utf-8") == "more\n"
assert opened["state"] == "running"
assert written["input_length"] == 3
assert "/workspace" in read["output"]

View file

@ -105,6 +105,54 @@ def test_vsock_exec_client_upload_archive_round_trip(
assert stub.closed is True
def test_vsock_exec_client_export_archive_round_trip(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(socket, "AF_VSOCK", 40, raising=False)
archive_bytes = io.BytesIO()
with tarfile.open(fileobj=archive_bytes, mode="w") as archive:
payload = b"hello\n"
info = tarfile.TarInfo(name="note.txt")
info.size = len(payload)
archive.addfile(info, io.BytesIO(payload))
archive_payload = archive_bytes.getvalue()
header = json.dumps(
{
"workspace_path": "/workspace/note.txt",
"artifact_type": "file",
"archive_size": len(archive_payload),
"entry_count": 1,
"bytes_written": 6,
}
).encode("utf-8") + b"\n"
stub = StubSocket(header + archive_payload)
def socket_factory(family: int, sock_type: int) -> StubSocket:
assert family == socket.AF_VSOCK
assert sock_type == socket.SOCK_STREAM
return stub
client = VsockExecClient(socket_factory=socket_factory)
archive_path = tmp_path / "export.tar"
response = client.export_archive(
1234,
5005,
workspace_path="/workspace/note.txt",
archive_path=archive_path,
timeout_seconds=60,
)
request = json.loads(stub.sent.decode("utf-8").strip())
assert request["action"] == "export_archive"
assert request["path"] == "/workspace/note.txt"
assert archive_path.read_bytes() == archive_payload
assert response.workspace_path == "/workspace/note.txt"
assert response.artifact_type == "file"
assert response.entry_count == 1
assert response.bytes_written == 6
assert stub.closed is True
def test_vsock_exec_client_shell_round_trip(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(socket, "AF_VSOCK", 40, raising=False)
responses = [

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import io
import json
import os
import subprocess
import tarfile
import time
@ -454,6 +455,239 @@ def test_workspace_sync_push_rejects_destination_outside_workspace(tmp_path: Pat
manager.push_workspace_sync(workspace_id, source_path=source_dir, dest="../escape")
def test_workspace_diff_and_export_round_trip(tmp_path: Path) -> None:
seed_dir = tmp_path / "seed"
seed_dir.mkdir()
(seed_dir / "note.txt").write_text("hello\n", encoding="utf-8")
update_dir = tmp_path / "update"
update_dir.mkdir()
(update_dir / "note.txt").write_text("hello from sync\n", encoding="utf-8")
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
workspace_id = str(
manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
seed_path=seed_dir,
)["workspace_id"]
)
manager.push_workspace_sync(workspace_id, source_path=update_dir)
diff_payload = manager.diff_workspace(workspace_id)
assert diff_payload["workspace_id"] == workspace_id
assert diff_payload["changed"] is True
assert diff_payload["summary"]["modified"] == 1
assert diff_payload["summary"]["text_patched"] == 1
assert "-hello\n" in diff_payload["patch"]
assert "+hello from sync\n" in diff_payload["patch"]
output_path = tmp_path / "exported-note.txt"
export_payload = manager.export_workspace(
workspace_id,
path="note.txt",
output_path=output_path,
)
assert export_payload["workspace_id"] == workspace_id
assert export_payload["artifact_type"] == "file"
assert output_path.read_text(encoding="utf-8") == "hello from sync\n"
status = manager.status_workspace(workspace_id)
logs = manager.logs_workspace(workspace_id)
assert status["command_count"] == 0
assert logs["count"] == 0
def test_workspace_export_directory_uses_exact_output_path(tmp_path: Path) -> None:
seed_dir = tmp_path / "seed"
nested_dir = seed_dir / "src"
nested_dir.mkdir(parents=True)
(nested_dir / "note.txt").write_text("hello\n", encoding="utf-8")
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
workspace_id = str(
manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
seed_path=seed_dir,
)["workspace_id"]
)
output_dir = tmp_path / "exported-src"
payload = manager.export_workspace(workspace_id, path="src", output_path=output_dir)
assert payload["artifact_type"] == "directory"
assert (output_dir / "note.txt").read_text(encoding="utf-8") == "hello\n"
assert not (output_dir / "src").exists()
def test_workspace_diff_requires_create_time_baseline(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
workspace_id = str(
manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
)["workspace_id"]
)
baseline_path = tmp_path / "vms" / "workspaces" / workspace_id / "baseline" / "workspace.tar"
baseline_path.unlink()
with pytest.raises(RuntimeError, match="requires a baseline snapshot"):
manager.diff_workspace(workspace_id)
def test_workspace_export_helpers_preserve_directory_symlinks(tmp_path: Path) -> None:
workspace_dir = tmp_path / "workspace"
workspace_dir.mkdir()
(workspace_dir / "note.txt").write_text("hello\n", encoding="utf-8")
os.symlink("note.txt", workspace_dir / "note-link")
(workspace_dir / "empty-dir").mkdir()
archive_path = tmp_path / "workspace-export.tar"
exported = vm_manager_module._prepare_workspace_export_archive( # noqa: SLF001
workspace_dir=workspace_dir,
workspace_path=".",
archive_path=archive_path,
)
assert exported.artifact_type == "directory"
output_dir = tmp_path / "output"
extracted = vm_manager_module._extract_workspace_export_archive( # noqa: SLF001
archive_path,
output_path=output_dir,
artifact_type="directory",
)
assert extracted["artifact_type"] == "directory"
assert (output_dir / "note.txt").read_text(encoding="utf-8") == "hello\n"
assert (output_dir / "note-link").is_symlink()
assert os.readlink(output_dir / "note-link") == "note.txt"
assert (output_dir / "empty-dir").is_dir()
def test_workspace_export_helpers_validate_missing_path_and_existing_output(tmp_path: Path) -> None:
workspace_dir = tmp_path / "workspace"
workspace_dir.mkdir()
(workspace_dir / "note.txt").write_text("hello\n", encoding="utf-8")
with pytest.raises(RuntimeError, match="workspace path does not exist"):
vm_manager_module._prepare_workspace_export_archive( # noqa: SLF001
workspace_dir=workspace_dir,
workspace_path="missing.txt",
archive_path=tmp_path / "missing.tar",
)
archive_path = tmp_path / "note-export.tar"
exported = vm_manager_module._prepare_workspace_export_archive( # noqa: SLF001
workspace_dir=workspace_dir,
workspace_path="note.txt",
archive_path=archive_path,
)
output_path = tmp_path / "note.txt"
output_path.write_text("already here\n", encoding="utf-8")
with pytest.raises(RuntimeError, match="output_path already exists"):
vm_manager_module._extract_workspace_export_archive( # noqa: SLF001
archive_path,
output_path=output_path,
artifact_type=exported.artifact_type,
)
def test_diff_workspace_trees_reports_empty_binary_symlink_and_type_changes(tmp_path: Path) -> None:
baseline_dir = tmp_path / "baseline"
current_dir = tmp_path / "current"
baseline_dir.mkdir()
current_dir.mkdir()
(baseline_dir / "modified.txt").write_text("before\n", encoding="utf-8")
(current_dir / "modified.txt").write_text("after\n", encoding="utf-8")
(baseline_dir / "deleted.txt").write_text("gone\n", encoding="utf-8")
(current_dir / "added.txt").write_text("new\n", encoding="utf-8")
(baseline_dir / "binary.bin").write_bytes(b"\x00before")
(current_dir / "binary.bin").write_bytes(b"\x00after")
os.symlink("link-target-old.txt", baseline_dir / "link")
os.symlink("link-target-new.txt", current_dir / "link")
(baseline_dir / "swap").mkdir()
(current_dir / "swap").write_text("type changed\n", encoding="utf-8")
(baseline_dir / "removed-empty").mkdir()
(current_dir / "added-empty").mkdir()
diff_payload = vm_manager_module._diff_workspace_trees( # noqa: SLF001
baseline_dir,
current_dir,
)
assert diff_payload["changed"] is True
assert diff_payload["summary"] == {
"total": 8,
"added": 2,
"modified": 3,
"deleted": 2,
"type_changed": 1,
"text_patched": 3,
"non_text": 5,
}
assert "--- a/modified.txt" in diff_payload["patch"]
assert "+++ b/modified.txt" in diff_payload["patch"]
assert "--- /dev/null" in diff_payload["patch"]
assert "+++ b/added.txt" in diff_payload["patch"]
assert "--- a/deleted.txt" in diff_payload["patch"]
assert "+++ /dev/null" in diff_payload["patch"]
entries = {entry["path"]: entry for entry in diff_payload["entries"]}
assert entries["binary.bin"]["text_patch"] is None
assert entries["link"]["artifact_type"] == "symlink"
assert entries["swap"]["artifact_type"] == "file"
assert entries["removed-empty"]["artifact_type"] == "directory"
assert entries["added-empty"]["artifact_type"] == "directory"
def test_diff_workspace_trees_unchanged_returns_empty_summary(tmp_path: Path) -> None:
baseline_dir = tmp_path / "baseline"
current_dir = tmp_path / "current"
baseline_dir.mkdir()
current_dir.mkdir()
(baseline_dir / "note.txt").write_text("same\n", encoding="utf-8")
(current_dir / "note.txt").write_text("same\n", encoding="utf-8")
diff_payload = vm_manager_module._diff_workspace_trees( # noqa: SLF001
baseline_dir,
current_dir,
)
assert diff_payload == {
"changed": False,
"summary": {
"total": 0,
"added": 0,
"modified": 0,
"deleted": 0,
"type_changed": 0,
"text_patched": 0,
"non_text": 0,
},
"entries": [],
"patch": "",
}
def test_workspace_shell_lifecycle_and_rehydration(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",

2
uv.lock generated
View file

@ -706,7 +706,7 @@ crypto = [
[[package]]
name = "pyro-mcp"
version = "2.5.0"
version = "2.6.0"
source = { editable = "." }
dependencies = [
{ name = "mcp" },