Add guest-only workspace secrets

Add explicit workspace secrets across the CLI, SDK, and MCP, with create-time secret definitions and per-call secret-to-env mapping for exec, shell open, and service start. Persist only safe secret metadata in workspace records, materialize secret files under /run/pyro-secrets, and redact secret values from exec output, shell reads, service logs, and surfaced errors.

Fix the remaining real-guest shell gap by shipping bundled guest init alongside the guest agent and patching both into guest-backed workspace rootfs images before boot. The new init mounts devpts so PTY shells work on Firecracker guests, while reset continues to recreate the sandbox and re-materialize secrets from stored task-local secret material.

Validation: uv lock; UV_CACHE_DIR=.uv-cache make check; UV_CACHE_DIR=.uv-cache make dist-check; and a real guest-backed Firecracker smoke covering workspace create with secrets, secret-backed exec, shell, service, reset, and delete.
This commit is contained in:
Thales Maciel 2026-03-12 15:43:34 -03:00
parent 18b8fd2a7d
commit fc72fcd3a1
32 changed files with 1980 additions and 181 deletions

View file

@ -2,6 +2,17 @@
All notable user-visible changes to `pyro-mcp` are documented here. 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 ## 2.8.0
- Added explicit named workspace snapshots across the CLI, Python SDK, and MCP server with - Added explicit named workspace snapshots across the CLI, Python SDK, and MCP server with

View file

