headroomlabs-ai/headroom · error · OSError

binary cache directory is not writable: {dest.parent}

Error message

binary cache directory is not writable: {dest.parent}

What it means

After mkdir(parents=True, exist_ok=True) succeeds, _download re-checks with _is_writable_dir that the resulting directory actually accepts writes. This catches cases mkdir cannot: directories that exist but are root-owned with no write bit, filesystems mounted read-only after the fact, or permission changes racing the check. Raised as plain OSError naming dest.parent.

Source

Thrown at headroom/binaries.py:246

        return url
    # Only substitute the github.com host so that paths remain intact.
    for prefix in ("https://github.com", "https://objects.githubusercontent.com"):
        if url.startswith(prefix):
            return mirror.rstrip("/") + url[len(prefix) :]
    return url


# ---------- Download + verify --------------------------------------------- #


def _download(url: str, dest: Path, *, progress: bool = True) -> None:
    if os.environ.get("HEADROOM_BINARIES_OFFLINE"):
        raise OfflineError(f"offline mode (HEADROOM_BINARIES_OFFLINE=1) but fetch required: {url}")
    if not _has_writable_existing_parent(dest.parent):
        raise OSError(f"binary cache directory parent is not writable: {dest.parent}")
    dest.parent.mkdir(parents=True, exist_ok=True)
    if not _is_writable_dir(dest.parent):
        raise OSError(f"binary cache directory is not writable: {dest.parent}")
    final_url = _mirror_url(url)
    req = urllib.request.Request(final_url, headers={"User-Agent": "headroom-binaries/1"})
    attempts = 3
    for attempt in range(1, attempts + 1):
        try:
            with urllib.request.urlopen(req, timeout=60) as resp:  # noqa: S310 (https)
                total = int(resp.headers.get("Content-Length") or 0)
                _stream_to(resp, dest, total, label=dest.name, show_progress=progress)
            return
        except urllib.error.URLError as e:
            dest.unlink(missing_ok=True)
            if attempt == attempts:
                raise BinaryFetchError(
                    f"failed to download {final_url} after {attempts} attempts: {e}"
                ) from e
            # GitHub release assets occasionally return a transient 5xx or
            # reset while redirecting to the object store. A short bounded
            # retry keeps proxy startup reliable without hiding persistent

View on GitHub (pinned to 322425c43b)

Solutions

  1. chmod/chown the cache directory for the runtime user: chown appuser <dir> && chmod u+w <dir>.
  2. Point the cache at a user-writable path (HOME/.cache/...) instead of a shared/system location.
  3. For read-only mounts, remount read-write or move the cache elsewhere.

Example fix

# before
$ ls -ld /opt/headroom/bin
# drwxr-xr-x root root -> OSError

# after
sudo chown -R appuser: /opt/headroom/bin
Defensive patterns

Strategy: validation

Validate before calling

import os, tempfile

def dir_accepts_writes(d: str) -> bool:
    try:
        with tempfile.TemporaryFile(dir=d):
            return True
    except OSError:
        return False

if not dir_accepts_writes(cache_dir):
    raise SystemExit(f"{cache_dir} exists but is not writable for this user")

Try / catch

try:
    ensure_binary(tool)
except OSError as e:
    if "not writable" in str(e):
        subprocess.run(["chown", "-R", getpass.getuser(), cache_dir])  # or alert ops
        ensure_binary(tool)  # one targeted retry after fixing perms
    else:
        raise

Prevention

When it happens

Trigger: Cache parent exists and is listable (so the parent check passed) but lacks write permission for the current user — mode 755 root-owned dir with the process running as non-root; or the directory sits on a read-only mount.

Common situations: Docker volumes mounted from the host with root ownership, Kubernetes hostPath volumes, or shared cache dirs shared across users with restrictive umask.

Related errors


AI-assisted analysis of headroomlabs-ai/headroom@322425c43b (2026-08-15). Data as JSON: /api/errors/e73234980b919034. Report an issue: GitHub.