Add guest-only workspace secrets
Add explicit workspace secrets across the CLI, SDK, and MCP, with create-time secret definitions and per-call secret-to-env mapping for exec, shell open, and service start. Persist only safe secret metadata in workspace records, materialize secret files under /run/pyro-secrets, and redact secret values from exec output, shell reads, service logs, and surfaced errors. Fix the remaining real-guest shell gap by shipping bundled guest init alongside the guest agent and patching both into guest-backed workspace rootfs images before boot. The new init mounts devpts so PTY shells work on Firecracker guests, while reset continues to recreate the sandbox and re-materialize secrets from stored task-local secret material. Validation: uv lock; UV_CACHE_DIR=.uv-cache make check; UV_CACHE_DIR=.uv-cache make dist-check; and a real guest-backed Firecracker smoke covering workspace create with secrets, secret-backed exec, shell, service, reset, and delete.
This commit is contained in:
parent
18b8fd2a7d
commit
fc72fcd3a1
32 changed files with 1980 additions and 181 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -2,6 +2,17 @@
|
|||
|
||||
All notable user-visible changes to `pyro-mcp` are documented here.
|
||||
|
||||
## 2.9.0
|
||||
|
||||
- Added explicit workspace secrets across the CLI, Python SDK, and MCP server with
|
||||
`pyro workspace create --secret/--secret-file`, `Pyro.create_workspace(..., secrets=...)`, and
|
||||
the matching `workspace_create` MCP inputs.
|
||||
- Added per-call secret-to-environment mapping for `workspace exec`, `workspace shell open`, and
|
||||
`workspace service start`, with secret values redacted from command output, shell reads, service
|
||||
logs, and persisted workspace logs.
|
||||
- Kept secret-backed workspaces guest-only and fail-closed while re-materializing persisted secret
|
||||
files outside `/workspace` across workspace creation and reset.
|
||||
|
||||
## 2.8.0
|
||||
|
||||
- Added explicit named workspace snapshots across the CLI, Python SDK, and MCP server with
|
||||
|
|
|
|||
40
README.md
40
README.md
|
|
@ -20,7 +20,7 @@ It exposes the same runtime in three public forms:
|
|||
- First run transcript: [docs/first-run.md](docs/first-run.md)
|
||||
- Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif)
|
||||
- PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/)
|
||||
- What's new in 2.8.0: [CHANGELOG.md#280](CHANGELOG.md#280)
|
||||
- What's new in 2.9.0: [CHANGELOG.md#290](CHANGELOG.md#290)
|
||||
- Host requirements: [docs/host-requirements.md](docs/host-requirements.md)
|
||||
- Integration targets: [docs/integrations.md](docs/integrations.md)
|
||||
- Public contract: [docs/public-contract.md](docs/public-contract.md)
|
||||
|
|
@ -57,7 +57,7 @@ What success looks like:
|
|||
```bash
|
||||
Platform: linux-x86_64
|
||||
Runtime: PASS
|
||||
Catalog version: 2.8.0
|
||||
Catalog version: 2.9.0
|
||||
...
|
||||
[pull] phase=install environment=debian:12
|
||||
[pull] phase=ready environment=debian:12
|
||||
|
|
@ -78,6 +78,8 @@ After the quickstart works:
|
|||
- prove the full one-shot lifecycle with `uvx --from pyro-mcp pyro demo`
|
||||
- create a persistent workspace with `uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo`
|
||||
- update a live workspace from the host with `uvx --from pyro-mcp pyro workspace sync push WORKSPACE_ID ./changes`
|
||||
- add literal or file-backed secrets with `uvx --from pyro-mcp pyro workspace create debian:12 --secret API_TOKEN=expected --secret-file PIP_TOKEN=./token.txt`
|
||||
- map one persisted secret into one exec, shell, or service call with `--secret-env API_TOKEN`
|
||||
- diff the live workspace against its create-time baseline with `uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID`
|
||||
- capture a checkpoint with `uvx --from pyro-mcp pyro workspace snapshot create WORKSPACE_ID checkpoint`
|
||||
- reset a broken workspace with `uvx --from pyro-mcp pyro workspace reset WORKSPACE_ID --snapshot checkpoint`
|
||||
|
|
@ -137,7 +139,7 @@ uvx --from pyro-mcp pyro env list
|
|||
Expected output:
|
||||
|
||||
```bash
|
||||
Catalog version: 2.8.0
|
||||
Catalog version: 2.9.0
|
||||
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
|
||||
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
|
||||
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
|
||||
|
|
@ -213,18 +215,20 @@ longer-term interaction model.
|
|||
|
||||
```bash
|
||||
pyro workspace create debian:12 --seed-path ./repo
|
||||
pyro workspace create debian:12 --seed-path ./repo --secret API_TOKEN=expected
|
||||
pyro workspace sync push WORKSPACE_ID ./changes --dest src
|
||||
pyro workspace exec WORKSPACE_ID -- cat src/note.txt
|
||||
pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"'
|
||||
pyro workspace diff WORKSPACE_ID
|
||||
pyro workspace snapshot create WORKSPACE_ID checkpoint
|
||||
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
|
||||
pyro workspace reset WORKSPACE_ID
|
||||
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
|
||||
pyro workspace shell open WORKSPACE_ID
|
||||
pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN
|
||||
pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'
|
||||
pyro workspace shell read WORKSPACE_ID SHELL_ID
|
||||
pyro workspace shell close WORKSPACE_ID SHELL_ID
|
||||
pyro workspace service start WORKSPACE_ID web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
|
||||
pyro workspace service start WORKSPACE_ID web --secret-env API_TOKEN --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
|
||||
pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done'
|
||||
pyro workspace service list WORKSPACE_ID
|
||||
pyro workspace service status WORKSPACE_ID web
|
||||
|
|
@ -239,7 +243,7 @@ Persistent workspaces start in `/workspace` and keep command history until you d
|
|||
machine consumption, add `--json` and read the returned `workspace_id`. Use `--seed-path` when
|
||||
you want the workspace to start from a host directory or a local `.tar` / `.tar.gz` / `.tgz`
|
||||
archive instead of an empty workspace. Use `pyro workspace sync push` when you want to import
|
||||
later host-side changes into a started workspace. Sync is non-atomic in `2.8.0`; if it fails
|
||||
later host-side changes into a started workspace. Sync is non-atomic in `2.9.0`; if it fails
|
||||
partway through, prefer `pyro workspace reset` to recover from `baseline` or one named snapshot.
|
||||
Use `pyro workspace diff` to compare the live `/workspace` tree to its immutable create-time
|
||||
baseline, and `pyro workspace export` to copy one changed file or directory back to the host. Use
|
||||
|
|
@ -251,6 +255,10 @@ persistent PTY session that keeps interactive shell state between calls. Use
|
|||
Typed readiness checks prefer `--ready-file`, `--ready-tcp`, or `--ready-http`; keep
|
||||
`--ready-command` as the escape hatch. Service metadata and logs live outside `/workspace`, so the
|
||||
internal service state does not appear in `pyro workspace diff` or `pyro workspace export`.
|
||||
Use `--secret` and `--secret-file` at workspace creation when the sandbox needs private tokens or
|
||||
config. Persisted secrets are materialized inside the guest at `/run/pyro-secrets/<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
|
||||
|
||||
|
|
@ -422,9 +430,25 @@ Advanced lifecycle tools:
|
|||
|
||||
Persistent workspace tools:
|
||||
|
||||
- `workspace_create(environment, vcpu_count=1, mem_mib=1024, ttl_seconds=600, network=false, allow_host_compat=false, seed_path=null)`
|
||||
- `workspace_create(environment, vcpu_count=1, mem_mib=1024, ttl_seconds=600, network=false, allow_host_compat=false, seed_path=null, secrets=null)`
|
||||
- `workspace_sync_push(workspace_id, source_path, dest="/workspace")`
|
||||
- `workspace_exec(workspace_id, command, timeout_seconds=30)`
|
||||
- `workspace_exec(workspace_id, command, timeout_seconds=30, secret_env=null)`
|
||||
- `workspace_export(workspace_id, path, output_path)`
|
||||
- `workspace_diff(workspace_id)`
|
||||
- `snapshot_create(workspace_id, snapshot_name)`
|
||||
- `snapshot_list(workspace_id)`
|
||||
- `snapshot_delete(workspace_id, snapshot_name)`
|
||||
- `workspace_reset(workspace_id, snapshot="baseline")`
|
||||
- `service_start(workspace_id, service_name, command, cwd="/workspace", readiness=null, ready_timeout_seconds=30, ready_interval_ms=500, secret_env=null)`
|
||||
- `service_list(workspace_id)`
|
||||
- `service_status(workspace_id, service_name)`
|
||||
- `service_logs(workspace_id, service_name, tail_lines=200)`
|
||||
- `service_stop(workspace_id, service_name)`
|
||||
- `shell_open(workspace_id, cwd="/workspace", cols=120, rows=30, secret_env=null)`
|
||||
- `shell_read(workspace_id, shell_id, cursor=0, max_chars=65536)`
|
||||
- `shell_write(workspace_id, shell_id, input, append_newline=true)`
|
||||
- `shell_signal(workspace_id, shell_id, signal_name="INT")`
|
||||
- `shell_close(workspace_id, shell_id)`
|
||||
- `workspace_status(workspace_id)`
|
||||
- `workspace_logs(workspace_id)`
|
||||
- `workspace_delete(workspace_id)`
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ Networking: tun=yes ip_forward=yes
|
|||
|
||||
```bash
|
||||
$ uvx --from pyro-mcp pyro env list
|
||||
Catalog version: 2.8.0
|
||||
Catalog version: 2.9.0
|
||||
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
|
||||
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
|
||||
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
|
||||
|
|
@ -72,12 +72,14 @@ deterministic structured result.
|
|||
$ uvx --from pyro-mcp pyro demo
|
||||
$ uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo
|
||||
$ uvx --from pyro-mcp pyro workspace sync push WORKSPACE_ID ./changes
|
||||
$ uvx --from pyro-mcp pyro workspace create debian:12 --secret API_TOKEN=expected --secret-file PIP_TOKEN=./token.txt
|
||||
$ uvx --from pyro-mcp pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"'
|
||||
$ uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID
|
||||
$ uvx --from pyro-mcp pyro workspace snapshot create WORKSPACE_ID checkpoint
|
||||
$ uvx --from pyro-mcp pyro workspace reset WORKSPACE_ID --snapshot checkpoint
|
||||
$ uvx --from pyro-mcp pyro workspace export WORKSPACE_ID note.txt --output ./note.txt
|
||||
$ uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID
|
||||
$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||
$ uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN
|
||||
$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --secret-env API_TOKEN --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||
$ uvx --from pyro-mcp pyro mcp serve
|
||||
```
|
||||
|
||||
|
|
@ -103,6 +105,9 @@ $ uvx --from pyro-mcp pyro workspace exec WORKSPACE_ID -- cat src/note.txt
|
|||
hello from synced workspace
|
||||
[workspace-exec] workspace_id=... sequence=1 cwd=/workspace execution_mode=guest_vsock exit_code=0 duration_ms=...
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"'
|
||||
[workspace-exec] workspace_id=... sequence=2 cwd=/workspace execution_mode=guest_vsock exit_code=0 duration_ms=...
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID
|
||||
[workspace-diff] workspace_id=... total=... added=... modified=... deleted=... type_changed=... text_patched=... non_text=...
|
||||
--- a/src/note.txt
|
||||
|
|
@ -123,7 +128,7 @@ Reset count: 1
|
|||
$ uvx --from pyro-mcp pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
|
||||
[workspace-export] workspace_id=... workspace_path=/workspace/src/note.txt output_path=... artifact_type=file entry_count=... bytes_written=... execution_mode=guest_vsock
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID
|
||||
$ uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN
|
||||
[workspace-shell-open] workspace_id=... shell_id=... state=running cwd=/workspace cols=120 rows=30 execution_mode=guest_vsock
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'
|
||||
|
|
@ -133,7 +138,7 @@ $ uvx --from pyro-mcp pyro workspace shell read WORKSPACE_ID SHELL_ID
|
|||
/workspace
|
||||
[workspace-shell-read] workspace_id=... shell_id=... state=running cursor=0 next_cursor=... truncated=False execution_mode=guest_vsock
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
|
||||
$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID web --secret-env API_TOKEN --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
|
||||
[workspace-service-start] workspace_id=... service=web state=running cwd=/workspace ready_type=file execution_mode=guest_vsock
|
||||
|
||||
$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done'
|
||||
|
|
@ -170,7 +175,7 @@ $ uvx --from pyro-mcp pyro workspace service stop WORKSPACE_ID worker
|
|||
Use `--seed-path` when the workspace should start from a host directory or a local
|
||||
`.tar` / `.tar.gz` / `.tgz` archive instead of an empty `/workspace`. Use
|
||||
`pyro workspace sync push` when you need to import later host-side changes into a started
|
||||
workspace. Sync is non-atomic in `2.8.0`; if it fails partway through, prefer `pyro workspace reset`
|
||||
workspace. Sync is non-atomic in `2.9.0`; if it fails partway through, prefer `pyro workspace reset`
|
||||
to recover from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the current
|
||||
`/workspace` tree to its immutable create-time baseline, `pyro workspace snapshot *` to create
|
||||
named checkpoints, and `pyro workspace export` to copy one changed file or directory back to the
|
||||
|
|
@ -178,7 +183,10 @@ host. Use `pyro workspace exec` for one-shot commands and `pyro workspace shell
|
|||
need a persistent interactive PTY session in that same workspace. Use `pyro workspace service *`
|
||||
when the workspace needs long-running background processes with typed readiness checks. Internal
|
||||
service state and logs stay outside `/workspace`, so service runtime data does not appear in
|
||||
workspace diff or export results.
|
||||
workspace diff or export results. Use `--secret` and `--secret-file` at workspace creation when
|
||||
the sandbox needs private tokens or config. Persisted secret files are materialized at
|
||||
`/run/pyro-secrets/<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:
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ uvx --from pyro-mcp pyro env list
|
|||
Expected output:
|
||||
|
||||
```bash
|
||||
Catalog version: 2.8.0
|
||||
Catalog version: 2.9.0
|
||||
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
|
||||
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
|
||||
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
|
||||
|
|
@ -176,6 +176,7 @@ After the CLI path works, you can move on to:
|
|||
|
||||
- persistent workspaces: `pyro workspace create debian:12 --seed-path ./repo`
|
||||
- live workspace updates: `pyro workspace sync push WORKSPACE_ID ./changes`
|
||||
- workspace secrets: `pyro workspace create debian:12 --secret API_TOKEN=expected --secret-file PIP_TOKEN=./token.txt`
|
||||
- baseline diff: `pyro workspace diff WORKSPACE_ID`
|
||||
- snapshots and reset: `pyro workspace snapshot create WORKSPACE_ID checkpoint` and `pyro workspace reset WORKSPACE_ID --snapshot checkpoint`
|
||||
- host export: `pyro workspace export WORKSPACE_ID note.txt --output ./note.txt`
|
||||
|
|
@ -191,18 +192,20 @@ Use `pyro workspace ...` when you need repeated commands in one sandbox instead
|
|||
|
||||
```bash
|
||||
pyro workspace create debian:12 --seed-path ./repo
|
||||
pyro workspace create debian:12 --seed-path ./repo --secret API_TOKEN=expected
|
||||
pyro workspace sync push WORKSPACE_ID ./changes --dest src
|
||||
pyro workspace exec WORKSPACE_ID -- cat src/note.txt
|
||||
pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"'
|
||||
pyro workspace diff WORKSPACE_ID
|
||||
pyro workspace snapshot create WORKSPACE_ID checkpoint
|
||||
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
|
||||
pyro workspace reset WORKSPACE_ID
|
||||
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
|
||||
pyro workspace shell open WORKSPACE_ID
|
||||
pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN
|
||||
pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'
|
||||
pyro workspace shell read WORKSPACE_ID SHELL_ID
|
||||
pyro workspace shell close WORKSPACE_ID SHELL_ID
|
||||
pyro workspace service start WORKSPACE_ID web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
|
||||
pyro workspace service start WORKSPACE_ID web --secret-env API_TOKEN --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
|
||||
pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done'
|
||||
pyro workspace service list WORKSPACE_ID
|
||||
pyro workspace service status WORKSPACE_ID web
|
||||
|
|
@ -217,7 +220,7 @@ Workspace commands default to the persistent `/workspace` directory inside the g
|
|||
the identifier programmatically, use `--json` and read the `workspace_id` field. Use `--seed-path`
|
||||
when the workspace should start from a host directory or a local `.tar` / `.tar.gz` / `.tgz`
|
||||
archive. Use `pyro workspace sync push` for later host-side changes to a started workspace. Sync
|
||||
is non-atomic in `2.8.0`; if it fails partway through, prefer `pyro workspace reset` to recover
|
||||
is non-atomic in `2.9.0`; if it fails partway through, prefer `pyro workspace reset` to recover
|
||||
from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the current workspace
|
||||
tree to its immutable create-time baseline, `pyro workspace snapshot *` to capture named
|
||||
checkpoints, and `pyro workspace export` to copy one changed file or directory back to the host. Use
|
||||
|
|
@ -225,7 +228,10 @@ checkpoints, and `pyro workspace export` to copy one changed file or directory b
|
|||
interactive PTY that survives across separate calls. Use `pyro workspace service *` when the
|
||||
workspace needs long-running background processes with typed readiness probes. Service metadata and
|
||||
logs stay outside `/workspace`, so the service runtime itself does not show up in workspace diff or
|
||||
export results.
|
||||
export results. Use `--secret` and `--secret-file` at workspace creation when the sandbox needs
|
||||
private tokens or config, and `--secret-env SECRET_NAME[=ENV_VAR]` when one exec, shell, or
|
||||
service call needs that secret as an environment variable. Persisted secret files are available in
|
||||
the guest at `/run/pyro-secrets/<name>`.
|
||||
|
||||
## Contributor Clone
|
||||
|
||||
|
|
|
|||
|
|
@ -31,9 +31,10 @@ Recommended surface:
|
|||
|
||||
- `vm_run`
|
||||
- `workspace_create(seed_path=...)` + `workspace_sync_push` + `workspace_exec` when the agent needs persistent workspace state
|
||||
- `workspace_create(..., secrets=...)` + `workspace_exec(..., secret_env=...)` when the workspace needs private tokens or authenticated setup
|
||||
- `workspace_diff` + `workspace_export` when the agent needs explicit baseline comparison or host-out file transfer
|
||||
- `start_service` / `list_services` / `status_service` / `logs_service` / `stop_service` when the agent needs long-running processes inside that workspace
|
||||
- `open_shell` / `read_shell` / `write_shell` when the agent needs an interactive PTY inside that workspace
|
||||
- `open_shell(..., secret_env=...)` / `read_shell` / `write_shell` when the agent needs an interactive PTY inside that workspace
|
||||
|
||||
Canonical example:
|
||||
|
||||
|
|
@ -69,9 +70,10 @@ Recommended default:
|
|||
|
||||
- `Pyro.run_in_vm(...)`
|
||||
- `Pyro.create_workspace(seed_path=...)` + `Pyro.push_workspace_sync(...)` + `Pyro.exec_workspace(...)` when repeated workspace commands are required
|
||||
- `Pyro.create_workspace(..., secrets=...)` + `Pyro.exec_workspace(..., secret_env=...)` when the workspace needs private tokens or authenticated setup
|
||||
- `Pyro.diff_workspace(...)` + `Pyro.export_workspace(...)` when the agent needs baseline comparison or host-out file transfer
|
||||
- `Pyro.start_service(...)` + `Pyro.list_services(...)` + `Pyro.logs_service(...)` when the agent needs long-running background processes in one workspace
|
||||
- `Pyro.open_shell(...)` + `Pyro.write_shell(...)` + `Pyro.read_shell(...)` when the agent needs an interactive PTY inside the workspace
|
||||
- `Pyro.start_service(..., secret_env=...)` + `Pyro.list_services(...)` + `Pyro.logs_service(...)` when the agent needs long-running background processes in one workspace
|
||||
- `Pyro.open_shell(..., secret_env=...)` + `Pyro.write_shell(...)` + `Pyro.read_shell(...)` when the agent needs an interactive PTY inside the workspace
|
||||
|
||||
Lifecycle note:
|
||||
|
||||
|
|
@ -82,6 +84,8 @@ Lifecycle note:
|
|||
`/workspace` that starts from host content
|
||||
- use `push_workspace_sync(...)` when later host-side changes need to be imported into that
|
||||
running workspace without recreating it
|
||||
- use `create_workspace(..., secrets=...)` plus `secret_env` on exec, shell, or service start when
|
||||
the agent needs private tokens or authenticated startup inside that workspace
|
||||
- use `diff_workspace(...)` when the agent needs a structured comparison against the immutable
|
||||
create-time baseline
|
||||
- use `export_workspace(...)` when the agent needs one file or directory copied back to the host
|
||||
|
|
|
|||
|
|
@ -64,17 +64,22 @@ Behavioral guarantees:
|
|||
- `pyro demo ollama` prints log lines plus a final summary line.
|
||||
- `pyro workspace create` auto-starts a persistent workspace.
|
||||
- `pyro workspace create --seed-path PATH` seeds `/workspace` from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive before the workspace is returned.
|
||||
- `pyro workspace create --secret NAME=VALUE` and `--secret-file NAME=PATH` persist guest-only UTF-8 secrets outside `/workspace`.
|
||||
- `pyro workspace sync push WORKSPACE_ID SOURCE_PATH [--dest WORKSPACE_PATH]` imports later host-side directory or archive content into a started workspace.
|
||||
- `pyro workspace export WORKSPACE_ID PATH --output HOST_PATH` exports one file or directory from `/workspace` back to the host.
|
||||
- `pyro workspace diff WORKSPACE_ID` compares the current `/workspace` tree to the immutable create-time baseline.
|
||||
- `pyro workspace snapshot *` manages explicit named snapshots in addition to the implicit `baseline`.
|
||||
- `pyro workspace reset WORKSPACE_ID [--snapshot SNAPSHOT_NAME|baseline]` recreates the full sandbox and restores `/workspace` from the chosen snapshot.
|
||||
- `pyro workspace service *` manages long-running named services inside one started workspace with typed readiness probes.
|
||||
- `pyro workspace exec --secret-env SECRET_NAME[=ENV_VAR]` maps one persisted secret into one exec call.
|
||||
- `pyro workspace service start --secret-env SECRET_NAME[=ENV_VAR]` maps one persisted secret into one service start call.
|
||||
- `pyro workspace exec` runs in the persistent `/workspace` for that workspace and does not auto-clean.
|
||||
- `pyro workspace shell open --secret-env SECRET_NAME[=ENV_VAR]` maps one persisted secret into the opened shell environment.
|
||||
- `pyro workspace shell *` manages persistent PTY sessions inside a started workspace.
|
||||
- `pyro workspace logs` returns persisted command history for that workspace until `pyro workspace delete`.
|
||||
- Workspace create/status results expose `workspace_seed` metadata describing how `/workspace` was initialized.
|
||||
- Workspace create/status/reset results expose `reset_count` and `last_reset_at`.
|
||||
- Workspace create/status/reset results expose safe `secrets` metadata with each secret name and source kind, but never the secret values.
|
||||
- `pyro workspace status` includes aggregate `service_count` and `running_service_count` fields.
|
||||
|
||||
## Python SDK Contract
|
||||
|
|
@ -92,7 +97,7 @@ Supported public entrypoints:
|
|||
- `Pyro.inspect_environment(environment)`
|
||||
- `Pyro.prune_environments()`
|
||||
- `Pyro.create_vm(...)`
|
||||
- `Pyro.create_workspace(...)`
|
||||
- `Pyro.create_workspace(..., secrets=None)`
|
||||
- `Pyro.push_workspace_sync(workspace_id, source_path, *, dest="/workspace")`
|
||||
- `Pyro.export_workspace(workspace_id, path, *, output_path)`
|
||||
- `Pyro.diff_workspace(workspace_id)`
|
||||
|
|
@ -100,19 +105,19 @@ Supported public entrypoints:
|
|||
- `Pyro.list_snapshots(workspace_id)`
|
||||
- `Pyro.delete_snapshot(workspace_id, snapshot_name)`
|
||||
- `Pyro.reset_workspace(workspace_id, *, snapshot="baseline")`
|
||||
- `Pyro.start_service(workspace_id, service_name, *, command, cwd="/workspace", readiness=None, ready_timeout_seconds=30, ready_interval_ms=500)`
|
||||
- `Pyro.start_service(workspace_id, service_name, *, command, cwd="/workspace", readiness=None, ready_timeout_seconds=30, ready_interval_ms=500, secret_env=None)`
|
||||
- `Pyro.list_services(workspace_id)`
|
||||
- `Pyro.status_service(workspace_id, service_name)`
|
||||
- `Pyro.logs_service(workspace_id, service_name, *, tail_lines=200, all=False)`
|
||||
- `Pyro.stop_service(workspace_id, service_name)`
|
||||
- `Pyro.open_shell(workspace_id, *, cwd="/workspace", cols=120, rows=30)`
|
||||
- `Pyro.open_shell(workspace_id, *, cwd="/workspace", cols=120, rows=30, secret_env=None)`
|
||||
- `Pyro.read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536)`
|
||||
- `Pyro.write_shell(workspace_id, shell_id, *, input, append_newline=True)`
|
||||
- `Pyro.signal_shell(workspace_id, shell_id, *, signal_name="INT")`
|
||||
- `Pyro.close_shell(workspace_id, shell_id)`
|
||||
- `Pyro.start_vm(vm_id)`
|
||||
- `Pyro.exec_vm(vm_id, *, command, timeout_seconds=30)`
|
||||
- `Pyro.exec_workspace(workspace_id, *, command, timeout_seconds=30)`
|
||||
- `Pyro.exec_workspace(workspace_id, *, command, timeout_seconds=30, secret_env=None)`
|
||||
- `Pyro.stop_vm(vm_id)`
|
||||
- `Pyro.delete_vm(vm_id)`
|
||||
- `Pyro.delete_workspace(workspace_id)`
|
||||
|
|
@ -131,7 +136,7 @@ Stable public method names:
|
|||
- `inspect_environment(environment)`
|
||||
- `prune_environments()`
|
||||
- `create_vm(...)`
|
||||
- `create_workspace(...)`
|
||||
- `create_workspace(..., secrets=None)`
|
||||
- `push_workspace_sync(workspace_id, source_path, *, dest="/workspace")`
|
||||
- `export_workspace(workspace_id, path, *, output_path)`
|
||||
- `diff_workspace(workspace_id)`
|
||||
|
|
@ -139,19 +144,19 @@ Stable public method names:
|
|||
- `list_snapshots(workspace_id)`
|
||||
- `delete_snapshot(workspace_id, snapshot_name)`
|
||||
- `reset_workspace(workspace_id, *, snapshot="baseline")`
|
||||
- `start_service(workspace_id, service_name, *, command, cwd="/workspace", readiness=None, ready_timeout_seconds=30, ready_interval_ms=500)`
|
||||
- `start_service(workspace_id, service_name, *, command, cwd="/workspace", readiness=None, ready_timeout_seconds=30, ready_interval_ms=500, secret_env=None)`
|
||||
- `list_services(workspace_id)`
|
||||
- `status_service(workspace_id, service_name)`
|
||||
- `logs_service(workspace_id, service_name, *, tail_lines=200, all=False)`
|
||||
- `stop_service(workspace_id, service_name)`
|
||||
- `open_shell(workspace_id, *, cwd="/workspace", cols=120, rows=30)`
|
||||
- `open_shell(workspace_id, *, cwd="/workspace", cols=120, rows=30, secret_env=None)`
|
||||
- `read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536)`
|
||||
- `write_shell(workspace_id, shell_id, *, input, append_newline=True)`
|
||||
- `signal_shell(workspace_id, shell_id, *, signal_name="INT")`
|
||||
- `close_shell(workspace_id, shell_id)`
|
||||
- `start_vm(vm_id)`
|
||||
- `exec_vm(vm_id, *, command, timeout_seconds=30)`
|
||||
- `exec_workspace(workspace_id, *, command, timeout_seconds=30)`
|
||||
- `exec_workspace(workspace_id, *, command, timeout_seconds=30, secret_env=None)`
|
||||
- `stop_vm(vm_id)`
|
||||
- `delete_vm(vm_id)`
|
||||
- `delete_workspace(workspace_id)`
|
||||
|
|
@ -169,6 +174,7 @@ Behavioral defaults:
|
|||
- `allow_host_compat` defaults to `False` on `create_vm(...)` and `run_in_vm(...)`.
|
||||
- `allow_host_compat` defaults to `False` on `create_workspace(...)`.
|
||||
- `Pyro.create_workspace(..., seed_path=...)` seeds `/workspace` from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive before the workspace is returned.
|
||||
- `Pyro.create_workspace(..., secrets=...)` persists guest-only UTF-8 secrets outside `/workspace`.
|
||||
- `Pyro.push_workspace_sync(...)` imports later host-side directory or archive content into a started workspace.
|
||||
- `Pyro.export_workspace(...)` exports one file or directory from `/workspace` to an explicit host path.
|
||||
- `Pyro.diff_workspace(...)` compares the current `/workspace` tree to the immutable create-time baseline.
|
||||
|
|
@ -176,10 +182,13 @@ Behavioral defaults:
|
|||
- `Pyro.list_snapshots(...)` lists the implicit `baseline` plus any named snapshots.
|
||||
- `Pyro.delete_snapshot(...)` deletes one named snapshot while leaving `baseline` intact.
|
||||
- `Pyro.reset_workspace(...)` recreates the full sandbox from `baseline` or one named snapshot and clears command, shell, and service history.
|
||||
- `Pyro.start_service(..., secret_env=...)` maps persisted workspace secrets into that service process as environment variables for that start call only.
|
||||
- `Pyro.start_service(...)` starts one named long-running process in a started workspace and waits for its typed readiness probe when configured.
|
||||
- `Pyro.list_services(...)`, `Pyro.status_service(...)`, `Pyro.logs_service(...)`, and `Pyro.stop_service(...)` manage those persisted workspace services.
|
||||
- `Pyro.exec_vm(...)` runs one command and auto-cleans that VM after the exec completes.
|
||||
- `Pyro.exec_workspace(..., secret_env=...)` maps persisted workspace secrets into that exec call as environment variables for that call only.
|
||||
- `Pyro.exec_workspace(...)` runs one command in the persistent workspace and leaves it alive.
|
||||
- `Pyro.open_shell(..., secret_env=...)` maps persisted workspace secrets into the shell environment when that shell opens.
|
||||
- `Pyro.open_shell(...)` opens a persistent PTY shell attached to one started workspace.
|
||||
- `Pyro.read_shell(...)` reads merged text output from that shell by cursor.
|
||||
- `Pyro.write_shell(...)`, `Pyro.signal_shell(...)`, and `Pyro.close_shell(...)` operate on that persistent shell session.
|
||||
|
|
@ -234,6 +243,7 @@ Behavioral defaults:
|
|||
- `vm_run` and `vm_create` expose `allow_host_compat`, which defaults to `false`.
|
||||
- `workspace_create` exposes `allow_host_compat`, which defaults to `false`.
|
||||
- `workspace_create` accepts optional `seed_path` and seeds `/workspace` from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive before the workspace is returned.
|
||||
- `workspace_create` accepts optional `secrets` and persists guest-only UTF-8 secret material outside `/workspace`.
|
||||
- `workspace_sync_push` imports later host-side directory or archive content into a started workspace, with an optional `dest` under `/workspace`.
|
||||
- `workspace_export` exports one file or directory from `/workspace` to an explicit host path.
|
||||
- `workspace_diff` compares the current `/workspace` tree to the immutable create-time baseline.
|
||||
|
|
@ -241,7 +251,9 @@ Behavioral defaults:
|
|||
- `workspace_reset` recreates the full sandbox and restores `/workspace` from `baseline` or one named snapshot.
|
||||
- `service_start`, `service_list`, `service_status`, `service_logs`, and `service_stop` manage persistent named services inside a started workspace.
|
||||
- `vm_exec` runs one command and auto-cleans that VM after the exec completes.
|
||||
- `workspace_exec` runs one command in a persistent `/workspace` and leaves the workspace alive.
|
||||
- `workspace_exec` accepts optional `secret_env` mappings for one exec call and leaves the workspace alive.
|
||||
- `service_start` accepts optional `secret_env` mappings for one service start call.
|
||||
- `shell_open` accepts optional `secret_env` mappings for the opened shell session.
|
||||
- `shell_open`, `shell_read`, `shell_write`, `shell_signal`, and `shell_close` manage persistent PTY shells inside a started workspace.
|
||||
|
||||
## Versioning Rule
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
This roadmap turns the agent-workspace vision into release-sized milestones.
|
||||
|
||||
Current baseline is `2.8.0`:
|
||||
Current baseline is `2.9.0`:
|
||||
|
||||
- workspace persistence exists and the public surface is now workspace-first
|
||||
- host crossing currently covers create-time seeding, later sync push, and explicit export
|
||||
|
|
@ -10,7 +10,8 @@ Current baseline is `2.8.0`:
|
|||
- immutable create-time baselines now power whole-workspace diff
|
||||
- multi-service lifecycle exists with typed readiness and aggregate workspace status counts
|
||||
- named snapshots and full workspace reset now exist
|
||||
- no secrets or explicit host port publication contract exists yet
|
||||
- explicit secrets now exist for guest-backed workspaces
|
||||
- no explicit host port publication contract exists yet
|
||||
|
||||
Locked roadmap decisions:
|
||||
|
||||
|
|
@ -34,7 +35,7 @@ also expected to update:
|
|||
3. [`2.6.0` Structured Export And Baseline Diff](task-workspace-ga/2.6.0-structured-export-and-baseline-diff.md) - Done
|
||||
4. [`2.7.0` Service Lifecycle And Typed Readiness](task-workspace-ga/2.7.0-service-lifecycle-and-typed-readiness.md) - Done
|
||||
5. [`2.8.0` Named Snapshots And Reset](task-workspace-ga/2.8.0-named-snapshots-and-reset.md) - Done
|
||||
6. [`2.9.0` Secrets](task-workspace-ga/2.9.0-secrets.md)
|
||||
6. [`2.9.0` Secrets](task-workspace-ga/2.9.0-secrets.md) - Done
|
||||
7. [`2.10.0` Network Policy And Host Port Publication](task-workspace-ga/2.10.0-network-policy-and-host-port-publication.md)
|
||||
8. [`3.0.0` Stable Workspace Product](task-workspace-ga/3.0.0-stable-workspace-product.md)
|
||||
9. [`3.1.0` Secondary Disk Tools](task-workspace-ga/3.1.0-secondary-disk-tools.md)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# `2.9.0` Secrets
|
||||
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
Add explicit secrets so workspaces can handle private dependencies,
|
||||
|
|
|
|||
|
|
@ -12,15 +12,31 @@ def main() -> None:
|
|||
tempfile.TemporaryDirectory(prefix="pyro-workspace-seed-") as seed_dir,
|
||||
tempfile.TemporaryDirectory(prefix="pyro-workspace-sync-") as sync_dir,
|
||||
tempfile.TemporaryDirectory(prefix="pyro-workspace-export-") as export_dir,
|
||||
tempfile.TemporaryDirectory(prefix="pyro-workspace-secret-") as secret_dir,
|
||||
):
|
||||
Path(seed_dir, "note.txt").write_text("hello from seed\n", encoding="utf-8")
|
||||
Path(sync_dir, "note.txt").write_text("hello from sync\n", encoding="utf-8")
|
||||
created = pyro.create_workspace(environment="debian:12", seed_path=seed_dir)
|
||||
secret_file = Path(secret_dir, "token.txt")
|
||||
secret_file.write_text("from-file\n", encoding="utf-8")
|
||||
created = pyro.create_workspace(
|
||||
environment="debian:12",
|
||||
seed_path=seed_dir,
|
||||
secrets=[
|
||||
{"name": "API_TOKEN", "value": "expected"},
|
||||
{"name": "FILE_TOKEN", "file_path": str(secret_file)},
|
||||
],
|
||||
)
|
||||
workspace_id = str(created["workspace_id"])
|
||||
try:
|
||||
pyro.push_workspace_sync(workspace_id, sync_dir)
|
||||
result = pyro.exec_workspace(workspace_id, command="cat note.txt")
|
||||
print(result["stdout"], end="")
|
||||
secret_result = pyro.exec_workspace(
|
||||
workspace_id,
|
||||
command='sh -lc \'printf "%s\\n" "$API_TOKEN"\'',
|
||||
secret_env={"API_TOKEN": "API_TOKEN"},
|
||||
)
|
||||
print(secret_result["stdout"], end="")
|
||||
diff_result = pyro.diff_workspace(workspace_id)
|
||||
print(f"changed={diff_result['changed']} total={diff_result['summary']['total']}")
|
||||
snapshot = pyro.create_snapshot(workspace_id, "checkpoint")
|
||||
|
|
@ -28,11 +44,22 @@ def main() -> None:
|
|||
exported_path = Path(export_dir, "note.txt")
|
||||
pyro.export_workspace(workspace_id, "note.txt", output_path=exported_path)
|
||||
print(exported_path.read_text(encoding="utf-8"), end="")
|
||||
shell = pyro.open_shell(workspace_id, secret_env={"API_TOKEN": "API_TOKEN"})
|
||||
shell_id = str(shell["shell_id"])
|
||||
pyro.write_shell(
|
||||
workspace_id,
|
||||
shell_id,
|
||||
input='printf "%s\\n" "$API_TOKEN"',
|
||||
)
|
||||
shell_output = pyro.read_shell(workspace_id, shell_id, cursor=0)
|
||||
print(f"shell_output_len={len(shell_output['output'])}")
|
||||
pyro.close_shell(workspace_id, shell_id)
|
||||
pyro.start_service(
|
||||
workspace_id,
|
||||
"web",
|
||||
command="touch .web-ready && while true; do sleep 60; done",
|
||||
readiness={"type": "file", "path": ".web-ready"},
|
||||
secret_env={"API_TOKEN": "API_TOKEN"},
|
||||
)
|
||||
services = pyro.list_services(workspace_id)
|
||||
print(f"services={services['count']} running={services['running_count']}")
|
||||
|
|
@ -43,6 +70,7 @@ def main() -> None:
|
|||
pyro.stop_service(workspace_id, "web")
|
||||
reset = pyro.reset_workspace(workspace_id, snapshot="checkpoint")
|
||||
print(f"reset_count={reset['reset_count']}")
|
||||
print(f"secret_count={len(reset['secrets'])}")
|
||||
logs = pyro.logs_workspace(workspace_id)
|
||||
print(f"workspace_id={workspace_id} command_count={logs['count']}")
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "pyro-mcp"
|
||||
version = "2.8.0"
|
||||
version = "2.9.0"
|
||||
description = "Ephemeral Firecracker sandboxes with curated environments, persistent workspaces, and MCP tools."
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import json
|
|||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import struct
|
||||
|
|
@ -29,6 +30,7 @@ BUFFER_SIZE = 65536
|
|||
WORKSPACE_ROOT = PurePosixPath("/workspace")
|
||||
SHELL_ROOT = Path("/run/pyro-shells")
|
||||
SERVICE_ROOT = Path("/run/pyro-services")
|
||||
SECRET_ROOT = Path("/run/pyro-secrets")
|
||||
SERVICE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
SHELL_SIGNAL_MAP = {
|
||||
"HUP": signal.SIGHUP,
|
||||
|
|
@ -42,6 +44,17 @@ _SHELLS: dict[str, "GuestShellSession"] = {}
|
|||
_SHELLS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _redact_text(text: str, redact_values: list[str]) -> str:
|
||||
redacted = text
|
||||
for secret_value in sorted(
|
||||
{item for item in redact_values if item != ""},
|
||||
key=len,
|
||||
reverse=True,
|
||||
):
|
||||
redacted = redacted.replace(secret_value, "[REDACTED]")
|
||||
return redacted
|
||||
|
||||
|
||||
def _read_request(conn: socket.socket) -> dict[str, Any]:
|
||||
chunks: list[bytes] = []
|
||||
while True:
|
||||
|
|
@ -139,6 +152,15 @@ def _service_metadata_path(service_name: str) -> Path:
|
|||
return SERVICE_ROOT / f"{service_name}.json"
|
||||
|
||||
|
||||
def _normalize_secret_name(secret_name: str) -> str:
|
||||
normalized = secret_name.strip()
|
||||
if normalized == "":
|
||||
raise RuntimeError("secret name is required")
|
||||
if re.fullmatch(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$", normalized) is None:
|
||||
raise RuntimeError("secret name is invalid")
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_symlink_target(member_path: PurePosixPath, link_target: str) -> None:
|
||||
target = link_target.strip()
|
||||
if target == "":
|
||||
|
|
@ -215,6 +237,49 @@ def _extract_archive(payload: bytes, destination: str) -> dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
def _install_secrets_archive(payload: bytes) -> dict[str, Any]:
|
||||
SECRET_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
for existing in SECRET_ROOT.iterdir():
|
||||
if existing.is_dir() and not existing.is_symlink():
|
||||
shutil.rmtree(existing, ignore_errors=True)
|
||||
else:
|
||||
existing.unlink(missing_ok=True)
|
||||
bytes_written = 0
|
||||
entry_count = 0
|
||||
with tarfile.open(fileobj=io.BytesIO(payload), mode="r:*") as archive:
|
||||
for member in archive.getmembers():
|
||||
member_name = _normalize_member_name(member.name)
|
||||
target_path = SECRET_ROOT.joinpath(*member_name.parts)
|
||||
entry_count += 1
|
||||
if member.isdir():
|
||||
target_path.mkdir(parents=True, exist_ok=True)
|
||||
target_path.chmod(0o700)
|
||||
continue
|
||||
if member.isfile():
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.parent.chmod(0o700)
|
||||
source = archive.extractfile(member)
|
||||
if source is None:
|
||||
raise RuntimeError(f"failed to read secret archive member: {member.name}")
|
||||
with target_path.open("wb") as handle:
|
||||
while True:
|
||||
chunk = source.read(BUFFER_SIZE)
|
||||
if chunk == b"":
|
||||
break
|
||||
handle.write(chunk)
|
||||
target_path.chmod(0o600)
|
||||
bytes_written += member.size
|
||||
continue
|
||||
if member.issym() or member.islnk():
|
||||
raise RuntimeError(f"secret archive may not contain links: {member.name}")
|
||||
raise RuntimeError(f"unsupported secret archive member type: {member.name}")
|
||||
return {
|
||||
"destination": str(SECRET_ROOT),
|
||||
"entry_count": entry_count,
|
||||
"bytes_written": bytes_written,
|
||||
}
|
||||
|
||||
|
||||
def _inspect_archive(archive_path: Path) -> tuple[int, int]:
|
||||
entry_count = 0
|
||||
bytes_written = 0
|
||||
|
|
@ -263,13 +328,22 @@ def _prepare_export_archive(path: str) -> dict[str, Any]:
|
|||
raise
|
||||
|
||||
|
||||
def _run_command(command: str, timeout_seconds: int) -> dict[str, Any]:
|
||||
def _run_command(
|
||||
command: str,
|
||||
timeout_seconds: int,
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
started = time.monotonic()
|
||||
command_env = os.environ.copy()
|
||||
if env is not None:
|
||||
command_env.update(env)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["/bin/sh", "-lc", command],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=command_env,
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
|
|
@ -293,6 +367,16 @@ def _set_pty_size(fd: int, rows: int, cols: int) -> None:
|
|||
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
||||
|
||||
|
||||
def _shell_argv(*, interactive: bool) -> list[str]:
|
||||
shell_program = shutil.which("bash") or "/bin/sh"
|
||||
argv = [shell_program]
|
||||
if shell_program.endswith("bash"):
|
||||
argv.extend(["--noprofile", "--norc"])
|
||||
if interactive:
|
||||
argv.append("-i")
|
||||
return argv
|
||||
|
||||
|
||||
class GuestShellSession:
|
||||
"""In-guest PTY-backed interactive shell session."""
|
||||
|
||||
|
|
@ -304,6 +388,8 @@ class GuestShellSession:
|
|||
cwd_text: str,
|
||||
cols: int,
|
||||
rows: int,
|
||||
env_overrides: dict[str, str] | None = None,
|
||||
redact_values: list[str] | None = None,
|
||||
) -> None:
|
||||
self.shell_id = shell_id
|
||||
self.cwd = cwd_text
|
||||
|
|
@ -316,6 +402,7 @@ class GuestShellSession:
|
|||
self._lock = threading.RLock()
|
||||
self._output = ""
|
||||
self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
||||
self._redact_values = list(redact_values or [])
|
||||
self._metadata_path = SHELL_ROOT / f"{shell_id}.json"
|
||||
self._log_path = SHELL_ROOT / f"{shell_id}.log"
|
||||
self._master_fd: int | None = None
|
||||
|
|
@ -331,8 +418,10 @@ class GuestShellSession:
|
|||
"PROMPT_COMMAND": "",
|
||||
}
|
||||
)
|
||||
if env_overrides is not None:
|
||||
env.update(env_overrides)
|
||||
process = subprocess.Popen( # noqa: S603
|
||||
["/bin/bash", "--noprofile", "--norc", "-i"],
|
||||
_shell_argv(interactive=True),
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
|
|
@ -371,8 +460,9 @@ class GuestShellSession:
|
|||
|
||||
def read(self, *, cursor: int, max_chars: int) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
clamped_cursor = min(max(cursor, 0), len(self._output))
|
||||
output = self._output[clamped_cursor : clamped_cursor + max_chars]
|
||||
redacted_output = _redact_text(self._output, self._redact_values)
|
||||
clamped_cursor = min(max(cursor, 0), len(redacted_output))
|
||||
output = redacted_output[clamped_cursor : clamped_cursor + max_chars]
|
||||
next_cursor = clamped_cursor + len(output)
|
||||
payload = self.summary()
|
||||
payload.update(
|
||||
|
|
@ -380,7 +470,7 @@ class GuestShellSession:
|
|||
"cursor": clamped_cursor,
|
||||
"next_cursor": next_cursor,
|
||||
"output": output,
|
||||
"truncated": next_cursor < len(self._output),
|
||||
"truncated": next_cursor < len(redacted_output),
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
|
@ -514,6 +604,8 @@ def _create_shell(
|
|||
cwd_text: str,
|
||||
cols: int,
|
||||
rows: int,
|
||||
env_overrides: dict[str, str] | None = None,
|
||||
redact_values: list[str] | None = None,
|
||||
) -> GuestShellSession:
|
||||
_, cwd_path = _normalize_shell_cwd(cwd_text)
|
||||
with _SHELLS_LOCK:
|
||||
|
|
@ -525,6 +617,8 @@ def _create_shell(
|
|||
cwd_text=cwd_text,
|
||||
cols=cols,
|
||||
rows=rows,
|
||||
env_overrides=env_overrides,
|
||||
redact_values=redact_values,
|
||||
)
|
||||
_SHELLS[shell_id] = session
|
||||
return session
|
||||
|
|
@ -634,7 +728,12 @@ def _refresh_service_payload(service_name: str, payload: dict[str, Any]) -> dict
|
|||
return refreshed
|
||||
|
||||
|
||||
def _run_readiness_probe(readiness: dict[str, Any] | None, *, cwd: Path) -> bool:
|
||||
def _run_readiness_probe(
|
||||
readiness: dict[str, Any] | None,
|
||||
*,
|
||||
cwd: Path,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> bool:
|
||||
if readiness is None:
|
||||
return True
|
||||
readiness_type = str(readiness["type"])
|
||||
|
|
@ -658,11 +757,15 @@ def _run_readiness_probe(readiness: dict[str, Any] | None, *, cwd: Path) -> bool
|
|||
except (urllib.error.URLError, TimeoutError, ValueError):
|
||||
return False
|
||||
if readiness_type == "command":
|
||||
command_env = os.environ.copy()
|
||||
if env is not None:
|
||||
command_env.update(env)
|
||||
proc = subprocess.run( # noqa: S603
|
||||
["/bin/sh", "-lc", str(readiness["command"])],
|
||||
cwd=str(cwd),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=command_env,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
|
|
@ -678,6 +781,7 @@ def _start_service(
|
|||
readiness: dict[str, Any] | None,
|
||||
ready_timeout_seconds: int,
|
||||
ready_interval_ms: int,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
normalized_service_name = _normalize_service_name(service_name)
|
||||
normalized_cwd, cwd_path = _normalize_shell_cwd(cwd_text)
|
||||
|
|
@ -718,9 +822,13 @@ def _start_service(
|
|||
encoding="utf-8",
|
||||
)
|
||||
runner_path.chmod(0o700)
|
||||
service_env = os.environ.copy()
|
||||
if env is not None:
|
||||
service_env.update(env)
|
||||
process = subprocess.Popen( # noqa: S603
|
||||
[str(runner_path)],
|
||||
cwd=str(cwd_path),
|
||||
env=service_env,
|
||||
text=True,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
|
@ -747,7 +855,7 @@ def _start_service(
|
|||
payload["ended_at"] = payload.get("ended_at") or time.time()
|
||||
_write_service_metadata(normalized_service_name, payload)
|
||||
return payload
|
||||
if _run_readiness_probe(readiness, cwd=cwd_path):
|
||||
if _run_readiness_probe(readiness, cwd=cwd_path, env=env):
|
||||
payload["ready_at"] = time.time()
|
||||
_write_service_metadata(normalized_service_name, payload)
|
||||
return payload
|
||||
|
|
@ -817,16 +925,38 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
|
|||
destination = str(request.get("destination", "/workspace"))
|
||||
payload = _read_exact(conn, archive_size)
|
||||
return _extract_archive(payload, destination)
|
||||
if action == "install_secrets":
|
||||
archive_size = int(request.get("archive_size", 0))
|
||||
if archive_size < 0:
|
||||
raise RuntimeError("archive_size must not be negative")
|
||||
payload = _read_exact(conn, archive_size)
|
||||
return _install_secrets_archive(payload)
|
||||
if action == "open_shell":
|
||||
shell_id = str(request.get("shell_id", "")).strip()
|
||||
if shell_id == "":
|
||||
raise RuntimeError("shell_id is required")
|
||||
cwd_text, _ = _normalize_shell_cwd(str(request.get("cwd", "/workspace")))
|
||||
env_payload = request.get("env")
|
||||
env_overrides = None
|
||||
if env_payload is not None:
|
||||
if not isinstance(env_payload, dict):
|
||||
raise RuntimeError("shell env must be a JSON object")
|
||||
env_overrides = {
|
||||
_normalize_secret_name(str(key)): str(value) for key, value in env_payload.items()
|
||||
}
|
||||
redact_values_payload = request.get("redact_values")
|
||||
redact_values: list[str] | None = None
|
||||
if redact_values_payload is not None:
|
||||
if not isinstance(redact_values_payload, list):
|
||||
raise RuntimeError("redact_values must be a list")
|
||||
redact_values = [str(item) for item in redact_values_payload]
|
||||
session = _create_shell(
|
||||
shell_id=shell_id,
|
||||
cwd_text=cwd_text,
|
||||
cols=int(request.get("cols", 120)),
|
||||
rows=int(request.get("rows", 30)),
|
||||
env_overrides=env_overrides,
|
||||
redact_values=redact_values,
|
||||
)
|
||||
return session.summary()
|
||||
if action == "read_shell":
|
||||
|
|
@ -866,6 +996,15 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
|
|||
cwd_text = str(request.get("cwd", "/workspace"))
|
||||
readiness = request.get("readiness")
|
||||
readiness_payload = dict(readiness) if isinstance(readiness, dict) else None
|
||||
env_payload = request.get("env")
|
||||
env = None
|
||||
if env_payload is not None:
|
||||
if not isinstance(env_payload, dict):
|
||||
raise RuntimeError("service env must be a JSON object")
|
||||
env = {
|
||||
_normalize_secret_name(str(key)): str(value)
|
||||
for key, value in env_payload.items()
|
||||
}
|
||||
return _start_service(
|
||||
service_name=service_name,
|
||||
command=command,
|
||||
|
|
@ -873,6 +1012,7 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
|
|||
readiness=readiness_payload,
|
||||
ready_timeout_seconds=int(request.get("ready_timeout_seconds", 30)),
|
||||
ready_interval_ms=int(request.get("ready_interval_ms", 500)),
|
||||
env=env,
|
||||
)
|
||||
if action == "status_service":
|
||||
service_name = str(request.get("service_name", "")).strip()
|
||||
|
|
@ -887,12 +1027,19 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
|
|||
return _stop_service(service_name)
|
||||
command = str(request.get("command", ""))
|
||||
timeout_seconds = int(request.get("timeout_seconds", 30))
|
||||
return _run_command(command, timeout_seconds)
|
||||
env_payload = request.get("env")
|
||||
env = None
|
||||
if env_payload is not None:
|
||||
if not isinstance(env_payload, dict):
|
||||
raise RuntimeError("exec env must be a JSON object")
|
||||
env = {_normalize_secret_name(str(key)): str(value) for key, value in env_payload.items()}
|
||||
return _run_command(command, timeout_seconds, env=env)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
SHELL_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
SERVICE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
SECRET_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
family = getattr(socket, "AF_VSOCK", None)
|
||||
if family is None:
|
||||
raise SystemExit("AF_VSOCK is unavailable")
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ AGENT=/opt/pyro/bin/pyro_guest_agent.py
|
|||
mount -t proc proc /proc || true
|
||||
mount -t sysfs sysfs /sys || true
|
||||
mount -t devtmpfs devtmpfs /dev || true
|
||||
mkdir -p /run /tmp
|
||||
mkdir -p /dev/pts /run /tmp
|
||||
mount -t devpts devpts /dev/pts -o mode=620,ptmxmode=666 || true
|
||||
hostname pyro-vm || true
|
||||
|
||||
cmdline="$(cat /proc/cmdline 2>/dev/null || true)"
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ class Pyro:
|
|||
network: bool = False,
|
||||
allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT,
|
||||
seed_path: str | Path | None = None,
|
||||
secrets: list[dict[str, str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._manager.create_workspace(
|
||||
environment=environment,
|
||||
|
|
@ -96,6 +97,7 @@ class Pyro:
|
|||
network=network,
|
||||
allow_host_compat=allow_host_compat,
|
||||
seed_path=seed_path,
|
||||
secrets=secrets,
|
||||
)
|
||||
|
||||
def exec_workspace(
|
||||
|
|
@ -104,11 +106,13 @@ class Pyro:
|
|||
*,
|
||||
command: str,
|
||||
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._manager.exec_workspace(
|
||||
workspace_id,
|
||||
command=command,
|
||||
timeout_seconds=timeout_seconds,
|
||||
secret_env=secret_env,
|
||||
)
|
||||
|
||||
def status_workspace(self, workspace_id: str) -> dict[str, Any]:
|
||||
|
|
@ -170,12 +174,14 @@ class Pyro:
|
|||
cwd: str = "/workspace",
|
||||
cols: int = 120,
|
||||
rows: int = 30,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._manager.open_shell(
|
||||
workspace_id,
|
||||
cwd=cwd,
|
||||
cols=cols,
|
||||
rows=rows,
|
||||
secret_env=secret_env,
|
||||
)
|
||||
|
||||
def read_shell(
|
||||
|
|
@ -234,6 +240,7 @@ class Pyro:
|
|||
readiness: dict[str, Any] | None = None,
|
||||
ready_timeout_seconds: int = 30,
|
||||
ready_interval_ms: int = 500,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._manager.start_service(
|
||||
workspace_id,
|
||||
|
|
@ -243,6 +250,7 @@ class Pyro:
|
|||
readiness=readiness,
|
||||
ready_timeout_seconds=ready_timeout_seconds,
|
||||
ready_interval_ms=ready_interval_ms,
|
||||
secret_env=secret_env,
|
||||
)
|
||||
|
||||
def list_services(self, workspace_id: str) -> dict[str, Any]:
|
||||
|
|
@ -403,6 +411,7 @@ class Pyro:
|
|||
network: bool = False,
|
||||
allow_host_compat: bool = DEFAULT_ALLOW_HOST_COMPAT,
|
||||
seed_path: str | None = None,
|
||||
secrets: list[dict[str, str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create and start a persistent workspace."""
|
||||
return self.create_workspace(
|
||||
|
|
@ -413,6 +422,7 @@ class Pyro:
|
|||
network=network,
|
||||
allow_host_compat=allow_host_compat,
|
||||
seed_path=seed_path,
|
||||
secrets=secrets,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
|
|
@ -420,12 +430,14 @@ class Pyro:
|
|||
workspace_id: str,
|
||||
command: str,
|
||||
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run one command inside an existing persistent workspace."""
|
||||
return self.exec_workspace(
|
||||
workspace_id,
|
||||
command=command,
|
||||
timeout_seconds=timeout_seconds,
|
||||
secret_env=secret_env,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
|
|
@ -490,9 +502,16 @@ class Pyro:
|
|||
cwd: str = "/workspace",
|
||||
cols: int = 120,
|
||||
rows: int = 30,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Open a persistent interactive shell inside one workspace."""
|
||||
return self.open_shell(workspace_id, cwd=cwd, cols=cols, rows=rows)
|
||||
return self.open_shell(
|
||||
workspace_id,
|
||||
cwd=cwd,
|
||||
cols=cols,
|
||||
rows=rows,
|
||||
secret_env=secret_env,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
async def shell_read(
|
||||
|
|
@ -554,6 +573,7 @@ class Pyro:
|
|||
ready_command: str | None = None,
|
||||
ready_timeout_seconds: int = 30,
|
||||
ready_interval_ms: int = 500,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Start a named long-running service inside a workspace."""
|
||||
readiness: dict[str, Any] | None = None
|
||||
|
|
@ -573,6 +593,7 @@ class Pyro:
|
|||
readiness=readiness,
|
||||
ready_timeout_seconds=ready_timeout_seconds,
|
||||
ready_interval_ms=ready_interval_ms,
|
||||
secret_env=secret_env,
|
||||
)
|
||||
|
||||
@server.tool()
|
||||
|
|
|
|||
|
|
@ -168,6 +168,18 @@ def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> N
|
|||
print(f"Workspace seed: {mode} from {seed_path}")
|
||||
else:
|
||||
print(f"Workspace seed: {mode}")
|
||||
secrets = payload.get("secrets")
|
||||
if isinstance(secrets, list) and secrets:
|
||||
secret_descriptions = []
|
||||
for secret in secrets:
|
||||
if not isinstance(secret, dict):
|
||||
continue
|
||||
secret_descriptions.append(
|
||||
f"{str(secret.get('name', 'unknown'))} "
|
||||
f"({str(secret.get('source_kind', 'literal'))})"
|
||||
)
|
||||
if secret_descriptions:
|
||||
print("Secrets: " + ", ".join(secret_descriptions))
|
||||
print(f"Execution mode: {str(payload.get('execution_mode', 'pending'))}")
|
||||
print(
|
||||
f"Resources: {int(payload.get('vcpu_count', 0))} vCPU / "
|
||||
|
|
@ -671,6 +683,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
Examples:
|
||||
pyro workspace create debian:12
|
||||
pyro workspace create debian:12 --seed-path ./repo
|
||||
pyro workspace create debian:12 --secret API_TOKEN=expected
|
||||
pyro workspace sync push WORKSPACE_ID ./changes
|
||||
pyro workspace snapshot create WORKSPACE_ID checkpoint
|
||||
pyro workspace reset WORKSPACE_ID --snapshot checkpoint
|
||||
|
|
@ -724,6 +737,20 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
"before the workspace is returned."
|
||||
),
|
||||
)
|
||||
workspace_create_parser.add_argument(
|
||||
"--secret",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="NAME=VALUE",
|
||||
help="Persist one literal UTF-8 secret for this workspace.",
|
||||
)
|
||||
workspace_create_parser.add_argument(
|
||||
"--secret-file",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="NAME=PATH",
|
||||
help="Persist one UTF-8 secret copied from a host file at create time.",
|
||||
)
|
||||
workspace_create_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
|
|
@ -736,7 +763,14 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
"Run one non-interactive command in the persistent `/workspace` "
|
||||
"for a workspace."
|
||||
),
|
||||
epilog="Example:\n pyro workspace exec WORKSPACE_ID -- cat note.txt",
|
||||
epilog=dedent(
|
||||
"""
|
||||
Examples:
|
||||
pyro workspace exec WORKSPACE_ID -- cat note.txt
|
||||
pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- \
|
||||
sh -lc 'test \"$API_TOKEN\" = \"expected\"'
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
workspace_exec_parser.add_argument(
|
||||
|
|
@ -750,6 +784,13 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
default=30,
|
||||
help="Maximum time allowed for the workspace command.",
|
||||
)
|
||||
workspace_exec_parser.add_argument(
|
||||
"--secret-env",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="SECRET[=ENV_VAR]",
|
||||
help="Expose one persisted workspace secret as an environment variable for this exec.",
|
||||
)
|
||||
workspace_exec_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
|
|
@ -1016,6 +1057,13 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
default=30,
|
||||
help="Shell terminal height in rows.",
|
||||
)
|
||||
workspace_shell_open_parser.add_argument(
|
||||
"--secret-env",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="SECRET[=ENV_VAR]",
|
||||
help="Expose one persisted workspace secret as an environment variable in the shell.",
|
||||
)
|
||||
workspace_shell_open_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
|
|
@ -1181,6 +1229,9 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
Examples:
|
||||
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
|
||||
sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||
pyro workspace service start WORKSPACE_ID app --secret-env API_TOKEN -- \
|
||||
sh -lc 'test \"$API_TOKEN\" = \"expected\"; touch .ready; \
|
||||
while true; do sleep 60; done'
|
||||
pyro workspace service start WORKSPACE_ID app --ready-command 'test -f .ready' -- \
|
||||
sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||
"""
|
||||
|
|
@ -1222,6 +1273,13 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
default=DEFAULT_SERVICE_READY_INTERVAL_MS,
|
||||
help="Polling interval between readiness checks.",
|
||||
)
|
||||
workspace_service_start_parser.add_argument(
|
||||
"--secret-env",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="SECRET[=ENV_VAR]",
|
||||
help="Expose one persisted workspace secret as an environment variable for this service.",
|
||||
)
|
||||
workspace_service_start_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
|
|
@ -1438,6 +1496,38 @@ def _require_command(command_args: list[str]) -> str:
|
|||
return shlex.join(command_args)
|
||||
|
||||
|
||||
def _parse_workspace_secret_option(value: str) -> dict[str, str]:
|
||||
name, sep, secret_value = value.partition("=")
|
||||
if sep == "" or name.strip() == "" or secret_value == "":
|
||||
raise ValueError("workspace secrets must use NAME=VALUE")
|
||||
return {"name": name.strip(), "value": secret_value}
|
||||
|
||||
|
||||
def _parse_workspace_secret_file_option(value: str) -> dict[str, str]:
|
||||
name, sep, file_path = value.partition("=")
|
||||
if sep == "" or name.strip() == "" or file_path.strip() == "":
|
||||
raise ValueError("workspace secret files must use NAME=PATH")
|
||||
return {"name": name.strip(), "file_path": file_path.strip()}
|
||||
|
||||
|
||||
def _parse_workspace_secret_env_options(values: list[str]) -> dict[str, str]:
|
||||
parsed: dict[str, str] = {}
|
||||
for raw_value in values:
|
||||
secret_name, sep, env_name = raw_value.partition("=")
|
||||
normalized_secret_name = secret_name.strip()
|
||||
if normalized_secret_name == "":
|
||||
raise ValueError("workspace secret env mappings must name a secret")
|
||||
normalized_env_name = env_name.strip() if sep != "" else normalized_secret_name
|
||||
if normalized_env_name == "":
|
||||
raise ValueError("workspace secret env mappings must name an environment variable")
|
||||
if normalized_secret_name in parsed:
|
||||
raise ValueError(
|
||||
f"workspace secret env mapping references {normalized_secret_name!r} more than once"
|
||||
)
|
||||
parsed[normalized_secret_name] = normalized_env_name
|
||||
return parsed
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = _build_parser().parse_args()
|
||||
pyro = Pyro()
|
||||
|
|
@ -1529,6 +1619,16 @@ def main() -> None:
|
|||
return
|
||||
if args.command == "workspace":
|
||||
if args.workspace_command == "create":
|
||||
secrets = [
|
||||
*(
|
||||
_parse_workspace_secret_option(value)
|
||||
for value in getattr(args, "secret", [])
|
||||
),
|
||||
*(
|
||||
_parse_workspace_secret_file_option(value)
|
||||
for value in getattr(args, "secret_file", [])
|
||||
),
|
||||
]
|
||||
payload = pyro.create_workspace(
|
||||
environment=args.environment,
|
||||
vcpu_count=args.vcpu_count,
|
||||
|
|
@ -1537,6 +1637,7 @@ def main() -> None:
|
|||
network=args.network,
|
||||
allow_host_compat=args.allow_host_compat,
|
||||
seed_path=args.seed_path,
|
||||
secrets=secrets or None,
|
||||
)
|
||||
if bool(args.json):
|
||||
_print_json(payload)
|
||||
|
|
@ -1545,12 +1646,14 @@ def main() -> None:
|
|||
return
|
||||
if args.workspace_command == "exec":
|
||||
command = _require_command(args.command_args)
|
||||
secret_env = _parse_workspace_secret_env_options(getattr(args, "secret_env", []))
|
||||
if bool(args.json):
|
||||
try:
|
||||
payload = pyro.exec_workspace(
|
||||
args.workspace_id,
|
||||
command=command,
|
||||
timeout_seconds=args.timeout_seconds,
|
||||
secret_env=secret_env or None,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_print_json({"ok": False, "error": str(exc)})
|
||||
|
|
@ -1562,6 +1665,7 @@ def main() -> None:
|
|||
args.workspace_id,
|
||||
command=command,
|
||||
timeout_seconds=args.timeout_seconds,
|
||||
secret_env=secret_env or None,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
|
|
@ -1703,12 +1807,14 @@ def main() -> None:
|
|||
return
|
||||
if args.workspace_command == "shell":
|
||||
if args.workspace_shell_command == "open":
|
||||
secret_env = _parse_workspace_secret_env_options(getattr(args, "secret_env", []))
|
||||
try:
|
||||
payload = pyro.open_shell(
|
||||
args.workspace_id,
|
||||
cwd=args.cwd,
|
||||
cols=args.cols,
|
||||
rows=args.rows,
|
||||
secret_env=secret_env or None,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if bool(args.json):
|
||||
|
|
@ -1825,6 +1931,7 @@ def main() -> None:
|
|||
elif args.ready_command is not None:
|
||||
readiness = {"type": "command", "command": args.ready_command}
|
||||
command = _require_command(args.command_args)
|
||||
secret_env = _parse_workspace_secret_env_options(getattr(args, "secret_env", []))
|
||||
try:
|
||||
payload = pyro.start_service(
|
||||
args.workspace_id,
|
||||
|
|
@ -1834,6 +1941,7 @@ def main() -> None:
|
|||
readiness=readiness,
|
||||
ready_timeout_seconds=args.ready_timeout_seconds,
|
||||
ready_interval_ms=args.ready_interval_ms,
|
||||
secret_env=secret_env or None,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if bool(args.json):
|
||||
|
|
|
|||
|
|
@ -30,8 +30,11 @@ PUBLIC_CLI_WORKSPACE_CREATE_FLAGS = (
|
|||
"--network",
|
||||
"--allow-host-compat",
|
||||
"--seed-path",
|
||||
"--secret",
|
||||
"--secret-file",
|
||||
"--json",
|
||||
)
|
||||
PUBLIC_CLI_WORKSPACE_EXEC_FLAGS = ("--timeout-seconds", "--secret-env", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_DIFF_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS = ("--output", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_RESET_FLAGS = ("--snapshot", "--json")
|
||||
|
|
@ -45,11 +48,18 @@ PUBLIC_CLI_WORKSPACE_SERVICE_START_FLAGS = (
|
|||
"--ready-command",
|
||||
"--ready-timeout-seconds",
|
||||
"--ready-interval-ms",
|
||||
"--secret-env",
|
||||
"--json",
|
||||
)
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_STATUS_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_STOP_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS = ("--cwd", "--cols", "--rows", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS = (
|
||||
"--cwd",
|
||||
"--cols",
|
||||
"--rows",
|
||||
"--secret-env",
|
||||
"--json",
|
||||
)
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS = ("--cursor", "--max-chars", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_WRITE_FLAGS = ("--input", "--no-newline", "--json")
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_SIGNAL_FLAGS = ("--signal", "--json")
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ class RuntimePaths:
|
|||
firecracker_bin: Path
|
||||
jailer_bin: Path
|
||||
guest_agent_path: Path | None
|
||||
guest_init_path: Path | None
|
||||
artifacts_dir: Path
|
||||
notice_path: Path
|
||||
manifest: dict[str, Any]
|
||||
|
|
@ -93,6 +94,7 @@ def resolve_runtime_paths(
|
|||
firecracker_bin = bundle_root / str(firecracker_entry.get("path", ""))
|
||||
jailer_bin = bundle_root / str(jailer_entry.get("path", ""))
|
||||
guest_agent_path: Path | None = None
|
||||
guest_init_path: Path | None = None
|
||||
guest = manifest.get("guest")
|
||||
if isinstance(guest, dict):
|
||||
agent_entry = guest.get("agent")
|
||||
|
|
@ -100,11 +102,18 @@ def resolve_runtime_paths(
|
|||
raw_agent_path = agent_entry.get("path")
|
||||
if isinstance(raw_agent_path, str):
|
||||
guest_agent_path = bundle_root / raw_agent_path
|
||||
init_entry = guest.get("init")
|
||||
if isinstance(init_entry, dict):
|
||||
raw_init_path = init_entry.get("path")
|
||||
if isinstance(raw_init_path, str):
|
||||
guest_init_path = bundle_root / raw_init_path
|
||||
artifacts_dir = bundle_root / "profiles"
|
||||
|
||||
required_paths = [firecracker_bin, jailer_bin]
|
||||
if guest_agent_path is not None:
|
||||
required_paths.append(guest_agent_path)
|
||||
if guest_init_path is not None:
|
||||
required_paths.append(guest_init_path)
|
||||
|
||||
for path in required_paths:
|
||||
if not path.exists():
|
||||
|
|
@ -126,12 +135,17 @@ def resolve_runtime_paths(
|
|||
f"runtime checksum mismatch for {full_path}; expected {raw_hash}, got {actual}"
|
||||
)
|
||||
if isinstance(guest, dict):
|
||||
agent_entry = guest.get("agent")
|
||||
if isinstance(agent_entry, dict):
|
||||
raw_path = agent_entry.get("path")
|
||||
raw_hash = agent_entry.get("sha256")
|
||||
for entry_name, malformed_message in (
|
||||
("agent", "runtime guest agent manifest entry is malformed"),
|
||||
("init", "runtime guest init manifest entry is malformed"),
|
||||
):
|
||||
guest_entry = guest.get(entry_name)
|
||||
if not isinstance(guest_entry, dict):
|
||||
continue
|
||||
raw_path = guest_entry.get("path")
|
||||
raw_hash = guest_entry.get("sha256")
|
||||
if not isinstance(raw_path, str) or not isinstance(raw_hash, str):
|
||||
raise RuntimeError("runtime guest agent manifest entry is malformed")
|
||||
raise RuntimeError(malformed_message)
|
||||
full_path = bundle_root / raw_path
|
||||
actual = _sha256(full_path)
|
||||
if actual != raw_hash:
|
||||
|
|
@ -145,6 +159,7 @@ def resolve_runtime_paths(
|
|||
firecracker_bin=firecracker_bin,
|
||||
jailer_bin=jailer_bin,
|
||||
guest_agent_path=guest_agent_path,
|
||||
guest_init_path=guest_init_path,
|
||||
artifacts_dir=artifacts_dir,
|
||||
notice_path=notice_path,
|
||||
manifest=manifest,
|
||||
|
|
@ -227,6 +242,7 @@ def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]:
|
|||
"firecracker_bin": str(paths.firecracker_bin),
|
||||
"jailer_bin": str(paths.jailer_bin),
|
||||
"guest_agent_path": str(paths.guest_agent_path) if paths.guest_agent_path else None,
|
||||
"guest_init_path": str(paths.guest_init_path) if paths.guest_init_path else None,
|
||||
"artifacts_dir": str(paths.artifacts_dir),
|
||||
"artifacts_present": paths.artifacts_dir.exists(),
|
||||
"notice_path": str(paths.notice_path),
|
||||
|
|
|
|||
57
src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro-init
Normal file
57
src/pyro_mcp/runtime_bundle/linux-x86_64/guest/pyro-init
Normal 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'
|
||||
|
|
@ -10,6 +10,7 @@ import json
|
|||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import struct
|
||||
|
|
@ -29,6 +30,7 @@ BUFFER_SIZE = 65536
|
|||
WORKSPACE_ROOT = PurePosixPath("/workspace")
|
||||
SHELL_ROOT = Path("/run/pyro-shells")
|
||||
SERVICE_ROOT = Path("/run/pyro-services")
|
||||
SECRET_ROOT = Path("/run/pyro-secrets")
|
||||
SERVICE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
SHELL_SIGNAL_MAP = {
|
||||
"HUP": signal.SIGHUP,
|
||||
|
|
@ -42,6 +44,17 @@ _SHELLS: dict[str, "GuestShellSession"] = {}
|
|||
_SHELLS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _redact_text(text: str, redact_values: list[str]) -> str:
|
||||
redacted = text
|
||||
for secret_value in sorted(
|
||||
{item for item in redact_values if item != ""},
|
||||
key=len,
|
||||
reverse=True,
|
||||
):
|
||||
redacted = redacted.replace(secret_value, "[REDACTED]")
|
||||
return redacted
|
||||
|
||||
|
||||
def _read_request(conn: socket.socket) -> dict[str, Any]:
|
||||
chunks: list[bytes] = []
|
||||
while True:
|
||||
|
|
@ -139,6 +152,15 @@ def _service_metadata_path(service_name: str) -> Path:
|
|||
return SERVICE_ROOT / f"{service_name}.json"
|
||||
|
||||
|
||||
def _normalize_secret_name(secret_name: str) -> str:
|
||||
normalized = secret_name.strip()
|
||||
if normalized == "":
|
||||
raise RuntimeError("secret name is required")
|
||||
if re.fullmatch(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$", normalized) is None:
|
||||
raise RuntimeError("secret name is invalid")
|
||||
return normalized
|
||||
|
||||
|
||||
def _validate_symlink_target(member_path: PurePosixPath, link_target: str) -> None:
|
||||
target = link_target.strip()
|
||||
if target == "":
|
||||
|
|
@ -215,6 +237,49 @@ def _extract_archive(payload: bytes, destination: str) -> dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
def _install_secrets_archive(payload: bytes) -> dict[str, Any]:
|
||||
SECRET_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
for existing in SECRET_ROOT.iterdir():
|
||||
if existing.is_dir() and not existing.is_symlink():
|
||||
shutil.rmtree(existing, ignore_errors=True)
|
||||
else:
|
||||
existing.unlink(missing_ok=True)
|
||||
bytes_written = 0
|
||||
entry_count = 0
|
||||
with tarfile.open(fileobj=io.BytesIO(payload), mode="r:*") as archive:
|
||||
for member in archive.getmembers():
|
||||
member_name = _normalize_member_name(member.name)
|
||||
target_path = SECRET_ROOT.joinpath(*member_name.parts)
|
||||
entry_count += 1
|
||||
if member.isdir():
|
||||
target_path.mkdir(parents=True, exist_ok=True)
|
||||
target_path.chmod(0o700)
|
||||
continue
|
||||
if member.isfile():
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.parent.chmod(0o700)
|
||||
source = archive.extractfile(member)
|
||||
if source is None:
|
||||
raise RuntimeError(f"failed to read secret archive member: {member.name}")
|
||||
with target_path.open("wb") as handle:
|
||||
while True:
|
||||
chunk = source.read(BUFFER_SIZE)
|
||||
if chunk == b"":
|
||||
break
|
||||
handle.write(chunk)
|
||||
target_path.chmod(0o600)
|
||||
bytes_written += member.size
|
||||
continue
|
||||
if member.issym() or member.islnk():
|
||||
raise RuntimeError(f"secret archive may not contain links: {member.name}")
|
||||
raise RuntimeError(f"unsupported secret archive member type: {member.name}")
|
||||
return {
|
||||
"destination": str(SECRET_ROOT),
|
||||
"entry_count": entry_count,
|
||||
"bytes_written": bytes_written,
|
||||
}
|
||||
|
||||
|
||||
def _inspect_archive(archive_path: Path) -> tuple[int, int]:
|
||||
entry_count = 0
|
||||
bytes_written = 0
|
||||
|
|
@ -263,13 +328,22 @@ def _prepare_export_archive(path: str) -> dict[str, Any]:
|
|||
raise
|
||||
|
||||
|
||||
def _run_command(command: str, timeout_seconds: int) -> dict[str, Any]:
|
||||
def _run_command(
|
||||
command: str,
|
||||
timeout_seconds: int,
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
started = time.monotonic()
|
||||
command_env = os.environ.copy()
|
||||
if env is not None:
|
||||
command_env.update(env)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["/bin/sh", "-lc", command],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=command_env,
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
|
|
@ -293,6 +367,16 @@ def _set_pty_size(fd: int, rows: int, cols: int) -> None:
|
|||
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
||||
|
||||
|
||||
def _shell_argv(*, interactive: bool) -> list[str]:
|
||||
shell_program = shutil.which("bash") or "/bin/sh"
|
||||
argv = [shell_program]
|
||||
if shell_program.endswith("bash"):
|
||||
argv.extend(["--noprofile", "--norc"])
|
||||
if interactive:
|
||||
argv.append("-i")
|
||||
return argv
|
||||
|
||||
|
||||
class GuestShellSession:
|
||||
"""In-guest PTY-backed interactive shell session."""
|
||||
|
||||
|
|
@ -304,6 +388,8 @@ class GuestShellSession:
|
|||
cwd_text: str,
|
||||
cols: int,
|
||||
rows: int,
|
||||
env_overrides: dict[str, str] | None = None,
|
||||
redact_values: list[str] | None = None,
|
||||
) -> None:
|
||||
self.shell_id = shell_id
|
||||
self.cwd = cwd_text
|
||||
|
|
@ -316,6 +402,7 @@ class GuestShellSession:
|
|||
self._lock = threading.RLock()
|
||||
self._output = ""
|
||||
self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
||||
self._redact_values = list(redact_values or [])
|
||||
self._metadata_path = SHELL_ROOT / f"{shell_id}.json"
|
||||
self._log_path = SHELL_ROOT / f"{shell_id}.log"
|
||||
self._master_fd: int | None = None
|
||||
|
|
@ -331,8 +418,10 @@ class GuestShellSession:
|
|||
"PROMPT_COMMAND": "",
|
||||
}
|
||||
)
|
||||
if env_overrides is not None:
|
||||
env.update(env_overrides)
|
||||
process = subprocess.Popen( # noqa: S603
|
||||
["/bin/bash", "--noprofile", "--norc", "-i"],
|
||||
_shell_argv(interactive=True),
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
|
|
@ -371,8 +460,9 @@ class GuestShellSession:
|
|||
|
||||
def read(self, *, cursor: int, max_chars: int) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
clamped_cursor = min(max(cursor, 0), len(self._output))
|
||||
output = self._output[clamped_cursor : clamped_cursor + max_chars]
|
||||
redacted_output = _redact_text(self._output, self._redact_values)
|
||||
clamped_cursor = min(max(cursor, 0), len(redacted_output))
|
||||
output = redacted_output[clamped_cursor : clamped_cursor + max_chars]
|
||||
next_cursor = clamped_cursor + len(output)
|
||||
payload = self.summary()
|
||||
payload.update(
|
||||
|
|
@ -380,7 +470,7 @@ class GuestShellSession:
|
|||
"cursor": clamped_cursor,
|
||||
"next_cursor": next_cursor,
|
||||
"output": output,
|
||||
"truncated": next_cursor < len(self._output),
|
||||
"truncated": next_cursor < len(redacted_output),
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
|
@ -514,6 +604,8 @@ def _create_shell(
|
|||
cwd_text: str,
|
||||
cols: int,
|
||||
rows: int,
|
||||
env_overrides: dict[str, str] | None = None,
|
||||
redact_values: list[str] | None = None,
|
||||
) -> GuestShellSession:
|
||||
_, cwd_path = _normalize_shell_cwd(cwd_text)
|
||||
with _SHELLS_LOCK:
|
||||
|
|
@ -525,6 +617,8 @@ def _create_shell(
|
|||
cwd_text=cwd_text,
|
||||
cols=cols,
|
||||
rows=rows,
|
||||
env_overrides=env_overrides,
|
||||
redact_values=redact_values,
|
||||
)
|
||||
_SHELLS[shell_id] = session
|
||||
return session
|
||||
|
|
@ -634,7 +728,12 @@ def _refresh_service_payload(service_name: str, payload: dict[str, Any]) -> dict
|
|||
return refreshed
|
||||
|
||||
|
||||
def _run_readiness_probe(readiness: dict[str, Any] | None, *, cwd: Path) -> bool:
|
||||
def _run_readiness_probe(
|
||||
readiness: dict[str, Any] | None,
|
||||
*,
|
||||
cwd: Path,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> bool:
|
||||
if readiness is None:
|
||||
return True
|
||||
readiness_type = str(readiness["type"])
|
||||
|
|
@ -658,11 +757,15 @@ def _run_readiness_probe(readiness: dict[str, Any] | None, *, cwd: Path) -> bool
|
|||
except (urllib.error.URLError, TimeoutError, ValueError):
|
||||
return False
|
||||
if readiness_type == "command":
|
||||
command_env = os.environ.copy()
|
||||
if env is not None:
|
||||
command_env.update(env)
|
||||
proc = subprocess.run( # noqa: S603
|
||||
["/bin/sh", "-lc", str(readiness["command"])],
|
||||
cwd=str(cwd),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=command_env,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
|
|
@ -678,6 +781,7 @@ def _start_service(
|
|||
readiness: dict[str, Any] | None,
|
||||
ready_timeout_seconds: int,
|
||||
ready_interval_ms: int,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
normalized_service_name = _normalize_service_name(service_name)
|
||||
normalized_cwd, cwd_path = _normalize_shell_cwd(cwd_text)
|
||||
|
|
@ -718,9 +822,13 @@ def _start_service(
|
|||
encoding="utf-8",
|
||||
)
|
||||
runner_path.chmod(0o700)
|
||||
service_env = os.environ.copy()
|
||||
if env is not None:
|
||||
service_env.update(env)
|
||||
process = subprocess.Popen( # noqa: S603
|
||||
[str(runner_path)],
|
||||
cwd=str(cwd_path),
|
||||
env=service_env,
|
||||
text=True,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
|
@ -747,7 +855,7 @@ def _start_service(
|
|||
payload["ended_at"] = payload.get("ended_at") or time.time()
|
||||
_write_service_metadata(normalized_service_name, payload)
|
||||
return payload
|
||||
if _run_readiness_probe(readiness, cwd=cwd_path):
|
||||
if _run_readiness_probe(readiness, cwd=cwd_path, env=env):
|
||||
payload["ready_at"] = time.time()
|
||||
_write_service_metadata(normalized_service_name, payload)
|
||||
return payload
|
||||
|
|
@ -817,16 +925,38 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
|
|||
destination = str(request.get("destination", "/workspace"))
|
||||
payload = _read_exact(conn, archive_size)
|
||||
return _extract_archive(payload, destination)
|
||||
if action == "install_secrets":
|
||||
archive_size = int(request.get("archive_size", 0))
|
||||
if archive_size < 0:
|
||||
raise RuntimeError("archive_size must not be negative")
|
||||
payload = _read_exact(conn, archive_size)
|
||||
return _install_secrets_archive(payload)
|
||||
if action == "open_shell":
|
||||
shell_id = str(request.get("shell_id", "")).strip()
|
||||
if shell_id == "":
|
||||
raise RuntimeError("shell_id is required")
|
||||
cwd_text, _ = _normalize_shell_cwd(str(request.get("cwd", "/workspace")))
|
||||
env_payload = request.get("env")
|
||||
env_overrides = None
|
||||
if env_payload is not None:
|
||||
if not isinstance(env_payload, dict):
|
||||
raise RuntimeError("shell env must be a JSON object")
|
||||
env_overrides = {
|
||||
_normalize_secret_name(str(key)): str(value) for key, value in env_payload.items()
|
||||
}
|
||||
redact_values_payload = request.get("redact_values")
|
||||
redact_values: list[str] | None = None
|
||||
if redact_values_payload is not None:
|
||||
if not isinstance(redact_values_payload, list):
|
||||
raise RuntimeError("redact_values must be a list")
|
||||
redact_values = [str(item) for item in redact_values_payload]
|
||||
session = _create_shell(
|
||||
shell_id=shell_id,
|
||||
cwd_text=cwd_text,
|
||||
cols=int(request.get("cols", 120)),
|
||||
rows=int(request.get("rows", 30)),
|
||||
env_overrides=env_overrides,
|
||||
redact_values=redact_values,
|
||||
)
|
||||
return session.summary()
|
||||
if action == "read_shell":
|
||||
|
|
@ -866,6 +996,15 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
|
|||
cwd_text = str(request.get("cwd", "/workspace"))
|
||||
readiness = request.get("readiness")
|
||||
readiness_payload = dict(readiness) if isinstance(readiness, dict) else None
|
||||
env_payload = request.get("env")
|
||||
env = None
|
||||
if env_payload is not None:
|
||||
if not isinstance(env_payload, dict):
|
||||
raise RuntimeError("service env must be a JSON object")
|
||||
env = {
|
||||
_normalize_secret_name(str(key)): str(value)
|
||||
for key, value in env_payload.items()
|
||||
}
|
||||
return _start_service(
|
||||
service_name=service_name,
|
||||
command=command,
|
||||
|
|
@ -873,6 +1012,7 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
|
|||
readiness=readiness_payload,
|
||||
ready_timeout_seconds=int(request.get("ready_timeout_seconds", 30)),
|
||||
ready_interval_ms=int(request.get("ready_interval_ms", 500)),
|
||||
env=env,
|
||||
)
|
||||
if action == "status_service":
|
||||
service_name = str(request.get("service_name", "")).strip()
|
||||
|
|
@ -887,12 +1027,19 @@ def _dispatch(request: dict[str, Any], conn: socket.socket) -> dict[str, Any]:
|
|||
return _stop_service(service_name)
|
||||
command = str(request.get("command", ""))
|
||||
timeout_seconds = int(request.get("timeout_seconds", 30))
|
||||
return _run_command(command, timeout_seconds)
|
||||
env_payload = request.get("env")
|
||||
env = None
|
||||
if env_payload is not None:
|
||||
if not isinstance(env_payload, dict):
|
||||
raise RuntimeError("exec env must be a JSON object")
|
||||
env = {_normalize_secret_name(str(key)): str(value) for key, value in env_payload.items()}
|
||||
return _run_command(command, timeout_seconds, env=env)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
SHELL_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
SERVICE_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
SECRET_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
family = getattr(socket, "AF_VSOCK", None)
|
||||
if family is None:
|
||||
raise SystemExit("AF_VSOCK is unavailable")
|
||||
|
|
|
|||
|
|
@ -25,7 +25,11 @@
|
|||
"guest": {
|
||||
"agent": {
|
||||
"path": "guest/pyro_guest_agent.py",
|
||||
"sha256": "58dd2e09d05538228540d8c667b1acb42c2e6c579f7883b70d483072570f2499"
|
||||
"sha256": "76a0bd05b523bb952ab9eaf5a3f2e0cbf1fc458d1e44894e2c0d206b05896328"
|
||||
},
|
||||
"init": {
|
||||
"path": "guest/pyro-init",
|
||||
"sha256": "96e3653955db049496cc9dc7042f3778460966e3ee7559da50224ab92ee8060b"
|
||||
}
|
||||
},
|
||||
"platform": "linux-x86_64",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from typing import Any
|
|||
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
|
||||
|
||||
DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
|
||||
DEFAULT_CATALOG_VERSION = "2.8.0"
|
||||
DEFAULT_CATALOG_VERSION = "2.9.0"
|
||||
OCI_MANIFEST_ACCEPT = ", ".join(
|
||||
(
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ class VsockExecClient:
|
|||
command: str,
|
||||
timeout_seconds: int,
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
uds_path: str | None = None,
|
||||
) -> GuestExecResponse:
|
||||
payload = self._request_json(
|
||||
|
|
@ -88,6 +89,7 @@ class VsockExecClient:
|
|||
{
|
||||
"command": command,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"env": env,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
uds_path=uds_path,
|
||||
|
|
@ -136,6 +138,40 @@ class VsockExecClient:
|
|||
bytes_written=int(payload.get("bytes_written", 0)),
|
||||
)
|
||||
|
||||
def install_secrets(
|
||||
self,
|
||||
guest_cid: int,
|
||||
port: int,
|
||||
archive_path: Path,
|
||||
*,
|
||||
timeout_seconds: int = 60,
|
||||
uds_path: str | None = None,
|
||||
) -> GuestArchiveResponse:
|
||||
request = {
|
||||
"action": "install_secrets",
|
||||
"archive_size": archive_path.stat().st_size,
|
||||
}
|
||||
sock = self._connect(guest_cid, port, timeout_seconds, uds_path=uds_path)
|
||||
try:
|
||||
sock.sendall((json.dumps(request) + "\n").encode("utf-8"))
|
||||
with archive_path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(65536), b""):
|
||||
sock.sendall(chunk)
|
||||
payload = self._recv_json_payload(sock)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("guest secret install response must be a JSON object")
|
||||
error = payload.get("error")
|
||||
if error is not None:
|
||||
raise RuntimeError(str(error))
|
||||
return GuestArchiveResponse(
|
||||
destination=str(payload.get("destination", "/run/pyro-secrets")),
|
||||
entry_count=int(payload.get("entry_count", 0)),
|
||||
bytes_written=int(payload.get("bytes_written", 0)),
|
||||
)
|
||||
|
||||
def export_archive(
|
||||
self,
|
||||
guest_cid: int,
|
||||
|
|
@ -191,6 +227,8 @@ class VsockExecClient:
|
|||
cwd: str,
|
||||
cols: int,
|
||||
rows: int,
|
||||
env: dict[str, str] | None = None,
|
||||
redact_values: list[str] | None = None,
|
||||
timeout_seconds: int = 30,
|
||||
uds_path: str | None = None,
|
||||
) -> GuestShellSummary:
|
||||
|
|
@ -203,6 +241,8 @@ class VsockExecClient:
|
|||
"cwd": cwd,
|
||||
"cols": cols,
|
||||
"rows": rows,
|
||||
"env": env,
|
||||
"redact_values": redact_values,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
uds_path=uds_path,
|
||||
|
|
@ -336,6 +376,7 @@ class VsockExecClient:
|
|||
readiness: dict[str, Any] | None,
|
||||
ready_timeout_seconds: int,
|
||||
ready_interval_ms: int,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout_seconds: int = 60,
|
||||
uds_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
|
|
@ -350,6 +391,7 @@ class VsockExecClient:
|
|||
"readiness": readiness,
|
||||
"ready_timeout_seconds": ready_timeout_seconds,
|
||||
"ready_interval_ms": ready_interval_ms,
|
||||
"env": env,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
uds_path=uds_path,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,7 @@ import codecs
|
|||
import fcntl
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
|
|
@ -29,6 +30,27 @@ _LOCAL_SHELLS: dict[str, "LocalShellSession"] = {}
|
|||
_LOCAL_SHELLS_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _shell_argv(*, interactive: bool) -> list[str]:
|
||||
shell_program = shutil.which("bash") or "/bin/sh"
|
||||
argv = [shell_program]
|
||||
if shell_program.endswith("bash"):
|
||||
argv.extend(["--noprofile", "--norc"])
|
||||
if interactive:
|
||||
argv.append("-i")
|
||||
return argv
|
||||
|
||||
|
||||
def _redact_text(text: str, redact_values: list[str]) -> str:
|
||||
redacted = text
|
||||
for secret_value in sorted(
|
||||
{item for item in redact_values if item != ""},
|
||||
key=len,
|
||||
reverse=True,
|
||||
):
|
||||
redacted = redacted.replace(secret_value, "[REDACTED]")
|
||||
return redacted
|
||||
|
||||
|
||||
def _set_pty_size(fd: int, rows: int, cols: int) -> None:
|
||||
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
||||
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
||||
|
|
@ -45,6 +67,8 @@ class LocalShellSession:
|
|||
display_cwd: str,
|
||||
cols: int,
|
||||
rows: int,
|
||||
env_overrides: dict[str, str] | None = None,
|
||||
redact_values: list[str] | None = None,
|
||||
) -> None:
|
||||
self.shell_id = shell_id
|
||||
self.cwd = display_cwd
|
||||
|
|
@ -63,6 +87,7 @@ class LocalShellSession:
|
|||
self._reader: threading.Thread | None = None
|
||||
self._waiter: threading.Thread | None = None
|
||||
self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
||||
self._redact_values = list(redact_values or [])
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
|
|
@ -71,13 +96,15 @@ class LocalShellSession:
|
|||
"PROMPT_COMMAND": "",
|
||||
}
|
||||
)
|
||||
if env_overrides is not None:
|
||||
env.update(env_overrides)
|
||||
|
||||
process: subprocess.Popen[bytes]
|
||||
try:
|
||||
master_fd, slave_fd = os.openpty()
|
||||
except OSError:
|
||||
process = subprocess.Popen( # noqa: S603
|
||||
["/bin/bash", "--noprofile", "--norc"],
|
||||
_shell_argv(interactive=False),
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
|
|
@ -93,7 +120,7 @@ class LocalShellSession:
|
|||
try:
|
||||
_set_pty_size(slave_fd, rows, cols)
|
||||
process = subprocess.Popen( # noqa: S603
|
||||
["/bin/bash", "--noprofile", "--norc", "-i"],
|
||||
_shell_argv(interactive=True),
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
|
|
@ -133,8 +160,9 @@ class LocalShellSession:
|
|||
|
||||
def read(self, *, cursor: int, max_chars: int) -> dict[str, object]:
|
||||
with self._lock:
|
||||
clamped_cursor = min(max(cursor, 0), len(self._output))
|
||||
output = self._output[clamped_cursor : clamped_cursor + max_chars]
|
||||
redacted_output = _redact_text(self._output, self._redact_values)
|
||||
clamped_cursor = min(max(cursor, 0), len(redacted_output))
|
||||
output = redacted_output[clamped_cursor : clamped_cursor + max_chars]
|
||||
next_cursor = clamped_cursor + len(output)
|
||||
payload = self.summary()
|
||||
payload.update(
|
||||
|
|
@ -142,7 +170,7 @@ class LocalShellSession:
|
|||
"cursor": clamped_cursor,
|
||||
"next_cursor": next_cursor,
|
||||
"output": output,
|
||||
"truncated": next_cursor < len(self._output),
|
||||
"truncated": next_cursor < len(redacted_output),
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
|
@ -287,6 +315,8 @@ def create_local_shell(
|
|||
display_cwd: str,
|
||||
cols: int,
|
||||
rows: int,
|
||||
env_overrides: dict[str, str] | None = None,
|
||||
redact_values: list[str] | None = None,
|
||||
) -> LocalShellSession:
|
||||
session_key = f"{workspace_id}:{shell_id}"
|
||||
with _LOCAL_SHELLS_LOCK:
|
||||
|
|
@ -298,6 +328,8 @@ def create_local_shell(
|
|||
display_cwd=display_cwd,
|
||||
cols=cols,
|
||||
rows=rows,
|
||||
env_overrides=env_overrides,
|
||||
redact_values=redact_values,
|
||||
)
|
||||
_LOCAL_SHELLS[session_key] = session
|
||||
return session
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
|
|
@ -134,40 +135,71 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
|
|||
source_dir = tmp_path / "seed"
|
||||
source_dir.mkdir()
|
||||
(source_dir / "note.txt").write_text("ok\n", encoding="utf-8")
|
||||
secret_file = tmp_path / "token.txt"
|
||||
secret_file.write_text("from-file\n", encoding="utf-8")
|
||||
|
||||
created = pyro.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
seed_path=source_dir,
|
||||
secrets=[
|
||||
{"name": "API_TOKEN", "value": "expected"},
|
||||
{"name": "FILE_TOKEN", "file_path": str(secret_file)},
|
||||
],
|
||||
)
|
||||
workspace_id = str(created["workspace_id"])
|
||||
updated_dir = tmp_path / "updated"
|
||||
updated_dir.mkdir()
|
||||
(updated_dir / "more.txt").write_text("more\n", encoding="utf-8")
|
||||
synced = pyro.push_workspace_sync(workspace_id, updated_dir, dest="subdir")
|
||||
executed = pyro.exec_workspace(workspace_id, command="cat note.txt")
|
||||
executed = pyro.exec_workspace(
|
||||
workspace_id,
|
||||
command='sh -lc \'printf "%s\\n" "$API_TOKEN"\'',
|
||||
secret_env={"API_TOKEN": "API_TOKEN"},
|
||||
)
|
||||
diff_payload = pyro.diff_workspace(workspace_id)
|
||||
snapshot = pyro.create_snapshot(workspace_id, "checkpoint")
|
||||
snapshots = pyro.list_snapshots(workspace_id)
|
||||
export_path = tmp_path / "exported-note.txt"
|
||||
exported = pyro.export_workspace(workspace_id, "note.txt", output_path=export_path)
|
||||
shell = pyro.open_shell(
|
||||
workspace_id,
|
||||
secret_env={"API_TOKEN": "API_TOKEN"},
|
||||
)
|
||||
shell_id = str(shell["shell_id"])
|
||||
pyro.write_shell(workspace_id, shell_id, input='printf "%s\\n" "$API_TOKEN"')
|
||||
shell_output: dict[str, Any] = {}
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline:
|
||||
shell_output = pyro.read_shell(workspace_id, shell_id, cursor=0, max_chars=65536)
|
||||
if "[REDACTED]" in str(shell_output.get("output", "")):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
shell_closed = pyro.close_shell(workspace_id, shell_id)
|
||||
service = pyro.start_service(
|
||||
workspace_id,
|
||||
"app",
|
||||
command="sh -lc 'touch .ready; while true; do sleep 60; done'",
|
||||
command=(
|
||||
'sh -lc \'trap "exit 0" TERM; printf "%s\\n" "$API_TOKEN" >&2; '
|
||||
'touch .ready; while true; do sleep 60; done\''
|
||||
),
|
||||
readiness={"type": "file", "path": ".ready"},
|
||||
secret_env={"API_TOKEN": "API_TOKEN"},
|
||||
)
|
||||
services = pyro.list_services(workspace_id)
|
||||
service_status = pyro.status_service(workspace_id, "app")
|
||||
service_logs = pyro.logs_service(workspace_id, "app", all=True)
|
||||
service_stopped = pyro.stop_service(workspace_id, "app")
|
||||
reset = pyro.reset_workspace(workspace_id, snapshot="checkpoint")
|
||||
deleted_snapshot = pyro.delete_snapshot(workspace_id, "checkpoint")
|
||||
status = pyro.status_workspace(workspace_id)
|
||||
logs = pyro.logs_workspace(workspace_id)
|
||||
deleted = pyro.delete_workspace(workspace_id)
|
||||
|
||||
assert executed["stdout"] == "ok\n"
|
||||
assert created["secrets"] == [
|
||||
{"name": "API_TOKEN", "source_kind": "literal"},
|
||||
{"name": "FILE_TOKEN", "source_kind": "file"},
|
||||
]
|
||||
assert executed["stdout"] == "[REDACTED]\n"
|
||||
assert created["workspace_seed"]["mode"] == "directory"
|
||||
assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
|
||||
assert diff_payload["changed"] is True
|
||||
|
|
@ -175,12 +207,15 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None:
|
|||
assert snapshots["count"] == 2
|
||||
assert exported["output_path"] == str(export_path)
|
||||
assert export_path.read_text(encoding="utf-8") == "ok\n"
|
||||
assert shell_output["output"].count("[REDACTED]") >= 1
|
||||
assert shell_closed["closed"] is True
|
||||
assert service["state"] == "running"
|
||||
assert services["count"] == 1
|
||||
assert service_status["state"] == "running"
|
||||
assert service_logs["stderr"].count("[REDACTED]") >= 1
|
||||
assert service_logs["tail_lines"] is None
|
||||
assert service_stopped["state"] == "stopped"
|
||||
assert reset["workspace_reset"]["snapshot_name"] == "checkpoint"
|
||||
assert reset["secrets"] == created["secrets"]
|
||||
assert deleted_snapshot["deleted"] is True
|
||||
assert status["command_count"] == 0
|
||||
assert status["service_count"] == 0
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
|
@ -75,12 +76,15 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
"create",
|
||||
).format_help()
|
||||
assert "--seed-path" in workspace_create_help
|
||||
assert "--secret" in workspace_create_help
|
||||
assert "--secret-file" in workspace_create_help
|
||||
assert "seed into `/workspace`" in workspace_create_help
|
||||
|
||||
workspace_exec_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"),
|
||||
"exec",
|
||||
).format_help()
|
||||
assert "--secret-env" in workspace_exec_help
|
||||
assert "persistent `/workspace`" in workspace_exec_help
|
||||
assert "pyro workspace exec WORKSPACE_ID -- cat note.txt" in workspace_exec_help
|
||||
|
||||
|
|
@ -158,6 +162,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
assert "--ready-tcp" in workspace_service_start_help
|
||||
assert "--ready-http" in workspace_service_start_help
|
||||
assert "--ready-command" in workspace_service_start_help
|
||||
assert "--secret-env" in workspace_service_start_help
|
||||
|
||||
workspace_service_logs_help = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "service"), "logs"
|
||||
|
|
@ -171,6 +176,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
assert "--cwd" in workspace_shell_open_help
|
||||
assert "--cols" in workspace_shell_open_help
|
||||
assert "--rows" in workspace_shell_open_help
|
||||
assert "--secret-env" in workspace_shell_open_help
|
||||
|
||||
workspace_shell_read_help = _subparser_choice(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "read"
|
||||
|
|
@ -550,10 +556,12 @@ def test_cli_workspace_exec_prints_human_output(
|
|||
*,
|
||||
command: str,
|
||||
timeout_seconds: int,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert command == "cat note.txt"
|
||||
assert timeout_seconds == 30
|
||||
assert secret_env is None
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"sequence": 2,
|
||||
|
|
@ -572,6 +580,7 @@ def test_cli_workspace_exec_prints_human_output(
|
|||
workspace_command="exec",
|
||||
workspace_id="workspace-123",
|
||||
timeout_seconds=30,
|
||||
secret_env=[],
|
||||
json=False,
|
||||
command_args=["--", "cat", "note.txt"],
|
||||
)
|
||||
|
|
@ -1322,11 +1331,17 @@ def test_cli_workspace_exec_prints_json_and_exits_nonzero(
|
|||
) -> None:
|
||||
class StubPyro:
|
||||
def exec_workspace(
|
||||
self, workspace_id: str, *, command: str, timeout_seconds: int
|
||||
self,
|
||||
workspace_id: str,
|
||||
*,
|
||||
command: str,
|
||||
timeout_seconds: int,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert command == "false"
|
||||
assert timeout_seconds == 30
|
||||
assert secret_env is None
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"sequence": 1,
|
||||
|
|
@ -1345,6 +1360,7 @@ def test_cli_workspace_exec_prints_json_and_exits_nonzero(
|
|||
workspace_command="exec",
|
||||
workspace_id="workspace-123",
|
||||
timeout_seconds=30,
|
||||
secret_env=[],
|
||||
json=True,
|
||||
command_args=["--", "false"],
|
||||
)
|
||||
|
|
@ -1363,9 +1379,15 @@ def test_cli_workspace_exec_prints_human_error(
|
|||
) -> None:
|
||||
class StubPyro:
|
||||
def exec_workspace(
|
||||
self, workspace_id: str, *, command: str, timeout_seconds: int
|
||||
self,
|
||||
workspace_id: str,
|
||||
*,
|
||||
command: str,
|
||||
timeout_seconds: int,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
del workspace_id, command, timeout_seconds
|
||||
assert secret_env is None
|
||||
raise RuntimeError("exec boom")
|
||||
|
||||
class ExecParser:
|
||||
|
|
@ -1375,6 +1397,7 @@ def test_cli_workspace_exec_prints_human_error(
|
|||
workspace_command="exec",
|
||||
workspace_id="workspace-123",
|
||||
timeout_seconds=30,
|
||||
secret_env=[],
|
||||
json=False,
|
||||
command_args=["--", "cat", "note.txt"],
|
||||
)
|
||||
|
|
@ -1538,11 +1561,13 @@ def test_cli_workspace_shell_open_and_read_human(
|
|||
cwd: str,
|
||||
cols: int,
|
||||
rows: int,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
assert workspace_id == "workspace-123"
|
||||
assert cwd == "/workspace"
|
||||
assert cols == 120
|
||||
assert rows == 30
|
||||
assert secret_env is None
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"shell_id": "shell-123",
|
||||
|
|
@ -1595,6 +1620,7 @@ def test_cli_workspace_shell_open_and_read_human(
|
|||
cwd="/workspace",
|
||||
cols=120,
|
||||
rows=30,
|
||||
secret_env=[],
|
||||
json=False,
|
||||
)
|
||||
|
||||
|
|
@ -1758,7 +1784,9 @@ def test_cli_workspace_shell_open_and_read_json(
|
|||
cwd: str,
|
||||
cols: int,
|
||||
rows: int,
|
||||
secret_env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
assert secret_env is None
|
||||
return {
|
||||
"workspace_id": workspace_id,
|
||||
"shell_id": "shell-123",
|
||||
|
|
@ -1807,6 +1835,7 @@ def test_cli_workspace_shell_open_and_read_json(
|
|||
cwd="/workspace",
|
||||
cols=120,
|
||||
rows=30,
|
||||
secret_env=[],
|
||||
json=True,
|
||||
)
|
||||
|
||||
|
|
@ -2798,3 +2827,210 @@ def test_cli_demo_ollama_verbose_and_error_paths(
|
|||
with pytest.raises(SystemExit, match="1"):
|
||||
cli.main()
|
||||
assert "[error] tool loop failed" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_cli_workspace_create_passes_secrets(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
secret_file = tmp_path / "token.txt"
|
||||
secret_file.write_text("from-file\n", encoding="utf-8")
|
||||
|
||||
class StubPyro:
|
||||
def create_workspace(self, **kwargs: Any) -> dict[str, Any]:
|
||||
assert kwargs["environment"] == "debian:12"
|
||||
assert kwargs["seed_path"] == "./repo"
|
||||
assert kwargs["secrets"] == [
|
||||
{"name": "API_TOKEN", "value": "expected"},
|
||||
{"name": "FILE_TOKEN", "file_path": str(secret_file)},
|
||||
]
|
||||
return {"workspace_id": "ws-123"}
|
||||
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="create",
|
||||
environment="debian:12",
|
||||
vcpu_count=1,
|
||||
mem_mib=1024,
|
||||
ttl_seconds=600,
|
||||
network=False,
|
||||
allow_host_compat=False,
|
||||
seed_path="./repo",
|
||||
secret=["API_TOKEN=expected"],
|
||||
secret_file=[f"FILE_TOKEN={secret_file}"],
|
||||
json=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
output = json.loads(capsys.readouterr().out)
|
||||
assert output["workspace_id"] == "ws-123"
|
||||
|
||||
|
||||
def test_cli_workspace_exec_passes_secret_env(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def exec_workspace(self, workspace_id: str, **kwargs: Any) -> dict[str, Any]:
|
||||
assert workspace_id == "ws-123"
|
||||
assert kwargs["command"] == "sh -lc 'test \"$API_TOKEN\" = \"expected\"'"
|
||||
assert kwargs["secret_env"] == {"API_TOKEN": "API_TOKEN", "TOKEN": "PIP_TOKEN"}
|
||||
return {"exit_code": 0, "stdout": "", "stderr": ""}
|
||||
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="exec",
|
||||
workspace_id="ws-123",
|
||||
timeout_seconds=30,
|
||||
secret_env=["API_TOKEN", "TOKEN=PIP_TOKEN"],
|
||||
json=True,
|
||||
command_args=["--", "sh", "-lc", 'test "$API_TOKEN" = "expected"'],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
output = json.loads(capsys.readouterr().out)
|
||||
assert output["exit_code"] == 0
|
||||
|
||||
|
||||
def test_cli_workspace_shell_open_passes_secret_env(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def open_shell(self, workspace_id: str, **kwargs: Any) -> dict[str, Any]:
|
||||
assert workspace_id == "ws-123"
|
||||
assert kwargs["secret_env"] == {"TOKEN": "TOKEN", "API": "API_TOKEN"}
|
||||
return {"workspace_id": workspace_id, "shell_id": "shell-1", "state": "running"}
|
||||
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="shell",
|
||||
workspace_shell_command="open",
|
||||
workspace_id="ws-123",
|
||||
cwd="/workspace",
|
||||
cols=120,
|
||||
rows=30,
|
||||
secret_env=["TOKEN", "API=API_TOKEN"],
|
||||
json=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
output = json.loads(capsys.readouterr().out)
|
||||
assert output["shell_id"] == "shell-1"
|
||||
|
||||
|
||||
def test_cli_workspace_service_start_passes_secret_env(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
class StubPyro:
|
||||
def start_service(
|
||||
self,
|
||||
workspace_id: str,
|
||||
service_name: str,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
assert workspace_id == "ws-123"
|
||||
assert service_name == "app"
|
||||
assert kwargs["secret_env"] == {"TOKEN": "TOKEN", "API": "API_TOKEN"}
|
||||
assert kwargs["readiness"] == {"type": "file", "path": ".ready"}
|
||||
assert kwargs["command"] == "sh -lc 'touch .ready && while true; do sleep 60; done'"
|
||||
return {"workspace_id": workspace_id, "service_name": service_name, "state": "running"}
|
||||
|
||||
class StubParser:
|
||||
def parse_args(self) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
command="workspace",
|
||||
workspace_command="service",
|
||||
workspace_service_command="start",
|
||||
workspace_id="ws-123",
|
||||
service_name="app",
|
||||
cwd="/workspace",
|
||||
ready_file=".ready",
|
||||
ready_tcp=None,
|
||||
ready_http=None,
|
||||
ready_command=None,
|
||||
ready_timeout_seconds=30,
|
||||
ready_interval_ms=500,
|
||||
secret_env=["TOKEN", "API=API_TOKEN"],
|
||||
json=True,
|
||||
command_args=["--", "sh", "-lc", "touch .ready && while true; do sleep 60; done"],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
|
||||
monkeypatch.setattr(cli, "Pyro", StubPyro)
|
||||
cli.main()
|
||||
output = json.loads(capsys.readouterr().out)
|
||||
assert output["state"] == "running"
|
||||
|
||||
|
||||
def test_cli_workspace_secret_parsers_validate_syntax(tmp_path: Path) -> None:
|
||||
secret_file = tmp_path / "token.txt"
|
||||
secret_file.write_text("expected\n", encoding="utf-8")
|
||||
|
||||
assert cli._parse_workspace_secret_option("API_TOKEN=expected") == { # noqa: SLF001
|
||||
"name": "API_TOKEN",
|
||||
"value": "expected",
|
||||
}
|
||||
assert cli._parse_workspace_secret_file_option(f"FILE_TOKEN={secret_file}") == { # noqa: SLF001
|
||||
"name": "FILE_TOKEN",
|
||||
"file_path": str(secret_file),
|
||||
}
|
||||
assert cli._parse_workspace_secret_env_options(["TOKEN", "API=PIP_TOKEN"]) == { # noqa: SLF001
|
||||
"TOKEN": "TOKEN",
|
||||
"API": "PIP_TOKEN",
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="NAME=VALUE"):
|
||||
cli._parse_workspace_secret_option("API_TOKEN") # noqa: SLF001
|
||||
with pytest.raises(ValueError, match="NAME=PATH"):
|
||||
cli._parse_workspace_secret_file_option("FILE_TOKEN=") # noqa: SLF001
|
||||
with pytest.raises(ValueError, match="must name a secret"):
|
||||
cli._parse_workspace_secret_env_options(["=TOKEN"]) # noqa: SLF001
|
||||
with pytest.raises(ValueError, match="must name an environment variable"):
|
||||
cli._parse_workspace_secret_env_options(["TOKEN="]) # noqa: SLF001
|
||||
with pytest.raises(ValueError, match="more than once"):
|
||||
cli._parse_workspace_secret_env_options(["TOKEN", "TOKEN=API_TOKEN"]) # noqa: SLF001
|
||||
|
||||
|
||||
def test_print_workspace_summary_human_includes_secret_metadata(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
cli._print_workspace_summary_human(
|
||||
{
|
||||
"workspace_id": "ws-123",
|
||||
"environment": "debian:12",
|
||||
"state": "started",
|
||||
"workspace_path": "/workspace",
|
||||
"workspace_seed": {
|
||||
"mode": "directory",
|
||||
"seed_path": "/tmp/repo",
|
||||
},
|
||||
"secrets": [
|
||||
{"name": "API_TOKEN", "source_kind": "literal"},
|
||||
{"name": "FILE_TOKEN", "source_kind": "file"},
|
||||
],
|
||||
"execution_mode": "guest_vsock",
|
||||
"vcpu_count": 1,
|
||||
"mem_mib": 1024,
|
||||
"command_count": 0,
|
||||
},
|
||||
action="Workspace",
|
||||
)
|
||||
output = capsys.readouterr().out
|
||||
assert "Workspace ID: ws-123" in output
|
||||
assert "Workspace seed: directory from /tmp/repo" in output
|
||||
assert "Secrets: API_TOKEN (literal), FILE_TOKEN (file)" in output
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from pyro_mcp.contract import (
|
|||
PUBLIC_CLI_RUN_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_CREATE_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_DIFF_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_EXEC_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_RESET_FLAGS,
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS,
|
||||
|
|
@ -94,6 +95,11 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None:
|
|||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_CREATE_FLAGS:
|
||||
assert flag in workspace_create_help_text
|
||||
workspace_exec_help_text = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "exec"
|
||||
).format_help()
|
||||
for flag in PUBLIC_CLI_WORKSPACE_EXEC_FLAGS:
|
||||
assert flag in workspace_exec_help_text
|
||||
workspace_sync_help_text = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"),
|
||||
"sync",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
|
@ -14,6 +15,8 @@ def test_resolve_runtime_paths_default_bundle() -> None:
|
|||
assert paths.jailer_bin.exists()
|
||||
assert paths.guest_agent_path is not None
|
||||
assert paths.guest_agent_path.exists()
|
||||
assert paths.guest_init_path is not None
|
||||
assert paths.guest_init_path.exists()
|
||||
assert paths.artifacts_dir.exists()
|
||||
assert paths.manifest.get("platform") == "linux-x86_64"
|
||||
|
||||
|
|
@ -51,17 +54,56 @@ def test_resolve_runtime_paths_checksum_mismatch(
|
|||
guest_agent_path = source.guest_agent_path
|
||||
if guest_agent_path is None:
|
||||
raise AssertionError("expected guest agent in runtime bundle")
|
||||
guest_init_path = source.guest_init_path
|
||||
if guest_init_path is None:
|
||||
raise AssertionError("expected guest init in runtime bundle")
|
||||
copied_guest_dir = copied_platform / "guest"
|
||||
copied_guest_dir.mkdir(parents=True, exist_ok=True)
|
||||
(copied_guest_dir / "pyro_guest_agent.py").write_text(
|
||||
guest_agent_path.read_text(encoding="utf-8"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(copied_guest_dir / "pyro-init").write_text(
|
||||
guest_init_path.read_text(encoding="utf-8"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("PYRO_RUNTIME_BUNDLE_DIR", str(copied_bundle))
|
||||
with pytest.raises(RuntimeError, match="checksum mismatch"):
|
||||
resolve_runtime_paths()
|
||||
|
||||
|
||||
def test_resolve_runtime_paths_guest_init_checksum_mismatch(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
source = resolve_runtime_paths()
|
||||
copied_bundle = tmp_path / "bundle"
|
||||
shutil.copytree(source.bundle_root.parent, copied_bundle)
|
||||
copied_platform = copied_bundle / "linux-x86_64"
|
||||
copied_guest_init = copied_platform / "guest" / "pyro-init"
|
||||
copied_guest_init.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
||||
monkeypatch.setenv("PYRO_RUNTIME_BUNDLE_DIR", str(copied_bundle))
|
||||
with pytest.raises(RuntimeError, match="checksum mismatch"):
|
||||
resolve_runtime_paths()
|
||||
|
||||
|
||||
def test_resolve_runtime_paths_guest_init_manifest_malformed(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
source = resolve_runtime_paths()
|
||||
copied_bundle = tmp_path / "bundle"
|
||||
shutil.copytree(source.bundle_root.parent, copied_bundle)
|
||||
manifest_path = copied_bundle / "linux-x86_64" / "manifest.json"
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
guest = manifest.get("guest")
|
||||
if not isinstance(guest, dict):
|
||||
raise AssertionError("expected guest manifest section")
|
||||
guest["init"] = {"path": "guest/pyro-init"}
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
||||
monkeypatch.setenv("PYRO_RUNTIME_BUNDLE_DIR", str(copied_bundle))
|
||||
with pytest.raises(RuntimeError, match="runtime guest init manifest entry is malformed"):
|
||||
resolve_runtime_paths()
|
||||
|
||||
|
||||
def test_doctor_report_has_runtime_fields() -> None:
|
||||
report = doctor_report()
|
||||
assert "runtime_ok" in report
|
||||
|
|
@ -72,6 +114,7 @@ def test_doctor_report_has_runtime_fields() -> None:
|
|||
assert isinstance(runtime, dict)
|
||||
assert "firecracker_bin" in runtime
|
||||
assert "guest_agent_path" in runtime
|
||||
assert "guest_init_path" in runtime
|
||||
assert "component_versions" in runtime
|
||||
assert "environments" in runtime
|
||||
networking = report["networking"]
|
||||
|
|
|
|||
|
|
@ -191,6 +191,8 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
source_dir = tmp_path / "seed"
|
||||
source_dir.mkdir()
|
||||
(source_dir / "note.txt").write_text("ok\n", encoding="utf-8")
|
||||
secret_file = tmp_path / "token.txt"
|
||||
secret_file.write_text("from-file\n", encoding="utf-8")
|
||||
|
||||
def _extract_structured(raw_result: object) -> dict[str, Any]:
|
||||
if not isinstance(raw_result, tuple) or len(raw_result) != 2:
|
||||
|
|
@ -209,6 +211,10 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
"environment": "debian:12-base",
|
||||
"allow_host_compat": True,
|
||||
"seed_path": str(source_dir),
|
||||
"secrets": [
|
||||
{"name": "API_TOKEN", "value": "expected"},
|
||||
{"name": "FILE_TOKEN", "file_path": str(secret_file)},
|
||||
],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
|
@ -231,7 +237,8 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
"workspace_exec",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"command": "cat subdir/more.txt",
|
||||
"command": 'sh -lc \'printf "%s\\n" "$API_TOKEN"\'',
|
||||
"secret_env": {"API_TOKEN": "API_TOKEN"},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
|
@ -264,8 +271,12 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
{
|
||||
"workspace_id": workspace_id,
|
||||
"service_name": "app",
|
||||
"command": "sh -lc 'touch .ready; while true; do sleep 60; done'",
|
||||
"command": (
|
||||
'sh -lc \'trap "exit 0" TERM; printf "%s\\n" "$API_TOKEN" >&2; '
|
||||
'touch .ready; while true; do sleep 60; done\''
|
||||
),
|
||||
"ready_file": ".ready",
|
||||
"secret_env": {"API_TOKEN": "API_TOKEN"},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
|
@ -357,8 +368,12 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
) = asyncio.run(_run())
|
||||
assert created["state"] == "started"
|
||||
assert created["workspace_seed"]["mode"] == "directory"
|
||||
assert created["secrets"] == [
|
||||
{"name": "API_TOKEN", "source_kind": "literal"},
|
||||
{"name": "FILE_TOKEN", "source_kind": "file"},
|
||||
]
|
||||
assert synced["workspace_sync"]["destination"] == "/workspace/subdir"
|
||||
assert executed["stdout"] == "more\n"
|
||||
assert executed["stdout"] == "[REDACTED]\n"
|
||||
assert diffed["changed"] is True
|
||||
assert snapshot["snapshot"]["snapshot_name"] == "checkpoint"
|
||||
assert [entry["snapshot_name"] for entry in snapshots["snapshots"]] == [
|
||||
|
|
@ -370,9 +385,11 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None:
|
|||
assert service["state"] == "running"
|
||||
assert services["count"] == 1
|
||||
assert service_status["state"] == "running"
|
||||
assert service_logs["stderr"].count("[REDACTED]") >= 1
|
||||
assert service_logs["tail_lines"] is None
|
||||
assert service_stopped["state"] == "stopped"
|
||||
assert reset["workspace_reset"]["snapshot_name"] == "checkpoint"
|
||||
assert reset["secrets"] == created["secrets"]
|
||||
assert reset["command_count"] == 0
|
||||
assert reset["service_count"] == 0
|
||||
assert deleted_snapshot["deleted"] is True
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ def _fake_runtime_paths(tmp_path: Path) -> RuntimePaths:
|
|||
firecracker_bin = bundle_root / "bin" / "firecracker"
|
||||
jailer_bin = bundle_root / "bin" / "jailer"
|
||||
guest_agent_path = bundle_root / "guest" / "pyro_guest_agent.py"
|
||||
guest_init_path = bundle_root / "guest" / "pyro-init"
|
||||
artifacts_dir = bundle_root / "profiles"
|
||||
notice_path = bundle_parent / "NOTICE"
|
||||
|
||||
|
|
@ -54,6 +55,7 @@ def _fake_runtime_paths(tmp_path: Path) -> RuntimePaths:
|
|||
firecracker_bin.write_text("firecracker\n", encoding="utf-8")
|
||||
jailer_bin.write_text("jailer\n", encoding="utf-8")
|
||||
guest_agent_path.write_text("print('guest')\n", encoding="utf-8")
|
||||
guest_init_path.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||
notice_path.write_text("notice\n", encoding="utf-8")
|
||||
|
||||
return RuntimePaths(
|
||||
|
|
@ -62,6 +64,7 @@ def _fake_runtime_paths(tmp_path: Path) -> RuntimePaths:
|
|||
firecracker_bin=firecracker_bin,
|
||||
jailer_bin=jailer_bin,
|
||||
guest_agent_path=guest_agent_path,
|
||||
guest_init_path=guest_init_path,
|
||||
artifacts_dir=artifacts_dir,
|
||||
notice_path=notice_path,
|
||||
manifest={"platform": "linux-x86_64"},
|
||||
|
|
|
|||
|
|
@ -57,12 +57,13 @@ def test_vsock_exec_client_round_trip(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
return stub
|
||||
|
||||
client = VsockExecClient(socket_factory=socket_factory)
|
||||
response = client.exec(1234, 5005, "echo ok", 30)
|
||||
response = client.exec(1234, 5005, "echo ok", 30, env={"TOKEN": "expected"})
|
||||
|
||||
assert response.exit_code == 0
|
||||
assert response.stdout == "ok\n"
|
||||
assert stub.connected == (1234, 5005)
|
||||
assert b'"command": "echo ok"' in stub.sent
|
||||
assert b'"env": {"TOKEN": "expected"}' in stub.sent
|
||||
assert stub.closed is True
|
||||
|
||||
|
||||
|
|
@ -105,6 +106,39 @@ def test_vsock_exec_client_upload_archive_round_trip(
|
|||
assert stub.closed is True
|
||||
|
||||
|
||||
def test_vsock_exec_client_install_secrets_round_trip(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setattr(socket, "AF_VSOCK", 40, raising=False)
|
||||
archive_path = tmp_path / "secrets.tar"
|
||||
with tarfile.open(archive_path, "w") as archive:
|
||||
payload = b"expected\n"
|
||||
info = tarfile.TarInfo(name="API_TOKEN")
|
||||
info.size = len(payload)
|
||||
archive.addfile(info, io.BytesIO(payload))
|
||||
stub = StubSocket(
|
||||
b'{"destination":"/run/pyro-secrets","entry_count":1,"bytes_written":9}'
|
||||
)
|
||||
|
||||
def socket_factory(family: int, sock_type: int) -> StubSocket:
|
||||
assert family == socket.AF_VSOCK
|
||||
assert sock_type == socket.SOCK_STREAM
|
||||
return stub
|
||||
|
||||
client = VsockExecClient(socket_factory=socket_factory)
|
||||
response = client.install_secrets(1234, 5005, archive_path, timeout_seconds=60)
|
||||
|
||||
request_payload, archive_payload = stub.sent.split(b"\n", 1)
|
||||
request = json.loads(request_payload.decode("utf-8"))
|
||||
assert request["action"] == "install_secrets"
|
||||
assert int(request["archive_size"]) == archive_path.stat().st_size
|
||||
assert archive_payload == archive_path.read_bytes()
|
||||
assert response.destination == "/run/pyro-secrets"
|
||||
assert response.entry_count == 1
|
||||
assert response.bytes_written == 9
|
||||
assert stub.closed is True
|
||||
|
||||
|
||||
def test_vsock_exec_client_export_archive_round_trip(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
|
|
@ -241,6 +275,8 @@ def test_vsock_exec_client_shell_round_trip(monkeypatch: pytest.MonkeyPatch) ->
|
|||
cwd="/workspace",
|
||||
cols=120,
|
||||
rows=30,
|
||||
env={"TOKEN": "expected"},
|
||||
redact_values=["expected"],
|
||||
)
|
||||
assert opened.shell_id == "shell-1"
|
||||
read = client.read_shell(1234, 5005, shell_id="shell-1", cursor=0, max_chars=1024)
|
||||
|
|
@ -260,6 +296,8 @@ def test_vsock_exec_client_shell_round_trip(monkeypatch: pytest.MonkeyPatch) ->
|
|||
open_request = json.loads(stubs[0].sent.decode("utf-8").strip())
|
||||
assert open_request["action"] == "open_shell"
|
||||
assert open_request["shell_id"] == "shell-1"
|
||||
assert open_request["env"] == {"TOKEN": "expected"}
|
||||
assert open_request["redact_values"] == ["expected"]
|
||||
|
||||
|
||||
def test_vsock_exec_client_service_round_trip(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
|
@ -348,6 +386,7 @@ def test_vsock_exec_client_service_round_trip(monkeypatch: pytest.MonkeyPatch) -
|
|||
readiness={"type": "file", "path": "/workspace/.ready"},
|
||||
ready_timeout_seconds=30,
|
||||
ready_interval_ms=500,
|
||||
env={"TOKEN": "expected"},
|
||||
)
|
||||
assert started["service_name"] == "app"
|
||||
status = client.status_service(1234, 5005, service_name="app")
|
||||
|
|
@ -359,6 +398,7 @@ def test_vsock_exec_client_service_round_trip(monkeypatch: pytest.MonkeyPatch) -
|
|||
start_request = json.loads(stubs[0].sent.decode("utf-8").strip())
|
||||
assert start_request["action"] == "start_service"
|
||||
assert start_request["service_name"] == "app"
|
||||
assert start_request["env"] == {"TOKEN": "expected"}
|
||||
|
||||
|
||||
def test_vsock_exec_client_raises_agent_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import subprocess
|
|||
import tarfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -1713,3 +1713,212 @@ def test_workspace_service_probe_and_refresh_helpers(
|
|||
)
|
||||
assert stopped.state == "stopped"
|
||||
assert stopped.stop_reason == "sigterm"
|
||||
|
||||
|
||||
def test_workspace_secrets_redact_exec_shell_service_and_survive_reset(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
secret_file = tmp_path / "token.txt"
|
||||
secret_file.write_text("from-file\n", encoding="utf-8")
|
||||
|
||||
created = manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
secrets=[
|
||||
{"name": "API_TOKEN", "value": "expected"},
|
||||
{"name": "FILE_TOKEN", "file_path": str(secret_file)},
|
||||
],
|
||||
)
|
||||
workspace_id = str(created["workspace_id"])
|
||||
assert created["secrets"] == [
|
||||
{"name": "API_TOKEN", "source_kind": "literal"},
|
||||
{"name": "FILE_TOKEN", "source_kind": "file"},
|
||||
]
|
||||
|
||||
no_secret = manager.exec_workspace(
|
||||
workspace_id,
|
||||
command='sh -lc \'printf "%s" "${API_TOKEN:-missing}"\'',
|
||||
timeout_seconds=30,
|
||||
)
|
||||
assert no_secret["stdout"] == "missing"
|
||||
|
||||
executed = manager.exec_workspace(
|
||||
workspace_id,
|
||||
command='sh -lc \'printf "%s\\n" "$API_TOKEN"\'',
|
||||
timeout_seconds=30,
|
||||
secret_env={"API_TOKEN": "API_TOKEN"},
|
||||
)
|
||||
assert executed["stdout"] == "[REDACTED]\n"
|
||||
|
||||
logs = manager.logs_workspace(workspace_id)
|
||||
assert logs["entries"][-1]["stdout"] == "[REDACTED]\n"
|
||||
|
||||
shell = manager.open_shell(
|
||||
workspace_id,
|
||||
secret_env={"API_TOKEN": "API_TOKEN"},
|
||||
)
|
||||
shell_id = str(shell["shell_id"])
|
||||
manager.write_shell(workspace_id, shell_id, input_text='printf "%s\\n" "$API_TOKEN"')
|
||||
output = ""
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline:
|
||||
read = manager.read_shell(workspace_id, shell_id, cursor=0, max_chars=65536)
|
||||
output = str(read["output"])
|
||||
if "[REDACTED]" in output:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert "[REDACTED]" in output
|
||||
manager.close_shell(workspace_id, shell_id)
|
||||
|
||||
started = manager.start_service(
|
||||
workspace_id,
|
||||
"app",
|
||||
command=(
|
||||
'sh -lc \'trap "exit 0" TERM; printf "%s\\n" "$API_TOKEN" >&2; '
|
||||
'touch .ready; while true; do sleep 60; done\''
|
||||
),
|
||||
readiness={"type": "file", "path": ".ready"},
|
||||
secret_env={"API_TOKEN": "API_TOKEN"},
|
||||
)
|
||||
assert started["state"] == "running"
|
||||
service_logs = manager.logs_service(workspace_id, "app", tail_lines=None)
|
||||
assert "[REDACTED]" in str(service_logs["stderr"])
|
||||
|
||||
reset = manager.reset_workspace(workspace_id)
|
||||
assert reset["secrets"] == created["secrets"]
|
||||
|
||||
after_reset = manager.exec_workspace(
|
||||
workspace_id,
|
||||
command='sh -lc \'printf "%s\\n" "$API_TOKEN"\'',
|
||||
timeout_seconds=30,
|
||||
secret_env={"API_TOKEN": "API_TOKEN"},
|
||||
)
|
||||
assert after_reset["stdout"] == "[REDACTED]\n"
|
||||
|
||||
|
||||
def test_workspace_secret_validation_helpers(tmp_path: Path) -> None:
|
||||
manager = VmManager(
|
||||
backend_name="mock",
|
||||
base_dir=tmp_path / "vms",
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
assert vm_manager_module._normalize_workspace_secret_name("API_TOKEN") == "API_TOKEN" # noqa: SLF001
|
||||
with pytest.raises(ValueError, match="secret name must match"):
|
||||
vm_manager_module._normalize_workspace_secret_name("bad-name") # noqa: SLF001
|
||||
with pytest.raises(ValueError, match="must not be empty"):
|
||||
vm_manager_module._validate_workspace_secret_value("TOKEN", "") # noqa: SLF001
|
||||
with pytest.raises(ValueError, match="duplicate secret name"):
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
secrets=[
|
||||
{"name": "TOKEN", "value": "one"},
|
||||
{"name": "TOKEN", "value": "two"},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_prepare_workspace_secrets_handles_file_inputs_and_validation_errors(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
secrets_dir = tmp_path / "secrets"
|
||||
valid_file = tmp_path / "token.txt"
|
||||
valid_file.write_text("from-file\n", encoding="utf-8")
|
||||
invalid_utf8 = tmp_path / "invalid.bin"
|
||||
invalid_utf8.write_bytes(b"\xff\xfe")
|
||||
oversized = tmp_path / "oversized.txt"
|
||||
oversized.write_text(
|
||||
"x" * (vm_manager_module.WORKSPACE_SECRET_MAX_BYTES + 1),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
records, values = vm_manager_module._prepare_workspace_secrets( # noqa: SLF001
|
||||
[
|
||||
{"name": "B_TOKEN", "value": "literal"},
|
||||
{"name": "A_TOKEN", "file_path": str(valid_file)},
|
||||
],
|
||||
secrets_dir=secrets_dir,
|
||||
)
|
||||
assert [record.name for record in records] == ["A_TOKEN", "B_TOKEN"]
|
||||
assert values == {"A_TOKEN": "from-file\n", "B_TOKEN": "literal"}
|
||||
assert (secrets_dir / "A_TOKEN.secret").read_text(encoding="utf-8") == "from-file\n"
|
||||
assert oct(secrets_dir.stat().st_mode & 0o777) == "0o700"
|
||||
assert oct((secrets_dir / "A_TOKEN.secret").stat().st_mode & 0o777) == "0o600"
|
||||
|
||||
with pytest.raises(ValueError, match="must be a dictionary"):
|
||||
vm_manager_module._prepare_workspace_secrets( # noqa: SLF001
|
||||
[cast(dict[str, str], "bad")],
|
||||
secrets_dir=tmp_path / "bad1",
|
||||
)
|
||||
with pytest.raises(ValueError, match="missing 'name'"):
|
||||
vm_manager_module._prepare_workspace_secrets([{}], secrets_dir=tmp_path / "bad2") # noqa: SLF001
|
||||
with pytest.raises(ValueError, match="exactly one of 'value' or 'file_path'"):
|
||||
vm_manager_module._prepare_workspace_secrets( # noqa: SLF001
|
||||
[{"name": "TOKEN", "value": "x", "file_path": str(valid_file)}],
|
||||
secrets_dir=tmp_path / "bad3",
|
||||
)
|
||||
with pytest.raises(ValueError, match="file_path must not be empty"):
|
||||
vm_manager_module._prepare_workspace_secrets( # noqa: SLF001
|
||||
[{"name": "TOKEN", "file_path": " "}],
|
||||
secrets_dir=tmp_path / "bad4",
|
||||
)
|
||||
with pytest.raises(ValueError, match="does not exist"):
|
||||
vm_manager_module._prepare_workspace_secrets( # noqa: SLF001
|
||||
[{"name": "TOKEN", "file_path": str(tmp_path / "missing.txt")}],
|
||||
secrets_dir=tmp_path / "bad5",
|
||||
)
|
||||
with pytest.raises(ValueError, match="must be valid UTF-8 text"):
|
||||
vm_manager_module._prepare_workspace_secrets( # noqa: SLF001
|
||||
[{"name": "TOKEN", "file_path": str(invalid_utf8)}],
|
||||
secrets_dir=tmp_path / "bad6",
|
||||
)
|
||||
with pytest.raises(ValueError, match="must be at most"):
|
||||
vm_manager_module._prepare_workspace_secrets( # noqa: SLF001
|
||||
[{"name": "TOKEN", "file_path": str(oversized)}],
|
||||
secrets_dir=tmp_path / "bad7",
|
||||
)
|
||||
|
||||
|
||||
def test_workspace_secrets_require_guest_exec_on_firecracker_runtime(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class StubFirecrackerBackend:
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
del args, kwargs
|
||||
|
||||
def create(self, instance: Any) -> None:
|
||||
del instance
|
||||
|
||||
def start(self, instance: Any) -> None:
|
||||
del instance
|
||||
|
||||
def stop(self, instance: Any) -> None:
|
||||
del instance
|
||||
|
||||
def delete(self, instance: Any) -> None:
|
||||
del instance
|
||||
|
||||
monkeypatch.setattr(vm_manager_module, "FirecrackerBackend", StubFirecrackerBackend)
|
||||
manager = VmManager(
|
||||
backend_name="firecracker",
|
||||
base_dir=tmp_path / "vms",
|
||||
runtime_paths=resolve_runtime_paths(),
|
||||
network_manager=TapNetworkManager(enabled=False),
|
||||
)
|
||||
manager._runtime_capabilities = RuntimeCapabilities( # noqa: SLF001
|
||||
supports_vm_boot=True,
|
||||
supports_guest_exec=False,
|
||||
supports_guest_network=False,
|
||||
reason="guest exec is unavailable",
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="workspace secrets require guest execution"):
|
||||
manager.create_workspace(
|
||||
environment="debian:12-base",
|
||||
allow_host_compat=True,
|
||||
secrets=[{"name": "TOKEN", "value": "expected"}],
|
||||
)
|
||||
|
|
|
|||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -706,7 +706,7 @@ crypto = [
|
|||
|
||||
[[package]]
|
||||
name = "pyro-mcp"
|
||||
version = "2.8.0"
|
||||
version = "2.9.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "mcp" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue