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