@ -20,7 +20,7 @@ It exposes the same runtime in three public forms:
- First run transcript: [docs/first-run.md](docs/first-run.md) - First run transcript: [docs/first-run.md](docs/first-run.md)
- Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif) - 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/) - 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) - Host requirements: [docs/host-requirements.md](docs/host-requirements.md)
- Integration targets: [docs/integrations.md](docs/integrations.md) - Integration targets: [docs/integrations.md](docs/integrations.md)
- Public contract: [docs/public-contract.md](docs/public-contract.md) - Public contract: [docs/public-contract.md](docs/public-contract.md)
@ -57,7 +57,7 @@ What success looks like:
```bash ```bash
Platform: linux-x86_64 Platform: linux-x86_64
Runtime: PASS Runtime: PASS
Catalog version: 2.8.0 Catalog version: 2.9.0
... ...
[pull] phase=install environment=debian:12 [pull] phase=install environment=debian:12
[pull] phase=ready 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` - 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` - 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` - 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` - 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` - 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` - 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: Expected output:
```bash ```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 [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-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. 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 ```bash
pyro workspace create debian:12 --seed-path ./repo 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 sync push WORKSPACE_ID ./changes --dest src
pyro workspace exec WORKSPACE_ID -- cat src/note.txt 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 diff WORKSPACE_ID
pyro workspace snapshot create WORKSPACE_ID checkpoint pyro workspace snapshot create WORKSPACE_ID checkpoint
pyro workspace reset WORKSPACE_ID --snapshot checkpoint pyro workspace reset WORKSPACE_ID --snapshot checkpoint
pyro workspace reset WORKSPACE_ID pyro workspace reset WORKSPACE_ID
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt 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 write WORKSPACE_ID SHELL_ID --input 'pwd'
pyro workspace shell read WORKSPACE_ID SHELL_ID pyro workspace shell read WORKSPACE_ID SHELL_ID
pyro workspace shell close 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 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 list WORKSPACE_ID
pyro workspace service status WORKSPACE_ID web 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 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` 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 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. 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 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 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 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 `--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`. 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/<name>`, 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 ## Public Interfaces
@ -422,9 +430,25 @@ Advanced lifecycle tools:
Persistent workspace 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_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_status(workspace_id)`
- `workspace_logs(workspace_id)` - `workspace_logs(workspace_id)`
- `workspace_delete(workspace_id)` - `workspace_delete(workspace_id)`

View file

@ -22,7 +22,7 @@ Networking: tun=yes ip_forward=yes
```bash ```bash
$ uvx --from pyro-mcp pyro env list $ 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 [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-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. 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 demo
$ uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo $ 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 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 diff WORKSPACE_ID
$ uvx --from pyro-mcp pyro workspace snapshot create WORKSPACE_ID checkpoint $ 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 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 export WORKSPACE_ID note.txt --output ./note.txt
$ uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID $ 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 --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done' $ 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 $ 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 hello from synced workspace
[workspace-exec] workspace_id=... sequence=1 cwd=/workspace execution_mode=guest_vsock exit_code=0 duration_ms=... [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 $ uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID
[workspace-diff] workspace_id=... total=... added=... modified=... deleted=... type_changed=... text_patched=... non_text=... [workspace-diff] workspace_id=... total=... added=... modified=... deleted=... type_changed=... text_patched=... non_text=...
--- a/src/note.txt --- 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 $ 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 [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 [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' $ 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
[workspace-shell-read] workspace_id=... shell_id=... state=running cursor=0 next_cursor=... truncated=False execution_mode=guest_vsock [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 [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' $ 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 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 `.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 `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 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 `/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 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 *` 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 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 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/<name>`, 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: Example output:

View file

@ -83,7 +83,7 @@ uvx --from pyro-mcp pyro env list
Expected output: Expected output:
```bash ```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 [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-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. 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` - persistent workspaces: `pyro workspace create debian:12 --seed-path ./repo`
- live workspace updates: `pyro workspace sync push WORKSPACE_ID ./changes` - 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` - 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` - 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` - 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 ```bash
pyro workspace create debian:12 --seed-path ./repo 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 sync push WORKSPACE_ID ./changes --dest src
pyro workspace exec WORKSPACE_ID -- cat src/note.txt 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 diff WORKSPACE_ID
pyro workspace snapshot create WORKSPACE_ID checkpoint pyro workspace snapshot create WORKSPACE_ID checkpoint
pyro workspace reset WORKSPACE_ID --snapshot checkpoint pyro workspace reset WORKSPACE_ID --snapshot checkpoint
pyro workspace reset WORKSPACE_ID pyro workspace reset WORKSPACE_ID
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt 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 write WORKSPACE_ID SHELL_ID --input 'pwd'
pyro workspace shell read WORKSPACE_ID SHELL_ID pyro workspace shell read WORKSPACE_ID SHELL_ID
pyro workspace shell close 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 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 list WORKSPACE_ID
pyro workspace service status WORKSPACE_ID web 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` 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` 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 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 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 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 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 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 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 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/<name>`.
## Contributor Clone ## Contributor Clone

View file

@ -31,9 +31,10 @@ Recommended surface:
- `vm_run` - `vm_run`
- `workspace_create(seed_path=...)` + `workspace_sync_push` + `workspace_exec` when the agent needs persistent workspace state - `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 - `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 - `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: Canonical example:
@ -69,9 +70,10 @@ Recommended default:
- `Pyro.run_in_vm(...)` - `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(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.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.start_service(..., secret_env=...)` + `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.open_shell(..., secret_env=...)` + `Pyro.write_shell(...)` + `Pyro.read_shell(...)` when the agent needs an interactive PTY inside the workspace
Lifecycle note: Lifecycle note:
@ -82,6 +84,8 @@ Lifecycle note:
`/workspace` that starts from host content `/workspace` that starts from host content
- use `push_workspace_sync(...)` when later host-side changes need to be imported into that - use `push_workspace_sync(...)` when later host-side changes need to be imported into that
running workspace without recreating it 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 - use `diff_workspace(...)` when the agent needs a structured comparison against the immutable
create-time baseline create-time baseline
- use `export_workspace(...)` when the agent needs one file or directory copied back to the host - use `export_workspace(...)` when the agent needs one file or directory copied back to the host

View file

@ -64,17 +64,22 @@ Behavioral guarantees:
- `pyro demo ollama` prints log lines plus a final summary line. - `pyro demo ollama` prints log lines plus a final summary line.
- `pyro workspace create` auto-starts a persistent workspace. - `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 --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 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 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 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 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 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 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 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 shell *` manages persistent PTY sessions inside a started workspace.
- `pyro workspace logs` returns persisted command history for that workspace until `pyro workspace delete`. - `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 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 `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. - `pyro workspace status` includes aggregate `service_count` and `running_service_count` fields.
## Python SDK Contract ## Python SDK Contract
@ -92,7 +97,7 @@ Supported public entrypoints:
- `Pyro.inspect_environment(environment)` - `Pyro.inspect_environment(environment)`
- `Pyro.prune_environments()` - `Pyro.prune_environments()`
- `Pyro.create_vm(...)` - `Pyro.create_vm(...)`
- `Pyro.create_workspace(...)` - `Pyro.create_workspace(..., secrets=None)`
- `Pyro.push_workspace_sync(workspace_id, source_path, *, dest="/workspace")` - `Pyro.push_workspace_sync(workspace_id, source_path, *, dest="/workspace")`
- `Pyro.export_workspace(workspace_id, path, *, output_path)` - `Pyro.export_workspace(workspace_id, path, *, output_path)`
- `Pyro.diff_workspace(workspace_id)` - `Pyro.diff_workspace(workspace_id)`
@ -100,19 +105,19 @@ Supported public entrypoints:
- `Pyro.list_snapshots(workspace_id)` - `Pyro.list_snapshots(workspace_id)`
- `Pyro.delete_snapshot(workspace_id, snapshot_name)` - `Pyro.delete_snapshot(workspace_id, snapshot_name)`
- `Pyro.reset_workspace(workspace_id, *, snapshot="baseline")` - `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.list_services(workspace_id)`
- `Pyro.status_service(workspace_id, service_name)` - `Pyro.status_service(workspace_id, service_name)`
- `Pyro.logs_service(workspace_id, service_name, *, tail_lines=200, all=False)` - `Pyro.logs_service(workspace_id, service_name, *, tail_lines=200, all=False)`
- `Pyro.stop_service(workspace_id, service_name)` - `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.read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536)`
- `Pyro.write_shell(workspace_id, shell_id, *, input, append_newline=True)` - `Pyro.write_shell(workspace_id, shell_id, *, input, append_newline=True)`
- `Pyro.signal_shell(workspace_id, shell_id, *, signal_name="INT")` - `Pyro.signal_shell(workspace_id, shell_id, *, signal_name="INT")`
- `Pyro.close_shell(workspace_id, shell_id)` - `Pyro.close_shell(workspace_id, shell_id)`
- `Pyro.start_vm(vm_id)` - `Pyro.start_vm(vm_id)`
- `Pyro.exec_vm(vm_id, *, command, timeout_seconds=30)` - `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.stop_vm(vm_id)`
- `Pyro.delete_vm(vm_id)` - `Pyro.delete_vm(vm_id)`
- `Pyro.delete_workspace(workspace_id)` - `Pyro.delete_workspace(workspace_id)`
@ -131,7 +136,7 @@ Stable public method names:
- `inspect_environment(environment)` - `inspect_environment(environment)`
- `prune_environments()` - `prune_environments()`
- `create_vm(...)` - `create_vm(...)`
- `create_workspace(...)` - `create_workspace(..., secrets=None)`
- `push_workspace_sync(workspace_id, source_path, *, dest="/workspace")` - `push_workspace_sync(workspace_id, source_path, *, dest="/workspace")`
- `export_workspace(workspace_id, path, *, output_path)` - `export_workspace(workspace_id, path, *, output_path)`
- `diff_workspace(workspace_id)` - `diff_workspace(workspace_id)`
@ -139,19 +144,19 @@ Stable public method names:
- `list_snapshots(workspace_id)` - `list_snapshots(workspace_id)`
- `delete_snapshot(workspace_id, snapshot_name)` - `delete_snapshot(workspace_id, snapshot_name)`
- `reset_workspace(workspace_id, *, snapshot="baseline")` - `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)` - `list_services(workspace_id)`
- `status_service(workspace_id, service_name)` - `status_service(workspace_id, service_name)`
- `logs_service(workspace_id, service_name, *, tail_lines=200, all=False)` - `logs_service(workspace_id, service_name, *, tail_lines=200, all=False)`
- `stop_service(workspace_id, service_name)` - `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)` - `read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536)`
- `write_shell(workspace_id, shell_id, *, input, append_newline=True)` - `write_shell(workspace_id, shell_id, *, input, append_newline=True)`
- `signal_shell(workspace_id, shell_id, *, signal_name="INT")` - `signal_shell(workspace_id, shell_id, *, signal_name="INT")`
- `close_shell(workspace_id, shell_id)` - `close_shell(workspace_id, shell_id)`
- `start_vm(vm_id)` - `start_vm(vm_id)`
- `exec_vm(vm_id, *, command, timeout_seconds=30)` - `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)` - `stop_vm(vm_id)`
- `delete_vm(vm_id)` - `delete_vm(vm_id)`
- `delete_workspace(workspace_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_vm(...)` and `run_in_vm(...)`.
- `allow_host_compat` defaults to `False` on `create_workspace(...)`. - `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(..., 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.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.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. - `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.list_snapshots(...)` lists the implicit `baseline` plus any named snapshots.
- `Pyro.delete_snapshot(...)` deletes one named snapshot while leaving `baseline` intact. - `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.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.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.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_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.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.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.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. - `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`. - `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` 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 `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_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_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. - `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. - `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. - `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. - `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. - `shell_open`, `shell_read`, `shell_write`, `shell_signal`, and `shell_close` manage persistent PTY shells inside a started workspace.
## Versioning Rule ## Versioning Rule

View file

@ -2,7 +2,7 @@
This roadmap turns the agent-workspace vision into release-sized milestones. 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 - workspace persistence exists and the public surface is now workspace-first
- host crossing currently covers create-time seeding, later sync push, and explicit export - 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 - immutable create-time baselines now power whole-workspace diff
- multi-service lifecycle exists with typed readiness and aggregate workspace status counts - multi-service lifecycle exists with typed readiness and aggregate workspace status counts
- named snapshots and full workspace reset now exist - 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: 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 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 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 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) 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) 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) 9. [`3.1.0` Secondary Disk Tools](task-workspace-ga/3.1.0-secondary-disk-tools.md)

View file

@ -1,5 +1,7 @@
# `2.9.0` Secrets # `2.9.0` Secrets
Status: Done
## Goal ## Goal
Add explicit secrets so workspaces can handle private dependencies, Add explicit secrets so workspaces can handle private dependencies,

View file

@ -12,15 +12,31 @@ def main() -> None:
tempfile.TemporaryDirectory(prefix="pyro-workspace-seed-") as seed_dir, tempfile.TemporaryDirectory(prefix="pyro-workspace-seed-") as seed_dir,
tempfile.TemporaryDirectory(prefix="pyro-workspace-sync-") as sync_dir, tempfile.TemporaryDirectory(prefix="pyro-workspace-sync-") as sync_dir,
tempfile.TemporaryDirectory(prefix="pyro-workspace-export-") as export_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(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") 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"]) workspace_id = str(created["workspace_id"])
try: try:
pyro.push_workspace_sync(workspace_id, sync_dir) pyro.push_workspace_sync(workspace_id, sync_dir)
result = pyro.exec_workspace(workspace_id, command="cat note.txt") result = pyro.exec_workspace(workspace_id, command="cat note.txt")
print(result["stdout"], end="") 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) diff_result = pyro.diff_workspace(workspace_id)
print(f"changed={diff_result['changed']} total={diff_result['summary']['total']}") print(f"changed={diff_result['changed']} total={diff_result['summary']['total']}")
snapshot = pyro.create_snapshot(workspace_id, "checkpoint") snapshot = pyro.create_snapshot(workspace_id, "checkpoint")
@ -28,11 +44,22 @@ def main() -> None:
exported_path = Path(export_dir, "note.txt") exported_path = Path(export_dir, "note.txt")
pyro.export_workspace(workspace_id, "note.txt", output_path=exported_path) pyro.export_workspace(workspace_id, "note.txt", output_path=exported_path)
print(exported_path.read_text(encoding="utf-8"), end="") 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( pyro.start_service(
workspace_id, workspace_id,
"web", "web",
command="touch .web-ready && while true; do sleep 60; done", command="touch .web-ready && while true; do sleep 60; done",
readiness={"type": "file", "path": ".web-ready"}, readiness={"type": "file", "path": ".web-ready"},
secret_env={"API_TOKEN": "API_TOKEN"},
) )
services = pyro.list_services(workspace_id) services = pyro.list_services(workspace_id)
print(f"services={services['count']} running={services['running_count']}") print(f"services={services['count']} running={services['running_count']}")
@ -43,6 +70,7 @@ def main() -> None:
pyro.stop_service(workspace_id, "web") pyro.stop_service(workspace_id, "web")
reset = pyro.reset_workspace(workspace_id, snapshot="checkpoint") reset = pyro.reset_workspace(workspace_id, snapshot="checkpoint")
print(f"reset_count={reset['reset_count']}") print(f"reset_count={reset['reset_count']}")
print(f"secret_count={len(reset['secrets'])}")
logs = pyro.logs_workspace(workspace_id) logs = pyro.logs_workspace(workspace_id)
print(f"workspace_id={workspace_id} command_count={logs['count']}") print(f"workspace_id={workspace_id} command_count={logs['count']}")
finally: finally:

View file

@ -1,6 +1,6 @@
[project] [project]
name = "pyro-mcp" name = "pyro-mcp"
version = "2.8.0" version = "2.9.0"
description = "Ephemeral Firecracker sandboxes with curated environments, persistent workspaces, and MCP tools." description = "Ephemeral Firecracker sandboxes with curated environments, persistent workspaces, and MCP tools."
readme = "README.md" readme = "README.md"
license = { file = "LICENSE" } license = { file = "LICENSE" }

View file

@ -10,6 +10,7 @@ import json
import os import os
import re import re
import shlex import shlex
import shutil
import signal import signal
import socket import socket
import struct import struct
@ -29,6 +30,7 @@ BUFFER_SIZE = 65536
WORKSPACE_ROOT = PurePosixPath("/workspace") WORKSPACE_ROOT = PurePosixPath("/workspace")
SHELL_ROOT = Path("/run/pyro-shells") SHELL_ROOT = Path("/run/pyro-shells")
SERVICE_ROOT = Path("/run/pyro-services") 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}$") SERVICE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
SHELL_SIGNAL_MAP = { SHELL_SIGNAL_MAP = {
"HUP": signal.SIGHUP, "HUP": signal.SIGHUP,
@ -42,6 +44,17 @@ _SHELLS: dict[str, "GuestShellSession"] = {}
_SHELLS_LOCK = threading.Lock() _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]: def _read_request(conn: socket.socket) -> dict[str, Any]:
chunks: list[bytes] = [] chunks: list[bytes] = []
while True: while True:
@ -139,6 +152,15 @@ def _service_metadata_path(service_name: str) -> Path:
return SERVICE_ROOT / f"{service_name}.json" 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: def _validate_symlink_target(member_path: PurePosixPath, link_target: str) -> None:
target = link_target.strip() target = link_target.strip()
if target == "": 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]: def _inspect_archive(archive_path: Path) -> tuple[int, int]:
entry_count = 0 entry_count = 0
bytes_written = 0 bytes_written = 0
@ -263,13 +328,22 @@ def _prepare_export_archive(path: str) -> dict[str, Any]:
raise 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() started = time.monotonic()
command_env = os.environ.copy()
if env is not None:
command_env.update(env)
try: try:
proc = subprocess.run( proc = subprocess.run(
["/bin/sh", "-lc", command], ["/bin/sh", "-lc", command],
text=True, text=True,
capture_output=True, capture_output=True,
env=command_env,
timeout=timeout_seconds, timeout=timeout_seconds,
check=False, check=False,
) )
@ -293,6 +367,16 @@ def _set_pty_size(fd: int, rows: int, cols: int) -> None:
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize) 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: class GuestShellSession:
"""In-guest PTY-backed interactive shell session.""" """In-guest PTY-backed interactive shell session."""
@ -304,6 +388,8 @@ class GuestShellSession:
cwd_text: str, cwd_text: str,
cols: int, cols: int,
rows: int, rows: int,
env_overrides: dict[str, str] | None = None,
redact_values: list[str] | None = None,
) -> None: ) -> None:
self.shell_id = shell_id self.shell_id = shell_id
self.cwd = cwd_text self.cwd = cwd_text
@ -316,6 +402,7 @@ class GuestShellSession:
self._lock = threading.RLock() self._lock = threading.RLock()
self._output = "" self._output = ""
self._decoder = codecs.getincrementaldecoder("utf-8")("replace") self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
self._redact_values = list(redact_values or [])
self._metadata_path = SHELL_ROOT / f"{shell_id}.json" self._metadata_path = SHELL_ROOT / f"{shell_id}.json"
self._log_path = SHELL_ROOT / f"{shell_id}.log" self._log_path = SHELL_ROOT / f"{shell_id}.log"
self._master_fd: int | None = None self._master_fd: int | None = None
@ -331,8 +418,10 @@ class GuestShellSession:
"PROMPT_COMMAND": "", "PROMPT_COMMAND": "",
} }
) )
if env_overrides is not None:
env.update(env_overrides)
process = subprocess.Popen( # noqa: S603 process = subprocess.Popen( # noqa: S603
["/bin/bash", "--noprofile", "--norc", "-i"], _shell_argv(interactive=True),
stdin=slave_fd, stdin=slave_fd,
stdout=slave_fd, stdout=slave_fd,
stderr=slave_fd, stderr=slave_fd,
@ -371,8 +460,9 @@ class GuestShellSession:
def read(self, *, cursor: int, max_chars: int) -> dict[str, Any]: def read(self, *, cursor: int, max_chars: int) -> dict[str, Any]:
with self._lock: with self._lock:
clamped_cursor = min(max(cursor, 0), len(self._output)) redacted_output = _redact_text(self._output, self._redact_values)
output = self._output[clamped_cursor : clamped_cursor + max_chars] clamped_cursor = min(max(cursor, 0), len(redacted_output))
output = redacted_output[clamped_cursor : clamped_cursor + max_chars]
next_cursor = clamped_cursor + len(output) next_cursor = clamped_cursor + len(output)
payload = self.summary() payload = self.summary()
payload.update( payload.update(
@ -380,7 +470,7 @@ class GuestShellSession:
"cursor": clamped_cursor, "cursor": clamped_cursor,
"next_cursor": next_cursor, "next_cursor": next_cursor,
"output": output, "output": output,
"truncated": next_cursor < len(self._output), "truncated": next_cursor < len(redacted_output),
} }
) )
return payload return payload
@ -514,6 +604,8 @@ def _create_shell(
cwd_text: str, cwd_text: str,
cols: int, cols: int,
rows: int, rows: int,
env_overrides: dict[str, str] | None = None,
redact_values: list[str] | None = None,
) -> GuestShellSession: ) -> GuestShellSession:
_, cwd_path = _normalize_shell_cwd(cwd_text) _, cwd_path = _normalize_shell_cwd(cwd_text)
with _SHELLS_LOCK: with _SHELLS_LOCK:
@ -525,6 +617,8 @@ def _create_shell(
cwd_text=cwd_text, cwd_text=cwd_text,
cols=cols, cols=cols,
rows=rows, rows=rows,
env_overrides=env_overrides,
redact_values=redact_values,
) )
_SHELLS[shell_id] = session _SHELLS[shell_id] = session
return session return session
@ -634,7 +728,12 @@ def _refresh_service_payload(service_name: str, payload: dict[str, Any]) -> dict
return refreshed 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: if readiness is None:
return True return True
readiness_type = str(readiness["type"]) 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): except (urllib.error.URLError, TimeoutError, ValueError):
return False return False
if readiness_type == "command": if readiness_type == "command":
command_env = os.environ.copy()
if env is not None:
command_env.update(env)
proc = subprocess.run( # noqa: S603 proc = subprocess.run( # noqa: S603
["/bin/sh", "-lc", str(readiness["command"])], ["/bin/sh", "-lc", str(readiness["command"])],
cwd=str(cwd), cwd=str(cwd),
text=True, text=True,
capture_output=True, capture_output=True,
env=command_env,
timeout=10, timeout=10,
check=False, check=False,
) )
@ -678,6 +781,7 @@ def _start_service(
readiness: dict[str, Any] | None, readiness: dict[str, Any] | None,
ready_timeout_seconds: int, ready_timeout_seconds: int,
ready_interval_ms: int, ready_interval_ms: int,
env: dict[str, str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
normalized_service_name = _normalize_service_name(service_name) normalized_service_name = _normalize_service_name(service_name)
normalized_cwd, cwd_path = _normalize_shell_cwd(cwd_text) normalized_cwd, cwd_path = _normalize_shell_cwd(cwd_text)
@ -718,9 +822,13 @@ def _start_service(
encoding="utf-8", encoding="utf-8",
) )
runner_path.chmod(0o700) runner_path.chmod(0o700)
service_env = os.environ.copy()
if env is not None:
service_env.update(env)
process = subprocess.Popen( # noqa: S603 process = subprocess.Popen( # noqa: S603
[str(runner_path)], [str(runner_path)],
cwd=str(cwd_path), cwd=str(cwd_path),
env=service_env,
text=True, text=True,
start_new_session=True, start_new_session=True,
) )
@ -747,7 +855,7 @@ def _start_service(
payload["ended_at"] = payload.get("ended_at") or time.time() payload["ended_at"] = payload.get("ended_at") or time.time()
_write_service_metadata(normalized_service_name, payload) _write_service_metadata(normalized_service_name, payload)
return 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() payload["ready_at"] = time.time()
_write_service_metadata(normalized_service_name, payload) _write_service_metadata(normalized_service_name, payload)
return 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")) destination = str(request.get("destination", "/workspace"))
payload = _read_exact(conn, archive_size) payload = _read_exact(conn, archive_size)
return _extract_archive(payload, destination) 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": if action == "open_shell":
shell_id = str(request.get("shell_id", "")).strip() shell_id = str(request.get("shell_id", "")).strip()
if shell_id == "": if shell_id == "":
raise RuntimeError("shell_id is required") raise RuntimeError("shell_id is required")
cwd_text, _ = _normalize_shell_cwd(str(request.get("cwd", "/workspace"))) 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( session = _create_shell(
shell_id=shell_id, shell_id=shell_id,
cwd_text=cwd_text, cwd_text=cwd_text,
cols=int(request.get("cols", 120)), cols=int(request.get("cols", 120)),
rows=int(request.get("rows", 30)), rows=int(request.get("rows", 30)),
env_overrides=env_overrides,
redact_values=redact_values,
) )
return session.summary() return session.summary()
if action == "read_shell": 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")) cwd_text = str(request.get("cwd", "/workspace"))
readiness = request.get("readiness") readiness = request.get("readiness")
readiness_payload = dict(readiness) if isinstance(readiness, dict) else None 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( return _start_service(
service_name=service_name, service_name=service_name,
command=command, command=command,
@ -873,6 +1012,7 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
readiness=readiness_payload, readiness=readiness_payload,
ready_timeout_seconds=int(request.get("ready_timeout_seconds", 30)), ready_timeout_seconds=int(request.get("ready_timeout_seconds", 30)),
ready_interval_ms=int(request.get("ready_interval_ms", 500)), ready_interval_ms=int(request.get("ready_interval_ms", 500)),
env=env,
) )
if action == "status_service": if action == "status_service":
service_name = str(request.get("service_name", "")).strip() 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) return _stop_service(service_name)
command = str(request.get("command", "")) command = str(request.get("command", ""))
timeout_seconds = int(request.get("timeout_seconds", 30)) 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: def main() -> None:
SHELL_ROOT.mkdir(parents=True, exist_ok=True) SHELL_ROOT.mkdir(parents=True, exist_ok=True)
SERVICE_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) family = getattr(socket, "AF_VSOCK", None)
if family is None: if family is None:
raise SystemExit("AF_VSOCK is unavailable") raise SystemExit("AF_VSOCK is unavailable")

View file

@ -7,7 +7,8 @@ AGENT=/opt/pyro/bin/pyro_guest_agent.py
mount -t proc proc /proc || true mount -t proc proc /proc || true
mount -t sysfs sysfs /sys || true mount -t sysfs sysfs /sys || true
mount -t devtmpfs devtmpfs /dev || 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 hostname pyro-vm || true
cmdline="$(cat /proc/cmdline 2>/dev/null || true)" cmdline="$(cat /proc/cmdline 2>/dev/null || true)"

View file

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

View file

@ -168,6 +168,18 @@ def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> N
print(f"Workspace seed: {mode} from {seed_path}") print(f"Workspace seed: {mode} from {seed_path}")
else: else:
print(f"Workspace seed: {mode}") 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"Execution mode: {str(payload.get('execution_mode', 'pending'))}")
print( print(
f"Resources: {int(payload.get('vcpu_count', 0))} vCPU / " f"Resources: {int(payload.get('vcpu_count', 0))} vCPU / "
@ -671,6 +683,7 @@ def _build_parser() -> argparse.ArgumentParser:
Examples: Examples:
pyro workspace create debian:12 pyro workspace create debian:12
pyro workspace create debian:12 --seed-path ./repo 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 sync push WORKSPACE_ID ./changes
pyro workspace snapshot create WORKSPACE_ID checkpoint pyro workspace snapshot create WORKSPACE_ID checkpoint
pyro workspace reset WORKSPACE_ID --snapshot checkpoint pyro workspace reset WORKSPACE_ID --snapshot checkpoint
@ -724,6 +737,20 @@ def _build_parser() -> argparse.ArgumentParser:
"before the workspace is returned." "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( workspace_create_parser.add_argument(
"--json", "--json",
action="store_true", action="store_true",
@ -736,7 +763,14 @@ def _build_parser() -> argparse.ArgumentParser:
"Run one non-interactive command in the persistent `/workspace` " "Run one non-interactive command in the persistent `/workspace` "
"for a 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, formatter_class=_HelpFormatter,
) )
workspace_exec_parser.add_argument( workspace_exec_parser.add_argument(
@ -750,6 +784,13 @@ def _build_parser() -> argparse.ArgumentParser:
default=30, default=30,
help="Maximum time allowed for the workspace command.", 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( workspace_exec_parser.add_argument(
"--json", "--json",
action="store_true", action="store_true",
@ -1016,6 +1057,13 @@ def _build_parser() -> argparse.ArgumentParser:
default=30, default=30,
help="Shell terminal height in rows.", 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( workspace_shell_open_parser.add_argument(
"--json", "--json",
action="store_true", action="store_true",
@ -1181,6 +1229,9 @@ def _build_parser() -> argparse.ArgumentParser:
Examples: Examples:
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \ pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
sh -lc 'touch .ready && while true; do sleep 60; done' 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' -- \ pyro workspace service start WORKSPACE_ID app --ready-command 'test -f .ready' -- \
sh -lc 'touch .ready && while true; do sleep 60; done' 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, default=DEFAULT_SERVICE_READY_INTERVAL_MS,
help="Polling interval between readiness checks.", 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( workspace_service_start_parser.add_argument(
"--json", "--json",
action="store_true", action="store_true",
@ -1438,6 +1496,38 @@ def _require_command(command_args: list[str]) -> str:
return shlex.join(command_args) 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: def main() -> None:
args = _build_parser().parse_args() args = _build_parser().parse_args()
pyro = Pyro() pyro = Pyro()
@ -1529,6 +1619,16 @@ def main() -> None:
return return
if args.command == "workspace": if args.command == "workspace":
if args.workspace_command == "create": 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( payload = pyro.create_workspace(
environment=args.environment, environment=args.environment,
vcpu_count=args.vcpu_count, vcpu_count=args.vcpu_count,
@ -1537,6 +1637,7 @@ def main() -> None:
network=args.network, network=args.network,
allow_host_compat=args.allow_host_compat, allow_host_compat=args.allow_host_compat,
seed_path=args.seed_path, seed_path=args.seed_path,
secrets=secrets or None,
) )
if bool(args.json): if bool(args.json):
_print_json(payload) _print_json(payload)
@ -1545,12 +1646,14 @@ def main() -> None:
return return
if args.workspace_command == "exec": if args.workspace_command == "exec":
command = _require_command(args.command_args) command = _require_command(args.command_args)
secret_env = _parse_workspace_secret_env_options(getattr(args, "secret_env", []))
if bool(args.json): if bool(args.json):
try: try:
payload = pyro.exec_workspace( payload = pyro.exec_workspace(
args.workspace_id, args.workspace_id,
command=command, command=command,
timeout_seconds=args.timeout_seconds, timeout_seconds=args.timeout_seconds,
secret_env=secret_env or None,
) )
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
_print_json({"ok": False, "error": str(exc)}) _print_json({"ok": False, "error": str(exc)})
@ -1562,6 +1665,7 @@ def main() -> None:
args.workspace_id, args.workspace_id,
command=command, command=command,
timeout_seconds=args.timeout_seconds, timeout_seconds=args.timeout_seconds,
secret_env=secret_env or None,
) )
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
print(f"[error] {exc}", file=sys.stderr, flush=True) print(f"[error] {exc}", file=sys.stderr, flush=True)
@ -1703,12 +1807,14 @@ def main() -> None:
return return
if args.workspace_command == "shell": if args.workspace_command == "shell":
if args.workspace_shell_command == "open": if args.workspace_shell_command == "open":
secret_env = _parse_workspace_secret_env_options(getattr(args, "secret_env", []))
try: try:
payload = pyro.open_shell( payload = pyro.open_shell(
args.workspace_id, args.workspace_id,
cwd=args.cwd, cwd=args.cwd,
cols=args.cols, cols=args.cols,
rows=args.rows, rows=args.rows,
secret_env=secret_env or None,
) )
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
if bool(args.json): if bool(args.json):
@ -1825,6 +1931,7 @@ def main() -> None:
elif args.ready_command is not None: elif args.ready_command is not None:
readiness = {"type": "command", "command": args.ready_command} readiness = {"type": "command", "command": args.ready_command}
command = _require_command(args.command_args) command = _require_command(args.command_args)
secret_env = _parse_workspace_secret_env_options(getattr(args, "secret_env", []))
try: try:
payload = pyro.start_service( payload = pyro.start_service(
args.workspace_id, args.workspace_id,
@ -1834,6 +1941,7 @@ def main() -> None:
readiness=readiness, readiness=readiness,
ready_timeout_seconds=args.ready_timeout_seconds, ready_timeout_seconds=args.ready_timeout_seconds,
ready_interval_ms=args.ready_interval_ms, ready_interval_ms=args.ready_interval_ms,
secret_env=secret_env or None,
) )
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
if bool(args.json): if bool(args.json):

View file

@ -30,8 +30,11 @@ PUBLIC_CLI_WORKSPACE_CREATE_FLAGS = (
"--network", "--network",
"--allow-host-compat", "--allow-host-compat",
"--seed-path", "--seed-path",
"--secret",
"--secret-file",
"--json", "--json",
) )
PUBLIC_CLI_WORKSPACE_EXEC_FLAGS = ("--timeout-seconds", "--secret-env", "--json")
PUBLIC_CLI_WORKSPACE_DIFF_FLAGS = ("--json",) PUBLIC_CLI_WORKSPACE_DIFF_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS = ("--output", "--json") PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS = ("--output", "--json")
PUBLIC_CLI_WORKSPACE_RESET_FLAGS = ("--snapshot", "--json") PUBLIC_CLI_WORKSPACE_RESET_FLAGS = ("--snapshot", "--json")
@ -45,11 +48,18 @@ PUBLIC_CLI_WORKSPACE_SERVICE_START_FLAGS = (
"--ready-command", "--ready-command",
"--ready-timeout-seconds", "--ready-timeout-seconds",
"--ready-interval-ms", "--ready-interval-ms",
"--secret-env",
"--json", "--json",
) )
PUBLIC_CLI_WORKSPACE_SERVICE_STATUS_FLAGS = ("--json",) PUBLIC_CLI_WORKSPACE_SERVICE_STATUS_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_SERVICE_STOP_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_READ_FLAGS = ("--cursor", "--max-chars", "--json")
PUBLIC_CLI_WORKSPACE_SHELL_WRITE_FLAGS = ("--input", "--no-newline", "--json") PUBLIC_CLI_WORKSPACE_SHELL_WRITE_FLAGS = ("--input", "--no-newline", "--json")
PUBLIC_CLI_WORKSPACE_SHELL_SIGNAL_FLAGS = ("--signal", "--json") PUBLIC_CLI_WORKSPACE_SHELL_SIGNAL_FLAGS = ("--signal", "--json")

View file

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

View file

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

View file

@ -10,6 +10,7 @@ import json
import os import os
import re import re
import shlex import shlex
import shutil
import signal import signal
import socket import socket
import struct import struct
@ -29,6 +30,7 @@ BUFFER_SIZE = 65536
WORKSPACE_ROOT = PurePosixPath("/workspace") WORKSPACE_ROOT = PurePosixPath("/workspace")
SHELL_ROOT = Path("/run/pyro-shells") SHELL_ROOT = Path("/run/pyro-shells")
SERVICE_ROOT = Path("/run/pyro-services") 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}$") SERVICE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
SHELL_SIGNAL_MAP = { SHELL_SIGNAL_MAP = {
"HUP": signal.SIGHUP, "HUP": signal.SIGHUP,
@ -42,6 +44,17 @@ _SHELLS: dict[str, "GuestShellSession"] = {}
_SHELLS_LOCK = threading.Lock() _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]: def _read_request(conn: socket.socket) -> dict[str, Any]:
chunks: list[bytes] = [] chunks: list[bytes] = []
while True: while True:
@ -139,6 +152,15 @@ def _service_metadata_path(service_name: str) -> Path:
return SERVICE_ROOT / f"{service_name}.json" 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: def _validate_symlink_target(member_path: PurePosixPath, link_target: str) -> None:
target = link_target.strip() target = link_target.strip()
if target == "": 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]: def _inspect_archive(archive_path: Path) -> tuple[int, int]:
entry_count = 0 entry_count = 0
bytes_written = 0 bytes_written = 0
@ -263,13 +328,22 @@ def _prepare_export_archive(path: str) -> dict[str, Any]:
raise 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() started = time.monotonic()
command_env = os.environ.copy()
if env is not None:
command_env.update(env)
try: try:
proc = subprocess.run( proc = subprocess.run(
["/bin/sh", "-lc", command], ["/bin/sh", "-lc", command],
text=True, text=True,
capture_output=True, capture_output=True,
env=command_env,
timeout=timeout_seconds, timeout=timeout_seconds,
check=False, check=False,
) )
@ -293,6 +367,16 @@ def _set_pty_size(fd: int, rows: int, cols: int) -> None:
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize) 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: class GuestShellSession:
"""In-guest PTY-backed interactive shell session.""" """In-guest PTY-backed interactive shell session."""
@ -304,6 +388,8 @@ class GuestShellSession:
cwd_text: str, cwd_text: str,
cols: int, cols: int,
rows: int, rows: int,
env_overrides: dict[str, str] | None = None,
redact_values: list[str] | None = None,
) -> None: ) -> None:
self.shell_id = shell_id self.shell_id = shell_id
self.cwd = cwd_text self.cwd = cwd_text
@ -316,6 +402,7 @@ class GuestShellSession:
self._lock = threading.RLock() self._lock = threading.RLock()
self._output = "" self._output = ""
self._decoder = codecs.getincrementaldecoder("utf-8")("replace") self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
self._redact_values = list(redact_values or [])
self._metadata_path = SHELL_ROOT / f"{shell_id}.json" self._metadata_path = SHELL_ROOT / f"{shell_id}.json"
self._log_path = SHELL_ROOT / f"{shell_id}.log" self._log_path = SHELL_ROOT / f"{shell_id}.log"
self._master_fd: int | None = None self._master_fd: int | None = None
@ -331,8 +418,10 @@ class GuestShellSession:
"PROMPT_COMMAND": "", "PROMPT_COMMAND": "",
} }
) )
if env_overrides is not None:
env.update(env_overrides)
process = subprocess.Popen( # noqa: S603 process = subprocess.Popen( # noqa: S603
["/bin/bash", "--noprofile", "--norc", "-i"], _shell_argv(interactive=True),
stdin=slave_fd, stdin=slave_fd,
stdout=slave_fd, stdout=slave_fd,
stderr=slave_fd, stderr=slave_fd,
@ -371,8 +460,9 @@ class GuestShellSession:
def read(self, *, cursor: int, max_chars: int) -> dict[str, Any]: def read(self, *, cursor: int, max_chars: int) -> dict[str, Any]:
with self._lock: with self._lock:
clamped_cursor = min(max(cursor, 0), len(self._output)) redacted_output = _redact_text(self._output, self._redact_values)
output = self._output[clamped_cursor : clamped_cursor + max_chars] clamped_cursor = min(max(cursor, 0), len(redacted_output))
output = redacted_output[clamped_cursor : clamped_cursor + max_chars]
next_cursor = clamped_cursor + len(output) next_cursor = clamped_cursor + len(output)
payload = self.summary() payload = self.summary()
payload.update( payload.update(
@ -380,7 +470,7 @@ class GuestShellSession:
"cursor": clamped_cursor, "cursor": clamped_cursor,
"next_cursor": next_cursor, "next_cursor": next_cursor,
"output": output, "output": output,
"truncated": next_cursor < len(self._output), "truncated": next_cursor < len(redacted_output),
} }
) )
return payload return payload
@ -514,6 +604,8 @@ def _create_shell(
cwd_text: str, cwd_text: str,
cols: int, cols: int,
rows: int, rows: int,
env_overrides: dict[str, str] | None = None,
redact_values: list[str] | None = None,
) -> GuestShellSession: ) -> GuestShellSession:
_, cwd_path = _normalize_shell_cwd(cwd_text) _, cwd_path = _normalize_shell_cwd(cwd_text)
with _SHELLS_LOCK: with _SHELLS_LOCK:
@ -525,6 +617,8 @@ def _create_shell(
cwd_text=cwd_text, cwd_text=cwd_text,
cols=cols, cols=cols,
rows=rows, rows=rows,
env_overrides=env_overrides,
redact_values=redact_values,
) )
_SHELLS[shell_id] = session _SHELLS[shell_id] = session
return session return session
@ -634,7 +728,12 @@ def _refresh_service_payload(service_name: str, payload: dict[str, Any]) -> dict
return refreshed 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: if readiness is None:
return True return True
readiness_type = str(readiness["type"]) 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): except (urllib.error.URLError, TimeoutError, ValueError):
return False return False
if readiness_type == "command": if readiness_type == "command":
command_env = os.environ.copy()
if env is not None:
command_env.update(env)
proc = subprocess.run( # noqa: S603 proc = subprocess.run( # noqa: S603
["/bin/sh", "-lc", str(readiness["command"])], ["/bin/sh", "-lc", str(readiness["command"])],
cwd=str(cwd), cwd=str(cwd),
text=True, text=True,
capture_output=True, capture_output=True,
env=command_env,
timeout=10, timeout=10,
check=False, check=False,
) )
@ -678,6 +781,7 @@ def _start_service(
readiness: dict[str, Any] | None, readiness: dict[str, Any] | None,
ready_timeout_seconds: int, ready_timeout_seconds: int,
ready_interval_ms: int, ready_interval_ms: int,
env: dict[str, str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
normalized_service_name = _normalize_service_name(service_name) normalized_service_name = _normalize_service_name(service_name)
normalized_cwd, cwd_path = _normalize_shell_cwd(cwd_text) normalized_cwd, cwd_path = _normalize_shell_cwd(cwd_text)
@ -718,9 +822,13 @@ def _start_service(
encoding="utf-8", encoding="utf-8",
) )
runner_path.chmod(0o700) runner_path.chmod(0o700)
service_env = os.environ.copy()
if env is not None:
service_env.update(env)
process = subprocess.Popen( # noqa: S603 process = subprocess.Popen( # noqa: S603
[str(runner_path)], [str(runner_path)],
cwd=str(cwd_path), cwd=str(cwd_path),
env=service_env,
text=True, text=True,
start_new_session=True, start_new_session=True,
) )
@ -747,7 +855,7 @@ def _start_service(
payload["ended_at"] = payload.get("ended_at") or time.time() payload["ended_at"] = payload.get("ended_at") or time.time()
_write_service_metadata(normalized_service_name, payload) _write_service_metadata(normalized_service_name, payload)
return 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() payload["ready_at"] = time.time()
_write_service_metadata(normalized_service_name, payload) _write_service_metadata(normalized_service_name, payload)
return 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")) destination = str(request.get("destination", "/workspace"))
payload = _read_exact(conn, archive_size) payload = _read_exact(conn, archive_size)
return _extract_archive(payload, destination) 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": if action == "open_shell":
shell_id = str(request.get("shell_id", "")).strip() shell_id = str(request.get("shell_id", "")).strip()
if shell_id == "": if shell_id == "":
raise RuntimeError("shell_id is required") raise RuntimeError("shell_id is required")
cwd_text, _ = _normalize_shell_cwd(str(request.get("cwd", "/workspace"))) 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( session = _create_shell(
shell_id=shell_id, shell_id=shell_id,
cwd_text=cwd_text, cwd_text=cwd_text,
cols=int(request.get("cols", 120)), cols=int(request.get("cols", 120)),
rows=int(request.get("rows", 30)), rows=int(request.get("rows", 30)),
env_overrides=env_overrides,
redact_values=redact_values,
) )
return session.summary() return session.summary()
if action == "read_shell": 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")) cwd_text = str(request.get("cwd", "/workspace"))
readiness = request.get("readiness") readiness = request.get("readiness")
readiness_payload = dict(readiness) if isinstance(readiness, dict) else None 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( return _start_service(
service_name=service_name, service_name=service_name,
command=command, command=command,
@ -873,6 +1012,7 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
readiness=readiness_payload, readiness=readiness_payload,
ready_timeout_seconds=int(request.get("ready_timeout_seconds", 30)), ready_timeout_seconds=int(request.get("ready_timeout_seconds", 30)),
ready_interval_ms=int(request.get("ready_interval_ms", 500)), ready_interval_ms=int(request.get("ready_interval_ms", 500)),
env=env,
) )
if action == "status_service": if action == "status_service":
service_name = str(request.get("service_name", "")).strip() 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) return _stop_service(service_name)
command = str(request.get("command", "")) command = str(request.get("command", ""))
timeout_seconds = int(request.get("timeout_seconds", 30)) 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: def main() -> None:
SHELL_ROOT.mkdir(parents=True, exist_ok=True) SHELL_ROOT.mkdir(parents=True, exist_ok=True)
SERVICE_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) family = getattr(socket, "AF_VSOCK", None)
if family is None: if family is None:
raise SystemExit("AF_VSOCK is unavailable") raise SystemExit("AF_VSOCK is unavailable")

