Add stopped-workspace disk export and inspection

Finish the 3.1.0 secondary disk-tools milestone so stable workspaces can be
stopped, inspected offline, exported as raw ext4 images, and started again
without changing the primary workspace-first interaction model.

Add workspace stop/start plus workspace disk export/list/read across the CLI,
SDK, and MCP, backed by a new offline debugfs inspection helper and guest-only
validation. Scrub runtime-only guest state before disk inspection/export, and
fix the real guest reliability gaps by flushing the filesystem on stop and
removing stale Firecracker socket files before restart.

Update the docs, examples, changelog, and roadmap to mark 3.1.0 done, and
cover the new lifecycle/disk paths with API, CLI, manager, contract, and
package-surface tests.

Validation: uv lock; UV_CACHE_DIR=.uv-cache make check; UV_CACHE_DIR=.uv-cache
make dist-check; real guest-backed smoke for create, shell/service activity,
stop, workspace disk list/read/export, start, exec, and delete.
This commit is contained in:
Thales Maciel 2026-03-12 20:57:16 -03:00
parent f2d20ef30a
commit 287f6d100f
26 changed files with 2585 additions and 34 deletions

View file

@ -22,7 +22,7 @@ Networking: tun=yes ip_forward=yes
```bash
$ uvx --from pyro-mcp pyro env list
Catalog version: 3.0.0
Catalog version: 3.1.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.
@ -81,6 +81,11 @@ $ uvx --from pyro-mcp pyro workspace snapshot create "$WORKSPACE_ID" checkpoint
$ uvx --from pyro-mcp pyro workspace service start "$WORKSPACE_ID" web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
$ uvx --from pyro-mcp pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint
$ uvx --from pyro-mcp pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt
$ uvx --from pyro-mcp pyro workspace stop "$WORKSPACE_ID"
$ uvx --from pyro-mcp pyro workspace disk list "$WORKSPACE_ID"
$ uvx --from pyro-mcp pyro workspace disk read "$WORKSPACE_ID" note.txt
$ uvx --from pyro-mcp pyro workspace disk export "$WORKSPACE_ID" --output ./workspace.ext4
$ uvx --from pyro-mcp pyro workspace start "$WORKSPACE_ID"
$ uvx --from pyro-mcp pyro workspace delete "$WORKSPACE_ID"
```
@ -200,12 +205,33 @@ $ uvx --from pyro-mcp pyro workspace service stop WORKSPACE_ID web
$ uvx --from pyro-mcp pyro workspace service stop WORKSPACE_ID worker
[workspace-service-stop] workspace_id=... service=worker state=stopped execution_mode=guest_vsock
$ uvx --from pyro-mcp pyro workspace stop WORKSPACE_ID
Workspace ID: ...
State: stopped
$ uvx --from pyro-mcp pyro workspace disk list WORKSPACE_ID src --recursive
Workspace: ...
Path: /workspace/src
- /workspace/src [directory]
- /workspace/src/note.txt [file] bytes=...
$ uvx --from pyro-mcp pyro workspace disk read WORKSPACE_ID src/note.txt
hello from synced workspace
[workspace-disk-read] workspace_id=... path=/workspace/src/note.txt size_bytes=... truncated=False
$ uvx --from pyro-mcp pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4
[workspace-disk-export] workspace_id=... output_path=... disk_format=ext4 bytes_written=...
$ uvx --from pyro-mcp pyro workspace start WORKSPACE_ID
Workspace ID: ...
State: started
```
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 `3.0.0`; if it fails partway through, prefer `pyro workspace reset`
workspace. Sync is non-atomic in `3.1.0`; if it fails partway through, prefer `pyro workspace reset`
to recover from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the current
`/workspace` tree to its immutable create-time baseline, `pyro workspace snapshot *` to create
named checkpoints, and `pyro workspace export` to copy one changed file or directory back to the
@ -219,7 +245,9 @@ service must be reachable from the host on `127.0.0.1`. Use `--secret` and `--se
workspace creation when the sandbox needs private tokens or config. Persisted secret files are
materialized at `/run/pyro-secrets/<name>`, and `--secret-env SECRET_NAME[=ENV_VAR]` maps one
secret into one exec, shell, or service call without storing that environment mapping on the
workspace itself.
workspace itself. Use `pyro workspace stop` plus `pyro workspace disk list|read|export` when you
need offline inspection or one raw ext4 copy from a stopped guest-backed workspace, then
`pyro workspace start` to resume the same workspace.
The stable workspace walkthrough GIF in the README is rendered from
[docs/assets/workspace-first-run.tape](assets/workspace-first-run.tape) with

