diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d099a1..863a6d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,63 +2,6 @@ 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 diff --git a/Makefile b/Makefile index 465a2ab..8c71e5b 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,6 @@ PYTHON ?= uv run python UV_CACHE_DIR ?= .uv-cache -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) +PYTEST_FLAGS ?= -n auto OLLAMA_BASE_URL ?= http://localhost:11434/v1 OLLAMA_MODEL ?= llama3.2:3b OLLAMA_DEMO_FLAGS ?= @@ -18,9 +17,8 @@ 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-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 +.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 help: @printf '%s\n' \ @@ -37,7 +35,6 @@ 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' \ @@ -85,16 +82,13 @@ test: check: lint typecheck test dist-check: - 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 + .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 pypi-publish: @if [ -z "$$TWINE_PASSWORD" ]; then \ @@ -119,9 +113,6 @@ 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) diff --git a/README.md b/README.md index 06956d1..793a7b3 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,34 @@ # pyro-mcp -`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. +`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`. [![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 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) +- Install: [docs/install.md](docs/install.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) +- 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) +- First run transcript: [docs/first-run.md](docs/first-run.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/) - -## 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. +- 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) ## Quickstart @@ -54,7 +38,8 @@ 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 prepare debian:12 +uvx --from pyro-mcp pyro env list +uvx --from pyro-mcp pyro env pull debian:12 uvx --from pyro-mcp pyro run debian:12 -- git --version ``` @@ -63,7 +48,8 @@ uvx --from pyro-mcp pyro run debian:12 -- git --version ```bash # Already installed pyro doctor -pyro prepare debian:12 +pyro env list +pyro env pull debian:12 pyro run debian:12 -- git --version ``` @@ -74,7 +60,7 @@ What success looks like: ```bash Platform: linux-x86_64 Runtime: PASS -Catalog version: 4.4.0 +Catalog version: 4.0.0 ... [pull] phase=install environment=debian:12 [pull] phase=ready environment=debian:12 @@ -87,76 +73,89 @@ 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. `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. +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) ## Chat Host Quickstart -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: +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. ```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) @@ -164,16 +163,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 cold-start or review-eval: +Claude Code: ```bash -claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start +claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve ``` -Codex repro-fix or inspect: +Codex: ```bash -codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix +codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve ``` OpenCode `opencode.json` snippet: @@ -184,72 +183,229 @@ OpenCode `opencode.json` snippet: "pyro": { "type": "local", "enabled": true, - "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"] + "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"] } } } ``` -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. +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 `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with -`pyro` in the same command or config shape. +Profile progression: -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. +- `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 -## Zero To Hero +## Supported Hosts -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. +Supported today: -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. +- Linux x86_64 +- Python 3.12+ +- `uv` +- `/dev/kvm` -## Manual Terminal Workspace Flow +Optional for outbound guest networking: -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: +- `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 ```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 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" +uvx --from pyro-mcp pyro doctor ``` -Add `workspace-full` only when the chat or your manual debugging loop really -needs: +Expected success signals: -- persistent PTY shells -- long-running services and readiness probes -- guest networking and published ports -- secrets -- stopped-workspace disk inspection +```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 +``` -The five recipe docs show when those capabilities are justified: -[docs/use-cases/README.md](docs/use-cases/README.md) +### 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/`, 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. ## Official Environments @@ -259,10 +415,216 @@ Current official environments in the shipped catalog: - `debian:12-base` - `debian:12-build` -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. +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. ## Contributor Workflow @@ -275,12 +637,11 @@ 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: @@ -291,9 +652,20 @@ 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. diff --git a/docs/first-run.md b/docs/first-run.md index 04af501..842bb59 100644 --- a/docs/first-run.md +++ b/docs/first-run.md @@ -1,36 +1,28 @@ # First Run Transcript -This is the intended evaluator-to-chat-host path for a first successful run on -a supported host. - +This is the intended evaluator 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`. - -`pyro-mcp` currently has no users. Expect breaking changes while the chat-host -path is still being shaped. +`uvx --from pyro-mcp` prefix. If you are running from a source checkout instead +of the published package, replace `pyro` with `uv run pyro`. ## 1. Verify the host ```bash -$ uvx --from pyro-mcp pyro doctor --environment debian:12 +$ uvx --from pyro-mcp pyro doctor 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.4.0 +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. @@ -38,10 +30,9 @@ 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 @@ -54,6 +45,9 @@ 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 ``` @@ -68,152 +62,239 @@ $ 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. Start the MCP server +## 5. Continue into the stable workspace path -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: +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. ```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" ``` -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 +## 6. Optional one-shot demo and expanded workspace flow ```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/`, 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, @@ -228,5 +309,7 @@ $ uvx --from pyro-mcp pyro demo } ``` -`pyro demo` proves the one-shot create/start/exec/delete VM lifecycle works end -to end. +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). diff --git a/docs/install.md b/docs/install.md index c809a35..88f3d84 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,17 +1,11 @@ # 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` @@ -46,27 +40,27 @@ Use either of these equivalent evaluator paths: ```bash # Package without install uvx --from pyro-mcp pyro doctor -uvx --from pyro-mcp pyro prepare debian:12 +uvx --from pyro-mcp pyro env list +uvx --from pyro-mcp pyro env pull debian:12 uvx --from pyro-mcp pyro run debian:12 -- git --version ``` ```bash # Already installed pyro doctor -pyro prepare debian:12 +pyro env list +pyro env pull 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, the intended next step is a warmed daily loop -plus a named chat mode through `pyro host connect` or `pyro host print-config`. +After that one-shot proof works, continue into the stable workspace path with `pyro workspace ...`. -## 1. Check the host +### 1. Check the host first ```bash -uvx --from pyro-mcp pyro doctor --environment debian:12 +uvx --from pyro-mcp pyro doctor ``` Expected success signals: @@ -76,16 +70,13 @@ 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 @@ -94,22 +85,21 @@ uvx --from pyro-mcp pyro env list Expected output: ```bash -Catalog version: 4.4.0 +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 +### 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: @@ -120,7 +110,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 @@ -136,124 +126,17 @@ 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. 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 in terminals or capture tools. Use `--json` if you need a deterministic structured result. -## 5. Warm the daily loop +If guest execution is unavailable, the command fails unless you explicitly pass +`--allow-host-compat`. -```bash -uvx --from pyro-mcp pyro prepare debian:12 -``` +## 5. Continue into the stable workspace path -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. +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 @@ -264,49 +147,202 @@ 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" ``` -When you need deeper debugging or richer recipes, add: +This is the stable persistent-workspace contract: -- `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 +- `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 -## 9. Trustworthy verification path +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. -The five recipe docs in [use-cases/README.md](use-cases/README.md) are backed -by a real Firecracker smoke pack: +## 6. Optional demo proof point ```bash -make smoke-use-cases +uvx --from pyro-mcp pyro demo ``` -Treat that smoke pack as the trustworthy guest-backed verification path for the -advertised chat-host workflows. +`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`. ## Installed CLI -If you already installed the package, the same path works with plain `pyro ...`: +If you already installed the package, the same evaluator path works with plain `pyro ...`: ```bash uv tool install pyro-mcp pyro --version -pyro doctor --environment debian:12 -pyro prepare debian:12 +pyro doctor +pyro env list +pyro env pull debian:12 pyro run debian:12 -- git --version -pyro mcp serve ``` -## Contributor clone +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/`. 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 ```bash git lfs install diff --git a/docs/integrations.md b/docs/integrations.md index 4974302..2883784 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -1,257 +1,164 @@ -# Chat Host Integrations +# Integration Targets -This page documents the intended product path for `pyro-mcp`: +These are the main ways to integrate `pyro-mcp` into an LLM application. -- 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 +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). -`pyro-mcp` currently has no users. Expect breaking changes while this chat-host -path is still being shaped. +## Recommended Default -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). +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. -Recommended first commands before connecting a host: +That keeps the model-facing contract small: -```bash -pyro doctor --environment debian:12 -pyro prepare debian:12 -``` +- one tool +- one command +- one ephemeral VM +- automatic cleanup -## Recommended Modes +Profile progression: -Use a named mode when one workflow already matches the job: +- `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 -```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 -``` +## OpenAI Responses API -The mode-backed raw server forms are: +Best when: -```bash -pyro mcp serve --mode repro-fix -pyro mcp serve --mode inspect -pyro mcp serve --mode cold-start -pyro mcp serve --mode review-eval -``` +- 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 -Use the generic no-mode path only when the named mode feels too narrow. +Recommended surface: -## Generic Default +- `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 -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. +Canonical example: -```bash -pyro mcp serve -``` +- [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) -If the host does not preserve cwd, fall back to: +## MCP Clients -```bash -pyro mcp serve --project-path /abs/path/to/repo -``` +Best when: -If you are outside a repo checkout entirely, start from a clean clone source: +- 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 -```bash -pyro mcp serve --repo-url https://github.com/example/project.git -``` +Recommended entrypoint: -Use `--profile workspace-full` only when the chat truly needs shells, services, -snapshots, secrets, network policy, or disk tools. +- `pyro mcp serve` -## Helper First +Profile progression: -Use the helper flow before the raw host CLI commands: +- `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 -```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 -``` +Host-specific onramps: -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. +- 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) -## Claude Code +## Direct Python SDK -Preferred: +Best when: -```bash -pyro host connect claude-code --mode cold-start -``` +- your application owns orchestration itself +- you do not need MCP transport +- you want direct access to `Pyro` -Repair: +Recommended default: -```bash -pyro host repair claude-code -``` +- `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 -Package without install: +Lifecycle note: -```bash -claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start -claude mcp list -``` +- `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 -If Claude Code launches the server from an unexpected cwd, use: +Examples: -```bash -claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start --project-path /abs/path/to/repo -``` +- [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) -Already installed: +## Agent Framework Wrappers -```bash -claude mcp add pyro -- pyro mcp serve -claude mcp list -``` +Examples: -Reference: +- LangChain tools +- PydanticAI tools +- custom in-house orchestration layers -- [claude_code_mcp.md](../examples/claude_code_mcp.md) +Best when: -## Codex +- you already have an application framework that expects a Python callable tool +- you want to wrap `vm_run` behind framework-specific abstractions -Preferred: +Recommended pattern: -```bash -pyro host connect codex --mode repro-fix -``` +- 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 + +Concrete example: -Repair: - -```bash -pyro host repair codex -``` - -Package without install: - -```bash -codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix -codex mcp list -``` - -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 -``` +- [examples/langchain_vm_run.py](../examples/langchain_vm_run.py) + +## Selection Rule + +Choose the narrowest integration that matches the host environment: + +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. diff --git a/docs/public-contract.md b/docs/public-contract.md index 69412f0..d225c0b 100644 --- a/docs/public-contract.md +++ b/docs/public-contract.md @@ -1,192 +1,375 @@ # Public Contract -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. +This document defines the stable public interface for `pyro-mcp` `3.x`. ## Package Identity -- distribution name: `pyro-mcp` -- public executable: `pyro` -- primary product entrypoint: `pyro mcp serve` +- 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` -`pyro-mcp` is a disposable MCP workspace for chat-based coding agents on Linux -`x86_64` KVM hosts. +Stable product framing: -## Supported Product Path +- `pyro run` is the stable one-shot entrypoint. +- `pyro workspace ...` is the stable persistent workspace contract. -The intended user journey is: +## CLI Contract -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` +Top-level commands: -## 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` -What to expect from that path: +Stable `pyro run` interface: -- `pyro run -- ` 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 +- positional environment name +- `--vcpu-count` +- `--mem-mib` +- `--timeout-seconds` +- `--ttl-seconds` +- `--network` +- `--allow-host-compat` +- `--json` -These commands exist to validate and debug the chat-host path. They are not the -main product destination. +Behavioral guarantees: -## MCP Entry Point +- `pyro run -- ` 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. -The product entrypoint is: +## Python SDK Contract -```bash -pyro mcp serve -``` +Primary facade: -What to expect: +- `Pyro` -- 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 +Supported public entrypoints: -Host-specific setup docs: +- `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(...)` -- [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) +Stable public method names: -The chat-host bootstrap helper surface is: +- `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(...)` -- `pyro host connect claude-code` -- `pyro host connect codex` -- `pyro host print-config opencode` -- `pyro host doctor` -- `pyro host repair HOST` +Behavioral defaults: -These helpers wrap the same `pyro mcp serve` entrypoint and are the preferred -setup and repair path for supported hosts. +- `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`. -## Named Modes +## MCP Contract -The supported named modes are: +Stable MCP profiles: -| 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 | +- `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 -Use the generic no-mode path when one of those named modes feels too narrow for -the job. +Behavioral defaults: -## Generic Workspace Contract +- `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`. -`workspace-core` is the normal chat path. It exposes: +Primary tool: - `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_patch_apply` -- `workspace_diff` - `workspace_export` +- `workspace_patch_apply` +- `workspace_disk_export` +- `workspace_disk_list` +- `workspace_disk_read` +- `workspace_diff` +- `snapshot_create` +- `snapshot_list` +- `snapshot_delete` - `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` -That is enough for the normal persistent editing loop: +Behavioral defaults: -- 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 +- `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. -Move to `workspace-full` only when the chat truly needs: +## Versioning Rule -- 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. +- `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. diff --git a/docs/roadmap/llm-chat-ergonomics.md b/docs/roadmap/llm-chat-ergonomics.md index 440f528..93fa8e4 100644 --- a/docs/roadmap/llm-chat-ergonomics.md +++ b/docs/roadmap/llm-chat-ergonomics.md @@ -6,15 +6,12 @@ goal: make the core agent-workspace use cases feel trivial from a chat-driven LLM interface. -Current baseline is `4.5.0`: +Current baseline is `4.0.0`: -- `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 +- the stable workspace contract exists across CLI, SDK, and MCP +- one-shot `pyro run` still exists as the narrow entrypoint - 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 @@ -36,16 +33,9 @@ More concretely, the model should not need to: - choose from an unnecessarily large tool surface when a smaller profile would work -The next gaps for the narrowed persona are now about real-project credibility: +The remaining UX friction for a technically strong new user is now narrower: -- 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 +- no major chat-host ergonomics gaps remain in the current roadmap ## Locked Decisions @@ -53,24 +43,9 @@ The next gaps for the narrowed persona are now about real-project credibility: 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 -- 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 +- capability milestones should update CLI, SDK, and MCP together - 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 @@ -87,16 +62,6 @@ The next gaps for the narrowed persona are now about real-project credibility: 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: @@ -127,29 +92,10 @@ 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: -- [`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) +- no further chat-ergonomics milestones are currently planned in this roadmap. ## Expected Outcome @@ -171,16 +117,3 @@ 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 diff --git a/docs/roadmap/llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md b/docs/roadmap/llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md deleted file mode 100644 index e3e6986..0000000 --- a/docs/roadmap/llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md +++ /dev/null @@ -1,56 +0,0 @@ -# `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 diff --git a/docs/roadmap/llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md b/docs/roadmap/llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md deleted file mode 100644 index a9d07a4..0000000 --- a/docs/roadmap/llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md +++ /dev/null @@ -1,53 +0,0 @@ -# `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 diff --git a/docs/roadmap/llm-chat-ergonomics/4.3.0-reviewable-agent-output.md b/docs/roadmap/llm-chat-ergonomics/4.3.0-reviewable-agent-output.md deleted file mode 100644 index b1b02ba..0000000 --- a/docs/roadmap/llm-chat-ergonomics/4.3.0-reviewable-agent-output.md +++ /dev/null @@ -1,54 +0,0 @@ -# `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 diff --git a/docs/roadmap/llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md b/docs/roadmap/llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md deleted file mode 100644 index 52d2d22..0000000 --- a/docs/roadmap/llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md +++ /dev/null @@ -1,56 +0,0 @@ -# `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 diff --git a/docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md b/docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md deleted file mode 100644 index ecbd8b8..0000000 --- a/docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md +++ /dev/null @@ -1,57 +0,0 @@ -# `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 diff --git a/docs/roadmap/llm-chat-ergonomics/4.6.0-git-tracked-project-sources.md b/docs/roadmap/llm-chat-ergonomics/4.6.0-git-tracked-project-sources.md deleted file mode 100644 index b170b21..0000000 --- a/docs/roadmap/llm-chat-ergonomics/4.6.0-git-tracked-project-sources.md +++ /dev/null @@ -1,55 +0,0 @@ -# `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 diff --git a/docs/roadmap/llm-chat-ergonomics/4.7.0-project-source-diagnostics-and-recovery.md b/docs/roadmap/llm-chat-ergonomics/4.7.0-project-source-diagnostics-and-recovery.md deleted file mode 100644 index a758e79..0000000 --- a/docs/roadmap/llm-chat-ergonomics/4.7.0-project-source-diagnostics-and-recovery.md +++ /dev/null @@ -1,50 +0,0 @@ -# `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` diff --git a/docs/roadmap/llm-chat-ergonomics/4.8.0-first-class-chat-environment-selection.md b/docs/roadmap/llm-chat-ergonomics/4.8.0-first-class-chat-environment-selection.md deleted file mode 100644 index fcb80eb..0000000 --- a/docs/roadmap/llm-chat-ergonomics/4.8.0-first-class-chat-environment-selection.md +++ /dev/null @@ -1,52 +0,0 @@ -# `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 diff --git a/docs/roadmap/llm-chat-ergonomics/4.9.0-real-repo-qualification-smokes.md b/docs/roadmap/llm-chat-ergonomics/4.9.0-real-repo-qualification-smokes.md deleted file mode 100644 index 04bea7c..0000000 --- a/docs/roadmap/llm-chat-ergonomics/4.9.0-real-repo-qualification-smokes.md +++ /dev/null @@ -1,52 +0,0 @@ -# `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 diff --git a/docs/roadmap/llm-chat-ergonomics/5.0.0-whole-project-sandbox-development.md b/docs/roadmap/llm-chat-ergonomics/5.0.0-whole-project-sandbox-development.md deleted file mode 100644 index ba2195a..0000000 --- a/docs/roadmap/llm-chat-ergonomics/5.0.0-whole-project-sandbox-development.md +++ /dev/null @@ -1,53 +0,0 @@ -# `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 diff --git a/docs/use-cases/README.md b/docs/use-cases/README.md index e471332..15911d6 100644 --- a/docs/use-cases/README.md +++ b/docs/use-cases/README.md @@ -1,6 +1,6 @@ # Workspace Use-Case Recipes -These recipes turn the chat-host workspace path into five concrete agent flows. +These recipes turn the stable workspace surface 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 mode | Smoke target | Recipe | +| Use case | Recommended profile | Smoke target | Recipe | | --- | --- | --- | --- | -| 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) | +| 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) | All five recipes use the same real Firecracker-backed smoke runner: @@ -30,9 +30,3 @@ 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 -``` diff --git a/docs/use-cases/cold-start-repo-validation.md b/docs/use-cases/cold-start-repo-validation.md index 763210b..f856906 100644 --- a/docs/use-cases/cold-start-repo-validation.md +++ b/docs/use-cases/cold-start-repo-validation.md @@ -1,12 +1,6 @@ # Cold-Start Repo Validation -Recommended mode: `cold-start` - -Recommended startup: - -```bash -pyro host connect claude-code --mode cold-start -``` +Recommended profile: `workspace-full` Smoke target: @@ -18,18 +12,26 @@ 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. -Chat-host recipe: +Canonical SDK flow: -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. +```python +from pyro_mcp import Pyro -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. +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) +``` This recipe is intentionally guest-local and deterministic. It proves startup, service readiness, validation, and host-out report capture without depending on diff --git a/docs/use-cases/parallel-workspaces.md b/docs/use-cases/parallel-workspaces.md index 685f6a4..ddc29b7 100644 --- a/docs/use-cases/parallel-workspaces.md +++ b/docs/use-cases/parallel-workspaces.md @@ -1,12 +1,6 @@ # Parallel Isolated Workspaces -Recommended mode: `repro-fix` - -Recommended startup: - -```bash -pyro host connect codex --mode repro-fix -``` +Recommended profile: `workspace-core` Smoke target: @@ -17,16 +11,33 @@ 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. -Chat-host recipe: +Canonical SDK flow: -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. +```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"]) +``` 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. Parallel work still means “open another workspace in -the same mode,” not “pick a special parallel-work mode.” +active at the same time. diff --git a/docs/use-cases/repro-fix-loop.md b/docs/use-cases/repro-fix-loop.md index ad920c5..f302974 100644 --- a/docs/use-cases/repro-fix-loop.md +++ b/docs/use-cases/repro-fix-loop.md @@ -1,12 +1,6 @@ # Repro Plus Fix Loop -Recommended mode: `repro-fix` - -Recommended startup: - -```bash -pyro host connect codex --mode repro-fix -``` +Recommended profile: `workspace-core` Smoke target: @@ -18,21 +12,31 @@ 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. -Chat-host recipe: +Canonical SDK flow: -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. +```python +from pyro_mcp import Pyro -If the mode feels too narrow for the job, fall back to the generic bare -`pyro mcp serve` path. +pyro = Pyro() +created = pyro.create_workspace(environment="debian:12", seed_path="./broken-repro") +workspace_id = str(created["workspace_id"]) -This is the main `repro-fix` story: model-native file ops, repeatable exec, +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, structured diff, explicit export, and reset-over-repair. diff --git a/docs/use-cases/review-eval-workflows.md b/docs/use-cases/review-eval-workflows.md index 4012c34..eabe981 100644 --- a/docs/use-cases/review-eval-workflows.md +++ b/docs/use-cases/review-eval-workflows.md @@ -1,12 +1,6 @@ # Review And Evaluation Workflows -Recommended mode: `review-eval` - -Recommended startup: - -```bash -pyro host connect claude-code --mode review-eval -``` +Recommended profile: `workspace-full` Smoke target: @@ -17,15 +11,30 @@ 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. -Chat-host recipe: +Canonical SDK flow: -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. +```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) +``` This is the stable shell-facing story: readable PTY output for chat loops, checkpointed evaluation, explicit export, and reset when a review branch goes diff --git a/docs/use-cases/untrusted-inspection.md b/docs/use-cases/untrusted-inspection.md index aab7ada..a089faa 100644 --- a/docs/use-cases/untrusted-inspection.md +++ b/docs/use-cases/untrusted-inspection.md @@ -1,12 +1,6 @@ # Unsafe Or Untrusted Code Inspection -Recommended mode: `inspect` - -Recommended startup: - -```bash -pyro host connect codex --mode inspect -``` +Recommended profile: `workspace-core` Smoke target: @@ -17,13 +11,24 @@ 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. -Chat-host recipe: +Canonical SDK flow: -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. +```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) +``` This recipe stays offline-by-default, uses only explicit file reads and execs, and exports only the inspection report the agent chose to materialize. diff --git a/docs/vision.md b/docs/vision.md index 2192cce..cdf852c 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -1,19 +1,16 @@ # Vision -`pyro-mcp` should become the disposable MCP workspace for chat-based coding -agents. +`pyro-mcp` should become the disposable sandbox where an agent can do real +development work safely, repeatedly, and reproducibly. -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. +That is a different product from a generic VM wrapper, a secure CI runner, or a +task queue with better isolation. ## Core Thesis The goal is not just to run one command in a microVM. -The goal is to give a chat-hosted coding agent a bounded workspace where it can: +The goal is to give an LLM or coding agent a bounded workspace where it can: - inspect a repo - install dependencies @@ -26,25 +23,6 @@ The goal is to give a chat-hosted 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: @@ -54,10 +32,9 @@ product story. - 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, or -library ergonomics. +Those products optimize for queued work, throughput, retries, matrix builds, and +shared infrastructure. `pyro-mcp` should optimize for agent loops: @@ -80,15 +57,10 @@ 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 from a chat host, not a job the agent submits to a runner. +inhabits, not a job the agent submits. ## 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 @@ -113,6 +85,11 @@ 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: @@ -124,16 +101,10 @@ The sandbox should expose the things an agent actually needs to reason about: - readiness - exported results -## The Shape Of The Product +## The Shape Of An LLM-First Sandbox -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 +The strongest future direction is a small, agent-native contract built around +workspaces, shells, files, services, and reset. Representative primitives: @@ -143,57 +114,95 @@ 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 promise that every lower-level repo surface -should be treated as equally stable or equally important. +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 ## 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. -They should remain subordinate to the workspace model, not replace it with a -raw SSH story. +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. -Disk-level operations are useful for: +Disk-level operations are also useful, but they should remain supporting tools. +They are good for: - fast workspace seeding - snapshotting - offline inspection +- diffing - export/import without a full boot -They should remain supporting tools rather than the product identity. +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. ## What To Build Next -Features should keep reinforcing the chat-host path in this order: +Features should be prioritized in this order: -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 +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 The completed workspace GA roadmap lives in [roadmap/task-workspace-ga.md](roadmap/task-workspace-ga.md). -The follow-on milestones that make the chat-host path clearer live in +The next implementation milestones that make those workflows feel natural from +chat-driven LLM interfaces 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 make Claude Code, Codex, or OpenCode feel more natural and powerful -when they work inside a disposable sandbox?" +"Does this help an agent inhabit a safe disposable workspace and do real +software work inside it?" -If the better description is "it helps build a broader VM toolkit or SDK", it -is probably pushing the product in the wrong direction. +If the better description is "it helps submit, schedule, and report jobs", the +feature is probably pushing the product in the wrong direction. diff --git a/examples/claude_code_mcp.md b/examples/claude_code_mcp.md index de9931c..d62cae4 100644 --- a/examples/claude_code_mcp.md +++ b/examples/claude_code_mcp.md @@ -1,49 +1,21 @@ # Claude Code MCP Setup -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 -``` +Recommended profile: `workspace-core`. Package without install: ```bash -claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start +claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve 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 --mode cold-start +claude mcp add pyro -- pyro mcp serve 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: diff --git a/examples/codex_mcp.md b/examples/codex_mcp.md index 53f3c7b..6838a7d 100644 --- a/examples/codex_mcp.md +++ b/examples/codex_mcp.md @@ -1,49 +1,21 @@ # Codex MCP Setup -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 -``` +Recommended profile: `workspace-core`. Package without install: ```bash -codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix +codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve 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 --mode repro-fix +codex mcp add pyro -- pyro mcp serve 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: diff --git a/examples/mcp_client_config.md b/examples/mcp_client_config.md index f4de5f0..51d7419 100644 --- a/examples/mcp_client_config.md +++ b/examples/mcp_client_config.md @@ -1,11 +1,6 @@ # MCP Client Config Example -Recommended named modes for most chat hosts in `4.x`: - -- `repro-fix` -- `inspect` -- `cold-start` -- `review-eval` +Default for most chat hosts in `4.x`: `workspace-core`. Use the host-specific examples first when they apply: @@ -13,18 +8,8 @@ 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 or when the named modes are too narrow for the workflow. +shape. `pyro-mcp` is intended to be exposed to LLM clients through the public `pyro` CLI. @@ -35,7 +20,7 @@ Generic stdio MCP configuration using `uvx`: "mcpServers": { "pyro": { "command": "uvx", - "args": ["--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"] + "args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"] } } } @@ -48,28 +33,21 @@ If `pyro-mcp` is already installed locally, the same server can be configured wi "mcpServers": { "pyro": { "command": "pyro", - "args": ["mcp", "serve", "--mode", "repro-fix"] + "args": ["mcp", "serve"] } } } ``` -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. +Profile progression: -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-core`: the default and recommended first persistent chat profile +- `vm-run`: expose only `vm_run` - `workspace-full`: explicit advanced opt-in for shells, services, snapshots, secrets, network policy, and disk tools -Primary mode for most agents: +Primary profile for most agents: -- `repro-fix` +- `workspace-core` Use lifecycle tools only when the agent needs persistent VM state across multiple tool calls. diff --git a/examples/opencode_mcp_config.json b/examples/opencode_mcp_config.json index 518dc7d..c060946 100644 --- a/examples/opencode_mcp_config.json +++ b/examples/opencode_mcp_config.json @@ -3,7 +3,7 @@ "pyro": { "type": "local", "enabled": true, - "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"] + "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"] } } } diff --git a/pyproject.toml b/pyproject.toml index 5e9b649..2f078d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "pyro-mcp" -version = "4.5.0" -description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM." +version = "4.0.0" +description = "Stable Firecracker workspaces, one-shot sandboxes, and MCP tools for coding agents." readme = "README.md" license = { file = "LICENSE" } authors = [ @@ -9,7 +9,7 @@ authors = [ ] requires-python = ">=3.12" classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", diff --git a/scripts/daily_loop_smoke.py b/scripts/daily_loop_smoke.py deleted file mode 100644 index dc40980..0000000 --- a/scripts/daily_loop_smoke.py +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env python3 - -"""Run the real guest-backed daily-loop smoke.""" - -from pyro_mcp.daily_loop_smoke import main - -if __name__ == "__main__": - main() diff --git a/src/pyro_mcp/api.py b/src/pyro_mcp/api.py index 967b05c..6dfc5fb 100644 --- a/src/pyro_mcp/api.py +++ b/src/pyro_mcp/api.py @@ -8,22 +8,11 @@ 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, @@ -35,77 +24,12 @@ 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: @@ -115,44 +39,6 @@ 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.""" @@ -300,9 +186,6 @@ 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, @@ -579,40 +462,15 @@ class Pyro: allow_host_compat=allow_host_compat, ) - 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: + def create_server(self, *, profile: McpToolProfile = "workspace-core") -> 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. 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. + advanced workspace surface. """ normalized_profile = _validate_mcp_profile(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] - ) + enabled_tools = set(_PROFILE_TOOLS[normalized_profile]) server = FastMCP(name="pyro_mcp") def _enabled(tool_name: str) -> bool: @@ -725,59 +583,9 @@ class Pyro: return self.reap_expired() if _enabled("workspace_create"): - workspace_create_description = _workspace_create_description( - startup_source, - mode=normalized_mode, - ) + if normalized_profile == "workspace-core": - 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) + @server.tool(name="workspace_create") async def workspace_create_core( environment: str, vcpu_count: int = DEFAULT_VCPU_COUNT, @@ -788,7 +596,8 @@ class Pyro: name: str | None = None, labels: dict[str, str] | None = None, ) -> dict[str, Any]: - return _create_workspace_from_server_defaults( + """Create and start a persistent workspace.""" + return self.create_workspace( environment=environment, vcpu_count=vcpu_count, mem_mib=mem_mib, @@ -803,7 +612,7 @@ class Pyro: else: - @server.tool(name="workspace_create", description=workspace_create_description) + @server.tool(name="workspace_create") async def workspace_create_full( environment: str, vcpu_count: int = DEFAULT_VCPU_COUNT, @@ -816,7 +625,8 @@ class Pyro: name: str | None = None, labels: dict[str, str] | None = None, ) -> dict[str, Any]: - return _create_workspace_from_server_defaults( + """Create and start a persistent workspace.""" + return self.create_workspace( environment=environment, vcpu_count=vcpu_count, mem_mib=mem_mib, @@ -856,21 +666,15 @@ class Pyro: ) if _enabled("workspace_exec"): - if normalized_mode is not None or normalized_profile == "workspace-core": + if normalized_profile == "workspace-core": - @server.tool( - name="workspace_exec", - description=_tool_description( - "workspace_exec", - mode=normalized_mode, - fallback="Run one command inside an existing persistent workspace.", - ), - ) + @server.tool(name="workspace_exec") 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, @@ -880,20 +684,14 @@ class Pyro: else: - @server.tool( - name="workspace_exec", - description=_tool_description( - "workspace_exec", - mode=normalized_mode, - fallback="Run one command inside an existing persistent workspace.", - ), - ) + @server.tool(name="workspace_exec") 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, @@ -940,32 +738,15 @@ 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( - description=_tool_description( - "workspace_export", - mode=normalized_mode, - fallback="Export one file or directory from `/workspace` back to the host.", - ) - ) + @server.tool() async def workspace_export( workspace_id: str, path: str, output_path: str, ) -> dict[str, Any]: + """Export one file or directory from `/workspace` back to the host.""" return self.export_workspace(workspace_id, path, output_path=output_path) if _enabled("workspace_diff"): @@ -977,21 +758,13 @@ class Pyro: if _enabled("workspace_file_list"): - @server.tool( - description=_tool_description( - "workspace_file_list", - mode=normalized_mode, - fallback=( - "List metadata for files and directories under one " - "live workspace path." - ), - ) - ) + @server.tool() 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, @@ -1000,18 +773,13 @@ class Pyro: if _enabled("workspace_file_read"): - @server.tool( - description=_tool_description( - "workspace_file_read", - mode=normalized_mode, - fallback="Read one regular text file from a live workspace path.", - ) - ) + @server.tool() 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, @@ -1020,18 +788,13 @@ class Pyro: if _enabled("workspace_file_write"): - @server.tool( - description=_tool_description( - "workspace_file_write", - mode=normalized_mode, - fallback="Create or replace one regular text file under `/workspace`.", - ) - ) + @server.tool() 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, @@ -1040,17 +803,12 @@ class Pyro: if _enabled("workspace_patch_apply"): - @server.tool( - description=_tool_description( - "workspace_patch_apply", - mode=normalized_mode, - fallback="Apply a unified text patch inside one live workspace.", - ) - ) + @server.tool() 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, @@ -1128,62 +886,27 @@ class Pyro: return self.reset_workspace(workspace_id, snapshot=snapshot) if _enabled("shell_open"): - if normalized_mode == "review-eval": - @server.tool( - description=_tool_description( - "shell_open", - mode=normalized_mode, - fallback="Open a persistent interactive shell inside one workspace.", - ) + @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, ) - 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( - description=_tool_description( - "shell_read", - mode=normalized_mode, - fallback="Read merged PTY output from a workspace shell.", - ) - ) + @server.tool() async def shell_read( workspace_id: str, shell_id: str, @@ -1192,6 +915,7 @@ 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, @@ -1203,19 +927,14 @@ class Pyro: if _enabled("shell_write"): - @server.tool( - description=_tool_description( - "shell_write", - mode=normalized_mode, - fallback="Write text input to a persistent workspace shell.", - ) - ) + @server.tool() 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, @@ -1225,18 +944,13 @@ class Pyro: if _enabled("shell_signal"): - @server.tool( - description=_tool_description( - "shell_signal", - mode=normalized_mode, - fallback="Send a signal to the shell process group.", - ) - ) + @server.tool() 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, @@ -1245,142 +959,74 @@ class Pyro: if _enabled("shell_close"): - @server.tool( - description=_tool_description( - "shell_close", - mode=normalized_mode, - fallback="Close a persistent workspace shell.", - ) - ) + @server.tool() 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( - description=_tool_description( - "service_start", - mode=normalized_mode, - fallback="Start a named long-running service inside a workspace.", - ) + @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, ) - 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( - description=_tool_description( - "service_list", - mode=normalized_mode, - fallback="List named services in one workspace.", - ) - ) + @server.tool() 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( - description=_tool_description( - "service_status", - mode=normalized_mode, - fallback="Inspect one named workspace service.", - ) - ) + @server.tool() 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( - description=_tool_description( - "service_logs", - mode=normalized_mode, - fallback="Read persisted stdout/stderr for one workspace service.", - ) - ) + @server.tool() 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, @@ -1390,14 +1036,9 @@ class Pyro: if _enabled("service_stop"): - @server.tool( - description=_tool_description( - "service_stop", - mode=normalized_mode, - fallback="Stop one running service in a workspace.", - ) - ) + @server.tool() 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"): diff --git a/src/pyro_mcp/cli.py b/src/pyro_mcp/cli.py index 79eb3a9..47df4ee 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -8,21 +8,12 @@ import shlex import sys from pathlib import Path from textwrap import dedent -from typing import Any, cast +from typing import Any from pyro_mcp import __version__ -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.api import Pyro +from pyro_mcp.contract import PUBLIC_MCP_PROFILES 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 @@ -155,7 +146,6 @@ 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): @@ -173,108 +163,12 @@ 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") @@ -297,16 +191,7 @@ 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") - 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 != "": + if isinstance(seed_path, str) and seed_path != "": print(f"Workspace seed: {mode} from {seed_path}") else: print(f"Workspace seed: {mode}") @@ -592,147 +477,6 @@ 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): @@ -892,73 +636,24 @@ 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=( - "Validate the host and serve disposable MCP workspaces for chat-based " - "coding agents on supported Linux x86_64 KVM hosts." + "Run stable one-shot and persistent workspace workflows on supported " + "Linux x86_64 KVM hosts." ), epilog=dedent( """ - Suggested zero-to-hero path: + Suggested first run: pyro doctor - pyro prepare debian:12 + pyro env list + pyro env pull debian:12 pyro run debian:12 -- git --version - pyro host connect claude-code - 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: + Continue into the stable workspace path after that: 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 @@ -966,6 +661,8 @@ 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, @@ -973,51 +670,6 @@ 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.", @@ -1103,151 +755,18 @@ 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`. This is the " - "main product path for Claude Code, Codex, and OpenCode." + "guest execution with `pyro doctor` and `pyro run`. Bare `pyro " + "mcp serve` now starts the recommended `workspace-core` profile." ), 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 """ @@ -1260,83 +779,36 @@ 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` 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." + "serve` now starts `workspace-core`, the recommended first profile " + "for most chat hosts." ), epilog=dedent( """ - Generic default path: + Default and recommended first start: 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 and the - recommended first profile for most chat hosts + workspace-core: default for normal persistent chat editing vm-run: smallest one-shot-only surface - workspace-full: larger opt-in surface for shells, services, + workspace-full: advanced 4.x opt-in surface for shells, services, snapshots, secrets, network policy, and disk tools - 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. + Use --profile workspace-full only when the host truly needs the full + advanced workspace surface. """ ), formatter_class=_HelpFormatter, ) - mcp_profile_group = mcp_serve_parser.add_mutually_exclusive_group() - mcp_profile_group.add_argument( + mcp_serve_parser.add_argument( "--profile", choices=PUBLIC_MCP_PROFILES, default="workspace-core", help=( - "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." + "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." ), ) - 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", @@ -1393,7 +865,8 @@ 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( @@ -1415,7 +888,7 @@ def _build_parser() -> argparse.ArgumentParser: "workspace", help="Manage persistent workspaces.", description=( - "Use the workspace model when you need one sandbox to stay alive " + "Use the stable workspace contract when you need one sandbox to stay alive " "across repeated exec, shell, service, diff, export, snapshot, and reset calls." ), epilog=dedent( @@ -1435,7 +908,6 @@ 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 @@ -1514,7 +986,8 @@ 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( @@ -1564,7 +1037,8 @@ 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( """ @@ -1800,7 +1274,8 @@ 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, ) @@ -1992,7 +1467,8 @@ 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, @@ -2118,7 +1594,8 @@ 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( """ @@ -2341,7 +1818,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 when the workflow needs a custom readiness check. + remains available as an escape hatch. """ ), formatter_class=_HelpFormatter, @@ -2570,38 +2047,12 @@ 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, @@ -2637,16 +2088,11 @@ 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, plus " - "daily-loop warmth before your first run or before reconnecting a " - "chat host." - ), + description="Check host prerequisites and embedded runtime health before your first run.", epilog=dedent( """ Examples: pyro doctor - pyro doctor --environment debian:12 pyro doctor --json """ ), @@ -2657,14 +2103,6 @@ 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", @@ -2827,24 +2265,6 @@ 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] = { @@ -2880,66 +2300,8 @@ 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, - 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") + pyro.create_server(profile=args.profile).run(transport="stdio") return if args.command == "run": command = _require_command(args.command_args) @@ -2992,7 +2354,10 @@ 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", []) @@ -3027,7 +2392,9 @@ 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, @@ -3074,8 +2441,7 @@ def main() -> None: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc _print_workspace_exec_human(payload) - exit_code_raw = payload.get("exit_code", 1) - exit_code = exit_code_raw if isinstance(exit_code_raw, int) else 1 + exit_code = int(payload.get("exit_code", 1)) if exit_code != 0: raise SystemExit(exit_code) return @@ -3611,13 +2977,6 @@ 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): @@ -3633,17 +2992,7 @@ def main() -> None: print(f"Deleted workspace: {str(payload.get('workspace_id', 'unknown'))}") return if args.command == "doctor": - 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 + payload = doctor_report(platform=args.platform) if bool(args.json): _print_json(payload) else: @@ -3674,7 +3023,3 @@ def main() -> None: return result = run_demo(network=bool(args.network)) _print_json(result) - - -if __name__ == "__main__": - main() diff --git a/src/pyro_mcp/contract.py b/src/pyro_mcp/contract.py index a7b2ba1..714acf7 100644 --- a/src/pyro_mcp/contract.py +++ b/src/pyro_mcp/contract.py @@ -2,34 +2,11 @@ from __future__ import annotations -PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "host", "mcp", "prepare", "run", "workspace") +PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "mcp", "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 = ( - "--mode", - "--profile", - "--project-path", - "--repo-url", - "--repo-ref", - "--no-project-source", -) -PUBLIC_CLI_PREPARE_FLAGS = ("--network", "--force", "--json") +PUBLIC_CLI_MCP_SERVE_FLAGS = ("--profile",) PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = ( "create", "delete", @@ -48,7 +25,6 @@ PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = ( "start", "status", "stop", - "summary", "sync", "update", ) @@ -125,7 +101,6 @@ 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", @@ -144,7 +119,6 @@ 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", @@ -191,7 +165,6 @@ PUBLIC_SDK_METHODS = ( "stop_service", "stop_vm", "stop_workspace", - "summarize_workspace", "update_workspace", "write_shell", "write_workspace_file", @@ -236,7 +209,6 @@ PUBLIC_MCP_TOOLS = ( "workspace_logs", "workspace_patch_apply", "workspace_reset", - "workspace_summary", "workspace_start", "workspace_status", "workspace_stop", @@ -258,82 +230,8 @@ 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 diff --git a/src/pyro_mcp/daily_loop.py b/src/pyro_mcp/daily_loop.py deleted file mode 100644 index 164e1ba..0000000 --- a/src/pyro_mcp/daily_loop.py +++ /dev/null @@ -1,152 +0,0 @@ -"""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), - } diff --git a/src/pyro_mcp/daily_loop_smoke.py b/src/pyro_mcp/daily_loop_smoke.py deleted file mode 100644 index 4cd82c7..0000000 --- a/src/pyro_mcp/daily_loop_smoke.py +++ /dev/null @@ -1,131 +0,0 @@ -"""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() diff --git a/src/pyro_mcp/doctor.py b/src/pyro_mcp/doctor.py index 1de3ba3..296fedb 100644 --- a/src/pyro_mcp/doctor.py +++ b/src/pyro_mcp/doctor.py @@ -5,18 +5,16 @@ 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, environment=args.environment) + report = doctor_report(platform=args.platform) print(json.dumps(report, indent=2, sort_keys=True)) diff --git a/src/pyro_mcp/host_helpers.py b/src/pyro_mcp/host_helpers.py deleted file mode 100644 index dc06654..0000000 --- a/src/pyro_mcp/host_helpers.py +++ /dev/null @@ -1,370 +0,0 @@ -"""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), - ] diff --git a/src/pyro_mcp/project_startup.py b/src/pyro_mcp/project_startup.py deleted file mode 100644 index 102d631..0000000 --- a/src/pyro_mcp/project_startup.py +++ /dev/null @@ -1,149 +0,0 @@ -"""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}" diff --git a/src/pyro_mcp/runtime.py b/src/pyro_mcp/runtime.py index 24779a3..832e666 100644 --- a/src/pyro_mcp/runtime.py +++ b/src/pyro_mcp/runtime.py @@ -11,13 +11,6 @@ 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" @@ -207,11 +200,7 @@ def runtime_capabilities(paths: RuntimePaths) -> RuntimeCapabilities: ) -def doctor_report( - *, - platform: str = DEFAULT_PLATFORM, - environment: str = DEFAULT_PREPARE_ENVIRONMENT, -) -> dict[str, Any]: +def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]: """Build a runtime diagnostics report.""" report: dict[str, Any] = { "platform": platform, @@ -269,36 +258,6 @@ def doctor_report( "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 diff --git a/src/pyro_mcp/server.py b/src/pyro_mcp/server.py index 455e9d2..daf1820 100644 --- a/src/pyro_mcp/server.py +++ b/src/pyro_mcp/server.py @@ -2,11 +2,9 @@ from __future__ import annotations -from pathlib import Path - from mcp.server.fastmcp import FastMCP -from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode +from pyro_mcp.api import McpToolProfile, Pyro from pyro_mcp.vm_manager import VmManager @@ -14,29 +12,14 @@ 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. - Bare server creation uses the generic `workspace-core` path in 4.x. Use - `mode=...` for one of the named use-case surfaces, or + `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. By default, the server auto-detects the - nearest Git worktree root from its current working directory for - project-aware `workspace_create` calls. + advanced workspace surface. """ - 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, - ) + return Pyro(manager=manager).create_server(profile=profile) def main() -> None: diff --git a/src/pyro_mcp/vm_environments.py b/src/pyro_mcp/vm_environments.py index bd65b56..6419792 100644 --- a/src/pyro_mcp/vm_environments.py +++ b/src/pyro_mcp/vm_environments.py @@ -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.5.0" +DEFAULT_CATALOG_VERSION = "4.0.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.5.0,<5.0.0" + compatibility: str = ">=4.0.0,<5.0.0" @dataclass(frozen=True) diff --git a/src/pyro_mcp/vm_manager.py b/src/pyro_mcp/vm_manager.py index 669b9cd..e25be5f 100644 --- a/src/pyro_mcp/vm_manager.py +++ b/src/pyro_mcp/vm_manager.py @@ -24,15 +24,6 @@ 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, @@ -88,13 +79,12 @@ DEFAULT_TIMEOUT_SECONDS = 30 DEFAULT_TTL_SECONDS = 600 DEFAULT_ALLOW_HOST_COMPAT = False -WORKSPACE_LAYOUT_VERSION = 9 +WORKSPACE_LAYOUT_VERSION = 8 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" @@ -126,18 +116,7 @@ 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"] @@ -297,7 +276,9 @@ 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")), @@ -335,35 +316,6 @@ 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.""" @@ -551,7 +503,9 @@ 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)) ), ) @@ -570,8 +524,6 @@ 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 @@ -582,19 +534,14 @@ class PreparedWorkspaceSeed: *, destination: str = WORKSPACE_GUEST_PATH, path_key: str = "seed_path", - include_origin: bool = True, ) -> dict[str, Any]: - payload = { + return { "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: @@ -667,8 +614,6 @@ 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, @@ -683,8 +628,6 @@ 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"])), @@ -926,7 +869,9 @@ 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 @@ -954,7 +899,9 @@ 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: @@ -1044,7 +991,9 @@ 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"])) @@ -1524,7 +1473,9 @@ 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 @@ -1787,7 +1738,7 @@ def _start_local_service( ), "status=$?", f"printf '%s' \"$status\" > {shlex.quote(str(status_path))}", - 'exit "$status"', + "exit \"$status\"", ] ) + "\n", @@ -1970,7 +1921,9 @@ 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) @@ -3629,152 +3582,6 @@ 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, *, @@ -3940,23 +3747,19 @@ 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) - 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) + prepared_seed = 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) @@ -3965,7 +3768,6 @@ 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) @@ -4000,7 +3802,9 @@ 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) @@ -4081,7 +3885,6 @@ 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"]) @@ -4093,18 +3896,6 @@ 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, @@ -4186,8 +3977,8 @@ class VmManager: def export_workspace( self, workspace_id: str, - path: str, *, + path: str, output_path: str | Path, ) -> dict[str, Any]: normalized_path, _ = _normalize_workspace_destination(path) @@ -4219,23 +4010,6 @@ 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, @@ -4395,22 +4169,6 @@ 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, @@ -4528,15 +4286,6 @@ 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, @@ -4614,17 +4363,6 @@ 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), @@ -4658,11 +4396,6 @@ 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, @@ -4703,7 +4436,9 @@ 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) @@ -4897,7 +4632,9 @@ 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) @@ -5172,7 +4909,8 @@ 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( @@ -5259,24 +4997,6 @@ 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]: @@ -5396,18 +5116,6 @@ 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) @@ -5441,153 +5149,6 @@ 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) @@ -5635,7 +5196,9 @@ 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: @@ -5818,7 +5381,9 @@ 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, @@ -5989,7 +5554,9 @@ 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 @@ -6096,30 +5663,12 @@ 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, - *, - origin_kind: WorkspaceSeedOriginKind | None = None, - origin_ref: str | None = None, - ) -> PreparedWorkspaceSeed: + def _prepare_workspace_seed(self, seed_path: str | Path | None) -> PreparedWorkspaceSeed: if seed_path is None: - return PreparedWorkspaceSeed( - mode="empty", - source_path=None, - origin_kind="empty" if origin_kind is None else origin_kind, - origin_ref=origin_ref, - ) + return PreparedWorkspaceSeed(mode="empty", source_path=None) 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" @@ -6131,24 +5680,23 @@ class VmManager: raise return PreparedWorkspaceSeed( mode="directory", - source_path=public_source_path, - origin_kind=effective_origin_kind, - origin_ref=effective_origin_ref, + source_path=str(resolved_source_path), 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=public_source_path, - origin_kind=effective_origin_kind, - origin_ref=effective_origin_ref, + source_path=str(resolved_source_path), archive_path=resolved_source_path, entry_count=entry_count, bytes_written=bytes_written, @@ -6209,9 +5757,6 @@ 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 @@ -6227,9 +5772,6 @@ 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" @@ -6245,7 +5787,8 @@ 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 {rootfs_path}" + f"workspace {workspace.workspace_id!r} rootfs image is unavailable at " + f"{rootfs_path}" ) return rootfs_path @@ -6262,7 +5805,9 @@ 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( @@ -6421,46 +5966,6 @@ 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, @@ -6817,12 +6322,10 @@ 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) diff --git a/src/pyro_mcp/workspace_use_case_smokes.py b/src/pyro_mcp/workspace_use_case_smokes.py index b69a90d..01c1338 100644 --- a/src/pyro_mcp/workspace_use_case_smokes.py +++ b/src/pyro_mcp/workspace_use_case_smokes.py @@ -3,7 +3,6 @@ from __future__ import annotations import argparse -import asyncio import tempfile import time from dataclasses import dataclass @@ -29,7 +28,7 @@ USE_CASE_CHOICES: Final[tuple[str, ...]] = USE_CASE_SCENARIOS + (USE_CASE_ALL_SC class WorkspaceUseCaseRecipe: scenario: str title: str - mode: Literal["repro-fix", "inspect", "cold-start", "review-eval"] + profile: Literal["workspace-core", "workspace-full"] smoke_target: str doc_path: str summary: str @@ -39,7 +38,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = ( WorkspaceUseCaseRecipe( scenario="cold-start-validation", title="Cold-Start Repo Validation", - mode="cold-start", + profile="workspace-full", smoke_target="smoke-cold-start-validation", doc_path="docs/use-cases/cold-start-repo-validation.md", summary=( @@ -50,7 +49,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = ( WorkspaceUseCaseRecipe( scenario="repro-fix-loop", title="Repro Plus Fix Loop", - mode="repro-fix", + profile="workspace-core", smoke_target="smoke-repro-fix-loop", doc_path="docs/use-cases/repro-fix-loop.md", summary=( @@ -61,7 +60,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = ( WorkspaceUseCaseRecipe( scenario="parallel-workspaces", title="Parallel Isolated Workspaces", - mode="repro-fix", + profile="workspace-core", smoke_target="smoke-parallel-workspaces", doc_path="docs/use-cases/parallel-workspaces.md", summary=( @@ -72,7 +71,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = ( WorkspaceUseCaseRecipe( scenario="untrusted-inspection", title="Unsafe Or Untrusted Code Inspection", - mode="inspect", + profile="workspace-core", smoke_target="smoke-untrusted-inspection", doc_path="docs/use-cases/untrusted-inspection.md", summary=( @@ -83,7 +82,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = ( WorkspaceUseCaseRecipe( scenario="review-eval", title="Review And Evaluation Workflows", - mode="review-eval", + profile="workspace-full", smoke_target="smoke-review-eval", doc_path="docs/use-cases/review-eval-workflows.md", summary=( @@ -108,15 +107,6 @@ 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, *, @@ -136,31 +126,6 @@ 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 @@ -195,19 +160,14 @@ def _scenario_cold_start_validation(pyro: Pyro, *, root: Path, environment: str) ) workspace_id: str | None = None try: - created = _create_project_aware_workspace( + workspace_id = _create_workspace( pyro, environment=environment, - project_path=seed_dir, - mode="cold-start", + seed_path=seed_dir, 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 @@ -261,20 +221,14 @@ def _scenario_repro_fix_loop(pyro: Pyro, *, root: Path, environment: str) -> Non ) workspace_id: str | None = None try: - created = _create_project_aware_workspace( + workspace_id = _create_workspace( pyro, environment=environment, - project_path=seed_dir, - mode="repro-fix", + seed_path=seed_dir, 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") @@ -464,11 +418,6 @@ 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: @@ -502,7 +451,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}) mode={recipe.mode}") + _log(f"starting {recipe.scenario} ({recipe.title}) profile={recipe.profile}") scenario_root = root / scenario_name scenario_root.mkdir(parents=True, exist_ok=True) runner = _SCENARIO_RUNNERS[scenario_name] diff --git a/tests/test_api.py b/tests/test_api.py index 56b461f..8772754 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,19 +1,12 @@ 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, @@ -22,28 +15,6 @@ 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( @@ -163,172 +134,6 @@ 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( @@ -575,7 +380,6 @@ 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) @@ -612,9 +416,6 @@ 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 @@ -1178,14 +979,6 @@ 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, @@ -1317,9 +1110,6 @@ 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"}) ) @@ -1421,7 +1211,6 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non ) return ( status, - summary, logs, opened, read, @@ -1436,15 +1225,13 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non results = asyncio.run(_run()) assert results[0]["state"] == "started" - 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 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 calls == [ ("status_workspace", {"workspace_id": "workspace-123"}), - ("summarize_workspace", {"workspace_id": "workspace-123"}), ("logs_workspace", {"workspace_id": "workspace-123"}), ( "open_shell", diff --git a/tests/test_cli.py b/tests/test_cli.py index ecb6e74..aaef8d1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,7 +9,6 @@ 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: @@ -27,24 +26,17 @@ def test_cli_help_guides_first_run() -> None: parser = cli._build_parser() help_text = parser.format_help() - assert "Suggested zero-to-hero path:" in help_text + assert "Suggested first run:" in help_text assert "pyro doctor" in help_text - assert "pyro prepare debian:12" in help_text + assert "pyro env list" in help_text + assert "pyro env pull debian:12" in help_text assert "pyro run debian:12 -- git --version" 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 "Continue into the stable workspace path after that:" 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: @@ -62,41 +54,8 @@ 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() @@ -110,22 +69,17 @@ 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: 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 + assert "workspace-full: advanced 4.x opt-in surface" in mcp_help workspace_help = _subparser_choice(parser, "workspace").format_help() - assert "Use the workspace model when you need one sandbox to stay alive" in workspace_help + assert "stable workspace contract" 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 @@ -137,7 +91,6 @@ 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( @@ -192,12 +145,6 @@ 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() @@ -360,94 +307,6 @@ 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], @@ -485,22 +344,13 @@ def test_cli_doctor_prints_json( ) -> None: class StubParser: def parse_args(self) -> argparse.Namespace: - return argparse.Namespace( - command="doctor", - platform="linux-x86_64", - environment="debian:12", - json=True, - ) + return argparse.Namespace(command="doctor", platform="linux-x86_64", json=True) monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) monkeypatch.setattr( cli, "doctor_report", - lambda *, platform, environment: { - "platform": platform, - "environment": environment, - "runtime_ok": True, - }, + lambda platform: {"platform": platform, "runtime_ok": True}, ) cli.main() output = json.loads(capsys.readouterr().out) @@ -719,7 +569,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: @@ -995,7 +845,10 @@ 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( @@ -1466,7 +1319,13 @@ 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: @@ -1914,7 +1773,10 @@ 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 @@ -2361,7 +2223,8 @@ 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 @@ -2528,168 +2391,6 @@ 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], @@ -3104,7 +2805,7 @@ def test_cli_workspace_shell_open_prints_id_only( assert captured.err == "" -def test_chat_host_docs_and_examples_recommend_modes_first() -> None: +def test_chat_host_docs_and_examples_recommend_workspace_core() -> 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") @@ -3113,81 +2814,47 @@ def test_chat_host_docs_and_examples_recommend_modes_first() -> 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_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" + 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" assert "## Chat Host Quickstart" 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 "uvx --from pyro-mcp pyro mcp serve" in readme + assert claude_cmd in readme + assert codex_cmd in readme assert "examples/opencode_mcp_config.json" 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 "recommended first profile for normal persistent chat editing" in readme - 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 "## 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 "workspace-full" in install - assert "--project-path /abs/path/to/repo" in install - assert "pyro mcp serve --mode cold-start" in install - 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 claude_cmd in first_run + assert codex_cmd in first_run - 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 "Bare `pyro mcp serve` now starts `workspace-core`." 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 "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 ( + '`Pyro.create_server()` for most chat hosts now that `workspace-core` ' + "is the default profile" in integrations + ) - assert "Recommended named modes for most chat hosts in `4.x`:" in mcp_config + assert "Default for most chat hosts in `4.x`: `workspace-core`." 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": { @@ -3201,8 +2868,6 @@ def test_chat_host_docs_and_examples_recommend_modes_first() -> None: "pyro", "mcp", "serve", - "--mode", - "repro-fix", ], } } @@ -3217,32 +2882,7 @@ 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 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 + assert 'workspace disk read "$WORKSPACE_ID" note.txt --content-only' in first_run def test_cli_workspace_shell_write_signal_close_json( @@ -4325,163 +3965,22 @@ def test_cli_doctor_prints_human( ) -> None: class StubParser: def parse_args(self) -> argparse.Namespace: - return argparse.Namespace( - command="doctor", - platform="linux-x86_64", - environment="debian:12", - json=False, - ) + return argparse.Namespace(command="doctor", platform="linux-x86_64", json=False) monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) monkeypatch.setattr( cli, "doctor_report", - lambda *, platform, environment: { + lambda platform: { "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( @@ -4518,25 +4017,11 @@ def test_cli_run_json_error_exits_nonzero( def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None: - observed: dict[str, Any] = {} + observed: dict[str, str] = {} class StubPyro: - 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: + def create_server(self, *, profile: str) -> 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", (), @@ -4545,29 +4030,12 @@ 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", - mode=None, - project_path="/repo", - repo_url=None, - repo_ref=None, - no_project_source=False, - ) + return argparse.Namespace(command="mcp", mcp_command="serve", profile="workspace-core") monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) monkeypatch.setattr(cli, "Pyro", StubPyro) cli.main() - assert observed == { - "profile": "workspace-core", - "mode": None, - "project_path": "/repo", - "repo_url": None, - "repo_ref": None, - "no_project_source": False, - "transport": "stdio", - } + assert observed == {"profile": "workspace-core", "transport": "stdio"} def test_cli_demo_default_prints_json( @@ -4685,7 +4153,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": ""} diff --git a/tests/test_daily_loop.py b/tests/test_daily_loop.py deleted file mode 100644 index b511158..0000000 --- a/tests/test_daily_loop.py +++ /dev/null @@ -1,359 +0,0 @@ -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 diff --git a/tests/test_daily_loop_smoke.py b/tests/test_daily_loop_smoke.py deleted file mode 100644 index 2d75fbc..0000000 --- a/tests/test_daily_loop_smoke.py +++ /dev/null @@ -1,138 +0,0 @@ -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"] diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 3c3196e..9629771 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -15,18 +15,13 @@ def test_doctor_main_prints_json( ) -> None: class StubParser: def parse_args(self) -> argparse.Namespace: - return argparse.Namespace(platform="linux-x86_64", environment="debian:12") + return argparse.Namespace(platform="linux-x86_64") monkeypatch.setattr(doctor_module, "_build_parser", lambda: StubParser()) monkeypatch.setattr( doctor_module, "doctor_report", - lambda *, platform, environment: { - "platform": platform, - "environment": environment, - "runtime_ok": True, - "issues": [], - }, + lambda platform: {"platform": platform, "runtime_ok": True, "issues": []}, ) doctor_module.main() output = json.loads(capsys.readouterr().out) @@ -37,4 +32,3 @@ 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" diff --git a/tests/test_host_helpers.py b/tests/test_host_helpers.py deleted file mode 100644 index 2255208..0000000 --- a/tests/test_host_helpers.py +++ /dev/null @@ -1,501 +0,0 @@ -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"]] diff --git a/tests/test_project_startup.py b/tests/test_project_startup.py deleted file mode 100644 index fd8a6b1..0000000 --- a/tests/test_project_startup.py +++ /dev/null @@ -1,236 +0,0 @@ -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 diff --git a/tests/test_public_contract.py b/tests/test_public_contract.py index 2f5385a..5033d4a 100644 --- a/tests/test_public_contract.py +++ b/tests/test_public_contract.py @@ -15,16 +15,9 @@ 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, @@ -60,16 +53,10 @@ 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, ) @@ -115,35 +102,6 @@ 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 @@ -152,8 +110,6 @@ 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: @@ -288,11 +244,6 @@ 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", @@ -387,14 +338,6 @@ 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"] diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 60ebc4b..a2b9004 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -6,32 +6,7 @@ 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: @@ -134,7 +109,6 @@ 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) @@ -148,61 +122,6 @@ 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) diff --git a/tests/test_server.py b/tests/test_server.py index 1ad39b9..1481149 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import subprocess from pathlib import Path from typing import Any, cast @@ -9,8 +8,6 @@ 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, ) @@ -19,28 +16,6 @@ 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", @@ -87,151 +62,6 @@ 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", @@ -634,9 +464,6 @@ 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", @@ -674,7 +501,6 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None: service_status, service_logs, service_stopped, - summary, reset, deleted_snapshot, logs, @@ -700,7 +526,6 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None: service_status, service_logs, service_stopped, - summary, reset, deleted_snapshot, logs, @@ -737,10 +562,6 @@ 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 diff --git a/tests/test_vm_manager.py b/tests/test_vm_manager.py index 73fc74f..22e953c 100644 --- a/tests/test_vm_manager.py +++ b/tests/test_vm_manager.py @@ -699,124 +699,6 @@ 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() diff --git a/tests/test_workspace_ports.py b/tests/test_workspace_ports.py index 54a3fbe..d63cfae 100644 --- a/tests/test_workspace_ports.py +++ b/tests/test_workspace_ports.py @@ -15,13 +15,6 @@ 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) @@ -57,26 +50,18 @@ def test_workspace_port_proxy_handler_ignores_upstream_connect_failure( def test_workspace_port_proxy_forwards_tcp_traffic() -> None: - 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 = socketserver.ThreadingTCPServer( + (workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0), + _EchoHandler, + ) 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]) - 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 = workspace_ports._ProxyServer( # noqa: SLF001 + (workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0), + (upstream_host, upstream_port), + ) proxy_thread = threading.Thread(target=proxy.serve_forever, daemon=True) proxy_thread.start() try: @@ -217,8 +202,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 = _socketpair_or_skip() - upstream, upstream_peer = _socketpair_or_skip() + source, source_peer = socket.socketpair() + upstream, upstream_peer = socket.socketpair() source_peer.close() class FakeSelector: @@ -261,17 +246,10 @@ def test_workspace_port_proxy_handler_handles_recv_and_send_errors( monkeypatch: Any, ) -> None: def _run_once(*, close_source: bool) -> None: - source, source_peer = _socketpair_or_skip() - upstream, upstream_peer = _socketpair_or_skip() + source, source_peer = socket.socketpair() + upstream, upstream_peer = socket.socketpair() if not close_source: - 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}") + source_peer.sendall(b"hello") class FakeSelector: def register(self, *_args: Any, **_kwargs: Any) -> None: diff --git a/tests/test_workspace_use_case_smokes.py b/tests/test_workspace_use_case_smokes.py index f369587..1a5a836 100644 --- a/tests/test_workspace_use_case_smokes.py +++ b/tests/test_workspace_use_case_smokes.py @@ -391,69 +391,6 @@ 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 @@ -499,43 +436,6 @@ 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 = ( @@ -561,7 +461,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.mode in recipe_text + assert recipe.profile in recipe_text assert recipe.smoke_target in recipe_text assert f"{recipe.smoke_target}:" in makefile_text diff --git a/uv.lock b/uv.lock index 14f3147..95bd68b 100644 --- a/uv.lock +++ b/uv.lock @@ -715,7 +715,7 @@ crypto = [ [[package]] name = "pyro-mcp" -version = "4.5.0" +version = "4.0.0" source = { editable = "." } dependencies = [ { name = "mcp" },