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

@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import time
from pathlib import Path
from typing import Any, cast
@ -134,40 +135,71 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
source_dir = tmp_path / "seed"
source_dir.mkdir()
(source_dir / "note.txt").write_text("ok\n", encoding="utf-8")
secret_file = tmp_path / "token.txt"
secret_file.write_text("from-file\n", encoding="utf-8")
created = pyro.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
seed_path=source_dir,
secrets=[
{"name": "API_TOKEN", "value": "expected"},
{"name": "FILE_TOKEN", "file_path": str(secret_file)},
],
)
workspace_id = str(created["workspace_id"])
updated_dir = tmp_path / "updated"
updated_dir.mkdir()
(updated_dir / "more.txt").write_text("more\n", encoding="utf-8")
synced = pyro.push_workspace_sync(workspace_id, updated_dir, dest="subdir")
executed = pyro.exec_workspace(workspace_id, command="cat note.txt")
executed = pyro.exec_workspace(
workspace_id,
command='sh -lc \'printf "%s\\n" "$API_TOKEN"\'',
secret_env={"API_TOKEN": "API_TOKEN"},
)
diff_payload = pyro.diff_workspace(workspace_id)
snapshot = pyro.create_snapshot(workspace_id, "checkpoint")
snapshots = pyro.list_snapshots(workspace_id)
export_path = tmp_path / "exported-note.txt"
exported = pyro.export_workspace(workspace_id, "note.txt", output_path=export_path)
shell = pyro.open_shell(
workspace_id,
secret_env={"API_TOKEN": "API_TOKEN"},
)
shell_id = str(shell["shell_id"])
pyro.write_shell(workspace_id, shell_id, input='printf "%s\\n" "$API_TOKEN"')
shell_output: dict[str, Any] = {}
deadline = time.time() + 5
while time.time() < deadline:
shell_output = pyro.read_shell(workspace_id, shell_id, cursor=0, max_chars=65536)
if "[REDACTED]" in str(shell_output.get("output", "")):
break
time.sleep(0.05)
shell_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'",
command=(
'sh -lc \'trap "exit 0" TERM; printf "%s\\n" "$API_TOKEN" >&2; '
'touch .ready; while true; do sleep 60; done\''
),
readiness={"type": "file", "path": ".ready"},
secret_env={"API_TOKEN": "API_TOKEN"},
)
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")
reset = pyro.reset_workspace(workspace_id, snapshot="checkpoint")
deleted_snapshot = pyro.delete_snapshot(workspace_id, "checkpoint")
status = pyro.status_workspace(workspace_id)
logs = pyro.logs_workspace(workspace_id)
deleted = pyro.delete_workspace(workspace_id)
assert executed["stdout"] == "ok\n"
assert created["secrets"] == [
{"name": "API_TOKEN", "source_kind": "literal"},
{"name": "FILE_TOKEN", "source_kind": "file"},
]
assert executed["stdout"] == "[REDACTED]\n"
assert created["workspace_seed"]["mode"] == "directory"
assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
assert diff_payload["changed"] is True
@ -175,12 +207,15 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
assert snapshots["count"] == 2
assert exported["output_path"] == str(export_path)
assert export_path.read_text(encoding="utf-8") == "ok\n"
assert shell_output["output"].count("[REDACTED]") >= 1
assert shell_closed["closed"] is True
assert service["state"] == "running"
assert services["count"] == 1
assert service_status["state"] == "running"
assert service_logs["stderr"].count("[REDACTED]") >= 1
assert service_logs["tail_lines"] is None
assert service_stopped["state"] == "stopped"
assert reset["workspace_reset"]["snapshot_name"] == "checkpoint"
assert reset["secrets"] == created["secrets"]
assert deleted_snapshot["deleted"] is True
assert status["command_count"] == 0
assert status["service_count"] == 0

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

View file

