agvisor 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agvisor-0.1.0/.gitignore +7 -0
- agvisor-0.1.0/LICENSE +21 -0
- agvisor-0.1.0/PKG-INFO +177 -0
- agvisor-0.1.0/README.md +164 -0
- agvisor-0.1.0/SECURITY.md +25 -0
- agvisor-0.1.0/pyproject.toml +21 -0
- agvisor-0.1.0/src/agvisor/__init__.py +5 -0
- agvisor-0.1.0/src/agvisor/desktop.py +41 -0
- agvisor-0.1.0/src/agvisor/limits.py +32 -0
- agvisor-0.1.0/src/agvisor/sandbox.py +253 -0
- agvisor-0.1.0/tests/test_args.py +243 -0
- agvisor-0.1.0/tests/test_gvisor_disk.py +19 -0
agvisor-0.1.0/.gitignore
ADDED
agvisor-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sadok Barbouche
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
agvisor-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agvisor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: gVisor sandboxes for coding agents, with resource limits and optional GUI
|
|
5
|
+
Author: Sadok Barbouche
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Provides-Extra: web
|
|
11
|
+
Requires-Dist: cryptography>=42; extra == 'web'
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# agvisor
|
|
15
|
+
|
|
16
|
+
gVisor (`runsc`) sandboxes for coding agents. Thin wrapper over the `docker` CLI: CPU/memory/pids limits, optional GNOME desktop over RDP.
|
|
17
|
+
|
|
18
|
+
> **Alpha, and not security-audited.** Read [Security](#security) before running code you don't trust.
|
|
19
|
+
|
|
20
|
+
## Host setup (Linux VM or server)
|
|
21
|
+
|
|
22
|
+
Install [gVisor](https://gvisor.dev/docs/user_guide/install/), then register the runtime in `/etc/docker/daemon.json` and `systemctl reload docker` (a *restart* bounces every running container unless `live-restore` is on).
|
|
23
|
+
The two `runtimeArgs` are only needed for GUI sandboxes (systemd needs cgroup v2, `sudo` needs suid). Register them as a **second** runtime, so headless sandboxes keep the strict one, and pass `runtime="runsc-gui"` with `gui=True`:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{"runtimes": {"runsc": {"path": "/usr/local/bin/runsc"},
|
|
27
|
+
"runsc-gui": {"path": "/usr/local/bin/runsc",
|
|
28
|
+
"runtimeArgs": ["--in-sandbox-cgroup=v2", "--allow-suid"]}}}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
(Putting the args on `runsc` itself also works and needs no `runtime=`, but then every sandbox on the host gets them.)
|
|
32
|
+
|
|
33
|
+
**On AWS/Azure/GCP VMs:** with `network="bridge"` a sandbox can reach the cloud metadata endpoint (`169.254.169.254`) and read the VM's instance credentials. Block it on the host (untested here): `sudo iptables -I DOCKER-USER -d 169.254.169.254 -j DROP`, or use `network="none"`.
|
|
34
|
+
|
|
35
|
+
GUI image (x86_64), from the [gVisor systemd post](https://gvisor.dev/blog/2026/09/17/systemd-in-gvisor/):
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
git clone https://github.com/google/gvisor.git && cd gvisor
|
|
39
|
+
docker build -t systemd-debian-gnome \
|
|
40
|
+
-f images/systemd-user/systemd-debian-gnome/Dockerfile.x86_64 images/systemd-user/systemd-debian-gnome
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The image is ~8 GB and the build takes ~30 min on 2 vCPUs (checked on a 2 vCPU / 4 GB VM: peak ~1.5 GB RAM). A desktop needs about 0.7 GB RAM idle, so give it `memory="1536m"` or more.
|
|
44
|
+
|
|
45
|
+
**Host firewall:** if yours drops traffic from `docker0` (e.g. `INPUT -i docker0 -j DROP`, a common way to keep sandboxes away from the host), the gateway can't reach a desktop on the default bridge: guacd hangs without an error. Create a network for desktops (`docker network create desktops`) and pass `network="desktops"`. Your egress rules don't apply to it, so don't run untrusted code there.
|
|
46
|
+
|
|
47
|
+
## Use
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from agvisor import Limits
|
|
51
|
+
from agvisor.sandbox import create_sandbox # creates AND starts the sandbox
|
|
52
|
+
|
|
53
|
+
SMALL = Limits(cpus=1, memory="512m", pids=128) # define once, reuse for any number of sandboxes
|
|
54
|
+
|
|
55
|
+
with create_sandbox(limits=SMALL, lifetime=3600) as sb: # headless
|
|
56
|
+
r = sb.exec("python3 -c 'print(1+1)'", timeout=30)
|
|
57
|
+
print(r.returncode, r.stdout)
|
|
58
|
+
|
|
59
|
+
with create_sandbox(gui=True, limits=Limits(cpus=4, memory="6g")) as sb: # desktop
|
|
60
|
+
print(f"RDP 127.0.0.1:{sb.rdp_port} user=gnome password={sb.password}")
|
|
61
|
+
input("press enter to destroy")
|
|
62
|
+
|
|
63
|
+
sb = create_sandbox(host="ssh://user@my-vm") # same thing, on a remote VM/server (e.g. EC2, Azure)
|
|
64
|
+
sb.stop() # without `with`, you stop it yourself
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
| param | meaning |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `image` | default `ubuntu:24.04`, or the GNOME image when `gui=True` |
|
|
70
|
+
| `gui` | GNOME desktop over RDP, with a random RDP password (not the image's `gnome:gnome`). `start()` waits for the desktop to boot: ~30-60 s |
|
|
71
|
+
| `runtime` | docker runtime, default `runsc`. With a separate GUI runtime: `"runsc-gui"`. Must be a gVisor runtime: plain `runc` is refused for `gui` (it runs `--privileged`, which is only simulated under gVisor) |
|
|
72
|
+
| `limits` | `Limits(cpus, memory, pids, shm, disk)`: default 2 cores, `2g`, 512 processes, docker's `/dev/shm` size, `5g` root disk. Zero or negative values are rejected (docker and runsc would read 0 as unlimited) |
|
|
73
|
+
| `network` | `"bridge"` (internet, default), `"none"` (headless only) or the name of a docker network you created |
|
|
74
|
+
| `lifetime` | seconds until the sandbox deletes itself, even if your process dies (headless only) |
|
|
75
|
+
| `idle_ttl` | seconds without an `exec()` before the sandbox deletes itself, even if your process dies (headless only). Expiry can lag by up to ~5s. See below |
|
|
76
|
+
| `name` | makes the sandbox resumable, see below |
|
|
77
|
+
| `host` | `None` = docker on this machine (or `$DOCKER_HOST`); `"ssh://user@host"` = docker on a remote VM/server |
|
|
78
|
+
|
|
79
|
+
`sb.exec(cmd, input=None, timeout=120)` runs `sh -c cmd` inside and returns a `CompletedProcess`; on timeout the command is killed and `returncode` is 124. That relies on GNU `timeout`, so use Debian/Ubuntu-based images (`python:3.12-slim`, `ubuntu`). On Alpine (busybox) the code is 143 and only the shell is killed: the children of a compound command (`a; b`) keep running. Also don't set an `ENTRYPOINT` in your image: agvisor starts it with `sleep infinity` as arguments.
|
|
80
|
+
|
|
81
|
+
Under gVisor, the limits behave differently from plain docker (checked on real `runsc`):
|
|
82
|
+
- `pids` budgets the sandbox runtime's *host threads*, not guest processes: an idle sandbox already uses ~35. Keep the default (512): at 64–128, a fork storm made `fork` fail with `Out of memory` and, pushed harder, killed the sandbox.
|
|
83
|
+
- Going over `memory` or `pids` kills the **whole sandbox**, not one process, and the host doesn't notice. Afterwards `exec` returns `returncode` 1 or 128 with `is not running` or `WaitPID` in stderr. Call `create_sandbox(name=...)` again: it starts a fresh container on the same `/workspace` volume.
|
|
84
|
+
- `disk` caps the **root filesystem** (runsc's `overlay2` annotation, checked on release-20261026.0): past it, writes fail with `No space left on device` and the sandbox keeps running. Whole number + `k/m/g/t`: `1.5g` and `2gb` are rejected. It does **not** cover `/tmp`, which gVisor backs with RAM (counted in `memory`), nor the `/workspace` volume of a named sandbox: that is a plain host directory with **no size cap**, so a runaway agent can fill the host's disk through it. Until that has a cap, keep `/var/lib/docker` on a disk of its own, and don't share it with anything you care about. `tests/test_gvisor_disk.py` checks the cap against real gVisor.
|
|
85
|
+
- Inside, `uname -r` reports `4.19.0-gvisor` and `/sys/fs/cgroup` and `/sys/class/net` don't exist (use `/proc/net/dev`).
|
|
86
|
+
|
|
87
|
+
### Resuming a sandbox
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
with create_sandbox("python:3.12", name="my-project") as sb: # session 1
|
|
91
|
+
sb.exec("git clone https://github.com/me/repo && python -m venv .venv && .venv/bin/pip install -r repo/requirements.txt")
|
|
92
|
+
# the container is gone (CPU/RAM freed), /workspace is kept
|
|
93
|
+
|
|
94
|
+
with create_sandbox("python:3.12", name="my-project") as sb: # session 2, days later
|
|
95
|
+
sb.exec("ls -a") # -> repo/ .venv/ (cwd is /workspace)
|
|
96
|
+
|
|
97
|
+
create_sandbox(name="my-project").delete() # forget it: erases the data too
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- **Kept:** everything under `sb.data_dir` (`/workspace`; the `gnome` home for `gui=True`), in a docker volume `agvisor-<name>`.
|
|
101
|
+
- **Not kept:** processes, and anything outside `data_dir` (e.g. `apt install`): gVisor discards the rootfs overlay when the container is destroyed. Work in `/workspace` (venvs, repos, caches), or bake system packages into your image.
|
|
102
|
+
- **Keep everything:** don't call `stop()`. The container stays running and the next `create_sandbox(name=...)` re-attaches to it with processes and installed packages intact. (`limits`/`image` are then ignored: they were fixed at creation.)
|
|
103
|
+
- The volume lives on the docker host. On a cloud VM, keep `/var/lib/docker` on a persistent disk (EBS / managed disk), not instance-store, and resume on the same VM. Don't set `--overlay2=all:*` in the `runsc` args: it would stop volumes persisting.
|
|
104
|
+
|
|
105
|
+
For a remote host the RDP port is on that host's `127.0.0.1`: tunnel it with `ssh -L`.
|
|
106
|
+
`network="none"` cuts network access (headless only: GUI needs the published port).
|
|
107
|
+
|
|
108
|
+
### Expiry and cleanup
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from agvisor import list_sandboxes
|
|
112
|
+
|
|
113
|
+
sb = create_sandbox(name="u42", idle_ttl=900, lifetime=4 * 3600) # gone after 15 idle minutes, or 4h at most
|
|
114
|
+
for sb in list_sandboxes(host="ssh://user@my-vm"): # every agvisor sandbox on that docker host
|
|
115
|
+
print(sb.name); sb.stop() # exec(), stop() and delete() work on these handles
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
- `idle_ttl`: the clock restarts when each `exec()` starts and ends; background processes don't count. `exec(timeout=)` must be shorter than `idle_ttl` (else `ValueError`), so a command can't outlive the sandbox. It is fixed at creation, like `limits`.
|
|
119
|
+
- A named sandbox that expires keeps its files: the next `create_sandbox(name=...)` makes a fresh container on the same volume. `delete()` is what erases them: run it from your own retention job.
|
|
120
|
+
- `list_sandboxes()` also finds anonymous sandboxes left behind by a crashed process. Don't `start()` a stopped handle: it would make a fresh default-image container.
|
|
121
|
+
|
|
122
|
+
## GUI in a web page (React, Next.js, Vue, plain HTML)
|
|
123
|
+
|
|
124
|
+
Browsers can't speak RDP, so there is a gateway in between: `browser <-wss-> gateway/gateway.js <-> guacd <-RDP-> sandbox`.
|
|
125
|
+
Start both on the docker host with the compose file (`pip install 'agvisor[web]'` for the token helper):
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
export AGVISOR_KEY=$(openssl rand -hex 32) # shared by the gateway and your backend; compose refuses to start without it
|
|
129
|
+
docker compose up -d --build # guacd + gateway, both on 127.0.0.1; gateway on :8090 (GATEWAY_PORT to change)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The compose file pins both images by digest, binds `guacd` to loopback (its image defaults to `0.0.0.0`, an open RDP proxy on host networking), and runs the gateway as a non-root, read-only container. Linux only: both use the host network. Without Docker: `cd gateway && npm install && npm start`, plus `guacd` bound to `127.0.0.1`.
|
|
133
|
+
|
|
134
|
+
Put the gateway behind your TLS reverse proxy (so the page uses `wss://`) and your auth. In your backend, after the user is authorised:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
sb = create_sandbox(gui=True)
|
|
138
|
+
token = sb.desktop_token(bytes.fromhex(os.environ["AGVISOR_KEY"]), ttl=60) # server-side only: it holds the RDP password
|
|
139
|
+
# return {"gateway": "wss://your.host/desktop", "token": token} to the page
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
In the page, give the element a size and pass both values (the component is `web/`, npm package `agvisor-desktop`):
|
|
143
|
+
|
|
144
|
+
```jsx
|
|
145
|
+
import "agvisor-desktop"; // React / Next.js ('use client'): safe to import during SSR, it does nothing on the server
|
|
146
|
+
<agvisor-desktop gateway={gateway} token={token} style={{ width: "100%", height: 600 }} />
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
```html
|
|
150
|
+
<!-- Vue: compilerOptions.isCustomElement = (tag) => tag === "agvisor-desktop"; plain HTML: <script type="module"> -->
|
|
151
|
+
<agvisor-desktop :gateway="gateway" :token="token" style="width:100%;height:600px"></agvisor-desktop>
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
A new `token` reconnects. The element fires an `error` event (`e.detail` = message) when the connection fails or drops, e.g. a rejected or expired token: mint a fresh one and set it. TypeScript needs a JSX `IntrinsicElements` entry for the tag.
|
|
155
|
+
A token only has to be valid when the session *opens*, but anyone holding it can open one until `ttl` runs out.
|
|
156
|
+
In `web/`: `npm test` checks the token round trip against the real gateway with a fake guacd; `npm run test:browser` runs the component in real Chrome (render, mouse, keyboard, resize, errors, cleanup).
|
|
157
|
+
|
|
158
|
+
## Security
|
|
159
|
+
|
|
160
|
+
**This is alpha software and nobody has audited it.** gVisor shrinks the part of the kernel that sandboxed code can reach; it is not a guarantee, and this library's own code has not been reviewed for flaws. Don't use it as the boundary for hostile multi-tenant workloads without a review of your own, and keep `runsc` up to date (tested with release-20261026.0). Known gaps:
|
|
161
|
+
|
|
162
|
+
- **Whoever can run `docker` on the host controls every sandbox**, and is effectively root on that host. agvisor adds no authentication or authorization: put your own in front of it.
|
|
163
|
+
- **`name=` has no ownership check.** `create_sandbox(name="x")` re-attaches to whatever running container is called `agvisor-x`, removes a stopped one of that name, and `list_sandboxes()` returns every sandbox on the host. If a tenant can choose the name, they can attach to, run commands in, stop and `delete()` another tenant's sandbox. Build names yourself (e.g. `f"{tenant_id}-{run_id}"`) and check ownership before you pass them in.
|
|
164
|
+
- **`/workspace` has no size cap.** `Limits(disk=)` only caps the root filesystem. The `/workspace` volume of a named sandbox is a plain host directory, so a runaway or hostile process can fill the host's disk through it. Anonymous sandboxes have no volume: their writes fall under `disk` (and RAM-backed `/tmp` under `memory`). For named ones, put `/var/lib/docker` on a disk of its own, so a full disk doesn't take other services down with it.
|
|
165
|
+
- **Sandboxes on one docker network can reach each other.** That is Docker's default: every sandbox on `"bridge"` (the default) or on a network you share between tenants can connect to the others' open ports. Use `network="none"`, a separate network per tenant or run, or a network created with `-o com.docker.network.bridge.enable_icc=false` (from Docker's documentation; not tested with agvisor).
|
|
166
|
+
- **`network="bridge"` also reaches the outside:** the internet, services listening on the host itself (through the bridge gateway), and on a cloud VM the metadata endpoint that serves instance credentials (see Host setup). Block what you don't want on the host's firewall, or use `network="none"`.
|
|
167
|
+
- **`gui=True` runs `--privileged`.** Under gVisor that only grants simulated capabilities; under plain `runc` it would be real, so agvisor refuses `runc` for GUI sandboxes. The RDP password is random per sandbox and the gateway token is encrypted and authenticated, but anyone holding a token can open a session until it expires.
|
|
168
|
+
|
|
169
|
+
To report a vulnerability, email sadokbarbouche1@outlook.com instead of opening a public issue (details in `SECURITY.md`).
|
|
170
|
+
|
|
171
|
+
## CI and dependency updates
|
|
172
|
+
|
|
173
|
+
- `.github/workflows/ci.yml` runs on every PR, every push to `main`, and weekly (new advisories appear without commits):
|
|
174
|
+
`test` (Python tests, the gateway token round trip, and the component in real Chrome), `audit` (`pip-audit` on the locked Python deps, `npm audit` + registry-signature check on `web/`), `semgrep` (code scan of the Python, the JS and the workflows).
|
|
175
|
+
- `.github/dependabot.yml` opens weekly update PRs for `uv`, `npm` and the GitHub Actions themselves (minor+patch grouped, 7-day cooldown). Those PRs run the same checks.
|
|
176
|
+
- Not configurable from a workflow, set these in the repo settings: make `test`, `audit` and `semgrep` required checks on `main`, and turn on Dependabot alerts.
|
|
177
|
+
- Run it locally: `uv run python tests/test_args.py`, then in `web/`: `npm test` and `npm run test:browser` (after `npm ci` in both `web/` and `gateway/`), plus `uvx pip-audit …`, `uvx semgrep …` (exact commands in `ci.yml`).
|
agvisor-0.1.0/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# agvisor
|
|
2
|
+
|
|
3
|
+
gVisor (`runsc`) sandboxes for coding agents. Thin wrapper over the `docker` CLI: CPU/memory/pids limits, optional GNOME desktop over RDP.
|
|
4
|
+
|
|
5
|
+
> **Alpha, and not security-audited.** Read [Security](#security) before running code you don't trust.
|
|
6
|
+
|
|
7
|
+
## Host setup (Linux VM or server)
|
|
8
|
+
|
|
9
|
+
Install [gVisor](https://gvisor.dev/docs/user_guide/install/), then register the runtime in `/etc/docker/daemon.json` and `systemctl reload docker` (a *restart* bounces every running container unless `live-restore` is on).
|
|
10
|
+
The two `runtimeArgs` are only needed for GUI sandboxes (systemd needs cgroup v2, `sudo` needs suid). Register them as a **second** runtime, so headless sandboxes keep the strict one, and pass `runtime="runsc-gui"` with `gui=True`:
|
|
11
|
+
|
|
12
|
+
```json
|
|
13
|
+
{"runtimes": {"runsc": {"path": "/usr/local/bin/runsc"},
|
|
14
|
+
"runsc-gui": {"path": "/usr/local/bin/runsc",
|
|
15
|
+
"runtimeArgs": ["--in-sandbox-cgroup=v2", "--allow-suid"]}}}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
(Putting the args on `runsc` itself also works and needs no `runtime=`, but then every sandbox on the host gets them.)
|
|
19
|
+
|
|
20
|
+
**On AWS/Azure/GCP VMs:** with `network="bridge"` a sandbox can reach the cloud metadata endpoint (`169.254.169.254`) and read the VM's instance credentials. Block it on the host (untested here): `sudo iptables -I DOCKER-USER -d 169.254.169.254 -j DROP`, or use `network="none"`.
|
|
21
|
+
|
|
22
|
+
GUI image (x86_64), from the [gVisor systemd post](https://gvisor.dev/blog/2026/09/17/systemd-in-gvisor/):
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
git clone https://github.com/google/gvisor.git && cd gvisor
|
|
26
|
+
docker build -t systemd-debian-gnome \
|
|
27
|
+
-f images/systemd-user/systemd-debian-gnome/Dockerfile.x86_64 images/systemd-user/systemd-debian-gnome
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The image is ~8 GB and the build takes ~30 min on 2 vCPUs (checked on a 2 vCPU / 4 GB VM: peak ~1.5 GB RAM). A desktop needs about 0.7 GB RAM idle, so give it `memory="1536m"` or more.
|
|
31
|
+
|
|
32
|
+
**Host firewall:** if yours drops traffic from `docker0` (e.g. `INPUT -i docker0 -j DROP`, a common way to keep sandboxes away from the host), the gateway can't reach a desktop on the default bridge: guacd hangs without an error. Create a network for desktops (`docker network create desktops`) and pass `network="desktops"`. Your egress rules don't apply to it, so don't run untrusted code there.
|
|
33
|
+
|
|
34
|
+
## Use
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from agvisor import Limits
|
|
38
|
+
from agvisor.sandbox import create_sandbox # creates AND starts the sandbox
|
|
39
|
+
|
|
40
|
+
SMALL = Limits(cpus=1, memory="512m", pids=128) # define once, reuse for any number of sandboxes
|
|
41
|
+
|
|
42
|
+
with create_sandbox(limits=SMALL, lifetime=3600) as sb: # headless
|
|
43
|
+
r = sb.exec("python3 -c 'print(1+1)'", timeout=30)
|
|
44
|
+
print(r.returncode, r.stdout)
|
|
45
|
+
|
|
46
|
+
with create_sandbox(gui=True, limits=Limits(cpus=4, memory="6g")) as sb: # desktop
|
|
47
|
+
print(f"RDP 127.0.0.1:{sb.rdp_port} user=gnome password={sb.password}")
|
|
48
|
+
input("press enter to destroy")
|
|
49
|
+
|
|
50
|
+
sb = create_sandbox(host="ssh://user@my-vm") # same thing, on a remote VM/server (e.g. EC2, Azure)
|
|
51
|
+
sb.stop() # without `with`, you stop it yourself
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
| param | meaning |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `image` | default `ubuntu:24.04`, or the GNOME image when `gui=True` |
|
|
57
|
+
| `gui` | GNOME desktop over RDP, with a random RDP password (not the image's `gnome:gnome`). `start()` waits for the desktop to boot: ~30-60 s |
|
|
58
|
+
| `runtime` | docker runtime, default `runsc`. With a separate GUI runtime: `"runsc-gui"`. Must be a gVisor runtime: plain `runc` is refused for `gui` (it runs `--privileged`, which is only simulated under gVisor) |
|
|
59
|
+
| `limits` | `Limits(cpus, memory, pids, shm, disk)`: default 2 cores, `2g`, 512 processes, docker's `/dev/shm` size, `5g` root disk. Zero or negative values are rejected (docker and runsc would read 0 as unlimited) |
|
|
60
|
+
| `network` | `"bridge"` (internet, default), `"none"` (headless only) or the name of a docker network you created |
|
|
61
|
+
| `lifetime` | seconds until the sandbox deletes itself, even if your process dies (headless only) |
|
|
62
|
+
| `idle_ttl` | seconds without an `exec()` before the sandbox deletes itself, even if your process dies (headless only). Expiry can lag by up to ~5s. See below |
|
|
63
|
+
| `name` | makes the sandbox resumable, see below |
|
|
64
|
+
| `host` | `None` = docker on this machine (or `$DOCKER_HOST`); `"ssh://user@host"` = docker on a remote VM/server |
|
|
65
|
+
|
|
66
|
+
`sb.exec(cmd, input=None, timeout=120)` runs `sh -c cmd` inside and returns a `CompletedProcess`; on timeout the command is killed and `returncode` is 124. That relies on GNU `timeout`, so use Debian/Ubuntu-based images (`python:3.12-slim`, `ubuntu`). On Alpine (busybox) the code is 143 and only the shell is killed: the children of a compound command (`a; b`) keep running. Also don't set an `ENTRYPOINT` in your image: agvisor starts it with `sleep infinity` as arguments.
|
|
67
|
+
|
|
68
|
+
Under gVisor, the limits behave differently from plain docker (checked on real `runsc`):
|
|
69
|
+
- `pids` budgets the sandbox runtime's *host threads*, not guest processes: an idle sandbox already uses ~35. Keep the default (512): at 64–128, a fork storm made `fork` fail with `Out of memory` and, pushed harder, killed the sandbox.
|
|
70
|
+
- Going over `memory` or `pids` kills the **whole sandbox**, not one process, and the host doesn't notice. Afterwards `exec` returns `returncode` 1 or 128 with `is not running` or `WaitPID` in stderr. Call `create_sandbox(name=...)` again: it starts a fresh container on the same `/workspace` volume.
|
|
71
|
+
- `disk` caps the **root filesystem** (runsc's `overlay2` annotation, checked on release-20261026.0): past it, writes fail with `No space left on device` and the sandbox keeps running. Whole number + `k/m/g/t`: `1.5g` and `2gb` are rejected. It does **not** cover `/tmp`, which gVisor backs with RAM (counted in `memory`), nor the `/workspace` volume of a named sandbox: that is a plain host directory with **no size cap**, so a runaway agent can fill the host's disk through it. Until that has a cap, keep `/var/lib/docker` on a disk of its own, and don't share it with anything you care about. `tests/test_gvisor_disk.py` checks the cap against real gVisor.
|
|
72
|
+
- Inside, `uname -r` reports `4.19.0-gvisor` and `/sys/fs/cgroup` and `/sys/class/net` don't exist (use `/proc/net/dev`).
|
|
73
|
+
|
|
74
|
+
### Resuming a sandbox
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
with create_sandbox("python:3.12", name="my-project") as sb: # session 1
|
|
78
|
+
sb.exec("git clone https://github.com/me/repo && python -m venv .venv && .venv/bin/pip install -r repo/requirements.txt")
|
|
79
|
+
# the container is gone (CPU/RAM freed), /workspace is kept
|
|
80
|
+
|
|
81
|
+
with create_sandbox("python:3.12", name="my-project") as sb: # session 2, days later
|
|
82
|
+
sb.exec("ls -a") # -> repo/ .venv/ (cwd is /workspace)
|
|
83
|
+
|
|
84
|
+
create_sandbox(name="my-project").delete() # forget it: erases the data too
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
- **Kept:** everything under `sb.data_dir` (`/workspace`; the `gnome` home for `gui=True`), in a docker volume `agvisor-<name>`.
|
|
88
|
+
- **Not kept:** processes, and anything outside `data_dir` (e.g. `apt install`): gVisor discards the rootfs overlay when the container is destroyed. Work in `/workspace` (venvs, repos, caches), or bake system packages into your image.
|
|
89
|
+
- **Keep everything:** don't call `stop()`. The container stays running and the next `create_sandbox(name=...)` re-attaches to it with processes and installed packages intact. (`limits`/`image` are then ignored: they were fixed at creation.)
|
|
90
|
+
- The volume lives on the docker host. On a cloud VM, keep `/var/lib/docker` on a persistent disk (EBS / managed disk), not instance-store, and resume on the same VM. Don't set `--overlay2=all:*` in the `runsc` args: it would stop volumes persisting.
|
|
91
|
+
|
|
92
|
+
For a remote host the RDP port is on that host's `127.0.0.1`: tunnel it with `ssh -L`.
|
|
93
|
+
`network="none"` cuts network access (headless only: GUI needs the published port).
|
|
94
|
+
|
|
95
|
+
### Expiry and cleanup
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from agvisor import list_sandboxes
|
|
99
|
+
|
|
100
|
+
sb = create_sandbox(name="u42", idle_ttl=900, lifetime=4 * 3600) # gone after 15 idle minutes, or 4h at most
|
|
101
|
+
for sb in list_sandboxes(host="ssh://user@my-vm"): # every agvisor sandbox on that docker host
|
|
102
|
+
print(sb.name); sb.stop() # exec(), stop() and delete() work on these handles
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
- `idle_ttl`: the clock restarts when each `exec()` starts and ends; background processes don't count. `exec(timeout=)` must be shorter than `idle_ttl` (else `ValueError`), so a command can't outlive the sandbox. It is fixed at creation, like `limits`.
|
|
106
|
+
- A named sandbox that expires keeps its files: the next `create_sandbox(name=...)` makes a fresh container on the same volume. `delete()` is what erases them: run it from your own retention job.
|
|
107
|
+
- `list_sandboxes()` also finds anonymous sandboxes left behind by a crashed process. Don't `start()` a stopped handle: it would make a fresh default-image container.
|
|
108
|
+
|
|
109
|
+
## GUI in a web page (React, Next.js, Vue, plain HTML)
|
|
110
|
+
|
|
111
|
+
Browsers can't speak RDP, so there is a gateway in between: `browser <-wss-> gateway/gateway.js <-> guacd <-RDP-> sandbox`.
|
|
112
|
+
Start both on the docker host with the compose file (`pip install 'agvisor[web]'` for the token helper):
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
export AGVISOR_KEY=$(openssl rand -hex 32) # shared by the gateway and your backend; compose refuses to start without it
|
|
116
|
+
docker compose up -d --build # guacd + gateway, both on 127.0.0.1; gateway on :8090 (GATEWAY_PORT to change)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The compose file pins both images by digest, binds `guacd` to loopback (its image defaults to `0.0.0.0`, an open RDP proxy on host networking), and runs the gateway as a non-root, read-only container. Linux only: both use the host network. Without Docker: `cd gateway && npm install && npm start`, plus `guacd` bound to `127.0.0.1`.
|
|
120
|
+
|
|
121
|
+
Put the gateway behind your TLS reverse proxy (so the page uses `wss://`) and your auth. In your backend, after the user is authorised:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
sb = create_sandbox(gui=True)
|
|
125
|
+
token = sb.desktop_token(bytes.fromhex(os.environ["AGVISOR_KEY"]), ttl=60) # server-side only: it holds the RDP password
|
|
126
|
+
# return {"gateway": "wss://your.host/desktop", "token": token} to the page
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
In the page, give the element a size and pass both values (the component is `web/`, npm package `agvisor-desktop`):
|
|
130
|
+
|
|
131
|
+
```jsx
|
|
132
|
+
import "agvisor-desktop"; // React / Next.js ('use client'): safe to import during SSR, it does nothing on the server
|
|
133
|
+
<agvisor-desktop gateway={gateway} token={token} style={{ width: "100%", height: 600 }} />
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```html
|
|
137
|
+
<!-- Vue: compilerOptions.isCustomElement = (tag) => tag === "agvisor-desktop"; plain HTML: <script type="module"> -->
|
|
138
|
+
<agvisor-desktop :gateway="gateway" :token="token" style="width:100%;height:600px"></agvisor-desktop>
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
A new `token` reconnects. The element fires an `error` event (`e.detail` = message) when the connection fails or drops, e.g. a rejected or expired token: mint a fresh one and set it. TypeScript needs a JSX `IntrinsicElements` entry for the tag.
|
|
142
|
+
A token only has to be valid when the session *opens*, but anyone holding it can open one until `ttl` runs out.
|
|
143
|
+
In `web/`: `npm test` checks the token round trip against the real gateway with a fake guacd; `npm run test:browser` runs the component in real Chrome (render, mouse, keyboard, resize, errors, cleanup).
|
|
144
|
+
|
|
145
|
+
## Security
|
|
146
|
+
|
|
147
|
+
**This is alpha software and nobody has audited it.** gVisor shrinks the part of the kernel that sandboxed code can reach; it is not a guarantee, and this library's own code has not been reviewed for flaws. Don't use it as the boundary for hostile multi-tenant workloads without a review of your own, and keep `runsc` up to date (tested with release-20261026.0). Known gaps:
|
|
148
|
+
|
|
149
|
+
- **Whoever can run `docker` on the host controls every sandbox**, and is effectively root on that host. agvisor adds no authentication or authorization: put your own in front of it.
|
|
150
|
+
- **`name=` has no ownership check.** `create_sandbox(name="x")` re-attaches to whatever running container is called `agvisor-x`, removes a stopped one of that name, and `list_sandboxes()` returns every sandbox on the host. If a tenant can choose the name, they can attach to, run commands in, stop and `delete()` another tenant's sandbox. Build names yourself (e.g. `f"{tenant_id}-{run_id}"`) and check ownership before you pass them in.
|
|
151
|
+
- **`/workspace` has no size cap.** `Limits(disk=)` only caps the root filesystem. The `/workspace` volume of a named sandbox is a plain host directory, so a runaway or hostile process can fill the host's disk through it. Anonymous sandboxes have no volume: their writes fall under `disk` (and RAM-backed `/tmp` under `memory`). For named ones, put `/var/lib/docker` on a disk of its own, so a full disk doesn't take other services down with it.
|
|
152
|
+
- **Sandboxes on one docker network can reach each other.** That is Docker's default: every sandbox on `"bridge"` (the default) or on a network you share between tenants can connect to the others' open ports. Use `network="none"`, a separate network per tenant or run, or a network created with `-o com.docker.network.bridge.enable_icc=false` (from Docker's documentation; not tested with agvisor).
|
|
153
|
+
- **`network="bridge"` also reaches the outside:** the internet, services listening on the host itself (through the bridge gateway), and on a cloud VM the metadata endpoint that serves instance credentials (see Host setup). Block what you don't want on the host's firewall, or use `network="none"`.
|
|
154
|
+
- **`gui=True` runs `--privileged`.** Under gVisor that only grants simulated capabilities; under plain `runc` it would be real, so agvisor refuses `runc` for GUI sandboxes. The RDP password is random per sandbox and the gateway token is encrypted and authenticated, but anyone holding a token can open a session until it expires.
|
|
155
|
+
|
|
156
|
+
To report a vulnerability, email sadokbarbouche1@outlook.com instead of opening a public issue (details in `SECURITY.md`).
|
|
157
|
+
|
|
158
|
+
## CI and dependency updates
|
|
159
|
+
|
|
160
|
+
- `.github/workflows/ci.yml` runs on every PR, every push to `main`, and weekly (new advisories appear without commits):
|
|
161
|
+
`test` (Python tests, the gateway token round trip, and the component in real Chrome), `audit` (`pip-audit` on the locked Python deps, `npm audit` + registry-signature check on `web/`), `semgrep` (code scan of the Python, the JS and the workflows).
|
|
162
|
+
- `.github/dependabot.yml` opens weekly update PRs for `uv`, `npm` and the GitHub Actions themselves (minor+patch grouped, 7-day cooldown). Those PRs run the same checks.
|
|
163
|
+
- Not configurable from a workflow, set these in the repo settings: make `test`, `audit` and `semgrep` required checks on `main`, and turn on Dependabot alerts.
|
|
164
|
+
- Run it locally: `uv run python tests/test_args.py`, then in `web/`: `npm test` and `npm run test:browser` (after `npm ci` in both `web/` and `gateway/`), plus `uvx pip-audit …`, `uvx semgrep …` (exact commands in `ci.yml`).
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
agvisor is alpha software and has not been security-audited. The README's "Security" section lists the known gaps.
|
|
4
|
+
|
|
5
|
+
## Supported versions
|
|
6
|
+
|
|
7
|
+
Only the latest release gets fixes (0.x: no backports).
|
|
8
|
+
|
|
9
|
+
## Reporting a vulnerability
|
|
10
|
+
|
|
11
|
+
Please don't open a public issue for a vulnerability: it would tell everyone about the flaw before there is a fix. Email **sadokbarbouche1@outlook.com** with the version, steps to reproduce, and the impact (for example a sandbox escape, a bypassed limit, a forged or replayed gateway token, or an injection through `image`, `name`, `network` or `runtime`).
|
|
12
|
+
|
|
13
|
+
For ordinary bugs, questions and feature requests, open a GitHub issue.
|
|
14
|
+
|
|
15
|
+
## What to expect
|
|
16
|
+
|
|
17
|
+
An acknowledgement within 5 business days. This is a one-person project: I'll aim to ship a fix or a mitigation within 90 days, and credit you in the release notes if you want.
|
|
18
|
+
|
|
19
|
+
## Scope
|
|
20
|
+
|
|
21
|
+
In scope: `src/agvisor/`, `gateway/` and `web/`.
|
|
22
|
+
|
|
23
|
+
Out of scope:
|
|
24
|
+
- Bugs in gVisor, Docker, guacd or GNOME: report those upstream.
|
|
25
|
+
- Gaps the README's Security section already documents (no `/workspace` size cap, no `name=` ownership check, sandboxes on one network reaching each other).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "agvisor"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "gVisor sandboxes for coding agents, with resource limits and optional GUI"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "Sadok Barbouche" }]
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
classifiers = ["Development Status :: 3 - Alpha"] # PyPI badge; the README says what "alpha" means here (Security section)
|
|
11
|
+
dependencies = []
|
|
12
|
+
|
|
13
|
+
[project.optional-dependencies]
|
|
14
|
+
web = ["cryptography>=42"] # only for Sandbox.desktop_token()
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["hatchling>=1.27"] # SPDX `license = "MIT"` (PEP 639) needs 1.27+
|
|
18
|
+
build-backend = "hatchling.build"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.sdist]
|
|
21
|
+
include = ["/src", "/tests", "/README.md", "/LICENSE", "/SECURITY.md"] # the Python package only: not gateway/, web/, CI files
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""GUI sandbox specifics: the GNOME-over-RDP image details and the token for gateway/gateway.js."""
|
|
2
|
+
import base64
|
|
3
|
+
import hmac
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
GUI_IMAGE = "systemd-debian-gnome" # built from the gVisor repo, see README
|
|
9
|
+
GUI_USER = "gnome"
|
|
10
|
+
GUI_HOME = f"/home/{GUI_USER}" # persisted for named gui sandboxes (assumed home of GUI_USER: verify on the image)
|
|
11
|
+
RDP_PORT = 3389
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def make_token(key: bytes, rdp_port: int, password: str, ttl: int = 300) -> str:
|
|
15
|
+
"""Token for <agvisor-desktop> (needs `pip install agvisor[web]`). Valid for `ttl` seconds to *open* a session.
|
|
16
|
+
|
|
17
|
+
`key` is the 32-byte secret gateway/gateway.js also has. guacamole-lite can only decrypt AES-CBC, which does
|
|
18
|
+
not detect tampering: a client could flip IV bits and turn `{"expiration": 1...` into `9...`, making an
|
|
19
|
+
expired token last for centuries. So the token is encrypt-then-MAC: gateway.js checks the HMAC first.
|
|
20
|
+
"""
|
|
21
|
+
from cryptography.hazmat.primitives import padding
|
|
22
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
23
|
+
|
|
24
|
+
if len(key) != 32:
|
|
25
|
+
raise ValueError("key must be exactly 32 bytes (same as AGVISOR_KEY, hex-decoded)")
|
|
26
|
+
# one secret, two purposes: derive separate AES and HMAC keys (gateway.js derives them the same way)
|
|
27
|
+
enc_key, mac_key = (hmac.digest(key, label, "sha256") for label in (b"agvisor-enc", b"agvisor-mac"))
|
|
28
|
+
|
|
29
|
+
msg = json.dumps({
|
|
30
|
+
"expiration": int((time.time() + ttl) * 1000), # checked by gateway.js
|
|
31
|
+
"connection": {"type": "rdp", "settings": {
|
|
32
|
+
"hostname": "127.0.0.1", "port": str(rdp_port), # guacd runs on the docker host
|
|
33
|
+
"username": GUI_USER, "password": password,
|
|
34
|
+
"security": "any", "ignore-cert": True, "resize-method": "display-update"}},
|
|
35
|
+
}).encode()
|
|
36
|
+
iv, pad = os.urandom(16), padding.PKCS7(128).padder()
|
|
37
|
+
enc = Cipher(algorithms.AES(enc_key), modes.CBC(iv)).encryptor()
|
|
38
|
+
ct = enc.update(pad.update(msg) + pad.finalize()) + enc.finalize()
|
|
39
|
+
mac = hmac.HMAC(mac_key, iv + ct, "sha256").digest() # over the exact bytes gateway.js will verify
|
|
40
|
+
b64 = lambda b: base64.b64encode(b).decode() # guacamole-lite token format, plus `mac` (it ignores it)
|
|
41
|
+
return b64(json.dumps({"iv": b64(iv), "value": b64(ct), "mac": b64(mac)}).encode())
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class Limits:
|
|
7
|
+
"""Resource limits, enforced by gVisor/cgroups on the machine hosting the sandbox.
|
|
8
|
+
|
|
9
|
+
cpus CPU cores, fractions allowed (0.5).
|
|
10
|
+
memory Hard RAM cap in docker syntax ("512m", "4g"). Swap is disabled.
|
|
11
|
+
pids Max processes/threads (stops fork bombs).
|
|
12
|
+
shm /dev/shm size ("1g"). Default: docker's 64m, or 1g for gui.
|
|
13
|
+
disk Hard cap on the root filesystem: everything the sandbox writes outside /workspace and /tmp ("5g").
|
|
14
|
+
A whole number + optional k/m/g/t (no decimals, no "gb"). It does NOT cover the /workspace volume of a
|
|
15
|
+
named sandbox (a plain host directory: unbounded), and /tmp is RAM, counted in `memory`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
cpus: float = 2.0
|
|
19
|
+
memory: str = "2g"
|
|
20
|
+
pids: int = 512
|
|
21
|
+
shm: str | None = None
|
|
22
|
+
disk: str = "5g"
|
|
23
|
+
|
|
24
|
+
def __post_init__(self):
|
|
25
|
+
# docker reads 0 as "unlimited": 0 must not silently remove a cap the caller asked for
|
|
26
|
+
mem = re.match(r"\d+(\.\d+)?", str(self.memory))
|
|
27
|
+
if not (self.cpus > 0 and self.pids > 0 and mem and float(mem.group()) > 0):
|
|
28
|
+
raise ValueError(f"cpus, memory and pids must be > 0, got {self}")
|
|
29
|
+
# runsc reads "0" as unlimited and rejects "1.5g"/"2gb" only when the container starts: fail here instead
|
|
30
|
+
disk = re.fullmatch(r"(\d+)[kmgtKMGT]?", str(self.disk))
|
|
31
|
+
if not (disk and int(disk[1]) > 0):
|
|
32
|
+
raise ValueError(f"disk must be a whole number > 0 plus optional k/m/g/t (e.g. '5g'), got {self.disk!r}")
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import codecs
|
|
2
|
+
import collections
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import secrets
|
|
6
|
+
import subprocess
|
|
7
|
+
import uuid
|
|
8
|
+
|
|
9
|
+
from .desktop import GUI_HOME, GUI_IMAGE, GUI_USER, RDP_PORT, make_token
|
|
10
|
+
from .limits import Limits
|
|
11
|
+
|
|
12
|
+
_KEEP = 100_000 # exec(on_output=) keeps only this many trailing characters: the rest already went to on_output
|
|
13
|
+
_MARK = "/tmp/.agvisor_active" # idle_ttl: every exec() touches it; PID 1 exits once it is older than idle_ttl
|
|
14
|
+
# gui: RDP checks GNOME Remote Desktop's own credentials file, which the image bakes as gnome:gnome, NOT the unix
|
|
15
|
+
# password. Run as GUI_USER with the file on stdin and its path as $1: store ours, wait for the desktop to boot
|
|
16
|
+
# (up to 120 s), restart the RDP service (it reads the file only at startup), wait until it listens on 3389 (0D3D).
|
|
17
|
+
_RDP_CREDS = f"{GUI_HOME}/.local/share/gnome-remote-desktop/credentials.ini"
|
|
18
|
+
_RDP_SETUP = r"""set -e
|
|
19
|
+
umask 077
|
|
20
|
+
cat > "$1"
|
|
21
|
+
export XDG_RUNTIME_DIR=/run/user/$(id -u)
|
|
22
|
+
S=gnome-remote-desktop-headless.service
|
|
23
|
+
wait_for() { n=0; until "$@"; do n=$((n+1)); [ $n -le 120 ] || { echo "desktop not ready after 120s: $*" >&2; exit 1; }; sleep 1; done; }
|
|
24
|
+
wait_for systemctl --user is-active --quiet $S
|
|
25
|
+
systemctl --user restart $S
|
|
26
|
+
wait_for grep -qE ':0D3D [0-9A-F:]+ 0A' /proc/net/tcp /proc/net/tcp6
|
|
27
|
+
"""
|
|
28
|
+
_RUNC = ("runc", "io.containerd.runc.v2") # --privileged (gui) is only simulated under gVisor, real under these
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Sandbox:
|
|
32
|
+
"""host=None -> local docker; host="ssh://user@vm" -> docker on a remote VM/server."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, image=None, *, gui=False, limits=Limits(), network="bridge", lifetime=None,
|
|
35
|
+
idle_ttl=None, name=None, host=None, runtime="runsc"):
|
|
36
|
+
self.gui, self.limits, self.network, self.lifetime, self.host = gui, limits, network, lifetime, host
|
|
37
|
+
self.idle_ttl, self.runtime = idle_ttl, runtime
|
|
38
|
+
# strict like `name`: it ends up in --runtime=. And never plain runc for a gui: it is run --privileged
|
|
39
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", runtime) or (gui and runtime in _RUNC):
|
|
40
|
+
raise ValueError(f"invalid runtime {runtime!r}: must be a gVisor (runsc) runtime registered in docker")
|
|
41
|
+
self.image = image or (GUI_IMAGE if gui else "ubuntu:24.04")
|
|
42
|
+
if self.image.startswith("-"): # `docker run ... IMAGE` would parse it as a flag, e.g. --privileged
|
|
43
|
+
raise ValueError(f"invalid image name: {self.image!r}")
|
|
44
|
+
if lifetime is not None and (gui or lifetime <= 0):
|
|
45
|
+
raise ValueError("lifetime must be > 0 seconds and is only supported for headless sandboxes")
|
|
46
|
+
if idle_ttl is not None and (gui or idle_ttl <= 0):
|
|
47
|
+
raise ValueError("idle_ttl must be > 0 seconds and is only supported for headless sandboxes")
|
|
48
|
+
# strict: `name` ends up in --name and --volume, where a ',' or ':' would inject mount options
|
|
49
|
+
if name is not None and not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,62}", name):
|
|
50
|
+
raise ValueError(f"invalid name {name!r}: letters, digits, '_', '.', '-' only (max 63)")
|
|
51
|
+
self.persistent = name is not None
|
|
52
|
+
self.name = f"agvisor-{name or uuid.uuid4().hex[:8]}" # also the name of the data volume
|
|
53
|
+
self.data_dir = (GUI_HOME if gui else "/workspace") if self.persistent else None # survives stop()
|
|
54
|
+
self.rdp_port = self.password = None # set by start() when gui; user is GUI_USER
|
|
55
|
+
self._running = False
|
|
56
|
+
|
|
57
|
+
def _docker(self, *args, input=None, check=True):
|
|
58
|
+
cmd = ["docker", *(["-H", self.host] if self.host else []), *args]
|
|
59
|
+
r = subprocess.run(cmd, input=input, capture_output=True, text=True)
|
|
60
|
+
if check and r.returncode:
|
|
61
|
+
raise RuntimeError(r.stderr.strip())
|
|
62
|
+
return r
|
|
63
|
+
|
|
64
|
+
def _stream(self, *args, on_output):
|
|
65
|
+
"""Like _docker, but calls on_output(text) as each chunk arrives. stderr is merged into stdout."""
|
|
66
|
+
cmd = ["docker", *(["-H", self.host] if self.host else []), *args]
|
|
67
|
+
p = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
|
68
|
+
dec, tail, kept = codecs.getincrementaldecoder("utf-8")("replace"), collections.deque(), 0 # a chunk can end mid-character
|
|
69
|
+
while chunk := os.read(p.stdout.fileno(), 4096):
|
|
70
|
+
if text := dec.decode(chunk):
|
|
71
|
+
tail.append(text)
|
|
72
|
+
kept += len(text)
|
|
73
|
+
while kept - len(tail[0]) >= _KEEP: # a runaway `yes` must not fill our memory
|
|
74
|
+
kept -= len(tail.popleft())
|
|
75
|
+
on_output(text)
|
|
76
|
+
p.wait()
|
|
77
|
+
return subprocess.CompletedProcess(cmd, p.returncode, "".join(tail), "")
|
|
78
|
+
|
|
79
|
+
def run_args(self):
|
|
80
|
+
l = self.limits
|
|
81
|
+
a = ["run", "-d", f"--runtime={self.runtime}", f"--name={self.name}",
|
|
82
|
+
f"--cpus={l.cpus}", f"--memory={l.memory}", f"--memory-swap={l.memory}",
|
|
83
|
+
f"--pids-limit={l.pids}", f"--network={self.network}",
|
|
84
|
+
# gVisor caps the rootfs overlay (a file on the host disk): writes past it get ENOSPC. Per-container,
|
|
85
|
+
# no daemon setting needed. `root:` only: `all:` would also overlay /workspace and stop it persisting.
|
|
86
|
+
f"--annotation=dev.gvisor.flag.overlay2=root:self,size={l.disk}"]
|
|
87
|
+
if self.lifetime or self.idle_ttl: # PID 1 ends on its own: the container exits, then --rm deletes it, even if we crash
|
|
88
|
+
a.append("--rm")
|
|
89
|
+
a.append("--label=agvisor=1") # what list_sandboxes() finds
|
|
90
|
+
if self.persistent:
|
|
91
|
+
a.append(f"--label=agvisor.name={self.name.removeprefix('agvisor-')}")
|
|
92
|
+
if self.data_dir: # a docker volume is a host directory: it is outside gVisor's discarded rootfs overlay
|
|
93
|
+
a.append(f"--volume={self.name}:{self.data_dir}")
|
|
94
|
+
if not self.gui:
|
|
95
|
+
a.append(f"--workdir={self.data_dir}") # relative paths in exec() land on persistent storage
|
|
96
|
+
if self.gui: # --privileged only grants simulated caps inside gVisor, not host access
|
|
97
|
+
a += ["--privileged", f"--shm-size={l.shm or '1g'}", f"--publish=127.0.0.1::{RDP_PORT}"]
|
|
98
|
+
elif l.shm:
|
|
99
|
+
a.append(f"--shm-size={l.shm}")
|
|
100
|
+
if not self.gui: # PID 1 is `sleep`, which never reaps orphans: they stay zombies and eat the pids limit
|
|
101
|
+
a.append("--init") # (gui: systemd is PID 1 and reaps)
|
|
102
|
+
return a + [self.image] + self._pid1()
|
|
103
|
+
|
|
104
|
+
def _pid1(self):
|
|
105
|
+
if self.gui:
|
|
106
|
+
return []
|
|
107
|
+
if not self.idle_ttl:
|
|
108
|
+
return ["sleep", str(self.lifetime or "infinity")]
|
|
109
|
+
# exits when the marker is older than idle_ttl (checked every 5s); a missing marker counts as expired.
|
|
110
|
+
# `timeout` adds lifetime as a hard cap on top.
|
|
111
|
+
watch = (f"touch {_MARK}; while [ $(( $(date +%s) - $(stat -c %Y {_MARK} 2>/dev/null || echo 0) )) "
|
|
112
|
+
f"-lt {self.idle_ttl} ]; do sleep 5; done")
|
|
113
|
+
return (["timeout", str(self.lifetime)] if self.lifetime else []) + ["sh", "-c", watch]
|
|
114
|
+
|
|
115
|
+
def _left_running(self):
|
|
116
|
+
"""A named container an earlier session never stopped: resume it, processes and all."""
|
|
117
|
+
if not self.persistent:
|
|
118
|
+
return False
|
|
119
|
+
r = self._docker("inspect", "-f", "{{.State.Running}}", self.name, check=False)
|
|
120
|
+
return r.stdout.strip() == "true"
|
|
121
|
+
|
|
122
|
+
def start(self):
|
|
123
|
+
if self._running: # create_sandbox() already started it, then `with` calls start() again
|
|
124
|
+
return self
|
|
125
|
+
resumed = self._left_running() # limits/image args are ignored then: they were fixed at creation
|
|
126
|
+
if not resumed:
|
|
127
|
+
if self.persistent: # a stopped leftover would block the name; its data lives in the volume
|
|
128
|
+
self._docker("rm", "-f", self.name, check=False)
|
|
129
|
+
self._docker(*self.run_args())
|
|
130
|
+
self._running = True # the container exists from here, so stop() must clean up if setup fails
|
|
131
|
+
try:
|
|
132
|
+
if self.gui: # random host port + random password instead of the image's gnome:gnome
|
|
133
|
+
out = self._docker("port", self.name, f"{RDP_PORT}/tcp").stdout
|
|
134
|
+
self.rdp_port = int(out.splitlines()[0].rsplit(":", 1)[1])
|
|
135
|
+
self.password = secrets.token_urlsafe(12) # urlsafe: no quote that could break the file below
|
|
136
|
+
self._docker("exec", "-i", "-u", "root", self.name, "chpasswd",
|
|
137
|
+
input=f"{GUI_USER}:{self.password}\n") # sudo / console login
|
|
138
|
+
creds = f"[RDP]\ncredentials={{'username': <'{GUI_USER}'>, 'password': <'{self.password}'>}}\n"
|
|
139
|
+
self._docker("exec", "-i", "-u", GUI_USER, self.name, "sh", "-c", _RDP_SETUP, "sh", _RDP_CREDS,
|
|
140
|
+
input=creds) # RDP login; stdin keeps the password out of `ps`. Returns once RDP listens
|
|
141
|
+
except BaseException:
|
|
142
|
+
if resumed: # never destroy a container (and its processes) that we merely re-attached to
|
|
143
|
+
self._running = False
|
|
144
|
+
else:
|
|
145
|
+
self.stop()
|
|
146
|
+
raise
|
|
147
|
+
return self
|
|
148
|
+
|
|
149
|
+
def exec(self, cmd, *, input=None, timeout=120, on_output=None):
|
|
150
|
+
"""Run a shell command inside. Returns CompletedProcess; returncode 124 = timed out.
|
|
151
|
+
`on_output(text)` is called with output as it is produced (stderr merged into stdout, which then holds
|
|
152
|
+
only the last ~100 KB; it must not raise, and can't be combined with `input`).
|
|
153
|
+
That needs GNU `timeout`, i.e. a Debian/Ubuntu-based image. Alpine's busybox returns 143 instead and kills
|
|
154
|
+
only the shell: the children of a compound command (`a; b`) keep running."""
|
|
155
|
+
if self.idle_ttl:
|
|
156
|
+
if timeout + 6 >= self.idle_ttl: # +5 = the kill grace below, +1 = the watchdog compares whole seconds
|
|
157
|
+
raise ValueError(f"timeout={timeout}s must stay below idle_ttl={self.idle_ttl}s, "
|
|
158
|
+
"or the sandbox could expire in the middle of the command")
|
|
159
|
+
# activity = an exec starting or ending (the trap also fires on `exit`)
|
|
160
|
+
cmd = f"trap 'touch {_MARK} 2>/dev/null' EXIT; touch {_MARK} 2>/dev/null; {cmd}"
|
|
161
|
+
argv = ["timeout", "-k", "5", str(timeout), "sh", "-c", cmd] # kills inside, not just the client
|
|
162
|
+
if on_output:
|
|
163
|
+
if input is not None:
|
|
164
|
+
raise ValueError("on_output can't be combined with input")
|
|
165
|
+
return self._stream("exec", "-i", self.name, *argv, on_output=on_output)
|
|
166
|
+
return self._docker("exec", "-i", self.name, *argv, input=input, check=False)
|
|
167
|
+
|
|
168
|
+
def desktop_token(self, key: bytes, ttl=300):
|
|
169
|
+
"""Token for <agvisor-desktop>. Mint it server-side only: it carries the RDP password.
|
|
170
|
+
Replayable until `ttl` runs out."""
|
|
171
|
+
if self.rdp_port is None:
|
|
172
|
+
raise ValueError("start() a gui=True sandbox first")
|
|
173
|
+
return make_token(key, self.rdp_port, self.password, ttl)
|
|
174
|
+
|
|
175
|
+
def stop(self):
|
|
176
|
+
"""Remove the container and free its CPU/RAM. A named sandbox keeps its data volume."""
|
|
177
|
+
self._docker("rm", "-f", self.name, check=False)
|
|
178
|
+
self._running = False
|
|
179
|
+
|
|
180
|
+
def delete(self):
|
|
181
|
+
"""stop() and also erase the persistent data, so create_sandbox(name=...) starts from scratch."""
|
|
182
|
+
self.stop()
|
|
183
|
+
if self.persistent:
|
|
184
|
+
self._docker("volume", "rm", "-f", self.name, check=False)
|
|
185
|
+
|
|
186
|
+
def __enter__(self):
|
|
187
|
+
return self.start()
|
|
188
|
+
|
|
189
|
+
def __exit__(self, *_):
|
|
190
|
+
self.stop()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def list_sandboxes(host: str | None = None) -> list[Sandbox]:
|
|
194
|
+
"""Every agvisor sandbox on that docker host, running or not, as handles: exec(), stop() and delete() work.
|
|
195
|
+
Anonymous ones (leaked by a crashed process) are included, so this is also how you find and kill them.
|
|
196
|
+
Don't start() a stopped handle: that makes a fresh default-image container."""
|
|
197
|
+
out = Sandbox(host=host)._docker("ps", "-a", "--filter", "label=agvisor",
|
|
198
|
+
"--format", '{{.Names}} {{.State}} {{.Label "agvisor.name"}}').stdout
|
|
199
|
+
boxes = []
|
|
200
|
+
for line in out.splitlines():
|
|
201
|
+
cname, state, *label = line.split() # an empty label leaves no third field
|
|
202
|
+
sb = Sandbox(name=label[0] if label else None, host=host)
|
|
203
|
+
if not label:
|
|
204
|
+
sb.name = cname # anonymous: its random container name is all there is
|
|
205
|
+
sb._running = state == "running"
|
|
206
|
+
boxes.append(sb)
|
|
207
|
+
return boxes
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def create_sandbox(
|
|
211
|
+
image: str | None = None,
|
|
212
|
+
*,
|
|
213
|
+
gui: bool = False,
|
|
214
|
+
limits: Limits = Limits(),
|
|
215
|
+
network: str = "bridge",
|
|
216
|
+
lifetime: int | None = None,
|
|
217
|
+
idle_ttl: int | None = None,
|
|
218
|
+
name: str | None = None,
|
|
219
|
+
host: str | None = None,
|
|
220
|
+
runtime: str = "runsc",
|
|
221
|
+
) -> Sandbox:
|
|
222
|
+
"""Create and start a gVisor sandbox. Use `with create_sandbox(...) as sb:` or call sb.stop() yourself.
|
|
223
|
+
|
|
224
|
+
image Image to run. Default: ubuntu:24.04, or the GNOME desktop image when gui=True.
|
|
225
|
+
gui True -> GNOME desktop over RDP (sb.rdp_port, sb.password, sb.desktop_token()). start() returns once the
|
|
226
|
+
desktop has booted and RDP listens (~30-60 s).
|
|
227
|
+
limits Limits(cpus, memory, pids, shm, disk). Default: 2 cores, 2g RAM, 512 processes, 5g root disk.
|
|
228
|
+
Reusable across sandboxes. `disk` doesn't cover the /workspace volume of a named sandbox.
|
|
229
|
+
name Makes the sandbox resumable. Files in sb.data_dir (/workspace; the gnome home when gui) are kept
|
|
230
|
+
in a docker volume on the host machine and survive stop(), `with` exit and lifetime expiry.
|
|
231
|
+
create_sandbox(name=same) later gets them back: it re-attaches to the container if an earlier
|
|
232
|
+
session left it running (processes and installed packages intact), else makes a fresh one on the
|
|
233
|
+
same volume. sb.delete() erases the data. Default: anonymous, nothing is kept.
|
|
234
|
+
There is NO ownership check: anyone who passes the same name gets that sandbox, so namespace
|
|
235
|
+
names per tenant yourself. The volume has no size cap either (limits.disk covers the rest).
|
|
236
|
+
|
|
237
|
+
network "bridge" (internet access, default), "none" (headless only: gui needs its RDP port) or the name of a
|
|
238
|
+
docker network you created. A host firewall that drops traffic from docker0 blocks the gateway from
|
|
239
|
+
reaching a gui sandbox there: use a network of its own.
|
|
240
|
+
runtime The docker runtime, default "runsc". Name a second one (e.g. "runsc-gui", same runsc plus the GUI
|
|
241
|
+
flags) to keep the strict one for headless sandboxes. Must be a gVisor runtime: plain runc is
|
|
242
|
+
refused for gui, which runs --privileged.
|
|
243
|
+
lifetime Seconds until the sandbox destroys itself, even if your process dies. Headless only.
|
|
244
|
+
Default: lives until stop().
|
|
245
|
+
idle_ttl Seconds without an exec() before the sandbox destroys itself, even if your process dies. The clock
|
|
246
|
+
restarts when each exec() starts and ends; background processes don't count. exec(timeout=) must be
|
|
247
|
+
shorter. A named sandbox keeps its files, and create_sandbox(name=same) later makes a fresh
|
|
248
|
+
container. Combine with lifetime for a hard cap. Headless only. Fixed at creation.
|
|
249
|
+
host Which machine supplies the resources. None -> docker on this machine (or $DOCKER_HOST).
|
|
250
|
+
"ssh://user@host" -> docker on a remote VM/server, e.g. an EC2 or Azure VM.
|
|
251
|
+
"""
|
|
252
|
+
return Sandbox(image, gui=gui, limits=limits, network=network, lifetime=lifetime, idle_ttl=idle_ttl,
|
|
253
|
+
name=name, host=host, runtime=runtime).start()
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import threading
|
|
4
|
+
from unittest import mock
|
|
5
|
+
|
|
6
|
+
from agvisor import Limits, Sandbox
|
|
7
|
+
from agvisor.sandbox import _RDP_SETUP, create_sandbox, list_sandboxes
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_headless():
|
|
11
|
+
a = Sandbox(limits=Limits(cpus=1, memory="512m", pids=64)).run_args()
|
|
12
|
+
assert {"--runtime=runsc", "--cpus=1", "--memory=512m", "--memory-swap=512m", "--pids-limit=64"} <= set(a)
|
|
13
|
+
assert "--privileged" not in a and a[-2:] == ["sleep", "infinity"]
|
|
14
|
+
assert "--init" in a # a real PID 1 reaps orphans; `sleep` would leave zombies that eat the pids limit
|
|
15
|
+
assert "--annotation=dev.gvisor.flag.overlay2=root:self,size=5g" in a # default disk cap
|
|
16
|
+
a = Sandbox(limits=Limits(disk="100M"), gui=True).run_args()
|
|
17
|
+
assert "--annotation=dev.gvisor.flag.overlay2=root:self,size=100M" in a # gui too; root only, never `all:`
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_gui_start():
|
|
21
|
+
s = Sandbox(gui=True)
|
|
22
|
+
a = s.run_args()
|
|
23
|
+
assert "--privileged" in a and "--publish=127.0.0.1::3389" in a and a[-1] == "systemd-debian-gnome"
|
|
24
|
+
assert "--init" not in a # systemd is PID 1 here
|
|
25
|
+
calls = []
|
|
26
|
+
|
|
27
|
+
def fake(*args, input=None, **_):
|
|
28
|
+
calls.append((args, input))
|
|
29
|
+
return subprocess.CompletedProcess(args, 0, "127.0.0.1:49153\n[::]:49153\n", "")
|
|
30
|
+
|
|
31
|
+
s._docker = fake
|
|
32
|
+
s.start()
|
|
33
|
+
chpasswd, rdp = calls[-2], calls[-1]
|
|
34
|
+
assert s.rdp_port == 49153 and chpasswd[1] == f"gnome:{s.password}\n" # sudo / console password
|
|
35
|
+
# RDP checks GNOME Remote Desktop's own file (baked as gnome:gnome), not the unix password: it must get ours
|
|
36
|
+
assert rdp[0][:5] == ("exec", "-i", "-u", "gnome", s.name) and rdp[0][-1].endswith("/gnome-remote-desktop/credentials.ini")
|
|
37
|
+
assert rdp[1] == f"[RDP]\ncredentials={{'username': <'gnome'>, 'password': <'{s.password}'>}}\n"
|
|
38
|
+
assert not any(s.password in str(a) for a in rdp[0]) # stdin only: a command line shows up in `ps`
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_gui_setup_failure_cleans_up():
|
|
42
|
+
calls, run = fake_docker(fail=(_RDP_SETUP,)) # e.g. the desktop never booted
|
|
43
|
+
with mock.patch("agvisor.sandbox.subprocess.run", run):
|
|
44
|
+
try:
|
|
45
|
+
create_sandbox(gui=True)
|
|
46
|
+
except RuntimeError:
|
|
47
|
+
pass
|
|
48
|
+
else:
|
|
49
|
+
raise AssertionError("expected the RDP setup failure to propagate")
|
|
50
|
+
assert calls[-1][1:3] == ["rm", "-f"] # a fresh container that never became usable is not left running
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_runtime():
|
|
54
|
+
assert "--runtime=runsc" in Sandbox().run_args()
|
|
55
|
+
assert "--runtime=runsc-gui" in Sandbox(gui=True, runtime="runsc-gui").run_args()
|
|
56
|
+
for bad in ({"runtime": "--privileged"}, {"runtime": "runsc,x"}, {"runtime": ""},
|
|
57
|
+
{"gui": True, "runtime": "runc"}, {"gui": True, "runtime": "io.containerd.runc.v2"}): # gui = --privileged
|
|
58
|
+
try:
|
|
59
|
+
Sandbox(**bad)
|
|
60
|
+
except ValueError:
|
|
61
|
+
continue
|
|
62
|
+
raise AssertionError(f"accepted {bad}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_create_sandbox_starts_once():
|
|
66
|
+
calls = []
|
|
67
|
+
|
|
68
|
+
def fake_run(cmd, **_):
|
|
69
|
+
calls.append(cmd)
|
|
70
|
+
return subprocess.CompletedProcess(cmd, 0, "", "")
|
|
71
|
+
|
|
72
|
+
with mock.patch("agvisor.sandbox.subprocess.run", fake_run):
|
|
73
|
+
with create_sandbox(limits=Limits(cpus=1, memory="512m", pids=64), host="ssh://u@vm") as sb:
|
|
74
|
+
assert sb._running # `with` calls start() again: must be a no-op
|
|
75
|
+
assert calls[0][:3] == ["docker", "-H", "ssh://u@vm"] # host= routes docker to the remote machine
|
|
76
|
+
assert [c[3] for c in calls] == ["run", "rm"] # one docker run, then cleanup
|
|
77
|
+
assert {"--cpus=1", "--memory=512m", "--pids-limit=64"} <= set(calls[0]) # flat params reach docker
|
|
78
|
+
assert not sb._running
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_limits_reject_unlimited():
|
|
82
|
+
Limits(cpus=0.5, memory="512m", pids=1) # valid
|
|
83
|
+
for ok in ("1", "100k", "512M", "5g", "1T"): # what runsc accepted on a real VM
|
|
84
|
+
Limits(disk=ok)
|
|
85
|
+
bad_disk = [{"disk": d} for d in ("0", "0g", "", "1.5g", "2gb", "-1g", "lots", " 1g", "1g\n")] # 0 = unlimited in runsc
|
|
86
|
+
for bad in ({"cpus": 0}, {"memory": "0"}, {"memory": "0g"}, {"memory": "lots"}, {"pids": 0}, {"pids": -1}, *bad_disk):
|
|
87
|
+
try:
|
|
88
|
+
Limits(**bad)
|
|
89
|
+
except ValueError:
|
|
90
|
+
continue
|
|
91
|
+
raise AssertionError(f"accepted {bad}: docker would treat it as unlimited or fail late")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_lifetime_and_validation():
|
|
95
|
+
a = Sandbox(lifetime=60).run_args()
|
|
96
|
+
assert "--rm" in a and a[-2:] == ["sleep", "60"]
|
|
97
|
+
assert "--rm" not in Sandbox().run_args()
|
|
98
|
+
for bad in ({"image": "--privileged"}, {"lifetime": 0}, {"gui": True, "lifetime": 60}):
|
|
99
|
+
try:
|
|
100
|
+
Sandbox(**bad)
|
|
101
|
+
except ValueError:
|
|
102
|
+
continue
|
|
103
|
+
raise AssertionError(f"accepted {bad}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def fake_docker(inspect_out="", fail=(), ps_out=""):
|
|
107
|
+
"""Patchable subprocess.run: records commands; `fail` = words that make a command exit 1."""
|
|
108
|
+
calls = []
|
|
109
|
+
|
|
110
|
+
def run(cmd, **_):
|
|
111
|
+
calls.append(cmd)
|
|
112
|
+
out = {"inspect": inspect_out, "port": "127.0.0.1:49153\n", "ps": ps_out}.get(cmd[1], "")
|
|
113
|
+
bad = any(w in cmd for w in fail)
|
|
114
|
+
return subprocess.CompletedProcess(cmd, 1 if bad else 0, out, "boom" if bad else "")
|
|
115
|
+
|
|
116
|
+
return calls, run
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_named_sandbox_mounts_volume():
|
|
120
|
+
a = Sandbox(name="proj").run_args()
|
|
121
|
+
assert "--name=agvisor-proj" in a and "--volume=agvisor-proj:/workspace" in a and "--workdir=/workspace" in a
|
|
122
|
+
g = Sandbox(name="proj", gui=True).run_args()
|
|
123
|
+
assert "--volume=agvisor-proj:/home/gnome" in g and not any(x.startswith("--workdir") for x in g)
|
|
124
|
+
assert not any(x.startswith("--volume") for x in Sandbox().run_args()) # anonymous: nothing persisted
|
|
125
|
+
for bad in ("", "-x", "a b", "a,type=bind,src=/", "a:/etc", "../x", "x" * 64):
|
|
126
|
+
try:
|
|
127
|
+
Sandbox(name=bad)
|
|
128
|
+
except ValueError:
|
|
129
|
+
continue
|
|
130
|
+
raise AssertionError(f"accepted name {bad!r}")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_resume_and_delete():
|
|
134
|
+
calls, run = fake_docker(inspect_out="false\n") # earlier container is gone/stopped: fresh one, same volume
|
|
135
|
+
with mock.patch("agvisor.sandbox.subprocess.run", run):
|
|
136
|
+
sb = create_sandbox(name="proj")
|
|
137
|
+
sb.delete()
|
|
138
|
+
assert [c[1] for c in calls] == ["inspect", "rm", "run", "rm", "volume"]
|
|
139
|
+
assert calls[-1][1:] == ["volume", "rm", "-f", "agvisor-proj"]
|
|
140
|
+
|
|
141
|
+
calls, run = fake_docker(inspect_out="true\n") # left running: re-attach, never docker run
|
|
142
|
+
with mock.patch("agvisor.sandbox.subprocess.run", run):
|
|
143
|
+
sb = create_sandbox(name="proj")
|
|
144
|
+
assert [c[1] for c in calls] == ["inspect"] and sb._running
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_failed_resume_keeps_the_container():
|
|
148
|
+
calls, run = fake_docker(inspect_out="true\n", fail=("chpasswd",))
|
|
149
|
+
with mock.patch("agvisor.sandbox.subprocess.run", run):
|
|
150
|
+
try:
|
|
151
|
+
create_sandbox(name="proj", gui=True)
|
|
152
|
+
except RuntimeError:
|
|
153
|
+
pass
|
|
154
|
+
else:
|
|
155
|
+
raise AssertionError("expected the chpasswd failure to propagate")
|
|
156
|
+
assert "rm" not in [c[1] for c in calls] # we re-attached to someone's container: don't destroy it
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_idle_ttl():
|
|
160
|
+
a = Sandbox(idle_ttl=60).run_args()
|
|
161
|
+
assert "--rm" in a and a[-3:-1] == ["sh", "-c"] and "-lt 60 ]" in a[-1] # PID 1 is the idle watchdog
|
|
162
|
+
a = Sandbox(idle_ttl=60, lifetime=3600).run_args() # lifetime stays a hard cap on top of the idle clock
|
|
163
|
+
assert a[-5:-1] == ["timeout", "3600", "sh", "-c"]
|
|
164
|
+
for bad in ({"idle_ttl": 0}, {"idle_ttl": -5}, {"idle_ttl": 60, "gui": True}):
|
|
165
|
+
try:
|
|
166
|
+
Sandbox(**bad)
|
|
167
|
+
except ValueError:
|
|
168
|
+
continue
|
|
169
|
+
raise AssertionError(f"accepted {bad}")
|
|
170
|
+
|
|
171
|
+
sb, seen = Sandbox(idle_ttl=30), []
|
|
172
|
+
sb._docker = lambda *args, **_: seen.append(args) or subprocess.CompletedProcess(args, 0, "", "")
|
|
173
|
+
sb.exec("ls", timeout=10)
|
|
174
|
+
assert "/tmp/.agvisor_active" in seen[0][-1] and seen[0][-1].endswith("; ls") # exec resets the idle clock
|
|
175
|
+
try:
|
|
176
|
+
sb.exec("ls", timeout=30) # could still be running when the sandbox expires
|
|
177
|
+
except ValueError:
|
|
178
|
+
pass
|
|
179
|
+
else:
|
|
180
|
+
raise AssertionError("exec timeout >= idle_ttl must be rejected")
|
|
181
|
+
assert len(seen) == 1 # ...before any docker call
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_list_sandboxes():
|
|
185
|
+
assert "--label=agvisor=1" in Sandbox().run_args() # what list_sandboxes() filters on
|
|
186
|
+
assert "--label=agvisor.name=proj" in Sandbox(name="proj").run_args()
|
|
187
|
+
assert not any(x.startswith("--label=agvisor.name") for x in Sandbox().run_args())
|
|
188
|
+
|
|
189
|
+
calls, run = fake_docker(ps_out="agvisor-proj running proj\nagvisor-ab12cd34 exited \n")
|
|
190
|
+
with mock.patch("agvisor.sandbox.subprocess.run", run):
|
|
191
|
+
named, anon = list_sandboxes()
|
|
192
|
+
anon.stop() # a leaked anonymous sandbox can be found and killed
|
|
193
|
+
assert calls[0][1:5] == ["ps", "-a", "--filter", "label=agvisor"]
|
|
194
|
+
assert (named.name, named.persistent, named._running) == ("agvisor-proj", True, True)
|
|
195
|
+
assert (anon.name, anon.persistent, anon._running) == ("agvisor-ab12cd34", False, False)
|
|
196
|
+
assert calls[-1][1:] == ["rm", "-f", "agvisor-ab12cd34"]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_exec_streams_output():
|
|
200
|
+
r, w = os.pipe()
|
|
201
|
+
first, got = threading.Event(), []
|
|
202
|
+
|
|
203
|
+
def writer(): # the first chunk ends in the middle of "é": it must not come out garbled
|
|
204
|
+
os.write(w, b"h\xc3")
|
|
205
|
+
first.wait(5)
|
|
206
|
+
os.write(w, b"\xa9llo\n")
|
|
207
|
+
os.close(w)
|
|
208
|
+
|
|
209
|
+
class Popen:
|
|
210
|
+
def __init__(self, cmd, **_):
|
|
211
|
+
self.cmd, self.stdout, self.returncode = cmd, os.fdopen(r, "rb"), 3
|
|
212
|
+
|
|
213
|
+
def wait(self):
|
|
214
|
+
return self.returncode
|
|
215
|
+
|
|
216
|
+
sb = Sandbox(name="proj", host="ssh://u@vm")
|
|
217
|
+
threading.Thread(target=writer).start()
|
|
218
|
+
with mock.patch("agvisor.sandbox.subprocess.Popen", Popen):
|
|
219
|
+
res = sb.exec("echo hi", on_output=lambda t: (got.append(t), first.set()))
|
|
220
|
+
assert got == ["h", "\u00e9llo\n"] and (res.returncode, res.stdout) == (3, "h\u00e9llo\n")
|
|
221
|
+
assert res.args[:3] == ["docker", "-H", "ssh://u@vm"] and res.args[3:6] == ["exec", "-i", "agvisor-proj"]
|
|
222
|
+
try:
|
|
223
|
+
sb.exec("cat", input="x", on_output=print)
|
|
224
|
+
except ValueError:
|
|
225
|
+
return
|
|
226
|
+
raise AssertionError("accepted stdin together with on_output")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
if __name__ == "__main__":
|
|
230
|
+
test_headless()
|
|
231
|
+
test_gui_start()
|
|
232
|
+
test_gui_setup_failure_cleans_up()
|
|
233
|
+
test_runtime()
|
|
234
|
+
test_create_sandbox_starts_once()
|
|
235
|
+
test_limits_reject_unlimited()
|
|
236
|
+
test_lifetime_and_validation()
|
|
237
|
+
test_named_sandbox_mounts_volume()
|
|
238
|
+
test_resume_and_delete()
|
|
239
|
+
test_failed_resume_keeps_the_container()
|
|
240
|
+
test_idle_ttl()
|
|
241
|
+
test_list_sandboxes()
|
|
242
|
+
test_exec_streams_output()
|
|
243
|
+
print("ok")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Needs a docker host with real gVisor: the disk cap is a runsc feature, so CI (no runsc) can't run this.
|
|
2
|
+
|
|
3
|
+
AGVISOR_HOST=ssh://user@vm AGVISOR_IMAGE=node:22-alpine uv run python tests/test_gvisor_disk.py
|
|
4
|
+
|
|
5
|
+
AGVISOR_HOST unset -> docker on this machine. AGVISOR_IMAGE: any image already on that host (avoids a pull).
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
from agvisor import Limits, create_sandbox
|
|
10
|
+
|
|
11
|
+
host, image = os.environ.get("AGVISOR_HOST"), os.environ.get("AGVISOR_IMAGE")
|
|
12
|
+
# /var/tmp, not /tmp: gVisor mounts a RAM-backed tmpfs on /tmp, which never touches the disk cap
|
|
13
|
+
write = "dd if=/dev/zero of=/var/tmp/big bs=1M count=300 2>&1; du -m /var/tmp/big"
|
|
14
|
+
|
|
15
|
+
with create_sandbox(image, limits=Limits(memory="512m", disk="100m"), network="none", host=host) as sb:
|
|
16
|
+
out = sb.exec(write, timeout=60).stdout
|
|
17
|
+
assert "No space left on device" in out, out # stopped by the cap, not by RAM or the host disk
|
|
18
|
+
assert out.rstrip().endswith("100\t/var/tmp/big"), out # ...at exactly the cap
|
|
19
|
+
print("ok")
|