Add task sync push milestone

Tasks could start from host content in 2.2.0, but there was still no post-create path to update a live workspace from the host. This change adds the next host-to-task step so repeated fix or review loops do not require recreating the task for every local change.

Add task sync push across the CLI, Python SDK, and MCP server, reusing the existing safe archive import path from seeded task creation instead of introducing a second transfer stack. The implementation keeps sync separate from workspace_seed metadata, validates destinations under /workspace, and documents the current non-atomic recovery path as delete-and-recreate.

Validation:
- uv lock
- UV_CACHE_DIR=.uv-cache uv run pytest --no-cov tests/test_cli.py tests/test_vm_manager.py tests/test_api.py tests/test_server.py tests/test_public_contract.py
- UV_CACHE_DIR=.uv-cache make check
- UV_CACHE_DIR=.uv-cache make dist-check
- real guest-backed smoke: task create --source-path, task sync push, task exec to verify both files, task delete
This commit is contained in:
Thales Maciel 2026-03-11 22:20:55 -03:00
parent aa886b346e
commit 9e11dcf9ab
19 changed files with 461 additions and 41 deletions

View file

@ -2,6 +2,15 @@
All notable user-visible changes to `pyro-mcp` are documented here. All notable user-visible changes to `pyro-mcp` are documented here.
## 2.3.0
- Added `task sync push` across the CLI, Python SDK, and MCP server so started task workspaces can
import later host-side directory or archive content without being recreated.
- Reused the existing safe archive import path with an explicit destination under `/workspace`,
including host-side and guest-backed task support.
- Documented sync as a non-atomic update path in `2.3.0`, with delete-and-recreate as the recovery
path if a sync fails partway through.
## 2.2.0 ## 2.2.0
- Added seeded task creation across the CLI, Python SDK, and MCP server with an optional - Added seeded task creation across the CLI, Python SDK, and MCP server with an optional

View file

