xqo-docker 3.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xqo_docker-3.0.0/LICENSE +21 -0
- xqo_docker-3.0.0/PKG-INFO +37 -0
- xqo_docker-3.0.0/README.md +21 -0
- xqo_docker-3.0.0/pyproject.toml +27 -0
- xqo_docker-3.0.0/setup.cfg +4 -0
- xqo_docker-3.0.0/src/xqo_docker/__init__.py +25 -0
- xqo_docker-3.0.0/src/xqo_docker/backend.py +123 -0
- xqo_docker-3.0.0/src/xqo_docker.egg-info/PKG-INFO +37 -0
- xqo_docker-3.0.0/src/xqo_docker.egg-info/SOURCES.txt +12 -0
- xqo_docker-3.0.0/src/xqo_docker.egg-info/dependency_links.txt +1 -0
- xqo_docker-3.0.0/src/xqo_docker.egg-info/entry_points.txt +2 -0
- xqo_docker-3.0.0/src/xqo_docker.egg-info/requires.txt +5 -0
- xqo_docker-3.0.0/src/xqo_docker.egg-info/top_level.txt +1 -0
- xqo_docker-3.0.0/tests/test_docker.py +126 -0
xqo_docker-3.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dudikkocak
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xqo-docker
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: Docker sandbox backend for xqo run_code
|
|
5
|
+
Author: dudikkocak
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/dudikkocak/xqo
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: xqo<4,>=3.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-timeout>=2.3; extra == "dev"
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# xqo-docker
|
|
18
|
+
|
|
19
|
+
The `docker` sandbox backend for [xqo](https://github.com/dudikkocak/xqo)'s `run_code`: each run happens in a
|
|
20
|
+
throwaway container with no network (unless the user grants it), memory, CPU and process limits, and the host
|
|
21
|
+
user's uid. Python projects get a uv venv inside the image; Node, Go, Rust and .NET projects use their official
|
|
22
|
+
images when the toolchains plugin is installed.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install xqo-docker
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
"sandbox": {"backend": "docker"}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
With the plugin installed, `"backend": "auto"` picks Docker whenever the daemon answers and falls back to `local`
|
|
33
|
+
otherwise. `"plugins": {"disable": ["docker"]}` turns it off without uninstalling. `/api/plugins` shows whether the
|
|
34
|
+
daemon answered (`status.daemon`).
|
|
35
|
+
|
|
36
|
+
Needs the `docker` CLI and a running daemon (Docker Desktop, Docker Engine or a compatible one). The image is set
|
|
37
|
+
with `sandbox.image` (default `astral/uv:python3.12-bookworm-slim`); it needs `uv` and `python` on its PATH.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# xqo-docker
|
|
2
|
+
|
|
3
|
+
The `docker` sandbox backend for [xqo](https://github.com/dudikkocak/xqo)'s `run_code`: each run happens in a
|
|
4
|
+
throwaway container with no network (unless the user grants it), memory, CPU and process limits, and the host
|
|
5
|
+
user's uid. Python projects get a uv venv inside the image; Node, Go, Rust and .NET projects use their official
|
|
6
|
+
images when the toolchains plugin is installed.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install xqo-docker
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```json
|
|
13
|
+
"sandbox": {"backend": "docker"}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
With the plugin installed, `"backend": "auto"` picks Docker whenever the daemon answers and falls back to `local`
|
|
17
|
+
otherwise. `"plugins": {"disable": ["docker"]}` turns it off without uninstalling. `/api/plugins` shows whether the
|
|
18
|
+
daemon answered (`status.daemon`).
|
|
19
|
+
|
|
20
|
+
Needs the `docker` CLI and a running daemon (Docker Desktop, Docker Engine or a compatible one). The image is set
|
|
21
|
+
with `sandbox.image` (default `astral/uv:python3.12-bookworm-slim`); it needs `uv` and `python` on its PATH.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "xqo-docker"
|
|
7
|
+
version = "3.0.0"
|
|
8
|
+
description = "Docker sandbox backend for xqo run_code"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{name = "dudikkocak"}]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"xqo>=3.0,<4",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = ["pytest>=8", "pytest-timeout>=2.3"]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/dudikkocak/xqo"
|
|
22
|
+
|
|
23
|
+
[project.entry-points."xqo.plugins"]
|
|
24
|
+
docker = "xqo_docker:DockerPlugin"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
where = ["src"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""xqo-docker: the "docker" sandbox backend for run_code (throwaway containers, no network unless granted,
|
|
2
|
+
toolchain images). With it installed, "sandbox": {"backend": "auto"} picks Docker when the daemon answers; without it
|
|
3
|
+
(or with "plugins": {"disable": ["docker"]}) runs use the local backend."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import importlib
|
|
7
|
+
|
|
8
|
+
from xqo.api import Plugin, docker_available
|
|
9
|
+
|
|
10
|
+
__version__ = "3.0.0"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DockerPlugin(Plugin):
|
|
14
|
+
name = "docker"
|
|
15
|
+
description = "Docker sandbox backend for run_code"
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def register(cls) -> None:
|
|
19
|
+
importlib.import_module(f"{__name__}.backend") # registers "docker" with register_backend
|
|
20
|
+
|
|
21
|
+
def configure(self, ctx, cfg):
|
|
22
|
+
return {"backend": "docker"}
|
|
23
|
+
|
|
24
|
+
def status(self) -> dict:
|
|
25
|
+
return {**self.obj, "daemon": docker_available()}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Docker sandbox backend (plugin): throwaway containers for run_code, with toolchain images and offline runs.
|
|
2
|
+
The core keeps the `local` backend and the shared run/venv machinery."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import uuid
|
|
8
|
+
from pathlib import Path, PurePosixPath
|
|
9
|
+
|
|
10
|
+
from xqo.api import (DOCKER_PATH, Backend, Sandbox, current_toolchains, docker_available, docker_kill, docker_user,
|
|
11
|
+
is_python_project, mount_hint, net_args, register_backend, setup_net_args, uv_pip_command)
|
|
12
|
+
|
|
13
|
+
@register_backend
|
|
14
|
+
class DockerBackend(Backend):
|
|
15
|
+
"""A throwaway container: no network, memory/CPU/pid limits, the host user's uid. Python projects get a uv venv;
|
|
16
|
+
Node/Go/Rust/.NET projects run in their official image (at the version the project asks for), and mixed projects
|
|
17
|
+
in a composite image built once. Dependencies are installed by setup containers (network on, `setup_network`)
|
|
18
|
+
into the task's .deps-docker dir and shared download caches, which runs mount instead of copying."""
|
|
19
|
+
name = "docker"
|
|
20
|
+
toolchains = True
|
|
21
|
+
isolates_network = True
|
|
22
|
+
|
|
23
|
+
def available(self) -> bool:
|
|
24
|
+
return docker_available()
|
|
25
|
+
|
|
26
|
+
async def command(self, sb, work, command, env_dir, comps, network=False):
|
|
27
|
+
cfg, res, run_cmd = sb.config, {}, command
|
|
28
|
+
image, mounts, env = cfg.image, [], {}
|
|
29
|
+
toolchains = current_toolchains() # the toolchains plugin, or None
|
|
30
|
+
if toolchains and env_dir is not None and cfg.toolchains and any(c.kind != "python" for c in comps):
|
|
31
|
+
plan = toolchains.docker_plan(work, comps, cfg)
|
|
32
|
+
image = plan["image"]
|
|
33
|
+
info = {"kind": "image", "image": image, "tools": plan["tools"]}
|
|
34
|
+
if plan["dockerfile"]:
|
|
35
|
+
built = await sb.build_image(image, plan["dockerfile"], env_dir.parent / ".deps-docker")
|
|
36
|
+
info.update(built)
|
|
37
|
+
res["toolchains"] = [info]
|
|
38
|
+
if not info.get("error"):
|
|
39
|
+
deps = env_dir.parent / ".deps-docker"
|
|
40
|
+
runner = DockerRunner(sb, image, work, deps, toolchains.cache_root(cfg.cache_dir) / "docker")
|
|
41
|
+
prep = await toolchains.prepare(runner, work, comps, deps, cfg, {"HOME": "/tmp"})
|
|
42
|
+
res["toolchains"] += prep.info
|
|
43
|
+
env = {**prep.env, **({} if network or cfg.network else DOCKER_RUN_OFFLINE)}
|
|
44
|
+
mounts = runner.mounts()
|
|
45
|
+
if prep.managed and (note := toolchains.install_note(command)):
|
|
46
|
+
res["notes"] = [note]
|
|
47
|
+
container = f"orch-run-{uuid.uuid4().hex[:12]}" # named, so a cancel can kill it
|
|
48
|
+
argv = ["docker", "run", "--rm", "-i", "--name", container, "--memory", cfg.memory, "--cpus", cfg.cpus,
|
|
49
|
+
"--pids-limit", "256", *docker_user(), "-v", f"{work}:/work", *mounts, "-w", "/work",
|
|
50
|
+
"-e", "HOME=/tmp"]
|
|
51
|
+
argv += [x for k, v in env.items() if k != "HOME" for x in ("-e", f"{k}={v}")]
|
|
52
|
+
if env_dir is not None and cfg.venv and is_python_project(work):
|
|
53
|
+
denv = env_dir.with_name(env_dir.name + "-docker")
|
|
54
|
+
venv = await sb.prepare_docker_env(work, denv, image)
|
|
55
|
+
res["venv"] = venv
|
|
56
|
+
if not venv.get("error"): # run inside the task's venv; uv run/sync use it too
|
|
57
|
+
argv += ["-v", f"{denv}:/venv", "-e", "VIRTUAL_ENV=/venv/env", "-e", "UV_PROJECT_ENVIRONMENT=/venv/env",
|
|
58
|
+
"-e", "UV_CACHE_DIR=/venv/cache", "-e", "UV_PYTHON_INSTALL_DIR=/venv/python",
|
|
59
|
+
"-e", f"PATH=/venv/env/bin:{DOCKER_PATH}"]
|
|
60
|
+
run_cmd = uv_pip_command(command)
|
|
61
|
+
if network: # approved by the user for this run: the host's CA and proxies, like setup containers
|
|
62
|
+
argv += net_args(cfg)
|
|
63
|
+
elif not cfg.network:
|
|
64
|
+
argv += ["--network", "none"]
|
|
65
|
+
argv += [image, "sh", "-c", run_cmd]
|
|
66
|
+
return {"argv": argv, "env": None, "run_cmd": run_cmd, "container": container, "result": res}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# runs have no network: tools fail fast instead of trying to download
|
|
70
|
+
DOCKER_RUN_OFFLINE = {"GOPROXY": "off", "GOTOOLCHAIN": "local", "CARGO_NET_OFFLINE": "true",
|
|
71
|
+
"npm_config_prefer_offline": "true"}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class DockerRunner:
|
|
75
|
+
"""toolchains.prepare's setup commands, each in a throwaway container of the run's image: the run copy at /work,
|
|
76
|
+
the task's deps at /deps, the shared download caches at /cache; network on unless setup_network is false."""
|
|
77
|
+
versions = False # the image is the version
|
|
78
|
+
host_env = False
|
|
79
|
+
|
|
80
|
+
def __init__(self, sb: "Sandbox", image: str, work: Path, deps: Path, cache: Path):
|
|
81
|
+
self.sb, self.image, self.work, self.deps, self.cache = sb, image, work, deps, cache
|
|
82
|
+
deps.mkdir(parents=True, exist_ok=True) # made by us, not by Docker (it would make them root's)
|
|
83
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
|
|
85
|
+
def deps_view(self, deps: Path):
|
|
86
|
+
return PurePosixPath("/deps")
|
|
87
|
+
|
|
88
|
+
def mounts(self) -> list[str]:
|
|
89
|
+
return ["-v", f"{self.deps}:/deps", "-v", f"{self.cache}:/cache"]
|
|
90
|
+
|
|
91
|
+
def where(self, p: Path) -> str:
|
|
92
|
+
"""A host path under the run copy or the deps dir, as the container sees it."""
|
|
93
|
+
for host, ctr in ((self.work, "/work"), (self.deps, "/deps")):
|
|
94
|
+
try:
|
|
95
|
+
return (PurePosixPath(ctr) / p.relative_to(host).as_posix()).as_posix()
|
|
96
|
+
except ValueError:
|
|
97
|
+
pass
|
|
98
|
+
raise ValueError(f"{p} is not mounted")
|
|
99
|
+
|
|
100
|
+
def exe(self, argv: list[str], env: dict) -> list[str]:
|
|
101
|
+
return ["corepack", *argv] if argv[0] in ("pnpm", "yarn") else argv # node images ship corepack
|
|
102
|
+
|
|
103
|
+
def link(self, target: Path, link: Path) -> None:
|
|
104
|
+
os.symlink(self.where(target), link, target_is_directory=True) # dangling on the host, valid inside
|
|
105
|
+
|
|
106
|
+
async def sh(self, argv, cwd: Path, env: dict, timeout: float):
|
|
107
|
+
cfg = self.sb.config
|
|
108
|
+
name = f"orch-setup-{uuid.uuid4().hex[:12]}"
|
|
109
|
+
dargv = ["docker", "run", "--rm", "-i", "--name", name, "--memory", cfg.memory, "--cpus", cfg.cpus,
|
|
110
|
+
"--pids-limit", "512", *docker_user(), "-v", f"{self.work}:/work", *self.mounts(),
|
|
111
|
+
*setup_net_args(cfg), "-w", self.where(cwd),
|
|
112
|
+
*[x for k, v in env.items() for x in ("-e", f"{k}={v}")]]
|
|
113
|
+
if not cfg.setup_network:
|
|
114
|
+
dargv += ["--network", "none"]
|
|
115
|
+
try:
|
|
116
|
+
code, out = await self.sb._sh([*dargv, self.image, *argv], self.work, dict(os.environ), timeout)
|
|
117
|
+
except asyncio.CancelledError:
|
|
118
|
+
await docker_kill(name)
|
|
119
|
+
raise
|
|
120
|
+
if code == -1:
|
|
121
|
+
await docker_kill(name)
|
|
122
|
+
return code, (mount_hint(out) + "\n" + out) if code == 125 and mount_hint(out) else out
|
|
123
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xqo-docker
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: Docker sandbox backend for xqo run_code
|
|
5
|
+
Author: dudikkocak
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/dudikkocak/xqo
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: xqo<4,>=3.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-timeout>=2.3; extra == "dev"
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# xqo-docker
|
|
18
|
+
|
|
19
|
+
The `docker` sandbox backend for [xqo](https://github.com/dudikkocak/xqo)'s `run_code`: each run happens in a
|
|
20
|
+
throwaway container with no network (unless the user grants it), memory, CPU and process limits, and the host
|
|
21
|
+
user's uid. Python projects get a uv venv inside the image; Node, Go, Rust and .NET projects use their official
|
|
22
|
+
images when the toolchains plugin is installed.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install xqo-docker
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
"sandbox": {"backend": "docker"}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
With the plugin installed, `"backend": "auto"` picks Docker whenever the daemon answers and falls back to `local`
|
|
33
|
+
otherwise. `"plugins": {"disable": ["docker"]}` turns it off without uninstalling. `/api/plugins` shows whether the
|
|
34
|
+
daemon answered (`status.daemon`).
|
|
35
|
+
|
|
36
|
+
Needs the `docker` CLI and a running daemon (Docker Desktop, Docker Engine or a compatible one). The image is set
|
|
37
|
+
with `sandbox.image` (default `astral/uv:python3.12-bookworm-slim`); it needs `uv` and `python` on its PATH.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/xqo_docker/__init__.py
|
|
5
|
+
src/xqo_docker/backend.py
|
|
6
|
+
src/xqo_docker.egg-info/PKG-INFO
|
|
7
|
+
src/xqo_docker.egg-info/SOURCES.txt
|
|
8
|
+
src/xqo_docker.egg-info/dependency_links.txt
|
|
9
|
+
src/xqo_docker.egg-info/entry_points.txt
|
|
10
|
+
src/xqo_docker.egg-info/requires.txt
|
|
11
|
+
src/xqo_docker.egg-info/top_level.txt
|
|
12
|
+
tests/test_docker.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
xqo_docker
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""The docker sandbox backend, driven by a fake `docker` script (no daemon needed), plus one real-daemon run."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from xqo.api import Sandbox, SandboxConfig, build, docker_available, mount_hint
|
|
10
|
+
from xqo.sandbox import format_result # not part of the plugin API; used here only to check the report text
|
|
11
|
+
|
|
12
|
+
posix_only = pytest.mark.skipif(os.name == "nt", reason="the fake docker is a sh script")
|
|
13
|
+
PREFIX = "python -c 'import sys; print(sys.prefix)'"
|
|
14
|
+
DENIED = ("docker: Error response from daemon: mounts denied:\nThe path /tmp/orch-run-cgm9yxvr/work is not shared "
|
|
15
|
+
"from the host and is not known to Docker.\nYou can configure shared paths from Docker -> Preferences...\n")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def fake_docker(tmp_path, monkeypatch, script):
|
|
19
|
+
bin_ = tmp_path / "bin"
|
|
20
|
+
bin_.mkdir()
|
|
21
|
+
(bin_ / "docker").write_text("#!/bin/sh\n" + script)
|
|
22
|
+
(bin_ / "docker").chmod(0o755)
|
|
23
|
+
monkeypatch.setenv("PATH", f"{bin_}{os.pathsep}{os.environ['PATH']}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def project(tmp_path, reqs="# no deps\n"):
|
|
27
|
+
ws = tmp_path / "ws"
|
|
28
|
+
ws.mkdir(parents=True)
|
|
29
|
+
(ws / "m.py").write_text("print('ok')\n")
|
|
30
|
+
(ws / "requirements.txt").write_text(reqs)
|
|
31
|
+
return ws
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_plugin_is_found_and_active(tmp_path):
|
|
35
|
+
cfg = tmp_path / "agents.json"
|
|
36
|
+
cfg.write_text(json.dumps({"agents": [{"name": "planner", "skills": ["planning"]}]}))
|
|
37
|
+
bus = build(cfg, tmp_path / "artifacts", str(tmp_path / "logs" / "events.jsonl"), mock=True, departments="none")
|
|
38
|
+
st = bus.plugins.states["docker"]
|
|
39
|
+
assert st.state == "active" and st.source == "xqo_docker:DockerPlugin"
|
|
40
|
+
assert Sandbox(SandboxConfig(backend="docker")).network_requestable
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@posix_only
|
|
44
|
+
def test_run_with_network_drops_none_and_offline_env(tmp_path, monkeypatch):
|
|
45
|
+
log = tmp_path / "docker.log"
|
|
46
|
+
fake_docker(tmp_path, monkeypatch, f'printf "%s\\n" "$@" > "{log}"\n')
|
|
47
|
+
ca = tmp_path / "ca.pem"
|
|
48
|
+
ca.write_text("x")
|
|
49
|
+
sb = Sandbox(SandboxConfig(backend="docker", venv=False, ca_file=str(ca)))
|
|
50
|
+
asyncio.run(sb.run(tmp_path / "ws", "curl https://example.com", network=True))
|
|
51
|
+
argv = log.read_text().splitlines()
|
|
52
|
+
assert "none" not in argv and f"{ca.resolve()}:/etc/ssl/orch-ca.pem:ro" in argv
|
|
53
|
+
asyncio.run(sb.run(tmp_path / "ws", "curl https://example.com"))
|
|
54
|
+
argv = log.read_text().splitlines()
|
|
55
|
+
assert argv[argv.index("--network") + 1] == "none" and not any("orch-ca" in a for a in argv)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@posix_only
|
|
59
|
+
def test_mounts_the_run_dir_copy(tmp_path, monkeypatch):
|
|
60
|
+
log = tmp_path / "docker.log"
|
|
61
|
+
fake_docker(tmp_path, monkeypatch, f'printf "%s\\n" "$@" > "{log}"\n')
|
|
62
|
+
ws = tmp_path / "ws"
|
|
63
|
+
ws.mkdir()
|
|
64
|
+
root = tmp_path / "runs"
|
|
65
|
+
r = asyncio.run(Sandbox(SandboxConfig(backend="docker", run_dir=str(root))).run(ws, "true"))
|
|
66
|
+
mount = next(a for a in log.read_text().splitlines() if a.endswith(":/work"))
|
|
67
|
+
assert mount.startswith(str(root.resolve()) + os.sep + "orch-run-") and r["exit_code"] == 0 and "error" not in r
|
|
68
|
+
assert not list(root.glob("orch-run-*")) # removed after the run
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@posix_only
|
|
72
|
+
def test_mounts_denied_reported_with_a_fix(tmp_path, monkeypatch):
|
|
73
|
+
assert "File Sharing" in mount_hint(DENIED)
|
|
74
|
+
fake_docker(tmp_path, monkeypatch, f"cat >&2 <<'X'\n{DENIED}X\nexit 125\n")
|
|
75
|
+
ws = tmp_path / "ws"
|
|
76
|
+
ws.mkdir()
|
|
77
|
+
(ws / "requirements.txt").write_text("# none\n")
|
|
78
|
+
sb = Sandbox(SandboxConfig(backend="docker", run_dir=str(tmp_path / "runs")))
|
|
79
|
+
r = asyncio.run(sb.run(ws, "python -m unittest", env_dir=tmp_path / "task" / ".venv"))
|
|
80
|
+
assert r["exit_code"] == 125 and "File Sharing" in r["error"] and "File Sharing" in r["venv"]["error"]
|
|
81
|
+
out = format_result(r)
|
|
82
|
+
assert "[sandbox ERROR: Docker can't mount" in out and "[venv ERROR: Docker can't mount" in out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
FAKE_DOCKER = """printf '%s\\0' "$@" >> "$XQO_FAKE_DOCKER_LOG"; printf '\\036' >> "$XQO_FAKE_DOCKER_LOG"\n"""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@posix_only
|
|
89
|
+
def test_venv_made_with_uv_in_the_image(tmp_path, monkeypatch):
|
|
90
|
+
fake_docker(tmp_path, monkeypatch, FAKE_DOCKER)
|
|
91
|
+
log = tmp_path / "docker.log"
|
|
92
|
+
monkeypatch.setenv("XQO_FAKE_DOCKER_LOG", str(log))
|
|
93
|
+
ws, env = project(tmp_path), tmp_path / "task" / ".venv"
|
|
94
|
+
(ws / "tests").mkdir()
|
|
95
|
+
sb = Sandbox(SandboxConfig(backend="docker"))
|
|
96
|
+
r = asyncio.run(sb.run(ws, "pytest -q", env_dir=env))
|
|
97
|
+
calls = lambda: [c.split("\0")[:-1] for c in log.read_text().split("\x1e")[:-1]] # noqa: E731
|
|
98
|
+
setup, run = calls()
|
|
99
|
+
script = setup[-1]
|
|
100
|
+
assert f"{env}-docker:/venv" in setup and setup[:2] == ["run", "--rm"]
|
|
101
|
+
assert "uv venv --quiet /venv/env" in script and "uv pip install --quiet --python /venv/env/bin/python -r requirements.txt" in script
|
|
102
|
+
assert "pytest" in script and "-m venv" not in script and "\npip " not in script
|
|
103
|
+
assert SandboxConfig().image in setup and "--network" not in setup # deps may be downloaded during setup
|
|
104
|
+
assert f"{env}-docker:/venv" in run and "VIRTUAL_ENV=/venv/env" in run and run[run.index("none") - 1] == "--network"
|
|
105
|
+
assert r["venv"]["tool"] == "uv" and r["venv"]["created"] and r["venv"]["installed"] == ["requirements.txt"]
|
|
106
|
+
(tmp_path / "task" / ".venv-docker" / "env").mkdir() # what the image's uv would have made
|
|
107
|
+
r = asyncio.run(sb.run(ws, "pytest -q", env_dir=env)) # same deps: no setup container
|
|
108
|
+
assert r["venv"].get("reused") and len(calls()) == 3
|
|
109
|
+
r = asyncio.run(sb.run(ws, "pip install x", env_dir=env))
|
|
110
|
+
assert calls()[-1][-1] == "uv pip install x" and r["ran"] == calls()[-1][-1] and "VIRTUAL_ENV=/venv/env" in calls()[-1]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
IMAGE = SandboxConfig().image
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _image_ready():
|
|
117
|
+
return docker_available() and subprocess.run(["docker", "image", "inspect", IMAGE], capture_output=True).returncode == 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.mark.skipif(not _image_ready(), reason=f"docker or the {IMAGE} image is not available")
|
|
121
|
+
def test_real_docker_runs_in_uv_venv(tmp_path):
|
|
122
|
+
ws, env = project(tmp_path), tmp_path / "task" / ".venv"
|
|
123
|
+
r = asyncio.run(Sandbox(SandboxConfig(backend="docker", timeout=60)).run(ws, PREFIX + " && uv --version", env_dir=env))
|
|
124
|
+
assert r["exit_code"] == 0, r
|
|
125
|
+
assert r["stdout"].splitlines()[0] == "/venv/env" and r["stdout"].splitlines()[1].startswith("uv ")
|
|
126
|
+
assert (tmp_path / "task" / ".venv-docker" / "env").is_dir()
|