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

@ -168,6 +168,18 @@ def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> N
print(f"Workspace seed: {mode} from {seed_path}")
else:
print(f"Workspace seed: {mode}")
secrets = payload.get("secrets")
if isinstance(secrets, list) and secrets:
secret_descriptions = []
for secret in secrets:
if not isinstance(secret, dict):
continue
secret_descriptions.append(
f"{str(secret.get('name', 'unknown'))} "
f"({str(secret.get('source_kind', 'literal'))})"
)
if secret_descriptions:
print("Secrets: " + ", ".join(secret_descriptions))
print(f"Execution mode: {str(payload.get('execution_mode', 'pending'))}")
print(
f"Resources: {int(payload.get('vcpu_count', 0))} vCPU / "
@ -671,6 +683,7 @@ def _build_parser() -> argparse.ArgumentParser:
Examples:
pyro workspace create debian:12
pyro workspace create debian:12 --seed-path ./repo
pyro workspace create debian:12 --secret API_TOKEN=expected
pyro workspace sync push WORKSPACE_ID ./changes
pyro workspace snapshot create WORKSPACE_ID checkpoint
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
@ -724,6 +737,20 @@ def _build_parser() -> argparse.ArgumentParser:
"before the workspace is returned."
),
)
workspace_create_parser.add_argument(
"--secret",
action="append",
default=[],
metavar="NAME=VALUE",
help="Persist one literal UTF-8 secret for this workspace.",
)
workspace_create_parser.add_argument(
"--secret-file",
action="append",
default=[],
metavar="NAME=PATH",
help="Persist one UTF-8 secret copied from a host file at create time.",
)
workspace_create_parser.add_argument(
"--json",
action="store_true",
@ -736,7 +763,14 @@ def _build_parser() -> argparse.ArgumentParser:
"Run one non-interactive command in the persistent `/workspace` "
"for a workspace."
),
epilog="Example:\n pyro workspace exec WORKSPACE_ID -- cat note.txt",
epilog=dedent(
"""
Examples:
pyro workspace exec WORKSPACE_ID -- cat note.txt
pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- \
sh -lc 'test \"$API_TOKEN\" = \"expected\"'
"""
),
formatter_class=_HelpFormatter,
)
workspace_exec_parser.add_argument(
@ -750,6 +784,13 @@ def _build_parser() -> argparse.ArgumentParser:
default=30,
help="Maximum time allowed for the workspace command.",
)
workspace_exec_parser.add_argument(
"--secret-env",
action="append",
default=[],
metavar="SECRET[=ENV_VAR]",
help="Expose one persisted workspace secret as an environment variable for this exec.",
)
workspace_exec_parser.add_argument(
"--json",
action="store_true",
@ -1016,6 +1057,13 @@ def _build_parser() -> argparse.ArgumentParser:
default=30,
help="Shell terminal height in rows.",
)
workspace_shell_open_parser.add_argument(
"--secret-env",
action="append",
default=[],
metavar="SECRET[=ENV_VAR]",
help="Expose one persisted workspace secret as an environment variable in the shell.",
)
workspace_shell_open_parser.add_argument(
"--json",
action="store_true",
@ -1181,6 +1229,9 @@ def _build_parser() -> argparse.ArgumentParser:
Examples:
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
sh -lc 'touch .ready && while true; do sleep 60; done'
pyro workspace service start WORKSPACE_ID app --secret-env API_TOKEN -- \
sh -lc 'test \"$API_TOKEN\" = \"expected\"; touch .ready; \
while true; do sleep 60; done'
pyro workspace service start WORKSPACE_ID app --ready-command 'test -f .ready' -- \
sh -lc 'touch .ready && while true; do sleep 60; done'
"""
@ -1222,6 +1273,13 @@ def _build_parser() -> argparse.ArgumentParser:
default=DEFAULT_SERVICE_READY_INTERVAL_MS,
help="Polling interval between readiness checks.",
)
workspace_service_start_parser.add_argument(
"--secret-env",
action="append",
default=[],
metavar="SECRET[=ENV_VAR]",
help="Expose one persisted workspace secret as an environment variable for this service.",
)
workspace_service_start_parser.add_argument(
"--json",
action="store_true",
@ -1438,6 +1496,38 @@ def _require_command(command_args: list[str]) -> str:
return shlex.join(command_args)
def _parse_workspace_secret_option(value: str) -> dict[str, str]:
name, sep, secret_value = value.partition("=")
if sep == "" or name.strip() == "" or secret_value == "":
raise ValueError("workspace secrets must use NAME=VALUE")
return {"name": name.strip(), "value": secret_value}
def _parse_workspace_secret_file_option(value: str) -> dict[str, str]:
name, sep, file_path = value.partition("=")
if sep == "" or name.strip() == "" or file_path.strip() == "":
raise ValueError("workspace secret files must use NAME=PATH")
return {"name": name.strip(), "file_path": file_path.strip()}
def _parse_workspace_secret_env_options(values: list[str]) -> dict[str, str]:
parsed: dict[str, str] = {}
for raw_value in values:
secret_name, sep, env_name = raw_value.partition("=")
normalized_secret_name = secret_name.strip()
if normalized_secret_name == "":
raise ValueError("workspace secret env mappings must name a secret")
normalized_env_name = env_name.strip() if sep != "" else normalized_secret_name
if normalized_env_name == "":
raise ValueError("workspace secret env mappings must name an environment variable")
if normalized_secret_name in parsed:
raise ValueError(
f"workspace secret env mapping references {normalized_secret_name!r} more than once"
)
parsed[normalized_secret_name] = normalized_env_name
return parsed
def main() -> None:
args = _build_parser().parse_args()
pyro = Pyro()
@ -1529,6 +1619,16 @@ def main() -> None:
return
if args.command == "workspace":
if args.workspace_command == "create":
secrets = [
*(
_parse_workspace_secret_option(value)
for value in getattr(args, "secret", [])
),
*(
_parse_workspace_secret_file_option(value)
for value in getattr(args, "secret_file", [])
),
]
payload = pyro.create_workspace(
environment=args.environment,
vcpu_count=args.vcpu_count,
@ -1537,6 +1637,7 @@ def main() -> None:
network=args.network,
allow_host_compat=args.allow_host_compat,
seed_path=args.seed_path,
secrets=secrets or None,
)
if bool(args.json):
_print_json(payload)
@ -1545,12 +1646,14 @@ def main() -> None:
return
if args.workspace_command == "exec":
command = _require_command(args.command_args)
secret_env = _parse_workspace_secret_env_options(getattr(args, "secret_env", []))
if bool(args.json):
try:
payload = pyro.exec_workspace(
args.workspace_id,
command=command,
timeout_seconds=args.timeout_seconds,
secret_env=secret_env or None,
)
except Exception as exc: # noqa: BLE001
_print_json({"ok": False, "error": str(exc)})
@ -1562,6 +1665,7 @@ def main() -> None:
args.workspace_id,
command=command,
timeout_seconds=args.timeout_seconds,
secret_env=secret_env or None,
)
except Exception as exc: # noqa: BLE001
print(f"[error] {exc}", file=sys.stderr, flush=True)
@ -1703,12 +1807,14 @@ def main() -> None:
return
if args.workspace_command == "shell":
if args.workspace_shell_command == "open":
secret_env = _parse_workspace_secret_env_options(getattr(args, "secret_env", []))
try:
payload = pyro.open_shell(
args.workspace_id,
cwd=args.cwd,
cols=args.cols,
rows=args.rows,
secret_env=secret_env or None,
)
except Exception as exc: # noqa: BLE001
if bool(args.json):
@ -1825,6 +1931,7 @@ def main() -> None:
elif args.ready_command is not None:
readiness = {"type": "command", "command": args.ready_command}
command = _require_command(args.command_args)
secret_env = _parse_workspace_secret_env_options(getattr(args, "secret_env", []))
try:
payload = pyro.start_service(
args.workspace_id,
@ -1834,6 +1941,7 @@ def main() -> None:
readiness=readiness,
ready_timeout_seconds=args.ready_timeout_seconds,
ready_interval_ms=args.ready_interval_ms,
secret_env=secret_env or None,
)
except Exception as exc: # noqa: BLE001
if bool(args.json):