@ -18,7 +18,7 @@ It exposes the same runtime in three public forms:
- First run transcript: [docs/first-run.md](docs/first-run.md) - First run transcript: [docs/first-run.md](docs/first-run.md)
- Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif) - 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/) - PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/)
- What's new in 2.2.0: [CHANGELOG.md#220](CHANGELOG.md#220) - What's new in 2.3.0: [CHANGELOG.md#230](CHANGELOG.md#230)
- Host requirements: [docs/host-requirements.md](docs/host-requirements.md) - Host requirements: [docs/host-requirements.md](docs/host-requirements.md)
- Integration targets: [docs/integrations.md](docs/integrations.md) - Integration targets: [docs/integrations.md](docs/integrations.md)
- Public contract: [docs/public-contract.md](docs/public-contract.md) - Public contract: [docs/public-contract.md](docs/public-contract.md)
@ -55,7 +55,7 @@ What success looks like:
```bash ```bash
Platform: linux-x86_64 Platform: linux-x86_64
Runtime: PASS Runtime: PASS
Catalog version: 2.2.0 Catalog version: 2.3.0
... ...
[pull] phase=install environment=debian:12 [pull] phase=install environment=debian:12
[pull] phase=ready environment=debian:12 [pull] phase=ready environment=debian:12
@ -75,6 +75,7 @@ After the quickstart works:
- prove the full one-shot lifecycle with `uvx --from pyro-mcp pyro demo` - prove the full one-shot lifecycle with `uvx --from pyro-mcp pyro demo`
- create a persistent workspace with `uvx --from pyro-mcp pyro task create debian:12 --source-path ./repo` - create a persistent workspace with `uvx --from pyro-mcp pyro task create debian:12 --source-path ./repo`
- update a live task from the host with `uvx --from pyro-mcp pyro task sync push TASK_ID ./changes`
- move to Python or MCP via [docs/integrations.md](docs/integrations.md) - move to Python or MCP via [docs/integrations.md](docs/integrations.md)
## Supported Hosts ## Supported Hosts
@ -199,8 +200,8 @@ workspace without recreating the sandbox every time.
```bash ```bash
pyro task create debian:12 --source-path ./repo pyro task create debian:12 --source-path ./repo
pyro task exec TASK_ID -- sh -lc 'printf "hello from task\n" > note.txt' pyro task sync push TASK_ID ./changes --dest src
pyro task exec TASK_ID -- cat note.txt pyro task exec TASK_ID -- cat src/note.txt
pyro task logs TASK_ID pyro task logs TASK_ID
pyro task delete TASK_ID pyro task delete TASK_ID
``` ```
@ -208,7 +209,9 @@ pyro task delete TASK_ID
Task workspaces start in `/workspace` and keep command history until you delete them. For machine Task workspaces start in `/workspace` and keep command history until you delete them. For machine
consumption, add `--json` and read the returned `task_id`. Use `--source-path` when you want the consumption, add `--json` and read the returned `task_id`. Use `--source-path` when you want the
task to start from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive instead of an task to start from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive instead of an
empty workspace. empty workspace. Use `pyro task sync push` when you want to import later host-side changes into a
started task. Sync is non-atomic in `2.3.0`; if it fails partway through, delete and recreate the
task from its seed.
## Public Interfaces ## Public Interfaces
@ -353,8 +356,8 @@ pyro = Pyro()
task = pyro.create_task(environment="debian:12", source_path="./repo") task = pyro.create_task(environment="debian:12", source_path="./repo")
task_id = task["task_id"] task_id = task["task_id"]
try: try:
pyro.exec_task(task_id, command="printf 'hello from task\\n' > note.txt") pyro.push_task_sync(task_id, "./changes", dest="src")
result = pyro.exec_task(task_id, command="cat note.txt") result = pyro.exec_task(task_id, command="cat src/note.txt")
print(result["stdout"], end="") print(result["stdout"], end="")
finally: finally:
pyro.delete_task(task_id) pyro.delete_task(task_id)
@ -381,6 +384,7 @@ Advanced lifecycle tools:
Persistent workspace tools: Persistent workspace tools:
- `task_create(environment, vcpu_count=1, mem_mib=1024, ttl_seconds=600, network=false, allow_host_compat=false, source_path=null)` - `task_create(environment, vcpu_count=1, mem_mib=1024, ttl_seconds=600, network=false, allow_host_compat=false, source_path=null)`
- `task_sync_push(task_id, source_path, dest="/workspace")`
- `task_exec(task_id, command, timeout_seconds=30)` - `task_exec(task_id, command, timeout_seconds=30)`
- `task_status(task_id)` - `task_status(task_id)`
- `task_logs(task_id)` - `task_logs(task_id)`

View file

@ -22,7 +22,7 @@ Networking: tun=yes ip_forward=yes
```bash ```bash
$ uvx --from pyro-mcp pyro env list $ uvx --from pyro-mcp pyro env list
Catalog version: 2.2.0 Catalog version: 2.3.0
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. 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-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. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
@ -71,6 +71,7 @@ deterministic structured result.
```bash ```bash
$ uvx --from pyro-mcp pyro demo $ uvx --from pyro-mcp pyro demo
$ uvx --from pyro-mcp pyro task create debian:12 --source-path ./repo $ uvx --from pyro-mcp pyro task create debian:12 --source-path ./repo
$ uvx --from pyro-mcp pyro task sync push TASK_ID ./changes
$ uvx --from pyro-mcp pyro mcp serve $ uvx --from pyro-mcp pyro mcp serve
``` ```
@ -89,16 +90,18 @@ Execution mode: guest_vsock
Resources: 1 vCPU / 1024 MiB Resources: 1 vCPU / 1024 MiB
Command count: 0 Command count: 0
$ uvx --from pyro-mcp pyro task exec TASK_ID -- sh -lc 'printf "hello from task\n" > note.txt' $ uvx --from pyro-mcp pyro task sync push TASK_ID ./changes --dest src
[task-exec] task_id=... sequence=1 cwd=/workspace execution_mode=guest_vsock exit_code=0 duration_ms=... [task-sync] task_id=... mode=directory source=... destination=/workspace/src entry_count=... bytes_written=... execution_mode=guest_vsock
$ uvx --from pyro-mcp pyro task exec TASK_ID -- cat note.txt $ uvx --from pyro-mcp pyro task exec TASK_ID -- cat src/note.txt
hello from task hello from synced task
[task-exec] task_id=... sequence=2 cwd=/workspace execution_mode=guest_vsock exit_code=0 duration_ms=... [task-exec] task_id=... sequence=1 cwd=/workspace execution_mode=guest_vsock exit_code=0 duration_ms=...
``` ```
Use `--source-path` when the task should start from a host directory or a local Use `--source-path` when the task should start from a host directory or a local
`.tar` / `.tar.gz` / `.tgz` archive instead of an empty `/workspace`. `.tar` / `.tar.gz` / `.tgz` archive instead of an empty `/workspace`. Use
`pyro task sync push` when you need to import later host-side changes into a started task.
Sync is non-atomic in `2.3.0`; if it fails partway through, delete and recreate the task.
Example output: Example output:

View file

@ -83,7 +83,7 @@ uvx --from pyro-mcp pyro env list
Expected output: Expected output:
```bash ```bash
Catalog version: 2.2.0 Catalog version: 2.3.0
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. 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-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. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
@ -175,6 +175,7 @@ pyro run debian:12 -- git --version
After the CLI path works, you can move on to: After the CLI path works, you can move on to:
- persistent workspaces: `pyro task create debian:12 --source-path ./repo` - persistent workspaces: `pyro task create debian:12 --source-path ./repo`
- live task updates: `pyro task sync push TASK_ID ./changes`
- MCP: `pyro mcp serve` - MCP: `pyro mcp serve`
- Python SDK: `from pyro_mcp import Pyro` - Python SDK: `from pyro_mcp import Pyro`
- Demos: `pyro demo` or `pyro demo --network` - Demos: `pyro demo` or `pyro demo --network`
@ -185,8 +186,8 @@ Use `pyro task ...` when you need repeated commands in one sandbox instead of on
```bash ```bash
pyro task create debian:12 --source-path ./repo pyro task create debian:12 --source-path ./repo
pyro task exec TASK_ID -- sh -lc 'printf "hello from task\n" > note.txt' pyro task sync push TASK_ID ./changes --dest src
pyro task exec TASK_ID -- cat note.txt pyro task exec TASK_ID -- cat src/note.txt
pyro task logs TASK_ID pyro task logs TASK_ID
pyro task delete TASK_ID pyro task delete TASK_ID
``` ```
@ -194,6 +195,8 @@ pyro task delete TASK_ID
Task commands default to the persistent `/workspace` directory inside the guest. If you need the Task commands default to the persistent `/workspace` directory inside the guest. If you need the
task identifier programmatically, use `--json` and read the `task_id` field. Use `--source-path` task identifier programmatically, use `--json` and read the `task_id` field. Use `--source-path`
when the task should start from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive. when the task should start from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive.
Use `pyro task sync push` for later host-side changes to a started task. Sync is non-atomic in
`2.3.0`; if it fails partway through, delete and recreate the task from its seed.
## Contributor Clone ## Contributor Clone

View file

@ -30,7 +30,7 @@ Best when:
Recommended surface: Recommended surface:
- `vm_run` - `vm_run`
- `task_create(source_path=...)` + `task_exec` when the agent needs persistent workspace state - `task_create(source_path=...)` + `task_sync_push` + `task_exec` when the agent needs persistent workspace state
Canonical example: Canonical example:
@ -65,7 +65,7 @@ Best when:
Recommended default: Recommended default:
- `Pyro.run_in_vm(...)` - `Pyro.run_in_vm(...)`
- `Pyro.create_task(source_path=...)` + `Pyro.exec_task(...)` when repeated workspace commands are required - `Pyro.create_task(source_path=...)` + `Pyro.push_task_sync(...)` + `Pyro.exec_task(...)` when repeated workspace commands are required
Lifecycle note: Lifecycle note:
@ -74,6 +74,8 @@ Lifecycle note:
that final exec that final exec
- use `create_task(source_path=...)` when the agent needs repeated commands in one persistent - use `create_task(source_path=...)` when the agent needs repeated commands in one persistent
`/workspace` that starts from host content `/workspace` that starts from host content
- use `push_task_sync(...)` when later host-side changes need to be imported into that running
workspace without recreating the task
Examples: Examples:

View file

@ -20,6 +20,7 @@ Top-level commands:
- `pyro mcp serve` - `pyro mcp serve`
- `pyro run` - `pyro run`
- `pyro task create` - `pyro task create`
- `pyro task sync push`
- `pyro task exec` - `pyro task exec`
- `pyro task status` - `pyro task status`
- `pyro task logs` - `pyro task logs`
@ -48,6 +49,8 @@ Behavioral guarantees:
- `pyro task create` auto-starts a persistent workspace. - `pyro task create` auto-starts a persistent workspace.
- `pyro task create --source-path PATH` seeds `/workspace` from a host directory or a local - `pyro task create --source-path PATH` seeds `/workspace` from a host directory or a local
`.tar` / `.tar.gz` / `.tgz` archive before the task is returned. `.tar` / `.tar.gz` / `.tgz` archive before the task is returned.
- `pyro task sync push TASK_ID SOURCE_PATH [--dest WORKSPACE_PATH]` imports later host-side
directory or archive content into a started task workspace.
- `pyro task exec` runs in the persistent `/workspace` for that task and does not auto-clean. - `pyro task exec` runs in the persistent `/workspace` for that task and does not auto-clean.
- `pyro task logs` returns persisted command history for that task until `pyro task delete`. - `pyro task logs` returns persisted command history for that task until `pyro task delete`.
- Task create/status results expose `workspace_seed` metadata describing how `/workspace` was - Task create/status results expose `workspace_seed` metadata describing how `/workspace` was
@ -69,6 +72,7 @@ Supported public entrypoints:
- `Pyro.prune_environments()` - `Pyro.prune_environments()`
- `Pyro.create_vm(...)` - `Pyro.create_vm(...)`
- `Pyro.create_task(...)` - `Pyro.create_task(...)`
- `Pyro.push_task_sync(task_id, source_path, *, dest="/workspace")`
- `Pyro.start_vm(vm_id)` - `Pyro.start_vm(vm_id)`
- `Pyro.exec_vm(vm_id, *, command, timeout_seconds=30)` - `Pyro.exec_vm(vm_id, *, command, timeout_seconds=30)`
- `Pyro.exec_task(task_id, *, command, timeout_seconds=30)` - `Pyro.exec_task(task_id, *, command, timeout_seconds=30)`
@ -91,6 +95,7 @@ Stable public method names:
- `prune_environments()` - `prune_environments()`
- `create_vm(...)` - `create_vm(...)`
- `create_task(...)` - `create_task(...)`
- `push_task_sync(task_id, source_path, *, dest="/workspace")`
- `start_vm(vm_id)` - `start_vm(vm_id)`
- `exec_vm(vm_id, *, command, timeout_seconds=30)` - `exec_vm(vm_id, *, command, timeout_seconds=30)`
- `exec_task(task_id, *, command, timeout_seconds=30)` - `exec_task(task_id, *, command, timeout_seconds=30)`
@ -112,6 +117,8 @@ Behavioral defaults:
- `allow_host_compat` defaults to `False` on `create_task(...)`. - `allow_host_compat` defaults to `False` on `create_task(...)`.
- `Pyro.create_task(..., source_path=...)` seeds `/workspace` from a host directory or a local - `Pyro.create_task(..., source_path=...)` seeds `/workspace` from a host directory or a local
`.tar` / `.tar.gz` / `.tgz` archive before the task is returned. `.tar` / `.tar.gz` / `.tgz` archive before the task is returned.
- `Pyro.push_task_sync(...)` imports later host-side directory or archive content into a started
task workspace.
- `Pyro.exec_vm(...)` runs one command and auto-cleans that VM after the exec completes. - `Pyro.exec_vm(...)` runs one command and auto-cleans that VM after the exec completes.
- `Pyro.exec_task(...)` runs one command in the persistent task workspace and leaves the task alive. - `Pyro.exec_task(...)` runs one command in the persistent task workspace and leaves the task alive.
@ -136,6 +143,7 @@ Advanced lifecycle tools:
Task workspace tools: Task workspace tools:
- `task_create` - `task_create`
- `task_sync_push`
- `task_exec` - `task_exec`
- `task_status` - `task_status`
- `task_logs` - `task_logs`
@ -149,6 +157,8 @@ Behavioral defaults:
- `task_create` exposes `allow_host_compat`, which defaults to `false`. - `task_create` exposes `allow_host_compat`, which defaults to `false`.
- `task_create` accepts optional `source_path` and seeds `/workspace` from a host directory or a - `task_create` accepts optional `source_path` and seeds `/workspace` from a host directory or a
local `.tar` / `.tar.gz` / `.tgz` archive before the task is returned. local `.tar` / `.tar.gz` / `.tgz` archive before the task is returned.
- `task_sync_push` imports later host-side directory or archive content into a started task
workspace, with an optional `dest` under `/workspace`.
- `vm_exec` runs one command and auto-cleans that VM after the exec completes. - `vm_exec` runs one command and auto-cleans that VM after the exec completes.
- `task_exec` runs one command in a persistent `/workspace` and leaves the task alive. - `task_exec` runs one command in a persistent `/workspace` and leaves the task alive.

View file

@ -1,20 +1,29 @@
from __future__ import annotations from __future__ import annotations
import tempfile
from pathlib import Path
from pyro_mcp import Pyro from pyro_mcp import Pyro
def main() -> None: def main() -> None:
pyro = Pyro() pyro = Pyro()
created = pyro.create_task(environment="debian:12") with (
task_id = str(created["task_id"]) tempfile.TemporaryDirectory(prefix="pyro-task-seed-") as seed_dir,
try: tempfile.TemporaryDirectory(prefix="pyro-task-sync-") as sync_dir,
pyro.exec_task(task_id, command="printf 'hello from task\\n' > note.txt") ):
result = pyro.exec_task(task_id, command="cat note.txt") Path(seed_dir, "note.txt").write_text("hello from seed\n", encoding="utf-8")
print(result["stdout"], end="") Path(sync_dir, "note.txt").write_text("hello from sync\n", encoding="utf-8")
logs = pyro.logs_task(task_id) created = pyro.create_task(environment="debian:12", source_path=seed_dir)
print(f"task_id={task_id} command_count={logs['count']}") task_id = str(created["task_id"])
finally: try:
pyro.delete_task(task_id) pyro.push_task_sync(task_id, sync_dir)
result = pyro.exec_task(task_id, command="cat note.txt")
print(result["stdout"], end="")
logs = pyro.logs_task(task_id)
print(f"task_id={task_id} command_count={logs['count']}")
finally:
pyro.delete_task(task_id)
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -1,6 +1,6 @@
[project] [project]
name = "pyro-mcp" name = "pyro-mcp"
version = "2.2.0" version = "2.3.0"
description = "Curated Linux environments for ephemeral Firecracker-backed VM execution." description = "Curated Linux environments for ephemeral Firecracker-backed VM execution."
readme = "README.md" readme = "README.md"
license = { file = "LICENSE" } license = { file = "LICENSE" }

View file

@ -110,6 +110,15 @@ class Pyro:
def status_task(self, task_id: str) -> dict[str, Any]: def status_task(self, task_id: str) -> dict[str, Any]:
return self._manager.status_task(task_id) return self._manager.status_task(task_id)
def push_task_sync(
self,
task_id: str,
source_path: str | Path,
*,
dest: str = "/workspace",
) -> dict[str, Any]:
return self._manager.push_task_sync(task_id, source_path=source_path, dest=dest)
def logs_task(self, task_id: str) -> dict[str, Any]: def logs_task(self, task_id: str) -> dict[str, Any]:
return self._manager.logs_task(task_id) return self._manager.logs_task(task_id)
@ -269,6 +278,15 @@ class Pyro:
"""Run one command inside an existing task workspace.""" """Run one command inside an existing task workspace."""
return self.exec_task(task_id, command=command, timeout_seconds=timeout_seconds) return self.exec_task(task_id, command=command, timeout_seconds=timeout_seconds)
@server.tool()
async def task_sync_push(
task_id: str,
source_path: str,
dest: str = "/workspace",
) -> dict[str, Any]:
"""Push host content into the persistent workspace of a started task."""
return self.push_task_sync(task_id, source_path=source_path, dest=dest)
@server.tool() @server.tool()
async def task_status(task_id: str) -> dict[str, Any]: async def task_status(task_id: str) -> dict[str, Any]:
"""Inspect task state and latest command metadata.""" """Inspect task state and latest command metadata."""

View file

@ -197,6 +197,23 @@ def _print_task_exec_human(payload: dict[str, Any]) -> None:
) )
def _print_task_sync_human(payload: dict[str, Any]) -> None:
workspace_sync = payload.get("workspace_sync")
if not isinstance(workspace_sync, dict):
print(f"Synced task: {str(payload.get('task_id', 'unknown'))}")
return
print(
"[task-sync] "
f"task_id={str(payload.get('task_id', 'unknown'))} "
f"mode={str(workspace_sync.get('mode', 'unknown'))} "
f"source={str(workspace_sync.get('source_path', 'unknown'))} "
f"destination={str(workspace_sync.get('destination', TASK_WORKSPACE_GUEST_PATH))} "
f"entry_count={int(workspace_sync.get('entry_count', 0))} "
f"bytes_written={int(workspace_sync.get('bytes_written', 0))} "
f"execution_mode={str(payload.get('execution_mode', 'unknown'))}"
)
def _print_task_logs_human(payload: dict[str, Any]) -> None: def _print_task_logs_human(payload: dict[str, Any]) -> None:
entries = payload.get("entries") entries = payload.get("entries")
if not isinstance(entries, list) or not entries: if not isinstance(entries, list) or not entries:
@ -250,7 +267,8 @@ def _build_parser() -> argparse.ArgumentParser:
pyro run debian:12 -- git --version pyro run debian:12 -- git --version
Need repeated commands in one workspace after that? Need repeated commands in one workspace after that?
pyro task create debian:12 pyro task create debian:12 --source-path ./repo
pyro task sync push TASK_ID ./changes
Use `pyro mcp serve` only after the CLI validation path works. Use `pyro mcp serve` only after the CLI validation path works.
""" """
@ -456,6 +474,7 @@ def _build_parser() -> argparse.ArgumentParser:
""" """
Examples: Examples:
pyro task create debian:12 --source-path ./repo pyro task create debian:12 --source-path ./repo
pyro task sync push TASK_ID ./repo --dest src
pyro task exec TASK_ID -- sh -lc 'printf "hello\\n" > note.txt' pyro task exec TASK_ID -- sh -lc 'printf "hello\\n" > note.txt'
pyro task logs TASK_ID pyro task logs TASK_ID
""" """
@ -472,6 +491,7 @@ def _build_parser() -> argparse.ArgumentParser:
Examples: Examples:
pyro task create debian:12 pyro task create debian:12
pyro task create debian:12 --source-path ./repo pyro task create debian:12 --source-path ./repo
pyro task sync push TASK_ID ./changes
""" """
), ),
formatter_class=_HelpFormatter, formatter_class=_HelpFormatter,
@ -552,6 +572,56 @@ def _build_parser() -> argparse.ArgumentParser:
"for example `pyro task exec TASK_ID -- cat note.txt`." "for example `pyro task exec TASK_ID -- cat note.txt`."
), ),
) )
task_sync_parser = task_subparsers.add_parser(
"sync",
help="Push host content into a started task workspace.",
description=(
"Push host directory or archive content into `/workspace` for an existing "
"started task."
),
epilog=dedent(
"""
Examples:
pyro task sync push TASK_ID ./repo
pyro task sync push TASK_ID ./patches --dest src
Sync is non-atomic. If a sync fails partway through, delete and recreate the task.
"""
),
formatter_class=_HelpFormatter,
)
task_sync_subparsers = task_sync_parser.add_subparsers(
dest="task_sync_command",
required=True,
metavar="SYNC",
)
task_sync_push_parser = task_sync_subparsers.add_parser(
"push",
help="Push one host directory or archive into a started task.",
description="Import host content into `/workspace` or a subdirectory of it.",
epilog="Example:\n pyro task sync push TASK_ID ./repo --dest src",
formatter_class=_HelpFormatter,
)
task_sync_push_parser.add_argument(
"task_id",
metavar="TASK_ID",
help="Persistent task identifier.",
)
task_sync_push_parser.add_argument(
"source_path",
metavar="SOURCE_PATH",
help="Host directory or .tar/.tar.gz/.tgz archive to push into the task workspace.",
)
task_sync_push_parser.add_argument(
"--dest",
default=TASK_WORKSPACE_GUEST_PATH,
help="Workspace destination path. Relative values resolve inside `/workspace`.",
)
task_sync_push_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
task_status_parser = task_subparsers.add_parser( task_status_parser = task_subparsers.add_parser(
"status", "status",
help="Inspect one task workspace.", help="Inspect one task workspace.",
@ -821,6 +891,30 @@ def main() -> None:
if exit_code != 0: if exit_code != 0:
raise SystemExit(exit_code) raise SystemExit(exit_code)
return return
if args.task_command == "sync" and args.task_sync_command == "push":
if bool(args.json):
try:
payload = pyro.push_task_sync(
args.task_id,
args.source_path,
dest=args.dest,
)
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.push_task_sync(
args.task_id,
args.source_path,
dest=args.dest,
)
except Exception as exc: # noqa: BLE001
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
_print_task_sync_human(payload)
return
if args.task_command == "status": if args.task_command == "status":
payload = pyro.status_task(args.task_id) payload = pyro.status_task(args.task_id)
if bool(args.json): if bool(args.json):

View file

@ -5,7 +5,8 @@ from __future__ import annotations
PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "mcp", "run", "task") PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "mcp", "run", "task")
PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",) PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",)
PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune") PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune")
PUBLIC_CLI_TASK_SUBCOMMANDS = ("create", "delete", "exec", "logs", "status") PUBLIC_CLI_TASK_SUBCOMMANDS = ("create", "delete", "exec", "logs", "status", "sync")
PUBLIC_CLI_TASK_SYNC_SUBCOMMANDS = ("push",)
PUBLIC_CLI_TASK_CREATE_FLAGS = ( PUBLIC_CLI_TASK_CREATE_FLAGS = (
"--vcpu-count", "--vcpu-count",
"--mem-mib", "--mem-mib",
@ -15,6 +16,7 @@ PUBLIC_CLI_TASK_CREATE_FLAGS = (
"--source-path", "--source-path",
"--json", "--json",
) )
PUBLIC_CLI_TASK_SYNC_PUSH_FLAGS = ("--dest", "--json")
PUBLIC_CLI_RUN_FLAGS = ( PUBLIC_CLI_RUN_FLAGS = (
"--vcpu-count", "--vcpu-count",
"--mem-mib", "--mem-mib",
@ -39,6 +41,7 @@ PUBLIC_SDK_METHODS = (
"network_info_vm", "network_info_vm",
"prune_environments", "prune_environments",
"pull_environment", "pull_environment",
"push_task_sync",
"reap_expired", "reap_expired",
"run_in_vm", "run_in_vm",
"start_vm", "start_vm",
@ -63,4 +66,5 @@ PUBLIC_MCP_TOOLS = (
"task_exec", "task_exec",
"task_logs", "task_logs",
"task_status", "task_status",
"task_sync_push",
) )

View file

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

View file

@ -194,11 +194,11 @@ class PreparedWorkspaceSeed:
bytes_written: int = 0 bytes_written: int = 0
cleanup_dir: Path | None = None cleanup_dir: Path | None = None
def to_payload(self) -> dict[str, Any]: def to_payload(self, *, destination: str = TASK_WORKSPACE_GUEST_PATH) -> dict[str, Any]:
return { return {
"mode": self.mode, "mode": self.mode,
"source_path": self.source_path, "source_path": self.source_path,
"destination": TASK_WORKSPACE_GUEST_PATH, "destination": destination,
"entry_count": self.entry_count, "entry_count": self.entry_count,
"bytes_written": self.bytes_written, "bytes_written": self.bytes_written,
} }
@ -372,6 +372,8 @@ def _normalize_workspace_destination(destination: str) -> tuple[str, PurePosixPa
if candidate == "": if candidate == "":
raise ValueError("workspace destination must not be empty") raise ValueError("workspace destination must not be empty")
destination_path = PurePosixPath(candidate) destination_path = PurePosixPath(candidate)
if any(part == ".." for part in destination_path.parts):
raise ValueError("workspace destination must stay inside /workspace")
workspace_root = PurePosixPath(TASK_WORKSPACE_GUEST_PATH) workspace_root = PurePosixPath(TASK_WORKSPACE_GUEST_PATH)
if not destination_path.is_absolute(): if not destination_path.is_absolute():
destination_path = workspace_root / destination_path destination_path = workspace_root / destination_path
@ -1218,6 +1220,52 @@ class VmManager:
finally: finally:
prepared_seed.cleanup() prepared_seed.cleanup()
def push_task_sync(
self,
task_id: str,
*,
source_path: str | Path,
dest: str = TASK_WORKSPACE_GUEST_PATH,
) -> dict[str, Any]:
prepared_seed = self._prepare_workspace_seed(source_path)
if prepared_seed.archive_path is None:
prepared_seed.cleanup()
raise ValueError("source_path is required")
normalized_destination, _ = _normalize_workspace_destination(dest)
with self._lock:
task = self._load_task_locked(task_id)
self._ensure_task_not_expired_locked(task, time.time())
self._refresh_task_liveness_locked(task)
if task.state != "started":
raise RuntimeError(
f"task {task_id} must be in 'started' state before task_sync_push"
)
instance = task.to_instance(workdir=self._task_runtime_dir(task.task_id))
try:
import_summary = self._backend.import_archive(
instance,
archive_path=prepared_seed.archive_path,
destination=normalized_destination,
)
finally:
prepared_seed.cleanup()
workspace_sync = prepared_seed.to_payload(destination=normalized_destination)
workspace_sync["entry_count"] = int(import_summary["entry_count"])
workspace_sync["bytes_written"] = int(import_summary["bytes_written"])
workspace_sync["destination"] = str(import_summary["destination"])
with self._lock:
task = self._load_task_locked(task_id)
task.state = instance.state
task.firecracker_pid = instance.firecracker_pid
task.last_error = instance.last_error
task.metadata = dict(instance.metadata)
self._save_task_locked(task)
return {
"task_id": task_id,
"execution_mode": instance.metadata.get("execution_mode", "pending"),
"workspace_sync": workspace_sync,
}
def exec_task(self, task_id: str, *, command: str, timeout_seconds: int = 30) -> dict[str, Any]: def exec_task(self, task_id: str, *, command: str, timeout_seconds: int = 30) -> dict[str, Any]:
if timeout_seconds <= 0: if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive") raise ValueError("timeout_seconds must be positive")

View file

@ -49,6 +49,7 @@ def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None:
assert "vm_run" in tool_names assert "vm_run" in tool_names
assert "vm_create" in tool_names assert "vm_create" in tool_names
assert "task_create" in tool_names assert "task_create" in tool_names
assert "task_sync_push" in tool_names
def test_pyro_vm_run_tool_executes(tmp_path: Path) -> None: def test_pyro_vm_run_tool_executes(tmp_path: Path) -> None:
@ -124,6 +125,10 @@ def test_pyro_task_methods_delegate_to_manager(tmp_path: Path) -> None:
source_path=source_dir, source_path=source_dir,
) )
task_id = str(created["task_id"]) task_id = str(created["task_id"])
updated_dir = tmp_path / "updated"
updated_dir.mkdir()
(updated_dir / "more.txt").write_text("more\n", encoding="utf-8")
synced = pyro.push_task_sync(task_id, updated_dir, dest="subdir")
executed = pyro.exec_task(task_id, command="cat note.txt") executed = pyro.exec_task(task_id, command="cat note.txt")
status = pyro.status_task(task_id) status = pyro.status_task(task_id)
logs = pyro.logs_task(task_id) logs = pyro.logs_task(task_id)
@ -131,6 +136,7 @@ def test_pyro_task_methods_delegate_to_manager(tmp_path: Path) -> None:
assert executed["stdout"] == "ok\n" assert executed["stdout"] == "ok\n"
assert created["workspace_seed"]["mode"] == "directory" assert created["workspace_seed"]["mode"] == "directory"
assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
assert status["command_count"] == 1 assert status["command_count"] == 1
assert logs["count"] == 1 assert logs["count"] == 1
assert deleted["deleted"] is True assert deleted["deleted"] is True

View file

@ -30,6 +30,7 @@ def test_cli_help_guides_first_run() -> None:
assert "pyro env list" in help_text assert "pyro env list" in help_text
assert "pyro env pull debian:12" in help_text assert "pyro env pull debian:12" in help_text
assert "pyro run debian:12 -- git --version" in help_text assert "pyro run debian:12 -- git --version" in help_text
assert "pyro task sync push TASK_ID ./changes" in help_text
assert "Use `pyro mcp serve` only after the CLI validation path works." in help_text assert "Use `pyro mcp serve` only after the CLI validation path works." in help_text
@ -61,6 +62,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
task_help = _subparser_choice(parser, "task").format_help() task_help = _subparser_choice(parser, "task").format_help()
assert "pyro task create debian:12 --source-path ./repo" in task_help assert "pyro task create debian:12 --source-path ./repo" in task_help
assert "pyro task sync push TASK_ID ./repo --dest src" in task_help
assert "pyro task exec TASK_ID" in task_help assert "pyro task exec TASK_ID" in task_help
task_create_help = _subparser_choice(_subparser_choice(parser, "task"), "create").format_help() task_create_help = _subparser_choice(_subparser_choice(parser, "task"), "create").format_help()
@ -71,6 +73,16 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
assert "persistent `/workspace`" in task_exec_help assert "persistent `/workspace`" in task_exec_help
assert "pyro task exec TASK_ID -- cat note.txt" in task_exec_help assert "pyro task exec TASK_ID -- cat note.txt" in task_exec_help
task_sync_help = _subparser_choice(_subparser_choice(parser, "task"), "sync").format_help()
assert "Sync is non-atomic." in task_sync_help
assert "pyro task sync push TASK_ID ./repo" in task_sync_help
task_sync_push_help = _subparser_choice(
_subparser_choice(_subparser_choice(parser, "task"), "sync"), "push"
).format_help()
assert "--dest" in task_sync_push_help
assert "Import host content into `/workspace`" in task_sync_push_help
def test_cli_run_prints_json( def test_cli_run_prints_json(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
@ -456,6 +468,89 @@ def test_cli_task_exec_prints_human_output(
assert "[task-exec] task_id=task-123 sequence=2 cwd=/workspace" in captured.err assert "[task-exec] task_id=task-123 sequence=2 cwd=/workspace" in captured.err
def test_cli_task_sync_push_prints_json(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
class StubPyro:
def push_task_sync(self, task_id: str, source_path: str, *, dest: str) -> dict[str, Any]:
assert task_id == "task-123"
assert source_path == "./repo"
assert dest == "src"
return {
"task_id": task_id,
"execution_mode": "guest_vsock",
"workspace_sync": {
"mode": "directory",
"source_path": "/tmp/repo",
"destination": "/workspace/src",
"entry_count": 2,
"bytes_written": 12,
},
}
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="task",
task_command="sync",
task_sync_command="push",
task_id="task-123",
source_path="./repo",
dest="src",
json=True,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
output = json.loads(capsys.readouterr().out)
assert output["workspace_sync"]["destination"] == "/workspace/src"
def test_cli_task_sync_push_prints_human(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def push_task_sync(self, task_id: str, source_path: str, *, dest: str) -> dict[str, Any]:
assert task_id == "task-123"
assert source_path == "./repo"
assert dest == "/workspace"
return {
"task_id": task_id,
"execution_mode": "guest_vsock",
"workspace_sync": {
"mode": "directory",
"source_path": "/tmp/repo",
"destination": "/workspace",
"entry_count": 2,
"bytes_written": 12,
},
}
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="task",
task_command="sync",
task_sync_command="push",
task_id="task-123",
source_path="./repo",
dest="/workspace",
json=False,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
output = capsys.readouterr().out
assert "[task-sync] task_id=task-123 mode=directory source=/tmp/repo" in output
assert (
"destination=/workspace entry_count=2 bytes_written=12 "
"execution_mode=guest_vsock"
) in output
def test_cli_task_logs_and_delete_print_human( def test_cli_task_logs_and_delete_print_human(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str], capsys: pytest.CaptureFixture[str],

View file

@ -19,6 +19,8 @@ from pyro_mcp.contract import (
PUBLIC_CLI_RUN_FLAGS, PUBLIC_CLI_RUN_FLAGS,
PUBLIC_CLI_TASK_CREATE_FLAGS, PUBLIC_CLI_TASK_CREATE_FLAGS,
PUBLIC_CLI_TASK_SUBCOMMANDS, PUBLIC_CLI_TASK_SUBCOMMANDS,
PUBLIC_CLI_TASK_SYNC_PUSH_FLAGS,
PUBLIC_CLI_TASK_SYNC_SUBCOMMANDS,
PUBLIC_MCP_TOOLS, PUBLIC_MCP_TOOLS,
PUBLIC_SDK_METHODS, PUBLIC_SDK_METHODS,
) )
@ -73,6 +75,14 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
).format_help() ).format_help()
for flag in PUBLIC_CLI_TASK_CREATE_FLAGS: for flag in PUBLIC_CLI_TASK_CREATE_FLAGS:
assert flag in task_create_help_text assert flag in task_create_help_text
task_sync_help_text = _subparser_choice(_subparser_choice(parser, "task"), "sync").format_help()
for subcommand_name in PUBLIC_CLI_TASK_SYNC_SUBCOMMANDS:
assert subcommand_name in task_sync_help_text
task_sync_push_help_text = _subparser_choice(
_subparser_choice(_subparser_choice(parser, "task"), "sync"), "push"
).format_help()
for flag in PUBLIC_CLI_TASK_SYNC_PUSH_FLAGS:
assert flag in task_sync_push_help_text
demo_help_text = _subparser_choice(parser, "demo").format_help() demo_help_text = _subparser_choice(parser, "demo").format_help()
for subcommand_name in PUBLIC_CLI_DEMO_SUBCOMMANDS: for subcommand_name in PUBLIC_CLI_DEMO_SUBCOMMANDS:

View file

@ -33,6 +33,7 @@ def test_create_server_registers_vm_tools(tmp_path: Path) -> None:
assert "vm_status" in tool_names assert "vm_status" in tool_names
assert "task_create" in tool_names assert "task_create" in tool_names
assert "task_logs" in tool_names assert "task_logs" in tool_names
assert "task_sync_push" in tool_names
def test_vm_run_round_trip(tmp_path: Path) -> None: def test_vm_run_round_trip(tmp_path: Path) -> None:
@ -183,7 +184,13 @@ def test_task_tools_round_trip(tmp_path: Path) -> None:
raise TypeError("expected structured dictionary result") raise TypeError("expected structured dictionary result")
return cast(dict[str, Any], structured) return cast(dict[str, Any], structured)
async def _run() -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]: async def _run() -> tuple[
dict[str, Any],
dict[str, Any],
dict[str, Any],
dict[str, Any],
dict[str, Any],
]:
server = create_server(manager=manager) server = create_server(manager=manager)
created = _extract_structured( created = _extract_structured(
await server.call_tool( await server.call_tool(
@ -196,22 +203,36 @@ def test_task_tools_round_trip(tmp_path: Path) -> None:
) )
) )
task_id = str(created["task_id"]) task_id = str(created["task_id"])
update_dir = tmp_path / "update"
update_dir.mkdir()
(update_dir / "more.txt").write_text("more\n", encoding="utf-8")
synced = _extract_structured(
await server.call_tool(
"task_sync_push",
{
"task_id": task_id,
"source_path": str(update_dir),
"dest": "subdir",
},
)
)
executed = _extract_structured( executed = _extract_structured(
await server.call_tool( await server.call_tool(
"task_exec", "task_exec",
{ {
"task_id": task_id, "task_id": task_id,
"command": "cat note.txt", "command": "cat subdir/more.txt",
}, },
) )
) )
logs = _extract_structured(await server.call_tool("task_logs", {"task_id": task_id})) logs = _extract_structured(await server.call_tool("task_logs", {"task_id": task_id}))
deleted = _extract_structured(await server.call_tool("task_delete", {"task_id": task_id})) deleted = _extract_structured(await server.call_tool("task_delete", {"task_id": task_id}))
return created, executed, logs, deleted return created, synced, executed, logs, deleted
created, executed, logs, deleted = asyncio.run(_run()) created, synced, executed, logs, deleted = asyncio.run(_run())
assert created["state"] == "started" assert created["state"] == "started"
assert created["workspace_seed"]["mode"] == "directory" assert created["workspace_seed"]["mode"] == "directory"
assert executed["stdout"] == "ok\n" assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
assert executed["stdout"] == "more\n"
assert logs["count"] == 1 assert logs["count"] == 1
assert deleted["deleted"] is True assert deleted["deleted"] is True

View file

@ -363,6 +363,90 @@ def test_task_create_seeds_tar_archive_into_workspace(tmp_path: Path) -> None:
assert executed["stdout"] == "archive\n" assert executed["stdout"] == "archive\n"
def test_task_sync_push_updates_started_workspace(tmp_path: Path) -> None:
source_dir = tmp_path / "seed"
source_dir.mkdir()
(source_dir / "note.txt").write_text("hello\n", encoding="utf-8")
update_dir = tmp_path / "update"
update_dir.mkdir()
(update_dir / "more.txt").write_text("more\n", encoding="utf-8")
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
created = manager.create_task(
environment="debian:12-base",
allow_host_compat=True,
source_path=source_dir,
)
task_id = str(created["task_id"])
synced = manager.push_task_sync(task_id, source_path=update_dir, dest="subdir")
assert synced["workspace_sync"]["mode"] == "directory"
assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
executed = manager.exec_task(task_id, command="cat subdir/more.txt", timeout_seconds=30)
assert executed["stdout"] == "more\n"
status = manager.status_task(task_id)
assert status["command_count"] == 1
assert status["workspace_seed"]["mode"] == "directory"
def test_task_sync_push_requires_started_task(tmp_path: Path) -> None:
source_dir = tmp_path / "seed"
source_dir.mkdir()
(source_dir / "note.txt").write_text("hello\n", encoding="utf-8")
update_dir = tmp_path / "update"
update_dir.mkdir()
(update_dir / "more.txt").write_text("more\n", encoding="utf-8")
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
created = manager.create_task(
environment="debian:12-base",
allow_host_compat=True,
source_path=source_dir,
)
task_id = str(created["task_id"])
task_path = tmp_path / "vms" / "tasks" / task_id / "task.json"
payload = json.loads(task_path.read_text(encoding="utf-8"))
payload["state"] = "stopped"
task_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
with pytest.raises(RuntimeError, match="must be in 'started' state before task_sync_push"):
manager.push_task_sync(task_id, source_path=update_dir)
def test_task_sync_push_rejects_destination_outside_workspace(tmp_path: Path) -> None:
source_dir = tmp_path / "seed"
source_dir.mkdir()
(source_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),
)
task_id = str(
manager.create_task(
environment="debian:12-base",
allow_host_compat=True,
)["task_id"]
)
with pytest.raises(ValueError, match="workspace destination must stay inside /workspace"):
manager.push_task_sync(task_id, source_path=source_dir, dest="../escape")
def test_task_create_rejects_unsafe_seed_archive(tmp_path: Path) -> None: def test_task_create_rejects_unsafe_seed_archive(tmp_path: Path) -> None:
archive_path = tmp_path / "bad.tgz" archive_path = tmp_path / "bad.tgz"
with tarfile.open(archive_path, "w:gz") as archive: with tarfile.open(archive_path, "w:gz") as archive:

2
uv.lock generated
View file

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