From 9b360cc47020c17b4fe3211826683e109443b18c Mon Sep 17 00:00:00 2001 From: jgforbes Date: Wed, 21 Oct 2026 02:09:35 +0000 Subject: [PATCH] feat: add opt-in direct GPU access (--gpu) Expose host GPU devices inside the sandbox behind an explicit --gpu flag. NVIDIA (/dev/nvidiaN + auxiliary), DRM (dri/cardN, renderDN) and AMD (kfd) character devices are discovered at launch and mounted individually with --dev-bind, along with read-only sysfs and driver metadata (ld.so.cache, OpenCL/Vulkan/glvnd ICDs, /proc/driver/nvidia). Symlinked nodes and device directories are rejected, non-matching names are skipped, and a missing GPU fails with a clear error before bwrap runs. GPU mounts follow the private /dev, so explicit --with/--with-ro binds can still override them and the OpenCode v2 isolation overlay stays last. The feature is default-off; without --gpu no GPU-specific mounts or probes are added. Ported from the sandbox-podman-persistent-tmp branch (6283b83), scoped to the GPU feature only. Tests are ported from unittest/test_podman to the pytest harness in tests/ (14 new tests; full suite: 76 passed). Docs note that dri/cardN also exposes display/KMS and that NPUs stay out of scope, per upstream feedback on the earlier proposal (#6). --- README.md | 95 +++++++++++++- opencodebox | 88 +++++++++++++ tests/conftest.py | 6 +- tests/test_gpu.py | 308 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 493 insertions(+), 4 deletions(-) create mode 100644 tests/test_gpu.py diff --git a/README.md b/README.md index 3632137..9216fdb 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Run OpenCode inside a bubblewrap sandbox for security isolation. - **Controlled Filesystem**: Most system filesystem mounted read-only - **Custom Bind Mounts**: Add read-write or read-only access with `--with` and `--with-ro` - **Mise Support**: Integrated with [mise](https://mise.jdx.dev) for tool management +- **Direct GPU Access (Opt-In)**: Expose NVIDIA/DRM GPU devices with `--gpu` - **SSH Agent Forwarding**: Supports SSH commit signing through the host `ssh-agent` - **Seccomp Sandbox Filter**: Mitigates kernel privilege escalation vulnerabilities (see [details](#seccomp-sandbox-filter)) @@ -50,6 +51,7 @@ opencodebox [OPTIONS] [OPENCODE_ARGS...] - `--with /host[:/sandbox]` - Bind host path read-write to sandbox - `--with-ro /host[:/sandbox]` - Bind host path read-only to sandbox +- `--gpu` - Expose host GPU devices for direct sandbox workloads; see [direct GPU access](#direct-gpu-access-opt-in) These options allow you to specify additional directories to mount inside the sandbox for read-write or read-only access. @@ -74,7 +76,7 @@ opencodebox --with /data --with-ro /config serve ## How It Works -1. Parse arguments (`--with`, `--with-ro`, `--version`, `--help`) +1. Parse arguments (`--with`, `--with-ro`, `--gpu`, `--version`, `--help`) 2. Check prerequisites (bwrap and opencode) 3. Load seccomp sandbox filter (see [details](#seccomp-sandbox-filter)) 4. **Enforce security restrictions**: @@ -150,6 +152,95 @@ Each tool is mounted only when `command -v ` succeeds on the host. If the - **State directory**: `~/.local/state/opencode` is mounted read-write so that locks and TUI state stay shared between the sandbox and the host. The launcher masks the host background-service registration inside the sandbox, but a sandboxed process can still create files in that directory, and the host client reads some of them (including the pty runtime registration). Treat that directory as shared state, not as a trust boundary. - **SSH directory**: `~/.ssh` must have permissions `0700`. Fix with: `chmod 700 ~/.ssh` +## Direct GPU Access (Opt-In) + +`--gpu` exposes GPU devices to programs running **directly inside opencodebox**: + +```bash +opencodebox --gpu +``` + +When testing a checkout, use `./opencodebox` rather than an older installed copy. +Launch from your normal host environment: an existing sandbox without GPU +mounts cannot forward devices it cannot see. No mounts are added to an already +running session. + +### Devices and driver dependencies + +The switch discovers and mounts these existing character devices individually +using bubblewrap's `--dev-bind` (not a bind of the entire host `/dev`): + +| Device family | Paths | +|---|---| +| NVIDIA GPUs | `/dev/nvidiaN` (numeric N) | +| NVIDIA auxiliary devices | `/dev/nvidiactl`, `/dev/nvidia-uvm`, `/dev/nvidia-uvm-tools`, `/dev/nvidia-modeset`, `/dev/nvidia-caps/nvidia-capN` | +| DRM GPUs (Intel/AMD/NVIDIA) | `/dev/dri/cardN`, `/dev/dri/renderDN` | +| AMD compute | `/dev/kfd` | + +Note that `/dev/dri/cardN` also exposes the display/KMS interface, not just +rendering; `/dev/dri/renderDN` is the render-only node. There is no per-device +selection: both are mounted when present. NPU devices (`/dev/accel/*`) are out +of scope. + +It requires at least one NVIDIA GPU or DRM card/render character device. It +skips non-character files and symlinked device nodes, rejects symlinked GPU +parent directories, and fails clearly when no GPU is found. These checks assume +trusted host `/dev`; they do not authenticate device drivers or eliminate +concurrent replacement races. Devices disappearing during launch cause a mount +failure rather than silent partial access. + +GPU mode also mounts these **read-only, if present**: + +- `/sys`, preserving the host sysfs topology and its symlink relationships. +- `/etc/ld.so.cache`, for host driver library discovery. +- `/etc/OpenCL/vendors`, `/etc/vulkan/icd.d`, `/etc/glvnd/egl_vendor.d`. +- `/proc/driver/nvidia`, when an NVIDIA GPU device was found. + +Driver libraries and `/usr/share` vendor manifests under `/usr` are already +visible. The switch does not install CUDA, ROCm, PyTorch, or other SDKs. Libraries, +cache entries, or manifests pointing outside the exposed filesystem may need +explicit same-path `--with-ro` mounts. For example, a ROCm installation under +`/opt/rocm` may require both that path and its versioned symlink target. Direct +GPU access is not a guarantee of compatibility with every driver/SDK layout. + +### Verify and troubleshoot + +For NVIDIA, first ensure `nvidia-smi -L` works **on the host**. After launching +with `--gpu`, run inside the new sandbox: + +```bash +nvidia-smi -L +# If a CUDA-enabled PyTorch environment is already installed: +python -c 'import torch; print(torch.cuda.is_available()); print(torch.cuda.device_count())' +``` + +`nvidia-smi` succeeding alone does not prove a CUDA workload works. When NVIDIA +GPU nodes exist but `/dev/nvidia-uvm` is absent, the launcher warns that CUDA may +fail. Initialize the NVIDIA/UVM driver using your distribution's normal host +setup procedure, then restart opencodebox. The launcher never loads modules, +runs `sudo`, changes device permissions, or initializes GPUs automatically. + +Existing host permissions, ACLs, supplementary group access, SELinux rules, and +device-cgroup restrictions still apply. Fix access through the host's normal +GPU configuration rather than granting blanket privileges. Hotplugged or +replaced device nodes may require a sandbox restart. Explicit `--with` mounts +still take precedence and can hide or replace GPU-related mounts. + +### Security Notes + +> **Warning:** `--gpu` exposes all matching GPU nodes present at launch, subject +> to host access controls. It adds access to GPU driver ioctls, shared GPU +> resources, and potentially display/control interfaces—not just compute. Driver +> vulnerabilities, resource exhaustion, and host display interference are risks. +> Read-only `/sys` also exposes non-GPU host hardware/kernel information. There +> is no GPU memory quota, per-device selection, or cross-session isolation. + +The local namespace, capability, and seccomp settings are unchanged; the seccomp +filter does not broadly block GPU ioctls. GPU visibility environment variables +are not security boundaries. This switch does not forward X11/Wayland sockets +or otherwise provide desktop-session access. Without `--gpu`, no GPU-specific +mounts or probes are added. + ## SSH Agent and Git Signing If `SSH_AUTH_SOCK` points to a valid socket, `opencodebox` forwards that socket into the sandbox. This allows SSH commit signing with keys already loaded by `ssh-add` on the host. This feature does not mount private SSH keys into the sandbox. @@ -204,7 +295,7 @@ bash tests/seccomp.sh # seccomp integration (real bwrap + prebuilt .bpf ``` Launcher tests are black-box: `bwrap` is mocked, the argv it would receive is -asserted. GPU tests (future) plug into `tests/conftest.py` the same way. +asserted. GPU tests (`tests/test_gpu.py`) use the same `tests/conftest.py` harness. ### Seccomp BPF filter diff --git a/opencodebox b/opencodebox index 71595ad..1e6f2ff 100755 --- a/opencodebox +++ b/opencodebox @@ -27,6 +27,7 @@ Usage: Options: --with /host[:/sandbox] Bind host path read-write (sandbox path defaults to host path) --with-ro /host[:/sandbox] Bind host path read-only (sandbox path defaults to host path) + --gpu Expose host GPU devices and read-only driver metadata --version, -v Show version information --help, -h Show this help message @@ -39,6 +40,7 @@ Isolation: the printed URL in the host browser). Examples: + opencodebox --gpu opencodebox --with ../app-backend opencodebox --with /mnt/data:/workspace/data opencodebox --with-ro /etc/hosts @@ -163,10 +165,87 @@ sanitize_ssh_public_key_file() { esac } +# BEGIN GPU MOUNTS +# Internal root arguments allow fixture testing without real GPU hardware. +# The launcher always calls this with the host's standard paths. /dev must be +# trusted: type/name checks are not driver authentication or race protection. +configure_gpu_mounts() { + local dev_root="${1:-/dev}" sys_root="${2:-/sys}" + local etc_root="${3:-/etc}" proc_root="${4:-/proc}" + local device relative primary=0 nvidia=0 uvm=0 dri_dir=0 caps_dir=0 + local -a mounts=() + + if [[ -L "$dev_root/dri" || -L "$dev_root/nvidia-caps" ]]; then + echo "Error: --gpu refuses symlinked GPU device directories." >&2 + return 1 + fi + + for device in "$dev_root"/nvidia[0-9]* \ + "$dev_root"/nvidiactl "$dev_root"/nvidia-uvm \ + "$dev_root"/nvidia-uvm-tools "$dev_root"/nvidia-modeset \ + "$dev_root"/nvidia-caps/nvidia-cap[0-9]* \ + "$dev_root"/dri/card[0-9]* "$dev_root"/dri/renderD[0-9]* \ + "$dev_root"/kfd; do + relative="${device#"$dev_root/"}" + # Globs discover candidates; anchored expressions enforce numeric names. + if [[ ! "$relative" =~ ^nvidia[0-9]+$ && + ! "$relative" =~ ^dri/(card[0-9]+|renderD[0-9]+)$ && + ! "$relative" =~ ^nvidia-caps/nvidia-cap[0-9]+$ ]]; then + case "$relative" in + nvidiactl|nvidia-uvm|nvidia-uvm-tools|nvidia-modeset|kfd) ;; + *) continue ;; + esac + fi + # stat without -L does not follow symlinks. Fix locale for its type text. + [[ ! -L "$device" ]] || continue + [[ "$(LC_ALL=C stat -c '%F' "$device" 2>/dev/null)" == "character special file" ]] || continue + + if [[ "$relative" =~ ^nvidia[0-9]+$ ]]; then + primary=$((primary + 1)) + nvidia=1 + elif [[ "$relative" =~ ^dri/(card[0-9]+|renderD[0-9]+)$ ]]; then + primary=$((primary + 1)) + fi + [[ "$relative" == nvidia-uvm ]] && uvm=1 + if [[ "$relative" == dri/* && "$dri_dir" == 0 ]]; then + mounts+=(--dir /dev/dri) + dri_dir=1 + fi + if [[ "$relative" == nvidia-caps/* && "$caps_dir" == 0 ]]; then + mounts+=(--dir /dev/nvidia-caps) + caps_dir=1 + fi + mounts+=(--dev-bind "$device" "/dev/$relative") + done + + if [[ "$primary" == 0 ]]; then + echo "Error: --gpu found no NVIDIA or DRM GPU character devices. Initialize the GPU driver on the host and relaunch opencodebox." >&2 + return 1 + fi + + # Preserve sysfs symlink topology and the host's driver library discovery. + # /usr (including /usr/share vendor manifests) is already read-only mounted. + mounts+=(--ro-bind-try "$sys_root" /sys) + mounts+=(--ro-bind-try "$etc_root/ld.so.cache" /etc/ld.so.cache) + mounts+=(--ro-bind-try "$etc_root/OpenCL/vendors" /etc/OpenCL/vendors) + mounts+=(--ro-bind-try "$etc_root/vulkan/icd.d" /etc/vulkan/icd.d) + mounts+=(--ro-bind-try "$etc_root/glvnd/egl_vendor.d" /etc/glvnd/egl_vendor.d) + if [[ "$nvidia" == 1 ]]; then + mounts+=(--ro-bind-try "$proc_root/driver/nvidia" /proc/driver/nvidia) + if [[ "$uvm" == 0 ]]; then + echo "Warning: NVIDIA UVM device is absent; CUDA workloads may fail. Initialize the NVIDIA/UVM driver on the host and restart the sandbox." >&2 + fi + fi + BWRAP_ARGS+=("${mounts[@]}") + echo "Warning: --gpu exposes host GPU devices and read-only sysfs; GPU resources and driver access are shared with the host." >&2 +} +# END GPU MOUNTS + # Parse --with and --with-ro arguments # These allow users to bind additional paths into the sandbox declare -a EXTRA_BINDS=() declare -a REMAINING_ARGS=() +GPU_ENABLED=false add_readonly_regular_file() { local file="$1" @@ -253,6 +332,10 @@ while [[ $# -gt 0 ]]; do parse_bind_arg "--ro-bind" "$1" shift ;; + --gpu) + GPU_ENABLED=true + shift + ;; # Pass through all other args to opencode *) REMAINING_ARGS+=("$1") @@ -513,6 +596,11 @@ declare -a BWRAP_ARGS=( "$OPCODE_BIND_FLAG" "$OPCODE_ROOT" /opt/opencode ) +# Device mounts must follow the private /dev; explicit user binds still come last. +if [[ "$GPU_ENABLED" == true ]]; then + configure_gpu_mounts || exit 1 +fi + # Private keyring (unmount if present) [[ -d "$HOME/.local/share/keyrings" ]] && BWRAP_ARGS+=(--tmpfs "$HOME/.local/share/keyrings") diff --git a/tests/conftest.py b/tests/conftest.py index e6b99e9..92ffd4f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -63,11 +63,13 @@ def command(self, name: str, body: str) -> None: ) path.chmod(0o755) - def run_launcher(self, *args: str, cwd: Path | None = None) -> subprocess.CompletedProcess: + def run_launcher(self, *args: str, cwd: Path | None = None, + script: Path | None = None) -> subprocess.CompletedProcess: + """Run the launcher (or an adapted copy of it) as a black box.""" self.capture.unlink(missing_ok=True) self.calls.unlink(missing_ok=True) result = subprocess.run( - [str(self.bin / "bash"), str(LAUNCHER), *args], + [str(self.bin / "bash"), str(script if script is not None else LAUNCHER), *args], cwd=str(cwd or self.project), env=self.env, capture_output=True, diff --git a/tests/test_gpu.py b/tests/test_gpu.py new file mode 100644 index 0000000..9e4784a --- /dev/null +++ b/tests/test_gpu.py @@ -0,0 +1,308 @@ +"""GPU mount contract tests; no devices, containers, or services are started. + +Run with: uv run --group dev pytest tests/test_gpu.py -v +Regular fixture files become character devices only to the mocked stat command. +The helper is extracted from the production launcher, never reimplemented here. +""" + +import json +import shlex +import shutil +import subprocess +from pathlib import Path + +import pytest + +from conftest import LAUNCHER, Launcher + +GPU_BEGIN, GPU_END = "# BEGIN GPU MOUNTS", "# END GPU MOUNTS" +HELPER_CALL = "configure_gpu_mounts || exit 1" + + +class GpuLauncher(Launcher): + """Launcher harness plus fake /dev, /sys, /etc, /proc roots for the helper. + + A mocked `stat` reports fixture device files as character devices; every + other stat invocation is delegated to the real stat binary. + """ + + def __init__(self, root: Path): + super().__init__(root) + self.roots = [self.root / (name + " fixture") + for name in ("dev", "sys", "etc", "proc")] + self.dev, self.sys, self.etc, self.proc = self.roots + for root in self.roots: + root.mkdir() + self.devices: set[str] = set() + # conftest symlinks the real stat; replace it with the fixture mock. + (self.bin / "stat").unlink() + real_stat = shutil.which("stat", path="/usr/bin:/bin") + assert real_stat is not None + self.command("stat", f""" +if '%F' in sys.argv and Path(sys.argv[-1]).exists() and sys.argv[-1] in json.loads(os.environ['GPU_TEST_DEVICES']): + print('character special file') +else: + os.execv({real_stat!r}, ['stat', *sys.argv[1:]]) +""") + self.env["GPU_TEST_DEVICES"] = "[]" + + def device(self, name: str, character: bool = True) -> Path: + path = self.dev / name + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + if character: + self.devices.add(str(path)) + self.env["GPU_TEST_DEVICES"] = json.dumps(sorted(self.devices)) + return path + + def fixture_call(self) -> str: + return "configure_gpu_mounts " + shlex.join(map(str, self.roots)) + " || exit 1" + + def helper_text(self) -> str: + text = LAUNCHER.read_text() + assert text.count(GPU_BEGIN) == 1, "GPU helper needs its BEGIN marker" + assert text.count(GPU_END) == 1, "GPU helper needs its END marker" + helper = text.split(GPU_BEGIN, 1)[1].split(GPU_END, 1)[0] + assert "configure_gpu_mounts" in helper + return helper + + def run_helper(self) -> subprocess.CompletedProcess: + script = (self.helper_text() + '\nBWRAP_ARGS=(--dev /dev --proc /proc)\n' + + self.fixture_call() + '\nexec bwrap "${BWRAP_ARGS[@]}" --\n') + self.capture.unlink(missing_ok=True) + self.calls.unlink(missing_ok=True) + return subprocess.run( + [str(self.bin / "bash"), "-c", script], cwd=str(self.project), + env=self.env, capture_output=True, text=True, timeout=30, + ) + + def run_fixture_launcher(self, *args: str) -> subprocess.CompletedProcess: + """Run the launcher with the helper pointed at the fixture roots.""" + text = LAUNCHER.read_text() + assert text.count(HELPER_CALL) == 1, "launcher must propagate helper failure" + launcher = self.root / "fixture launcher" + launcher.write_text(text.replace(HELPER_CALL, self.fixture_call(), 1)) + return self.run_launcher(*args, script=launcher) + + @staticmethod + def binds(options: list, flag: str = "--dev-bind") -> list: + return [options[i:i + 3] for i, item in enumerate(options) if item == flag] + + def assert_devices(self, options: list, names: list) -> None: + assert sorted(map(tuple, self.binds(options))) == sorted( + ("--dev-bind", str(self.dev / name), "/dev/" + name) for name in names) + for name in names: + assert any(call[0] == "stat" and "-c" in call and "%F" in call + and str(self.dev / name) == call[-1] + for call in self.command_calls()), name + pairs = [options[i:i + 2] for i in range(len(options) - 1)] + for parent in ("dri", "nvidia-caps"): + expected = any(name.startswith(parent + "/") for name in names) + assert pairs.count(["--dir", "/dev/" + parent]) == int(expected) + if expected: + assert options.index("/dev/" + parent) < min( + options.index(str(self.dev / name)) for name in names + if name.startswith(parent + "/")) + + def assert_failure(self, result: subprocess.CompletedProcess) -> None: + assert result.returncode != 0, result.stdout + result.stderr + assert not self.capture.exists(), "bwrap ran after GPU setup failed" + assert result.stderr.strip(), "GPU failure needs a diagnostic" + + +@pytest.fixture +def gpu(tmp_path: Path) -> GpuLauncher: + return GpuLauncher(tmp_path) + + +def test_vendor_fixtures(gpu): + fixtures = { + "intel": ["dri/card0", "dri/renderD128"], + "amd": ["dri/card1", "dri/renderD129", "kfd"], + "nvidia": ["nvidia0", "nvidia12", "nvidiactl", "nvidia-uvm", + "nvidia-uvm-tools", "nvidia-modeset", "nvidia-caps/nvidia-cap1"], + "mixed": ["nvidia0", "nvidia-uvm", "dri/card0", "dri/renderD128", "kfd"], + "card-only": ["dri/card12"], + "render-only": ["dri/renderD130"], + } + for vendor, names in fixtures.items(): + gpu.devices.clear() + for path in gpu.dev.iterdir(): + if path.is_dir(): + shutil.rmtree(path) + else: + path.unlink() + for name in names: + gpu.device(name) + result = gpu.run_helper() + options, _ = gpu.captured(result) + assert options[:4] == ["--dev", "/dev", "--proc", "/proc"] + gpu.assert_devices(options, names) + assert "uvm" not in result.stderr.lower() + + +def test_optional_metadata_present(gpu): + gpu.device("nvidia0") + gpu.device("nvidia-uvm") + (gpu.etc / "ld.so.cache").touch() + directories = ["OpenCL/vendors", "vulkan/icd.d", "glvnd/egl_vendor.d"] + for directory in directories: + (gpu.etc / directory).mkdir(parents=True) + (gpu.proc / "driver/nvidia").mkdir(parents=True) + options, _ = gpu.captured(gpu.run_helper()) + expected = [("--ro-bind-try", str(gpu.sys), "/sys")] + expected += [("--ro-bind-try", str(gpu.etc / name), "/etc/" + name) + for name in ["ld.so.cache", *directories]] + expected += [("--ro-bind-try", str(gpu.proc / "driver/nvidia"), + "/proc/driver/nvidia")] + assert sorted(map(tuple, gpu.binds(options, "--ro-bind-try"))) == sorted(expected) + + +def test_missing_optional_metadata_is_not_required(gpu): + gpu.device("dri/renderD128") + gpu.sys.rmdir() + options, _ = gpu.captured(gpu.run_helper()) + gpu.assert_devices(options, ["dri/renderD128"]) + assert not gpu.binds(options, "--ro-bind") + assert "/proc/driver/nvidia" not in options + + +def test_nvidia_proc_metadata_requires_nvidia_primary(gpu): + gpu.device("dri/card0") + gpu.device("nvidiactl") + (gpu.proc / "driver/nvidia").mkdir(parents=True) + options, _ = gpu.captured(gpu.run_helper()) + assert "/proc/driver/nvidia" not in options + + +def test_nvidia_warns_without_valid_uvm(gpu): + gpu.device("nvidia0") + for kind in ("absent", "regular", "symlink", "character"): + path = gpu.dev / "nvidia-uvm" + path.unlink(missing_ok=True) + if kind == "regular": + gpu.device("nvidia-uvm", character=False) + elif kind == "symlink": + path.symlink_to(gpu.device("target")) + gpu.devices.add(str(path)) + gpu.env["GPU_TEST_DEVICES"] = json.dumps(sorted(gpu.devices)) + elif kind == "character": + gpu.device("nvidia-uvm") + result = gpu.run_helper() + options, _ = gpu.captured(result) + assert ("uvm" in result.stderr.lower()) == (kind != "character") + gpu.assert_devices(options, ["nvidia0"] + + (["nvidia-uvm"] if kind == "character" else [])) + + +def test_no_primary_fails_including_auxiliary_only(gpu): + gpu.assert_failure(gpu.run_helper()) + for name in ("nvidiactl", "nvidia-uvm", "nvidia-uvm-tools", "nvidia-modeset", + "nvidia-caps/nvidia-cap0", "kfd"): + gpu.device(name) + gpu.assert_failure(gpu.run_helper()) + + +def test_invalid_names_and_noncharacters_do_not_count_as_primary(gpu): + for name in ("nvidia", "nvidia0extra", "nvidia-1", "nvidia1.2", "xnvidia0", + "dri/card", "dri/card0extra", "dri/card-1", "dri/xcard0", + "dri/renderD", "dri/renderD128extra", "dri/renderD-1", + "nvidia-caps/nvidia-cap", "nvidia-caps/nvidia-cap1extra"): + gpu.device(name) + for name in ("nvidia1", "dri/card1", "dri/renderD129", "kfd"): + gpu.device(name, character=False) + (gpu.dev / "nvidia2").mkdir() + gpu.assert_failure(gpu.run_helper()) + gpu.device("nvidia0") + options, _ = gpu.captured(gpu.run_helper()) + gpu.assert_devices(options, ["nvidia0"]) + + +def test_symlink_nodes_skipped_even_if_stat_reports_character(gpu): + target = gpu.device("target") + names = ["nvidia0", "dri/card0", "dri/renderD128", "nvidiactl", "nvidia-uvm", + "nvidia-uvm-tools", "nvidia-modeset", "nvidia-caps/nvidia-cap1", "kfd"] + for name in names: + path = gpu.dev / name + path.parent.mkdir(parents=True, exist_ok=True) + path.symlink_to(target) + gpu.devices.add(str(path)) + (gpu.dev / "nvidia99").symlink_to(gpu.dev / "missing") + gpu.env["GPU_TEST_DEVICES"] = json.dumps(sorted(gpu.devices)) + gpu.assert_failure(gpu.run_helper()) + gpu.device("nvidia1") + options, _ = gpu.captured(gpu.run_helper()) + gpu.assert_devices(options, ["nvidia1"]) + + +def test_symlink_parent_directories_rejected(gpu): + gpu.device("nvidia0") + for parent, child in (("dri", "card0"), ("nvidia-caps", "nvidia-cap1")): + target = gpu.root / (parent + " external") + target.mkdir() + (target / child).touch() + link = gpu.dev / parent + link.symlink_to(target, target_is_directory=True) + gpu.assert_failure(gpu.run_helper()) + link.unlink() + + +def test_help_documents_gpu(gpu): + for flag in ("--help", "-h"): + result = gpu.run_launcher(flag) + assert result.returncode == 0, result.stderr + assert "--gpu" in result.stdout + assert not gpu.capture.exists() + + +def test_default_does_not_probe_or_mount_gpu(gpu): + options, command = gpu.captured(gpu.run_launcher("-s", "session")) + assert not gpu.binds(options) + assert "/sys" not in options + assert "/etc/ld.so.cache" not in options + assert not any(call[0] == "stat" and "%F" in call + for call in gpu.command_calls()) + assert command[3:] == ["--", "-s", "session"] + + +def test_cli_exits_on_helper_failure(gpu): + gpu.assert_failure(gpu.run_fixture_launcher("--gpu", "-s", "session")) + + +def test_repeated_flags_preserve_arguments_and_mount_once(gpu): + gpu.device("dri/renderD128") + options, command = gpu.captured(gpu.run_fixture_launcher( + "--gpu", "-s", "session with spaces", "--gpu", "run", "prompt", "")) + gpu.assert_devices(options, ["dri/renderD128"]) + assert command[3:] == ["--", "-s", "session with spaces", "run", "prompt", ""] + assert options.count("/sys") == 1 + + +def test_composition_seccomp_namespaces_and_override_order(gpu): + gpu.device("dri/renderD128") + seccomp = gpu.home / ".local/share/opencodebox/seccomp-security.bpf" + seccomp.parent.mkdir(parents=True) + seccomp.write_bytes(b"test filter") + (gpu.bin / "uname").unlink() + gpu.command("uname", "print('x86_64')") + baseline, _ = gpu.captured(gpu.run_launcher()) + override = gpu.root / "GPU override" + override.mkdir() + options, command = gpu.captured(gpu.run_fixture_launcher( + "--with-ro", f"{override}:/dev/dri", "--gpu", "-s", "session", "--gpu")) + gpu.assert_devices(options, ["dri/renderD128"]) + # GPU mounts come first, explicit user binds override them, and the + # OpenCode 2 isolation overlay still forms the tail (later binds win). + user_idx = options.index(str(override)) + gpu_idx = options.index(str(gpu.dev / "dri/renderD128")) + overlay_idx = options.index(str(gpu.home / ".local/share/opencodebox/disabled-service.json")) + assert gpu_idx < user_idx < overlay_idx + assert command[3:] == ["--", "-s", "session"] + def isolation(args): + return [arg for arg in args if arg.startswith("--unshare") + or arg in ("--share-net", "--die-with-parent", "--new-session")] + assert isolation(options) == isolation(baseline) + for args in (baseline, options): + assert args.count("--seccomp") == 1 + assert args[args.index("--seccomp") + 1].isdigit() + assert ["--dev", "/dev"] in [args[i:i + 2] for i in range(len(args) - 1)]