View file

@ -85,7 +85,7 @@ uvx --from pyro-mcp pyro env list
Expected output:
```bash
Catalog version: 3.0.0
Catalog version: 3.1.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.
@ -159,6 +159,7 @@ This is the stable persistent-workspace contract:
- `workspace snapshot *` and `workspace reset` make reset-over-repair explicit
- `workspace diff` compares against the immutable create-time baseline
- `workspace export` copies results back to the host
- `workspace stop|start` and `workspace disk *` add secondary stopped-workspace inspection and raw ext4 export
## 6. Optional demo proof point
@ -210,6 +211,7 @@ After the CLI path works, you can move on to:
- baseline diff: `pyro workspace diff WORKSPACE_ID`
- snapshots and reset: `pyro workspace snapshot create WORKSPACE_ID checkpoint` and `pyro workspace reset WORKSPACE_ID --snapshot checkpoint`
- host export: `pyro workspace export WORKSPACE_ID note.txt --output ./note.txt`
- stopped-workspace inspection: `pyro workspace stop WORKSPACE_ID`, `pyro workspace disk list WORKSPACE_ID`, `pyro workspace disk read WORKSPACE_ID note.txt`, and `pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4`
- interactive shells: `pyro workspace shell open WORKSPACE_ID`
- long-running services: `pyro workspace service start WORKSPACE_ID app --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done'`
- localhost-published ports: `pyro workspace create debian:12 --network-policy egress+published-ports` and `pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app`
@ -246,6 +248,11 @@ pyro workspace service status WORKSPACE_ID web
pyro workspace service logs WORKSPACE_ID web --tail-lines 50
pyro workspace service stop WORKSPACE_ID web
pyro workspace service stop WORKSPACE_ID worker
pyro workspace stop WORKSPACE_ID
pyro workspace disk list WORKSPACE_ID
pyro workspace disk read WORKSPACE_ID src/note.txt
pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4
pyro workspace start WORKSPACE_ID
pyro workspace logs WORKSPACE_ID
pyro workspace delete WORKSPACE_ID
```
@ -254,7 +261,7 @@ 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 `3.0.0`; if it fails partway through, prefer `pyro workspace reset` to recover
is non-atomic in `3.1.0`; if it fails partway through, prefer `pyro workspace reset` to recover
from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the current workspace
tree to its immutable create-time baseline, `pyro workspace snapshot *` to capture named
checkpoints, and `pyro workspace export` to copy one changed file or directory back to the host. Use
@ -268,7 +275,9 @@ service must be reachable from the host on `127.0.0.1`. Use `--secret` and `--se
workspace creation when the sandbox needs private tokens or config, and
`--secret-env SECRET_NAME[=ENV_VAR]` when one exec, shell, or service call needs that secret as an
environment variable. Persisted secret files are available in the guest at
`/run/pyro-secrets/<name>`.
`/run/pyro-secrets/<name>`. Use `pyro workspace stop` plus `pyro workspace disk list|read|export`
when you need offline inspection or one raw ext4 copy from a stopped guest-backed workspace, then
`pyro workspace start` to resume it.
## Contributor Clone

View file

@ -35,6 +35,7 @@ Recommended surface:
- `workspace_create(..., secrets=...)` + `workspace_exec(..., secret_env=...)` when the workspace needs private tokens or authenticated setup
- `workspace_create(..., network_policy="egress+published-ports")` + `start_service(..., published_ports=[...])` when the host must probe one workspace service
- `workspace_diff` + `workspace_export` when the agent needs explicit baseline comparison or host-out file transfer
- `stop_workspace(...)` + `list_workspace_disk(...)` / `read_workspace_disk(...)` / `export_workspace_disk(...)` when one stopped guest-backed workspace needs offline inspection or a raw ext4 copy
- `start_service` / `list_services` / `status_service` / `logs_service` / `stop_service` when the agent needs long-running processes inside that workspace
- `open_shell(..., secret_env=...)` / `read_shell` / `write_shell` when the agent needs an interactive PTY inside that workspace
@ -95,6 +96,9 @@ Lifecycle note:
- 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 `stop_workspace(...)` plus `list_workspace_disk(...)`, `read_workspace_disk(...)`, or
`export_workspace_disk(...)` when the agent needs offline inspection or one raw ext4 copy from
a stopped guest-backed workspace
- use `start_service(...)` when the agent needs long-running processes and typed readiness inside
one workspace
- use `open_shell(...)` when the agent needs interactive shell state instead of one-shot execs

View file

