fix(install): use --userns=keep-id under Podman so bind-mount writes don't fail (#2846)

## Description

`build_runtime_command` unconditionally adds `--user <uid>:<gid>` on
non-Windows hosts:

```python
# headroom/install/runtime.py
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()}"])
```

That is correct for Docker, where container UIDs equal host UIDs, but
wrong for rootless Podman, where the host user is already mapped to
container UID 0 and the `/etc/subuid` range is mapped to container UIDs
1 and above. Passing `--user $(id -u):$(id -g)` therefore selects a
container UID backed by a subordinate host UID that owns nothing. The
bind-mounted `~/.headroom` appears inside the container as `root:root`
and is unwritable, so every write fails:

```text
PermissionError: [Errno 13] Permission denied: '/tmp/headroom-home/.headroom/memories'
event=proxy_inbound_request_aborted path=/v1/messages reason=PermissionError
```

The proxy still starts and reports healthy, so the failure only surfaces
once a request touches a write path. As the reporter confirmed,
`--userns=keep-id` (or omitting `--user`) fixes it.

The fix detects Podman and uses `--userns=keep-id` instead of `--user`,
which maps the host user to the same UID inside the container and keeps
the bind mounts writable. Docker still gets `--user`, unchanged.
Detection is subprocess-free: it resolves the `docker` binary and checks
its real name for the common `docker -> podman` symlink shim (e.g. NixOS
`/run/current-system/sw/bin/docker -> podman`), with an explicit
`HEADROOM_CONTAINER_RUNTIME` (`podman` / `docker`) override for setups
the symlink heuristic cannot see, such as a wrapper script.

Fixes #2804

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/install/runtime.py`: added `_container_runtime_is_podman()`
(env override, then a `docker`-binary realpath basename check, no
subprocess). In `build_runtime_command`, when Podman is detected the
command uses `--userns=keep-id` instead of `--user <uid>:<gid>`.
- `tests/test_install/test_runtime.py`: pinned the existing docker test
to the Docker path via `HEADROOM_CONTAINER_RUNTIME=docker` and asserted
`--userns=keep-id` is absent there; added
`test_build_runtime_command_podman_uses_keep_id_not_user` asserting the
Podman path drops `--user` and adds `--userns=keep-id`.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
# Fail-before (source fix stashed, new test kept):
tests/test_install/test_runtime.py::test_build_runtime_command_podman_uses_keep_id_not_user FAILED
  assert "--userns=keep-id" in command
  AssertionError: assert '--userns=keep-id' in ['docker', 'run', '--rm', ...]

# Pass-after (fix applied):
tests/test_install/test_runtime.py  26 passed

# Broader install suite (excluding the pre-existing env-specific PowerShell installer test):
tests/test_install/  142 passed, 1 skipped

# uvx ruff@0.15.17 check  -> All checks passed!
# uvx mypy@1.20.2 headroom/install/runtime.py -> Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1,
ruff 0.15.17 and mypy 1.20.2 via uvx.
- Exact command / steps: confirmed `build_runtime_command` adds `--user`
unconditionally on non-Windows, then drove both runtimes
deterministically via the `HEADROOM_CONTAINER_RUNTIME` override.
Fail-before with `git stash push headroom/install/runtime.py` and
`python -m pytest tests/test_install/test_runtime.py -k
podman_uses_keep_id` (the command still carries `--user`, no keep-id),
pass-after with `git stash pop` and rerunning the file (26 passed).
- Observed result: with Podman detected the docker command now contains
`--userns=keep-id` and no `--user`/`1000:1001`, matching the
`--userns=keep-id` invocation the reporter verified writes successfully;
with Docker it is unchanged (`--user 1000:1001`, no keep-id).
- Not tested: a live rootless-Podman deployment writing to a bind mount
(no Podman in this environment). The command construction is verified
directly, and `--userns=keep-id` is the documented, reporter-confirmed
switch for the rootless-Podman ID-mapping.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`: it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Additional Notes

Detection is intentionally subprocess-free and conservative: it only
diverges from today's behavior when the `docker` binary literally
resolves to a `podman`-named target, or when
`HEADROOM_CONTAINER_RUNTIME` is set. Real Docker installs are untouched.
The override also gives a clean escape hatch in both directions if a
given host's symlink layout hides the runtime. This is the `--user` half
of the persistent-docker + Podman issues; the separate host-memory-path
problem (#2803) is addressed in its own PR.
This commit is contained in:
Abhay Singh
2026-08-08 12:00:48 +05:30
committed by GitHub
parent 14c4c9d5b7
commit 3488f8d4b5
2 changed files with 72 additions and 4 deletions
+38 -4
View File
@@ -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 <host-uid>:<host-gid>`` 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}"])
+34
View File
@@ -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 <host-uid>:<host-gid> 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: