Add chat-friendly shell read rendering
Make workspace shell reads usable as direct chat-model input without changing the PTY or cursor model. This adds optional plain rendering and idle-window batching across CLI, SDK, and MCP while keeping raw reads backward-compatible. Implement the rendering and wait-for-idle logic in the manager layer so the existing guest/backend shell transport stays unchanged. The new helper strips ANSI and other terminal control noise, handles carriage-return overwrite and backspace, and preserves raw cursor semantics even when plain output is requested. Refresh the stable shell docs/examples to recommend --plain --wait-for-idle-ms 300, mark the 3.5.0 roadmap milestone done, and bump the package/catalog version to 3.5.0. Validation: uv lock; UV_CACHE_DIR=.uv-cache make check; UV_CACHE_DIR=.uv-cache make dist-check; real guest-backed Firecracker smoke covering shell open/write/read with ANSI plus delayed output.
This commit is contained in:
parent
eecfd7a7d7
commit
21a88312b6
22 changed files with 539 additions and 45 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -2,6 +2,16 @@
|
|||
|
||||
All notable user-visible changes to `pyro-mcp` are documented here.
|
||||
|
||||
## 3.5.0
|
||||
|
||||
- Added chat-friendly shell reads with `--plain` and `--wait-for-idle-ms` across the CLI,
|
||||
Python SDK, and MCP server so PTY sessions can be fed back into a chat model without
|
||||
client-side ANSI cleanup.
|
||||
- Kept raw cursor-based shell reads intact for advanced clients while adding manager-side
|
||||
output rendering and idle batching on top of the existing guest/backend shell transport.
|
||||
- Updated the stable shell examples and docs to recommend `workspace shell read --plain
|
||||
--wait-for-idle-ms 300` for model-facing interactive loops.
|
||||
|
||||
## 3.4.0
|
||||
|
||||
- Added stable MCP/server tool profiles with `vm-run`, `workspace-core`, and
|
||||
|
|
|
|||
15
README.md
15
README.md
|
|
@ -22,7 +22,7 @@ It exposes the same runtime in three public forms:
|
|||
- Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif)
|
||||
- Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif)
|
||||
- PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/)
|
||||
- What's new in 3.4.0: [CHANGELOG.md#340](CHANGELOG.md#340)
|
||||
- What's new in 3.5.0: [CHANGELOG.md#350](CHANGELOG.md#350)
|
||||
- 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)
|
||||
|
|
@ -59,7 +59,7 @@ What success looks like:
|
|||
```bash
|
||||
Platform: linux-x86_64
|
||||
Runtime: PASS
|
||||
Catalog version: 3.4.0
|
||||
Catalog version: 3.5.0
|
||||
...
|
||||
[pull] phase=install environment=debian:12
|
||||
[pull] phase=ready environment=debian:12
|
||||
|
|
@ -189,7 +189,7 @@ uvx --from pyro-mcp pyro env list
|
|||
Expected output:
|
||||
|
||||
```bash
|
||||
Catalog version: 3.4.0
|
||||
Catalog version: 3.5.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.
|
||||
|
|
@ -282,7 +282,7 @@ pyro workspace reset WORKSPACE_ID
|
|||
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
|
||||
pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN
|
||||
pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'
|
||||
pyro workspace shell read WORKSPACE_ID SHELL_ID
|
||||
pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300
|
||||
pyro workspace shell close WORKSPACE_ID SHELL_ID
|
||||
pyro workspace service start WORKSPACE_ID web --secret-env API_TOKEN --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'
|
||||
|
|
@ -305,14 +305,15 @@ 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 `3.4.0`; if it fails
|
||||
later host-side changes into a started workspace. Sync is non-atomic in `3.5.0`; if it fails
|
||||
partway through, prefer `pyro workspace reset` to recover from `baseline` or one named snapshot.
|
||||
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 snapshot *` and `pyro workspace reset` when you want explicit checkpoints and
|
||||
full-sandbox recovery. 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. Use
|
||||
persistent PTY session that keeps interactive shell state between calls. Prefer
|
||||
`pyro workspace shell read --plain --wait-for-idle-ms 300` for chat-facing shell reads. 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
|
||||
|
|
@ -518,7 +519,7 @@ Persistent workspace tools:
|
|||
- `service_logs(workspace_id, service_name, tail_lines=200)`
|
||||
- `service_stop(workspace_id, service_name)`
|
||||
- `shell_open(workspace_id, cwd="/workspace", cols=120, rows=30, secret_env=null)`
|
||||
- `shell_read(workspace_id, shell_id, cursor=0, max_chars=65536)`
|
||||
- `shell_read(workspace_id, shell_id, cursor=0, max_chars=65536, plain=False, wait_for_idle_ms=None)`
|
||||
- `shell_write(workspace_id, shell_id, input, append_newline=true)`
|
||||
- `shell_signal(workspace_id, shell_id, signal_name="INT")`
|
||||
- `shell_close(workspace_id, shell_id)`
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ Networking: tun=yes ip_forward=yes
|
|||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro env list
|
||||
Catalog version: 3.4.0
|
||||
Catalog version: 3.5.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.
|
||||
|
|
@ -182,9 +182,9 @@ $ uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID --secret-env API_TO
|
|||
$ uvx --from pyro-mcp pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'
|
||||
[workspace-shell-write] workspace_id=... shell_id=... state=running cwd=/workspace cols=120 rows=30 execution_mode=guest_vsock
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace shell read WORKSPACE_ID SHELL_ID
|
||||
$ uvx --from pyro-mcp pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300
|
||||
/workspace
|
||||
[workspace-shell-read] workspace_id=... shell_id=... state=running cursor=0 next_cursor=... truncated=False execution_mode=guest_vsock
|
||||
[workspace-shell-read] workspace_id=... shell_id=... state=running cursor=0 next_cursor=... truncated=False plain=True wait_for_idle_ms=300 execution_mode=guest_vsock
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID web --secret-env API_TOKEN --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
|
||||
|
|
@ -252,7 +252,7 @@ State: started
|
|||
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 `3.4.0`; if it fails partway through, prefer `pyro workspace reset`
|
||||
workspace. Sync is non-atomic in `3.5.0`; if it fails partway through, prefer `pyro workspace reset`
|
||||
to recover from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the current
|
||||
`/workspace` tree to its immutable create-time baseline, `pyro workspace snapshot *` to create
|
||||
named checkpoints, and `pyro workspace export` to copy one changed file or directory back to the
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ uvx --from pyro-mcp pyro env list
|
|||
Expected output:
|
||||
|
||||
```bash
|
||||
Catalog version: 3.4.0
|
||||
Catalog version: 3.5.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.
|
||||
|
|
@ -251,7 +251,7 @@ pyro workspace reset WORKSPACE_ID
|
|||
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
|
||||
pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN
|
||||
pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'
|
||||
pyro workspace shell read WORKSPACE_ID SHELL_ID
|
||||
pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300
|
||||
pyro workspace shell close WORKSPACE_ID SHELL_ID
|
||||
pyro workspace service start WORKSPACE_ID web --secret-env API_TOKEN --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'
|
||||
|
|
@ -274,12 +274,13 @@ 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 `3.4.0`; if it fails partway through, prefer `pyro workspace reset` to recover
|
||||
is non-atomic in `3.5.0`; if it fails partway through, prefer `pyro workspace reset` to recover
|
||||
from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the current workspace
|
||||
tree to its immutable create-time baseline, `pyro workspace snapshot *` to capture named
|
||||
checkpoints, 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. Use `pyro workspace service *` when the
|
||||
interactive PTY that survives across separate calls. Prefer
|
||||
`pyro workspace shell read --plain --wait-for-idle-ms 300` for chat-facing shell loops. 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. Use `--network-policy egress` when the workspace needs outbound guest networking,
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ Recommended default:
|
|||
- `Pyro.create_workspace(..., network_policy="egress+published-ports")` + `Pyro.start_service(..., published_ports=[...])` when the host must probe one workspace service
|
||||
- `Pyro.diff_workspace(...)` + `Pyro.export_workspace(...)` when the agent needs baseline comparison or host-out file transfer
|
||||
- `Pyro.start_service(..., secret_env=...)` + `Pyro.list_services(...)` + `Pyro.logs_service(...)` when the agent needs long-running background processes in one workspace
|
||||
- `Pyro.open_shell(..., secret_env=...)` + `Pyro.write_shell(...)` + `Pyro.read_shell(...)` when the agent needs an interactive PTY inside the workspace
|
||||
- `Pyro.open_shell(..., secret_env=...)` + `Pyro.write_shell(...)` + `Pyro.read_shell(..., plain=True, wait_for_idle_ms=300)` when the agent needs an interactive PTY inside the workspace
|
||||
|
||||
Lifecycle note:
|
||||
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ Behavioral guarantees:
|
|||
- `pyro workspace patch apply WORKSPACE_ID --patch TEXT` applies one unified text patch with add/modify/delete operations under `/workspace`.
|
||||
- `pyro workspace shell open --secret-env SECRET_NAME[=ENV_VAR]` maps one persisted secret into the opened shell environment.
|
||||
- `pyro workspace shell *` manages persistent PTY sessions inside a started workspace.
|
||||
- `pyro workspace shell read --plain --wait-for-idle-ms 300` is the recommended chat-facing read mode; raw shell reads remain available without `--plain`.
|
||||
- `pyro workspace logs` returns persisted command history for that workspace until `pyro workspace delete`.
|
||||
- `pyro workspace update` changes only discovery metadata such as `name` and key/value `labels`.
|
||||
- Workspace create/status results expose `workspace_seed` metadata describing how `/workspace` was initialized.
|
||||
|
|
@ -157,7 +158,7 @@ Supported public entrypoints:
|
|||
- `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, secret_env=None)`
|
||||
- `Pyro.read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536)`
|
||||
- `Pyro.read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536, plain=False, wait_for_idle_ms=None)`
|
||||
- `Pyro.write_shell(workspace_id, shell_id, *, input, append_newline=True)`
|
||||
- `Pyro.signal_shell(workspace_id, shell_id, *, signal_name="INT")`
|
||||
- `Pyro.close_shell(workspace_id, shell_id)`
|
||||
|
|
@ -207,7 +208,7 @@ Stable public method names:
|
|||
- `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, secret_env=None)`
|
||||
- `read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536)`
|
||||
- `read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536, plain=False, wait_for_idle_ms=None)`
|
||||
- `write_shell(workspace_id, shell_id, *, input, append_newline=True)`
|
||||
- `signal_shell(workspace_id, shell_id, *, signal_name="INT")`
|
||||
- `close_shell(workspace_id, shell_id)`
|
||||
|
|
@ -260,7 +261,7 @@ Behavioral defaults:
|
|||
- `Pyro.exec_workspace(...)` runs one command in the persistent workspace and leaves it alive.
|
||||
- `Pyro.open_shell(..., secret_env=...)` maps persisted workspace secrets into the shell environment when that shell opens.
|
||||
- `Pyro.open_shell(...)` opens a persistent PTY shell attached to one started workspace.
|
||||
- `Pyro.read_shell(...)` reads merged text output from that shell by cursor.
|
||||
- `Pyro.read_shell(...)` reads merged text output from that shell by cursor, with optional plain rendering and idle batching for chat-facing consumers.
|
||||
- `Pyro.write_shell(...)`, `Pyro.signal_shell(...)`, and `Pyro.close_shell(...)` operate on that persistent shell session.
|
||||
- `Pyro.update_workspace(...)` changes only discovery metadata such as `name` and key/value `labels`.
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ goal:
|
|||
make the core agent-workspace use cases feel trivial from a chat-driven LLM
|
||||
interface.
|
||||
|
||||
Current baseline is `3.4.0`:
|
||||
Current baseline is `3.5.0`:
|
||||
|
||||
- the stable workspace contract exists across CLI, SDK, and MCP
|
||||
- one-shot `pyro run` still exists as the narrow entrypoint
|
||||
|
|
@ -48,7 +48,7 @@ More concretely, the model should not need to:
|
|||
1. [`3.2.0` Model-Native Workspace File Ops](llm-chat-ergonomics/3.2.0-model-native-workspace-file-ops.md) - Done
|
||||
2. [`3.3.0` Workspace Naming And Discovery](llm-chat-ergonomics/3.3.0-workspace-naming-and-discovery.md) - Done
|
||||
3. [`3.4.0` Tool Profiles And Canonical Chat Flows](llm-chat-ergonomics/3.4.0-tool-profiles-and-canonical-chat-flows.md) - Done
|
||||
4. [`3.5.0` Chat-Friendly Shell Output](llm-chat-ergonomics/3.5.0-chat-friendly-shell-output.md)
|
||||
4. [`3.5.0` Chat-Friendly Shell Output](llm-chat-ergonomics/3.5.0-chat-friendly-shell-output.md) - Done
|
||||
5. [`3.6.0` Use-Case Recipes And Smoke Packs](llm-chat-ergonomics/3.6.0-use-case-recipes-and-smoke-packs.md)
|
||||
|
||||
Completed so far:
|
||||
|
|
@ -61,6 +61,8 @@ Completed so far:
|
|||
- `3.4.0` added stable MCP/server tool profiles with `vm-run`, `workspace-core`, and
|
||||
`workspace-full`, plus canonical profile-based OpenAI and MCP examples so chat hosts can start
|
||||
narrow and widen only when needed.
|
||||
- `3.5.0` added chat-friendly shell reads with plain-text rendering and idle batching so PTY
|
||||
sessions are readable enough to feed directly back into a chat model.
|
||||
|
||||
## Expected Outcome
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# `3.5.0` Chat-Friendly Shell Output
|
||||
|
||||
Status: Planned
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,13 @@ def main() -> None:
|
|||
pyro.write_shell(workspace_id, shell_id, input="pwd")
|
||||
deadline = time.time() + 5
|
||||
while True:
|
||||
read = pyro.read_shell(workspace_id, shell_id, cursor=0)
|
||||
read = pyro.read_shell(
|
||||
workspace_id,
|
||||
shell_id,
|
||||
cursor=0,
|
||||
plain=True,
|
||||
wait_for_idle_ms=300,
|
||||
)
|
||||
output = str(read["output"])
|
||||
if "/workspace" in output or time.time() >= deadline:
|
||||
print(output, end="")
|
||||
|
|
|
|||
|
|
@ -83,7 +83,13 @@ def main() -> None:
|
|||
shell_id,
|
||||
input='printf "%s\\n" "$API_TOKEN"',
|
||||
)
|
||||
shell_output = pyro.read_shell(workspace_id, shell_id, cursor=0)
|
||||
shell_output = pyro.read_shell(
|
||||
workspace_id,
|
||||
shell_id,
|
||||
cursor=0,
|
||||
plain=True,
|
||||
wait_for_idle_ms=300,
|
||||
)
|
||||
print(f"shell_output_len={len(shell_output['output'])}")
|
||||
pyro.close_shell(workspace_id, shell_id)
|
||||
pyro.start_service(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "pyro-mcp"
|
||||
version = "3.4.0"
|
||||
version = "3.5.0"
|
||||
description = "Stable Firecracker workspaces, one-shot sandboxes, and MCP tools for coding agents."
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
|
|
|
|||
|
|
@ -330,12 +330,16 @@ class Pyro:
|
|||
*,
|
||||
cursor: int = 0,
|
||||
max_chars: int = 65536,
|
||||
plain: bool = False,
|
||||
wait_for_idle_ms: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._manager.read_shell(
|
||||
workspace_id,
|
||||
shell_id,
|
||||
cursor=cursor,
|
||||
max_chars=max_chars,
|
||||
plain=plain,
|
||||
wait_for_idle_ms=wait_for_idle_ms,
|
||||
)
|
||||
|
||||
def write_shell(
|
||||
|
|
@ -902,6 +906,8 @@ class Pyro:
|
|||
shell_id: str,
|
||||
cursor: int = 0,
|
||||
max_chars: int = 65536,
|
||||
plain: bool = False,
|
||||
wait_for_idle_ms: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Read merged PTY output from a workspace shell."""
|
||||
return self.read_shell(
|
||||
|
|
@ -909,6 +915,8 @@ class Pyro:
|
|||
shell_id,
|
||||
cursor=cursor,
|
||||
max_chars=max_chars,
|
||||
plain=plain,
|
||||
wait_for_idle_ms=wait_for_idle_ms,
|
||||
)
|
||||
|
||||
if _enabled("shell_write"):
|
||||
|
|
|
|||
|
|
@ -532,6 +532,8 @@ def _print_workspace_shell_read_human(payload: dict[str, Any]) -> None:
|
|||
f"cursor={int(payload.get('cursor', 0))} "
|
||||
f"next_cursor={int(payload.get('next_cursor', 0))} "
|
||||
f"truncated={bool(payload.get('truncated', False))} "
|
||||
f"plain={bool(payload.get('plain', False))} "
|
||||
f"wait_for_idle_ms={payload.get('wait_for_idle_ms')} "
|
||||
f"execution_mode={str(payload.get('execution_mode', 'unknown'))}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
|
|
@ -1529,7 +1531,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
Examples:
|
||||
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 read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300
|
||||
pyro workspace shell signal WORKSPACE_ID SHELL_ID --signal INT
|
||||
pyro workspace shell close WORKSPACE_ID SHELL_ID
|
||||
|
||||
|
|
@ -1592,10 +1594,10 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
epilog=dedent(
|
||||
"""
|
||||
Example:
|
||||
pyro workspace shell read WORKSPACE_ID SHELL_ID --cursor 0
|
||||
pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300
|
||||
|
||||
Shell output is written to stdout. The read summary is written to stderr.
|
||||
Use --json for a deterministic structured response.
|
||||
Use --plain for chat-friendly output and --json for a deterministic structured response.
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
|
|
@ -1622,6 +1624,17 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
default=65536,
|
||||
help="Maximum number of characters to return from the current cursor position.",
|
||||
)
|
||||
workspace_shell_read_parser.add_argument(
|
||||
"--plain",
|
||||
action="store_true",
|
||||
help="Strip terminal control sequences and normalize shell output for chat consumption.",
|
||||
)
|
||||
workspace_shell_read_parser.add_argument(
|
||||
"--wait-for-idle-ms",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Wait for this many milliseconds of shell-idle time before returning output.",
|
||||
)
|
||||
workspace_shell_read_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
|
|
@ -2652,6 +2665,8 @@ def main() -> None:
|
|||
args.shell_id,
|
||||
cursor=args.cursor,
|
||||
max_chars=args.max_chars,
|
||||
plain=bool(args.plain),
|
||||
wait_for_idle_ms=args.wait_for_idle_ms,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if bool(args.json):
|
||||
|
|
|
|||
|
|
@ -83,7 +83,13 @@ PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS = (
|
|||
"--secret-env",
|
||||
"--json",
|
||||
)
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS = ("--cursor", "--max-chars", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS = (
|
||||
"--cursor",
|
||||
"--max-chars",
|
||||
"--plain",
|
||||
"--wait-for-idle-ms",
|
||||
"--json",
|
||||
)
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_WRITE_FLAGS = ("--input", "--no-newline", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_SIGNAL_FLAGS = ("--signal", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_CLOSE_FLAGS = ("--json",)
|
||||
|
|
|
|||
|
|
@ -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 = "3.4.0"
|
||||
DEFAULT_CATALOG_VERSION = "3.5.0"
|
||||
OCI_MANIFEST_ACCEPT = ", ".join(
|
||||
(
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ from pyro_mcp.workspace_files import (
|
|||
write_workspace_file,
|
||||
)
|
||||
from pyro_mcp.workspace_ports import DEFAULT_PUBLISHED_PORT_HOST
|
||||
from pyro_mcp.workspace_shell_output import render_plain_shell_output
|
||||
from pyro_mcp.workspace_shells import (
|
||||
create_local_shell,
|
||||
get_local_shell,
|
||||
|
|
@ -97,6 +98,9 @@ WORKSPACE_SECRET_MAX_BYTES = 64 * 1024
|
|||
DEFAULT_SHELL_COLS = 120
|
||||
DEFAULT_SHELL_ROWS = 30
|
||||
DEFAULT_SHELL_MAX_CHARS = 65536
|
||||
DEFAULT_SHELL_WAIT_FOR_IDLE_MS = 300
|
||||
MAX_SHELL_WAIT_FOR_IDLE_MS = 10000
|
||||
SHELL_IDLE_POLL_INTERVAL_SECONDS = 0.05
|
||||
DEFAULT_WORKSPACE_DISK_READ_MAX_BYTES = 65536
|
||||
DEFAULT_WORKSPACE_FILE_READ_MAX_BYTES = DEFAULT_WORKSPACE_FILE_READ_LIMIT
|
||||
WORKSPACE_FILE_MAX_BYTES = WORKSPACE_FILE_MAX_LIMIT
|
||||
|
|
@ -4597,24 +4601,42 @@ class VmManager:
|
|||
*,
|
||||
cursor: int = 0,
|
||||
max_chars: int = DEFAULT_SHELL_MAX_CHARS,
|
||||
plain: bool = False,
|
||||
wait_for_idle_ms: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if cursor < 0:
|
||||
raise ValueError("cursor must not be negative")
|
||||
if max_chars <= 0:
|
||||
raise ValueError("max_chars must be positive")
|
||||
if wait_for_idle_ms is not None and (
|
||||
wait_for_idle_ms <= 0 or wait_for_idle_ms > MAX_SHELL_WAIT_FOR_IDLE_MS
|
||||
):
|
||||
raise ValueError(
|
||||
f"wait_for_idle_ms must be between 1 and {MAX_SHELL_WAIT_FOR_IDLE_MS}"
|
||||
)
|
||||
with self._lock:
|
||||
workspace = self._load_workspace_locked(workspace_id)
|
||||
instance = self._workspace_instance_for_live_shell_locked(workspace)
|
||||
shell = self._load_workspace_shell_locked(workspace_id, shell_id)
|
||||
redact_values = self._workspace_secret_redact_values_locked(workspace)
|
||||
try:
|
||||
payload = self._backend.read_shell(
|
||||
instance,
|
||||
workspace_id=workspace_id,
|
||||
shell_id=shell_id,
|
||||
cursor=cursor,
|
||||
max_chars=max_chars,
|
||||
)
|
||||
if wait_for_idle_ms is None:
|
||||
payload = self._backend.read_shell(
|
||||
instance,
|
||||
workspace_id=workspace_id,
|
||||
shell_id=shell_id,
|
||||
cursor=cursor,
|
||||
max_chars=max_chars,
|
||||
)
|
||||
else:
|
||||
payload = self._read_shell_with_idle_wait(
|
||||
instance=instance,
|
||||
workspace_id=workspace_id,
|
||||
shell_id=shell_id,
|
||||
cursor=cursor,
|
||||
max_chars=max_chars,
|
||||
wait_for_idle_ms=wait_for_idle_ms,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise _redact_exception(exc, redact_values) from exc
|
||||
updated_shell = self._workspace_shell_record_from_payload(
|
||||
|
|
@ -4631,17 +4653,84 @@ class VmManager:
|
|||
workspace.metadata = dict(instance.metadata)
|
||||
self._save_workspace_locked(workspace)
|
||||
self._save_workspace_shell_locked(updated_shell)
|
||||
raw_output = _redact_text(str(payload.get("output", "")), redact_values)
|
||||
rendered_output = render_plain_shell_output(raw_output) if plain else raw_output
|
||||
truncated = bool(payload.get("truncated", False))
|
||||
if plain and len(rendered_output) > max_chars:
|
||||
rendered_output = rendered_output[:max_chars]
|
||||
truncated = True
|
||||
response = self._serialize_workspace_shell(updated_shell)
|
||||
response.update(
|
||||
{
|
||||
"cursor": int(payload.get("cursor", cursor)),
|
||||
"next_cursor": int(payload.get("next_cursor", cursor)),
|
||||
"output": _redact_text(str(payload.get("output", "")), redact_values),
|
||||
"truncated": bool(payload.get("truncated", False)),
|
||||
"output": rendered_output,
|
||||
"truncated": truncated,
|
||||
"plain": plain,
|
||||
"wait_for_idle_ms": wait_for_idle_ms,
|
||||
}
|
||||
)
|
||||
return response
|
||||
|
||||
def _read_shell_with_idle_wait(
|
||||
self,
|
||||
*,
|
||||
instance: VmInstance,
|
||||
workspace_id: str,
|
||||
shell_id: str,
|
||||
cursor: int,
|
||||
max_chars: int,
|
||||
wait_for_idle_ms: int,
|
||||
) -> dict[str, Any]:
|
||||
wait_seconds = wait_for_idle_ms / 1000
|
||||
current_cursor = cursor
|
||||
remaining_chars = max_chars
|
||||
raw_chunks: list[str] = []
|
||||
last_payload: dict[str, Any] | None = None
|
||||
saw_output = False
|
||||
deadline = time.monotonic() + wait_seconds
|
||||
while True:
|
||||
payload = self._backend.read_shell(
|
||||
instance,
|
||||
workspace_id=workspace_id,
|
||||
shell_id=shell_id,
|
||||
cursor=current_cursor,
|
||||
max_chars=remaining_chars,
|
||||
)
|
||||
last_payload = payload
|
||||
next_cursor = int(payload.get("next_cursor", current_cursor))
|
||||
chunk = str(payload.get("output", ""))
|
||||
advanced = next_cursor > current_cursor or bool(chunk)
|
||||
if advanced:
|
||||
saw_output = True
|
||||
if chunk:
|
||||
raw_chunks.append(chunk)
|
||||
consumed = max(0, next_cursor - current_cursor)
|
||||
current_cursor = next_cursor
|
||||
remaining_chars = max(0, remaining_chars - consumed)
|
||||
deadline = time.monotonic() + wait_seconds
|
||||
if remaining_chars <= 0 or str(payload.get("state", "")) != "running":
|
||||
break
|
||||
time.sleep(SHELL_IDLE_POLL_INTERVAL_SECONDS)
|
||||
continue
|
||||
if str(payload.get("state", "")) != "running":
|
||||
break
|
||||
if time.monotonic() >= deadline:
|
||||
break
|
||||
if not saw_output and wait_seconds <= 0:
|
||||
break
|
||||
time.sleep(SHELL_IDLE_POLL_INTERVAL_SECONDS)
|
||||
if last_payload is None:
|
||||
raise RuntimeError(f"shell {shell_id} did not return a read payload")
|
||||
if current_cursor == cursor:
|
||||
return last_payload
|
||||
aggregated = dict(last_payload)
|
||||
aggregated["cursor"] = cursor
|
||||
aggregated["next_cursor"] = current_cursor
|
||||
aggregated["output"] = "".join(raw_chunks)
|
||||
aggregated["truncated"] = bool(last_payload.get("truncated", False)) or remaining_chars <= 0
|
||||
return aggregated
|
||||
|
||||
def write_shell(
|
||||
self,
|
||||
workspace_id: str,
|
||||
|
|
|
|||
116
src/pyro_mcp/workspace_shell_output.py
Normal file
116
src/pyro_mcp/workspace_shell_output.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""Helpers for chat-friendly workspace shell output rendering."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _apply_csi(
|
||||
final: str,
|
||||
parameters: str,
|
||||
line: list[str],
|
||||
cursor: int,
|
||||
lines: list[str],
|
||||
) -> tuple[list[str], int, list[str]]:
|
||||
if final == "K":
|
||||
mode = parameters or "0"
|
||||
if mode in {"0", ""}:
|
||||
del line[cursor:]
|
||||
elif mode == "1":
|
||||
for index in range(min(cursor, len(line))):
|
||||
line[index] = " "
|
||||
elif mode == "2":
|
||||
line.clear()
|
||||
cursor = 0
|
||||
elif final == "J":
|
||||
mode = parameters or "0"
|
||||
if mode in {"2", "3"}:
|
||||
lines.clear()
|
||||
line.clear()
|
||||
cursor = 0
|
||||
return line, cursor, lines
|
||||
|
||||
|
||||
def _consume_escape_sequence(
|
||||
text: str,
|
||||
index: int,
|
||||
line: list[str],
|
||||
cursor: int,
|
||||
lines: list[str],
|
||||
) -> tuple[int, list[str], int, list[str]]:
|
||||
if index + 1 >= len(text):
|
||||
return len(text), line, cursor, lines
|
||||
leader = text[index + 1]
|
||||
if leader == "[":
|
||||
cursor_index = index + 2
|
||||
while cursor_index < len(text):
|
||||
char = text[cursor_index]
|
||||
if "\x40" <= char <= "\x7e":
|
||||
parameters = text[index + 2 : cursor_index]
|
||||
line, cursor, lines = _apply_csi(char, parameters, line, cursor, lines)
|
||||
return cursor_index + 1, line, cursor, lines
|
||||
cursor_index += 1
|
||||
return len(text), line, cursor, lines
|
||||
if leader in {"]", "P", "_", "^"}:
|
||||
cursor_index = index + 2
|
||||
while cursor_index < len(text):
|
||||
char = text[cursor_index]
|
||||
if char == "\x07":
|
||||
return cursor_index + 1, line, cursor, lines
|
||||
if char == "\x1b" and cursor_index + 1 < len(text) and text[cursor_index + 1] == "\\":
|
||||
return cursor_index + 2, line, cursor, lines
|
||||
cursor_index += 1
|
||||
return len(text), line, cursor, lines
|
||||
if leader == "O":
|
||||
return min(index + 3, len(text)), line, cursor, lines
|
||||
return min(index + 2, len(text)), line, cursor, lines
|
||||
|
||||
|
||||
def render_plain_shell_output(raw_text: str) -> str:
|
||||
"""Render PTY output into chat-friendly plain text."""
|
||||
lines: list[str] = []
|
||||
line: list[str] = []
|
||||
cursor = 0
|
||||
ended_with_newline = False
|
||||
index = 0
|
||||
while index < len(raw_text):
|
||||
char = raw_text[index]
|
||||
if char == "\x1b":
|
||||
index, line, cursor, lines = _consume_escape_sequence(
|
||||
raw_text,
|
||||
index,
|
||||
line,
|
||||
cursor,
|
||||
lines,
|
||||
)
|
||||
ended_with_newline = False
|
||||
continue
|
||||
if char == "\r":
|
||||
cursor = 0
|
||||
ended_with_newline = False
|
||||
index += 1
|
||||
continue
|
||||
if char == "\n":
|
||||
lines.append("".join(line))
|
||||
line = []
|
||||
cursor = 0
|
||||
ended_with_newline = True
|
||||
index += 1
|
||||
continue
|
||||
if char == "\b":
|
||||
if cursor > 0:
|
||||
cursor -= 1
|
||||
if cursor < len(line):
|
||||
del line[cursor]
|
||||
ended_with_newline = False
|
||||
index += 1
|
||||
continue
|
||||
if char == "\t" or (ord(char) >= 32 and ord(char) != 127):
|
||||
if cursor < len(line):
|
||||
line[cursor] = char
|
||||
else:
|
||||
line.append(char)
|
||||
cursor += 1
|
||||
ended_with_newline = False
|
||||
index += 1
|
||||
if line or ended_with_newline:
|
||||
lines.append("".join(line))
|
||||
return "\n".join(lines)
|
||||
|
|
@ -37,7 +37,7 @@ def test_pyro_run_in_vm_delegates_to_manager(tmp_path: Path) -> None:
|
|||
assert str(result["stdout"]) == "ok\n"
|
||||
|
||||
|
||||
def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None:
|
||||
def test_pyro_create_server_registers_full_profile_and_shell_read_schema(tmp_path: Path) -> None:
|
||||
pyro = Pyro(
|
||||
manager=VmManager(
|
||||
backend_name="mock",
|
||||
|
|
@ -46,13 +46,17 @@ def test_pyro_create_server_registers_vm_run(tmp_path: Path) -> None:
|
|||
)
|
||||
)
|
||||
|
||||
async def _run() -> list[str]:
|
||||
async def _run() -> tuple[list[str], dict[str, dict[str, Any]]]:
|
||||
server = pyro.create_server()
|
||||
tools = await server.list_tools()
|
||||
return sorted(tool.name for tool in tools)
|
||||
tool_map = {tool.name: tool.model_dump() for tool in tools}
|
||||
return sorted(tool_map), tool_map
|
||||
|
||||
tool_names = asyncio.run(_run())
|
||||
tool_names, tool_map = asyncio.run(_run())
|
||||
assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS))
|
||||
shell_read_properties = tool_map["shell_read"]["inputSchema"]["properties"]
|
||||
assert "plain" in shell_read_properties
|
||||
assert "wait_for_idle_ms" in shell_read_properties
|
||||
|
||||
|
||||
def test_pyro_create_server_vm_run_profile_registers_only_vm_run(tmp_path: Path) -> None:
|
||||
|
|
@ -977,6 +981,8 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
|
|||
*,
|
||||
cursor: int = 0,
|
||||
max_chars: int = 65536,
|
||||
plain: bool = False,
|
||||
wait_for_idle_ms: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
calls.append(
|
||||
(
|
||||
|
|
@ -986,6 +992,8 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
|
|||
"shell_id": shell_id,
|
||||
"cursor": cursor,
|
||||
"max_chars": max_chars,
|
||||
"plain": plain,
|
||||
"wait_for_idle_ms": wait_for_idle_ms,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
|
@ -1097,6 +1105,8 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
|
|||
"shell_id": "shell-1",
|
||||
"cursor": 5,
|
||||
"max_chars": 1024,
|
||||
"plain": True,
|
||||
"wait_for_idle_ms": 300,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
|
@ -1212,6 +1222,8 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non
|
|||
"shell_id": "shell-1",
|
||||
"cursor": 5,
|
||||
"max_chars": 1024,
|
||||
"plain": True,
|
||||
"wait_for_idle_ms": 300,
|
||||
},
|
||||
),
|
||||
(
|
||||
|
|
|
|||
|
|
@ -278,6 +278,8 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
_subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "read"
|
||||
).format_help()
|
||||
assert "Shell output is written to stdout." in workspace_shell_read_help
|
||||
assert "--plain" in workspace_shell_read_help
|
||||
assert "--wait-for-idle-ms" in workspace_shell_read_help
|
||||
|
||||
workspace_shell_write_help = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "write"
|
||||
|
|
@ -2259,11 +2261,15 @@ def test_cli_workspace_shell_open_and_read_human(
|
|||
*,
|
||||
cursor: int,
|
||||
max_chars: int,
|
||||
plain: bool = False,
|
||||
wait_for_idle_ms: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert shell_id == "shell-123"
|
||||
assert cursor == 0
|
||||
assert max_chars == 1024
|
||||
assert plain is True
|
||||
assert wait_for_idle_ms == 300
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"shell_id": shell_id,
|
||||
|
|
@ -2279,6 +2285,8 @@ def test_cli_workspace_shell_open_and_read_human(
|
|||
"next_cursor": 14,
|
||||
"output": "pyro$ pwd\n",
|
||||
"truncated": False,
|
||||
"plain": plain,
|
||||
"wait_for_idle_ms": wait_for_idle_ms,
|
||||
}
|
||||
|
||||
class OpenParser:
|
||||
|
|
@ -2309,6 +2317,8 @@ def test_cli_workspace_shell_open_and_read_human(
|
|||
shell_id="shell-123",
|
||||
cursor=0,
|
||||
max_chars=1024,
|
||||
plain=True,
|
||||
wait_for_idle_ms=300,
|
||||
json=False,
|
||||
)
|
||||
|
||||
|
|
@ -2319,6 +2329,8 @@ def test_cli_workspace_shell_open_and_read_human(
|
|||
assert "pyro$ pwd\n" in captured.out
|
||||
assert "[workspace-shell-open] workspace_id=workspace-123 shell_id=shell-123" in captured.err
|
||||
assert "[workspace-shell-read] workspace_id=workspace-123 shell_id=shell-123" in captured.err
|
||||
assert "plain=True" in captured.err
|
||||
assert "wait_for_idle_ms=300" in captured.err
|
||||
|
||||
|
||||
def test_cli_workspace_shell_write_signal_close_json(
|
||||
|
|
@ -2478,7 +2490,11 @@ def test_cli_workspace_shell_open_and_read_json(
|
|||
*,
|
||||
cursor: int,
|
||||
max_chars: int,
|
||||
plain: bool = False,
|
||||
wait_for_idle_ms: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
assert plain is False
|
||||
assert wait_for_idle_ms is None
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"shell_id": shell_id,
|
||||
|
|
@ -2494,6 +2510,8 @@ def test_cli_workspace_shell_open_and_read_json(
|
|||
"next_cursor": max_chars,
|
||||
"output": "pyro$ pwd\n",
|
||||
"truncated": False,
|
||||
"plain": plain,
|
||||
"wait_for_idle_ms": wait_for_idle_ms,
|
||||
}
|
||||
|
||||
class OpenParser:
|
||||
|
|
@ -2526,6 +2544,8 @@ def test_cli_workspace_shell_open_and_read_json(
|
|||
shell_id="shell-123",
|
||||
cursor=0,
|
||||
max_chars=1024,
|
||||
plain=False,
|
||||
wait_for_idle_ms=None,
|
||||
json=True,
|
||||
)
|
||||
|
||||
|
|
@ -2655,7 +2675,16 @@ def test_cli_workspace_shell_write_signal_close_human(
|
|||
("shell_command", "kwargs"),
|
||||
[
|
||||
("open", {"cwd": "/workspace", "cols": 120, "rows": 30}),
|
||||
("read", {"shell_id": "shell-123", "cursor": 0, "max_chars": 1024}),
|
||||
(
|
||||
"read",
|
||||
{
|
||||
"shell_id": "shell-123",
|
||||
"cursor": 0,
|
||||
"max_chars": 1024,
|
||||
"plain": False,
|
||||
"wait_for_idle_ms": None,
|
||||
},
|
||||
),
|
||||
("write", {"shell_id": "shell-123", "input": "pwd", "no_newline": False}),
|
||||
("signal", {"shell_id": "shell-123", "signal": "INT"}),
|
||||
("close", {"shell_id": "shell-123"}),
|
||||
|
|
|
|||
|
|
@ -1239,6 +1239,144 @@ def test_workspace_shell_lifecycle_and_rehydration(tmp_path: Path) -> None:
|
|||
manager_rehydrated.read_shell(workspace_id, second_shell_id)
|
||||
|
||||
|
||||
def test_workspace_read_shell_plain_renders_control_sequences(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
created = manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
)
|
||||
workspace_id = str(created["workspace_id"])
|
||||
opened = manager.open_shell(workspace_id)
|
||||
shell_id = str(opened["shell_id"])
|
||||
|
||||
monkeypatch.setattr(
|
||||
manager._backend, # noqa: SLF001
|
||||
"read_shell",
|
||||
lambda *args, **kwargs: {
|
||||
"shell_id": shell_id,
|
||||
"cwd": "/workspace",
|
||||
"cols": 120,
|
||||
"rows": 30,
|
||||
"state": "running",
|
||||
"started_at": 1.0,
|
||||
"ended_at": None,
|
||||
"exit_code": None,
|
||||
"execution_mode": "host_compat",
|
||||
"cursor": 0,
|
||||
"next_cursor": 15,
|
||||
"output": "hello\r\x1b[2Kbye\n",
|
||||
"truncated": False,
|
||||
},
|
||||
)
|
||||
|
||||
read = manager.read_shell(
|
||||
workspace_id,
|
||||
shell_id,
|
||||
cursor=0,
|
||||
max_chars=1024,
|
||||
plain=True,
|
||||
)
|
||||
|
||||
assert read["output"] == "bye\n"
|
||||
assert read["plain"] is True
|
||||
assert read["wait_for_idle_ms"] is None
|
||||
|
||||
|
||||
def test_workspace_read_shell_wait_for_idle_batches_output(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
created = manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
)
|
||||
workspace_id = str(created["workspace_id"])
|
||||
opened = manager.open_shell(workspace_id)
|
||||
shell_id = str(opened["shell_id"])
|
||||
|
||||
payloads = [
|
||||
{
|
||||
"shell_id": shell_id,
|
||||
"cwd": "/workspace",
|
||||
"cols": 120,
|
||||
"rows": 30,
|
||||
"state": "running",
|
||||
"started_at": 1.0,
|
||||
"ended_at": None,
|
||||
"exit_code": None,
|
||||
"execution_mode": "host_compat",
|
||||
"cursor": 0,
|
||||
"next_cursor": 4,
|
||||
"output": "one\n",
|
||||
"truncated": False,
|
||||
},
|
||||
{
|
||||
"shell_id": shell_id,
|
||||
"cwd": "/workspace",
|
||||
"cols": 120,
|
||||
"rows": 30,
|
||||
"state": "running",
|
||||
"started_at": 1.0,
|
||||
"ended_at": None,
|
||||
"exit_code": None,
|
||||
"execution_mode": "host_compat",
|
||||
"cursor": 4,
|
||||
"next_cursor": 8,
|
||||
"output": "two\n",
|
||||
"truncated": False,
|
||||
},
|
||||
{
|
||||
"shell_id": shell_id,
|
||||
"cwd": "/workspace",
|
||||
"cols": 120,
|
||||
"rows": 30,
|
||||
"state": "running",
|
||||
"started_at": 1.0,
|
||||
"ended_at": None,
|
||||
"exit_code": None,
|
||||
"execution_mode": "host_compat",
|
||||
"cursor": 8,
|
||||
"next_cursor": 8,
|
||||
"output": "",
|
||||
"truncated": False,
|
||||
},
|
||||
]
|
||||
|
||||
def fake_read_shell(*args: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
del args, kwargs
|
||||
return payloads.pop(0)
|
||||
|
||||
monotonic_values = iter([0.0, 0.05, 0.10, 0.41])
|
||||
monkeypatch.setattr(manager._backend, "read_shell", fake_read_shell) # noqa: SLF001
|
||||
monkeypatch.setattr(time, "monotonic", lambda: next(monotonic_values))
|
||||
monkeypatch.setattr(time, "sleep", lambda _: None)
|
||||
|
||||
read = manager.read_shell(
|
||||
workspace_id,
|
||||
shell_id,
|
||||
cursor=0,
|
||||
max_chars=1024,
|
||||
wait_for_idle_ms=300,
|
||||
)
|
||||
|
||||
assert read["output"] == "one\ntwo\n"
|
||||
assert read["next_cursor"] == 8
|
||||
assert read["wait_for_idle_ms"] == 300
|
||||
assert read["plain"] is False
|
||||
|
||||
|
||||
def test_workspace_create_rejects_unsafe_seed_archive(tmp_path: Path) -> None:
|
||||
archive_path = tmp_path / "bad.tgz"
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
|
|
@ -3092,6 +3230,24 @@ def test_workspace_stop_and_start_preserve_logs_and_clear_live_state(tmp_path: P
|
|||
assert rerun["stdout"] == "hello from seed\n"
|
||||
|
||||
|
||||
def test_workspace_read_shell_rejects_invalid_wait_for_idle_ms(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
created = manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
)
|
||||
workspace_id = str(created["workspace_id"])
|
||||
opened = manager.open_shell(workspace_id)
|
||||
shell_id = str(opened["shell_id"])
|
||||
|
||||
with pytest.raises(ValueError, match="wait_for_idle_ms must be between 1 and 10000"):
|
||||
manager.read_shell(workspace_id, shell_id, cursor=0, max_chars=1024, wait_for_idle_ms=0)
|
||||
|
||||
|
||||
def test_workspace_stop_flushes_guest_filesystem_before_stopping(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
|
|
|||
36
tests/test_workspace_shell_output.py
Normal file
36
tests/test_workspace_shell_output.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pyro_mcp.workspace_shell_output import render_plain_shell_output
|
||||
|
||||
|
||||
def test_render_plain_shell_output_strips_ansi_osc_and_controls() -> None:
|
||||
raw = "\x1b]0;title\x07\x1b[31mred\x1b[0m\t\x01done"
|
||||
assert render_plain_shell_output(raw) == "red\tdone"
|
||||
|
||||
|
||||
def test_render_plain_shell_output_handles_carriage_return_and_backspace() -> None:
|
||||
raw = "hello\r\x1b[2Kbye\nabc\b\bZ"
|
||||
assert render_plain_shell_output(raw) == "bye\naZ"
|
||||
|
||||
|
||||
def test_render_plain_shell_output_preserves_trailing_newlines() -> None:
|
||||
assert render_plain_shell_output("line one\n") == "line one\n"
|
||||
assert render_plain_shell_output("\n") == "\n"
|
||||
|
||||
|
||||
def test_render_plain_shell_output_handles_line_clear_modes_and_overwrite() -> None:
|
||||
assert render_plain_shell_output("abcde\rab\x1b[1KZ") == " Zde"
|
||||
assert render_plain_shell_output("hello\x1b[2Kx") == "x"
|
||||
|
||||
|
||||
def test_render_plain_shell_output_handles_full_screen_clear() -> None:
|
||||
assert render_plain_shell_output("one\ntwo\x1b[2Jz") == "z"
|
||||
assert render_plain_shell_output("one\ntwo\x1b[3Jz") == "z"
|
||||
|
||||
|
||||
def test_render_plain_shell_output_ignores_incomplete_and_non_csi_escape_sequences() -> None:
|
||||
assert render_plain_shell_output("\x1b") == ""
|
||||
assert render_plain_shell_output("\x1b[") == ""
|
||||
assert render_plain_shell_output("\x1b]title\x1b\\ok") == "ok"
|
||||
assert render_plain_shell_output("a\x1bOPb") == "ab"
|
||||
assert render_plain_shell_output("a\x1bXb") == "ab"
|
||||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -706,7 +706,7 @@ crypto = [
|
|||
|
||||
[[package]]
|
||||
name = "pyro-mcp"
|
||||
version = "3.4.0"
|
||||
version = "3.5.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "mcp" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue