Files
ai-agent/Dockerfile
Gabriel Vidal 27b3caa0f9 feat(ai-agent): fold the runner into the container — standalone spawns sessions
GOAL.md's first gap: without a runner a standalone image is a read-only
viewer, because launching `claude -p` needed a host-side process.

The Claude Code CLI is a self-contained native binary, so the image can just
carry it — and the *same* sidecar/sidecar.py the homelab runs on its host runs
in-container against it. With RUNNER_IN_CONTAINER=1 (the standalone default)
docker-entrypoint.sh starts the runner on the loopback, mints a SIDECAR_TOKEN
if none was given, overrides SIDECAR_URL to point at it, and gives the CLI a
writable $HOME on the data volume. The transcripts it writes there become a
third live source (RUNNER_TRANSCRIPTS_DIR → SOURCE_DIRS), so an in-container
session streams into the viewer like any other.

Verified end to end: a standalone container (workspace + data, no homelab, no
host sidecar) spawns a session, the CLI runs it, and the turn renders in the
conversation list with its model tag. The CLI authenticates from
ANTHROPIC_API_KEY or a Claude home mounted at RUNNER_HOME.

The homelab is unchanged: it leaves the flag off and keeps its host sidecar,
which is what lets a run use the host's own hooks, skills and credentials.
standalone-smoke.sh now also asserts the CLI is on PATH and the runner is
healthy — the packaging property that would otherwise regress silently.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:08:54 +02:00

73 lines
4.0 KiB
Docker

# syntax=docker/dockerfile:1
# BuildKit `--mount=type=cache` steps below persist the npm/pip/vite/apt caches
# across builds (survive layer-cache misses), so a dependency bump or a pruned
# builder re-installs from the local cache instead of re-downloading everything.
# Requires BuildKit (default with `docker compose build` / buildx here).
# ── stage 1: build the React PWA frontend ───────────────────────────────────
FROM node:20-alpine AS frontend
WORKDIR /build
COPY frontend/package.json frontend/package-lock.json* frontend/.npmrc ./
# @gabvdl/ui now comes from the homelab private registry (verdaccio) instead of a
# vendored tarball. The .npmrc scopes @gabvdl → http://localhost:4873; this build
# stage runs with `network: host` (see docker-compose.yml) so that localhost is
# the host-bound verdaccio. `npm ci` is deterministic (installs exactly the
# lockfile) and faster than `npm install`; the cache mount keeps the download
# cache warm across builds so a lockfile change re-links from cache instead of
# re-fetching all ~250 MB of deps.
RUN --mount=type=cache,target=/root/.npm npm ci
COPY frontend/ ./
# Persist Vite's esbuild dep pre-bundle cache across builds. (The app bundle
# itself is re-rollup'd on every source change — that ~17s is inherent to a
# production build and no cache avoids it; this mount only saves the dep
# optimize pass on cold builds.)
RUN --mount=type=cache,target=/build/node_modules/.vite npm run build
# ── stage 2: FastAPI backend serving the API + the built frontend ───────────
FROM python:3.12-slim
WORKDIR /app
# git: read each project's last-commit date / recent commits from the mounted repo.
# curl/ca-certificates: fetch the Claude Code CLI below. ripgrep: the CLI's search.
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update \
&& apt-get install -y --no-install-recommends git curl ca-certificates ripgrep
# ── the in-image runner: the Claude Code CLI itself ─────────────────────────
# GOAL.md's first gap is folding the host sidecar into the container. Claude Code
# ships as a self-contained native binary, so the image can just carry it: with
# RUNNER_IN_CONTAINER=1 the entrypoint runs sidecar/sidecar.py against this binary
# on the container's loopback, and the image can spawn sessions with no host
# process at all. The homelab keeps using its host sidecar (nothing changes there).
#
# The installer drops a versioned binary under $HOME/.local/share/claude and
# symlinks it; we resolve the symlink into /usr/local/bin so the CLI is on PATH
# for whatever uid the container is later told to run as.
ARG CLAUDE_VERSION=stable
RUN --mount=type=cache,target=/root/.cache/claude-install \
HOME=/tmp/claude-install sh -c \
"curl -fsSL https://claude.ai/install.sh | bash -s -- ${CLAUDE_VERSION}" \
&& cp -L /tmp/claude-install/.local/bin/claude /usr/local/bin/claude \
&& chmod 0755 /usr/local/bin/claude \
&& rm -rf /tmp/claude-install \
&& claude --version
# Deps first, keyed on requirements.txt only, so editing backend code below never
# re-resolves pip. Cache mount keeps wheels warm across dep bumps / pruned builds.
COPY backend/requirements.txt /app/requirements.txt
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r /app/requirements.txt
COPY backend/ /app/
# The runner: the same FastAPI wrapper the homelab runs on the host, launched
# in-container by the entrypoint when RUNNER_IN_CONTAINER=1 (dead weight otherwise
# — it shares the backend's fastapi/uvicorn, so it costs no extra deps).
COPY sidecar/sidecar.py /app/sidecar/sidecar.py
COPY --chmod=0755 docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# Built PWA assets served as static files at the web root.
COPY --from=frontend /build/dist /app/static
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]