diff --git a/CHANGELOG.md b/CHANGELOG.md index e1ff83e..27fb84c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable user-visible changes to `pyro-mcp` are documented here. +## 2.9.0 + +- Added explicit workspace secrets across the CLI, Python SDK, and MCP server with + `pyro workspace create --secret/--secret-file`, `Pyro.create_workspace(..., secrets=...)`, and + the matching `workspace_create` MCP inputs. +- Added per-call secret-to-environment mapping for `workspace exec`, `workspace shell open`, and + `workspace service start`, with secret values redacted from command output, shell reads, service + logs, and persisted workspace logs. +- Kept secret-backed workspaces guest-only and fail-closed while re-materializing persisted secret + files outside `/workspace` across workspace creation and reset. + ## 2.8.0 - Added explicit named workspace snapshots across the CLI, Python SDK, and MCP server with diff --git a/README.md b/README.md index 518ed0f..11fb968 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ It exposes the same runtime in three public forms: - First run transcript: [docs/first-run.md](docs/first-run.md) - Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif) - PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/) -- What's new in 2.8.0: [CHANGELOG.md#280](CHANGELOG.md#280) +- What's new in 2.9.0: [CHANGELOG.md#290](CHANGELOG.md#290) - Host requirements: [docs/host-requirements.md](docs/host-requirements.md) - Integration targets: [docs/integrations.md](docs/integrations.md) - Public contract: [docs/public-contract.md](docs/public-contract.md) @@ -57,7 +57,7 @@ What success looks like: ```bash Platform: linux-x86_64 Runtime: PASS -Catalog version: 2.8.0 +Catalog version: 2.9.0 ... [pull] phase=install environment=debian:12 [pull] phase=ready environment=debian:12 @@ -78,6 +78,8 @@ After the quickstart works: - prove the full one-shot lifecycle with `uvx --from pyro-mcp pyro demo` - create a persistent workspace with `uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo` - update a live workspace from the host with `uvx --from pyro-mcp pyro workspace sync push WORKSPACE_ID ./changes` +- add literal or file-backed secrets with `uvx --from pyro-mcp pyro workspace create debian:12 --secret API_TOKEN=expected --secret-file PIP_TOKEN=./token.txt` +- map one persisted secret into one exec, shell, or service call with `--secret-env API_TOKEN` - diff the live workspace against its create-time baseline with `uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID` - capture a checkpoint with `uvx --from pyro-mcp pyro workspace snapshot create WORKSPACE_ID checkpoint` - reset a broken workspace with `uvx --from pyro-mcp pyro workspace reset WORKSPACE_ID --snapshot checkpoint` @@ -137,7 +139,7 @@ uvx --from pyro-mcp pyro env list Expected output: ```bash -Catalog version: 2.8.0 +Catalog version: 2.9.0 debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. @@ -213,18 +215,20 @@ longer-term interaction model. ```bash pyro workspace create debian:12 --seed-path ./repo +pyro workspace create debian:12 --seed-path ./repo --secret API_TOKEN=expected pyro workspace sync push WORKSPACE_ID ./changes --dest src pyro workspace exec WORKSPACE_ID -- cat src/note.txt +pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"' pyro workspace diff WORKSPACE_ID pyro workspace snapshot create WORKSPACE_ID checkpoint pyro workspace reset WORKSPACE_ID --snapshot checkpoint pyro workspace reset WORKSPACE_ID pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt -pyro workspace shell open WORKSPACE_ID +pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd' pyro workspace shell read WORKSPACE_ID SHELL_ID pyro workspace shell close WORKSPACE_ID SHELL_ID -pyro workspace service start WORKSPACE_ID web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done' +pyro workspace service start WORKSPACE_ID web --secret-env API_TOKEN --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done' pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done' pyro workspace service list WORKSPACE_ID pyro workspace service status WORKSPACE_ID web @@ -239,7 +243,7 @@ Persistent workspaces start in `/workspace` and keep command history until you d machine consumption, add `--json` and read the returned `workspace_id`. Use `--seed-path` when you want the workspace to start from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive instead of an empty workspace. Use `pyro workspace sync push` when you want to import -later host-side changes into a started workspace. Sync is non-atomic in `2.8.0`; if it fails +later host-side changes into a started workspace. Sync is non-atomic in `2.9.0`; if it fails partway through, prefer `pyro workspace reset` to recover from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the live `/workspace` tree to its immutable create-time baseline, and `pyro workspace export` to copy one changed file or directory back to the host. Use @@ -251,6 +255,10 @@ persistent PTY session that keeps interactive shell state between calls. Use Typed readiness checks prefer `--ready-file`, `--ready-tcp`, or `--ready-http`; keep `--ready-command` as the escape hatch. Service metadata and logs live outside `/workspace`, so the internal service state does not appear in `pyro workspace diff` or `pyro workspace export`. +Use `--secret` and `--secret-file` at workspace creation when the sandbox needs private tokens or +config. Persisted secrets are materialized inside the guest at `/run/pyro-secrets/`, and +`--secret-env SECRET_NAME[=ENV_VAR]` maps one secret into one exec, shell, or service call without +exposing the raw value in workspace status, logs, diffs, or exports. ## Public Interfaces @@ -422,9 +430,25 @@ Advanced lifecycle tools: Persistent workspace tools: -- `workspace_create(environment, vcpu_count=1, mem_mib=1024, ttl_seconds=600, network=false, allow_host_compat=false, seed_path=null)` +- `workspace_create(environment, vcpu_count=1, mem_mib=1024, ttl_seconds=600, network=false, allow_host_compat=false, seed_path=null, secrets=null)` - `workspace_sync_push(workspace_id, source_path, dest="/workspace")` -- `workspace_exec(workspace_id, command, timeout_seconds=30)` +- `workspace_exec(workspace_id, command, timeout_seconds=30, secret_env=null)` +- `workspace_export(workspace_id, path, output_path)` +- `workspace_diff(workspace_id)` +- `snapshot_create(workspace_id, snapshot_name)` +- `snapshot_list(workspace_id)` +- `snapshot_delete(workspace_id, snapshot_name)` +- `workspace_reset(workspace_id, snapshot="baseline")` +- `service_start(workspace_id, service_name, command, cwd="/workspace", readiness=null, ready_timeout_seconds=30, ready_interval_ms=500, secret_env=null)` +- `service_list(workspace_id)` +- `service_status(workspace_id, service_name)` +- `service_logs(workspace_id, service_name, tail_lines=200)` +- `service_stop(workspace_id, service_name)` +- `shell_open(workspace_id, cwd="/workspace", cols=120, rows=30, secret_env=null)` +- `shell_read(workspace_id, shell_id, cursor=0, max_chars=65536)` +- `shell_write(workspace_id, shell_id, input, append_newline=true)` +- `shell_signal(workspace_id, shell_id, signal_name="INT")` +- `shell_close(workspace_id, shell_id)` - `workspace_status(workspace_id)` - `workspace_logs(workspace_id)` - `workspace_delete(workspace_id)` diff --git a/docs/first-run.md b/docs/first-run.md index 0eb14da..437b291 100644 --- a/docs/first-run.md +++ b/docs/first-run.md @@ -22,7 +22,7 @@ Networking: tun=yes ip_forward=yes ```bash $ uvx --from pyro-mcp pyro env list -Catalog version: 2.8.0 +Catalog version: 2.9.0 debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. @@ -72,12 +72,14 @@ deterministic structured result. $ uvx --from pyro-mcp pyro demo $ uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo $ uvx --from pyro-mcp pyro workspace sync push WORKSPACE_ID ./changes +$ uvx --from pyro-mcp pyro workspace create debian:12 --secret API_TOKEN=expected --secret-file PIP_TOKEN=./token.txt +$ uvx --from pyro-mcp pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"' $ uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID $ uvx --from pyro-mcp pyro workspace snapshot create WORKSPACE_ID checkpoint $ uvx --from pyro-mcp pyro workspace reset WORKSPACE_ID --snapshot checkpoint $ uvx --from pyro-mcp pyro workspace export WORKSPACE_ID note.txt --output ./note.txt -$ uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID -$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done' +$ uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN +$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --secret-env API_TOKEN --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done' $ uvx --from pyro-mcp pyro mcp serve ``` @@ -103,6 +105,9 @@ $ uvx --from pyro-mcp pyro workspace exec WORKSPACE_ID -- cat src/note.txt hello from synced workspace [workspace-exec] workspace_id=... sequence=1 cwd=/workspace execution_mode=guest_vsock exit_code=0 duration_ms=... +$ uvx --from pyro-mcp pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"' +[workspace-exec] workspace_id=... sequence=2 cwd=/workspace execution_mode=guest_vsock exit_code=0 duration_ms=... + $ uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID [workspace-diff] workspace_id=... total=... added=... modified=... deleted=... type_changed=... text_patched=... non_text=... --- a/src/note.txt @@ -123,7 +128,7 @@ Reset count: 1 $ uvx --from pyro-mcp pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt [workspace-export] workspace_id=... workspace_path=/workspace/src/note.txt output_path=... artifact_type=file entry_count=... bytes_written=... execution_mode=guest_vsock -$ uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID +$ uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN [workspace-shell-open] workspace_id=... shell_id=... state=running cwd=/workspace cols=120 rows=30 execution_mode=guest_vsock $ uvx --from pyro-mcp pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd' @@ -133,7 +138,7 @@ $ uvx --from pyro-mcp pyro workspace shell read WORKSPACE_ID SHELL_ID /workspace [workspace-shell-read] workspace_id=... shell_id=... state=running cursor=0 next_cursor=... truncated=False execution_mode=guest_vsock -$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done' +$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID web --secret-env API_TOKEN --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done' [workspace-service-start] workspace_id=... service=web state=running cwd=/workspace ready_type=file execution_mode=guest_vsock $ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done' @@ -170,7 +175,7 @@ $ uvx --from pyro-mcp pyro workspace service stop WORKSPACE_ID worker Use `--seed-path` when the workspace should start from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive instead of an empty `/workspace`. Use `pyro workspace sync push` when you need to import later host-side changes into a started -workspace. Sync is non-atomic in `2.8.0`; if it fails partway through, prefer `pyro workspace reset` +workspace. Sync is non-atomic in `2.9.0`; if it fails partway through, prefer `pyro workspace reset` to recover from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the current `/workspace` tree to its immutable create-time baseline, `pyro workspace snapshot *` to create named checkpoints, and `pyro workspace export` to copy one changed file or directory back to the @@ -178,7 +183,10 @@ host. Use `pyro workspace exec` for one-shot commands and `pyro workspace shell need a persistent interactive PTY session in that same workspace. Use `pyro workspace service *` when the workspace needs long-running background processes with typed readiness checks. Internal service state and logs stay outside `/workspace`, so service runtime data does not appear in -workspace diff or export results. +workspace diff or export results. Use `--secret` and `--secret-file` at workspace creation when +the sandbox needs private tokens or config. Persisted secret files are materialized at +`/run/pyro-secrets/`, and `--secret-env SECRET_NAME[=ENV_VAR]` maps one secret into one +exec, shell, or service call without storing that environment mapping on the workspace itself. Example output: diff --git a/docs/install.md b/docs/install.md index 89d41b6..7c05827 100644 --- a/docs/install.md +++ b/docs/install.md @@ -83,7 +83,7 @@ uvx --from pyro-mcp pyro env list Expected output: ```bash -Catalog version: 2.8.0 +Catalog version: 2.9.0 debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. @@ -176,6 +176,7 @@ After the CLI path works, you can move on to: - persistent workspaces: `pyro workspace create debian:12 --seed-path ./repo` - live workspace updates: `pyro workspace sync push WORKSPACE_ID ./changes` +- workspace secrets: `pyro workspace create debian:12 --secret API_TOKEN=expected --secret-file PIP_TOKEN=./token.txt` - baseline diff: `pyro workspace diff WORKSPACE_ID` - snapshots and reset: `pyro workspace snapshot create WORKSPACE_ID checkpoint` and `pyro workspace reset WORKSPACE_ID --snapshot checkpoint` - host export: `pyro workspace export WORKSPACE_ID note.txt --output ./note.txt` @@ -191,18 +192,20 @@ Use `pyro workspace ...` when you need repeated commands in one sandbox instead ```bash pyro workspace create debian:12 --seed-path ./repo +pyro workspace create debian:12 --seed-path ./repo --secret API_TOKEN=expected pyro workspace sync push WORKSPACE_ID ./changes --dest src pyro workspace exec WORKSPACE_ID -- cat src/note.txt +pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"' pyro workspace diff WORKSPACE_ID pyro workspace snapshot create WORKSPACE_ID checkpoint pyro workspace reset WORKSPACE_ID --snapshot checkpoint pyro workspace reset WORKSPACE_ID pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt -pyro workspace shell open WORKSPACE_ID +pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd' pyro workspace shell read WORKSPACE_ID SHELL_ID pyro workspace shell close WORKSPACE_ID SHELL_ID -pyro workspace service start WORKSPACE_ID web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done' +pyro workspace service start WORKSPACE_ID web --secret-env API_TOKEN --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done' pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done' pyro workspace service list WORKSPACE_ID pyro workspace service status WORKSPACE_ID web @@ -217,7 +220,7 @@ Workspace commands default to the persistent `/workspace` directory inside the g the identifier programmatically, use `--json` and read the `workspace_id` field. Use `--seed-path` when the workspace should start from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive. Use `pyro workspace sync push` for later host-side changes to a started workspace. Sync -is non-atomic in `2.8.0`; if it fails partway through, prefer `pyro workspace reset` to recover +is non-atomic in `2.9.0`; if it fails partway through, prefer `pyro workspace reset` to recover from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the current workspace tree to its immutable create-time baseline, `pyro workspace snapshot *` to capture named checkpoints, and `pyro workspace export` to copy one changed file or directory back to the host. Use @@ -225,7 +228,10 @@ checkpoints, and `pyro workspace export` to copy one changed file or directory b interactive PTY that survives across separate calls. Use `pyro workspace service *` when the workspace needs long-running background processes with typed readiness probes. Service metadata and logs stay outside `/workspace`, so the service runtime itself does not show up in workspace diff or -export results. +export results. Use `--secret` and `--secret-file` at workspace creation when the sandbox needs +private tokens or config, and `--secret-env SECRET_NAME[=ENV_VAR]` when one exec, shell, or +service call needs that secret as an environment variable. Persisted secret files are available in +the guest at `/run/pyro-secrets/`. ## Contributor Clone diff --git a/docs/integrations.md b/docs/integrations.md index 9c0f5ac..c5744ef 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -31,9 +31,10 @@ Recommended surface: - `vm_run` - `workspace_create(seed_path=...)` + `workspace_sync_push` + `workspace_exec` when the agent needs persistent workspace state +- `workspace_create(..., secrets=...)` + `workspace_exec(..., secret_env=...)` when the workspace needs private tokens or authenticated setup - `workspace_diff` + `workspace_export` when the agent needs explicit baseline comparison or host-out file transfer - `start_service` / `list_services` / `status_service` / `logs_service` / `stop_service` when the agent needs long-running processes inside that workspace -- `open_shell` / `read_shell` / `write_shell` when the agent needs an interactive PTY inside that workspace +- `open_shell(..., secret_env=...)` / `read_shell` / `write_shell` when the agent needs an interactive PTY inside that workspace Canonical example: @@ -69,9 +70,10 @@ Recommended default: - `Pyro.run_in_vm(...)` - `Pyro.create_workspace(seed_path=...)` + `Pyro.push_workspace_sync(...)` + `Pyro.exec_workspace(...)` when repeated workspace commands are required +- `Pyro.create_workspace(..., secrets=...)` + `Pyro.exec_workspace(..., secret_env=...)` when the workspace needs private tokens or authenticated setup - `Pyro.diff_workspace(...)` + `Pyro.export_workspace(...)` when the agent needs baseline comparison or host-out file transfer -- `Pyro.start_service(...)` + `Pyro.list_services(...)` + `Pyro.logs_service(...)` when the agent needs long-running background processes in one workspace -- `Pyro.open_shell(...)` + `Pyro.write_shell(...)` + `Pyro.read_shell(...)` when the agent needs an interactive PTY inside the workspace +- `Pyro.start_service(..., secret_env=...)` + `Pyro.list_services(...)` + `Pyro.logs_service(...)` when the agent needs long-running background processes in one workspace +- `Pyro.open_shell(..., secret_env=...)` + `Pyro.write_shell(...)` + `Pyro.read_shell(...)` when the agent needs an interactive PTY inside the workspace Lifecycle note: @@ -82,6 +84,8 @@ Lifecycle note: `/workspace` that starts from host content - use `push_workspace_sync(...)` when later host-side changes need to be imported into that running workspace without recreating it +- use `create_workspace(..., secrets=...)` plus `secret_env` on exec, shell, or service start when + the agent needs private tokens or authenticated startup inside that workspace - use `diff_workspace(...)` when the agent needs a structured comparison against the immutable create-time baseline - use `export_workspace(...)` when the agent needs one file or directory copied back to the host diff --git a/docs/public-contract.md b/docs/public-contract.md index 4e06343..16a94d2 100644 --- a/docs/public-contract.md +++ b/docs/public-contract.md @@ -64,17 +64,22 @@ Behavioral guarantees: - `pyro demo ollama` prints log lines plus a final summary line. - `pyro workspace create` auto-starts a persistent workspace. - `pyro workspace create --seed-path PATH` seeds `/workspace` from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive before the workspace is returned. +- `pyro workspace create --secret NAME=VALUE` and `--secret-file NAME=PATH` persist guest-only UTF-8 secrets outside `/workspace`. - `pyro workspace sync push WORKSPACE_ID SOURCE_PATH [--dest WORKSPACE_PATH]` imports later host-side directory or archive content into a started workspace. - `pyro workspace export WORKSPACE_ID PATH --output HOST_PATH` exports one file or directory from `/workspace` back to the host. - `pyro workspace diff WORKSPACE_ID` compares the current `/workspace` tree to the immutable create-time baseline. - `pyro workspace snapshot *` manages explicit named snapshots in addition to the implicit `baseline`. - `pyro workspace reset WORKSPACE_ID [--snapshot SNAPSHOT_NAME|baseline]` recreates the full sandbox and restores `/workspace` from the chosen snapshot. - `pyro workspace service *` manages long-running named services inside one started workspace with typed readiness probes. +- `pyro workspace exec --secret-env SECRET_NAME[=ENV_VAR]` maps one persisted secret into one exec call. +- `pyro workspace service start --secret-env SECRET_NAME[=ENV_VAR]` maps one persisted secret into one service start call. - `pyro workspace exec` runs in the persistent `/workspace` for that workspace and does not auto-clean. +- `pyro workspace shell open --secret-env SECRET_NAME[=ENV_VAR]` maps one persisted secret into the opened shell environment. - `pyro workspace shell *` manages persistent PTY sessions inside a started workspace. - `pyro workspace logs` returns persisted command history for that workspace until `pyro workspace delete`. - Workspace create/status results expose `workspace_seed` metadata describing how `/workspace` was initialized. - Workspace create/status/reset results expose `reset_count` and `last_reset_at`. +- Workspace create/status/reset results expose safe `secrets` metadata with each secret name and source kind, but never the secret values. - `pyro workspace status` includes aggregate `service_count` and `running_service_count` fields. ## Python SDK Contract @@ -92,7 +97,7 @@ Supported public entrypoints: - `Pyro.inspect_environment(environment)` - `Pyro.prune_environments()` - `Pyro.create_vm(...)` -- `Pyro.create_workspace(...)` +- `Pyro.create_workspace(..., secrets=None)` - `Pyro.push_workspace_sync(workspace_id, source_path, *, dest="/workspace")` - `Pyro.export_workspace(workspace_id, path, *, output_path)` - `Pyro.diff_workspace(workspace_id)` @@ -100,19 +105,19 @@ Supported public entrypoints: - `Pyro.list_snapshots(workspace_id)` - `Pyro.delete_snapshot(workspace_id, snapshot_name)` - `Pyro.reset_workspace(workspace_id, *, snapshot="baseline")` -- `Pyro.start_service(workspace_id, service_name, *, command, cwd="/workspace", readiness=None, ready_timeout_seconds=30, ready_interval_ms=500)` +- `Pyro.start_service(workspace_id, service_name, *, command, cwd="/workspace", readiness=None, ready_timeout_seconds=30, ready_interval_ms=500, secret_env=None)` - `Pyro.list_services(workspace_id)` - `Pyro.status_service(workspace_id, service_name)` - `Pyro.logs_service(workspace_id, service_name, *, tail_lines=200, all=False)` - `Pyro.stop_service(workspace_id, service_name)` -- `Pyro.open_shell(workspace_id, *, cwd="/workspace", cols=120, rows=30)` +- `Pyro.open_shell(workspace_id, *, cwd="/workspace", cols=120, rows=30, secret_env=None)` - `Pyro.read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536)` - `Pyro.write_shell(workspace_id, shell_id, *, input, append_newline=True)` - `Pyro.signal_shell(workspace_id, shell_id, *, signal_name="INT")` - `Pyro.close_shell(workspace_id, shell_id)` - `Pyro.start_vm(vm_id)` - `Pyro.exec_vm(vm_id, *, command, timeout_seconds=30)` -- `Pyro.exec_workspace(workspace_id, *, command, timeout_seconds=30)` +- `Pyro.exec_workspace(workspace_id, *, command, timeout_seconds=30, secret_env=None)` - `Pyro.stop_vm(vm_id)` - `Pyro.delete_vm(vm_id)` - `Pyro.delete_workspace(workspace_id)` @@ -131,7 +136,7 @@ Stable public method names: - `inspect_environment(environment)` - `prune_environments()` - `create_vm(...)` -- `create_workspace(...)` +- `create_workspace(..., secrets=None)` - `push_workspace_sync(workspace_id, source_path, *, dest="/workspace")` - `export_workspace(workspace_id, path, *, output_path)` - `diff_workspace(workspace_id)` @@ -139,19 +144,19 @@ Stable public method names: - `list_snapshots(workspace_id)` - `delete_snapshot(workspace_id, snapshot_name)` - `reset_workspace(workspace_id, *, snapshot="baseline")` -- `start_service(workspace_id, service_name, *, command, cwd="/workspace", readiness=None, ready_timeout_seconds=30, ready_interval_ms=500)` +- `start_service(workspace_id, service_name, *, command, cwd="/workspace", readiness=None, ready_timeout_seconds=30, ready_interval_ms=500, secret_env=None)` - `list_services(workspace_id)` - `status_service(workspace_id, service_name)` - `logs_service(workspace_id, service_name, *, tail_lines=200, all=False)` - `stop_service(workspace_id, service_name)` -- `open_shell(workspace_id, *, cwd="/workspace", cols=120, rows=30)` +- `open_shell(workspace_id, *, cwd="/workspace", cols=120, rows=30, secret_env=None)` - `read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536)` - `write_shell(workspace_id, shell_id, *, input, append_newline=True)` - `signal_shell(workspace_id, shell_id, *, signal_name="INT")` - `close_shell(workspace_id, shell_id)` - `start_vm(vm_id)` - `exec_vm(vm_id, *, command, timeout_seconds=30)` -- `exec_workspace(workspace_id, *, command, timeout_seconds=30)` +- `exec_workspace(workspace_id, *, command, timeout_seconds=30, secret_env=None)` - `stop_vm(vm_id)` - `delete_vm(vm_id)` - `delete_workspace(workspace_id)` @@ -169,6 +174,7 @@ Behavioral defaults: - `allow_host_compat` defaults to `False` on `create_vm(...)` and `run_in_vm(...)`. - `allow_host_compat` defaults to `False` on `create_workspace(...)`. - `Pyro.create_workspace(..., seed_path=...)` seeds `/workspace` from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive before the workspace is returned. +- `Pyro.create_workspace(..., secrets=...)` persists guest-only UTF-8 secrets outside `/workspace`. - `Pyro.push_workspace_sync(...)` imports later host-side directory or archive content into a started workspace. - `Pyro.export_workspace(...)` exports one file or directory from `/workspace` to an explicit host path. - `Pyro.diff_workspace(...)` compares the current `/workspace` tree to the immutable create-time baseline. @@ -176,10 +182,13 @@ Behavioral defaults: - `Pyro.list_snapshots(...)` lists the implicit `baseline` plus any named snapshots. - `Pyro.delete_snapshot(...)` deletes one named snapshot while leaving `baseline` intact. - `Pyro.reset_workspace(...)` recreates the full sandbox from `baseline` or one named snapshot and clears command, shell, and service history. +- `Pyro.start_service(..., secret_env=...)` maps persisted workspace secrets into that service process as environment variables for that start call only. - `Pyro.start_service(...)` starts one named long-running process in a started workspace and waits for its typed readiness probe when configured. - `Pyro.list_services(...)`, `Pyro.status_service(...)`, `Pyro.logs_service(...)`, and `Pyro.stop_service(...)` manage those persisted workspace services. - `Pyro.exec_vm(...)` runs one command and auto-cleans that VM after the exec completes. +- `Pyro.exec_workspace(..., secret_env=...)` maps persisted workspace secrets into that exec call as environment variables for that call only. - `Pyro.exec_workspace(...)` runs one command in the persistent workspace and leaves it alive. +- `Pyro.open_shell(..., secret_env=...)` maps persisted workspace secrets into the shell environment when that shell opens. - `Pyro.open_shell(...)` opens a persistent PTY shell attached to one started workspace. - `Pyro.read_shell(...)` reads merged text output from that shell by cursor. - `Pyro.write_shell(...)`, `Pyro.signal_shell(...)`, and `Pyro.close_shell(...)` operate on that persistent shell session. @@ -234,6 +243,7 @@ Behavioral defaults: - `vm_run` and `vm_create` expose `allow_host_compat`, which defaults to `false`. - `workspace_create` exposes `allow_host_compat`, which defaults to `false`. - `workspace_create` accepts optional `seed_path` and seeds `/workspace` from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive before the workspace is returned. +- `workspace_create` accepts optional `secrets` and persists guest-only UTF-8 secret material outside `/workspace`. - `workspace_sync_push` imports later host-side directory or archive content into a started workspace, with an optional `dest` under `/workspace`. - `workspace_export` exports one file or directory from `/workspace` to an explicit host path. - `workspace_diff` compares the current `/workspace` tree to the immutable create-time baseline. @@ -241,7 +251,9 @@ Behavioral defaults: - `workspace_reset` recreates the full sandbox and restores `/workspace` from `baseline` or one named snapshot. - `service_start`, `service_list`, `service_status`, `service_logs`, and `service_stop` manage persistent named services inside a started workspace. - `vm_exec` runs one command and auto-cleans that VM after the exec completes. -- `workspace_exec` runs one command in a persistent `/workspace` and leaves the workspace alive. +- `workspace_exec` accepts optional `secret_env` mappings for one exec call and leaves the workspace alive. +- `service_start` accepts optional `secret_env` mappings for one service start call. +- `shell_open` accepts optional `secret_env` mappings for the opened shell session. - `shell_open`, `shell_read`, `shell_write`, `shell_signal`, and `shell_close` manage persistent PTY shells inside a started workspace. ## Versioning Rule diff --git a/docs/roadmap/task-workspace-ga.md b/docs/roadmap/task-workspace-ga.md index a8461c8..768c869 100644 --- a/docs/roadmap/task-workspace-ga.md +++ b/docs/roadmap/task-workspace-ga.md @@ -2,7 +2,7 @@ This roadmap turns the agent-workspace vision into release-sized milestones. -Current baseline is `2.8.0`: +Current baseline is `2.9.0`: - workspace persistence exists and the public surface is now workspace-first - host crossing currently covers create-time seeding, later sync push, and explicit export @@ -10,7 +10,8 @@ Current baseline is `2.8.0`: - immutable create-time baselines now power whole-workspace diff - multi-service lifecycle exists with typed readiness and aggregate workspace status counts - named snapshots and full workspace reset now exist -- no secrets or explicit host port publication contract exists yet +- explicit secrets now exist for guest-backed workspaces +- no explicit host port publication contract exists yet Locked roadmap decisions: @@ -34,7 +35,7 @@ also expected to update: 3. [`2.6.0` Structured Export And Baseline Diff](task-workspace-ga/2.6.0-structured-export-and-baseline-diff.md) - Done 4. [`2.7.0` Service Lifecycle And Typed Readiness](task-workspace-ga/2.7.0-service-lifecycle-and-typed-readiness.md) - Done 5. [`2.8.0` Named Snapshots And Reset](task-workspace-ga/2.8.0-named-snapshots-and-reset.md) - Done -6. [`2.9.0` Secrets](task-workspace-ga/2.9.0-secrets.md) +6. [`2.9.0` Secrets](task-workspace-ga/2.9.0-secrets.md) - Done 7. [`2.10.0` Network Policy And Host Port Publication](task-workspace-ga/2.10.0-network-policy-and-host-port-publication.md) 8. [`3.0.0` Stable Workspace Product](task-workspace-ga/3.0.0-stable-workspace-product.md) 9. [`3.1.0` Secondary Disk Tools](task-workspace-ga/3.1.0-secondary-disk-tools.md) diff --git a/docs/roadmap/task-workspace-ga/2.9.0-secrets.md b/docs/roadmap/task-workspace-ga/2.9.0-secrets.md index efb6c62..2dd82af 100644 --- a/docs/roadmap/task-workspace-ga/2.9.0-secrets.md +++ b/docs/roadmap/task-workspace-ga/2.9.0-secrets.md @@ -1,5 +1,7 @@ # `2.9.0` Secrets +Status: Done + ## Goal Add explicit secrets so workspaces can handle private dependencies, diff --git a/examples/python_workspace.py b/examples/python_workspace.py index f26957a..c53a978 100644 --- a/examples/python_workspace.py +++ b/examples/python_workspace.py @@ -12,15 +12,31 @@ def main() -> None: tempfile.TemporaryDirectory(prefix="pyro-workspace-seed-") as seed_dir, tempfile.TemporaryDirectory(prefix="pyro-workspace-sync-") as sync_dir, tempfile.TemporaryDirectory(prefix="pyro-workspace-export-") as export_dir, + tempfile.TemporaryDirectory(prefix="pyro-workspace-secret-") as secret_dir, ): Path(seed_dir, "note.txt").write_text("hello from seed\n", encoding="utf-8") Path(sync_dir, "note.txt").write_text("hello from sync\n", encoding="utf-8") - created = pyro.create_workspace(environment="debian:12", seed_path=seed_dir) + secret_file = Path(secret_dir, "token.txt") + secret_file.write_text("from-file\n", encoding="utf-8") + created = pyro.create_workspace( + environment="debian:12", + seed_path=seed_dir, + secrets=[ + {"name": "API_TOKEN", "value": "expected"}, + {"name": "FILE_TOKEN", "file_path": str(secret_file)}, + ], + ) workspace_id = str(created["workspace_id"]) try: pyro.push_workspace_sync(workspace_id, sync_dir) result = pyro.exec_workspace(workspace_id, command="cat note.txt") print(result["stdout"], end="") + secret_result = pyro.exec_workspace( + workspace_id, + command='sh -lc \'printf "%s\\n" "$API_TOKEN"\'', + secret_env={"API_TOKEN": "API_TOKEN"}, + ) + print(secret_result["stdout"], end="") diff_result = pyro.diff_workspace(workspace_id) print(f"changed={diff_result['changed']} total={diff_result['summary']['total']}") snapshot = pyro.create_snapshot(workspace_id, "checkpoint") @@ -28,11 +44,22 @@ def main() -> None: exported_path = Path(export_dir, "note.txt") pyro.export_workspace(workspace_id, "note.txt", output_path=exported_path) print(exported_path.read_text(encoding="utf-8"), end="") + shell = pyro.open_shell(workspace_id, secret_env={"API_TOKEN": "API_TOKEN"}) + shell_id = str(shell["shell_id"]) + pyro.write_shell( + workspace_id, + shell_id, + input='printf "%s\\n" "$API_TOKEN"', + ) + shell_output = pyro.read_shell(workspace_id, shell_id, cursor=0) + print(f"shell_output_len={len(shell_output['output'])}") + pyro.close_shell(workspace_id, shell_id) pyro.start_service( workspace_id, "web", command="touch .web-ready && while true; do sleep 60; done", readiness={"type": "file", "path": ".web-ready"}, + secret_env={"API_TOKEN": "API_TOKEN"}, ) services = pyro.list_services(workspace_id) print(f"services={services['count']} running={services['running_count']}") @@ -43,6 +70,7 @@ def main() -> None: pyro.stop_service(workspace_id, "web") reset = pyro.reset_workspace(workspace_id, snapshot="checkpoint") print(f"reset_count={reset['reset_count']}") + print(f"secret_count={len(reset['secrets'])}") logs = pyro.logs_workspace(workspace_id) print(f"workspace_id={workspace_id} command_count={logs['count']}") finally: diff --git a/pyproject.toml b/pyproject.toml index ed8a41d..3c72319 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyro-mcp" -version = "2.8.0" +version = "2.9.0" description = "Ephemeral Firecracker sandboxes with curated environments, persistent workspaces, and MCP tools." readme = "README.md" license = { file = "LICENSE" } diff --git a/runtime_sources/linux-x86_64/guest/pyro_guest_agent.py b/runtime_sources/linux-x86_64/guest/pyro_guest_agent.py index 739bc67..607c76d 100644 --- a/runtime_sources/linux-x86_64/guest/pyro_guest_agent.py +++ b/runtime_sources/linux-x86_64/guest/pyro_guest_agent.py @@ -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") diff --git a/runtime_sources/linux-x86_64/scripts/pyro-init b/runtime_sources/linux-x86_64/scripts/pyro-init index 6d4b9eb..2e8a82a 100755 --- a/runtime_sources/linux-x86_64/scripts/pyro-init +++ b/runtime_sources/linux-x86_64/scripts/pyro-init @@ -7,7 +7,8 @@ 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 /run /tmp +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)" diff --git a/src/pyro_mcp/api.py b/src/pyro_mcp/api.py index 0e5ea1e..a6e1832 100644 --- a/src/pyro_mcp/api.py +++ b/src/pyro_mcp/api.py @@ -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() diff --git a/src/pyro_mcp/cli.py b/src/pyro_mcp/cli.py index b80c153..655804b 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -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): diff --git a/src/pyro_mcp/contract.py b/src/pyro_mcp/contract.py index cd92478..274e34b 100644 --- a/src/pyro_mcp/contract.py +++ b/src/pyro_mcp/contract.py @@ -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") diff --git a/src/pyro_mcp/runtime.py b/src/pyro_mcp/runtime.py index a950108..832e666 100644 --- a/src/pyro_mcp/runtime.py +++ b/src/pyro_mcp/runtime.py @@ -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), diff --git a/src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro-init b/src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro-init new file mode 100644 index 0000000..2e8a82a --- /dev/null +++ b/src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro-init @@ -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' diff --git a/src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro_guest_agent.py b/src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro_guest_agent.py index 739bc67..607c76d 100755 --- a/src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro_guest_agent.py +++ b/src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro_guest_agent.py @@ -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") diff --git a/src/pyro_mcp/runtime_bundle/linux-x86_64/manifest.json b/src/pyro_mcp/runtime_bundle/linux-x86_64/manifest.json index 65229d9..3dcc733 100644 --- a/src/pyro_mcp/runtime_bundle/linux-x86_64/manifest.json +++ b/src/pyro_mcp/runtime_bundle/linux-x86_64/manifest.json @@ -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", diff --git a/src/pyro_mcp/vm_environments.py b/src/pyro_mcp/vm_environments.py index 3611bf1..7a5e82e 100644 --- a/src/pyro_mcp/vm_environments.py +++ b/src/pyro_mcp/vm_environments.py @@ -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", diff --git a/src/pyro_mcp/vm_guest.py b/src/pyro_mcp/vm_guest.py index 253bc98..e2f2c88 100644 --- a/src/pyro_mcp/vm_guest.py +++ b/src/pyro_mcp/vm_guest.py @@ -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, diff --git a/src/pyro_mcp/vm_manager.py b/src/pyro_mcp/vm_manager.py index 9f9cc51..0457523 100644 --- a/src/pyro_mcp/vm_manager.py +++ b/src/pyro_mcp/vm_manager.py @@ -3,6 +3,7 @@ from __future__ import annotations import difflib +import io import json import os import re @@ -49,7 +50,7 @@ DEFAULT_TIMEOUT_SECONDS = 30 DEFAULT_TTL_SECONDS = 600 DEFAULT_ALLOW_HOST_COMPAT = False -WORKSPACE_LAYOUT_VERSION = 5 +WORKSPACE_LAYOUT_VERSION = 6 WORKSPACE_BASELINE_DIRNAME = "baseline" WORKSPACE_BASELINE_ARCHIVE_NAME = "workspace.tar" WORKSPACE_SNAPSHOTS_DIRNAME = "snapshots" @@ -57,10 +58,14 @@ WORKSPACE_DIRNAME = "workspace" WORKSPACE_COMMANDS_DIRNAME = "commands" WORKSPACE_SHELLS_DIRNAME = "shells" WORKSPACE_SERVICES_DIRNAME = "services" +WORKSPACE_SECRETS_DIRNAME = "secrets" WORKSPACE_RUNTIME_DIRNAME = "runtime" WORKSPACE_GUEST_PATH = "/workspace" WORKSPACE_GUEST_AGENT_PATH = "/opt/pyro/bin/pyro_guest_agent.py" +WORKSPACE_GUEST_INIT_PATH = "/opt/pyro/bin/pyro-init" +WORKSPACE_GUEST_SECRETS_PATH = "/run/pyro-secrets" WORKSPACE_ARCHIVE_UPLOAD_TIMEOUT_SECONDS = 60 +WORKSPACE_SECRET_MAX_BYTES = 64 * 1024 DEFAULT_SHELL_COLS = 120 DEFAULT_SHELL_ROWS = 30 DEFAULT_SHELL_MAX_CHARS = 65536 @@ -70,11 +75,13 @@ DEFAULT_SERVICE_LOG_TAIL_LINES = 200 WORKSPACE_SHELL_SIGNAL_NAMES = shell_signal_names() WORKSPACE_SERVICE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") WORKSPACE_SNAPSHOT_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +WORKSPACE_SECRET_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$") WorkspaceSeedMode = Literal["empty", "directory", "tar_archive"] WorkspaceArtifactType = Literal["file", "directory", "symlink"] WorkspaceServiceReadinessType = Literal["file", "tcp", "http", "command"] WorkspaceSnapshotKind = Literal["baseline", "named"] +WorkspaceSecretSourceKind = Literal["literal", "file"] @dataclass @@ -119,6 +126,7 @@ class WorkspaceRecord: command_count: int = 0 last_command: dict[str, Any] | None = None workspace_seed: dict[str, Any] = field(default_factory=dict) + secrets: list[WorkspaceSecretRecord] = field(default_factory=list) reset_count: int = 0 last_reset_at: float | None = None @@ -130,6 +138,7 @@ class WorkspaceRecord: command_count: int = 0, last_command: dict[str, Any] | None = None, workspace_seed: dict[str, Any] | None = None, + secrets: list[WorkspaceSecretRecord] | None = None, ) -> WorkspaceRecord: return cls( workspace_id=instance.vm_id, @@ -149,6 +158,7 @@ class WorkspaceRecord: command_count=command_count, last_command=last_command, workspace_seed=dict(workspace_seed or _empty_workspace_seed_payload()), + secrets=list(secrets or []), reset_count=0, last_reset_at=None, ) @@ -192,6 +202,7 @@ class WorkspaceRecord: "command_count": self.command_count, "last_command": self.last_command, "workspace_seed": self.workspace_seed, + "secrets": [secret.to_payload() for secret in self.secrets], "reset_count": self.reset_count, "last_reset_at": self.last_reset_at, } @@ -216,6 +227,7 @@ class WorkspaceRecord: command_count=int(payload.get("command_count", 0)), last_command=_optional_dict(payload.get("last_command")), workspace_seed=_workspace_seed_dict(payload.get("workspace_seed")), + secrets=_workspace_secret_records(payload.get("secrets")), reset_count=int(payload.get("reset_count", 0)), last_reset_at=( None @@ -225,6 +237,30 @@ class WorkspaceRecord: ) +@dataclass(frozen=True) +class WorkspaceSecretRecord: + """Persistent secret metadata stored on disk per workspace.""" + + name: str + source_kind: WorkspaceSecretSourceKind + stored_path: str + + def to_payload(self) -> dict[str, Any]: + return { + "name": self.name, + "source_kind": self.source_kind, + "stored_path": self.stored_path, + } + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> WorkspaceSecretRecord: + return cls( + name=str(payload["name"]), + source_kind=cast(WorkspaceSecretSourceKind, str(payload.get("source_kind", "literal"))), + stored_path=str(payload["stored_path"]), + ) + + @dataclass class WorkspaceSnapshotRecord: """Persistent snapshot metadata stored on disk per workspace.""" @@ -498,6 +534,42 @@ def _workspace_seed_dict(value: object) -> dict[str, Any]: return payload +def _workspace_secret_records(value: object) -> list[WorkspaceSecretRecord]: + if not isinstance(value, list): + return [] + records: list[WorkspaceSecretRecord] = [] + for item in value: + if not isinstance(item, dict): + continue + records.append(WorkspaceSecretRecord.from_payload(item)) + return records + + +def _serialize_workspace_secret_public(secret: WorkspaceSecretRecord) -> dict[str, Any]: + return { + "name": secret.name, + "source_kind": secret.source_kind, + } + + +def _redact_text(text: str, secret_values: list[str]) -> str: + redacted = text + for secret_value in sorted( + {value for value in secret_values if value != ""}, + key=len, + reverse=True, + ): + redacted = redacted.replace(secret_value, "[REDACTED]") + return redacted + + +def _redact_exception(exc: Exception, secret_values: list[str]) -> Exception: + redacted_message = _redact_text(str(exc), secret_values) + if redacted_message == str(exc): + return exc + return exc.__class__(redacted_message) + + def _serialize_network(network: NetworkConfig | None) -> dict[str, Any] | None: if network is None: return None @@ -530,9 +602,17 @@ def _deserialize_network(payload: object) -> NetworkConfig | None: ) -def _run_host_command(workdir: Path, command: str, timeout_seconds: int) -> VmExecResult: +def _run_host_command( + workdir: Path, + command: str, + timeout_seconds: int, + *, + env_overrides: dict[str, str] | None = None, +) -> VmExecResult: started = time.monotonic() env = {"PATH": os.environ.get("PATH", ""), "HOME": str(workdir)} + if env_overrides is not None: + env.update(env_overrides) try: proc = subprocess.run( # noqa: S603 ["bash", "-lc", command], # noqa: S607 @@ -679,6 +759,111 @@ def _write_empty_seed_archive(archive_path: Path) -> None: pass +def _prepare_workspace_secrets( + secrets: list[dict[str, str]] | None, + *, + secrets_dir: Path, +) -> tuple[list[WorkspaceSecretRecord], dict[str, str]]: + if not secrets: + return [], {} + secrets_dir.mkdir(parents=True, exist_ok=True) + records: list[WorkspaceSecretRecord] = [] + values_by_name: dict[str, str] = {} + for index, item in enumerate(secrets, start=1): + if not isinstance(item, dict): + raise ValueError(f"secret #{index} must be a dictionary") + raw_name = item.get("name") + if raw_name is None: + raise ValueError(f"secret #{index} is missing 'name'") + name = _normalize_workspace_secret_name(str(raw_name)) + if name in values_by_name: + raise ValueError(f"duplicate secret name: {name}") + has_value = "value" in item + has_file_path = "file_path" in item + if has_value == has_file_path: + raise ValueError( + f"secret {name!r} must provide exactly one of 'value' or 'file_path'" + ) + source_kind: WorkspaceSecretSourceKind + if has_value: + value = _validate_workspace_secret_value(name, str(item["value"])) + source_kind = "literal" + else: + raw_file_path = str(item["file_path"]).strip() + if raw_file_path == "": + raise ValueError(f"secret {name!r} file_path must not be empty") + resolved_file_path = Path(raw_file_path).expanduser().resolve() + if not resolved_file_path.exists() or not resolved_file_path.is_file(): + raise ValueError(f"secret file for {name!r} does not exist: {resolved_file_path}") + try: + raw_bytes = resolved_file_path.read_bytes() + except OSError as exc: + raise ValueError( + f"failed to read secret file for {name!r}: {resolved_file_path}" + ) from exc + if len(raw_bytes) > WORKSPACE_SECRET_MAX_BYTES: + raise ValueError( + f"secret {name!r} must be at most {WORKSPACE_SECRET_MAX_BYTES} bytes" + ) + try: + value = raw_bytes.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError(f"secret {name!r} must be valid UTF-8 text") from exc + value = _validate_workspace_secret_value(name, value) + source_kind = "file" + stored_path = f"{name}.secret" + secret_path = secrets_dir / stored_path + secret_path.write_text(value, encoding="utf-8") + secret_path.chmod(0o600) + values_by_name[name] = value + records.append( + WorkspaceSecretRecord( + name=name, + source_kind=source_kind, + stored_path=stored_path, + ) + ) + secrets_dir.chmod(0o700) + records.sort(key=lambda item: item.name) + return records, {record.name: values_by_name[record.name] for record in records} + + +def _load_workspace_secret_values( + *, + workspace_dir: Path, + secrets: list[WorkspaceSecretRecord], +) -> dict[str, str]: + values: dict[str, str] = {} + for secret in secrets: + secret_path = workspace_dir / WORKSPACE_SECRETS_DIRNAME / secret.stored_path + if not secret_path.exists() or not secret_path.is_file(): + raise RuntimeError(f"secret material is unavailable for {secret.name!r}") + values[secret.name] = secret_path.read_text(encoding="utf-8") + return values + + +def _build_workspace_secret_archive( + *, + workspace_dir: Path, + secrets: list[WorkspaceSecretRecord], + archive_path: Path, +) -> tuple[int, int]: + archive_path.parent.mkdir(parents=True, exist_ok=True) + entry_count = 0 + bytes_written = 0 + with tarfile.open(archive_path, "w") as archive: + for secret in secrets: + secret_path = workspace_dir / WORKSPACE_SECRETS_DIRNAME / secret.stored_path + value = secret_path.read_bytes() + info = tarfile.TarInfo(name=secret.name) + info.size = len(value) + info.mode = 0o600 + archive.addfile(info, io.BytesIO(value)) + entry_count += 1 + bytes_written += len(value) + return entry_count, bytes_written + + def _persist_workspace_baseline( prepared_seed: PreparedWorkspaceSeed, *, @@ -931,6 +1116,49 @@ def _normalize_workspace_snapshot_name( return normalized +def _normalize_workspace_secret_name(secret_name: str) -> str: + normalized = secret_name.strip() + if normalized == "": + raise ValueError("secret name must not be empty") + if WORKSPACE_SECRET_NAME_RE.fullmatch(normalized) is None: + raise ValueError( + "secret name must match " + r"^[A-Za-z_][A-Za-z0-9_]{0,63}$" + ) + return normalized + + +def _validate_workspace_secret_value(secret_name: str, value: str) -> str: + try: + encoded = value.encode("utf-8") + except UnicodeEncodeError as exc: + raise ValueError(f"secret {secret_name!r} must be valid UTF-8 text") from exc + if value == "": + raise ValueError(f"secret {secret_name!r} must not be empty") + if len(encoded) > WORKSPACE_SECRET_MAX_BYTES: + raise ValueError( + f"secret {secret_name!r} must be at most {WORKSPACE_SECRET_MAX_BYTES} bytes" + ) + return value + + +def _normalize_workspace_secret_env_mapping( + secret_env: dict[str, str] | None, +) -> dict[str, str]: + if secret_env is None: + return {} + normalized: dict[str, str] = {} + for secret_name, env_name in secret_env.items(): + normalized_secret_name = _normalize_workspace_secret_name(str(secret_name)) + normalized_env_name = _normalize_workspace_secret_name(str(env_name)) + if normalized_secret_name in normalized: + raise ValueError( + f"secret_env references secret {normalized_secret_name!r} more than once" + ) + normalized[normalized_secret_name] = normalized_env_name + return normalized + + def _normalize_workspace_service_readiness( readiness: dict[str, Any] | None, ) -> dict[str, Any] | None: @@ -1030,11 +1258,19 @@ def _stop_process_group(pid: int, *, wait_seconds: int = 5) -> tuple[bool, bool] return True, True -def _run_service_probe_command(cwd: Path, command: str) -> int: +def _run_service_probe_command( + cwd: Path, + command: str, + *, + env_overrides: dict[str, str] | None = None, +) -> int: + env = {"PATH": os.environ.get("PATH", ""), "HOME": str(cwd)} + if env_overrides is not None: + env.update(env_overrides) proc = subprocess.run( # noqa: S603 ["bash", "-lc", command], # noqa: S607 cwd=cwd, - env={"PATH": os.environ.get("PATH", ""), "HOME": str(cwd)}, + env=env, text=True, capture_output=True, timeout=10, @@ -1048,6 +1284,7 @@ def _service_ready_on_host( readiness: dict[str, Any] | None, workspace_dir: Path, cwd: Path, + env_overrides: dict[str, str] | None = None, ) -> bool: if readiness is None: return True @@ -1073,7 +1310,14 @@ def _service_ready_on_host( return False if readiness_type == "command": try: - return _run_service_probe_command(cwd, str(readiness["command"])) == 0 + return ( + _run_service_probe_command( + cwd, + str(readiness["command"]), + env_overrides=env_overrides, + ) + == 0 + ) except (OSError, subprocess.TimeoutExpired): return False raise RuntimeError(f"unsupported readiness type: {readiness_type}") @@ -1120,6 +1364,7 @@ def _start_local_service( readiness: dict[str, Any] | None, ready_timeout_seconds: int, ready_interval_ms: int, + env_overrides: dict[str, str] | None = None, ) -> WorkspaceServiceRecord: services_dir.mkdir(parents=True, exist_ok=True) cwd = _workspace_host_destination(workspace_dir, cwd_text) @@ -1151,9 +1396,13 @@ def _start_local_service( encoding="utf-8", ) runner_path.chmod(0o700) + env = {"PATH": os.environ.get("PATH", ""), "HOME": str(cwd)} + if env_overrides is not None: + env.update(env_overrides) process = subprocess.Popen( # noqa: S603 [str(runner_path)], cwd=str(cwd), + env=env, text=True, start_new_session=True, ) @@ -1182,7 +1431,12 @@ def _start_local_service( if service.ended_at is None: service.ended_at = time.time() return service - if _service_ready_on_host(readiness=readiness, workspace_dir=workspace_dir, cwd=cwd): + if _service_ready_on_host( + readiness=readiness, + workspace_dir=workspace_dir, + cwd=cwd, + env_overrides=env_overrides, + ): service.ready_at = time.time() return service if time.monotonic() >= deadline: @@ -1219,17 +1473,24 @@ def _instance_workspace_host_dir(instance: VmInstance) -> Path: return Path(raw_value) -def _patch_rootfs_guest_agent(rootfs_image: Path, guest_agent_path: Path) -> None: +def _patch_rootfs_runtime_file( + rootfs_image: Path, + *, + source_path: Path, + destination_path: str, + asset_label: str, + file_mode: str | None = None, +) -> None: debugfs_path = shutil.which("debugfs") if debugfs_path is None: raise RuntimeError( "debugfs is required to seed workspaces on guest-backed runtimes" ) - with tempfile.TemporaryDirectory(prefix="pyro-guest-agent-") as temp_dir: - staged_agent_path = Path(temp_dir) / "pyro_guest_agent.py" - shutil.copy2(guest_agent_path, staged_agent_path) + with tempfile.TemporaryDirectory(prefix=f"pyro-{asset_label}-") as temp_dir: + staged_path = Path(temp_dir) / Path(destination_path).name + shutil.copy2(source_path, staged_path) subprocess.run( # noqa: S603 - [debugfs_path, "-w", "-R", f"rm {WORKSPACE_GUEST_AGENT_PATH}", str(rootfs_image)], + [debugfs_path, "-w", "-R", f"rm {destination_path}", str(rootfs_image)], text=True, capture_output=True, check=False, @@ -1239,16 +1500,29 @@ def _patch_rootfs_guest_agent(rootfs_image: Path, guest_agent_path: Path) -> Non debugfs_path, "-w", "-R", - f"write {staged_agent_path} {WORKSPACE_GUEST_AGENT_PATH}", + f"write {staged_path} {destination_path}", str(rootfs_image), ], text=True, capture_output=True, check=False, ) + if proc.returncode == 0 and file_mode is not None: + proc = subprocess.run( # noqa: S603 + [ + debugfs_path, + "-w", + "-R", + f"set_inode_field {destination_path} mode {file_mode}", + str(rootfs_image), + ], + text=True, + capture_output=True, + check=False, + ) if proc.returncode != 0: raise RuntimeError( - "failed to patch guest agent into workspace rootfs: " + f"failed to patch {asset_label} into workspace rootfs: " f"{proc.stderr.strip() or proc.stdout.strip()}" ) @@ -1491,6 +1765,7 @@ class VmBackend: timeout_seconds: int, *, workdir: Path | None = None, + env: dict[str, str] | None = None, ) -> VmExecResult: raise NotImplementedError @@ -1509,6 +1784,14 @@ class VmBackend: ) -> dict[str, Any]: raise NotImplementedError + def install_secrets( # pragma: no cover + self, + instance: VmInstance, + *, + archive_path: Path, + ) -> dict[str, Any]: + raise NotImplementedError + def export_archive( # pragma: no cover self, instance: VmInstance, @@ -1527,6 +1810,8 @@ class VmBackend: cwd: str, cols: int, rows: int, + env: dict[str, str] | None = None, + redact_values: list[str] | None = None, ) -> dict[str, Any]: raise NotImplementedError @@ -1582,6 +1867,7 @@ class VmBackend: readiness: dict[str, Any] | None, ready_timeout_seconds: int, ready_interval_ms: int, + env: dict[str, str] | None = None, ) -> dict[str, Any]: raise NotImplementedError @@ -1631,8 +1917,14 @@ class MockBackend(VmBackend): timeout_seconds: int, *, workdir: Path | None = None, + env: dict[str, str] | None = None, ) -> VmExecResult: - return _run_host_command(workdir or instance.workdir, command, timeout_seconds) + return _run_host_command( + workdir or instance.workdir, + command, + timeout_seconds, + env_overrides=env, + ) def stop(self, instance: VmInstance) -> None: marker_path = instance.workdir / ".stopped" @@ -1654,6 +1946,20 @@ class MockBackend(VmBackend): destination=destination, ) + def install_secrets( + self, + instance: VmInstance, + *, + archive_path: Path, + ) -> dict[str, Any]: + del instance + entry_count, bytes_written = _inspect_seed_archive(archive_path) + return { + "destination": WORKSPACE_GUEST_SECRETS_PATH, + "entry_count": entry_count, + "bytes_written": bytes_written, + } + def export_archive( self, instance: VmInstance, @@ -1683,6 +1989,8 @@ class MockBackend(VmBackend): cwd: str, cols: int, rows: int, + env: dict[str, str] | None = None, + redact_values: list[str] | None = None, ) -> dict[str, Any]: session = create_local_shell( workspace_id=workspace_id, @@ -1691,6 +1999,8 @@ class MockBackend(VmBackend): display_cwd=cwd, cols=cols, rows=rows, + env_overrides=env, + redact_values=redact_values, ) summary = session.summary() summary["execution_mode"] = "host_compat" @@ -1766,6 +2076,7 @@ class MockBackend(VmBackend): readiness: dict[str, Any] | None, ready_timeout_seconds: int, ready_interval_ms: int, + env: dict[str, str] | None = None, ) -> dict[str, Any]: services_dir = instance.workdir.parent / WORKSPACE_SERVICES_DIRNAME service = _start_local_service( @@ -1778,6 +2089,7 @@ class MockBackend(VmBackend): readiness=readiness, ready_timeout_seconds=ready_timeout_seconds, ready_interval_ms=ready_interval_ms, + env_overrides=env, ) return service.to_payload() @@ -1988,6 +2300,7 @@ class FirecrackerBackend(VmBackend): # pragma: no cover timeout_seconds: int, *, workdir: Path | None = None, + env: dict[str, str] | None = None, ) -> VmExecResult: if self._runtime_capabilities.supports_guest_exec: guest_cid = int(instance.metadata["guest_cid"]) @@ -2001,6 +2314,7 @@ class FirecrackerBackend(VmBackend): # pragma: no cover port, command, timeout_seconds, + env=env, uds_path=uds_path, ) break @@ -2017,7 +2331,12 @@ class FirecrackerBackend(VmBackend): # pragma: no cover duration_ms=response.duration_ms, ) instance.metadata["execution_mode"] = "host_compat" - return _run_host_command(workdir or instance.workdir, command, timeout_seconds) + return _run_host_command( + workdir or instance.workdir, + command, + timeout_seconds, + env_overrides=env, + ) def stop(self, instance: VmInstance) -> None: process = self._processes.pop(instance.vm_id, None) @@ -2094,6 +2413,39 @@ class FirecrackerBackend(VmBackend): # pragma: no cover destination=destination, ) + def install_secrets( + self, + instance: VmInstance, + *, + archive_path: Path, + ) -> dict[str, Any]: + if self._runtime_capabilities.supports_guest_exec: + guest_cid = int(instance.metadata["guest_cid"]) + port = int(instance.metadata["guest_exec_port"]) + uds_path = instance.metadata.get("guest_exec_uds_path") + deadline = time.monotonic() + 10 + while True: + try: + response = self._guest_exec_client.install_secrets( + guest_cid, + port, + archive_path, + timeout_seconds=WORKSPACE_ARCHIVE_UPLOAD_TIMEOUT_SECONDS, + uds_path=uds_path, + ) + return { + "destination": response.destination, + "entry_count": response.entry_count, + "bytes_written": response.bytes_written, + } + except (OSError, RuntimeError) as exc: + if time.monotonic() >= deadline: + raise RuntimeError( + f"guest secret transport did not become ready: {exc}" + ) from exc + time.sleep(0.2) + raise RuntimeError("workspace secrets require guest execution") + def export_archive( self, instance: VmInstance, @@ -2152,6 +2504,8 @@ class FirecrackerBackend(VmBackend): # pragma: no cover cwd: str, cols: int, rows: int, + env: dict[str, str] | None = None, + redact_values: list[str] | None = None, ) -> dict[str, Any]: del workspace_id guest_cid = int(instance.metadata["guest_cid"]) @@ -2164,6 +2518,8 @@ class FirecrackerBackend(VmBackend): # pragma: no cover cwd=cwd, cols=cols, rows=rows, + env=env, + redact_values=redact_values, uds_path=uds_path, ) return { @@ -2292,6 +2648,7 @@ class FirecrackerBackend(VmBackend): # pragma: no cover readiness: dict[str, Any] | None, ready_timeout_seconds: int, ready_interval_ms: int, + env: dict[str, str] | None = None, ) -> dict[str, Any]: if self._runtime_capabilities.supports_guest_exec: guest_cid = int(instance.metadata["guest_cid"]) @@ -2306,6 +2663,7 @@ class FirecrackerBackend(VmBackend): # pragma: no cover readiness=readiness, ready_timeout_seconds=ready_timeout_seconds, ready_interval_ms=ready_interval_ms, + env=env, uds_path=uds_path, ) payload["execution_mode"] = instance.metadata.get("execution_mode", "pending") @@ -2322,6 +2680,7 @@ class FirecrackerBackend(VmBackend): # pragma: no cover readiness=readiness, ready_timeout_seconds=ready_timeout_seconds, ready_interval_ms=ready_interval_ms, + env_overrides=env, ) return service.to_payload() @@ -2701,6 +3060,7 @@ class VmManager: 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]: self._validate_limits(vcpu_count=vcpu_count, mem_mib=mem_mib, ttl_seconds=ttl_seconds) get_environment(environment, runtime_paths=self._runtime_paths) @@ -2713,6 +3073,7 @@ class VmManager: commands_dir = self._workspace_commands_dir(workspace_id) shells_dir = self._workspace_shells_dir(workspace_id) services_dir = self._workspace_services_dir(workspace_id) + secrets_dir = self._workspace_secrets_dir(workspace_id) snapshots_dir = self._workspace_snapshots_dir(workspace_id) baseline_archive_path = self._workspace_baseline_archive_path(workspace_id) workspace_dir.mkdir(parents=True, exist_ok=False) @@ -2720,7 +3081,9 @@ class VmManager: commands_dir.mkdir(parents=True, exist_ok=True) shells_dir.mkdir(parents=True, exist_ok=True) services_dir.mkdir(parents=True, exist_ok=True) + secrets_dir.mkdir(parents=True, exist_ok=True) snapshots_dir.mkdir(parents=True, exist_ok=True) + secret_records, _ = _prepare_workspace_secrets(secrets, secrets_dir=secrets_dir) _persist_workspace_baseline( prepared_seed, baseline_archive_path=baseline_archive_path, @@ -2751,20 +3114,27 @@ class VmManager: ) self._backend.create(instance) if self._runtime_capabilities.supports_guest_exec: - self._ensure_workspace_guest_agent_support(instance) + self._ensure_workspace_guest_bootstrap_support(instance) with self._lock: self._start_instance_locked(instance) + workspace = WorkspaceRecord.from_instance( + instance, + workspace_seed=prepared_seed.to_payload(), + secrets=secret_records, + ) + if workspace.secrets: + self._install_workspace_secrets_locked(workspace, instance) self._require_guest_exec_or_opt_in(instance) - workspace_seed = prepared_seed.to_payload() import_summary = self._backend.import_archive( instance, archive_path=baseline_archive_path, destination=WORKSPACE_GUEST_PATH, ) + workspace_seed = dict(workspace.workspace_seed) workspace_seed["entry_count"] = int(import_summary["entry_count"]) workspace_seed["bytes_written"] = int(import_summary["bytes_written"]) workspace_seed["destination"] = str(import_summary["destination"]) - workspace = WorkspaceRecord.from_instance(instance, workspace_seed=workspace_seed) + workspace.workspace_seed = workspace_seed self._save_workspace_locked(workspace) return self._serialize_workspace(workspace) except Exception: @@ -3067,9 +3437,12 @@ class VmManager: ) self._backend.create(recreated) if self._runtime_capabilities.supports_guest_exec: - self._ensure_workspace_guest_agent_support(recreated) + self._ensure_workspace_guest_bootstrap_support(recreated) with self._lock: self._start_instance_locked(recreated) + workspace = self._load_workspace_locked(workspace_id) + if workspace.secrets: + self._install_workspace_secrets_locked(workspace, recreated) self._require_guest_exec_or_opt_in(recreated) reset_summary = self._backend.import_archive( recreated, @@ -3120,9 +3493,11 @@ class VmManager: *, command: str, timeout_seconds: int = 30, + secret_env: dict[str, str] | None = None, ) -> dict[str, Any]: if timeout_seconds <= 0: raise ValueError("timeout_seconds must be positive") + normalized_secret_env = _normalize_workspace_secret_env_mapping(secret_env) with self._lock: workspace = self._load_workspace_locked(workspace_id) self._ensure_workspace_not_expired_locked(workspace, time.time()) @@ -3134,12 +3509,26 @@ class VmManager: instance = workspace.to_instance( workdir=self._workspace_runtime_dir(workspace.workspace_id) ) - exec_result, execution_mode = self._exec_instance( - instance, - command=command, - timeout_seconds=timeout_seconds, - host_workdir=self._workspace_host_dir(workspace.workspace_id), - guest_cwd=WORKSPACE_GUEST_PATH, + redact_values = self._workspace_secret_redact_values_locked(workspace) + env_values = self._workspace_secret_env_values_locked(workspace, normalized_secret_env) + if workspace.secrets and normalized_secret_env: + self._install_workspace_secrets_locked(workspace, instance) + try: + exec_result, execution_mode = self._exec_instance( + instance, + command=command, + timeout_seconds=timeout_seconds, + host_workdir=self._workspace_host_dir(workspace.workspace_id), + guest_cwd=WORKSPACE_GUEST_PATH, + env=env_values or None, + ) + except Exception as exc: + raise _redact_exception(exc, redact_values) from exc + redacted_exec_result = VmExecResult( + stdout=_redact_text(exec_result.stdout, redact_values), + stderr=_redact_text(exec_result.stderr, redact_values), + exit_code=exec_result.exit_code, + duration_ms=exec_result.duration_ms, ) with self._lock: workspace = self._load_workspace_locked(workspace_id) @@ -3150,7 +3539,7 @@ class VmManager: entry = self._record_workspace_command_locked( workspace, command=command, - exec_result=exec_result, + exec_result=redacted_exec_result, execution_mode=execution_mode, cwd=WORKSPACE_GUEST_PATH, ) @@ -3160,10 +3549,10 @@ class VmManager: "environment": workspace.environment, "environment_version": workspace.metadata.get("environment_version"), "command": command, - "stdout": exec_result.stdout, - "stderr": exec_result.stderr, - "exit_code": exec_result.exit_code, - "duration_ms": exec_result.duration_ms, + "stdout": redacted_exec_result.stdout, + "stderr": redacted_exec_result.stderr, + "exit_code": redacted_exec_result.exit_code, + "duration_ms": redacted_exec_result.duration_ms, "execution_mode": execution_mode, "sequence": entry["sequence"], "cwd": WORKSPACE_GUEST_PATH, @@ -3176,24 +3565,35 @@ class VmManager: cwd: str = WORKSPACE_GUEST_PATH, cols: int = DEFAULT_SHELL_COLS, rows: int = DEFAULT_SHELL_ROWS, + secret_env: dict[str, str] | None = None, ) -> dict[str, Any]: if cols <= 0: raise ValueError("cols must be positive") if rows <= 0: raise ValueError("rows must be positive") normalized_cwd, _ = _normalize_workspace_destination(cwd) + normalized_secret_env = _normalize_workspace_secret_env_mapping(secret_env) shell_id = uuid.uuid4().hex[:12] with self._lock: workspace = self._load_workspace_locked(workspace_id) instance = self._workspace_instance_for_live_shell_locked(workspace) - payload = self._backend.open_shell( - instance, - workspace_id=workspace_id, - shell_id=shell_id, - cwd=normalized_cwd, - cols=cols, - rows=rows, - ) + redact_values = self._workspace_secret_redact_values_locked(workspace) + env_values = self._workspace_secret_env_values_locked(workspace, normalized_secret_env) + if workspace.secrets and normalized_secret_env: + self._install_workspace_secrets_locked(workspace, instance) + try: + payload = self._backend.open_shell( + instance, + workspace_id=workspace_id, + shell_id=shell_id, + cwd=normalized_cwd, + cols=cols, + rows=rows, + env=env_values or None, + redact_values=redact_values, + ) + except Exception as exc: + raise _redact_exception(exc, redact_values) from exc shell = self._workspace_shell_record_from_payload( workspace_id=workspace_id, shell_id=shell_id, @@ -3225,13 +3625,17 @@ class VmManager: workspace = self._load_workspace_locked(workspace_id) instance = self._workspace_instance_for_live_shell_locked(workspace) shell = self._load_workspace_shell_locked(workspace_id, shell_id) - payload = self._backend.read_shell( - instance, - workspace_id=workspace_id, - shell_id=shell_id, - cursor=cursor, - max_chars=max_chars, - ) + redact_values = self._workspace_secret_redact_values_locked(workspace) + try: + payload = self._backend.read_shell( + instance, + workspace_id=workspace_id, + shell_id=shell_id, + cursor=cursor, + max_chars=max_chars, + ) + except Exception as exc: + raise _redact_exception(exc, redact_values) from exc updated_shell = self._workspace_shell_record_from_payload( workspace_id=workspace_id, shell_id=shell_id, @@ -3251,7 +3655,7 @@ class VmManager: { "cursor": int(payload.get("cursor", cursor)), "next_cursor": int(payload.get("next_cursor", cursor)), - "output": str(payload.get("output", "")), + "output": _redact_text(str(payload.get("output", "")), redact_values), "truncated": bool(payload.get("truncated", False)), } ) @@ -3269,13 +3673,17 @@ class VmManager: workspace = self._load_workspace_locked(workspace_id) instance = self._workspace_instance_for_live_shell_locked(workspace) shell = self._load_workspace_shell_locked(workspace_id, shell_id) - payload = self._backend.write_shell( - instance, - workspace_id=workspace_id, - shell_id=shell_id, - input_text=input_text, - append_newline=append_newline, - ) + redact_values = self._workspace_secret_redact_values_locked(workspace) + try: + payload = self._backend.write_shell( + instance, + workspace_id=workspace_id, + shell_id=shell_id, + input_text=input_text, + append_newline=append_newline, + ) + except Exception as exc: + raise _redact_exception(exc, redact_values) from exc updated_shell = self._workspace_shell_record_from_payload( workspace_id=workspace_id, shell_id=shell_id, @@ -3315,12 +3723,16 @@ class VmManager: workspace = self._load_workspace_locked(workspace_id) instance = self._workspace_instance_for_live_shell_locked(workspace) shell = self._load_workspace_shell_locked(workspace_id, shell_id) - payload = self._backend.signal_shell( - instance, - workspace_id=workspace_id, - shell_id=shell_id, - signal_name=normalized_signal, - ) + redact_values = self._workspace_secret_redact_values_locked(workspace) + try: + payload = self._backend.signal_shell( + instance, + workspace_id=workspace_id, + shell_id=shell_id, + signal_name=normalized_signal, + ) + except Exception as exc: + raise _redact_exception(exc, redact_values) from exc updated_shell = self._workspace_shell_record_from_payload( workspace_id=workspace_id, shell_id=shell_id, @@ -3348,11 +3760,15 @@ class VmManager: workspace = self._load_workspace_locked(workspace_id) instance = self._workspace_instance_for_live_shell_locked(workspace) shell = self._load_workspace_shell_locked(workspace_id, shell_id) - payload = self._backend.close_shell( - instance, - workspace_id=workspace_id, - shell_id=shell_id, - ) + redact_values = self._workspace_secret_redact_values_locked(workspace) + try: + payload = self._backend.close_shell( + instance, + workspace_id=workspace_id, + shell_id=shell_id, + ) + except Exception as exc: + raise _redact_exception(exc, redact_values) from exc closed_shell = self._workspace_shell_record_from_payload( workspace_id=workspace_id, shell_id=shell_id, @@ -3381,10 +3797,12 @@ class VmManager: readiness: dict[str, Any] | None = None, ready_timeout_seconds: int = DEFAULT_SERVICE_READY_TIMEOUT_SECONDS, ready_interval_ms: int = DEFAULT_SERVICE_READY_INTERVAL_MS, + secret_env: dict[str, str] | None = None, ) -> dict[str, Any]: normalized_service_name = _normalize_workspace_service_name(service_name) normalized_cwd, _ = _normalize_workspace_destination(cwd) normalized_readiness = _normalize_workspace_service_readiness(readiness) + normalized_secret_env = _normalize_workspace_secret_env_mapping(secret_env) if ready_timeout_seconds <= 0: raise ValueError("ready_timeout_seconds must be positive") if ready_interval_ms <= 0: @@ -3392,6 +3810,10 @@ class VmManager: with self._lock: workspace = self._load_workspace_locked(workspace_id) instance = self._workspace_instance_for_live_service_locked(workspace) + redact_values = self._workspace_secret_redact_values_locked(workspace) + env_values = self._workspace_secret_env_values_locked(workspace, normalized_secret_env) + if workspace.secrets and normalized_secret_env: + self._install_workspace_secrets_locked(workspace, instance) existing = self._load_workspace_service_locked_optional( workspace_id, normalized_service_name, @@ -3411,16 +3833,20 @@ class VmManager: workspace_id, normalized_service_name, ) - payload = self._backend.start_service( - instance, - workspace_id=workspace_id, - service_name=normalized_service_name, - command=command, - cwd=normalized_cwd, - readiness=normalized_readiness, - ready_timeout_seconds=ready_timeout_seconds, - ready_interval_ms=ready_interval_ms, - ) + try: + payload = self._backend.start_service( + instance, + workspace_id=workspace_id, + service_name=normalized_service_name, + command=command, + cwd=normalized_cwd, + readiness=normalized_readiness, + ready_timeout_seconds=ready_timeout_seconds, + ready_interval_ms=ready_interval_ms, + env=env_values or None, + ) + except Exception as exc: + raise _redact_exception(exc, redact_values) from exc service = self._workspace_service_record_from_payload( workspace_id=workspace_id, service_name=normalized_service_name, @@ -3475,12 +3901,16 @@ class VmManager: workspace = self._load_workspace_locked(workspace_id) instance = self._workspace_instance_for_live_service_locked(workspace) service = self._load_workspace_service_locked(workspace_id, normalized_service_name) - payload = self._backend.logs_service( - instance, - workspace_id=workspace_id, - service_name=normalized_service_name, - tail_lines=tail_lines, - ) + redact_values = self._workspace_secret_redact_values_locked(workspace) + try: + payload = self._backend.logs_service( + instance, + workspace_id=workspace_id, + service_name=normalized_service_name, + tail_lines=tail_lines, + ) + except Exception as exc: + raise _redact_exception(exc, redact_values) from exc service = self._workspace_service_record_from_payload( workspace_id=workspace_id, service_name=normalized_service_name, @@ -3498,8 +3928,8 @@ class VmManager: response = self._serialize_workspace_service(service) response.update( { - "stdout": str(payload.get("stdout", "")), - "stderr": str(payload.get("stderr", "")), + "stdout": _redact_text(str(payload.get("stdout", "")), redact_values), + "stderr": _redact_text(str(payload.get("stderr", "")), redact_values), "tail_lines": tail_lines, "truncated": bool(payload.get("truncated", False)), } @@ -3512,11 +3942,15 @@ class VmManager: workspace = self._load_workspace_locked(workspace_id) instance = self._workspace_instance_for_live_service_locked(workspace) service = self._load_workspace_service_locked(workspace_id, normalized_service_name) - payload = self._backend.stop_service( - instance, - workspace_id=workspace_id, - service_name=normalized_service_name, - ) + redact_values = self._workspace_secret_redact_values_locked(workspace) + try: + payload = self._backend.stop_service( + instance, + workspace_id=workspace_id, + service_name=normalized_service_name, + ) + except Exception as exc: + raise _redact_exception(exc, redact_values) from exc service = self._workspace_service_record_from_payload( workspace_id=workspace_id, service_name=normalized_service_name, @@ -3549,10 +3983,17 @@ class VmManager: self._refresh_workspace_liveness_locked(workspace) self._save_workspace_locked(workspace) entries = self._read_workspace_logs_locked(workspace.workspace_id) + redact_values = self._workspace_secret_redact_values_locked(workspace) + redacted_entries = [] + for entry in entries: + redacted_entry = dict(entry) + redacted_entry["stdout"] = _redact_text(str(entry.get("stdout", "")), redact_values) + redacted_entry["stderr"] = _redact_text(str(entry.get("stderr", "")), redact_values) + redacted_entries.append(redacted_entry) return { "workspace_id": workspace.workspace_id, - "count": len(entries), - "entries": entries, + "count": len(redacted_entries), + "entries": redacted_entries, } def delete_workspace( @@ -3625,6 +4066,9 @@ class VmManager: "execution_mode": workspace.metadata.get("execution_mode", "pending"), "workspace_path": WORKSPACE_GUEST_PATH, "workspace_seed": _workspace_seed_dict(workspace.workspace_seed), + "secrets": [ + _serialize_workspace_secret_public(secret) for secret in workspace.secrets + ], "command_count": workspace.command_count, "last_command": workspace.last_command, "reset_count": workspace.reset_count, @@ -3725,6 +4169,60 @@ class VmManager: "host compatibility when guest execution is unavailable." ) + def _require_workspace_secret_support(self, instance: VmInstance) -> None: + if self._backend_name == "mock": + return + if self._runtime_capabilities.supports_guest_exec: + return + reason = self._runtime_capabilities.reason or ( + "runtime does not support guest-backed secret installation" + ) + raise RuntimeError( + "workspace secrets require guest execution and are unavailable for this " + f"workspace: {reason}" + ) + + def _workspace_secret_values_locked(self, workspace: WorkspaceRecord) -> dict[str, str]: + return _load_workspace_secret_values( + workspace_dir=self._workspace_dir(workspace.workspace_id), + secrets=workspace.secrets, + ) + + def _workspace_secret_redact_values_locked(self, workspace: WorkspaceRecord) -> list[str]: + return list(self._workspace_secret_values_locked(workspace).values()) + + def _workspace_secret_env_values_locked( + self, + workspace: WorkspaceRecord, + secret_env: dict[str, str], + ) -> dict[str, str]: + secret_values = self._workspace_secret_values_locked(workspace) + env_values: dict[str, str] = {} + for secret_name, env_name in secret_env.items(): + if secret_name not in secret_values: + raise ValueError( + f"secret_env references unknown workspace secret {secret_name!r}" + ) + env_values[env_name] = secret_values[secret_name] + return env_values + + def _install_workspace_secrets_locked( + self, + workspace: WorkspaceRecord, + instance: VmInstance, + ) -> None: + if not workspace.secrets: + return + self._require_workspace_secret_support(instance) + with tempfile.TemporaryDirectory(prefix="pyro-workspace-secrets-") as temp_dir: + archive_path = Path(temp_dir) / "workspace-secrets.tar" + _build_workspace_secret_archive( + workspace_dir=self._workspace_dir(workspace.workspace_id), + secrets=workspace.secrets, + archive_path=archive_path, + ) + self._backend.install_secrets(instance, archive_path=archive_path) + def _get_instance_locked(self, vm_id: str) -> VmInstance: try: return self._instances[vm_id] @@ -3771,6 +4269,7 @@ class VmManager: timeout_seconds: int, host_workdir: Path | None = None, guest_cwd: str | None = None, + env: dict[str, str] | None = None, ) -> tuple[VmExecResult, str]: if timeout_seconds <= 0: raise ValueError("timeout_seconds must be positive") @@ -3784,12 +4283,21 @@ class VmManager: else: instance.metadata["execution_mode"] = "host_compat" workdir = host_workdir - exec_result = self._backend.exec( - instance, - prepared_command, - timeout_seconds, - workdir=workdir, - ) + if env is None: + exec_result = self._backend.exec( + instance, + prepared_command, + timeout_seconds, + workdir=workdir, + ) + else: + exec_result = self._backend.exec( + instance, + prepared_command, + timeout_seconds, + workdir=workdir, + env=env, + ) execution_mode = instance.metadata.get("execution_mode", "unknown") return exec_result, execution_mode @@ -3832,15 +4340,33 @@ class VmManager: bytes_written=bytes_written, ) - def _ensure_workspace_guest_agent_support(self, instance: VmInstance) -> None: - if self._runtime_paths is None or self._runtime_paths.guest_agent_path is None: + def _ensure_workspace_guest_bootstrap_support(self, instance: VmInstance) -> None: + if ( + self._runtime_paths is None + or self._runtime_paths.guest_agent_path is None + or self._runtime_paths.guest_init_path is None + ): raise RuntimeError( - "runtime bundle does not provide a guest agent for workspace operations" + "runtime bundle does not provide guest bootstrap assets for workspace operations" ) rootfs_image = instance.metadata.get("rootfs_image") if rootfs_image is None or rootfs_image == "": raise RuntimeError("workspace rootfs image is unavailable for guest operations") - _patch_rootfs_guest_agent(Path(rootfs_image), self._runtime_paths.guest_agent_path) + rootfs_path = Path(rootfs_image) + _patch_rootfs_runtime_file( + rootfs_path, + source_path=self._runtime_paths.guest_init_path, + destination_path=WORKSPACE_GUEST_INIT_PATH, + asset_label="guest-init", + file_mode="0100755", + ) + _patch_rootfs_runtime_file( + rootfs_path, + source_path=self._runtime_paths.guest_agent_path, + destination_path=WORKSPACE_GUEST_AGENT_PATH, + asset_label="guest-agent", + file_mode="0100755", + ) def _workspace_dir(self, workspace_id: str) -> Path: return self._workspaces_dir / workspace_id @@ -3875,6 +4401,9 @@ class VmManager: def _workspace_services_dir(self, workspace_id: str) -> Path: return self._workspace_dir(workspace_id) / WORKSPACE_SERVICES_DIRNAME + def _workspace_secrets_dir(self, workspace_id: str) -> Path: + return self._workspace_dir(workspace_id) / WORKSPACE_SECRETS_DIRNAME + def _workspace_metadata_path(self, workspace_id: str) -> Path: return self._workspace_dir(workspace_id) / "workspace.json" diff --git a/src/pyro_mcp/workspace_shells.py b/src/pyro_mcp/workspace_shells.py index d082777..e439042 100644 --- a/src/pyro_mcp/workspace_shells.py +++ b/src/pyro_mcp/workspace_shells.py @@ -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 diff --git a/tests/test_api.py b/tests/test_api.py index db3dbc5..1990d79 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import time from pathlib import Path from typing import Any, cast @@ -134,40 +135,71 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None: source_dir = tmp_path / "seed" source_dir.mkdir() (source_dir / "note.txt").write_text("ok\n", encoding="utf-8") + secret_file = tmp_path / "token.txt" + secret_file.write_text("from-file\n", encoding="utf-8") created = pyro.create_workspace( environment="debian:12-base", allow_host_compat=True, seed_path=source_dir, + secrets=[ + {"name": "API_TOKEN", "value": "expected"}, + {"name": "FILE_TOKEN", "file_path": str(secret_file)}, + ], ) workspace_id = str(created["workspace_id"]) updated_dir = tmp_path / "updated" updated_dir.mkdir() (updated_dir / "more.txt").write_text("more\n", encoding="utf-8") synced = pyro.push_workspace_sync(workspace_id, updated_dir, dest="subdir") - executed = pyro.exec_workspace(workspace_id, command="cat note.txt") + executed = pyro.exec_workspace( + workspace_id, + command='sh -lc \'printf "%s\\n" "$API_TOKEN"\'', + secret_env={"API_TOKEN": "API_TOKEN"}, + ) diff_payload = pyro.diff_workspace(workspace_id) snapshot = pyro.create_snapshot(workspace_id, "checkpoint") snapshots = pyro.list_snapshots(workspace_id) export_path = tmp_path / "exported-note.txt" exported = pyro.export_workspace(workspace_id, "note.txt", output_path=export_path) + shell = pyro.open_shell( + workspace_id, + secret_env={"API_TOKEN": "API_TOKEN"}, + ) + shell_id = str(shell["shell_id"]) + pyro.write_shell(workspace_id, shell_id, input='printf "%s\\n" "$API_TOKEN"') + shell_output: dict[str, Any] = {} + deadline = time.time() + 5 + while time.time() < deadline: + shell_output = pyro.read_shell(workspace_id, shell_id, cursor=0, max_chars=65536) + if "[REDACTED]" in str(shell_output.get("output", "")): + break + time.sleep(0.05) + shell_closed = pyro.close_shell(workspace_id, shell_id) service = pyro.start_service( workspace_id, "app", - command="sh -lc 'touch .ready; while true; do sleep 60; done'", + 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"}, ) services = pyro.list_services(workspace_id) service_status = pyro.status_service(workspace_id, "app") service_logs = pyro.logs_service(workspace_id, "app", all=True) - service_stopped = pyro.stop_service(workspace_id, "app") reset = pyro.reset_workspace(workspace_id, snapshot="checkpoint") deleted_snapshot = pyro.delete_snapshot(workspace_id, "checkpoint") status = pyro.status_workspace(workspace_id) logs = pyro.logs_workspace(workspace_id) deleted = pyro.delete_workspace(workspace_id) - assert executed["stdout"] == "ok\n" + assert created["secrets"] == [ + {"name": "API_TOKEN", "source_kind": "literal"}, + {"name": "FILE_TOKEN", "source_kind": "file"}, + ] + assert executed["stdout"] == "[REDACTED]\n" assert created["workspace_seed"]["mode"] == "directory" assert synced["workspace_sync"]["destination"] == "/workspace/subdir" assert diff_payload["changed"] is True @@ -175,12 +207,15 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None: assert snapshots["count"] == 2 assert exported["output_path"] == str(export_path) assert export_path.read_text(encoding="utf-8") == "ok\n" + assert shell_output["output"].count("[REDACTED]") >= 1 + assert shell_closed["closed"] is True assert service["state"] == "running" assert services["count"] == 1 assert service_status["state"] == "running" + assert service_logs["stderr"].count("[REDACTED]") >= 1 assert service_logs["tail_lines"] is None - assert service_stopped["state"] == "stopped" assert reset["workspace_reset"]["snapshot_name"] == "checkpoint" + assert reset["secrets"] == created["secrets"] assert deleted_snapshot["deleted"] is True assert status["command_count"] == 0 assert status["service_count"] == 0 diff --git a/tests/test_cli.py b/tests/test_cli.py index f487007..f123642 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_public_contract.py b/tests/test_public_contract.py index 0dce0ed..6f22889 100644 --- a/tests/test_public_contract.py +++ b/tests/test_public_contract.py @@ -19,6 +19,7 @@ from pyro_mcp.contract import ( PUBLIC_CLI_RUN_FLAGS, PUBLIC_CLI_WORKSPACE_CREATE_FLAGS, PUBLIC_CLI_WORKSPACE_DIFF_FLAGS, + PUBLIC_CLI_WORKSPACE_EXEC_FLAGS, PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS, PUBLIC_CLI_WORKSPACE_RESET_FLAGS, PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS, @@ -94,6 +95,11 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None: ).format_help() for flag in PUBLIC_CLI_WORKSPACE_CREATE_FLAGS: assert flag in workspace_create_help_text + workspace_exec_help_text = _subparser_choice( + _subparser_choice(parser, "workspace"), "exec" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_EXEC_FLAGS: + assert flag in workspace_exec_help_text workspace_sync_help_text = _subparser_choice( _subparser_choice(parser, "workspace"), "sync", diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 930ef4f..a2b9004 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import shutil from pathlib import Path import pytest @@ -14,6 +15,8 @@ def test_resolve_runtime_paths_default_bundle() -> None: assert paths.jailer_bin.exists() assert paths.guest_agent_path is not None assert paths.guest_agent_path.exists() + assert paths.guest_init_path is not None + assert paths.guest_init_path.exists() assert paths.artifacts_dir.exists() assert paths.manifest.get("platform") == "linux-x86_64" @@ -51,17 +54,56 @@ def test_resolve_runtime_paths_checksum_mismatch( guest_agent_path = source.guest_agent_path if guest_agent_path is None: raise AssertionError("expected guest agent in runtime bundle") + guest_init_path = source.guest_init_path + if guest_init_path is None: + raise AssertionError("expected guest init in runtime bundle") copied_guest_dir = copied_platform / "guest" copied_guest_dir.mkdir(parents=True, exist_ok=True) (copied_guest_dir / "pyro_guest_agent.py").write_text( guest_agent_path.read_text(encoding="utf-8"), encoding="utf-8", ) + (copied_guest_dir / "pyro-init").write_text( + guest_init_path.read_text(encoding="utf-8"), + encoding="utf-8", + ) monkeypatch.setenv("PYRO_RUNTIME_BUNDLE_DIR", str(copied_bundle)) with pytest.raises(RuntimeError, match="checksum mismatch"): resolve_runtime_paths() +def test_resolve_runtime_paths_guest_init_checksum_mismatch( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + source = resolve_runtime_paths() + copied_bundle = tmp_path / "bundle" + shutil.copytree(source.bundle_root.parent, copied_bundle) + copied_platform = copied_bundle / "linux-x86_64" + copied_guest_init = copied_platform / "guest" / "pyro-init" + copied_guest_init.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + monkeypatch.setenv("PYRO_RUNTIME_BUNDLE_DIR", str(copied_bundle)) + with pytest.raises(RuntimeError, match="checksum mismatch"): + resolve_runtime_paths() + + +def test_resolve_runtime_paths_guest_init_manifest_malformed( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + source = resolve_runtime_paths() + copied_bundle = tmp_path / "bundle" + shutil.copytree(source.bundle_root.parent, copied_bundle) + manifest_path = copied_bundle / "linux-x86_64" / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + guest = manifest.get("guest") + if not isinstance(guest, dict): + raise AssertionError("expected guest manifest section") + guest["init"] = {"path": "guest/pyro-init"} + manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + monkeypatch.setenv("PYRO_RUNTIME_BUNDLE_DIR", str(copied_bundle)) + with pytest.raises(RuntimeError, match="runtime guest init manifest entry is malformed"): + resolve_runtime_paths() + + def test_doctor_report_has_runtime_fields() -> None: report = doctor_report() assert "runtime_ok" in report @@ -72,6 +114,7 @@ def test_doctor_report_has_runtime_fields() -> None: assert isinstance(runtime, dict) assert "firecracker_bin" in runtime assert "guest_agent_path" in runtime + assert "guest_init_path" in runtime assert "component_versions" in runtime assert "environments" in runtime networking = report["networking"] diff --git a/tests/test_server.py b/tests/test_server.py index 7c1cca7..f9eac6d 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -191,6 +191,8 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None: source_dir = tmp_path / "seed" source_dir.mkdir() (source_dir / "note.txt").write_text("ok\n", encoding="utf-8") + secret_file = tmp_path / "token.txt" + secret_file.write_text("from-file\n", encoding="utf-8") def _extract_structured(raw_result: object) -> dict[str, Any]: if not isinstance(raw_result, tuple) or len(raw_result) != 2: @@ -209,6 +211,10 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None: "environment": "debian:12-base", "allow_host_compat": True, "seed_path": str(source_dir), + "secrets": [ + {"name": "API_TOKEN", "value": "expected"}, + {"name": "FILE_TOKEN", "file_path": str(secret_file)}, + ], }, ) ) @@ -231,7 +237,8 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None: "workspace_exec", { "workspace_id": workspace_id, - "command": "cat subdir/more.txt", + "command": 'sh -lc \'printf "%s\\n" "$API_TOKEN"\'', + "secret_env": {"API_TOKEN": "API_TOKEN"}, }, ) ) @@ -264,8 +271,12 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None: { "workspace_id": workspace_id, "service_name": "app", - "command": "sh -lc 'touch .ready; while true; do sleep 60; done'", + "command": ( + 'sh -lc \'trap "exit 0" TERM; printf "%s\\n" "$API_TOKEN" >&2; ' + 'touch .ready; while true; do sleep 60; done\'' + ), "ready_file": ".ready", + "secret_env": {"API_TOKEN": "API_TOKEN"}, }, ) ) @@ -357,8 +368,12 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None: ) = asyncio.run(_run()) assert created["state"] == "started" assert created["workspace_seed"]["mode"] == "directory" + assert created["secrets"] == [ + {"name": "API_TOKEN", "source_kind": "literal"}, + {"name": "FILE_TOKEN", "source_kind": "file"}, + ] assert synced["workspace_sync"]["destination"] == "/workspace/subdir" - assert executed["stdout"] == "more\n" + assert executed["stdout"] == "[REDACTED]\n" assert diffed["changed"] is True assert snapshot["snapshot"]["snapshot_name"] == "checkpoint" assert [entry["snapshot_name"] for entry in snapshots["snapshots"]] == [ @@ -370,9 +385,11 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None: assert service["state"] == "running" assert services["count"] == 1 assert service_status["state"] == "running" + assert service_logs["stderr"].count("[REDACTED]") >= 1 assert service_logs["tail_lines"] is None assert service_stopped["state"] == "stopped" assert reset["workspace_reset"]["snapshot_name"] == "checkpoint" + assert reset["secrets"] == created["secrets"] assert reset["command_count"] == 0 assert reset["service_count"] == 0 assert deleted_snapshot["deleted"] is True diff --git a/tests/test_vm_environments.py b/tests/test_vm_environments.py index c9ab1e6..c87606f 100644 --- a/tests/test_vm_environments.py +++ b/tests/test_vm_environments.py @@ -42,6 +42,7 @@ def _fake_runtime_paths(tmp_path: Path) -> RuntimePaths: firecracker_bin = bundle_root / "bin" / "firecracker" jailer_bin = bundle_root / "bin" / "jailer" guest_agent_path = bundle_root / "guest" / "pyro_guest_agent.py" + guest_init_path = bundle_root / "guest" / "pyro-init" artifacts_dir = bundle_root / "profiles" notice_path = bundle_parent / "NOTICE" @@ -54,6 +55,7 @@ def _fake_runtime_paths(tmp_path: Path) -> RuntimePaths: firecracker_bin.write_text("firecracker\n", encoding="utf-8") jailer_bin.write_text("jailer\n", encoding="utf-8") guest_agent_path.write_text("print('guest')\n", encoding="utf-8") + guest_init_path.write_text("#!/bin/sh\n", encoding="utf-8") notice_path.write_text("notice\n", encoding="utf-8") return RuntimePaths( @@ -62,6 +64,7 @@ def _fake_runtime_paths(tmp_path: Path) -> RuntimePaths: 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={"platform": "linux-x86_64"}, diff --git a/tests/test_vm_guest.py b/tests/test_vm_guest.py index 8ce007c..2728cc8 100644 --- a/tests/test_vm_guest.py +++ b/tests/test_vm_guest.py @@ -57,12 +57,13 @@ def test_vsock_exec_client_round_trip(monkeypatch: pytest.MonkeyPatch) -> None: return stub client = VsockExecClient(socket_factory=socket_factory) - response = client.exec(1234, 5005, "echo ok", 30) + response = client.exec(1234, 5005, "echo ok", 30, env={"TOKEN": "expected"}) assert response.exit_code == 0 assert response.stdout == "ok\n" assert stub.connected == (1234, 5005) assert b'"command": "echo ok"' in stub.sent + assert b'"env": {"TOKEN": "expected"}' in stub.sent assert stub.closed is True @@ -105,6 +106,39 @@ def test_vsock_exec_client_upload_archive_round_trip( assert stub.closed is True +def test_vsock_exec_client_install_secrets_round_trip( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr(socket, "AF_VSOCK", 40, raising=False) + archive_path = tmp_path / "secrets.tar" + with tarfile.open(archive_path, "w") as archive: + payload = b"expected\n" + info = tarfile.TarInfo(name="API_TOKEN") + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + stub = StubSocket( + b'{"destination":"/run/pyro-secrets","entry_count":1,"bytes_written":9}' + ) + + def socket_factory(family: int, sock_type: int) -> StubSocket: + assert family == socket.AF_VSOCK + assert sock_type == socket.SOCK_STREAM + return stub + + client = VsockExecClient(socket_factory=socket_factory) + response = client.install_secrets(1234, 5005, archive_path, timeout_seconds=60) + + request_payload, archive_payload = stub.sent.split(b"\n", 1) + request = json.loads(request_payload.decode("utf-8")) + assert request["action"] == "install_secrets" + assert int(request["archive_size"]) == archive_path.stat().st_size + assert archive_payload == archive_path.read_bytes() + assert response.destination == "/run/pyro-secrets" + assert response.entry_count == 1 + assert response.bytes_written == 9 + assert stub.closed is True + + def test_vsock_exec_client_export_archive_round_trip( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -241,6 +275,8 @@ def test_vsock_exec_client_shell_round_trip(monkeypatch: pytest.MonkeyPatch) -> cwd="/workspace", cols=120, rows=30, + env={"TOKEN": "expected"}, + redact_values=["expected"], ) assert opened.shell_id == "shell-1" read = client.read_shell(1234, 5005, shell_id="shell-1", cursor=0, max_chars=1024) @@ -260,6 +296,8 @@ def test_vsock_exec_client_shell_round_trip(monkeypatch: pytest.MonkeyPatch) -> open_request = json.loads(stubs[0].sent.decode("utf-8").strip()) assert open_request["action"] == "open_shell" assert open_request["shell_id"] == "shell-1" + assert open_request["env"] == {"TOKEN": "expected"} + assert open_request["redact_values"] == ["expected"] def test_vsock_exec_client_service_round_trip(monkeypatch: pytest.MonkeyPatch) -> None: @@ -348,6 +386,7 @@ def test_vsock_exec_client_service_round_trip(monkeypatch: pytest.MonkeyPatch) - readiness={"type": "file", "path": "/workspace/.ready"}, ready_timeout_seconds=30, ready_interval_ms=500, + env={"TOKEN": "expected"}, ) assert started["service_name"] == "app" status = client.status_service(1234, 5005, service_name="app") @@ -359,6 +398,7 @@ def test_vsock_exec_client_service_round_trip(monkeypatch: pytest.MonkeyPatch) - start_request = json.loads(stubs[0].sent.decode("utf-8").strip()) assert start_request["action"] == "start_service" assert start_request["service_name"] == "app" + assert start_request["env"] == {"TOKEN": "expected"} def test_vsock_exec_client_raises_agent_error(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/test_vm_manager.py b/tests/test_vm_manager.py index 146e6bd..44c1fc0 100644 --- a/tests/test_vm_manager.py +++ b/tests/test_vm_manager.py @@ -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"}], + ) diff --git a/uv.lock b/uv.lock index 436eb8c..b2d9352 100644 --- a/uv.lock +++ b/uv.lock @@ -706,7 +706,7 @@ crypto = [ [[package]] name = "pyro-mcp" -version = "2.8.0" +version = "2.9.0" source = { editable = "." } dependencies = [ { name = "mcp" },