Dockerfile
Rama main · réplica de lectura del repositorio original.
# HarnessRouter — self-hosted, all-in-one.
#
# One container runs the whole product: the UI, the gateway, and the agent runner. There is
# no cloud dependency, no control plane to reach, and nothing to provision. State is SQLite
# and files on one mounted volume.
#
# Why one container rather than compose-by-default: self-hosting should be `docker run`. The
# three processes are supervised by a tiny entrypoint, and because the gateway talks to the
# runner over loopback it needs no pool, no service discovery and no cloud identity.
#
# AGENT CLIs ARE INSTALLED ON FIRST RUN, NOT BAKED IN. This is a licensing requirement, not a
# size optimisation: Claude Code ships under Anthropic's own terms ("SEE LICENSE IN README.md")
# and hermes-agent declares no license at all, so neither may be redistributed inside a public
# image. Installing them at first start means YOU install them, under their terms, exactly as
# if you had run npm/pip yourself — and it keeps this image redistributable.
#
# They land in the data volume, so the cost is paid once per volume rather than once per start.
# Choose backends at RUN time:
#
# docker run -e HR_BACKENDS=claude,codex,hermes,pi,dsh,opencode,qwen,gemini,cline,omp ... # the default
# docker run -e HR_BACKENDS=opencode ... # lean
#
# WITH_BROWSER is still a build arg because Chromium and its system libraries genuinely belong
# in the image layer.
# ── UI build ──────────────────────────────────────────────────────────────────
# This is the SAME console the hosted product runs. Surfaces with no self-hosted backend
# (billing, marketplace, analytics, sign-in) are hidden by the edition flag rather than removed,
# so the two stay one codebase. See ui/src/lib/edition.ts.
FROM node:22-slim AS ui
WORKDIR /ui
# git: the UI depends on the ReifyUI component library straight from its repository.
RUN apt-get update -y && apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# .npmrc matters at THIS layer: it carries legacy-peer-deps, without which npm refuses the
# tree (several deps still declare React 18 peers while the app runs 19).
COPY ui/package.json ui/package-lock.json* ui/.npmrc ./
COPY ui/vendor ./vendor
RUN npm ci --no-audit --no-fund 2>/dev/null || npm install --no-audit --no-fund
COPY ui/ ./
# Inlined into the client bundle at build time, so a self-hosted image can never present a
# hosted-only surface no matter how it is run.
ENV NEXT_TELEMETRY_DISABLED=1 \
NEXT_PUBLIC_HR_EDITION=selfhost
RUN npm run build
# The bundles have no room for their packages' notices, and those licences ask that a copy carries them.
RUN node scripts/third-party-licenses.mjs > THIRD-PARTY-console.txt
# ── the prompt hand-over for native CLIs ──────────────────────────────────────
# runner/prompt_argv.c: the library that hands jcode its prompt from a file instead of from its command line
# (what `ps` lists). Built on the runtime's own base, so it links against the glibc it runs with; the compiler
# stays in this stage and only the library is copied out.
FROM python:3.12-slim AS prompt-argv
RUN apt-get update -y && apt-get install -y --no-install-recommends gcc libc6-dev \
&& rm -rf /var/lib/apt/lists/*
COPY runner/prompt_argv.c /src/prompt_argv.c
RUN gcc -shared -fPIC -O2 -Wall -Wextra -Werror -o /src/prompt_argv.so /src/prompt_argv.c
# ── runtime ───────────────────────────────────────────────────────────────────
FROM python:3.12-slim
ARG WITH_BROWSER=0
ENV DEBIAN_FRONTEND=noninteractive \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
NEXT_TELEMETRY_DISABLED=1 \
HARNESS_WORKSPACE=/data/workspaces \
HR_DATA_DIR=/data
# tmux is here for openhands and nothing else: openhands-tools declares `libtmux`, the python
# client, and libtmux drives the real `tmux` binary — upstream's own image apt-installs it. Without
# it the agent-server dies on import (`ModuleNotFoundError` / a tmux that is not there), which is a
# failure at START, not at use. The locale above matters to it too: libtmux's format parsing needs
# a UTF-8 locale, which LANG=C.UTF-8 already gives.
RUN apt-get update -y && apt-get install -y --no-install-recommends \
curl ca-certificates git bash tini tmux \
&& rm -rf /var/lib/apt/lists/*
# Document preview. The built-in skills create .docx/.pptx/.xlsx routinely, and LibreOffice is
# also how those become a PDF — the officecli skill's own PDF export needs a plugin that is not
# installed. A presentation you can only download is a worse answer than one you can look at. Browsers render none of them, so the console asks the gateway for a PDF rendition and
# LibreOffice headless is what makes it. Impress/Writer/Calc only, no recommends: the full
# libreoffice metapackage drags in Java and a desktop stack for no benefit here.
ARG WITH_DOC_PREVIEW=1
RUN set -eux; \
if [ "$WITH_DOC_PREVIEW" = "1" ]; then \
apt-get update -y && apt-get install -y --no-install-recommends \
libreoffice-impress libreoffice-writer libreoffice-calc fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*; \
fi
# Assembling generated clips into one film. ffmpeg cuts and letterboxes the shots; ffprobe is how
# the gateway learns a clip's real duration and how the assembled film's length is checked against
# the timeline it was cut from. Built without it, the media server reports export unavailable and
# refuses honestly — every clip is still downloadable on its own — rather than degrading silently.
ARG WITH_MEDIA=1
RUN set -eux; \
if [ "$WITH_MEDIA" = "1" ]; then \
apt-get update -y && apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*; \
fi
# Node is needed for the UI server and for the npm-based agent CLIs. 22, not 20: pi's
# engine floor is >=22.19, and node 20 has been end-of-life since April 2026 anyway —
# the other CLIs (claude >=18, codex >=20) run unchanged on 22.
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* /root/.npm
# Optional: browser automation for harnesses that verify their own output.
RUN set -eux; \
if [ "$WITH_BROWSER" = "1" ]; then \
pip install --no-cache-dir playwright && playwright install --with-deps chromium; \
fi
WORKDIR /app
COPY gateway/requirements.txt /app/gateway/requirements.txt
COPY runner/requirements.txt /app/runner/requirements.txt
# The gateway's requirements include hosted-only clients (cloud identity, the hosted document
# store). They are imported lazily and never touched self-hosted, so they are stripped here to
# keep the image small and the dependency surface honest about what this build actually uses.
RUN grep -viE "^(azure-|azure_)" /app/gateway/requirements.txt > /app/gateway/req.local.txt \
&& pip install --no-cache-dir -r /app/gateway/req.local.txt \
&& pip install --no-cache-dir -r /app/runner/requirements.txt
# Built-in skills, from their own repository. Pinned by ref so two builds of the same image tag
# ship the same catalogue; HR_SKILLS_REF=<sha> for an exact pin, WITH_BUILTIN_SKILLS=0 for none.
ARG WITH_BUILTIN_SKILLS=1
ARG HR_SKILLS_REPO=https://github.com/HarnessRouter/skills.git
ARG HR_SKILLS_REF=main
ENV HR_BUILTIN_SKILLS_DIR=/opt/harnessrouter/skills
# officecli keeps an edited document open in a resident process and flushes it to disk some seconds
# after that process goes idle. A task that writes a document and then ENDS races that timer: the
# last file it touched is collected before the flush, and the user downloads a workbook whose
# content silently is not there — `validate` still passes, because the in-memory document is fine.
# Observed exactly that way: a spreadsheet whose every cell went into a sheet the saved file did
# not contain. Flushing on every command costs some speed and removes the race entirely, which is
# the right trade for a document a person is going to open.
ENV OFFICECLI_RESIDENT_FLUSH=each
COPY docker/install-skills.sh /tmp/install-skills.sh
# The RUN layer is keyed on this ARG, so passing a commit sha is what makes the layer rebuild when
# the skills repo moves. Leaving HR_SKILLS_REF at `main` keeps whatever catalogue an earlier build
# fetched — release CI resolves the sha and passes it, which is both the cache-bust and the record
# of exactly what shipped. A local build that wants the newest skills should pass a sha too.
#
# This used to be an `ADD https://api.github.com/...` fetching the branch head. That call is
# unauthenticated, GitHub allows 60 an hour per IP, and CI runners share a busy pool — so every
# release build eventually died on "failed to load cache key: invalid response status 403" before
# it compiled a line.
RUN chmod +x /tmp/install-skills.sh \
&& WITH_BUILTIN_SKILLS="$WITH_BUILTIN_SKILLS" HR_SKILLS_REPO="$HR_SKILLS_REPO" \
HR_SKILLS_REF="$HR_SKILLS_REF" HR_SKILLS_DIR=/opt/harnessrouter/skills \
/tmp/install-skills.sh \
&& rm -f /tmp/install-skills.sh
# Starter Kits: a Harness plus an app, both baked in. Same pull-and-pin shape as the skills
# bundle above, and the same rule: pass a commit sha to get the newest catalogue, because that
# is what changes the layer's inputs.
# What this build IS, for the discovery document and anything else that reports a version. The
# release workflow passes the tag; a local build says so honestly rather than claiming a number.
# Without this the gateway fell back to a literal that had been six releases stale, so every
# install told clients it was 0.3.0 (found by the 0.9.0 release sanity run).
ARG HR_VERSION=dev
ENV HR_VERSION=${HR_VERSION}
ARG WITH_STARTER_KITS=1
ARG HR_KITS_REPO=https://github.com/HarnessRouter/starter-kit.git
ARG HR_KITS_REF=main
ENV HR_KITS_DIR=/opt/harnessrouter/kits
COPY docker/install-kits.sh /tmp/install-kits.sh
RUN chmod +x /tmp/install-kits.sh \
&& WITH_STARTER_KITS="$WITH_STARTER_KITS" HR_KITS_REPO="$HR_KITS_REPO" \
HR_KITS_REF="$HR_KITS_REF" HR_KITS_DIR=/opt/harnessrouter/kits \
/tmp/install-kits.sh \
&& rm -f /tmp/install-kits.sh
COPY gateway/ /app/gateway/
COPY runner/ /app/runner/
COPY --from=prompt-argv /src/prompt_argv.so /app/runner/prompt_argv.so
COPY docker/entrypoint.sh /app/entrypoint.sh
# The hash-locked dependency trees the first-boot installer fills vibe's, fast-agent's and dcode's
# venvs from (pip --require-hashes): part of this reviewed image, never fetched at boot.
COPY docker/locks/ /app/locks/
COPY docker/hr-retention /usr/local/bin/hr-retention
RUN chmod +x /app/entrypoint.sh /usr/local/bin/hr-retention
# The memory engines' stack, so nobody needs a clone to start them: `docker cp <container>:/app/deploy/memories .`
# then `./memories/up.sh` on the host. Files only, root's like the rest of /app (below): nothing in the
# container runs them, and up.sh runs on the host, where Docker is. Its .env is never copied (.dockerignore).
COPY deploy/memories/ /app/deploy/memories/
# Next.js standalone output: server + only the modules it actually needs.
COPY --from=ui /ui/.next/standalone /app/ui/
COPY --from=ui /ui/.next/static /app/ui/.next/static
COPY --from=ui /ui/public /app/ui/public
# Apache-2.0 asks that these travel with a copy, and NOTICE is what says what this image carries.
COPY LICENSE NOTICE /app/
COPY --from=ui /ui/THIRD-PARTY-console.txt /app/THIRD-PARTY-console.txt
# `agent` is the PRODUCT's user: the gateway and the console run as it and it owns the data
# volume. The container itself starts as root and the entrypoint drops privileges per process,
# because the runner needs root for one thing: every agent CLI runs as its own per-session uid,
# which owns its session directory and nothing else (the write-wall; see docker/entrypoint.sh).
# The CLIs refuse to run as root anyway (they gate their own permission bypass on it), and they
# never do.
#
# The `rm -rf` of the npm caches is not tidying: root-owned build-time files in a cache a runtime
# user reads made every install fail with EACCES on a fresh volume, and its output is discarded
# (entrypoint.sh: `|| true`). The cache has no runtime value, so it is deleted rather than chowned.
#
# Everything under /app is root's, and `agent` owns only what it keeps: root runs the entrypoint and
# the runner from here, and a product user that can write them is root at the next start (reported
# privately, GHSA-mm72-wr37-26pv). The gateway's and the console's own code are root's too: neither
# writes there at run time but for Next's cache, and code that cannot change itself cannot keep a
# compromise across a restart. Compiled here because `agent` cannot write __pycache__. The prompt
# launchers (runner/prompt_launcher.py and .mjs, prompt_preload.cjs, prompt_argv.so) run as each session's uid, so
# they are read-only to everyone.
RUN useradd -m -u 10001 agent \
&& rm -rf /home/agent/.npm /root/.npm \
&& mkdir -p /data /app/ui/.next/cache \
&& chown -R root:root /app \
&& chmod -R go-w,a+rX /app \
&& chmod 0444 /app/runner/prompt_launcher.py /app/runner/prompt_launcher.mjs /app/runner/prompt_preload.cjs \
&& chmod 0555 /app/runner/prompt_argv.so \
&& python -m compileall -q /app/gateway /app/runner \
&& chown -R agent:agent /data /home/agent /app/ui/.next/cache \
&& chmod -R a+rX /opt/harnessrouter/skills /opt/harnessrouter/kits
EXPOSE 3000
VOLUME ["/data"]
# How deploy/memories/up.sh finds a running HarnessRouter, whatever the image is named or tagged.
LABEL io.harnessrouter.role=instance
# The UI is the only published port. The gateway and runner stay on loopback — nothing else
# needs to be reachable from outside the container.
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s \
CMD curl -fsS http://127.0.0.1:8080/healthz >/dev/null || exit 1
ENTRYPOINT ["/usr/bin/tini", "--", "/app/entrypoint.sh"]