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.
This commit is contained in:
Thales Maciel 2026-03-12 05:36:28 -03:00
parent 84a7e18d4d
commit f504f0a331
28 changed files with 4098 additions and 124 deletions

View file

@ -2,6 +2,16 @@
All notable user-visible changes to `pyro-mcp` are documented here.
## 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

View file

@ -20,7 +20,7 @@ It exposes the same runtime in three public forms:
- First run transcript: [docs/first-run.md](docs/first-run.md)
- Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif)
- PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/)
- What's new in 2.6.0: [CHANGELOG.md#260](CHANGELOG.md#260)
- What's new in 2.7.0: [CHANGELOG.md#270](CHANGELOG.md#270)
- Host requirements: [docs/host-requirements.md](docs/host-requirements.md)
- Integration targets: [docs/integrations.md](docs/integrations.md)
- Public contract: [docs/public-contract.md](docs/public-contract.md)
@ -57,7 +57,7 @@ What success looks like:
```bash
Platform: linux-x86_64
Runtime: PASS
Catalog version: 2.6.0
Catalog version: 2.7.0
...
[pull] phase=install environment=debian:12
[pull] phase=ready environment=debian:12
@ -81,6 +81,7 @@ After the quickstart works:
- diff the live workspace against its create-time baseline with `uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID`
- export a changed file or directory with `uvx --from pyro-mcp pyro workspace export WORKSPACE_ID note.txt --output ./note.txt`
- open a persistent interactive shell with `uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID`
- start long-running workspace services with `uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done'`
- move to Python or MCP via [docs/integrations.md](docs/integrations.md)
## Supported Hosts
@ -134,7 +135,7 @@ uvx --from pyro-mcp pyro env list
Expected output:
```bash
Catalog version: 2.6.0
Catalog version: 2.7.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.
@ -218,6 +219,13 @@ pyro workspace shell open WORKSPACE_ID
pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'
pyro workspace shell read WORKSPACE_ID SHELL_ID
pyro workspace shell close WORKSPACE_ID SHELL_ID
pyro workspace service start WORKSPACE_ID web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done'
pyro workspace service list WORKSPACE_ID
pyro workspace service status WORKSPACE_ID web
pyro workspace service logs WORKSPACE_ID web --tail-lines 50
pyro workspace service stop WORKSPACE_ID web
pyro workspace service stop WORKSPACE_ID worker
pyro workspace logs WORKSPACE_ID
pyro workspace delete WORKSPACE_ID
```
@ -226,12 +234,16 @@ Persistent workspaces start in `/workspace` and keep command history until you d
machine consumption, add `--json` and read the returned `workspace_id`. Use `--seed-path` when
you want the workspace to start from a host directory or a local `.tar` / `.tar.gz` / `.tgz`
archive instead of an empty workspace. Use `pyro workspace sync push` when you want to import
later host-side changes into a started workspace. Sync is non-atomic in `2.6.0`; if it fails
later host-side changes into a started workspace. Sync is non-atomic in `2.7.0`; if it fails
partway through, delete and recreate the workspace from its seed. Use `pyro workspace diff` to
compare the live `/workspace` tree to its immutable create-time baseline, and `pyro workspace export`
to copy one changed file or directory back to the host. Use `pyro workspace exec` for one-shot
non-interactive commands inside a live workspace, and `pyro workspace shell *` when you need a
persistent PTY session that keeps interactive shell state between calls.
persistent PTY session that keeps interactive shell state between calls. Use
`pyro workspace service *` when the workspace needs one or more long-running background processes.
Typed readiness checks prefer `--ready-file`, `--ready-tcp`, or `--ready-http`; keep
`--ready-command` as the escape hatch. Service metadata and logs live outside `/workspace`, so the
internal service state does not appear in `pyro workspace diff` or `pyro workspace export`.
## Public Interfaces

View file

@ -22,7 +22,7 @@ Networking: tun=yes ip_forward=yes
```bash
$ uvx --from pyro-mcp pyro env list
Catalog version: 2.6.0
Catalog version: 2.7.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.
@ -75,6 +75,7 @@ $ uvx --from pyro-mcp pyro workspace sync push WORKSPACE_ID ./changes
$ uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID
$ uvx --from pyro-mcp pyro workspace export WORKSPACE_ID note.txt --output ./note.txt
$ uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID
$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done'
$ uvx --from pyro-mcp pyro mcp serve
```
@ -118,16 +119,52 @@ $ uvx --from pyro-mcp pyro workspace shell write WORKSPACE_ID SHELL_ID --input '
$ uvx --from pyro-mcp pyro workspace shell read WORKSPACE_ID SHELL_ID
/workspace
[workspace-shell-read] workspace_id=... shell_id=... state=running cursor=0 next_cursor=... truncated=False execution_mode=guest_vsock
$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
[workspace-service-start] workspace_id=... service=web state=running cwd=/workspace ready_type=file execution_mode=guest_vsock
$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done'
[workspace-service-start] workspace_id=... service=worker state=running cwd=/workspace ready_type=file execution_mode=guest_vsock
$ uvx --from pyro-mcp pyro workspace service list WORKSPACE_ID
Workspace: ...
Services: 2 total, 2 running
- web [running] cwd=/workspace readiness=file
- worker [running] cwd=/workspace readiness=file
$ uvx --from pyro-mcp pyro workspace service status WORKSPACE_ID web
Workspace: ...
Service: web
State: running
Command: sh -lc 'touch .web-ready && while true; do sleep 60; done'
Cwd: /workspace
Readiness: file /workspace/.web-ready
Execution mode: guest_vsock
$ uvx --from pyro-mcp pyro workspace service logs WORKSPACE_ID web --tail-lines 50
Workspace: ...
Service: web
State: running
...
$ uvx --from pyro-mcp pyro workspace service stop WORKSPACE_ID web
[workspace-service-stop] workspace_id=... service=web state=stopped execution_mode=guest_vsock
$ uvx --from pyro-mcp pyro workspace service stop WORKSPACE_ID worker
[workspace-service-stop] workspace_id=... service=worker state=stopped execution_mode=guest_vsock
```
Use `--seed-path` when the workspace should start from a host directory or a local
`.tar` / `.tar.gz` / `.tgz` archive instead of an empty `/workspace`. Use
`pyro workspace sync push` when you need to import later host-side changes into a started
workspace. Sync is non-atomic in `2.6.0`; if it fails partway through, delete and recreate the
workspace. Sync is non-atomic in `2.7.0`; if it fails partway through, delete and recreate the
workspace. Use `pyro workspace diff` to compare the current `/workspace` tree to its immutable
create-time baseline, and `pyro workspace export` to copy one changed file or directory back to
the host. Use `pyro workspace exec` for one-shot commands and `pyro workspace shell *` when you
need a persistent interactive PTY session in that same workspace.
need a persistent interactive PTY session in that same workspace. Use `pyro workspace service *`
when the workspace needs long-running background processes with typed readiness checks. Internal
service state and logs stay outside `/workspace`, so service runtime data does not appear in
workspace diff or export results.
Example output:

View file

@ -83,7 +83,7 @@ uvx --from pyro-mcp pyro env list
Expected output:
```bash
Catalog version: 2.6.0
Catalog version: 2.7.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.
@ -179,6 +179,7 @@ After the CLI path works, you can move on to:
- baseline diff: `pyro workspace diff WORKSPACE_ID`
- host export: `pyro workspace export WORKSPACE_ID note.txt --output ./note.txt`
- interactive shells: `pyro workspace shell open WORKSPACE_ID`
- long-running services: `pyro workspace service start WORKSPACE_ID app --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done'`
- MCP: `pyro mcp serve`
- Python SDK: `from pyro_mcp import Pyro`
- Demos: `pyro demo` or `pyro demo --network`
@ -197,6 +198,13 @@ pyro workspace shell open WORKSPACE_ID
pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'
pyro workspace shell read WORKSPACE_ID SHELL_ID
pyro workspace shell close WORKSPACE_ID SHELL_ID
pyro workspace service start WORKSPACE_ID web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done'
pyro workspace service list WORKSPACE_ID
pyro workspace service status WORKSPACE_ID web
pyro workspace service logs WORKSPACE_ID web --tail-lines 50
pyro workspace service stop WORKSPACE_ID web
pyro workspace service stop WORKSPACE_ID worker
pyro workspace logs WORKSPACE_ID
pyro workspace delete WORKSPACE_ID
```
@ -205,11 +213,14 @@ Workspace commands default to the persistent `/workspace` directory inside the g
the identifier programmatically, use `--json` and read the `workspace_id` field. Use `--seed-path`
when the workspace should start from a host directory or a local `.tar` / `.tar.gz` / `.tgz`
archive. Use `pyro workspace sync push` for later host-side changes to a started workspace. Sync
is non-atomic in `2.6.0`; if it fails partway through, delete and recreate the workspace from its
is non-atomic in `2.7.0`; if it fails partway through, delete and recreate the workspace from its
seed. Use `pyro workspace diff` to compare the current workspace tree to its immutable create-time
baseline, and `pyro workspace export` to copy one changed file or directory back to the host. Use
`pyro workspace exec` for one-shot commands and `pyro workspace shell *` when you need an
interactive PTY that survives across separate calls.
interactive PTY that survives across separate calls. Use `pyro workspace service *` when the
workspace needs long-running background processes with typed readiness probes. Service metadata and
logs stay outside `/workspace`, so the service runtime itself does not show up in workspace diff or
export results.
## Contributor Clone

View file

@ -32,6 +32,7 @@ Recommended surface:
- `vm_run`
- `workspace_create(seed_path=...)` + `workspace_sync_push` + `workspace_exec` when the agent needs persistent workspace state
- `workspace_diff` + `workspace_export` when the agent needs explicit baseline comparison or host-out file transfer
- `start_service` / `list_services` / `status_service` / `logs_service` / `stop_service` when the agent needs long-running processes inside that workspace
- `open_shell` / `read_shell` / `write_shell` when the agent needs an interactive PTY inside that workspace
Canonical example:
@ -69,6 +70,7 @@ Recommended default:
- `Pyro.run_in_vm(...)`
- `Pyro.create_workspace(seed_path=...)` + `Pyro.push_workspace_sync(...)` + `Pyro.exec_workspace(...)` when repeated workspace commands are required
- `Pyro.diff_workspace(...)` + `Pyro.export_workspace(...)` when the agent needs baseline comparison or host-out file transfer
- `Pyro.start_service(...)` + `Pyro.list_services(...)` + `Pyro.logs_service(...)` when the agent needs long-running background processes in one workspace
- `Pyro.open_shell(...)` + `Pyro.write_shell(...)` + `Pyro.read_shell(...)` when the agent needs an interactive PTY inside the workspace
Lifecycle note:
@ -83,6 +85,8 @@ Lifecycle note:
- use `diff_workspace(...)` when the agent needs a structured comparison against the immutable
create-time baseline
- use `export_workspace(...)` when the agent needs one file or directory copied back to the host
- use `start_service(...)` when the agent needs long-running processes and typed readiness inside
one workspace
- use `open_shell(...)` when the agent needs interactive shell state instead of one-shot execs
Examples:

View file

@ -24,6 +24,11 @@ Top-level commands:
- `pyro workspace exec`
- `pyro workspace export`
- `pyro workspace diff`
- `pyro workspace service start`
- `pyro workspace service list`
- `pyro workspace service status`
- `pyro workspace service logs`
- `pyro workspace service stop`
- `pyro workspace shell open`
- `pyro workspace shell read`
- `pyro workspace shell write`
@ -58,10 +63,12 @@ Behavioral guarantees:
- `pyro workspace sync push WORKSPACE_ID SOURCE_PATH [--dest WORKSPACE_PATH]` imports later host-side directory or archive content into a started workspace.
- `pyro workspace export WORKSPACE_ID PATH --output HOST_PATH` exports one file or directory from `/workspace` back to the host.
- `pyro workspace diff WORKSPACE_ID` compares the current `/workspace` tree to the immutable create-time baseline.
- `pyro workspace service *` manages long-running named services inside one started workspace with typed readiness probes.
- `pyro workspace exec` runs in the persistent `/workspace` for that workspace and does not auto-clean.
- `pyro workspace shell *` manages persistent PTY sessions inside a started workspace.
- `pyro workspace logs` returns persisted command history for that workspace until `pyro workspace delete`.
- Workspace create/status results expose `workspace_seed` metadata describing how `/workspace` was initialized.
- `pyro workspace status` includes aggregate `service_count` and `running_service_count` fields.
## Python SDK Contract
@ -82,6 +89,11 @@ Supported public entrypoints:
- `Pyro.push_workspace_sync(workspace_id, source_path, *, dest="/workspace")`
- `Pyro.export_workspace(workspace_id, path, *, output_path)`
- `Pyro.diff_workspace(workspace_id)`
- `Pyro.start_service(workspace_id, service_name, *, command, cwd="/workspace", readiness=None, ready_timeout_seconds=30, ready_interval_ms=500)`
- `Pyro.list_services(workspace_id)`
- `Pyro.status_service(workspace_id, service_name)`
- `Pyro.logs_service(workspace_id, service_name, *, tail_lines=200, all=False)`
- `Pyro.stop_service(workspace_id, service_name)`
- `Pyro.open_shell(workspace_id, *, cwd="/workspace", cols=120, rows=30)`
- `Pyro.read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536)`
- `Pyro.write_shell(workspace_id, shell_id, *, input, append_newline=True)`
@ -112,6 +124,11 @@ Stable public method names:
- `push_workspace_sync(workspace_id, source_path, *, dest="/workspace")`
- `export_workspace(workspace_id, path, *, output_path)`
- `diff_workspace(workspace_id)`
- `start_service(workspace_id, service_name, *, command, cwd="/workspace", readiness=None, ready_timeout_seconds=30, ready_interval_ms=500)`
- `list_services(workspace_id)`
- `status_service(workspace_id, service_name)`
- `logs_service(workspace_id, service_name, *, tail_lines=200, all=False)`
- `stop_service(workspace_id, service_name)`
- `open_shell(workspace_id, *, cwd="/workspace", cols=120, rows=30)`
- `read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536)`
- `write_shell(workspace_id, shell_id, *, input, append_newline=True)`
@ -140,6 +157,8 @@ Behavioral defaults:
- `Pyro.push_workspace_sync(...)` imports later host-side directory or archive content into a started workspace.
- `Pyro.export_workspace(...)` exports one file or directory from `/workspace` to an explicit host path.
- `Pyro.diff_workspace(...)` compares the current `/workspace` tree to the immutable create-time baseline.
- `Pyro.start_service(...)` starts one named long-running process in a started workspace and waits for its typed readiness probe when configured.
- `Pyro.list_services(...)`, `Pyro.status_service(...)`, `Pyro.logs_service(...)`, and `Pyro.stop_service(...)` manage those persisted workspace services.
- `Pyro.exec_vm(...)` runs one command and auto-cleans that VM after the exec completes.
- `Pyro.exec_workspace(...)` runs one command in the persistent workspace and leaves it alive.
- `Pyro.open_shell(...)` opens a persistent PTY shell attached to one started workspace.
@ -171,6 +190,11 @@ Persistent workspace tools:
- `workspace_exec`
- `workspace_export`
- `workspace_diff`
- `service_start`
- `service_list`
- `service_status`
- `service_logs`
- `service_stop`
- `shell_open`
- `shell_read`
- `shell_write`
@ -190,6 +214,7 @@ Behavioral defaults:
- `workspace_sync_push` imports later host-side directory or archive content into a started workspace, with an optional `dest` under `/workspace`.
- `workspace_export` exports one file or directory from `/workspace` to an explicit host path.
- `workspace_diff` compares the current `/workspace` tree to the immutable create-time baseline.
- `service_start`, `service_list`, `service_status`, `service_logs`, and `service_stop` manage persistent named services inside a started workspace.
- `vm_exec` runs one command and auto-cleans that VM after the exec completes.
- `workspace_exec` runs one command in a persistent `/workspace` and leaves the workspace alive.
- `shell_open`, `shell_read`, `shell_write`, `shell_signal`, and `shell_close` manage persistent PTY shells inside a started workspace.

View file

@ -2,13 +2,14 @@
This roadmap turns the agent-workspace vision into release-sized milestones.
Current baseline is `2.6.0`:
Current baseline is `2.7.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
- no service, snapshot, reset, or secrets contract exists yet
- multi-service lifecycle exists with typed readiness and aggregate workspace status counts
- no snapshot, reset, or secrets contract exists yet
Locked roadmap decisions:
@ -30,7 +31,7 @@ also expected to update:
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)
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)
6. [`2.9.0` Secrets](task-workspace-ga/2.9.0-secrets.md)
7. [`2.10.0` Network Policy And Host Port Publication](task-workspace-ga/2.10.0-network-policy-and-host-port-publication.md)

