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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue