diff --git a/headroom/install/runtime.py b/headroom/install/runtime.py index 0736e0a33..5288cdc74 100644 --- a/headroom/install/runtime.py +++ b/headroom/install/runtime.py @@ -56,6 +56,32 @@ def _is_windows() -> bool: return sys.platform.startswith("win") +def _container_runtime_is_podman() -> bool: + """Best-effort: is the ``docker`` command actually Podman? + + Rootless Podman maps the host user to container UID 0, so the + ``--user :`` flag that is correct for Docker instead + selects a subordinate UID that owns none of the bind-mounted host + directories, and every write into ``~/.headroom`` fails (#2804). Detect the + common ``docker -> podman`` shim (e.g. NixOS + ``/run/current-system/sw/bin/docker -> podman``) by resolving the binary and + checking its real name. ``HEADROOM_CONTAINER_RUNTIME`` (``podman`` / ``docker``) + is an explicit override for setups the symlink heuristic cannot see, such as a + wrapper script. No subprocess is spawned. + """ + override = os.environ.get("HEADROOM_CONTAINER_RUNTIME", "").strip().lower() + if override: + return override == "podman" + resolved = shutil.which("docker") + if not resolved: + return False + try: + real = os.path.realpath(resolved) + except OSError: + real = resolved + return "podman" in os.path.basename(real).lower() + + def _deployment_env(manifest: DeploymentManifest) -> dict[str, str]: return { "HEADROOM_DEPLOYMENT_PROFILE": manifest.profile, @@ -136,10 +162,18 @@ def build_runtime_command(manifest: DeploymentManifest) -> list[str]: if docker_gpus: command.extend(["--gpus", docker_gpus]) if not _is_windows(): - getuid = getattr(os, "getuid", None) - getgid = getattr(os, "getgid", None) - if callable(getuid) and callable(getgid): - command.extend(["--user", f"{getuid()}:{getgid()}"]) + if _container_runtime_is_podman(): + # Rootless Podman maps the host user to container UID 0, so --user + # would map to a subordinate UID that owns none of the bind mounts and + # every write into ~/.headroom fails (#2804). keep-id maps the host + # user to the same UID inside the container, keeping the mounts + # writable. Docker maps UIDs 1:1, so --user stays correct there. + command.append("--userns=keep-id") + else: + getuid = getattr(os, "getuid", None) + getgid = getattr(os, "getgid", None) + if callable(getuid) and callable(getgid): + command.extend(["--user", f"{getuid()}:{getgid()}"]) runtime_env = {**manifest.base_env, **_deployment_env(manifest)} for name, value in runtime_env.items(): command.extend(["--env", f"{name}={value}"]) diff --git a/tests/test_install/test_runtime.py b/tests/test_install/test_runtime.py index ce654d859..8b5e08ed4 100644 --- a/tests/test_install/test_runtime.py +++ b/tests/test_install/test_runtime.py @@ -281,6 +281,9 @@ def test_build_runtime_command_python_and_docker_user(monkeypatch, tmp_path: Pat monkeypatch.setattr("headroom.install.runtime.sys.platform", "linux") monkeypatch.setattr("headroom.install.runtime.os.getuid", lambda: 1000, raising=False) monkeypatch.setattr("headroom.install.runtime.os.getgid", lambda: 1001, raising=False) + # Force the Docker path deterministically regardless of the test host's + # `docker` binary (it might resolve to a podman shim). + monkeypatch.setenv("HEADROOM_CONTAINER_RUNTIME", "docker") docker_manifest = DeploymentManifest( profile="default", preset="persistent-docker", @@ -299,6 +302,37 @@ def test_build_runtime_command_python_and_docker_user(monkeypatch, tmp_path: Pat command = build_runtime_command(docker_manifest) assert "--user" in command assert "1000:1001" in command + assert "--userns=keep-id" not in command + + +def test_build_runtime_command_podman_uses_keep_id_not_user(monkeypatch, tmp_path: Path) -> None: + """Under rootless Podman, --user : selects a subordinate + UID that owns none of the bind mounts, so writes into ~/.headroom fail. The + command must use --userns=keep-id and drop --user instead (#2804).""" + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setattr("headroom.install.runtime.sys.platform", "linux") + monkeypatch.setattr("headroom.install.runtime.os.getuid", lambda: 1000, raising=False) + monkeypatch.setattr("headroom.install.runtime.os.getgid", lambda: 1001, raising=False) + monkeypatch.setenv("HEADROOM_CONTAINER_RUNTIME", "podman") + manifest = DeploymentManifest( + profile="default", + preset="persistent-docker", + runtime_kind="docker", + supervisor_kind="none", + scope="user", + provider_mode="manual", + targets=[], + port=8787, + host="127.0.0.1", + backend="anthropic", + image="ghcr.io/headroomlabs-ai/headroom:latest", + base_env={"HEADROOM_PORT": "8787"}, + proxy_args=["--host", "127.0.0.1", "--port", "8787"], + ) + command = build_runtime_command(manifest) + assert "--userns=keep-id" in command + assert "--user" not in command + assert "1000:1001" not in command def test_read_pid_handles_invalid_content(monkeypatch, tmp_path: Path) -> None: