headroomlabs-ai/headroom · error · OSError

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

Error message

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

What it means

Before attempting mkdir, _download checks that the nearest existing ancestor of the destination's parent is writable (_has_writable_existing_parent). A plain OSError (not a custom type) is raised naming dest.parent, because no amount of mkdir can succeed when the existing part of the path is on read-only media or owned by another user.

Source

Thrown at headroom/binaries.py:243

def _mirror_url(url: str) -> str:
    mirror = os.environ.get("HEADROOM_BINARIES_MIRROR")
    if not mirror:
        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

View on GitHub (pinned to 322425c43b)

Solutions

  1. Relocate the binary cache to a writable location (the cache root is derived from dest; set the corresponding HEADROOM cache env var, e.g. XDG-style HOME/.cache, or mount an emptyDir at the old path).
  2. chown/chmod the existing parent so the runtime user can write: chown -R appuser /opt/headroom.
  3. In Kubernetes with readOnlyRootFilesystem, mount a writable volume (emptyDir) at the cache path.

Example fix

# before
volumeMounts: []  # readOnlyRootFilesystem: true -> OSError

# after
volumeMounts:
- {name: bin-cache, mountPath: /opt/headroom/bin}
volumes:
- {name: bin-cache, emptyDir: {}}
Defensive patterns

Strategy: validation

Validate before calling

import os

def cache_parent_writable(cache_parent: str) -> bool:
    p = Path(cache_parent)
    probe = p if p.exists() else next((a for a in p.parents if a.exists()), Path('/'))
    return os.access(probe, os.W_OK)

if not cache_parent_writable("/opt/headroom/bin"):
    raise SystemExit("cache parent not writable; set a writable cache dir or fix ownership")

Try / catch

try:
    ensure_binary(tool)
except OSError as e:
    if "not writable" in str(e):
        logger.error("fix cache dir permissions (%s) or move it to a writable path", e)
    raise

Prevention

When it happens

Trigger: Binary cache directory rooted under a path whose existing portion is not writable by the current user — e.g. /opt/headroom/bin without chown, or a read-only root filesystem in a hardened container.

Common situations: Containers running as non-root with the cache configured under /usr/local or another root-owned tree; read-only rootfs (securityContext.readOnlyRootFilesystem) with no writable volume mounted for the cache.

Related errors


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