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

@ -87,6 +87,7 @@ class Pyro:
network: bool = False,
allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT,
seed_path: str | Path | None = None,
secrets: list[dict[str, str]] | None = None,
) -> dict[str, Any]:
return self._manager.create_workspace(
environment=environment,
@ -96,6 +97,7 @@ class Pyro:
network=network,
allow_host_compat=allow_host_compat,
seed_path=seed_path,
secrets=secrets,
)
def exec_workspace(
@ -104,11 +106,13 @@ class Pyro:
*,
command: str,
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
secret_env: dict[str, str] | None = None,
) -> dict[str, Any]:
return self._manager.exec_workspace(
workspace_id,
command=command,
timeout_seconds=timeout_seconds,
secret_env=secret_env,
)
def status_workspace(self, workspace_id: str) -> dict[str, Any]:
@ -170,12 +174,14 @@ class Pyro:
cwd: str = "/workspace",
cols: int = 120,
rows: int = 30,
secret_env: dict[str, str] | None = None,
) -> dict[str, Any]:
return self._manager.open_shell(
workspace_id,
cwd=cwd,
cols=cols,
rows=rows,
secret_env=secret_env,
)
def read_shell(
@ -234,6 +240,7 @@ class Pyro:
readiness: dict[str, Any] | None = None,
ready_timeout_seconds: int = 30,
ready_interval_ms: int = 500,
secret_env: dict[str, str] | None = None,
) -> dict[str, Any]:
return self._manager.start_service(
workspace_id,
@ -243,6 +250,7 @@ class Pyro:
readiness=readiness,
ready_timeout_seconds=ready_timeout_seconds,
ready_interval_ms=ready_interval_ms,
secret_env=secret_env,
)
def list_services(self, workspace_id: str) -> dict[str, Any]:
@ -403,6 +411,7 @@ class Pyro:
network: bool = False,
allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT,
seed_path: str | None = None,
secrets: list[dict[str, str]] | None = None,
) -> dict[str, Any]:
"""Create and start a persistent workspace."""
return self.create_workspace(
@ -413,6 +422,7 @@ class Pyro:
network=network,
allow_host_compat=allow_host_compat,
seed_path=seed_path,
secrets=secrets,
)
@server.tool()
@ -420,12 +430,14 @@ class Pyro:
workspace_id: str,
command: str,
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
secret_env: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Run one command inside an existing persistent workspace."""
return self.exec_workspace(
workspace_id,
command=command,
timeout_seconds=timeout_seconds,
secret_env=secret_env,
)
@server.tool()
@ -490,9 +502,16 @@ class Pyro:
cwd: str = "/workspace",
cols: int = 120,
rows: int = 30,
secret_env: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Open a persistent interactive shell inside one workspace."""
return self.open_shell(workspace_id, cwd=cwd, cols=cols, rows=rows)
return self.open_shell(
workspace_id,
cwd=cwd,
cols=cols,
rows=rows,
secret_env=secret_env,
)
@server.tool()
async def shell_read(
@ -554,6 +573,7 @@ class Pyro:
ready_command: str | None = None,
ready_timeout_seconds: int = 30,
ready_interval_ms: int = 500,
secret_env: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Start a named long-running service inside a workspace."""
readiness: dict[str, Any] | None = None
@ -573,6 +593,7 @@ class Pyro:
readiness=readiness,
ready_timeout_seconds=ready_timeout_seconds,
ready_interval_ms=ready_interval_ms,
secret_env=secret_env,
)
@server.tool()

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):

View file

@ -30,8 +30,11 @@ PUBLIC_CLI_WORKSPACE_CREATE_FLAGS = (
"--network",
"--allow-host-compat",
"--seed-path",
"--secret",
"--secret-file",
"--json",
)
PUBLIC_CLI_WORKSPACE_EXEC_FLAGS = ("--timeout-seconds", "--secret-env", "--json")
PUBLIC_CLI_WORKSPACE_DIFF_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS = ("--output", "--json")
PUBLIC_CLI_WORKSPACE_RESET_FLAGS = ("--snapshot", "--json")
@ -45,11 +48,18 @@ PUBLIC_CLI_WORKSPACE_SERVICE_START_FLAGS = (
"--ready-command",
"--ready-timeout-seconds",
"--ready-interval-ms",
"--secret-env",
"--json",
)
PUBLIC_CLI_WORKSPACE_SERVICE_STATUS_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_SERVICE_STOP_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS = ("--cwd", "--cols", "--rows", "--json")
PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS = (
"--cwd",
"--cols",
"--rows",
"--secret-env",
"--json",
)
PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS = ("--cursor", "--max-chars", "--json")
PUBLIC_CLI_WORKSPACE_SHELL_WRITE_FLAGS = ("--input", "--no-newline", "--json")
PUBLIC_CLI_WORKSPACE_SHELL_SIGNAL_FLAGS = ("--signal", "--json")

View file

@ -25,6 +25,7 @@ class RuntimePaths:
firecracker_bin: Path
jailer_bin: Path
guest_agent_path: Path | None
guest_init_path: Path | None
artifacts_dir: Path
notice_path: Path
manifest: dict[str, Any]
@ -93,6 +94,7 @@ def resolve_runtime_paths(
firecracker_bin = bundle_root / str(firecracker_entry.get("path", ""))
jailer_bin = bundle_root / str(jailer_entry.get("path", ""))
guest_agent_path: Path | None = None
guest_init_path: Path | None = None
guest = manifest.get("guest")
if isinstance(guest, dict):
agent_entry = guest.get("agent")
@ -100,11 +102,18 @@ def resolve_runtime_paths(
raw_agent_path = agent_entry.get("path")
if isinstance(raw_agent_path, str):
guest_agent_path = bundle_root / raw_agent_path
init_entry = guest.get("init")
if isinstance(init_entry, dict):
raw_init_path = init_entry.get("path")
if isinstance(raw_init_path, str):
guest_init_path = bundle_root / raw_init_path
artifacts_dir = bundle_root / "profiles"
required_paths = [firecracker_bin, jailer_bin]
if guest_agent_path is not None:
required_paths.append(guest_agent_path)
if guest_init_path is not None:
required_paths.append(guest_init_path)
for path in required_paths:
if not path.exists():
@ -126,12 +135,17 @@ def resolve_runtime_paths(
f"runtime checksum mismatch for {full_path}; expected {raw_hash}, got {actual}"
)
if isinstance(guest, dict):
agent_entry = guest.get("agent")
if isinstance(agent_entry, dict):
raw_path = agent_entry.get("path")
raw_hash = agent_entry.get("sha256")
for entry_name, malformed_message in (
("agent", "runtime guest agent manifest entry is malformed"),
("init", "runtime guest init manifest entry is malformed"),
):
guest_entry = guest.get(entry_name)
if not isinstance(guest_entry, dict):
continue
raw_path = guest_entry.get("path")
raw_hash = guest_entry.get("sha256")
if not isinstance(raw_path, str) or not isinstance(raw_hash, str):
raise RuntimeError("runtime guest agent manifest entry is malformed")
raise RuntimeError(malformed_message)
full_path = bundle_root / raw_path
actual = _sha256(full_path)
if actual != raw_hash:
@ -145,6 +159,7 @@ def resolve_runtime_paths(
firecracker_bin=firecracker_bin,
jailer_bin=jailer_bin,
guest_agent_path=guest_agent_path,
guest_init_path=guest_init_path,
artifacts_dir=artifacts_dir,
notice_path=notice_path,
manifest=manifest,
@ -227,6 +242,7 @@ def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]:
"firecracker_bin": str(paths.firecracker_bin),
"jailer_bin": str(paths.jailer_bin),
"guest_agent_path": str(paths.guest_agent_path) if paths.guest_agent_path else None,
"guest_init_path": str(paths.guest_init_path) if paths.guest_init_path else None,
"artifacts_dir": str(paths.artifacts_dir),
"artifacts_present": paths.artifacts_dir.exists(),
"notice_path": str(paths.notice_path),

View file

@ -0,0 +1,57 @@
#!/bin/sh
set -eu
PATH=/usr/sbin:/usr/bin:/sbin:/bin
AGENT=/opt/pyro/bin/pyro_guest_agent.py
mount -t proc proc /proc || true
mount -t sysfs sysfs /sys || true
mount -t devtmpfs devtmpfs /dev || true
mkdir -p /dev/pts /run /tmp
mount -t devpts devpts /dev/pts -o mode=620,ptmxmode=666 || true
hostname pyro-vm || true
cmdline="$(cat /proc/cmdline 2>/dev/null || true)"
get_arg() {
key="$1"
for token in $cmdline; do
case "$token" in
"$key"=*)
printf '%s' "${token#*=}"
return 0
;;
esac
done
return 1
}
ip link set lo up || true
if ip link show eth0 >/dev/null 2>&1; then
ip link set eth0 up || true
guest_ip="$(get_arg pyro.guest_ip || true)"
gateway_ip="$(get_arg pyro.gateway_ip || true)"
netmask="$(get_arg pyro.netmask || true)"
dns_csv="$(get_arg pyro.dns || true)"
if [ -n "$guest_ip" ] && [ -n "$netmask" ]; then
ip addr add "$guest_ip/$netmask" dev eth0 || true
fi
if [ -n "$gateway_ip" ]; then
ip route add default via "$gateway_ip" dev eth0 || true
fi
if [ -n "$dns_csv" ]; then
: > /etc/resolv.conf
old_ifs="$IFS"
IFS=,
for dns in $dns_csv; do
printf 'nameserver %s\n' "$dns" >> /etc/resolv.conf
done
IFS="$old_ifs"
fi
fi
if [ -f "$AGENT" ]; then
python3 "$AGENT" &
fi
exec /bin/sh -lc 'trap : TERM INT; while true; do sleep 3600; done'

View file

@ -10,6 +10,7 @@ import json
import os
import re
import shlex
import shutil
import signal
import socket
import struct
@ -29,6 +30,7 @@ BUFFER_SIZE = 65536
WORKSPACE_ROOT = PurePosixPath("/workspace")
SHELL_ROOT = Path("/run/pyro-shells")
SERVICE_ROOT = Path("/run/pyro-services")
SECRET_ROOT = Path("/run/pyro-secrets")
SERVICE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
SHELL_SIGNAL_MAP = {
"HUP": signal.SIGHUP,
@ -42,6 +44,17 @@ _SHELLS: dict[str, "GuestShellSession"] = {}
_SHELLS_LOCK = threading.Lock()
def _redact_text(text: str, redact_values: list[str]) -> str:
redacted = text
for secret_value in sorted(
{item for item in redact_values if item != ""},
key=len,
reverse=True,
):
redacted = redacted.replace(secret_value, "[REDACTED]")
return redacted
def _read_request(conn: socket.socket) -> dict[str, Any]:
chunks: list[bytes] = []
while True:
@ -139,6 +152,15 @@ def _service_metadata_path(service_name: str) -> Path:
return SERVICE_ROOT / f"{service_name}.json"
def _normalize_secret_name(secret_name: str) -> str:
normalized = secret_name.strip()
if normalized == "":
raise RuntimeError("secret name is required")
if re.fullmatch(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$", normalized) is None:
raise RuntimeError("secret name is invalid")
return normalized
def _validate_symlink_target(member_path: PurePosixPath, link_target: str) -> None:
target = link_target.strip()
if target == "":
@ -215,6 +237,49 @@ def _extract_archive(payload: bytes, destination: str) -> dict[str, Any]:
}
def _install_secrets_archive(payload: bytes) -> dict[str, Any]:
SECRET_ROOT.mkdir(parents=True, exist_ok=True)
for existing in SECRET_ROOT.iterdir():
if existing.is_dir() and not existing.is_symlink():
shutil.rmtree(existing, ignore_errors=True)
else:
existing.unlink(missing_ok=True)
bytes_written = 0
entry_count = 0
with tarfile.open(fileobj=io.BytesIO(payload), mode="r:*") as archive:
for member in archive.getmembers():
member_name = _normalize_member_name(member.name)
target_path = SECRET_ROOT.joinpath(*member_name.parts)
entry_count += 1
if member.isdir():
target_path.mkdir(parents=True, exist_ok=True)
target_path.chmod(0o700)
continue
if member.isfile():
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.parent.chmod(0o700)
source = archive.extractfile(member)
if source is None:
raise RuntimeError(f"failed to read secret archive member: {member.name}")
with target_path.open("wb") as handle:
while True:
chunk = source.read(BUFFER_SIZE)
if chunk == b"":
break
handle.write(chunk)
target_path.chmod(0o600)
bytes_written += member.size
continue
if member.issym() or member.islnk():
raise RuntimeError(f"secret archive may not contain links: {member.name}")
raise RuntimeError(f"unsupported secret archive member type: {member.name}")
return {
"destination": str(SECRET_ROOT),
"entry_count": entry_count,
"bytes_written": bytes_written,
}
def _inspect_archive(archive_path: Path) -> tuple[int, int]:
entry_count = 0
bytes_written = 0
@ -263,13 +328,22 @@ def _prepare_export_archive(path: str) -> dict[str, Any]:
raise
def _run_command(command: str, timeout_seconds: int) -> dict[str, Any]:
def _run_command(
command: str,
timeout_seconds: int,
*,
env: dict[str, str] | None = None,
) -> dict[str, Any]:
started = time.monotonic()
command_env = os.environ.copy()
if env is not None:
command_env.update(env)
try:
proc = subprocess.run(
["/bin/sh", "-lc", command],
text=True,
capture_output=True,
env=command_env,
timeout=timeout_seconds,
check=False,
)
@ -293,6 +367,16 @@ def _set_pty_size(fd: int, rows: int, cols: int) -> None:
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
def _shell_argv(*, interactive: bool) -> list[str]:
shell_program = shutil.which("bash") or "/bin/sh"
argv = [shell_program]
if shell_program.endswith("bash"):
argv.extend(["--noprofile", "--norc"])
if interactive:
argv.append("-i")
return argv
class GuestShellSession:
"""In-guest PTY-backed interactive shell session."""
@ -304,6 +388,8 @@ class GuestShellSession:
cwd_text: str,
cols: int,
rows: int,
env_overrides: dict[str, str] | None = None,
redact_values: list[str] | None = None,
) -> None:
self.shell_id = shell_id
self.cwd = cwd_text
@ -316,6 +402,7 @@ class GuestShellSession:
self._lock = threading.RLock()
self._output = ""
self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
self._redact_values = list(redact_values or [])
self._metadata_path = SHELL_ROOT / f"{shell_id}.json"
self._log_path = SHELL_ROOT / f"{shell_id}.log"
self._master_fd: int | None = None
@ -331,8 +418,10 @@ class GuestShellSession:
"PROMPT_COMMAND": "",
}
)
if env_overrides is not None:
env.update(env_overrides)
process = subprocess.Popen( # noqa: S603
["/bin/bash", "--noprofile", "--norc", "-i"],
_shell_argv(interactive=True),
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
@ -371,8 +460,9 @@ class GuestShellSession:
def read(self, *, cursor: int, max_chars: int) -> dict[str, Any]:
with self._lock:
clamped_cursor = min(max(cursor, 0), len(self._output))
output = self._output[clamped_cursor : clamped_cursor + max_chars]
redacted_output = _redact_text(self._output, self._redact_values)
clamped_cursor = min(max(cursor, 0), len(redacted_output))
output = redacted_output[clamped_cursor : clamped_cursor + max_chars]
next_cursor = clamped_cursor + len(output)
payload = self.summary()
payload.update(
@ -380,7 +470,7 @@ class GuestShellSession:
"cursor": clamped_cursor,
"next_cursor": next_cursor,
"output": output,
"truncated": next_cursor < len(self._output),
"truncated": next_cursor < len(redacted_output),
}
)
return payload
@ -514,6 +604,8 @@ def _create_shell(
cwd_text: str,
cols: int,
rows: int,
env_overrides: dict[str, str] | None = None,
redact_values: list[str] | None = None,
) -> GuestShellSession:
_, cwd_path = _normalize_shell_cwd(cwd_text)
with _SHELLS_LOCK:
@ -525,6 +617,8 @@ def _create_shell(
cwd_text=cwd_text,
cols=cols,
rows=rows,
env_overrides=env_overrides,
redact_values=redact_values,
)
_SHELLS[shell_id] = session
return session
@ -634,7 +728,12 @@ def _refresh_service_payload(service_name: str, payload: dict[str, Any]) -> dict
return refreshed
def _run_readiness_probe(readiness: dict[str, Any] | None, *, cwd: Path) -> bool:
def _run_readiness_probe(
readiness: dict[str, Any] | None,
*,
cwd: Path,
env: dict[str, str] | None = None,
) -> bool:
if readiness is None:
return True
readiness_type = str(readiness["type"])
@ -658,11 +757,15 @@ def _run_readiness_probe(readiness: dict[str, Any] | None, *, cwd: Path) -> bool
except (urllib.error.URLError, TimeoutError, ValueError):
return False
if readiness_type == "command":
command_env = os.environ.copy()
if env is not None:
command_env.update(env)
proc = subprocess.run( # noqa: S603
["/bin/sh", "-lc", str(readiness["command"])],
cwd=str(cwd),
text=True,
capture_output=True,
env=command_env,
timeout=10,
check=False,
)
@ -678,6 +781,7 @@ def _start_service(
readiness: dict[str, Any] | None,
ready_timeout_seconds: int,
ready_interval_ms: int,
env: dict[str, str] | None = None,
) -> dict[str, Any]:
normalized_service_name = _normalize_service_name(service_name)
normalized_cwd, cwd_path = _normalize_shell_cwd(cwd_text)
@ -718,9 +822,13 @@ def _start_service(
encoding="utf-8",
)
runner_path.chmod(0o700)
service_env = os.environ.copy()
if env is not None:
service_env.update(env)
process = subprocess.Popen( # noqa: S603
[str(runner_path)],
cwd=str(cwd_path),
env=service_env,
text=True,
start_new_session=True,
)
@ -747,7 +855,7 @@ def _start_service(
payload["ended_at"] = payload.get("ended_at") or time.time()
_write_service_metadata(normalized_service_name, payload)
return payload
if _run_readiness_probe(readiness, cwd=cwd_path):
if _run_readiness_probe(readiness, cwd=cwd_path, env=env):
payload["ready_at"] = time.time()
_write_service_metadata(normalized_service_name, payload)
return payload
@ -817,16 +925,38 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
destination = str(request.get("destination", "/workspace"))
payload = _read_exact(conn, archive_size)
return _extract_archive(payload, destination)
if action == "install_secrets":
archive_size = int(request.get("archive_size", 0))
if archive_size < 0:
raise RuntimeError("archive_size must not be negative")
payload = _read_exact(conn, archive_size)
return _install_secrets_archive(payload)
if action == "open_shell":
shell_id = str(request.get("shell_id", "")).strip()
if shell_id == "":
raise RuntimeError("shell_id is required")
cwd_text, _ = _normalize_shell_cwd(str(request.get("cwd", "/workspace")))
env_payload = request.get("env")
env_overrides = None
if env_payload is not None:
if not isinstance(env_payload, dict):
raise RuntimeError("shell env must be a JSON object")
env_overrides = {
_normalize_secret_name(str(key)): str(value) for key, value in env_payload.items()
}
redact_values_payload = request.get("redact_values")
redact_values: list[str] | None = None
if redact_values_payload is not None:
if not isinstance(redact_values_payload, list):
raise RuntimeError("redact_values must be a list")
redact_values = [str(item) for item in redact_values_payload]
session = _create_shell(
shell_id=shell_id,
cwd_text=cwd_text,
cols=int(request.get("cols", 120)),
rows=int(request.get("rows", 30)),
env_overrides=env_overrides,
redact_values=redact_values,
)
return session.summary()
if action == "read_shell":
@ -866,6 +996,15 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
cwd_text = str(request.get("cwd", "/workspace"))
readiness = request.get("readiness")
readiness_payload = dict(readiness) if isinstance(readiness, dict) else None
env_payload = request.get("env")
env = None
if env_payload is not None:
if not isinstance(env_payload, dict):
raise RuntimeError("service env must be a JSON object")
env = {
_normalize_secret_name(str(key)): str(value)
for key, value in env_payload.items()
}
return _start_service(
service_name=service_name,
command=command,
@ -873,6 +1012,7 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
readiness=readiness_payload,
ready_timeout_seconds=int(request.get("ready_timeout_seconds", 30)),
ready_interval_ms=int(request.get("ready_interval_ms", 500)),
env=env,
)
if action == "status_service":
service_name = str(request.get("service_name", "")).strip()
@ -887,12 +1027,19 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
return _stop_service(service_name)
command = str(request.get("command", ""))
timeout_seconds = int(request.get("timeout_seconds", 30))
return _run_command(command, timeout_seconds)
env_payload = request.get("env")
env = None
if env_payload is not None:
if not isinstance(env_payload, dict):
raise RuntimeError("exec env must be a JSON object")
env = {_normalize_secret_name(str(key)): str(value) for key, value in env_payload.items()}
return _run_command(command, timeout_seconds, env=env)
def main() -> None:
SHELL_ROOT.mkdir(parents=True, exist_ok=True)
SERVICE_ROOT.mkdir(parents=True, exist_ok=True)
SECRET_ROOT.mkdir(parents=True, exist_ok=True)
family = getattr(socket, "AF_VSOCK", None)
if family is None:
raise SystemExit("AF_VSOCK is unavailable")

View file

@ -25,7 +25,11 @@
"guest": {
"agent": {
"path": "guest/pyro_guest_agent.py",
"sha256": "58dd2e09d05538228540d8c667b1acb42c2e6c579f7883b70d483072570f2499"
"sha256": "76a0bd05b523bb952ab9eaf5a3f2e0cbf1fc458d1e44894e2c0d206b05896328"
},
"init": {
"path": "guest/pyro-init",
"sha256": "96e3653955db049496cc9dc7042f3778460966e3ee7559da50224ab92ee8060b"
}
},
"platform": "linux-x86_64",

View file

@ -19,7 +19,7 @@ from typing import Any
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
DEFAULT_CATALOG_VERSION = "2.8.0"
DEFAULT_CATALOG_VERSION = "2.9.0"
OCI_MANIFEST_ACCEPT = ", ".join(
(
"application/vnd.oci.image.index.v1+json",

View file

@ -80,6 +80,7 @@ class VsockExecClient:
command: str,
timeout_seconds: int,
*,
env: dict[str, str] | None = None,
uds_path: str | None = None,
) -> GuestExecResponse:
payload = self._request_json(
@ -88,6 +89,7 @@ class VsockExecClient:
{
"command": command,
"timeout_seconds": timeout_seconds,
"env": env,
},
timeout_seconds=timeout_seconds,
uds_path=uds_path,
@ -136,6 +138,40 @@ class VsockExecClient:
bytes_written=int(payload.get("bytes_written", 0)),
)
def install_secrets(
self,
guest_cid: int,
port: int,
archive_path: Path,
*,
timeout_seconds: int = 60,
uds_path: str | None = None,
) -> GuestArchiveResponse:
request = {
"action": "install_secrets",
"archive_size": archive_path.stat().st_size,
}
sock = self._connect(guest_cid, port, timeout_seconds, uds_path=uds_path)
try:
sock.sendall((json.dumps(request) + "\n").encode("utf-8"))
with archive_path.open("rb") as handle:
for chunk in iter(lambda: handle.read(65536), b""):
sock.sendall(chunk)
payload = self._recv_json_payload(sock)
finally:
sock.close()
if not isinstance(payload, dict):
raise RuntimeError("guest secret install response must be a JSON object")
error = payload.get("error")
if error is not None:
raise RuntimeError(str(error))
return GuestArchiveResponse(
destination=str(payload.get("destination", "/run/pyro-secrets")),
entry_count=int(payload.get("entry_count", 0)),
bytes_written=int(payload.get("bytes_written", 0)),
)
def export_archive(
self,
guest_cid: int,
@ -191,6 +227,8 @@ class VsockExecClient:
cwd: str,
cols: int,
rows: int,
env: dict[str, str] | None = None,
redact_values: list[str] | None = None,
timeout_seconds: int = 30,
uds_path: str | None = None,
) -> GuestShellSummary:
@ -203,6 +241,8 @@ class VsockExecClient:
"cwd": cwd,
"cols": cols,
"rows": rows,
"env": env,
"redact_values": redact_values,
},
timeout_seconds=timeout_seconds,
uds_path=uds_path,
@ -336,6 +376,7 @@ class VsockExecClient:
readiness: dict[str, Any] | None,
ready_timeout_seconds: int,
ready_interval_ms: int,
env: dict[str, str] | None = None,
timeout_seconds: int = 60,
uds_path: str | None = None,
) -> dict[str, Any]:
@ -350,6 +391,7 @@ class VsockExecClient:
"readiness": readiness,
"ready_timeout_seconds": ready_timeout_seconds,
"ready_interval_ms": ready_interval_ms,
"env": env,
},
timeout_seconds=timeout_seconds,
uds_path=uds_path,

File diff suppressed because it is too large Load diff

View file

@ -6,6 +6,7 @@ import codecs
import fcntl
import os
import shlex
import shutil
import signal
import struct
import subprocess
@ -29,6 +30,27 @@ _LOCAL_SHELLS: dict[str, "LocalShellSession"] = {}
_LOCAL_SHELLS_LOCK = threading.Lock()
def _shell_argv(*, interactive: bool) -> list[str]:
shell_program = shutil.which("bash") or "/bin/sh"
argv = [shell_program]
if shell_program.endswith("bash"):
argv.extend(["--noprofile", "--norc"])
if interactive:
argv.append("-i")
return argv
def _redact_text(text: str, redact_values: list[str]) -> str:
redacted = text
for secret_value in sorted(
{item for item in redact_values if item != ""},
key=len,
reverse=True,
):
redacted = redacted.replace(secret_value, "[REDACTED]")
return redacted
def _set_pty_size(fd: int, rows: int, cols: int) -> None:
winsize = struct.pack("HHHH", rows, cols, 0, 0)
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
@ -45,6 +67,8 @@ class LocalShellSession:
display_cwd: str,
cols: int,
rows: int,
env_overrides: dict[str, str] | None = None,
redact_values: list[str] | None = None,
) -> None:
self.shell_id = shell_id
self.cwd = display_cwd
@ -63,6 +87,7 @@ class LocalShellSession:
self._reader: threading.Thread | None = None
self._waiter: threading.Thread | None = None
self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
self._redact_values = list(redact_values or [])
env = os.environ.copy()
env.update(
{
@ -71,13 +96,15 @@ class LocalShellSession:
"PROMPT_COMMAND": "",
}
)
if env_overrides is not None:
env.update(env_overrides)
process: subprocess.Popen[bytes]
try:
master_fd, slave_fd = os.openpty()
except OSError:
process = subprocess.Popen( # noqa: S603
["/bin/bash", "--noprofile", "--norc"],
_shell_argv(interactive=False),
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
@ -93,7 +120,7 @@ class LocalShellSession:
try:
_set_pty_size(slave_fd, rows, cols)
process = subprocess.Popen( # noqa: S603
["/bin/bash", "--noprofile", "--norc", "-i"],
_shell_argv(interactive=True),
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
@ -133,8 +160,9 @@ class LocalShellSession:
def read(self, *, cursor: int, max_chars: int) -> dict[str, object]:
with self._lock:
clamped_cursor = min(max(cursor, 0), len(self._output))
output = self._output[clamped_cursor : clamped_cursor + max_chars]
redacted_output = _redact_text(self._output, self._redact_values)
clamped_cursor = min(max(cursor, 0), len(redacted_output))
output = redacted_output[clamped_cursor : clamped_cursor + max_chars]
next_cursor = clamped_cursor + len(output)
payload = self.summary()
payload.update(
@ -142,7 +170,7 @@ class LocalShellSession:
"cursor": clamped_cursor,
"next_cursor": next_cursor,
"output": output,
"truncated": next_cursor < len(self._output),
"truncated": next_cursor < len(redacted_output),
}
)
return payload
@ -287,6 +315,8 @@ def create_local_shell(
display_cwd: str,
cols: int,
rows: int,
env_overrides: dict[str, str] | None = None,
redact_values: list[str] | None = None,
) -> LocalShellSession:
session_key = f"{workspace_id}:{shell_id}"
with _LOCAL_SHELLS_LOCK:
@ -298,6 +328,8 @@ def create_local_shell(
display_cwd=display_cwd,
cols=cols,
rows=rows,
env_overrides=env_overrides,
redact_values=redact_values,
)
_LOCAL_SHELLS[session_key] = session
return session