Add guest-only workspace secrets

Add explicit workspace secrets across the CLI, SDK, and MCP, with create-time secret definitions and per-call secret-to-env mapping for exec, shell open, and service start. Persist only safe secret metadata in workspace records, materialize secret files under /run/pyro-secrets, and redact secret values from exec output, shell reads, service logs, and surfaced errors.

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

Validation: uv lock; UV_CACHE_DIR=.uv-cache make check; UV_CACHE_DIR=.uv-cache make dist-check; and a real guest-backed Firecracker smoke covering workspace create with secrets, secret-backed exec, shell, service, reset, and delete.
This commit is contained in:
Thales Maciel 2026-03-12 15:43:34 -03:00
parent 18b8fd2a7d
commit fc72fcd3a1
32 changed files with 1980 additions and 181 deletions

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any, cast
import pytest
@ -75,12 +76,15 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
"create",
).format_help()
assert "--seed-path" in workspace_create_help
assert "--secret" in workspace_create_help
assert "--secret-file" in workspace_create_help
assert "seed into `/workspace`" in workspace_create_help
workspace_exec_help = _subparser_choice(
_subparser_choice(parser, "workspace"),
"exec",
).format_help()
assert "--secret-env" in workspace_exec_help
assert "persistent `/workspace`" in workspace_exec_help
assert "pyro workspace exec WORKSPACE_ID -- cat note.txt" in workspace_exec_help
@ -158,6 +162,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
assert "--ready-tcp" in workspace_service_start_help
assert "--ready-http" in workspace_service_start_help
assert "--ready-command" in workspace_service_start_help
assert "--secret-env" in workspace_service_start_help
workspace_service_logs_help = _subparser_choice(
_subparser_choice(_subparser_choice(parser, "workspace"), "service"), "logs"
@ -171,6 +176,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
assert "--cwd" in workspace_shell_open_help
assert "--cols" in workspace_shell_open_help
assert "--rows" in workspace_shell_open_help
assert "--secret-env" in workspace_shell_open_help
workspace_shell_read_help = _subparser_choice(
_subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "read"
@ -550,10 +556,12 @@ def test_cli_workspace_exec_prints_human_output(
*,
command: str,
timeout_seconds: int,
secret_env: dict[str, str] | None = None,
) -> dict[str, Any]:
assert workspace_id == "workspace-123"
assert command == "cat note.txt"
assert timeout_seconds == 30
assert secret_env is None
return {
"workspace_id": workspace_id,
"sequence": 2,
@ -572,6 +580,7 @@ def test_cli_workspace_exec_prints_human_output(
workspace_command="exec",
workspace_id="workspace-123",
timeout_seconds=30,
secret_env=[],
json=False,
command_args=["--", "cat", "note.txt"],
)
@ -1322,11 +1331,17 @@ def test_cli_workspace_exec_prints_json_and_exits_nonzero(
) -> None:
class StubPyro:
def exec_workspace(
self, workspace_id: str, *, command: str, timeout_seconds: int
self,
workspace_id: str,
*,
command: str,
timeout_seconds: int,
secret_env: dict[str, str] | None = None,
) -> dict[str, Any]:
assert workspace_id == "workspace-123"
assert command == "false"
assert timeout_seconds == 30
assert secret_env is None
return {
"workspace_id": workspace_id,
"sequence": 1,
@ -1345,6 +1360,7 @@ def test_cli_workspace_exec_prints_json_and_exits_nonzero(
workspace_command="exec",
workspace_id="workspace-123",
timeout_seconds=30,
secret_env=[],
json=True,
command_args=["--", "false"],
)
@ -1363,9 +1379,15 @@ def test_cli_workspace_exec_prints_human_error(
) -> None:
class StubPyro:
def exec_workspace(
self, workspace_id: str, *, command: str, timeout_seconds: int
self,
workspace_id: str,
*,
command: str,
timeout_seconds: int,
secret_env: dict[str, str] | None = None,
) -> dict[str, Any]:
del workspace_id, command, timeout_seconds
assert secret_env is None
raise RuntimeError("exec boom")
class ExecParser:
@ -1375,6 +1397,7 @@ def test_cli_workspace_exec_prints_human_error(
workspace_command="exec",
workspace_id="workspace-123",
timeout_seconds=30,
secret_env=[],
json=False,
command_args=["--", "cat", "note.txt"],
)
@ -1538,11 +1561,13 @@ def test_cli_workspace_shell_open_and_read_human(
cwd: str,
cols: int,
rows: int,
secret_env: dict[str, str] | None = None,
) -> dict[str, Any]:
assert workspace_id == "workspace-123"
assert cwd == "/workspace"
assert cols == 120
assert rows == 30
assert secret_env is None
return {
"workspace_id": workspace_id,
"shell_id": "shell-123",
@ -1595,6 +1620,7 @@ def test_cli_workspace_shell_open_and_read_human(
cwd="/workspace",
cols=120,
rows=30,
secret_env=[],
json=False,
)
@ -1758,7 +1784,9 @@ def test_cli_workspace_shell_open_and_read_json(
cwd: str,
cols: int,
rows: int,
secret_env: dict[str, str] | None = None,
) -> dict[str, Any]:
assert secret_env is None
return {
"workspace_id": workspace_id,
"shell_id": "shell-123",
@ -1807,6 +1835,7 @@ def test_cli_workspace_shell_open_and_read_json(
cwd="/workspace",
cols=120,
rows=30,
secret_env=[],
json=True,
)
@ -2798,3 +2827,210 @@ def test_cli_demo_ollama_verbose_and_error_paths(
with pytest.raises(SystemExit, match="1"):
cli.main()
assert "[error] tool loop failed" in capsys.readouterr().out
def test_cli_workspace_create_passes_secrets(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
tmp_path: Path,
) -> None:
secret_file = tmp_path / "token.txt"
secret_file.write_text("from-file\n", encoding="utf-8")
class StubPyro:
def create_workspace(self, **kwargs: Any) -> dict[str, Any]:
assert kwargs["environment"] == "debian:12"
assert kwargs["seed_path"] == "./repo"
assert kwargs["secrets"] == [
{"name": "API_TOKEN", "value": "expected"},
{"name": "FILE_TOKEN", "file_path": str(secret_file)},
]
return {"workspace_id": "ws-123"}
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="workspace",
workspace_command="create",
environment="debian:12",
vcpu_count=1,
mem_mib=1024,
ttl_seconds=600,
network=False,
allow_host_compat=False,
seed_path="./repo",
secret=["API_TOKEN=expected"],
secret_file=[f"FILE_TOKEN={secret_file}"],
json=True,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
output = json.loads(capsys.readouterr().out)
assert output["workspace_id"] == "ws-123"
def test_cli_workspace_exec_passes_secret_env(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def exec_workspace(self, workspace_id: str, **kwargs: Any) -> dict[str, Any]:
assert workspace_id == "ws-123"
assert kwargs["command"] == "sh -lc 'test \"$API_TOKEN\" = \"expected\"'"
assert kwargs["secret_env"] == {"API_TOKEN": "API_TOKEN", "TOKEN": "PIP_TOKEN"}
return {"exit_code": 0, "stdout": "", "stderr": ""}
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="workspace",
workspace_command="exec",
workspace_id="ws-123",
timeout_seconds=30,
secret_env=["API_TOKEN", "TOKEN=PIP_TOKEN"],
json=True,
command_args=["--", "sh", "-lc", 'test "$API_TOKEN" = "expected"'],
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
output = json.loads(capsys.readouterr().out)
assert output["exit_code"] == 0
def test_cli_workspace_shell_open_passes_secret_env(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def open_shell(self, workspace_id: str, **kwargs: Any) -> dict[str, Any]:
assert workspace_id == "ws-123"
assert kwargs["secret_env"] == {"TOKEN": "TOKEN", "API": "API_TOKEN"}
return {"workspace_id": workspace_id, "shell_id": "shell-1", "state": "running"}
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="workspace",
workspace_command="shell",
workspace_shell_command="open",
workspace_id="ws-123",
cwd="/workspace",
cols=120,
rows=30,
secret_env=["TOKEN", "API=API_TOKEN"],
json=True,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
output = json.loads(capsys.readouterr().out)
assert output["shell_id"] == "shell-1"
def test_cli_workspace_service_start_passes_secret_env(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def start_service(
self,
workspace_id: str,
service_name: str,
**kwargs: Any,
) -> dict[str, Any]:
assert workspace_id == "ws-123"
assert service_name == "app"
assert kwargs["secret_env"] == {"TOKEN": "TOKEN", "API": "API_TOKEN"}
assert kwargs["readiness"] == {"type": "file", "path": ".ready"}
assert kwargs["command"] == "sh -lc 'touch .ready && while true; do sleep 60; done'"
return {"workspace_id": workspace_id, "service_name": service_name, "state": "running"}
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="workspace",
workspace_command="service",
workspace_service_command="start",
workspace_id="ws-123",
service_name="app",
cwd="/workspace",
ready_file=".ready",
ready_tcp=None,
ready_http=None,
ready_command=None,
ready_timeout_seconds=30,
ready_interval_ms=500,
secret_env=["TOKEN", "API=API_TOKEN"],
json=True,
command_args=["--", "sh", "-lc", "touch .ready && while true; do sleep 60; done"],
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
output = json.loads(capsys.readouterr().out)
assert output["state"] == "running"
def test_cli_workspace_secret_parsers_validate_syntax(tmp_path: Path) -> None:
secret_file = tmp_path / "token.txt"
secret_file.write_text("expected\n", encoding="utf-8")
assert cli._parse_workspace_secret_option("API_TOKEN=expected") == { # noqa: SLF001
"name": "API_TOKEN",
"value": "expected",
}
assert cli._parse_workspace_secret_file_option(f"FILE_TOKEN={secret_file}") == { # noqa: SLF001
"name": "FILE_TOKEN",
"file_path": str(secret_file),
}
assert cli._parse_workspace_secret_env_options(["TOKEN", "API=PIP_TOKEN"]) == { # noqa: SLF001
"TOKEN": "TOKEN",
"API": "PIP_TOKEN",
}
with pytest.raises(ValueError, match="NAME=VALUE"):
cli._parse_workspace_secret_option("API_TOKEN") # noqa: SLF001
with pytest.raises(ValueError, match="NAME=PATH"):
cli._parse_workspace_secret_file_option("FILE_TOKEN=") # noqa: SLF001
with pytest.raises(ValueError, match="must name a secret"):
cli._parse_workspace_secret_env_options(["=TOKEN"]) # noqa: SLF001
with pytest.raises(ValueError, match="must name an environment variable"):
cli._parse_workspace_secret_env_options(["TOKEN="]) # noqa: SLF001
with pytest.raises(ValueError, match="more than once"):
cli._parse_workspace_secret_env_options(["TOKEN", "TOKEN=API_TOKEN"]) # noqa: SLF001
def test_print_workspace_summary_human_includes_secret_metadata(
capsys: pytest.CaptureFixture[str],
) -> None:
cli._print_workspace_summary_human(
{
"workspace_id": "ws-123",
"environment": "debian:12",
"state": "started",
"workspace_path": "/workspace",
"workspace_seed": {
"mode": "directory",
"seed_path": "/tmp/repo",
},
"secrets": [
{"name": "API_TOKEN", "source_kind": "literal"},
{"name": "FILE_TOKEN", "source_kind": "file"},
],
"execution_mode": "guest_vsock",
"vcpu_count": 1,
"mem_mib": 1024,
"command_count": 0,
},
action="Workspace",
)
output = capsys.readouterr().out
assert "Workspace ID: ws-123" in output
assert "Workspace seed: directory from /tmp/repo" in output
assert "Secrets: API_TOKEN (literal), FILE_TOKEN (file)" in output