@ -19,6 +19,7 @@ from pyro_mcp.contract import (
PUBLIC_CLI_RUN_FLAGS,
PUBLIC_CLI_WORKSPACE_CREATE_FLAGS,
PUBLIC_CLI_WORKSPACE_DIFF_FLAGS,
PUBLIC_CLI_WORKSPACE_EXEC_FLAGS,
PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS,
PUBLIC_CLI_WORKSPACE_RESET_FLAGS,
PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS,
@ -94,6 +95,11 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
).format_help()
for flag in PUBLIC_CLI_WORKSPACE_CREATE_FLAGS:
assert flag in workspace_create_help_text
workspace_exec_help_text = _subparser_choice(
_subparser_choice(parser, "workspace"), "exec"
).format_help()
for flag in PUBLIC_CLI_WORKSPACE_EXEC_FLAGS:
assert flag in workspace_exec_help_text
workspace_sync_help_text = _subparser_choice(
_subparser_choice(parser, "workspace"),
"sync",

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import json
import shutil
from pathlib import Path
import pytest
@ -14,6 +15,8 @@ def test_resolve_runtime_paths_default_bundle() -> None:
assert paths.jailer_bin.exists()
assert paths.guest_agent_path is not None
assert paths.guest_agent_path.exists()
assert paths.guest_init_path is not None
assert paths.guest_init_path.exists()
assert paths.artifacts_dir.exists()
assert paths.manifest.get("platform") == "linux-x86_64"
@ -51,17 +54,56 @@ def test_resolve_runtime_paths_checksum_mismatch(
guest_agent_path = source.guest_agent_path
if guest_agent_path is None:
raise AssertionError("expected guest agent in runtime bundle")
guest_init_path = source.guest_init_path
if guest_init_path is None:
raise AssertionError("expected guest init in runtime bundle")
copied_guest_dir = copied_platform / "guest"
copied_guest_dir.mkdir(parents=True, exist_ok=True)
(copied_guest_dir / "pyro_guest_agent.py").write_text(
guest_agent_path.read_text(encoding="utf-8"),
encoding="utf-8",
)
(copied_guest_dir / "pyro-init").write_text(
guest_init_path.read_text(encoding="utf-8"),
encoding="utf-8",
)
monkeypatch.setenv("PYRO_RUNTIME_BUNDLE_DIR", str(copied_bundle))
with pytest.raises(RuntimeError, match="checksum mismatch"):
resolve_runtime_paths()
def test_resolve_runtime_paths_guest_init_checksum_mismatch(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
source = resolve_runtime_paths()
copied_bundle = tmp_path / "bundle"
shutil.copytree(source.bundle_root.parent, copied_bundle)
copied_platform = copied_bundle / "linux-x86_64"
copied_guest_init = copied_platform / "guest" / "pyro-init"
copied_guest_init.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
monkeypatch.setenv("PYRO_RUNTIME_BUNDLE_DIR", str(copied_bundle))
with pytest.raises(RuntimeError, match="checksum mismatch"):
resolve_runtime_paths()
def test_resolve_runtime_paths_guest_init_manifest_malformed(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
source = resolve_runtime_paths()
copied_bundle = tmp_path / "bundle"
shutil.copytree(source.bundle_root.parent, copied_bundle)
manifest_path = copied_bundle / "linux-x86_64" / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
guest = manifest.get("guest")
if not isinstance(guest, dict):
raise AssertionError("expected guest manifest section")
guest["init"] = {"path": "guest/pyro-init"}
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
monkeypatch.setenv("PYRO_RUNTIME_BUNDLE_DIR", str(copied_bundle))
with pytest.raises(RuntimeError, match="runtime guest init manifest entry is malformed"):
resolve_runtime_paths()
def test_doctor_report_has_runtime_fields() -> None:
report = doctor_report()
assert "runtime_ok" in report
@ -72,6 +114,7 @@ def test_doctor_report_has_runtime_fields() -> None:
assert isinstance(runtime, dict)
assert "firecracker_bin" in runtime
assert "guest_agent_path" in runtime
assert "guest_init_path" in runtime
assert "component_versions" in runtime
assert "environments" in runtime
networking = report["networking"]

View file

@ -191,6 +191,8 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
source_dir = tmp_path / "seed"
source_dir.mkdir()
(source_dir / "note.txt").write_text("ok\n", encoding="utf-8")
secret_file = tmp_path / "token.txt"
secret_file.write_text("from-file\n", encoding="utf-8")
def _extract_structured(raw_result: object) -> dict[str, Any]:
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
@ -209,6 +211,10 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
"environment": "debian:12-base",
"allow_host_compat": True,
"seed_path": str(source_dir),
"secrets": [
{"name": "API_TOKEN", "value": "expected"},
{"name": "FILE_TOKEN", "file_path": str(secret_file)},
],
},
)
)
@ -231,7 +237,8 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
"workspace_exec",
{
"workspace_id": workspace_id,
"command": "cat subdir/more.txt",
"command": 'sh -lc \'printf "%s\\n" "$API_TOKEN"\'',
"secret_env": {"API_TOKEN": "API_TOKEN"},
},
)
)
@ -264,8 +271,12 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
{
"workspace_id": workspace_id,
"service_name": "app",
"command": "sh -lc 'touch .ready; while true; do sleep 60; done'",
"command": (
'sh -lc \'trap "exit 0" TERM; printf "%s\\n" "$API_TOKEN" >&2; '
'touch .ready; while true; do sleep 60; done\''
),
"ready_file": ".ready",
"secret_env": {"API_TOKEN": "API_TOKEN"},
},
)
)
@ -357,8 +368,12 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
) = asyncio.run(_run())
assert created["state"] == "started"
assert created["workspace_seed"]["mode"] == "directory"
assert created["secrets"] == [
{"name": "API_TOKEN", "source_kind": "literal"},
{"name": "FILE_TOKEN", "source_kind": "file"},
]
assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
assert executed["stdout"] == "more\n"
assert executed["stdout"] == "[REDACTED]\n"
assert diffed["changed"] is True
assert snapshot["snapshot"]["snapshot_name"] == "checkpoint"
assert [entry["snapshot_name"] for entry in snapshots["snapshots"]] == [
@ -370,9 +385,11 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
assert service["state"] == "running"
assert services["count"] == 1
assert service_status["state"] == "running"
assert service_logs["stderr"].count("[REDACTED]") >= 1
assert service_logs["tail_lines"] is None
assert service_stopped["state"] == "stopped"
assert reset["workspace_reset"]["snapshot_name"] == "checkpoint"
assert reset["secrets"] == created["secrets"]
assert reset["command_count"] == 0
assert reset["service_count"] == 0
assert deleted_snapshot["deleted"] is True

View file

@ -42,6 +42,7 @@ def _fake_runtime_paths(tmp_path: Path) -> RuntimePaths:
firecracker_bin = bundle_root / "bin" / "firecracker"
jailer_bin = bundle_root / "bin" / "jailer"
guest_agent_path = bundle_root / "guest" / "pyro_guest_agent.py"
guest_init_path = bundle_root / "guest" / "pyro-init"
artifacts_dir = bundle_root / "profiles"
notice_path = bundle_parent / "NOTICE"
@ -54,6 +55,7 @@ def _fake_runtime_paths(tmp_path: Path) -> RuntimePaths:
firecracker_bin.write_text("firecracker\n", encoding="utf-8")
jailer_bin.write_text("jailer\n", encoding="utf-8")
guest_agent_path.write_text("print('guest')\n", encoding="utf-8")
guest_init_path.write_text("#!/bin/sh\n", encoding="utf-8")
notice_path.write_text("notice\n", encoding="utf-8")
return RuntimePaths(
@ -62,6 +64,7 @@ def _fake_runtime_paths(tmp_path: Path) -> RuntimePaths:
firecracker_bin=firecracker_bin,
jailer_bin=jailer_bin,
guest_agent_path=guest_agent_path,
guest_init_path=guest_init_path,
artifacts_dir=artifacts_dir,
notice_path=notice_path,
manifest={"platform": "linux-x86_64"},

View file

@ -57,12 +57,13 @@ def test_vsock_exec_client_round_trip(monkeypatch: pytest.MonkeyPatch) -> None:
return stub
client = VsockExecClient(socket_factory=socket_factory)
response = client.exec(1234, 5005, "echo ok", 30)
response = client.exec(1234, 5005, "echo ok", 30, env={"TOKEN": "expected"})
assert response.exit_code == 0
assert response.stdout == "ok\n"
assert stub.connected == (1234, 5005)
assert b'"command": "echo ok"' in stub.sent
assert b'"env": {"TOKEN": "expected"}' in stub.sent
assert stub.closed is True
@ -105,6 +106,39 @@ def test_vsock_exec_client_upload_archive_round_trip(
assert stub.closed is True
def test_vsock_exec_client_install_secrets_round_trip(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(socket, "AF_VSOCK", 40, raising=False)
archive_path = tmp_path / "secrets.tar"
with tarfile.open(archive_path, "w") as archive:
payload = b"expected\n"
info = tarfile.TarInfo(name="API_TOKEN")
info.size = len(payload)
archive.addfile(info, io.BytesIO(payload))
stub = StubSocket(
b'{"destination":"/run/pyro-secrets","entry_count":1,"bytes_written":9}'
)
def socket_factory(family: int, sock_type: int) -> StubSocket:
assert family == socket.AF_VSOCK
assert sock_type == socket.SOCK_STREAM
return stub
client = VsockExecClient(socket_factory=socket_factory)
response = client.install_secrets(1234, 5005, archive_path, timeout_seconds=60)
request_payload, archive_payload = stub.sent.split(b"\n", 1)
request = json.loads(request_payload.decode("utf-8"))
assert request["action"] == "install_secrets"
assert int(request["archive_size"]) == archive_path.stat().st_size
assert archive_payload == archive_path.read_bytes()
assert response.destination == "/run/pyro-secrets"
assert response.entry_count == 1
assert response.bytes_written == 9
assert stub.closed is True
def test_vsock_exec_client_export_archive_round_trip(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
@ -241,6 +275,8 @@ def test_vsock_exec_client_shell_round_trip(monkeypatch: pytest.MonkeyPatch) ->
cwd="/workspace",
cols=120,
rows=30,
env={"TOKEN": "expected"},
redact_values=["expected"],
)
assert opened.shell_id == "shell-1"
read = client.read_shell(1234, 5005, shell_id="shell-1", cursor=0, max_chars=1024)
@ -260,6 +296,8 @@ def test_vsock_exec_client_shell_round_trip(monkeypatch: pytest.MonkeyPatch) ->
open_request = json.loads(stubs[0].sent.decode("utf-8").strip())
assert open_request["action"] == "open_shell"
assert open_request["shell_id"] == "shell-1"
assert open_request["env"] == {"TOKEN": "expected"}
assert open_request["redact_values"] == ["expected"]
def test_vsock_exec_client_service_round_trip(monkeypatch: pytest.MonkeyPatch) -> None:
@ -348,6 +386,7 @@ def test_vsock_exec_client_service_round_trip(monkeypatch: pytest.MonkeyPatch) -
readiness={"type": "file", "path": "/workspace/.ready"},
ready_timeout_seconds=30,
ready_interval_ms=500,
env={"TOKEN": "expected"},
)
assert started["service_name"] == "app"
status = client.status_service(1234, 5005, service_name="app")
@ -359,6 +398,7 @@ def test_vsock_exec_client_service_round_trip(monkeypatch: pytest.MonkeyPatch) -
start_request = json.loads(stubs[0].sent.decode("utf-8").strip())
assert start_request["action"] == "start_service"
assert start_request["service_name"] == "app"
assert start_request["env"] == {"TOKEN": "expected"}
def test_vsock_exec_client_raises_agent_error(monkeypatch: pytest.MonkeyPatch) -> None:

View file

@ -8,7 +8,7 @@ import subprocess
import tarfile
import time
from pathlib import Path
from typing import Any
from typing import Any, cast
import pytest
@ -1713,3 +1713,212 @@ def test_workspace_service_probe_and_refresh_helpers(
)
assert stopped.state == "stopped"
assert stopped.stop_reason == "sigterm"
def test_workspace_secrets_redact_exec_shell_service_and_survive_reset(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
secret_file = tmp_path / "token.txt"
secret_file.write_text("from-file\n", encoding="utf-8")
created = manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
secrets=[
{"name": "API_TOKEN", "value": "expected"},
{"name": "FILE_TOKEN", "file_path": str(secret_file)},
],
)
workspace_id = str(created["workspace_id"])
assert created["secrets"] == [
{"name": "API_TOKEN", "source_kind": "literal"},
{"name": "FILE_TOKEN", "source_kind": "file"},
]
no_secret = manager.exec_workspace(
workspace_id,
command='sh -lc \'printf "%s" "${API_TOKEN:-missing}"\'',
timeout_seconds=30,
)
assert no_secret["stdout"] == "missing"
executed = manager.exec_workspace(
workspace_id,
command='sh -lc \'printf "%s\\n" "$API_TOKEN"\'',
timeout_seconds=30,
secret_env={"API_TOKEN": "API_TOKEN"},
)
assert executed["stdout"] == "[REDACTED]\n"
logs = manager.logs_workspace(workspace_id)
assert logs["entries"][-1]["stdout"] == "[REDACTED]\n"
shell = manager.open_shell(
workspace_id,
secret_env={"API_TOKEN": "API_TOKEN"},
)
shell_id = str(shell["shell_id"])
manager.write_shell(workspace_id, shell_id, input_text='printf "%s\\n" "$API_TOKEN"')
output = ""
deadline = time.time() + 5
while time.time() < deadline:
read = manager.read_shell(workspace_id, shell_id, cursor=0, max_chars=65536)
output = str(read["output"])
if "[REDACTED]" in output:
break
time.sleep(0.05)
assert "[REDACTED]" in output
manager.close_shell(workspace_id, shell_id)
started = manager.start_service(
workspace_id,
"app",
command=(
'sh -lc \'trap "exit 0" TERM; printf "%s\\n" "$API_TOKEN" >&2; '
'touch .ready; while true; do sleep 60; done\''
),
readiness={"type": "file", "path": ".ready"},
secret_env={"API_TOKEN": "API_TOKEN"},
)
assert started["state"] == "running"
service_logs = manager.logs_service(workspace_id, "app", tail_lines=None)
assert "[REDACTED]" in str(service_logs["stderr"])
reset = manager.reset_workspace(workspace_id)
assert reset["secrets"] == created["secrets"]
after_reset = manager.exec_workspace(
workspace_id,
command='sh -lc \'printf "%s\\n" "$API_TOKEN"\'',
timeout_seconds=30,
secret_env={"API_TOKEN": "API_TOKEN"},
)
assert after_reset["stdout"] == "[REDACTED]\n"
def test_workspace_secret_validation_helpers(tmp_path: Path) -> None:
manager = VmManager(
backend_name="mock",
base_dir=tmp_path / "vms",
network_manager=TapNetworkManager(enabled=False),
)
assert vm_manager_module._normalize_workspace_secret_name("API_TOKEN") == "API_TOKEN" # noqa: SLF001
with pytest.raises(ValueError, match="secret name must match"):
vm_manager_module._normalize_workspace_secret_name("bad-name") # noqa: SLF001
with pytest.raises(ValueError, match="must not be empty"):
vm_manager_module._validate_workspace_secret_value("TOKEN", "") # noqa: SLF001
with pytest.raises(ValueError, match="duplicate secret name"):
manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
secrets=[
{"name": "TOKEN", "value": "one"},
{"name": "TOKEN", "value": "two"},
],
)
def test_prepare_workspace_secrets_handles_file_inputs_and_validation_errors(
tmp_path: Path,
) -> None:
secrets_dir = tmp_path / "secrets"
valid_file = tmp_path / "token.txt"
valid_file.write_text("from-file\n", encoding="utf-8")
invalid_utf8 = tmp_path / "invalid.bin"
invalid_utf8.write_bytes(b"\xff\xfe")
oversized = tmp_path / "oversized.txt"
oversized.write_text(
"x" * (vm_manager_module.WORKSPACE_SECRET_MAX_BYTES + 1),
encoding="utf-8",
)
records, values = vm_manager_module._prepare_workspace_secrets( # noqa: SLF001
[
{"name": "B_TOKEN", "value": "literal"},
{"name": "A_TOKEN", "file_path": str(valid_file)},
],
secrets_dir=secrets_dir,
)
assert [record.name for record in records] == ["A_TOKEN", "B_TOKEN"]
assert values == {"A_TOKEN": "from-file\n", "B_TOKEN": "literal"}
assert (secrets_dir / "A_TOKEN.secret").read_text(encoding="utf-8") == "from-file\n"
assert oct(secrets_dir.stat().st_mode & 0o777) == "0o700"
assert oct((secrets_dir / "A_TOKEN.secret").stat().st_mode & 0o777) == "0o600"
with pytest.raises(ValueError, match="must be a dictionary"):
vm_manager_module._prepare_workspace_secrets( # noqa: SLF001
[cast(dict[str, str], "bad")],
secrets_dir=tmp_path / "bad1",
)
with pytest.raises(ValueError, match="missing 'name'"):
vm_manager_module._prepare_workspace_secrets([{}], secrets_dir=tmp_path / "bad2") # noqa: SLF001
with pytest.raises(ValueError, match="exactly one of 'value' or 'file_path'"):
vm_manager_module._prepare_workspace_secrets( # noqa: SLF001
[{"name": "TOKEN", "value": "x", "file_path": str(valid_file)}],
secrets_dir=tmp_path / "bad3",
)
with pytest.raises(ValueError, match="file_path must not be empty"):
vm_manager_module._prepare_workspace_secrets( # noqa: SLF001
[{"name": "TOKEN", "file_path": " "}],
secrets_dir=tmp_path / "bad4",
)
with pytest.raises(ValueError, match="does not exist"):
vm_manager_module._prepare_workspace_secrets( # noqa: SLF001
[{"name": "TOKEN", "file_path": str(tmp_path / "missing.txt")}],
secrets_dir=tmp_path / "bad5",
)
with pytest.raises(ValueError, match="must be valid UTF-8 text"):
vm_manager_module._prepare_workspace_secrets( # noqa: SLF001
[{"name": "TOKEN", "file_path": str(invalid_utf8)}],
secrets_dir=tmp_path / "bad6",
)
with pytest.raises(ValueError, match="must be at most"):
vm_manager_module._prepare_workspace_secrets( # noqa: SLF001
[{"name": "TOKEN", "file_path": str(oversized)}],
secrets_dir=tmp_path / "bad7",
)
def test_workspace_secrets_require_guest_exec_on_firecracker_runtime(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
class StubFirecrackerBackend:
def __init__(self, *args: Any, **kwargs: Any) -> None:
del args, kwargs
def create(self, instance: Any) -> None:
del instance
def start(self, instance: Any) -> None:
del instance
def stop(self, instance: Any) -> None:
del instance
def delete(self, instance: Any) -> None:
del instance
monkeypatch.setattr(vm_manager_module, "FirecrackerBackend", StubFirecrackerBackend)
manager = VmManager(
backend_name="firecracker",
base_dir=tmp_path / "vms",
runtime_paths=resolve_runtime_paths(),
network_manager=TapNetworkManager(enabled=False),
)
manager._runtime_capabilities = RuntimeCapabilities( # noqa: SLF001
supports_vm_boot=True,
supports_guest_exec=False,
supports_guest_network=False,
reason="guest exec is unavailable",
)
with pytest.raises(RuntimeError, match="workspace secrets require guest execution"):
manager.create_workspace(
environment="debian:12-base",
allow_host_compat=True,
secrets=[{"name": "TOKEN", "value": "expected"}],
)