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:
parent
18b8fd2a7d
commit
fc72fcd3a1
32 changed files with 1980 additions and 181 deletions
|
|
@ -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"}],
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue