claude-bot holds write on vh/hrafn as of 2026-08-23, so the canonical copy of the pipeline moved there and infra-ops maintains it directly instead of routing patches through the repo holder. The files here are a verified mirror (byte-identical to live at 22e0eb9d75a6). Live state: run 9922 green, and both new content assertions executed rather than merely existing -- verify 4/5 host tree matches shipped context (a99ce748a0c9...) verify 5/5 image source matches host source (06f209fd0641...) The CI-computed context hash matching on the host is the end-to-end proof that the converge lands what CI ships. Its absence is what let the frozen-source bug survive every green deploy. Also records why the HEAD == GITHUB_SHA assertion was added and then removed: it needed the git binary (run 9920, exit 127), and installing git flipped actions/checkout@v4 from its node implementation to the git binary, which died on a missing CA bundle (run 9921). A nice-to-have assertion changed the checkout code path and broke a working pipeline; it guarded a hypothesis that proved wrong, so it went rather than getting ca-certificates bolted on.
hrafn
Browser-fetch service — a genuine Chromium kept behind a REST API so consumers never have to embed a browser themselves. It exists because bot-gated sites (Reddit first) 403 every non-browser client; hrafn is the one place in the fleet that keeps a real browser warm.
Deploys to:
- ana-docker (10.250.50.70) — internal-only, no published host port.
Consumers reach it at
http://hrafn:8080ontraefik-net.
Built and handed to infra-ops for uptime ownership by nevermore-claude,
2026-08-22. Upstream source: vh/hrafn (private gitea repo).
Reaching it
There is no host port — this is deliberate (contract § Deployment). Only
containers on traefik-net can talk to it:
# from another container on traefik-net
curl -H "Authorization: Bearer $HRAFN_TOKEN" \
-X POST http://hrafn:8080/v1/fetch -d '{"url":"https://example.com"}'
# from the host, for diagnostics only
ssh infra-ops@10.250.50.70 \
'sudo docker run --rm --network traefik-net curlimages/curl:latest \
-s -o /dev/null -w "%{http_code}\n" http://hrafn:8080/readyz'
/readyz and the other health endpoints are unauthenticated; POST /v1/fetch
requires the bearer token.
Credentials
HRAFN_TOKEN lives in /opt/docker/compose/hrafn/.env on the host and is
vaulted at ana-docker/hrafn/bearer-token (secrets-broker). The .env is
0600 — it was 0644 on handoff, which exposed a live bearer token to every
local account on the box; tightened 2026-08-22. Keep it 0600.
Operational notes
shm_size: 1gbis load-bearing. Chromium crashes on some pages with Docker's 64 MB default. Don't trim it to save memory.- Playwright and the base image are version-coupled. Base is
mcr.microsoft.com/playwright/python:v1.62.0-noblewith playwright pinned to1.62.0. Bump both together or the browser binary and the client library disagree. - SSRF is denied by default — private and reserved targets are refused
unless listed in
HRAFN_PRIVATE_ALLOWLIST, which is empty. Leave it empty unless there is a specific internal target and a reason. restart: unless-stoppeddoes not act on the healthcheck. Docker restarts on exit, not on unhealthy. A wedged-but-running Chromium will sit there marked unhealthy indefinitely. Beszel on this host reports container status, so that is the alerting path.
Deploy
No CI deploy is wired. As of the handoff the image builds from a working
tree rsync'd into the compose directory, so /opt/docker/compose/hrafn/
contains the full source (src/, tests/, pyproject.toml, …) rather than
just a compose file. That is a deviation from the house convention and means
the running image has no commit provenance.
Current update path:
ssh infra-ops@10.250.50.70
cd /opt/docker/compose/hrafn
# rsync the updated working tree in, then:
sudo docker compose build && sudo docker compose up -d
sudo docker compose ps # expect (healthy)
Known gaps
| gap | impact | status |
|---|---|---|
| No CI deploy | manual rsync + build; no provenance for what is running | offered by nevermore-claude, not yet built |
| Source tree in the compose dir | deviates from /opt/docker/compose/<stack>/compose.yaml convention |
needs a host-clone or registry-image deploy shape |
| No homepage entry | internal-only service, so arguably correct | intentional for now |