marin-shellbox 0.2.151.dev37846966940__tar.gz → 0.2.153.dev37866254792__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.
- marin_shellbox-0.2.151.dev37846966940/README.md → marin_shellbox-0.2.153.dev37866254792/PKG-INFO +31 -6
- marin_shellbox-0.2.151.dev37846966940/PKG-INFO → marin_shellbox-0.2.153.dev37866254792/README.md +11 -26
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/pyproject.toml +2 -2
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/local/machine.py +25 -70
- marin_shellbox-0.2.153.dev37866254792/src/shellbox/backends/local/python_environment.py +118 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/shellsim/machine.py +22 -1
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/test_local_machine.py +44 -28
- marin_shellbox-0.2.153.dev37866254792/tests/test_shellsim_machine.py +106 -0
- marin_shellbox-0.2.151.dev37846966940/tests/test_shellsim_machine.py +0 -55
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/.gitignore +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/benchmarks/tasktrove/compare.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/benchmarks/tasktrove/sample.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/__init__.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/agent.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/__init__.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/daytona/__init__.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/daytona/machine.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/docker/__init__.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/docker/machine.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/gvisor/__init__.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/gvisor/machine.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/iris/__init__.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/iris/machine.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/local/__init__.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/local/launch.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/qemu/__init__.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/qemu/bundle.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/qemu/environment.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/qemu/guest/disk-init +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/qemu/guest/init +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/qemu/guest/pty-agent.c +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/qemu/image.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/qemu/machine.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/shellsim/__init__.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/shellsim/environment.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/image.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/machine.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/harbor_smoke.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/manual/README.md +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/manual/check_machine.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/manual/check_prepared_image.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/manual/check_qemu_isolation.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/manual/check_shell.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/manual/task/instruction.md +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/manual/task/task.toml +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/manual/task/tests/test.sh +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/manual/tasktrove_nl2bash_command.sh +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/test_daytona_machine.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/test_docker_machine.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/test_gvisor_machine.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/test_harbor_shellsim.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/test_image_cache.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/test_iris_machine.py +0 -0
- {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/test_qemu_machine.py +0 -0
marin_shellbox-0.2.151.dev37846966940/README.md → marin_shellbox-0.2.153.dev37866254792/PKG-INFO
RENAMED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: marin-shellbox
|
|
3
|
+
Version: 0.2.153.dev37866254792
|
|
4
|
+
Summary: Shell execution machines with local and remote backends
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: bubblewrap-bin==0.11.0; sys_platform == 'linux'
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Provides-Extra: daytona
|
|
9
|
+
Requires-Dist: daytona==0.200.2; extra == 'daytona'
|
|
10
|
+
Requires-Dist: marin-rigging>=0.2.0; extra == 'daytona'
|
|
11
|
+
Provides-Extra: gvisor
|
|
12
|
+
Provides-Extra: iris
|
|
13
|
+
Requires-Dist: marin-iris==0.2.153.dev37866254792; extra == 'iris'
|
|
14
|
+
Provides-Extra: qemu
|
|
15
|
+
Requires-Dist: quicksand-qemu<0.6,>=0.5.12; extra == 'qemu'
|
|
16
|
+
Provides-Extra: shellsim
|
|
17
|
+
Requires-Dist: marin-rigging>=0.2.0; extra == 'shellsim'
|
|
18
|
+
Requires-Dist: shellsim<0.2,>=0.1.29; extra == 'shellsim'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
1
21
|
# Marin Shellbox (0.1)
|
|
2
22
|
|
|
3
23
|
`marin-shellbox` provides three Harbor import paths:
|
|
@@ -88,8 +108,7 @@ these settings to its resource flags. Storage limits require a Docker storage
|
|
|
88
108
|
driver that supports `--storage-opt size`, and GPU allocation requires a GPU
|
|
89
109
|
runtime. Daytona applies CPU, memory, storage, and GPU settings through its SDK.
|
|
90
110
|
It rounds memory and storage up to whole GiB. Iris applies CPU, memory, and storage settings. QEMU applies CPU and
|
|
91
|
-
memory settings. Backends reject resource overrides that they cannot apply
|
|
92
|
-
`memory_mb`.
|
|
111
|
+
memory settings. Backends reject resource overrides that they cannot apply.
|
|
93
112
|
|
|
94
113
|
`Command.user` selects the execution user for one command. Docker accepts a
|
|
95
114
|
username or UID string. An omitted user retains the image's user. ShellSim and
|
|
@@ -157,9 +176,11 @@ With the `qemu` extra, `quicksand_qemu.get_bin_dir()` gives the QEMU executable
|
|
|
157
176
|
```python
|
|
158
177
|
from pathlib import Path
|
|
159
178
|
from shellbox.backends.local.machine import LocalMachineFactory
|
|
179
|
+
from shellbox.backends.local.python_environment import build_python_environment
|
|
160
180
|
from shellbox.machine import Command, HostImage, MachineSpec
|
|
161
181
|
|
|
162
|
-
|
|
182
|
+
environment = build_python_environment(Path("/var/tmp/grader-env"), Path("grader.lock"), "3.12.13")
|
|
183
|
+
factory = LocalMachineFactory(read_only=(environment.root,), bin_dirs=(environment.bin_dir,))
|
|
163
184
|
machine = await factory.create(MachineSpec(HostImage(), workdir="/app"))
|
|
164
185
|
try:
|
|
165
186
|
result = await machine.run(Command(("python3", "/tests/grade.py")))
|
|
@@ -167,16 +188,20 @@ finally:
|
|
|
167
188
|
await machine.close()
|
|
168
189
|
```
|
|
169
190
|
|
|
170
|
-
- Each machine has a root directory of its own, which `create` makes empty under the host's temporary directory and `close` removes. Commands see it as `/`, with the host's `/usr`, `/bin`, `/sbin`, `/lib*`, `/etc`, `/opt`, `/sys` and `/run/systemd/resolve` mounted read-only over it, together with each
|
|
191
|
+
- Each machine has a root directory of its own, which `create` makes empty under the host's temporary directory and `close` removes. Commands see it as `/`, with the host's `/usr`, `/bin`, `/sbin`, `/lib*`, `/etc`, `/opt`, `/sys` and `/run/systemd/resolve` mounted read-only over it, together with each directory in `read_only` at its own host path. Every other path, including `/app`, `/tests`, `/tmp` and `HOME`, is the machine's own, so paths that the host process uses, such as an Iris task's `/app`, are never touched. No other host file is visible.
|
|
171
192
|
- Machines are independent, so a process may run any number of them at once.
|
|
172
193
|
- Files persist in the machine's root between commands. Each command gets new PID, IPC and UTS namespaces and a fresh `/proc`, `/dev` and `/dev/shm`; a command's processes end when it exits or times out. Under `NetworkPolicy.DENY` it also gets a network namespace with only loopback, which refuses TCP and UDP to the host and beyond; under `ALLOW` it uses the host network.
|
|
173
194
|
- `upload` copies into the machine's root and rejects targets in the read-only directories. `download` reads the machine's root or the read-only host directories; a missing path raises `RuntimeError`, as the container backends do.
|
|
174
|
-
- Commands never inherit the host environment, which can hold cluster credentials. Each command gets `PATH` with `bin_dirs` ahead of the standard system directories, `HOME=/home/shellbox`, `LANG=C.UTF-8`, and
|
|
195
|
+
- Commands never inherit the host environment, which can hold cluster credentials. Each command gets `PATH` with `bin_dirs` ahead of the standard system directories, `HOME=/home/shellbox`, `LANG=C.UTF-8`, and `PYTHONHASHSEED` when the factory has a `hash_seed`, then `MachineSpec.env` and `Command.env`.
|
|
175
196
|
- `/bin/sh` enters the working directory and starts the program, so a missing program exits with status 127, as in the container backends. Output beyond `output_limit_bytes` is discarded while the command runs.
|
|
176
197
|
- Commands have no capabilities and run with `no_new_privs`, so setuid programs cannot raise their privileges. They have no core files, files of at most 1 GiB, and, for a command with a timeout, at most the timeout multiplied by the usable CPUs plus 5 seconds of CPU time per process. `RLIMIT_NPROC` counts all of a user's tasks on the host, so the backend limits the command's user to the host's task count when the command starts plus 256; the kernel does not apply it to root.
|
|
177
|
-
-
|
|
198
|
+
- The backend enforces no memory limit, since an address-space limit breaks NumPy and SymPy allocations. `memory_mb`, CPU, storage, and GPU requests raise `UnsupportedMachineSpec`.
|
|
178
199
|
- `Command.user` keeps the process's user when it is omitted or names that user. Another user requires a root process; the command then runs with that account's UID and primary group, no supplementary groups and no capabilities. A user without a host account raises `UnsupportedMachineSpec`.
|
|
179
200
|
|
|
201
|
+
The factory mounts exactly the directories in `read_only` and infers nothing from `bin_dirs`, which only sets `PATH`. The caller must keep each `read_only` directory self-contained. A symlink whose target lies outside the mounted directories does not resolve in the sandbox. A uv venv, for example, links `python` to an interpreter elsewhere on the host, so mounting the venv alone leaves `python3` missing. The factory raises `ValueError` for a `read_only` path that is not an existing directory.
|
|
202
|
+
|
|
203
|
+
`build_python_environment(root, lock, python_version)` builds such a directory for Python. It installs a uv-managed CPython of the given full version under `root/python`, creates a venv at `root/venv` from it, and runs `uv pip sync --require-hashes` with the lock, a requirements file with hashes such as `uv pip compile --generate-hashes` writes. Every file and symlink of the environment lies under `root`, and the venv refers to its interpreter by absolute path, so the root must stay where it was built. It returns a `PythonEnvironment` whose `root` goes in `read_only` and whose `bin_dir` goes in `bin_dirs`. Concurrent builders of one root on a host take an exclusive `flock` on a file beside it, so the environment is built once. A completion marker records a finished build; a root without one is a failed or interrupted build and is removed and rebuilt. A finished root built from another lock or Python version raises `ValueError`, and a failed uv command raises `UvError` with uv's error output. The builder needs `uv` on `PATH` and network access to download the interpreter and packages.
|
|
204
|
+
|
|
180
205
|
The factory checks once, when it is created, that `bwrap` can build a sandbox, and raises `SandboxUnavailable` with each candidate's error when it cannot. It tries the bundled binary, then any `bwrap` on `PATH`, or only the `bwrap` path it is given. Run as root, `bwrap` creates its namespaces without a user namespace, which needs `CAP_SYS_ADMIN` and `CAP_NET_ADMIN`, no AppArmor confinement, and a seccomp filter that allows `pivot_root`. Container runtimes withhold all three by default. On Kubernetes, the container's `securityContext` needs `capabilities.add: [SYS_ADMIN, NET_ADMIN]` and `appArmorProfile: {type: Unconfined}`, and no `RuntimeDefault` seccomp profile. Run as another user, `bwrap` needs unprivileged user namespaces; Ubuntu restricts them to programs whose AppArmor profile allows them, including `/usr/bin/bwrap`, so there the factory uses the system binary.
|
|
181
206
|
|
|
182
207
|
## ShellSim backend
|
marin_shellbox-0.2.151.dev37846966940/PKG-INFO → marin_shellbox-0.2.153.dev37866254792/README.md
RENAMED
|
@@ -1,23 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: marin-shellbox
|
|
3
|
-
Version: 0.2.151.dev37846966940
|
|
4
|
-
Summary: Shell execution machines with local and remote backends
|
|
5
|
-
Requires-Python: >=3.12
|
|
6
|
-
Requires-Dist: bubblewrap-bin==0.11.0; sys_platform == 'linux'
|
|
7
|
-
Requires-Dist: httpx>=0.27
|
|
8
|
-
Provides-Extra: daytona
|
|
9
|
-
Requires-Dist: daytona==0.200.2; extra == 'daytona'
|
|
10
|
-
Requires-Dist: marin-rigging>=0.2.0; extra == 'daytona'
|
|
11
|
-
Provides-Extra: gvisor
|
|
12
|
-
Provides-Extra: iris
|
|
13
|
-
Requires-Dist: marin-iris==0.2.151.dev37846966940; extra == 'iris'
|
|
14
|
-
Provides-Extra: qemu
|
|
15
|
-
Requires-Dist: quicksand-qemu<0.6,>=0.5.12; extra == 'qemu'
|
|
16
|
-
Provides-Extra: shellsim
|
|
17
|
-
Requires-Dist: marin-rigging>=0.2.0; extra == 'shellsim'
|
|
18
|
-
Requires-Dist: shellsim<0.2,>=0.1.29; extra == 'shellsim'
|
|
19
|
-
Description-Content-Type: text/markdown
|
|
20
|
-
|
|
21
1
|
# Marin Shellbox (0.1)
|
|
22
2
|
|
|
23
3
|
`marin-shellbox` provides three Harbor import paths:
|
|
@@ -108,8 +88,7 @@ these settings to its resource flags. Storage limits require a Docker storage
|
|
|
108
88
|
driver that supports `--storage-opt size`, and GPU allocation requires a GPU
|
|
109
89
|
runtime. Daytona applies CPU, memory, storage, and GPU settings through its SDK.
|
|
110
90
|
It rounds memory and storage up to whole GiB. Iris applies CPU, memory, and storage settings. QEMU applies CPU and
|
|
111
|
-
memory settings. Backends reject resource overrides that they cannot apply
|
|
112
|
-
`memory_mb`.
|
|
91
|
+
memory settings. Backends reject resource overrides that they cannot apply.
|
|
113
92
|
|
|
114
93
|
`Command.user` selects the execution user for one command. Docker accepts a
|
|
115
94
|
username or UID string. An omitted user retains the image's user. ShellSim and
|
|
@@ -177,9 +156,11 @@ With the `qemu` extra, `quicksand_qemu.get_bin_dir()` gives the QEMU executable
|
|
|
177
156
|
```python
|
|
178
157
|
from pathlib import Path
|
|
179
158
|
from shellbox.backends.local.machine import LocalMachineFactory
|
|
159
|
+
from shellbox.backends.local.python_environment import build_python_environment
|
|
180
160
|
from shellbox.machine import Command, HostImage, MachineSpec
|
|
181
161
|
|
|
182
|
-
|
|
162
|
+
environment = build_python_environment(Path("/var/tmp/grader-env"), Path("grader.lock"), "3.12.13")
|
|
163
|
+
factory = LocalMachineFactory(read_only=(environment.root,), bin_dirs=(environment.bin_dir,))
|
|
183
164
|
machine = await factory.create(MachineSpec(HostImage(), workdir="/app"))
|
|
184
165
|
try:
|
|
185
166
|
result = await machine.run(Command(("python3", "/tests/grade.py")))
|
|
@@ -187,16 +168,20 @@ finally:
|
|
|
187
168
|
await machine.close()
|
|
188
169
|
```
|
|
189
170
|
|
|
190
|
-
- Each machine has a root directory of its own, which `create` makes empty under the host's temporary directory and `close` removes. Commands see it as `/`, with the host's `/usr`, `/bin`, `/sbin`, `/lib*`, `/etc`, `/opt`, `/sys` and `/run/systemd/resolve` mounted read-only over it, together with each
|
|
171
|
+
- Each machine has a root directory of its own, which `create` makes empty under the host's temporary directory and `close` removes. Commands see it as `/`, with the host's `/usr`, `/bin`, `/sbin`, `/lib*`, `/etc`, `/opt`, `/sys` and `/run/systemd/resolve` mounted read-only over it, together with each directory in `read_only` at its own host path. Every other path, including `/app`, `/tests`, `/tmp` and `HOME`, is the machine's own, so paths that the host process uses, such as an Iris task's `/app`, are never touched. No other host file is visible.
|
|
191
172
|
- Machines are independent, so a process may run any number of them at once.
|
|
192
173
|
- Files persist in the machine's root between commands. Each command gets new PID, IPC and UTS namespaces and a fresh `/proc`, `/dev` and `/dev/shm`; a command's processes end when it exits or times out. Under `NetworkPolicy.DENY` it also gets a network namespace with only loopback, which refuses TCP and UDP to the host and beyond; under `ALLOW` it uses the host network.
|
|
193
174
|
- `upload` copies into the machine's root and rejects targets in the read-only directories. `download` reads the machine's root or the read-only host directories; a missing path raises `RuntimeError`, as the container backends do.
|
|
194
|
-
- Commands never inherit the host environment, which can hold cluster credentials. Each command gets `PATH` with `bin_dirs` ahead of the standard system directories, `HOME=/home/shellbox`, `LANG=C.UTF-8`, and
|
|
175
|
+
- Commands never inherit the host environment, which can hold cluster credentials. Each command gets `PATH` with `bin_dirs` ahead of the standard system directories, `HOME=/home/shellbox`, `LANG=C.UTF-8`, and `PYTHONHASHSEED` when the factory has a `hash_seed`, then `MachineSpec.env` and `Command.env`.
|
|
195
176
|
- `/bin/sh` enters the working directory and starts the program, so a missing program exits with status 127, as in the container backends. Output beyond `output_limit_bytes` is discarded while the command runs.
|
|
196
177
|
- Commands have no capabilities and run with `no_new_privs`, so setuid programs cannot raise their privileges. They have no core files, files of at most 1 GiB, and, for a command with a timeout, at most the timeout multiplied by the usable CPUs plus 5 seconds of CPU time per process. `RLIMIT_NPROC` counts all of a user's tasks on the host, so the backend limits the command's user to the host's task count when the command starts plus 256; the kernel does not apply it to root.
|
|
197
|
-
-
|
|
178
|
+
- The backend enforces no memory limit, since an address-space limit breaks NumPy and SymPy allocations. `memory_mb`, CPU, storage, and GPU requests raise `UnsupportedMachineSpec`.
|
|
198
179
|
- `Command.user` keeps the process's user when it is omitted or names that user. Another user requires a root process; the command then runs with that account's UID and primary group, no supplementary groups and no capabilities. A user without a host account raises `UnsupportedMachineSpec`.
|
|
199
180
|
|
|
181
|
+
The factory mounts exactly the directories in `read_only` and infers nothing from `bin_dirs`, which only sets `PATH`. The caller must keep each `read_only` directory self-contained. A symlink whose target lies outside the mounted directories does not resolve in the sandbox. A uv venv, for example, links `python` to an interpreter elsewhere on the host, so mounting the venv alone leaves `python3` missing. The factory raises `ValueError` for a `read_only` path that is not an existing directory.
|
|
182
|
+
|
|
183
|
+
`build_python_environment(root, lock, python_version)` builds such a directory for Python. It installs a uv-managed CPython of the given full version under `root/python`, creates a venv at `root/venv` from it, and runs `uv pip sync --require-hashes` with the lock, a requirements file with hashes such as `uv pip compile --generate-hashes` writes. Every file and symlink of the environment lies under `root`, and the venv refers to its interpreter by absolute path, so the root must stay where it was built. It returns a `PythonEnvironment` whose `root` goes in `read_only` and whose `bin_dir` goes in `bin_dirs`. Concurrent builders of one root on a host take an exclusive `flock` on a file beside it, so the environment is built once. A completion marker records a finished build; a root without one is a failed or interrupted build and is removed and rebuilt. A finished root built from another lock or Python version raises `ValueError`, and a failed uv command raises `UvError` with uv's error output. The builder needs `uv` on `PATH` and network access to download the interpreter and packages.
|
|
184
|
+
|
|
200
185
|
The factory checks once, when it is created, that `bwrap` can build a sandbox, and raises `SandboxUnavailable` with each candidate's error when it cannot. It tries the bundled binary, then any `bwrap` on `PATH`, or only the `bwrap` path it is given. Run as root, `bwrap` creates its namespaces without a user namespace, which needs `CAP_SYS_ADMIN` and `CAP_NET_ADMIN`, no AppArmor confinement, and a seccomp filter that allows `pivot_root`. Container runtimes withhold all three by default. On Kubernetes, the container's `securityContext` needs `capabilities.add: [SYS_ADMIN, NET_ADMIN]` and `appArmorProfile: {type: Unconfined}`, and no `RuntimeDefault` seccomp profile. Run as another user, `bwrap` needs unprivileged user namespaces; Ubuntu restricts them to programs whose AppArmor profile allows them, including `/usr/bin/bwrap`, so there the factory uses the system binary.
|
|
201
186
|
|
|
202
187
|
## ShellSim backend
|
{marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/pyproject.toml
RENAMED
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "marin-shellbox"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.153.dev37866254792"
|
|
8
8
|
description = "Shell execution machines with local and remote backends"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -16,7 +16,7 @@ shellsim = ["shellsim>=0.1.29,<0.2", "marin-rigging>=0.2.0"]
|
|
|
16
16
|
gvisor = []
|
|
17
17
|
daytona = ["daytona==0.200.2", "marin-rigging>=0.2.0"]
|
|
18
18
|
iris = [
|
|
19
|
-
"marin-iris==0.2.
|
|
19
|
+
"marin-iris==0.2.153.dev37866254792",
|
|
20
20
|
]
|
|
21
21
|
|
|
22
22
|
[project.scripts]
|
|
@@ -97,63 +97,11 @@ def _copy(source: Path, target: Path) -> None:
|
|
|
97
97
|
shutil.copy2(source, target)
|
|
98
98
|
|
|
99
99
|
|
|
100
|
-
def _interpreter_roots(bin_dirs: Iterable[Path]) -> tuple[str, ...]:
|
|
101
|
-
"""Directories to read for the programs in ``bin_dirs``: each venv and the Python it links to, or the directory."""
|
|
102
|
-
roots: list[str] = []
|
|
103
|
-
for directory in bin_dirs:
|
|
104
|
-
venv = directory.parent
|
|
105
|
-
if (venv / "pyvenv.cfg").exists():
|
|
106
|
-
roots += [str(venv), str(Path(os.path.realpath(directory / "python3")).parent.parent)]
|
|
107
|
-
else:
|
|
108
|
-
roots.append(str(directory))
|
|
109
|
-
return tuple(roots)
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
MAX_LINK_HOPS = 40
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def _link_chain(path: Path) -> list[tuple[str, str]]:
|
|
116
|
-
"""Every symlink met while resolving ``path``, as ``(link, target)`` pairs in resolution order."""
|
|
117
|
-
links: list[tuple[str, str]] = []
|
|
118
|
-
parts = list(path.parts[1:])
|
|
119
|
-
current = PurePosixPath("/")
|
|
120
|
-
while parts:
|
|
121
|
-
current = current / parts.pop(0)
|
|
122
|
-
if not os.path.islink(current):
|
|
123
|
-
continue
|
|
124
|
-
if len(links) >= MAX_LINK_HOPS:
|
|
125
|
-
raise RuntimeError(f"{path} has more than {MAX_LINK_HOPS} symlink hops")
|
|
126
|
-
target = os.readlink(current)
|
|
127
|
-
links.append((str(current), target))
|
|
128
|
-
resolved = PurePosixPath(os.path.normpath(current.parent / target))
|
|
129
|
-
parts = [*resolved.parts[1:], *parts]
|
|
130
|
-
current = PurePosixPath("/")
|
|
131
|
-
return links
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def _interpreter_links(bin_dirs: Iterable[Path], roots: Iterable[str]) -> tuple[tuple[str, str], ...]:
|
|
135
|
-
"""Symlinks on the way from each venv's ``python3`` to its interpreter that lie outside the mounted ``roots``.
|
|
136
|
-
|
|
137
|
-
uv links a venv's interpreter through a minor-version alias directory beside the install it resolves to;
|
|
138
|
-
the sandbox mounts the resolved install, so it must recreate the alias for the venv's link to resolve.
|
|
139
|
-
"""
|
|
140
|
-
mounted = tuple(map(PurePosixPath, (*SYSTEM_DIRECTORIES, *roots)))
|
|
141
|
-
links = []
|
|
142
|
-
for directory in bin_dirs:
|
|
143
|
-
if not (directory.parent / "pyvenv.cfg").exists():
|
|
144
|
-
continue
|
|
145
|
-
for link, target in _link_chain(directory / "python3"):
|
|
146
|
-
if not _within(PurePosixPath(link), mounted) and (link, target) not in links:
|
|
147
|
-
links.append((link, target))
|
|
148
|
-
return tuple(links)
|
|
149
|
-
|
|
150
|
-
|
|
151
100
|
def _sandbox_argv(
|
|
152
101
|
bwrap: Path,
|
|
153
102
|
*,
|
|
154
103
|
root: Path,
|
|
155
104
|
read_only: Iterable[str],
|
|
156
|
-
links: Iterable[tuple[str, str]] = (),
|
|
157
105
|
network: NetworkPolicy,
|
|
158
106
|
account: pwd.struct_passwd | None,
|
|
159
107
|
) -> list[str]:
|
|
@@ -177,9 +125,7 @@ def _sandbox_argv(
|
|
|
177
125
|
argv += ["--ro-bind-try", path, path]
|
|
178
126
|
argv += ["--dev", "/dev", "--tmpfs", SHARED_MEMORY, "--proc", "/proc"]
|
|
179
127
|
for path in read_only:
|
|
180
|
-
argv += ["--ro-bind
|
|
181
|
-
for link, target in links:
|
|
182
|
-
argv += ["--symlink", target, link]
|
|
128
|
+
argv += ["--ro-bind", path, path]
|
|
183
129
|
if account is not None:
|
|
184
130
|
argv += ["setpriv", f"--reuid={account.pw_uid}", f"--regid={account.pw_gid}", "--clear-groups", "--"]
|
|
185
131
|
return argv
|
|
@@ -295,13 +241,11 @@ class LocalMachine:
|
|
|
295
241
|
bwrap: Path,
|
|
296
242
|
root: Path,
|
|
297
243
|
read_only: tuple[str, ...],
|
|
298
|
-
links: tuple[tuple[str, str], ...],
|
|
299
244
|
environment: dict[str, str],
|
|
300
245
|
):
|
|
301
246
|
self.spec = spec
|
|
302
247
|
self.root = root
|
|
303
248
|
self.read_only = read_only
|
|
304
|
-
self._links = links
|
|
305
249
|
self._bwrap = bwrap
|
|
306
250
|
self._environment = environment
|
|
307
251
|
self._closed = False
|
|
@@ -332,7 +276,6 @@ class LocalMachine:
|
|
|
332
276
|
self._bwrap,
|
|
333
277
|
root=self.root,
|
|
334
278
|
read_only=self.read_only,
|
|
335
|
-
links=self._links,
|
|
336
279
|
network=self.spec.network,
|
|
337
280
|
account=_command_account(command.user),
|
|
338
281
|
)
|
|
@@ -406,12 +349,16 @@ class LocalMachineFactory:
|
|
|
406
349
|
"""Run trusted commands on this host in bubblewrap sandboxes, any number of machines at a time.
|
|
407
350
|
|
|
408
351
|
A machine's commands see the host's system directories (``/usr``, ``/etc``, ``/opt`` and the like)
|
|
409
|
-
and
|
|
410
|
-
``HOME``, lies in a root directory of the machine's own that starts empty and is removed by
|
|
411
|
-
``close``; no other host file is visible.
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
352
|
+
and each directory in ``read_only`` at its own path, read-only. Every other path, including ``/tmp``
|
|
353
|
+
and ``HOME``, lies in a root directory of the machine's own that starts empty and is removed by
|
|
354
|
+
``close``; no other host file is visible. The caller keeps each ``read_only`` directory self-contained:
|
|
355
|
+
a symlink that leads out of the mounted directories does not resolve in the sandbox.
|
|
356
|
+
``build_python_environment`` builds such a directory for a Python interpreter and its packages.
|
|
357
|
+
|
|
358
|
+
Commands never inherit the host's environment: they get ``bin_dirs`` ahead of a standard ``PATH``,
|
|
359
|
+
``HOME``, ``LANG``, ``PYTHONHASHSEED`` when the factory has a ``hash_seed``, and the spec's and
|
|
360
|
+
command's variables. ``bin_dirs`` only sets ``PATH``; a directory outside the mounted ones is not
|
|
361
|
+
visible to commands. A spec with ``memory_mb`` is rejected, since the backend enforces no memory limit.
|
|
415
362
|
|
|
416
363
|
``bwrap`` names the executable to use. By default the factory takes the first of the bundled bwrap
|
|
417
364
|
and any bwrap on ``PATH`` that can build a sandbox on this host, and raises ``SandboxUnavailable``
|
|
@@ -420,12 +367,22 @@ class LocalMachineFactory:
|
|
|
420
367
|
|
|
421
368
|
backend: Backend = Backend.LOCAL
|
|
422
369
|
|
|
423
|
-
def __init__(
|
|
370
|
+
def __init__(
|
|
371
|
+
self,
|
|
372
|
+
*,
|
|
373
|
+
read_only: tuple[Path, ...] = (),
|
|
374
|
+
bin_dirs: tuple[Path, ...] = (),
|
|
375
|
+
bwrap: Path | None = None,
|
|
376
|
+
hash_seed: str | None = None,
|
|
377
|
+
):
|
|
378
|
+
self.read_only = tuple(directory.absolute() for directory in read_only)
|
|
379
|
+
missing = [str(directory) for directory in self.read_only if not directory.is_dir()]
|
|
380
|
+
if missing:
|
|
381
|
+
raise ValueError(f"Read-only directories do not exist: {', '.join(missing)}")
|
|
424
382
|
self.bin_dirs = tuple(directory.absolute() for directory in bin_dirs)
|
|
425
383
|
self.hash_seed = hash_seed
|
|
426
384
|
self.bwrap = _working_bwrap(_bwrap_candidates(bwrap))
|
|
427
|
-
self._read_only =
|
|
428
|
-
self._links = _interpreter_links(self.bin_dirs, self._read_only)
|
|
385
|
+
self._read_only = tuple(map(str, self.read_only))
|
|
429
386
|
logger.info("Local backend sandboxes commands with %s", self.bwrap)
|
|
430
387
|
|
|
431
388
|
async def create(self, spec: MachineSpec) -> LocalMachine:
|
|
@@ -449,6 +406,4 @@ class LocalMachineFactory:
|
|
|
449
406
|
}
|
|
450
407
|
if self.hash_seed is not None:
|
|
451
408
|
environment["PYTHONHASHSEED"] = self.hash_seed
|
|
452
|
-
return LocalMachine(
|
|
453
|
-
spec, bwrap=self.bwrap, root=root, read_only=self._read_only, links=self._links, environment=environment
|
|
454
|
-
)
|
|
409
|
+
return LocalMachine(spec, bwrap=self.bwrap, root=root, read_only=self._read_only, environment=environment)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Copyright The Marin Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Build self-contained Python environments for local machines to mount read-only.
|
|
5
|
+
|
|
6
|
+
An environment root holds a uv-managed CPython and a venv made from it, so every file and symlink the
|
|
7
|
+
interpreter needs lies under the root. A ``LocalMachineFactory`` given the root in ``read_only`` and
|
|
8
|
+
the venv's ``bin`` directory in ``bin_dirs`` runs that interpreter with no other host directory mounted.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import fcntl
|
|
12
|
+
import hashlib
|
|
13
|
+
import os
|
|
14
|
+
import shlex
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
PYTHON_DIRECTORY = "python"
|
|
21
|
+
VENV_DIRECTORY = "venv"
|
|
22
|
+
COMPLETE_MARKER = ".complete"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class PythonEnvironment:
|
|
27
|
+
"""A venv at ``root/venv`` whose interpreter is installed at ``root/python``.
|
|
28
|
+
|
|
29
|
+
The venv links to its interpreter by absolute path, so the root must stay where it was built.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
root: Path
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def bin_dir(self) -> Path:
|
|
36
|
+
return self.root / VENV_DIRECTORY / "bin"
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def python(self) -> Path:
|
|
40
|
+
return self.bin_dir / "python3"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _marker_text(lock: Path, python_version: str) -> str:
|
|
44
|
+
return f"python {python_version}\nlock sha256 {hashlib.sha256(lock.read_bytes()).hexdigest()}\n"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class UvError(RuntimeError):
|
|
48
|
+
"""A uv command failed; the message holds the command and uv's error output."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _uv(*args: str, environment: dict[str, str] | None = None) -> None:
|
|
52
|
+
command = ["uv", *args]
|
|
53
|
+
result = subprocess.run(
|
|
54
|
+
command,
|
|
55
|
+
stderr=subprocess.PIPE,
|
|
56
|
+
text=True,
|
|
57
|
+
env=None if environment is None else {**os.environ, **environment},
|
|
58
|
+
check=False,
|
|
59
|
+
)
|
|
60
|
+
if result.returncode != 0:
|
|
61
|
+
raise UvError(f"{shlex.join(command)} exited with status {result.returncode}:\n{result.stderr}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _build(environment: PythonEnvironment, lock: Path, python_version: str) -> None:
|
|
65
|
+
install_dir = {"UV_PYTHON_INSTALL_DIR": str(environment.root / PYTHON_DIRECTORY)}
|
|
66
|
+
# --no-bin keeps uv from linking the interpreter into the user's executable directory.
|
|
67
|
+
_uv("python", "install", "--no-config", "--no-bin", python_version, environment=install_dir)
|
|
68
|
+
_uv(
|
|
69
|
+
"venv",
|
|
70
|
+
"--no-config",
|
|
71
|
+
"--managed-python",
|
|
72
|
+
"--no-python-downloads",
|
|
73
|
+
"--python",
|
|
74
|
+
python_version,
|
|
75
|
+
str(environment.root / VENV_DIRECTORY),
|
|
76
|
+
environment=install_dir,
|
|
77
|
+
)
|
|
78
|
+
# Copying overrides a UV_LINK_MODE of symlink, which would point installed files into uv's cache.
|
|
79
|
+
_uv(
|
|
80
|
+
"pip",
|
|
81
|
+
"sync",
|
|
82
|
+
"--no-config",
|
|
83
|
+
"--python",
|
|
84
|
+
str(environment.python),
|
|
85
|
+
"--require-hashes",
|
|
86
|
+
"--link-mode",
|
|
87
|
+
"copy",
|
|
88
|
+
str(lock),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_python_environment(root: Path, lock: Path, python_version: str) -> PythonEnvironment:
|
|
93
|
+
"""Build a venv under ``root`` with CPython ``python_version`` and the packages of the hash lock ``lock``.
|
|
94
|
+
|
|
95
|
+
``python_version`` is a full version such as ``3.12.13``, which uv downloads into ``root``. ``lock`` is a
|
|
96
|
+
requirements file with hashes for every package, as ``uv pip compile --generate-hashes`` writes.
|
|
97
|
+
|
|
98
|
+
Concurrent callers on one host build the root once: an exclusive ``flock`` beside it serializes
|
|
99
|
+
builders, and a completion marker records a finished build. A root without the marker is a failed
|
|
100
|
+
or interrupted build and is rebuilt. A finished root built from another lock or version raises
|
|
101
|
+
``ValueError``. A failed uv command raises ``UvError`` with uv's error output.
|
|
102
|
+
"""
|
|
103
|
+
root = root.absolute()
|
|
104
|
+
environment = PythonEnvironment(root)
|
|
105
|
+
expected = _marker_text(lock, python_version)
|
|
106
|
+
root.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
with (root.parent / f"{root.name}.lock").open("w") as guard:
|
|
108
|
+
fcntl.flock(guard, fcntl.LOCK_EX)
|
|
109
|
+
marker = root / COMPLETE_MARKER
|
|
110
|
+
if marker.exists():
|
|
111
|
+
if marker.read_text() != expected:
|
|
112
|
+
raise ValueError(f"{root} holds an environment built from other inputs:\n{marker.read_text()}")
|
|
113
|
+
return environment
|
|
114
|
+
if root.exists():
|
|
115
|
+
shutil.rmtree(root)
|
|
116
|
+
_build(environment, lock, python_version)
|
|
117
|
+
marker.write_text(expected)
|
|
118
|
+
return environment
|
|
@@ -31,6 +31,7 @@ DEFAULT_CPU_LIMIT = 10_000_000_000
|
|
|
31
31
|
DEFAULT_DISK_LIMIT = 256 * 1024 * 1024
|
|
32
32
|
DEFAULT_OUTPUT_LIMIT = 128 * 1024 * 1024
|
|
33
33
|
DEFAULT_MEMORY_MB = 256
|
|
34
|
+
SIMULATOR_PANIC_EXIT_CODE = 1
|
|
34
35
|
ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
|
|
35
36
|
|
|
36
37
|
|
|
@@ -45,7 +46,16 @@ class ShellSimShellSession:
|
|
|
45
46
|
async def execute(
|
|
46
47
|
self, command: str, *, wait: float = 120, output_limit_bytes: int = DEFAULT_MACHINE_OUTPUT_LIMIT_BYTES
|
|
47
48
|
) -> ShellUpdate:
|
|
48
|
-
|
|
49
|
+
try:
|
|
50
|
+
result = await self.machine._run_source(command)
|
|
51
|
+
except shellsim.SimulationError as error:
|
|
52
|
+
output = str(error).encode()
|
|
53
|
+
return ShellUpdate(
|
|
54
|
+
output[:output_limit_bytes],
|
|
55
|
+
ShellStatus.COMPLETED,
|
|
56
|
+
SIMULATOR_PANIC_EXIT_CODE,
|
|
57
|
+
len(output) > output_limit_bytes,
|
|
58
|
+
)
|
|
49
59
|
output = result.stdout + result.stderr
|
|
50
60
|
return ShellUpdate(
|
|
51
61
|
output[:output_limit_bytes],
|
|
@@ -123,6 +133,17 @@ class ShellSimMachine:
|
|
|
123
133
|
except TimeoutError:
|
|
124
134
|
self._closed = True
|
|
125
135
|
return Result(None, b"", b"", False, False, ExitReason.TIMED_OUT)
|
|
136
|
+
except shellsim.SimulationError as error:
|
|
137
|
+
stderr = str(error).encode()
|
|
138
|
+
limit = command.output_limit_bytes
|
|
139
|
+
return Result(
|
|
140
|
+
SIMULATOR_PANIC_EXIT_CODE,
|
|
141
|
+
b"",
|
|
142
|
+
stderr[:limit],
|
|
143
|
+
False,
|
|
144
|
+
len(stderr) > limit,
|
|
145
|
+
ExitReason.EXITED,
|
|
146
|
+
)
|
|
126
147
|
limit = command.output_limit_bytes
|
|
127
148
|
return Result(
|
|
128
149
|
result.returncode,
|
|
@@ -7,7 +7,6 @@ import asyncio
|
|
|
7
7
|
import os
|
|
8
8
|
import shutil
|
|
9
9
|
import socket
|
|
10
|
-
import sys
|
|
11
10
|
import tarfile
|
|
12
11
|
import tempfile
|
|
13
12
|
import uuid
|
|
@@ -16,6 +15,7 @@ from pathlib import Path
|
|
|
16
15
|
|
|
17
16
|
import pytest
|
|
18
17
|
from shellbox.backends.local.machine import NPROC_HEADROOM, LocalMachine, LocalMachineFactory, SandboxUnavailable
|
|
18
|
+
from shellbox.backends.local.python_environment import PythonEnvironment, UvError, build_python_environment
|
|
19
19
|
from shellbox.machine import (
|
|
20
20
|
Command,
|
|
21
21
|
DockerImage,
|
|
@@ -66,6 +66,13 @@ release.set()
|
|
|
66
66
|
print(started)
|
|
67
67
|
"""
|
|
68
68
|
SPEC = MachineSpec(HostImage(), workdir="/app")
|
|
69
|
+
PYTHON_VERSION = "3.12.13"
|
|
70
|
+
# A hash lock as ``uv pip compile --generate-hashes`` writes it, for a small pure-Python package.
|
|
71
|
+
LOCK = """\
|
|
72
|
+
six==1.17.0 \\
|
|
73
|
+
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \\
|
|
74
|
+
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
|
|
75
|
+
"""
|
|
69
76
|
|
|
70
77
|
|
|
71
78
|
def make_factory(**options) -> LocalMachineFactory:
|
|
@@ -80,10 +87,23 @@ def factory() -> LocalMachineFactory:
|
|
|
80
87
|
return make_factory()
|
|
81
88
|
|
|
82
89
|
|
|
90
|
+
@pytest.fixture(scope="session")
|
|
91
|
+
def python_environment(tmp_path_factory) -> PythonEnvironment:
|
|
92
|
+
lock = tmp_path_factory.mktemp("lock") / "requirements.lock"
|
|
93
|
+
lock.write_text(LOCK)
|
|
94
|
+
try:
|
|
95
|
+
return build_python_environment(tmp_path_factory.mktemp("environment") / "root", lock, PYTHON_VERSION)
|
|
96
|
+
except UvError as error:
|
|
97
|
+
# uv reports an unreachable or failed fetch this way; any other failure is a bug in the builder.
|
|
98
|
+
if not str(error).startswith("uv python install") or "Failed to download" not in str(error):
|
|
99
|
+
raise
|
|
100
|
+
pytest.skip(f"uv could not download a managed CPython {PYTHON_VERSION}: {error}")
|
|
101
|
+
|
|
102
|
+
|
|
83
103
|
@pytest.fixture
|
|
84
|
-
def python_factory() -> LocalMachineFactory:
|
|
85
|
-
"""A factory whose commands find
|
|
86
|
-
return make_factory(
|
|
104
|
+
def python_factory(python_environment) -> LocalMachineFactory:
|
|
105
|
+
"""A factory whose commands find python3 in a self-contained environment that it mounts read-only."""
|
|
106
|
+
return make_factory(read_only=(python_environment.root,), bin_dirs=(python_environment.bin_dir,))
|
|
87
107
|
|
|
88
108
|
|
|
89
109
|
@pytest.fixture
|
|
@@ -145,7 +165,7 @@ def test_bin_dirs_win_path_lookup_and_host_environment_is_not_inherited(tmp_path
|
|
|
145
165
|
fake = bin_dir / "python3"
|
|
146
166
|
fake.write_text("#!/bin/sh\necho fake-python\n")
|
|
147
167
|
fake.chmod(0o755)
|
|
148
|
-
factory = make_factory(bin_dirs=(bin_dir,))
|
|
168
|
+
factory = make_factory(read_only=(bin_dir,), bin_dirs=(bin_dir,))
|
|
149
169
|
|
|
150
170
|
async def scenario(machine: LocalMachine) -> tuple[Result, Result]:
|
|
151
171
|
return await machine.run(Command(("python3",))), await machine.run(Command(("env",)))
|
|
@@ -337,26 +357,22 @@ def test_a_transfer_does_not_follow_a_symlink_out_of_the_machine_root(tmp_path,
|
|
|
337
357
|
assert not (outside_dir / "planted.txt").exists() and not (tmp_path / "stolen.txt").exists()
|
|
338
358
|
|
|
339
359
|
|
|
340
|
-
def
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
(
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
await machine.close()
|
|
360
|
-
|
|
361
|
-
result = asyncio.run(scenario())
|
|
362
|
-
assert result.stdout.decode().strip() == str(venv), result.stderr.decode()
|
|
360
|
+
def test_a_python_environment_runs_its_own_interpreter_and_locked_packages(python_environment, python_factory):
|
|
361
|
+
script = "import sys, six; print(sys.prefix); print(sys.base_prefix)"
|
|
362
|
+
result = run_command(python_factory, Command(("python3", "-c", script)))
|
|
363
|
+
assert result.exit_code == 0, result.stderr.decode()
|
|
364
|
+
prefix, base_prefix = map(Path, result.stdout.decode().split())
|
|
365
|
+
assert prefix == python_environment.bin_dir.parent
|
|
366
|
+
assert base_prefix.is_relative_to(python_environment.root)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def test_every_symlink_in_a_python_environment_resolves_inside_its_root(python_environment):
|
|
370
|
+
root = python_environment.root
|
|
371
|
+
links = [
|
|
372
|
+
Path(directory, name)
|
|
373
|
+
for directory, directories, files in os.walk(root)
|
|
374
|
+
for name in (*directories, *files)
|
|
375
|
+
if os.path.islink(Path(directory, name))
|
|
376
|
+
]
|
|
377
|
+
assert python_environment.python in links
|
|
378
|
+
assert [link for link in links if not Path(os.path.realpath(link)).is_relative_to(root)] == []
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Copyright The Marin Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Behavior checks for the simulated shell and verifier boundary."""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from shellbox.backends.shellsim.machine import ShellSimMachineFactory
|
|
10
|
+
from shellbox.machine import Command, ExitReason, MachineSpec, ShellSimBuiltins, ShellStatus
|
|
11
|
+
|
|
12
|
+
PANIC_COMMAND = "cat /workspace/numbers.txt | python3 -c 'import sys; print(sum(int(line) for line in sys.stdin))'"
|
|
13
|
+
PANIC_OUTPUT_LIMIT_BYTES = 64
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_shell_state_and_one_shot_command() -> None:
|
|
17
|
+
async def check() -> None:
|
|
18
|
+
machine = await ShellSimMachineFactory().create(MachineSpec(ShellSimBuiltins()))
|
|
19
|
+
try:
|
|
20
|
+
shell = await machine.open_shell()
|
|
21
|
+
first = await shell.execute(
|
|
22
|
+
'cd /tmp; export ANSWER=hello; answer() { echo "$ANSWER"; }; echo file >/workspace/result'
|
|
23
|
+
)
|
|
24
|
+
assert first.status is ShellStatus.COMPLETED
|
|
25
|
+
second = await shell.execute("pwd; answer")
|
|
26
|
+
assert second.output == b"/tmp\nhello\n"
|
|
27
|
+
|
|
28
|
+
verifier = await machine.run(Command(("sh", "-c", "pwd; cat /workspace/result")))
|
|
29
|
+
assert verifier.stdout == b"/workspace\nfile\n"
|
|
30
|
+
after = await shell.execute("pwd; answer")
|
|
31
|
+
assert after.output == b"/tmp\nhello\n"
|
|
32
|
+
finally:
|
|
33
|
+
await machine.close()
|
|
34
|
+
|
|
35
|
+
asyncio.run(check())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_file_transfer_and_bounded_output(tmp_path: Path) -> None:
|
|
39
|
+
async def check() -> None:
|
|
40
|
+
machine = await ShellSimMachineFactory().create(MachineSpec(ShellSimBuiltins()))
|
|
41
|
+
try:
|
|
42
|
+
source = tmp_path / "source"
|
|
43
|
+
source.mkdir()
|
|
44
|
+
(source / "data.txt").write_text("payload")
|
|
45
|
+
await machine.upload(source, "/workspace/imported")
|
|
46
|
+
shell = await machine.open_shell()
|
|
47
|
+
result = await shell.execute("cat /workspace/imported/data.txt; printf 'x'", output_limit_bytes=4)
|
|
48
|
+
assert result.output == b"payl"
|
|
49
|
+
assert result.truncated
|
|
50
|
+
|
|
51
|
+
target = tmp_path / "downloaded"
|
|
52
|
+
target.mkdir()
|
|
53
|
+
await machine.download("/workspace/imported", target)
|
|
54
|
+
assert (target / "data.txt").read_text() == "payload"
|
|
55
|
+
finally:
|
|
56
|
+
await machine.close()
|
|
57
|
+
|
|
58
|
+
asyncio.run(check())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_run_simulator_panic_returns_failed_result(tmp_path: Path) -> None:
|
|
62
|
+
async def check() -> None:
|
|
63
|
+
machine = await ShellSimMachineFactory().create(MachineSpec(ShellSimBuiltins()))
|
|
64
|
+
try:
|
|
65
|
+
source = tmp_path / "numbers.txt"
|
|
66
|
+
source.write_text("1\n2\n")
|
|
67
|
+
await machine.upload(source, "/workspace/numbers.txt")
|
|
68
|
+
|
|
69
|
+
result = await machine.run(Command(("sh", "-c", PANIC_COMMAND), output_limit_bytes=PANIC_OUTPUT_LIMIT_BYTES))
|
|
70
|
+
assert result.reason is ExitReason.EXITED
|
|
71
|
+
assert result.exit_code == 1
|
|
72
|
+
assert result.stdout == b""
|
|
73
|
+
assert result.stderr.startswith(b"shellsim operation panicked")
|
|
74
|
+
assert len(result.stderr) == PANIC_OUTPUT_LIMIT_BYTES
|
|
75
|
+
assert result.stderr_truncated
|
|
76
|
+
|
|
77
|
+
followup = await machine.run(Command(("cat", "/workspace/numbers.txt")))
|
|
78
|
+
assert (followup.exit_code, followup.stdout) == (0, b"1\n2\n")
|
|
79
|
+
finally:
|
|
80
|
+
await machine.close()
|
|
81
|
+
|
|
82
|
+
asyncio.run(check())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_shell_session_simulator_panic_returns_failed_update(tmp_path: Path) -> None:
|
|
86
|
+
async def check() -> None:
|
|
87
|
+
machine = await ShellSimMachineFactory().create(MachineSpec(ShellSimBuiltins()))
|
|
88
|
+
try:
|
|
89
|
+
source = tmp_path / "numbers.txt"
|
|
90
|
+
source.write_text("1\n2\n")
|
|
91
|
+
await machine.upload(source, "/workspace/numbers.txt")
|
|
92
|
+
shell = await machine.open_shell()
|
|
93
|
+
|
|
94
|
+
update = await shell.execute(PANIC_COMMAND, output_limit_bytes=PANIC_OUTPUT_LIMIT_BYTES)
|
|
95
|
+
assert update.status is ShellStatus.COMPLETED
|
|
96
|
+
assert update.exit_code == 1
|
|
97
|
+
assert update.output.startswith(b"shellsim operation panicked")
|
|
98
|
+
assert len(update.output) == PANIC_OUTPUT_LIMIT_BYTES
|
|
99
|
+
assert update.truncated
|
|
100
|
+
|
|
101
|
+
followup = await shell.execute("cat /workspace/numbers.txt")
|
|
102
|
+
assert (followup.exit_code, followup.output) == (0, b"1\n2\n")
|
|
103
|
+
finally:
|
|
104
|
+
await machine.close()
|
|
105
|
+
|
|
106
|
+
asyncio.run(check())
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
# Copyright The Marin Authors
|
|
2
|
-
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
-
|
|
4
|
-
"""Behavior checks for the simulated shell and verifier boundary."""
|
|
5
|
-
|
|
6
|
-
import asyncio
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
|
|
9
|
-
from shellbox.backends.shellsim.machine import ShellSimMachineFactory
|
|
10
|
-
from shellbox.machine import Command, MachineSpec, ShellSimBuiltins, ShellStatus
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def test_shell_state_and_one_shot_command() -> None:
|
|
14
|
-
async def check() -> None:
|
|
15
|
-
machine = await ShellSimMachineFactory().create(MachineSpec(ShellSimBuiltins()))
|
|
16
|
-
try:
|
|
17
|
-
shell = await machine.open_shell()
|
|
18
|
-
first = await shell.execute(
|
|
19
|
-
'cd /tmp; export ANSWER=hello; answer() { echo "$ANSWER"; }; echo file >/workspace/result'
|
|
20
|
-
)
|
|
21
|
-
assert first.status is ShellStatus.COMPLETED
|
|
22
|
-
second = await shell.execute("pwd; answer")
|
|
23
|
-
assert second.output == b"/tmp\nhello\n"
|
|
24
|
-
|
|
25
|
-
verifier = await machine.run(Command(("sh", "-c", "pwd; cat /workspace/result")))
|
|
26
|
-
assert verifier.stdout == b"/workspace\nfile\n"
|
|
27
|
-
after = await shell.execute("pwd; answer")
|
|
28
|
-
assert after.output == b"/tmp\nhello\n"
|
|
29
|
-
finally:
|
|
30
|
-
await machine.close()
|
|
31
|
-
|
|
32
|
-
asyncio.run(check())
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def test_file_transfer_and_bounded_output(tmp_path: Path) -> None:
|
|
36
|
-
async def check() -> None:
|
|
37
|
-
machine = await ShellSimMachineFactory().create(MachineSpec(ShellSimBuiltins()))
|
|
38
|
-
try:
|
|
39
|
-
source = tmp_path / "source"
|
|
40
|
-
source.mkdir()
|
|
41
|
-
(source / "data.txt").write_text("payload")
|
|
42
|
-
await machine.upload(source, "/workspace/imported")
|
|
43
|
-
shell = await machine.open_shell()
|
|
44
|
-
result = await shell.execute("cat /workspace/imported/data.txt; printf 'x'", output_limit_bytes=4)
|
|
45
|
-
assert result.output == b"payl"
|
|
46
|
-
assert result.truncated
|
|
47
|
-
|
|
48
|
-
target = tmp_path / "downloaded"
|
|
49
|
-
target.mkdir()
|
|
50
|
-
await machine.download("/workspace/imported", target)
|
|
51
|
-
assert (target / "data.txt").read_text() == "payload"
|
|
52
|
-
finally:
|
|
53
|
-
await machine.close()
|
|
54
|
-
|
|
55
|
-
asyncio.run(check())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|