Compare commits

...

8 commits

Author SHA1 Message Date
aeed5e1943
add milestones 2026-03-14 11:18:48 -03:00
663241d5d2 Add daily-loop prepare and readiness checks
Make the local chat-host loop explicit and cheap so users can warm the machine once instead of rediscovering environment and guest setup on every session.

Add cache-backed daily-loop manifests plus the new `pyro prepare` flow, extend `pyro doctor --environment` with warm/cold/stale readiness reporting, and add `make smoke-daily-loop` to prove the warmed repro-fix reset path end to end.

Also fix `python -m pyro_mcp.cli` to invoke `main()` so the new smoke and `dist-check` actually exercise the CLI module, and update the docs/roadmap to present `doctor -> prepare -> connect host -> reset` as the recommended daily path.

Validation: `uv lock`, `UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make check`, `UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make dist-check`, and `UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make smoke-daily-loop`.
2026-03-13 21:17:59 -03:00
d0cf6d8f21 Add opinionated MCP modes for workspace workflows
Introduce explicit repro-fix, inspect, cold-start, and review-eval modes across the MCP server, CLI, and host helpers, with canonical mode-to-tool mappings, narrowed schemas, and mode-specific tool descriptions on top of the existing workspace runtime.

Reposition the docs, host onramps, and use-case recipes so named modes are the primary user-facing startup story while the generic no-mode workspace-core path remains the escape hatch, and update the shared smoke runner to validate repro-fix and cold-start through mode-backed servers.

Validation: UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache uv run pytest --no-cov tests/test_api.py tests/test_server.py tests/test_host_helpers.py tests/test_public_contract.py tests/test_cli.py tests/test_workspace_use_case_smokes.py; UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make check; UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make dist-check; real guest-backed make smoke-repro-fix-loop smoke-cold-start-validation outside the sandbox.
2026-03-13 20:00:35 -03:00
dc86d84e96 Add workspace review summaries
Add workspace summary across the CLI, SDK, and MCP, and include it in the workspace-core profile so chat hosts can review one concise view of the current session.

Persist lightweight review events for syncs, file edits, patch applies, exports, service lifecycle, and snapshot activity, then synthesize them with command history, current services, snapshot state, and current diff data since the last reset.

Update the walkthroughs, use-case docs, public contract, changelog, and roadmap for 4.3.0, and make dist-check invoke the CLI module directly so local package reinstall quirks do not break the packaging gate.

Validation: uv lock; ./.venv/bin/pytest --no-cov tests/test_vm_manager.py tests/test_cli.py tests/test_api.py tests/test_server.py tests/test_public_contract.py tests/test_workspace_use_case_smokes.py; UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make check; UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make dist-check; real guest-backed workspace create -> patch apply -> workspace summary --json -> delete smoke.
2026-03-13 19:21:11 -03:00
899a6760c4 Add host bootstrap and repair helpers
Add a dedicated pyro host surface for supported chat hosts so Claude Code, Codex, and OpenCode users can connect or repair the canonical MCP setup without hand-writing raw commands or config edits.

Implement the shared host helper layer and wire it through the CLI with connect, print-config, doctor, and repair, all generated from the same canonical pyro mcp serve command shape and project-source flags. Update the docs, public contract, examples, changelog, and roadmap so the helper flow becomes the primary onramp while raw host-specific commands remain as reference material.

Harden the verification path that this milestone exposed: temp git repos in tests now disable commit signing, socket-based port tests skip cleanly when the sandbox forbids those primitives, and make test still uses multiple cores by default but caps xdist workers to a stable value so make check stays fast and deterministic here.

Validation:
- uv lock
- UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make check
- UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make dist-check
2026-03-13 16:46:29 -03:00
535efc6919 Add project-aware chat startup defaults
Make repo-root chat startup native by letting MCP servers carry a default project source for workspace creation. When a chat host starts from a Git checkout, workspace_create can now omit seed_path and inherit the server startup source; explicit --project-path and clean-clone --repo-url/--repo-ref paths are supported as fallbacks.

Add project startup resolution and materialization, surface origin_kind/origin_ref in workspace_seed, update chat-host docs and the repro/fix smoke to use project-aware workspace creation, and switch dist-check to uv run pyro so verification stays stable after uv reinstalls.

Validated with uv lock, focused startup/server/CLI pytest coverage, UV_CACHE_DIR=.uv-cache make check, UV_CACHE_DIR=.uv-cache make dist-check, and real guest-backed smokes for both explicit project_path and bare repo-root auto-detection.
2026-03-13 15:51:47 -03:00
9b9b83ebeb
Add post-4.0 chat product roadmap
Extend the chat ergonomics roadmap now that the core workspace and MCP path are in place for the narrowed chat-host persona.

Add the next planned phase around project-aware chat startup, host bootstrap and repair, reviewable agent output, opinionated use-case modes, and faster daily loops so the roadmap keeps pushing toward a repo-aware daily tool instead of a generic VM or SDK story.

Document the constraints explicitly: optimize the MCP/chat-host path first, keep disk tools secondary, and take advantage of the current no-users-yet window to make breaking product-shaping changes when needed.
2026-03-13 15:18:02 -03:00
999fe1b23a
Reframe pyro around the chat-host path
Make the docs and help text unapologetically teach  as the product path for Claude Code, Codex, and OpenCode on Linux KVM.

Rewrite the README, install/first-run/integration guides, public contract, vision, and use-case docs around the zero-to-hero chat flow, and explicitly note that there are no users yet so breaking changes are acceptable while the interface is still being shaped.

Update package metadata, CLI help, and the docs/help expectation tests to match the new positioning. Validate the reframe with usage: pyro [-h] [--version] COMMAND ...

Validate the host and serve disposable MCP workspaces for chat-based coding agents on supported Linux x86_64 KVM hosts.

positional arguments:
  COMMAND
    env        Inspect and manage curated environments.
    mcp        Run the MCP server.
    run        Run one command inside an ephemeral VM.
    workspace  Manage persistent workspaces.
    doctor     Inspect runtime and host diagnostics.
    demo       Run built-in demos.

options:
  -h, --help   show this help message and exit
  --version    show program's version number and exit

Suggested zero-to-hero path:
  pyro doctor
  pyro env list
  pyro env pull debian:12
  pyro run debian:12 -- git --version
  pyro mcp serve

Connect a chat host after that:
  claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
  codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve

If you want terminal-level visibility into the workspace model:
  pyro workspace create debian:12 --seed-path ./repo --id-only
  pyro workspace sync push WORKSPACE_ID ./changes
  pyro workspace exec WORKSPACE_ID -- cat note.txt
  pyro workspace diff WORKSPACE_ID
  pyro workspace snapshot create WORKSPACE_ID checkpoint
  pyro workspace reset WORKSPACE_ID --snapshot checkpoint
  pyro workspace shell open WORKSPACE_ID --id-only
  pyro workspace service start WORKSPACE_ID app --ready-file .ready --                 sh -lc 'touch .ready && while true; do sleep 60; done'
  pyro workspace export WORKSPACE_ID note.txt --output ./note.txt, usage: pyro mcp serve [-h] [--profile {vm-run,workspace-core,workspace-full}]

Expose pyro tools over stdio for an MCP client. Bare `pyro mcp serve` now starts `workspace-core`, the recommended first profile for most chat hosts.

options:
  -h, --help            show this help message and exit
  --profile {vm-run,workspace-core,workspace-full}
                        Expose only one model-facing tool profile. `workspace-
                        core` is the default and recommended first profile for
                        most chat hosts; `workspace-full` is the larger opt-in
                        profile. (default: workspace-core)

Default and recommended first start:
  pyro mcp serve

Profiles:
  workspace-core: default for normal persistent chat editing
  vm-run: smallest one-shot-only surface
  workspace-full: larger opt-in surface for shells, services,
    snapshots, secrets, network policy, and disk tools

Use --profile workspace-full only when the host truly needs those
extra workspace capabilities., and uv run ruff check .
All checks passed!
uv run mypy
Success: no issues found in 61 source files
uv run pytest -n auto
============================= test session starts ==============================
platform linux -- Python 3.12.10, pytest-9.0.2, pluggy-1.6.0
rootdir: /home/thales/projects/personal/pyro
configfile: pyproject.toml
testpaths: tests
plugins: anyio-4.12.1, xdist-3.8.0, cov-7.0.0
created: 32/32 workers
32 workers [393 items]

........................................................................ [ 18%]
........................................................................ [ 36%]
........................................................................ [ 54%]
........................................................................ [ 73%]
........................................................................ [ 91%]
.................................                                        [100%]
=============================== warnings summary ===============================
../../../.local/share/uv/python/cpython-3.12.10-linux-x86_64-gnu/lib/python3.12/importlib/metadata/__init__.py:467: 32 warnings
  /home/thales/.local/share/uv/python/cpython-3.12.10-linux-x86_64-gnu/lib/python3.12/importlib/metadata/__init__.py:467: DeprecationWarning: Implicit None on return values is deprecated and will raise KeyErrors.
    return self.metadata['Version']

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.10-final-0 _______________

Name                                        Stmts   Miss  Cover   Missing
-------------------------------------------------------------------------
src/pyro_mcp/__init__.py                       25      0   100%
src/pyro_mcp/api.py                           307      7    98%   37-38, 63, 69, 72, 75, 548
src/pyro_mcp/cli.py                          1132    141    88%   288-289, 332-333, 336, 344, 367-368, 394-395, 398, 406, 450, 460-461, 464, 477, 483-484, 498-499, 502, 566-575, 592-593, 596, 635, 2180, 2182, 2226, 2236, 2280, 2284-2285, 2295, 2302, 2344-2351, 2392, 2409-2414, 2459-2461, 2470-2472, 2483-2485, 2494-2496, 2503-2505, 2510-2512, 2523-2528, 2530, 2541-2546, 2567-2572, 2574, 2589-2594, 2596, 2608, 2623, 2637, 2655-2660, 2669-2674, 2676, 2683-2688, 2690, 2701-2706, 2708, 2719-2724, 2726, 2737-2742, 2764, 2787, 2806, 2824, 2841, 2899, 3017
src/pyro_mcp/contract.py                       52      0   100%
src/pyro_mcp/demo.py                           16      0   100%
src/pyro_mcp/doctor.py                         12      0   100%
src/pyro_mcp/ollama_demo.py                   245      6    98%   289, 294, 299, 318, 439, 550
src/pyro_mcp/runtime.py                       142     14    90%   80, 84, 88, 92, 120, 130, 144, 173, 182, 194, 230-232, 262
src/pyro_mcp/runtime_boot_check.py             33      0   100%
src/pyro_mcp/runtime_build.py                 546     47    91%   92, 127, 181, 189, 238-240, 263-265, 300, 325, 331, 340-341, 343, 392, 396, 413, 416, 492-494, 497-499, 522, 525, 578, 615, 620, 646-647, 649, 686, 688, 694, 697, 725, 765, 779, 791, 805, 808, 1002, 1009, 1198
src/pyro_mcp/runtime_bundle/__init__.py         0      0   100%
src/pyro_mcp/runtime_network_check.py          15      0   100%
src/pyro_mcp/server.py                          8      0   100%
src/pyro_mcp/vm_environments.py               386     55    86%   128, 131, 267, 274, 281, 304-306, 329-331, 352-353, 355, 380, 382, 392-394, 415, 418, 421, 429, 431, 436-437, 446-448, 488, 495-496, 502, 515, 526, 539, 546, 549, 570, 596, 599, 608-609, 613, 617, 626, 629, 636, 644, 647, 659, 667, 676, 682, 685
src/pyro_mcp/vm_firecracker.py                 47      0   100%
src/pyro_mcp/vm_guest.py                      206     22    89%   139, 142, 173, 176, 202, 205, 208, 211, 217, 239, 262-279, 291, 313, 633-634, 643
src/pyro_mcp/vm_manager.py                   2846    355    88%   625, 642, 650-657, 677, 684, 688, 712-715, 795-796, 818, 828, 830, 845, 853-855, 858, 870, 872, 881, 889, 892, 901-902, 910, 913, 919, 926, 929, 933, 951-955, 1010-1011, 1050, 1096, 1102, 1114, 1150, 1156, 1159, 1168, 1170, 1173-1177, 1230, 1236, 1239, 1248, 1250, 1253-1257, 1268, 1277, 1280, 1284-1290, 1319, 1322-1324, 1326, 1333, 1335, 1345, 1347, 1349, 1352-1353, 1361, 1377, 1379, 1381, 1391, 1403-1404, 1408, 1424, 1440-1441, 1443, 1447, 1450-1451, 1469, 1476, 1488, 1505, 1508-1509, 1511, 1582-1583, 1586-1588, 1599, 1602, 1605, 1617, 1638, 1649-1650, 1657-1658, 1669-1671, 1781, 1792-1798, 1808, 1860, 1870, 1891, 1894-1895, 1901-1904, 1910, 1922-1962, 1991-1993, 2034, 2046-2047, 2077, 2146, 2175, 2524-2528, 2598-2602, 2614, 2720, 3563, 3577, 3580, 3583, 3648-3653, 3720, 3802, 3842-3843, 3846-3847, 3862-3863, 3914, 4194, 4229, 4232, 4237, 4250, 4254, 4263, 4277, 4316, 4349, 4444, 4472-4473, 4477-4478, 4504, 4530-4531, 4576, 4578, 4600-4601, 4629, 4631, 4661-4662, 4681-4682, 4734, 4738, 4741-4743, 4745, 4747, 4776-4777, 4809-4845, 4863-4864, 4903, 4905, 4934, 4954-4955, 4977, 4988-4990, 5036, 5049-5050, 5059-5061, 5104-5105, 5171-5178, 5189-5192, 5203, 5208, 5216-5230, 5240, 5473-5476, 5485-5490, 5498-5503, 5513, 5557, 5577, 5601-5602, 5678-5680, 5706-5725, 5784, 5789, 5804, 5832, 5836, 5848, 5884-5886, 5946, 5950, 6079, 6111, 6155, 6170, 6189, 6201, 6242, 6251, 6256, 6269, 6274, 6296, 6394, 6422-6423
src/pyro_mcp/vm_network.py                    134     22    84%   65-66, 139, 201, 203, 205, 226, 317-331, 350-351, 360, 362, 372-384
src/pyro_mcp/workspace_disk.py                164      0   100%
src/pyro_mcp/workspace_files.py               293      0   100%
src/pyro_mcp/workspace_ports.py                79      1    99%   116
src/pyro_mcp/workspace_shell_output.py         88      2    98%   16, 61
src/pyro_mcp/workspace_shells.py              235     26    89%   105-118, 193-194, 226-227, 230-235, 251, 257-259, 263, 270-271, 299, 301, 303, 306-307
src/pyro_mcp/workspace_use_case_smokes.py     216      8    96%   131, 134-135, 423-426, 490
-------------------------------------------------------------------------
TOTAL                                        7227    706    90%
Required test coverage of 90% reached. Total coverage: 90.23%
======================= 393 passed, 32 warnings in 5.60s =======================.
2026-03-13 15:03:20 -03:00
58 changed files with 7145 additions and 1935 deletions

View file

@ -2,6 +2,63 @@
All notable user-visible changes to `pyro-mcp` are documented here.
## 4.5.0
- Added `pyro prepare` as the machine-level warmup path for the daily local
loop, with cached reuse when the runtime, catalog, and environment state are
already warm.
- Extended `pyro doctor` with daily-loop readiness output so users can see
whether the machine is cold, warm, or stale for `debian:12` before they
reconnect a chat host.
- Added `make smoke-daily-loop` to prove the warmed repro/fix/reset path end
to end on a real guest-backed machine.
## 4.4.0
- Added explicit named MCP/server modes for the main workspace workflows:
`repro-fix`, `inspect`, `cold-start`, and `review-eval`.
- Kept the generic no-mode `workspace-core` path available as the escape hatch,
while making named modes the first user-facing story across help text, host
helpers, and the recipe docs.
- Aligned the shared use-case smoke runner with those modes so the repro/fix
and cold-start flows now prove a mode-backed happy path instead of only the
generic profile path.
## 4.3.0
- Added `pyro workspace summary`, `Pyro.summarize_workspace()`, and MCP
`workspace_summary` so users and chat hosts can review a concise view of the
current workspace session since the last reset.
- Added a lightweight review-event log for edits, syncs, exports, service
lifecycle, and snapshot activity without duplicating the command journal.
- Updated the main workspace walkthroughs and review/eval recipe so
`workspace summary` is the first review surface before dropping down to raw
diffs, logs, and exported files.
## 4.2.0
- Added host bootstrap and repair helpers with `pyro host connect`,
`pyro host print-config`, `pyro host doctor`, and `pyro host repair` for the
supported Claude Code, Codex, and OpenCode flows.
- Repositioned the docs and examples so supported hosts now start from the
helper flow first, while keeping raw `pyro mcp serve` commands as the
underlying MCP entrypoint and advanced fallback.
- Added deterministic host-helper coverage so the shipped helper commands and
OpenCode config snippet stay aligned with the canonical `pyro mcp serve`
command shape.
## 4.1.0
- Added project-aware MCP startup so bare `pyro mcp serve` from a repo root can
auto-detect the current Git checkout and let `workspace_create` omit
`seed_path` safely.
- Added explicit fallback startup flags for chat hosts that do not preserve the
server working directory: `--project-path`, `--repo-url`, `--repo-ref`, and
`--no-project-source`.
- Extended workspace seed metadata with startup origin fields so chat-facing
workspace creation can show whether a workspace came from a manual seed path,
the current project, or a clean cloned repo source.
## 4.0.0
- Flipped the default MCP/server profile from `workspace-full` to

View file

@ -1,6 +1,7 @@
PYTHON ?= uv run python
UV_CACHE_DIR ?= .uv-cache
PYTEST_FLAGS ?= -n auto
PYTEST_WORKERS ?= $(shell sh -c 'n=$$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc 2>/dev/null || echo 2); if [ "$$n" -gt 8 ]; then n=8; fi; if [ "$$n" -lt 2 ]; then echo 1; else echo $$n; fi')
PYTEST_FLAGS ?= -n $(PYTEST_WORKERS)
OLLAMA_BASE_URL ?= http://localhost:11434/v1
OLLAMA_MODEL ?= llama3.2:3b
OLLAMA_DEMO_FLAGS ?=
@ -17,8 +18,9 @@ TWINE_USERNAME ?= __token__
PYPI_REPOSITORY_URL ?=
USE_CASE_ENVIRONMENT ?= debian:12
USE_CASE_SMOKE_FLAGS ?=
DAILY_LOOP_ENVIRONMENT ?= debian:12
.PHONY: help setup lint format typecheck test check dist-check pypi-publish demo network-demo doctor ollama ollama-demo run-server install-hooks smoke-use-cases smoke-cold-start-validation smoke-repro-fix-loop smoke-parallel-workspaces smoke-untrusted-inspection smoke-review-eval runtime-bundle runtime-binaries runtime-kernel runtime-rootfs runtime-agent runtime-validate runtime-manifest runtime-sync runtime-clean runtime-fetch-binaries runtime-build-kernel-real runtime-build-rootfs-real runtime-materialize runtime-export-environment-oci runtime-export-official-environments-oci runtime-publish-environment-oci runtime-publish-official-environments-oci runtime-boot-check runtime-network-check
.PHONY: help setup lint format typecheck test check dist-check pypi-publish demo network-demo doctor ollama ollama-demo run-server install-hooks smoke-daily-loop smoke-use-cases smoke-cold-start-validation smoke-repro-fix-loop smoke-parallel-workspaces smoke-untrusted-inspection smoke-review-eval runtime-bundle runtime-binaries runtime-kernel runtime-rootfs runtime-agent runtime-validate runtime-manifest runtime-sync runtime-clean runtime-fetch-binaries runtime-build-kernel-real runtime-build-rootfs-real runtime-materialize runtime-export-environment-oci runtime-export-official-environments-oci runtime-publish-environment-oci runtime-publish-official-environments-oci runtime-boot-check runtime-network-check
help:
@printf '%s\n' \
@ -35,6 +37,7 @@ help:
' demo Run the deterministic VM demo' \
' network-demo Run the deterministic VM demo with guest networking enabled' \
' doctor Show runtime and host diagnostics' \
' smoke-daily-loop Run the real guest-backed prepare plus reset daily-loop smoke' \
' smoke-use-cases Run all real guest-backed workspace use-case smokes' \
' smoke-cold-start-validation Run the cold-start repo validation smoke' \
' smoke-repro-fix-loop Run the repro-plus-fix loop smoke' \
@ -82,13 +85,16 @@ test:
check: lint typecheck test
dist-check:
.venv/bin/pyro --version
.venv/bin/pyro --help >/dev/null
.venv/bin/pyro mcp --help >/dev/null
.venv/bin/pyro run --help >/dev/null
.venv/bin/pyro env list >/dev/null
.venv/bin/pyro env inspect debian:12 >/dev/null
.venv/bin/pyro doctor >/dev/null
uv run python -m pyro_mcp.cli --version
uv run python -m pyro_mcp.cli --help >/dev/null
uv run python -m pyro_mcp.cli prepare --help >/dev/null
uv run python -m pyro_mcp.cli host --help >/dev/null
uv run python -m pyro_mcp.cli host doctor >/dev/null
uv run python -m pyro_mcp.cli mcp --help >/dev/null
uv run python -m pyro_mcp.cli run --help >/dev/null
uv run python -m pyro_mcp.cli env list >/dev/null
uv run python -m pyro_mcp.cli env inspect debian:12 >/dev/null
uv run python -m pyro_mcp.cli doctor --environment debian:12 >/dev/null
pypi-publish:
@if [ -z "$$TWINE_PASSWORD" ]; then \
@ -113,6 +119,9 @@ network-demo:
doctor:
uv run pyro doctor
smoke-daily-loop:
uv run python scripts/daily_loop_smoke.py --environment "$(DAILY_LOOP_ENVIRONMENT)"
smoke-use-cases:
uv run python scripts/workspace_use_case_smoke.py --scenario all --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS)

698
README.md
View file

@ -1,34 +1,50 @@
# pyro-mcp
`pyro-mcp` is a stable agent workspace product for one-shot commands and persistent work inside ephemeral Firecracker microVMs using curated Linux environments such as `debian:12`.
`pyro-mcp` is a disposable MCP workspace for chat-based coding agents such as
Claude Code, Codex, and OpenCode.
It is built for Linux `x86_64` hosts with working KVM. The product path is:
1. prove the host works
2. connect a chat host over MCP
3. let the agent work inside a disposable workspace
4. validate the workflow with the recipe-backed smoke pack
`pyro-mcp` currently has no users. Expect breaking changes while this chat-host
path is still being shaped.
This repo is not trying to be a generic VM toolkit, a CI runner, or an
SDK-first platform.
[![PyPI version](https://img.shields.io/pypi/v/pyro-mcp.svg)](https://pypi.org/project/pyro-mcp/)
This is for coding agents, MCP clients, and developers who want isolated command execution and stable disposable workspaces in ephemeral microVMs.
It exposes the same runtime in three public forms:
- the `pyro` CLI
- the Python SDK via `from pyro_mcp import Pyro`
- an MCP server so LLM clients can call VM tools directly
## Start Here
- Install: [docs/install.md](docs/install.md)
- Vision: [docs/vision.md](docs/vision.md)
- Workspace GA roadmap: [docs/roadmap/task-workspace-ga.md](docs/roadmap/task-workspace-ga.md)
- LLM chat roadmap: [docs/roadmap/llm-chat-ergonomics.md](docs/roadmap/llm-chat-ergonomics.md)
- Use-case recipes: [docs/use-cases/README.md](docs/use-cases/README.md)
- Install and zero-to-hero path: [docs/install.md](docs/install.md)
- First run transcript: [docs/first-run.md](docs/first-run.md)
- Chat host integrations: [docs/integrations.md](docs/integrations.md)
- Use-case recipes: [docs/use-cases/README.md](docs/use-cases/README.md)
- Vision: [docs/vision.md](docs/vision.md)
- Public contract: [docs/public-contract.md](docs/public-contract.md)
- Host requirements: [docs/host-requirements.md](docs/host-requirements.md)
- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md)
- Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif)
- Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif)
- What's new in 4.5.0: [CHANGELOG.md#450](CHANGELOG.md#450)
- PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/)
- What's new in 4.0.0: [CHANGELOG.md#400](CHANGELOG.md#400)
- 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)
- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md)
- Changelog: [CHANGELOG.md](CHANGELOG.md)
## Who It's For
- Claude Code users who want disposable workspaces instead of running directly
on the host
- Codex users who want an MCP-backed sandbox for repo setup, bug fixing, and
evaluation loops
- OpenCode users who want the same disposable workspace model
- people evaluating repo setup, test, and app-start workflows from a chat
interface on a clean machine
If you want a general VM platform, a queueing system, or a broad SDK product,
this repo is intentionally biased away from that story.
## Quickstart
@ -38,8 +54,7 @@ Use either of these equivalent quickstart paths:
# Package without install
python -m pip install uv
uvx --from pyro-mcp pyro doctor
uvx --from pyro-mcp pyro env list
uvx --from pyro-mcp pyro env pull debian:12
uvx --from pyro-mcp pyro prepare debian:12
uvx --from pyro-mcp pyro run debian:12 -- git --version
```
@ -48,8 +63,7 @@ uvx --from pyro-mcp pyro run debian:12 -- git --version
```bash
# Already installed
pyro doctor
pyro env list
pyro env pull debian:12
pyro prepare debian:12
pyro run debian:12 -- git --version
```
@ -60,7 +74,7 @@ What success looks like:
```bash
Platform: linux-x86_64
Runtime: PASS
Catalog version: 4.0.0
Catalog version: 4.4.0
...
[pull] phase=install environment=debian:12
[pull] phase=ready environment=debian:12
@ -73,89 +87,76 @@ Pulled: debian:12
git version ...
```
The first pull downloads an OCI environment from public Docker Hub, requires outbound HTTPS
access to `registry-1.docker.io`, and needs local cache space for the guest image.
## Stable Workspace Path
`pyro run` is the stable one-shot entrypoint. `pyro workspace ...` is the stable path when an
agent needs one sandbox to stay alive across repeated commands, shells, services, checkpoints,
diffs, exports, and reset.
After that stable walkthrough works, continue with the recipe set in
[docs/use-cases/README.md](docs/use-cases/README.md). It packages the five core workspace stories
into documented flows plus real guest-backed smoke targets such as `make smoke-use-cases` and
`make smoke-repro-fix-loop`. At this point `make smoke-use-cases` is the
trustworthy guest-backed release-gate path for the advertised workspace workflows.
The commands below use plain `pyro ...`. Run the same flow with `uvx --from pyro-mcp pyro ...`
for the published package, or `uv run pyro ...` from a source checkout.
```bash
uv tool install pyro-mcp
WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --id-only)"
pyro workspace list
pyro workspace update "$WORKSPACE_ID" --label owner=codex
pyro workspace sync push "$WORKSPACE_ID" ./changes
pyro workspace file read "$WORKSPACE_ID" note.txt --content-only
pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch
pyro workspace exec "$WORKSPACE_ID" -- cat note.txt
pyro workspace snapshot create "$WORKSPACE_ID" checkpoint
pyro workspace service start "$WORKSPACE_ID" web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint
pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt
pyro workspace delete "$WORKSPACE_ID"
```
![Stable workspace walkthrough](docs/assets/workspace-first-run.gif)
That stable workspace path gives you:
- initial host-in seeding with `--seed-path`
- discovery metadata with `--name`, `--label`, `workspace list`, and `workspace update`
- later host-in updates with `workspace sync push`
- model-native file inspection and text edits with `workspace file *` and `workspace patch apply`
- one-shot commands with `workspace exec` and persistent PTYs with `workspace shell *`
- long-running processes with `workspace service *`
- explicit checkpoints with `workspace snapshot *`
- full-sandbox recovery with `workspace reset`
- baseline comparison with `workspace diff`
- explicit host-out export with `workspace export`
- secondary stopped-workspace disk inspection with `workspace stop|start` and `workspace disk *`
After the quickstart works:
- prove the full one-shot lifecycle with `uvx --from pyro-mcp pyro demo`
- start most chat hosts with `uvx --from pyro-mcp pyro mcp serve`
- create a persistent workspace with `uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo`
- add a human-friendly workspace name with `uvx --from pyro-mcp pyro workspace create debian:12 --name repro-fix --label issue=123`
- rediscover or retag workspaces with `uvx --from pyro-mcp pyro workspace list` and `uvx --from pyro-mcp pyro workspace update WORKSPACE_ID --label owner=codex`
- update a live workspace from the host with `uvx --from pyro-mcp pyro workspace sync push WORKSPACE_ID ./changes`
- enable outbound guest networking for one workspace with `uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress`
- add literal or file-backed secrets with `uvx --from pyro-mcp pyro workspace create debian:12 --secret API_TOKEN=expected --secret-file PIP_TOKEN=./token.txt`
- map one persisted secret into one exec, shell, or service call with `--secret-env API_TOKEN`
- inspect and edit files without shell quoting with `uvx --from pyro-mcp pyro workspace file read WORKSPACE_ID src/app.py --content-only`, `uvx --from pyro-mcp pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py`, and `uvx --from pyro-mcp pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch`
- diff the live workspace against its create-time baseline with `uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID`
- capture a checkpoint with `uvx --from pyro-mcp pyro workspace snapshot create WORKSPACE_ID checkpoint`
- reset a broken workspace with `uvx --from pyro-mcp pyro workspace reset WORKSPACE_ID --snapshot checkpoint`
- 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 --id-only`
- start long-running workspace services with `uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done'`
- publish one guest service port to the host with `uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress+published-ports` and `uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app`
- stop a workspace for offline inspection with `uvx --from pyro-mcp pyro workspace stop WORKSPACE_ID`
- inspect or export one stopped guest rootfs with `uvx --from pyro-mcp pyro workspace disk list WORKSPACE_ID`, `uvx --from pyro-mcp pyro workspace disk read WORKSPACE_ID note.txt --content-only`, and `uvx --from pyro-mcp pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4`
- move to Python or MCP via [docs/integrations.md](docs/integrations.md)
The first pull downloads an OCI environment from public Docker Hub, requires
outbound HTTPS access to `registry-1.docker.io`, and needs local cache space
for the guest image. `pyro prepare debian:12` performs that install step
automatically, then proves create, exec, reset, and delete on one throwaway
workspace so the daily loop is warm before the chat host connects.
## Chat Host Quickstart
For most MCP chat hosts, bare `pyro mcp serve` now starts `workspace-core`. It exposes the practical
persistent editing loop without shells, services, snapshots, secrets, network
policy, or disk tools.
After the quickstart works, make the daily loop explicit before you connect the
chat host:
```bash
uvx --from pyro-mcp pyro doctor --environment debian:12
uvx --from pyro-mcp pyro prepare debian:12
```
Then connect a chat host in one named mode. Use the helper flow first:
```bash
uvx --from pyro-mcp pyro host connect codex --mode repro-fix
uvx --from pyro-mcp pyro host connect codex --mode inspect
uvx --from pyro-mcp pyro host connect claude-code --mode cold-start
uvx --from pyro-mcp pyro host connect claude-code --mode review-eval
uvx --from pyro-mcp pyro host print-config opencode --mode repro-fix
```
If setup drifts or you want to inspect it first:
```bash
uvx --from pyro-mcp pyro host doctor
uvx --from pyro-mcp pyro host repair claude-code
uvx --from pyro-mcp pyro host repair codex
uvx --from pyro-mcp pyro host repair opencode
```
Those helpers wrap the same `pyro mcp serve` entrypoint. Use a named mode when
one workflow already matches the job. Fall back to the generic no-mode path
when the mode feels too narrow.
Mode examples:
```bash
uvx --from pyro-mcp pyro mcp serve --mode repro-fix
uvx --from pyro-mcp pyro mcp serve --mode inspect
uvx --from pyro-mcp pyro mcp serve --mode cold-start
uvx --from pyro-mcp pyro mcp serve --mode review-eval
```
Generic escape hatch:
```bash
uvx --from pyro-mcp pyro mcp serve
```
From a repo root, the generic path auto-detects the current Git checkout and
lets the first `workspace_create` omit `seed_path`. If the host does not
preserve the server working directory, use:
```bash
uvx --from pyro-mcp pyro host connect codex --project-path /abs/path/to/repo
uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
```
If you are starting outside a local checkout, use a clean clone source:
```bash
uvx --from pyro-mcp pyro host connect codex --repo-url https://github.com/example/project.git
uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git
```
Copy-paste host-specific starts:
- Claude Code: [examples/claude_code_mcp.md](examples/claude_code_mcp.md)
@ -163,16 +164,16 @@ Copy-paste host-specific starts:
- OpenCode: [examples/opencode_mcp_config.json](examples/opencode_mcp_config.json)
- Generic MCP config: [examples/mcp_client_config.md](examples/mcp_client_config.md)
Claude Code:
Claude Code cold-start or review-eval:
```bash
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
```
Codex:
Codex repro-fix or inspect:
```bash
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
```
OpenCode `opencode.json` snippet:
@ -183,229 +184,72 @@ OpenCode `opencode.json` snippet:
"pyro": {
"type": "local",
"enabled": true,
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
}
}
}
```
If `pyro-mcp` is already installed, replace the `uvx --from pyro-mcp pyro`
command with `pyro` in the same host-specific command or config shape. Use
`--profile workspace-full` only when the host truly needs the full advanced
workspace surface.
If OpenCode launches the server from an unexpected cwd, use
`pyro host print-config opencode --project-path /abs/path/to/repo` or add
`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same command
array.
Profile progression:
If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with
`pyro` in the same command or config shape.
- `workspace-core`: default and recommended first profile for normal persistent chat editing
- `vm-run`: smallest one-shot-only surface
- `workspace-full`: explicit advanced opt-in when the chat truly needs shells, services, snapshots, secrets, network policy, or disk tools
Use the generic no-mode path when the named mode feels too narrow. Move to
`--profile workspace-full` only when the chat truly needs shells, services,
snapshots, secrets, network policy, or disk tools.
## Supported Hosts
## Zero To Hero
Supported today:
1. Validate the host with `pyro doctor`.
2. Warm the machine-level daily loop with `pyro prepare debian:12`.
3. Prove guest execution with `pyro run debian:12 -- git --version`.
4. Connect Claude Code, Codex, or OpenCode with one named mode such as
`pyro host connect codex --mode repro-fix`, then fall back to raw
`pyro mcp serve --mode ...` or the generic no-mode path when needed.
5. Start with one recipe from [docs/use-cases/README.md](docs/use-cases/README.md).
`repro-fix` is the shortest chat-first mode and story.
6. Use `workspace reset` as the normal retry step inside that warmed loop.
7. Use `make smoke-use-cases` as the trustworthy guest-backed verification path
for the advertised workflows.
- Linux x86_64
- Python 3.12+
- `uv`
- `/dev/kvm`
That is the intended user journey. The terminal commands exist to validate and
debug that chat-host path, not to replace it as the main product story.
Optional for outbound guest networking:
## Manual Terminal Workspace Flow
- `ip`
- `nft` or `iptables`
- privilege to create TAP devices and configure NAT
Not supported today:
- macOS
- Windows
- Linux hosts without working KVM at `/dev/kvm`
## Detailed Walkthrough
If you want the expanded version of the canonical quickstart, use the step-by-step flow below.
### 1. Check the host
If you want to understand what the agent gets inside the sandbox, or debug a
recipe outside the chat host, use the terminal companion flow below:
```bash
uvx --from pyro-mcp pyro doctor
uv tool install pyro-mcp
WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --id-only)"
pyro workspace list
pyro workspace sync push "$WORKSPACE_ID" ./changes
pyro workspace file read "$WORKSPACE_ID" note.txt --content-only
pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch
pyro workspace exec "$WORKSPACE_ID" -- cat note.txt
pyro workspace summary "$WORKSPACE_ID"
pyro workspace snapshot create "$WORKSPACE_ID" checkpoint
pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint
pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt
pyro workspace delete "$WORKSPACE_ID"
```
Expected success signals:
Add `workspace-full` only when the chat or your manual debugging loop really
needs:
```bash
Platform: linux-x86_64
Runtime: PASS
KVM: exists=yes readable=yes writable=yes
Environment cache: /home/you/.cache/pyro-mcp/environments
Capabilities: vm_boot=yes guest_exec=yes guest_network=yes
Networking: tun=yes ip_forward=yes
```
- persistent PTY shells
- long-running services and readiness probes
- guest networking and published ports
- secrets
- stopped-workspace disk inspection
### 2. Inspect the catalog
```bash
uvx --from pyro-mcp pyro env list
```
Expected output:
```bash
Catalog version: 4.0.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.
```
### 3. Pull the default environment
```bash
uvx --from pyro-mcp pyro env pull debian:12
```
The first pull downloads an OCI environment from public Docker Hub, requires outbound HTTPS
access to `registry-1.docker.io`, and needs local cache space for the guest image.
See [docs/host-requirements.md](docs/host-requirements.md) for the full host requirements.
### 4. Run one command in a guest
```bash
uvx --from pyro-mcp pyro run debian:12 -- git --version
```
Expected success signals:
```bash
[run] environment=debian:12 execution_mode=guest_vsock exit_code=0 duration_ms=...
git version ...
```
The guest command output and the `[run] ...` summary are written to different streams, so they
may appear in either order in terminals or capture tools. Use `--json` if you need a
deterministic structured result.
### 5. Optional demos
```bash
uvx --from pyro-mcp pyro demo
uvx --from pyro-mcp pyro demo --network
```
`pyro demo` proves the one-shot create/start/exec/delete VM lifecycle works end to end.
Example output:
```json
{
"cleanup": {
"deleted": true,
"reason": "post_exec_cleanup",
"vm_id": "..."
},
"command": "git --version",
"environment": "debian:12",
"execution_mode": "guest_vsock",
"exit_code": 0,
"stdout": "git version ...\n"
}
```
When you are done evaluating and want to remove stale cached environments, run `pyro env prune`.
If you prefer a fuller copy-pasteable transcript, see [docs/first-run.md](docs/first-run.md).
The walkthrough GIF above was rendered from [docs/assets/first-run.tape](docs/assets/first-run.tape) using [scripts/render_tape.sh](scripts/render_tape.sh).
## Stable Workspaces
Use `pyro run` for one-shot commands. Use `pyro workspace ...` when you need repeated commands in one
workspace without recreating the sandbox every time.
The project direction is an agent workspace, not a CI job runner. Persistent
workspaces are meant to let an agent stay inside one bounded sandbox across multiple
steps. See [docs/vision.md](docs/vision.md) for the product thesis and the
longer-term interaction model.
```bash
pyro workspace create debian:12 --seed-path ./repo
pyro workspace create debian:12 --network-policy egress
pyro workspace create debian:12 --seed-path ./repo --secret API_TOKEN=expected
pyro workspace create debian:12 --network-policy egress+published-ports
pyro workspace sync push WORKSPACE_ID ./changes --dest src
pyro workspace file list WORKSPACE_ID src --recursive
pyro workspace file read WORKSPACE_ID src/note.txt --content-only
pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py
pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch
pyro workspace exec WORKSPACE_ID -- cat src/note.txt
pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"'
pyro workspace diff WORKSPACE_ID
pyro workspace snapshot create WORKSPACE_ID checkpoint
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
pyro workspace reset WORKSPACE_ID
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN --id-only
pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'
pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300
pyro workspace shell close WORKSPACE_ID SHELL_ID
pyro workspace service start WORKSPACE_ID web --secret-env API_TOKEN --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done'
pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app
pyro workspace service list WORKSPACE_ID
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 --content-only
pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4
pyro workspace start WORKSPACE_ID
pyro workspace logs WORKSPACE_ID
pyro workspace delete WORKSPACE_ID
```
Persistent workspaces start in `/workspace` and keep command history until you delete them. For
machine consumption, use `--id-only` for only the identifier or `--json` for the full
workspace payload. 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 `4.0.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 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 snapshot *` and `pyro workspace reset` when you want explicit checkpoints and
full-sandbox recovery. 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. Prefer
`pyro workspace shell read --plain --wait-for-idle-ms 300` for chat-facing shell reads. Use
`pyro workspace service *` when the workspace needs one or more long-running background processes.
Typed readiness checks prefer `--ready-file`, `--ready-tcp`, or `--ready-http`; keep
`--ready-command` as the escape hatch. Service metadata and logs live outside `/workspace`, so the
internal service state does not appear in `pyro workspace diff` or `pyro workspace export`.
Use `--network-policy egress` when the workspace needs outbound guest networking, and
`--network-policy egress+published-ports` plus `workspace service start --publish` when one
service must be probed from the host on `127.0.0.1`.
Use `--secret` and `--secret-file` at workspace creation when the sandbox needs private tokens or
config. Persisted secrets are materialized inside the guest at `/run/pyro-secrets/<name>`, and
`--secret-env SECRET_NAME[=ENV_VAR]` maps one secret into one exec, shell, or service call without
exposing the raw value in workspace status, logs, diffs, or exports. 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.
## Public Interfaces
The public user-facing interface is `pyro` and `Pyro`. After the CLI validation path works, you can choose one of three surfaces:
- `pyro` for direct CLI usage, including one-shot `run` and persistent `workspace` workflows
- `from pyro_mcp import Pyro` for Python orchestration
- `pyro mcp serve` for MCP clients
Command forms:
- published package without install: `uvx --from pyro-mcp pyro ...`
- installed package: `pyro ...`
- source checkout: `uv run pyro ...`
`Makefile` targets are contributor conveniences for this repository and are not the primary product UX.
The five recipe docs show when those capabilities are justified:
[docs/use-cases/README.md](docs/use-cases/README.md)
## Official Environments
@ -415,216 +259,10 @@ Current official environments in the shipped catalog:
- `debian:12-base`
- `debian:12-build`
The package ships the embedded Firecracker runtime and a package-controlled environment catalog.
Official environments are pulled as OCI artifacts from public Docker Hub repositories into a local
cache on first use or through `pyro env pull`.
End users do not need registry credentials to pull or run official environments.
The default cache location is `~/.cache/pyro-mcp/environments`; override it with
`PYRO_ENVIRONMENT_CACHE_DIR`.
## CLI
List available environments:
```bash
pyro env list
```
Prefetch one environment:
```bash
pyro env pull debian:12
```
Run one command in an ephemeral VM:
```bash
pyro run debian:12 -- git --version
```
Run with outbound internet enabled:
```bash
pyro run debian:12 --network -- \
'python3 -c "import urllib.request; print(urllib.request.urlopen(\"https://example.com\", timeout=10).status)"'
```
Show runtime and host diagnostics:
```bash
pyro doctor
pyro doctor --json
```
`pyro run` defaults to `1 vCPU / 1024 MiB`.
It fails closed when guest boot or guest exec is unavailable.
Use `--allow-host-compat` only if you explicitly want host execution.
Run the MCP server after the CLI path above works. Start most chat hosts with
`workspace-core`:
```bash
pyro mcp serve
```
Profile progression for chat hosts:
- `workspace-core`: recommended first profile for normal persistent chat editing
- `vm-run`: expose only `vm_run` for one-shot-only hosts
- `workspace-full`: expose shells, services, snapshots, secrets, network policy, and disk tools when the chat truly needs the full stable surface
Run the deterministic demo:
```bash
pyro demo
pyro demo --network
```
Run the Ollama demo:
```bash
ollama serve
ollama pull llama3.2:3b
pyro demo ollama
```
## Python SDK
```python
from pyro_mcp import Pyro
pyro = Pyro()
result = pyro.run_in_vm(
environment="debian:12",
command="git --version",
timeout_seconds=30,
network=False,
)
print(result["stdout"])
```
Lower-level lifecycle control remains available:
```python
from pyro_mcp import Pyro
pyro = Pyro()
created = pyro.create_vm(
environment="debian:12",
ttl_seconds=600,
network=True,
)
vm_id = created["vm_id"]
pyro.start_vm(vm_id)
result = pyro.exec_vm(vm_id, command="git --version", timeout_seconds=30)
print(result["stdout"])
```
`exec_vm()` is a one-command auto-cleaning call. After it returns, the VM is already deleted.
Environment management is also available through the SDK:
```python
from pyro_mcp import Pyro
pyro = Pyro()
print(pyro.list_environments())
print(pyro.inspect_environment("debian:12"))
```
For repeated commands in one workspace:
```python
from pyro_mcp import Pyro
pyro = Pyro()
workspace = pyro.create_workspace(environment="debian:12", seed_path="./repo")
workspace_id = workspace["workspace_id"]
try:
pyro.push_workspace_sync(workspace_id, "./changes", dest="src")
result = pyro.exec_workspace(workspace_id, command="cat src/note.txt")
print(result["stdout"], end="")
finally:
pyro.delete_workspace(workspace_id)
```
## MCP Tools
Primary agent-facing tool:
- `vm_run(environment, command, vcpu_count=1, mem_mib=1024, timeout_seconds=30, ttl_seconds=600, network=false, allow_host_compat=false)`
Advanced lifecycle tools:
- `vm_list_environments()`
- `vm_create(environment, vcpu_count=1, mem_mib=1024, ttl_seconds=600, network=false, allow_host_compat=false)`
- `vm_start(vm_id)`
- `vm_exec(vm_id, command, timeout_seconds=30)` auto-cleans the VM after that command
- `vm_stop(vm_id)`
- `vm_delete(vm_id)`
- `vm_status(vm_id)`
- `vm_network_info(vm_id)`
- `vm_reap_expired()`
Persistent workspace tools:
- `workspace_create(environment, vcpu_count=1, mem_mib=1024, ttl_seconds=600, network_policy="off", allow_host_compat=false, seed_path=null, secrets=null)`
- `workspace_sync_push(workspace_id, source_path, dest="/workspace")`
- `workspace_exec(workspace_id, command, timeout_seconds=30, secret_env=null)`
- `workspace_export(workspace_id, path, output_path)`
- `workspace_diff(workspace_id)`
- `snapshot_create(workspace_id, snapshot_name)`
- `snapshot_list(workspace_id)`
- `snapshot_delete(workspace_id, snapshot_name)`
- `workspace_reset(workspace_id, snapshot="baseline")`
- `service_start(workspace_id, service_name, command, cwd="/workspace", readiness=null, ready_timeout_seconds=30, ready_interval_ms=500, secret_env=null, published_ports=null)`
- `service_list(workspace_id)`
- `service_status(workspace_id, service_name)`
- `service_logs(workspace_id, service_name, tail_lines=200)`
- `service_stop(workspace_id, service_name)`
- `shell_open(workspace_id, cwd="/workspace", cols=120, rows=30, secret_env=null)`
- `shell_read(workspace_id, shell_id, cursor=0, max_chars=65536, plain=False, wait_for_idle_ms=None)`
- `shell_write(workspace_id, shell_id, input, append_newline=true)`
- `shell_signal(workspace_id, shell_id, signal_name="INT")`
- `shell_close(workspace_id, shell_id)`
- `workspace_status(workspace_id)`
- `workspace_logs(workspace_id)`
- `workspace_delete(workspace_id)`
Recommended MCP tool profiles:
- `vm-run`: `vm_run` only
- `workspace-core`: `vm_run`, `workspace_create`, `workspace_list`, `workspace_update`, `workspace_status`, `workspace_sync_push`, `workspace_exec`, `workspace_logs`, `workspace_file_list`, `workspace_file_read`, `workspace_file_write`, `workspace_patch_apply`, `workspace_diff`, `workspace_export`, `workspace_reset`, `workspace_delete`
- `workspace-full`: the complete stable MCP surface above
## Integration Examples
- Python one-shot SDK example: [examples/python_run.py](examples/python_run.py)
- Python lifecycle example: [examples/python_lifecycle.py](examples/python_lifecycle.py)
- Python workspace example: [examples/python_workspace.py](examples/python_workspace.py)
- Claude Code MCP setup: [examples/claude_code_mcp.md](examples/claude_code_mcp.md)
- Codex MCP setup: [examples/codex_mcp.md](examples/codex_mcp.md)
- OpenCode MCP config: [examples/opencode_mcp_config.json](examples/opencode_mcp_config.json)
- Generic MCP client config: [examples/mcp_client_config.md](examples/mcp_client_config.md)
- Claude Desktop MCP config: [examples/claude_desktop_mcp_config.json](examples/claude_desktop_mcp_config.json)
- Cursor MCP config: [examples/cursor_mcp_config.json](examples/cursor_mcp_config.json)
- OpenAI Responses API example: [examples/openai_responses_vm_run.py](examples/openai_responses_vm_run.py)
- OpenAI Responses `workspace-core` example: [examples/openai_responses_workspace_core.py](examples/openai_responses_workspace_core.py)
- LangChain wrapper example: [examples/langchain_vm_run.py](examples/langchain_vm_run.py)
- Agent-ready `vm_run` example: [examples/agent_vm_run.py](examples/agent_vm_run.py)
## Runtime
The package ships an embedded Linux x86_64 runtime payload with:
- Firecracker
- Jailer
- guest agent
- runtime manifest and diagnostics
No system Firecracker installation is required.
`pyro` installs curated environments into a local cache and reports their status through `pyro env inspect` and `pyro doctor`.
The public CLI is human-readable by default; add `--json` for structured output.
The embedded Firecracker runtime ships with the package. Official environments
are pulled as OCI artifacts from public Docker Hub into a local cache on first
use or through `pyro env pull`. End users do not need registry credentials to
pull or run the official environments.
## Contributor Workflow
@ -637,11 +275,12 @@ make check
make dist-check
```
Contributor runtime sources live under `runtime_sources/`. The packaged runtime bundle under
`src/pyro_mcp/runtime_bundle/` contains the embedded boot/runtime assets plus manifest metadata;
end-user environment installs pull OCI-published environments by default. Use
`PYRO_RUNTIME_BUNDLE_DIR=build/runtime_bundle` only when you are explicitly validating a locally
built contributor runtime bundle.
Contributor runtime sources live under `runtime_sources/`. The packaged runtime
bundle under `src/pyro_mcp/runtime_bundle/` contains the embedded boot/runtime
assets plus manifest metadata. End-user environment installs pull
OCI-published environments by default. Use
`PYRO_RUNTIME_BUNDLE_DIR=build/runtime_bundle` only when you are explicitly
validating a locally built contributor runtime bundle.
Official environment publication is performed locally against Docker Hub:
@ -652,20 +291,9 @@ make runtime-materialize
make runtime-publish-official-environments-oci
```
`make runtime-publish-environment-oci` auto-exports the OCI layout for the selected
environment if it is missing.
The publisher accepts either `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` or
`OCI_REGISTRY_USERNAME` and `OCI_REGISTRY_PASSWORD`.
Docker Hub uploads are chunked by default for large rootfs layers; if you need to tune a slow
link, use `PYRO_OCI_UPLOAD_TIMEOUT_SECONDS`, `PYRO_OCI_UPLOAD_CHUNK_SIZE_BYTES`, and
`PYRO_OCI_REQUEST_TIMEOUT_SECONDS`.
For a local PyPI publish:
```bash
export TWINE_PASSWORD='pypi-...'
make pypi-publish
```
`make pypi-publish` defaults `TWINE_USERNAME` to `__token__`.
Set `PYPI_REPOSITORY_URL=https://test.pypi.org/legacy/` to publish to TestPyPI instead.

View file

@ -1,28 +1,36 @@
# First Run Transcript
This is the intended evaluator path for a first successful run on a supported host.
This is the intended evaluator-to-chat-host path for a first successful run on
a supported host.
Copy the commands as-is. Paths and timing values will differ on your machine.
The same sequence works with an installed `pyro` binary by dropping the
`uvx --from pyro-mcp` prefix. If you are running from a source checkout instead
of the published package, replace `pyro` with `uv run pyro`.
`uvx --from pyro-mcp` prefix. If you are running from a source checkout
instead of the published package, replace `pyro` with `uv run pyro`.
`pyro-mcp` currently has no users. Expect breaking changes while the chat-host
path is still being shaped.
## 1. Verify the host
```bash
$ uvx --from pyro-mcp pyro doctor
$ uvx --from pyro-mcp pyro doctor --environment debian:12
Platform: linux-x86_64
Runtime: PASS
KVM: exists=yes readable=yes writable=yes
Environment cache: /home/you/.cache/pyro-mcp/environments
Catalog version: 4.5.0
Capabilities: vm_boot=yes guest_exec=yes guest_network=yes
Networking: tun=yes ip_forward=yes
Daily loop: COLD (debian:12)
Run: pyro prepare debian:12
```
## 2. Inspect the catalog
```bash
$ uvx --from pyro-mcp pyro env list
Catalog version: 4.0.0
Catalog version: 4.4.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.
@ -30,9 +38,10 @@ debian:12-build [installed|not installed] Debian 12 environment with Git and com
## 3. Pull the default environment
The first pull downloads an OCI environment from public Docker Hub, requires outbound HTTPS
access to `registry-1.docker.io`, and needs local cache space for the guest image. See
[host-requirements.md](host-requirements.md) for the full host requirements.
The first pull downloads an OCI environment from public Docker Hub, requires
outbound HTTPS access to `registry-1.docker.io`, and needs local cache space
for the guest image. See [host-requirements.md](host-requirements.md) for the
full host requirements.
```bash
$ uvx --from pyro-mcp pyro env pull debian:12
@ -45,9 +54,6 @@ Installed: yes
Cache dir: /home/you/.cache/pyro-mcp/environments
Default packages: bash, coreutils, git
Install dir: /home/you/.cache/pyro-mcp/environments/linux-x86_64/debian_12-1.0.0
Install manifest: /home/you/.cache/pyro-mcp/environments/linux-x86_64/debian_12-1.0.0/environment.json
Kernel image: /home/you/.cache/pyro-mcp/environments/linux-x86_64/debian_12-1.0.0/vmlinux
Rootfs image: /home/you/.cache/pyro-mcp/environments/linux-x86_64/debian_12-1.0.0/rootfs.ext4
OCI source: registry-1.docker.io/thalesmaciel/pyro-environment-debian-12:1.0.0
```
@ -62,239 +68,152 @@ $ uvx --from pyro-mcp pyro run debian:12 -- git --version
git version ...
```
The guest command output and the `[run] ...` summary are written to different streams, so they
may appear in either order in terminals or capture tools. Use `--json` if you need a
deterministic structured result.
The guest command output and the `[run] ...` summary are written to different
streams, so they may appear in either order in terminals or capture tools. Use
`--json` if you need a deterministic structured result.
## 5. Continue into the stable workspace path
## 5. Start the MCP server
The commands below use the published-package form. The same stable workspace path works with an
installed `pyro` binary by dropping the `uvx --from pyro-mcp` prefix, or with `uv run pyro` from
a source checkout.
Warm the daily loop first so the host is already ready for repeated create and
reset cycles:
```bash
$ uvx --from pyro-mcp pyro prepare debian:12
Prepare: debian:12
Daily loop: WARM
Result: prepared network_prepared=no
```
Use a named mode when one workflow already matches the job:
```bash
$ uvx --from pyro-mcp pyro mcp serve --mode repro-fix
$ uvx --from pyro-mcp pyro mcp serve --mode inspect
$ uvx --from pyro-mcp pyro mcp serve --mode cold-start
$ uvx --from pyro-mcp pyro mcp serve --mode review-eval
```
Use the generic no-mode path when the mode feels too narrow. Bare
`pyro mcp serve` still starts `workspace-core`. From a repo root, it also
auto-detects the current Git checkout so the first `workspace_create` can omit
`seed_path`:
```bash
$ uvx --from pyro-mcp pyro mcp serve
```
If the host does not preserve the server working directory:
```bash
$ uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
```
If you are outside a local checkout:
```bash
$ uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git
```
## 6. Connect a chat host
Use the helper flow first:
```bash
$ uvx --from pyro-mcp pyro host connect codex --mode repro-fix
$ uvx --from pyro-mcp pyro host connect codex --mode inspect
$ uvx --from pyro-mcp pyro host connect claude-code --mode cold-start
$ uvx --from pyro-mcp pyro host connect claude-code --mode review-eval
$ uvx --from pyro-mcp pyro host print-config opencode --mode repro-fix
```
If setup drifts later:
```bash
$ uvx --from pyro-mcp pyro host doctor
$ uvx --from pyro-mcp pyro host repair claude-code
$ uvx --from pyro-mcp pyro host repair codex
$ uvx --from pyro-mcp pyro host repair opencode
```
Claude Code cold-start or review-eval:
```bash
$ uvx --from pyro-mcp pyro host connect claude-code --mode cold-start
$ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
$ claude mcp list
```
Codex repro-fix or inspect:
```bash
$ uvx --from pyro-mcp pyro host connect codex --mode repro-fix
$ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
$ codex mcp list
```
OpenCode uses the local config shape shown in:
- [opencode_mcp_config.json](../examples/opencode_mcp_config.json)
Other host-specific references:
- [claude_code_mcp.md](../examples/claude_code_mcp.md)
- [codex_mcp.md](../examples/codex_mcp.md)
- [mcp_client_config.md](../examples/mcp_client_config.md)
## 7. Continue into a real workflow
Once the host is connected, move to one of the five recipe docs in
[use-cases/README.md](use-cases/README.md).
The shortest chat-first mode and story is:
- [use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md)
If you want terminal-level visibility into what the agent gets, use the manual
workspace flow below:
```bash
$ export WORKSPACE_ID="$(uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --id-only)"
$ uvx --from pyro-mcp pyro workspace list
$ uvx --from pyro-mcp pyro workspace update "$WORKSPACE_ID" --label owner=codex
$ uvx --from pyro-mcp pyro workspace sync push "$WORKSPACE_ID" ./changes
$ uvx --from pyro-mcp pyro workspace file read "$WORKSPACE_ID" note.txt --content-only
$ uvx --from pyro-mcp pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch
$ uvx --from pyro-mcp pyro workspace exec "$WORKSPACE_ID" -- cat note.txt
$ uvx --from pyro-mcp pyro workspace summary "$WORKSPACE_ID"
$ 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 --content-only
$ 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"
```
## 6. Optional one-shot demo and expanded workspace flow
Move to the generic no-mode path when the named mode is too narrow. Move to
`--profile workspace-full` only when the chat really needs shells, services,
snapshots, secrets, network policy, or disk tools.
## 8. Trust the smoke pack
The repo now treats the full smoke pack as the trustworthy guest-backed
verification path for the advertised workflows:
```bash
$ make smoke-use-cases
```
That runner creates real guest-backed workspaces, exercises all five documented
stories, exports concrete results where relevant, and cleans up on both success
and failure.
For the machine-level warmup plus retry story specifically:
```bash
$ make smoke-daily-loop
```
## 9. Optional one-shot demo
```bash
$ uvx --from pyro-mcp pyro demo
$ uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123
$ uvx --from pyro-mcp pyro workspace list
$ uvx --from pyro-mcp pyro workspace update WORKSPACE_ID --label owner=codex
$ uvx --from pyro-mcp pyro workspace sync push WORKSPACE_ID ./changes
$ uvx --from pyro-mcp pyro workspace file list WORKSPACE_ID src --recursive
$ uvx --from pyro-mcp pyro workspace file read WORKSPACE_ID src/app.py --content-only
$ uvx --from pyro-mcp pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py
$ uvx --from pyro-mcp pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch
$ uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress
$ uvx --from pyro-mcp pyro workspace create debian:12 --secret API_TOKEN=expected --secret-file PIP_TOKEN=./token.txt
$ uvx --from pyro-mcp pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"'
$ uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID
$ uvx --from pyro-mcp pyro workspace snapshot create WORKSPACE_ID checkpoint
$ 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 shell open WORKSPACE_ID --secret-env API_TOKEN --id-only
$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --secret-env API_TOKEN --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done'
$ uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress+published-ports
$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app
$ uvx --from pyro-mcp pyro mcp serve
$ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
$ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
```
For most chat hosts, bare `pyro mcp serve` now starts `workspace-core`, the
recommended first MCP profile.
Move to `workspace-full` only when the host truly needs shells, services,
snapshots, secrets, network policy, or disk tools.
Host-specific MCP starts:
- Claude Code: [examples/claude_code_mcp.md](../examples/claude_code_mcp.md)
- Codex: [examples/codex_mcp.md](../examples/codex_mcp.md)
- OpenCode: [examples/opencode_mcp_config.json](../examples/opencode_mcp_config.json)
- Generic MCP config: [examples/mcp_client_config.md](../examples/mcp_client_config.md)
`pyro demo` proves the one-shot create/start/exec/delete VM lifecycle works end to end.
Once that stable workspace flow works, continue with the five recipe docs in
[use-cases/README.md](use-cases/README.md) or run the real guest-backed smoke packs directly with
`make smoke-use-cases`. Treat that smoke pack as the trustworthy guest-backed
verification path for the advertised workspace workflows.
When you need repeated commands in one sandbox, switch to `pyro workspace ...`:
```bash
$ uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo
Workspace ID: ...
Environment: debian:12
State: started
Workspace: /workspace
Workspace seed: directory from ...
Network policy: off
Execution mode: guest_vsock
Resources: 1 vCPU / 1024 MiB
Command count: 0
$ uvx --from pyro-mcp pyro workspace sync push WORKSPACE_ID ./changes --dest src
[workspace-sync] workspace_id=... mode=directory source=... destination=/workspace/src entry_count=... bytes_written=... execution_mode=guest_vsock
$ uvx --from pyro-mcp pyro workspace file list WORKSPACE_ID src --recursive
Workspace file path: /workspace/src
- /workspace/src/note.txt [file] bytes=...
$ uvx --from pyro-mcp pyro workspace file read WORKSPACE_ID src/note.txt
hello from synced workspace
[workspace-file-read] workspace_id=... path=/workspace/src/note.txt size_bytes=... truncated=False execution_mode=guest_vsock
$ uvx --from pyro-mcp pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch
$ 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 exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"'
[workspace-exec] workspace_id=... sequence=2 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 snapshot create WORKSPACE_ID checkpoint
[workspace-snapshot-create] snapshot_name=checkpoint kind=named entry_count=... bytes_written=...
$ uvx --from pyro-mcp pyro workspace reset WORKSPACE_ID --snapshot checkpoint
Workspace reset from snapshot: checkpoint (named)
[workspace-reset] destination=/workspace entry_count=... bytes_written=...
Workspace ID: ...
State: started
Command count: 0
Reset count: 1
$ 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 --secret-env API_TOKEN --id-only
[workspace-shell-open] workspace_id=... shell_id=... state=running cwd=/workspace cols=120 rows=30 execution_mode=guest_vsock
$ uvx --from pyro-mcp pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'
[workspace-shell-write] workspace_id=... shell_id=... state=running cwd=/workspace cols=120 rows=30 execution_mode=guest_vsock
$ uvx --from pyro-mcp pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300
/workspace
[workspace-shell-read] workspace_id=... shell_id=... state=running cursor=0 next_cursor=... truncated=False plain=True wait_for_idle_ms=300 execution_mode=guest_vsock
$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID web --secret-env API_TOKEN --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
[workspace-service-start] workspace_id=... service=web state=running cwd=/workspace ready_type=file execution_mode=guest_vsock
$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done'
[workspace-service-start] workspace_id=... service=worker state=running cwd=/workspace ready_type=file execution_mode=guest_vsock
$ uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress+published-ports
Workspace ID: ...
Network policy: egress+published-ports
...
$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app
[workspace-service-start] workspace_id=... service=app state=running cwd=/workspace ready_type=http execution_mode=guest_vsock published=127.0.0.1:18080->8080/tcp
$ uvx --from pyro-mcp pyro workspace service list WORKSPACE_ID
Workspace: ...
Services: 2 total, 2 running
- web [running] cwd=/workspace readiness=file
- worker [running] cwd=/workspace readiness=file
$ uvx --from pyro-mcp pyro workspace service status WORKSPACE_ID web
Workspace: ...
Service: web
State: running
Command: sh -lc 'touch .web-ready && while true; do sleep 60; done'
Cwd: /workspace
Readiness: file /workspace/.web-ready
Execution mode: guest_vsock
$ uvx --from pyro-mcp pyro workspace service logs WORKSPACE_ID web --tail-lines 50
Workspace: ...
Service: web
State: running
...
$ uvx --from pyro-mcp pyro workspace service stop WORKSPACE_ID web
[workspace-service-stop] workspace_id=... service=web state=stopped execution_mode=guest_vsock
$ 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 `4.0.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
host. Use `pyro workspace file *` and `pyro workspace patch apply` for model-native text edits,
`pyro workspace exec` for one-shot commands, and `pyro workspace shell *` when you
need a persistent interactive PTY session in that same workspace. Use `pyro workspace service *`
when the workspace needs long-running background processes with typed readiness checks. Internal
service state and logs stay outside `/workspace`, so service runtime data does not appear in
workspace diff or export results. Use `--network-policy egress` for outbound guest networking, and
`--network-policy egress+published-ports` plus `workspace service start --publish` when one
service must be reachable from the host on `127.0.0.1`. Use `--secret` and `--secret-file` at
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. 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
[scripts/render_tape.sh](../scripts/render_tape.sh).
Example output:
```json
{
"cleanup": {
"deleted": true,
@ -309,7 +228,5 @@ Example output:
}
```
When you are done evaluating and want to remove stale cached environments, run `pyro env prune`.
If `pyro doctor` reports `Runtime: FAIL`, or if the `pyro run` summary does not show
`execution_mode=guest_vsock`, stop and use [troubleshooting.md](troubleshooting.md).
`pyro demo` proves the one-shot create/start/exec/delete VM lifecycle works end
to end.

View file

@ -1,11 +1,17 @@
# Install
`pyro-mcp` is built for chat-based coding agents on Linux `x86_64` with KVM.
This document is intentionally biased toward that path.
`pyro-mcp` currently has no users. Expect breaking changes while the chat-host
flow is still being shaped.
## Support Matrix
Supported today:
- Linux x86_64
- Python 3.12+
- Linux `x86_64`
- Python `3.12+`
- `uv`
- `/dev/kvm`
@ -40,27 +46,27 @@ Use either of these equivalent evaluator paths:
```bash
# Package without install
uvx --from pyro-mcp pyro doctor
uvx --from pyro-mcp pyro env list
uvx --from pyro-mcp pyro env pull debian:12
uvx --from pyro-mcp pyro prepare debian:12
uvx --from pyro-mcp pyro run debian:12 -- git --version
```
```bash
# Already installed
pyro doctor
pyro env list
pyro env pull debian:12
pyro prepare debian:12
pyro run debian:12 -- git --version
```
If you are running from a repo checkout instead, replace `pyro` with `uv run pyro`.
If you are running from a repo checkout instead, replace `pyro` with
`uv run pyro`.
After that one-shot proof works, continue into the stable workspace path with `pyro workspace ...`.
After that one-shot proof works, the intended next step is a warmed daily loop
plus a named chat mode through `pyro host connect` or `pyro host print-config`.
### 1. Check the host first
## 1. Check the host
```bash
uvx --from pyro-mcp pyro doctor
uvx --from pyro-mcp pyro doctor --environment debian:12
```
Expected success signals:
@ -70,13 +76,16 @@ Platform: linux-x86_64
Runtime: PASS
KVM: exists=yes readable=yes writable=yes
Environment cache: /home/you/.cache/pyro-mcp/environments
Catalog version: 4.5.0
Capabilities: vm_boot=yes guest_exec=yes guest_network=yes
Networking: tun=yes ip_forward=yes
Daily loop: COLD (debian:12)
Run: pyro prepare debian:12
```
If `Runtime: FAIL`, stop here and use [troubleshooting.md](troubleshooting.md).
### 2. Inspect the catalog
## 2. Inspect the catalog
```bash
uvx --from pyro-mcp pyro env list
@ -85,21 +94,22 @@ uvx --from pyro-mcp pyro env list
Expected output:
```bash
Catalog version: 4.0.0
Catalog version: 4.4.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.
```
### 3. Pull the default environment
## 3. Pull the default environment
```bash
uvx --from pyro-mcp pyro env pull debian:12
```
The first pull downloads an OCI environment from public Docker Hub, requires outbound HTTPS
access to `registry-1.docker.io`, and needs local cache space for the guest image. See
[host-requirements.md](host-requirements.md) for the full host requirements.
The first pull downloads an OCI environment from public Docker Hub, requires
outbound HTTPS access to `registry-1.docker.io`, and needs local cache space
for the guest image. See [host-requirements.md](host-requirements.md) for the
full host requirements.
Expected success signals:
@ -110,7 +120,7 @@ Pulled: debian:12
...
```
### 4. Run one command in a guest
## 4. Run one command in a guest
```bash
uvx --from pyro-mcp pyro run debian:12 -- git --version
@ -126,17 +136,124 @@ Expected success signals:
git version ...
```
The guest command output and the `[run] ...` summary are written to different streams, so they
may appear in either order in terminals or capture tools. Use `--json` if you need a
The guest command output and the `[run] ...` summary are written to different
streams, so they may appear in either order. Use `--json` if you need a
deterministic structured result.
If guest execution is unavailable, the command fails unless you explicitly pass
`--allow-host-compat`.
## 5. Warm the daily loop
## 5. Continue into the stable workspace path
```bash
uvx --from pyro-mcp pyro prepare debian:12
```
The commands below use plain `pyro ...`. Run the same flow with `uvx --from pyro-mcp pyro ...`
for the published package, or `uv run pyro ...` from a source checkout.
That one command ensures the environment is installed, proves one guest-backed
create/exec/reset/delete loop, and records a warm manifest so the next
`pyro prepare debian:12` call can reuse it instead of repeating the full cycle.
## 6. Connect a chat host
Use the helper flow first:
```bash
uvx --from pyro-mcp pyro host connect codex --mode repro-fix
uvx --from pyro-mcp pyro host connect codex --mode inspect
uvx --from pyro-mcp pyro host connect claude-code --mode cold-start
uvx --from pyro-mcp pyro host connect claude-code --mode review-eval
uvx --from pyro-mcp pyro host print-config opencode --mode repro-fix
```
If setup drifts later, inspect and repair it with:
```bash
uvx --from pyro-mcp pyro host doctor
uvx --from pyro-mcp pyro host repair claude-code
uvx --from pyro-mcp pyro host repair codex
uvx --from pyro-mcp pyro host repair opencode
```
Use a named mode when one workflow already matches the job:
```bash
uvx --from pyro-mcp pyro mcp serve --mode repro-fix
uvx --from pyro-mcp pyro mcp serve --mode inspect
uvx --from pyro-mcp pyro mcp serve --mode cold-start
uvx --from pyro-mcp pyro mcp serve --mode review-eval
```
Use the generic no-mode path when the mode feels too narrow. Bare
`pyro mcp serve` still starts `workspace-core`. From a repo root, it also
auto-detects the current Git checkout so the first `workspace_create` can omit
`seed_path`.
```bash
uvx --from pyro-mcp pyro mcp serve
```
If the host does not preserve the server working directory, use:
```bash
uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
```
If you are starting outside a local checkout, use a clean clone source:
```bash
uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git
```
Copy-paste host-specific starts:
- Claude Code setup: [claude_code_mcp.md](../examples/claude_code_mcp.md)
- Codex setup: [codex_mcp.md](../examples/codex_mcp.md)
- OpenCode config: [opencode_mcp_config.json](../examples/opencode_mcp_config.json)
- Generic MCP fallback: [mcp_client_config.md](../examples/mcp_client_config.md)
Claude Code cold-start or review-eval:
```bash
pyro host connect claude-code --mode cold-start
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
```
Codex repro-fix or inspect:
```bash
pyro host connect codex --mode repro-fix
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
```
OpenCode uses the `mcp` / `type: "local"` config shape shown in
[opencode_mcp_config.json](../examples/opencode_mcp_config.json).
If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with
`pyro` in the same command or config shape.
Use the generic no-mode path when the named mode is too narrow. Move to
`--profile workspace-full` only when the chat truly needs shells, services,
snapshots, secrets, network policy, or disk tools.
## 7. Go from zero to hero
The intended user journey is:
1. validate the host with `pyro doctor --environment debian:12`
2. warm the machine with `pyro prepare debian:12`
3. prove guest execution with `pyro run debian:12 -- git --version`
4. connect Claude Code, Codex, or OpenCode with one named mode such as
`pyro host connect codex --mode repro-fix`, then use raw
`pyro mcp serve --mode ...` or the generic no-mode path when needed
5. use `workspace reset` as the normal retry step inside that warmed loop
6. start with one use-case recipe from [use-cases/README.md](use-cases/README.md)
7. trust but verify with `make smoke-use-cases`
If you want the shortest chat-first story, start with
[use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md).
## 8. Manual terminal workspace flow
If you want to inspect the workspace model directly from the terminal, use the
companion flow below. This is for understanding and debugging the chat-host
product, not the primary story.
```bash
uv tool install pyro-mcp
@ -147,202 +264,49 @@ pyro workspace sync push "$WORKSPACE_ID" ./changes
pyro workspace file read "$WORKSPACE_ID" note.txt --content-only
pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch
pyro workspace exec "$WORKSPACE_ID" -- cat note.txt
pyro workspace summary "$WORKSPACE_ID"
pyro workspace snapshot create "$WORKSPACE_ID" checkpoint
pyro workspace service start "$WORKSPACE_ID" web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint
pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt
pyro workspace delete "$WORKSPACE_ID"
```
This is the stable persistent-workspace contract:
When you need deeper debugging or richer recipes, add:
- `workspace create` seeds `/workspace`
- `workspace create --name/--label`, `workspace list`, and `workspace update` make workspaces discoverable
- `workspace sync push` imports later host-side changes
- `workspace file *` and `workspace patch apply` cover model-native text inspection and edits
- `workspace exec` and `workspace shell *` keep work inside one sandbox
- `workspace service *` manages long-running processes with typed readiness
- `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
- `pyro workspace shell *` for interactive PTY state
- `pyro workspace service *` for long-running processes and readiness probes
- `pyro workspace create --network-policy egress+published-ports` plus
`workspace service start --publish` for host-probed services
- `pyro workspace create --secret` and `--secret-file` when the sandbox needs
private tokens
- `pyro workspace stop` plus `workspace disk *` for offline inspection
When that stable workspace path is working, continue with the recipe index at
[use-cases/README.md](use-cases/README.md). It groups the five core workspace stories and the
real smoke targets behind them, starting with `make smoke-use-cases` or one of the per-scenario
targets such as `make smoke-repro-fix-loop`.
Treat `make smoke-use-cases` as the trustworthy guest-backed verification path for the advertised
workspace workflows.
## 9. Trustworthy verification path
## 6. Optional demo proof point
The five recipe docs in [use-cases/README.md](use-cases/README.md) are backed
by a real Firecracker smoke pack:
```bash
uvx --from pyro-mcp pyro demo
make smoke-use-cases
```
`pyro demo` proves the one-shot create/start/exec/delete VM lifecycle works end to end.
Example output:
```json
{
"cleanup": {
"deleted": true,
"reason": "post_exec_cleanup",
"vm_id": "..."
},
"command": "git --version",
"environment": "debian:12",
"execution_mode": "guest_vsock",
"exit_code": 0,
"stdout": "git version ...\n"
}
```
For a fuller copy-pasteable transcript, see [first-run.md](first-run.md).
When you are done evaluating and want to remove stale cached environments, run `pyro env prune`.
Treat that smoke pack as the trustworthy guest-backed verification path for the
advertised chat-host workflows.
## Installed CLI
If you already installed the package, the same evaluator path works with plain `pyro ...`:
If you already installed the package, the same path works with plain `pyro ...`:
```bash
uv tool install pyro-mcp
pyro --version
pyro doctor
pyro env list
pyro env pull debian:12
pyro doctor --environment debian:12
pyro prepare debian:12
pyro run debian:12 -- git --version
pyro mcp serve
```
After the CLI path works, you can move on to:
- persistent workspaces: `pyro workspace create debian:12 --seed-path ./repo`
- workspace discovery metadata: `pyro workspace create debian:12 --name repro-fix --label issue=123`
- workspace discovery commands: `pyro workspace list` and `pyro workspace update WORKSPACE_ID --label owner=codex`
- live workspace updates: `pyro workspace sync push WORKSPACE_ID ./changes`
- guest networking policy: `pyro workspace create debian:12 --network-policy egress`
- workspace secrets: `pyro workspace create debian:12 --secret API_TOKEN=expected --secret-file PIP_TOKEN=./token.txt`
- model-native file editing: `pyro workspace file read WORKSPACE_ID src/app.py --content-only`, `pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py`, and `pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch`
- 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 --content-only`, and `pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4`
- interactive shells: `pyro workspace shell open WORKSPACE_ID --id-only`
- 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`
- MCP: `pyro mcp serve`
- Python SDK: `from pyro_mcp import Pyro`
- Demos: `pyro demo` or `pyro demo --network`
## Chat Host Quickstart
For most chat-host integrations, bare `pyro mcp serve` now starts
`workspace-core`:
```bash
uvx --from pyro-mcp pyro mcp serve
```
Copy-paste host-specific starts:
- Claude Code: [examples/claude_code_mcp.md](../examples/claude_code_mcp.md)
- Codex: [examples/codex_mcp.md](../examples/codex_mcp.md)
- OpenCode: [examples/opencode_mcp_config.json](../examples/opencode_mcp_config.json)
- Generic MCP config: [examples/mcp_client_config.md](../examples/mcp_client_config.md)
Claude Code:
```bash
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
```
Codex:
```bash
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
```
OpenCode uses the `mcp`/`type: "local"` config shape shown in
[examples/opencode_mcp_config.json](../examples/opencode_mcp_config.json). If
`pyro-mcp` is already installed, replace the `uvx --from pyro-mcp pyro`
command with `pyro` in the same host-specific command or config shape. Use
`--profile workspace-full` only when the host truly needs the full advanced
workspace surface.
Use profile progression like this:
- `workspace-core`: default and recommended first profile for normal persistent chat editing
- `vm-run`: one-shot-only integrations
- `workspace-full`: explicit advanced opt-in when the host truly needs shells, services, snapshots, secrets, network policy, or disk tools
## Stable Workspace
Use `pyro workspace ...` when you need repeated commands in one sandbox instead of one-shot `pyro run`.
```bash
pyro workspace create debian:12 --seed-path ./repo
pyro workspace create debian:12 --network-policy egress
pyro workspace create debian:12 --seed-path ./repo --secret API_TOKEN=expected
pyro workspace create debian:12 --network-policy egress+published-ports
pyro workspace sync push WORKSPACE_ID ./changes --dest src
pyro workspace file list WORKSPACE_ID src --recursive
pyro workspace file read WORKSPACE_ID src/note.txt --content-only
pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py
pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch
pyro workspace exec WORKSPACE_ID -- cat src/note.txt
pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"'
pyro workspace diff WORKSPACE_ID
pyro workspace snapshot create WORKSPACE_ID checkpoint
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
pyro workspace reset WORKSPACE_ID
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN --id-only
pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'
pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300
pyro workspace shell close WORKSPACE_ID SHELL_ID
pyro workspace service start WORKSPACE_ID web --secret-env API_TOKEN --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done'
pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app
pyro workspace service list WORKSPACE_ID
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 --content-only
pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4
pyro workspace start WORKSPACE_ID
pyro workspace logs WORKSPACE_ID
pyro workspace delete WORKSPACE_ID
```
Workspace commands default to the persistent `/workspace` directory inside the guest. If you need
the identifier programmatically, use `--id-only` for only the identifier or `--json` for the full
workspace payload. 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 `4.0.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
`pyro workspace exec` for one-shot commands and `pyro workspace shell *` when you need an
interactive PTY that survives across separate calls. Prefer
`pyro workspace shell read --plain --wait-for-idle-ms 300` for chat-facing shell loops. Use `pyro workspace service *` when the
workspace needs long-running background processes with typed readiness probes. Service metadata and
logs stay outside `/workspace`, so the service runtime itself does not show up in workspace diff or
export results. Use `--network-policy egress` when the workspace needs outbound guest networking,
and `--network-policy egress+published-ports` plus `workspace service start --publish` when one
service must be reachable from the host on `127.0.0.1`. Use `--secret` and `--secret-file` at
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>`. 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
## Contributor clone
```bash
git lfs install

View file

@ -1,164 +1,257 @@
# Integration Targets
# Chat Host Integrations
These are the main ways to integrate `pyro-mcp` into an LLM application.
This page documents the intended product path for `pyro-mcp`:
Use this page after you have already validated the host and guest execution through the
CLI path in [install.md](install.md) or [first-run.md](first-run.md).
- validate the host with the CLI
- warm the daily loop with `pyro prepare debian:12`
- run `pyro mcp serve`
- connect a chat host
- let the agent work inside disposable workspaces
## Recommended Default
`pyro-mcp` currently has no users. Expect breaking changes while this chat-host
path is still being shaped.
Bare `pyro mcp serve` now starts `workspace-core`. Use `vm_run` only for one-shot
integrations, and promote the chat surface to `workspace-full` only when it
truly needs shells, services, snapshots, secrets, network policy, or disk
tools.
Use this page after you have already validated the host and guest execution
through [install.md](install.md) or [first-run.md](first-run.md).
That keeps the model-facing contract small:
Recommended first commands before connecting a host:
- one tool
- one command
- one ephemeral VM
- automatic cleanup
```bash
pyro doctor --environment debian:12
pyro prepare debian:12
```
Profile progression:
## Recommended Modes
- `workspace-core`: default and recommended first profile for persistent chat editing
- `vm-run`: one-shot only
- `workspace-full`: the full stable workspace surface, including shells, services, snapshots, secrets, network policy, and disk tools
Use a named mode when one workflow already matches the job:
## OpenAI Responses API
```bash
pyro host connect codex --mode repro-fix
pyro host connect codex --mode inspect
pyro host connect claude-code --mode cold-start
pyro host connect claude-code --mode review-eval
```
Best when:
The mode-backed raw server forms are:
- your agent already uses OpenAI models directly
- you want a normal tool-calling loop instead of MCP transport
- you want the smallest amount of integration code
```bash
pyro mcp serve --mode repro-fix
pyro mcp serve --mode inspect
pyro mcp serve --mode cold-start
pyro mcp serve --mode review-eval
```
Recommended surface:
Use the generic no-mode path only when the named mode feels too narrow.
- `vm_run` for one-shot loops
- the `workspace-core` tool set for the normal persistent chat loop
- the `workspace-full` tool set only when the host explicitly needs advanced workspace capabilities
## Generic Default
Canonical example:
Bare `pyro mcp serve` starts `workspace-core`. From a repo root, it also
auto-detects the current Git checkout so the first `workspace_create` can omit
`seed_path`. That is the product path.
- [examples/openai_responses_vm_run.py](../examples/openai_responses_vm_run.py)
- [examples/openai_responses_workspace_core.py](../examples/openai_responses_workspace_core.py)
- [docs/use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md)
```bash
pyro mcp serve
```
## MCP Clients
If the host does not preserve cwd, fall back to:
Best when:
```bash
pyro mcp serve --project-path /abs/path/to/repo
```
- your host application already supports MCP
- you want `pyro` to run as an external stdio server
- you want tool schemas to be discovered directly from the server
If you are outside a repo checkout entirely, start from a clean clone source:
Recommended entrypoint:
```bash
pyro mcp serve --repo-url https://github.com/example/project.git
```
- `pyro mcp serve`
Use `--profile workspace-full` only when the chat truly needs shells, services,
snapshots, secrets, network policy, or disk tools.
Profile progression:
## Helper First
- `pyro mcp serve --profile vm-run` for the smallest one-shot surface
- `pyro mcp serve` for the normal persistent chat loop
- `pyro mcp serve --profile workspace-full` only when the model truly needs advanced workspace tools
Use the helper flow before the raw host CLI commands:
Host-specific onramps:
```bash
pyro host connect codex --mode repro-fix
pyro host connect codex --mode inspect
pyro host connect claude-code --mode cold-start
pyro host connect claude-code --mode review-eval
pyro host print-config opencode --mode repro-fix
pyro host doctor
pyro host repair opencode
```
- Claude Code: [examples/claude_code_mcp.md](../examples/claude_code_mcp.md)
- Codex: [examples/codex_mcp.md](../examples/codex_mcp.md)
- OpenCode: [examples/opencode_mcp_config.json](../examples/opencode_mcp_config.json)
- Generic MCP config: [examples/mcp_client_config.md](../examples/mcp_client_config.md)
- Claude Desktop fallback: [examples/claude_desktop_mcp_config.json](../examples/claude_desktop_mcp_config.json)
- Cursor fallback: [examples/cursor_mcp_config.json](../examples/cursor_mcp_config.json)
- Use-case recipes: [docs/use-cases/README.md](use-cases/README.md)
These helpers wrap the same `pyro mcp serve` entrypoint, make named modes the
first user-facing story, and still leave the generic no-mode path available
when a mode is too narrow.
## Direct Python SDK
## Claude Code
Best when:
Preferred:
- your application owns orchestration itself
- you do not need MCP transport
- you want direct access to `Pyro`
```bash
pyro host connect claude-code --mode cold-start
```
Recommended default:
Repair:
- `Pyro.run_in_vm(...)`
- `Pyro.create_server()` for most chat hosts now that `workspace-core` is the default profile
- `Pyro.create_workspace(name=..., labels=...)` + `Pyro.list_workspaces()` + `Pyro.update_workspace(...)` when repeated workspaces need human-friendly discovery metadata
- `Pyro.create_workspace(seed_path=...)` + `Pyro.push_workspace_sync(...)` + `Pyro.exec_workspace(...)` when repeated workspace commands are required
- `Pyro.list_workspace_files(...)` / `Pyro.read_workspace_file(...)` / `Pyro.write_workspace_file(...)` / `Pyro.apply_workspace_patch(...)` when the agent needs model-native file inspection and text edits inside one live workspace
- `Pyro.create_workspace(..., secrets=...)` + `Pyro.exec_workspace(..., secret_env=...)` when the workspace needs private tokens or authenticated setup
- `Pyro.create_workspace(..., network_policy="egress+published-ports")` + `Pyro.start_service(..., published_ports=[...])` when the host must probe one workspace service
- `Pyro.diff_workspace(...)` + `Pyro.export_workspace(...)` when the agent needs baseline comparison or host-out file transfer
- `Pyro.start_service(..., secret_env=...)` + `Pyro.list_services(...)` + `Pyro.logs_service(...)` when the agent needs long-running background processes in one workspace
- `Pyro.open_shell(..., secret_env=...)` + `Pyro.write_shell(...)` + `Pyro.read_shell(..., plain=True, wait_for_idle_ms=300)` when the agent needs an interactive PTY inside the workspace
```bash
pyro host repair claude-code
```
Lifecycle note:
Package without install:
- `Pyro.exec_vm(...)` runs one command and auto-cleans the VM afterward
- use `create_vm(...)` + `start_vm(...)` only when you need pre-exec inspection or status before
that final exec
- use `create_workspace(seed_path=...)` when the agent needs repeated commands in one persistent
`/workspace` that starts from host content
- use `create_workspace(name=..., labels=...)`, `list_workspaces()`, and `update_workspace(...)`
when the agent or operator needs to rediscover the right workspace later without external notes
- use `push_workspace_sync(...)` when later host-side changes need to be imported into that
running workspace without recreating it
- use `list_workspace_files(...)`, `read_workspace_file(...)`, `write_workspace_file(...)`, and
`apply_workspace_patch(...)` when the agent should inspect or edit workspace files without shell
quoting tricks
- use `create_workspace(..., secrets=...)` plus `secret_env` on exec, shell, or service start when
the agent needs private tokens or authenticated startup inside that workspace
- use `create_workspace(..., network_policy="egress+published-ports")` plus
`start_service(..., published_ports=[...])` when the host must probe one service from that
workspace
- 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
```bash
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
claude mcp list
```
Examples:
If Claude Code launches the server from an unexpected cwd, use:
- [examples/python_run.py](../examples/python_run.py)
- [examples/python_lifecycle.py](../examples/python_lifecycle.py)
- [examples/python_workspace.py](../examples/python_workspace.py)
- [examples/python_shell.py](../examples/python_shell.py)
- [docs/use-cases/README.md](use-cases/README.md)
```bash
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start --project-path /abs/path/to/repo
```
## Agent Framework Wrappers
Already installed:
Examples:
```bash
claude mcp add pyro -- pyro mcp serve
claude mcp list
```
- LangChain tools
- PydanticAI tools
- custom in-house orchestration layers
Reference:
Best when:
- [claude_code_mcp.md](../examples/claude_code_mcp.md)
- you already have an application framework that expects a Python callable tool
- you want to wrap `vm_run` behind framework-specific abstractions
## Codex
Recommended pattern:
Preferred:
- keep the framework wrapper thin
- map one-shot framework tool input directly onto `vm_run`
- expose `workspace_*` only when the framework truly needs repeated commands in one workspace
```bash
pyro host connect codex --mode repro-fix
```
Concrete example:
Repair:
- [examples/langchain_vm_run.py](../examples/langchain_vm_run.py)
```bash
pyro host repair codex
```
## Selection Rule
Package without install:
Choose the narrowest integration that matches the host environment:
```bash
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
codex mcp list
```
1. OpenAI Responses API if you want a direct provider tool loop.
2. MCP if your host already speaks MCP.
3. Python SDK if you own orchestration and do not need transport.
4. Framework wrappers only as thin adapters over the same `vm_run` contract.
If Codex launches the server from an unexpected cwd, use:
```bash
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix --project-path /abs/path/to/repo
```
Already installed:
```bash
codex mcp add pyro -- pyro mcp serve
codex mcp list
```
Reference:
- [codex_mcp.md](../examples/codex_mcp.md)
## OpenCode
Preferred:
```bash
pyro host print-config opencode
pyro host repair opencode
```
Use the local MCP config shape from:
- [opencode_mcp_config.json](../examples/opencode_mcp_config.json)
Minimal `opencode.json` snippet:
```json
{
"mcp": {
"pyro": {
"type": "local",
"enabled": true,
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
}
}
}
```
If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with
`pyro` in the same config shape.
If OpenCode launches the server from an unexpected cwd, add
`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same command
array.
## Generic MCP Fallback
Use this only when the host expects a plain `mcpServers` JSON config, when the
named modes are too narrow, and when it does not already have a dedicated
example in the repo:
- [mcp_client_config.md](../examples/mcp_client_config.md)
Generic `mcpServers` shape:
```json
{
"mcpServers": {
"pyro": {
"command": "uvx",
"args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"]
}
}
}
```
## When To Use `workspace-full`
Stay on bare `pyro mcp serve` unless the chat host truly needs:
- persistent PTY shell sessions
- long-running services and readiness probes
- secrets
- guest networking and published ports
- stopped-workspace disk inspection or raw ext4 export
When that is necessary:
```bash
pyro mcp serve --profile workspace-full
```
## Recipe-Backed Workflows
Once the host is connected, move to the five real workflows in
[use-cases/README.md](use-cases/README.md):
- cold-start repo validation
- repro plus fix loops
- parallel isolated workspaces
- unsafe or untrusted code inspection
- review and evaluation workflows
Validate the whole story with:
```bash
make smoke-use-cases
```
For the machine-warmup plus reset/retry path specifically:
```bash
make smoke-daily-loop
```

View file

@ -1,375 +1,192 @@
# Public Contract
This document defines the stable public interface for `pyro-mcp` `3.x`.
This document describes the chat way to use `pyro-mcp` in `4.x`.
`pyro-mcp` currently has no users. Expect breaking changes while this chat-host
path is still being shaped.
This document is intentionally biased. It describes the path users are meant to
follow today:
- prove the host with the terminal companion commands
- serve disposable workspaces over MCP
- connect Claude Code, Codex, or OpenCode
- use the recipe-backed workflows
This page does not try to document every building block in the repo. It
documents the chat-host path the project is actively shaping.
## Package Identity
- Distribution name: `pyro-mcp`
- Public executable: `pyro`
- Public Python import: `from pyro_mcp import Pyro`
- Public package-level factory: `from pyro_mcp import create_server`
- distribution name: `pyro-mcp`
- public executable: `pyro`
- primary product entrypoint: `pyro mcp serve`
Stable product framing:
`pyro-mcp` is a disposable MCP workspace for chat-based coding agents on Linux
`x86_64` KVM hosts.
- `pyro run` is the stable one-shot entrypoint.
- `pyro workspace ...` is the stable persistent workspace contract.
## Supported Product Path
## CLI Contract
The intended user journey is:
Top-level commands:
1. `pyro doctor`
2. `pyro prepare debian:12`
3. `pyro run debian:12 -- git --version`
4. `pyro mcp serve`
5. connect Claude Code, Codex, or OpenCode
6. use `workspace reset` as the normal retry step
7. run one of the documented recipe-backed workflows
8. validate the whole story with `make smoke-use-cases`
## Evaluator CLI
These terminal commands are the documented companion path for the chat-host
product:
- `pyro doctor`
- `pyro prepare`
- `pyro env list`
- `pyro env pull`
- `pyro env inspect`
- `pyro env prune`
- `pyro mcp serve`
- `pyro run`
- `pyro workspace create`
- `pyro workspace list`
- `pyro workspace sync push`
- `pyro workspace stop`
- `pyro workspace start`
- `pyro workspace exec`
- `pyro workspace file list`
- `pyro workspace file read`
- `pyro workspace file write`
- `pyro workspace export`
- `pyro workspace patch apply`
- `pyro workspace disk export`
- `pyro workspace disk list`
- `pyro workspace disk read`
- `pyro workspace diff`
- `pyro workspace snapshot create`
- `pyro workspace snapshot list`
- `pyro workspace snapshot delete`
- `pyro workspace reset`
- `pyro workspace service start`
- `pyro workspace service list`
- `pyro workspace service status`
- `pyro workspace service logs`
- `pyro workspace service stop`
- `pyro workspace shell open`
- `pyro workspace shell read`
- `pyro workspace shell write`
- `pyro workspace shell signal`
- `pyro workspace shell close`
- `pyro workspace status`
- `pyro workspace update`
- `pyro workspace logs`
- `pyro workspace delete`
- `pyro doctor`
- `pyro demo`
- `pyro demo ollama`
Stable `pyro run` interface:
What to expect from that path:
- positional environment name
- `--vcpu-count`
- `--mem-mib`
- `--timeout-seconds`
- `--ttl-seconds`
- `--network`
- `--allow-host-compat`
- `--json`
- `pyro run <environment> -- <command>` defaults to `1 vCPU / 1024 MiB`
- `pyro run` fails if guest boot or guest exec is unavailable unless
`--allow-host-compat` is set
- `pyro run`, `pyro env list`, `pyro env pull`, `pyro env inspect`,
`pyro env prune`, `pyro doctor`, and `pyro prepare` are human-readable by
default and return structured JSON with `--json`
- the first official environment pull downloads from public Docker Hub into the
local environment cache
- `pyro prepare debian:12` proves the warmed daily loop with one throwaway
workspace create, exec, reset, and delete cycle
- `pyro demo` proves the one-shot create/start/exec/delete VM lifecycle end to
end
Behavioral guarantees:
These commands exist to validate and debug the chat-host path. They are not the
main product destination.
- `pyro run <environment> -- <command>` defaults to `1 vCPU / 1024 MiB`.
- `pyro run` fails if guest boot or guest exec is unavailable unless `--allow-host-compat` is set.
- `pyro run`, `pyro env list`, `pyro env pull`, `pyro env inspect`, `pyro env prune`, and `pyro doctor` are human-readable by default and return structured JSON with `--json`.
- `pyro demo ollama` prints log lines plus a final summary line.
- `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 create --id-only` prints only the new `workspace_id` plus a trailing newline.
- `pyro workspace create --name NAME --label KEY=VALUE` attaches human-oriented discovery metadata without changing the stable `workspace_id`.
- `pyro workspace create --network-policy {off,egress,egress+published-ports}` controls workspace guest networking and whether services may publish localhost ports.
- `pyro mcp serve --profile {vm-run,workspace-core,workspace-full}` narrows the model-facing MCP surface without changing runtime behavior; `workspace-core` is the recommended first profile for most chat hosts.
- `pyro workspace create --secret NAME=VALUE` and `--secret-file NAME=PATH` persist guest-only UTF-8 secrets outside `/workspace`.
- `pyro workspace list` returns persisted workspaces sorted by most recent `last_activity_at`.
- `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 file list WORKSPACE_ID [PATH] [--recursive]` returns metadata for one live path under `/workspace`.
- `pyro workspace file read WORKSPACE_ID PATH [--max-bytes N] [--content-only]` reads one regular text file under `/workspace`.
- `pyro workspace file write WORKSPACE_ID PATH --text TEXT` and `--text-file PATH` create or replace one regular text file under `/workspace`, creating missing parent directories automatically.
- `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] [--content-only]` 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.
- `pyro workspace service *` manages long-running named services inside one started workspace with typed readiness probes.
- `pyro workspace service start --publish GUEST_PORT` or `--publish HOST_PORT:GUEST_PORT` publishes one guest TCP port to `127.0.0.1` on the host.
- `pyro workspace exec --secret-env SECRET_NAME[=ENV_VAR]` maps one persisted secret into one exec call.
- `pyro workspace service start --secret-env SECRET_NAME[=ENV_VAR]` maps one persisted secret into one service start call.
- `pyro workspace exec` runs in the persistent `/workspace` for that workspace and does not auto-clean.
- `pyro workspace patch apply WORKSPACE_ID --patch TEXT` and `--patch-file PATH` apply one unified text patch with add/modify/delete operations under `/workspace`.
- `pyro workspace shell open --id-only` prints only the new `shell_id` plus a trailing newline.
- `pyro workspace shell open --secret-env SECRET_NAME[=ENV_VAR]` maps one persisted secret into the opened shell environment.
- `pyro workspace shell *` manages persistent PTY sessions inside a started workspace.
- `pyro workspace shell read --plain --wait-for-idle-ms 300` is the recommended chat-facing read mode; raw shell reads remain available without `--plain`.
- `pyro workspace logs` returns persisted command history for that workspace until `pyro workspace delete`.
- `pyro workspace update` changes only discovery metadata such as `name` and key/value `labels`.
- Workspace create/status results expose `workspace_seed` metadata describing how `/workspace` was initialized.
- Workspace create/status/reset/update results expose `name`, `labels`, and `last_activity_at`.
- Workspace create/status/reset results expose `network_policy`.
- Workspace create/status/reset results expose `reset_count` and `last_reset_at`.
- Workspace create/status/reset results expose safe `secrets` metadata with each secret name and source kind, but never the secret values.
- `pyro workspace status` includes aggregate `service_count` and `running_service_count` fields.
- `pyro workspace list` returns one summary row per persisted workspace with `workspace_id`, `name`, `labels`, `environment`, `state`, `created_at`, `last_activity_at`, `expires_at`, `command_count`, `service_count`, and `running_service_count`.
- `pyro workspace service start`, `pyro workspace service list`, and `pyro workspace service status` expose published-port metadata when present.
## MCP Entry Point
## Python SDK Contract
The product entrypoint is:
Primary facade:
```bash
pyro mcp serve
```
- `Pyro`
What to expect:
Supported public entrypoints:
- named modes are now the first chat-host story:
- `pyro mcp serve --mode repro-fix`
- `pyro mcp serve --mode inspect`
- `pyro mcp serve --mode cold-start`
- `pyro mcp serve --mode review-eval`
- bare `pyro mcp serve` remains the generic no-mode path and starts
`workspace-core`
- from a repo root, bare `pyro mcp serve` also auto-detects the current Git
checkout so `workspace_create` can omit `seed_path`
- `pyro mcp serve --profile workspace-full` explicitly opts into the larger
tool surface
- `pyro mcp serve --profile vm-run` exposes the smallest one-shot-only surface
- `pyro mcp serve --project-path /abs/path/to/repo` is the fallback when the
host does not preserve cwd
- `pyro mcp serve --repo-url ... [--repo-ref ...]` starts from a clean clone
source instead of a local checkout
- `create_server()`
- `Pyro.create_server()`
- `Pyro.list_environments()`
- `Pyro.pull_environment(environment)`
- `Pyro.inspect_environment(environment)`
- `Pyro.prune_environments()`
- `Pyro.create_vm(...)`
- `Pyro.create_workspace(..., name=None, labels=None, network_policy="off", secrets=None)`
- `Pyro.list_workspaces()`
- `Pyro.push_workspace_sync(workspace_id, source_path, *, dest="/workspace")`
- `Pyro.stop_workspace(workspace_id)`
- `Pyro.start_workspace(workspace_id)`
- `Pyro.list_workspace_files(workspace_id, path="/workspace", recursive=False)`
- `Pyro.read_workspace_file(workspace_id, path, *, max_bytes=65536)`
- `Pyro.write_workspace_file(workspace_id, path, *, text)`
- `Pyro.export_workspace(workspace_id, path, *, output_path)`
- `Pyro.apply_workspace_patch(workspace_id, *, patch)`
- `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)`
- `Pyro.delete_snapshot(workspace_id, snapshot_name)`
- `Pyro.reset_workspace(workspace_id, *, snapshot="baseline")`
- `Pyro.start_service(workspace_id, service_name, *, command, cwd="/workspace", readiness=None, ready_timeout_seconds=30, ready_interval_ms=500, secret_env=None, published_ports=None)`
- `Pyro.list_services(workspace_id)`
- `Pyro.status_service(workspace_id, service_name)`
- `Pyro.logs_service(workspace_id, service_name, *, tail_lines=200, all=False)`
- `Pyro.stop_service(workspace_id, service_name)`
- `Pyro.open_shell(workspace_id, *, cwd="/workspace", cols=120, rows=30, secret_env=None)`
- `Pyro.read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536, plain=False, wait_for_idle_ms=None)`
- `Pyro.write_shell(workspace_id, shell_id, *, input, append_newline=True)`
- `Pyro.signal_shell(workspace_id, shell_id, *, signal_name="INT")`
- `Pyro.close_shell(workspace_id, shell_id)`
- `Pyro.start_vm(vm_id)`
- `Pyro.exec_vm(vm_id, *, command, timeout_seconds=30)`
- `Pyro.exec_workspace(workspace_id, *, command, timeout_seconds=30, secret_env=None)`
- `Pyro.stop_vm(vm_id)`
- `Pyro.delete_vm(vm_id)`
- `Pyro.delete_workspace(workspace_id)`
- `Pyro.status_vm(vm_id)`
- `Pyro.status_workspace(workspace_id)`
- `Pyro.update_workspace(workspace_id, *, name=None, clear_name=False, labels=None, clear_labels=None)`
- `Pyro.logs_workspace(workspace_id)`
- `Pyro.network_info_vm(vm_id)`
- `Pyro.reap_expired()`
- `Pyro.run_in_vm(...)`
Host-specific setup docs:
Stable public method names:
- [claude_code_mcp.md](../examples/claude_code_mcp.md)
- [codex_mcp.md](../examples/codex_mcp.md)
- [opencode_mcp_config.json](../examples/opencode_mcp_config.json)
- [mcp_client_config.md](../examples/mcp_client_config.md)
- `create_server()`
- `list_environments()`
- `pull_environment(environment)`
- `inspect_environment(environment)`
- `prune_environments()`
- `create_vm(...)`
- `create_workspace(..., name=None, labels=None, network_policy="off", secrets=None)`
- `list_workspaces()`
- `push_workspace_sync(workspace_id, source_path, *, dest="/workspace")`
- `stop_workspace(workspace_id)`
- `start_workspace(workspace_id)`
- `list_workspace_files(workspace_id, path="/workspace", recursive=False)`
- `read_workspace_file(workspace_id, path, *, max_bytes=65536)`
- `write_workspace_file(workspace_id, path, *, text)`
- `export_workspace(workspace_id, path, *, output_path)`
- `apply_workspace_patch(workspace_id, *, patch)`
- `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)`
- `delete_snapshot(workspace_id, snapshot_name)`
- `reset_workspace(workspace_id, *, snapshot="baseline")`
- `start_service(workspace_id, service_name, *, command, cwd="/workspace", readiness=None, ready_timeout_seconds=30, ready_interval_ms=500, secret_env=None, published_ports=None)`
- `list_services(workspace_id)`
- `status_service(workspace_id, service_name)`
- `logs_service(workspace_id, service_name, *, tail_lines=200, all=False)`
- `stop_service(workspace_id, service_name)`
- `open_shell(workspace_id, *, cwd="/workspace", cols=120, rows=30, secret_env=None)`
- `read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536, plain=False, wait_for_idle_ms=None)`
- `write_shell(workspace_id, shell_id, *, input, append_newline=True)`
- `signal_shell(workspace_id, shell_id, *, signal_name="INT")`
- `close_shell(workspace_id, shell_id)`
- `start_vm(vm_id)`
- `exec_vm(vm_id, *, command, timeout_seconds=30)`
- `exec_workspace(workspace_id, *, command, timeout_seconds=30, secret_env=None)`
- `stop_vm(vm_id)`
- `delete_vm(vm_id)`
- `delete_workspace(workspace_id)`
- `status_vm(vm_id)`
- `status_workspace(workspace_id)`
- `update_workspace(workspace_id, *, name=None, clear_name=False, labels=None, clear_labels=None)`
- `logs_workspace(workspace_id)`
- `network_info_vm(vm_id)`
- `reap_expired()`
- `run_in_vm(...)`
The chat-host bootstrap helper surface is:
Behavioral defaults:
- `pyro host connect claude-code`
- `pyro host connect codex`
- `pyro host print-config opencode`
- `pyro host doctor`
- `pyro host repair HOST`
- `Pyro.create_vm(...)` and `Pyro.run_in_vm(...)` default to `vcpu_count=1` and `mem_mib=1024`.
- `Pyro.create_workspace(...)` defaults to `vcpu_count=1` and `mem_mib=1024`.
- `allow_host_compat` defaults to `False` on `create_vm(...)` and `run_in_vm(...)`.
- `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.create_workspace(..., name=..., labels=...)` attaches human-oriented discovery metadata without changing the stable `workspace_id`.
- `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.list_workspaces()` returns persisted workspace summaries sorted by most recent `last_activity_at`.
- `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.list_workspace_files(...)`, `Pyro.read_workspace_file(...)`, and `Pyro.write_workspace_file(...)` provide structured live `/workspace` inspection and text edits without shell quoting.
- `Pyro.export_workspace(...)` exports one file or directory from `/workspace` to an explicit host path.
- `Pyro.apply_workspace_patch(...)` applies unified text patches for add/modify/delete operations under `/workspace`.
- `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.
- `Pyro.delete_snapshot(...)` deletes one named snapshot while leaving `baseline` intact.
- `Pyro.reset_workspace(...)` recreates the full sandbox from `baseline` or one named snapshot and clears command, shell, and service history.
- `Pyro.start_service(..., secret_env=...)` maps persisted workspace secrets into that service process as environment variables for that start call only.
- `Pyro.start_service(...)` starts one named long-running process in a started workspace and waits for its typed readiness probe when configured.
- `Pyro.start_service(..., published_ports=[...])` publishes one or more guest TCP ports to `127.0.0.1` on the host when the workspace network policy is `egress+published-ports`.
- `Pyro.list_services(...)`, `Pyro.status_service(...)`, `Pyro.logs_service(...)`, and `Pyro.stop_service(...)` manage those persisted workspace services.
- `Pyro.exec_vm(...)` runs one command and auto-cleans that VM after the exec completes.
- `Pyro.exec_workspace(..., secret_env=...)` maps persisted workspace secrets into that exec call as environment variables for that call only.
- `Pyro.exec_workspace(...)` runs one command in the persistent workspace and leaves it alive.
- `Pyro.open_shell(..., secret_env=...)` maps persisted workspace secrets into the shell environment when that shell opens.
- `Pyro.open_shell(...)` opens a persistent PTY shell attached to one started workspace.
- `Pyro.read_shell(...)` reads merged text output from that shell by cursor, with optional plain rendering and idle batching for chat-facing consumers.
- `Pyro.write_shell(...)`, `Pyro.signal_shell(...)`, and `Pyro.close_shell(...)` operate on that persistent shell session.
- `Pyro.update_workspace(...)` changes only discovery metadata such as `name` and key/value `labels`.
These helpers wrap the same `pyro mcp serve` entrypoint and are the preferred
setup and repair path for supported hosts.
## MCP Contract
## Named Modes
Stable MCP profiles:
The supported named modes are:
- `vm-run`: exposes only `vm_run`
- `workspace-core`: exposes `vm_run`, `workspace_create`, `workspace_list`, `workspace_update`, `workspace_status`, `workspace_sync_push`, `workspace_exec`, `workspace_logs`, `workspace_file_list`, `workspace_file_read`, `workspace_file_write`, `workspace_patch_apply`, `workspace_diff`, `workspace_export`, `workspace_reset`, and `workspace_delete`
- `workspace-full`: exposes the complete stable MCP surface below
| Mode | Intended workflow | Key tools |
| --- | --- | --- |
| `repro-fix` | reproduce, patch, rerun, diff, export, reset | file ops, patch, diff, export, reset, summary |
| `inspect` | inspect suspicious or unfamiliar code with the smallest persistent surface | file list/read, exec, export, summary |
| `cold-start` | validate a fresh repo and keep services alive long enough to prove readiness | exec, export, reset, summary, service tools |
| `review-eval` | interactive review, checkpointing, shell-driven evaluation, and export | shell tools, snapshot tools, diff/export, summary |
Behavioral defaults:
Use the generic no-mode path when one of those named modes feels too narrow for
the job.
- `pyro mcp serve`, `create_server()`, and `Pyro.create_server()` default to `workspace-core`.
- `workspace-core` is the default and recommended first profile for most new chat-host integrations.
- `create_server(profile="workspace-full")` and `Pyro.create_server(profile="workspace-full")` opt into the full advanced workspace surface explicitly.
- `workspace-core` narrows `workspace_create` by omitting `network_policy` and `secrets`.
- `workspace-core` narrows `workspace_exec` by omitting `secret_env`.
## Generic Workspace Contract
Primary tool:
`workspace-core` is the normal chat path. It exposes:
- `vm_run`
Advanced lifecycle tools:
- `vm_list_environments`
- `vm_create`
- `vm_start`
- `vm_exec`
- `vm_stop`
- `vm_delete`
- `vm_status`
- `vm_network_info`
- `vm_reap_expired`
Persistent workspace tools:
- `workspace_create`
- `workspace_list`
- `workspace_update`
- `workspace_status`
- `workspace_sync_push`
- `workspace_stop`
- `workspace_start`
- `workspace_exec`
- `workspace_logs`
- `workspace_summary`
- `workspace_file_list`
- `workspace_file_read`
- `workspace_file_write`
- `workspace_export`
- `workspace_patch_apply`
- `workspace_disk_export`
- `workspace_disk_list`
- `workspace_disk_read`
- `workspace_diff`
- `snapshot_create`
- `snapshot_list`
- `snapshot_delete`
- `workspace_export`
- `workspace_reset`
- `service_start`
- `service_list`
- `service_status`
- `service_logs`
- `service_stop`
- `shell_open`
- `shell_read`
- `shell_write`
- `shell_signal`
- `shell_close`
- `workspace_status`
- `workspace_update`
- `workspace_logs`
- `workspace_delete`
Behavioral defaults:
That is enough for the normal persistent editing loop:
- `vm_run` and `vm_create` default to `vcpu_count=1` and `mem_mib=1024`.
- `workspace_create` defaults to `vcpu_count=1` and `mem_mib=1024`.
- `vm_run` and `vm_create` expose `allow_host_compat`, which defaults to `false`.
- `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_create` accepts optional `name` and `labels` metadata for human discovery without changing the stable `workspace_id`.
- `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_list` returns persisted workspace summaries sorted by most recent `last_activity_at`.
- `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_file_list`, `workspace_file_read`, and `workspace_file_write` provide structured live `/workspace` inspection and text edits without shell quoting.
- `workspace_export` exports one file or directory from `/workspace` to an explicit host path.
- `workspace_patch_apply` applies unified text patches for add/modify/delete operations under `/workspace`.
- `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.
- `service_start`, `service_list`, `service_status`, `service_logs`, and `service_stop` manage persistent named services inside a started workspace.
- `service_start` accepts optional `published_ports` to expose guest TCP ports on `127.0.0.1` when the workspace network policy is `egress+published-ports`.
- `vm_exec` runs one command and auto-cleans that VM after the exec completes.
- `workspace_exec` accepts optional `secret_env` mappings for one exec call and leaves the workspace alive.
- `workspace_update` changes only discovery metadata such as `name` and key/value `labels`.
- `service_start` accepts optional `secret_env` mappings for one service start call.
- `shell_open` accepts optional `secret_env` mappings for the opened shell session.
- `shell_open`, `shell_read`, `shell_write`, `shell_signal`, and `shell_close` manage persistent PTY shells inside a started workspace.
- create one workspace, often without `seed_path` when the server already has a
project source
- sync or seed repo content
- inspect and edit files without shell quoting
- run commands repeatedly in one sandbox
- review the current session in one concise summary
- diff and export results
- reset and retry
- delete the workspace when the task is done
## Versioning Rule
Move to `workspace-full` only when the chat truly needs:
- `pyro-mcp` uses SemVer.
- Environment names are stable identifiers in the shipped catalog.
- Changing a public command name, public flag, public method name, public MCP tool name, or required request field is a breaking change.
- persistent PTY shell sessions
- long-running services and readiness probes
- secrets
- guest networking and published ports
- stopped-workspace disk inspection
## Recipe-Backed Workflows
The documented product workflows are:
| Workflow | Recommended mode | Doc |
| --- | --- | --- |
| Cold-start repo validation | `cold-start` | [use-cases/cold-start-repo-validation.md](use-cases/cold-start-repo-validation.md) |
| Repro plus fix loop | `repro-fix` | [use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md) |
| Parallel isolated workspaces | `repro-fix` | [use-cases/parallel-workspaces.md](use-cases/parallel-workspaces.md) |
| Unsafe or untrusted code inspection | `inspect` | [use-cases/untrusted-inspection.md](use-cases/untrusted-inspection.md) |
| Review and evaluation workflows | `review-eval` | [use-cases/review-eval-workflows.md](use-cases/review-eval-workflows.md) |
Treat this smoke pack as the trustworthy guest-backed verification path for the
advertised product:
```bash
make smoke-use-cases
```
The chat-host MCP path above is the thing the docs are intentionally shaping
around.

View file

@ -6,12 +6,15 @@ goal:
make the core agent-workspace use cases feel trivial from a chat-driven LLM
interface.
Current baseline is `4.0.0`:
Current baseline is `4.5.0`:
- the stable workspace contract exists across CLI, SDK, and MCP
- one-shot `pyro run` still exists as the narrow entrypoint
- `pyro mcp serve` is now the default product entrypoint
- `workspace-core` is now the default MCP profile
- one-shot `pyro run` still exists as the terminal companion path
- workspaces already support seeding, sync push, exec, export, diff, snapshots,
reset, services, PTY shells, secrets, network policy, and published ports
- host-specific onramps exist for Claude Code, Codex, and OpenCode
- the five documented use cases are now recipe-backed and smoke-tested
- stopped-workspace disk tools now exist, but remain explicitly secondary
## What "Trivial In Chat" Means
@ -33,9 +36,16 @@ More concretely, the model should not need to:
- choose from an unnecessarily large tool surface when a smaller profile would
work
The remaining UX friction for a technically strong new user is now narrower:
The next gaps for the narrowed persona are now about real-project credibility:
- no major chat-host ergonomics gaps remain in the current roadmap
- current-checkout startup is still brittle for messy local repos with unreadable,
generated, or permission-sensitive files
- the guest-backed smoke pack is strong, but it still proves shaped scenarios
better than arbitrary local-repo readiness
- the chat-host path still does not let users choose the sandbox environment as
a first-class part of host connection and server startup
- the product should not claim full whole-project development readiness until it
qualifies a real-project loop beyond fixture-shaped use cases
## Locked Decisions
@ -43,9 +53,24 @@ The remaining UX friction for a technically strong new user is now narrower:
or runner abstractions
- keep disk tools secondary and do not make them the main chat-facing surface
- prefer narrow tool profiles and structured outputs over more raw shell calls
- capability milestones should update CLI, SDK, and MCP together
- optimize the MCP/chat-host path first and keep the CLI companion path good
enough to validate and debug it
- lower-level SDK and repo substrate work can continue, but they should not
drive milestone scope or naming
- CLI-only ergonomics are allowed when the SDK and MCP surfaces already have the
structured behavior natively
- prioritize repo-aware startup, trust, and daily-loop speed before adding more
low-level workspace surface area
- for repo-root auto-detection and `--project-path` inside a Git checkout, the
default project source should become Git-tracked files only
- `--repo-url` remains the clean-clone path when users do not want to trust the
local checkout as the startup source
- environment selection must become first-class in the chat-host path before the
product claims whole-project development readiness
- real-project readiness must be proven with guest-backed qualification smokes
that cover ignored, generated, and unreadable-file cases
- breaking changes are acceptable while there are still no users and the
chat-host product is still being shaped
- every milestone below must also update docs, help text, runnable examples,
and at least one real smoke scenario
@ -62,6 +87,16 @@ The remaining UX friction for a technically strong new user is now narrower:
9. [`3.10.0` Use-Case Smoke Trust And Recipe Fidelity](llm-chat-ergonomics/3.10.0-use-case-smoke-trust-and-recipe-fidelity.md) - Done
10. [`3.11.0` Host-Specific MCP Onramps](llm-chat-ergonomics/3.11.0-host-specific-mcp-onramps.md) - Done
11. [`4.0.0` Workspace-Core Default Profile](llm-chat-ergonomics/4.0.0-workspace-core-default-profile.md) - Done
12. [`4.1.0` Project-Aware Chat Startup](llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md) - Done
13. [`4.2.0` Host Bootstrap And Repair](llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md) - Done
14. [`4.3.0` Reviewable Agent Output](llm-chat-ergonomics/4.3.0-reviewable-agent-output.md) - Done
15. [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md) - Done
16. [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md) - Done
17. [`4.6.0` Git-Tracked Project Sources](llm-chat-ergonomics/4.6.0-git-tracked-project-sources.md) - Planned
18. [`4.7.0` Project Source Diagnostics And Recovery](llm-chat-ergonomics/4.7.0-project-source-diagnostics-and-recovery.md) - Planned
19. [`4.8.0` First-Class Chat Environment Selection](llm-chat-ergonomics/4.8.0-first-class-chat-environment-selection.md) - Planned
20. [`4.9.0` Real-Repo Qualification Smokes](llm-chat-ergonomics/4.9.0-real-repo-qualification-smokes.md) - Planned
21. [`5.0.0` Whole-Project Sandbox Development](llm-chat-ergonomics/5.0.0-whole-project-sandbox-development.md) - Planned
Completed so far:
@ -92,10 +127,29 @@ Completed so far:
config manually.
- `4.0.0` flipped the default MCP/server profile to `workspace-core`, so the bare entrypoint now
matches the recommended narrow chat-host profile across CLI, SDK, and package-level factories.
- `4.1.0` made repo-root startup native for chat hosts, so bare `pyro mcp serve` can auto-detect
the current Git checkout and let the first `workspace_create` omit `seed_path`, with explicit
`--project-path` and `--repo-url` fallbacks when cwd is not the source of truth.
- `4.2.0` adds first-class host bootstrap and repair helpers so Claude Code,
Codex, and OpenCode users can connect or repair the supported chat-host path
without manually composing raw MCP commands or config edits.
- `4.3.0` adds a concise workspace review surface so users can inspect what the
agent changed and ran since the last reset without reconstructing the
session from several lower-level views by hand.
- `4.4.0` adds named use-case modes so chat hosts can start from `repro-fix`,
`inspect`, `cold-start`, or `review-eval` instead of choosing from the full
generic workspace surface first.
- `4.5.0` adds `pyro prepare`, daily-loop readiness in `pyro doctor`, and a
real `make smoke-daily-loop` verification path so the local machine warmup
story is explicit before the chat host connects.
Planned next:
- no further chat-ergonomics milestones are currently planned in this roadmap.
- [`4.6.0` Git-Tracked Project Sources](llm-chat-ergonomics/4.6.0-git-tracked-project-sources.md)
- [`4.7.0` Project Source Diagnostics And Recovery](llm-chat-ergonomics/4.7.0-project-source-diagnostics-and-recovery.md)
- [`4.8.0` First-Class Chat Environment Selection](llm-chat-ergonomics/4.8.0-first-class-chat-environment-selection.md)
- [`4.9.0` Real-Repo Qualification Smokes](llm-chat-ergonomics/4.9.0-real-repo-qualification-smokes.md)
- [`5.0.0` Whole-Project Sandbox Development](llm-chat-ergonomics/5.0.0-whole-project-sandbox-development.md)
## Expected Outcome
@ -117,3 +171,16 @@ The intended model-facing shape is:
- human-mode content reads are copy-paste safe
- the default bare MCP server entrypoint matches the recommended narrow profile
- the five core use cases are documented and smoke-tested end to end
- starting from the current repo feels native from the first chat-host setup
- supported hosts can be connected or repaired without manual config spelunking
- users can review one concise summary of what the agent changed and ran
- the main workflows feel like named modes instead of one giant reference
- reset and retry loops are fast enough to encourage daily use
- repo-root startup is robust even when the local checkout contains ignored,
generated, or unreadable files
- chat-host users can choose the sandbox environment as part of the normal
connect/start path
- the product has guest-backed qualification for real local repos, not only
shaped fixture scenarios
- it becomes credible to tell a user they can develop a real project inside
sandboxes, not just evaluate or patch one

View file

@ -0,0 +1,56 @@
# `4.1.0` Project-Aware Chat Startup
Status: Done
## Goal
Make "current repo to disposable sandbox" the default story for the narrowed
chat-host user, without requiring manual workspace seeding choreography first.
## Public API Changes
The chat entrypoint should gain one documented project-aware startup path:
- `pyro mcp serve` should accept an explicit local project source, such as the
current checkout
- the product path should optionally support a clean-clone source, such as a
repo URL, when the user is not starting from a local checkout
- the first useful chat turn should not depend on manually teaching
`workspace create ... --seed-path ...` before the host can do real work
Exact flag names can still change, but the product needs one obvious "use this
repo" path and one obvious "start from that repo" path.
## Implementation Boundaries
- keep host crossing explicit; do not silently mutate the user's checkout
- prefer local checkout seeding first, because that is the most natural daily
chat path
- preserve existing explicit sync, export, diff, and reset primitives rather
than inventing a hidden live-sync layer
- keep the startup story compatible with the existing `workspace-core` product
path
## Non-Goals
- no generic SCM integration platform
- no background multi-repo workspace manager
- no always-on bidirectional live sync between host checkout and guest
## Acceptance Scenarios
- from a repo root, a user can connect Claude Code, Codex, or OpenCode and the
first workspace starts from that repo without extra terminal choreography
- from outside a repo checkout, a user can still start from a documented clean
source such as a repo URL
- the README and install docs can teach a repo-aware chat flow before the
manual terminal workspace flow
## Required Repo Updates
- README, install docs, first-run docs, integrations docs, and public contract
updated to show the repo-aware chat startup path
- help text updated so the repo-aware startup path is visible from `pyro` and
`pyro mcp serve --help`
- at least one recipe and one real smoke scenario updated to validate the new
startup story

View file

@ -0,0 +1,53 @@
# `4.2.0` Host Bootstrap And Repair
Status: Done
## Goal
Make supported chat hosts feel one-command to connect and easy to repair when a
local config drifts or the product changes shape.
## Public API Changes
The CLI should grow a small host-helper surface for the supported chat hosts:
- `pyro host connect claude-code`
- `pyro host connect codex`
- `pyro host print-config opencode`
- `pyro host doctor`
- `pyro host repair HOST`
The exact names can still move, but the product needs a first-class bootstrap
and repair path for Claude Code, Codex, and OpenCode.
## Implementation Boundaries
- host helpers should wrap the same `pyro mcp serve` entrypoint rather than
introduce per-host runtime behavior
- config changes should remain inspectable and predictable
- support both installed-package and `uvx`-style usage where that materially
reduces friction
- keep the host helper story narrow to the current supported hosts
## Non-Goals
- no GUI installer or onboarding wizard
- no attempt to support every possible MCP-capable editor or chat shell
- no hidden network service or account-based control plane
## Acceptance Scenarios
- a new Claude Code or Codex user can connect `pyro` with one command
- an OpenCode user can print or materialize a correct config without hand-writing
JSON
- a user with a stale or broken local host config can run one repair or doctor
flow instead of debugging MCP setup manually
## Required Repo Updates
- new host-helper docs and examples for all supported chat hosts
- README, install docs, and integrations docs updated to prefer the helper
flows when available
- help text updated with exact connect and repair commands
- runnable verification or smoke coverage that proves the shipped host-helper
examples stay current

View file

@ -0,0 +1,54 @@
# `4.3.0` Reviewable Agent Output
Status: Done
## Goal
Make it easy for a human to review what the agent actually did inside the
sandbox without manually reconstructing the session from diffs, logs, and raw
artifacts.
## Public API Changes
The product should expose a concise workspace review surface, for example:
- `pyro workspace summary WORKSPACE_ID`
- `workspace_summary` on the MCP side
- structured JSON plus a short human-readable summary view
The summary should cover the things a chat-host user cares about:
- commands run
- files changed
- diff or patch summary
- services started
- artifacts exported
- final workspace outcome
## Implementation Boundaries
- prefer concise review surfaces over raw event firehoses
- keep raw logs, diffs, and exported files available as drill-down tools
- summarize only the sandbox activity the product can actually observe
- make the summary good enough to paste into a chat, bug report, or PR comment
## Non-Goals
- no full compliance or audit product
- no attempt to summarize the model's hidden reasoning
- no remote storage backend for session history
## Acceptance Scenarios
- after a repro-fix or review-eval run, a user can inspect one summary and
understand what changed and what to review next
- the summary is useful enough to accompany exported patches or artifacts
- unsafe-inspection and review-eval flows become easier to trust because the
user can review agent-visible actions in one place
## Required Repo Updates
- public contract, help text, README, and recipe docs updated with the new
summary path
- at least one host-facing example showing how to ask for or export the summary
- at least one real smoke scenario validating the review surface end to end

View file

@ -0,0 +1,56 @@
# `4.4.0` Opinionated Use-Case Modes
Status: Done
## Goal
Stop making chat-host users think in terms of one giant workspace surface and
let them start from a small mode that matches the job they want the agent to do.
## Public API Changes
The chat entrypoint should gain named use-case modes, for example:
- `pyro mcp serve --mode repro-fix`
- `pyro mcp serve --mode inspect`
- `pyro mcp serve --mode cold-start`
- `pyro mcp serve --mode review-eval`
Modes should narrow the product story by selecting the right defaults for:
- tool surface
- workspace bootstrap behavior
- docs and example prompts
- expected export and review outputs
Parallel workspace use should come from opening more than one named workspace
inside the same mode, not from introducing a scheduler or queue abstraction.
## Implementation Boundaries
- build modes on top of the existing `workspace-core` and `workspace-full`
capabilities instead of inventing separate backends
- keep the mode list short and mapped to the documented use cases
- make modes visible from help text, host helpers, and recipe docs together
- let users opt out to the generic workspace path when the mode is too narrow
## Non-Goals
- no user-defined mode DSL
- no hidden host-specific behavior for the same mode name
- no CI-style pipelines, matrix builds, or queueing abstractions
## Acceptance Scenarios
- a new user can pick one mode and avoid reading the full workspace surface
before starting
- the documented use cases map cleanly to named entry modes
- parallel issue or PR work feels like "open another workspace in the same
mode", not "submit another job"
## Required Repo Updates
- help text, README, install docs, integrations docs, and use-case recipes
updated to teach the named modes
- host-specific setup docs updated so supported hosts can start in a named mode
- at least one smoke scenario proving a mode-specific happy path end to end

View file

@ -0,0 +1,57 @@
# `4.5.0` Faster Daily Loops
Status: Done
## Goal
Make the day-to-day chat-host loop feel cheap enough that users reach for it
for normal work, not only for special high-isolation tasks.
## Public API Changes
The product now adds an explicit fast-path for repeated local use:
- `pyro prepare [ENVIRONMENT] [--network] [--force] [--json]`
- `pyro doctor --environment ENVIRONMENT` daily-loop readiness output
- `make smoke-daily-loop` to prove the warmed machine plus reset/retry story
The exact command names can still move, but the user-visible story needs to be:
- set the machine up once
- reconnect quickly
- create or reset a workspace cheaply
- keep iterating without redoing heavy setup work
## Implementation Boundaries
- optimize local-first loops on one machine before thinking about remote
execution
- focus on startup, create, reset, and retry latency rather than queue
throughput
- keep the fast path compatible with the repo-aware startup story and the
supported chat hosts
- prefer explicit caching and prewarm semantics over hidden long-running
daemons
## Non-Goals
- no cloud prewarm service
- no scheduler or queueing layer
- no daemon requirement for normal daily use
## Acceptance Scenarios
- after the first setup, entering the chat-host path again does not feel like
redoing the whole product onboarding
- reset and retry become cheap enough to recommend as the default repro-fix
workflow
- docs can present `pyro` as a daily coding-agent tool, not only as a special
heavy-duty sandbox
## Required Repo Updates
- docs now show the recommended daily-use fast path
- diagnostics and help text now show whether the machine is already warm and
ready
- the repo now includes `make smoke-daily-loop` as a repeat-loop verification
scenario for the daily workflow

View file

@ -0,0 +1,55 @@
# `4.6.0` Git-Tracked Project Sources
Status: Planned
## Goal
Make repo-root startup and `--project-path` robust for messy real checkouts by
stopping the default chat-host path from trying to ingest every readable and
unreadable file in the working tree.
## Public API Changes
Project-aware startup should change its default local source semantics:
- bare `pyro mcp serve` from inside a Git checkout should seed from Git-tracked
files only
- `pyro mcp serve --project-path PATH` should also use Git-tracked files only
when `PATH` is inside a Git checkout
- `--repo-url` remains the clean-clone path when the user wants a host-side
clone instead of the local checkout
- explicit `workspace create --seed-path PATH` remains unchanged in this
milestone
## Implementation Boundaries
- apply the new semantics only to project-aware startup sources, not every
explicit directory seed
- do not silently include ignored or untracked junk in the default chat-host
path
- preserve explicit diff, export, sync push, and reset behavior
- surface the chosen project source clearly enough that users know what the
sandbox started from
## Non-Goals
- no generic SCM abstraction layer
- no silent live sync between the host checkout and the guest
- no change to explicit archive seeding semantics in this milestone
## Acceptance Scenarios
- starting `pyro mcp serve` from a repo root no longer fails on unreadable
build artifacts or ignored runtime byproducts
- starting from `--project-path` inside a Git repo behaves the same way
- users can predict that the startup source matches the tracked project state
rather than the entire working tree
## Required Repo Updates
- README, install docs, integrations docs, and public contract updated to state
what local project-aware startup actually includes
- help text updated to distinguish project-aware startup from explicit
`--seed-path` behavior
- at least one guest-backed smoke scenario added for a repo with ignored,
generated, and unreadable files

View file

@ -0,0 +1,50 @@
# `4.7.0` Project Source Diagnostics And Recovery
Status: Planned
## Goal
Make project-source selection and startup failures understandable enough that a
chat-host user can recover without reading internals or raw tracebacks.
## Public API Changes
The chat-host path should expose clearer project-source diagnostics:
- `pyro doctor` should report the active project-source kind and its readiness
- `pyro mcp serve` and host helpers should explain whether they are using
tracked local files, `--project-path`, `--repo-url`, or no project source
- startup failures should recommend the right fallback:
`--project-path`, `--repo-url`, `--no-project-source`, or explicit
`seed_path`
## Implementation Boundaries
- keep diagnostics focused on the chat-host path rather than inventing a broad
source-management subsystem
- prefer actionable recovery guidance over long implementation detail dumps
- make project-source diagnostics visible from the same surfaces users already
touch: help text, `doctor`, host helpers, and startup errors
## Non-Goals
- no generic repo-health audit product
- no attempt to auto-fix arbitrary local checkout corruption
- no host-specific divergence in project-source behavior
## Acceptance Scenarios
- a user can tell which project source the chat host will use before creating a
workspace
- a user who hits a project-source failure gets a concrete recovery path instead
of a raw permission traceback
- host helper doctor and repair flows can explain project-source problems, not
only MCP config problems
## Required Repo Updates
- docs, help text, and troubleshooting updated with project-source diagnostics
and fallback guidance
- at least one smoke or targeted CLI test covering the new failure guidance
- host-helper docs updated to show when to prefer `--project-path`,
`--repo-url`, or `--no-project-source`

View file

@ -0,0 +1,52 @@
# `4.8.0` First-Class Chat Environment Selection
Status: Planned
## Goal
Make curated environment choice part of the normal chat-host path so full
project work is not implicitly tied to one default environment.
## Public API Changes
Environment selection should become first-class in the chat-host path:
- `pyro mcp serve` should accept an explicit environment
- `pyro host connect` should accept and preserve an explicit environment
- `pyro host print-config` and `pyro host repair` should preserve the selected
environment where relevant
- named modes should be able to recommend a default environment when one is
better for the workflow, without removing explicit user choice
## Implementation Boundaries
- keep environment selection aligned with the existing curated environment
catalog
- avoid inventing host-specific environment behavior for the same mode
- keep the default environment path simple for the quickest evaluator flow
- ensure the chosen environment is visible from generated config, help text, and
diagnostics
## Non-Goals
- no custom user-built environment pipeline in this milestone
- no per-host environment negotiation logic
- no attempt to solve arbitrary dependency installation through environment
sprawl alone
## Acceptance Scenarios
- a user can choose a build-oriented environment such as `debian:12-build`
before connecting the chat host
- host helpers, raw server startup, and printed configs all preserve the same
environment choice
- docs can teach whole-project development without pretending every project fits
the same default environment
## Required Repo Updates
- README, install docs, integrations docs, public contract, and host examples
updated to show environment selection in the chat-host path
- help text updated for raw server startup and host helpers
- at least one guest-backed smoke scenario updated to prove a non-default
environment in the chat-host flow

View file

@ -0,0 +1,52 @@
# `4.9.0` Real-Repo Qualification Smokes
Status: Planned
## Goal
Replace fixture-only confidence with guest-backed proof that the chat-host path
works against messy local repos and clean-clone startup sources.
## Public API Changes
No new runtime surface is required in this milestone. The main additions are
qualification smokes and their supporting fixtures.
The new coverage should prove:
- repo-root startup from a local Git checkout with ignored, generated, and
unreadable files
- `--repo-url` clean-clone startup
- a realistic install, test, patch, rerun, and export loop
- at least one nontrivial service-start or readiness loop
## Implementation Boundaries
- keep the smoke pack guest-backed and deterministic enough to use as a release
gate
- focus on realistic repo-shape and project-loop problems, not synthetic
micro-feature assertions
- prefer a small number of representative project fixtures over a large matrix
of toy repos
## Non-Goals
- no promise to qualify every language ecosystem in one milestone
- no cloud or remote execution qualification layer
- no broad benchmark suite beyond what is needed to prove readiness
## Acceptance Scenarios
- the repo has one clear smoke target for real-repo qualification
- at least one local-checkout smoke proves the new Git-tracked startup behavior
- at least one clean-clone smoke proves the `--repo-url` path
- failures in these smokes clearly separate project-source issues from runtime
or host issues
## Required Repo Updates
- new guest-backed smoke targets and any supporting fixtures
- roadmap, use-case docs, and release/readiness docs updated to point at the
new qualification path
- troubleshooting updated with the distinction between shaped use-case smokes
and real-repo qualification smokes

View file

@ -0,0 +1,53 @@
# `5.0.0` Whole-Project Sandbox Development
Status: Planned
## Goal
Reach the point where it is credible to tell a user they can develop a real
project inside sandboxes, not just validate, inspect, or patch one.
## Public API Changes
No new generic VM breadth is required here. This milestone should consolidate
the earlier pieces into one believable full-project product story:
- robust project-aware startup
- explicit environment selection in the chat-host path
- summaries, reset, export, and service workflows that hold up during longer
work loops
- qualification targets that prove a nontrivial development cycle
## Implementation Boundaries
- keep the product centered on the chat-host workspace path rather than a broad
CLI or SDK platform story
- use the existing named modes and generic workspace path where they fit, but
teach one end-to-end full-project development walkthrough
- prioritize daily development credibility over adding new low-level sandbox
surfaces
## Non-Goals
- no attempt to become a generic remote dev environment platform
- no scheduler, queue, or CI matrix abstractions
- no claim that every possible project type is equally well supported
## Acceptance Scenarios
- the docs contain one end-to-end “develop a project in sandboxes” walkthrough
- that walkthrough covers dependency install, tests, patching, reruns, review,
and export, with app/service startup when relevant
- at least one guest-backed qualification target proves the story on a
nontrivial project
- the readiness docs can honestly say whole-project development is supported
with explicit caveats instead of hedged aspirational language
## Required Repo Updates
- README, install docs, integrations docs, use-case docs, and public contract
updated to include the whole-project development story
- at least one walkthrough asset or transcript added for the new end-to-end
path
- readiness and troubleshooting docs updated with the actual supported scope and
remaining caveats

View file

@ -1,6 +1,6 @@
# Workspace Use-Case Recipes
These recipes turn the stable workspace surface into five concrete agent flows.
These recipes turn the chat-host workspace path into five concrete agent flows.
They are the canonical next step after the quickstart in [install.md](../install.md)
or [first-run.md](../first-run.md).
@ -12,13 +12,13 @@ make smoke-use-cases
Recipe matrix:
| Use case | Recommended profile | Smoke target | Recipe |
| Use case | Recommended mode | Smoke target | Recipe |
| --- | --- | --- | --- |
| Cold-start repo validation | `workspace-full` | `make smoke-cold-start-validation` | [cold-start-repo-validation.md](cold-start-repo-validation.md) |
| Repro plus fix loop | `workspace-core` | `make smoke-repro-fix-loop` | [repro-fix-loop.md](repro-fix-loop.md) |
| Parallel isolated workspaces | `workspace-core` | `make smoke-parallel-workspaces` | [parallel-workspaces.md](parallel-workspaces.md) |
| Unsafe or untrusted code inspection | `workspace-core` | `make smoke-untrusted-inspection` | [untrusted-inspection.md](untrusted-inspection.md) |
| Review and evaluation workflows | `workspace-full` | `make smoke-review-eval` | [review-eval-workflows.md](review-eval-workflows.md) |
| Cold-start repo validation | `cold-start` | `make smoke-cold-start-validation` | [cold-start-repo-validation.md](cold-start-repo-validation.md) |
| Repro plus fix loop | `repro-fix` | `make smoke-repro-fix-loop` | [repro-fix-loop.md](repro-fix-loop.md) |
| Parallel isolated workspaces | `repro-fix` | `make smoke-parallel-workspaces` | [parallel-workspaces.md](parallel-workspaces.md) |
| Unsafe or untrusted code inspection | `inspect` | `make smoke-untrusted-inspection` | [untrusted-inspection.md](untrusted-inspection.md) |
| Review and evaluation workflows | `review-eval` | `make smoke-review-eval` | [review-eval-workflows.md](review-eval-workflows.md) |
All five recipes use the same real Firecracker-backed smoke runner:
@ -30,3 +30,9 @@ That runner generates its own host fixtures, creates real guest-backed workspace
verifies the intended flow, exports one concrete result when relevant, and cleans
up on both success and failure. Treat `make smoke-use-cases` as the trustworthy
guest-backed verification path for the advertised workspace workflows.
For a concise review before exporting, resetting, or handing work off, use:
```bash
pyro workspace summary WORKSPACE_ID
```

View file

@ -1,6 +1,12 @@
# Cold-Start Repo Validation
Recommended profile: `workspace-full`
Recommended mode: `cold-start`
Recommended startup:
```bash
pyro host connect claude-code --mode cold-start
```
Smoke target:
@ -12,26 +18,18 @@ Use this flow when an agent needs to treat a fresh repo like a new user would:
seed it into a workspace, run the validation script, keep one long-running
process alive, probe it from another command, and export a validation report.
Canonical SDK flow:
Chat-host recipe:
```python
from pyro_mcp import Pyro
1. Create one workspace from the repo seed.
2. Run the validation command inside that workspace.
3. Start the app as a long-running service with readiness configured.
4. Probe the ready service from another command in the same workspace.
5. Export the validation report back to the host.
6. Delete the workspace when the evaluation is done.
pyro = Pyro()
created = pyro.create_workspace(environment="debian:12", seed_path="./repo")
workspace_id = str(created["workspace_id"])
pyro.exec_workspace(workspace_id, command="sh validate.sh")
pyro.start_service(
workspace_id,
"app",
command="sh serve.sh",
readiness={"type": "file", "path": ".app-ready"},
)
pyro.exec_workspace(workspace_id, command="sh -lc 'test -f .app-ready && cat service-state.txt'")
pyro.export_workspace(workspace_id, "validation-report.txt", output_path="./validation-report.txt")
pyro.delete_workspace(workspace_id)
```
If the named mode feels too narrow, fall back to the generic no-mode path and
then opt into `--profile workspace-full` only when you truly need the larger
advanced surface.
This recipe is intentionally guest-local and deterministic. It proves startup,
service readiness, validation, and host-out report capture without depending on

View file

@ -1,6 +1,12 @@
# Parallel Isolated Workspaces
Recommended profile: `workspace-core`
Recommended mode: `repro-fix`
Recommended startup:
```bash
pyro host connect codex --mode repro-fix
```
Smoke target:
@ -11,33 +17,16 @@ make smoke-parallel-workspaces
Use this flow when the agent needs one isolated workspace per issue, branch, or
review thread and must rediscover the right one later.
Canonical SDK flow:
Chat-host recipe:
```python
from pyro_mcp import Pyro
pyro = Pyro()
alpha = pyro.create_workspace(
environment="debian:12",
seed_path="./repo",
name="parallel-alpha",
labels={"branch": "alpha", "issue": "123"},
)
beta = pyro.create_workspace(
environment="debian:12",
seed_path="./repo",
name="parallel-beta",
labels={"branch": "beta", "issue": "456"},
)
pyro.write_workspace_file(alpha["workspace_id"], "branch.txt", text="alpha\n")
pyro.write_workspace_file(beta["workspace_id"], "branch.txt", text="beta\n")
pyro.update_workspace(alpha["workspace_id"], labels={"branch": "alpha", "owner": "alice"})
pyro.list_workspaces()
pyro.delete_workspace(alpha["workspace_id"])
pyro.delete_workspace(beta["workspace_id"])
```
1. Create one workspace per issue or branch with a human-friendly name and
labels.
2. Mutate each workspace independently.
3. Rediscover the right workspace later with `workspace_list`.
4. Update metadata when ownership or issue mapping changes.
5. Delete each workspace independently when its task is done.
The important proof here is operational, not syntactic: names, labels, list
ordering, and file contents stay isolated even when multiple workspaces are
active at the same time.
active at the same time. Parallel work still means “open another workspace in
the same mode,” not “pick a special parallel-work mode.”

View file

@ -1,6 +1,12 @@
# Repro Plus Fix Loop
Recommended profile: `workspace-core`
Recommended mode: `repro-fix`
Recommended startup:
```bash
pyro host connect codex --mode repro-fix
```
Smoke target:
@ -12,31 +18,21 @@ Use this flow when the agent has to reproduce a bug, patch files without shell
quoting tricks, rerun the failing command, diff the result, export the fix, and
reset back to baseline.
Canonical SDK flow:
Chat-host recipe:
```python
from pyro_mcp import Pyro
1. Start the server from the repo root with bare `pyro mcp serve`, or use
`--project-path` if the host does not preserve cwd.
2. Create one workspace from that project-aware server without manually passing
`seed_path`.
3. Run the failing command.
4. Inspect the broken file with structured file reads.
5. Apply the fix with `workspace_patch_apply`.
6. Rerun the failing command in the same workspace.
7. Diff and export the changed result.
8. Reset to baseline and delete the workspace.
pyro = Pyro()
created = pyro.create_workspace(environment="debian:12", seed_path="./broken-repro")
workspace_id = str(created["workspace_id"])
If the mode feels too narrow for the job, fall back to the generic bare
`pyro mcp serve` path.
pyro.exec_workspace(workspace_id, command="sh check.sh")
pyro.read_workspace_file(workspace_id, "message.txt")
pyro.apply_workspace_patch(
workspace_id,
patch="--- a/message.txt\n+++ b/message.txt\n@@ -1 +1 @@\n-broken\n+fixed\n",
)
pyro.exec_workspace(workspace_id, command="sh check.sh")
pyro.diff_workspace(workspace_id)
pyro.export_workspace(workspace_id, "message.txt", output_path="./message.txt")
pyro.reset_workspace(workspace_id)
pyro.delete_workspace(workspace_id)
```
Canonical MCP/chat example:
- [examples/openai_responses_workspace_core.py](../../examples/openai_responses_workspace_core.py)
This is the main `workspace-core` story: model-native file ops, repeatable exec,
This is the main `repro-fix` story: model-native file ops, repeatable exec,
structured diff, explicit export, and reset-over-repair.

View file

@ -1,6 +1,12 @@
# Review And Evaluation Workflows
Recommended profile: `workspace-full`
Recommended mode: `review-eval`
Recommended startup:
```bash
pyro host connect claude-code --mode review-eval
```
Smoke target:
@ -11,30 +17,15 @@ make smoke-review-eval
Use this flow when an agent needs to read a checklist interactively, run an
evaluation script, checkpoint or reset its changes, and export the final report.
Canonical SDK flow:
Chat-host recipe:
```python
from pyro_mcp import Pyro
pyro = Pyro()
created = pyro.create_workspace(environment="debian:12", seed_path="./review-fixture")
workspace_id = str(created["workspace_id"])
pyro.create_snapshot(workspace_id, "pre-review")
shell = pyro.open_shell(workspace_id)
pyro.write_shell(workspace_id, shell["shell_id"], input="cat CHECKLIST.md")
pyro.read_shell(
workspace_id,
shell["shell_id"],
plain=True,
wait_for_idle_ms=300,
)
pyro.close_shell(workspace_id, shell["shell_id"])
pyro.exec_workspace(workspace_id, command="sh review.sh")
pyro.export_workspace(workspace_id, "review-report.txt", output_path="./review-report.txt")
pyro.reset_workspace(workspace_id, snapshot="pre-review")
pyro.delete_workspace(workspace_id)
```
1. Create a named snapshot before the review starts.
2. Open a readable PTY shell and inspect the checklist interactively.
3. Run the review or evaluation script in the same workspace.
4. Capture `workspace summary` to review what changed and what to export.
5. Export the final report.
6. Reset back to the snapshot if the review branch goes sideways.
7. Delete the workspace when the evaluation is done.
This is the stable shell-facing story: readable PTY output for chat loops,
checkpointed evaluation, explicit export, and reset when a review branch goes

View file

@ -1,6 +1,12 @@
# Unsafe Or Untrusted Code Inspection
Recommended profile: `workspace-core`
Recommended mode: `inspect`
Recommended startup:
```bash
pyro host connect codex --mode inspect
```
Smoke target:
@ -11,24 +17,13 @@ make smoke-untrusted-inspection
Use this flow when the agent needs to inspect suspicious code or an unfamiliar
repo without granting more capabilities than necessary.
Canonical SDK flow:
Chat-host recipe:
```python
from pyro_mcp import Pyro
pyro = Pyro()
created = pyro.create_workspace(environment="debian:12", seed_path="./suspicious-repo")
workspace_id = str(created["workspace_id"])
pyro.list_workspace_files(workspace_id, path="/workspace", recursive=True)
pyro.read_workspace_file(workspace_id, "suspicious.sh")
pyro.exec_workspace(
workspace_id,
command="sh -lc \"grep -n 'curl' suspicious.sh > inspection-report.txt\"",
)
pyro.export_workspace(workspace_id, "inspection-report.txt", output_path="./inspection-report.txt")
pyro.delete_workspace(workspace_id)
```
1. Create one workspace from the suspicious repo seed.
2. Inspect the tree with structured file listing and file reads.
3. Run the smallest possible command that produces the inspection report.
4. Export only the report the agent chose to materialize.
5. Delete the workspace when inspection is complete.
This recipe stays offline-by-default, uses only explicit file reads and execs,
and exports only the inspection report the agent chose to materialize.

View file

@ -1,16 +1,19 @@
# Vision
`pyro-mcp` should become the disposable sandbox where an agent can do real
development work safely, repeatedly, and reproducibly.
`pyro-mcp` should become the disposable MCP workspace for chat-based coding
agents.
That is a different product from a generic VM wrapper, a secure CI runner, or a
task queue with better isolation.
That is a different product from a generic VM wrapper, a secure CI runner, or
an SDK-first platform.
`pyro-mcp` currently has no users. That means we can still make breaking
changes freely while we shape the chat-host path into the right product.
## Core Thesis
The goal is not just to run one command in a microVM.
The goal is to give an LLM or coding agent a bounded workspace where it can:
The goal is to give a chat-hosted coding agent a bounded workspace where it can:
- inspect a repo
- install dependencies
@ -23,6 +26,25 @@ The goal is to give an LLM or coding agent a bounded workspace where it can:
The sandbox is the execution boundary for agentic software work.
## Current Product Focus
The product path should be obvious and narrow:
- Claude Code
- Codex
- OpenCode
- Linux `x86_64` with KVM
The happy path is:
1. prove the host with the terminal companion commands
2. run `pyro mcp serve`
3. connect a chat host
4. work through one disposable workspace per task
The repo can contain lower-level building blocks, but they should not drive the
product story.
## What This Is Not
`pyro-mcp` should not drift into:
@ -32,9 +54,10 @@ The sandbox is the execution boundary for agentic software work.
- a generic CI job runner
- a scheduler or queueing platform
- a broad VM orchestration product
- an SDK product that happens to have an MCP server on the side
Those products optimize for queued work, throughput, retries, matrix builds, and
shared infrastructure.
Those products optimize for queued work, throughput, retries, matrix builds, or
library ergonomics.
`pyro-mcp` should optimize for agent loops:
@ -57,10 +80,15 @@ Any sandbox product starts to look like CI if the main abstraction is:
That shape is useful, but it is not the center of the vision.
To stay aligned, the primary abstraction should be a workspace the agent
inhabits, not a job the agent submits.
inhabits from a chat host, not a job the agent submits to a runner.
## Product Principles
### Chat Hosts First
The product should be shaped around the MCP path used from chat interfaces.
Everything else is there to support, debug, or build that path.
### Workspace-First
The default mental model should be "open a disposable workspace" rather than
@ -85,11 +113,6 @@ Anything that crosses the host boundary should be intentional and visible:
Agents should be able to checkpoint, reset, and retry cheaply. Disposable state
is a feature, not a limitation.
### Same Contract Across Surfaces
CLI, Python, and MCP should expose the same underlying workspace model so the
product feels coherent no matter how it is consumed.
### Agent-Native Observability
The sandbox should expose the things an agent actually needs to reason about:
@ -101,10 +124,16 @@ The sandbox should expose the things an agent actually needs to reason about:
- readiness
- exported results
## The Shape Of An LLM-First Sandbox
## The Shape Of The Product
The strongest future direction is a small, agent-native contract built around
workspaces, shells, files, services, and reset.
The strongest direction is a small chat-facing contract built around:
- one MCP server
- one disposable workspace model
- structured file inspection and edits
- repeated commands in the same sandbox
- service lifecycle when the workflow needs it
- reset as a first-class workflow primitive
Representative primitives:
@ -114,95 +143,57 @@ Representative primitives:
- `workspace.sync_push`
- `workspace.export`
- `workspace.diff`
- `workspace.snapshot`
- `workspace.reset`
- `workspace.exec`
- `shell.open`
- `shell.read`
- `shell.write`
- `shell.signal`
- `shell.close`
- `workspace.exec`
- `service.start`
- `service.status`
- `service.logs`
- `service.stop`
These names are illustrative, not a committed public API.
The important point is the interaction model:
- a shell session is interactive state inside the sandbox
- a workspace is durable for the life of the task
- services are first-class, not accidental background jobs
- reset is a core workflow primitive
These names are illustrative, not a promise that every lower-level repo surface
should be treated as equally stable or equally important.
## Interactive Shells And Disk Operations
Interactive shells are aligned with the vision because they make the agent feel
present inside the sandbox rather than reduced to one-shot job submission.
That does not mean `pyro-mcp` should become a raw SSH replacement. The shell
should sit inside a higher-level workspace model with structured file, service,
diff, and reset operations around it.
They should remain subordinate to the workspace model, not replace it with a
raw SSH story.
Disk-level operations are also useful, but they should remain supporting tools.
They are good for:
Disk-level operations are useful for:
- fast workspace seeding
- snapshotting
- offline inspection
- diffing
- export/import without a full boot
They should not become the primary product identity. If the center of the
product becomes "operate on VM disks", it will read as image tooling rather
than an agent workspace.
They should remain supporting tools rather than the product identity.
## What To Build Next
Features should be prioritized in this order:
Features should keep reinforcing the chat-host path in this order:
1. Repeated commands in one persistent workspace
2. Interactive shell sessions with PTY semantics
3. Structured workspace sync and export
4. Service lifecycle and readiness checks
5. Snapshot and reset workflows
6. Explicit secrets and network policy
7. Secondary disk-level import/export and inspection tools
1. make the first chat-host setup painfully obvious
2. make the recipe-backed workflows feel trivial from chat
3. keep the smoke pack trustworthy enough to gate the advertised stories
4. keep the terminal companion path good enough to debug what the chat sees
5. let lower-level repo surfaces move freely when the chat-host product needs it
The completed workspace GA roadmap lives in
[roadmap/task-workspace-ga.md](roadmap/task-workspace-ga.md).
The next implementation milestones that make those workflows feel natural from
chat-driven LLM interfaces live in
The follow-on milestones that make the chat-host path clearer live in
[roadmap/llm-chat-ergonomics.md](roadmap/llm-chat-ergonomics.md).
## Naming Guidance
Prefer language that reinforces the workspace model:
- `workspace`
- `sandbox`
- `shell`
- `service`
- `snapshot`
- `reset`
Avoid centering language that makes the product feel like CI infrastructure:
- `job`
- `runner`
- `pipeline`
- `worker`
- `queue`
- `build matrix`
## Litmus Test
When evaluating a new feature, ask:
"Does this help an agent inhabit a safe disposable workspace and do real
software work inside it?"
"Does this make Claude Code, Codex, or OpenCode feel more natural and powerful
when they work inside a disposable sandbox?"
If the better description is "it helps submit, schedule, and report jobs", the
feature is probably pushing the product in the wrong direction.
If the better description is "it helps build a broader VM toolkit or SDK", it
is probably pushing the product in the wrong direction.

View file

@ -1,21 +1,49 @@
# Claude Code MCP Setup
Recommended profile: `workspace-core`.
Recommended modes:
- `cold-start`
- `review-eval`
Preferred helper flow:
```bash
pyro host connect claude-code --mode cold-start
pyro host connect claude-code --mode review-eval
pyro host doctor --mode cold-start
```
Package without install:
```bash
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
claude mcp list
```
Run that from the repo root when you want the first `workspace_create` to start
from the current checkout automatically.
Already installed:
```bash
claude mcp add pyro -- pyro mcp serve
claude mcp add pyro -- pyro mcp serve --mode cold-start
claude mcp list
```
If Claude Code launches the server from an unexpected cwd, pin the project
explicitly:
```bash
pyro host connect claude-code --mode cold-start --project-path /abs/path/to/repo
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start --project-path /abs/path/to/repo
```
If the local config drifts later:
```bash
pyro host repair claude-code --mode cold-start
```
Move to `workspace-full` only when the chat truly needs shells, services,
snapshots, secrets, network policy, or disk tools:

View file

@ -1,21 +1,49 @@
# Codex MCP Setup
Recommended profile: `workspace-core`.
Recommended modes:
- `repro-fix`
- `inspect`
Preferred helper flow:
```bash
pyro host connect codex --mode repro-fix
pyro host connect codex --mode inspect
pyro host doctor --mode repro-fix
```
Package without install:
```bash
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
codex mcp list
```
Run that from the repo root when you want the first `workspace_create` to start
from the current checkout automatically.
Already installed:
```bash
codex mcp add pyro -- pyro mcp serve
codex mcp add pyro -- pyro mcp serve --mode repro-fix
codex mcp list
```
If Codex launches the server from an unexpected cwd, pin the project
explicitly:
```bash
pyro host connect codex --mode repro-fix --project-path /abs/path/to/repo
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix --project-path /abs/path/to/repo
```
If the local config drifts later:
```bash
pyro host repair codex --mode repro-fix
```
Move to `workspace-full` only when the chat truly needs shells, services,
snapshots, secrets, network policy, or disk tools:

View file

@ -1,6 +1,11 @@
# MCP Client Config Example
Default for most chat hosts in `4.x`: `workspace-core`.
Recommended named modes for most chat hosts in `4.x`:
- `repro-fix`
- `inspect`
- `cold-start`
- `review-eval`
Use the host-specific examples first when they apply:
@ -8,8 +13,18 @@ Use the host-specific examples first when they apply:
- Codex: [examples/codex_mcp.md](codex_mcp.md)
- OpenCode: [examples/opencode_mcp_config.json](opencode_mcp_config.json)
Preferred repair/bootstrap helpers:
- `pyro host connect codex --mode repro-fix`
- `pyro host connect codex --mode inspect`
- `pyro host connect claude-code --mode cold-start`
- `pyro host connect claude-code --mode review-eval`
- `pyro host print-config opencode --mode repro-fix`
- `pyro host doctor --mode repro-fix`
- `pyro host repair opencode --mode repro-fix`
Use this generic config only when the host expects a plain `mcpServers` JSON
shape.
shape or when the named modes are too narrow for the workflow.
`pyro-mcp` is intended to be exposed to LLM clients through the public `pyro` CLI.
@ -20,7 +35,7 @@ Generic stdio MCP configuration using `uvx`:
"mcpServers": {
"pyro": {
"command": "uvx",
"args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"]
"args": ["--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
}
}
}
@ -33,21 +48,28 @@ If `pyro-mcp` is already installed locally, the same server can be configured wi
"mcpServers": {
"pyro": {
"command": "pyro",
"args": ["mcp", "serve"]
"args": ["mcp", "serve", "--mode", "repro-fix"]
}
}
}
```
Profile progression:
If the host does not preserve the server working directory and you want the
first `workspace_create` to start from a specific checkout, add
`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same args list.
- `workspace-core`: the default and recommended first persistent chat profile
- `vm-run`: expose only `vm_run`
Mode progression:
- `repro-fix`: patch, rerun, diff, export, reset
- `inspect`: narrow offline-by-default inspection
- `cold-start`: validation plus service readiness
- `review-eval`: shell and snapshot-driven review
- generic no-mode path: the fallback when the named mode is too narrow
- `workspace-full`: explicit advanced opt-in for shells, services, snapshots, secrets, network policy, and disk tools
Primary profile for most agents:
Primary mode for most agents:
- `workspace-core`
- `repro-fix`
Use lifecycle tools only when the agent needs persistent VM state across multiple tool calls.

View file

@ -3,7 +3,7 @@
"pyro": {
"type": "local",
"enabled": true,
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
}
}
}

View file

@ -1,7 +1,7 @@
[project]
name = "pyro-mcp"
version = "4.0.0"
description = "Stable Firecracker workspaces, one-shot sandboxes, and MCP tools for coding agents."
version = "4.5.0"
description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM."
readme = "README.md"
license = { file = "LICENSE" }
authors = [
@ -9,7 +9,7 @@ authors = [
]
requires-python = ">=3.12"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",

View file

@ -0,0 +1,8 @@
#!/usr/bin/env python3
"""Run the real guest-backed daily-loop smoke."""
from pyro_mcp.daily_loop_smoke import main
if __name__ == "__main__":
main()

View file

@ -8,11 +8,22 @@ from typing import Any, Literal, cast
from mcp.server.fastmcp import FastMCP
from pyro_mcp.contract import (
PUBLIC_MCP_COLD_START_MODE_TOOLS,
PUBLIC_MCP_INSPECT_MODE_TOOLS,
PUBLIC_MCP_MODES,
PUBLIC_MCP_PROFILES,
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS,
PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS,
)
from pyro_mcp.project_startup import (
ProjectStartupSource,
describe_project_startup_source,
materialize_project_startup_source,
resolve_project_startup_source,
)
from pyro_mcp.vm_manager import (
DEFAULT_ALLOW_HOST_COMPAT,
DEFAULT_MEM_MIB,
@ -24,12 +35,77 @@ from pyro_mcp.vm_manager import (
)
McpToolProfile = Literal["vm-run", "workspace-core", "workspace-full"]
WorkspaceUseCaseMode = Literal["repro-fix", "inspect", "cold-start", "review-eval"]
_PROFILE_TOOLS: dict[str, tuple[str, ...]] = {
"vm-run": PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
"workspace-core": PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
"workspace-full": PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS,
}
_MODE_TOOLS: dict[str, tuple[str, ...]] = {
"repro-fix": PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
"inspect": PUBLIC_MCP_INSPECT_MODE_TOOLS,
"cold-start": PUBLIC_MCP_COLD_START_MODE_TOOLS,
"review-eval": PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS,
}
_MODE_CREATE_INTENT: dict[str, str] = {
"repro-fix": "to reproduce a failure, patch files, rerun, diff, export, and reset",
"inspect": "to inspect suspicious or unfamiliar code with the smallest persistent surface",
"cold-start": (
"to validate a fresh repo, keep one service alive, and export a "
"validation report"
),
"review-eval": "to review interactively, checkpoint work, and export the final report",
}
_MODE_TOOL_DESCRIPTIONS: dict[str, dict[str, str]] = {
"repro-fix": {
"workspace_file_read": "Read one workspace file while investigating the broken state.",
"workspace_file_write": "Write one workspace file directly as part of the fix loop.",
"workspace_patch_apply": "Apply a structured text patch inside the workspace fix loop.",
"workspace_export": "Export the fixed result or patch-ready file back to the host.",
"workspace_summary": (
"Summarize the current repro/fix session for review before export "
"or reset."
),
},
"inspect": {
"workspace_file_list": "List suspicious files under the current workspace path.",
"workspace_file_read": "Read one suspicious or unfamiliar workspace file.",
"workspace_export": (
"Export only the inspection report or artifact you chose to "
"materialize."
),
"workspace_summary": "Summarize the current inspection session and its exported results.",
},
"cold-start": {
"workspace_create": "Create and start a persistent workspace for cold-start validation.",
"workspace_export": "Export the validation report or other final host-visible result.",
"workspace_summary": (
"Summarize the current validation session, service state, and "
"exports."
),
"service_start": "Start a named validation or app service and wait for typed readiness.",
"service_list": "List running and exited validation services in the current workspace.",
"service_status": "Inspect one validation service and its readiness outcome.",
"service_logs": "Read stdout and stderr from one validation service.",
"service_stop": "Stop one validation service in the workspace.",
},
"review-eval": {
"workspace_create": (
"Create and start a persistent workspace for interactive review "
"or evaluation."
),
"workspace_summary": "Summarize the current review session before exporting or resetting.",
"shell_open": "Open an interactive review shell inside the workspace.",
"shell_read": "Read chat-friendly PTY output from the current review shell.",
"shell_write": "Send one line of input to the current review shell.",
"shell_signal": "Interrupt or terminate the current review shell process group.",
"shell_close": "Close the current review shell.",
"snapshot_create": "Create a checkpoint before the review branch diverges.",
"snapshot_list": "List the baseline plus named review checkpoints.",
"snapshot_delete": "Delete one named review checkpoint.",
},
}
def _validate_mcp_profile(profile: str) -> McpToolProfile:
@ -39,6 +115,44 @@ def _validate_mcp_profile(profile: str) -> McpToolProfile:
return cast(McpToolProfile, profile)
def _validate_workspace_mode(mode: str) -> WorkspaceUseCaseMode:
if mode not in PUBLIC_MCP_MODES:
expected = ", ".join(PUBLIC_MCP_MODES)
raise ValueError(f"unknown workspace mode {mode!r}; expected one of: {expected}")
return cast(WorkspaceUseCaseMode, mode)
def _workspace_create_description(
startup_source: ProjectStartupSource | None,
*,
mode: WorkspaceUseCaseMode | None = None,
) -> str:
if mode is not None:
prefix = (
"Create and start a persistent workspace "
f"{_MODE_CREATE_INTENT[mode]}."
)
else:
prefix = "Create and start a persistent workspace."
if startup_source is None:
return prefix
described_source = describe_project_startup_source(startup_source)
if described_source is None:
return prefix
return f"{prefix} If `seed_path` is omitted, the server seeds from {described_source}."
def _tool_description(
tool_name: str,
*,
mode: WorkspaceUseCaseMode | None,
fallback: str,
) -> str:
if mode is None:
return fallback
return _MODE_TOOL_DESCRIPTIONS.get(mode, {}).get(tool_name, fallback)
class Pyro:
"""High-level facade over the ephemeral VM runtime."""
@ -186,6 +300,9 @@ class Pyro:
def logs_workspace(self, workspace_id: str) -> dict[str, Any]:
return self._manager.logs_workspace(workspace_id)
def summarize_workspace(self, workspace_id: str) -> dict[str, Any]:
return self._manager.summarize_workspace(workspace_id)
def export_workspace(
self,
workspace_id: str,
@ -462,15 +579,40 @@ class Pyro:
allow_host_compat=allow_host_compat,
)
def create_server(self, *, profile: McpToolProfile = "workspace-core") -> FastMCP:
def create_server(
self,
*,
profile: McpToolProfile = "workspace-core",
mode: WorkspaceUseCaseMode | None = None,
project_path: str | Path | None = None,
repo_url: str | None = None,
repo_ref: str | None = None,
no_project_source: bool = False,
) -> FastMCP:
"""Create an MCP server for one of the stable public tool profiles.
`workspace-core` is the default stable chat-host profile in 4.x. Use
`profile="workspace-full"` only when the host truly needs the full
advanced workspace surface.
advanced workspace surface. By default, the server auto-detects the
nearest Git worktree root from its current working directory and uses
that source when `workspace_create` omits `seed_path`. `project_path`,
`repo_url`, and `no_project_source` override that behavior explicitly.
"""
normalized_profile = _validate_mcp_profile(profile)
enabled_tools = set(_PROFILE_TOOLS[normalized_profile])
normalized_mode = _validate_workspace_mode(mode) if mode is not None else None
if normalized_mode is not None and normalized_profile != "workspace-core":
raise ValueError("mode and profile are mutually exclusive")
startup_source = resolve_project_startup_source(
project_path=project_path,
repo_url=repo_url,
repo_ref=repo_ref,
no_project_source=no_project_source,
)
enabled_tools = set(
_MODE_TOOLS[normalized_mode]
if normalized_mode is not None
else _PROFILE_TOOLS[normalized_profile]
)
server = FastMCP(name="pyro_mcp")
def _enabled(tool_name: str) -> bool:
@ -583,9 +725,59 @@ class Pyro:
return self.reap_expired()
if _enabled("workspace_create"):
if normalized_profile == "workspace-core":
workspace_create_description = _workspace_create_description(
startup_source,
mode=normalized_mode,
)
@server.tool(name="workspace_create")
def _create_workspace_from_server_defaults(
*,
environment: str,
vcpu_count: int,
mem_mib: int,
ttl_seconds: int,
network_policy: str,
allow_host_compat: bool,
seed_path: str | None,
secrets: list[dict[str, str]] | None,
name: str | None,
labels: dict[str, str] | None,
) -> dict[str, Any]:
if seed_path is not None or startup_source is None:
return self.create_workspace(
environment=environment,
vcpu_count=vcpu_count,
mem_mib=mem_mib,
ttl_seconds=ttl_seconds,
network_policy=network_policy,
allow_host_compat=allow_host_compat,
seed_path=seed_path,
secrets=secrets,
name=name,
labels=labels,
)
with materialize_project_startup_source(startup_source) as resolved_seed_path:
prepared_seed = self._manager._prepare_workspace_seed( # noqa: SLF001
resolved_seed_path,
origin_kind=startup_source.kind,
origin_ref=startup_source.origin_ref,
)
return self._manager.create_workspace(
environment=environment,
vcpu_count=vcpu_count,
mem_mib=mem_mib,
ttl_seconds=ttl_seconds,
network_policy=network_policy,
allow_host_compat=allow_host_compat,
secrets=secrets,
name=name,
labels=labels,
_prepared_seed=prepared_seed,
)
if normalized_mode is not None or normalized_profile == "workspace-core":
@server.tool(name="workspace_create", description=workspace_create_description)
async def workspace_create_core(
environment: str,
vcpu_count: int = DEFAULT_VCPU_COUNT,
@ -596,8 +788,7 @@ class Pyro:
name: str | None = None,
labels: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Create and start a persistent workspace."""
return self.create_workspace(
return _create_workspace_from_server_defaults(
environment=environment,
vcpu_count=vcpu_count,
mem_mib=mem_mib,
@ -612,7 +803,7 @@ class Pyro:
else:
@server.tool(name="workspace_create")
@server.tool(name="workspace_create", description=workspace_create_description)
async def workspace_create_full(
environment: str,
vcpu_count: int = DEFAULT_VCPU_COUNT,
@ -625,8 +816,7 @@ class Pyro:
name: str | None = None,
labels: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Create and start a persistent workspace."""
return self.create_workspace(
return _create_workspace_from_server_defaults(
environment=environment,
vcpu_count=vcpu_count,
mem_mib=mem_mib,
@ -666,15 +856,21 @@ class Pyro:
)
if _enabled("workspace_exec"):
if normalized_profile == "workspace-core":
if normalized_mode is not None or normalized_profile == "workspace-core":
@server.tool(name="workspace_exec")
@server.tool(
name="workspace_exec",
description=_tool_description(
"workspace_exec",
mode=normalized_mode,
fallback="Run one command inside an existing persistent workspace.",
),
)
async def workspace_exec_core(
workspace_id: str,
command: str,
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
) -> dict[str, Any]:
"""Run one command inside an existing persistent workspace."""
return self.exec_workspace(
workspace_id,
command=command,
@ -684,14 +880,20 @@ class Pyro:
else:
@server.tool(name="workspace_exec")
@server.tool(
name="workspace_exec",
description=_tool_description(
"workspace_exec",
mode=normalized_mode,
fallback="Run one command inside an existing persistent workspace.",
),
)
async def workspace_exec_full(
workspace_id: str,
command: str,
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
secret_env: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Run one command inside an existing persistent workspace."""
return self.exec_workspace(
workspace_id,
command=command,
@ -738,15 +940,32 @@ class Pyro:
"""Return persisted command history for one workspace."""
return self.logs_workspace(workspace_id)
if _enabled("workspace_summary"):
@server.tool(
description=_tool_description(
"workspace_summary",
mode=normalized_mode,
fallback="Summarize the current workspace session for human review.",
)
)
async def workspace_summary(workspace_id: str) -> dict[str, Any]:
return self.summarize_workspace(workspace_id)
if _enabled("workspace_export"):
@server.tool()
@server.tool(
description=_tool_description(
"workspace_export",
mode=normalized_mode,
fallback="Export one file or directory from `/workspace` back to the host.",
)
)
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)
if _enabled("workspace_diff"):
@ -758,13 +977,21 @@ class Pyro:
if _enabled("workspace_file_list"):
@server.tool()
@server.tool(
description=_tool_description(
"workspace_file_list",
mode=normalized_mode,
fallback=(
"List metadata for files and directories under one "
"live workspace path."
),
)
)
async def workspace_file_list(
workspace_id: str,
path: str = "/workspace",
recursive: bool = False,
) -> dict[str, Any]:
"""List metadata for files and directories under one live workspace path."""
return self.list_workspace_files(
workspace_id,
path=path,
@ -773,13 +1000,18 @@ class Pyro:
if _enabled("workspace_file_read"):
@server.tool()
@server.tool(
description=_tool_description(
"workspace_file_read",
mode=normalized_mode,
fallback="Read one regular text file from a live workspace path.",
)
)
async def workspace_file_read(
workspace_id: str,
path: str,
max_bytes: int = 65536,
) -> dict[str, Any]:
"""Read one regular text file from a live workspace path."""
return self.read_workspace_file(
workspace_id,
path,
@ -788,13 +1020,18 @@ class Pyro:
if _enabled("workspace_file_write"):
@server.tool()
@server.tool(
description=_tool_description(
"workspace_file_write",
mode=normalized_mode,
fallback="Create or replace one regular text file under `/workspace`.",
)
)
async def workspace_file_write(
workspace_id: str,
path: str,
text: str,
) -> dict[str, Any]:
"""Create or replace one regular text file under `/workspace`."""
return self.write_workspace_file(
workspace_id,
path,
@ -803,12 +1040,17 @@ class Pyro:
if _enabled("workspace_patch_apply"):
@server.tool()
@server.tool(
description=_tool_description(
"workspace_patch_apply",
mode=normalized_mode,
fallback="Apply a unified text patch inside one live workspace.",
)
)
async def workspace_patch_apply(
workspace_id: str,
patch: str,
) -> dict[str, Any]:
"""Apply a unified text patch inside one live workspace."""
return self.apply_workspace_patch(
workspace_id,
patch=patch,
@ -886,27 +1128,62 @@ class Pyro:
return self.reset_workspace(workspace_id, snapshot=snapshot)
if _enabled("shell_open"):
if normalized_mode == "review-eval":
@server.tool()
async def shell_open(
workspace_id: str,
cwd: str = "/workspace",
cols: int = 120,
rows: int = 30,
secret_env: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Open a persistent interactive shell inside one workspace."""
return self.open_shell(
workspace_id,
cwd=cwd,
cols=cols,
rows=rows,
secret_env=secret_env,
@server.tool(
description=_tool_description(
"shell_open",
mode=normalized_mode,
fallback="Open a persistent interactive shell inside one workspace.",
)
)
async def shell_open(
workspace_id: str,
cwd: str = "/workspace",
cols: int = 120,
rows: int = 30,
) -> dict[str, Any]:
return self.open_shell(
workspace_id,
cwd=cwd,
cols=cols,
rows=rows,
secret_env=None,
)
else:
@server.tool(
description=_tool_description(
"shell_open",
mode=normalized_mode,
fallback="Open a persistent interactive shell inside one workspace.",
)
)
async def shell_open(
workspace_id: str,
cwd: str = "/workspace",
cols: int = 120,
rows: int = 30,
secret_env: dict[str, str] | None = None,
) -> dict[str, Any]:
return self.open_shell(
workspace_id,
cwd=cwd,
cols=cols,
rows=rows,
secret_env=secret_env,
)
if _enabled("shell_read"):
@server.tool()
@server.tool(
description=_tool_description(
"shell_read",
mode=normalized_mode,
fallback="Read merged PTY output from a workspace shell.",
)
)
async def shell_read(
workspace_id: str,
shell_id: str,
@ -915,7 +1192,6 @@ class Pyro:
plain: bool = False,
wait_for_idle_ms: int | None = None,
) -> dict[str, Any]:
"""Read merged PTY output from a workspace shell."""
return self.read_shell(
workspace_id,
shell_id,
@ -927,14 +1203,19 @@ class Pyro:
if _enabled("shell_write"):
@server.tool()
@server.tool(
description=_tool_description(
"shell_write",
mode=normalized_mode,
fallback="Write text input to a persistent workspace shell.",
)
)
async def shell_write(
workspace_id: str,
shell_id: str,
input: str,
append_newline: bool = True,
) -> dict[str, Any]:
"""Write text input to a persistent workspace shell."""
return self.write_shell(
workspace_id,
shell_id,
@ -944,13 +1225,18 @@ class Pyro:
if _enabled("shell_signal"):
@server.tool()
@server.tool(
description=_tool_description(
"shell_signal",
mode=normalized_mode,
fallback="Send a signal to the shell process group.",
)
)
async def shell_signal(
workspace_id: str,
shell_id: str,
signal_name: str = "INT",
) -> dict[str, Any]:
"""Send a signal to the shell process group."""
return self.signal_shell(
workspace_id,
shell_id,
@ -959,74 +1245,142 @@ class Pyro:
if _enabled("shell_close"):
@server.tool()
@server.tool(
description=_tool_description(
"shell_close",
mode=normalized_mode,
fallback="Close a persistent workspace shell.",
)
)
async def shell_close(workspace_id: str, shell_id: str) -> dict[str, Any]:
"""Close a persistent workspace shell."""
return self.close_shell(workspace_id, shell_id)
if _enabled("service_start"):
if normalized_mode == "cold-start":
@server.tool()
async def service_start(
workspace_id: str,
service_name: str,
command: str,
cwd: str = "/workspace",
ready_file: str | None = None,
ready_tcp: str | None = None,
ready_http: str | None = None,
ready_command: str | None = None,
ready_timeout_seconds: int = 30,
ready_interval_ms: int = 500,
secret_env: dict[str, str] | None = None,
published_ports: list[dict[str, int | None]] | None = None,
) -> dict[str, Any]:
"""Start a named long-running service inside a workspace."""
readiness: dict[str, Any] | None = None
if ready_file is not None:
readiness = {"type": "file", "path": ready_file}
elif ready_tcp is not None:
readiness = {"type": "tcp", "address": ready_tcp}
elif ready_http is not None:
readiness = {"type": "http", "url": ready_http}
elif ready_command is not None:
readiness = {"type": "command", "command": ready_command}
return self.start_service(
workspace_id,
service_name,
command=command,
cwd=cwd,
readiness=readiness,
ready_timeout_seconds=ready_timeout_seconds,
ready_interval_ms=ready_interval_ms,
secret_env=secret_env,
published_ports=published_ports,
@server.tool(
description=_tool_description(
"service_start",
mode=normalized_mode,
fallback="Start a named long-running service inside a workspace.",
)
)
async def service_start(
workspace_id: str,
service_name: str,
command: str,
cwd: str = "/workspace",
ready_file: str | None = None,
ready_tcp: str | None = None,
ready_http: str | None = None,
ready_command: str | None = None,
ready_timeout_seconds: int = 30,
ready_interval_ms: int = 500,
) -> dict[str, Any]:
readiness: dict[str, Any] | None = None
if ready_file is not None:
readiness = {"type": "file", "path": ready_file}
elif ready_tcp is not None:
readiness = {"type": "tcp", "address": ready_tcp}
elif ready_http is not None:
readiness = {"type": "http", "url": ready_http}
elif ready_command is not None:
readiness = {"type": "command", "command": ready_command}
return self.start_service(
workspace_id,
service_name,
command=command,
cwd=cwd,
readiness=readiness,
ready_timeout_seconds=ready_timeout_seconds,
ready_interval_ms=ready_interval_ms,
secret_env=None,
published_ports=None,
)
else:
@server.tool(
description=_tool_description(
"service_start",
mode=normalized_mode,
fallback="Start a named long-running service inside a workspace.",
)
)
async def service_start(
workspace_id: str,
service_name: str,
command: str,
cwd: str = "/workspace",
ready_file: str | None = None,
ready_tcp: str | None = None,
ready_http: str | None = None,
ready_command: str | None = None,
ready_timeout_seconds: int = 30,
ready_interval_ms: int = 500,
secret_env: dict[str, str] | None = None,
published_ports: list[dict[str, int | None]] | None = None,
) -> dict[str, Any]:
readiness: dict[str, Any] | None = None
if ready_file is not None:
readiness = {"type": "file", "path": ready_file}
elif ready_tcp is not None:
readiness = {"type": "tcp", "address": ready_tcp}
elif ready_http is not None:
readiness = {"type": "http", "url": ready_http}
elif ready_command is not None:
readiness = {"type": "command", "command": ready_command}
return self.start_service(
workspace_id,
service_name,
command=command,
cwd=cwd,
readiness=readiness,
ready_timeout_seconds=ready_timeout_seconds,
ready_interval_ms=ready_interval_ms,
secret_env=secret_env,
published_ports=published_ports,
)
if _enabled("service_list"):
@server.tool()
@server.tool(
description=_tool_description(
"service_list",
mode=normalized_mode,
fallback="List named services in one workspace.",
)
)
async def service_list(workspace_id: str) -> dict[str, Any]:
"""List named services in one workspace."""
return self.list_services(workspace_id)
if _enabled("service_status"):
@server.tool()
@server.tool(
description=_tool_description(
"service_status",
mode=normalized_mode,
fallback="Inspect one named workspace service.",
)
)
async def service_status(workspace_id: str, service_name: str) -> dict[str, Any]:
"""Inspect one named workspace service."""
return self.status_service(workspace_id, service_name)
if _enabled("service_logs"):
@server.tool()
@server.tool(
description=_tool_description(
"service_logs",
mode=normalized_mode,
fallback="Read persisted stdout/stderr for one workspace service.",
)
)
async def service_logs(
workspace_id: str,
service_name: str,
tail_lines: int = 200,
all: bool = False,
) -> dict[str, Any]:
"""Read persisted stdout/stderr for one workspace service."""
return self.logs_service(
workspace_id,
service_name,
@ -1036,9 +1390,14 @@ class Pyro:
if _enabled("service_stop"):
@server.tool()
@server.tool(
description=_tool_description(
"service_stop",
mode=normalized_mode,
fallback="Stop one running service in a workspace.",
)
)
async def service_stop(workspace_id: str, service_name: str) -> dict[str, Any]:
"""Stop one running service in a workspace."""
return self.stop_service(workspace_id, service_name)
if _enabled("workspace_delete"):

View file

@ -8,12 +8,21 @@ import shlex
import sys
from pathlib import Path
from textwrap import dedent
from typing import Any
from typing import Any, cast
from pyro_mcp import __version__
from pyro_mcp.api import Pyro
from pyro_mcp.contract import PUBLIC_MCP_PROFILES
from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode
from pyro_mcp.contract import PUBLIC_MCP_MODES, PUBLIC_MCP_PROFILES
from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT
from pyro_mcp.demo import run_demo
from pyro_mcp.host_helpers import (
HostDoctorEntry,
HostServerConfig,
connect_cli_host,
doctor_hosts,
print_or_write_opencode_config,
repair_host,
)
from pyro_mcp.ollama_demo import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL, run_ollama_tool_demo
from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report
from pyro_mcp.vm_environments import DEFAULT_CATALOG_VERSION
@ -146,6 +155,7 @@ def _print_doctor_human(payload: dict[str, Any]) -> None:
)
runtime = payload.get("runtime")
if isinstance(runtime, dict):
print(f"Catalog version: {str(runtime.get('catalog_version', 'unknown'))}")
print(f"Environment cache: {str(runtime.get('cache_dir', 'unknown'))}")
capabilities = runtime.get("capabilities")
if isinstance(capabilities, dict):
@ -163,12 +173,108 @@ def _print_doctor_human(payload: dict[str, Any]) -> None:
f"tun={'yes' if bool(networking.get('tun_available')) else 'no'} "
f"ip_forward={'yes' if bool(networking.get('ip_forward_enabled')) else 'no'}"
)
daily_loop = payload.get("daily_loop")
if isinstance(daily_loop, dict):
status = str(daily_loop.get("status", "cold")).upper()
environment = str(daily_loop.get("environment", DEFAULT_PREPARE_ENVIRONMENT))
print(f"Daily loop: {status} ({environment})")
print(
" "
f"installed={'yes' if bool(daily_loop.get('installed')) else 'no'} "
f"network_prepared={'yes' if bool(daily_loop.get('network_prepared')) else 'no'}"
)
prepared_at = daily_loop.get("prepared_at")
if prepared_at is not None:
print(f" prepared_at={prepared_at}")
reason = daily_loop.get("reason")
if isinstance(reason, str) and reason != "":
print(f" reason={reason}")
if str(daily_loop.get("status", "cold")) != "warm":
print(f" Run: pyro prepare {environment}")
if isinstance(issues, list) and issues:
print("Issues:")
for issue in issues:
print(f"- {issue}")
def _print_prepare_human(payload: dict[str, Any]) -> None:
environment = str(payload.get("environment", DEFAULT_PREPARE_ENVIRONMENT))
status = str(payload.get("status", "cold")).upper()
print(f"Prepare: {environment}")
print(f"Daily loop: {status}")
print(
"Result: "
f"{'reused' if bool(payload.get('reused')) else 'prepared'} "
f"network_prepared={'yes' if bool(payload.get('network_prepared')) else 'no'}"
)
print(f"Cache dir: {str(payload.get('cache_dir', 'unknown'))}")
print(f"Manifest: {str(payload.get('manifest_path', 'unknown'))}")
prepared_at = payload.get("prepared_at")
if prepared_at is not None:
print(f"Prepared at: {prepared_at}")
print(f"Duration: {int(payload.get('last_prepare_duration_ms', 0))} ms")
reason = payload.get("reason")
if isinstance(reason, str) and reason != "":
print(f"Reason: {reason}")
def _build_host_server_config(args: argparse.Namespace) -> HostServerConfig:
return HostServerConfig(
installed_package=bool(getattr(args, "installed_package", False)),
profile=cast(McpToolProfile, str(getattr(args, "profile", "workspace-core"))),
mode=cast(WorkspaceUseCaseMode | None, getattr(args, "mode", None)),
project_path=getattr(args, "project_path", None),
repo_url=getattr(args, "repo_url", None),
repo_ref=getattr(args, "repo_ref", None),
no_project_source=bool(getattr(args, "no_project_source", False)),
)
def _print_host_connect_human(payload: dict[str, Any]) -> None:
host = str(payload.get("host", "unknown"))
server_command = payload.get("server_command")
verification_command = payload.get("verification_command")
print(f"Connected pyro to {host}.")
if isinstance(server_command, list):
print("Server command: " + shlex.join(str(item) for item in server_command))
if isinstance(verification_command, list):
print("Verify with: " + shlex.join(str(item) for item in verification_command))
def _print_host_print_config_human(payload: dict[str, Any]) -> None:
rendered_config = payload.get("rendered_config")
if isinstance(rendered_config, str):
_write_stream(rendered_config, stream=sys.stdout)
return
output_path = payload.get("output_path")
if isinstance(output_path, str):
print(f"Wrote OpenCode config to {output_path}")
def _print_host_repair_human(payload: dict[str, Any]) -> None:
host = str(payload.get("host", "unknown"))
if host == "opencode":
print(f"Repaired OpenCode config at {str(payload.get('config_path', 'unknown'))}.")
backup_path = payload.get("backup_path")
if isinstance(backup_path, str):
print(f"Backed up the previous config to {backup_path}.")
return
_print_host_connect_human(payload)
def _print_host_doctor_human(entries: list[HostDoctorEntry]) -> None:
for index, entry in enumerate(entries):
print(
f"{entry.host}: {entry.status} "
f"installed={'yes' if entry.installed else 'no'} "
f"configured={'yes' if entry.configured else 'no'}"
)
print(f" details: {entry.details}")
print(f" repair: {entry.repair_command}")
if index != len(entries) - 1:
print()
def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> None:
print(f"{action} ID: {str(payload.get('workspace_id', 'unknown'))}")
name = payload.get("name")
@ -191,7 +297,16 @@ def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> N
if isinstance(workspace_seed, dict):
mode = str(workspace_seed.get("mode", "empty"))
seed_path = workspace_seed.get("seed_path")
if isinstance(seed_path, str) and seed_path != "":
origin_kind = workspace_seed.get("origin_kind")
origin_ref = workspace_seed.get("origin_ref")
if isinstance(origin_kind, str) and isinstance(origin_ref, str) and origin_ref != "":
if origin_kind == "project_path":
print(f"Workspace seed: {mode} from project {origin_ref}")
elif origin_kind == "repo_url":
print(f"Workspace seed: {mode} from clean clone {origin_ref}")
else:
print(f"Workspace seed: {mode} from {origin_ref}")
elif isinstance(seed_path, str) and seed_path != "":
print(f"Workspace seed: {mode} from {seed_path}")
else:
print(f"Workspace seed: {mode}")
@ -477,6 +592,147 @@ def _print_workspace_logs_human(payload: dict[str, Any]) -> None:
print(stderr, end="" if stderr.endswith("\n") else "\n", file=sys.stderr)
def _print_workspace_review_summary_human(payload: dict[str, Any]) -> None:
print(f"Workspace review: {str(payload.get('workspace_id', 'unknown'))}")
name = payload.get("name")
if isinstance(name, str) and name != "":
print(f"Name: {name}")
labels = payload.get("labels")
if isinstance(labels, dict) and labels:
rendered_labels = ", ".join(
f"{str(key)}={str(value)}" for key, value in sorted(labels.items())
)
print(f"Labels: {rendered_labels}")
print(f"Environment: {str(payload.get('environment', 'unknown'))}")
print(f"State: {str(payload.get('state', 'unknown'))}")
print(f"Last activity at: {payload.get('last_activity_at')}")
print(f"Session started at: {payload.get('session_started_at')}")
outcome = payload.get("outcome")
if isinstance(outcome, dict):
print(
"Outcome: "
f"commands={int(outcome.get('command_count', 0))} "
f"services={int(outcome.get('running_service_count', 0))}/"
f"{int(outcome.get('service_count', 0))} "
f"exports={int(outcome.get('export_count', 0))} "
f"snapshots={int(outcome.get('snapshot_count', 0))} "
f"resets={int(outcome.get('reset_count', 0))}"
)
last_command = outcome.get("last_command")
if isinstance(last_command, dict):
print(
"Last command: "
f"{str(last_command.get('command', 'unknown'))} "
f"(exit_code={int(last_command.get('exit_code', -1))})"
)
def _print_events(title: str, events: object, *, formatter: Any) -> None:
if not isinstance(events, list) or not events:
return
print(f"{title}:")
for event in events:
if not isinstance(event, dict):
continue
print(f"- {formatter(event)}")
commands = payload.get("commands")
if isinstance(commands, dict):
_print_events(
"Recent commands",
commands.get("recent"),
formatter=lambda event: (
f"#{int(event.get('sequence', 0))} "
f"exit={int(event.get('exit_code', -1))} "
f"cwd={str(event.get('cwd', WORKSPACE_GUEST_PATH))} "
f"cmd={str(event.get('command', ''))}"
),
)
edits = payload.get("edits")
if isinstance(edits, dict):
_print_events(
"Recent edits",
edits.get("recent"),
formatter=lambda event: (
f"{str(event.get('event_kind', 'edit'))} "
f"at={event.get('recorded_at')} "
f"path={event.get('path') or event.get('destination') or 'n/a'}"
),
)
changes = payload.get("changes")
if isinstance(changes, dict):
if not bool(changes.get("available")):
print(f"Changes: unavailable ({str(changes.get('reason', 'unknown reason'))})")
elif not bool(changes.get("changed")):
print("Changes: no current workspace changes.")
else:
summary = changes.get("summary")
if isinstance(summary, dict):
print(
"Changes: "
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"non_text={int(summary.get('non_text', 0))}"
)
_print_events(
"Top changed paths",
changes.get("entries"),
formatter=lambda event: (
f"{str(event.get('status', 'changed'))} "
f"{str(event.get('path', 'unknown'))} "
f"[{str(event.get('artifact_type', 'unknown'))}]"
),
)
services = payload.get("services")
if isinstance(services, dict):
_print_events(
"Current services",
services.get("current"),
formatter=lambda event: (
f"{str(event.get('service_name', 'unknown'))} "
f"state={str(event.get('state', 'unknown'))}"
),
)
_print_events(
"Recent service events",
services.get("recent"),
formatter=lambda event: (
f"{str(event.get('event_kind', 'service'))} "
f"{str(event.get('service_name', 'unknown'))} "
f"state={str(event.get('state', 'unknown'))}"
),
)
artifacts = payload.get("artifacts")
if isinstance(artifacts, dict):
_print_events(
"Recent exports",
artifacts.get("exports"),
formatter=lambda event: (
f"{str(event.get('workspace_path', 'unknown'))} -> "
f"{str(event.get('output_path', 'unknown'))}"
),
)
snapshots = payload.get("snapshots")
if isinstance(snapshots, dict):
print(f"Named snapshots: {int(snapshots.get('named_count', 0))}")
_print_events(
"Recent snapshot events",
snapshots.get("recent"),
formatter=lambda event: (
f"{str(event.get('event_kind', 'snapshot'))} "
f"{str(event.get('snapshot_name', 'unknown'))}"
),
)
def _print_workspace_snapshot_human(payload: dict[str, Any], *, prefix: str) -> None:
snapshot = payload.get("snapshot")
if not isinstance(snapshot, dict):
@ -636,24 +892,73 @@ class _HelpFormatter(
return help_string
def _add_host_server_source_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--installed-package",
action="store_true",
help="Use `pyro mcp serve` instead of the default `uvx --from pyro-mcp pyro mcp serve`.",
)
profile_group = parser.add_mutually_exclusive_group()
profile_group.add_argument(
"--profile",
choices=PUBLIC_MCP_PROFILES,
default="workspace-core",
help="Explicit profile for the host helper flow when not using a named mode.",
)
profile_group.add_argument(
"--mode",
choices=PUBLIC_MCP_MODES,
help="Opinionated use-case mode for the host helper flow.",
)
source_group = parser.add_mutually_exclusive_group()
source_group.add_argument(
"--project-path",
help="Pin the server to this local project path instead of relying on host cwd.",
)
source_group.add_argument(
"--repo-url",
help="Seed default workspaces from a clean clone of this repository URL.",
)
source_group.add_argument(
"--no-project-source",
action="store_true",
help="Disable automatic Git checkout detection from the current working directory.",
)
parser.add_argument(
"--repo-ref",
help="Optional branch, tag, or commit to checkout after cloning --repo-url.",
)
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Run stable one-shot and persistent workspace workflows on supported "
"Linux x86_64 KVM hosts."
"Validate the host and serve disposable MCP workspaces for chat-based "
"coding agents on supported Linux x86_64 KVM hosts."
),
epilog=dedent(
"""
Suggested first run:
Suggested zero-to-hero path:
pyro doctor
pyro env list
pyro env pull debian:12
pyro prepare debian:12
pyro run debian:12 -- git --version
pyro host connect claude-code
Continue into the stable workspace path after that:
Connect a chat host after that:
pyro host connect claude-code
pyro host connect codex
pyro host print-config opencode
Daily local loop after the first warmup:
pyro doctor --environment debian:12
pyro prepare debian:12
pyro workspace reset WORKSPACE_ID
If you want terminal-level visibility into the workspace model:
pyro workspace create debian:12 --seed-path ./repo --id-only
pyro workspace sync push WORKSPACE_ID ./changes
pyro workspace exec WORKSPACE_ID -- cat note.txt
pyro workspace summary WORKSPACE_ID
pyro workspace diff WORKSPACE_ID
pyro workspace snapshot create WORKSPACE_ID checkpoint
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
@ -661,8 +966,6 @@ def _build_parser() -> argparse.ArgumentParser:
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
sh -lc 'touch .ready && while true; do sleep 60; done'
pyro workspace export WORKSPACE_ID note.txt --output ./note.txt
Use `pyro mcp serve` only after the CLI validation path works.
"""
),
formatter_class=_HelpFormatter,
@ -670,6 +973,51 @@ def _build_parser() -> argparse.ArgumentParser:
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
subparsers = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
prepare_parser = subparsers.add_parser(
"prepare",
help="Warm the local machine for the daily workspace loop.",
description=(
"Warm the recommended guest-backed daily loop by ensuring the "
"environment is installed and proving create, exec, reset, and "
"delete on one throwaway workspace."
),
epilog=dedent(
f"""
Examples:
pyro prepare
pyro prepare {DEFAULT_PREPARE_ENVIRONMENT}
pyro prepare {DEFAULT_PREPARE_ENVIRONMENT} --network
pyro prepare {DEFAULT_PREPARE_ENVIRONMENT} --force
"""
),
formatter_class=_HelpFormatter,
)
prepare_parser.add_argument(
"environment",
nargs="?",
default=DEFAULT_PREPARE_ENVIRONMENT,
metavar="ENVIRONMENT",
help=(
"Curated environment to warm for the daily loop. Defaults to "
f"`{DEFAULT_PREPARE_ENVIRONMENT}`."
),
)
prepare_parser.add_argument(
"--network",
action="store_true",
help="Also warm guest networking by proving one egress-enabled workspace cycle.",
)
prepare_parser.add_argument(
"--force",
action="store_true",
help="Rerun warmup even when a compatible warm manifest already exists.",
)
prepare_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
env_parser = subparsers.add_parser(
"env",
help="Inspect and manage curated environments.",
@ -755,18 +1103,151 @@ def _build_parser() -> argparse.ArgumentParser:
help="Print structured JSON instead of human-readable output.",
)
host_parser = subparsers.add_parser(
"host",
help="Bootstrap and repair supported chat-host configs.",
description=(
"Connect or repair the supported Claude Code, Codex, and OpenCode "
"host setups without hand-writing MCP commands or config."
),
epilog=dedent(
"""
Examples:
pyro host connect claude-code
pyro host connect claude-code --mode cold-start
pyro host connect codex --project-path /abs/path/to/repo
pyro host print-config opencode
pyro host repair opencode
pyro host doctor
"""
),
formatter_class=_HelpFormatter,
)
host_subparsers = host_parser.add_subparsers(dest="host_command", required=True, metavar="HOST")
host_connect_parser = host_subparsers.add_parser(
"connect",
help="Connect Claude Code or Codex in one step.",
description=(
"Ensure the supported host has a `pyro` MCP server entry that wraps "
"the canonical `pyro mcp serve` command."
),
epilog=dedent(
"""
Examples:
pyro host connect claude-code
pyro host connect claude-code --mode cold-start
pyro host connect codex --installed-package
pyro host connect codex --mode repro-fix
pyro host connect codex --project-path /abs/path/to/repo
"""
),
formatter_class=_HelpFormatter,
)
host_connect_parser.add_argument(
"host",
choices=("claude-code", "codex"),
help="Chat host to connect and update in place.",
)
_add_host_server_source_args(host_connect_parser)
host_print_config_parser = host_subparsers.add_parser(
"print-config",
help="Print or write the canonical OpenCode config snippet.",
description=(
"Render the canonical OpenCode `mcp.pyro` config entry so it can be "
"copied into or written to `opencode.json`."
),
epilog=dedent(
"""
Examples:
pyro host print-config opencode
pyro host print-config opencode --mode repro-fix
pyro host print-config opencode --output ./opencode.json
pyro host print-config opencode --project-path /abs/path/to/repo
"""
),
formatter_class=_HelpFormatter,
)
host_print_config_parser.add_argument(
"host",
choices=("opencode",),
help="Host config shape to render.",
)
_add_host_server_source_args(host_print_config_parser)
host_print_config_parser.add_argument(
"--output",
help="Write the rendered JSON to this path instead of printing it to stdout.",
)
host_doctor_parser = host_subparsers.add_parser(
"doctor",
help="Inspect supported host setup status.",
description=(
"Report whether Claude Code, Codex, and OpenCode are installed, "
"configured, missing, or drifted relative to the canonical `pyro` MCP setup."
),
epilog=dedent(
"""
Examples:
pyro host doctor
pyro host doctor --mode inspect
pyro host doctor --project-path /abs/path/to/repo
pyro host doctor --installed-package
"""
),
formatter_class=_HelpFormatter,
)
_add_host_server_source_args(host_doctor_parser)
host_doctor_parser.add_argument(
"--config-path",
help="Override the OpenCode config path when inspecting or repairing that host.",
)
host_repair_parser = host_subparsers.add_parser(
"repair",
help="Repair one supported host to the canonical `pyro` setup.",
description=(
"Repair a stale or broken host config by reapplying the canonical "
"`pyro mcp serve` setup for that host."
),
epilog=dedent(
"""
Examples:
pyro host repair claude-code
pyro host repair claude-code --mode review-eval
pyro host repair codex --project-path /abs/path/to/repo
pyro host repair opencode
"""
),
formatter_class=_HelpFormatter,
)
host_repair_parser.add_argument(
"host",
choices=("claude-code", "codex", "opencode"),
help="Host config to repair.",
)
_add_host_server_source_args(host_repair_parser)
host_repair_parser.add_argument(
"--config-path",
help="Override the OpenCode config path when repairing that host.",
)
mcp_parser = subparsers.add_parser(
"mcp",
help="Run the MCP server.",
description=(
"Run the MCP server after you have already validated the host and "
"guest execution with `pyro doctor` and `pyro run`. Bare `pyro "
"mcp serve` now starts the recommended `workspace-core` profile."
"guest execution with `pyro doctor` and `pyro run`. This is the "
"main product path for Claude Code, Codex, and OpenCode."
),
epilog=dedent(
"""
Examples:
pyro mcp serve
pyro mcp serve --mode repro-fix
pyro mcp serve --mode inspect
pyro mcp serve --project-path .
pyro mcp serve --repo-url https://github.com/example/project.git
pyro mcp serve --profile vm-run
pyro mcp serve --profile workspace-full
"""
@ -779,36 +1260,83 @@ def _build_parser() -> argparse.ArgumentParser:
help="Run the MCP server over stdio.",
description=(
"Expose pyro tools over stdio for an MCP client. Bare `pyro mcp "
"serve` now starts `workspace-core`, the recommended first profile "
"for most chat hosts."
"serve` starts the generic `workspace-core` path. Use `--mode` to "
"start from an opinionated use-case flow, or `--profile` to choose "
"a generic profile directly. When launched from inside a Git "
"checkout, it also seeds the first workspace from that repo by default."
),
epilog=dedent(
"""
Default and recommended first start:
Generic default path:
pyro mcp serve
pyro mcp serve --project-path .
pyro mcp serve --repo-url https://github.com/example/project.git
Named modes:
repro-fix: structured edit / diff / export / reset loop
inspect: smallest persistent inspection surface
cold-start: validation plus service readiness
review-eval: shell plus snapshots for review workflows
Profiles:
workspace-core: default for normal persistent chat editing
workspace-core: default for normal persistent chat editing and the
recommended first profile for most chat hosts
vm-run: smallest one-shot-only surface
workspace-full: advanced 4.x opt-in surface for shells, services,
workspace-full: larger opt-in surface for shells, services,
snapshots, secrets, network policy, and disk tools
Use --profile workspace-full only when the host truly needs the full
advanced workspace surface.
Project-aware startup:
- bare `pyro mcp serve` auto-detects the nearest Git checkout
from the current working directory
- use --project-path when the host does not preserve cwd
- use --repo-url for a clean-clone source outside a local checkout
Use --mode when one named use case already matches the job. Fall
back to the generic no-mode path when the mode feels too narrow.
"""
),
formatter_class=_HelpFormatter,
)
mcp_serve_parser.add_argument(
mcp_profile_group = mcp_serve_parser.add_mutually_exclusive_group()
mcp_profile_group.add_argument(
"--profile",
choices=PUBLIC_MCP_PROFILES,
default="workspace-core",
help=(
"Expose only one model-facing tool profile. `workspace-core` is "
"the default and recommended first profile for most chat hosts; "
"`workspace-full` is the explicit advanced opt-in surface."
"Expose one generic model-facing tool profile instead of a named mode. "
"`workspace-core` is the generic default and `workspace-full` is the "
"larger opt-in profile."
),
)
mcp_profile_group.add_argument(
"--mode",
choices=PUBLIC_MCP_MODES,
help="Expose one opinionated use-case mode instead of the generic profile path.",
)
mcp_source_group = mcp_serve_parser.add_mutually_exclusive_group()
mcp_source_group.add_argument(
"--project-path",
help=(
"Seed default workspaces from this local project path. If the path "
"is inside a Git checkout, pyro uses that repo root."
),
)
mcp_source_group.add_argument(
"--repo-url",
help=(
"Seed default workspaces from a clean host-side clone of this repo URL "
"when `workspace_create` omits `seed_path`."
),
)
mcp_serve_parser.add_argument(
"--repo-ref",
help="Optional branch, tag, or commit to checkout after cloning --repo-url.",
)
mcp_serve_parser.add_argument(
"--no-project-source",
action="store_true",
help=("Disable automatic Git checkout detection from the current working directory."),
)
run_parser = subparsers.add_parser(
"run",
@ -865,8 +1393,7 @@ def _build_parser() -> argparse.ArgumentParser:
"--allow-host-compat",
action="store_true",
help=(
"Opt into host-side compatibility execution if guest boot or guest exec "
"is unavailable."
"Opt into host-side compatibility execution if guest boot or guest exec is unavailable."
),
)
run_parser.add_argument(
@ -888,7 +1415,7 @@ def _build_parser() -> argparse.ArgumentParser:
"workspace",
help="Manage persistent workspaces.",
description=(
"Use the stable workspace contract when you need one sandbox to stay alive "
"Use the workspace model when you need one sandbox to stay alive "
"across repeated exec, shell, service, diff, export, snapshot, and reset calls."
),
epilog=dedent(
@ -908,6 +1435,7 @@ def _build_parser() -> argparse.ArgumentParser:
pyro workspace start WORKSPACE_ID
pyro workspace snapshot create WORKSPACE_ID checkpoint
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
pyro workspace summary WORKSPACE_ID
pyro workspace diff WORKSPACE_ID
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
pyro workspace shell open WORKSPACE_ID --id-only
@ -986,8 +1514,7 @@ def _build_parser() -> argparse.ArgumentParser:
"--allow-host-compat",
action="store_true",
help=(
"Opt into host-side compatibility execution if guest boot or guest exec "
"is unavailable."
"Opt into host-side compatibility execution if guest boot or guest exec is unavailable."
),
)
workspace_create_parser.add_argument(
@ -1037,8 +1564,7 @@ def _build_parser() -> argparse.ArgumentParser:
"exec",
help="Run one command inside an existing workspace.",
description=(
"Run one non-interactive command in the persistent `/workspace` "
"for a workspace."
"Run one non-interactive command in the persistent `/workspace` for a workspace."
),
epilog=dedent(
"""
@ -1274,8 +1800,7 @@ def _build_parser() -> argparse.ArgumentParser:
"created automatically."
),
epilog=(
"Example:\n"
" pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py"
"Example:\n pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py"
),
formatter_class=_HelpFormatter,
)
@ -1467,8 +1992,7 @@ def _build_parser() -> argparse.ArgumentParser:
"start",
help="Start one stopped workspace without resetting it.",
description=(
"Start a previously stopped workspace from its preserved rootfs and "
"workspace state."
"Start a previously stopped workspace from its preserved rootfs and workspace state."
),
epilog="Example:\n pyro workspace start WORKSPACE_ID",
formatter_class=_HelpFormatter,
@ -1594,8 +2118,7 @@ def _build_parser() -> argparse.ArgumentParser:
"shell",
help="Open and manage persistent interactive shells.",
description=(
"Open one or more persistent interactive PTY shell sessions inside a started "
"workspace."
"Open one or more persistent interactive PTY shell sessions inside a started workspace."
),
epilog=dedent(
"""
@ -1818,7 +2341,7 @@ def _build_parser() -> argparse.ArgumentParser:
pyro workspace service stop WORKSPACE_ID app
Use `--ready-file` by default in the curated Debian environments. `--ready-command`
remains available as an escape hatch.
remains available when the workflow needs a custom readiness check.
"""
),
formatter_class=_HelpFormatter,
@ -2047,12 +2570,38 @@ while true; do sleep 60; done'
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_summary_parser = workspace_subparsers.add_parser(
"summary",
help="Summarize the current workspace session for review.",
description=(
"Summarize the current workspace session since the last reset, including recent "
"commands, edits, services, exports, snapshots, and current change status."
),
epilog=dedent(
"""
Example:
pyro workspace summary WORKSPACE_ID
Use `workspace logs`, `workspace diff`, and `workspace export` for drill-down.
"""
),
formatter_class=_HelpFormatter,
)
workspace_summary_parser.add_argument(
"workspace_id",
metavar="WORKSPACE_ID",
help="Persistent workspace identifier.",
)
workspace_summary_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_logs_parser = workspace_subparsers.add_parser(
"logs",
help="Show command history for one workspace.",
description=(
"Show persisted command history, including stdout and stderr, "
"for one workspace."
"Show persisted command history, including stdout and stderr, for one workspace."
),
epilog="Example:\n pyro workspace logs WORKSPACE_ID",
formatter_class=_HelpFormatter,
@ -2088,11 +2637,16 @@ while true; do sleep 60; done'
doctor_parser = subparsers.add_parser(
"doctor",
help="Inspect runtime and host diagnostics.",
description="Check host prerequisites and embedded runtime health before your first run.",
description=(
"Check host prerequisites and embedded runtime health, plus "
"daily-loop warmth before your first run or before reconnecting a "
"chat host."
),
epilog=dedent(
"""
Examples:
pyro doctor
pyro doctor --environment debian:12
pyro doctor --json
"""
),
@ -2103,6 +2657,14 @@ while true; do sleep 60; done'
default=DEFAULT_PLATFORM,
help="Runtime platform to inspect.",
)
doctor_parser.add_argument(
"--environment",
default=DEFAULT_PREPARE_ENVIRONMENT,
help=(
"Environment to inspect for the daily-loop warm manifest. "
f"Defaults to `{DEFAULT_PREPARE_ENVIRONMENT}`."
),
)
doctor_parser.add_argument(
"--json",
action="store_true",
@ -2265,6 +2827,24 @@ def _parse_workspace_publish_options(values: list[str]) -> list[dict[str, int |
def main() -> None:
args = _build_parser().parse_args()
pyro = Pyro()
if args.command == "prepare":
try:
payload = pyro.manager.prepare_daily_loop(
args.environment,
network=bool(args.network),
force=bool(args.force),
)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
_print_prepare_human(payload)
return
if args.command == "env":
if args.env_command == "list":
list_payload: dict[str, Any] = {
@ -2300,8 +2880,66 @@ def main() -> None:
else:
_print_prune_human(prune_payload)
return
if args.command == "host":
config = _build_host_server_config(args)
if args.host_command == "connect":
try:
payload = connect_cli_host(args.host, config=config)
except Exception as exc: # noqa: BLE001
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
_print_host_connect_human(payload)
return
if args.host_command == "print-config":
try:
output_path = (
None if args.output is None else Path(args.output).expanduser().resolve()
)
payload = print_or_write_opencode_config(config=config, output_path=output_path)
except Exception as exc: # noqa: BLE001
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
_print_host_print_config_human(payload)
return
if args.host_command == "doctor":
try:
config_path = (
None
if args.config_path is None
else Path(args.config_path).expanduser().resolve()
)
entries = doctor_hosts(config=config, config_path=config_path)
except Exception as exc: # noqa: BLE001
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
_print_host_doctor_human(entries)
return
if args.host_command == "repair":
try:
if args.host != "opencode" and args.config_path is not None:
raise ValueError(
"--config-path is only supported for `pyro host repair opencode`"
)
config_path = (
None
if args.config_path is None
else Path(args.config_path).expanduser().resolve()
)
payload = repair_host(args.host, config=config, config_path=config_path)
except Exception as exc: # noqa: BLE001
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
_print_host_repair_human(payload)
return
if args.command == "mcp":
pyro.create_server(profile=args.profile).run(transport="stdio")
pyro.create_server(
profile=args.profile,
mode=getattr(args, "mode", None),
project_path=args.project_path,
repo_url=args.repo_url,
repo_ref=args.repo_ref,
no_project_source=bool(args.no_project_source),
).run(transport="stdio")
return
if args.command == "run":
command = _require_command(args.command_args)
@ -2354,10 +2992,7 @@ def main() -> None:
if args.command == "workspace":
if args.workspace_command == "create":
secrets = [
*(
_parse_workspace_secret_option(value)
for value in getattr(args, "secret", [])
),
*(_parse_workspace_secret_option(value) for value in getattr(args, "secret", [])),
*(
_parse_workspace_secret_file_option(value)
for value in getattr(args, "secret_file", [])
@ -2392,9 +3027,7 @@ def main() -> None:
return
if args.workspace_command == "update":
labels = _parse_workspace_label_options(getattr(args, "label", []))
clear_labels = _parse_workspace_clear_label_options(
getattr(args, "clear_label", [])
)
clear_labels = _parse_workspace_clear_label_options(getattr(args, "clear_label", []))
try:
payload = pyro.update_workspace(
args.workspace_id,
@ -2441,7 +3074,8 @@ def main() -> None:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
_print_workspace_exec_human(payload)
exit_code = int(payload.get("exit_code", 1))
exit_code_raw = payload.get("exit_code", 1)
exit_code = exit_code_raw if isinstance(exit_code_raw, int) else 1
if exit_code != 0:
raise SystemExit(exit_code)
return
@ -2977,6 +3611,13 @@ def main() -> None:
else:
_print_workspace_summary_human(payload, action="Workspace")
return
if args.workspace_command == "summary":
payload = pyro.summarize_workspace(args.workspace_id)
if bool(args.json):
_print_json(payload)
else:
_print_workspace_review_summary_human(payload)
return
if args.workspace_command == "logs":
payload = pyro.logs_workspace(args.workspace_id)
if bool(args.json):
@ -2992,7 +3633,17 @@ def main() -> None:
print(f"Deleted workspace: {str(payload.get('workspace_id', 'unknown'))}")
return
if args.command == "doctor":
payload = doctor_report(platform=args.platform)
try:
payload = doctor_report(
platform=args.platform,
environment=args.environment,
)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
@ -3023,3 +3674,7 @@ def main() -> None:
return
result = run_demo(network=bool(args.network))
_print_json(result)
if __name__ == "__main__":
main()

View file

@ -2,11 +2,34 @@
from __future__ import annotations
PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "mcp", "run", "workspace")
PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "host", "mcp", "prepare", "run", "workspace")
PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",)
PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune")
PUBLIC_CLI_DOCTOR_FLAGS = ("--platform", "--environment", "--json")
PUBLIC_CLI_HOST_SUBCOMMANDS = ("connect", "doctor", "print-config", "repair")
PUBLIC_CLI_HOST_COMMON_FLAGS = (
"--installed-package",
"--mode",
"--profile",
"--project-path",
"--repo-url",
"--repo-ref",
"--no-project-source",
)
PUBLIC_CLI_HOST_CONNECT_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS
PUBLIC_CLI_HOST_DOCTOR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",)
PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--output",)
PUBLIC_CLI_HOST_REPAIR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",)
PUBLIC_CLI_MCP_SUBCOMMANDS = ("serve",)
PUBLIC_CLI_MCP_SERVE_FLAGS = ("--profile",)
PUBLIC_CLI_MCP_SERVE_FLAGS = (
"--mode",
"--profile",
"--project-path",
"--repo-url",
"--repo-ref",
"--no-project-source",
)
PUBLIC_CLI_PREPARE_FLAGS = ("--network", "--force", "--json")
PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = (
"create",
"delete",
@ -25,6 +48,7 @@ PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = (
"start",
"status",
"stop",
"summary",
"sync",
"update",
)
@ -101,6 +125,7 @@ PUBLIC_CLI_WORKSPACE_SNAPSHOT_LIST_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_START_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_STATUS_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_STOP_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_SUMMARY_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS = ("--dest", "--json")
PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS = (
"--name",
@ -119,6 +144,7 @@ PUBLIC_CLI_RUN_FLAGS = (
"--json",
)
PUBLIC_MCP_PROFILES = ("vm-run", "workspace-core", "workspace-full")
PUBLIC_MCP_MODES = ("repro-fix", "inspect", "cold-start", "review-eval")
PUBLIC_SDK_METHODS = (
"apply_workspace_patch",
@ -165,6 +191,7 @@ PUBLIC_SDK_METHODS = (
"stop_service",
"stop_vm",
"stop_workspace",
"summarize_workspace",
"update_workspace",
"write_shell",
"write_workspace_file",
@ -209,6 +236,7 @@ PUBLIC_MCP_TOOLS = (
"workspace_logs",
"workspace_patch_apply",
"workspace_reset",
"workspace_summary",
"workspace_start",
"workspace_status",
"workspace_stop",
@ -230,8 +258,82 @@ PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS = (
"workspace_logs",
"workspace_patch_apply",
"workspace_reset",
"workspace_summary",
"workspace_status",
"workspace_sync_push",
"workspace_update",
)
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS = (
"workspace_create",
"workspace_delete",
"workspace_diff",
"workspace_exec",
"workspace_export",
"workspace_file_list",
"workspace_file_read",
"workspace_file_write",
"workspace_list",
"workspace_logs",
"workspace_patch_apply",
"workspace_reset",
"workspace_summary",
"workspace_status",
"workspace_sync_push",
"workspace_update",
)
PUBLIC_MCP_INSPECT_MODE_TOOLS = (
"workspace_create",
"workspace_delete",
"workspace_exec",
"workspace_export",
"workspace_file_list",
"workspace_file_read",
"workspace_list",
"workspace_logs",
"workspace_summary",
"workspace_status",
"workspace_update",
)
PUBLIC_MCP_COLD_START_MODE_TOOLS = (
"service_list",
"service_logs",
"service_start",
"service_status",
"service_stop",
"workspace_create",
"workspace_delete",
"workspace_exec",
"workspace_export",
"workspace_file_list",
"workspace_file_read",
"workspace_list",
"workspace_logs",
"workspace_reset",
"workspace_summary",
"workspace_status",
"workspace_update",
)
PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS = (
"shell_close",
"shell_open",
"shell_read",
"shell_signal",
"shell_write",
"snapshot_create",
"snapshot_delete",
"snapshot_list",
"workspace_create",
"workspace_delete",
"workspace_diff",
"workspace_exec",
"workspace_export",
"workspace_file_list",
"workspace_file_read",
"workspace_list",
"workspace_logs",
"workspace_reset",
"workspace_summary",
"workspace_status",
"workspace_update",
)
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS = PUBLIC_MCP_TOOLS

152
src/pyro_mcp/daily_loop.py Normal file
View file

@ -0,0 +1,152 @@
"""Machine-level daily-loop warmup state for the CLI prepare flow."""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
DEFAULT_PREPARE_ENVIRONMENT = "debian:12"
PREPARE_MANIFEST_LAYOUT_VERSION = 1
DailyLoopStatus = Literal["cold", "warm", "stale"]
def _environment_key(environment: str) -> str:
return environment.replace("/", "_").replace(":", "_")
@dataclass(frozen=True)
class DailyLoopManifest:
"""Persisted machine-readiness proof for one environment on one platform."""
environment: str
environment_version: str
platform: str
catalog_version: str
bundle_version: str | None
prepared_at: float
network_prepared: bool
last_prepare_duration_ms: int
def to_payload(self) -> dict[str, Any]:
return {
"layout_version": PREPARE_MANIFEST_LAYOUT_VERSION,
"environment": self.environment,
"environment_version": self.environment_version,
"platform": self.platform,
"catalog_version": self.catalog_version,
"bundle_version": self.bundle_version,
"prepared_at": self.prepared_at,
"network_prepared": self.network_prepared,
"last_prepare_duration_ms": self.last_prepare_duration_ms,
}
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> "DailyLoopManifest":
return cls(
environment=str(payload["environment"]),
environment_version=str(payload["environment_version"]),
platform=str(payload["platform"]),
catalog_version=str(payload["catalog_version"]),
bundle_version=(
None if payload.get("bundle_version") is None else str(payload["bundle_version"])
),
prepared_at=float(payload["prepared_at"]),
network_prepared=bool(payload.get("network_prepared", False)),
last_prepare_duration_ms=int(payload.get("last_prepare_duration_ms", 0)),
)
def prepare_manifest_path(cache_dir: Path, *, platform: str, environment: str) -> Path:
return cache_dir / ".prepare" / platform / f"{_environment_key(environment)}.json"
def load_prepare_manifest(path: Path) -> tuple[DailyLoopManifest | None, str | None]:
if not path.exists():
return None, None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
return None, f"prepare manifest is unreadable: {exc}"
if not isinstance(payload, dict):
return None, "prepare manifest is not a JSON object"
try:
manifest = DailyLoopManifest.from_payload(payload)
except (KeyError, TypeError, ValueError) as exc:
return None, f"prepare manifest is invalid: {exc}"
return manifest, None
def write_prepare_manifest(path: Path, manifest: DailyLoopManifest) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(manifest.to_payload(), indent=2, sort_keys=True),
encoding="utf-8",
)
def evaluate_daily_loop_status(
*,
environment: str,
environment_version: str,
platform: str,
catalog_version: str,
bundle_version: str | None,
installed: bool,
manifest: DailyLoopManifest | None,
manifest_error: str | None = None,
) -> tuple[DailyLoopStatus, str | None]:
if manifest_error is not None:
return "stale", manifest_error
if manifest is None:
if not installed:
return "cold", "environment is not installed"
return "cold", "daily loop has not been prepared yet"
if not installed:
return "stale", "environment install is missing"
if manifest.environment != environment:
return "stale", "prepare manifest environment does not match the selected environment"
if manifest.environment_version != environment_version:
return "stale", "environment version changed since the last prepare run"
if manifest.platform != platform:
return "stale", "platform changed since the last prepare run"
if manifest.catalog_version != catalog_version:
return "stale", "catalog version changed since the last prepare run"
if manifest.bundle_version != bundle_version:
return "stale", "runtime bundle version changed since the last prepare run"
return "warm", None
def prepare_request_is_satisfied(
manifest: DailyLoopManifest | None,
*,
require_network: bool,
) -> bool:
if manifest is None:
return False
if require_network and not manifest.network_prepared:
return False
return True
def serialize_daily_loop_report(
*,
environment: str,
status: DailyLoopStatus,
installed: bool,
cache_dir: Path,
manifest_path: Path,
reason: str | None,
manifest: DailyLoopManifest | None,
) -> dict[str, Any]:
return {
"environment": environment,
"status": status,
"installed": installed,
"network_prepared": bool(manifest.network_prepared) if manifest is not None else False,
"prepared_at": None if manifest is None else manifest.prepared_at,
"manifest_path": str(manifest_path),
"reason": reason,
"cache_dir": str(cache_dir),
}

View file

@ -0,0 +1,131 @@
"""Real guest-backed smoke for the daily local prepare and reset loop."""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import tempfile
from pathlib import Path
from pyro_mcp.api import Pyro
from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT
def _log(message: str) -> None:
print(f"[daily-loop] {message}", flush=True)
def _write_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def _run_prepare(environment: str) -> dict[str, object]:
proc = subprocess.run( # noqa: S603
[sys.executable, "-m", "pyro_mcp.cli", "prepare", environment, "--json"],
text=True,
capture_output=True,
check=False,
)
if proc.returncode != 0:
raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "pyro prepare failed")
payload = json.loads(proc.stdout)
if not isinstance(payload, dict):
raise RuntimeError("pyro prepare did not return a JSON object")
return payload
def run_daily_loop_smoke(*, environment: str = DEFAULT_PREPARE_ENVIRONMENT) -> None:
_log(f"prepare environment={environment}")
first_prepare = _run_prepare(environment)
assert bool(first_prepare["prepared"]) is True, first_prepare
second_prepare = _run_prepare(environment)
assert bool(second_prepare["reused"]) is True, second_prepare
pyro = Pyro()
with tempfile.TemporaryDirectory(prefix="pyro-daily-loop-") as temp_dir:
root = Path(temp_dir)
seed_dir = root / "seed"
export_dir = root / "export"
_write_text(seed_dir / "message.txt", "broken\n")
_write_text(
seed_dir / "check.sh",
"#!/bin/sh\n"
"set -eu\n"
"value=$(cat message.txt)\n"
'[ "$value" = "fixed" ] || {\n'
" printf 'expected fixed got %s\\n' \"$value\" >&2\n"
" exit 1\n"
"}\n"
"printf '%s\\n' \"$value\"\n",
)
workspace_id: str | None = None
try:
created = pyro.create_workspace(
environment=environment,
seed_path=seed_dir,
name="daily-loop",
labels={"suite": "daily-loop-smoke"},
)
workspace_id = str(created["workspace_id"])
_log(f"workspace_id={workspace_id}")
failing = pyro.exec_workspace(workspace_id, command="sh check.sh")
assert int(failing["exit_code"]) != 0, failing
patched = pyro.apply_workspace_patch(
workspace_id,
patch=("--- a/message.txt\n+++ b/message.txt\n@@ -1 +1 @@\n-broken\n+fixed\n"),
)
assert bool(patched["changed"]) is True, patched
passing = pyro.exec_workspace(workspace_id, command="sh check.sh")
assert int(passing["exit_code"]) == 0, passing
assert str(passing["stdout"]) == "fixed\n", passing
export_path = export_dir / "message.txt"
exported = pyro.export_workspace(
workspace_id,
"message.txt",
output_path=export_path,
)
assert export_path.read_text(encoding="utf-8") == "fixed\n"
assert str(exported["artifact_type"]) == "file", exported
reset = pyro.reset_workspace(workspace_id)
assert int(reset["reset_count"]) == 1, reset
rerun = pyro.exec_workspace(workspace_id, command="sh check.sh")
assert int(rerun["exit_code"]) != 0, rerun
reset_read = pyro.read_workspace_file(workspace_id, "message.txt")
assert str(reset_read["content"]) == "broken\n", reset_read
finally:
if workspace_id is not None:
try:
pyro.delete_workspace(workspace_id)
except Exception:
pass
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Run the real guest-backed daily-loop prepare and reset smoke.",
)
parser.add_argument(
"--environment",
default=DEFAULT_PREPARE_ENVIRONMENT,
help=f"Environment to warm and test. Defaults to `{DEFAULT_PREPARE_ENVIRONMENT}`.",
)
return parser
def main() -> None:
args = build_arg_parser().parse_args()
run_daily_loop_smoke(environment=args.environment)
if __name__ == "__main__":
main()

View file

@ -5,16 +5,18 @@ from __future__ import annotations
import argparse
import json
from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT
from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Inspect bundled runtime health for pyro-mcp.")
parser.add_argument("--platform", default=DEFAULT_PLATFORM)
parser.add_argument("--environment", default=DEFAULT_PREPARE_ENVIRONMENT)
return parser
def main() -> None:
args = _build_parser().parse_args()
report = doctor_report(platform=args.platform)
report = doctor_report(platform=args.platform, environment=args.environment)
print(json.dumps(report, indent=2, sort_keys=True))

View file

@ -0,0 +1,370 @@
"""Helpers for bootstrapping and repairing supported MCP chat hosts."""
from __future__ import annotations
import json
import shlex
import shutil
import subprocess
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Literal
from pyro_mcp.api import McpToolProfile, WorkspaceUseCaseMode
SUPPORTED_HOST_CONNECT_TARGETS = ("claude-code", "codex")
SUPPORTED_HOST_REPAIR_TARGETS = ("claude-code", "codex", "opencode")
SUPPORTED_HOST_PRINT_CONFIG_TARGETS = ("opencode",)
DEFAULT_HOST_SERVER_NAME = "pyro"
DEFAULT_OPENCODE_CONFIG_PATH = Path.home() / ".config" / "opencode" / "opencode.json"
HostStatus = Literal["drifted", "missing", "ok", "unavailable"]
@dataclass(frozen=True)
class HostServerConfig:
installed_package: bool = False
profile: McpToolProfile = "workspace-core"
mode: WorkspaceUseCaseMode | None = None
project_path: str | None = None
repo_url: str | None = None
repo_ref: str | None = None
no_project_source: bool = False
@dataclass(frozen=True)
class HostDoctorEntry:
host: str
installed: bool
configured: bool
status: HostStatus
details: str
repair_command: str
def _run_command(command: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run( # noqa: S603
command,
check=False,
capture_output=True,
text=True,
)
def _host_binary(host: str) -> str:
if host == "claude-code":
return "claude"
if host == "codex":
return "codex"
raise ValueError(f"unsupported CLI host {host!r}")
def _canonical_server_command(config: HostServerConfig) -> list[str]:
if config.mode is not None and config.profile != "workspace-core":
raise ValueError("--mode and --profile are mutually exclusive")
if config.project_path is not None and config.repo_url is not None:
raise ValueError("--project-path and --repo-url are mutually exclusive")
if config.no_project_source and (
config.project_path is not None
or config.repo_url is not None
or config.repo_ref is not None
):
raise ValueError(
"--no-project-source cannot be combined with --project-path, --repo-url, or --repo-ref"
)
if config.repo_ref is not None and config.repo_url is None:
raise ValueError("--repo-ref requires --repo-url")
command = ["pyro", "mcp", "serve"]
if not config.installed_package:
command = ["uvx", "--from", "pyro-mcp", *command]
if config.mode is not None:
command.extend(["--mode", config.mode])
elif config.profile != "workspace-core":
command.extend(["--profile", config.profile])
if config.project_path is not None:
command.extend(["--project-path", config.project_path])
elif config.repo_url is not None:
command.extend(["--repo-url", config.repo_url])
if config.repo_ref is not None:
command.extend(["--repo-ref", config.repo_ref])
elif config.no_project_source:
command.append("--no-project-source")
return command
def _render_cli_command(command: list[str]) -> str:
return shlex.join(command)
def _repair_command(host: str, config: HostServerConfig, *, config_path: Path | None = None) -> str:
command = ["pyro", "host", "repair", host]
if config.installed_package:
command.append("--installed-package")
if config.mode is not None:
command.extend(["--mode", config.mode])
elif config.profile != "workspace-core":
command.extend(["--profile", config.profile])
if config.project_path is not None:
command.extend(["--project-path", config.project_path])
elif config.repo_url is not None:
command.extend(["--repo-url", config.repo_url])
if config.repo_ref is not None:
command.extend(["--repo-ref", config.repo_ref])
elif config.no_project_source:
command.append("--no-project-source")
if config_path is not None:
command.extend(["--config-path", str(config_path)])
return _render_cli_command(command)
def _command_matches(output: str, expected: list[str]) -> bool:
normalized_output = output.strip()
if ":" in normalized_output:
normalized_output = normalized_output.split(":", 1)[1].strip()
try:
parsed = shlex.split(normalized_output)
except ValueError:
parsed = normalized_output.split()
return parsed == expected
def _upsert_opencode_config(
*,
config_path: Path,
config: HostServerConfig,
) -> tuple[dict[str, object], Path | None]:
existing_payload: dict[str, object] = {}
backup_path: Path | None = None
if config_path.exists():
raw_text = config_path.read_text(encoding="utf-8")
try:
parsed = json.loads(raw_text)
except json.JSONDecodeError:
timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S")
backup_path = config_path.with_name(f"{config_path.name}.bak-{timestamp}")
shutil.move(str(config_path), str(backup_path))
parsed = {}
if isinstance(parsed, dict):
existing_payload = parsed
else:
timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S")
backup_path = config_path.with_name(f"{config_path.name}.bak-{timestamp}")
shutil.move(str(config_path), str(backup_path))
payload = dict(existing_payload)
mcp_payload = payload.get("mcp")
if not isinstance(mcp_payload, dict):
mcp_payload = {}
else:
mcp_payload = dict(mcp_payload)
mcp_payload[DEFAULT_HOST_SERVER_NAME] = canonical_opencode_entry(config)
payload["mcp"] = mcp_payload
return payload, backup_path
def canonical_opencode_entry(config: HostServerConfig) -> dict[str, object]:
return {
"type": "local",
"enabled": True,
"command": _canonical_server_command(config),
}
def render_opencode_config(config: HostServerConfig) -> str:
return (
json.dumps(
{"mcp": {DEFAULT_HOST_SERVER_NAME: canonical_opencode_entry(config)}},
indent=2,
)
+ "\n"
)
def print_or_write_opencode_config(
*,
config: HostServerConfig,
output_path: Path | None = None,
) -> dict[str, object]:
rendered = render_opencode_config(config)
if output_path is None:
return {
"host": "opencode",
"rendered_config": rendered,
"server_command": _canonical_server_command(config),
}
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(rendered, encoding="utf-8")
return {
"host": "opencode",
"output_path": str(output_path),
"server_command": _canonical_server_command(config),
}
def connect_cli_host(host: str, *, config: HostServerConfig) -> dict[str, object]:
binary = _host_binary(host)
if shutil.which(binary) is None:
raise RuntimeError(f"{binary} CLI is not installed or not on PATH")
server_command = _canonical_server_command(config)
_run_command([binary, "mcp", "remove", DEFAULT_HOST_SERVER_NAME])
result = _run_command([binary, "mcp", "add", DEFAULT_HOST_SERVER_NAME, "--", *server_command])
if result.returncode != 0:
details = (result.stderr or result.stdout).strip() or f"{binary} mcp add failed"
raise RuntimeError(details)
return {
"host": host,
"server_command": server_command,
"verification_command": [binary, "mcp", "list"],
}
def repair_opencode_host(
*,
config: HostServerConfig,
config_path: Path | None = None,
) -> dict[str, object]:
resolved_path = (
DEFAULT_OPENCODE_CONFIG_PATH
if config_path is None
else config_path.expanduser().resolve()
)
resolved_path.parent.mkdir(parents=True, exist_ok=True)
payload, backup_path = _upsert_opencode_config(config_path=resolved_path, config=config)
resolved_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
result: dict[str, object] = {
"host": "opencode",
"config_path": str(resolved_path),
"server_command": _canonical_server_command(config),
}
if backup_path is not None:
result["backup_path"] = str(backup_path)
return result
def repair_host(
host: str,
*,
config: HostServerConfig,
config_path: Path | None = None,
) -> dict[str, object]:
if host == "opencode":
return repair_opencode_host(config=config, config_path=config_path)
return connect_cli_host(host, config=config)
def _doctor_cli_host(host: str, *, config: HostServerConfig) -> HostDoctorEntry:
binary = _host_binary(host)
repair_command = _repair_command(host, config)
if shutil.which(binary) is None:
return HostDoctorEntry(
host=host,
installed=False,
configured=False,
status="unavailable",
details=f"{binary} CLI was not found on PATH",
repair_command=repair_command,
)
expected_command = _canonical_server_command(config)
get_result = _run_command([binary, "mcp", "get", DEFAULT_HOST_SERVER_NAME])
combined_get_output = (get_result.stdout + get_result.stderr).strip()
if get_result.returncode == 0:
status: HostStatus = (
"ok" if _command_matches(combined_get_output, expected_command) else "drifted"
)
return HostDoctorEntry(
host=host,
installed=True,
configured=True,
status=status,
details=combined_get_output or f"{binary} MCP entry exists",
repair_command=repair_command,
)
list_result = _run_command([binary, "mcp", "list"])
combined_list_output = (list_result.stdout + list_result.stderr).strip()
configured = DEFAULT_HOST_SERVER_NAME in combined_list_output.split()
return HostDoctorEntry(
host=host,
installed=True,
configured=configured,
status="drifted" if configured else "missing",
details=combined_get_output or combined_list_output or f"{binary} MCP entry missing",
repair_command=repair_command,
)
def _doctor_opencode_host(
*,
config: HostServerConfig,
config_path: Path | None = None,
) -> HostDoctorEntry:
resolved_path = (
DEFAULT_OPENCODE_CONFIG_PATH
if config_path is None
else config_path.expanduser().resolve()
)
repair_command = _repair_command("opencode", config, config_path=config_path)
installed = shutil.which("opencode") is not None
if not resolved_path.exists():
return HostDoctorEntry(
host="opencode",
installed=installed,
configured=False,
status="missing" if installed else "unavailable",
details=f"OpenCode config missing at {resolved_path}",
repair_command=repair_command,
)
try:
payload = json.loads(resolved_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
return HostDoctorEntry(
host="opencode",
installed=installed,
configured=False,
status="drifted" if installed else "unavailable",
details=f"OpenCode config is invalid JSON: {exc}",
repair_command=repair_command,
)
if not isinstance(payload, dict):
return HostDoctorEntry(
host="opencode",
installed=installed,
configured=False,
status="drifted" if installed else "unavailable",
details="OpenCode config must be a JSON object",
repair_command=repair_command,
)
mcp_payload = payload.get("mcp")
if not isinstance(mcp_payload, dict) or DEFAULT_HOST_SERVER_NAME not in mcp_payload:
return HostDoctorEntry(
host="opencode",
installed=installed,
configured=False,
status="missing" if installed else "unavailable",
details=f"OpenCode config at {resolved_path} is missing mcp.pyro",
repair_command=repair_command,
)
configured_entry = mcp_payload[DEFAULT_HOST_SERVER_NAME]
expected_entry = canonical_opencode_entry(config)
status: HostStatus = "ok" if configured_entry == expected_entry else "drifted"
return HostDoctorEntry(
host="opencode",
installed=installed,
configured=True,
status=status,
details=f"OpenCode config path: {resolved_path}",
repair_command=repair_command,
)
def doctor_hosts(
*,
config: HostServerConfig,
config_path: Path | None = None,
) -> list[HostDoctorEntry]:
return [
_doctor_cli_host("claude-code", config=config),
_doctor_cli_host("codex", config=config),
_doctor_opencode_host(config=config, config_path=config_path),
]

View file

@ -0,0 +1,149 @@
"""Server-scoped project startup source helpers for MCP chat flows."""
from __future__ import annotations
import shutil
import subprocess
import tempfile
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator, Literal
ProjectStartupSourceKind = Literal["project_path", "repo_url"]
@dataclass(frozen=True)
class ProjectStartupSource:
"""Server-scoped default source for workspace creation."""
kind: ProjectStartupSourceKind
origin_ref: str
resolved_path: Path | None = None
repo_ref: str | None = None
def _run_git(command: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
return subprocess.run( # noqa: S603
command,
cwd=str(cwd) if cwd is not None else None,
check=False,
capture_output=True,
text=True,
)
def _detect_git_root(start_dir: Path) -> Path | None:
result = _run_git(["git", "rev-parse", "--show-toplevel"], cwd=start_dir)
if result.returncode != 0:
return None
stdout = result.stdout.strip()
if stdout == "":
return None
return Path(stdout).expanduser().resolve()
def _resolve_project_path(project_path: str | Path, *, cwd: Path) -> Path:
resolved = Path(project_path).expanduser()
if not resolved.is_absolute():
resolved = (cwd / resolved).resolve()
else:
resolved = resolved.resolve()
if not resolved.exists():
raise ValueError(f"project_path {resolved} does not exist")
if not resolved.is_dir():
raise ValueError(f"project_path {resolved} must be a directory")
git_root = _detect_git_root(resolved)
if git_root is not None:
return git_root
return resolved
def resolve_project_startup_source(
*,
project_path: str | Path | None = None,
repo_url: str | None = None,
repo_ref: str | None = None,
no_project_source: bool = False,
cwd: Path | None = None,
) -> ProjectStartupSource | None:
working_dir = Path.cwd() if cwd is None else cwd.resolve()
if no_project_source:
if project_path is not None or repo_url is not None or repo_ref is not None:
raise ValueError(
"--no-project-source cannot be combined with --project-path, "
"--repo-url, or --repo-ref"
)
return None
if project_path is not None and repo_url is not None:
raise ValueError("--project-path and --repo-url are mutually exclusive")
if repo_ref is not None and repo_url is None:
raise ValueError("--repo-ref requires --repo-url")
if project_path is not None:
resolved_path = _resolve_project_path(project_path, cwd=working_dir)
return ProjectStartupSource(
kind="project_path",
origin_ref=str(resolved_path),
resolved_path=resolved_path,
)
if repo_url is not None:
normalized_repo_url = repo_url.strip()
if normalized_repo_url == "":
raise ValueError("--repo-url must not be empty")
normalized_repo_ref = None if repo_ref is None else repo_ref.strip()
if normalized_repo_ref == "":
raise ValueError("--repo-ref must not be empty")
return ProjectStartupSource(
kind="repo_url",
origin_ref=normalized_repo_url,
repo_ref=normalized_repo_ref,
)
detected_root = _detect_git_root(working_dir)
if detected_root is None:
return None
return ProjectStartupSource(
kind="project_path",
origin_ref=str(detected_root),
resolved_path=detected_root,
)
@contextmanager
def materialize_project_startup_source(source: ProjectStartupSource) -> Iterator[Path]:
if source.kind == "project_path":
if source.resolved_path is None:
raise RuntimeError("project_path source is missing a resolved path")
yield source.resolved_path
return
temp_dir = Path(tempfile.mkdtemp(prefix="pyro-project-source-"))
clone_dir = temp_dir / "clone"
try:
clone_result = _run_git(["git", "clone", "--quiet", source.origin_ref, str(clone_dir)])
if clone_result.returncode != 0:
stderr = clone_result.stderr.strip() or "git clone failed"
raise RuntimeError(f"failed to clone repo_url {source.origin_ref!r}: {stderr}")
if source.repo_ref is not None:
checkout_result = _run_git(
["git", "checkout", "--quiet", source.repo_ref],
cwd=clone_dir,
)
if checkout_result.returncode != 0:
stderr = checkout_result.stderr.strip() or "git checkout failed"
raise RuntimeError(
f"failed to checkout repo_ref {source.repo_ref!r} for "
f"repo_url {source.origin_ref!r}: {stderr}"
)
yield clone_dir
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
def describe_project_startup_source(source: ProjectStartupSource | None) -> str | None:
if source is None:
return None
if source.kind == "project_path":
return f"the current project at {source.origin_ref}"
if source.repo_ref is None:
return f"the clean clone source {source.origin_ref}"
return f"the clean clone source {source.origin_ref} at ref {source.repo_ref}"

View file

@ -11,6 +11,13 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
from pyro_mcp.daily_loop import (
DEFAULT_PREPARE_ENVIRONMENT,
evaluate_daily_loop_status,
load_prepare_manifest,
prepare_manifest_path,
serialize_daily_loop_report,
)
from pyro_mcp.vm_network import TapNetworkManager
DEFAULT_PLATFORM = "linux-x86_64"
@ -200,7 +207,11 @@ def runtime_capabilities(paths: RuntimePaths) -> RuntimeCapabilities:
)
def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]:
def doctor_report(
*,
platform: str = DEFAULT_PLATFORM,
environment: str = DEFAULT_PREPARE_ENVIRONMENT,
) -> dict[str, Any]:
"""Build a runtime diagnostics report."""
report: dict[str, Any] = {
"platform": platform,
@ -258,6 +269,36 @@ def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]:
"cache_dir": str(environment_store.cache_dir),
"environments": environment_store.list_environments(),
}
environment_details = environment_store.inspect_environment(environment)
manifest_path = prepare_manifest_path(
environment_store.cache_dir,
platform=platform,
environment=environment,
)
manifest, manifest_error = load_prepare_manifest(manifest_path)
status, reason = evaluate_daily_loop_status(
environment=environment,
environment_version=str(environment_details["version"]),
platform=platform,
catalog_version=environment_store.catalog_version,
bundle_version=(
None
if paths.manifest.get("bundle_version") is None
else str(paths.manifest["bundle_version"])
),
installed=bool(environment_details["installed"]),
manifest=manifest,
manifest_error=manifest_error,
)
report["daily_loop"] = serialize_daily_loop_report(
environment=environment,
status=status,
installed=bool(environment_details["installed"]),
cache_dir=environment_store.cache_dir,
manifest_path=manifest_path,
reason=reason,
manifest=manifest,
)
if not report["kvm"]["exists"]:
report["issues"] = ["/dev/kvm is not available on this host"]
return report

View file

@ -2,9 +2,11 @@
from __future__ import annotations
from pathlib import Path
from mcp.server.fastmcp import FastMCP
from pyro_mcp.api import McpToolProfile, Pyro
from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode
from pyro_mcp.vm_manager import VmManager
@ -12,14 +14,29 @@ def create_server(
manager: VmManager | None = None,
*,
profile: McpToolProfile = "workspace-core",
mode: WorkspaceUseCaseMode | None = None,
project_path: str | Path | None = None,
repo_url: str | None = None,
repo_ref: str | None = None,
no_project_source: bool = False,
) -> FastMCP:
"""Create and return a configured MCP server instance.
`workspace-core` is the default stable chat-host profile in 4.x. Use
Bare server creation uses the generic `workspace-core` path in 4.x. Use
`mode=...` for one of the named use-case surfaces, or
`profile="workspace-full"` only when the host truly needs the full
advanced workspace surface.
advanced workspace surface. By default, the server auto-detects the
nearest Git worktree root from its current working directory for
project-aware `workspace_create` calls.
"""
return Pyro(manager=manager).create_server(profile=profile)
return Pyro(manager=manager).create_server(
profile=profile,
mode=mode,
project_path=project_path,
repo_url=repo_url,
repo_ref=repo_ref,
no_project_source=no_project_source,
)
def main() -> None:

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 = "4.0.0"
DEFAULT_CATALOG_VERSION = "4.5.0"
OCI_MANIFEST_ACCEPT = ", ".join(
(
"application/vnd.oci.image.index.v1+json",
@ -48,7 +48,7 @@ class VmEnvironment:
oci_repository: str | None = None
oci_reference: str | None = None
source_digest: str | None = None
compatibility: str = ">=4.0.0,<5.0.0"
compatibility: str = ">=4.5.0,<5.0.0"
@dataclass(frozen=True)

View file

@ -24,6 +24,15 @@ from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath
from typing import Any, Literal, cast
from pyro_mcp.daily_loop import (
DailyLoopManifest,
evaluate_daily_loop_status,
load_prepare_manifest,
prepare_manifest_path,
prepare_request_is_satisfied,
serialize_daily_loop_report,
write_prepare_manifest,
)
from pyro_mcp.runtime import (
RuntimeCapabilities,
RuntimePaths,
@ -79,12 +88,13 @@ DEFAULT_TIMEOUT_SECONDS = 30
DEFAULT_TTL_SECONDS = 600
DEFAULT_ALLOW_HOST_COMPAT = False
WORKSPACE_LAYOUT_VERSION = 8
WORKSPACE_LAYOUT_VERSION = 9
WORKSPACE_BASELINE_DIRNAME = "baseline"
WORKSPACE_BASELINE_ARCHIVE_NAME = "workspace.tar"
WORKSPACE_SNAPSHOTS_DIRNAME = "snapshots"
WORKSPACE_DIRNAME = "workspace"
WORKSPACE_COMMANDS_DIRNAME = "commands"
WORKSPACE_REVIEW_DIRNAME = "review"
WORKSPACE_SHELLS_DIRNAME = "shells"
WORKSPACE_SERVICES_DIRNAME = "services"
WORKSPACE_SECRETS_DIRNAME = "secrets"
@ -116,7 +126,18 @@ WORKSPACE_SECRET_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$")
WORKSPACE_LABEL_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
WorkspaceSeedMode = Literal["empty", "directory", "tar_archive"]
WorkspaceSeedOriginKind = Literal["empty", "manual_seed_path", "project_path", "repo_url"]
WorkspaceArtifactType = Literal["file", "directory", "symlink"]
WorkspaceReviewEventKind = Literal[
"file_write",
"patch_apply",
"service_start",
"service_stop",
"snapshot_create",
"snapshot_delete",
"sync_push",
"workspace_export",
]
WorkspaceServiceReadinessType = Literal["file", "tcp", "http", "command"]
WorkspaceSnapshotKind = Literal["baseline", "named"]
WorkspaceSecretSourceKind = Literal["literal", "file"]
@ -276,9 +297,7 @@ class WorkspaceRecord:
network=_deserialize_network(payload.get("network")),
name=_normalize_workspace_name(_optional_str(payload.get("name")), allow_none=True),
labels=_normalize_workspace_labels(payload.get("labels")),
last_activity_at=float(
payload.get("last_activity_at", float(payload["created_at"]))
),
last_activity_at=float(payload.get("last_activity_at", float(payload["created_at"]))),
command_count=int(payload.get("command_count", 0)),
last_command=_optional_dict(payload.get("last_command")),
workspace_seed=_workspace_seed_dict(payload.get("workspace_seed")),
@ -316,6 +335,35 @@ class WorkspaceSecretRecord:
)
@dataclass(frozen=True)
class WorkspaceReviewEventRecord:
"""Persistent concise review event metadata stored on disk per workspace."""
workspace_id: str
event_kind: WorkspaceReviewEventKind
recorded_at: float
payload: dict[str, Any]
def to_payload(self) -> dict[str, Any]:
return {
"layout_version": WORKSPACE_LAYOUT_VERSION,
"workspace_id": self.workspace_id,
"event_kind": self.event_kind,
"recorded_at": self.recorded_at,
"payload": self.payload,
}
@classmethod
def from_payload(cls, payload: dict[str, Any]) -> WorkspaceReviewEventRecord:
raw_payload = payload.get("payload")
return cls(
workspace_id=str(payload["workspace_id"]),
event_kind=cast(WorkspaceReviewEventKind, str(payload["event_kind"])),
recorded_at=float(payload["recorded_at"]),
payload=dict(raw_payload) if isinstance(raw_payload, dict) else {},
)
@dataclass
class WorkspaceSnapshotRecord:
"""Persistent snapshot metadata stored on disk per workspace."""
@ -503,9 +551,7 @@ class WorkspacePublishedPortRecord:
host=str(payload.get("host", DEFAULT_PUBLISHED_PORT_HOST)),
protocol=str(payload.get("protocol", "tcp")),
proxy_pid=(
None
if payload.get("proxy_pid") is None
else int(payload.get("proxy_pid", 0))
None if payload.get("proxy_pid") is None else int(payload.get("proxy_pid", 0))
),
)
@ -524,6 +570,8 @@ class PreparedWorkspaceSeed:
mode: WorkspaceSeedMode
source_path: str | None
origin_kind: WorkspaceSeedOriginKind = "empty"
origin_ref: str | None = None
archive_path: Path | None = None
entry_count: int = 0
bytes_written: int = 0
@ -534,14 +582,19 @@ class PreparedWorkspaceSeed:
*,
destination: str = WORKSPACE_GUEST_PATH,
path_key: str = "seed_path",
include_origin: bool = True,
) -> dict[str, Any]:
return {
payload = {
"mode": self.mode,
path_key: self.source_path,
"destination": destination,
"entry_count": self.entry_count,
"bytes_written": self.bytes_written,
}
if include_origin:
payload["origin_kind"] = self.origin_kind
payload["origin_ref"] = self.origin_ref
return payload
def cleanup(self) -> None:
if self.cleanup_dir is not None:
@ -614,6 +667,8 @@ def _empty_workspace_seed_payload() -> dict[str, Any]:
return {
"mode": "empty",
"seed_path": None,
"origin_kind": "empty",
"origin_ref": None,
"destination": WORKSPACE_GUEST_PATH,
"entry_count": 0,
"bytes_written": 0,
@ -628,6 +683,8 @@ def _workspace_seed_dict(value: object) -> dict[str, Any]:
{
"mode": str(value.get("mode", payload["mode"])),
"seed_path": _optional_str(value.get("seed_path")),
"origin_kind": str(value.get("origin_kind", payload["origin_kind"])),
"origin_ref": _optional_str(value.get("origin_ref")),
"destination": str(value.get("destination", payload["destination"])),
"entry_count": int(value.get("entry_count", payload["entry_count"])),
"bytes_written": int(value.get("bytes_written", payload["bytes_written"])),
@ -869,9 +926,7 @@ def _validate_workspace_file_read_max_bytes(max_bytes: int) -> int:
if max_bytes <= 0:
raise ValueError("max_bytes must be positive")
if max_bytes > WORKSPACE_FILE_MAX_BYTES:
raise ValueError(
f"max_bytes must be at most {WORKSPACE_FILE_MAX_BYTES} bytes"
)
raise ValueError(f"max_bytes must be at most {WORKSPACE_FILE_MAX_BYTES} bytes")
return max_bytes
@ -899,9 +954,7 @@ def _decode_workspace_patch_text(path: str, content_bytes: bytes) -> str:
try:
return content_bytes.decode("utf-8")
except UnicodeDecodeError as exc:
raise RuntimeError(
f"workspace patch only supports UTF-8 text files: {path}"
) from exc
raise RuntimeError(f"workspace patch only supports UTF-8 text files: {path}") from exc
def _normalize_archive_member_name(name: str) -> PurePosixPath:
@ -991,9 +1044,7 @@ def _prepare_workspace_secrets(
has_value = "value" in item
has_file_path = "file_path" in item
if has_value == has_file_path:
raise ValueError(
f"secret {name!r} must provide exactly one of 'value' or 'file_path'"
)
raise ValueError(f"secret {name!r} must provide exactly one of 'value' or 'file_path'")
source_kind: WorkspaceSecretSourceKind
if has_value:
value = _validate_workspace_secret_value(name, str(item["value"]))
@ -1473,9 +1524,7 @@ def _normalize_workspace_published_port_specs(
)
dedupe_key = (spec.host_port, spec.guest_port)
if dedupe_key in seen_guest_ports:
raise ValueError(
"published ports must not repeat the same host/guest port mapping"
)
raise ValueError("published ports must not repeat the same host/guest port mapping")
seen_guest_ports.add(dedupe_key)
normalized.append(spec)
return normalized
@ -1738,7 +1787,7 @@ def _start_local_service(
),
"status=$?",
f"printf '%s' \"$status\" > {shlex.quote(str(status_path))}",
"exit \"$status\"",
'exit "$status"',
]
)
+ "\n",
@ -1921,9 +1970,7 @@ def _patch_rootfs_runtime_file(
) -> None:
debugfs_path = shutil.which("debugfs")
if debugfs_path is None:
raise RuntimeError(
"debugfs is required to seed workspaces on guest-backed runtimes"
)
raise RuntimeError("debugfs is required to seed workspaces on guest-backed runtimes")
with tempfile.TemporaryDirectory(prefix=f"pyro-{asset_label}-") as temp_dir:
staged_path = Path(temp_dir) / Path(destination_path).name
shutil.copy2(source_path, staged_path)
@ -3582,6 +3629,152 @@ class VmManager:
def prune_environments(self) -> dict[str, object]:
return self._environment_store.prune_environments()
def prepare_daily_loop(
self,
environment: str,
*,
network: bool = False,
force: bool = False,
) -> dict[str, Any]:
spec = get_environment(environment, runtime_paths=self._runtime_paths)
if self._backend_name != "firecracker":
raise RuntimeError("pyro prepare requires a guest-backed runtime and is unavailable")
if not self._runtime_capabilities.supports_vm_boot:
reason = self._runtime_capabilities.reason or "runtime does not support guest boot"
raise RuntimeError(
f"pyro prepare requires guest-backed workspace boot and is unavailable: {reason}"
)
if not self._runtime_capabilities.supports_guest_exec:
reason = self._runtime_capabilities.reason or (
"runtime does not support guest command execution"
)
raise RuntimeError(
f"pyro prepare requires guest command execution and is unavailable: {reason}"
)
if network and not self._runtime_capabilities.supports_guest_network:
reason = self._runtime_capabilities.reason or (
"runtime does not support guest networking"
)
raise RuntimeError(
f"pyro prepare --network requires guest networking and is unavailable: {reason}"
)
runtime_paths = self._runtime_paths
if runtime_paths is None:
raise RuntimeError("runtime paths are unavailable for pyro prepare")
platform = str(runtime_paths.manifest.get("platform", "linux-x86_64"))
bundle_version = cast(str | None, runtime_paths.manifest.get("bundle_version"))
manifest_path = prepare_manifest_path(
self._environment_store.cache_dir,
platform=platform,
environment=environment,
)
manifest, manifest_error = load_prepare_manifest(manifest_path)
status, status_reason = evaluate_daily_loop_status(
environment=environment,
environment_version=spec.version,
platform=platform,
catalog_version=self._environment_store.catalog_version,
bundle_version=bundle_version,
installed=bool(self.inspect_environment(environment)["installed"]),
manifest=manifest,
manifest_error=manifest_error,
)
if (
not force
and status == "warm"
and prepare_request_is_satisfied(manifest, require_network=network)
):
if manifest is None:
raise AssertionError("warm prepare state requires a manifest")
payload = serialize_daily_loop_report(
environment=environment,
status="warm",
installed=True,
cache_dir=self._environment_store.cache_dir,
manifest_path=manifest_path,
reason="reused existing warm manifest",
manifest=manifest,
)
payload.update(
{
"prepared": True,
"reused": True,
"executed": False,
"forced": force,
"network_requested": network,
"last_prepare_duration_ms": manifest.last_prepare_duration_ms,
}
)
return payload
self._environment_store.ensure_installed(environment)
started = time.monotonic()
workspace_id: str | None = None
execution_mode = "pending"
try:
created = self.create_workspace(
environment=environment,
network_policy="egress" if network else "off",
allow_host_compat=False,
)
workspace_id = str(created["workspace_id"])
exec_result = self.exec_workspace(
workspace_id,
command="pwd",
timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
)
execution_mode = str(exec_result.get("execution_mode", "unknown"))
if int(exec_result.get("exit_code", 1)) != 0:
raise RuntimeError("prepare guest exec failed")
if str(exec_result.get("stdout", "")) != f"{WORKSPACE_GUEST_PATH}\n":
raise RuntimeError("prepare guest exec returned an unexpected working directory")
self.reset_workspace(workspace_id)
finally:
if workspace_id is not None:
try:
self.delete_workspace(workspace_id, reason="prepare_cleanup")
except Exception:
pass
duration_ms = int((time.monotonic() - started) * 1000)
prepared_at = time.time()
preserved_network_prepared = bool(
manifest is not None and status == "warm" and manifest.network_prepared
)
prepared_manifest = DailyLoopManifest(
environment=environment,
environment_version=spec.version,
platform=platform,
catalog_version=self._environment_store.catalog_version,
bundle_version=bundle_version,
prepared_at=prepared_at,
network_prepared=network or preserved_network_prepared,
last_prepare_duration_ms=duration_ms,
)
write_prepare_manifest(manifest_path, prepared_manifest)
payload = serialize_daily_loop_report(
environment=environment,
status="warm",
installed=True,
cache_dir=self._environment_store.cache_dir,
manifest_path=manifest_path,
reason=status_reason,
manifest=prepared_manifest,
)
payload.update(
{
"prepared": True,
"reused": False,
"executed": True,
"forced": force,
"network_requested": network,
"last_prepare_duration_ms": duration_ms,
"execution_mode": execution_mode,
}
)
return payload
def create_vm(
self,
*,
@ -3747,19 +3940,23 @@ class VmManager:
secrets: list[dict[str, str]] | None = None,
name: str | None = None,
labels: dict[str, str] | None = None,
_prepared_seed: PreparedWorkspaceSeed | None = None,
) -> dict[str, Any]:
self._validate_limits(vcpu_count=vcpu_count, mem_mib=mem_mib, ttl_seconds=ttl_seconds)
get_environment(environment, runtime_paths=self._runtime_paths)
normalized_network_policy = _normalize_workspace_network_policy(str(network_policy))
normalized_name = None if name is None else _normalize_workspace_name(name)
normalized_labels = _normalize_workspace_labels(labels)
prepared_seed = self._prepare_workspace_seed(seed_path)
if _prepared_seed is not None and seed_path is not None:
raise ValueError("_prepared_seed and seed_path are mutually exclusive")
prepared_seed = _prepared_seed or self._prepare_workspace_seed(seed_path)
now = time.time()
workspace_id = uuid.uuid4().hex[:12]
workspace_dir = self._workspace_dir(workspace_id)
runtime_dir = self._workspace_runtime_dir(workspace_id)
host_workspace_dir = self._workspace_host_dir(workspace_id)
commands_dir = self._workspace_commands_dir(workspace_id)
review_dir = self._workspace_review_dir(workspace_id)
shells_dir = self._workspace_shells_dir(workspace_id)
services_dir = self._workspace_services_dir(workspace_id)
secrets_dir = self._workspace_secrets_dir(workspace_id)
@ -3768,6 +3965,7 @@ class VmManager:
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)
review_dir.mkdir(parents=True, exist_ok=True)
shells_dir.mkdir(parents=True, exist_ok=True)
services_dir.mkdir(parents=True, exist_ok=True)
secrets_dir.mkdir(parents=True, exist_ok=True)
@ -3802,9 +4000,7 @@ class VmManager:
raise RuntimeError(
f"max active VMs reached ({self._max_active_vms}); delete old VMs first"
)
self._require_workspace_network_policy_support(
network_policy=normalized_network_policy
)
self._require_workspace_network_policy_support(network_policy=normalized_network_policy)
self._backend.create(instance)
if self._runtime_capabilities.supports_guest_exec:
self._ensure_workspace_guest_bootstrap_support(instance)
@ -3885,6 +4081,7 @@ class VmManager:
workspace_sync = prepared_seed.to_payload(
destination=normalized_destination,
path_key="source_path",
include_origin=False,
)
workspace_sync["entry_count"] = int(import_summary["entry_count"])
workspace_sync["bytes_written"] = int(import_summary["bytes_written"])
@ -3896,6 +4093,18 @@ class VmManager:
workspace.last_error = instance.last_error
workspace.metadata = dict(instance.metadata)
self._touch_workspace_activity_locked(workspace)
self._record_workspace_review_event_locked(
workspace_id,
event_kind="sync_push",
payload={
"mode": str(workspace_sync["mode"]),
"source_path": str(workspace_sync["source_path"]),
"destination": str(workspace_sync["destination"]),
"entry_count": int(workspace_sync["entry_count"]),
"bytes_written": int(workspace_sync["bytes_written"]),
"execution_mode": str(instance.metadata.get("execution_mode", "pending")),
},
)
self._save_workspace_locked(workspace)
return {
"workspace_id": workspace_id,
@ -3977,8 +4186,8 @@ class VmManager:
def export_workspace(
self,
workspace_id: str,
*,
path: str,
*,
output_path: str | Path,
) -> dict[str, Any]:
normalized_path, _ = _normalize_workspace_destination(path)
@ -4010,6 +4219,23 @@ class VmManager:
workspace.firecracker_pid = instance.firecracker_pid
workspace.last_error = instance.last_error
workspace.metadata = dict(instance.metadata)
self._record_workspace_review_event_locked(
workspace_id,
event_kind="workspace_export",
payload={
"workspace_path": normalized_path,
"output_path": str(Path(str(extracted["output_path"]))),
"artifact_type": str(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"),
)
),
},
)
self._save_workspace_locked(workspace)
return {
"workspace_id": workspace_id,
@ -4169,6 +4395,22 @@ class VmManager:
workspace.firecracker_pid = instance.firecracker_pid
workspace.last_error = instance.last_error
workspace.metadata = dict(instance.metadata)
self._touch_workspace_activity_locked(workspace)
self._record_workspace_review_event_locked(
workspace_id,
event_kind="file_write",
payload={
"path": str(payload["path"]),
"size_bytes": int(payload["size_bytes"]),
"bytes_written": int(payload["bytes_written"]),
"execution_mode": str(
payload.get(
"execution_mode",
instance.metadata.get("execution_mode", "pending"),
)
),
},
)
self._save_workspace_locked(workspace)
return {
"workspace_id": workspace_id,
@ -4286,6 +4528,15 @@ class VmManager:
workspace.last_error = instance.last_error
workspace.metadata = dict(instance.metadata)
self._touch_workspace_activity_locked(workspace)
self._record_workspace_review_event_locked(
workspace_id,
event_kind="patch_apply",
payload={
"summary": dict(summary),
"entries": [dict(entry) for entry in entries[:10]],
"execution_mode": str(instance.metadata.get("execution_mode", "pending")),
},
)
self._save_workspace_locked(workspace)
return {
"workspace_id": workspace_id,
@ -4363,6 +4614,17 @@ class VmManager:
self._touch_workspace_activity_locked(workspace)
self._save_workspace_locked(workspace)
self._save_workspace_snapshot_locked(snapshot)
self._record_workspace_review_event_locked(
workspace_id,
event_kind="snapshot_create",
payload={
"snapshot_name": snapshot.snapshot_name,
"kind": snapshot.kind,
"entry_count": snapshot.entry_count,
"bytes_written": snapshot.bytes_written,
"created_at": snapshot.created_at,
},
)
return {
"workspace_id": workspace_id,
"snapshot": self._serialize_workspace_snapshot(snapshot),
@ -4396,6 +4658,11 @@ class VmManager:
self._load_workspace_snapshot_locked(workspace_id, normalized_snapshot_name)
self._delete_workspace_snapshot_locked(workspace_id, normalized_snapshot_name)
self._touch_workspace_activity_locked(workspace)
self._record_workspace_review_event_locked(
workspace_id,
event_kind="snapshot_delete",
payload={"snapshot_name": normalized_snapshot_name},
)
self._save_workspace_locked(workspace)
return {
"workspace_id": workspace_id,
@ -4436,9 +4703,7 @@ class VmManager:
recreated = workspace.to_instance(
workdir=self._workspace_runtime_dir(workspace.workspace_id)
)
self._require_workspace_network_policy_support(
network_policy=workspace.network_policy
)
self._require_workspace_network_policy_support(network_policy=workspace.network_policy)
self._backend.create(recreated)
if self._runtime_capabilities.supports_guest_exec:
self._ensure_workspace_guest_bootstrap_support(recreated)
@ -4632,9 +4897,7 @@ class VmManager:
if wait_for_idle_ms is not None and (
wait_for_idle_ms <= 0 or wait_for_idle_ms > MAX_SHELL_WAIT_FOR_IDLE_MS
):
raise ValueError(
f"wait_for_idle_ms must be between 1 and {MAX_SHELL_WAIT_FOR_IDLE_MS}"
)
raise ValueError(f"wait_for_idle_ms must be between 1 and {MAX_SHELL_WAIT_FOR_IDLE_MS}")
with self._lock:
workspace = self._load_workspace_locked(workspace_id)
instance = self._workspace_instance_for_live_shell_locked(workspace)
@ -4909,8 +5172,7 @@ class VmManager:
if normalized_published_ports:
if workspace.network_policy != "egress+published-ports":
raise RuntimeError(
"published ports require workspace network_policy "
"'egress+published-ports'"
"published ports require workspace network_policy 'egress+published-ports'"
)
if instance.network is None:
raise RuntimeError(
@ -4997,6 +5259,24 @@ class VmManager:
self._touch_workspace_activity_locked(workspace)
self._save_workspace_locked(workspace)
self._save_workspace_service_locked(service)
self._record_workspace_review_event_locked(
workspace_id,
event_kind="service_start",
payload={
"service_name": service.service_name,
"state": service.state,
"command": service.command,
"cwd": service.cwd,
"readiness": (
dict(service.readiness) if service.readiness is not None else None
),
"ready_at": service.ready_at,
"published_ports": [
_serialize_workspace_published_port_public(published_port)
for published_port in service.published_ports
],
},
)
return self._serialize_workspace_service(service)
def list_services(self, workspace_id: str) -> dict[str, Any]:
@ -5116,6 +5396,18 @@ class VmManager:
workspace.firecracker_pid = instance.firecracker_pid
workspace.last_error = instance.last_error
workspace.metadata = dict(instance.metadata)
self._touch_workspace_activity_locked(workspace)
self._record_workspace_review_event_locked(
workspace_id,
event_kind="service_stop",
payload={
"service_name": service.service_name,
"state": service.state,
"exit_code": service.exit_code,
"stop_reason": service.stop_reason,
"ended_at": service.ended_at,
},
)
self._save_workspace_locked(workspace)
self._save_workspace_service_locked(service)
return self._serialize_workspace_service(service)
@ -5149,6 +5441,153 @@ class VmManager:
"entries": redacted_entries,
}
def summarize_workspace(self, workspace_id: str) -> dict[str, Any]:
with self._lock:
workspace = self._load_workspace_locked(workspace_id)
self._ensure_workspace_not_expired_locked(workspace, time.time())
self._refresh_workspace_liveness_locked(workspace)
self._refresh_workspace_service_counts_locked(workspace)
self._save_workspace_locked(workspace)
command_entries = self._read_workspace_logs_locked(workspace.workspace_id)
recent_commands = [
{
"sequence": int(entry["sequence"]),
"command": str(entry["command"]),
"cwd": str(entry["cwd"]),
"exit_code": int(entry["exit_code"]),
"duration_ms": int(entry["duration_ms"]),
"execution_mode": str(entry["execution_mode"]),
"recorded_at": float(entry["recorded_at"]),
}
for entry in command_entries[-5:]
]
recent_commands.reverse()
review_events = self._list_workspace_review_events_locked(workspace.workspace_id)
def _recent_events(
kinds: set[WorkspaceReviewEventKind],
*,
limit: int = 5,
) -> list[dict[str, Any]]:
matched = [
{
"event_kind": event.event_kind,
"recorded_at": event.recorded_at,
**event.payload,
}
for event in review_events
if event.event_kind in kinds
]
matched = matched[-limit:]
matched.reverse()
return matched
current_services = [
self._serialize_workspace_service(service)
for service in self._list_workspace_services_locked(workspace.workspace_id)
]
current_services.sort(key=lambda item: str(item["service_name"]))
try:
snapshots = self._list_workspace_snapshots_locked(workspace)
named_snapshot_count = max(len(snapshots) - 1, 0)
except RuntimeError:
named_snapshot_count = 0
service_count = len(current_services)
running_service_count = sum(
1 for service in current_services if service["state"] == "running"
)
execution_mode = str(workspace.metadata.get("execution_mode", "pending"))
payload: dict[str, Any] = {
"workspace_id": workspace.workspace_id,
"name": workspace.name,
"labels": dict(workspace.labels),
"environment": workspace.environment,
"state": workspace.state,
"workspace_path": WORKSPACE_GUEST_PATH,
"execution_mode": execution_mode,
"last_activity_at": workspace.last_activity_at,
"session_started_at": (
workspace.last_reset_at
if workspace.last_reset_at is not None
else workspace.created_at
),
"outcome": {
"command_count": workspace.command_count,
"last_command": workspace.last_command,
"service_count": service_count,
"running_service_count": running_service_count,
"export_count": sum(
1 for event in review_events if event.event_kind == "workspace_export"
),
"snapshot_count": named_snapshot_count,
"reset_count": workspace.reset_count,
},
"commands": {
"total": workspace.command_count,
"recent": recent_commands,
},
"edits": {
"recent": _recent_events({"sync_push", "file_write", "patch_apply"}),
},
"services": {
"current": current_services,
"recent": _recent_events({"service_start", "service_stop"}),
},
"artifacts": {
"exports": _recent_events({"workspace_export"}),
},
"snapshots": {
"named_count": named_snapshot_count,
"recent": _recent_events({"snapshot_create", "snapshot_delete"}),
},
}
if payload["state"] != "started":
payload["changes"] = {
"available": False,
"reason": (
f"workspace {workspace_id!r} must be in 'started' state before "
"workspace_summary can compute current changes"
),
"changed": False,
"summary": None,
"entries": [],
}
return payload
try:
diff_payload = self.diff_workspace(workspace_id)
except Exception as exc:
payload["changes"] = {
"available": False,
"reason": str(exc),
"changed": False,
"summary": None,
"entries": [],
}
return payload
diff_entries: list[dict[str, Any]] = []
raw_entries = diff_payload.get("entries")
if isinstance(raw_entries, list):
for entry in raw_entries[:10]:
if not isinstance(entry, dict):
continue
diff_entries.append(
{key: value for key, value in entry.items() if key != "text_patch"}
)
payload["changes"] = {
"available": True,
"reason": None,
"changed": bool(diff_payload.get("changed", False)),
"summary": diff_payload.get("summary"),
"entries": diff_entries,
}
return payload
def stop_workspace(self, workspace_id: str) -> dict[str, Any]:
with self._lock:
workspace = self._load_workspace_locked(workspace_id)
@ -5196,9 +5635,7 @@ class VmManager:
self._stop_workspace_services_locked(workspace, instance)
self._close_workspace_shells_locked(workspace, instance)
try:
self._require_workspace_network_policy_support(
network_policy=workspace.network_policy
)
self._require_workspace_network_policy_support(network_policy=workspace.network_policy)
if self._runtime_capabilities.supports_guest_exec:
self._ensure_workspace_guest_bootstrap_support(instance)
with self._lock:
@ -5381,9 +5818,7 @@ class VmManager:
"execution_mode": workspace.metadata.get("execution_mode", "pending"),
"workspace_path": WORKSPACE_GUEST_PATH,
"workspace_seed": _workspace_seed_dict(workspace.workspace_seed),
"secrets": [
_serialize_workspace_secret_public(secret) for secret in workspace.secrets
],
"secrets": [_serialize_workspace_secret_public(secret) for secret in workspace.secrets],
"command_count": workspace.command_count,
"last_command": workspace.last_command,
"reset_count": workspace.reset_count,
@ -5554,9 +5989,7 @@ class VmManager:
env_values: dict[str, str] = {}
for secret_name, env_name in secret_env.items():
if secret_name not in secret_values:
raise ValueError(
f"secret_env references unknown workspace secret {secret_name!r}"
)
raise ValueError(f"secret_env references unknown workspace secret {secret_name!r}")
env_values[env_name] = secret_values[secret_name]
return env_values
@ -5663,12 +6096,30 @@ class VmManager:
execution_mode = instance.metadata.get("execution_mode", "unknown")
return exec_result, execution_mode
def _prepare_workspace_seed(self, seed_path: str | Path | None) -> PreparedWorkspaceSeed:
def _prepare_workspace_seed(
self,
seed_path: str | Path | None,
*,
origin_kind: WorkspaceSeedOriginKind | None = None,
origin_ref: str | None = None,
) -> PreparedWorkspaceSeed:
if seed_path is None:
return PreparedWorkspaceSeed(mode="empty", source_path=None)
return PreparedWorkspaceSeed(
mode="empty",
source_path=None,
origin_kind="empty" if origin_kind is None else origin_kind,
origin_ref=origin_ref,
)
resolved_source_path = Path(seed_path).expanduser().resolve()
if not resolved_source_path.exists():
raise ValueError(f"seed_path {resolved_source_path} does not exist")
effective_origin_kind: WorkspaceSeedOriginKind = (
"manual_seed_path" if origin_kind is None else origin_kind
)
effective_origin_ref = str(resolved_source_path) if origin_ref is None else origin_ref
public_source_path = (
None if effective_origin_kind == "repo_url" else str(resolved_source_path)
)
if resolved_source_path.is_dir():
cleanup_dir = Path(tempfile.mkdtemp(prefix="pyro-workspace-seed-"))
archive_path = cleanup_dir / "workspace-seed.tar"
@ -5680,23 +6131,24 @@ class VmManager:
raise
return PreparedWorkspaceSeed(
mode="directory",
source_path=str(resolved_source_path),
source_path=public_source_path,
origin_kind=effective_origin_kind,
origin_ref=effective_origin_ref,
archive_path=archive_path,
entry_count=entry_count,
bytes_written=bytes_written,
cleanup_dir=cleanup_dir,
)
if (
not resolved_source_path.is_file()
or not _is_supported_seed_archive(resolved_source_path)
if not resolved_source_path.is_file() or not _is_supported_seed_archive(
resolved_source_path
):
raise ValueError(
"seed_path must be a directory or a .tar/.tar.gz/.tgz archive"
)
raise ValueError("seed_path must be a directory or a .tar/.tar.gz/.tgz archive")
entry_count, bytes_written = _inspect_seed_archive(resolved_source_path)
return PreparedWorkspaceSeed(
mode="tar_archive",
source_path=str(resolved_source_path),
source_path=public_source_path,
origin_kind=effective_origin_kind,
origin_ref=effective_origin_ref,
archive_path=resolved_source_path,
entry_count=entry_count,
bytes_written=bytes_written,
@ -5757,6 +6209,9 @@ class VmManager:
def _workspace_commands_dir(self, workspace_id: str) -> Path:
return self._workspace_dir(workspace_id) / WORKSPACE_COMMANDS_DIRNAME
def _workspace_review_dir(self, workspace_id: str) -> Path:
return self._workspace_dir(workspace_id) / WORKSPACE_REVIEW_DIRNAME
def _workspace_shells_dir(self, workspace_id: str) -> Path:
return self._workspace_dir(workspace_id) / WORKSPACE_SHELLS_DIRNAME
@ -5772,6 +6227,9 @@ class VmManager:
def _workspace_shell_record_path(self, workspace_id: str, shell_id: str) -> Path:
return self._workspace_shells_dir(workspace_id) / f"{shell_id}.json"
def _workspace_review_record_path(self, workspace_id: str, event_id: str) -> Path:
return self._workspace_review_dir(workspace_id) / f"{event_id}.json"
def _workspace_service_record_path(self, workspace_id: str, service_name: str) -> Path:
return self._workspace_services_dir(workspace_id) / f"{service_name}.json"
@ -5787,8 +6245,7 @@ class VmManager:
rootfs_path = Path(raw_rootfs_image)
if not rootfs_path.exists():
raise RuntimeError(
f"workspace {workspace.workspace_id!r} rootfs image is unavailable at "
f"{rootfs_path}"
f"workspace {workspace.workspace_id!r} rootfs image is unavailable at {rootfs_path}"
)
return rootfs_path
@ -5805,9 +6262,7 @@ class VmManager:
f"workspace {workspace.workspace_id!r} must be stopped before {operation_name}"
)
if workspace.metadata.get("execution_mode") == "host_compat":
raise RuntimeError(
f"{operation_name} is unavailable for host_compat workspaces"
)
raise RuntimeError(f"{operation_name} is unavailable for host_compat workspaces")
return self._workspace_rootfs_image_path_locked(workspace)
def _scrub_workspace_runtime_state_locked(
@ -5966,6 +6421,46 @@ class VmManager:
entries.append(entry)
return entries
def _record_workspace_review_event_locked(
self,
workspace_id: str,
*,
event_kind: WorkspaceReviewEventKind,
payload: dict[str, Any],
when: float | None = None,
) -> WorkspaceReviewEventRecord:
recorded_at = time.time() if when is None else when
event = WorkspaceReviewEventRecord(
workspace_id=workspace_id,
event_kind=event_kind,
recorded_at=recorded_at,
payload=dict(payload),
)
event_id = f"{int(recorded_at * 1_000_000_000):020d}-{uuid.uuid4().hex[:8]}"
record_path = self._workspace_review_record_path(workspace_id, event_id)
record_path.parent.mkdir(parents=True, exist_ok=True)
record_path.write_text(
json.dumps(event.to_payload(), indent=2, sort_keys=True),
encoding="utf-8",
)
return event
def _list_workspace_review_events_locked(
self,
workspace_id: str,
) -> list[WorkspaceReviewEventRecord]:
review_dir = self._workspace_review_dir(workspace_id)
if not review_dir.exists():
return []
events: list[WorkspaceReviewEventRecord] = []
for record_path in sorted(review_dir.glob("*.json")):
payload = json.loads(record_path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
continue
events.append(WorkspaceReviewEventRecord.from_payload(payload))
events.sort(key=lambda item: (item.recorded_at, item.event_kind))
return events
def _workspace_instance_for_live_shell_locked(self, workspace: WorkspaceRecord) -> VmInstance:
instance = self._workspace_instance_for_live_operation_locked(
workspace,
@ -6322,10 +6817,12 @@ class VmManager:
shutil.rmtree(self._workspace_runtime_dir(workspace_id), ignore_errors=True)
shutil.rmtree(self._workspace_host_dir(workspace_id), ignore_errors=True)
shutil.rmtree(self._workspace_commands_dir(workspace_id), ignore_errors=True)
shutil.rmtree(self._workspace_review_dir(workspace_id), ignore_errors=True)
shutil.rmtree(self._workspace_shells_dir(workspace_id), ignore_errors=True)
shutil.rmtree(self._workspace_services_dir(workspace_id), ignore_errors=True)
self._workspace_host_dir(workspace_id).mkdir(parents=True, exist_ok=True)
self._workspace_commands_dir(workspace_id).mkdir(parents=True, exist_ok=True)
self._workspace_review_dir(workspace_id).mkdir(parents=True, exist_ok=True)
self._workspace_shells_dir(workspace_id).mkdir(parents=True, exist_ok=True)
self._workspace_services_dir(workspace_id).mkdir(parents=True, exist_ok=True)

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import argparse
import asyncio
import tempfile
import time
from dataclasses import dataclass
@ -28,7 +29,7 @@ USE_CASE_CHOICES: Final[tuple[str, ...]] = USE_CASE_SCENARIOS + (USE_CASE_ALL_SC
class WorkspaceUseCaseRecipe:
scenario: str
title: str
profile: Literal["workspace-core", "workspace-full"]
mode: Literal["repro-fix", "inspect", "cold-start", "review-eval"]
smoke_target: str
doc_path: str
summary: str
@ -38,7 +39,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
WorkspaceUseCaseRecipe(
scenario="cold-start-validation",
title="Cold-Start Repo Validation",
profile="workspace-full",
mode="cold-start",
smoke_target="smoke-cold-start-validation",
doc_path="docs/use-cases/cold-start-repo-validation.md",
summary=(
@ -49,7 +50,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
WorkspaceUseCaseRecipe(
scenario="repro-fix-loop",
title="Repro Plus Fix Loop",
profile="workspace-core",
mode="repro-fix",
smoke_target="smoke-repro-fix-loop",
doc_path="docs/use-cases/repro-fix-loop.md",
summary=(
@ -60,7 +61,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
WorkspaceUseCaseRecipe(
scenario="parallel-workspaces",
title="Parallel Isolated Workspaces",
profile="workspace-core",
mode="repro-fix",
smoke_target="smoke-parallel-workspaces",
doc_path="docs/use-cases/parallel-workspaces.md",
summary=(
@ -71,7 +72,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
WorkspaceUseCaseRecipe(
scenario="untrusted-inspection",
title="Unsafe Or Untrusted Code Inspection",
profile="workspace-core",
mode="inspect",
smoke_target="smoke-untrusted-inspection",
doc_path="docs/use-cases/untrusted-inspection.md",
summary=(
@ -82,7 +83,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
WorkspaceUseCaseRecipe(
scenario="review-eval",
title="Review And Evaluation Workflows",
profile="workspace-full",
mode="review-eval",
smoke_target="smoke-review-eval",
doc_path="docs/use-cases/review-eval-workflows.md",
summary=(
@ -107,6 +108,15 @@ def _log(message: str) -> None:
print(f"[smoke] {message}", flush=True)
def _extract_structured_tool_result(raw_result: object) -> dict[str, object]:
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
raise TypeError("unexpected MCP tool result shape")
_, structured = raw_result
if not isinstance(structured, dict):
raise TypeError("expected structured dictionary result")
return structured
def _create_workspace(
pyro: Pyro,
*,
@ -126,6 +136,31 @@ def _create_workspace(
return str(created["workspace_id"])
def _create_project_aware_workspace(
pyro: Pyro,
*,
environment: str,
project_path: Path,
mode: Literal["repro-fix", "cold-start"],
name: str,
labels: dict[str, str],
) -> dict[str, object]:
async def _run() -> dict[str, object]:
server = pyro.create_server(mode=mode, project_path=project_path)
return _extract_structured_tool_result(
await server.call_tool(
"workspace_create",
{
"environment": environment,
"name": name,
"labels": labels,
},
)
)
return asyncio.run(_run())
def _safe_delete_workspace(pyro: Pyro, workspace_id: str | None) -> None:
if workspace_id is None:
return
@ -160,14 +195,19 @@ def _scenario_cold_start_validation(pyro: Pyro, *, root: Path, environment: str)
)
workspace_id: str | None = None
try:
workspace_id = _create_workspace(
created = _create_project_aware_workspace(
pyro,
environment=environment,
seed_path=seed_dir,
project_path=seed_dir,
mode="cold-start",
name="cold-start-validation",
labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "cold-start-validation"},
)
workspace_id = str(created["workspace_id"])
_log(f"cold-start-validation workspace_id={workspace_id}")
workspace_seed = created["workspace_seed"]
assert isinstance(workspace_seed, dict), created
assert workspace_seed["origin_kind"] == "project_path", created
validation = pyro.exec_workspace(workspace_id, command="sh validate.sh")
assert int(validation["exit_code"]) == 0, validation
assert str(validation["stdout"]) == "validated\n", validation
@ -221,14 +261,20 @@ def _scenario_repro_fix_loop(pyro: Pyro, *, root: Path, environment: str) -> Non
)
workspace_id: str | None = None
try:
workspace_id = _create_workspace(
created = _create_project_aware_workspace(
pyro,
environment=environment,
seed_path=seed_dir,
project_path=seed_dir,
mode="repro-fix",
name="repro-fix-loop",
labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "repro-fix-loop"},
)
workspace_id = str(created["workspace_id"])
_log(f"repro-fix-loop workspace_id={workspace_id}")
workspace_seed = created["workspace_seed"]
assert isinstance(workspace_seed, dict), created
assert workspace_seed["origin_kind"] == "project_path", created
assert workspace_seed["origin_ref"] == str(seed_dir.resolve()), created
initial_read = pyro.read_workspace_file(workspace_id, "message.txt")
assert str(initial_read["content"]) == "broken\n", initial_read
failing = pyro.exec_workspace(workspace_id, command="sh check.sh")
@ -418,6 +464,11 @@ def _scenario_review_eval(pyro: Pyro, *, root: Path, environment: str) -> None:
assert int(rerun["exit_code"]) == 0, rerun
pyro.export_workspace(workspace_id, "review-report.txt", output_path=export_path)
assert export_path.read_text(encoding="utf-8") == "review=pass\n"
summary = pyro.summarize_workspace(workspace_id)
assert summary["workspace_id"] == workspace_id, summary
assert summary["changes"]["available"] is True, summary
assert summary["artifacts"]["exports"], summary
assert summary["snapshots"]["named_count"] >= 1, summary
finally:
if shell_id is not None and workspace_id is not None:
try:
@ -451,7 +502,7 @@ def run_workspace_use_case_scenario(
scenario_names = USE_CASE_SCENARIOS if scenario == USE_CASE_ALL_SCENARIO else (scenario,)
for scenario_name in scenario_names:
recipe = _RECIPE_BY_SCENARIO[scenario_name]
_log(f"starting {recipe.scenario} ({recipe.title}) profile={recipe.profile}")
_log(f"starting {recipe.scenario} ({recipe.title}) mode={recipe.mode}")
scenario_root = root / scenario_name
scenario_root.mkdir(parents=True, exist_ok=True)
runner = _SCENARIO_RUNNERS[scenario_name]

View file

@ -1,12 +1,19 @@
from __future__ import annotations
import asyncio
import subprocess
import time
from pathlib import Path
from typing import Any, cast
import pytest
from pyro_mcp.api import Pyro
from pyro_mcp.contract import (
PUBLIC_MCP_COLD_START_MODE_TOOLS,
PUBLIC_MCP_INSPECT_MODE_TOOLS,
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS,
PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS,
@ -15,6 +22,28 @@ from pyro_mcp.vm_manager import VmManager
from pyro_mcp.vm_network import TapNetworkManager
def _git(repo: Path, *args: str) -> str:
result = subprocess.run( # noqa: S603
["git", "-c", "commit.gpgsign=false", *args],
cwd=repo,
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()
def _make_repo(root: Path, *, content: str = "hello\n") -> Path:
root.mkdir()
_git(root, "init")
_git(root, "config", "user.name", "Pyro Tests")
_git(root, "config", "user.email", "pyro-tests@example.com")
(root / "note.txt").write_text(content, encoding="utf-8")
_git(root, "add", "note.txt")
_git(root, "commit", "-m", "init")
return root
def test_pyro_run_in_vm_delegates_to_manager(tmp_path: Path) -> None:
pyro = Pyro(
manager=VmManager(
@ -134,6 +163,172 @@ def test_pyro_create_server_workspace_core_profile_registers_expected_tools_and_
assert "workspace_disk_export" not in tool_map
def test_pyro_create_server_repro_fix_mode_registers_expected_tools_and_schemas(
tmp_path: Path,
) -> None:
pyro = Pyro(
manager=VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
)
async def _run() -> tuple[list[str], dict[str, dict[str, Any]]]:
server = pyro.create_server(mode="repro-fix")
tools = await server.list_tools()
tool_map = {tool.name: tool.model_dump() for tool in tools}
return sorted(tool_map), tool_map
tool_names, tool_map = asyncio.run(_run())
assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_REPRO_FIX_MODE_TOOLS))
create_properties = tool_map["workspace_create"]["inputSchema"]["properties"]
assert "network_policy" not in create_properties
assert "secrets" not in create_properties
exec_properties = tool_map["workspace_exec"]["inputSchema"]["properties"]
assert "secret_env" not in exec_properties
assert "service_start" not in tool_map
assert "shell_open" not in tool_map
assert "snapshot_create" not in tool_map
assert "reproduce a failure" in str(tool_map["workspace_create"]["description"])
def test_pyro_create_server_cold_start_mode_registers_expected_tools_and_schemas(
tmp_path: Path,
) -> None:
pyro = Pyro(
manager=VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
)
async def _run() -> tuple[list[str], dict[str, dict[str, Any]]]:
server = pyro.create_server(mode="cold-start")
tools = await server.list_tools()
tool_map = {tool.name: tool.model_dump() for tool in tools}
return sorted(tool_map), tool_map
tool_names, tool_map = asyncio.run(_run())
assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_COLD_START_MODE_TOOLS))
assert "shell_open" not in tool_map
assert "snapshot_create" not in tool_map
service_start_properties = tool_map["service_start"]["inputSchema"]["properties"]
assert "secret_env" not in service_start_properties
assert "published_ports" not in service_start_properties
create_properties = tool_map["workspace_create"]["inputSchema"]["properties"]
assert "network_policy" not in create_properties
def test_pyro_create_server_review_eval_mode_registers_expected_tools_and_schemas(
tmp_path: Path,
) -> None:
pyro = Pyro(
manager=VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
)
async def _run() -> tuple[list[str], dict[str, dict[str, Any]]]:
server = pyro.create_server(mode="review-eval")
tools = await server.list_tools()
tool_map = {tool.name: tool.model_dump() for tool in tools}
return sorted(tool_map), tool_map
tool_names, tool_map = asyncio.run(_run())
assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS))
assert "service_start" not in tool_map
assert "shell_open" in tool_map
assert "snapshot_create" in tool_map
shell_open_properties = tool_map["shell_open"]["inputSchema"]["properties"]
assert "secret_env" not in shell_open_properties
def test_pyro_create_server_inspect_mode_registers_expected_tools(tmp_path: Path) -> None:
pyro = Pyro(
manager=VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
)
async def _run() -> list[str]:
server = pyro.create_server(mode="inspect")
tools = await server.list_tools()
return sorted(tool.name for tool in tools)
assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_INSPECT_MODE_TOOLS))
def test_pyro_create_server_rejects_mode_and_non_default_profile(tmp_path: Path) -> None:
pyro = Pyro(
manager=VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
)
with pytest.raises(ValueError, match="mutually exclusive"):
pyro.create_server(profile="workspace-full", mode="repro-fix")
def test_pyro_create_server_project_path_updates_workspace_create_description_and_default_seed(
tmp_path: Path,
) -> None:
repo = _make_repo(tmp_path / "repo", content="project-aware\n")
pyro = Pyro(
manager=VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
)
def _extract_structured(raw_result: object) -> dict[str, Any]:
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
raise TypeError("unexpected call_tool result shape")
_, structured = raw_result
if not isinstance(structured, dict):
raise TypeError("expected structured dictionary result")
return cast(dict[str, Any], structured)
async def _run() -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
server = pyro.create_server(project_path=repo)
tools = await server.list_tools()
tool_map = {tool.name: tool.model_dump() for tool in tools}
created = _extract_structured(
await server.call_tool(
"workspace_create",
{
"environment": "debian:12-base",
"allow_host_compat": True,
},
)
)
executed = _extract_structured(
await server.call_tool(
"workspace_exec",
{
"workspace_id": created["workspace_id"],
"command": "cat note.txt",
},
)
)
return tool_map["workspace_create"], created, executed
workspace_create_tool, created, executed = asyncio.run(_run())
assert "If `seed_path` is omitted" in str(workspace_create_tool["description"])
assert str(repo.resolve()) in str(workspace_create_tool["description"])
assert created["workspace_seed"]["origin_kind"] == "project_path"
assert created["workspace_seed"]["origin_ref"] == str(repo.resolve())
assert executed["stdout"] == "project-aware\n"
def test_pyro_vm_run_tool_executes(tmp_path: Path) -> None:
pyro = Pyro(
manager=VmManager(
@ -380,6 +575,7 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
services = pyro.list_services(workspace_id)
service_status = pyro.status_service(workspace_id, "app")
service_logs = pyro.logs_service(workspace_id, "app", all=True)
summary = pyro.summarize_workspace(workspace_id)
reset = pyro.reset_workspace(workspace_id, snapshot="checkpoint")
deleted_snapshot = pyro.delete_snapshot(workspace_id, "checkpoint")
status = pyro.status_workspace(workspace_id)
@ -416,6 +612,9 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
assert service_status["state"] == "running"
assert service_logs["stderr"].count("[REDACTED]") >= 1
assert service_logs["tail_lines"] is None
assert summary["workspace_id"] == workspace_id
assert summary["commands"]["total"] >= 1
assert summary["changes"]["available"] is True
assert reset["workspace_reset"]["snapshot_name"] == "checkpoint"
assert reset["secrets"] == created["secrets"]
assert deleted_snapshot["deleted"] is True
@ -979,6 +1178,14 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
calls.append(("logs_workspace", {"workspace_id": workspace_id}))
return {"workspace_id": workspace_id, "count": 0, "entries": []}
def summarize_workspace(self, workspace_id: str) -> dict[str, Any]:
calls.append(("summarize_workspace", {"workspace_id": workspace_id}))
return {
"workspace_id": workspace_id,
"state": "started",
"changes": {"available": True, "changed": False, "summary": None, "entries": []},
}
def open_shell(
self,
workspace_id: str,
@ -1110,6 +1317,9 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
status = _extract_structured(
await server.call_tool("workspace_status", {"workspace_id": "workspace-123"})
)
summary = _extract_structured(
await server.call_tool("workspace_summary", {"workspace_id": "workspace-123"})
)
logs = _extract_structured(
await server.call_tool("workspace_logs", {"workspace_id": "workspace-123"})
)
@ -1211,6 +1421,7 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
)
return (
status,
summary,
logs,
opened,
read,
@ -1225,13 +1436,15 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
results = asyncio.run(_run())
assert results[0]["state"] == "started"
assert results[1]["count"] == 0
assert results[2]["shell_id"] == "shell-1"
assert results[6]["closed"] is True
assert results[7]["state"] == "running"
assert results[10]["state"] == "running"
assert results[1]["workspace_id"] == "workspace-123"
assert results[2]["count"] == 0
assert results[3]["shell_id"] == "shell-1"
assert results[7]["closed"] is True
assert results[8]["state"] == "running"
assert results[11]["state"] == "running"
assert calls == [
("status_workspace", {"workspace_id": "workspace-123"}),
("summarize_workspace", {"workspace_id": "workspace-123"}),
("logs_workspace", {"workspace_id": "workspace-123"}),
(
"open_shell",

View file

@ -9,6 +9,7 @@ from typing import Any, cast
import pytest
import pyro_mcp.cli as cli
from pyro_mcp.host_helpers import HostDoctorEntry
def _subparser_choice(parser: argparse.ArgumentParser, name: str) -> argparse.ArgumentParser:
@ -26,17 +27,24 @@ def test_cli_help_guides_first_run() -> None:
parser = cli._build_parser()
help_text = parser.format_help()
assert "Suggested first run:" in help_text
assert "Suggested zero-to-hero path:" in help_text
assert "pyro doctor" in help_text
assert "pyro env list" in help_text
assert "pyro env pull debian:12" in help_text
assert "pyro prepare debian:12" in help_text
assert "pyro run debian:12 -- git --version" in help_text
assert "Continue into the stable workspace path after that:" in help_text
assert "pyro host connect claude-code" in help_text
assert "Connect a chat host after that:" in help_text
assert "pyro host connect claude-code" in help_text
assert "pyro host connect codex" in help_text
assert "pyro host print-config opencode" in help_text
assert "Daily local loop after the first warmup:" in help_text
assert "pyro doctor --environment debian:12" in help_text
assert "pyro workspace reset WORKSPACE_ID" in help_text
assert "If you want terminal-level visibility into the workspace model:" in help_text
assert "pyro workspace exec WORKSPACE_ID -- cat note.txt" in help_text
assert "pyro workspace summary WORKSPACE_ID" in help_text
assert "pyro workspace snapshot create WORKSPACE_ID checkpoint" in help_text
assert "pyro workspace reset WORKSPACE_ID --snapshot checkpoint" in help_text
assert "pyro workspace sync push WORKSPACE_ID ./changes" in help_text
assert "Use `pyro mcp serve` only after the CLI validation path works." in help_text
def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
@ -54,8 +62,41 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
assert "pyro env pull debian:12" in env_help
assert "downloads from public Docker Hub" in env_help
prepare_help = _subparser_choice(parser, "prepare").format_help()
assert "Warm the recommended guest-backed daily loop" in prepare_help
assert "pyro prepare debian:12 --network" in prepare_help
assert "--network" in prepare_help
assert "--force" in prepare_help
host_help = _subparser_choice(parser, "host").format_help()
assert "Connect or repair the supported Claude Code, Codex, and OpenCode" in host_help
assert "pyro host connect claude-code" in host_help
assert "pyro host repair opencode" in host_help
host_connect_help = _subparser_choice(
_subparser_choice(parser, "host"), "connect"
).format_help()
assert "--installed-package" in host_connect_help
assert "--project-path" in host_connect_help
assert "--repo-url" in host_connect_help
assert "--repo-ref" in host_connect_help
assert "--no-project-source" in host_connect_help
host_print_config_help = _subparser_choice(
_subparser_choice(parser, "host"), "print-config"
).format_help()
assert "--output" in host_print_config_help
host_doctor_help = _subparser_choice(_subparser_choice(parser, "host"), "doctor").format_help()
assert "--config-path" in host_doctor_help
host_repair_help = _subparser_choice(_subparser_choice(parser, "host"), "repair").format_help()
assert "--config-path" in host_repair_help
doctor_help = _subparser_choice(parser, "doctor").format_help()
assert "Check host prerequisites and embedded runtime health" in doctor_help
assert "--environment" in doctor_help
assert "pyro doctor --environment debian:12" in doctor_help
assert "pyro doctor --json" in doctor_help
demo_help = _subparser_choice(parser, "demo").format_help()
@ -69,17 +110,22 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
assert "vm-run" in mcp_help
assert "recommended first profile for most chat hosts" in mcp_help
assert "workspace-core: default for normal persistent chat editing" in mcp_help
assert "workspace-full: advanced 4.x opt-in surface" in mcp_help
assert "workspace-full: larger opt-in surface" in mcp_help
assert "--project-path" in mcp_help
assert "--repo-url" in mcp_help
assert "--repo-ref" in mcp_help
assert "--no-project-source" in mcp_help
assert "pyro mcp serve --project-path ." in mcp_help
assert "pyro mcp serve --repo-url https://github.com/example/project.git" in mcp_help
workspace_help = _subparser_choice(parser, "workspace").format_help()
assert "stable workspace contract" in workspace_help
assert "Use the workspace model when you need one sandbox to stay alive" in workspace_help
assert "pyro workspace create debian:12 --seed-path ./repo" in workspace_help
assert "--id-only" in workspace_help
assert "pyro workspace create debian:12 --name repro-fix --label issue=123" in workspace_help
assert "pyro workspace list" in workspace_help
assert (
"pyro workspace update WORKSPACE_ID --name retry-run --label owner=codex"
in workspace_help
"pyro workspace update WORKSPACE_ID --name retry-run --label owner=codex" 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
@ -91,6 +137,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
assert "pyro workspace start WORKSPACE_ID" in workspace_help
assert "pyro workspace snapshot create WORKSPACE_ID checkpoint" in workspace_help
assert "pyro workspace reset WORKSPACE_ID --snapshot checkpoint" in workspace_help
assert "pyro workspace summary WORKSPACE_ID" in workspace_help
assert "pyro workspace shell open WORKSPACE_ID --id-only" in workspace_help
workspace_create_help = _subparser_choice(
@ -145,6 +192,12 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
assert "--label" in workspace_update_help
assert "--clear-label" in workspace_update_help
workspace_summary_help = _subparser_choice(
_subparser_choice(parser, "workspace"), "summary"
).format_help()
assert "Summarize the current workspace session since the last reset" in workspace_summary_help
assert "pyro workspace summary WORKSPACE_ID" in workspace_summary_help
workspace_file_help = _subparser_choice(
_subparser_choice(parser, "workspace"), "file"
).format_help()
@ -307,6 +360,94 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
assert "Close a persistent workspace shell" in workspace_shell_close_help
def test_cli_host_connect_dispatch(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
pass
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="host",
host_command="connect",
host="codex",
installed_package=False,
profile="workspace-core",
project_path=None,
repo_url=None,
repo_ref=None,
no_project_source=False,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
monkeypatch.setattr(
cli,
"connect_cli_host",
lambda host, *, config: {
"host": host,
"server_command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"],
"verification_command": ["codex", "mcp", "list"],
},
)
cli.main()
captured = capsys.readouterr()
assert captured.out == (
"Connected pyro to codex.\n"
"Server command: uvx --from pyro-mcp pyro mcp serve\n"
"Verify with: codex mcp list\n"
)
assert captured.err == ""
def test_cli_host_doctor_prints_human(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
pass
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="host",
host_command="doctor",
installed_package=False,
profile="workspace-core",
project_path=None,
repo_url=None,
repo_ref=None,
no_project_source=False,
config_path=None,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
monkeypatch.setattr(
cli,
"doctor_hosts",
lambda **_: [
HostDoctorEntry(
host="codex",
installed=True,
configured=False,
status="missing",
details="codex entry missing",
repair_command="pyro host repair codex",
)
],
)
cli.main()
captured = capsys.readouterr()
assert "codex: missing installed=yes configured=no" in captured.out
assert "repair: pyro host repair codex" in captured.out
assert captured.err == ""
def test_cli_run_prints_json(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
@ -344,13 +485,22 @@ def test_cli_doctor_prints_json(
) -> None:
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(command="doctor", platform="linux-x86_64", json=True)
return argparse.Namespace(
command="doctor",
platform="linux-x86_64",
environment="debian:12",
json=True,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(
cli,
"doctor_report",
lambda platform: {"platform": platform, "runtime_ok": True},
lambda *, platform, environment: {
"platform": platform,
"environment": environment,
"runtime_ok": True,
},
)
cli.main()
output = json.loads(capsys.readouterr().out)
@ -569,7 +719,7 @@ def test_cli_requires_command_preserves_shell_argument_boundaries() -> None:
command = cli._require_command(
["--", "sh", "-lc", 'printf "hello from workspace\\n" > note.txt']
)
assert command == 'sh -lc \'printf "hello from workspace\\n" > note.txt\''
assert command == "sh -lc 'printf \"hello from workspace\\n\" > note.txt'"
def test_cli_read_utf8_text_file_rejects_non_utf8(tmp_path: Path) -> None:
@ -845,10 +995,7 @@ def test_cli_workspace_exec_prints_human_output(
cli.main()
captured = capsys.readouterr()
assert captured.out == "hello\n"
assert (
"[workspace-exec] workspace_id=workspace-123 sequence=2 cwd=/workspace"
in captured.err
)
assert "[workspace-exec] workspace_id=workspace-123 sequence=2 cwd=/workspace" in captured.err
def test_print_workspace_summary_human_handles_last_command_and_secret_filtering(
@ -1319,13 +1466,7 @@ def test_cli_workspace_patch_apply_reads_patch_file(
tmp_path: Path,
) -> None:
patch_path = tmp_path / "fix.patch"
patch_text = (
"--- a/src/app.py\n"
"+++ b/src/app.py\n"
"@@ -1 +1 @@\n"
"-print('hi')\n"
"+print('hello')\n"
)
patch_text = "--- a/src/app.py\n+++ b/src/app.py\n@@ -1 +1 @@\n-print('hi')\n+print('hello')\n"
patch_path.write_text(patch_text, encoding="utf-8")
class StubPyro:
@ -1773,10 +1914,7 @@ def test_cli_workspace_diff_prints_human_output(
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 "[workspace-diff] workspace_id=workspace-123 total=1 added=0 modified=1" in output
assert "--- a/note.txt" in output
@ -2223,8 +2361,7 @@ def test_cli_workspace_sync_push_prints_human(
output = capsys.readouterr().out
assert "[workspace-sync] workspace_id=workspace-123 mode=directory source=/tmp/repo" in output
assert (
"destination=/workspace entry_count=2 bytes_written=12 "
"execution_mode=guest_vsock"
"destination=/workspace entry_count=2 bytes_written=12 execution_mode=guest_vsock"
) in output
@ -2391,6 +2528,168 @@ def test_cli_workspace_logs_prints_json(
assert payload["count"] == 0
def test_cli_workspace_summary_prints_json(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def summarize_workspace(self, workspace_id: str) -> dict[str, Any]:
assert workspace_id == "workspace-123"
return {
"workspace_id": workspace_id,
"name": "review-eval",
"labels": {"suite": "smoke"},
"environment": "debian:12",
"state": "started",
"last_activity_at": 2.0,
"session_started_at": 1.0,
"outcome": {
"command_count": 1,
"last_command": {"command": "cat note.txt", "exit_code": 0},
"service_count": 0,
"running_service_count": 0,
"export_count": 1,
"snapshot_count": 1,
"reset_count": 0,
},
"commands": {"total": 1, "recent": []},
"edits": {"recent": []},
"changes": {"available": True, "changed": False, "summary": None, "entries": []},
"services": {"current": [], "recent": []},
"artifacts": {"exports": []},
"snapshots": {"named_count": 1, "recent": []},
}
class SummaryParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="workspace",
workspace_command="summary",
workspace_id="workspace-123",
json=True,
)
monkeypatch.setattr(cli, "_build_parser", lambda: SummaryParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
payload = json.loads(capsys.readouterr().out)
assert payload["workspace_id"] == "workspace-123"
assert payload["outcome"]["export_count"] == 1
def test_cli_workspace_summary_prints_human(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def summarize_workspace(self, workspace_id: str) -> dict[str, Any]:
assert workspace_id == "workspace-123"
return {
"workspace_id": workspace_id,
"name": "review-eval",
"labels": {"suite": "smoke", "use_case": "review-eval"},
"environment": "debian:12",
"state": "started",
"last_activity_at": 3.0,
"session_started_at": 1.0,
"outcome": {
"command_count": 2,
"last_command": {"command": "sh review.sh", "exit_code": 0},
"service_count": 1,
"running_service_count": 0,
"export_count": 1,
"snapshot_count": 1,
"reset_count": 0,
},
"commands": {
"total": 2,
"recent": [
{
"sequence": 2,
"command": "sh review.sh",
"cwd": "/workspace",
"exit_code": 0,
"duration_ms": 12,
"execution_mode": "guest_vsock",
"recorded_at": 3.0,
}
],
},
"edits": {
"recent": [
{
"event_kind": "patch_apply",
"recorded_at": 2.0,
"path": "/workspace/note.txt",
}
]
},
"changes": {
"available": True,
"changed": True,
"summary": {
"total": 1,
"added": 0,
"modified": 1,
"deleted": 0,
"type_changed": 0,
"text_patched": 1,
"non_text": 0,
},
"entries": [
{
"path": "/workspace/note.txt",
"status": "modified",
"artifact_type": "file",
}
],
},
"services": {
"current": [{"service_name": "app", "state": "stopped"}],
"recent": [
{
"event_kind": "service_stop",
"service_name": "app",
"state": "stopped",
}
],
},
"artifacts": {
"exports": [
{
"workspace_path": "review-report.txt",
"output_path": "/tmp/review-report.txt",
}
]
},
"snapshots": {
"named_count": 1,
"recent": [{"event_kind": "snapshot_create", "snapshot_name": "checkpoint"}],
},
}
class SummaryParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="workspace",
workspace_command="summary",
workspace_id="workspace-123",
json=False,
)
monkeypatch.setattr(cli, "_build_parser", lambda: SummaryParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
output = capsys.readouterr().out
assert "Workspace review: workspace-123" in output
assert "Outcome: commands=2 services=0/1 exports=1 snapshots=1 resets=0" in output
assert "Recent commands:" in output
assert "Recent edits:" in output
assert "Changes: total=1 added=0 modified=1 deleted=0 type_changed=0 non_text=0" in output
assert "Recent exports:" in output
assert "Recent snapshot events:" in output
def test_cli_workspace_delete_prints_human(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
@ -2805,7 +3104,7 @@ def test_cli_workspace_shell_open_prints_id_only(
assert captured.err == ""
def test_chat_host_docs_and_examples_recommend_workspace_core() -> None:
def test_chat_host_docs_and_examples_recommend_modes_first() -> None:
readme = Path("README.md").read_text(encoding="utf-8")
install = Path("docs/install.md").read_text(encoding="utf-8")
first_run = Path("docs/first-run.md").read_text(encoding="utf-8")
@ -2814,47 +3113,81 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None:
claude_code = Path("examples/claude_code_mcp.md").read_text(encoding="utf-8")
codex = Path("examples/codex_mcp.md").read_text(encoding="utf-8")
opencode = json.loads(Path("examples/opencode_mcp_config.json").read_text(encoding="utf-8"))
claude_cmd = "claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve"
codex_cmd = "codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve"
claude_helper = "pyro host connect claude-code --mode cold-start"
codex_helper = "pyro host connect codex --mode repro-fix"
inspect_helper = "pyro host connect codex --mode inspect"
review_helper = "pyro host connect claude-code --mode review-eval"
opencode_helper = "pyro host print-config opencode --mode repro-fix"
claude_cmd = "claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start"
codex_cmd = "codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix"
assert "## Chat Host Quickstart" in readme
assert "uvx --from pyro-mcp pyro mcp serve" in readme
assert claude_cmd in readme
assert codex_cmd in readme
assert claude_helper in readme
assert codex_helper in readme
assert inspect_helper in readme
assert review_helper in readme
assert opencode_helper in readme
assert "examples/opencode_mcp_config.json" in readme
assert "recommended first profile for normal persistent chat editing" in readme
assert "pyro host doctor" in readme
assert "pyro mcp serve --mode repro-fix" in readme
assert "generic no-mode path" in readme
assert "auto-detects the current Git checkout" in readme.replace("\n", " ")
assert "--project-path /abs/path/to/repo" in readme
assert "--repo-url https://github.com/example/project.git" in readme
assert "## Chat Host Quickstart" in install
assert "uvx --from pyro-mcp pyro mcp serve" in install
assert claude_cmd in install
assert codex_cmd in install
assert "## 6. Connect a chat host" in install
assert claude_helper in install
assert codex_helper in install
assert inspect_helper in install
assert review_helper in install
assert opencode_helper in install
assert "workspace-full" in install
assert "--project-path /abs/path/to/repo" in install
assert "pyro mcp serve --mode cold-start" in install
assert claude_cmd in first_run
assert codex_cmd in first_run
assert claude_helper in first_run
assert codex_helper in first_run
assert inspect_helper in first_run
assert review_helper in first_run
assert opencode_helper in first_run
assert "--project-path /abs/path/to/repo" in first_run
assert "pyro mcp serve --mode review-eval" in first_run
assert "Bare `pyro mcp serve` now starts `workspace-core`." in integrations
assert claude_helper in integrations
assert codex_helper in integrations
assert inspect_helper in integrations
assert review_helper in integrations
assert opencode_helper in integrations
assert "## Recommended Modes" in integrations
assert "pyro mcp serve --mode inspect" in integrations
assert "auto-detects the current Git checkout" in integrations
assert "examples/claude_code_mcp.md" in integrations
assert "examples/codex_mcp.md" in integrations
assert "examples/opencode_mcp_config.json" in integrations
assert (
'`Pyro.create_server()` for most chat hosts now that `workspace-core` '
"is the default profile" in integrations
)
assert "generic no-mode path" in integrations
assert "--project-path /abs/path/to/repo" in integrations
assert "--repo-url https://github.com/example/project.git" in integrations
assert "Default for most chat hosts in `4.x`: `workspace-core`." in mcp_config
assert "Recommended named modes for most chat hosts in `4.x`:" in mcp_config
assert "Use the host-specific examples first when they apply:" in mcp_config
assert "claude_code_mcp.md" in mcp_config
assert "codex_mcp.md" in mcp_config
assert "opencode_mcp_config.json" in mcp_config
assert '"serve", "--mode", "repro-fix"' in mcp_config
assert claude_helper in claude_code
assert claude_cmd in claude_code
assert "claude mcp list" in claude_code
assert "pyro host repair claude-code --mode cold-start" in claude_code
assert "workspace-full" in claude_code
assert "--project-path /abs/path/to/repo" in claude_code
assert codex_helper in codex
assert codex_cmd in codex
assert "codex mcp list" in codex
assert "pyro host repair codex --mode repro-fix" in codex
assert "workspace-full" in codex
assert "--project-path /abs/path/to/repo" in codex
assert opencode == {
"mcp": {
@ -2868,6 +3201,8 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None:
"pyro",
"mcp",
"serve",
"--mode",
"repro-fix",
],
}
}
@ -2882,7 +3217,32 @@ def test_content_only_read_docs_are_aligned() -> None:
assert 'workspace file read "$WORKSPACE_ID" note.txt --content-only' in readme
assert 'workspace file read "$WORKSPACE_ID" note.txt --content-only' in install
assert 'workspace file read "$WORKSPACE_ID" note.txt --content-only' in first_run
assert 'workspace disk read "$WORKSPACE_ID" note.txt --content-only' in first_run
assert 'workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch' in first_run
def test_daily_loop_docs_are_aligned() -> None:
readme = Path("README.md").read_text(encoding="utf-8")
install = Path("docs/install.md").read_text(encoding="utf-8")
first_run = Path("docs/first-run.md").read_text(encoding="utf-8")
integrations = Path("docs/integrations.md").read_text(encoding="utf-8")
assert "pyro prepare debian:12" in readme
assert "pyro prepare debian:12" in install
assert "pyro prepare debian:12" in first_run
assert "pyro prepare debian:12" in integrations
assert "pyro doctor --environment debian:12" in readme
assert "pyro doctor --environment debian:12" in install
assert "pyro doctor --environment debian:12" in first_run
def test_workspace_summary_docs_are_aligned() -> None:
readme = Path("README.md").read_text(encoding="utf-8")
install = Path("docs/install.md").read_text(encoding="utf-8")
first_run = Path("docs/first-run.md").read_text(encoding="utf-8")
assert 'workspace summary "$WORKSPACE_ID"' in readme
assert 'workspace summary "$WORKSPACE_ID"' in install
assert 'workspace summary "$WORKSPACE_ID"' in first_run
def test_cli_workspace_shell_write_signal_close_json(
@ -3965,22 +4325,163 @@ def test_cli_doctor_prints_human(
) -> None:
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(command="doctor", platform="linux-x86_64", json=False)
return argparse.Namespace(
command="doctor",
platform="linux-x86_64",
environment="debian:12",
json=False,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(
cli,
"doctor_report",
lambda platform: {
lambda *, platform, environment: {
"platform": platform,
"runtime_ok": True,
"issues": [],
"kvm": {"exists": True, "readable": True, "writable": True},
"runtime": {"catalog_version": "4.5.0", "cache_dir": "/cache"},
"daily_loop": {
"environment": environment,
"status": "cold",
"installed": False,
"network_prepared": False,
"prepared_at": None,
"manifest_path": "/cache/.prepare/linux-x86_64/debian_12.json",
"reason": "daily loop has not been prepared yet",
"cache_dir": "/cache",
},
},
)
cli.main()
output = capsys.readouterr().out
assert "Runtime: PASS" in output
assert "Daily loop: COLD (debian:12)" in output
assert "Run: pyro prepare debian:12" in output
def test_cli_prepare_prints_human(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubManager:
def prepare_daily_loop(
self,
environment: str,
*,
network: bool,
force: bool,
) -> dict[str, object]:
assert environment == "debian:12"
assert network is True
assert force is False
return {
"environment": environment,
"status": "warm",
"prepared": True,
"reused": False,
"executed": True,
"network_prepared": True,
"prepared_at": 123.0,
"manifest_path": "/cache/.prepare/linux-x86_64/debian_12.json",
"cache_dir": "/cache",
"last_prepare_duration_ms": 456,
"reason": None,
}
class StubPyro:
def __init__(self) -> None:
self.manager = StubManager()
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="prepare",
environment="debian:12",
network=True,
force=False,
json=False,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
output = capsys.readouterr().out
assert "Prepare: debian:12" in output
assert "Daily loop: WARM" in output
assert "Result: prepared network_prepared=yes" in output
def test_cli_prepare_prints_json_and_errors(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class SuccessManager:
def prepare_daily_loop(
self,
environment: str,
*,
network: bool,
force: bool,
) -> dict[str, object]:
assert environment == "debian:12"
assert network is False
assert force is True
return {"environment": environment, "reused": True}
class SuccessPyro:
def __init__(self) -> None:
self.manager = SuccessManager()
class SuccessParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="prepare",
environment="debian:12",
network=False,
force=True,
json=True,
)
monkeypatch.setattr(cli, "_build_parser", lambda: SuccessParser())
monkeypatch.setattr(cli, "Pyro", SuccessPyro)
cli.main()
payload = json.loads(capsys.readouterr().out)
assert payload["reused"] is True
class ErrorManager:
def prepare_daily_loop(
self,
environment: str,
*,
network: bool,
force: bool,
) -> dict[str, object]:
del environment, network, force
raise RuntimeError("prepare failed")
class ErrorPyro:
def __init__(self) -> None:
self.manager = ErrorManager()
class ErrorParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="prepare",
environment="debian:12",
network=False,
force=False,
json=True,
)
monkeypatch.setattr(cli, "_build_parser", lambda: ErrorParser())
monkeypatch.setattr(cli, "Pyro", ErrorPyro)
with pytest.raises(SystemExit, match="1"):
cli.main()
error_payload = json.loads(capsys.readouterr().out)
assert error_payload["ok"] is False
assert error_payload["error"] == "prepare failed"
def test_cli_run_json_error_exits_nonzero(
@ -4017,11 +4518,25 @@ def test_cli_run_json_error_exits_nonzero(
def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
observed: dict[str, str] = {}
observed: dict[str, Any] = {}
class StubPyro:
def create_server(self, *, profile: str) -> Any:
def create_server(
self,
*,
profile: str,
mode: str | None,
project_path: str | None,
repo_url: str | None,
repo_ref: str | None,
no_project_source: bool,
) -> Any:
observed["profile"] = profile
observed["mode"] = mode
observed["project_path"] = project_path
observed["repo_url"] = repo_url
observed["repo_ref"] = repo_ref
observed["no_project_source"] = no_project_source
return type(
"StubServer",
(),
@ -4030,12 +4545,29 @@ def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(command="mcp", mcp_command="serve", profile="workspace-core")
return argparse.Namespace(
command="mcp",
mcp_command="serve",
profile="workspace-core",
mode=None,
project_path="/repo",
repo_url=None,
repo_ref=None,
no_project_source=False,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
assert observed == {"profile": "workspace-core", "transport": "stdio"}
assert observed == {
"profile": "workspace-core",
"mode": None,
"project_path": "/repo",
"repo_url": None,
"repo_ref": None,
"no_project_source": False,
"transport": "stdio",
}
def test_cli_demo_default_prints_json(
@ -4153,7 +4685,7 @@ def test_cli_workspace_exec_passes_secret_env(
class StubPyro:
def exec_workspace(self, workspace_id: str, **kwargs: Any) -> dict[str, Any]:
assert workspace_id == "ws-123"
assert kwargs["command"] == "sh -lc 'test \"$API_TOKEN\" = \"expected\"'"
assert kwargs["command"] == 'sh -lc \'test "$API_TOKEN" = "expected"\''
assert kwargs["secret_env"] == {"API_TOKEN": "API_TOKEN", "TOKEN": "PIP_TOKEN"}
return {"exit_code": 0, "stdout": "", "stderr": ""}

359
tests/test_daily_loop.py Normal file
View file

@ -0,0 +1,359 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from pyro_mcp.daily_loop import (
DailyLoopManifest,
evaluate_daily_loop_status,
load_prepare_manifest,
prepare_manifest_path,
prepare_request_is_satisfied,
)
from pyro_mcp.runtime import RuntimeCapabilities
from pyro_mcp.vm_manager import VmManager
def test_prepare_daily_loop_executes_then_reuses(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "state",
cache_dir=tmp_path / "cache",
)
monkeypatch.setattr(manager, "_backend_name", "firecracker")
monkeypatch.setattr(
manager,
"_runtime_capabilities",
RuntimeCapabilities(
supports_vm_boot=True,
supports_guest_exec=True,
supports_guest_network=True,
),
)
monkeypatch.setattr(
manager,
"inspect_environment",
lambda environment: {"installed": True},
)
monkeypatch.setattr(
manager._environment_store,
"ensure_installed",
lambda environment: object(),
)
observed: dict[str, object] = {}
def fake_create_workspace(**kwargs: object) -> dict[str, object]:
observed["network_policy"] = kwargs["network_policy"]
return {"workspace_id": "ws-123"}
def fake_exec_workspace(
workspace_id: str,
*,
command: str,
timeout_seconds: int = 30,
secret_env: dict[str, str] | None = None,
) -> dict[str, object]:
observed["exec"] = {
"workspace_id": workspace_id,
"command": command,
"timeout_seconds": timeout_seconds,
"secret_env": secret_env,
}
return {
"workspace_id": workspace_id,
"stdout": "/workspace\n",
"stderr": "",
"exit_code": 0,
"duration_ms": 1,
"execution_mode": "guest_vsock",
}
def fake_reset_workspace(
workspace_id: str,
*,
snapshot: str = "baseline",
) -> dict[str, object]:
observed["reset"] = {"workspace_id": workspace_id, "snapshot": snapshot}
return {"workspace_id": workspace_id}
def fake_delete_workspace(
workspace_id: str,
*,
reason: str = "explicit_delete",
) -> dict[str, object]:
observed["delete"] = {"workspace_id": workspace_id, "reason": reason}
return {"workspace_id": workspace_id, "deleted": True}
monkeypatch.setattr(manager, "create_workspace", fake_create_workspace)
monkeypatch.setattr(manager, "exec_workspace", fake_exec_workspace)
monkeypatch.setattr(manager, "reset_workspace", fake_reset_workspace)
monkeypatch.setattr(manager, "delete_workspace", fake_delete_workspace)
first = manager.prepare_daily_loop("debian:12")
assert first["prepared"] is True
assert first["executed"] is True
assert first["reused"] is False
assert first["network_prepared"] is False
assert first["execution_mode"] == "guest_vsock"
assert observed["network_policy"] == "off"
assert observed["exec"] == {
"workspace_id": "ws-123",
"command": "pwd",
"timeout_seconds": 30,
"secret_env": None,
}
assert observed["reset"] == {"workspace_id": "ws-123", "snapshot": "baseline"}
assert observed["delete"] == {"workspace_id": "ws-123", "reason": "prepare_cleanup"}
second = manager.prepare_daily_loop("debian:12")
assert second["prepared"] is True
assert second["executed"] is False
assert second["reused"] is True
assert second["reason"] == "reused existing warm manifest"
def test_prepare_daily_loop_force_and_network_upgrade_manifest(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "state",
cache_dir=tmp_path / "cache",
)
monkeypatch.setattr(manager, "_backend_name", "firecracker")
monkeypatch.setattr(
manager,
"_runtime_capabilities",
RuntimeCapabilities(
supports_vm_boot=True,
supports_guest_exec=True,
supports_guest_network=True,
),
)
monkeypatch.setattr(
manager,
"inspect_environment",
lambda environment: {"installed": True},
)
monkeypatch.setattr(
manager._environment_store,
"ensure_installed",
lambda environment: object(),
)
observed_policies: list[str] = []
def fake_create_workspace(**kwargs: object) -> dict[str, object]:
observed_policies.append(str(kwargs["network_policy"]))
return {"workspace_id": "ws-1"}
monkeypatch.setattr(manager, "create_workspace", fake_create_workspace)
monkeypatch.setattr(
manager,
"exec_workspace",
lambda workspace_id, **kwargs: {
"workspace_id": workspace_id,
"stdout": "/workspace\n",
"stderr": "",
"exit_code": 0,
"duration_ms": 1,
"execution_mode": "guest_vsock",
},
)
monkeypatch.setattr(
manager,
"reset_workspace",
lambda workspace_id, **kwargs: {"workspace_id": workspace_id},
)
monkeypatch.setattr(
manager,
"delete_workspace",
lambda workspace_id, **kwargs: {"workspace_id": workspace_id, "deleted": True},
)
manager.prepare_daily_loop("debian:12")
payload = manager.prepare_daily_loop("debian:12", network=True, force=True)
assert payload["executed"] is True
assert payload["network_prepared"] is True
assert observed_policies == ["off", "egress"]
manifest_path = prepare_manifest_path(
tmp_path / "cache",
platform="linux-x86_64",
environment="debian:12",
)
manifest, manifest_error = load_prepare_manifest(manifest_path)
assert manifest_error is None
if manifest is None:
raise AssertionError("expected prepare manifest")
assert manifest.network_prepared is True
def test_prepare_daily_loop_requires_guest_capabilities(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "state",
cache_dir=tmp_path / "cache",
)
with pytest.raises(RuntimeError, match="guest-backed runtime"):
manager.prepare_daily_loop("debian:12")
def test_load_prepare_manifest_reports_invalid_json(tmp_path: Path) -> None:
manifest_path = prepare_manifest_path(
tmp_path,
platform="linux-x86_64",
environment="debian:12",
)
manifest_path.parent.mkdir(parents=True, exist_ok=True)
manifest_path.write_text("{broken", encoding="utf-8")
manifest, error = load_prepare_manifest(manifest_path)
assert manifest is None
assert error is not None
def test_prepare_manifest_round_trip(tmp_path: Path) -> None:
manifest_path = prepare_manifest_path(
tmp_path,
platform="linux-x86_64",
environment="debian:12",
)
manifest = DailyLoopManifest(
environment="debian:12",
environment_version="1.0.0",
platform="linux-x86_64",
catalog_version="4.5.0",
bundle_version="bundle-1",
prepared_at=123.0,
network_prepared=True,
last_prepare_duration_ms=456,
)
manifest_path.parent.mkdir(parents=True, exist_ok=True)
manifest_path.write_text(json.dumps(manifest.to_payload()), encoding="utf-8")
loaded, error = load_prepare_manifest(manifest_path)
assert error is None
assert loaded == manifest
def test_load_prepare_manifest_rejects_non_object(tmp_path: Path) -> None:
manifest_path = prepare_manifest_path(
tmp_path,
platform="linux-x86_64",
environment="debian:12",
)
manifest_path.parent.mkdir(parents=True, exist_ok=True)
manifest_path.write_text('["not-an-object"]', encoding="utf-8")
manifest, error = load_prepare_manifest(manifest_path)
assert manifest is None
assert error == "prepare manifest is not a JSON object"
def test_load_prepare_manifest_rejects_invalid_payload(tmp_path: Path) -> None:
manifest_path = prepare_manifest_path(
tmp_path,
platform="linux-x86_64",
environment="debian:12",
)
manifest_path.parent.mkdir(parents=True, exist_ok=True)
manifest_path.write_text(json.dumps({"environment": "debian:12"}), encoding="utf-8")
manifest, error = load_prepare_manifest(manifest_path)
assert manifest is None
assert error is not None
assert "prepare manifest is invalid" in error
def test_evaluate_daily_loop_status_edge_cases() -> None:
manifest = DailyLoopManifest(
environment="debian:12",
environment_version="1.0.0",
platform="linux-x86_64",
catalog_version="4.5.0",
bundle_version="bundle-1",
prepared_at=1.0,
network_prepared=False,
last_prepare_duration_ms=2,
)
assert evaluate_daily_loop_status(
environment="debian:12",
environment_version="1.0.0",
platform="linux-x86_64",
catalog_version="4.5.0",
bundle_version="bundle-1",
installed=True,
manifest=manifest,
manifest_error="broken manifest",
) == ("stale", "broken manifest")
assert evaluate_daily_loop_status(
environment="debian:12",
environment_version="1.0.0",
platform="linux-x86_64",
catalog_version="4.5.0",
bundle_version="bundle-1",
installed=False,
manifest=manifest,
) == ("stale", "environment install is missing")
assert evaluate_daily_loop_status(
environment="debian:12-build",
environment_version="1.0.0",
platform="linux-x86_64",
catalog_version="4.5.0",
bundle_version="bundle-1",
installed=True,
manifest=manifest,
) == ("stale", "prepare manifest environment does not match the selected environment")
assert evaluate_daily_loop_status(
environment="debian:12",
environment_version="2.0.0",
platform="linux-x86_64",
catalog_version="4.5.0",
bundle_version="bundle-1",
installed=True,
manifest=manifest,
) == ("stale", "environment version changed since the last prepare run")
assert evaluate_daily_loop_status(
environment="debian:12",
environment_version="1.0.0",
platform="linux-aarch64",
catalog_version="4.5.0",
bundle_version="bundle-1",
installed=True,
manifest=manifest,
) == ("stale", "platform changed since the last prepare run")
assert evaluate_daily_loop_status(
environment="debian:12",
environment_version="1.0.0",
platform="linux-x86_64",
catalog_version="4.5.0",
bundle_version="bundle-2",
installed=True,
manifest=manifest,
) == ("stale", "runtime bundle version changed since the last prepare run")
def test_prepare_request_is_satisfied_network_gate() -> None:
manifest = DailyLoopManifest(
environment="debian:12",
environment_version="1.0.0",
platform="linux-x86_64",
catalog_version="4.5.0",
bundle_version="bundle-1",
prepared_at=1.0,
network_prepared=False,
last_prepare_duration_ms=2,
)
assert prepare_request_is_satisfied(None, require_network=False) is False
assert prepare_request_is_satisfied(manifest, require_network=True) is False
assert prepare_request_is_satisfied(manifest, require_network=False) is True

View file

@ -0,0 +1,138 @@
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from types import SimpleNamespace
import pytest
import pyro_mcp.daily_loop_smoke as smoke_module
class _FakePyro:
def __init__(self) -> None:
self.workspace_id = "ws-1"
self.message = "broken\n"
self.deleted = False
def create_workspace(
self,
*,
environment: str,
seed_path: Path,
name: str | None = None,
labels: dict[str, str] | None = None,
) -> dict[str, object]:
assert environment == "debian:12"
assert seed_path.is_dir()
assert name == "daily-loop"
assert labels == {"suite": "daily-loop-smoke"}
return {"workspace_id": self.workspace_id}
def exec_workspace(self, workspace_id: str, *, command: str) -> dict[str, object]:
assert workspace_id == self.workspace_id
if command != "sh check.sh":
raise AssertionError(f"unexpected command: {command}")
if self.message == "fixed\n":
return {"exit_code": 0, "stdout": "fixed\n"}
return {"exit_code": 1, "stderr": "expected fixed got broken\n"}
def apply_workspace_patch(self, workspace_id: str, *, patch: str) -> dict[str, object]:
assert workspace_id == self.workspace_id
assert "+fixed" in patch
self.message = "fixed\n"
return {"changed": True}
def export_workspace(
self,
workspace_id: str,
path: str,
*,
output_path: Path,
) -> dict[str, object]:
assert workspace_id == self.workspace_id
assert path == "message.txt"
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(self.message, encoding="utf-8")
return {"artifact_type": "file"}
def reset_workspace(self, workspace_id: str) -> dict[str, object]:
assert workspace_id == self.workspace_id
self.message = "broken\n"
return {"reset_count": 1}
def read_workspace_file(self, workspace_id: str, path: str) -> dict[str, object]:
assert workspace_id == self.workspace_id
assert path == "message.txt"
return {"content": self.message}
def delete_workspace(self, workspace_id: str) -> dict[str, object]:
assert workspace_id == self.workspace_id
self.deleted = True
return {"workspace_id": workspace_id, "deleted": True}
def test_run_prepare_parses_json(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
subprocess,
"run",
lambda *args, **kwargs: SimpleNamespace(
returncode=0,
stdout=json.dumps({"prepared": True}),
stderr="",
),
)
payload = smoke_module._run_prepare("debian:12")
assert payload == {"prepared": True}
def test_run_prepare_raises_on_failure(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
subprocess,
"run",
lambda *args, **kwargs: SimpleNamespace(
returncode=1,
stdout="",
stderr="prepare failed",
),
)
with pytest.raises(RuntimeError, match="prepare failed"):
smoke_module._run_prepare("debian:12")
def test_run_daily_loop_smoke_happy_path(monkeypatch: pytest.MonkeyPatch) -> None:
prepare_calls: list[str] = []
fake_pyro = _FakePyro()
def fake_run_prepare(environment: str) -> dict[str, object]:
prepare_calls.append(environment)
return {"prepared": True, "reused": len(prepare_calls) > 1}
monkeypatch.setattr(smoke_module, "_run_prepare", fake_run_prepare)
monkeypatch.setattr(smoke_module, "Pyro", lambda: fake_pyro)
smoke_module.run_daily_loop_smoke(environment="debian:12")
assert prepare_calls == ["debian:12", "debian:12"]
assert fake_pyro.deleted is True
def test_main_runs_selected_environment(
monkeypatch: pytest.MonkeyPatch,
) -> None:
observed: list[str] = []
monkeypatch.setattr(
smoke_module,
"run_daily_loop_smoke",
lambda *, environment: observed.append(environment),
)
monkeypatch.setattr(
smoke_module,
"build_arg_parser",
lambda: SimpleNamespace(
parse_args=lambda: SimpleNamespace(environment="debian:12-build")
),
)
smoke_module.main()
assert observed == ["debian:12-build"]

View file

@ -15,13 +15,18 @@ def test_doctor_main_prints_json(
) -> None:
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(platform="linux-x86_64")
return argparse.Namespace(platform="linux-x86_64", environment="debian:12")
monkeypatch.setattr(doctor_module, "_build_parser", lambda: StubParser())
monkeypatch.setattr(
doctor_module,
"doctor_report",
lambda platform: {"platform": platform, "runtime_ok": True, "issues": []},
lambda *, platform, environment: {
"platform": platform,
"environment": environment,
"runtime_ok": True,
"issues": [],
},
)
doctor_module.main()
output = json.loads(capsys.readouterr().out)
@ -32,3 +37,4 @@ def test_doctor_build_parser_defaults_platform() -> None:
parser = doctor_module._build_parser()
args = parser.parse_args([])
assert args.platform == DEFAULT_PLATFORM
assert args.environment == "debian:12"

501
tests/test_host_helpers.py Normal file
View file

@ -0,0 +1,501 @@
from __future__ import annotations
import json
import shutil
import sys
from pathlib import Path
from subprocess import CompletedProcess
import pytest
import pyro_mcp.host_helpers as host_helpers
from pyro_mcp.host_helpers import (
DEFAULT_OPENCODE_CONFIG_PATH,
HostServerConfig,
_canonical_server_command,
_command_matches,
_repair_command,
connect_cli_host,
doctor_hosts,
print_or_write_opencode_config,
repair_host,
)
def _install_fake_mcp_cli(tmp_path: Path, name: str) -> tuple[Path, Path]:
bin_dir = tmp_path / "bin"
bin_dir.mkdir(parents=True)
state_path = tmp_path / f"{name}-state.json"
script_path = bin_dir / name
script_path.write_text(
"\n".join(
[
f"#!{sys.executable}",
"import json",
"import shlex",
"import sys",
f"STATE_PATH = {str(state_path)!r}",
"try:",
" with open(STATE_PATH, 'r', encoding='utf-8') as handle:",
" state = json.load(handle)",
"except FileNotFoundError:",
" state = {}",
"args = sys.argv[1:]",
"if args[:2] == ['mcp', 'add']:",
" name = args[2]",
" marker = args.index('--')",
" state[name] = args[marker + 1:]",
" with open(STATE_PATH, 'w', encoding='utf-8') as handle:",
" json.dump(state, handle)",
" print(f'added {name}')",
" raise SystemExit(0)",
"if args[:2] == ['mcp', 'remove']:",
" name = args[2]",
" if name in state:",
" del state[name]",
" with open(STATE_PATH, 'w', encoding='utf-8') as handle:",
" json.dump(state, handle)",
" print(f'removed {name}')",
" raise SystemExit(0)",
" print('not found', file=sys.stderr)",
" raise SystemExit(1)",
"if args[:2] == ['mcp', 'get']:",
" name = args[2]",
" if name not in state:",
" print('not found', file=sys.stderr)",
" raise SystemExit(1)",
" print(f'{name}: {shlex.join(state[name])}')",
" raise SystemExit(0)",
"if args[:2] == ['mcp', 'list']:",
" for item in sorted(state):",
" print(item)",
" raise SystemExit(0)",
"print('unsupported', file=sys.stderr)",
"raise SystemExit(2)",
]
),
encoding="utf-8",
)
script_path.chmod(0o755)
return bin_dir, state_path
def test_connect_cli_host_replaces_existing_entry(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
bin_dir, state_path = _install_fake_mcp_cli(tmp_path, "codex")
state_path.write_text(json.dumps({"pyro": ["old", "command"]}), encoding="utf-8")
monkeypatch.setenv("PATH", str(bin_dir))
payload = connect_cli_host("codex", config=HostServerConfig())
assert payload["host"] == "codex"
assert payload["server_command"] == ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]
assert json.loads(state_path.read_text(encoding="utf-8")) == {
"pyro": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]
}
def test_canonical_server_command_validates_and_renders_variants() -> None:
assert _canonical_server_command(HostServerConfig(installed_package=True)) == [
"pyro",
"mcp",
"serve",
]
assert _canonical_server_command(
HostServerConfig(profile="workspace-full", project_path="/repo")
) == [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
"--profile",
"workspace-full",
"--project-path",
"/repo",
]
assert _canonical_server_command(
HostServerConfig(repo_url="https://example.com/repo.git", repo_ref="main")
) == [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
"--repo-url",
"https://example.com/repo.git",
"--repo-ref",
"main",
]
assert _canonical_server_command(HostServerConfig(mode="repro-fix")) == [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
"--mode",
"repro-fix",
]
assert _canonical_server_command(HostServerConfig(no_project_source=True)) == [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
"--no-project-source",
]
with pytest.raises(ValueError, match="mutually exclusive"):
_canonical_server_command(
HostServerConfig(project_path="/repo", repo_url="https://example.com/repo.git")
)
with pytest.raises(ValueError, match="cannot be combined"):
_canonical_server_command(HostServerConfig(project_path="/repo", no_project_source=True))
with pytest.raises(ValueError, match="requires --repo-url"):
_canonical_server_command(HostServerConfig(repo_ref="main"))
with pytest.raises(ValueError, match="mutually exclusive"):
_canonical_server_command(
HostServerConfig(profile="workspace-full", mode="repro-fix")
)
def test_repair_command_and_command_matches_cover_edge_cases() -> None:
assert _repair_command("codex", HostServerConfig()) == "pyro host repair codex"
assert _repair_command("codex", HostServerConfig(project_path="/repo")) == (
"pyro host repair codex --project-path /repo"
)
assert _repair_command(
"opencode",
HostServerConfig(installed_package=True, profile="workspace-full", repo_url="file:///repo"),
config_path=Path("/tmp/opencode.json"),
) == (
"pyro host repair opencode --installed-package --profile workspace-full "
"--repo-url file:///repo --config-path /tmp/opencode.json"
)
assert _repair_command("codex", HostServerConfig(no_project_source=True)) == (
"pyro host repair codex --no-project-source"
)
assert _repair_command("codex", HostServerConfig(mode="inspect")) == (
"pyro host repair codex --mode inspect"
)
assert _command_matches(
"pyro: uvx --from pyro-mcp pyro mcp serve",
["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"],
)
assert _command_matches(
'"uvx --from pyro-mcp pyro mcp serve',
['"uvx', "--from", "pyro-mcp", "pyro", "mcp", "serve"],
)
assert not _command_matches("pyro: uvx --from pyro-mcp pyro mcp serve --profile vm-run", [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
])
def test_connect_cli_host_reports_missing_cli_and_add_failure(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
with pytest.raises(ValueError, match="unsupported CLI host"):
connect_cli_host("unsupported", config=HostServerConfig())
monkeypatch.setenv("PATH", "")
with pytest.raises(RuntimeError, match="codex CLI is not installed"):
connect_cli_host("codex", config=HostServerConfig())
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
script_path = bin_dir / "codex"
script_path.write_text(
"\n".join(
[
f"#!{sys.executable}",
"import sys",
"raise SystemExit(1 if sys.argv[1:3] == ['mcp', 'add'] else 0)",
]
),
encoding="utf-8",
)
script_path.chmod(0o755)
monkeypatch.setenv("PATH", str(bin_dir))
with pytest.raises(RuntimeError, match="codex mcp add failed"):
connect_cli_host("codex", config=HostServerConfig())
def test_doctor_hosts_reports_ok_and_drifted(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
codex_bin, codex_state = _install_fake_mcp_cli(tmp_path / "codex", "codex")
claude_bin, claude_state = _install_fake_mcp_cli(tmp_path / "claude", "claude")
combined_path = str(codex_bin) + ":" + str(claude_bin)
monkeypatch.setenv("PATH", combined_path)
codex_state.write_text(
json.dumps({"pyro": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]}),
encoding="utf-8",
)
claude_state.write_text(
json.dumps(
{
"pyro": [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
"--profile",
"workspace-full",
]
}
),
encoding="utf-8",
)
opencode_config = tmp_path / "opencode.json"
opencode_config.write_text(
json.dumps(
{
"mcp": {
"pyro": {
"type": "local",
"enabled": True,
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"],
}
}
}
),
encoding="utf-8",
)
entries = doctor_hosts(config=HostServerConfig(), config_path=opencode_config)
by_host = {entry.host: entry for entry in entries}
assert by_host["codex"].status == "ok"
assert by_host["codex"].configured is True
assert by_host["claude-code"].status == "drifted"
assert by_host["claude-code"].configured is True
assert by_host["opencode"].status == "ok"
assert by_host["opencode"].configured is True
def test_doctor_hosts_reports_missing_and_drifted_opencode_shapes(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setenv("PATH", "")
config_path = tmp_path / "opencode.json"
config_path.write_text("[]", encoding="utf-8")
entries = doctor_hosts(config=HostServerConfig(), config_path=config_path)
by_host = {entry.host: entry for entry in entries}
assert by_host["opencode"].status == "unavailable"
assert "JSON object" in by_host["opencode"].details
config_path.write_text(json.dumps({"mcp": {}}), encoding="utf-8")
entries = doctor_hosts(config=HostServerConfig(), config_path=config_path)
by_host = {entry.host: entry for entry in entries}
assert by_host["opencode"].status == "unavailable"
assert "missing mcp.pyro" in by_host["opencode"].details
config_path.write_text(
json.dumps({"mcp": {"pyro": {"type": "local", "enabled": False, "command": ["wrong"]}}}),
encoding="utf-8",
)
entries = doctor_hosts(config=HostServerConfig(), config_path=config_path)
by_host = {entry.host: entry for entry in entries}
assert by_host["opencode"].status == "drifted"
assert by_host["opencode"].configured is True
def test_doctor_hosts_reports_invalid_json_for_installed_opencode(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config_path = tmp_path / "opencode.json"
config_path.write_text("{invalid", encoding="utf-8")
monkeypatch.setattr(
shutil,
"which",
lambda name: "/usr/bin/opencode" if name == "opencode" else None,
)
entries = doctor_hosts(config=HostServerConfig(), config_path=config_path)
by_host = {entry.host: entry for entry in entries}
assert by_host["opencode"].status == "drifted"
assert "invalid JSON" in by_host["opencode"].details
def test_repair_opencode_preserves_unrelated_keys(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setenv("PATH", "")
config_path = tmp_path / "opencode.json"
config_path.write_text(
json.dumps({"theme": "light", "mcp": {"other": {"type": "local"}}}),
encoding="utf-8",
)
payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path)
assert payload["config_path"] == str(config_path.resolve())
repaired = json.loads(config_path.read_text(encoding="utf-8"))
assert repaired["theme"] == "light"
assert repaired["mcp"]["other"] == {"type": "local"}
assert repaired["mcp"]["pyro"] == {
"type": "local",
"enabled": True,
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"],
}
def test_repair_opencode_backs_up_non_object_json(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setenv("PATH", "")
config_path = tmp_path / "opencode.json"
config_path.write_text("[]", encoding="utf-8")
payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path)
backup_path = Path(str(payload["backup_path"]))
assert backup_path.exists()
assert backup_path.read_text(encoding="utf-8") == "[]"
def test_repair_opencode_backs_up_invalid_json(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setenv("PATH", "")
config_path = tmp_path / "opencode.json"
config_path.write_text("{invalid", encoding="utf-8")
payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path)
backup_path = Path(str(payload["backup_path"]))
assert backup_path.exists()
assert backup_path.read_text(encoding="utf-8") == "{invalid"
repaired = json.loads(config_path.read_text(encoding="utf-8"))
assert repaired["mcp"]["pyro"]["command"] == [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
]
def test_print_or_write_opencode_config_writes_json(tmp_path: Path) -> None:
output_path = tmp_path / "opencode.json"
payload = print_or_write_opencode_config(
config=HostServerConfig(project_path="/repo"),
output_path=output_path,
)
assert payload["output_path"] == str(output_path)
rendered = json.loads(output_path.read_text(encoding="utf-8"))
assert rendered == {
"mcp": {
"pyro": {
"type": "local",
"enabled": True,
"command": [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
"--project-path",
"/repo",
],
}
}
}
def test_print_or_write_opencode_config_returns_rendered_text() -> None:
payload = print_or_write_opencode_config(config=HostServerConfig(profile="vm-run"))
assert payload["host"] == "opencode"
assert payload["server_command"] == [
"uvx",
"--from",
"pyro-mcp",
"pyro",
"mcp",
"serve",
"--profile",
"vm-run",
]
rendered = str(payload["rendered_config"])
assert '"type": "local"' in rendered
assert '"command": [' in rendered
def test_doctor_reports_opencode_missing_when_config_absent(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setenv("PATH", "")
entries = doctor_hosts(
config=HostServerConfig(),
config_path=tmp_path / "missing-opencode.json",
)
by_host = {entry.host: entry for entry in entries}
assert by_host["opencode"].status == "unavailable"
assert str(DEFAULT_OPENCODE_CONFIG_PATH) not in by_host["opencode"].details
def test_repair_host_delegates_non_opencode_and_doctor_handles_list_only_configured(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
host_helpers,
"connect_cli_host",
lambda host, *, config: {"host": host, "profile": config.profile},
)
assert repair_host("codex", config=HostServerConfig(profile="vm-run")) == {
"host": "codex",
"profile": "vm-run",
}
commands: list[list[str]] = []
def _fake_run_command(command: list[str]) -> CompletedProcess[str]:
commands.append(command)
if command[:3] == ["codex", "mcp", "get"]:
return CompletedProcess(command, 1, "", "not found")
if command[:3] == ["codex", "mcp", "list"]:
return CompletedProcess(command, 0, "pyro\n", "")
raise AssertionError(command)
monkeypatch.setattr(
shutil,
"which",
lambda name: "/usr/bin/codex" if name == "codex" else None,
)
monkeypatch.setattr(host_helpers, "_run_command", _fake_run_command)
entry = host_helpers._doctor_cli_host("codex", config=HostServerConfig())
assert entry.status == "drifted"
assert entry.configured is True
assert commands == [["codex", "mcp", "get", "pyro"], ["codex", "mcp", "list"]]

View file

@ -0,0 +1,236 @@
from __future__ import annotations
import subprocess
from pathlib import Path
import pytest
import pyro_mcp.project_startup as project_startup
from pyro_mcp.project_startup import (
ProjectStartupSource,
describe_project_startup_source,
materialize_project_startup_source,
resolve_project_startup_source,
)
def _git(repo: Path, *args: str) -> str:
result = subprocess.run( # noqa: S603
["git", "-c", "commit.gpgsign=false", *args],
cwd=repo,
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()
def _make_repo(root: Path, *, filename: str = "note.txt", content: str = "hello\n") -> Path:
root.mkdir()
_git(root, "init")
_git(root, "config", "user.name", "Pyro Tests")
_git(root, "config", "user.email", "pyro-tests@example.com")
(root / filename).write_text(content, encoding="utf-8")
_git(root, "add", filename)
_git(root, "commit", "-m", "init")
return root
def test_resolve_project_startup_source_detects_nearest_git_root(tmp_path: Path) -> None:
repo = _make_repo(tmp_path / "repo")
nested = repo / "src" / "pkg"
nested.mkdir(parents=True)
resolved = resolve_project_startup_source(cwd=nested)
assert resolved == ProjectStartupSource(
kind="project_path",
origin_ref=str(repo.resolve()),
resolved_path=repo.resolve(),
)
def test_resolve_project_startup_source_project_path_prefers_git_root(tmp_path: Path) -> None:
repo = _make_repo(tmp_path / "repo")
nested = repo / "nested"
nested.mkdir()
resolved = resolve_project_startup_source(project_path=nested, cwd=tmp_path)
assert resolved == ProjectStartupSource(
kind="project_path",
origin_ref=str(repo.resolve()),
resolved_path=repo.resolve(),
)
def test_resolve_project_startup_source_validates_flag_combinations(tmp_path: Path) -> None:
repo = _make_repo(tmp_path / "repo")
with pytest.raises(ValueError, match="mutually exclusive"):
resolve_project_startup_source(project_path=repo, repo_url="https://example.com/repo.git")
with pytest.raises(ValueError, match="requires --repo-url"):
resolve_project_startup_source(repo_ref="main")
with pytest.raises(ValueError, match="cannot be combined"):
resolve_project_startup_source(project_path=repo, no_project_source=True)
def test_resolve_project_startup_source_handles_explicit_none_and_empty_values(
tmp_path: Path,
) -> None:
repo = _make_repo(tmp_path / "repo")
outside = tmp_path / "outside"
outside.mkdir()
assert resolve_project_startup_source(no_project_source=True, cwd=tmp_path) is None
assert resolve_project_startup_source(cwd=outside) is None
with pytest.raises(ValueError, match="must not be empty"):
resolve_project_startup_source(repo_url=" ", cwd=repo)
with pytest.raises(ValueError, match="must not be empty"):
resolve_project_startup_source(repo_url="https://example.com/repo.git", repo_ref=" ")
def test_resolve_project_startup_source_rejects_missing_or_non_directory_project_path(
tmp_path: Path,
) -> None:
missing = tmp_path / "missing"
file_path = tmp_path / "note.txt"
file_path.write_text("hello\n", encoding="utf-8")
with pytest.raises(ValueError, match="does not exist"):
resolve_project_startup_source(project_path=missing, cwd=tmp_path)
with pytest.raises(ValueError, match="must be a directory"):
resolve_project_startup_source(project_path=file_path, cwd=tmp_path)
def test_resolve_project_startup_source_keeps_plain_relative_directory_when_not_a_repo(
tmp_path: Path,
) -> None:
plain = tmp_path / "plain"
plain.mkdir()
resolved = resolve_project_startup_source(project_path="plain", cwd=tmp_path)
assert resolved == ProjectStartupSource(
kind="project_path",
origin_ref=str(plain.resolve()),
resolved_path=plain.resolve(),
)
def test_materialize_project_startup_source_clones_local_repo_url_at_ref(tmp_path: Path) -> None:
repo = _make_repo(tmp_path / "repo", content="one\n")
first_commit = _git(repo, "rev-parse", "HEAD")
(repo / "note.txt").write_text("two\n", encoding="utf-8")
_git(repo, "add", "note.txt")
_git(repo, "commit", "-m", "update")
source = ProjectStartupSource(
kind="repo_url",
origin_ref=str(repo.resolve()),
repo_ref=first_commit,
)
with materialize_project_startup_source(source) as clone_dir:
assert (clone_dir / "note.txt").read_text(encoding="utf-8") == "one\n"
def test_materialize_project_startup_source_validates_project_source_and_clone_failures(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
with pytest.raises(RuntimeError, match="missing a resolved path"):
with materialize_project_startup_source(
ProjectStartupSource(kind="project_path", origin_ref="/repo", resolved_path=None)
):
pass
source = ProjectStartupSource(kind="repo_url", origin_ref="https://example.com/repo.git")
def _clone_failure(
command: list[str],
*,
cwd: Path | None = None,
) -> subprocess.CompletedProcess[str]:
del cwd
return subprocess.CompletedProcess(command, 1, "", "clone failed")
monkeypatch.setattr("pyro_mcp.project_startup._run_git", _clone_failure)
with pytest.raises(RuntimeError, match="failed to clone repo_url"):
with materialize_project_startup_source(source):
pass
def test_materialize_project_startup_source_reports_checkout_failure(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repo = _make_repo(tmp_path / "repo", content="one\n")
source = ProjectStartupSource(
kind="repo_url",
origin_ref=str(repo.resolve()),
repo_ref="missing-ref",
)
original_run_git = project_startup._run_git
def _checkout_failure(
command: list[str],
*,
cwd: Path | None = None,
) -> subprocess.CompletedProcess[str]:
if command[:2] == ["git", "checkout"]:
return subprocess.CompletedProcess(command, 1, "", "checkout failed")
return original_run_git(command, cwd=cwd)
monkeypatch.setattr("pyro_mcp.project_startup._run_git", _checkout_failure)
with pytest.raises(RuntimeError, match="failed to checkout repo_ref"):
with materialize_project_startup_source(source):
pass
def test_describe_project_startup_source_formats_project_and_repo_sources(tmp_path: Path) -> None:
repo = _make_repo(tmp_path / "repo")
project_description = describe_project_startup_source(
ProjectStartupSource(
kind="project_path",
origin_ref=str(repo.resolve()),
resolved_path=repo.resolve(),
)
)
repo_description = describe_project_startup_source(
ProjectStartupSource(
kind="repo_url",
origin_ref="https://example.com/repo.git",
repo_ref="main",
)
)
assert project_description == f"the current project at {repo.resolve()}"
assert repo_description == "the clean clone source https://example.com/repo.git at ref main"
def test_describe_project_startup_source_handles_none_and_repo_without_ref() -> None:
assert describe_project_startup_source(None) is None
assert (
describe_project_startup_source(
ProjectStartupSource(kind="repo_url", origin_ref="https://example.com/repo.git")
)
== "the clean clone source https://example.com/repo.git"
)
def test_detect_git_root_returns_none_for_empty_stdout(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
project_startup,
"_run_git",
lambda command, *, cwd=None: subprocess.CompletedProcess(command, 0, "\n", ""),
)
assert project_startup._detect_git_root(Path.cwd()) is None

View file

@ -15,9 +15,16 @@ from pyro_mcp.cli import _build_parser
from pyro_mcp.contract import (
PUBLIC_CLI_COMMANDS,
PUBLIC_CLI_DEMO_SUBCOMMANDS,
PUBLIC_CLI_DOCTOR_FLAGS,
PUBLIC_CLI_ENV_SUBCOMMANDS,
PUBLIC_CLI_HOST_CONNECT_FLAGS,
PUBLIC_CLI_HOST_DOCTOR_FLAGS,
PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS,
PUBLIC_CLI_HOST_REPAIR_FLAGS,
PUBLIC_CLI_HOST_SUBCOMMANDS,
PUBLIC_CLI_MCP_SERVE_FLAGS,
PUBLIC_CLI_MCP_SUBCOMMANDS,
PUBLIC_CLI_PREPARE_FLAGS,
PUBLIC_CLI_RUN_FLAGS,
PUBLIC_CLI_WORKSPACE_CREATE_FLAGS,
PUBLIC_CLI_WORKSPACE_DIFF_FLAGS,
@ -53,10 +60,16 @@ from pyro_mcp.contract import (
PUBLIC_CLI_WORKSPACE_START_FLAGS,
PUBLIC_CLI_WORKSPACE_STOP_FLAGS,
PUBLIC_CLI_WORKSPACE_SUBCOMMANDS,
PUBLIC_CLI_WORKSPACE_SUMMARY_FLAGS,
PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS,
PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS,
PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS,
PUBLIC_MCP_COLD_START_MODE_TOOLS,
PUBLIC_MCP_INSPECT_MODE_TOOLS,
PUBLIC_MCP_MODES,
PUBLIC_MCP_PROFILES,
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS,
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
PUBLIC_SDK_METHODS,
)
@ -102,6 +115,35 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
env_help_text = _subparser_choice(parser, "env").format_help()
for subcommand_name in PUBLIC_CLI_ENV_SUBCOMMANDS:
assert subcommand_name in env_help_text
prepare_help_text = _subparser_choice(parser, "prepare").format_help()
for flag in PUBLIC_CLI_PREPARE_FLAGS:
assert flag in prepare_help_text
host_help_text = _subparser_choice(parser, "host").format_help()
for subcommand_name in PUBLIC_CLI_HOST_SUBCOMMANDS:
assert subcommand_name in host_help_text
host_connect_help_text = _subparser_choice(
_subparser_choice(parser, "host"), "connect"
).format_help()
for flag in PUBLIC_CLI_HOST_CONNECT_FLAGS:
assert flag in host_connect_help_text
host_doctor_help_text = _subparser_choice(
_subparser_choice(parser, "host"), "doctor"
).format_help()
for flag in PUBLIC_CLI_HOST_DOCTOR_FLAGS:
assert flag in host_doctor_help_text
host_print_config_help_text = _subparser_choice(
_subparser_choice(parser, "host"), "print-config"
).format_help()
for flag in PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS:
assert flag in host_print_config_help_text
host_repair_help_text = _subparser_choice(
_subparser_choice(parser, "host"), "repair"
).format_help()
for flag in PUBLIC_CLI_HOST_REPAIR_FLAGS:
assert flag in host_repair_help_text
doctor_help_text = _subparser_choice(parser, "doctor").format_help()
for flag in PUBLIC_CLI_DOCTOR_FLAGS:
assert flag in doctor_help_text
mcp_help_text = _subparser_choice(parser, "mcp").format_help()
for subcommand_name in PUBLIC_CLI_MCP_SUBCOMMANDS:
assert subcommand_name in mcp_help_text
@ -110,6 +152,8 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
assert flag in mcp_serve_help_text
for profile_name in PUBLIC_MCP_PROFILES:
assert profile_name in mcp_serve_help_text
for mode_name in PUBLIC_MCP_MODES:
assert mode_name in mcp_serve_help_text
workspace_help_text = _subparser_choice(parser, "workspace").format_help()
for subcommand_name in PUBLIC_CLI_WORKSPACE_SUBCOMMANDS:
@ -244,6 +288,11 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
).format_help()
for flag in PUBLIC_CLI_WORKSPACE_STOP_FLAGS:
assert flag in workspace_stop_help_text
workspace_summary_help_text = _subparser_choice(
_subparser_choice(parser, "workspace"), "summary"
).format_help()
for flag in PUBLIC_CLI_WORKSPACE_SUMMARY_FLAGS:
assert flag in workspace_summary_help_text
workspace_shell_help_text = _subparser_choice(
_subparser_choice(parser, "workspace"),
"shell",
@ -338,6 +387,14 @@ def test_public_mcp_tools_match_contract(tmp_path: Path) -> None:
assert tool_names == tuple(sorted(PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS))
def test_public_mcp_modes_are_declared_and_non_empty() -> None:
assert PUBLIC_MCP_MODES == ("repro-fix", "inspect", "cold-start", "review-eval")
assert PUBLIC_MCP_REPRO_FIX_MODE_TOOLS
assert PUBLIC_MCP_INSPECT_MODE_TOOLS
assert PUBLIC_MCP_COLD_START_MODE_TOOLS
assert PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS
def test_pyproject_exposes_single_public_cli_script() -> None:
pyproject = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
scripts = pyproject["project"]["scripts"]

View file

@ -6,7 +6,32 @@ from pathlib import Path
import pytest
from pyro_mcp.daily_loop import DailyLoopManifest, prepare_manifest_path, write_prepare_manifest
from pyro_mcp.runtime import doctor_report, resolve_runtime_paths, runtime_capabilities
from pyro_mcp.vm_environments import EnvironmentStore, get_environment
def _materialize_installed_environment(
environment_store: EnvironmentStore,
*,
name: str,
) -> None:
spec = get_environment(name, runtime_paths=environment_store._runtime_paths)
install_dir = environment_store._install_dir(spec)
install_dir.mkdir(parents=True, exist_ok=True)
(install_dir / "vmlinux").write_text("kernel\n", encoding="utf-8")
(install_dir / "rootfs.ext4").write_text("rootfs\n", encoding="utf-8")
(install_dir / "environment.json").write_text(
json.dumps(
{
"name": spec.name,
"version": spec.version,
"source": "test-cache",
"source_digest": spec.source_digest,
}
),
encoding="utf-8",
)
def test_resolve_runtime_paths_default_bundle() -> None:
@ -109,6 +134,7 @@ def test_doctor_report_has_runtime_fields() -> None:
assert "runtime_ok" in report
assert "kvm" in report
assert "networking" in report
assert "daily_loop" in report
if report["runtime_ok"]:
runtime = report.get("runtime")
assert isinstance(runtime, dict)
@ -122,6 +148,61 @@ def test_doctor_report_has_runtime_fields() -> None:
assert "tun_available" in networking
def test_doctor_report_daily_loop_statuses(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setenv("PYRO_ENVIRONMENT_CACHE_DIR", str(tmp_path))
cold_report = doctor_report(environment="debian:12")
cold_daily_loop = cold_report["daily_loop"]
assert cold_daily_loop["status"] == "cold"
assert cold_daily_loop["installed"] is False
paths = resolve_runtime_paths()
environment_store = EnvironmentStore(runtime_paths=paths, cache_dir=tmp_path)
_materialize_installed_environment(environment_store, name="debian:12")
installed_report = doctor_report(environment="debian:12")
installed_daily_loop = installed_report["daily_loop"]
assert installed_daily_loop["status"] == "cold"
assert installed_daily_loop["installed"] is True
manifest_path = prepare_manifest_path(
tmp_path,
platform="linux-x86_64",
environment="debian:12",
)
write_prepare_manifest(
manifest_path,
DailyLoopManifest(
environment="debian:12",
environment_version="1.0.0",
platform="linux-x86_64",
catalog_version=environment_store.catalog_version,
bundle_version=(
None
if paths.manifest.get("bundle_version") is None
else str(paths.manifest.get("bundle_version"))
),
prepared_at=123.0,
network_prepared=True,
last_prepare_duration_ms=456,
),
)
warm_report = doctor_report(environment="debian:12")
warm_daily_loop = warm_report["daily_loop"]
assert warm_daily_loop["status"] == "warm"
assert warm_daily_loop["network_prepared"] is True
stale_manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
stale_manifest["catalog_version"] = "0.0.0"
manifest_path.write_text(json.dumps(stale_manifest), encoding="utf-8")
stale_report = doctor_report(environment="debian:12")
stale_daily_loop = stale_report["daily_loop"]
assert stale_daily_loop["status"] == "stale"
assert "catalog version changed" in str(stale_daily_loop["reason"])
def test_runtime_capabilities_reports_real_bundle_flags() -> None:
paths = resolve_runtime_paths()
capabilities = runtime_capabilities(paths)

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import subprocess
from pathlib import Path
from typing import Any, cast
@ -8,6 +9,8 @@ import pytest
import pyro_mcp.server as server_module
from pyro_mcp.contract import (
PUBLIC_MCP_COLD_START_MODE_TOOLS,
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS,
PUBLIC_MCP_VM_RUN_PROFILE_TOOLS,
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS,
)
@ -16,6 +19,28 @@ from pyro_mcp.vm_manager import VmManager
from pyro_mcp.vm_network import TapNetworkManager
def _git(repo: Path, *args: str) -> str:
result = subprocess.run( # noqa: S603
["git", "-c", "commit.gpgsign=false", *args],
cwd=repo,
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()
def _make_repo(root: Path, *, content: str = "hello\n") -> Path:
root.mkdir()
_git(root, "init")
_git(root, "config", "user.name", "Pyro Tests")
_git(root, "config", "user.email", "pyro-tests@example.com")
(root / "note.txt").write_text(content, encoding="utf-8")
_git(root, "add", "note.txt")
_git(root, "commit", "-m", "init")
return root
def test_create_server_registers_vm_tools(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
@ -62,6 +87,151 @@ def test_create_server_workspace_core_profile_registers_expected_tools(tmp_path:
assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS))
def test_create_server_repro_fix_mode_registers_expected_tools(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
async def _run() -> list[str]:
server = create_server(manager=manager, mode="repro-fix")
tools = await server.list_tools()
return sorted(tool.name for tool in tools)
assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_REPRO_FIX_MODE_TOOLS))
def test_create_server_cold_start_mode_registers_expected_tools(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
async def _run() -> list[str]:
server = create_server(manager=manager, mode="cold-start")
tools = await server.list_tools()
return sorted(tool.name for tool in tools)
assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_COLD_START_MODE_TOOLS))
def test_create_server_workspace_create_description_mentions_project_source(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
repo = _make_repo(tmp_path / "repo")
async def _run() -> dict[str, Any]:
server = create_server(manager=manager, project_path=repo)
tools = await server.list_tools()
tool_map = {tool.name: tool.model_dump() for tool in tools}
return tool_map["workspace_create"]
workspace_create = asyncio.run(_run())
description = cast(str, workspace_create["description"])
assert "If `seed_path` is omitted" in description
assert str(repo.resolve()) in description
def test_create_server_project_path_seeds_workspace_when_seed_path_is_omitted(
tmp_path: Path,
) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
repo = _make_repo(tmp_path / "repo", content="project-aware\n")
def _extract_structured(raw_result: object) -> dict[str, Any]:
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
raise TypeError("unexpected call_tool result shape")
_, structured = raw_result
if not isinstance(structured, dict):
raise TypeError("expected structured dictionary result")
return cast(dict[str, Any], structured)
async def _run() -> tuple[dict[str, Any], dict[str, Any]]:
server = create_server(manager=manager, project_path=repo)
created = _extract_structured(
await server.call_tool(
"workspace_create",
{
"environment": "debian:12-base",
"allow_host_compat": True,
},
)
)
executed = _extract_structured(
await server.call_tool(
"workspace_exec",
{
"workspace_id": created["workspace_id"],
"command": "cat note.txt",
},
)
)
return created, executed
created, executed = asyncio.run(_run())
assert created["workspace_seed"]["mode"] == "directory"
assert created["workspace_seed"]["seed_path"] == str(repo.resolve())
assert created["workspace_seed"]["origin_kind"] == "project_path"
assert created["workspace_seed"]["origin_ref"] == str(repo.resolve())
assert executed["stdout"] == "project-aware\n"
def test_create_server_repo_url_seeds_workspace_when_seed_path_is_omitted(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
repo = _make_repo(tmp_path / "repo", content="committed\n")
(repo / "note.txt").write_text("dirty\n", encoding="utf-8")
def _extract_structured(raw_result: object) -> dict[str, Any]:
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
raise TypeError("unexpected call_tool result shape")
_, structured = raw_result
if not isinstance(structured, dict):
raise TypeError("expected structured dictionary result")
return cast(dict[str, Any], structured)
async def _run() -> tuple[dict[str, Any], dict[str, Any]]:
server = create_server(manager=manager, repo_url=str(repo.resolve()))
created = _extract_structured(
await server.call_tool(
"workspace_create",
{
"environment": "debian:12-base",
"allow_host_compat": True,
},
)
)
executed = _extract_structured(
await server.call_tool(
"workspace_exec",
{
"workspace_id": created["workspace_id"],
"command": "cat note.txt",
},
)
)
return created, executed
created, executed = asyncio.run(_run())
assert created["workspace_seed"]["mode"] == "directory"
assert created["workspace_seed"]["seed_path"] is None
assert created["workspace_seed"]["origin_kind"] == "repo_url"
assert created["workspace_seed"]["origin_ref"] == str(repo.resolve())
assert executed["stdout"] == "committed\n"
def test_vm_run_round_trip(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
@ -464,6 +634,9 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
},
)
)
summary = _extract_structured(
await server.call_tool("workspace_summary", {"workspace_id": workspace_id})
)
reset = _extract_structured(
await server.call_tool(
"workspace_reset",
@ -501,6 +674,7 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
service_status,
service_logs,
service_stopped,
summary,
reset,
deleted_snapshot,
logs,
@ -526,6 +700,7 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
service_status,
service_logs,
service_stopped,
summary,
reset,
deleted_snapshot,
logs,
@ -562,6 +737,10 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
assert service_logs["stderr"].count("[REDACTED]") >= 1
assert service_logs["tail_lines"] is None
assert service_stopped["state"] == "stopped"
assert summary["workspace_id"] == created["workspace_id"]
assert summary["commands"]["total"] >= 1
assert summary["changes"]["available"] is True
assert summary["artifacts"]["exports"][0]["workspace_path"] == "/workspace/subdir/more.txt"
assert reset["workspace_reset"]["snapshot_name"] == "checkpoint"
assert reset["secrets"] == created["secrets"]
assert reset["command_count"] == 0

View file

@ -699,6 +699,124 @@ def test_workspace_diff_and_export_round_trip(tmp_path: Path) -> None:
assert logs["count"] == 0
def test_workspace_summary_synthesizes_current_session(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 / "more.txt").write_text("more\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,
name="review-eval",
labels={"suite": "smoke"},
)["workspace_id"]
)
manager.push_workspace_sync(workspace_id, source_path=update_dir)
manager.write_workspace_file(workspace_id, "src/app.py", text="print('hello')\n")
manager.apply_workspace_patch(
workspace_id,
patch=(
"--- a/note.txt\n"
"+++ b/note.txt\n"
"@@ -1 +1 @@\n"
"-hello\n"
"+patched\n"
),
)
manager.exec_workspace(workspace_id, command="cat note.txt", timeout_seconds=30)
manager.create_snapshot(workspace_id, "checkpoint")
export_path = tmp_path / "exported-note.txt"
manager.export_workspace(workspace_id, "note.txt", output_path=export_path)
manager.start_service(
workspace_id,
"app",
command='sh -lc \'trap "exit 0" TERM; touch .ready; while true; do sleep 60; done\'',
readiness={"type": "file", "path": ".ready"},
)
manager.stop_service(workspace_id, "app")
summary = manager.summarize_workspace(workspace_id)
assert summary["workspace_id"] == workspace_id
assert summary["name"] == "review-eval"
assert summary["labels"] == {"suite": "smoke"}
assert summary["outcome"]["command_count"] == 1
assert summary["outcome"]["export_count"] == 1
assert summary["outcome"]["snapshot_count"] == 1
assert summary["commands"]["total"] == 1
assert summary["commands"]["recent"][0]["command"] == "cat note.txt"
assert [event["event_kind"] for event in summary["edits"]["recent"]] == [
"patch_apply",
"file_write",
"sync_push",
]
assert summary["changes"]["available"] is True
assert summary["changes"]["changed"] is True
assert summary["changes"]["summary"]["total"] == 4
assert summary["services"]["current"][0]["service_name"] == "app"
assert [event["event_kind"] for event in summary["services"]["recent"]] == [
"service_stop",
"service_start",
]
assert summary["artifacts"]["exports"][0]["workspace_path"] == "/workspace/note.txt"
assert summary["snapshots"]["named_count"] == 1
assert summary["snapshots"]["recent"][0]["snapshot_name"] == "checkpoint"
def test_workspace_summary_degrades_gracefully_for_stopped_and_legacy_workspaces(
tmp_path: Path,
) -> None:
seed_dir = tmp_path / "seed"
seed_dir.mkdir()
(seed_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),
)
stopped_workspace_id = str(
manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
seed_path=seed_dir,
)["workspace_id"]
)
manager.exec_workspace(stopped_workspace_id, command="cat note.txt", timeout_seconds=30)
manager.stop_workspace(stopped_workspace_id)
stopped_summary = manager.summarize_workspace(stopped_workspace_id)
assert stopped_summary["commands"]["total"] == 1
assert stopped_summary["changes"]["available"] is False
assert "must be in 'started' state" in str(stopped_summary["changes"]["reason"])
legacy_workspace_id = str(
manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
seed_path=seed_dir,
)["workspace_id"]
)
baseline_path = (
tmp_path / "vms" / "workspaces" / legacy_workspace_id / "baseline" / "workspace.tar"
)
baseline_path.unlink()
legacy_summary = manager.summarize_workspace(legacy_workspace_id)
assert legacy_summary["changes"]["available"] is False
assert "baseline snapshot" in str(legacy_summary["changes"]["reason"])
def test_workspace_file_ops_and_patch_round_trip(tmp_path: Path) -> None:
seed_dir = tmp_path / "seed"
seed_dir.mkdir()

View file

@ -15,6 +15,13 @@ import pytest
from pyro_mcp import workspace_ports
def _socketpair_or_skip() -> tuple[socket.socket, socket.socket]:
try:
return socket.socketpair()
except PermissionError as exc:
pytest.skip(f"socketpair unavailable in this environment: {exc}")
class _EchoHandler(socketserver.BaseRequestHandler):
def handle(self) -> None:
data = self.request.recv(65536)
@ -50,18 +57,26 @@ def test_workspace_port_proxy_handler_ignores_upstream_connect_failure(
def test_workspace_port_proxy_forwards_tcp_traffic() -> None:
upstream = socketserver.ThreadingTCPServer(
(workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0),
_EchoHandler,
)
try:
upstream = socketserver.ThreadingTCPServer(
(workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0),
_EchoHandler,
)
except PermissionError as exc:
pytest.skip(f"TCP bind unavailable in this environment: {exc}")
upstream_thread = threading.Thread(target=upstream.serve_forever, daemon=True)
upstream_thread.start()
upstream_host = str(upstream.server_address[0])
upstream_port = int(upstream.server_address[1])
proxy = workspace_ports._ProxyServer( # noqa: SLF001
(workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0),
(upstream_host, upstream_port),
)
try:
proxy = workspace_ports._ProxyServer( # noqa: SLF001
(workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0),
(upstream_host, upstream_port),
)
except PermissionError as exc:
upstream.shutdown()
upstream.server_close()
pytest.skip(f"proxy TCP bind unavailable in this environment: {exc}")
proxy_thread = threading.Thread(target=proxy.serve_forever, daemon=True)
proxy_thread.start()
try:
@ -202,8 +217,8 @@ def test_workspace_ports_main_shutdown_handler_stops_server(
def test_workspace_port_proxy_handler_handles_empty_and_invalid_selector_events(
monkeypatch: Any,
) -> None:
source, source_peer = socket.socketpair()
upstream, upstream_peer = socket.socketpair()
source, source_peer = _socketpair_or_skip()
upstream, upstream_peer = _socketpair_or_skip()
source_peer.close()
class FakeSelector:
@ -246,10 +261,17 @@ def test_workspace_port_proxy_handler_handles_recv_and_send_errors(
monkeypatch: Any,
) -> None:
def _run_once(*, close_source: bool) -> None:
source, source_peer = socket.socketpair()
upstream, upstream_peer = socket.socketpair()
source, source_peer = _socketpair_or_skip()
upstream, upstream_peer = _socketpair_or_skip()
if not close_source:
source_peer.sendall(b"hello")
try:
source_peer.sendall(b"hello")
except PermissionError as exc:
source.close()
source_peer.close()
upstream.close()
upstream_peer.close()
pytest.skip(f"socket send unavailable in this environment: {exc}")
class FakeSelector:
def register(self, *_args: Any, **_kwargs: Any) -> None:

View file

@ -391,6 +391,69 @@ class _FakePyro:
"workspace_reset": {"snapshot_name": snapshot},
}
def summarize_workspace(self, workspace_id: str) -> dict[str, Any]:
workspace = self._resolve_workspace(workspace_id)
changed = self._diff_changed(workspace)
return {
"workspace_id": workspace_id,
"name": workspace.name,
"labels": dict(workspace.labels),
"environment": workspace.environment,
"state": "started",
"last_activity_at": workspace.last_activity_at,
"session_started_at": workspace.created_at,
"outcome": {
"command_count": 0,
"last_command": None,
"service_count": len(workspace.services),
"running_service_count": sum(
1
for service in workspace.services.values()
if service["state"] == "running"
),
"export_count": 1,
"snapshot_count": max(len(workspace.snapshots) - 1, 0),
"reset_count": workspace.reset_count,
},
"commands": {"total": 0, "recent": []},
"edits": {"recent": []},
"changes": {
"available": True,
"reason": None,
"changed": changed,
"summary": {"total": 1 if changed else 0},
"entries": (
[
{
"path": "/workspace/artifact.txt",
"status": "modified",
"artifact_type": "file",
}
]
if changed
else []
),
},
"services": {
"current": [
{"service_name": name, "state": service["state"]}
for name, service in sorted(workspace.services.items())
],
"recent": [],
},
"artifacts": {
"exports": [
{
"workspace_path": "review-report.txt",
"output_path": str(
self._workspace_dir(workspace_id) / "exported-review.txt"
),
}
]
},
"snapshots": {"named_count": max(len(workspace.snapshots) - 1, 0), "recent": []},
}
def open_shell(self, workspace_id: str, **_: Any) -> dict[str, Any]:
workspace = self._resolve_workspace(workspace_id)
self._shell_counter += 1
@ -436,6 +499,43 @@ class _FakePyro:
workspace.shells.pop(shell_id, None)
return {"workspace_id": workspace_id, "shell_id": shell_id, "closed": True}
def create_server(
self,
*,
profile: str = "workspace-core",
mode: str | None = None,
project_path: Path,
) -> Any:
assert profile == "workspace-core"
assert mode in {"repro-fix", "cold-start"}
seed_path = Path(project_path)
outer = self
class _FakeServer:
async def call_tool(
self,
tool_name: str,
arguments: dict[str, Any],
) -> tuple[None, dict[str, Any]]:
if tool_name != "workspace_create":
raise AssertionError(f"unexpected tool call: {tool_name}")
result = outer.create_workspace(
environment=cast(str, arguments["environment"]),
seed_path=seed_path,
name=cast(str | None, arguments.get("name")),
labels=cast(dict[str, str] | None, arguments.get("labels")),
)
created = outer.status_workspace(cast(str, result["workspace_id"]))
created["workspace_seed"] = {
"mode": "directory",
"seed_path": str(seed_path.resolve()),
"origin_kind": "project_path",
"origin_ref": str(seed_path.resolve()),
}
return None, created
return _FakeServer()
def test_use_case_registry_has_expected_scenarios() -> None:
expected = (
@ -461,7 +561,7 @@ def test_use_case_docs_and_targets_stay_aligned() -> None:
recipe_text = (repo_root / recipe.doc_path).read_text(encoding="utf-8")
assert recipe.smoke_target in index_text
assert recipe.doc_path.rsplit("/", 1)[-1] in index_text
assert recipe.profile in recipe_text
assert recipe.mode in recipe_text
assert recipe.smoke_target in recipe_text
assert f"{recipe.smoke_target}:" in makefile_text

2
uv.lock generated
View file

@ -715,7 +715,7 @@ crypto = [
[[package]]
name = "pyro-mcp"
version = "4.0.0"
version = "4.5.0"
source = { editable = "." }
dependencies = [
{ name = "mcp" },