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.
Files changed (54) hide show
  1. marin_shellbox-0.2.151.dev37846966940/README.md → marin_shellbox-0.2.153.dev37866254792/PKG-INFO +31 -6
  2. marin_shellbox-0.2.151.dev37846966940/PKG-INFO → marin_shellbox-0.2.153.dev37866254792/README.md +11 -26
  3. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/pyproject.toml +2 -2
  4. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/local/machine.py +25 -70
  5. marin_shellbox-0.2.153.dev37866254792/src/shellbox/backends/local/python_environment.py +118 -0
  6. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/shellsim/machine.py +22 -1
  7. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/test_local_machine.py +44 -28
  8. marin_shellbox-0.2.153.dev37866254792/tests/test_shellsim_machine.py +106 -0
  9. marin_shellbox-0.2.151.dev37846966940/tests/test_shellsim_machine.py +0 -55
  10. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/.gitignore +0 -0
  11. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/benchmarks/tasktrove/compare.py +0 -0
  12. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/benchmarks/tasktrove/sample.py +0 -0
  13. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/__init__.py +0 -0
  14. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/agent.py +0 -0
  15. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/__init__.py +0 -0
  16. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/daytona/__init__.py +0 -0
  17. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/daytona/machine.py +0 -0
  18. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/docker/__init__.py +0 -0
  19. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/docker/machine.py +0 -0
  20. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/gvisor/__init__.py +0 -0
  21. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/gvisor/machine.py +0 -0
  22. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/iris/__init__.py +0 -0
  23. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/iris/machine.py +0 -0
  24. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/local/__init__.py +0 -0
  25. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/local/launch.py +0 -0
  26. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/qemu/__init__.py +0 -0
  27. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/qemu/bundle.py +0 -0
  28. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/qemu/environment.py +0 -0
  29. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/qemu/guest/disk-init +0 -0
  30. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/qemu/guest/init +0 -0
  31. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/qemu/guest/pty-agent.c +0 -0
  32. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/qemu/image.py +0 -0
  33. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/qemu/machine.py +0 -0
  34. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/shellsim/__init__.py +0 -0
  35. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/backends/shellsim/environment.py +0 -0
  36. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/image.py +0 -0
  37. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/src/shellbox/machine.py +0 -0
  38. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/harbor_smoke.py +0 -0
  39. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/manual/README.md +0 -0
  40. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/manual/check_machine.py +0 -0
  41. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/manual/check_prepared_image.py +0 -0
  42. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/manual/check_qemu_isolation.py +0 -0
  43. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/manual/check_shell.py +0 -0
  44. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/manual/task/instruction.md +0 -0
  45. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/manual/task/task.toml +0 -0
  46. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/manual/task/tests/test.sh +0 -0
  47. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/manual/tasktrove_nl2bash_command.sh +0 -0
  48. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/test_daytona_machine.py +0 -0
  49. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/test_docker_machine.py +0 -0
  50. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/test_gvisor_machine.py +0 -0
  51. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/test_harbor_shellsim.py +0 -0
  52. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/test_image_cache.py +0 -0
  53. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/test_iris_machine.py +0 -0
  54. {marin_shellbox-0.2.151.dev37846966940 → marin_shellbox-0.2.153.dev37866254792}/tests/test_qemu_machine.py +0 -0
@@ -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, except that the local backend ignores
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
- factory = LocalMachineFactory(bin_dirs=(Path("/opt/grader-venv/bin"),))
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 venv in `bin_dirs` and the Python installation its `python3` links to. 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
+ - 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 the host's `PYTHONHASHSEED` if set, then `MachineSpec.env` and `Command.env`.
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
- - `memory_mb` is ignored: an address-space limit breaks NumPy and SymPy allocations. CPU, storage, and GPU requests raise `UnsupportedMachineSpec`.
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
@@ -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, except that the local backend ignores
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
- factory = LocalMachineFactory(bin_dirs=(Path("/opt/grader-venv/bin"),))
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 venv in `bin_dirs` and the Python installation its `python3` links to. 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
+ - 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 the host's `PYTHONHASHSEED` if set, then `MachineSpec.env` and `Command.env`.
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
- - `memory_mb` is ignored: an address-space limit breaks NumPy and SymPy allocations. CPU, storage, and GPU requests raise `UnsupportedMachineSpec`.
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "marin-shellbox"
7
- version = "0.2.151.dev37846966940"
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.151.dev37846966940",
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-try", path, path]
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 the venvs and interpreters of ``bin_dirs`` read-only. Every other path, including ``/tmp`` 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. Commands never inherit the host's environment: they get
412
- ``bin_dirs`` ahead of a standard ``PATH``, ``HOME``, ``LANG``, ``PYTHONHASHSEED`` when the factory has
413
- a ``hash_seed``, and the spec's and command's variables. A spec with ``memory_mb`` is rejected, since
414
- the backend enforces no memory limit.
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__(self, *, bin_dirs: tuple[Path, ...] = (), bwrap: Path | None = None, hash_seed: str | None = None):
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 = _interpreter_roots(self.bin_dirs)
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
- result = await self.machine._run_source(command)
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 this test's Python, from a venv whose interpreter may lie anywhere."""
86
- return make_factory(bin_dirs=(Path(sys.executable).parent,))
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 test_a_venv_linked_through_an_alias_directory_runs_its_own_interpreter(tmp_path):
341
- # uv links a venv's python through ``cpython-3.12-<platform>``, an alias symlink beside the resolved install.
342
- interpreter = Path(os.path.realpath(sys.executable))
343
- real = tmp_path / "real"
344
- (real / "bin").mkdir(parents=True)
345
- (real / "bin" / "python3").symlink_to(interpreter)
346
- (tmp_path / "alias").symlink_to(real, target_is_directory=True)
347
- venv = tmp_path / "venv"
348
- (venv / "bin").mkdir(parents=True)
349
- (venv / "bin" / "python").symlink_to(tmp_path / "alias" / "bin" / "python3")
350
- (venv / "bin" / "python3").symlink_to("python")
351
- (venv / "pyvenv.cfg").write_text(f"home = {tmp_path / 'alias' / 'bin'}\nversion = 3\n")
352
- factory = make_factory(bin_dirs=(venv / "bin",))
353
-
354
- async def scenario() -> Result:
355
- machine = await factory.create(MachineSpec(HostImage(), workdir="/app"))
356
- try:
357
- return await machine.run(Command(("python3", "-c", "import sys; print(sys.prefix)")))
358
- finally:
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())