bytedance/deer-flow · error · GitHubAppAuthError

GITHUB_APP_PRIVATE_KEY_PATH points to nonexistent file: {p}

Error message

GITHUB_APP_PRIVATE_KEY_PATH points to nonexistent file: {p}

What it means

GitHubAppAuthError raised by load_app_private_key() when GITHUB_APP_PRIVATE_KEY_PATH is set but the file does not exist at the expanded path. Path is expanduser()-ed, so '~' works; existence is checked before read_text().

Source

Thrown at backend/app/gateway/github/app_auth.py:113

def load_app_private_key() -> str:
    """Return the App's RSA private key as a PEM string.

    Reads from ``GITHUB_APP_PRIVATE_KEY`` (inline PEM) if set, else from
    the path in ``GITHUB_APP_PRIVATE_KEY_PATH``. Inline takes precedence
    so operators can roll a key by setting an env var instead of moving
    files around in production.
    """
    inline = os.environ.get(_PRIVATE_KEY_ENV)
    if inline and inline.strip():
        return inline

    path = os.environ.get(_PRIVATE_KEY_PATH_ENV)
    if not path:
        raise GitHubAppAuthError(f"Neither {_PRIVATE_KEY_ENV} nor {_PRIVATE_KEY_PATH_ENV} is set")
    p = Path(path).expanduser()
    if not p.exists():
        raise GitHubAppAuthError(f"{_PRIVATE_KEY_PATH_ENV} points to nonexistent file: {p}")
    return p.read_text(encoding="utf-8")


def mint_app_jwt(*, now: float | None = None) -> str:
    """Sign a short-lived JWT identifying this App to GitHub.

    Args:
        now: Optional override for ``time.time()`` — tests use this.

    Returns:
        Signed RS256 JWT suitable for ``Authorization: Bearer <jwt>``.
    """
    issued_at = int(now if now is not None else time.time())
    payload = {
        # GitHub recommends iat 60s in the past to tolerate clock skew.
        "iat": issued_at - 60,
        "exp": issued_at + _APP_JWT_TTL_SECONDS,
        # iss must be a string in current pyjwt; GitHub accepts the

View on GitHub (pinned to 1dd6ba1acb)

Solutions

  1. Check the path from the process's point of view: `docker exec <gateway> ls -l "$GITHUB_APP_PRIVATE_KEY_PATH"`
  2. Fix the mount: compose volumes: - ./secrets/gh.pem:/run/secrets/gh.pem:ro and point _PATH at /run/secrets/gh.pem; in K8s verify the secret and volumeMount agree
  3. Strip stray whitespace/newlines from the env value (common with .env files edited on Windows)
  4. Remember ~ expands to the container user's home, not yours — prefer absolute paths in containers

Example fix

# before (compose)
environment:
  GITHUB_APP_PRIVATE_KEY_PATH: ~/secrets/gh.pem   # not mounted -> GitHubAppAuthError
# after
volumes:
  - ./secrets/gh.pem:/run/secrets/gh.pem:ro
environment:
  GITHUB_APP_PRIVATE_KEY_PATH: /run/secrets/gh.pem
Defensive patterns

Strategy: validation

Validate before calling

import os, pathlib
p = pathlib.Path(os.environ['GITHUB_APP_PRIVATE_KEY_PATH']).expanduser()
if not p.is_file():
    raise ConfigError(f'private key file missing: {p}')
key = p.read_text()
assert 'PRIVATE KEY' in key, 'file is not a PEM'

Type guard

null

Try / catch

except GitHubAppAuthError as e:
    if 'nonexistent file' in str(e):
        fix_mount_and_restart()  # mount check, then redeploy
    raise

Prevention

When it happens

Trigger: Calling load_app_private_key() with a _PATH value pointing to a missing file — secret not mounted, wrong mount path in compose/K8s, file only present on the host but the Gateway runs in a container, or a typo in the path.

Common situations: Docker bind-mount uses a relative path resolved against a different workdir; Kubernetes secret volume name mismatch; the pem was never copied to the server; container runs as a different user so ~ expands elsewhere; trailing whitespace/newline in the env var corrupting the path.

Related errors


AI-assisted analysis of bytedance/deer-flow@1dd6ba1acb (2026-08-14). Data as JSON: /api/errors/5e30516031ba4406. Report an issue: GitHub.