Add persistent workspace shell sessions

Let agents inhabit a workspace across separate calls instead of only submitting one-shot execs.

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

Mark the 2.5.0 roadmap milestone done, refresh docs/examples and the release metadata, and verify with uv lock, UV_CACHE_DIR=.uv-cache make check, and UV_CACHE_DIR=.uv-cache make dist-check.
This commit is contained in:
Thales Maciel 2026-03-12 02:31:57 -03:00
parent 2de31306b6
commit 3f8293ad24
28 changed files with 3265 additions and 81 deletions

View file

@ -64,6 +64,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
assert "pyro workspace create debian:12 --seed-path ./repo" in workspace_help
assert "pyro workspace sync push WORKSPACE_ID ./repo --dest src" in workspace_help
assert "pyro workspace exec WORKSPACE_ID" in workspace_help
assert "pyro workspace shell open WORKSPACE_ID" in workspace_help
workspace_create_help = _subparser_choice(
_subparser_choice(parser, "workspace"),
@ -92,6 +93,41 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
assert "--dest" in workspace_sync_push_help
assert "Import host content into `/workspace`" in workspace_sync_push_help
workspace_shell_help = _subparser_choice(
_subparser_choice(parser, "workspace"),
"shell",
).format_help()
assert "pyro workspace shell open WORKSPACE_ID" in workspace_shell_help
assert "Use `workspace exec` for one-shot commands." in workspace_shell_help
workspace_shell_open_help = _subparser_choice(
_subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "open"
).format_help()
assert "--cwd" in workspace_shell_open_help
assert "--cols" in workspace_shell_open_help
assert "--rows" in workspace_shell_open_help
workspace_shell_read_help = _subparser_choice(
_subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "read"
).format_help()
assert "Shell output is written to stdout." in workspace_shell_read_help
workspace_shell_write_help = _subparser_choice(
_subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "write"
).format_help()
assert "--input" in workspace_shell_write_help
assert "--no-newline" in workspace_shell_write_help
workspace_shell_signal_help = _subparser_choice(
_subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "signal"
).format_help()
assert "--signal" in workspace_shell_signal_help
workspace_shell_close_help = _subparser_choice(
_subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "close"
).format_help()
assert "Close a persistent workspace shell" in workspace_shell_close_help
def test_cli_run_prints_json(
monkeypatch: pytest.MonkeyPatch,
@ -681,6 +717,226 @@ def test_cli_workspace_status_and_delete_print_json(
assert deleted["deleted"] is True
def test_cli_workspace_shell_open_and_read_human(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def open_shell(
self,
workspace_id: str,
*,
cwd: str,
cols: int,
rows: int,
) -> dict[str, Any]:
assert workspace_id == "workspace-123"
assert cwd == "/workspace"
assert cols == 120
assert rows == 30
return {
"workspace_id": workspace_id,
"shell_id": "shell-123",
"state": "running",
"cwd": cwd,
"cols": cols,
"rows": rows,
"started_at": 1.0,
"ended_at": None,
"exit_code": None,
"execution_mode": "guest_vsock",
}
def read_shell(
self,
workspace_id: str,
shell_id: str,
*,
cursor: int,
max_chars: int,
) -> dict[str, Any]:
assert workspace_id == "workspace-123"
assert shell_id == "shell-123"
assert cursor == 0
assert max_chars == 1024
return {
"workspace_id": workspace_id,
"shell_id": shell_id,
"state": "running",
"cwd": "/workspace",
"cols": 120,
"rows": 30,
"started_at": 1.0,
"ended_at": None,
"exit_code": None,
"execution_mode": "guest_vsock",
"cursor": 0,
"next_cursor": 14,
"output": "pyro$ pwd\n",
"truncated": False,
}
class OpenParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="workspace",
workspace_command="shell",
workspace_shell_command="open",
workspace_id="workspace-123",
cwd="/workspace",
cols=120,
rows=30,
json=False,
)
monkeypatch.setattr(cli, "_build_parser", lambda: OpenParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
class ReadParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="workspace",
workspace_command="shell",
workspace_shell_command="read",
workspace_id="workspace-123",
shell_id="shell-123",
cursor=0,
max_chars=1024,
json=False,
)
monkeypatch.setattr(cli, "_build_parser", lambda: ReadParser())
cli.main()
captured = capsys.readouterr()
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
def test_cli_workspace_shell_write_signal_close_json(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def write_shell(
self,
workspace_id: str,
shell_id: str,
*,
input: str,
append_newline: bool,
) -> dict[str, Any]:
assert workspace_id == "workspace-123"
assert shell_id == "shell-123"
assert input == "pwd"
assert append_newline is False
return {
"workspace_id": workspace_id,
"shell_id": shell_id,
"state": "running",
"cwd": "/workspace",
"cols": 120,
"rows": 30,
"started_at": 1.0,
"ended_at": None,
"exit_code": None,
"execution_mode": "guest_vsock",
"input_length": 3,
"append_newline": False,
}
def signal_shell(
self,
workspace_id: str,
shell_id: str,
*,
signal_name: str,
) -> dict[str, Any]:
assert signal_name == "INT"
return {
"workspace_id": workspace_id,
"shell_id": shell_id,
"state": "running",
"cwd": "/workspace",
"cols": 120,
"rows": 30,
"started_at": 1.0,
"ended_at": None,
"exit_code": None,
"execution_mode": "guest_vsock",
"signal": signal_name,
}
def close_shell(self, workspace_id: str, shell_id: str) -> dict[str, Any]:
return {
"workspace_id": workspace_id,
"shell_id": shell_id,
"state": "stopped",
"cwd": "/workspace",
"cols": 120,
"rows": 30,
"started_at": 1.0,
"ended_at": 2.0,
"exit_code": 0,
"execution_mode": "guest_vsock",
"closed": True,
}
class WriteParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="workspace",
workspace_command="shell",
workspace_shell_command="write",
workspace_id="workspace-123",
shell_id="shell-123",
input="pwd",
no_newline=True,
json=True,
)
monkeypatch.setattr(cli, "_build_parser", lambda: WriteParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
written = json.loads(capsys.readouterr().out)
assert written["append_newline"] is False
class SignalParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="workspace",
workspace_command="shell",
workspace_shell_command="signal",
workspace_id="workspace-123",
shell_id="shell-123",
signal="INT",
json=True,
)
monkeypatch.setattr(cli, "_build_parser", lambda: SignalParser())
cli.main()
signaled = json.loads(capsys.readouterr().out)
assert signaled["signal"] == "INT"
class CloseParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="workspace",
workspace_command="shell",
workspace_shell_command="close",
workspace_id="workspace-123",
shell_id="shell-123",
json=True,
)
monkeypatch.setattr(cli, "_build_parser", lambda: CloseParser())
cli.main()
closed = json.loads(capsys.readouterr().out)
assert closed["closed"] is True
def test_cli_workspace_exec_json_error_exits_nonzero(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None: