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

@ -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"}],
)