@ -26,8 +26,13 @@ Top-level commands:
- `pyro run`
- `pyro workspace create`
- `pyro workspace sync push`
- `pyro workspace stop`
- `pyro workspace start`
- `pyro workspace exec`
- `pyro workspace export`
- `pyro workspace disk export`
- `pyro workspace disk list`
- `pyro workspace disk read`
- `pyro workspace diff`
- `pyro workspace snapshot create`
- `pyro workspace snapshot list`
@ -72,7 +77,13 @@ Behavioral guarantees:
- `pyro workspace create --network-policy {off,egress,egress+published-ports}` controls workspace guest networking and whether services may publish localhost ports.
- `pyro workspace create --secret NAME=VALUE` and `--secret-file NAME=PATH` persist guest-only UTF-8 secrets outside `/workspace`.
- `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 stop WORKSPACE_ID` stops one persistent workspace without deleting its `/workspace`, snapshots, or command history.
- `pyro workspace start WORKSPACE_ID` restarts one stopped workspace without resetting `/workspace`.
- `pyro workspace export WORKSPACE_ID PATH --output HOST_PATH` exports one file or directory from `/workspace` back to the host.
- `pyro workspace disk export WORKSPACE_ID --output HOST_PATH` copies the stopped guest-backed workspace rootfs as raw ext4 to the host.
- `pyro workspace disk list WORKSPACE_ID [PATH] [--recursive]` inspects a stopped guest-backed workspace rootfs offline without booting the guest.
- `pyro workspace disk read WORKSPACE_ID PATH [--max-bytes N]` reads one regular file from a stopped guest-backed workspace rootfs offline.
- `pyro workspace disk *` requires `state=stopped` and a guest-backed workspace; it fails on `host_compat`.
- `pyro workspace diff WORKSPACE_ID` compares the current `/workspace` tree to the immutable create-time baseline.
- `pyro workspace snapshot *` manages explicit named snapshots in addition to the implicit `baseline`.
- `pyro workspace reset WORKSPACE_ID [--snapshot SNAPSHOT_NAME|baseline]` recreates the full sandbox and restores `/workspace` from the chosen snapshot.
@ -108,7 +119,12 @@ Supported public entrypoints:
- `Pyro.create_vm(...)`
- `Pyro.create_workspace(..., network_policy="off", secrets=None)`
- `Pyro.push_workspace_sync(workspace_id, source_path, *, dest="/workspace")`
- `Pyro.stop_workspace(workspace_id)`
- `Pyro.start_workspace(workspace_id)`
- `Pyro.export_workspace(workspace_id, path, *, output_path)`
- `Pyro.export_workspace_disk(workspace_id, *, output_path)`
- `Pyro.list_workspace_disk(workspace_id, path="/workspace", recursive=False)`
- `Pyro.read_workspace_disk(workspace_id, path, *, max_bytes=65536)`
- `Pyro.diff_workspace(workspace_id)`
- `Pyro.create_snapshot(workspace_id, snapshot_name)`
- `Pyro.list_snapshots(workspace_id)`
@ -147,7 +163,12 @@ Stable public method names:
- `create_vm(...)`
- `create_workspace(..., network_policy="off", secrets=None)`
- `push_workspace_sync(workspace_id, source_path, *, dest="/workspace")`
- `stop_workspace(workspace_id)`
- `start_workspace(workspace_id)`
- `export_workspace(workspace_id, path, *, output_path)`
- `export_workspace_disk(workspace_id, *, output_path)`
- `list_workspace_disk(workspace_id, path="/workspace", recursive=False)`
- `read_workspace_disk(workspace_id, path, *, max_bytes=65536)`
- `diff_workspace(workspace_id)`
- `create_snapshot(workspace_id, snapshot_name)`
- `list_snapshots(workspace_id)`
@ -186,7 +207,13 @@ Behavioral defaults:
- `Pyro.create_workspace(..., network_policy="off"|"egress"|"egress+published-ports")` controls workspace guest networking and whether services may publish host ports.
- `Pyro.create_workspace(..., secrets=...)` persists guest-only UTF-8 secrets outside `/workspace`.
- `Pyro.push_workspace_sync(...)` imports later host-side directory or archive content into a started workspace.
- `Pyro.stop_workspace(...)` stops one persistent workspace without deleting its `/workspace`, snapshots, or command history.
- `Pyro.start_workspace(...)` restarts one stopped workspace without resetting `/workspace`.
- `Pyro.export_workspace(...)` exports one file or directory from `/workspace` to an explicit host path.
- `Pyro.export_workspace_disk(...)` copies the stopped guest-backed workspace rootfs as raw ext4 to an explicit host path.
- `Pyro.list_workspace_disk(...)` inspects a stopped guest-backed workspace rootfs offline without booting the guest.
- `Pyro.read_workspace_disk(...)` reads one regular file from a stopped guest-backed workspace rootfs offline.
- stopped-workspace disk helpers require `state=stopped` and a guest-backed workspace; they fail on `host_compat`.
- `Pyro.diff_workspace(...)` compares the current `/workspace` tree to the immutable create-time baseline.
- `Pyro.create_snapshot(...)` captures one named `/workspace` checkpoint.
- `Pyro.list_snapshots(...)` lists the implicit `baseline` plus any named snapshots.
@ -226,8 +253,13 @@ Persistent workspace tools:
- `workspace_create`
- `workspace_sync_push`
- `workspace_stop`
- `workspace_start`
- `workspace_exec`
- `workspace_export`
- `workspace_disk_export`
- `workspace_disk_list`
- `workspace_disk_read`
- `workspace_diff`
- `snapshot_create`
- `snapshot_list`
@ -257,7 +289,13 @@ Behavioral defaults:
- `workspace_create` accepts `network_policy` with `off`, `egress`, or `egress+published-ports` to control workspace guest networking and service port publication.
- `workspace_create` accepts optional `secrets` and persists guest-only UTF-8 secret material outside `/workspace`.
- `workspace_sync_push` imports later host-side directory or archive content into a started workspace, with an optional `dest` under `/workspace`.
- `workspace_stop` stops one persistent workspace without deleting its `/workspace`, snapshots, or command history.
- `workspace_start` restarts one stopped workspace without resetting `/workspace`.
- `workspace_export` exports one file or directory from `/workspace` to an explicit host path.
- `workspace_disk_export` copies the stopped guest-backed workspace rootfs as raw ext4 to an explicit host path.
- `workspace_disk_list` inspects a stopped guest-backed workspace rootfs offline without booting the guest.
- `workspace_disk_read` reads one regular file from a stopped guest-backed workspace rootfs offline.
- stopped-workspace disk tools require `state=stopped` and a guest-backed workspace; they fail on `host_compat`.
- `workspace_diff` compares the current `/workspace` tree to the immutable create-time baseline.
- `snapshot_create`, `snapshot_list`, and `snapshot_delete` manage explicit named snapshots in addition to the implicit `baseline`.
- `workspace_reset` recreates the full sandbox and restores `/workspace` from `baseline` or one named snapshot.

