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