View file

@ -1,5 +1,7 @@
# `2.7.0` Service Lifecycle And Typed Readiness
Status: Done
## Goal
Make app-style workspaces practical by adding first-class services and typed

View file

@ -26,6 +26,19 @@ def main() -> None:
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="")
pyro.start_service(
workspace_id,
"web",
command="touch .web-ready && while true; do sleep 60; done",
readiness={"type": "file", "path": ".web-ready"},
)
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']}")
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")
logs = pyro.logs_workspace(workspace_id)
print(f"workspace_id={workspace_id} command_count={logs['count']}")
finally:

View file

@ -1,6 +1,6 @@
[project]
name = "pyro-mcp"
version = "2.6.0"
version = "2.7.0"
description = "Ephemeral Firecracker sandboxes with curated environments, persistent workspaces, and MCP tools."
readme = "README.md"
license = { file = "LICENSE" }

View file

@ -8,7 +8,8 @@ import fcntl
import io
import json
import os
import pty
import re
import shlex
import signal
import socket
import struct
@ -18,6 +19,8 @@ import tempfile
import termios
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path, PurePosixPath
from typing import Any
@ -25,6 +28,8 @@ PORT = 5005
BUFFER_SIZE = 65536
WORKSPACE_ROOT = PurePosixPath("/workspace")
SHELL_ROOT = Path("/run/pyro-shells")
SERVICE_ROOT = Path("/run/pyro-services")
SERVICE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
SHELL_SIGNAL_MAP = {
"HUP": signal.SIGHUP,
"INT": signal.SIGINT,
@ -105,6 +110,35 @@ def _normalize_shell_cwd(cwd: str) -> tuple[str, Path]:
return str(normalized), host_path
def _normalize_service_name(service_name: str) -> str:
normalized = service_name.strip()
if normalized == "":
raise RuntimeError("service_name is required")
if SERVICE_NAME_RE.fullmatch(normalized) is None:
raise RuntimeError("service_name is invalid")
return normalized
def _service_stdout_path(service_name: str) -> Path:
return SERVICE_ROOT / f"{service_name}.stdout"
def _service_stderr_path(service_name: str) -> Path:
return SERVICE_ROOT / f"{service_name}.stderr"
def _service_status_path(service_name: str) -> Path:
return SERVICE_ROOT / f"{service_name}.status"
def _service_runner_path(service_name: str) -> Path:
return SERVICE_ROOT / f"{service_name}.runner.sh"
def _service_metadata_path(service_name: str) -> Path:
return SERVICE_ROOT / f"{service_name}.json"
def _validate_symlink_target(member_path: PurePosixPath, link_target: str) -> None:
target = link_target.strip()
if target == "":
@ -286,7 +320,7 @@ class GuestShellSession:
self._log_path = SHELL_ROOT / f"{shell_id}.log"
self._master_fd: int | None = None
master_fd, slave_fd = pty.openpty()
master_fd, slave_fd = os.openpty()
try:
_set_pty_size(slave_fd, rows, cols)
env = os.environ.copy()
@ -512,6 +546,268 @@ def _remove_shell(shell_id: str) -> GuestShellSession:
raise RuntimeError(f"shell {shell_id!r} does not exist") from exc
def _read_service_metadata(service_name: str) -> dict[str, Any]:
metadata_path = _service_metadata_path(service_name)
if not metadata_path.exists():
raise RuntimeError(f"service {service_name!r} does not exist")
payload = json.loads(metadata_path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise RuntimeError(f"service record for {service_name!r} is invalid")
return payload
def _write_service_metadata(service_name: str, payload: dict[str, Any]) -> None:
_service_metadata_path(service_name).write_text(
json.dumps(payload, indent=2, sort_keys=True),
encoding="utf-8",
)
def _service_exit_code(service_name: str) -> int | None:
status_path = _service_status_path(service_name)
if not status_path.exists():
return None
raw_value = status_path.read_text(encoding="utf-8", errors="ignore").strip()
if raw_value == "":
return None
return int(raw_value)
def _service_pid_running(pid: int | None) -> bool:
if pid is None:
return False
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def _tail_service_text(path: Path, *, tail_lines: int | None) -> tuple[str, bool]:
if not path.exists():
return "", False
text = path.read_text(encoding="utf-8", errors="replace")
if tail_lines is None:
return text, False
lines = text.splitlines(keepends=True)
if len(lines) <= tail_lines:
return text, False
return "".join(lines[-tail_lines:]), True
def _stop_service_process(pid: int) -> tuple[bool, bool]:
try:
os.killpg(pid, signal.SIGTERM)
except ProcessLookupError:
return False, False
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if not _service_pid_running(pid):
return True, False
time.sleep(0.1)
try:
os.killpg(pid, signal.SIGKILL)
except ProcessLookupError:
return True, False
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if not _service_pid_running(pid):
return True, True
time.sleep(0.1)
return True, True
def _refresh_service_payload(service_name: str, payload: dict[str, Any]) -> dict[str, Any]:
if str(payload.get("state", "stopped")) != "running":
return payload
pid = payload.get("pid")
normalized_pid = None if pid is None else int(pid)
if _service_pid_running(normalized_pid):
return payload
refreshed = dict(payload)
refreshed["state"] = "exited"
refreshed["ended_at"] = refreshed.get("ended_at") or time.time()
refreshed["exit_code"] = _service_exit_code(service_name)
_write_service_metadata(service_name, refreshed)
return refreshed
def _run_readiness_probe(readiness: dict[str, Any] | None, *, cwd: Path) -> bool:
if readiness is None:
return True
readiness_type = str(readiness["type"])
if readiness_type == "file":
_, ready_path = _normalize_destination(str(readiness["path"]))
return ready_path.exists()
if readiness_type == "tcp":
host, raw_port = str(readiness["address"]).rsplit(":", 1)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(1)
try:
sock.connect((host, int(raw_port)))
except OSError:
return False
return True
if readiness_type == "http":
request = urllib.request.Request(str(readiness["url"]), method="GET")
try:
with urllib.request.urlopen(request, timeout=2) as response: # noqa: S310
return 200 <= int(response.status) < 400
except (urllib.error.URLError, TimeoutError, ValueError):
return False
if readiness_type == "command":
proc = subprocess.run( # noqa: S603
["/bin/sh", "-lc", str(readiness["command"])],
cwd=str(cwd),
text=True,
capture_output=True,
timeout=10,
check=False,
)
return proc.returncode == 0
raise RuntimeError(f"unsupported readiness type: {readiness_type}")
def _start_service(
*,
service_name: str,
command: str,
cwd_text: str,
readiness: dict[str, Any] | None,
ready_timeout_seconds: int,
ready_interval_ms: int,
) -> dict[str, Any]:
normalized_service_name = _normalize_service_name(service_name)
normalized_cwd, cwd_path = _normalize_shell_cwd(cwd_text)
existing = None
metadata_path = _service_metadata_path(normalized_service_name)
if metadata_path.exists():
existing = _refresh_service_payload(
normalized_service_name,
_read_service_metadata(normalized_service_name),
)
if existing is not None and str(existing.get("state", "stopped")) == "running":
raise RuntimeError(f"service {normalized_service_name!r} is already running")
SERVICE_ROOT.mkdir(parents=True, exist_ok=True)
stdout_path = _service_stdout_path(normalized_service_name)
stderr_path = _service_stderr_path(normalized_service_name)
status_path = _service_status_path(normalized_service_name)
runner_path = _service_runner_path(normalized_service_name)
stdout_path.write_text("", encoding="utf-8")
stderr_path.write_text("", encoding="utf-8")
status_path.unlink(missing_ok=True)
runner_path.write_text(
"\n".join(
[
"#!/bin/sh",
"set +e",
f"cd {shlex.quote(str(cwd_path))}",
(
f"/bin/sh -lc {shlex.quote(command)}"
f" >> {shlex.quote(str(stdout_path))}"
f" 2>> {shlex.quote(str(stderr_path))}"
),
"status=$?",
f"printf '%s' \"$status\" > {shlex.quote(str(status_path))}",
"exit \"$status\"",
]
)
+ "\n",
encoding="utf-8",
)
runner_path.chmod(0o700)
process = subprocess.Popen( # noqa: S603
[str(runner_path)],
cwd=str(cwd_path),
text=True,
start_new_session=True,
)
payload: dict[str, Any] = {
"service_name": normalized_service_name,
"command": command,
"cwd": normalized_cwd,
"state": "running",
"started_at": time.time(),
"readiness": readiness,
"ready_at": None,
"ended_at": None,
"exit_code": None,
"pid": process.pid,
"stop_reason": None,
}
_write_service_metadata(normalized_service_name, payload)
deadline = time.monotonic() + ready_timeout_seconds
while True:
payload = _refresh_service_payload(normalized_service_name, payload)
if str(payload.get("state", "stopped")) != "running":
payload["state"] = "failed"
payload["stop_reason"] = "process_exited_before_ready"
payload["ended_at"] = payload.get("ended_at") or time.time()
_write_service_metadata(normalized_service_name, payload)
return payload
if _run_readiness_probe(readiness, cwd=cwd_path):
payload["ready_at"] = time.time()
_write_service_metadata(normalized_service_name, payload)
return payload
if time.monotonic() >= deadline:
_stop_service_process(process.pid)
payload = _refresh_service_payload(normalized_service_name, payload)
payload["state"] = "failed"
payload["stop_reason"] = "readiness_timeout"
payload["ended_at"] = payload.get("ended_at") or time.time()
_write_service_metadata(normalized_service_name, payload)
return payload
time.sleep(max(ready_interval_ms, 1) / 1000)
def _status_service(service_name: str) -> dict[str, Any]:
normalized_service_name = _normalize_service_name(service_name)
return _refresh_service_payload(
normalized_service_name,
_read_service_metadata(normalized_service_name),
)
def _logs_service(service_name: str, *, tail_lines: int | None) -> dict[str, Any]:
normalized_service_name = _normalize_service_name(service_name)
payload = _status_service(normalized_service_name)
stdout, stdout_truncated = _tail_service_text(
_service_stdout_path(normalized_service_name),
tail_lines=tail_lines,
)
stderr, stderr_truncated = _tail_service_text(
_service_stderr_path(normalized_service_name),
tail_lines=tail_lines,
)
payload.update(
{
"stdout": stdout,
"stderr": stderr,
"tail_lines": tail_lines,
"truncated": stdout_truncated or stderr_truncated,
}
)
return payload
def _stop_service(service_name: str) -> dict[str, Any]:
normalized_service_name = _normalize_service_name(service_name)
payload = _status_service(normalized_service_name)
pid = payload.get("pid")
if pid is None:
return payload
if str(payload.get("state", "stopped")) == "running":
_, killed = _stop_service_process(int(pid))
payload = _status_service(normalized_service_name)
payload["state"] = "stopped"
payload["stop_reason"] = "sigkill" if killed else "sigterm"
payload["ended_at"] = payload.get("ended_at") or time.time()
_write_service_metadata(normalized_service_name, payload)
return payload
def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
action = str(request.get("action", "exec"))
if action == "extract_archive":
@ -564,6 +860,31 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
if shell_id == "":
raise RuntimeError("shell_id is required")
return _remove_shell(shell_id).close()
if action == "start_service":
service_name = str(request.get("service_name", "")).strip()
command = str(request.get("command", ""))
cwd_text = str(request.get("cwd", "/workspace"))
readiness = request.get("readiness")
readiness_payload = dict(readiness) if isinstance(readiness, dict) else None
return _start_service(
service_name=service_name,
command=command,
cwd_text=cwd_text,
readiness=readiness_payload,
ready_timeout_seconds=int(request.get("ready_timeout_seconds", 30)),
ready_interval_ms=int(request.get("ready_interval_ms", 500)),
)
if action == "status_service":
service_name = str(request.get("service_name", "")).strip()
return _status_service(service_name)
if action == "logs_service":
service_name = str(request.get("service_name", "")).strip()
tail_lines = request.get("tail_lines")
normalized_tail_lines = None if tail_lines is None else int(tail_lines)
return _logs_service(service_name, tail_lines=normalized_tail_lines)
if action == "stop_service":
service_name = str(request.get("service_name", "")).strip()
return _stop_service(service_name)
command = str(request.get("command", ""))
timeout_seconds = int(request.get("timeout_seconds", 30))
return _run_command(command, timeout_seconds)
@ -571,6 +892,7 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
def main() -> None:
SHELL_ROOT.mkdir(parents=True, exist_ok=True)
SERVICE_ROOT.mkdir(parents=True, exist_ok=True)
family = getattr(socket, "AF_VSOCK", None)
if family is None:
raise SystemExit("AF_VSOCK is unavailable")

View file

@ -207,6 +207,50 @@ class Pyro:
def close_shell(self, workspace_id: str, shell_id: str) -> dict[str, Any]:
return self._manager.close_shell(workspace_id, shell_id)
def start_service(
self,
workspace_id: str,
service_name: str,
*,
command: str,
cwd: str = "/workspace",
readiness: dict[str, Any] | None = None,
ready_timeout_seconds: int = 30,
ready_interval_ms: int = 500,
) -> dict[str, Any]:
return self._manager.start_service(
workspace_id,
service_name,
command=command,
cwd=cwd,
readiness=readiness,
ready_timeout_seconds=ready_timeout_seconds,
ready_interval_ms=ready_interval_ms,
)
def list_services(self, workspace_id: str) -> dict[str, Any]:
return self._manager.list_services(workspace_id)
def status_service(self, workspace_id: str, service_name: str) -> dict[str, Any]:
return self._manager.status_service(workspace_id, service_name)
def logs_service(
self,
workspace_id: str,
service_name: str,
*,
tail_lines: int = 200,
all: bool = False,
) -> dict[str, Any]:
return self._manager.logs_service(
workspace_id,
service_name,
tail_lines=None if all else tail_lines,
)
def stop_service(self, workspace_id: str, service_name: str) -> dict[str, Any]:
return self._manager.stop_service(workspace_id, service_name)
def delete_workspace(self, workspace_id: str) -> dict[str, Any]:
return self._manager.delete_workspace(workspace_id)
@ -458,6 +502,69 @@ class Pyro:
"""Close a persistent workspace shell."""
return self.close_shell(workspace_id, shell_id)
@server.tool()
async def service_start(
workspace_id: str,
service_name: str,
command: str,
cwd: str = "/workspace",
ready_file: str | None = None,
ready_tcp: str | None = None,
ready_http: str | None = None,
ready_command: str | None = None,
ready_timeout_seconds: int = 30,
ready_interval_ms: int = 500,
) -> dict[str, Any]:
"""Start a named long-running service inside a workspace."""
readiness: dict[str, Any] | None = None
if ready_file is not None:
readiness = {"type": "file", "path": ready_file}
elif ready_tcp is not None:
readiness = {"type": "tcp", "address": ready_tcp}
elif ready_http is not None:
readiness = {"type": "http", "url": ready_http}
elif ready_command is not None:
readiness = {"type": "command", "command": ready_command}
return self.start_service(
workspace_id,
service_name,
command=command,
cwd=cwd,
readiness=readiness,
ready_timeout_seconds=ready_timeout_seconds,
ready_interval_ms=ready_interval_ms,
)
@server.tool()
async def service_list(workspace_id: str) -> dict[str, Any]:
"""List named services in one workspace."""
return self.list_services(workspace_id)
@server.tool()
async def service_status(workspace_id: str, service_name: str) -> dict[str, Any]:
"""Inspect one named workspace service."""
return self.status_service(workspace_id, service_name)
@server.tool()
async def service_logs(
workspace_id: str,
service_name: str,
tail_lines: int = 200,
all: bool = False,
) -> dict[str, Any]:
"""Read persisted stdout/stderr for one workspace service."""
return self.logs_service(
workspace_id,
service_name,
tail_lines=tail_lines,
all=all,
)
@server.tool()
async def service_stop(workspace_id: str, service_name: str) -> dict[str, Any]:
"""Stop one running service in a workspace."""
return self.stop_service(workspace_id, service_name)
@server.tool()
async def workspace_delete(workspace_id: str) -> dict[str, Any]:
"""Delete a persistent workspace and its backing sandbox."""

View file

@ -17,6 +17,9 @@ from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report
from pyro_mcp.vm_environments import DEFAULT_CATALOG_VERSION
from pyro_mcp.vm_manager import (
DEFAULT_MEM_MIB,
DEFAULT_SERVICE_LOG_TAIL_LINES,
DEFAULT_SERVICE_READY_INTERVAL_MS,
DEFAULT_SERVICE_READY_TIMEOUT_SECONDS,
DEFAULT_VCPU_COUNT,
WORKSPACE_GUEST_PATH,
WORKSPACE_SHELL_SIGNAL_NAMES,
@ -171,6 +174,11 @@ def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> N
f"{int(payload.get('mem_mib', 0))} MiB"
)
print(f"Command count: {int(payload.get('command_count', 0))}")
print(
"Services: "
f"{int(payload.get('running_service_count', 0))}/"
f"{int(payload.get('service_count', 0))} running"
)
last_command = payload.get("last_command")
if isinstance(last_command, dict):
print(
@ -304,6 +312,42 @@ def _print_workspace_shell_read_human(payload: dict[str, Any]) -> None:
)
def _print_workspace_service_summary_human(payload: dict[str, Any], *, prefix: str) -> None:
print(
f"[{prefix}] "
f"workspace_id={str(payload.get('workspace_id', 'unknown'))} "
f"service_name={str(payload.get('service_name', 'unknown'))} "
f"state={str(payload.get('state', 'unknown'))} "
f"cwd={str(payload.get('cwd', WORKSPACE_GUEST_PATH))} "
f"execution_mode={str(payload.get('execution_mode', 'unknown'))}",
file=sys.stderr,
flush=True,
)
def _print_workspace_service_list_human(payload: dict[str, Any]) -> None:
services = payload.get("services")
if not isinstance(services, list) or not services:
print("No workspace services found.")
return
for service in services:
if not isinstance(service, dict):
continue
print(
f"{str(service.get('service_name', 'unknown'))} "
f"[{str(service.get('state', 'unknown'))}] "
f"cwd={str(service.get('cwd', WORKSPACE_GUEST_PATH))}"
)
def _print_workspace_service_logs_human(payload: dict[str, Any]) -> None:
stdout = str(payload.get("stdout", ""))
stderr = str(payload.get("stderr", ""))
_write_stream(stdout, stream=sys.stdout)
_write_stream(stderr, stream=sys.stderr)
_print_workspace_service_summary_human(payload, prefix="workspace-service-logs")
class _HelpFormatter(
argparse.RawDescriptionHelpFormatter,
argparse.ArgumentDefaultsHelpFormatter,
@ -339,6 +383,8 @@ def _build_parser() -> argparse.ArgumentParser:
pyro workspace diff WORKSPACE_ID
pyro workspace export WORKSPACE_ID note.txt --output ./note.txt
pyro workspace shell open WORKSPACE_ID
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
sh -lc 'touch .ready && while true; do sleep 60; done'
Use `pyro mcp serve` only after the CLI validation path works.
"""
@ -549,6 +595,8 @@ def _build_parser() -> argparse.ArgumentParser:
pyro workspace diff WORKSPACE_ID
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
pyro workspace shell open WORKSPACE_ID
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
sh -lc 'touch .ready && while true; do sleep 60; done'
pyro workspace logs WORKSPACE_ID
"""
),
@ -570,6 +618,8 @@ def _build_parser() -> argparse.ArgumentParser:
pyro workspace create debian:12 --seed-path ./repo
pyro workspace sync push WORKSPACE_ID ./changes
pyro workspace diff WORKSPACE_ID
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
sh -lc 'touch .ready && while true; do sleep 60; done'
"""
),
formatter_class=_HelpFormatter,
@ -943,6 +993,160 @@ def _build_parser() -> argparse.ArgumentParser:
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_service_parser = workspace_subparsers.add_parser(
"service",
help="Manage long-running services inside a workspace.",
description=(
"Start, inspect, and stop named long-running services inside one started workspace."
),
epilog=dedent(
"""
Examples:
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
sh -lc 'touch .ready && while true; do sleep 60; done'
pyro workspace service list WORKSPACE_ID
pyro workspace service status WORKSPACE_ID app
pyro workspace service logs WORKSPACE_ID app --tail-lines 50
pyro workspace service stop WORKSPACE_ID app
Use `--ready-file` by default in the curated Debian environments. `--ready-command`
remains available as an escape hatch.
"""
),
formatter_class=_HelpFormatter,
)
workspace_service_subparsers = workspace_service_parser.add_subparsers(
dest="workspace_service_command",
required=True,
metavar="SERVICE",
)
workspace_service_start_parser = workspace_service_subparsers.add_parser(
"start",
help="Start one named long-running service.",
description="Start a named service inside a started workspace with optional readiness.",
epilog=dedent(
"""
Examples:
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
sh -lc 'touch .ready && while true; do sleep 60; done'
pyro workspace service start WORKSPACE_ID app --ready-command 'test -f .ready' -- \
sh -lc 'touch .ready && while true; do sleep 60; done'
"""
),
formatter_class=_HelpFormatter,
)
workspace_service_start_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
workspace_service_start_parser.add_argument("service_name", metavar="SERVICE_NAME")
workspace_service_start_parser.add_argument(
"--cwd",
default=WORKSPACE_GUEST_PATH,
help="Service working directory. Relative values resolve inside `/workspace`.",
)
workspace_service_start_parser.add_argument(
"--ready-file",
help="Mark the service ready once this workspace path exists.",
)
workspace_service_start_parser.add_argument(
"--ready-tcp",
help="Mark the service ready once this HOST:PORT accepts guest-local TCP connections.",
)
workspace_service_start_parser.add_argument(
"--ready-http",
help="Mark the service ready once this guest-local URL returns 2xx or 3xx.",
)
workspace_service_start_parser.add_argument(
"--ready-command",
help="Escape hatch readiness probe command. Use typed readiness when possible.",
)
workspace_service_start_parser.add_argument(
"--ready-timeout-seconds",
type=int,
default=DEFAULT_SERVICE_READY_TIMEOUT_SECONDS,
help="Maximum time to wait for readiness before failing the service start.",
)
workspace_service_start_parser.add_argument(
"--ready-interval-ms",
type=int,
default=DEFAULT_SERVICE_READY_INTERVAL_MS,
help="Polling interval between readiness checks.",
)
workspace_service_start_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_service_start_parser.add_argument(
"command_args",
nargs="*",
metavar="ARG",
help="Service command and arguments. Prefix them with `--`.",
)
workspace_service_list_parser = workspace_service_subparsers.add_parser(
"list",
help="List named services in one workspace.",
description="List named services and their current states for one workspace.",
epilog="Example:\n pyro workspace service list WORKSPACE_ID",
formatter_class=_HelpFormatter,
)
workspace_service_list_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
workspace_service_list_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_service_status_parser = workspace_service_subparsers.add_parser(
"status",
help="Inspect one service.",
description="Show state and readiness metadata for one named workspace service.",
epilog="Example:\n pyro workspace service status WORKSPACE_ID app",
formatter_class=_HelpFormatter,
)
workspace_service_status_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
workspace_service_status_parser.add_argument("service_name", metavar="SERVICE_NAME")
workspace_service_status_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_service_logs_parser = workspace_service_subparsers.add_parser(
"logs",
help="Read persisted service stdout and stderr.",
description="Read service stdout and stderr without using `workspace logs`.",
epilog="Example:\n pyro workspace service logs WORKSPACE_ID app --tail-lines 50",
formatter_class=_HelpFormatter,
)
workspace_service_logs_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
workspace_service_logs_parser.add_argument("service_name", metavar="SERVICE_NAME")
workspace_service_logs_parser.add_argument(
"--tail-lines",
type=int,
default=DEFAULT_SERVICE_LOG_TAIL_LINES,
help="Maximum number of trailing lines to return from each service log stream.",
)
workspace_service_logs_parser.add_argument(
"--all",
action="store_true",
help="Return full stdout and stderr instead of tailing them.",
)
workspace_service_logs_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_service_stop_parser = workspace_service_subparsers.add_parser(
"stop",
help="Stop one running service.",
description="Stop one named workspace service with TERM then KILL fallback.",
epilog="Example:\n pyro workspace service stop WORKSPACE_ID app",
formatter_class=_HelpFormatter,
)
workspace_service_stop_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
workspace_service_stop_parser.add_argument("service_name", metavar="SERVICE_NAME")
workspace_service_stop_parser.add_argument(
"--json",
action="store_true",
help="Print structured JSON instead of human-readable output.",
)
workspace_status_parser = workspace_subparsers.add_parser(
"status",
help="Inspect one workspace.",
@ -1372,6 +1576,128 @@ def main() -> None:
else:
_print_workspace_shell_summary_human(payload, prefix="workspace-shell-close")
return
if args.workspace_command == "service":
if args.workspace_service_command == "start":
readiness_count = sum(
value is not None
for value in (
args.ready_file,
args.ready_tcp,
args.ready_http,
args.ready_command,
)
)
if readiness_count > 1:
error = (
"choose at most one of --ready-file, --ready-tcp, "
"--ready-http, or --ready-command"
)
if bool(args.json):
_print_json({"ok": False, "error": error})
else:
print(f"[error] {error}", file=sys.stderr, flush=True)
raise SystemExit(1)
readiness: dict[str, Any] | None = None
if args.ready_file is not None:
readiness = {"type": "file", "path": args.ready_file}
elif args.ready_tcp is not None:
readiness = {"type": "tcp", "address": args.ready_tcp}
elif args.ready_http is not None:
readiness = {"type": "http", "url": args.ready_http}
elif args.ready_command is not None:
readiness = {"type": "command", "command": args.ready_command}
command = _require_command(args.command_args)
try:
payload = pyro.start_service(
args.workspace_id,
args.service_name,
command=command,
cwd=args.cwd,
readiness=readiness,
ready_timeout_seconds=args.ready_timeout_seconds,
ready_interval_ms=args.ready_interval_ms,
)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
_print_workspace_service_summary_human(
payload,
prefix="workspace-service-start",
)
return
if args.workspace_service_command == "list":
try:
payload = pyro.list_services(args.workspace_id)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
_print_workspace_service_list_human(payload)
return
if args.workspace_service_command == "status":
try:
payload = pyro.status_service(args.workspace_id, args.service_name)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
_print_workspace_service_summary_human(
payload,
prefix="workspace-service-status",
)
return
if args.workspace_service_command == "logs":
try:
payload = pyro.logs_service(
args.workspace_id,
args.service_name,
tail_lines=args.tail_lines,
all=bool(args.all),
)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
_print_workspace_service_logs_human(payload)
return
if args.workspace_service_command == "stop":
try:
payload = pyro.stop_service(args.workspace_id, args.service_name)
except Exception as exc: # noqa: BLE001
if bool(args.json):
_print_json({"ok": False, "error": str(exc)})
else:
print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc
if bool(args.json):
_print_json(payload)
else:
_print_workspace_service_summary_human(
payload,
prefix="workspace-service-stop",
)
return
if args.workspace_command == "status":
payload = pyro.status_workspace(args.workspace_id)
if bool(args.json):

View file

@ -12,10 +12,12 @@ PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = (
"exec",
"export",
"logs",
"service",
"shell",
"status",
"sync",
)
PUBLIC_CLI_WORKSPACE_SERVICE_SUBCOMMANDS = ("list", "logs", "start", "status", "stop")
PUBLIC_CLI_WORKSPACE_SHELL_SUBCOMMANDS = ("close", "open", "read", "signal", "write")
PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS = ("push",)
PUBLIC_CLI_WORKSPACE_CREATE_FLAGS = (
@ -29,6 +31,20 @@ PUBLIC_CLI_WORKSPACE_CREATE_FLAGS = (
)
PUBLIC_CLI_WORKSPACE_DIFF_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS = ("--output", "--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",
"--json",
)
PUBLIC_CLI_WORKSPACE_SERVICE_STATUS_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_SERVICE_STOP_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS = ("--cwd", "--cols", "--rows", "--json")
PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS = ("--cursor", "--max-chars", "--json")
PUBLIC_CLI_WORKSPACE_SHELL_WRITE_FLAGS = ("--input", "--no-newline", "--json")
@ -58,6 +74,8 @@ PUBLIC_SDK_METHODS = (
"export_workspace",
"inspect_environment",
"list_environments",
"list_services",
"logs_service",
"logs_workspace",
"network_info_vm",
"open_shell",
@ -68,14 +86,22 @@ PUBLIC_SDK_METHODS = (
"reap_expired",
"run_in_vm",
"signal_shell",
"start_service",
"start_vm",
"status_service",
"status_vm",
"status_workspace",
"stop_service",
"stop_vm",
"write_shell",
)
PUBLIC_MCP_TOOLS = (
"service_list",
"service_logs",
"service_start",
"service_status",
"service_stop",
"shell_close",
"shell_open",
"shell_read",

View file

@ -8,7 +8,8 @@ import fcntl
import io
import json
import os
import pty
import re
import shlex
import signal
import socket
import struct
@ -18,6 +19,8 @@ import tempfile
import termios
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path, PurePosixPath
from typing import Any
@ -25,6 +28,8 @@ PORT = 5005
BUFFER_SIZE = 65536
WORKSPACE_ROOT = PurePosixPath("/workspace")
SHELL_ROOT = Path("/run/pyro-shells")
SERVICE_ROOT = Path("/run/pyro-services")
SERVICE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
SHELL_SIGNAL_MAP = {
"HUP": signal.SIGHUP,
"INT": signal.SIGINT,
@ -105,6 +110,35 @@ def _normalize_shell_cwd(cwd: str) -> tuple[str, Path]:
return str(normalized), host_path
def _normalize_service_name(service_name: str) -> str:
normalized = service_name.strip()
if normalized == "":
raise RuntimeError("service_name is required")
if SERVICE_NAME_RE.fullmatch(normalized) is None:
raise RuntimeError("service_name is invalid")
return normalized
def _service_stdout_path(service_name: str) -> Path:
return SERVICE_ROOT / f"{service_name}.stdout"
def _service_stderr_path(service_name: str) -> Path:
return SERVICE_ROOT / f"{service_name}.stderr"
def _service_status_path(service_name: str) -> Path:
return SERVICE_ROOT / f"{service_name}.status"
def _service_runner_path(service_name: str) -> Path:
return SERVICE_ROOT / f"{service_name}.runner.sh"
def _service_metadata_path(service_name: str) -> Path:
return SERVICE_ROOT / f"{service_name}.json"
def _validate_symlink_target(member_path: PurePosixPath, link_target: str) -> None:
target = link_target.strip()
if target == "":
@ -286,7 +320,7 @@ class GuestShellSession:
self._log_path = SHELL_ROOT / f"{shell_id}.log"
self._master_fd: int | None = None
master_fd, slave_fd = pty.openpty()
master_fd, slave_fd = os.openpty()
try:
_set_pty_size(slave_fd, rows, cols)
env = os.environ.copy()
@ -512,6 +546,268 @@ def _remove_shell(shell_id: str) -> GuestShellSession:
raise RuntimeError(f"shell {shell_id!r} does not exist") from exc
def _read_service_metadata(service_name: str) -> dict[str, Any]:
metadata_path = _service_metadata_path(service_name)
if not metadata_path.exists():
raise RuntimeError(f"service {service_name!r} does not exist")
payload = json.loads(metadata_path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise RuntimeError(f"service record for {service_name!r} is invalid")
return payload
def _write_service_metadata(service_name: str, payload: dict[str, Any]) -> None:
_service_metadata_path(service_name).write_text(
json.dumps(payload, indent=2, sort_keys=True),
encoding="utf-8",
)
def _service_exit_code(service_name: str) -> int | None:
status_path = _service_status_path(service_name)
if not status_path.exists():
return None
raw_value = status_path.read_text(encoding="utf-8", errors="ignore").strip()
if raw_value == "":
return None
return int(raw_value)
def _service_pid_running(pid: int | None) -> bool:
if pid is None:
return False
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def _tail_service_text(path: Path, *, tail_lines: int | None) -> tuple[str, bool]:
if not path.exists():
return "", False
text = path.read_text(encoding="utf-8", errors="replace")
if tail_lines is None:
return text, False
lines = text.splitlines(keepends=True)
if len(lines) <= tail_lines:
return text, False
return "".join(lines[-tail_lines:]), True
def _stop_service_process(pid: int) -> tuple[bool, bool]:
try:
os.killpg(pid, signal.SIGTERM)
except ProcessLookupError:
return False, False
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if not _service_pid_running(pid):
return True, False
time.sleep(0.1)
try:
os.killpg(pid, signal.SIGKILL)
except ProcessLookupError:
return True, False
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
if not _service_pid_running(pid):
return True, True
time.sleep(0.1)
return True, True
def _refresh_service_payload(service_name: str, payload: dict[str, Any]) -> dict[str, Any]:
if str(payload.get("state", "stopped")) != "running":
return payload
pid = payload.get("pid")
normalized_pid = None if pid is None else int(pid)
if _service_pid_running(normalized_pid):
return payload
refreshed = dict(payload)
refreshed["state"] = "exited"
refreshed["ended_at"] = refreshed.get("ended_at") or time.time()
refreshed["exit_code"] = _service_exit_code(service_name)
_write_service_metadata(service_name, refreshed)
return refreshed
def _run_readiness_probe(readiness: dict[str, Any] | None, *, cwd: Path) -> bool:
if readiness is None:
return True
readiness_type = str(readiness["type"])
if readiness_type == "file":
_, ready_path = _normalize_destination(str(readiness["path"]))
return ready_path.exists()
if readiness_type == "tcp":
host, raw_port = str(readiness["address"]).rsplit(":", 1)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(1)
try:
sock.connect((host, int(raw_port)))
except OSError:
return False
return True
if readiness_type == "http":
request = urllib.request.Request(str(readiness["url"]), method="GET")
try:
with urllib.request.urlopen(request, timeout=2) as response: # noqa: S310
return 200 <= int(response.status) < 400
except (urllib.error.URLError, TimeoutError, ValueError):
return False
if readiness_type == "command":
proc = subprocess.run( # noqa: S603
["/bin/sh", "-lc", str(readiness["command"])],
cwd=str(cwd),
text=True,
capture_output=True,
timeout=10,
check=False,
)
return proc.returncode == 0
raise RuntimeError(f"unsupported readiness type: {readiness_type}")
def _start_service(
*,
service_name: str,
command: str,
cwd_text: str,
readiness: dict[str, Any] | None,
ready_timeout_seconds: int,
ready_interval_ms: int,
) -> dict[str, Any]:
normalized_service_name = _normalize_service_name(service_name)
normalized_cwd, cwd_path = _normalize_shell_cwd(cwd_text)
existing = None
metadata_path = _service_metadata_path(normalized_service_name)
if metadata_path.exists():
existing = _refresh_service_payload(
normalized_service_name,
_read_service_metadata(normalized_service_name),
)
if existing is not None and str(existing.get("state", "stopped")) == "running":
raise RuntimeError(f"service {normalized_service_name!r} is already running")
SERVICE_ROOT.mkdir(parents=True, exist_ok=True)
stdout_path = _service_stdout_path(normalized_service_name)
stderr_path = _service_stderr_path(normalized_service_name)
status_path = _service_status_path(normalized_service_name)
runner_path = _service_runner_path(normalized_service_name)
stdout_path.write_text("", encoding="utf-8")
stderr_path.write_text("", encoding="utf-8")
status_path.unlink(missing_ok=True)
runner_path.write_text(
"\n".join(
[
"#!/bin/sh",
"set +e",
f"cd {shlex.quote(str(cwd_path))}",
(
f"/bin/sh -lc {shlex.quote(command)}"
f" >> {shlex.quote(str(stdout_path))}"
f" 2>> {shlex.quote(str(stderr_path))}"
),
"status=$?",
f"printf '%s' \"$status\" > {shlex.quote(str(status_path))}",
"exit \"$status\"",
]
)
+ "\n",
encoding="utf-8",
)
runner_path.chmod(0o700)
process = subprocess.Popen( # noqa: S603
[str(runner_path)],
cwd=str(cwd_path),
text=True,
start_new_session=True,
)
payload: dict[str, Any] = {
"service_name": normalized_service_name,
"command": command,
"cwd": normalized_cwd,
"state": "running",
"started_at": time.time(),
"readiness": readiness,
"ready_at": None,
"ended_at": None,
"exit_code": None,
"pid": process.pid,
"stop_reason": None,
}
_write_service_metadata(normalized_service_name, payload)
deadline = time.monotonic() + ready_timeout_seconds
while True:
payload = _refresh_service_payload(normalized_service_name, payload)
if str(payload.get("state", "stopped")) != "running":
payload["state"] = "failed"
payload["stop_reason"] = "process_exited_before_ready"
payload["ended_at"] = payload.get("ended_at") or time.time()
_write_service_metadata(normalized_service_name, payload)
return payload
if _run_readiness_probe(readiness, cwd=cwd_path):
payload["ready_at"] = time.time()
_write_service_metadata(normalized_service_name, payload)
return payload
if time.monotonic() >= deadline:
_stop_service_process(process.pid)
payload = _refresh_service_payload(normalized_service_name, payload)
payload["state"] = "failed"
payload["stop_reason"] = "readiness_timeout"
payload["ended_at"] = payload.get("ended_at") or time.time()
_write_service_metadata(normalized_service_name, payload)
return payload
time.sleep(max(ready_interval_ms, 1) / 1000)
def _status_service(service_name: str) -> dict[str, Any]:
normalized_service_name = _normalize_service_name(service_name)
return _refresh_service_payload(
normalized_service_name,
_read_service_metadata(normalized_service_name),
)
def _logs_service(service_name: str, *, tail_lines: int | None) -> dict[str, Any]:
normalized_service_name = _normalize_service_name(service_name)
payload = _status_service(normalized_service_name)
stdout, stdout_truncated = _tail_service_text(
_service_stdout_path(normalized_service_name),
tail_lines=tail_lines,
)
stderr, stderr_truncated = _tail_service_text(
_service_stderr_path(normalized_service_name),
tail_lines=tail_lines,
)
payload.update(
{
"stdout": stdout,
"stderr": stderr,
"tail_lines": tail_lines,
"truncated": stdout_truncated or stderr_truncated,
}
)
return payload
def _stop_service(service_name: str) -> dict[str, Any]:
normalized_service_name = _normalize_service_name(service_name)
payload = _status_service(normalized_service_name)
pid = payload.get("pid")
if pid is None:
return payload
if str(payload.get("state", "stopped")) == "running":
_, killed = _stop_service_process(int(pid))
payload = _status_service(normalized_service_name)
payload["state"] = "stopped"
payload["stop_reason"] = "sigkill" if killed else "sigterm"
payload["ended_at"] = payload.get("ended_at") or time.time()
_write_service_metadata(normalized_service_name, payload)
return payload
def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
action = str(request.get("action", "exec"))
if action == "extract_archive":
@ -564,6 +860,31 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
if shell_id == "":
raise RuntimeError("shell_id is required")
return _remove_shell(shell_id).close()
if action == "start_service":
service_name = str(request.get("service_name", "")).strip()
command = str(request.get("command", ""))
cwd_text = str(request.get("cwd", "/workspace"))
readiness = request.get("readiness")
readiness_payload = dict(readiness) if isinstance(readiness, dict) else None
return _start_service(
service_name=service_name,
command=command,
cwd_text=cwd_text,
readiness=readiness_payload,
ready_timeout_seconds=int(request.get("ready_timeout_seconds", 30)),
ready_interval_ms=int(request.get("ready_interval_ms", 500)),
)
if action == "status_service":
service_name = str(request.get("service_name", "")).strip()
return _status_service(service_name)
if action == "logs_service":
service_name = str(request.get("service_name", "")).strip()
tail_lines = request.get("tail_lines")
normalized_tail_lines = None if tail_lines is None else int(tail_lines)
return _logs_service(service_name, tail_lines=normalized_tail_lines)
if action == "stop_service":
service_name = str(request.get("service_name", "")).strip()
return _stop_service(service_name)
command = str(request.get("command", ""))
timeout_seconds = int(request.get("timeout_seconds", 30))
return _run_command(command, timeout_seconds)
@ -571,6 +892,7 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
def main() -> None:
SHELL_ROOT.mkdir(parents=True, exist_ok=True)
SERVICE_ROOT.mkdir(parents=True, exist_ok=True)
family = getattr(socket, "AF_VSOCK", None)
if family is None:
raise SystemExit("AF_VSOCK is unavailable")

View file

@ -25,7 +25,7 @@
"guest": {
"agent": {
"path": "guest/pyro_guest_agent.py",
"sha256": "4118589ccd8f4ac8200d9cedf25d13ff515d77c28094bbbdb208310247688b40"
"sha256": "58dd2e09d05538228540d8c667b1acb42c2e6c579f7883b70d483072570f2499"
}
},
"platform": "linux-x86_64",

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 = "2.6.0"
DEFAULT_CATALOG_VERSION = "2.7.0"
OCI_MANIFEST_ACCEPT = ", ".join(
(
"application/vnd.oci.image.index.v1+json",

View file

@ -325,6 +325,102 @@ class VsockExecClient:
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,
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,
},
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,

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,6 @@ from __future__ import annotations
import codecs
import fcntl
import os
import pty
import shlex
import signal
import struct
@ -14,7 +13,7 @@ import termios
import threading
import time
from pathlib import Path
from typing import Literal
from typing import IO, Literal
ShellState = Literal["running", "stopped"]
@ -59,41 +58,60 @@ class LocalShellSession:
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")
env = os.environ.copy()
env.update(
{
"TERM": env.get("TERM", "xterm-256color"),
"PS1": "pyro$ ",
"PROMPT_COMMAND": "",
}
)
master_fd, slave_fd = pty.openpty()
process: subprocess.Popen[bytes]
try:
_set_pty_size(slave_fd, rows, cols)
env = os.environ.copy()
env.update(
{
"TERM": env.get("TERM", "xterm-256color"),
"PS1": "pyro$ ",
"PROMPT_COMMAND": "",
}
)
master_fd, slave_fd = os.openpty()
except OSError:
process = subprocess.Popen( # noqa: S603
["/bin/bash", "--noprofile", "--norc", "-i"],
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
["/bin/bash", "--noprofile", "--norc"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
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._input_pipe = process.stdin
self._output_pipe = process.stdout
else:
try:
_set_pty_size(slave_fd, rows, cols)
process = subprocess.Popen( # noqa: S603
["/bin/bash", "--noprofile", "--norc", "-i"],
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._master_fd = master_fd
self._reader = threading.Thread(target=self._reader_loop, daemon=True)
self._waiter = threading.Thread(target=self._waiter_loop, daemon=True)
self._reader.start()
@ -136,11 +154,16 @@ class LocalShellSession:
if self.state != "running":
raise RuntimeError(f"shell {self.shell_id} is not running")
master_fd = self._master_fd
if master_fd is None:
raise RuntimeError(f"shell {self.shell_id} transport is unavailable")
input_pipe = self._input_pipe
payload = text + ("\n" if append_newline else "")
try:
os.write(master_fd, payload.encode("utf-8"))
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
@ -195,11 +218,17 @@ class LocalShellSession:
def _reader_loop(self) -> None:
master_fd = self._master_fd
if master_fd is None:
output_pipe = self._output_pipe
if master_fd is None and output_pipe is None:
return
while True:
try:
chunk = os.read(master_fd, 65536)
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"":
@ -234,6 +263,14 @@ class LocalShellSession:
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:

View file

@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
import time
from pathlib import Path
from typing import Any, cast
@ -58,6 +57,11 @@ def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None:
assert "shell_write" in tool_names
assert "shell_signal" in tool_names
assert "shell_close" in tool_names
assert "service_start" in tool_names
assert "service_list" in tool_names
assert "service_status" in tool_names
assert "service_logs" in tool_names
assert "service_stop" in tool_names
def test_pyro_vm_run_tool_executes(tmp_path: Path) -> None:
@ -141,16 +145,16 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
diff_payload = pyro.diff_workspace(workspace_id)
export_path = tmp_path / "exported-note.txt"
exported = pyro.export_workspace(workspace_id, "note.txt", output_path=export_path)
opened = pyro.open_shell(workspace_id)
shell_id = str(opened["shell_id"])
written = pyro.write_shell(workspace_id, shell_id, input="pwd")
read = pyro.read_shell(workspace_id, shell_id)
deadline = time.time() + 5
while "/workspace" not in str(read["output"]) and time.time() < deadline:
read = pyro.read_shell(workspace_id, shell_id, cursor=0)
time.sleep(0.05)
signaled = pyro.signal_shell(workspace_id, shell_id)
closed = pyro.close_shell(workspace_id, shell_id)
service = pyro.start_service(
workspace_id,
"app",
command="sh -lc 'touch .ready; while true; do sleep 60; done'",
readiness={"type": "file", "path": ".ready"},
)
services = pyro.list_services(workspace_id)
service_status = pyro.status_service(workspace_id, "app")
service_logs = pyro.logs_service(workspace_id, "app", all=True)
service_stopped = pyro.stop_service(workspace_id, "app")
status = pyro.status_workspace(workspace_id)
logs = pyro.logs_workspace(workspace_id)
deleted = pyro.delete_workspace(workspace_id)
@ -158,13 +162,15 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
assert executed["stdout"] == "ok\n"
assert created["workspace_seed"]["mode"] == "directory"
assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
assert written["input_length"] == 3
assert diff_payload["changed"] is True
assert exported["output_path"] == str(export_path)
assert export_path.read_text(encoding="utf-8") == "ok\n"
assert "/workspace" in read["output"]
assert signaled["signal"] == "INT"
assert closed["closed"] is True
assert service["state"] == "running"
assert services["count"] == 1
assert service_status["state"] == "running"
assert service_logs["tail_lines"] is None
assert service_stopped["state"] == "stopped"
assert status["command_count"] == 1
assert status["service_count"] == 1
assert logs["count"] == 1
assert deleted["deleted"] is True

File diff suppressed because it is too large Load diff

View file

@ -20,6 +20,12 @@ from pyro_mcp.contract import (
PUBLIC_CLI_WORKSPACE_CREATE_FLAGS,
PUBLIC_CLI_WORKSPACE_DIFF_FLAGS,
PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS,
PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS,
PUBLIC_CLI_WORKSPACE_SERVICE_LOGS_FLAGS,
PUBLIC_CLI_WORKSPACE_SERVICE_START_FLAGS,
PUBLIC_CLI_WORKSPACE_SERVICE_STATUS_FLAGS,
PUBLIC_CLI_WORKSPACE_SERVICE_STOP_FLAGS,
PUBLIC_CLI_WORKSPACE_SERVICE_SUBCOMMANDS,
PUBLIC_CLI_WORKSPACE_SHELL_CLOSE_FLAGS,
PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS,
PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS,
@ -135,6 +141,37 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
).format_help()
for flag in PUBLIC_CLI_WORKSPACE_SHELL_CLOSE_FLAGS:
assert flag in workspace_shell_close_help_text
workspace_service_help_text = _subparser_choice(
_subparser_choice(parser, "workspace"),
"service",
).format_help()
for subcommand_name in PUBLIC_CLI_WORKSPACE_SERVICE_SUBCOMMANDS:
assert subcommand_name in workspace_service_help_text
workspace_service_start_help_text = _subparser_choice(
_subparser_choice(_subparser_choice(parser, "workspace"), "service"), "start"
).format_help()
for flag in PUBLIC_CLI_WORKSPACE_SERVICE_START_FLAGS:
assert flag in workspace_service_start_help_text
workspace_service_list_help_text = _subparser_choice(
_subparser_choice(_subparser_choice(parser, "workspace"), "service"), "list"
).format_help()
for flag in PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS:
assert flag in workspace_service_list_help_text
workspace_service_status_help_text = _subparser_choice(
_subparser_choice(_subparser_choice(parser, "workspace"), "service"), "status"
).format_help()
for flag in PUBLIC_CLI_WORKSPACE_SERVICE_STATUS_FLAGS:
assert flag in workspace_service_status_help_text
workspace_service_logs_help_text = _subparser_choice(
_subparser_choice(_subparser_choice(parser, "workspace"), "service"), "logs"
).format_help()
for flag in PUBLIC_CLI_WORKSPACE_SERVICE_LOGS_FLAGS:
assert flag in workspace_service_logs_help_text
workspace_service_stop_help_text = _subparser_choice(
_subparser_choice(_subparser_choice(parser, "workspace"), "service"), "stop"
).format_help()
for flag in PUBLIC_CLI_WORKSPACE_SERVICE_STOP_FLAGS:
assert flag in workspace_service_stop_help_text
demo_help_text = _subparser_choice(parser, "demo").format_help()
for subcommand_name in PUBLIC_CLI_DEMO_SUBCOMMANDS:

View file

@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
import time
from pathlib import Path
from typing import Any, cast
@ -42,6 +41,11 @@ def test_create_server_registers_vm_tools(tmp_path: Path) -> None:
assert "shell_write" in tool_names
assert "shell_signal" in tool_names
assert "shell_close" in tool_names
assert "service_start" in tool_names
assert "service_list" in tool_names
assert "service_status" in tool_names
assert "service_logs" in tool_names
assert "service_stop" in tool_names
def test_vm_run_round_trip(tmp_path: Path) -> None:
@ -192,20 +196,7 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
raise TypeError("expected structured dictionary result")
return cast(dict[str, Any], structured)
async def _run() -> tuple[
dict[str, Any],
dict[str, Any],
dict[str, Any],
dict[str, Any],
dict[str, Any],
dict[str, Any],
dict[str, Any],
dict[str, Any],
dict[str, Any],
dict[str, Any],
dict[str, Any],
dict[str, Any],
]:
async def _run() -> tuple[dict[str, Any], ...]:
server = create_server(manager=manager)
created = _extract_structured(
await server.call_tool(
@ -254,57 +245,45 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
},
)
)
opened = _extract_structured(
await server.call_tool("shell_open", {"workspace_id": workspace_id})
)
shell_id = str(opened["shell_id"])
written = _extract_structured(
service = _extract_structured(
await server.call_tool(
"shell_write",
"service_start",
{
"workspace_id": workspace_id,
"shell_id": shell_id,
"input": "pwd",
"service_name": "app",
"command": "sh -lc 'touch .ready; while true; do sleep 60; done'",
"ready_file": ".ready",
},
)
)
read = _extract_structured(
services = _extract_structured(
await server.call_tool("service_list", {"workspace_id": workspace_id})
)
service_status = _extract_structured(
await server.call_tool(
"shell_read",
"service_status",
{
"workspace_id": workspace_id,
"shell_id": shell_id,
"service_name": "app",
},
)
)
deadline = time.time() + 5
while "/workspace" not in str(read["output"]) and time.time() < deadline:
read = _extract_structured(
await server.call_tool(
"shell_read",
{
"workspace_id": workspace_id,
"shell_id": shell_id,
"cursor": 0,
},
)
)
await asyncio.sleep(0.05)
signaled = _extract_structured(
service_logs = _extract_structured(
await server.call_tool(
"shell_signal",
"service_logs",
{
"workspace_id": workspace_id,
"shell_id": shell_id,
"service_name": "app",
"all": True,
},
)
)
closed = _extract_structured(
service_stopped = _extract_structured(
await server.call_tool(
"shell_close",
"service_stop",
{
"workspace_id": workspace_id,
"shell_id": shell_id,
"service_name": "app",
},
)
)
@ -320,11 +299,11 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
executed,
diffed,
exported,
opened,
written,
read,
signaled,
closed,
service,
services,
service_status,
service_logs,
service_stopped,
logs,
deleted,
)
@ -335,11 +314,11 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
executed,
diffed,
exported,
opened,
written,
read,
signaled,
closed,
service,
services,
service_status,
service_logs,
service_stopped,
logs,
deleted,
) = asyncio.run(_run())
@ -350,10 +329,10 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
assert diffed["changed"] is True
assert exported["artifact_type"] == "file"
assert Path(str(exported["output_path"])).read_text(encoding="utf-8") == "more\n"
assert opened["state"] == "running"
assert written["input_length"] == 3
assert "/workspace" in read["output"]
assert signaled["signal"] == "INT"
assert closed["closed"] is True
assert service["state"] == "running"
assert services["count"] == 1
assert service_status["state"] == "running"
assert service_logs["tail_lines"] is None
assert service_stopped["state"] == "stopped"
assert logs["count"] == 1
assert deleted["deleted"] is True

View file

@ -262,6 +262,105 @@ def test_vsock_exec_client_shell_round_trip(monkeypatch: pytest.MonkeyPatch) ->
assert open_request["shell_id"] == "shell-1"
def test_vsock_exec_client_service_round_trip(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(socket, "AF_VSOCK", 40, raising=False)
responses = [
json.dumps(
{
"service_name": "app",
"command": "echo ok",
"cwd": "/workspace",
"state": "running",
"started_at": 1.0,
"ready_at": 2.0,
"ended_at": None,
"exit_code": None,
"pid": 42,
"readiness": {"type": "file", "path": "/workspace/.ready"},
"stop_reason": None,
}
).encode("utf-8"),
json.dumps(
{
"service_name": "app",
"command": "echo ok",
"cwd": "/workspace",
"state": "running",
"started_at": 1.0,
"ready_at": 2.0,
"ended_at": None,
"exit_code": None,
"pid": 42,
"readiness": {"type": "file", "path": "/workspace/.ready"},
"stop_reason": None,
}
).encode("utf-8"),
json.dumps(
{
"service_name": "app",
"command": "echo ok",
"cwd": "/workspace",
"state": "running",
"started_at": 1.0,
"ready_at": 2.0,
"ended_at": None,
"exit_code": None,
"pid": 42,
"readiness": {"type": "file", "path": "/workspace/.ready"},
"stop_reason": None,
"stdout": "ok\n",
"stderr": "",
"tail_lines": 200,
"truncated": False,
}
).encode("utf-8"),
json.dumps(
{
"service_name": "app",
"command": "echo ok",
"cwd": "/workspace",
"state": "stopped",
"started_at": 1.0,
"ready_at": 2.0,
"ended_at": 3.0,
"exit_code": 0,
"pid": 42,
"readiness": {"type": "file", "path": "/workspace/.ready"},
"stop_reason": "sigterm",
}
).encode("utf-8"),
]
stubs = [StubSocket(response) for response in responses]
remaining = list(stubs)
def socket_factory(family: int, sock_type: int) -> StubSocket:
assert family == socket.AF_VSOCK
assert sock_type == socket.SOCK_STREAM
return remaining.pop(0)
client = VsockExecClient(socket_factory=socket_factory)
started = client.start_service(
1234,
5005,
service_name="app",
command="echo ok",
cwd="/workspace",
readiness={"type": "file", "path": "/workspace/.ready"},
ready_timeout_seconds=30,
ready_interval_ms=500,
)
assert started["service_name"] == "app"
status = client.status_service(1234, 5005, service_name="app")
assert status["state"] == "running"
logs = client.logs_service(1234, 5005, service_name="app", tail_lines=200)
assert logs["stdout"] == "ok\n"
stopped = client.stop_service(1234, 5005, service_name="app")
assert stopped["state"] == "stopped"
start_request = json.loads(stubs[0].sent.decode("utf-8").strip())
assert start_request["action"] == "start_service"
assert start_request["service_name"] == "app"
def test_vsock_exec_client_raises_agent_error(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(socket, "AF_VSOCK", 40, raising=False)
stub = StubSocket(b'{"error":"shell is unavailable"}')

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import io
import json
import os
import signal
import subprocess
import tarfile
import time
@ -1144,3 +1145,369 @@ def test_reap_expired_workspaces_removes_invalid_and_expired_records(tmp_path: P
assert not invalid_dir.exists()
assert not (tmp_path / "vms" / "workspaces" / workspace_id).exists()
def test_workspace_service_lifecycle_and_status_counts(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
workspace_id = str(
manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
)["workspace_id"]
)
started = manager.start_service(
workspace_id,
"app",
command="sh -lc 'printf \"service ready\\n\"; touch .ready; while true; do sleep 60; done'",
readiness={"type": "file", "path": ".ready"},
)
assert started["state"] == "running"
listed = manager.list_services(workspace_id)
assert listed["count"] == 1
assert listed["running_count"] == 1
status = manager.status_service(workspace_id, "app")
assert status["state"] == "running"
assert status["ready_at"] is not None
logs = manager.logs_service(workspace_id, "app")
assert "service ready" in str(logs["stdout"])
workspace_status = manager.status_workspace(workspace_id)
assert workspace_status["service_count"] == 1
assert workspace_status["running_service_count"] == 1
stopped = manager.stop_service(workspace_id, "app")
assert stopped["state"] == "stopped"
assert stopped["stop_reason"] in {"sigterm", "sigkill"}
deleted = manager.delete_workspace(workspace_id)
assert deleted["deleted"] is True
def test_workspace_service_start_replaces_non_running_record(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
workspace_id = str(
manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
)["workspace_id"]
)
failed = manager.start_service(
workspace_id,
"app",
command="sh -lc 'exit 2'",
readiness={"type": "file", "path": ".ready"},
ready_timeout_seconds=1,
ready_interval_ms=50,
)
assert failed["state"] == "failed"
started = manager.start_service(
workspace_id,
"app",
command="sh -lc 'touch .ready; while true; do sleep 60; done'",
readiness={"type": "file", "path": ".ready"},
)
assert started["state"] == "running"
manager.delete_workspace(workspace_id)
def test_workspace_service_supports_command_readiness_and_helper_probes(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
workspace_id = str(
manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
)["workspace_id"]
)
command_started = manager.start_service(
workspace_id,
"command-ready",
command="sh -lc 'touch command.ready; while true; do sleep 60; done'",
readiness={"type": "command", "command": "test -f command.ready"},
)
assert command_started["state"] == "running"
listed = manager.list_services(workspace_id)
assert listed["count"] == 1
assert listed["running_count"] == 1
status = manager.status_workspace(workspace_id)
assert status["service_count"] == 1
assert status["running_service_count"] == 1
assert manager.stop_service(workspace_id, "command-ready")["state"] == "stopped"
workspace_dir = tmp_path / "vms" / "workspaces" / workspace_id / "workspace"
ready_file = workspace_dir / "probe.ready"
ready_file.write_text("ok\n", encoding="utf-8")
assert vm_manager_module._service_ready_on_host( # noqa: SLF001
readiness={"type": "file", "path": "/workspace/probe.ready"},
workspace_dir=workspace_dir,
cwd=workspace_dir,
)
class StubSocket:
def __enter__(self) -> StubSocket:
return self
def __exit__(self, *args: object) -> None:
del args
def settimeout(self, timeout: int) -> None:
assert timeout == 1
def connect(self, address: tuple[str, int]) -> None:
assert address == ("127.0.0.1", 8080)
monkeypatch.setattr("pyro_mcp.vm_manager.socket.socket", lambda *args: StubSocket())
assert vm_manager_module._service_ready_on_host( # noqa: SLF001
readiness={"type": "tcp", "address": "127.0.0.1:8080"},
workspace_dir=workspace_dir,
cwd=workspace_dir,
)
class StubResponse:
status = 204
def __enter__(self) -> StubResponse:
return self
def __exit__(self, *args: object) -> None:
del args
def _urlopen(request: object, timeout: int) -> StubResponse:
del request
assert timeout == 2
return StubResponse()
monkeypatch.setattr("pyro_mcp.vm_manager.urllib.request.urlopen", _urlopen)
assert vm_manager_module._service_ready_on_host( # noqa: SLF001
readiness={"type": "http", "url": "http://127.0.0.1:8080/"},
workspace_dir=workspace_dir,
cwd=workspace_dir,
)
def test_workspace_service_logs_tail_and_delete_cleanup(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
workspace_id = str(
manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
)["workspace_id"]
)
manager.start_service(
workspace_id,
"logger",
command=(
"sh -lc 'printf \"one\\n\"; printf \"two\\n\"; "
"touch .ready; while true; do sleep 60; done'"
),
readiness={"type": "file", "path": ".ready"},
)
logs = manager.logs_service(workspace_id, "logger", tail_lines=1)
assert logs["stdout"] == "two\n"
assert logs["truncated"] is True
services_dir = tmp_path / "vms" / "workspaces" / workspace_id / "services"
assert services_dir.exists()
deleted = manager.delete_workspace(workspace_id)
assert deleted["deleted"] is True
assert not services_dir.exists()
def test_workspace_status_stops_service_counts_when_workspace_is_stopped(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
workspace_id = str(
manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
)["workspace_id"]
)
manager.start_service(
workspace_id,
"app",
command="sh -lc 'touch .ready; while true; do sleep 60; done'",
readiness={"type": "file", "path": ".ready"},
)
service_path = tmp_path / "vms" / "workspaces" / workspace_id / "services" / "app.json"
live_service_payload = json.loads(service_path.read_text(encoding="utf-8"))
live_pid = int(live_service_payload["pid"])
try:
workspace_path = tmp_path / "vms" / "workspaces" / workspace_id / "workspace.json"
payload = json.loads(workspace_path.read_text(encoding="utf-8"))
payload["state"] = "stopped"
workspace_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
status = manager.status_workspace(workspace_id)
assert status["state"] == "stopped"
assert status["service_count"] == 1
assert status["running_service_count"] == 0
service_payload = json.loads(service_path.read_text(encoding="utf-8"))
assert service_payload["state"] == "stopped"
assert service_payload["stop_reason"] == "workspace_stopped"
finally:
vm_manager_module._stop_process_group(live_pid) # noqa: SLF001
def test_workspace_service_readiness_validation_helpers() -> None:
assert vm_manager_module._normalize_workspace_service_name("app-1") == "app-1" # noqa: SLF001
with pytest.raises(ValueError, match="service_name must not be empty"):
vm_manager_module._normalize_workspace_service_name(" ") # noqa: SLF001
with pytest.raises(ValueError, match="service_name must match"):
vm_manager_module._normalize_workspace_service_name("bad name") # noqa: SLF001
assert vm_manager_module._normalize_workspace_service_readiness( # noqa: SLF001
{"type": "file", "path": "subdir/.ready"}
) == {"type": "file", "path": "/workspace/subdir/.ready"}
assert vm_manager_module._normalize_workspace_service_readiness( # noqa: SLF001
{"type": "tcp", "address": "127.0.0.1:8080"}
) == {"type": "tcp", "address": "127.0.0.1:8080"}
assert vm_manager_module._normalize_workspace_service_readiness( # noqa: SLF001
{"type": "http", "url": "http://127.0.0.1:8080/"}
) == {"type": "http", "url": "http://127.0.0.1:8080/"}
assert vm_manager_module._normalize_workspace_service_readiness( # noqa: SLF001
{"type": "command", "command": "test -f .ready"}
) == {"type": "command", "command": "test -f .ready"}
with pytest.raises(ValueError, match="one of: file, tcp, http, command"):
vm_manager_module._normalize_workspace_service_readiness({"type": "bogus"}) # noqa: SLF001
with pytest.raises(ValueError, match="required for file readiness"):
vm_manager_module._normalize_workspace_service_readiness({"type": "file"}) # noqa: SLF001
with pytest.raises(ValueError, match="HOST:PORT format"):
vm_manager_module._normalize_workspace_service_readiness( # noqa: SLF001
{"type": "tcp", "address": "127.0.0.1"}
)
with pytest.raises(ValueError, match="required for http readiness"):
vm_manager_module._normalize_workspace_service_readiness({"type": "http"}) # noqa: SLF001
with pytest.raises(ValueError, match="required for command readiness"):
vm_manager_module._normalize_workspace_service_readiness({"type": "command"}) # noqa: SLF001
def test_workspace_service_text_and_exit_code_helpers(tmp_path: Path) -> None:
status_path = tmp_path / "service.status"
assert vm_manager_module._read_service_exit_code(status_path) is None # noqa: SLF001
status_path.write_text("", encoding="utf-8")
assert vm_manager_module._read_service_exit_code(status_path) is None # noqa: SLF001
status_path.write_text("7\n", encoding="utf-8")
assert vm_manager_module._read_service_exit_code(status_path) == 7 # noqa: SLF001
log_path = tmp_path / "service.log"
assert vm_manager_module._tail_text(log_path, tail_lines=10) == ("", False) # noqa: SLF001
log_path.write_text("one\ntwo\nthree\n", encoding="utf-8")
assert vm_manager_module._tail_text(log_path, tail_lines=None) == ( # noqa: SLF001
"one\ntwo\nthree\n",
False,
)
assert vm_manager_module._tail_text(log_path, tail_lines=5) == ( # noqa: SLF001
"one\ntwo\nthree\n",
False,
)
assert vm_manager_module._tail_text(log_path, tail_lines=1) == ("three\n", True) # noqa: SLF001
def test_workspace_service_process_group_helpers(monkeypatch: pytest.MonkeyPatch) -> None:
def _missing(_pid: int, _signal: int) -> None:
raise ProcessLookupError()
monkeypatch.setattr("pyro_mcp.vm_manager.os.killpg", _missing)
assert vm_manager_module._stop_process_group(123) == (False, False) # noqa: SLF001
kill_calls: list[int] = []
monotonic_values = iter([0.0, 0.0, 5.0, 5.0, 10.0])
running_states = iter([True, True, False])
def _killpg(_pid: int, signum: int) -> None:
kill_calls.append(signum)
def _monotonic() -> float:
return next(monotonic_values)
def _is_running(_pid: int | None) -> bool:
return next(running_states)
monkeypatch.setattr("pyro_mcp.vm_manager.os.killpg", _killpg)
monkeypatch.setattr("pyro_mcp.vm_manager.time.monotonic", _monotonic)
monkeypatch.setattr("pyro_mcp.vm_manager.time.sleep", lambda _seconds: None)
monkeypatch.setattr("pyro_mcp.vm_manager._pid_is_running", _is_running)
stopped, killed = vm_manager_module._stop_process_group(456, wait_seconds=5) # noqa: SLF001
assert (stopped, killed) == (True, True)
assert kill_calls == [signal.SIGTERM, signal.SIGKILL]
def test_workspace_service_probe_and_refresh_helpers(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
assert vm_manager_module._run_service_probe_command(tmp_path, "exit 3") == 3 # noqa: SLF001
services_dir = tmp_path / "services"
services_dir.mkdir()
status_path = services_dir / "app.status"
status_path.write_text("9\n", encoding="utf-8")
running = vm_manager_module.WorkspaceServiceRecord( # noqa: SLF001
workspace_id="workspace-1",
service_name="app",
command="sleep 60",
cwd="/workspace",
state="running",
started_at=time.time(),
readiness=None,
ready_at=None,
ended_at=None,
exit_code=None,
pid=1234,
execution_mode="host_compat",
stop_reason=None,
)
monkeypatch.setattr("pyro_mcp.vm_manager._pid_is_running", lambda _pid: False)
refreshed = vm_manager_module._refresh_local_service_record( # noqa: SLF001
running,
services_dir=services_dir,
)
assert refreshed.state == "exited"
assert refreshed.exit_code == 9
monkeypatch.setattr(
"pyro_mcp.vm_manager._stop_process_group",
lambda _pid: (True, False),
)
stopped = vm_manager_module._stop_local_service( # noqa: SLF001
refreshed,
services_dir=services_dir,
)
assert stopped.state == "stopped"
assert stopped.stop_reason == "sigterm"

View file

@ -165,6 +165,7 @@ def test_workspace_shells_write_and_signal_runtime_errors(
try:
with session._lock: # noqa: SLF001
session._master_fd = None # noqa: SLF001
session._input_pipe = None # noqa: SLF001
with pytest.raises(RuntimeError, match="transport is unavailable"):
session.write("pwd", append_newline=True)

2
uv.lock generated
View file

@ -706,7 +706,7 @@ crypto = [
[[package]]
name = "pyro-mcp"
version = "2.6.0"
version = "2.7.0"
source = { editable = "." }
dependencies = [
{ name = "mcp" },