View file

@ -2,7 +2,7 @@
This roadmap turns the agent-workspace vision into release-sized milestones.
Current baseline is `3.0.0`:
Current baseline is `3.1.0`:
- workspace persistence exists and the public surface is now workspace-first
- host crossing currently covers create-time seeding, later sync push, and explicit export
@ -38,12 +38,12 @@ also expected to update:
6. [`2.9.0` Secrets](task-workspace-ga/2.9.0-secrets.md) - Done
7. [`2.10.0` Network Policy And Host Port Publication](task-workspace-ga/2.10.0-network-policy-and-host-port-publication.md) - Done
8. [`3.0.0` Stable Workspace Product](task-workspace-ga/3.0.0-stable-workspace-product.md) - Done
9. [`3.1.0` Secondary Disk Tools](task-workspace-ga/3.1.0-secondary-disk-tools.md)
9. [`3.1.0` Secondary Disk Tools](task-workspace-ga/3.1.0-secondary-disk-tools.md) - Done
## Remaining Follow-Up
## Roadmap Status
The core workspace product is now stable. The remaining planned follow-up is intentionally
secondary:
The planned workspace roadmap is complete.
- `3.1.0` secondary disk tools for offline inspection and disk-level workflows
- no further roadmap milestone changes the stable workspace-first core contract
- `3.1.0` added secondary stopped-workspace disk export and offline inspection helpers without
changing the stable workspace-first core contract.
- Future work, if any, is now outside the planned vision milestones tracked in this roadmap.

View file

@ -1,38 +1,59 @@
# `3.1.0` Secondary Disk Tools
Status: Done
## Goal
Add the disk-level tools the vision explicitly places last, while keeping them
secondary to the workspace identity.
Add stopped-workspace disk tools the vision explicitly places last, while keeping them secondary
to the stable workspace identity.
## Public API Changes
Representative additions:
Shipped additions:
- stopped-workspace disk export/import helpers
- offline inspection helpers
- disk-oriented snapshot inspection
Exact command names should reinforce that these are supporting tools rather than
the primary product contract.
- `pyro workspace stop WORKSPACE_ID`
- `pyro workspace start WORKSPACE_ID`
- `pyro workspace disk export WORKSPACE_ID --output HOST_PATH`
- `pyro workspace disk list WORKSPACE_ID [PATH] [--recursive]`
- `pyro workspace disk read WORKSPACE_ID PATH [--max-bytes N]`
- matching Python SDK methods:
- `stop_workspace`
- `start_workspace`
- `export_workspace_disk`
- `list_workspace_disk`
- `read_workspace_disk`
- matching MCP tools:
- `workspace_stop`
- `workspace_start`
- `workspace_disk_export`
- `workspace_disk_list`
- `workspace_disk_read`
## Implementation Boundaries
- keep these tools scoped to seeding, inspection, and offline workflows
- keep these tools scoped to stopped-workspace inspection, export, and offline workflows
- do not replace shell, exec, services, diff, export, or reset as the main
interaction model
- prefer explicit stopped-workspace or offline semantics
- require guest-backed workspaces for `workspace disk *`
- keep disk export raw ext4 only in this milestone
- scrub runtime-only guest paths such as `/run/pyro-secrets`, `/run/pyro-shells`, and
`/run/pyro-services` before offline inspection or export
## Non-Goals
- no drift into generic image tooling identity
- no replacement of workspace-level host crossing
- no disk import
- no disk mutation
- no create-from-disk workflow
## Acceptance Scenarios
- inspect or export a stopped workspace disk for offline analysis
- import or snapshot content through disk-level tools without changing the main
workspace workflow
- stop a workspace, inspect `/workspace` offline, export raw ext4, then start the same workspace
again without resetting `/workspace`
- verify secret-backed workspaces scrub runtime-only guest paths before stopped-disk inspection
## Required Repo Updates