View file

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

View file

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

View file

@ -80,6 +80,7 @@ class VsockExecClient:
command: str, command: str,
timeout_seconds: int, timeout_seconds: int,
*, *,
env: dict[str, str] | None = None,
uds_path: str | None = None, uds_path: str | None = None,
) -> GuestExecResponse: ) -> GuestExecResponse:
payload = self._request_json( payload = self._request_json(
@ -88,6 +89,7 @@ class VsockExecClient:
{ {
"command": command, "command": command,
"timeout_seconds": timeout_seconds, "timeout_seconds": timeout_seconds,
"env": env,
}, },
timeout_seconds=timeout_seconds, timeout_seconds=timeout_seconds,
uds_path=uds_path, uds_path=uds_path,
@ -136,6 +138,40 @@ class VsockExecClient:
bytes_written=int(payload.get("bytes_written", 0)), 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( def export_archive(
self, self,
guest_cid: int, guest_cid: int,
@ -191,6 +227,8 @@ class VsockExecClient:
cwd: str, cwd: str,
cols: int, cols: int,
rows: int, rows: int,
env: dict[str, str] | None = None,
redact_values: list[str] | None = None,
timeout_seconds: int = 30, timeout_seconds: int = 30,
uds_path: str | None = None, uds_path: str | None = None,
) -> GuestShellSummary: ) -> GuestShellSummary:
@ -203,6 +241,8 @@ class VsockExecClient:
"cwd": cwd, "cwd": cwd,
"cols": cols, "cols": cols,
"rows": rows, "rows": rows,
"env": env,
"redact_values": redact_values,
}, },
timeout_seconds=timeout_seconds, timeout_seconds=timeout_seconds,
uds_path=uds_path, uds_path=uds_path,
@ -336,6 +376,7 @@ class VsockExecClient:
readiness: dict[str, Any] | None, readiness: dict[str, Any] | None,
ready_timeout_seconds: int, ready_timeout_seconds: int,
ready_interval_ms: int, ready_interval_ms: int,
env: dict[str, str] | None = None,
timeout_seconds: int = 60, timeout_seconds: int = 60,
uds_path: str | None = None, uds_path: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
@ -350,6 +391,7 @@ class VsockExecClient:
"readiness": readiness, "readiness": readiness,
"ready_timeout_seconds": ready_timeout_seconds, "ready_timeout_seconds": ready_timeout_seconds,
"ready_interval_ms": ready_interval_ms, "ready_interval_ms": ready_interval_ms,
"env": env,
}, },
timeout_seconds=timeout_seconds, timeout_seconds=timeout_seconds,
uds_path=uds_path, uds_path=uds_path,

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import time
from pathlib import Path from pathlib import Path
from typing import Any, cast 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 = tmp_path / "seed"
source_dir.mkdir() source_dir.mkdir()
(source_dir / "note.txt").write_text("ok\n", encoding="utf-8") (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( created = pyro.create_workspace(
environment="debian:12-base", environment="debian:12-base",
allow_host_compat=True, allow_host_compat=True,
seed_path=source_dir, seed_path=source_dir,
secrets=[
{"name": "API_TOKEN", "value": "expected"},
{"name": "FILE_TOKEN", "file_path": str(secret_file)},
],
) )
workspace_id = str(created["workspace_id"]) workspace_id = str(created["workspace_id"])
updated_dir = tmp_path / "updated" updated_dir = tmp_path / "updated"
updated_dir.mkdir() updated_dir.mkdir()
(updated_dir / "more.txt").write_text("more\n", encoding="utf-8") (updated_dir / "more.txt").write_text("more\n", encoding="utf-8")
synced = pyro.push_workspace_sync(workspace_id, updated_dir, dest="subdir") 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) diff_payload = pyro.diff_workspace(workspace_id)
snapshot = pyro.create_snapshot(workspace_id, "checkpoint") snapshot = pyro.create_snapshot(workspace_id, "checkpoint")
snapshots = pyro.list_snapshots(workspace_id) snapshots = pyro.list_snapshots(workspace_id)
export_path = tmp_path / "exported-note.txt" export_path = tmp_path / "exported-note.txt"
exported = pyro.export_workspace(workspace_id, "note.txt", output_path=export_path) 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( service = pyro.start_service(
workspace_id, workspace_id,
"app", "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"}, readiness={"type": "file", "path": ".ready"},
secret_env={"API_TOKEN": "API_TOKEN"},
) )
services = pyro.list_services(workspace_id) services = pyro.list_services(workspace_id)
service_status = pyro.status_service(workspace_id, "app") service_status = pyro.status_service(workspace_id, "app")
service_logs = pyro.logs_service(workspace_id, "app", all=True) 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") reset = pyro.reset_workspace(workspace_id, snapshot="checkpoint")
deleted_snapshot = pyro.delete_snapshot(workspace_id, "checkpoint") deleted_snapshot = pyro.delete_snapshot(workspace_id, "checkpoint")
status = pyro.status_workspace(workspace_id) status = pyro.status_workspace(workspace_id)
logs = pyro.logs_workspace(workspace_id) logs = pyro.logs_workspace(workspace_id)
deleted = pyro.delete_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 created["workspace_seed"]["mode"] == "directory"
assert synced["workspace_sync"]["destination"] == "/workspace/subdir" assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
assert diff_payload["changed"] is True 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 snapshots["count"] == 2
assert exported["output_path"] == str(export_path) assert exported["output_path"] == str(export_path)
assert export_path.read_text(encoding="utf-8") == "ok\n" 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 service["state"] == "running"
assert services["count"] == 1 assert services["count"] == 1
assert service_status["state"] == "running" assert service_status["state"] == "running"
assert service_logs["stderr"].count("[REDACTED]") >= 1
assert service_logs["tail_lines"] is None assert service_logs["tail_lines"] is None
assert service_stopped["state"] == "stopped"
assert reset["workspace_reset"]["snapshot_name"] == "checkpoint" assert reset["workspace_reset"]["snapshot_name"] == "checkpoint"
assert reset["secrets"] == created["secrets"]
assert deleted_snapshot["deleted"] is True assert deleted_snapshot["deleted"] is True
assert status["command_count"] == 0 assert status["command_count"] == 0
assert status["service_count"] == 0 assert status["service_count"] == 0

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import sys import sys
from pathlib import Path
from typing import Any, cast from typing import Any, cast
import pytest import pytest
@ -75,12 +76,15 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
"create", "create",
).format_help() ).format_help()
assert "--seed-path" in workspace_create_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 assert "seed into `/workspace`" in workspace_create_help
workspace_exec_help = _subparser_choice( workspace_exec_help = _subparser_choice(
_subparser_choice(parser, "workspace"), _subparser_choice(parser, "workspace"),
"exec", "exec",
).format_help() ).format_help()
assert "--secret-env" in workspace_exec_help
assert "persistent `/workspace`" in workspace_exec_help assert "persistent `/workspace`" in workspace_exec_help
assert "pyro workspace exec WORKSPACE_ID -- cat note.txt" 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-tcp" in workspace_service_start_help
assert "--ready-http" in workspace_service_start_help assert "--ready-http" in workspace_service_start_help
assert "--ready-command" 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( workspace_service_logs_help = _subparser_choice(
_subparser_choice(_subparser_choice(parser, "workspace"), "service"), "logs" _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 "--cwd" in workspace_shell_open_help
assert "--cols" in workspace_shell_open_help assert "--cols" in workspace_shell_open_help
assert "--rows" 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( workspace_shell_read_help = _subparser_choice(
_subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "read" _subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "read"
@ -550,10 +556,12 @@ def test_cli_workspace_exec_prints_human_output(
*, *,
command: str, command: str,
timeout_seconds: int, timeout_seconds: int,
secret_env: dict[str, str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
assert workspace_id == "workspace-123" assert workspace_id == "workspace-123"
assert command == "cat note.txt" assert command == "cat note.txt"
assert timeout_seconds == 30 assert timeout_seconds == 30
assert secret_env is None
return { return {
"workspace_id": workspace_id, "workspace_id": workspace_id,
"sequence": 2, "sequence": 2,
@ -572,6 +580,7 @@ def test_cli_workspace_exec_prints_human_output(
workspace_command="exec", workspace_command="exec",
workspace_id="workspace-123", workspace_id="workspace-123",
timeout_seconds=30, timeout_seconds=30,
secret_env=[],
json=False, json=False,
command_args=["--", "cat", "note.txt"], command_args=["--", "cat", "note.txt"],
) )
@ -1322,11 +1331,17 @@ def test_cli_workspace_exec_prints_json_and_exits_nonzero(
) -> None: ) -> None:
class StubPyro: class StubPyro:
def exec_workspace( 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]: ) -> dict[str, Any]:
assert workspace_id == "workspace-123" assert workspace_id == "workspace-123"
assert command == "false" assert command == "false"
assert timeout_seconds == 30 assert timeout_seconds == 30
assert secret_env is None
return { return {
"workspace_id": workspace_id, "workspace_id": workspace_id,
"sequence": 1, "sequence": 1,
@ -1345,6 +1360,7 @@ def test_cli_workspace_exec_prints_json_and_exits_nonzero(
workspace_command="exec", workspace_command="exec",
workspace_id="workspace-123", workspace_id="workspace-123",
timeout_seconds=30, timeout_seconds=30,
secret_env=[],
json=True, json=True,
command_args=["--", "false"], command_args=["--", "false"],
) )
@ -1363,9 +1379,15 @@ def test_cli_workspace_exec_prints_human_error(
) -> None: ) -> None:
class StubPyro: class StubPyro:
def exec_workspace( 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]: ) -> dict[str, Any]:
del workspace_id, command, timeout_seconds del workspace_id, command, timeout_seconds
assert secret_env is None
raise RuntimeError("exec boom") raise RuntimeError("exec boom")
class ExecParser: class ExecParser:
@ -1375,6 +1397,7 @@ def test_cli_workspace_exec_prints_human_error(
workspace_command="exec", workspace_command="exec",
workspace_id="workspace-123", workspace_id="workspace-123",
timeout_seconds=30, timeout_seconds=30,
secret_env=[],
json=False, json=False,
command_args=["--", "cat", "note.txt"], command_args=["--", "cat", "note.txt"],
) )
@ -1538,11 +1561,13 @@ def test_cli_workspace_shell_open_and_read_human(
cwd: str, cwd: str,
cols: int, cols: int,
rows: int, rows: int,
secret_env: dict[str, str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
assert workspace_id == "workspace-123" assert workspace_id == "workspace-123"
assert cwd == "/workspace" assert cwd == "/workspace"
assert cols == 120 assert cols == 120
assert rows == 30 assert rows == 30
assert secret_env is None
return { return {
"workspace_id": workspace_id, "workspace_id": workspace_id,
"shell_id": "shell-123", "shell_id": "shell-123",
@ -1595,6 +1620,7 @@ def test_cli_workspace_shell_open_and_read_human(
cwd="/workspace", cwd="/workspace",
cols=120, cols=120,
rows=30, rows=30,
secret_env=[],
json=False, json=False,
) )
@ -1758,7 +1784,9 @@ def test_cli_workspace_shell_open_and_read_json(
cwd: str, cwd: str,
cols: int, cols: int,
rows: int, rows: int,
secret_env: dict[str, str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
assert secret_env is None
return { return {
"workspace_id": workspace_id, "workspace_id": workspace_id,
"shell_id": "shell-123", "shell_id": "shell-123",
@ -1807,6 +1835,7 @@ def test_cli_workspace_shell_open_and_read_json(
cwd="/workspace", cwd="/workspace",
cols=120, cols=120,
rows=30, rows=30,
secret_env=[],
json=True, json=True,
) )
@ -2798,3 +2827,210 @@ def test_cli_demo_ollama_verbose_and_error_paths(
with pytest.raises(SystemExit, match="1"): with pytest.raises(SystemExit, match="1"):
cli.main() cli.main()
assert "[error] tool loop failed" in capsys.readouterr().out 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

View file

@ -19,6 +19,7 @@ from pyro_mcp.contract import (
PUBLIC_CLI_RUN_FLAGS, PUBLIC_CLI_RUN_FLAGS,
PUBLIC_CLI_WORKSPACE_CREATE_FLAGS, PUBLIC_CLI_WORKSPACE_CREATE_FLAGS,
PUBLIC_CLI_WORKSPACE_DIFF_FLAGS, PUBLIC_CLI_WORKSPACE_DIFF_FLAGS,
PUBLIC_CLI_WORKSPACE_EXEC_FLAGS,
PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS, PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS,
PUBLIC_CLI_WORKSPACE_RESET_FLAGS, PUBLIC_CLI_WORKSPACE_RESET_FLAGS,
PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS, PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS,
@ -94,6 +95,11 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
).format_help() ).format_help()
for flag in PUBLIC_CLI_WORKSPACE_CREATE_FLAGS: for flag in PUBLIC_CLI_WORKSPACE_CREATE_FLAGS:
assert flag in workspace_create_help_text 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( workspace_sync_help_text = _subparser_choice(
_subparser_choice(parser, "workspace"), _subparser_choice(parser, "workspace"),
"sync", "sync",

View file

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import shutil
from pathlib import Path from pathlib import Path
import pytest import pytest
@ -14,6 +15,8 @@ def test_resolve_runtime_paths_default_bundle() -> None:
assert paths.jailer_bin.exists() assert paths.jailer_bin.exists()
assert paths.guest_agent_path is not None assert paths.guest_agent_path is not None
assert paths.guest_agent_path.exists() 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.artifacts_dir.exists()
assert paths.manifest.get("platform") == "linux-x86_64" 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 guest_agent_path = source.guest_agent_path
if guest_agent_path is None: if guest_agent_path is None:
raise AssertionError("expected guest agent in runtime bundle") 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 = copied_platform / "guest"
copied_guest_dir.mkdir(parents=True, exist_ok=True) copied_guest_dir.mkdir(parents=True, exist_ok=True)
(copied_guest_dir / "pyro_guest_agent.py").write_text( (copied_guest_dir / "pyro_guest_agent.py").write_text(
guest_agent_path.read_text(encoding="utf-8"), guest_agent_path.read_text(encoding="utf-8"),
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)) monkeypatch.setenv("PYRO_RUNTIME_BUNDLE_DIR", str(copied_bundle))
with pytest.raises(RuntimeError, match="checksum mismatch"): with pytest.raises(RuntimeError, match="checksum mismatch"):
resolve_runtime_paths() 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: def test_doctor_report_has_runtime_fields() -> None:
report = doctor_report() report = doctor_report()
assert "runtime_ok" in report assert "runtime_ok" in report
@ -72,6 +114,7 @@ def test_doctor_report_has_runtime_fields() -> None:
assert isinstance(runtime, dict) assert isinstance(runtime, dict)
assert "firecracker_bin" in runtime assert "firecracker_bin" in runtime
assert "guest_agent_path" in runtime assert "guest_agent_path" in runtime
assert "guest_init_path" in runtime
assert "component_versions" in runtime assert "component_versions" in runtime
assert "environments" in runtime assert "environments" in runtime
networking = report["networking"] networking = report["networking"]

View file

@ -191,6 +191,8 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
source_dir = tmp_path / "seed" source_dir = tmp_path / "seed"
source_dir.mkdir() source_dir.mkdir()
(source_dir / "note.txt").write_text("ok\n", encoding="utf-8") (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]: def _extract_structured(raw_result: object) -> dict[str, Any]:
if not isinstance(raw_result, tuple) or len(raw_result) != 2: 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", "environment": "debian:12-base",
"allow_host_compat": True, "allow_host_compat": True,
"seed_path": str(source_dir), "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_exec",
{ {
"workspace_id": workspace_id, "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, "workspace_id": workspace_id,
"service_name": "app", "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", "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()) ) = asyncio.run(_run())
assert created["state"] == "started" assert created["state"] == "started"
assert created["workspace_seed"]["mode"] == "directory" 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 synced["workspace_sync"]["destination"] == "/workspace/subdir"
assert executed["stdout"] == "more\n" assert executed["stdout"] == "[REDACTED]\n"
assert diffed["changed"] is True assert diffed["changed"] is True
assert snapshot["snapshot"]["snapshot_name"] == "checkpoint" assert snapshot["snapshot"]["snapshot_name"] == "checkpoint"
assert [entry["snapshot_name"] for entry in snapshots["snapshots"]] == [ 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 service["state"] == "running"
assert services["count"] == 1 assert services["count"] == 1
assert service_status["state"] == "running" assert service_status["state"] == "running"
assert service_logs["stderr"].count("[REDACTED]") >= 1
assert service_logs["tail_lines"] is None assert service_logs["tail_lines"] is None
assert service_stopped["state"] == "stopped" assert service_stopped["state"] == "stopped"
assert reset["workspace_reset"]["snapshot_name"] == "checkpoint" assert reset["workspace_reset"]["snapshot_name"] == "checkpoint"
assert reset["secrets"] == created["secrets"]
assert reset["command_count"] == 0 assert reset["command_count"] == 0
assert reset["service_count"] == 0 assert reset["service_count"] == 0
assert deleted_snapshot["deleted"] is True assert deleted_snapshot["deleted"] is True

View file

@ -42,6 +42,7 @@ def _fake_runtime_paths(tmp_path: Path) -> RuntimePaths:
firecracker_bin = bundle_root / "bin" / "firecracker" firecracker_bin = bundle_root / "bin" / "firecracker"
jailer_bin = bundle_root / "bin" / "jailer" jailer_bin = bundle_root / "bin" / "jailer"
guest_agent_path = bundle_root / "guest" / "pyro_guest_agent.py" guest_agent_path = bundle_root / "guest" / "pyro_guest_agent.py"
guest_init_path = bundle_root / "guest" / "pyro-init"
artifacts_dir = bundle_root / "profiles" artifacts_dir = bundle_root / "profiles"
notice_path = bundle_parent / "NOTICE" 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") firecracker_bin.write_text("firecracker\n", encoding="utf-8")
jailer_bin.write_text("jailer\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_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") notice_path.write_text("notice\n", encoding="utf-8")
return RuntimePaths( return RuntimePaths(
@ -62,6 +64,7 @@ def _fake_runtime_paths(tmp_path: Path) -> RuntimePaths:
firecracker_bin=firecracker_bin, firecracker_bin=firecracker_bin,
jailer_bin=jailer_bin, jailer_bin=jailer_bin,
guest_agent_path=guest_agent_path, guest_agent_path=guest_agent_path,
guest_init_path=guest_init_path,
artifacts_dir=artifacts_dir, artifacts_dir=artifacts_dir,
notice_path=notice_path, notice_path=notice_path,
manifest={"platform": "linux-x86_64"}, manifest={"platform": "linux-x86_64"},

View file

@ -57,12 +57,13 @@ def test_vsock_exec_client_round_trip(monkeypatch: pytest.MonkeyPatch) -> None:
return stub return stub
client = VsockExecClient(socket_factory=socket_factory) 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.exit_code == 0
assert response.stdout == "ok\n" assert response.stdout == "ok\n"
assert stub.connected == (1234, 5005) assert stub.connected == (1234, 5005)
assert b'"command": "echo ok"' in stub.sent assert b'"command": "echo ok"' in stub.sent
assert b'"env": {"TOKEN": "expected"}' in stub.sent
assert stub.closed is True assert stub.closed is True
@ -105,6 +106,39 @@ def test_vsock_exec_client_upload_archive_round_trip(
assert stub.closed is True 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( def test_vsock_exec_client_export_archive_round_trip(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None: ) -> None:
@ -241,6 +275,8 @@ def test_vsock_exec_client_shell_round_trip(monkeypatch: pytest.MonkeyPatch) ->
cwd="/workspace", cwd="/workspace",
cols=120, cols=120,
rows=30, rows=30,
env={"TOKEN": "expected"},
redact_values=["expected"],
) )
assert opened.shell_id == "shell-1" assert opened.shell_id == "shell-1"
read = client.read_shell(1234, 5005, shell_id="shell-1", cursor=0, max_chars=1024) 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()) open_request = json.loads(stubs[0].sent.decode("utf-8").strip())
assert open_request["action"] == "open_shell" assert open_request["action"] == "open_shell"
assert open_request["shell_id"] == "shell-1" 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: 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"}, readiness={"type": "file", "path": "/workspace/.ready"},
ready_timeout_seconds=30, ready_timeout_seconds=30,
ready_interval_ms=500, ready_interval_ms=500,
env={"TOKEN": "expected"},
) )
assert started["service_name"] == "app" assert started["service_name"] == "app"
status = client.status_service(1234, 5005, 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()) start_request = json.loads(stubs[0].sent.decode("utf-8").strip())
assert start_request["action"] == "start_service" assert start_request["action"] == "start_service"
assert start_request["service_name"] == "app" assert start_request["service_name"] == "app"
assert start_request["env"] == {"TOKEN": "expected"}
def test_vsock_exec_client_raises_agent_error(monkeypatch: pytest.MonkeyPatch) -> None: def test_vsock_exec_client_raises_agent_error(monkeypatch: pytest.MonkeyPatch) -> None:

View file

@ -8,7 +8,7 @@ import subprocess
import tarfile import tarfile
import time import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, cast
import pytest import pytest
@ -1713,3 +1713,212 @@ def test_workspace_service_probe_and_refresh_helpers(
) )
assert stopped.state == "stopped" assert stopped.state == "stopped"
assert stopped.stop_reason == "sigterm" 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"}],
)

2
uv.lock generated
View file

@ -706,7 +706,7 @@ crypto = [
[[package]] [[package]]
name = "pyro-mcp" name = "pyro-mcp"
version = "2.8.0" version = "2.9.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "mcp" }, { name = "mcp" },